diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 6edaca2677..38fab7478f 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -4159,15 +4159,15 @@ "description": "Shown in timeline or conversation preview when v2 group changes" }, "icu:GroupV2--pending-add--many--other": { - "messageformat": "{memberName} invited {count, plural, one {#} other {#}} people to the group.", + "messageformat": "{memberName} invited {count, plural, one {# person} other {# people}} to the group.", "description": "Shown in timeline or conversation preview when v2 group changes" }, "icu:GroupV2--pending-add--many--you": { - "messageformat": "You invited {count, number} people to the group.", + "messageformat": "You invited {count, plural, one {# person} other {# people}} to the group.", "description": "Shown in timeline or conversation preview when v2 group changes" }, "icu:GroupV2--pending-add--many--unknown": { - "messageformat": "{count, plural, one {#} other {#}} people were invited to the group.", + "messageformat": "{count, plural, one {# person was} other {# people were}} invited to the group.", "description": "Shown in timeline or conversation preview when v2 group changes" }, "icu:GroupV2--pending-remove--decline--other": { @@ -4207,15 +4207,15 @@ "description": "Shown in timeline or conversation preview when v2 group changes" }, "icu:GroupV2--pending-remove--revoke--many--other": { - "messageformat": "{memberName} revoked invitations to the group for {count, number} people.", + "messageformat": "{memberName} revoked {count, plural, one {an invitation to the group for 1 person} other {invitations to the group for # people}}.", "description": "Shown in timeline or conversation preview when v2 group changes" }, "icu:GroupV2--pending-remove--revoke--many--you": { - "messageformat": "You revoked invitations to the group for {count, number} people.", + "messageformat": "You revoked {count, plural, one {an invitation to the group for 1 person} other {invitations to the group for # people}}.", "description": "Shown in timeline or conversation preview when v2 group changes" }, "icu:GroupV2--pending-remove--revoke--many--unknown": { - "messageformat": "An admin revoked invitations to the group for {count, number} people.", + "messageformat": "An admin revoked {count, plural, one {an invitation to the group for 1 person} other {invitations to the group for # people}}.", "description": "Shown in timeline or conversation preview when v2 group changes" }, "icu:GroupV2--pending-remove--revoke-invite-from--one--other": { @@ -4243,27 +4243,27 @@ "description": "Shown in timeline or conversation preview when v2 group changes" }, "icu:GroupV2--pending-remove--revoke-invite-from--many--other": { - "messageformat": "{adminName} revoked invitations to the group for {count, plural, one {# person} other {# people}} invited by {memberName}.", + "messageformat": "{adminName} revoked {count, plural, one {an invitation to the group for 1 person} other {invitations to the group for # people}} invited by {memberName}.", "description": "Shown in timeline or conversation preview when v2 group changes" }, "icu:GroupV2--pending-remove--revoke-invite-from--many--you": { - "messageformat": "You revoked invitations to the group for {count, number} people invited by {memberName}.", + "messageformat": "You revoked {count, plural, one {an invitation to the group for 1 person} other {invitations to the group for # people}} invited by {memberName}.", "description": "Shown in timeline or conversation preview when v2 group changes" }, "icu:GroupV2--pending-remove--revoke-invite-from--many--unknown": { - "messageformat": "An admin revoked invitations to the group for {count, number} people invited by {memberName}.", + "messageformat": "An admin revoked {count, plural, one {an invitation to the group for 1 person} other {invitations to the group for # people}} invited by {memberName}.", "description": "Shown in timeline or conversation preview when v2 group changes" }, "icu:GroupV2--pending-remove--revoke-invite-from-you--many--other": { - "messageformat": "{adminName} revoked the invitations to the group you sent to {count, number} people.", + "messageformat": "{adminName} revoked the {count, plural, one {invitation to the group you sent to 1 person} other {invitations to the group you sent to # people}}.", "description": "Shown in timeline or conversation preview when v2 group changes" }, "icu:GroupV2--pending-remove--revoke-invite-from-you--many--you": { - "messageformat": "You rescinded your invitation to {count, number} people.", + "messageformat": "You rescinded your invitation to {count, plural, one {# person} other {# people}}.", "description": "Shown in timeline or conversation preview when v2 group changes" }, "icu:GroupV2--pending-remove--revoke-invite-from-you--many--unknown": { - "messageformat": "An admin revoked the invitations to the group you sent to {count, number} people.", + "messageformat": "An admin revoked the {count, plural, one {invitation to the group you sent to 1 person} other {invitations to the group you sent to # people}}.", "description": "Shown in timeline or conversation preview when v2 group changes" }, "icu:GroupV2--admin-approval-add-one--you": { @@ -4454,6 +4454,10 @@ "messageformat": "This member will need to accept an invite to join this group again, and will not receive group messages until they accept:", "description": "Shown on Learn More popup after or Migration popup before GV1 migration" }, + "icu:GroupV1--Migration--info--invited--count": { + "messageformat": "{count, plural, one {# member} other {# members}} will need to accept an invite to join this group again, and will not receive group messages until they accept.", + "description": "Shown on Learn More popup after or Migration popup before GV1 migration" + }, "icu:GroupV1--Migration--info--removed--before--many": { "messageformat": "These members are not capable of joining New Groups, and will be removed from the group:", "description": "Shown on Learn More popup after or Migration popup before GV1 migration" @@ -4462,6 +4466,10 @@ "messageformat": "This member is not capable of joining New Groups, and will be removed from the group:", "description": "Shown on Learn More popup after or Migration popup before GV1 migration" }, + "icu:GroupV1--Migration--info--removed--before--count": { + "messageformat": "{count, plural, one {# member is} other {# members are}} not capable of joining New Groups, and will be removed from the group.", + "description": "Shown on Learn More popup after or Migration popup before GV1 migration" + }, "icu:GroupV1--Migration--info--removed--after--many": { "messageformat": "These members were not capable of joining New Groups, and were removed from the group:", "description": "Shown on Learn More popup after or Migration popup before GV1 migration" @@ -4470,6 +4478,10 @@ "messageformat": "This member was not capable of joining New Groups, and was removed from the group:", "description": "Shown on Learn More popup after or Migration popup before GV1 migration" }, + "icu:GroupV1--Migration--info--removed--after--count": { + "messageformat": "{count, plural, one {# member was not capable of joining New Groups, and was} other {# members were not capable of joining New Groups, and were}} removed from the group.", + "description": "Shown on Learn More popup after or Migration popup before GV1 migration" + }, "icu:GroupV1--Migration--invited--you": { "messageformat": "You couldn't be added to the New Group and have been invited to join.", "description": "Shown in timeline when a group is upgraded and you were invited instead of added" @@ -4479,7 +4491,7 @@ "description": "Shown in timeline when a group is upgraded and one person was invited, instead of added" }, "icu:GroupV1--Migration--invited--many": { - "messageformat": "{count, number} members couldn’t be added to the New Group and have been invited to join.", + "messageformat": "{count, plural, one {# member couldn’t be added to the New Group and has been invited to join} other {# members couldn’t be added to the New Group and have been invited to join}}.", "description": "Shown in timeline when a group is upgraded and some people were invited, instead of added" }, "icu:GroupV1--Migration--removed--one": { diff --git a/stylesheets/components/GroupDialog.scss b/stylesheets/components/GroupDialog.scss index ac29bf9eab..05714f0ac9 100644 --- a/stylesheets/components/GroupDialog.scss +++ b/stylesheets/components/GroupDialog.scss @@ -6,7 +6,7 @@ border-radius: 8px; margin-block: 0; margin-inline: auto; - max-height: 100%; + max-height: 80vh; max-width: 360px; padding: 16px; position: relative; diff --git a/ts/ConversationController.ts b/ts/ConversationController.ts index 1f6b2af16b..876734a128 100644 --- a/ts/ConversationController.ts +++ b/ts/ConversationController.ts @@ -262,7 +262,7 @@ export class ConversationController { getOrCreate( identifier: string | null, type: ConversationAttributesTypeType, - additionalInitialProps = {} + additionalInitialProps: Partial = {} ): ConversationModel { if (typeof identifier !== 'string') { throw new TypeError("'id' must be a string"); @@ -358,7 +358,7 @@ export class ConversationController { async getOrCreateAndWait( id: string | null, type: ConversationAttributesTypeType, - additionalInitialProps = {} + additionalInitialProps: Partial = {} ): Promise { await this.load(); const conversation = this.getOrCreate(id, type, additionalInitialProps); diff --git a/ts/components/GroupV1MigrationDialog.stories.tsx b/ts/components/GroupV1MigrationDialog.stories.tsx index cd17a0cbf8..3a6ba2e72b 100644 --- a/ts/components/GroupV1MigrationDialog.stories.tsx +++ b/ts/components/GroupV1MigrationDialog.stories.tsx @@ -36,11 +36,13 @@ const contact3: ConversationType = getDefaultConversation({ const createProps = (overrideProps: Partial = {}): PropsType => ({ areWeInvited: Boolean(overrideProps.areWeInvited), - droppedMembers: overrideProps.droppedMembers || [contact3, contact1], + droppedMembers: overrideProps.droppedMembers, + droppedMemberCount: overrideProps.droppedMemberCount || 0, getPreferredBadge: () => undefined, hasMigrated: Boolean(overrideProps.hasMigrated), i18n, - invitedMembers: overrideProps.invitedMembers || [contact2], + invitedMembers: overrideProps.invitedMembers, + invitedMemberCount: overrideProps.invitedMemberCount || 0, onMigrate: action('onMigrate'), onClose: action('onClose'), theme: ThemeType.light, @@ -75,23 +77,41 @@ export function MigratedYouAreInvited(): JSX.Element { ); } -export function NotYetMigratedMultipleDroppedAndInvitedMembers(): JSX.Element { +export function MigratedMultipleDroppedAndInvitedMember(): JSX.Element { return ( ); } -export function NotYetMigratedNoMembers(): JSX.Element { +export function MigratedMultipleDroppedAndInvitedMembers(): JSX.Element { return ( + ); +} + +export function MigratedNoMembers(): JSX.Element { + return ( + ); @@ -101,7 +121,65 @@ export function NotYetMigratedJustDroppedMember(): JSX.Element { return ( + ); +} + +export function NotYetMigratedJustDroppedMembers(): JSX.Element { + return ( + + ); +} + +export function NotYetMigratedDropped1(): JSX.Element { + return ( + + ); +} + +export function NotYetMigratedDropped2(): JSX.Element { + return ( + + ); +} + +export function MigratedJustCountIs1(): JSX.Element { + return ( + + ); +} + +export function MigratedJustCountIs2(): JSX.Element { + return ( + ); diff --git a/ts/components/GroupV1MigrationDialog.tsx b/ts/components/GroupV1MigrationDialog.tsx index 43bddbf039..f967e9b7e2 100644 --- a/ts/components/GroupV1MigrationDialog.tsx +++ b/ts/components/GroupV1MigrationDialog.tsx @@ -11,9 +11,11 @@ import { missingCaseError } from '../util/missingCaseError'; export type DataPropsType = { readonly areWeInvited: boolean; - readonly droppedMembers: Array; + readonly droppedMembers?: Array; + readonly droppedMemberCount: number; readonly hasMigrated: boolean; - readonly invitedMembers: Array; + readonly invitedMembers?: Array; + readonly invitedMemberCount: number; readonly getPreferredBadge: PreferredBadgeSelectorType; readonly i18n: LocalizerType; readonly theme: ThemeType; @@ -30,10 +32,12 @@ export const GroupV1MigrationDialog: React.FunctionComponent = React.memo(function GroupV1MigrationDialogInner({ areWeInvited, droppedMembers, + droppedMemberCount, getPreferredBadge, hasMigrated, i18n, invitedMembers, + invitedMemberCount, theme, onClose, onMigrate, @@ -88,6 +92,7 @@ export const GroupV1MigrationDialog: React.FunctionComponent = getPreferredBadge, i18n, members: invitedMembers, + count: invitedMemberCount, hasMigrated, kind: 'invited', theme, @@ -96,6 +101,7 @@ export const GroupV1MigrationDialog: React.FunctionComponent = getPreferredBadge, i18n, members: droppedMembers, + count: droppedMemberCount, hasMigrated, kind: 'dropped', theme, @@ -110,21 +116,50 @@ function renderMembers({ getPreferredBadge, i18n, members, + count, hasMigrated, kind, theme, }: Readonly<{ getPreferredBadge: PreferredBadgeSelectorType; i18n: LocalizerType; - members: Array; + members?: Array; + count: number; hasMigrated: boolean; kind: 'invited' | 'dropped'; theme: ThemeType; }>): React.ReactNode { - if (!members.length) { + if (count === 0) { return null; } + if (!members) { + if (kind === 'invited') { + return ( + + {i18n('icu:GroupV1--Migration--info--invited--count', { count })} + + ); + } + if (hasMigrated) { + return ( + + {i18n('icu:GroupV1--Migration--info--removed--after--count', { + count, + })} + + ); + } + + return ( + + {i18n('icu:GroupV1--Migration--info--removed--before--count', { + count, + })} + + ); + } + let text: string; switch (kind) { case 'invited': @@ -137,13 +172,13 @@ function renderMembers({ if (hasMigrated) { text = members.length === 1 - ? i18n('icu:GroupV1--Migration--info--removed--before--one') - : i18n('icu:GroupV1--Migration--info--removed--before--many'); + ? i18n('icu:GroupV1--Migration--info--removed--after--one') + : i18n('icu:GroupV1--Migration--info--removed--after--many'); } else { text = members.length === 1 - ? i18n('icu:GroupV1--Migration--info--removed--after--one') - : i18n('icu:GroupV1--Migration--info--removed--after--many'); + ? i18n('icu:GroupV1--Migration--info--removed--before--one') + : i18n('icu:GroupV1--Migration--info--removed--before--many'); } break; default: diff --git a/ts/components/conversation/GroupV1Migration.stories.tsx b/ts/components/conversation/GroupV1Migration.stories.tsx index 294d46713f..77b4a0e386 100644 --- a/ts/components/conversation/GroupV1Migration.stories.tsx +++ b/ts/components/conversation/GroupV1Migration.stories.tsx @@ -33,9 +33,11 @@ export default { areWeInvited: false, conversationId: '123', droppedMembers: [contact1], + droppedMemberCount: 1, getPreferredBadge: () => undefined, i18n, invitedMembers: [contact2], + invitedMemberCount: 1, theme: ThemeType.light, }, } satisfies Meta; @@ -55,7 +57,9 @@ export function MultipleDroppedAndInvitedMembers(args: PropsType): JSX.Element { ); } @@ -65,7 +69,9 @@ export function JustInvitedMembers(args: PropsType): JSX.Element { ); } @@ -75,11 +81,45 @@ export function JustDroppedMembers(args: PropsType): JSX.Element { ); } export function NoDroppedOrInvitedMembers(args: PropsType): JSX.Element { - return ; + return ( + + ); +} + +export function NoArraysCountIsZero(args: PropsType): JSX.Element { + return ( + + ); +} + +export function NoArraysWithCount(args: PropsType): JSX.Element { + return ( + + ); } diff --git a/ts/components/conversation/GroupV1Migration.tsx b/ts/components/conversation/GroupV1Migration.tsx index 26f89ef738..69e96f7d98 100644 --- a/ts/components/conversation/GroupV1Migration.tsx +++ b/ts/components/conversation/GroupV1Migration.tsx @@ -16,8 +16,10 @@ import * as log from '../../logging/log'; export type PropsDataType = { areWeInvited: boolean; conversationId: string; - droppedMembers: Array; - invitedMembers: Array; + droppedMembers?: Array; + invitedMembers?: Array; + droppedMemberCount: number; + invitedMemberCount: number; }; export type PropsHousekeepingType = { @@ -32,9 +34,11 @@ export function GroupV1Migration(props: PropsType): React.ReactElement { const { areWeInvited, droppedMembers, + droppedMemberCount, getPreferredBadge, i18n, invitedMembers, + invitedMemberCount, theme, } = props; const [showingDialog, setShowingDialog] = React.useState(false); @@ -55,12 +59,23 @@ export function GroupV1Migration(props: PropsType): React.ReactElement { <>

{i18n('icu:GroupV1--Migration--was-upgraded')}

+ {' '} {areWeInvited ? ( i18n('icu:GroupV1--Migration--invited--you') ) : ( <> - {renderUsers(invitedMembers, i18n, 'invited')} - {renderUsers(droppedMembers, i18n, 'removed')} + {renderUsers({ + members: invitedMembers, + count: invitedMemberCount, + i18n, + kind: 'invited', + })} + {renderUsers({ + members: droppedMembers, + count: droppedMemberCount, + i18n, + kind: 'removed', + })} )}

@@ -80,10 +95,12 @@ export function GroupV1Migration(props: PropsType): React.ReactElement { log.warn('GroupV1Migration: Modal called migrate()')} onClose={dismissDialog} theme={theme} @@ -93,16 +110,22 @@ export function GroupV1Migration(props: PropsType): React.ReactElement { ); } -function renderUsers( - members: Array, - i18n: LocalizerType, - kind: 'invited' | 'removed' -): React.ReactElement | null { - if (!members || members.length === 0) { +function renderUsers({ + members, + count, + i18n, + kind, +}: { + members?: Array; + count: number; + i18n: LocalizerType; + kind: 'invited' | 'removed'; +}): React.ReactElement | null { + if (count === 0) { return null; } - if (members.length === 1) { + if (members && count === 1) { const contact = ; return (

@@ -124,18 +147,16 @@ function renderUsers( ); } - const count = members.length; - return (

- {kind === 'invited' && members.length > 1 && ( + {kind === 'invited' && ( )} - {kind === 'removed' && members.length > 1 && ( + {kind === 'removed' && ( = ( conversationId: string ) => ( - {`Conversation(${conversationId})`} + {contactMap[conversationId] || 'UNKNOWN'} ); +function checkServiceIdEquivalence( + left: ServiceIdString | undefined, + right: ServiceIdString | undefined +): boolean { + return Boolean(left && right && contactMap[left] === contactMap[right]); +} + const renderChange = ( change: GroupV2ChangeType, { @@ -57,6 +79,7 @@ const renderChange = ( blockGroupLinkRequests={action('blockGroupLinkRequests')} conversationId="some-conversation-id" change={change} + checkServiceIdEquivalence={checkServiceIdEquivalence} groupBannedMemberships={groupBannedMemberships} groupMemberships={groupMemberships} groupName={groupName} @@ -603,6 +626,24 @@ export function MemberAddFromInvited(): JSX.Element { }, ], })} + {renderChange({ + from: OUR_PNI, + details: [ + { + type: 'member-add-from-invite', + aci: OUR_ACI, + }, + ], + })} + {renderChange({ + from: CONTACT_A_PNI, + details: [ + { + type: 'member-add-from-invite', + aci: CONTACT_A, + }, + ], + })} ); } @@ -923,6 +964,15 @@ export function PendingAddMany(): JSX.Element { }, ], })} + {renderChange({ + from: OUR_ACI, + details: [ + { + type: 'pending-add-many', + count: 1, + }, + ], + })} {renderChange({ from: CONTACT_A, details: [ @@ -932,11 +982,28 @@ export function PendingAddMany(): JSX.Element { }, ], })} + {renderChange({ + from: CONTACT_A, + details: [ + { + type: 'pending-add-many', + count: 1, + }, + ], + })} + {renderChange({ + details: [ + { + type: 'pending-add-many', + count: 5, + }, + ], + })} {renderChange({ details: [ { type: 'pending-add-many', - count: 5, + count: 1, }, ], })} @@ -1100,6 +1167,16 @@ export function PendingRemoveMany(): JSX.Element { }, ], })} + {renderChange({ + from: OUR_ACI, + details: [ + { + type: 'pending-remove-many', + count: 1, + inviter: OUR_ACI, + }, + ], + })} {renderChange({ from: ADMIN_A, details: [ @@ -1110,11 +1187,30 @@ export function PendingRemoveMany(): JSX.Element { }, ], })} + {renderChange({ + from: ADMIN_A, + details: [ + { + type: 'pending-remove-many', + count: 1, + inviter: OUR_ACI, + }, + ], + })} + {renderChange({ + details: [ + { + type: 'pending-remove-many', + count: 5, + inviter: OUR_ACI, + }, + ], + })} {renderChange({ details: [ { type: 'pending-remove-many', - count: 5, + count: 1, inviter: OUR_ACI, }, ], @@ -1129,6 +1225,16 @@ export function PendingRemoveMany(): JSX.Element { }, ], })} + {renderChange({ + from: OUR_ACI, + details: [ + { + type: 'pending-remove-many', + count: 1, + inviter: CONTACT_A, + }, + ], + })} {renderChange({ from: ADMIN_A, details: [ @@ -1139,6 +1245,16 @@ export function PendingRemoveMany(): JSX.Element { }, ], })} + {renderChange({ + from: ADMIN_A, + details: [ + { + type: 'pending-remove-many', + count: 1, + inviter: CONTACT_A, + }, + ], + })} {renderChange({ details: [ { @@ -1148,6 +1264,15 @@ export function PendingRemoveMany(): JSX.Element { }, ], })} + {renderChange({ + details: [ + { + type: 'pending-remove-many', + count: 1, + inviter: CONTACT_A, + }, + ], + })} {renderChange({ from: OUR_ACI, details: [ @@ -1157,6 +1282,15 @@ export function PendingRemoveMany(): JSX.Element { }, ], })} + {renderChange({ + from: OUR_ACI, + details: [ + { + type: 'pending-remove-many', + count: 1, + }, + ], + })} {renderChange({ from: CONTACT_A, @@ -1167,6 +1301,15 @@ export function PendingRemoveMany(): JSX.Element { }, ], })} + {renderChange({ + from: CONTACT_A, + details: [ + { + type: 'pending-remove-many', + count: 1, + }, + ], + })} {renderChange({ details: [ { @@ -1175,6 +1318,14 @@ export function PendingRemoveMany(): JSX.Element { }, ], })} + {renderChange({ + details: [ + { + type: 'pending-remove-many', + count: 1, + }, + ], + })} ); } @@ -1183,6 +1334,7 @@ export function AdminApprovalAdd(): JSX.Element { return ( <> {renderChange({ + from: OUR_ACI, details: [ { type: 'admin-approval-add-one', @@ -1191,6 +1343,7 @@ export function AdminApprovalAdd(): JSX.Element { ], })} {renderChange({ + from: CONTACT_A, details: [ { type: 'admin-approval-add-one', @@ -1332,6 +1485,21 @@ export function AdminApprovalBounce(): JSX.Element { { groupBannedMemberships: [CONTACT_A] } )} + Open request + {renderChange( + { + from: CONTACT_A, + details: [ + { + type: 'admin-approval-bounce', + aci: CONTACT_A, + times: 4, + isApprovalPending: true, + }, + ], + }, + { groupBannedMemberships: [] } + )} ); } diff --git a/ts/components/conversation/GroupV2Change.tsx b/ts/components/conversation/GroupV2Change.tsx index 0bb96e7aca..2a2888c4ab 100644 --- a/ts/components/conversation/GroupV2Change.tsx +++ b/ts/components/conversation/GroupV2Change.tsx @@ -29,16 +29,16 @@ import { ConfirmationDialog } from '../ConfirmationDialog'; export type PropsDataType = { areWeAdmin: boolean; + change: GroupV2ChangeType; conversationId: string; + groupBannedMemberships?: ReadonlyArray; groupMemberships?: ReadonlyArray<{ aci: AciString; isAdmin: boolean; }>; - groupBannedMemberships?: ReadonlyArray; groupName?: string; ourAci: AciString | undefined; ourPni: PniString | undefined; - change: GroupV2ChangeType; }; export type PropsActionsType = { @@ -49,6 +49,10 @@ export type PropsActionsType = { }; export type PropsHousekeepingType = { + checkServiceIdEquivalence( + left: ServiceIdString | undefined, + right: ServiceIdString | undefined + ): boolean; i18n: LocalizerType; renderContact: SmartContactRendererType; }; @@ -293,6 +297,7 @@ export function GroupV2Change(props: PropsType): ReactElement { areWeAdmin, blockGroupLinkRequests, change, + checkServiceIdEquivalence, conversationId, groupBannedMemberships, groupMemberships, @@ -306,6 +311,7 @@ export function GroupV2Change(props: PropsType): ReactElement { return ( <> {renderChange(change, { + checkServiceIdEquivalence, i18n, ourAci, ourPni, diff --git a/ts/components/conversation/Timeline.stories.tsx b/ts/components/conversation/Timeline.stories.tsx index 316ad4977f..ec08bd9afc 100644 --- a/ts/components/conversation/Timeline.stories.tsx +++ b/ts/components/conversation/Timeline.stories.tsx @@ -361,6 +361,7 @@ const renderItem = ({ isNextItemCallingNotification={false} theme={ThemeType.light} platform="darwin" + checkServiceIdEquivalence={() => false} containerElementRef={containerElementRef} containerWidthBreakpoint={containerWidthBreakpoint} conversationId="" diff --git a/ts/components/conversation/TimelineItem.stories.tsx b/ts/components/conversation/TimelineItem.stories.tsx index 1e3828c6b9..97458232c7 100644 --- a/ts/components/conversation/TimelineItem.stories.tsx +++ b/ts/components/conversation/TimelineItem.stories.tsx @@ -69,6 +69,7 @@ const getDefaultProps = () => ({ toggleSelectMessage: action('toggleSelectMessage'), reactToMessage: action('reactToMessage'), checkForAccount: action('checkForAccount'), + checkServiceIdEquivalence: () => false, clearTargetedMessage: action('clearTargetedMessage'), setMessageToEdit: action('setMessageToEdit'), setQuoteByMessageId: action('setQuoteByMessageId'), diff --git a/ts/components/conversation/TimelineItem.tsx b/ts/components/conversation/TimelineItem.tsx index 4883a8f13e..d72812c0c8 100644 --- a/ts/components/conversation/TimelineItem.tsx +++ b/ts/components/conversation/TimelineItem.tsx @@ -61,6 +61,7 @@ import { type MessageRequestResponseNotificationData, } from './MessageRequestResponseNotification'; import type { MessageRequestState } from './MessageRequestActionsConfirmation'; +import type { ServiceIdString } from '../../types/ServiceId'; type CallHistoryType = { type: 'callHistory'; @@ -172,6 +173,10 @@ export type TimelineItemType = ( ) & { timestamp: number }; type PropsLocalType = { + checkServiceIdEquivalence( + left: ServiceIdString | undefined, + right: ServiceIdString | undefined + ): boolean; containerElementRef: RefObject; conversationId: string; item?: TimelineItemType; diff --git a/ts/groupChange.ts b/ts/groupChange.ts index fa7782766b..35a304a573 100644 --- a/ts/groupChange.ts +++ b/ts/groupChange.ts @@ -37,6 +37,10 @@ export type RenderOptionsType = { ourAci: AciString | undefined; ourPni: PniString | undefined; renderContact: SmartContactRendererType; + checkServiceIdEquivalence( + left: ServiceIdString | undefined, + right: ServiceIdString | undefined + ): boolean; renderIntl: StringRendererType; }; @@ -83,6 +87,7 @@ function renderChangeDetail( options: RenderOptionsType ): string | T | ReadonlyArray { const { + checkServiceIdEquivalence, from, i18n: localizer, ourAci, @@ -243,11 +248,9 @@ function renderChangeDetail( return i18n('icu:GroupV2--access-invite-link--enabled--you'); } if (from) { - return i18n( - 'icu:GroupV2--access-invite-link--enabled--other', - - { adminName: renderContact(from) } - ); + return i18n('icu:GroupV2--access-invite-link--enabled--other', { + adminName: renderContact(from), + }); } return i18n('icu:GroupV2--access-invite-link--enabled--unknown'); } @@ -256,11 +259,9 @@ function renderChangeDetail( return i18n('icu:GroupV2--access-invite-link--disabled--you'); } if (from) { - return i18n( - 'icu:GroupV2--access-invite-link--disabled--other', - - { adminName: renderContact(from) } - ); + return i18n('icu:GroupV2--access-invite-link--disabled--other', { + adminName: renderContact(from), + }); } return i18n('icu:GroupV2--access-invite-link--disabled--unknown'); } @@ -304,7 +305,7 @@ function renderChangeDetail( const weAreJoiner = isOurServiceId(aci); const weAreInviter = isOurServiceId(inviter); - if (!from || from !== aci) { + if (!from || !checkServiceIdEquivalence(from, aci)) { if (weAreJoiner) { // They can't be the same, no fromYou check here if (from) { @@ -350,13 +351,9 @@ function renderChangeDetail( inviterName: renderContact(inviter), }); } - return i18n( - 'icu:GroupV2--member-add--from-invite--other-no-from', - - { - inviteeName: renderContact(aci), - } - ); + return i18n('icu:GroupV2--member-add--from-invite--other-no-from', { + inviteeName: renderContact(aci), + }); } if (detail.type === 'member-add-from-link') { const { aci } = detail; @@ -383,11 +380,9 @@ function renderChangeDetail( if (weAreJoiner) { if (from) { - return i18n( - 'icu:GroupV2--member-add-from-admin-approval--you--other', - - { adminName: renderContact(from) } - ); + return i18n('icu:GroupV2--member-add-from-admin-approval--you--other', { + adminName: renderContact(from), + }); } // Note: this shouldn't happen, because we only capture 'add-from-admin-approval' @@ -399,31 +394,23 @@ function renderChangeDetail( } if (fromYou) { - return i18n( - 'icu:GroupV2--member-add-from-admin-approval--other--you', - - { joinerName: renderContact(aci) } - ); + return i18n('icu:GroupV2--member-add-from-admin-approval--other--you', { + joinerName: renderContact(aci), + }); } if (from) { - return i18n( - 'icu:GroupV2--member-add-from-admin-approval--other--other', - - { - adminName: renderContact(from), - joinerName: renderContact(aci), - } - ); + return i18n('icu:GroupV2--member-add-from-admin-approval--other--other', { + adminName: renderContact(from), + joinerName: renderContact(aci), + }); } // Note: this shouldn't happen, because we only capture 'add-from-admin-approval' // status from group change events, which always have a sender. log.warn('member-add-from-admin-approval change type; we have no from'); - return i18n( - 'icu:GroupV2--member-add-from-admin-approval--other--unknown', - - { joinerName: renderContact(aci) } - ); + return i18n('icu:GroupV2--member-add-from-admin-approval--other--unknown', { + joinerName: renderContact(aci), + }); } if (detail.type === 'member-remove') { const { aci } = detail; @@ -446,7 +433,7 @@ function renderChangeDetail( memberName: renderContact(aci), }); } - if (from && from === aci) { + if (from && fromYou) { return i18n('icu:GroupV2--member-remove--other--self', { memberName: renderContact(from), }); @@ -468,11 +455,9 @@ function renderChangeDetail( if (newPrivilege === RoleEnum.ADMINISTRATOR) { if (weAreMember) { if (from) { - return i18n( - 'icu:GroupV2--member-privilege--promote--you--other', - - { adminName: renderContact(from) } - ); + return i18n('icu:GroupV2--member-privilege--promote--you--other', { + adminName: renderContact(from), + }); } return i18n('icu:GroupV2--member-privilege--promote--you--unknown'); @@ -509,20 +494,14 @@ function renderChangeDetail( }); } if (from) { - return i18n( - 'icu:GroupV2--member-privilege--demote--other--other', - - { - adminName: renderContact(from), - memberName: renderContact(aci), - } - ); + return i18n('icu:GroupV2--member-privilege--demote--other--other', { + adminName: renderContact(from), + memberName: renderContact(aci), + }); } - return i18n( - 'icu:GroupV2--member-privilege--demote--other--unknown', - - { memberName: renderContact(aci) } - ); + return i18n('icu:GroupV2--member-privilege--demote--other--unknown', { + memberName: renderContact(aci), + }); } log.warn( `member-privilege change type, privilege ${newPrivilege} is unknown` @@ -586,14 +565,12 @@ function renderChangeDetail( if (fromYou) { return i18n( 'icu:GroupV2--pending-remove--revoke-invite-from-you--one--you', - { inviteeName: renderContact(serviceId) } ); } if (from) { return i18n( 'icu:GroupV2--pending-remove--revoke-invite-from-you--one--other', - { adminName: renderContact(from), inviteeName: renderContact(serviceId), @@ -602,7 +579,6 @@ function renderChangeDetail( } return i18n( 'icu:GroupV2--pending-remove--revoke-invite-from-you--one--unknown', - { inviteeName: renderContact(serviceId) } ); } @@ -619,30 +595,24 @@ function renderChangeDetail( } if (inviter && sentByInviter) { if (weAreInvited) { - return i18n( - 'icu:GroupV2--pending-remove--revoke-own--to-you', - - { inviterName: renderContact(inviter) } - ); + return i18n('icu:GroupV2--pending-remove--revoke-own--to-you', { + inviterName: renderContact(inviter), + }); } - return i18n( - 'icu:GroupV2--pending-remove--revoke-own--unknown', - - { inviterName: renderContact(inviter) } - ); + return i18n('icu:GroupV2--pending-remove--revoke-own--unknown', { + inviterName: renderContact(inviter), + }); } if (inviter) { if (fromYou) { return i18n( 'icu:GroupV2--pending-remove--revoke-invite-from--one--you', - { memberName: renderContact(inviter) } ); } if (from) { return i18n( 'icu:GroupV2--pending-remove--revoke-invite-from--one--other', - { adminName: renderContact(from), memberName: renderContact(inviter), @@ -651,7 +621,6 @@ function renderChangeDetail( } return i18n( 'icu:GroupV2--pending-remove--revoke-invite-from--one--unknown', - { memberName: renderContact(inviter) } ); } @@ -673,14 +642,12 @@ function renderChangeDetail( if (fromYou) { return i18n( 'icu:GroupV2--pending-remove--revoke-invite-from-you--many--you', - { count } ); } if (from) { return i18n( 'icu:GroupV2--pending-remove--revoke-invite-from-you--many--other', - { adminName: renderContact(from), count, @@ -689,7 +656,6 @@ function renderChangeDetail( } return i18n( 'icu:GroupV2--pending-remove--revoke-invite-from-you--many--unknown', - { count } ); } @@ -697,7 +663,6 @@ function renderChangeDetail( if (fromYou) { return i18n( 'icu:GroupV2--pending-remove--revoke-invite-from--many--you', - { count, memberName: renderContact(inviter), @@ -707,7 +672,6 @@ function renderChangeDetail( if (from) { return i18n( 'icu:GroupV2--pending-remove--revoke-invite-from--many--other', - { adminName: renderContact(from), count, @@ -717,7 +681,6 @@ function renderChangeDetail( } return i18n( 'icu:GroupV2--pending-remove--revoke-invite-from--many--unknown', - { count, memberName: renderContact(inviter), @@ -730,20 +693,14 @@ function renderChangeDetail( }); } if (from) { - return i18n( - 'icu:GroupV2--pending-remove--revoke--many--other', - - { - memberName: renderContact(from), - count, - } - ); + return i18n('icu:GroupV2--pending-remove--revoke--many--other', { + memberName: renderContact(from), + count, + }); } - return i18n( - 'icu:GroupV2--pending-remove--revoke--many--unknown', - - { count } - ); + return i18n('icu:GroupV2--pending-remove--revoke--many--unknown', { + count, + }); } if (detail.type === 'admin-approval-add-one') { const { aci } = detail; @@ -768,35 +725,25 @@ function renderChangeDetail( } if (fromYou) { - return i18n( - 'icu:GroupV2--admin-approval-remove-one--other--you', - - { joinerName: renderContact(aci) } - ); + return i18n('icu:GroupV2--admin-approval-remove-one--other--you', { + joinerName: renderContact(aci), + }); } - if (from && from === aci) { - return i18n( - 'icu:GroupV2--admin-approval-remove-one--other--own', - - { joinerName: renderContact(aci) } - ); + if (from && fromYou) { + return i18n('icu:GroupV2--admin-approval-remove-one--other--own', { + joinerName: renderContact(aci), + }); } if (from) { - return i18n( - 'icu:GroupV2--admin-approval-remove-one--other--other', - - { - adminName: renderContact(from), - joinerName: renderContact(aci), - } - ); + return i18n('icu:GroupV2--admin-approval-remove-one--other--other', { + adminName: renderContact(from), + joinerName: renderContact(aci), + }); } - return i18n( - 'icu:GroupV2--admin-approval-remove-one--other--unknown', - - { joinerName: renderContact(aci) } - ); + return i18n('icu:GroupV2--admin-approval-remove-one--other--unknown', { + joinerName: renderContact(aci), + }); } if (detail.type === 'admin-approval-bounce') { const { aci, times, isApprovalPending } = detail; diff --git a/ts/groups.ts b/ts/groups.ts index 1cd28277ce..45a0f089c9 100644 --- a/ts/groups.ts +++ b/ts/groups.ts @@ -2429,12 +2429,15 @@ export async function initiateMigrationToGroupV2( groupChangeMessages.push({ ...generateBasicMessage(), type: 'group-v1-migration', - invitedGV2Members: pendingMembersV2.map( - ({ serviceId: uuid, ...rest }) => { - return { ...rest, uuid }; - } - ), - droppedGV2MemberIds, + groupMigration: { + areWeInvited: false, + droppedMemberIds: droppedGV2MemberIds, + invitedMembers: pendingMembersV2.map( + ({ serviceId: uuid, ...rest }) => { + return { ...rest, uuid }; + } + ), + }, readStatus: ReadStatus.Read, seenStatus: SeenStatus.Seen, }); diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index 399b4cee94..7af53f6fc5 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -65,8 +65,12 @@ export type CustomError = Error & { export type GroupMigrationType = { areWeInvited: boolean; - droppedMemberIds: Array; - invitedMembers: Array; + droppedMemberIds?: Array; + invitedMembers?: Array; + + // We don't generate data like this; these were added to support import/export + droppedMemberCount?: number; + invitedMemberCount?: number; }; export type QuotedAttachment = { @@ -131,6 +135,28 @@ export type EditHistoryType = { received_at_ms?: number; }; +type MessageType = + | 'call-history' + | 'change-number-notification' + | 'chat-session-refreshed' + | 'conversation-merge' + | 'delivery-issue' + | 'group-v1-migration' + | 'group-v2-change' + | 'group' + | 'incoming' + | 'keychange' + | 'outgoing' + | 'phone-number-discovery' + | 'profile-change' + | 'story' + | 'timer-notification' + | 'universal-timer-notification' + | 'contact-removed-notification' + | 'title-transition-notification' + | 'verified-change' + | 'message-request-response-event'; + export type MessageAttributesType = { bodyAttachment?: AttachmentType; bodyRanges?: ReadonlyArray; @@ -180,27 +206,7 @@ export type MessageAttributesType = { verifiedChanged?: string; id: string; - type: - | 'call-history' - | 'change-number-notification' - | 'chat-session-refreshed' - | 'conversation-merge' - | 'delivery-issue' - | 'group-v1-migration' - | 'group-v2-change' - | 'group' - | 'incoming' - | 'keychange' - | 'outgoing' - | 'phone-number-discovery' - | 'profile-change' - | 'story' - | 'timer-notification' - | 'universal-timer-notification' - | 'contact-removed-notification' - | 'title-transition-notification' - | 'verified-change' - | 'message-request-response-event'; + type: MessageType; body?: string; attachments?: Array; preview?: Array; @@ -437,7 +443,7 @@ export type ConversationAttributesType = { }; announcementsOnly?: boolean; avatar?: ContactAvatarType | null; - avatars?: Array; + avatars?: ReadonlyArray>; description?: string; expireTimer?: DurationInSeconds; membersV2?: Array; diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index 909dce8d38..274e9f4513 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -4516,9 +4516,10 @@ export class ConversationModel extends window.Backbone } } - const ourConversationId = - window.ConversationController.getOurConversationId(); - source = source || ourConversationId; + const ourConversation = + window.ConversationController.getOurConversationOrThrow(); + source = source || ourConversation.id; + const sourceServiceId = ourConversation.get('serviceId'); this.set({ expireTimer }); @@ -4535,7 +4536,7 @@ export class ConversationModel extends window.Backbone const isFromSyncOperation = reason === 'group sync' || reason === 'contact sync'; const isFromMe = - window.ConversationController.get(source)?.id === ourConversationId; + window.ConversationController.get(source) === ourConversation; const isNoteToSelf = isMe(this.attributes); const shouldBeRead = (isInitialSync && isFromSyncOperation) || isFromMe || isNoteToSelf; @@ -4547,6 +4548,7 @@ export class ConversationModel extends window.Backbone expirationTimerUpdate: { expireTimer, source, + sourceServiceId, fromSync, fromGroupUpdate, }, diff --git a/ts/services/backups/export.ts b/ts/services/backups/export.ts index 8d2cac3f7b..badcafe886 100644 --- a/ts/services/backups/export.ts +++ b/ts/services/backups/export.ts @@ -2,17 +2,21 @@ // SPDX-License-Identifier: AGPL-3.0-only import Long from 'long'; -import { Aci, Pni } from '@signalapp/libsignal-client'; +import { Aci, Pni, ServiceId } from '@signalapp/libsignal-client'; import pMap from 'p-map'; import pTimeout from 'p-timeout'; import { Readable } from 'stream'; -import { Backups } from '../../protobuf'; +import { Backups, SignalService } from '../../protobuf'; import Data from '../../sql/Client'; import type { PageMessagesCursorType } from '../../sql/Interface'; import * as log from '../../logging/log'; import { StorySendMode, MY_STORY_ID } from '../../types/Stories'; -import type { ServiceIdString } from '../../types/ServiceId'; +import { + isPniString, + type AciString, + type ServiceIdString, +} from '../../types/ServiceId'; import type { RawBodyRange } from '../../types/BodyRange'; import { LONG_ATTACHMENT_LIMIT } from '../../types/Message'; import type { @@ -25,6 +29,7 @@ import { drop } from '../../util/drop'; import { explodePromise } from '../../util/explodePromise'; import { isDirectConversation, + isGroup, isGroupV2, isMe, } from '../../util/whatTypeOfConversation'; @@ -42,11 +47,41 @@ import { parsePhoneNumberSharingMode, } from '../../util/phoneNumberSharingMode'; import { missingCaseError } from '../../util/missingCaseError'; -import { isNormalBubble } from '../../state/selectors/message'; +import { + isCallHistory, + isChatSessionRefreshed, + isContactRemovedNotification, + isConversationMerge, + isDeliveryIssue, + isEndSession, + isExpirationTimerUpdate, + isGiftBadge, + isGroupUpdate, + isGroupV1Migration, + isGroupV2Change, + isKeyChange, + isNormalBubble, + isPhoneNumberDiscovery, + isProfileChange, + isUniversalTimerNotification, + isUnsupportedMessage, + isVerifiedChange, +} from '../../state/selectors/message'; import * as Bytes from '../../Bytes'; import { canBeSynced as canPreferredReactionEmojiBeSynced } from '../../reactions/preferredReactionEmoji'; import { SendStatus } from '../../messages/MessageSendState'; import { BACKUP_VERSION } from './constants'; +import { getMessageIdForLogging } from '../../util/idForLogging'; +import { getCallsHistoryForRedux } from '../callHistoryLoader'; +import { makeLookup } from '../../util/makeLookup'; +import type { CallHistoryDetails } from '../../types/CallDisposition'; +import { isAciString } from '../../util/isAciString'; +import type { AboutMe } from './types'; +import { messageHasPaymentEvent } from '../../messages/helpers'; +import { + numberToAddressType, + numberToPhoneType, +} from '../../types/EmbeddedContact'; const MAX_CONCURRENCY = 10; @@ -224,6 +259,18 @@ export class BackupExportStream extends Readable { let cursor: PageMessagesCursorType | undefined; + const callHistory = getCallsHistoryForRedux(); + const callHistoryByCallId = makeLookup(callHistory, 'callId'); + + const me = window.ConversationController.getOurConversationOrThrow(); + const serviceId = me.get('serviceId'); + const aci = isAciString(serviceId) ? serviceId : undefined; + strictAssert(aci, 'We must have our own ACI'); + const aboutMe = { + aci, + pni: me.get('pni'), + }; + try { while (!cursor?.done) { // eslint-disable-next-line no-await-in-loop @@ -232,7 +279,7 @@ export class BackupExportStream extends Readable { // eslint-disable-next-line no-await-in-loop const items = await pMap( messages, - message => this.toChatItem(message), + message => this.toChatItem(message, { aboutMe, callHistoryByCallId }), { concurrency: MAX_CONCURRENCY } ); @@ -511,21 +558,22 @@ export class BackupExportStream extends Readable { } private async toChatItem( - message: MessageAttributesType - ): Promise { - if (!isNormalBubble(message)) { - return undefined; + message: MessageAttributesType, + options: { + aboutMe: AboutMe; + callHistoryByCallId: Record; } - + ): Promise { const chatId = this.getRecipientId({ id: message.conversationId }); if (chatId === undefined) { log.warn('backups: message chat not found'); return undefined; } - let authorId: Long; + let authorId: Long | undefined; const isOutgoing = message.type === 'outgoing'; + const isIncoming = message.type === 'incoming'; if (isOutgoing) { const ourAci = window.storage.user.getCheckedAci(); @@ -544,8 +592,9 @@ export class BackupExportStream extends Readable { serviceId: message.sourceServiceId, e164: message.source, }); - } else { - return undefined; + } + if (isOutgoing || isIncoming) { + strictAssert(authorId, 'Incoming/outgoing messages require an author'); } let expireStartDate: Long | undefined; @@ -570,36 +619,42 @@ export class BackupExportStream extends Readable { expiresInMs, revisions: [], sms: false, - standardMessage: { - quote: await this.toQuote(message.quote), - text: { - // Note that we store full text on the message model so we have to - // trim it before serializing. - body: message.body?.slice(0, LONG_ATTACHMENT_LIMIT), - bodyRanges: message.bodyRanges?.map(range => this.toBodyRange(range)), - }, + }; - linkPreview: message.preview?.map(preview => { - return { - url: preview.url, - title: preview.title, - description: preview.description, - date: getSafeLongFromTimestamp(preview.date), - }; - }), - reactions: message.reactions?.map(reaction => { - return { - emoji: reaction.emoji, - authorId: this.getOrPushPrivateRecipient({ - id: reaction.fromId, - }), - sentTimestamp: getSafeLongFromTimestamp(reaction.timestamp), - receivedTimestamp: getSafeLongFromTimestamp( - reaction.receivedAtDate ?? reaction.timestamp - ), - }; - }), + if (!isNormalBubble(message)) { + return this.toChatItemFromNonBubble(result, message, options); + } + + // TODO (DESKTOP-6964): put incoming/outgoing fields below onto non-bubble messages + result.standardMessage = { + quote: await this.toQuote(message.quote), + text: { + // Note that we store full text on the message model so we have to + // trim it before serializing. + body: message.body?.slice(0, LONG_ATTACHMENT_LIMIT), + bodyRanges: message.bodyRanges?.map(range => this.toBodyRange(range)), }, + + linkPreview: message.preview?.map(preview => { + return { + url: preview.url, + title: preview.title, + description: preview.description, + date: getSafeLongFromTimestamp(preview.date), + }; + }), + reactions: message.reactions?.map(reaction => { + return { + emoji: reaction.emoji, + authorId: this.getOrPushPrivateRecipient({ + id: reaction.fromId, + }), + sentTimestamp: getSafeLongFromTimestamp(reaction.timestamp), + receivedTimestamp: getSafeLongFromTimestamp( + reaction.receivedAtDate ?? reaction.timestamp + ), + }; + }), }; if (isOutgoing) { @@ -667,6 +722,795 @@ export class BackupExportStream extends Readable { return result; } + // TODO(indutny): convert to bytes + private aciToBytes(aci: AciString | string): Uint8Array { + return Aci.parseFromServiceIdString(aci).getRawUuidBytes(); + } + private serviceIdToBytes(serviceId: ServiceIdString): Uint8Array { + return ServiceId.parseFromServiceIdString(serviceId).getRawUuidBytes(); + } + + private async toChatItemFromNonBubble( + chatItem: Backups.IChatItem, + message: MessageAttributesType, + options: { + aboutMe: AboutMe; + callHistoryByCallId: Record; + } + ): Promise { + const { contact, sticker } = message; + + if (contact && contact[0]) { + const contactMessage = new Backups.ContactMessage(); + + // TODO (DESKTOP-6845): properly handle avatarUrlPath + + contactMessage.contact = contact.map(contactDetails => ({ + ...contactDetails, + number: contactDetails.number?.map(number => ({ + ...number, + type: numberToPhoneType(number.type), + })), + email: contactDetails.email?.map(email => ({ + ...email, + type: numberToPhoneType(email.type), + })), + address: contactDetails.address?.map(address => ({ + ...address, + type: numberToAddressType(address.type), + })), + })); + + // TODO (DESKTOP-6964): add reactions + + // eslint-disable-next-line no-param-reassign + chatItem.contactMessage = contactMessage; + return chatItem; + } + + if (message.isErased) { + // eslint-disable-next-line no-param-reassign + chatItem.remoteDeletedMessage = new Backups.RemoteDeletedMessage(); + return chatItem; + } + + if (sticker) { + const stickerMessage = new Backups.StickerMessage(); + + const stickerProto = new Backups.Sticker(); + stickerProto.emoji = sticker.emoji; + stickerProto.packId = Bytes.fromHex(sticker.packId); + stickerProto.packKey = Bytes.fromBase64(sticker.packKey); + stickerProto.stickerId = sticker.stickerId; + // TODO (DESKTOP-6845): properly handle data FilePointer + + // TODO (DESKTOP-6964): add reactions + + stickerMessage.sticker = stickerProto; + // eslint-disable-next-line no-param-reassign + chatItem.stickerMessage = stickerMessage; + + return chatItem; + } + + return this.toChatItemUpdate(chatItem, message, options); + } + + async toChatItemUpdate( + chatItem: Backups.IChatItem, + message: MessageAttributesType, + options: { + aboutMe: AboutMe; + callHistoryByCallId: Record; + } + ): Promise { + const logId = `toChatItemUpdate(${getMessageIdForLogging(message)})`; + + const updateMessage = new Backups.ChatUpdateMessage(); + // eslint-disable-next-line no-param-reassign + chatItem.updateMessage = updateMessage; + + if (isCallHistory(message)) { + // TODO (DESKTOP-6964) + // const callingMessage = new Backups.CallChatUpdate(); + // const { callId } = message; + // if (!callId) { + // throw new Error( + // `${logId}: Message was callHistory, but missing callId!` + // ); + // } + // const callHistory = callHistoryByCallId[callId]; + // if (!callHistory) { + // throw new Error( + // `${logId}: Message had callId, but no call history details were found!` + // ); + // } + // callingMessage.callId = Long.fromString(callId); + // if (callHistory.mode === CallMode.Group) { + // const groupCall = new Backups.GroupCallChatUpdate(); + // const { ringerId } = callHistory; + // if (!ringerId) { + // throw new Error( + // `${logId}: Message had missing ringerId for a group call!` + // ); + // } + // groupCall.startedCallAci = this.aciToBytes(ringerId); + // groupCall.startedCallTimestamp = Long.fromNumber(callHistory.timestamp); + // // Note: we don't store inCallACIs, instead relying on RingRTC in-memory state + // callingMessage.groupCall = groupCall; + // } else { + // const callMessage = new Backups.IndividualCallChatUpdate(); + // const { direction, type, status } = callHistory; + // if ( + // status === DirectCallStatus.Accepted || + // status === DirectCallStatus.Pending + // ) { + // if (type === CallType.Audio) { + // callMessage.type = + // direction === CallDirection.Incoming + // ? Backups.IndividualCallChatUpdate.Type.INCOMING_AUDIO_CALL + // : Backups.IndividualCallChatUpdate.Type.OUTGOING_AUDIO_CALL; + // } else if (type === CallType.Video) { + // callMessage.type = + // direction === CallDirection.Incoming + // ? Backups.IndividualCallChatUpdate.Type.INCOMING_VIDEO_CALL + // : Backups.IndividualCallChatUpdate.Type.OUTGOING_VIDEO_CALL; + // } else { + // throw new Error( + // `${logId}: Message direct status '${status}' call had type ${type}` + // ); + // } + // } else if (status === DirectCallStatus.Declined) { + // if (direction === CallDirection.Incoming) { + // // question: do we really not call declined calls things that we decline? + // throw new Error( + // `${logId}: Message direct call was declined but incoming` + // ); + // } + // if (type === CallType.Audio) { + // callMessage.type = + // Backups.IndividualCallChatUpdate.Type.UNANSWERED_OUTGOING_AUDIO_CALL; + // } else if (type === CallType.Video) { + // callMessage.type = + // Backups.IndividualCallChatUpdate.Type.UNANSWERED_OUTGOING_VIDEO_CALL; + // } else { + // throw new Error( + // `${logId}: Message direct status '${status}' call had type ${type}` + // ); + // } + // } else if (status === DirectCallStatus.Missed) { + // if (direction === CallDirection.Outgoing) { + // throw new Error( + // `${logId}: Message direct call was missed but outgoing` + // ); + // } + // if (type === CallType.Audio) { + // callMessage.type = + // Backups.IndividualCallChatUpdate.Type.MISSED_INCOMING_AUDIO_CALL; + // } else if (type === CallType.Video) { + // callMessage.type = + // Backups.IndividualCallChatUpdate.Type.MISSED_INCOMING_VIDEO_CALL; + // } else { + // throw new Error( + // `${logId}: Message direct status '${status}' call had type ${type}` + // ); + // } + // } else { + // throw new Error(`${logId}: Message direct call had status ${status}`); + // } + // callingMessage.callMessage = callMessage; + // } + // updateMessage.callingMessage = callingMessage; + // return chatItem; + } + + if (isExpirationTimerUpdate(message)) { + const expiresInSeconds = message.expirationTimerUpdate?.expireTimer; + const expiresInMs = (expiresInSeconds ?? 0) * 1000; + + const conversation = window.ConversationController.get( + message.conversationId + ); + + if (conversation && isGroup(conversation.attributes)) { + const groupChatUpdate = new Backups.GroupChangeChatUpdate(); + + const timerUpdate = new Backups.GroupExpirationTimerUpdate(); + timerUpdate.expiresInMs = expiresInMs; + + const sourceServiceId = message.expirationTimerUpdate?.sourceServiceId; + if (sourceServiceId && Aci.parseFromServiceIdString(sourceServiceId)) { + timerUpdate.updaterAci = uuidToBytes(sourceServiceId); + } + + const innerUpdate = new Backups.GroupChangeChatUpdate.Update(); + + innerUpdate.groupExpirationTimerUpdate = timerUpdate; + + groupChatUpdate.updates = [innerUpdate]; + + updateMessage.groupChange = groupChatUpdate; + + return chatItem; + } + + const source = + message.expirationTimerUpdate?.sourceServiceId || + message.expirationTimerUpdate?.source; + if (source && !chatItem.authorId) { + // eslint-disable-next-line no-param-reassign + chatItem.authorId = this.getOrPushPrivateRecipient({ + id: source, + }); + } + + const expirationTimerChange = new Backups.ExpirationTimerChatUpdate(); + expirationTimerChange.expiresInMs = expiresInMs; + + updateMessage.expirationTimerChange = expirationTimerChange; + + return chatItem; + } + + if (isGroupV2Change(message)) { + updateMessage.groupChange = await this.toGroupV2Update(message, options); + + return chatItem; + } + + if (isKeyChange(message)) { + const simpleUpdate = new Backups.SimpleChatUpdate(); + simpleUpdate.type = Backups.SimpleChatUpdate.Type.IDENTITY_UPDATE; + + updateMessage.simpleUpdate = simpleUpdate; + + return chatItem; + } + + if (isProfileChange(message)) { + const profileChange = new Backups.ProfileChangeChatUpdate(); + if (!message.profileChange) { + return undefined; + } + + const { newName, oldName } = message.profileChange; + profileChange.newName = newName; + profileChange.previousName = oldName; + + updateMessage.profileChange = profileChange; + + return chatItem; + } + + if (isVerifiedChange(message)) { + // TODO (DESKTOP-6964)): it can't be this simple if we show this in groups, right? + + const simpleUpdate = new Backups.SimpleChatUpdate(); + simpleUpdate.type = Backups.SimpleChatUpdate.Type.IDENTITY_VERIFIED; + + updateMessage.simpleUpdate = simpleUpdate; + + return chatItem; + } + + if (isConversationMerge(message)) { + const threadMerge = new Backups.ThreadMergeChatUpdate(); + const e164 = message.conversationMerge?.renderInfo.e164; + if (!e164) { + return undefined; + } + threadMerge.previousE164 = Long.fromString(e164); + + updateMessage.threadMerge = threadMerge; + + return chatItem; + } + + if (isPhoneNumberDiscovery(message)) { + // TODO (DESKTOP-6964): need to add to protos + } + + if (isUniversalTimerNotification(message)) { + // TODO (DESKTOP-6964): need to add to protos + } + + if (isContactRemovedNotification(message)) { + // TODO (DESKTOP-6964): this doesn't appear to be in the protos at all + } + + if (messageHasPaymentEvent(message)) { + // TODO (DESKTOP-6964): are these enough? + // SimpleChatUpdate + // PAYMENTS_ACTIVATED + // PAYMENT_ACTIVATION_REQUEST; + } + + if (isGiftBadge(message)) { + // TODO (DESKTOP-6964) + } + + if (isGroupUpdate(message)) { + // TODO (DESKTOP-6964) + // these old-school message types are no longer generated but we probably + // still want to render them + } + + if (isGroupV1Migration(message)) { + const { groupMigration } = message; + + const groupChatUpdate = new Backups.GroupChangeChatUpdate(); + + groupChatUpdate.updates = []; + + const areWeInvited = groupMigration?.areWeInvited ?? false; + const droppedMemberCount = + groupMigration?.droppedMemberCount ?? + groupMigration?.droppedMemberIds?.length ?? + message.droppedGV2MemberIds?.length ?? + 0; + const invitedMemberCount = + groupMigration?.invitedMemberCount ?? + groupMigration?.invitedMembers?.length ?? + message.invitedGV2Members?.length ?? + 0; + + let addedItem = false; + if (areWeInvited) { + const container = new Backups.GroupChangeChatUpdate.Update(); + container.groupV2MigrationSelfInvitedUpdate = + new Backups.GroupV2MigrationSelfInvitedUpdate(); + groupChatUpdate.updates.push(container); + addedItem = true; + } + if (droppedMemberCount > 0) { + const container = new Backups.GroupChangeChatUpdate.Update(); + const update = new Backups.GroupV2MigrationDroppedMembersUpdate(); + update.droppedMembersCount = droppedMemberCount; + container.groupV2MigrationDroppedMembersUpdate = update; + groupChatUpdate.updates.push(container); + addedItem = true; + } + if (invitedMemberCount > 0) { + const container = new Backups.GroupChangeChatUpdate.Update(); + const update = new Backups.GroupV2MigrationInvitedMembersUpdate(); + update.invitedMembersCount = invitedMemberCount; + container.groupV2MigrationInvitedMembersUpdate = update; + groupChatUpdate.updates.push(container); + addedItem = true; + } + + if (!addedItem) { + const container = new Backups.GroupChangeChatUpdate.Update(); + container.groupV2MigrationUpdate = new Backups.GroupV2MigrationUpdate(); + groupChatUpdate.updates.push(container); + } + + updateMessage.groupChange = groupChatUpdate; + + return chatItem; + } + + if (isDeliveryIssue(message)) { + // TODO (DESKTOP-6964) + } + + if (isEndSession(message)) { + const simpleUpdate = new Backups.SimpleChatUpdate(); + simpleUpdate.type = Backups.SimpleChatUpdate.Type.END_SESSION; + + updateMessage.simpleUpdate = simpleUpdate; + + return chatItem; + } + + if (isChatSessionRefreshed(message)) { + const simpleUpdate = new Backups.SimpleChatUpdate(); + simpleUpdate.type = Backups.SimpleChatUpdate.Type.CHAT_SESSION_REFRESH; + + updateMessage.simpleUpdate = simpleUpdate; + + return chatItem; + } + + if (isUnsupportedMessage(message)) { + // TODO (DESKTOP-6964): need to add to protos + } + + throw new Error( + `${logId}: Message was not a bubble, but didn't understand type` + ); + } + + async toGroupV2Update( + message: MessageAttributesType, + options: { + aboutMe: AboutMe; + } + ): Promise { + const logId = `toGroupV2Update(${getMessageIdForLogging(message)})`; + + const { groupV2Change } = message; + const { aboutMe } = options; + if (!isGroupV2Change(message) || !groupV2Change) { + throw new Error(`${logId}: Message was not a groupv2 change`); + } + + const { from, details } = groupV2Change; + const updates: Array = []; + + details.forEach(detail => { + const update = new Backups.GroupChangeChatUpdate.Update(); + const { type } = detail; + + if (type === 'create') { + const innerUpdate = new Backups.GroupCreationUpdate(); + if (from) { + innerUpdate.updaterAci = this.serviceIdToBytes(from); + } + update.groupCreationUpdate = innerUpdate; + updates.push(update); + } else if (type === 'access-attributes') { + const innerUpdate = + new Backups.GroupAttributesAccessLevelChangeUpdate(); + if (from) { + innerUpdate.updaterAci = this.serviceIdToBytes(from); + } + innerUpdate.accessLevel = detail.newPrivilege; + + update.groupAttributesAccessLevelChangeUpdate = innerUpdate; + updates.push(update); + } else if (type === 'access-members') { + const innerUpdate = + new Backups.GroupMembershipAccessLevelChangeUpdate(); + if (from) { + innerUpdate.updaterAci = this.serviceIdToBytes(from); + } + innerUpdate.accessLevel = detail.newPrivilege; + + update.groupMembershipAccessLevelChangeUpdate = innerUpdate; + updates.push(update); + } else if (type === 'access-invite-link') { + const innerUpdate = new Backups.GroupInviteLinkAdminApprovalUpdate(); + if (from) { + innerUpdate.updaterAci = this.serviceIdToBytes(from); + } + innerUpdate.linkRequiresAdminApproval = + detail.newPrivilege === + SignalService.AccessControl.AccessRequired.ADMINISTRATOR; + + update.groupInviteLinkAdminApprovalUpdate = innerUpdate; + updates.push(update); + } else if (type === 'announcements-only') { + const innerUpdate = new Backups.GroupAnnouncementOnlyChangeUpdate(); + if (from) { + innerUpdate.updaterAci = this.serviceIdToBytes(from); + } + innerUpdate.isAnnouncementOnly = detail.announcementsOnly; + + update.groupAnnouncementOnlyChangeUpdate = innerUpdate; + updates.push(update); + } else if (type === 'avatar') { + const innerUpdate = new Backups.GroupAvatarUpdate(); + if (from) { + innerUpdate.updaterAci = this.serviceIdToBytes(from); + } + innerUpdate.wasRemoved = detail.removed; + + update.groupAvatarUpdate = innerUpdate; + updates.push(update); + } else if (type === 'title') { + const innerUpdate = new Backups.GroupNameUpdate(); + if (from) { + innerUpdate.updaterAci = this.serviceIdToBytes(from); + } + innerUpdate.newGroupName = detail.newTitle; + + update.groupNameUpdate = innerUpdate; + updates.push(update); + } else if (type === 'group-link-add') { + const innerUpdate = new Backups.GroupInviteLinkEnabledUpdate(); + if (from) { + innerUpdate.updaterAci = this.serviceIdToBytes(from); + } + innerUpdate.linkRequiresAdminApproval = + detail.privilege === + SignalService.AccessControl.AccessRequired.ADMINISTRATOR; + + update.groupInviteLinkEnabledUpdate = innerUpdate; + updates.push(update); + } else if (type === 'group-link-reset') { + const innerUpdate = new Backups.GroupInviteLinkResetUpdate(); + if (from) { + innerUpdate.updaterAci = this.serviceIdToBytes(from); + } + + update.groupInviteLinkResetUpdate = innerUpdate; + updates.push(update); + } else if (type === 'group-link-remove') { + const innerUpdate = new Backups.GroupInviteLinkDisabledUpdate(); + if (from) { + innerUpdate.updaterAci = this.serviceIdToBytes(from); + } + + update.groupInviteLinkDisabledUpdate = innerUpdate; + updates.push(update); + } else if (type === 'member-add') { + if (from && from === detail.aci) { + const innerUpdate = new Backups.GroupMemberJoinedUpdate(); + innerUpdate.newMemberAci = this.serviceIdToBytes(from); + + update.groupMemberJoinedUpdate = innerUpdate; + updates.push(update); + return; + } + + const innerUpdate = new Backups.GroupMemberAddedUpdate(); + if (from) { + innerUpdate.updaterAci = this.serviceIdToBytes(from); + } + innerUpdate.newMemberAci = this.aciToBytes(detail.aci); + + update.groupMemberAddedUpdate = innerUpdate; + updates.push(update); + } else if (type === 'member-add-from-invite') { + if (from && checkServiceIdEquivalence(from, detail.aci)) { + const innerUpdate = new Backups.GroupInvitationAcceptedUpdate(); + innerUpdate.newMemberAci = this.aciToBytes(detail.aci); + if (detail.inviter) { + innerUpdate.inviterAci = this.aciToBytes(detail.inviter); + } + update.groupInvitationAcceptedUpdate = innerUpdate; + updates.push(update); + return; + } + + const innerUpdate = new Backups.GroupMemberAddedUpdate(); + innerUpdate.newMemberAci = this.aciToBytes(detail.aci); + if (from) { + innerUpdate.updaterAci = this.serviceIdToBytes(from); + } + if (detail.inviter) { + innerUpdate.inviterAci = this.aciToBytes(detail.inviter); + } + innerUpdate.hadOpenInvitation = true; + + update.groupMemberAddedUpdate = innerUpdate; + updates.push(update); + } else if (type === 'member-add-from-link') { + const innerUpdate = new Backups.GroupMemberJoinedByLinkUpdate(); + innerUpdate.newMemberAci = this.aciToBytes(detail.aci); + + update.groupMemberJoinedByLinkUpdate = innerUpdate; + updates.push(update); + } else if (type === 'member-add-from-admin-approval') { + const innerUpdate = new Backups.GroupJoinRequestApprovalUpdate(); + if (from) { + innerUpdate.updaterAci = this.serviceIdToBytes(from); + } + + innerUpdate.requestorAci = this.aciToBytes(detail.aci); + innerUpdate.wasApproved = true; + + update.groupJoinRequestApprovalUpdate = innerUpdate; + updates.push(update); + } else if (type === 'member-privilege') { + const innerUpdate = new Backups.GroupAdminStatusUpdate(); + if (from) { + innerUpdate.updaterAci = this.serviceIdToBytes(from); + } + + innerUpdate.memberAci = this.aciToBytes(detail.aci); + innerUpdate.wasAdminStatusGranted = + detail.newPrivilege === SignalService.Member.Role.ADMINISTRATOR; + + update.groupAdminStatusUpdate = innerUpdate; + updates.push(update); + } else if (type === 'member-remove') { + if (from && from === detail.aci) { + const innerUpdate = new Backups.GroupMemberLeftUpdate(); + innerUpdate.aci = this.serviceIdToBytes(from); + + update.groupMemberLeftUpdate = innerUpdate; + updates.push(update); + return; + } + + const innerUpdate = new Backups.GroupMemberRemovedUpdate(); + if (from) { + innerUpdate.removerAci = this.serviceIdToBytes(from); + } + innerUpdate.removedAci = this.aciToBytes(detail.aci); + + update.groupMemberRemovedUpdate = innerUpdate; + updates.push(update); + } else if (type === 'pending-add-one') { + if ( + (aboutMe.aci && detail.serviceId === aboutMe.aci) || + (aboutMe.pni && detail.serviceId === aboutMe.pni) + ) { + const innerUpdate = new Backups.SelfInvitedToGroupUpdate(); + if (from) { + innerUpdate.inviterAci = this.serviceIdToBytes(from); + } + + update.selfInvitedToGroupUpdate = innerUpdate; + updates.push(update); + return; + } + if ( + from && + ((aboutMe.aci && from === aboutMe.aci) || + (aboutMe.pni && from === aboutMe.pni)) + ) { + const innerUpdate = new Backups.SelfInvitedOtherUserToGroupUpdate(); + innerUpdate.inviteeServiceId = this.serviceIdToBytes( + detail.serviceId + ); + + update.selfInvitedOtherUserToGroupUpdate = innerUpdate; + updates.push(update); + return; + } + + const innerUpdate = new Backups.GroupUnknownInviteeUpdate(); + if (from) { + innerUpdate.inviterAci = this.serviceIdToBytes(from); + } + innerUpdate.inviteeCount = 1; + + update.groupUnknownInviteeUpdate = innerUpdate; + updates.push(update); + } else if (type === 'pending-add-many') { + const innerUpdate = new Backups.GroupUnknownInviteeUpdate(); + if (from) { + innerUpdate.inviterAci = this.serviceIdToBytes(from); + } + innerUpdate.inviteeCount = detail.count; + + update.groupUnknownInviteeUpdate = innerUpdate; + updates.push(update); + } else if (type === 'pending-remove-one') { + if (from && detail.serviceId && from === detail.serviceId) { + const innerUpdate = new Backups.GroupInvitationDeclinedUpdate(); + if (detail.inviter) { + innerUpdate.inviterAci = this.aciToBytes(detail.inviter); + } + if (isAciString(detail.serviceId)) { + innerUpdate.inviteeAci = this.aciToBytes(detail.serviceId); + } + + update.groupInvitationDeclinedUpdate = innerUpdate; + updates.push(update); + return; + } + if ( + (aboutMe.aci && detail.serviceId === aboutMe.aci) || + (aboutMe.pni && detail.serviceId === aboutMe.pni) + ) { + const innerUpdate = new Backups.GroupSelfInvitationRevokedUpdate(); + if (from) { + innerUpdate.revokerAci = this.serviceIdToBytes(from); + } + + update.groupSelfInvitationRevokedUpdate = innerUpdate; + updates.push(update); + return; + } + + const innerUpdate = new Backups.GroupInvitationRevokedUpdate(); + if (from) { + innerUpdate.updaterAci = this.serviceIdToBytes(from); + } + innerUpdate.invitees = [ + { + inviteeAci: isAciString(detail.serviceId) + ? this.aciToBytes(detail.serviceId) + : undefined, + inviteePni: isPniString(detail.serviceId) + ? this.serviceIdToBytes(detail.serviceId) + : undefined, + }, + ]; + + update.groupInvitationRevokedUpdate = innerUpdate; + updates.push(update); + } else if (type === 'pending-remove-many') { + const innerUpdate = new Backups.GroupInvitationRevokedUpdate(); + if (from) { + innerUpdate.updaterAci = this.serviceIdToBytes(from); + } + + innerUpdate.invitees = []; + for (let i = 0, max = detail.count; i < max; i += 1) { + // Yes, we're adding totally empty invitees. This is okay. + innerUpdate.invitees.push({}); + } + + update.groupInvitationRevokedUpdate = innerUpdate; + updates.push(update); + } else if (type === 'admin-approval-add-one') { + const innerUpdate = new Backups.GroupJoinRequestUpdate(); + innerUpdate.requestorAci = this.aciToBytes(detail.aci); + + update.groupJoinRequestUpdate = innerUpdate; + updates.push(update); + } else if (type === 'admin-approval-remove-one') { + if (from && detail.aci && from === detail.aci) { + const innerUpdate = new Backups.GroupJoinRequestCanceledUpdate(); + innerUpdate.requestorAci = this.aciToBytes(detail.aci); + + update.groupJoinRequestCanceledUpdate = innerUpdate; + updates.push(update); + return; + } + + const innerUpdate = new Backups.GroupJoinRequestApprovalUpdate(); + if (from) { + innerUpdate.updaterAci = this.serviceIdToBytes(from); + } + + innerUpdate.requestorAci = this.aciToBytes(detail.aci); + innerUpdate.wasApproved = false; + + update.groupJoinRequestApprovalUpdate = innerUpdate; + updates.push(update); + } else if (type === 'admin-approval-bounce') { + // We can't express all we need in GroupSequenceOfRequestsAndCancelsUpdate, so we + // add an additional groupJoinRequestUpdate to express that there + // is an approval pending. + if (detail.isApprovalPending) { + const innerUpdate = new Backups.GroupJoinRequestUpdate(); + innerUpdate.requestorAci = this.aciToBytes(detail.aci); + + // We need to create another update since the items we put in Update are oneof + const secondUpdate = new Backups.GroupChangeChatUpdate.Update(); + secondUpdate.groupJoinRequestUpdate = innerUpdate; + updates.push(secondUpdate); + + // not returning because we really do want both of these + } + + const innerUpdate = + new Backups.GroupSequenceOfRequestsAndCancelsUpdate(); + innerUpdate.requestorAci = this.aciToBytes(detail.aci); + innerUpdate.count = detail.times; + + update.groupSequenceOfRequestsAndCancelsUpdate = innerUpdate; + updates.push(update); + } else if (type === 'description') { + const innerUpdate = new Backups.GroupDescriptionUpdate(); + innerUpdate.newDescription = detail.removed + ? undefined + : detail.description; + if (from) { + innerUpdate.updaterAci = this.serviceIdToBytes(from); + } + + update.groupDescriptionUpdate = innerUpdate; + updates.push(update); + } else if (type === 'summary') { + const innerUpdate = new Backups.GenericGroupUpdate(); + if (from) { + innerUpdate.updaterAci = this.serviceIdToBytes(from); + } + + update.genericGroupUpdate = innerUpdate; + updates.push(update); + } else { + throw missingCaseError(type); + } + }); + + if (updates.length === 0) { + throw new Error(`${logId}: No updates generated from message`); + } + + const groupUpdate = new Backups.GroupChangeChatUpdate(); + groupUpdate.updates = updates; + + return groupUpdate; + } + private async toQuote( quote?: QuotedMessageType ): Promise { @@ -720,9 +1564,7 @@ export class BackupExportStream extends Readable { ...('mentionAci' in range ? { - mentionAci: Aci.parseFromServiceIdString( - range.mentionAci - ).getRawUuidBytes(), + mentionAci: this.aciToBytes(range.mentionAci), } : { // Numeric values are compatible between backup and message protos @@ -731,3 +1573,13 @@ export class BackupExportStream extends Readable { }; } } + +function checkServiceIdEquivalence( + left: ServiceIdString | undefined, + right: ServiceIdString | undefined +) { + const leftConvo = window.ConversationController.get(left); + const rightConvo = window.ConversationController.get(right); + + return leftConvo && rightConvo && leftConvo === rightConvo; +} diff --git a/ts/services/backups/import.ts b/ts/services/backups/import.ts index 499a3150f4..fd91514c8c 100644 --- a/ts/services/backups/import.ts +++ b/ts/services/backups/import.ts @@ -5,11 +5,13 @@ import { Aci, Pni } from '@signalapp/libsignal-client'; import { v4 as generateUuid } from 'uuid'; import pMap from 'p-map'; import { Writable } from 'stream'; +import { isNumber } from 'lodash'; -import { Backups } from '../../protobuf'; +import { Backups, SignalService } from '../../protobuf'; import Data from '../../sql/Client'; import * as log from '../../logging/log'; import { StorySendMode } from '../../types/Stories'; +import type { ServiceIdString } from '../../types/ServiceId'; import { fromAciObject, fromPniObject } from '../../types/ServiceId'; import * as Errors from '../../types/errors'; import type { @@ -36,6 +38,9 @@ import type { SendStateByConversationId } from '../../messages/MessageSendState' import { SeenStatus } from '../../MessageSeenStatus'; import * as Bytes from '../../Bytes'; import { BACKUP_VERSION } from './constants'; +import type { AboutMe } from './types'; +import type { GroupV2ChangeDetailType } from '../../groups'; +import { isNotNil } from '../../util/isNotNil'; const MAX_CONCURRENCY = 10; @@ -44,6 +49,11 @@ type ConversationOpType = Readonly<{ attributes: ConversationAttributesType; }>; +type ChatItemParseResult = { + message: Partial; + additionalMessages: Array>; +}; + async function processConversationOpBatch( batch: ReadonlyArray ): Promise { @@ -67,6 +77,7 @@ async function processConversationOpBatch( export class BackupImportStream extends Writable { private parsedBackupInfo = false; private logId = 'BackupImportStream(unknown)'; + private aboutMe: AboutMe | undefined; private readonly recipientIdToConvo = new Map< number, @@ -126,7 +137,19 @@ export class BackupImportStream extends Writable { } else { const frame = Backups.Frame.decode(data); - await this.processFrame(frame); + await this.processFrame(frame, { aboutMe: this.aboutMe }); + + if (!this.aboutMe && this.ourConversation) { + const { serviceId, pni } = this.ourConversation; + strictAssert( + isAciString(serviceId), + 'ourConversation serviceId must be ACI' + ); + this.aboutMe = { + aci: serviceId, + pni, + }; + } } done(); } catch (error) { @@ -181,7 +204,12 @@ export class BackupImportStream extends Writable { this.saveMessageBatcher.unregister(); } - private async processFrame(frame: Backups.Frame): Promise { + private async processFrame( + frame: Backups.Frame, + options: { aboutMe?: AboutMe } + ): Promise { + const { aboutMe } = options; + if (frame.account) { await this.fromAccount(frame.account); @@ -215,7 +243,13 @@ export class BackupImportStream extends Writable { } else if (frame.chat) { await this.fromChat(frame.chat); } else if (frame.chatItem) { - await this.fromChatItem(frame.chatItem); + if (!aboutMe) { + throw new Error( + 'processFrame: Processing a chatItem frame, but no aboutMe data!' + ); + } + + await this.fromChatItem(frame.chatItem, { aboutMe }); } else { log.warn(`${this.logId}: unsupported frame item ${frame.item}`); } @@ -494,30 +528,46 @@ export class BackupImportStream extends Writable { } } - private async fromChatItem(item: Backups.IChatItem): Promise { - strictAssert(this.ourConversation != null, 'AccountData missing'); + private async fromChatItem( + item: Backups.IChatItem, + options: { aboutMe: AboutMe } + ): Promise { + const { aboutMe } = options; - strictAssert(item.chatId != null, 'chatItem must have a chatId'); - strictAssert(item.authorId != null, 'chatItem must have a authorId'); - strictAssert(item.dateSent != null, 'chatItem must have a dateSent'); + const timestamp = item?.dateSent?.toNumber(); + const logId = `fromChatItem(${timestamp})`; + + strictAssert(this.ourConversation != null, `${logId}: AccountData missing`); + + strictAssert(item.chatId != null, `${logId}: must have a chatId`); + strictAssert(item.dateSent != null, `${logId}: must have a dateSent`); + strictAssert(timestamp, `${logId}: must have a timestamp`); const chatConvo = this.chatIdToConvo.get(item.chatId.toNumber()); - strictAssert(chatConvo !== undefined, 'chat conversation not found'); + strictAssert( + chatConvo !== undefined, + `${logId}: chat conversation not found` + ); - const authorConvo = this.recipientIdToConvo.get(item.authorId.toNumber()); - strictAssert(authorConvo !== undefined, 'author conversation not found'); + const authorConvo = item.authorId + ? this.recipientIdToConvo.get(item.authorId.toNumber()) + : undefined; - const isOutgoing = this.ourConversation.id === authorConvo.id; + const isOutgoing = + authorConvo && this.ourConversation.id === authorConvo?.id; + const isIncoming = + authorConvo && this.ourConversation.id !== authorConvo?.id; + const isDirectionLess = !isOutgoing && !isIncoming; let attributes: MessageAttributesType = { id: generateUuid(), canReplyToStory: false, conversationId: chatConvo.id, received_at: incrementMessageCounter(), - sent_at: item.dateSent.toNumber(), - source: authorConvo.e164, - sourceServiceId: authorConvo.serviceId, - timestamp: item.dateSent.toNumber(), + sent_at: timestamp, + source: authorConvo?.e164, + sourceServiceId: authorConvo?.serviceId, + timestamp, type: isOutgoing ? 'outgoing' : 'incoming', unidentifiedDeliveryReceived: false, expirationStartTimestamp: item.expireStartDate @@ -527,10 +577,14 @@ export class BackupImportStream extends Writable { ? DurationInSeconds.fromMillis(item.expiresInMs.toNumber()) : undefined, }; + const additionalMessages: Array = []; - if (isOutgoing) { - const { outgoing } = item; - strictAssert(outgoing, 'outgoing message must have outgoing field'); + const { outgoing, incoming, directionless } = item; + if (outgoing) { + strictAssert( + isOutgoing, + `${logId}: outgoing message must have outgoing field` + ); const sendStateByConversationId: SendStateByConversationId = {}; @@ -583,9 +637,12 @@ export class BackupImportStream extends Writable { attributes.sendStateByConversationId = sendStateByConversationId; chatConvo.active_at = attributes.sent_at; - } else { - const { incoming } = item; - strictAssert(incoming, 'incoming message must have incoming field'); + } + if (incoming) { + strictAssert( + isIncoming, + `${logId}: message with incoming field must be incoming` + ); attributes.received_at_ms = incoming.dateReceived?.toNumber() ?? Date.now(); @@ -600,24 +657,62 @@ export class BackupImportStream extends Writable { chatConvo.active_at = attributes.received_at_ms; } + if (directionless) { + strictAssert( + isDirectionLess, + `${logId}: directionless message must not be incoming/outgoing` + ); + } if (item.standardMessage) { + // TODO (DESKTOP-6964): add revisions to editHistory + attributes = { ...attributes, ...this.fromStandardMessage(item.standardMessage), }; + } else { + const result = await this.fromNonBubbleChatItem(item, { + aboutMe, + author: authorConvo, + conversation: chatConvo, + timestamp, + }); + + if (!result) { + throw new Error(`${logId}: fromNonBubbleChat item returned nothing!`); + } + + attributes = { + ...attributes, + ...result.message, + }; + + let sentAt = attributes.sent_at; + (result.additionalMessages || []).forEach(additional => { + sentAt -= 1; + additionalMessages.push({ + ...attributes, + sent_at: sentAt, + ...additional, + }); + }); } assertDev( isAciString(this.ourConversation.serviceId), - 'Our conversation must have ACI' + `${logId}: Our conversation must have ACI` ); this.saveMessage(attributes); + additionalMessages.forEach(additional => this.saveMessage(additional)); - if (isOutgoing) { - chatConvo.sentMessageCount = (chatConvo.sentMessageCount ?? 0) + 1; - } else { - chatConvo.messageCount = (chatConvo.messageCount ?? 0) + 1; + // TODO (DESKTOP-6964): We'll want to increment for more types here - stickers, etc. + if (item.standardMessage) { + if (isOutgoing) { + chatConvo.sentMessageCount = (chatConvo.sentMessageCount ?? 0) + 1; + } else { + chatConvo.messageCount = (chatConvo.messageCount ?? 0) + 1; + } } this.updateConversation(chatConvo); } @@ -658,4 +753,676 @@ export class BackupImportStream extends Writable { ), }; } + + private async fromNonBubbleChatItem( + chatItem: Backups.IChatItem, + options: { + aboutMe: AboutMe; + author?: ConversationAttributesType; + conversation: ConversationAttributesType; + timestamp: number; + } + ): Promise { + const { timestamp } = options; + const logId = `fromChatItemToNonBubble(${timestamp})`; + + if (chatItem.standardMessage) { + throw new Error(`${logId}: Got chat item with standardMessage set!`); + } + if (chatItem.contactMessage) { + // TODO (DESKTOP-6964) + } else if (chatItem.remoteDeletedMessage) { + return { + message: { + isErased: true, + }, + additionalMessages: [], + }; + } else if (chatItem.stickerMessage) { + // TODO (DESKTOP-6964) + } else if (chatItem.updateMessage) { + return this.fromChatItemUpdateMessage(chatItem.updateMessage, options); + } else { + throw new Error(`${logId}: Message was missing all five message types`); + } + + return undefined; + } + + private async fromChatItemUpdateMessage( + updateMessage: Backups.IChatUpdateMessage, + options: { + aboutMe: AboutMe; + author?: ConversationAttributesType; + conversation: ConversationAttributesType; + timestamp: number; + } + ): Promise { + const { aboutMe, author } = options; + + if (updateMessage.groupChange) { + return this.fromGroupUpdateMessage(updateMessage.groupChange, options); + } + + if (updateMessage.expirationTimerChange) { + const { expiresInMs } = updateMessage.expirationTimerChange; + + const sourceServiceId = author?.serviceId ?? aboutMe.aci; + const expireTimer = isNumber(expiresInMs) + ? DurationInSeconds.fromMillis(expiresInMs) + : DurationInSeconds.fromSeconds(0); + + return { + message: { + type: 'timer-notification', + sourceServiceId, + flags: SignalService.DataMessage.Flags.EXPIRATION_TIMER_UPDATE, + expirationTimerUpdate: { + expireTimer, + sourceServiceId, + }, + }, + additionalMessages: [], + }; + } + + // TODO (DESKTOP-6964): check these fields + // updateMessage.simpleUpdate + // updateMessage.profileChange + // updateMessage.threadMerge + // updateMessage.sessionSwitchover + // updateMessage.callingMessage + + return undefined; + } + + private async fromGroupUpdateMessage( + groupChange: Backups.IGroupChangeChatUpdate, + options: { + aboutMe: AboutMe; + timestamp: number; + } + ): Promise { + const { updates } = groupChange; + const { aboutMe, timestamp } = options; + const logId = `fromGroupUpdateMessage${timestamp}`; + + const details: Array = []; + let from: ServiceIdString | undefined; + const additionalMessages: Array> = []; + let migrationMessage: Partial | undefined; + function getDefaultMigrationMessage() { + return { + type: 'group-v1-migration' as const, + groupMigration: { + areWeInvited: false, + droppedMemberCount: 0, + invitedMemberCount: 0, + }, + }; + } + + let openApprovalServiceId: ServiceIdString | undefined; + let openBounceServiceId: ServiceIdString | undefined; + + updates?.forEach(update => { + if (update.genericGroupUpdate) { + const { updaterAci } = update.genericGroupUpdate; + if (updaterAci) { + from = fromAciObject(Aci.fromUuidBytes(updaterAci)); + } + details.push({ + type: 'summary', + }); + } + if (update.groupCreationUpdate) { + const { updaterAci } = update.groupCreationUpdate; + if (updaterAci) { + from = fromAciObject(Aci.fromUuidBytes(updaterAci)); + } + details.push({ + type: 'create', + }); + } + if (update.groupNameUpdate) { + const { updaterAci, newGroupName } = update.groupNameUpdate; + if (updaterAci) { + from = fromAciObject(Aci.fromUuidBytes(updaterAci)); + } + details.push({ + type: 'title', + newTitle: dropNull(newGroupName), + }); + } + if (update.groupAvatarUpdate) { + const { updaterAci, wasRemoved } = update.groupAvatarUpdate; + if (updaterAci) { + from = fromAciObject(Aci.fromUuidBytes(updaterAci)); + } + details.push({ + type: 'avatar', + removed: Boolean(dropNull(wasRemoved)), + }); + } + if (update.groupDescriptionUpdate) { + const { updaterAci, newDescription } = update.groupDescriptionUpdate; + if (updaterAci) { + from = fromAciObject(Aci.fromUuidBytes(updaterAci)); + } + const description = dropNull(newDescription); + details.push({ + type: 'description', + description, + removed: + description === undefined || description.length === 0 + ? true + : undefined, + }); + } + if (update.groupMembershipAccessLevelChangeUpdate) { + const { updaterAci, accessLevel } = + update.groupMembershipAccessLevelChangeUpdate; + if (updaterAci) { + from = fromAciObject(Aci.fromUuidBytes(updaterAci)); + } + details.push({ + type: 'access-members', + newPrivilege: + dropNull(accessLevel) ?? + SignalService.AccessControl.AccessRequired.UNKNOWN, + }); + } + if (update.groupAttributesAccessLevelChangeUpdate) { + const { updaterAci, accessLevel } = + update.groupAttributesAccessLevelChangeUpdate; + if (updaterAci) { + from = fromAciObject(Aci.fromUuidBytes(updaterAci)); + } + details.push({ + type: 'access-attributes', + newPrivilege: + dropNull(accessLevel) ?? + SignalService.AccessControl.AccessRequired.UNKNOWN, + }); + } + if (update.groupAnnouncementOnlyChangeUpdate) { + const { updaterAci, isAnnouncementOnly } = + update.groupAnnouncementOnlyChangeUpdate; + if (updaterAci) { + from = fromAciObject(Aci.fromUuidBytes(updaterAci)); + } + details.push({ + type: 'announcements-only', + announcementsOnly: Boolean(dropNull(isAnnouncementOnly)), + }); + } + if (update.groupAdminStatusUpdate) { + const { updaterAci, memberAci, wasAdminStatusGranted } = + update.groupAdminStatusUpdate; + if (updaterAci) { + from = fromAciObject(Aci.fromUuidBytes(updaterAci)); + } + if (!memberAci) { + throw new Error( + `${logId}: We can't render this without a target member!` + ); + } + details.push({ + type: 'member-privilege', + aci: fromAciObject(Aci.fromUuidBytes(memberAci)), + newPrivilege: wasAdminStatusGranted + ? SignalService.Member.Role.ADMINISTRATOR + : SignalService.Member.Role.DEFAULT, + }); + } + if (update.groupMemberLeftUpdate) { + const { aci } = update.groupMemberLeftUpdate; + if (!aci || Bytes.isEmpty(aci)) { + throw new Error(`${logId}: groupMemberLeftUpdate had missing aci!`); + } + from = fromAciObject(Aci.fromUuidBytes(aci)); + details.push({ + type: 'member-remove', + aci: fromAciObject(Aci.fromUuidBytes(aci)), + }); + } + if (update.groupMemberRemovedUpdate) { + const { removerAci, removedAci } = update.groupMemberRemovedUpdate; + if (removerAci) { + from = fromAciObject(Aci.fromUuidBytes(removerAci)); + } + if (!removedAci || Bytes.isEmpty(removedAci)) { + throw new Error( + `${logId}: groupMemberRemovedUpdate had missing removedAci!` + ); + } + details.push({ + type: 'member-remove', + aci: fromAciObject(Aci.fromUuidBytes(removedAci)), + }); + } + if (update.selfInvitedToGroupUpdate) { + const { inviterAci } = update.selfInvitedToGroupUpdate; + if (inviterAci) { + from = fromAciObject(Aci.fromUuidBytes(inviterAci)); + } + details.push({ + type: 'pending-add-one', + serviceId: aboutMe.aci, + }); + } + if (update.selfInvitedOtherUserToGroupUpdate) { + const { inviteeServiceId } = update.selfInvitedOtherUserToGroupUpdate; + from = aboutMe.aci; + if (!inviteeServiceId || Bytes.isEmpty(inviteeServiceId)) { + throw new Error( + `${logId}: selfInvitedOtherUserToGroupUpdate had missing inviteeServiceId!` + ); + } + details.push({ + type: 'pending-add-one', + serviceId: fromAciObject(Aci.fromUuidBytes(inviteeServiceId)), + }); + } + if (update.groupUnknownInviteeUpdate) { + const { inviterAci, inviteeCount } = update.groupUnknownInviteeUpdate; + if (inviterAci) { + from = fromAciObject(Aci.fromUuidBytes(inviterAci)); + } + if (!isNumber(inviteeCount)) { + throw new Error( + `${logId}: groupUnknownInviteeUpdate had non-number inviteeCount` + ); + } + details.push({ + type: 'pending-add-many', + count: inviteeCount, + }); + } + if (update.groupInvitationAcceptedUpdate) { + const { inviterAci, newMemberAci } = + update.groupInvitationAcceptedUpdate; + if (!newMemberAci || Bytes.isEmpty(newMemberAci)) { + throw new Error( + `${logId}: groupInvitationAcceptedUpdate had missing newMemberAci!` + ); + } + from = fromAciObject(Aci.fromUuidBytes(newMemberAci)); + const inviter = + inviterAci && Bytes.isNotEmpty(inviterAci) + ? fromAciObject(Aci.fromUuidBytes(inviterAci)) + : undefined; + details.push({ + type: 'member-add-from-invite', + aci: fromAciObject(Aci.fromUuidBytes(newMemberAci)), + inviter, + }); + } + if (update.groupInvitationDeclinedUpdate) { + const { inviterAci, inviteeAci } = update.groupInvitationDeclinedUpdate; + if (!inviteeAci || Bytes.isEmpty(inviteeAci)) { + throw new Error( + `${logId}: groupInvitationDeclinedUpdate had missing inviteeAci!` + ); + } + from = fromAciObject(Aci.fromUuidBytes(inviteeAci)); + details.push({ + type: 'pending-remove-one', + inviter: Bytes.isNotEmpty(inviterAci) + ? fromAciObject(Aci.fromUuidBytes(inviterAci)) + : undefined, + serviceId: from, + }); + } + if (update.groupMemberJoinedUpdate) { + const { newMemberAci } = update.groupMemberJoinedUpdate; + if (!newMemberAci || Bytes.isEmpty(newMemberAci)) { + throw new Error( + `${logId}: groupMemberJoinedUpdate had missing newMemberAci!` + ); + } + from = fromAciObject(Aci.fromUuidBytes(newMemberAci)); + details.push({ + type: 'member-add', + aci: fromAciObject(Aci.fromUuidBytes(newMemberAci)), + }); + } + if (update.groupMemberAddedUpdate) { + const { hadOpenInvitation, inviterAci, newMemberAci, updaterAci } = + update.groupMemberAddedUpdate; + if (Bytes.isNotEmpty(updaterAci)) { + from = fromAciObject(Aci.fromUuidBytes(updaterAci)); + } + if (!newMemberAci || Bytes.isEmpty(newMemberAci)) { + throw new Error( + `${logId}: groupMemberAddedUpdate had missing newMemberAci!` + ); + } + if (hadOpenInvitation || Bytes.isNotEmpty(inviterAci)) { + const inviter = + inviterAci && Bytes.isNotEmpty(inviterAci) + ? fromAciObject(Aci.fromUuidBytes(inviterAci)) + : undefined; + details.push({ + type: 'member-add-from-invite', + aci: fromAciObject(Aci.fromUuidBytes(newMemberAci)), + inviter, + }); + } else { + details.push({ + type: 'member-add', + aci: fromAciObject(Aci.fromUuidBytes(newMemberAci)), + }); + } + } + if (update.groupSelfInvitationRevokedUpdate) { + const { revokerAci } = update.groupSelfInvitationRevokedUpdate; + if (Bytes.isNotEmpty(revokerAci)) { + from = fromAciObject(Aci.fromUuidBytes(revokerAci)); + } + details.push({ + type: 'pending-remove-one', + serviceId: aboutMe.aci, + }); + } + if (update.groupInvitationRevokedUpdate) { + const { updaterAci, invitees } = update.groupInvitationRevokedUpdate; + if (Bytes.isNotEmpty(updaterAci)) { + from = fromAciObject(Aci.fromUuidBytes(updaterAci)); + } + if (!invitees || invitees.length === 0) { + throw new Error( + `${logId}: groupInvitationRevokedUpdate had missing invitees list!` + ); + } + + if (invitees.length === 1) { + const { inviteeAci, inviteePni } = invitees[0]; + let serviceId: ServiceIdString | undefined = Bytes.isNotEmpty( + inviteeAci + ) + ? fromAciObject(Aci.fromUuidBytes(inviteeAci)) + : undefined; + if (!serviceId) { + serviceId = Bytes.isNotEmpty(inviteePni) + ? fromPniObject(Pni.fromUuidBytes(inviteePni)) + : undefined; + } + if (serviceId) { + details.push({ + type: 'pending-remove-one', + serviceId, + }); + } else { + details.push({ + type: 'pending-remove-many', + count: 1, + }); + } + } else { + details.push({ + type: 'pending-remove-many', + count: invitees.length, + }); + } + } + if (update.groupJoinRequestUpdate) { + const { requestorAci } = update.groupJoinRequestUpdate; + if (!requestorAci || Bytes.isEmpty(requestorAci)) { + throw new Error( + `${logId}: groupInvitationRevokedUpdate was missing requestorAci!` + ); + } + from = fromAciObject(Aci.fromUuidBytes(requestorAci)); + openApprovalServiceId = from; + details.push({ + type: 'admin-approval-add-one', + aci: from, + }); + } + if (update.groupJoinRequestApprovalUpdate) { + const { updaterAci, requestorAci, wasApproved } = + update.groupJoinRequestApprovalUpdate; + if (!requestorAci || Bytes.isEmpty(requestorAci)) { + throw new Error( + `${logId}: groupJoinRequestApprovalUpdate was missing requestorAci!` + ); + } + if (Bytes.isNotEmpty(updaterAci)) { + from = fromAciObject(Aci.fromUuidBytes(updaterAci)); + } + + const aci = fromAciObject(Aci.fromUuidBytes(requestorAci)); + if (wasApproved) { + details.push({ + type: 'member-add-from-admin-approval', + aci, + }); + } else { + details.push({ + type: 'admin-approval-remove-one', + aci, + }); + } + } + if (update.groupJoinRequestCanceledUpdate) { + const { requestorAci } = update.groupJoinRequestCanceledUpdate; + if (!requestorAci || Bytes.isEmpty(requestorAci)) { + throw new Error( + `${logId}: groupJoinRequestCanceledUpdate was missing requestorAci!` + ); + } + from = fromAciObject(Aci.fromUuidBytes(requestorAci)); + details.push({ + type: 'admin-approval-remove-one', + aci: from, + }); + } + if (update.groupInviteLinkResetUpdate) { + const { updaterAci } = update.groupInviteLinkResetUpdate; + if (Bytes.isNotEmpty(updaterAci)) { + from = fromAciObject(Aci.fromUuidBytes(updaterAci)); + } + details.push({ + type: 'group-link-reset', + }); + } + if (update.groupInviteLinkEnabledUpdate) { + const { updaterAci, linkRequiresAdminApproval } = + update.groupInviteLinkEnabledUpdate; + if (Bytes.isNotEmpty(updaterAci)) { + from = fromAciObject(Aci.fromUuidBytes(updaterAci)); + } + details.push({ + type: 'group-link-add', + privilege: linkRequiresAdminApproval + ? SignalService.AccessControl.AccessRequired.ADMINISTRATOR + : SignalService.AccessControl.AccessRequired.ANY, + }); + } + if (update.groupInviteLinkAdminApprovalUpdate) { + const { updaterAci, linkRequiresAdminApproval } = + update.groupInviteLinkAdminApprovalUpdate; + if (Bytes.isNotEmpty(updaterAci)) { + from = fromAciObject(Aci.fromUuidBytes(updaterAci)); + } + details.push({ + type: 'access-invite-link', + newPrivilege: linkRequiresAdminApproval + ? SignalService.AccessControl.AccessRequired.ADMINISTRATOR + : SignalService.AccessControl.AccessRequired.ANY, + }); + } + if (update.groupInviteLinkDisabledUpdate) { + const { updaterAci } = update.groupInviteLinkDisabledUpdate; + if (Bytes.isNotEmpty(updaterAci)) { + from = fromAciObject(Aci.fromUuidBytes(updaterAci)); + } + details.push({ + type: 'group-link-remove', + }); + } + if (update.groupMemberJoinedByLinkUpdate) { + const { newMemberAci } = update.groupMemberJoinedByLinkUpdate; + if (!newMemberAci || Bytes.isEmpty(newMemberAci)) { + throw new Error( + `${logId}: groupMemberJoinedByLinkUpdate was missing newMemberAci!` + ); + } + from = fromAciObject(Aci.fromUuidBytes(newMemberAci)); + details.push({ + type: 'member-add-from-link', + aci: from, + }); + } + if (update.groupV2MigrationUpdate) { + migrationMessage = migrationMessage || getDefaultMigrationMessage(); + } + if (update.groupV2MigrationSelfInvitedUpdate) { + migrationMessage = migrationMessage || getDefaultMigrationMessage(); + const { groupMigration } = migrationMessage; + if (!groupMigration) { + throw new Error( + `${logId}: migrationMessage had no groupMigration processing groupV2MigrationSelfInvitedUpdate!` + ); + } + groupMigration.areWeInvited = true; + } + if (update.groupV2MigrationInvitedMembersUpdate) { + migrationMessage = migrationMessage || getDefaultMigrationMessage(); + const { groupMigration } = migrationMessage; + if (!groupMigration) { + throw new Error( + `${logId}: migrationMessage had no groupMigration processing groupV2MigrationInvitedMembersUpdate!` + ); + } + const { invitedMembersCount } = + update.groupV2MigrationInvitedMembersUpdate; + if (!isNumber(invitedMembersCount)) { + throw new Error( + `${logId}: groupV2MigrationInvitedMembersUpdate had a non-number invitedMembersCount!` + ); + } + groupMigration.invitedMemberCount = invitedMembersCount; + } + if (update.groupV2MigrationDroppedMembersUpdate) { + migrationMessage = migrationMessage || getDefaultMigrationMessage(); + const { groupMigration } = migrationMessage; + if (!groupMigration) { + throw new Error( + `${logId}: migrationMessage had no groupMigration processing groupV2MigrationDroppedMembersUpdate!` + ); + } + const { droppedMembersCount } = + update.groupV2MigrationDroppedMembersUpdate; + if (!isNumber(droppedMembersCount)) { + throw new Error( + `${logId}: groupV2MigrationDroppedMembersUpdate had a non-number droppedMembersCount!` + ); + } + groupMigration.droppedMemberCount = droppedMembersCount; + } + if (update.groupSequenceOfRequestsAndCancelsUpdate) { + const { count, requestorAci } = + update.groupSequenceOfRequestsAndCancelsUpdate; + if (!requestorAci || Bytes.isEmpty(requestorAci)) { + throw new Error( + `${logId}: groupSequenceOfRequestsAndCancelsUpdate was missing requestorAci!` + ); + } + if (!isNumber(count)) { + throw new Error( + `${logId}: groupSequenceOfRequestsAndCancelsUpdate had a non-number count!` + ); + } + const aci = fromAciObject(Aci.fromUuidBytes(requestorAci)); + openBounceServiceId = aci; + from = aci; + details.push({ + type: 'admin-approval-bounce', + aci, + times: count, + // This will be set later if we find an open approval request for this aci + isApprovalPending: false, + }); + } + if (update.groupExpirationTimerUpdate) { + const { updaterAci, expiresInMs } = update.groupExpirationTimerUpdate; + if (!updaterAci || Bytes.isEmpty(updaterAci)) { + throw new Error( + `${logId}: groupExpirationTimerUpdate was missing updaterAci!` + ); + } + const sourceServiceId = fromAciObject(Aci.fromUuidBytes(updaterAci)); + const expireTimer = isNumber(expiresInMs) + ? DurationInSeconds.fromMillis(expiresInMs) + : undefined; + additionalMessages.push({ + type: 'timer-notification', + sourceServiceId, + flags: SignalService.DataMessage.Flags.EXPIRATION_TIMER_UPDATE, + expirationTimerUpdate: { + expireTimer, + sourceServiceId, + }, + }); + } + }); + + let finalDetails = details; + if ( + openApprovalServiceId && + openBounceServiceId && + openApprovalServiceId === openBounceServiceId + ) { + finalDetails = details + .map(item => { + const approvalMatch = + item.type === 'admin-approval-add-one' && + item.aci === openApprovalServiceId; + if (approvalMatch) { + return undefined; + } + + const bounceMatch = + item.type === 'admin-approval-bounce' && + item.aci === openApprovalServiceId; + if (bounceMatch) { + return { + ...item, + isApprovalPending: true, + }; + } + + return item; + }) + .filter(isNotNil); + } + + if (migrationMessage) { + additionalMessages.push(migrationMessage); + } + + if (finalDetails.length === 0 && additionalMessages.length > 0) { + return { + message: additionalMessages[0], + additionalMessages: additionalMessages.slice(1), + }; + } + + if (finalDetails.length === 0) { + return undefined; + } + + return { + message: { + type: 'group-v2-change', + groupV2Change: { + from, + details: finalDetails, + }, + }, + additionalMessages, + }; + } } diff --git a/ts/services/backups/types.d.ts b/ts/services/backups/types.d.ts new file mode 100644 index 0000000000..31db9f647d --- /dev/null +++ b/ts/services/backups/types.d.ts @@ -0,0 +1,9 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { AciString, PniString } from '../../types/ServiceId'; + +export type AboutMe = { + aci: AciString; + pni?: PniString; +}; diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index 45e2e697b3..4eaaa5857c 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -862,23 +862,23 @@ export const getCachedSelectorForConversation = createSelector( } ); -export type GetConversationByIdType = (id?: string) => ConversationType; -export const getConversationSelector = createSelector( - getCachedSelectorForConversation, +export type GetConversationByAnyIdSelectorType = ( + id?: string +) => ConversationType | undefined; +export const getConversationByAnyIdSelector = createSelector( getConversationLookup, getConversationsByServiceId, getConversationsByE164, getConversationsByGroupId, ( - selector: CachedConversationSelectorType, byId: ConversationLookupType, byServiceId: ConversationLookupType, byE164: ConversationLookupType, byGroupId: ConversationLookupType - ): GetConversationByIdType => { + ): GetConversationByAnyIdSelectorType => { return (id?: string) => { if (!id) { - return selector(undefined); + return undefined; } const onServiceId = getOwn( @@ -886,19 +886,42 @@ export const getConversationSelector = createSelector( normalizeServiceId(id, 'getConversationSelector') ); if (onServiceId) { - return selector(onServiceId); + return onServiceId; } const onE164 = getOwn(byE164, id); if (onE164) { - return selector(onE164); + return onE164; } const onGroupId = getOwn(byGroupId, id); if (onGroupId) { - return selector(onGroupId); + return onGroupId; } const onId = getOwn(byId, id); if (onId) { - return selector(onId); + return onId; + } + + return undefined; + }; + } +); + +export type GetConversationByIdType = (id?: string) => ConversationType; +export const getConversationSelector = createSelector( + getCachedSelectorForConversation, + getConversationByAnyIdSelector, + ( + selector: CachedConversationSelectorType, + getById: GetConversationByAnyIdSelectorType + ): GetConversationByIdType => { + return (id?: string) => { + if (!id) { + return selector(undefined); + } + + const byId = getById(id); + if (byId) { + return selector(byId); } log.warn(`getConversationSelector: No conversation found for id ${id}`); @@ -908,6 +931,24 @@ export const getConversationSelector = createSelector( } ); +export type CheckServiceIdEquivalenceType = ( + left: ServiceIdString | undefined, + right: ServiceIdString | undefined +) => boolean; +export const getCheckServiceIdEquivalence = createSelector( + getConversationByAnyIdSelector, + ( + getById: GetConversationByAnyIdSelectorType + ): CheckServiceIdEquivalenceType => { + return ( + left: ServiceIdString | undefined, + right: ServiceIdString | undefined + ): boolean => { + return Boolean(left && right && getById(left) === getById(right)); + }; + } +); + export const getConversationByIdSelector = createSelector( getConversationLookup, conversationLookup => diff --git a/ts/state/selectors/message.ts b/ts/state/selectors/message.ts index 226305b5af..a1dd58177f 100644 --- a/ts/state/selectors/message.ts +++ b/ts/state/selectors/message.ts @@ -1113,6 +1113,8 @@ function getPropsForGroupV1Migration( conversationId: message.conversationId, droppedMembers, invitedMembers, + droppedMemberCount: droppedMembers.length, + invitedMemberCount: invitedMembers.length, }; } @@ -1120,19 +1122,30 @@ function getPropsForGroupV1Migration( areWeInvited, droppedMemberIds, invitedMembers: rawInvitedMembers, + droppedMemberCount: rawDroppedMemberCount, + invitedMemberCount: rawInvitedMemberCount, } = migration; - const invitedMembers = rawInvitedMembers.map(item => - conversationSelector(item.uuid) - ); - const droppedMembers = droppedMemberIds.map(conversationId => - conversationSelector(conversationId) - ); + const droppedMembers = droppedMemberIds + ? droppedMemberIds.map(conversationId => + conversationSelector(conversationId) + ) + : undefined; + const invitedMembers = rawInvitedMembers + ? rawInvitedMembers.map(item => conversationSelector(item.uuid)) + : undefined; + + const droppedMemberCount = + rawDroppedMemberCount ?? droppedMemberIds?.length ?? 0; + const invitedMemberCount = + rawInvitedMemberCount ?? invitedMembers?.length ?? 0; return { areWeInvited, conversationId: message.conversationId, droppedMembers, invitedMembers, + droppedMemberCount, + invitedMemberCount, }; } diff --git a/ts/state/smart/GroupV1MigrationDialog.tsx b/ts/state/smart/GroupV1MigrationDialog.tsx index 711a4a1882..623f7530be 100644 --- a/ts/state/smart/GroupV1MigrationDialog.tsx +++ b/ts/state/smart/GroupV1MigrationDialog.tsx @@ -72,7 +72,9 @@ export const SmartGroupV1MigrationDialog = memo( hasMigrated={hasMigrated} getPreferredBadge={getPreferredBadge} droppedMembers={droppedMembers} + droppedMemberCount={droppedMembers.length} invitedMembers={invitedMembers} + invitedMemberCount={invitedMembers.length} onMigrate={handleMigrate} onClose={closeGV2MigrationDialog} /> diff --git a/ts/state/smart/TimelineItem.tsx b/ts/state/smart/TimelineItem.tsx index 0bd3a42ed2..6010a14775 100644 --- a/ts/state/smart/TimelineItem.tsx +++ b/ts/state/smart/TimelineItem.tsx @@ -21,7 +21,10 @@ import { getTheme, getPlatform, } from '../selectors/user'; -import { getTargetedMessage } from '../selectors/conversations'; +import { + getTargetedMessage, + getCheckServiceIdEquivalence, +} from '../selectors/conversations'; import { useTimelineItem } from '../selectors/timeline'; import { areMessagesInSameGroup, @@ -86,6 +89,7 @@ export const SmartTimelineItem = memo(function SmartTimelineItem( const isTargeted = Boolean( targetedMessage && messageId === targetedMessage.id ); + const checkServiceIdEquivalence = useSelector(getCheckServiceIdEquivalence); const isNextItemCallingNotification = nextItem?.type === 'callHistory'; @@ -176,6 +180,7 @@ export const SmartTimelineItem = memo(function SmartTimelineItem( +): Array> { + return sortBy(messages, 'sent_at').map(message => + pick( + message, + 'droppedGV2MemberIds', + 'expirationTimerUpdate', + 'groupMigration', + 'groupV2Change', + 'invitedGV2Members', + 'sent_at', + 'timestamp', + 'type' + ) + ); +} + +async function symmetricRoundtripHarness( + messages: Array +) { + return asymmetricRoundtripHarness(messages, messages); +} + +async function asymmetricRoundtripHarness( + before: Array, + after: Array +) { + const outDir = mkdtempSync(path.join(tmpdir(), 'signal-temp-')); + try { + const targetOutputFile = path.join(outDir, 'backup.bin'); + + await Data.saveMessages(before, { forceSave: true, ourAci: OUR_ACI }); + + await backupsService.exportToDisk(targetOutputFile); + + await clearData(); + + await backupsService.importBackup(() => createReadStream(targetOutputFile)); + + const messagesFromDatabase = await Data._getAllMessages(); + + const expected = sortAndNormalize(after); + const actual = sortAndNormalize(messagesFromDatabase); + assert.deepEqual(expected, actual); + } finally { + rmSync(outDir, { recursive: true }); + } +} + +async function clearData() { + await Data._removeAllMessages(); + await Data._removeAllConversations(); + await Data.removeAllItems(); + window.storage.reset(); + window.ConversationController.reset(); + + await setupBasics(); +} + +async function setupBasics() { + await window.storage.put('uuid_id', `${OUR_ACI}.2`); + await window.storage.put('pni', OUR_PNI); + await window.storage.put('masterKey', MASTER_KEY); + await window.storage.put('profileKey', PROFILEKEY); + + await window.ConversationController.getOrCreateAndWait(OUR_ACI, 'private', { + pni: OUR_PNI, + systemGivenName: 'ME', + profileKey: Bytes.toBase64(PROFILEKEY), + }); +} + +let counter = 0; + +function createMessage( + change: GroupV2ChangeType, + { disableIncrement }: { disableIncrement: boolean } = { + disableIncrement: false, + } +): MessageAttributesType { + const groupConversation = window.ConversationController.get(GROUP_ID); + strictAssert(groupConversation, 'The group conversation must be created!'); + if (!disableIncrement) { + counter += 1; + } + + return { + conversationId: groupConversation.id, + groupV2Change: change, + id: generateGuid(), + received_at: counter, + sent_at: counter, + timestamp: counter, + type: 'group-v2-change', + }; +} + +describe('backup/groupv2/notifications', () => { + beforeEach(async () => { + await Data._removeAllMessages(); + await Data._removeAllConversations(); + window.storage.reset(); + + await setupBasics(); + + await window.ConversationController.getOrCreateAndWait( + CONTACT_A, + 'private', + { pni: CONTACT_A_PNI, systemGivenName: 'CONTACT_A' } + ); + await window.ConversationController.getOrCreateAndWait( + CONTACT_B, + 'private', + { systemGivenName: 'CONTACT_B' } + ); + await window.ConversationController.getOrCreateAndWait( + CONTACT_C, + 'private', + { systemGivenName: 'CONTACT_C' } + ); + await window.ConversationController.getOrCreateAndWait(ADMIN_A, 'private', { + systemGivenName: 'ADMIN_A', + }); + await window.ConversationController.getOrCreateAndWait( + INVITEE_A, + 'private', + { + systemGivenName: 'INVITEE_A', + } + ); + await window.ConversationController.getOrCreateAndWait(GROUP_ID, 'group', { + groupVersion: 2, + masterKey: Bytes.toBase64(getRandomBytes(32)), + name: 'Rock Enthusiasts', + }); + + await loadCallsHistory(); + window.Events = { + ...window.Events, + getTypingIndicatorSetting: () => false, + getLinkPreviewSetting: () => false, + }; + }); + + describe('roundtrips given groupv2 notifications with', () => { + it('Multiple items', async () => { + const messages: Array = [ + createMessage({ + from: CONTACT_A, + details: [ + { + type: 'title', + newTitle: 'Saturday Running', + }, + { + type: 'avatar', + removed: false, + }, + { + type: 'description', + description: + 'This is a long description.\n\nWe need a dialog to view it all!\n\nIt has a link to https://example.com', + }, + { + type: 'member-add', + aci: OUR_ACI, + }, + { + type: 'description', + description: 'Another description', + }, + { + type: 'member-privilege', + aci: OUR_ACI, + newPrivilege: RoleEnum.ADMINISTRATOR, + }, + ], + }), + ]; + + await symmetricRoundtripHarness(messages); + }); + + it('Create items', async () => { + const messages: Array = [ + createMessage({ + from: OUR_ACI, + details: [ + { + type: 'create', + }, + ], + }), + createMessage({ + from: CONTACT_A, + details: [ + { + type: 'create', + }, + ], + }), + createMessage({ + details: [ + { + type: 'create', + }, + ], + }), + ]; + + await symmetricRoundtripHarness(messages); + }); + + it('Title items', async () => { + const messages: Array = [ + createMessage({ + from: OUR_ACI, + details: [ + { + type: 'title', + newTitle: 'Saturday Running', + }, + ], + }), + createMessage({ + from: CONTACT_A, + details: [ + { + type: 'title', + newTitle: 'Saturday Running', + }, + ], + }), + createMessage({ + details: [ + { + type: 'title', + newTitle: 'Saturday Running', + }, + ], + }), + createMessage({ + from: OUR_ACI, + details: [ + { + type: 'title', + }, + ], + }), + createMessage({ + from: CONTACT_A, + details: [ + { + type: 'title', + }, + ], + }), + createMessage({ + details: [ + { + type: 'title', + }, + ], + }), + ]; + + await symmetricRoundtripHarness(messages); + }); + + it('Avatar items', async () => { + const messages: Array = [ + createMessage({ + from: OUR_ACI, + details: [ + { + type: 'avatar', + removed: false, + }, + ], + }), + createMessage({ + from: CONTACT_A, + details: [ + { + type: 'avatar', + removed: false, + }, + ], + }), + createMessage({ + details: [ + { + type: 'avatar', + removed: false, + }, + ], + }), + createMessage({ + from: OUR_ACI, + details: [ + { + type: 'avatar', + removed: true, + }, + ], + }), + createMessage({ + from: CONTACT_A, + details: [ + { + type: 'avatar', + removed: true, + }, + ], + }), + createMessage({ + details: [ + { + type: 'avatar', + removed: true, + }, + ], + }), + ]; + + await symmetricRoundtripHarness(messages); + }); + + it('AccessAttributes items', async () => { + const messages: Array = [ + createMessage({ + from: OUR_ACI, + details: [ + { + type: 'access-attributes', + newPrivilege: AccessControlEnum.ADMINISTRATOR, + }, + ], + }), + createMessage({ + from: ADMIN_A, + details: [ + { + type: 'access-attributes', + newPrivilege: AccessControlEnum.ADMINISTRATOR, + }, + ], + }), + createMessage({ + details: [ + { + type: 'access-attributes', + newPrivilege: AccessControlEnum.ADMINISTRATOR, + }, + ], + }), + createMessage({ + from: OUR_ACI, + details: [ + { + type: 'access-attributes', + newPrivilege: AccessControlEnum.MEMBER, + }, + ], + }), + createMessage({ + from: ADMIN_A, + details: [ + { + type: 'access-attributes', + newPrivilege: AccessControlEnum.MEMBER, + }, + ], + }), + createMessage({ + details: [ + { + type: 'access-attributes', + newPrivilege: AccessControlEnum.MEMBER, + }, + ], + }), + ]; + + await symmetricRoundtripHarness(messages); + }); + + it('AccessMembers items', async () => { + const messages: Array = [ + createMessage({ + from: OUR_ACI, + details: [ + { + type: 'access-members', + newPrivilege: AccessControlEnum.ADMINISTRATOR, + }, + ], + }), + createMessage({ + from: ADMIN_A, + details: [ + { + type: 'access-members', + newPrivilege: AccessControlEnum.ADMINISTRATOR, + }, + ], + }), + createMessage({ + details: [ + { + type: 'access-members', + newPrivilege: AccessControlEnum.ADMINISTRATOR, + }, + ], + }), + createMessage({ + from: OUR_ACI, + details: [ + { + type: 'access-members', + newPrivilege: AccessControlEnum.MEMBER, + }, + ], + }), + createMessage({ + from: ADMIN_A, + details: [ + { + type: 'access-members', + newPrivilege: AccessControlEnum.MEMBER, + }, + ], + }), + createMessage({ + details: [ + { + type: 'access-members', + newPrivilege: AccessControlEnum.MEMBER, + }, + ], + }), + ]; + + await symmetricRoundtripHarness(messages); + }); + + it('AccessInviteLink items', async () => { + const messages: Array = [ + createMessage({ + from: OUR_ACI, + details: [ + { + type: 'access-invite-link', + newPrivilege: AccessControlEnum.ANY, + }, + ], + }), + createMessage({ + from: ADMIN_A, + details: [ + { + type: 'access-invite-link', + newPrivilege: AccessControlEnum.ANY, + }, + ], + }), + createMessage({ + details: [ + { + type: 'access-invite-link', + newPrivilege: AccessControlEnum.ANY, + }, + ], + }), + createMessage({ + from: OUR_ACI, + details: [ + { + type: 'access-invite-link', + newPrivilege: AccessControlEnum.ADMINISTRATOR, + }, + ], + }), + createMessage({ + from: ADMIN_A, + details: [ + { + type: 'access-invite-link', + newPrivilege: AccessControlEnum.ADMINISTRATOR, + }, + ], + }), + createMessage({ + details: [ + { + type: 'access-invite-link', + newPrivilege: AccessControlEnum.ADMINISTRATOR, + }, + ], + }), + ]; + + await symmetricRoundtripHarness(messages); + }); + + it('MemberAdd items', async () => { + const messages: Array = [ + createMessage({ + from: OUR_ACI, + details: [ + { + type: 'member-add', + aci: OUR_ACI, + }, + ], + }), + createMessage({ + from: CONTACT_A, + details: [ + { + type: 'member-add', + aci: OUR_ACI, + }, + ], + }), + createMessage({ + details: [ + { + type: 'member-add', + aci: OUR_ACI, + }, + ], + }), + createMessage({ + from: OUR_ACI, + details: [ + { + type: 'member-add', + aci: CONTACT_A, + }, + ], + }), + createMessage({ + from: CONTACT_B, + details: [ + { + type: 'member-add', + aci: CONTACT_A, + }, + ], + }), + createMessage({ + details: [ + { + type: 'member-add', + aci: CONTACT_A, + }, + ], + }), + ]; + + await symmetricRoundtripHarness(messages); + }); + + it('MemberAddFromInvited items', async () => { + const messages: Array = [ + // the strings where someone added you - shown like a normal add + createMessage({ + from: CONTACT_A, + details: [ + { + type: 'member-add-from-invite', + aci: OUR_ACI, + inviter: CONTACT_B, + }, + ], + }), + createMessage({ + details: [ + { + type: 'member-add-from-invite', + aci: OUR_ACI, + inviter: CONTACT_A, + }, + ], + }), + // the rest of the 'someone added someone else' checks */ + createMessage({ + from: OUR_ACI, + details: [ + { + type: 'member-add-from-invite', + aci: CONTACT_A, + inviter: CONTACT_B, + }, + ], + }), + createMessage({ + from: CONTACT_A, + details: [ + { + type: 'member-add-from-invite', + aci: CONTACT_B, + inviter: CONTACT_C, + }, + ], + }), + createMessage({ + details: [ + { + type: 'member-add-from-invite', + aci: CONTACT_A, + inviter: CONTACT_B, + }, + ], + }), + // in all of these we know the user has accepted the invite + createMessage({ + from: OUR_ACI, + details: [ + { + type: 'member-add-from-invite', + aci: OUR_ACI, + inviter: CONTACT_A, + }, + ], + }), + createMessage({ + from: OUR_ACI, + details: [ + { + type: 'member-add-from-invite', + aci: OUR_ACI, + }, + ], + }), + createMessage({ + from: CONTACT_A, + details: [ + { + type: 'member-add-from-invite', + aci: CONTACT_A, + inviter: OUR_ACI, + }, + ], + }), + createMessage({ + from: CONTACT_A, + details: [ + { + type: 'member-add-from-invite', + aci: CONTACT_A, + inviter: CONTACT_B, + }, + ], + }), + createMessage({ + from: CONTACT_A, + details: [ + { + type: 'member-add-from-invite', + aci: CONTACT_A, + }, + ], + }), + // ACI accepts PNI invite: + // These don't roundtrip; the PNI is replaced with ACI. See next test below. + // createMessage({ + // from: OUR_PNI, + // details: [ + // { + // type: 'member-add-from-invite', + // aci: OUR_ACI, + // inviter: CONTACT_B, + // }, + // ], + // }), + // createMessage({ + // from: OUR_PNI, + // details: [ + // { + // type: 'member-add-from-invite', + // aci: OUR_ACI, + // }, + // ], + // }), + // createMessage({ + // from: CONTACT_A_PNI, + // details: [ + // { + // type: 'member-add-from-invite', + // aci: CONTACT_A, + // }, + // ], + // }), + ]; + + await symmetricRoundtripHarness(messages); + }); + + it('MemberAddFromInvited items', async () => { + const firstBefore = createMessage({ + from: OUR_PNI, + details: [ + { + type: 'member-add-from-invite', + aci: OUR_ACI, + inviter: CONTACT_B, + }, + ], + }); + const firstAfter = createMessage( + { + from: OUR_ACI, + details: [ + { + type: 'member-add-from-invite', + aci: OUR_ACI, + inviter: CONTACT_B, + }, + ], + }, + { disableIncrement: true } + ); + + const secondBefore = createMessage({ + from: OUR_PNI, + details: [ + { + type: 'member-add-from-invite', + aci: OUR_ACI, + }, + ], + }); + const secondAfter = createMessage( + { + from: OUR_ACI, + details: [ + { + type: 'member-add-from-invite', + aci: OUR_ACI, + }, + ], + }, + { disableIncrement: true } + ); + + const thirdBefore = createMessage({ + from: CONTACT_A_PNI, + details: [ + { + type: 'member-add-from-invite', + aci: CONTACT_A, + }, + ], + }); + const thirdAfter = createMessage( + { + from: CONTACT_A, + details: [ + { + type: 'member-add-from-invite', + aci: CONTACT_A, + }, + ], + }, + { disableIncrement: true } + ); + + const before = [firstBefore, secondBefore, thirdBefore]; + const after = [firstAfter, secondAfter, thirdAfter]; + + await asymmetricRoundtripHarness(before, after); + }); + + it('MemberAddFromLink items', async () => { + const messages: Array = [ + createMessage({ + from: OUR_ACI, + details: [ + { + type: 'member-add-from-link', + aci: OUR_ACI, + }, + ], + }), + createMessage({ + from: CONTACT_A, + details: [ + { + type: 'member-add-from-link', + aci: CONTACT_A, + }, + ], + }), + // This doesn't roundtrip because if people join via link, they do it themselves. + // See the next test. + // createMessage({ + // details: [ + // { + // type: 'member-add-from-link', + // aci: CONTACT_A, + // }, + // ], + // }), + ]; + + await symmetricRoundtripHarness(messages); + }); + + it('MemberAddFromLink items asymmetric', async () => { + const before: Array = [ + createMessage({ + details: [ + { + type: 'member-add-from-link', + aci: CONTACT_A, + }, + ], + }), + ]; + const after: Array = [ + createMessage( + { + from: CONTACT_A, + details: [ + { + type: 'member-add-from-link', + aci: CONTACT_A, + }, + ], + }, + { disableIncrement: true } + ), + ]; + + await asymmetricRoundtripHarness(before, after); + }); + + it('MemberAddFromAdminApproval items', async () => { + const messages: Array = [ + createMessage({ + from: ADMIN_A, + details: [ + { + type: 'member-add-from-admin-approval', + aci: OUR_ACI, + }, + ], + }), + createMessage({ + details: [ + { + type: 'member-add-from-admin-approval', + aci: OUR_ACI, + }, + ], + }), + createMessage({ + from: OUR_ACI, + details: [ + { + type: 'member-add-from-admin-approval', + aci: CONTACT_A, + }, + ], + }), + createMessage({ + from: ADMIN_A, + details: [ + { + type: 'member-add-from-admin-approval', + aci: CONTACT_A, + }, + ], + }), + createMessage({ + details: [ + { + type: 'member-add-from-admin-approval', + aci: CONTACT_A, + }, + ], + }), + ]; + + await symmetricRoundtripHarness(messages); + }); + + it('MemberRemove items', async () => { + const messages: Array = [ + createMessage({ + from: OUR_ACI, + details: [ + { + type: 'member-remove', + aci: OUR_ACI, + }, + ], + }), + createMessage({ + from: CONTACT_A, + details: [ + { + type: 'member-remove', + aci: OUR_ACI, + }, + ], + }), + createMessage({ + details: [ + { + type: 'member-remove', + aci: OUR_ACI, + }, + ], + }), + createMessage({ + from: OUR_ACI, + details: [ + { + type: 'member-remove', + aci: CONTACT_A, + }, + ], + }), + createMessage({ + from: CONTACT_A, + details: [ + { + type: 'member-remove', + aci: CONTACT_A, + }, + ], + }), + createMessage({ + from: CONTACT_B, + details: [ + { + type: 'member-remove', + aci: CONTACT_A, + }, + ], + }), + createMessage({ + details: [ + { + type: 'member-remove', + aci: CONTACT_A, + }, + ], + }), + ]; + + await symmetricRoundtripHarness(messages); + }); + + it('MemberPrivilege items', async () => { + const messages: Array = [ + createMessage({ + from: CONTACT_A, + details: [ + { + type: 'member-privilege', + aci: OUR_ACI, + newPrivilege: RoleEnum.ADMINISTRATOR, + }, + ], + }), + createMessage({ + details: [ + { + type: 'member-privilege', + aci: OUR_ACI, + newPrivilege: RoleEnum.ADMINISTRATOR, + }, + ], + }), + createMessage({ + from: OUR_ACI, + details: [ + { + type: 'member-privilege', + aci: CONTACT_A, + newPrivilege: RoleEnum.ADMINISTRATOR, + }, + ], + }), + createMessage({ + from: ADMIN_A, + details: [ + { + type: 'member-privilege', + aci: CONTACT_A, + newPrivilege: RoleEnum.ADMINISTRATOR, + }, + ], + }), + createMessage({ + details: [ + { + type: 'member-privilege', + aci: CONTACT_A, + newPrivilege: RoleEnum.ADMINISTRATOR, + }, + ], + }), + createMessage({ + from: CONTACT_A, + details: [ + { + type: 'member-privilege', + aci: OUR_ACI, + newPrivilege: RoleEnum.DEFAULT, + }, + ], + }), + createMessage({ + details: [ + { + type: 'member-privilege', + aci: OUR_ACI, + newPrivilege: RoleEnum.DEFAULT, + }, + ], + }), + createMessage({ + from: OUR_ACI, + details: [ + { + type: 'member-privilege', + aci: CONTACT_A, + newPrivilege: RoleEnum.DEFAULT, + }, + ], + }), + createMessage({ + from: ADMIN_A, + details: [ + { + type: 'member-privilege', + aci: CONTACT_A, + newPrivilege: RoleEnum.DEFAULT, + }, + ], + }), + createMessage({ + details: [ + { + type: 'member-privilege', + aci: CONTACT_A, + newPrivilege: RoleEnum.DEFAULT, + }, + ], + }), + ]; + + await symmetricRoundtripHarness(messages); + }); + + it('PendingAddOne items', async () => { + const messages: Array = [ + createMessage({ + from: CONTACT_A, + details: [ + { + type: 'pending-add-one', + serviceId: OUR_ACI, + }, + ], + }), + createMessage({ + details: [ + { + type: 'pending-add-one', + serviceId: OUR_ACI, + }, + ], + }), + createMessage({ + from: OUR_ACI, + details: [ + { + type: 'pending-add-one', + serviceId: INVITEE_A, + }, + ], + }), + // These don't roundtrip because we only have details if we're involved. See the + // next test. + // createMessage({ + // from: CONTACT_B, + // details: [ + // { + // type: 'pending-add-one', + // serviceId: INVITEE_A, + // }, + // ], + // }), + // createMessage({ + // details: [ + // { + // type: 'pending-add-one', + // serviceId: INVITEE_A, + // }, + // ], + // }), + ]; + + await symmetricRoundtripHarness(messages); + }); + + it('PendingAddOne items, asymmetric', async () => { + const firstBefore = createMessage({ + from: CONTACT_B, + details: [ + { + type: 'pending-add-one', + serviceId: INVITEE_A, + }, + ], + }); + const firstAfter = createMessage( + { + from: CONTACT_B, + details: [ + { + type: 'pending-add-many', + count: 1, + }, + ], + }, + { disableIncrement: true } + ); + + const secondBefore = createMessage({ + details: [ + { + type: 'pending-add-one', + serviceId: INVITEE_A, + }, + ], + }); + const secondAfter = createMessage( + { + details: [ + { + type: 'pending-add-many', + count: 1, + }, + ], + }, + { disableIncrement: true } + ); + + const before = [firstBefore, secondBefore]; + const after = [firstAfter, secondAfter]; + await asymmetricRoundtripHarness(before, after); + }); + + it('PendingAddMany items', async () => { + const messages: Array = [ + createMessage({ + from: OUR_ACI, + details: [ + { + type: 'pending-add-many', + count: 5, + }, + ], + }), + createMessage({ + from: OUR_ACI, + details: [ + { + type: 'pending-add-many', + count: 1, + }, + ], + }), + createMessage({ + from: CONTACT_A, + details: [ + { + type: 'pending-add-many', + count: 5, + }, + ], + }), + createMessage({ + from: CONTACT_A, + details: [ + { + type: 'pending-add-many', + count: 1, + }, + ], + }), + createMessage({ + details: [ + { + type: 'pending-add-many', + count: 5, + }, + ], + }), + createMessage({ + details: [ + { + type: 'pending-add-many', + count: 1, + }, + ], + }), + ]; + + await symmetricRoundtripHarness(messages); + }); + + it('PendingRemoveOne items', async () => { + const messages: Array = [ + createMessage({ + from: INVITEE_A, + details: [ + { + type: 'pending-remove-one', + serviceId: INVITEE_A, + inviter: OUR_ACI, + }, + ], + }), + createMessage({ + from: OUR_ACI, + details: [ + { + type: 'pending-remove-one', + serviceId: INVITEE_A, + // Not roundtripped unless you were invited, or invitee said no to invite + // inviter: OUR_ACI, + }, + ], + }), + createMessage({ + from: ADMIN_A, + details: [ + { + type: 'pending-remove-one', + serviceId: INVITEE_A, + // Not roundtripped unless you were invited, or invitee said no to invite + // inviter: OUR_ACI, + }, + ], + }), + createMessage({ + details: [ + { + type: 'pending-remove-one', + serviceId: INVITEE_A, + // Not roundtripped unless you were invited, or invitee said no to invite + // inviter: OUR_ACI, + }, + ], + }), + createMessage({ + from: INVITEE_A, + details: [ + { + type: 'pending-remove-one', + serviceId: INVITEE_A, + }, + ], + }), + createMessage({ + from: INVITEE_A, + details: [ + { + type: 'pending-remove-one', + serviceId: INVITEE_A, + inviter: CONTACT_B, + }, + ], + }), + + createMessage({ + from: CONTACT_B, + details: [ + { + type: 'pending-remove-one', + serviceId: OUR_ACI, + // Not roundtripped unless you were invited, or invitee said no to invite + // inviter: CONTACT_B, + }, + ], + }), + createMessage({ + from: CONTACT_A, + details: [ + { + type: 'pending-remove-one', + serviceId: CONTACT_B, + // Not roundtripped unless you were invited, or invitee said no to invite + // inviter: CONTACT_A, + }, + ], + }), + + createMessage({ + from: CONTACT_C, + details: [ + { + type: 'pending-remove-one', + serviceId: INVITEE_A, + // Not roundtripped unless you were invited, or invitee said no to invite + // inviter: CONTACT_B, + }, + ], + }), + createMessage({ + from: OUR_ACI, + details: [ + { + type: 'pending-remove-one', + serviceId: INVITEE_A, + // Not roundtripped unless you were invited, or invitee said no to invite + // inviter: CONTACT_B, + }, + ], + }), + createMessage({ + details: [ + { + type: 'pending-remove-one', + serviceId: INVITEE_A, + // Not roundtripped unless you were invited, or invitee said no to invite + // inviter: CONTACT_B, + }, + ], + }), + + createMessage({ + from: OUR_ACI, + details: [ + { + type: 'pending-remove-one', + serviceId: INVITEE_A, + }, + ], + }), + createMessage({ + from: CONTACT_B, + details: [ + { + type: 'pending-remove-one', + serviceId: INVITEE_A, + }, + ], + }), + createMessage({ + details: [ + { + type: 'pending-remove-one', + serviceId: INVITEE_A, + }, + ], + }), + ]; + + await symmetricRoundtripHarness(messages); + }); + + it('PendingRemoveMany items', async () => { + const messages: Array = [ + createMessage({ + from: OUR_ACI, + details: [ + { + type: 'pending-remove-many', + count: 5, + // Inviter is not roundtripped for a multi-remove + // inviter: OUR_ACI, + }, + ], + }), + createMessage({ + from: OUR_ACI, + details: [ + { + type: 'pending-remove-many', + count: 1, + // Inviter is not roundtripped for a multi-remove + // inviter: OUR_ACI, + }, + ], + }), + createMessage({ + from: ADMIN_A, + details: [ + { + type: 'pending-remove-many', + count: 5, + // Inviter is not roundtripped for a multi-remove + // inviter: OUR_ACI, + }, + ], + }), + createMessage({ + from: ADMIN_A, + details: [ + { + type: 'pending-remove-many', + count: 1, + // Inviter is not roundtripped for a multi-remove + // inviter: OUR_ACI, + }, + ], + }), + createMessage({ + details: [ + { + type: 'pending-remove-many', + count: 5, + // Inviter is not roundtripped for a multi-remove + // inviter: OUR_ACI, + }, + ], + }), + createMessage({ + details: [ + { + type: 'pending-remove-many', + count: 1, + // Inviter is not roundtripped for a multi-remove + // inviter: OUR_ACI, + }, + ], + }), + createMessage({ + from: OUR_ACI, + details: [ + { + type: 'pending-remove-many', + count: 5, + // Inviter is not roundtripped for a multi-remove + // inviter: CONTACT_A, + }, + ], + }), + createMessage({ + from: OUR_ACI, + details: [ + { + type: 'pending-remove-many', + count: 1, + // Inviter is not roundtripped for a multi-remove + // inviter: CONTACT_A, + }, + ], + }), + createMessage({ + from: ADMIN_A, + details: [ + { + type: 'pending-remove-many', + count: 5, + // Inviter is not roundtripped for a multi-remove + // inviter: CONTACT_A, + }, + ], + }), + createMessage({ + from: ADMIN_A, + details: [ + { + type: 'pending-remove-many', + count: 1, + // Inviter is not roundtripped for a multi-remove + // inviter: CONTACT_A, + }, + ], + }), + createMessage({ + details: [ + { + type: 'pending-remove-many', + count: 5, + // Inviter is not roundtripped for a multi-remove + // inviter: CONTACT_A, + }, + ], + }), + createMessage({ + details: [ + { + type: 'pending-remove-many', + count: 1, + // Inviter is not roundtripped for a multi-remove + // inviter: CONTACT_A, + }, + ], + }), + createMessage({ + from: OUR_ACI, + details: [ + { + type: 'pending-remove-many', + count: 5, + }, + ], + }), + createMessage({ + from: OUR_ACI, + details: [ + { + type: 'pending-remove-many', + count: 1, + }, + ], + }), + + createMessage({ + from: CONTACT_A, + details: [ + { + type: 'pending-remove-many', + count: 5, + }, + ], + }), + createMessage({ + from: CONTACT_A, + details: [ + { + type: 'pending-remove-many', + count: 1, + }, + ], + }), + createMessage({ + details: [ + { + type: 'pending-remove-many', + count: 5, + }, + ], + }), + createMessage({ + details: [ + { + type: 'pending-remove-many', + count: 1, + }, + ], + }), + ]; + + await symmetricRoundtripHarness(messages); + }); + + it('AdminApprovalAdd items', async () => { + const messages: Array = [ + createMessage({ + from: OUR_ACI, + details: [ + { + type: 'admin-approval-add-one', + aci: OUR_ACI, + }, + ], + }), + createMessage({ + from: CONTACT_A, + details: [ + { + type: 'admin-approval-add-one', + aci: CONTACT_A, + }, + ], + }), + ]; + + await symmetricRoundtripHarness(messages); + }); + + it('AdminApprovalRemove items', async () => { + const messages: Array = [ + createMessage({ + from: OUR_ACI, + details: [ + { + type: 'admin-approval-remove-one', + aci: OUR_ACI, + }, + ], + }), + createMessage({ + details: [ + { + type: 'admin-approval-remove-one', + aci: OUR_ACI, + }, + ], + }), + createMessage({ + from: OUR_ACI, + details: [ + { + type: 'admin-approval-remove-one', + aci: CONTACT_A, + }, + ], + }), + ]; + + await symmetricRoundtripHarness(messages); + }); + + it('AdminApprovalBounce items', async () => { + const messages: Array = [ + // Should show button: + createMessage({ + // From Joiner + from: CONTACT_A, + details: [ + { + type: 'admin-approval-bounce', + aci: CONTACT_A, + times: 1, + isApprovalPending: false, + }, + ], + }), + // These don't roundtrip, because we assume these always come from the requestor + // createMessage({ + // // From nobody + // details: [ + // { + // type: 'admin-approval-bounce', + // aci: CONTACT_A, + // times: 1, + // isApprovalPending: false, + // }, + // ], + // }), + // createMessage({ + // details: [ + // { + // type: 'admin-approval-bounce', + // aci: CONTACT_A, + // times: 1, + // isApprovalPending: false, + // }, + // // No group membership info + // ], + // }), + // Would show button, but we're not admin: + createMessage({ + from: CONTACT_A, + details: [ + { + type: 'admin-approval-bounce', + aci: CONTACT_A, + times: 1, + isApprovalPending: false, + }, + ], + }), + // Would show button, but user is a group member: + createMessage({ + from: CONTACT_A, + details: [ + { + type: 'admin-approval-bounce', + aci: CONTACT_A, + times: 1, + isApprovalPending: false, + }, + ], + }), + // Would show button, but user is already banned: + createMessage({ + from: CONTACT_A, + details: [ + { + type: 'admin-approval-bounce', + aci: CONTACT_A, + times: 1, + isApprovalPending: false, + }, + ], + }), + // Open request + createMessage({ + from: CONTACT_A, + details: [ + { + type: 'admin-approval-bounce', + aci: CONTACT_A, + times: 4, + isApprovalPending: true, + }, + ], + }), + ]; + + await symmetricRoundtripHarness(messages); + }); + + it('GroupLinkAdd items', async () => { + const messages: Array = [ + createMessage({ + from: OUR_ACI, + details: [ + { + type: 'group-link-add', + privilege: AccessControlEnum.ANY, + }, + ], + }), + createMessage({ + from: ADMIN_A, + details: [ + { + type: 'group-link-add', + privilege: AccessControlEnum.ANY, + }, + ], + }), + createMessage({ + details: [ + { + type: 'group-link-add', + privilege: AccessControlEnum.ANY, + }, + ], + }), + createMessage({ + from: OUR_ACI, + details: [ + { + type: 'group-link-add', + privilege: AccessControlEnum.ADMINISTRATOR, + }, + ], + }), + createMessage({ + from: ADMIN_A, + details: [ + { + type: 'group-link-add', + privilege: AccessControlEnum.ADMINISTRATOR, + }, + ], + }), + createMessage({ + details: [ + { + type: 'group-link-add', + privilege: AccessControlEnum.ADMINISTRATOR, + }, + ], + }), + ]; + + await symmetricRoundtripHarness(messages); + }); + + it('GroupLinkReset items', async () => { + const messages: Array = [ + createMessage({ + from: OUR_ACI, + details: [ + { + type: 'group-link-reset', + }, + ], + }), + createMessage({ + from: ADMIN_A, + details: [ + { + type: 'group-link-reset', + }, + ], + }), + createMessage({ + details: [ + { + type: 'group-link-reset', + }, + ], + }), + ]; + + await symmetricRoundtripHarness(messages); + }); + + it('GroupLinkRemove items', async () => { + const messages: Array = [ + createMessage({ + from: OUR_ACI, + details: [ + { + type: 'group-link-remove', + }, + ], + }), + createMessage({ + from: ADMIN_A, + details: [ + { + type: 'group-link-remove', + }, + ], + }), + createMessage({ + details: [ + { + type: 'group-link-remove', + }, + ], + }), + ]; + + await symmetricRoundtripHarness(messages); + }); + + it('DescriptionRemove items', async () => { + const messages: Array = [ + createMessage({ + from: OUR_ACI, + details: [ + { + removed: true, + type: 'description', + }, + ], + }), + createMessage({ + from: ADMIN_A, + details: [ + { + removed: true, + type: 'description', + }, + ], + }), + createMessage({ + details: [ + { + removed: true, + type: 'description', + }, + ], + }), + ]; + + await symmetricRoundtripHarness(messages); + }); + + it('DescriptionChange items', async () => { + const messages: Array = [ + createMessage({ + from: OUR_ACI, + details: [ + { + type: 'description', + description: + 'This is a long description.\n\nWe need a dialog to view it all!\n\nIt has a link to https://example.com', + }, + ], + }), + createMessage({ + from: ADMIN_A, + details: [ + { + type: 'description', + description: + 'This is a long description.\n\nWe need a dialog to view it all!\n\nIt has a link to https://example.com', + }, + ], + }), + createMessage({ + details: [ + { + type: 'description', + description: + 'This is a long description.\n\nWe need a dialog to view it all!\n\nIt has a link to https://example.com', + }, + ], + }), + ]; + + await symmetricRoundtripHarness(messages); + }); + + it('DescriptionChange items', async () => { + const messages: Array = [ + createMessage({ + from: OUR_ACI, + details: [ + { + type: 'announcements-only', + announcementsOnly: true, + }, + ], + }), + createMessage({ + from: ADMIN_A, + details: [ + { + type: 'announcements-only', + announcementsOnly: true, + }, + ], + }), + createMessage({ + details: [ + { + type: 'announcements-only', + announcementsOnly: true, + }, + ], + }), + createMessage({ + from: OUR_ACI, + details: [ + { + type: 'announcements-only', + announcementsOnly: false, + }, + ], + }), + createMessage({ + from: ADMIN_A, + details: [ + { + type: 'announcements-only', + announcementsOnly: false, + }, + ], + }), + createMessage({ + details: [ + { + type: 'announcements-only', + announcementsOnly: false, + }, + ], + }), + ]; + + await symmetricRoundtripHarness(messages); + }); + + it('Summary items', async () => { + const messages: Array = [ + createMessage({ + from: OUR_ACI, + details: [ + { + type: 'summary', + }, + ], + }), + ]; + + await symmetricRoundtripHarness(messages); + }); + }); + + describe('roundtrips given a timer change notification', () => { + it('in a group', async () => { + const groupConversation = window.ConversationController.get(GROUP_ID); + strictAssert( + groupConversation, + 'The group conversation must be created!' + ); + + counter += 1; + const zeroTimer = { + id: generateGuid(), + conversationId: groupConversation.id, + expirationTimerUpdate: { + expireTimer: DurationInSeconds.fromSeconds(5), + sourceServiceId: CONTACT_A, + }, + flags: EXPIRATION_TIMER_FLAG, + type: 'timer-notification' as const, + received_at: counter, + sent_at: counter, + timestamp: counter, + }; + + counter += 1; + const fiveSecondTimer = { + id: generateGuid(), + conversationId: groupConversation.id, + expirationTimerUpdate: { + expireTimer: DurationInSeconds.fromSeconds(5), + sourceServiceId: CONTACT_A, + }, + flags: EXPIRATION_TIMER_FLAG, + type: 'timer-notification' as const, + received_at: counter, + sent_at: counter, + timestamp: counter, + }; + + const messages: Array = [ + zeroTimer, + fiveSecondTimer, + ]; + + await symmetricRoundtripHarness(messages); + }); + + it('in a 1:1 conversation', async () => { + const contactA = window.ConversationController.get(CONTACT_A); + strictAssert(contactA, 'contactA conversation must be created!'); + + counter += 1; + const zeroTimer = { + id: generateGuid(), + conversationId: contactA.id, + expirationTimerUpdate: { + expireTimer: DurationInSeconds.fromSeconds(0), + sourceServiceId: CONTACT_A, + }, + sourceServiceId: CONTACT_A, + flags: EXPIRATION_TIMER_FLAG, + type: 'timer-notification' as const, + received_at: counter, + sent_at: counter, + timestamp: counter, + }; + + counter += 1; + const fiveSecondTimer = { + id: generateGuid(), + conversationId: contactA.id, + expirationTimerUpdate: { + expireTimer: DurationInSeconds.fromSeconds(5), + sourceServiceId: OUR_ACI, + }, + sourceServiceId: OUR_ACI, + flags: EXPIRATION_TIMER_FLAG, + type: 'timer-notification' as const, + received_at: counter, + sent_at: counter, + timestamp: counter, + }; + + const messages: Array = [ + zeroTimer, + fiveSecondTimer, + ]; + + await symmetricRoundtripHarness(messages); + }); + }); + + describe('roundtrips given migration notifications', () => { + it('symmetrically', async () => { + const groupConversation = window.ConversationController.get(GROUP_ID); + strictAssert( + groupConversation, + 'The group conversation must be created!' + ); + + counter += 1; + const droppedOnly = { + id: generateGuid(), + conversationId: groupConversation.id, + groupMigration: { + areWeInvited: false, + droppedMemberCount: 2, + invitedMemberCount: 0, + }, + type: 'group-v1-migration' as const, + received_at: counter, + sent_at: counter, + timestamp: counter, + }; + + counter += 1; + const invitedOnly = { + id: generateGuid(), + conversationId: groupConversation.id, + groupMigration: { + areWeInvited: false, + droppedMemberCount: 0, + invitedMemberCount: 1, + }, + type: 'group-v1-migration' as const, + received_at: counter, + sent_at: counter, + timestamp: counter, + }; + + counter += 1; + const bothAndInvited = { + id: generateGuid(), + conversationId: groupConversation.id, + groupMigration: { + areWeInvited: true, + droppedMemberCount: 2, + invitedMemberCount: 1, + }, + type: 'group-v1-migration' as const, + received_at: counter, + sent_at: counter, + timestamp: counter, + }; + + const messages: Array = [ + droppedOnly, + invitedOnly, + bothAndInvited, + ]; + + await symmetricRoundtripHarness(messages); + }); + + it('asymmetrically', async () => { + const groupConversation = window.ConversationController.get(GROUP_ID); + strictAssert( + groupConversation, + 'The group conversation must be created!' + ); + + counter += 1; + const legacyBefore = { + id: generateGuid(), + conversationId: groupConversation.id, + droppedGV2MemberIds: [CONTACT_C], + invitedGV2Members: [ + { uuid: CONTACT_A, timestamp: counter, role: RoleEnum.DEFAULT }, + { uuid: CONTACT_B, timestamp: counter, role: RoleEnum.DEFAULT }, + ], + type: 'group-v1-migration' as const, + received_at: counter, + sent_at: counter, + timestamp: counter, + }; + const legacyAfter = { + id: generateGuid(), + conversationId: groupConversation.id, + groupMigration: { + areWeInvited: false, + droppedMemberCount: 1, + invitedMemberCount: 2, + }, + type: 'group-v1-migration' as const, + received_at: counter, + sent_at: counter, + timestamp: counter, + }; + + counter += 1; + const allDataBefore = { + id: generateGuid(), + conversationId: groupConversation.id, + groupMigration: { + areWeInvited: true, + droppedMemberIds: [CONTACT_C], + invitedMembers: [ + { uuid: CONTACT_A, timestamp: counter, role: RoleEnum.DEFAULT }, + { uuid: CONTACT_B, timestamp: counter, role: RoleEnum.DEFAULT }, + ], + }, + type: 'group-v1-migration' as const, + received_at: counter, + sent_at: counter, + timestamp: counter, + }; + const allDataAfter = { + id: generateGuid(), + conversationId: groupConversation.id, + groupMigration: { + areWeInvited: true, + droppedMemberCount: 1, + invitedMemberCount: 2, + }, + type: 'group-v1-migration' as const, + received_at: counter, + sent_at: counter, + timestamp: counter, + }; + + const before = [legacyBefore, allDataBefore]; + const after = [legacyAfter, allDataAfter]; + + await asymmetricRoundtripHarness(before, after); + }); + }); +}); diff --git a/ts/test-node/app/base_config_test.ts b/ts/test-node/app/base_config_test.ts index e4b002fd35..9dfd533579 100644 --- a/ts/test-node/app/base_config_test.ts +++ b/ts/test-node/app/base_config_test.ts @@ -3,7 +3,7 @@ import * as path from 'path'; import { tmpdir } from 'os'; -import { chmodSync, rmdirSync, writeFileSync, mkdtempSync } from 'fs'; +import { chmodSync, rmSync, writeFileSync, mkdtempSync } from 'fs'; import { pathExists, readJsonSync } from 'fs-extra'; import { v4 as generateGuid } from 'uuid'; @@ -25,7 +25,7 @@ describe('base_config', () => { try { chmodSync(targetDir, 0o755); chmodSync(targetPath, 0o755); - rmdirSync(targetDir, { recursive: true }); + rmSync(targetDir, { recursive: true }); } catch (err) { assert.strictEqual(err.code, 'ENOENT'); } @@ -167,7 +167,7 @@ describe('base_config', () => { throwOnFilesystemErrors: true, }); config.set('foo', 123); - rmdirSync(targetDir, { recursive: true }); + rmSync(targetDir, { recursive: true }); assert.throws(() => config.set('foo', 456)); assert.strictEqual(config.get('foo'), 123); @@ -185,7 +185,7 @@ describe('base_config', () => { throwOnFilesystemErrors: false, }); config.set('foo', 123); - rmdirSync(targetDir, { recursive: true }); + rmSync(targetDir, { recursive: true }); config.set('bar', 456); diff --git a/ts/textsecure/MessageReceiver.ts b/ts/textsecure/MessageReceiver.ts index d03dd59bdb..7a460a30a6 100644 --- a/ts/textsecure/MessageReceiver.ts +++ b/ts/textsecure/MessageReceiver.ts @@ -2221,7 +2221,7 @@ export default class MessageReceiver const { sourceServiceId: sourceAci } = envelope; strictAssert( isAciString(sourceAci), - 'MessageReceiver.handleEditMesage: received message from PNI' + 'MessageReceiver.handleStoryMessage: received message from PNI' ); const attachments: Array = []; diff --git a/ts/util/getNotificationDataForMessage.ts b/ts/util/getNotificationDataForMessage.ts index e6f5416045..b463bdfbcf 100644 --- a/ts/util/getNotificationDataForMessage.ts +++ b/ts/util/getNotificationDataForMessage.ts @@ -143,6 +143,12 @@ export function getNotificationDataForMessage( ); const changes = GroupChange.renderChange(change, { + checkServiceIdEquivalence: (left, right) => { + return ( + window.ConversationController.get(left) === + window.ConversationController.get(right) + ); + }, i18n: window.i18n, ourAci: window.textsecure.storage.user.getCheckedAci(), ourPni: window.textsecure.storage.user.getCheckedPni(),