// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only

import lodash from 'lodash';
import type { ClientZkGroupCipher } from '@signalapp/libsignal-client/zkgroup.js';
import { LRUCache } from 'lru-cache';
import { createLogger } from './logging/log.std.ts';
import {
  getCheckedGroupCredentialsForToday,
  maybeFetchNewCredentials,
} from './services/groupCredentialFetcher.preload.ts';
import { DataReader, DataWriter } from './sql/Client.preload.ts';
import {
  toWebSafeBase64,
  fromWebSafeBase64,
} from './util/webSafeBase64.std.ts';
import { assertDev, strictAssert } from './util/assert.std.ts';
import { isMoreRecentThan } from './util/timestamp.std.ts';
import {
  MINUTE,
  DurationInSeconds,
  SECOND,
} from './util/durations/index.std.ts';
import { drop } from './util/drop.std.ts';
import { dropNull } from './util/dropNull.std.ts';
import {
  writeNewAttachmentData,
  readAttachmentData,
  maybeDeleteAttachmentFile,
} from './util/migrations.preload.ts';
import type {
  ConversationAttributesType,
  GroupV2MemberType,
  GroupV2PendingAdminApprovalType,
  GroupV2PendingMemberType,
  GroupV2BannedMemberType,
  MessageAttributesType,
} from './model-types.d.ts';
import {
  createProfileKeyCredentialPresentation,
  decodeProfileKeyCredentialPresentation,
  decryptGroupBlob,
  decryptProfileKey,
  decryptAci,
  decryptPni,
  decryptServiceId,
  deriveGroupID,
  deriveGroupPublicParams,
  deriveGroupSecretParams,
  encryptGroupBlob,
  encryptServiceId,
  getAuthCredentialPresentation,
  getClientZkAuthOperations,
  getClientZkGroupCipher,
  getClientZkProfileOperations,
  verifyNotarySignature,
} from './util/zkgroup.node.ts';
import {
  computeHash,
  deriveMasterKeyFromGroupV1,
  getRandomBytes,
} from './Crypto.node.ts';
import type {
  GroupCredentialsType,
  GroupLogResponseType,
} from './textsecure/WebAPI.preload.ts';
import {
  createGroup,
  getGroup,
  getGroupAvatar,
  getGroupLog,
  getGroupFromLink,
  getExternalGroupCredential,
  modifyGroup,
  uploadGroupAvatar,
} from './textsecure/WebAPI.preload.ts';
import { HTTPError } from './types/HTTPError.std.ts';
import { CURRENT_SCHEMA_VERSION as MAX_MESSAGE_SCHEMA } from './types/Message2.preload.ts';
import type { ConversationModel } from './models/conversations.preload.ts';
import { getGroupSizeHardLimit } from './groups/limits.dom.ts';
import {
  isGroupV1 as getIsGroupV1,
  isGroupV2 as getIsGroupV2,
  isGroupV2,
  isMe,
} from './util/whatTypeOfConversation.dom.ts';
import * as Bytes from './Bytes.std.ts';
import type { AvatarDataType } from './types/Avatar.std.ts';
import type { GroupV2ChangeDetailType } from './types/groups.std.ts';
import type {
  ServiceIdString,
  AciString,
  PniString,
} from './types/ServiceId.std.ts';
import {
  ServiceIdKind,
  isPniString,
  isServiceIdString,
} from './types/ServiceId.std.ts';
import { isAciString } from './util/isAciString.std.ts';
import * as Errors from './types/errors.std.ts';
import { SignalService as Proto } from './protobuf/index.std.ts';
import { isNotNil } from './util/isNotNil.std.ts';
import { isAccessControlEnabled } from './groups/util.std.ts';

import {
  conversationJobQueue,
  conversationQueueJobEnum,
} from './jobs/conversationJobQueue.preload.ts';
import { ReadStatus } from './messages/MessageReadStatus.std.ts';
import { SeenStatus } from './MessageSeenStatus.std.ts';
import { incrementMessageCounter } from './util/incrementMessageCounter.preload.ts';
import { sleep } from './util/sleep.std.ts';
import { groupInvitesRoute } from './util/signalRoutes.std.ts';
import {
  decodeGroupSendEndorsementsResponse,
  validateGroupSendEndorsementsExpiration,
} from './util/groupSendEndorsements.preload.ts';
import { getProfile } from './util/getProfile.preload.ts';
import { generateMessageId } from './util/generateMessageId.node.ts';
import { postSaveUpdates } from './util/cleanup.preload.ts';
import { MessageModel } from './models/messages.preload.ts';
import { areWePending } from './util/groupMembershipUtils.preload.ts';
import {
  isConversationAccepted,
  isTrustedContact,
} from './util/isConversationAccepted.preload.ts';
import { itemStorage } from './textsecure/Storage.preload.ts';
import {
  EMOJI_OUTGOING_BYTE_LIMIT,
  SERVER_EMOJI_BYTE_LIMIT,
  SERVER_STRING_BYTE_LIMIT,
} from './types/GroupMemberLabels.std.ts';
import { getConversationIdForLogging } from './util/idForLogging.preload.ts';
import { toNumber } from './util/toNumber.std.ts';

import Actions = Proto.GroupChange.Actions;
import AccessRequired = Proto.AccessControl.AccessRequired;
import MemberRole = Proto.Member.Role;

const { compact, difference, flatten, fromPairs, isNumber, omit, values } =
  lodash;

const log = createLogger('groups');

export { joinViaLink } from './groups/joinViaLink.preload.ts';

export type GroupFields = {
  readonly id: Uint8Array<ArrayBuffer>;
  readonly secretParams: Uint8Array<ArrayBuffer>;
  readonly publicParams: Uint8Array<ArrayBuffer>;
};

const MAX_CACHED_GROUP_FIELDS = 100;

const groupFieldsCache = new LRUCache<string, GroupFields>({
  max: MAX_CACHED_GROUP_FIELDS,
});

const { updateConversation } = DataWriter;

if (!isNumber(MAX_MESSAGE_SCHEMA)) {
  throw new Error(
    'groups.ts: Unable to capture max message schema from js/modules/types/message'
  );
}

function toGroupActionsParams(
  input: Pick<Actions.Params, 'version'> &
    Partial<Omit<Actions.Params, 'sourceUserId' | 'groupId'>>
): Actions.Params {
  return {
    sourceUserId: null,
    groupId: null,
    addMembers: null,
    deleteMembers: null,
    modifyMemberRoles: null,
    modifyMemberLabels: null,
    modifyMemberProfileKeys: null,
    addMembersPendingProfileKey: null,
    deleteMembersPendingProfileKey: null,
    promoteMembersPendingProfileKey: null,
    modifyTitle: null,
    modifyAvatar: null,
    modifyDisappearingMessageTimer: null,
    modifyAttributesAccess: null,
    modifyMemberAccess: null,
    modifyAddFromInviteLinkAccess: null,
    modifyMemberLabelAccess: null,
    addMembersPendingAdminApproval: null,
    deleteMembersPendingAdminApproval: null,
    promoteMembersPendingAdminApproval: null,
    modifyInviteLinkPassword: null,
    modifyDescription: null,
    modifyAnnouncementsOnly: null,
    addMembersBanned: null,
    deleteMembersBanned: null,
    promoteMembersPendingPniAciProfileKey: null,
    terminateGroup: null,
    ...input,
  };
}

type UpdatesResultType = {
  // The array of new messages to be added into the message timeline
  groupChangeMessages: Array<GroupChangeMessageType>;
  // The map of members in the group, and we largely just pull profile keys for each,
  //   because the group membership is updated in newAttributes
  newProfileKeys: Map<AciString, string>;
  // To be merged into the conversation model
  newAttributes: ConversationAttributesType;
};

type UploadedAvatarType = {
  data: Uint8Array<ArrayBuffer>;
  hash: string;
  key: string;
};

type BasicMessageType = Pick<
  MessageAttributesType,
  'readStatus' | 'seenStatus'
>;

type GroupV2ChangeMessageType = {
  type: 'group-v2-change';
} & Pick<MessageAttributesType, 'groupV2Change' | 'sourceServiceId'>;

type GroupV1MigrationMessageType = {
  type: 'group-v1-migration';
} & Pick<
  MessageAttributesType,
  'invitedGV2Members' | 'droppedGV2MemberIds' | 'groupMigration'
>;

type TimerNotificationMessageType = {
  type: 'timer-notification';
} & Pick<
  MessageAttributesType,
  'sourceServiceId' | 'flags' | 'expirationTimerUpdate'
>;

type GroupChangeMessageType = BasicMessageType &
  (
    | GroupV2ChangeMessageType
    | GroupV1MigrationMessageType
    | TimerNotificationMessageType
  );

// Constants

export const MASTER_KEY_LENGTH = 32;
const GROUP_TITLE_MAX_ENCRYPTED_BYTES = 1024;
const GROUP_DESC_MAX_ENCRYPTED_BYTES = 8192;
const TEMPORAL_AUTH_REJECTED_CODE = 401;
const GROUP_ACCESS_DENIED_CODE = 403;
const GROUP_NONEXISTENT_CODE = 404;
const SUPPORTED_CHANGE_EPOCH = 7; // support for GroupTerminateChangeUpdate
export const LINK_VERSION_ERROR = 'LINK_VERSION_ERROR';
const GROUP_INVITE_LINK_PASSWORD_LENGTH = 16;

// Group Links

export function generateGroupInviteLinkPassword(): Uint8Array<ArrayBuffer> {
  return getRandomBytes(GROUP_INVITE_LINK_PASSWORD_LENGTH);
}

// Group Links

export async function getPreJoinGroupInfo(
  inviteLinkPasswordBase64: string,
  masterKeyBase64: string
): Promise<Proto.GroupJoinInfo> {
  const data = deriveGroupFields(Bytes.fromBase64(masterKeyBase64));

  return makeRequestWithCredentials({
    // oxlint-disable-next-line typescript/restrict-template-expressions
    logId: `getPreJoinInfo/groupv2(${data.id})`,
    publicParams: Bytes.toBase64(data.publicParams),
    secretParams: Bytes.toBase64(data.secretParams),
    request: options => getGroupFromLink(inviteLinkPasswordBase64, options),
  });
}

export function buildGroupLink(
  conversation: ConversationAttributesType
): string | undefined {
  if (!isGroupV2(conversation)) {
    return undefined;
  }

  const { masterKey, groupInviteLinkPassword } = conversation;

  if (!groupInviteLinkPassword) {
    return undefined;
  }

  strictAssert(masterKey, 'buildGroupLink requires the master key!');

  const bytes = Proto.GroupInviteLink.encode({
    contents: {
      contentsV1: {
        groupMasterKey: Bytes.fromBase64(masterKey),
        inviteLinkPassword: Bytes.fromBase64(groupInviteLinkPassword),
      },
    },
  });

  const inviteCode = toWebSafeBase64(Bytes.toBase64(bytes));

  return groupInvitesRoute.toWebUrl({ inviteCode }).toString();
}

export function parseGroupLink(value: string): {
  masterKey: string;
  inviteLinkPassword: string;
} {
  const base64 = fromWebSafeBase64(value);
  const buffer = Bytes.fromBase64(base64);

  const inviteLinkProto = Proto.GroupInviteLink.decode(buffer);
  if (!inviteLinkProto.contents?.contentsV1) {
    const error = new Error(
      'parseGroupLink: Parsed proto is missing contentsV1'
    );
    error.name = LINK_VERSION_ERROR;
    throw error;
  }

  const {
    groupMasterKey: groupMasterKeyRaw,
    inviteLinkPassword: inviteLinkPasswordRaw,
  } = inviteLinkProto.contents.contentsV1;

  if (!groupMasterKeyRaw || !groupMasterKeyRaw.length) {
    throw new Error('contentsV1.groupMasterKey had no data!');
  }
  if (!inviteLinkPasswordRaw || !inviteLinkPasswordRaw.length) {
    throw new Error('contentsV1.inviteLinkPassword had no data!');
  }

  const masterKey = Bytes.toBase64(groupMasterKeyRaw);
  if (masterKey.length !== 44) {
    throw new Error(`masterKey had unexpected length ${masterKey.length}`);
  }
  const inviteLinkPassword = Bytes.toBase64(inviteLinkPasswordRaw);
  if (inviteLinkPassword.length === 0) {
    throw new Error(
      `inviteLinkPassword had unexpected length ${inviteLinkPassword.length}`
    );
  }

  return { masterKey, inviteLinkPassword };
}

// Group Modifications

async function uploadAvatar(options: {
  logId: string;
  publicParams: string;
  secretParams: string;
  data: Uint8Array<ArrayBuffer>;
}): Promise<UploadedAvatarType> {
  const { logId, publicParams, secretParams, data } = options;

  try {
    const clientZkGroupCipher = getClientZkGroupCipher(secretParams);

    const hash = computeHash(data);

    const blobPlaintext = Proto.GroupAttributeBlob.encode({
      content: {
        avatar: data,
      },
    });
    const ciphertext = encryptGroupBlob(clientZkGroupCipher, blobPlaintext);

    const key = await makeRequestWithCredentials({
      logId: `uploadGroupAvatar/${logId}`,
      publicParams,
      secretParams,
      request: requestOptions => uploadGroupAvatar(ciphertext, requestOptions),
    });

    return {
      data,
      hash,
      key,
    };
  } catch (error) {
    log.warn(
      `uploadAvatar/${logId} Failed to upload avatar`,
      Errors.toLogFormat(error)
    );
    throw error;
  }
}

function buildGroupTitleBuffer(
  clientZkGroupCipher: ClientZkGroupCipher,
  title: string
): Uint8Array<ArrayBuffer> {
  const titleBlobPlaintext = Proto.GroupAttributeBlob.encode({
    content: {
      title,
    },
  });

  const result = encryptGroupBlob(clientZkGroupCipher, titleBlobPlaintext);

  if (result.byteLength > GROUP_TITLE_MAX_ENCRYPTED_BYTES) {
    throw new Error('buildGroupTitleBuffer: encrypted group title is too long');
  }

  return result;
}

function buildGroupDescriptionBuffer(
  clientZkGroupCipher: ClientZkGroupCipher,
  description: string
): Uint8Array<ArrayBuffer> {
  const attrsBlobPlaintext = Proto.GroupAttributeBlob.encode({
    content: {
      descriptionText: description,
    },
  });

  const result = encryptGroupBlob(clientZkGroupCipher, attrsBlobPlaintext);

  if (result.byteLength > GROUP_DESC_MAX_ENCRYPTED_BYTES) {
    throw new Error(
      'buildGroupDescriptionBuffer: encrypted group title is too long'
    );
  }

  return result;
}

function buildGroupProto(
  attributes: Pick<
    ConversationAttributesType,
    | 'accessControl'
    | 'expireTimer'
    | 'id'
    | 'membersV2'
    | 'name'
    | 'pendingMembersV2'
    | 'publicParams'
    | 'revision'
    | 'secretParams'
  > & {
    avatarUrl?: string;
  }
): Proto.Group.Params {
  const logId = `groupv2(${attributes.id})`;

  const { publicParams, secretParams } = attributes;

  if (!publicParams) {
    throw new Error(
      `buildGroupProto/${logId}: attributes were missing publicParams!`
    );
  }
  if (!secretParams) {
    throw new Error(
      `buildGroupProto/${logId}: attributes were missing secretParams!`
    );
  }

  const serverPublicParamsBase64 = window.getServerPublicParams();
  const clientZkGroupCipher = getClientZkGroupCipher(secretParams);
  const clientZkProfileCipher = getClientZkProfileOperations(
    serverPublicParamsBase64
  );

  const publicKey = Bytes.fromBase64(publicParams);
  const version = attributes.revision || 0;

  let title: Uint8Array<ArrayBuffer> | null = null;
  if (attributes.name) {
    title = buildGroupTitleBuffer(clientZkGroupCipher, attributes.name);
  }

  let avatarUrl: string | null = null;
  if (attributes.avatarUrl) {
    avatarUrl = attributes.avatarUrl;
  }

  let disappearingMessagesTimer: Uint8Array<ArrayBuffer> | null = null;
  if (attributes.expireTimer) {
    const timerBlobPlaintext = Proto.GroupAttributeBlob.encode({
      content: {
        disappearingMessagesDuration: attributes.expireTimer,
      },
    });
    disappearingMessagesTimer = encryptGroupBlob(
      clientZkGroupCipher,
      timerBlobPlaintext
    );
  }

  const accessControl: Proto.AccessControl.Params = {
    attributes: attributes.accessControl?.attributes ?? AccessRequired.MEMBER,
    members: attributes.accessControl?.members ?? AccessRequired.MEMBER,
    addFromInviteLink:
      attributes.accessControl?.addFromInviteLink ?? AccessRequired.MEMBER,
    memberLabel: attributes.accessControl?.memberLabel ?? AccessRequired.MEMBER,
  };

  const members = (attributes.membersV2 || []).map(
    (item): Proto.Member.Params => {
      const conversation = window.ConversationController.get(item.aci);
      if (!conversation) {
        throw new Error(
          `buildGroupProto/${logId}: no conversation for member!`
        );
      }

      const profileKeyCredentialBase64 = conversation.get(
        'profileKeyCredential'
      );
      if (!profileKeyCredentialBase64) {
        throw new Error(
          `buildGroupProto/${logId}: member was missing profileKeyCredential!`
        );
      }
      const presentation = createProfileKeyCredentialPresentation(
        clientZkProfileCipher,
        profileKeyCredentialBase64,
        secretParams
      );

      return {
        role: item.role || MemberRole.DEFAULT,
        presentation,
        // intentionally left null
        userId: null,
        profileKey: null,
        joinedAtVersion: null,
        labelEmoji: null,
        labelString: null,
      };
    }
  );

  const ourAci = itemStorage.user.getCheckedAci();

  const ourAciCipherTextBuffer = encryptServiceId(clientZkGroupCipher, ourAci);

  const membersPendingProfileKey = (attributes.pendingMembersV2 || []).map(
    (item): Proto.MemberPendingProfileKey.Params => {
      const conversation = window.ConversationController.get(item.serviceId);
      if (!conversation) {
        throw new Error('buildGroupProto: no conversation for pending member!');
      }

      const serviceId = conversation.getCheckedServiceId(
        'buildGroupProto: pending member was missing serviceId!'
      );

      const uuidCipherTextBuffer = encryptServiceId(
        clientZkGroupCipher,
        serviceId
      );

      return {
        timestamp: BigInt(item.timestamp),
        addedByUserId: ourAciCipherTextBuffer,
        member: {
          userId: uuidCipherTextBuffer,
          role: item.role || MemberRole.DEFAULT,
          // intentionally left null
          presentation: null,
          profileKey: null,
          joinedAtVersion: null,
          labelEmoji: null,
          labelString: null,
        },
      };
    }
  );

  return {
    publicKey,
    version,
    title,
    avatarUrl,
    disappearingMessagesTimer,
    accessControl,
    members,
    membersPendingProfileKey,

    // Can't create group with these initial fields
    description: null,
    membersPendingAdminApproval: null,
    membersBanned: null,
    inviteLinkPassword: null,
    announcementsOnly: null,
    terminated: null,
  };
}

export async function buildAddMembersChange(
  conversation: Pick<
    ConversationAttributesType,
    'bannedMembersV2' | 'id' | 'publicParams' | 'revision' | 'secretParams'
  >,
  conversationIds: ReadonlyArray<string>
): Promise<undefined | Actions.Params> {
  const { id, publicParams, revision, secretParams } = conversation;

  const logId = `groupv2(${id})`;

  if (!publicParams) {
    throw new Error(
      `buildAddMembersChange/${logId}: attributes were missing publicParams!`
    );
  }
  if (!secretParams) {
    throw new Error(
      `buildAddMembersChange/${logId}: attributes were missing secretParams!`
    );
  }

  const newGroupVersion = (revision || 0) + 1;
  const serverPublicParamsBase64 = window.getServerPublicParams();
  const clientZkProfileCipher = getClientZkProfileOperations(
    serverPublicParamsBase64
  );
  const clientZkGroupCipher = getClientZkGroupCipher(secretParams);

  const ourAci = itemStorage.user.getCheckedAci();
  const ourAciCipherTextBuffer = encryptServiceId(clientZkGroupCipher, ourAci);

  const now = Date.now();

  const addMembers: Array<Actions.AddMemberAction.Params> = [];
  const addMembersPendingProfileKey: Array<Actions.AddMemberPendingProfileKeyAction.Params> =
    [];
  const deleteMembersBanned: Array<Actions.DeleteMemberAction.Params> = [];

  await Promise.all(
    conversationIds.map(async conversationId => {
      const contact = window.ConversationController.get(conversationId);
      if (!contact) {
        assertDev(
          false,
          `buildAddMembersChange/${logId}: missing local contact, skipping`
        );
        return;
      }

      const serviceId = contact.getServiceId();
      if (!serviceId) {
        assertDev(
          false,
          `buildAddMembersChange/${logId}: missing serviceId; skipping`
        );
        return;
      }

      // Refresh our local data to be sure
      if (!contact.get('profileKey') || !contact.get('profileKeyCredential')) {
        await contact.getProfiles();
      }

      const profileKey = contact.get('profileKey');
      const profileKeyCredential = contact.get('profileKeyCredential');

      const member = {
        userId: encryptServiceId(clientZkGroupCipher, serviceId),
        role: MemberRole.DEFAULT,
        joinedAtVersion: newGroupVersion,
        profileKey: null,
        labelEmoji: null,
        labelString: null,
      } satisfies Partial<Proto.Member.Params>;

      // This is inspired by [Android's equivalent code][0].
      //
      // [0]: https://github.com/signalapp/Signal-Android/blob/2be306867539ab1526f0e49d1aa7bd61e783d23f/libsignal/service/src/main/java/org/whispersystems/signalservice/api/groupsv2/GroupsV2Operations.java#L152-L174
      if (profileKey && profileKeyCredential) {
        const presentation = createProfileKeyCredentialPresentation(
          clientZkProfileCipher,
          profileKeyCredential,
          secretParams
        );

        addMembers.push({
          added: { ...member, presentation },
          joinFromInviteLink: false,
        });
      } else {
        addMembersPendingProfileKey.push({
          added: {
            member: { ...member, presentation: null },
            addedByUserId: ourAciCipherTextBuffer,
            timestamp: BigInt(now),
          },
        });
      }

      const doesMemberNeedUnban = conversation.bannedMembersV2?.some(
        bannedMember => bannedMember.serviceId === serviceId
      );
      if (doesMemberNeedUnban) {
        const uuidCipherTextBuffer = encryptServiceId(
          clientZkGroupCipher,
          serviceId
        );

        deleteMembersBanned.push({
          deletedUserId: uuidCipherTextBuffer,
        });
      }
    })
  );

  if (!addMembers.length && !addMembersPendingProfileKey.length) {
    // This shouldn't happen. When these actions are passed to `modifyGroupV2`, a warning
    //   will be logged.
    return undefined;
  }

  return toGroupActionsParams({
    version: newGroupVersion,
    addMembers: addMembers.length ? addMembers : null,
    addMembersPendingProfileKey: addMembersPendingProfileKey.length
      ? addMembersPendingProfileKey
      : null,
    deleteMembersBanned: deleteMembersBanned.length
      ? deleteMembersBanned
      : null,
  });
}

export async function buildUpdateAttributesChange(
  conversation: Pick<
    ConversationAttributesType,
    'id' | 'revision' | 'publicParams' | 'secretParams'
  >,
  attributes: Readonly<{
    avatar?: undefined | Uint8Array<ArrayBuffer>;
    description?: string;
    title?: string;
  }>
): Promise<undefined | Actions.Params> {
  const { publicParams, secretParams, revision, id } = conversation;

  const logId = `groupv2(${id})`;

  if (!publicParams) {
    throw new Error(
      `buildUpdateAttributesChange/${logId}: attributes were missing publicParams!`
    );
  }
  if (!secretParams) {
    throw new Error(
      `buildUpdateAttributesChange/${logId}: attributes were missing secretParams!`
    );
  }

  const clientZkGroupCipher = getClientZkGroupCipher(secretParams);

  // There are three possible states here:
  //
  // 1. 'avatar' not in attributes: we don't want to change the avatar.
  // 2. attributes.avatar === undefined: we want to clear the avatar.
  // 3. attributes.avatar !== undefined: we want to update the avatar.
  let modifyAvatar: Actions.ModifyAvatarAction.Params | null = null;
  if ('avatar' in attributes) {
    const { avatar } = attributes;
    if (avatar) {
      const uploadedAvatar = await uploadAvatar({
        data: avatar,
        logId,
        publicParams,
        secretParams,
      });
      modifyAvatar = { avatar: uploadedAvatar.key };
    } else {
      modifyAvatar = { avatar: null };
    }
  }

  const { title } = attributes;
  let modifyTitle: Actions.ModifyTitleAction.Params | null = null;
  if (title) {
    modifyTitle = {
      title: buildGroupTitleBuffer(clientZkGroupCipher, title),
    };
  }

  const { description } = attributes;
  let modifyDescription: Actions.ModifyDescriptionAction.Params | null = null;
  if (typeof description === 'string') {
    modifyDescription = {
      description: buildGroupDescriptionBuffer(
        clientZkGroupCipher,
        description
      ),
    };
  }

  if (
    modifyAvatar == null &&
    modifyTitle == null &&
    modifyDescription == null
  ) {
    // This shouldn't happen. When these actions are passed to `modifyGroupV2`, a warning
    //   will be logged.
    return undefined;
  }

  return toGroupActionsParams({
    version: (revision || 0) + 1,
    modifyAvatar,
    modifyTitle,
    modifyDescription,
  });
}

export function buildDisappearingMessagesTimerChange({
  expireTimer,
  group,
}: {
  expireTimer: DurationInSeconds;
  group: ConversationAttributesType;
}): Actions.Params {
  if (!group.secretParams) {
    throw new Error(
      'buildDisappearingMessagesTimerChange: group was missing secretParams!'
    );
  }
  const clientZkGroupCipher = getClientZkGroupCipher(group.secretParams);

  const blobPlaintext = Proto.GroupAttributeBlob.encode({
    content: {
      disappearingMessagesDuration: expireTimer,
    },
  });
  const blobCipherText = encryptGroupBlob(clientZkGroupCipher, blobPlaintext);

  return toGroupActionsParams({
    version: (group.revision || 0) + 1,
    modifyDisappearingMessageTimer: {
      timer: blobCipherText,
    },
  });
}

export function buildInviteLinkPasswordChange(
  group: ConversationAttributesType,
  inviteLinkPassword: string
): Actions.Params {
  return toGroupActionsParams({
    version: (group.revision || 0) + 1,
    modifyInviteLinkPassword: {
      inviteLinkPassword: Bytes.fromBase64(inviteLinkPassword),
    },
  });
}

export function buildNewGroupLinkChange(
  group: ConversationAttributesType,
  inviteLinkPassword: string,
  addFromInviteLinkAccess: AccessRequired
): Actions.Params {
  return toGroupActionsParams({
    version: (group.revision || 0) + 1,
    modifyAddFromInviteLinkAccess: {
      addFromInviteLinkAccess,
    },
    modifyInviteLinkPassword: {
      inviteLinkPassword: Bytes.fromBase64(inviteLinkPassword),
    },
  });
}

export function buildAccessControlAddFromInviteLinkChange(
  group: ConversationAttributesType,
  addFromInviteLinkAccess: AccessRequired
): Actions.Params {
  return toGroupActionsParams({
    version: (group.revision || 0) + 1,
    modifyAddFromInviteLinkAccess: {
      addFromInviteLinkAccess,
    },
  });
}

export function buildAnnouncementsOnlyChange(
  group: ConversationAttributesType,
  announcementsOnly: boolean
): Actions.Params {
  return toGroupActionsParams({
    version: (group.revision || 0) + 1,
    modifyAnnouncementsOnly: {
      announcementsOnly,
    },
  });
}

export function buildAccessControlAttributesChange(
  group: ConversationAttributesType,
  attributesAccess: AccessRequired
): Actions.Params {
  return toGroupActionsParams({
    version: (group.revision || 0) + 1,
    modifyAttributesAccess: {
      attributesAccess,
    },
  });
}

export function buildAccessControlMembersChange(
  group: ConversationAttributesType,
  membersAccess: AccessRequired
): Actions.Params {
  return toGroupActionsParams({
    version: (group.revision || 0) + 1,
    modifyMemberAccess: {
      membersAccess,
    },
  });
}

export function buildAccessControlMemberLabelChange(
  group: ConversationAttributesType,
  memberLabelAccess: AccessRequired
): Actions.Params {
  if (!group.secretParams) {
    throw new Error(
      'buildAccessControlMemberLabelChange: group was missing secretParams!'
    );
  }

  const modifyMemberLabels: Array<Actions.ModifyMemberLabelAction.Params> = [];

  // Clear out all non-admin labels
  const previousValue = group.accessControl?.memberLabel;
  if (
    previousValue !== AccessRequired.ADMINISTRATOR &&
    memberLabelAccess === AccessRequired.ADMINISTRATOR
  ) {
    const clientZkGroupCipher = getClientZkGroupCipher(group.secretParams);

    for (const member of group.membersV2 ?? []) {
      if (member.role === MemberRole.ADMINISTRATOR) {
        continue;
      }

      if (!member.labelString && !member.labelEmoji) {
        continue;
      }

      modifyMemberLabels.push({
        userId: encryptServiceId(clientZkGroupCipher, member.aci),
        labelEmoji: null,
        labelString: null,
      });
    }

    if (modifyMemberLabels.length) {
      log.info(
        `buildAccessControlMemberLabelChange: Found ${modifyMemberLabels.length} non-admins with labels. Clearing.`
      );
    }
  }

  return toGroupActionsParams({
    version: (group.revision || 0) + 1,
    modifyMemberLabelAccess: {
      memberLabelAccess,
    },
    modifyMemberLabels: modifyMemberLabels.length ? modifyMemberLabels : null,
  });
}

export function _maybeBuildAddBannedMemberActions({
  clientZkGroupCipher,
  group,
  ourAci,
  serviceId,
}: {
  clientZkGroupCipher: ClientZkGroupCipher;
  group: Pick<ConversationAttributesType, 'bannedMembersV2'>;
  ourAci: AciString;
  serviceId: ServiceIdString;
}): Pick<
  Proto.GroupChange.Actions.Params,
  'addMembersBanned' | 'deleteMembersBanned'
> {
  const doesMemberNeedBan =
    !group.bannedMembersV2?.some(member => member.serviceId === serviceId) &&
    serviceId !== ourAci;
  if (!doesMemberNeedBan) {
    return {
      addMembersBanned: null,
      deleteMembersBanned: null,
    };
  }
  // Sort current banned members by decreasing timestamp
  const sortedBannedMembers = [...(group.bannedMembersV2 ?? [])].sort(
    (a, b) => {
      return b.timestamp - a.timestamp;
    }
  );

  // All members after the limit have to be deleted and are older than the
  // rest of the list.
  const deletedBannedMembers = sortedBannedMembers.slice(
    Math.max(0, getGroupSizeHardLimit() - 1)
  );

  let deleteMembersBanned: Array<Actions.DeleteMemberBannedAction.Params> | null =
    null;
  if (deletedBannedMembers.length > 0) {
    deleteMembersBanned = deletedBannedMembers.map(bannedMember => {
      return {
        deletedUserId: encryptServiceId(
          clientZkGroupCipher,
          bannedMember.serviceId
        ),
      };
    });
  }

  return {
    addMembersBanned: [
      {
        added: {
          userId: encryptServiceId(clientZkGroupCipher, serviceId),
          timestamp: null,
        },
      },
    ],
    deleteMembersBanned,
  };
}

// TODO AND-1101
export function buildDeletePendingAdminApprovalMemberChange({
  group,
  ourAci,
  aci,
}: {
  group: ConversationAttributesType;
  ourAci: AciString;
  aci: AciString;
}): Actions.Params {
  if (!group.secretParams) {
    throw new Error(
      'buildDeletePendingAdminApprovalMemberChange: group was missing secretParams!'
    );
  }
  const clientZkGroupCipher = getClientZkGroupCipher(group.secretParams);
  const uuidCipherTextBuffer = encryptServiceId(clientZkGroupCipher, aci);

  const { addMembersBanned, deleteMembersBanned } =
    _maybeBuildAddBannedMemberActions({
      clientZkGroupCipher,
      group,
      ourAci,
      serviceId: aci,
    });

  return toGroupActionsParams({
    version: (group.revision || 0) + 1,
    deleteMembersPendingAdminApproval: [
      {
        deletedUserId: uuidCipherTextBuffer,
      },
    ],
    addMembersBanned,
    deleteMembersBanned,
  });
}

export function buildAddPendingAdminApprovalMemberChange({
  group,
  profileKeyCredentialBase64,
  serverPublicParamsBase64,
}: {
  group: ConversationAttributesType;
  profileKeyCredentialBase64: string;
  serverPublicParamsBase64: string;
}): Actions.Params {
  if (!group.secretParams) {
    throw new Error(
      'buildAddPendingAdminApprovalMemberChange: group was missing secretParams!'
    );
  }
  const clientZkProfileCipher = getClientZkProfileOperations(
    serverPublicParamsBase64
  );

  const presentation = createProfileKeyCredentialPresentation(
    clientZkProfileCipher,
    profileKeyCredentialBase64,
    group.secretParams
  );

  return toGroupActionsParams({
    version: (group.revision || 0) + 1,
    addMembersPendingAdminApproval: [
      {
        added: {
          presentation,
          userId: null,
          profileKey: null,
          timestamp: null,
        },
      },
    ],
  });
}

export function buildAddMember({
  group,
  profileKeyCredentialBase64,
  serverPublicParamsBase64,
  serviceId,
}: {
  group: ConversationAttributesType;
  profileKeyCredentialBase64: string;
  serverPublicParamsBase64: string;
  joinFromInviteLink?: boolean;
  serviceId: ServiceIdString;
}): Actions.Params {
  if (!group.secretParams) {
    throw new Error('buildAddMember: group was missing secretParams!');
  }
  const clientZkProfileCipher = getClientZkProfileOperations(
    serverPublicParamsBase64
  );

  const presentation = createProfileKeyCredentialPresentation(
    clientZkProfileCipher,
    profileKeyCredentialBase64,
    group.secretParams
  );

  const doesMemberNeedUnban = group.bannedMembersV2?.some(member => {
    return member.serviceId === serviceId;
  });

  let deletedUserId: Uint8Array<ArrayBuffer> | null = null;
  if (doesMemberNeedUnban) {
    const clientZkGroupCipher = getClientZkGroupCipher(group.secretParams);
    const userIdCipherText = encryptServiceId(clientZkGroupCipher, serviceId);
    deletedUserId = userIdCipherText;
  }

  return toGroupActionsParams({
    version: (group.revision || 0) + 1,
    addMembers: [
      {
        added: {
          presentation,
          role: MemberRole.DEFAULT,
          userId: null,
          profileKey: null,
          joinedAtVersion: null,
          labelEmoji: null,
          labelString: null,
        },
        joinFromInviteLink: null,
      },
    ],
    deleteMembersBanned: deletedUserId != null ? [{ deletedUserId }] : null,
  });
}

export function buildDeletePendingMemberChange({
  serviceIds,
  group,
}: {
  serviceIds: ReadonlyArray<ServiceIdString>;
  group: ConversationAttributesType;
}): Actions.Params {
  if (!group.secretParams) {
    throw new Error(
      'buildDeletePendingMemberChange: group was missing secretParams!'
    );
  }
  const clientZkGroupCipher = getClientZkGroupCipher(group.secretParams);

  return toGroupActionsParams({
    version: (group.revision || 0) + 1,
    deleteMembersPendingProfileKey: serviceIds.map(serviceId => {
      return {
        deletedUserId: encryptServiceId(clientZkGroupCipher, serviceId),
      };
    }),
  });
}

export function buildDeleteMemberChange({
  group,
  ourAci,
  serviceId,
}: {
  group: ConversationAttributesType;
  ourAci: AciString;
  serviceId: ServiceIdString;
}): Actions.Params {
  if (!group.secretParams) {
    throw new Error('buildDeleteMemberChange: group was missing secretParams!');
  }
  const clientZkGroupCipher = getClientZkGroupCipher(group.secretParams);
  const uuidCipherTextBuffer = encryptServiceId(clientZkGroupCipher, serviceId);

  const { addMembersBanned, deleteMembersBanned } =
    _maybeBuildAddBannedMemberActions({
      clientZkGroupCipher,
      group,
      ourAci,
      serviceId,
    });

  return toGroupActionsParams({
    version: (group.revision || 0) + 1,
    deleteMembers: [{ deletedUserId: uuidCipherTextBuffer }],
    addMembersBanned,
    deleteMembersBanned,
  });
}

export function buildAddBannedMemberChange({
  serviceId,
  group,
}: {
  serviceId: ServiceIdString;
  group: ConversationAttributesType;
}): Actions.Params {
  if (!group.secretParams) {
    throw new Error(
      'buildAddBannedMemberChange: group was missing secretParams!'
    );
  }
  const clientZkGroupCipher = getClientZkGroupCipher(group.secretParams);
  const userIdCipherText = encryptServiceId(clientZkGroupCipher, serviceId);

  const needsDeleteFromPendingAdminApproval =
    group.pendingAdminApprovalV2?.some(item => item.aci === serviceId);

  return toGroupActionsParams({
    version: (group.revision || 0) + 1,
    addMembersBanned: [
      {
        added: {
          userId: userIdCipherText,
          timestamp: null,
        },
      },
    ],
    deleteMembersPendingAdminApproval: needsDeleteFromPendingAdminApproval
      ? [
          {
            deletedUserId: userIdCipherText,
          },
        ]
      : null,
  });
}

export function buildModifyMemberRoleChange({
  serviceId,
  group,
  role,
}: {
  serviceId: ServiceIdString;
  group: ConversationAttributesType;
  role: number;
}): Actions.Params {
  if (!group.secretParams) {
    throw new Error(
      'buildModifyMemberRoleChange: group was missing secretParams!'
    );
  }

  const clientZkGroupCipher = getClientZkGroupCipher(group.secretParams);
  const userIdCipherText = encryptServiceId(clientZkGroupCipher, serviceId);

  const membership = group.membersV2?.find(member => member.aci === serviceId);
  const onlyAdminsCanAddMemberLabel =
    group.accessControl?.memberLabel ===
    Proto.AccessControl.AccessRequired.ADMINISTRATOR;
  const wasPreviouslyAnAdmin = membership?.role === MemberRole.ADMINISTRATOR;
  const nowNotAnAdmin = role !== MemberRole.ADMINISTRATOR;

  const shouldDropMemberLabel =
    membership?.labelString &&
    onlyAdminsCanAddMemberLabel &&
    wasPreviouslyAnAdmin &&
    nowNotAnAdmin;

  return toGroupActionsParams({
    version: (group.revision || 0) + 1,
    modifyMemberRoles: [
      {
        userId: userIdCipherText,
        role,
      },
    ],
    modifyMemberLabels: shouldDropMemberLabel
      ? [
          {
            userId: userIdCipherText,
            labelEmoji: null,
            labelString: null,
          },
        ]
      : null,
  });
}

export function buildModifyMemberLabelChange({
  serviceId,
  group,
  labelEmoji,
  labelString,
}: {
  serviceId: ServiceIdString;
  group: ConversationAttributesType;
  labelEmoji: string | undefined;
  labelString: string | undefined;
}): Actions.Params {
  const logId = `buildModifyMemberLabelChange(${getConversationIdForLogging(group)})`;

  if (!group.secretParams) {
    throw new Error(`${logId}: group was missing secretParams!`);
  }

  const clientZkGroupCipher = getClientZkGroupCipher(group.secretParams);
  const userIdCipherText = encryptServiceId(clientZkGroupCipher, serviceId);

  let encryptedLabelEmoji: Uint8Array<ArrayBuffer> | null = null;
  let encryptedLabelString: Uint8Array<ArrayBuffer> | null = null;

  if (labelEmoji) {
    const labelEmojiBytes = Bytes.fromString(labelEmoji);

    if (labelEmojiBytes.byteLength > EMOJI_OUTGOING_BYTE_LIMIT) {
      throw new Error(
        `${logId}: plaintext label emoji length (${labelEmojiBytes.byteLength}) is larger than limit (${EMOJI_OUTGOING_BYTE_LIMIT})!`
      );
    }

    encryptedLabelEmoji = encryptGroupBlob(
      clientZkGroupCipher,
      labelEmojiBytes
    );
    if (encryptedLabelEmoji.byteLength > SERVER_EMOJI_BYTE_LIMIT) {
      throw new Error(
        `${logId}: encrypted label emoji length (${encryptedLabelEmoji.byteLength}) is larger than limit (${SERVER_EMOJI_BYTE_LIMIT})!`
      );
    }
  }
  if (labelString) {
    encryptedLabelString = encryptGroupBlob(
      clientZkGroupCipher,
      Bytes.fromString(labelString)
    );
    if (encryptedLabelString.byteLength > SERVER_STRING_BYTE_LIMIT) {
      throw new Error(
        `${logId} encrypted label string length (${encryptedLabelString.byteLength}) is larger than limit (${SERVER_STRING_BYTE_LIMIT})!`
      );
    }
  }

  if (encryptedLabelEmoji && !encryptedLabelString) {
    throw new Error(`${logId} labelEmoji was provided, but not labelString!`);
  }

  return toGroupActionsParams({
    version: (group.revision || 0) + 1,
    modifyMemberLabels: [
      {
        userId: userIdCipherText,
        labelEmoji: encryptedLabelEmoji,
        labelString: encryptedLabelString,
      },
    ],
  });
}

export function buildPromotePendingAdminApprovalMemberChange({
  group,
  aci,
}: {
  group: ConversationAttributesType;
  aci: AciString;
}): Actions.Params {
  if (!group.secretParams) {
    throw new Error(
      'buildAddPendingAdminApprovalMemberChange: group was missing secretParams!'
    );
  }

  const clientZkGroupCipher = getClientZkGroupCipher(group.secretParams);
  const userIdCipherText = encryptServiceId(clientZkGroupCipher, aci);

  return toGroupActionsParams({
    version: (group.revision || 0) + 1,
    promoteMembersPendingAdminApproval: [
      {
        userId: userIdCipherText,
        role: MemberRole.DEFAULT,
      },
    ],
  });
}

export type BuildPromoteMemberChangeOptionsType = Readonly<{
  group: ConversationAttributesType;
  serverPublicParamsBase64: string;
  profileKeyCredentialBase64: string;
  isPendingPniAciProfileKey: boolean;
}>;

export function buildPromoteMemberChange({
  group,
  profileKeyCredentialBase64,
  serverPublicParamsBase64,
  isPendingPniAciProfileKey,
}: BuildPromoteMemberChangeOptionsType): Actions.Params {
  if (!group.secretParams) {
    throw new Error(
      'buildDisappearingMessagesTimerChange: group was missing secretParams!'
    );
  }

  const clientZkProfileCipher = getClientZkProfileOperations(
    serverPublicParamsBase64
  );

  const presentation = createProfileKeyCredentialPresentation(
    clientZkProfileCipher,
    profileKeyCredentialBase64,
    group.secretParams
  );

  return toGroupActionsParams({
    version: (group.revision || 0) + 1,
    ...(isPendingPniAciProfileKey
      ? {
          promoteMembersPendingPniAciProfileKey: [
            {
              presentation,
              userId: null,
              pni: null,
              profileKey: null,
            },
          ],
        }
      : {
          promoteMembersPendingProfileKey: [
            {
              presentation,
              userId: null,
              profileKey: null,
            },
          ],
        }),
  });
}

export function buildTerminateChange(
  group: ConversationAttributesType
): Actions.Params {
  return toGroupActionsParams({
    version: (group.revision || 0) + 1,
    terminateGroup: {},
  });
}

async function uploadGroupChange({
  actions,
  groupId,
  groupPublicParamsBase64,
  groupSecretParamsBase64,
  inviteLinkPassword,
}: {
  actions: Proto.GroupChange.Actions.Params;
  groupId: string;
  groupPublicParamsBase64: string;
  groupSecretParamsBase64: string;
  inviteLinkPassword?: string;
}): Promise<Proto.GroupChangeResponse.Params> {
  const logId = idForLogging(groupId);

  // Ensure we have the credentials we need before attempting GroupsV2 operations
  await maybeFetchNewCredentials();

  return makeRequestWithCredentials({
    logId: `uploadGroupChange/${logId}`,
    publicParams: groupPublicParamsBase64,
    secretParams: groupSecretParamsBase64,
    request: options => modifyGroup(actions, options, inviteLinkPassword),
  });
}

export async function modifyGroupV2({
  conversation,
  usingCredentialsFrom,
  createGroupChange,
  extraConversationsForSend,
  inviteLinkPassword,
  name,
  syncMessageOnly = false,
}: {
  conversation: ConversationModel;
  usingCredentialsFrom: ReadonlyArray<ConversationModel>;
  createGroupChange: () => Promise<Actions.Params | undefined>;
  extraConversationsForSend?: ReadonlyArray<string>;
  inviteLinkPassword?: string;
  name: string;
  syncMessageOnly?: boolean;
}): Promise<void> {
  const logId = `${name}/${conversation.idForLogging()}`;

  if (!getIsGroupV2(conversation.attributes)) {
    throw new Error(
      `modifyGroupV2/${logId}: Called for non-GroupV2 conversation`
    );
  }

  const startTime = Date.now();
  const timeoutTime = startTime + MINUTE;

  const MAX_ATTEMPTS = 5;

  let refreshedCredentials = false;

  for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) {
    log.info(`modifyGroupV2/${logId}: Starting attempt ${attempt}`);
    try {
      // oxlint-disable-next-line no-await-in-loop
      await window.waitForEmptyEventQueue();

      // Fetch profiles for contacts that do not have credentials (or have
      // expired credentials)
      {
        const membersMissingCredentials = usingCredentialsFrom.filter(member =>
          member.hasProfileKeyCredentialExpired()
        );
        const logIds = membersMissingCredentials.map(member =>
          member.idForLogging()
        );

        if (logIds.length !== 0) {
          log.info(
            `modifyGroupV2/${logId}: Fetching profiles for ${logIds.join(', ')}`
          );
        }

        // oxlint-disable-next-line no-await-in-loop
        await Promise.all(
          membersMissingCredentials.map(member => member.getProfiles())
        );
      }

      log.info(`modifyGroupV2/${logId}: Queuing attempt ${attempt}`);

      // oxlint-disable-next-line no-await-in-loop
      await conversation.queueJob('modifyGroupV2', async () => {
        log.info(`modifyGroupV2/${logId}: Running attempt ${attempt}`);

        const actions = await createGroupChange();
        if (!actions) {
          log.warn(
            `modifyGroupV2/${logId}: No change actions. Returning early.`
          );
          return;
        }

        // The new revision has to be exactly one more than the current revision
        //   or it won't upload properly, and it won't apply in maybeUpdateGroup
        const currentRevision = conversation.get('revision');
        const newRevision = actions.version;

        if ((currentRevision || 0) + 1 !== newRevision) {
          throw new Error(
            `modifyGroupV2/${logId}: Revision mismatch - ${currentRevision} to ${newRevision}.`
          );
        }

        const { groupId, secretParams, publicParams } = conversation.attributes;
        strictAssert(groupId, 'modifyGroupV2: missing groupId');
        strictAssert(secretParams, 'modifyGroupV2: missing secretParams');
        strictAssert(publicParams, 'modifyGroupV2: missing publicParams');

        // Upload. If we don't have permission, the server will return an error here.
        const groupChangeResponse = await uploadGroupChange({
          actions,
          groupId,
          groupPublicParamsBase64: publicParams,
          groupSecretParamsBase64: secretParams,
          inviteLinkPassword,
        });
        const { groupChange, groupSendEndorsementsResponse } =
          groupChangeResponse;
        strictAssert(groupChange, 'modifyGroupV2: missing groupChange');

        const groupChangeBuffer = Proto.GroupChange.encode(groupChange);
        const groupChangeBase64 = Bytes.toBase64(groupChangeBuffer);

        // Apply change locally, just like we would with an incoming change. This will
        //   change conversation state and add change notifications to the timeline.
        await maybeUpdateGroup({
          conversation,
          groupChange: {
            base64: groupChangeBase64,
            isTrusted: true,
          },
          newRevision,
        });

        const groupV2Info = conversation.getGroupV2Info({
          includePendingMembers: true,
          extraConversationsForSend,
        });
        strictAssert(groupV2Info, 'missing groupV2Info');

        await conversationJobQueue.add({
          type: conversationQueueJobEnum.enum.GroupUpdate,
          conversationId: conversation.id,
          groupChangeBase64,
          recipients: syncMessageOnly ? [] : groupV2Info.members.slice(),
          revision: groupV2Info.revision,
        });

        // Read this after `maybeUpdateGroup` because it may have been updated
        const { membersV2 } = conversation.attributes;
        strictAssert(membersV2, 'modifyGroupV2: missing membersV2');

        // If we are no longer a member - endorsement won't be present
        if (Bytes.isNotEmpty(groupSendEndorsementsResponse)) {
          try {
            log.info(`modifyGroupV2/${logId}: Saving group endorsements`);

            const groupEndorsementData = decodeGroupSendEndorsementsResponse({
              groupId,
              groupSendEndorsementsResponse,
              groupSecretParamsBase64: secretParams,
              groupMembersV2: membersV2,
            });

            await DataWriter.replaceAllEndorsementsForGroup(
              groupEndorsementData
            );
          } catch (error) {
            log.warn(
              `modifyGroupV2/${logId}: Problem saving group endorsements ${Errors.toLogFormat(error)}`
            );
          }
        }
      });

      // If we've gotten here with no error, we exit!
      log.info(
        `modifyGroupV2/${logId}: Update complete, with attempt ${attempt}!`
      );
      break;
    } catch (error) {
      if (error.code === 409 && Date.now() <= timeoutTime) {
        log.info(
          `modifyGroupV2/${logId}: Conflict while updating. Trying again...`
        );

        // oxlint-disable-next-line no-await-in-loop
        await conversation.fetchLatestGroupV2Data({ force: true });
      } else if (error.code === 400 && !refreshedCredentials) {
        const logIds = usingCredentialsFrom.map(member =>
          member.idForLogging()
        );
        if (logIds.length !== 0) {
          log.warn(
            `modifyGroupV2/${logId}: Profile key credentials were not ` +
              `up-to-date. Updating profiles for ${logIds.join(', ')} and retrying`
          );
        }

        for (const member of usingCredentialsFrom) {
          member.set({
            profileKeyCredential: null,
            profileKeyCredentialExpiration: null,
          });
        }

        // oxlint-disable-next-line no-await-in-loop
        await Promise.all(
          usingCredentialsFrom.map(member => member.getProfiles())
        );

        // Fetch credentials only once
        refreshedCredentials = true;
      } else if (error.code === 409) {
        log.warn(
          `modifyGroupV2/${logId}: Conflict while updating. Timed out; not retrying.`
        );
        // We don't wait here because we're breaking out of the loop immediately.
        void conversation.fetchLatestGroupV2Data({ force: true });
        throw error;
      } else {
        const errorString = Errors.toLogFormat(error);
        log.error(`modifyGroupV2/${logId}: Error updating: ${errorString}`);
        throw error;
      }
    }
  }
}

// Utility

export function idForLogging(groupId: string | undefined): string {
  return `groupv2(${groupId})`;
}

export function deriveGroupFields(
  masterKey: Uint8Array<ArrayBuffer>
): GroupFields {
  if (masterKey.length !== MASTER_KEY_LENGTH) {
    throw new Error(
      `deriveGroupFields: masterKey had length ${masterKey.length}, ` +
        `expected ${MASTER_KEY_LENGTH}`
    );
  }

  const cacheKey = Bytes.toBase64(masterKey);
  const cached = groupFieldsCache.get(cacheKey);
  if (cached) {
    return cached;
  }

  log.info('deriveGroupFields: cache miss');

  const secretParams = deriveGroupSecretParams(masterKey);
  const publicParams = deriveGroupPublicParams(secretParams);
  const id = deriveGroupID(secretParams);

  const fresh = {
    id,
    secretParams,
    publicParams,
  };
  groupFieldsCache.set(cacheKey, fresh);
  return fresh;
}

async function makeRequestWithCredentials<T>({
  logId,
  publicParams,
  secretParams,
  request,
}: {
  logId: string;
  publicParams: string;
  secretParams: string;
  request: (options: GroupCredentialsType) => Promise<T>;
}): Promise<T> {
  const groupCredentials = getCheckedGroupCredentialsForToday(
    `makeRequestWithCredentials/${logId}`
  );

  log.info(`makeRequestWithCredentials/${logId}: starting`);

  const todayOptions = getGroupCredentials({
    authCredentialBase64: groupCredentials.today.credential,
    groupPublicParamsBase64: publicParams,
    groupSecretParamsBase64: secretParams,
    serverPublicParamsBase64: window.getServerPublicParams(),
  });

  return request(todayOptions);
}

export async function fetchMembershipProof({
  publicParams,
  secretParams,
}: {
  publicParams: string;
  secretParams: string;
}): Promise<string | undefined> {
  // Ensure we have the credentials we need before attempting GroupsV2 operations
  await maybeFetchNewCredentials();

  if (!publicParams) {
    throw new Error('fetchMembershipProof: group was missing publicParams!');
  }
  if (!secretParams) {
    throw new Error('fetchMembershipProof: group was missing secretParams!');
  }

  const response = await makeRequestWithCredentials({
    logId: 'fetchMembershipProof',
    publicParams,
    secretParams,
    request: options => getExternalGroupCredential(options),
  });
  return dropNull(response.token);
}

// Creating a group

export async function createGroupV2(
  options: Readonly<{
    name: string;
    avatar: undefined | Uint8Array<ArrayBuffer>;
    expireTimer: undefined | DurationInSeconds;
    conversationIds: ReadonlyArray<string>;
    avatars?: ReadonlyArray<AvatarDataType>;
    refreshedCredentials?: boolean;
  }>
): Promise<ConversationModel> {
  const {
    name,
    avatar,
    expireTimer,
    conversationIds,
    avatars,
    refreshedCredentials = false,
  } = options;

  // Ensure we have the credentials we need before attempting GroupsV2 operations
  await maybeFetchNewCredentials();

  const masterKeyBuffer = getRandomBytes(32);
  const fields = deriveGroupFields(masterKeyBuffer);

  const groupId = Bytes.toBase64(fields.id);
  const logId = `groupv2(${groupId})`;

  const masterKey = Bytes.toBase64(masterKeyBuffer);
  const secretParams = Bytes.toBase64(fields.secretParams);
  const publicParams = Bytes.toBase64(fields.publicParams);

  const ourAci = itemStorage.user.getCheckedAci();

  const ourConversation =
    window.ConversationController.getOurConversationOrThrow();
  if (ourConversation.hasProfileKeyCredentialExpired()) {
    log.info(`createGroupV2/${logId}: fetching our own credentials`);
    await ourConversation.getProfiles();
  }

  const membersV2: Array<GroupV2MemberType> = [
    {
      aci: ourAci,
      role: MemberRole.ADMINISTRATOR,
      joinedAtVersion: 0,
    },
  ];
  const pendingMembersV2: Array<GroupV2PendingMemberType> = [];

  let uploadedAvatar: undefined | UploadedAvatarType;

  await Promise.all([
    ...conversationIds.map(async conversationId => {
      const contact = window.ConversationController.get(conversationId);
      if (!contact) {
        assertDev(
          false,
          `createGroupV2/${logId}: missing local contact, skipping`
        );
        return;
      }

      const contactServiceId = contact.getServiceId();
      if (!contactServiceId) {
        assertDev(
          false,
          `createGroupV2/${logId}: missing service id; skipping`
        );
        return;
      }

      // Refresh our local data to be sure
      if (contact.hasProfileKeyCredentialExpired()) {
        await contact.getProfiles();
      }

      if (contact.get('profileKey') && contact.get('profileKeyCredential')) {
        strictAssert(isAciString(contactServiceId), 'profile key without ACI');
        membersV2.push({
          aci: contactServiceId,
          role: MemberRole.DEFAULT,
          joinedAtVersion: 0,
        });
      } else {
        pendingMembersV2.push({
          addedByUserId: ourAci,
          serviceId: contactServiceId,
          timestamp: Date.now(),
          role: MemberRole.DEFAULT,
        });
      }
    }),
    (async () => {
      if (!avatar) {
        return;
      }

      uploadedAvatar = await uploadAvatar({
        data: avatar,
        logId,
        publicParams,
        secretParams,
      });
    })(),
  ]);

  if (membersV2.length + pendingMembersV2.length > getGroupSizeHardLimit()) {
    throw new Error(
      `createGroupV2/${logId}: Too many members! Member count: ${membersV2.length}, Pending member count: ${pendingMembersV2.length}`
    );
  }

  const protoAndConversationAttributes = {
    name,

    // Core GroupV2 info
    revision: 0,
    publicParams,
    secretParams,

    // GroupV2 state
    accessControl: {
      attributes: AccessRequired.MEMBER,
      members: AccessRequired.MEMBER,
      addFromInviteLink: AccessRequired.UNSATISFIABLE,
      memberLabel: AccessRequired.MEMBER,
    },
    membersV2,
    pendingMembersV2,
  };

  const groupProto = buildGroupProto({
    id: groupId,
    avatarUrl: uploadedAvatar?.key,
    ...protoAndConversationAttributes,
  });

  try {
    const groupResponse = await makeRequestWithCredentials({
      logId: `createGroupV2/${logId}`,
      publicParams,
      secretParams,
      request: requestOptions => createGroup(groupProto, requestOptions),
    });

    const { groupSendEndorsementsResponse } = groupResponse;
    strictAssert(
      Bytes.isNotEmpty(groupSendEndorsementsResponse),
      'missing groupSendEndorsementsResponse'
    );

    try {
      const groupEndorsementData = decodeGroupSendEndorsementsResponse({
        groupId,
        groupSendEndorsementsResponse,
        groupSecretParamsBase64: secretParams,
        groupMembersV2: membersV2,
      });

      await DataWriter.replaceAllEndorsementsForGroup(groupEndorsementData);
    } catch (error) {
      log.warn(
        `createGroupV2/${logId}: Problem saving group endorsements ${Errors.toLogFormat(error)}`
      );
    }
  } catch (error) {
    if (!(error instanceof HTTPError)) {
      throw error;
    }
    if (error.code !== 400 || refreshedCredentials) {
      throw error;
    }

    const logIds = conversationIds.map(conversationId => {
      const contact = window.ConversationController.get(conversationId);
      if (!contact) {
        return;
      }
      contact.set({
        profileKeyCredential: null,
        profileKeyCredentialExpiration: null,
      });

      return contact.idForLogging();
    });

    log.warn(
      `createGroupV2/${logId}: Profile key credentials were not ` +
        `up-to-date. Updating profiles for ${logIds.join(', ')} and retrying`
    );

    return createGroupV2({
      ...options,
      refreshedCredentials: true,
    });
  }

  let avatarAttribute: ConversationAttributesType['avatar'];
  if (uploadedAvatar) {
    try {
      avatarAttribute = {
        url: uploadedAvatar.key,
        ...(await writeNewAttachmentData(uploadedAvatar.data)),
        hash: uploadedAvatar.hash,
      };
    } catch (err) {
      log.warn(
        `createGroupV2/${logId}: avatar failed to save to disk. Continuing on`
      );
    }
  }

  const now = Date.now();

  const conversation = await window.ConversationController.getOrCreateAndWait(
    groupId,
    'group',
    {
      ...protoAndConversationAttributes,
      active_at: now,
      addedBy: ourAci,
      avatar: avatarAttribute,
      avatars,
      groupVersion: 2,
      masterKey,
      profileSharing: true,
      timestamp: now,
      needsStorageServiceSync: true,
    }
  );

  conversation.captureChange('createGroupV2');

  const timestamp = Date.now();
  const groupV2Info = conversation.getGroupV2Info({
    includePendingMembers: true,
  });
  strictAssert(groupV2Info, 'missing groupV2Info');

  await conversationJobQueue.add({
    type: conversationQueueJobEnum.enum.GroupUpdate,
    conversationId: conversation.id,
    recipients: groupV2Info.members.slice(),
    revision: groupV2Info.revision,
  });

  const createdTheGroupMessage = new MessageModel({
    ...generateMessageId(incrementMessageCounter()),

    schemaVersion: MAX_MESSAGE_SCHEMA,
    type: 'group-v2-change',
    sourceServiceId: ourAci,
    conversationId: conversation.id,
    readStatus: ReadStatus.Read,
    received_at_ms: timestamp,
    timestamp,
    seenStatus: SeenStatus.Seen,
    sent_at: timestamp,
    groupV2Change: {
      from: ourAci,
      details: [{ type: 'create' }],
    },
  });
  await window.MessageCache.saveMessage(createdTheGroupMessage, {
    forceSave: true,
  });
  window.MessageCache.register(createdTheGroupMessage);
  drop(conversation.onNewMessage(createdTheGroupMessage));

  if (expireTimer) {
    await conversation.updateExpirationTimer(expireTimer, {
      reason: 'createGroupV2',
      version: undefined,
    });
  }

  return conversation;
}

// Migrating a group

export async function hasV1GroupBeenMigrated(
  conversation: ConversationModel
): Promise<boolean> {
  const logId = conversation.idForLogging();
  const isGroupV1 = getIsGroupV1(conversation.attributes);
  if (!isGroupV1) {
    log.warn(
      `checkForGV2Existence/${logId}: Called for non-GroupV1 conversation!`
    );
    return false;
  }

  // Ensure we have the credentials we need before attempting GroupsV2 operations
  await maybeFetchNewCredentials();

  const groupId = conversation.get('groupId');
  if (!groupId) {
    throw new Error(`checkForGV2Existence/${logId}: No groupId!`);
  }

  const idBuffer = Bytes.fromBinary(groupId);
  const masterKeyBuffer = deriveMasterKeyFromGroupV1(idBuffer);
  const fields = deriveGroupFields(masterKeyBuffer);

  try {
    await makeRequestWithCredentials({
      logId: `getGroup/${logId}`,
      publicParams: Bytes.toBase64(fields.publicParams),
      secretParams: Bytes.toBase64(fields.secretParams),
      request: options => getGroup(options),
    });
    return true;
  } catch (error) {
    const { code } = error;
    return code !== GROUP_NONEXISTENT_CODE;
  }
}

export function maybeDeriveGroupV2Id(conversation: ConversationModel): boolean {
  const isGroupV1 = getIsGroupV1(conversation.attributes);
  const groupV1Id = conversation.get('groupId');
  const derived = conversation.get('derivedGroupV2Id');

  if (!isGroupV1 || !groupV1Id || derived) {
    return false;
  }

  const v1IdBuffer = Bytes.fromBinary(groupV1Id);
  const masterKeyBuffer = deriveMasterKeyFromGroupV1(v1IdBuffer);
  const fields = deriveGroupFields(masterKeyBuffer);
  const derivedGroupV2Id = Bytes.toBase64(fields.id);

  conversation.set({
    derivedGroupV2Id,
  });

  return true;
}

type WrappedGroupChangeType = Readonly<{
  base64: string;
  isTrusted: boolean;
}>;

type MigratePropsType = Readonly<{
  conversation: ConversationModel;
  newRevision?: number;
  receivedAt?: number;
  sentAt?: number;
  groupChange?: WrappedGroupChangeType;
}>;

async function isGroupEligibleToMigrate(
  conversation: ConversationModel
): Promise<boolean> {
  if (!getIsGroupV1(conversation.attributes)) {
    return false;
  }

  const areWeMember = conversation.areWeAGroupMember();
  if (!areWeMember) {
    return false;
  }

  const members = conversation.get('members') || [];
  for (let i = 0, max = members.length; i < max; i += 1) {
    const identifier = members[i];
    const contact = window.ConversationController.get(identifier);

    if (!contact) {
      return false;
    }
    if (!contact.getServiceId()) {
      return false;
    }
  }

  return true;
}

export async function getGroupMigrationMembers(
  conversation: ConversationModel
): Promise<{
  droppedGV2MemberIds: Array<string>;
  membersV2: Array<GroupV2MemberType>;
  pendingMembersV2: Array<GroupV2PendingMemberType>;
  previousGroupV1Members: Array<string>;
}> {
  const logId = conversation.idForLogging();

  const ourConversationId =
    window.ConversationController.getOurConversationId();
  if (!ourConversationId) {
    throw new Error(
      `getGroupMigrationMembers/${logId}: Couldn't fetch our own conversationId!`
    );
  }

  const ourAci = itemStorage.user.getCheckedAci();

  let areWeMember = false;
  let areWeInvited = false;

  const previousGroupV1Members = conversation.get('members') || [];
  const now = Date.now();
  const memberLookup: Record<string, boolean> = {};
  const membersV2: Array<GroupV2MemberType> = compact(
    await Promise.all(
      previousGroupV1Members.map(async e164 => {
        const contact = window.ConversationController.get(e164);

        if (!contact) {
          throw new Error(
            `getGroupMigrationMembers/${logId}: membersV2 - missing local contact for ${e164}, skipping.`
          );
        }
        if (
          !isMe(contact.attributes) &&
          window.Flags.GV2_MIGRATION_DISABLE_ADD
        ) {
          log.warn(
            `getGroupMigrationMembers/${logId}: membersV2 - skipping ${e164} due to GV2_MIGRATION_DISABLE_ADD flag`
          );
          return null;
        }

        const contactAci = contact.getAci();
        if (!contactAci) {
          log.warn(
            `getGroupMigrationMembers/${logId}: membersV2 - missing aci for ${e164}, skipping.`
          );
          return null;
        }

        if (!contact.get('profileKey')) {
          log.warn(
            `getGroupMigrationMembers/${logId}: membersV2 - missing profileKey for member ${e164}, skipping.`
          );
          return null;
        }

        // Refresh our local data to be sure
        if (!contact.get('profileKeyCredential')) {
          await contact.getProfiles();
        }

        if (!contact.get('profileKeyCredential')) {
          log.warn(
            `getGroupMigrationMembers/${logId}: membersV2 - no profileKeyCredential for ${e164}, skipping.`
          );
          return null;
        }

        const conversationId = contact.id;

        if (conversationId === ourConversationId) {
          areWeMember = true;
        }

        memberLookup[conversationId] = true;

        return {
          aci: contactAci,
          role: MemberRole.ADMINISTRATOR,
          joinedAtVersion: 0,
        };
      })
    )
  );

  const droppedGV2MemberIds: Array<string> = [];
  const pendingMembersV2: Array<GroupV2PendingMemberType> = compact(
    (previousGroupV1Members || []).map(e164 => {
      const contact = window.ConversationController.get(e164);

      if (!contact) {
        throw new Error(
          `getGroupMigrationMembers/${logId}: pendingMembersV2 - missing local contact for ${e164}, skipping.`
        );
      }

      const conversationId = contact.id;
      // If we've already added this contact above, we'll skip here
      if (memberLookup[conversationId]) {
        return null;
      }

      if (
        !isMe(contact.attributes) &&
        window.Flags.GV2_MIGRATION_DISABLE_INVITE
      ) {
        log.warn(
          `getGroupMigrationMembers/${logId}: pendingMembersV2 - skipping ${e164} due to GV2_MIGRATION_DISABLE_INVITE flag`
        );
        droppedGV2MemberIds.push(conversationId);
        return null;
      }

      const contactUuid = contact.getServiceId();
      if (!contactUuid) {
        log.warn(
          `getGroupMigrationMembers/${logId}: pendingMembersV2 - missing uuid for ${e164}, skipping.`
        );
        droppedGV2MemberIds.push(conversationId);
        return null;
      }

      if (conversationId === ourConversationId) {
        areWeInvited = true;
      }

      return {
        serviceId: contactUuid,
        timestamp: now,
        addedByUserId: ourAci,
        role: MemberRole.ADMINISTRATOR,
      };
    })
  );

  if (!areWeMember) {
    throw new Error(`getGroupMigrationMembers/${logId}: We are not a member!`);
  }
  if (areWeInvited) {
    throw new Error(`getGroupMigrationMembers/${logId}: We are invited!`);
  }

  return {
    droppedGV2MemberIds,
    membersV2,
    pendingMembersV2,
    previousGroupV1Members,
  };
}

// This is called when the user chooses to migrate a GroupV1. It will update the server,
//   then let all members know about the new group.
export async function initiateMigrationToGroupV2(
  conversation: ConversationModel
): Promise<void> {
  // Ensure we have the credentials we need before attempting GroupsV2 operations
  await maybeFetchNewCredentials();

  try {
    await conversation.queueJob('initiateMigrationToGroupV2', async () => {
      const isEligible = await isGroupEligibleToMigrate(conversation);
      const previousGroupV1Id = conversation.get('groupId');

      if (!isEligible || !previousGroupV1Id) {
        throw new Error(
          `initiateMigrationToGroupV2: conversation is not eligible to migrate! ${conversation.idForLogging()}`
        );
      }

      const groupV1IdBuffer = Bytes.fromBinary(previousGroupV1Id);
      const masterKeyBuffer = deriveMasterKeyFromGroupV1(groupV1IdBuffer);
      const fields = deriveGroupFields(masterKeyBuffer);

      const groupId = Bytes.toBase64(fields.id);
      const logId = `groupv2(${groupId})`;
      log.info(
        `initiateMigrationToGroupV2/${logId}: Migrating from ${conversation.idForLogging()}`
      );

      const masterKey = Bytes.toBase64(masterKeyBuffer);
      const secretParams = Bytes.toBase64(fields.secretParams);
      const publicParams = Bytes.toBase64(fields.publicParams);

      const ourConversationId =
        window.ConversationController.getOurConversationId();
      if (!ourConversationId) {
        throw new Error(
          `initiateMigrationToGroupV2/${logId}: Couldn't fetch our own conversationId!`
        );
      }
      const ourConversation =
        window.ConversationController.get(ourConversationId);
      if (!ourConversation) {
        throw new Error(
          `initiateMigrationToGroupV2/${logId}: cannot get our own conversation. Cannot migrate`
        );
      }

      const {
        membersV2,
        pendingMembersV2,
        droppedGV2MemberIds,
        previousGroupV1Members,
      } = await getGroupMigrationMembers(conversation);

      if (
        membersV2.length + pendingMembersV2.length >
        getGroupSizeHardLimit()
      ) {
        throw new Error(
          `initiateMigrationToGroupV2/${logId}: Too many members! Member count: ${membersV2.length}, Pending member count: ${pendingMembersV2.length}`
        );
      }

      // Note: A few group elements don't need to change here:
      //   - name
      //   - expireTimer
      let avatarAttribute: ConversationAttributesType['avatar'];

      const { avatar: currentAvatar } = conversation.attributes;
      if (currentAvatar?.path) {
        const avatarData = await readAttachmentData(currentAvatar);
        const { hash, key } = await uploadAvatar({
          logId,
          publicParams,
          secretParams,
          data: avatarData,
        });
        avatarAttribute = {
          ...currentAvatar,
          url: key,
          hash,
        };
      }

      const newAttributes = {
        ...conversation.attributes,
        avatar: avatarAttribute,

        // Core GroupV2 info
        revision: 0,
        groupId,
        groupVersion: 2,
        masterKey,
        publicParams,
        secretParams,

        // GroupV2 state
        accessControl: {
          attributes: AccessRequired.MEMBER,
          members: AccessRequired.MEMBER,
          addFromInviteLink: AccessRequired.UNSATISFIABLE,
          memberLabel: AccessRequired.MEMBER,
        },
        membersV2,
        pendingMembersV2,

        // Capture previous GroupV1 data for future use
        previousGroupV1Id,
        previousGroupV1Members,

        // Clear storage ID, since we need to start over on the storage service
        storageID: undefined,

        // Clear obsolete data
        derivedGroupV2Id: undefined,
        members: undefined,
      };

      const groupProto = buildGroupProto({
        ...newAttributes,
        avatarUrl: avatarAttribute?.url,
      });

      let groupSendEndorsementsResponse:
        | Uint8Array<ArrayBuffer>
        | null
        | undefined;
      try {
        const groupResponse = await makeRequestWithCredentials({
          logId: `initiateMigrationToGroupV2/${logId}`,
          publicParams,
          secretParams,
          request: options => createGroup(groupProto, options),
        });

        groupSendEndorsementsResponse =
          groupResponse.groupSendEndorsementsResponse;
      } catch (error) {
        log.error(
          `initiateMigrationToGroupV2/${logId}: Error creating group:`,
          Errors.toLogFormat(error)
        );

        throw error;
      }

      const groupChangeMessages: Array<GroupChangeMessageType> = [];
      groupChangeMessages.push({
        type: 'group-v1-migration',
        groupMigration: {
          areWeInvited: false,
          droppedMemberIds: droppedGV2MemberIds,
          invitedMembers: pendingMembersV2.map(
            ({ serviceId: uuid, ...rest }) => {
              return { ...rest, uuid };
            }
          ),
        },
        readStatus: ReadStatus.Read,
        seenStatus: SeenStatus.Seen,
      });

      await updateGroup({
        conversation,
        updates: {
          newAttributes,
          groupChangeMessages,
          newProfileKeys: new Map(),
        },
      });

      if (itemStorage.blocked.isGroupBlocked(previousGroupV1Id)) {
        await itemStorage.blocked.addBlockedGroup(groupId);
      }

      // Save these most recent updates to conversation
      await updateConversation(conversation.attributes);

      strictAssert(
        Bytes.isNotEmpty(groupSendEndorsementsResponse),
        'missing groupSendEndorsementsResponse'
      );

      try {
        const groupEndorsementData = decodeGroupSendEndorsementsResponse({
          groupId,
          groupSendEndorsementsResponse,
          groupSecretParamsBase64: secretParams,
          groupMembersV2: membersV2,
        });

        await DataWriter.replaceAllEndorsementsForGroup(groupEndorsementData);
      } catch (error) {
        log.warn(
          `initiateMigrationToGroupV2/${logId}: Problem saving group endorsements ${Errors.toLogFormat(error)}`
        );
      }
    });
  } catch (error) {
    const logId = conversation.idForLogging();
    if (!getIsGroupV1(conversation.attributes)) {
      throw error;
    }

    const alreadyMigrated = await hasV1GroupBeenMigrated(conversation);
    if (!alreadyMigrated) {
      log.error(
        `initiateMigrationToGroupV2/${logId}: Group has not already been migrated, re-throwing error`
      );
      throw error;
    }

    await respondToGroupV2Migration({
      conversation,
    });

    return;
  }

  const groupV2Info = conversation.getGroupV2Info({
    includePendingMembers: true,
  });
  strictAssert(groupV2Info, 'missing groupV2Info');

  await conversationJobQueue.add({
    type: conversationQueueJobEnum.enum.GroupUpdate,
    conversationId: conversation.id,
    recipients: groupV2Info.members.slice(),
    revision: groupV2Info.revision,
  });
}

export async function waitThenRespondToGroupV2Migration(
  options: MigratePropsType
): Promise<void> {
  // First wait to process all incoming messages on the websocket
  await window.waitForEmptyEventQueue();

  // Then wait to process all outstanding messages for this conversation
  const { conversation } = options;

  await conversation.queueJob('waitThenRespondToGroupV2Migration', async () => {
    try {
      // And finally try to migrate the group
      await respondToGroupV2Migration(options);
    } catch (error) {
      log.error(
        `waitThenRespondToGroupV2Migration/${conversation.idForLogging()}: respondToGroupV2Migration failure:`,
        Errors.toLogFormat(error)
      );
    }
  });
}

function buildMigrationBubble(
  previousGroupV1MembersIds: ReadonlyArray<string>,
  newAttributes: ConversationAttributesType
): GroupChangeMessageType {
  const ourAci = itemStorage.user.getCheckedAci();
  const ourPni = itemStorage.user.getPni();
  const ourConversationId =
    window.ConversationController.getOurConversationId();

  // Assemble items to commemorate this event for the timeline..
  const combinedConversationIds: Array<string> = [
    ...(newAttributes.membersV2 || []).map(item => item.aci),
    ...(newAttributes.pendingMembersV2 || []).map(item => item.serviceId),
  ].map(serviceId => {
    const conversation = window.ConversationController.lookupOrCreate({
      serviceId,
      reason: 'buildMigrationBubble',
    });
    strictAssert(conversation, `Conversation not found for ${serviceId}`);
    return conversation.id;
  });
  const droppedMemberIds: Array<string> = difference(
    previousGroupV1MembersIds,
    combinedConversationIds
  ).filter(id => id && id !== ourConversationId);
  const invitedMembers = (newAttributes.pendingMembersV2 || []).filter(
    item => item.serviceId !== ourAci && !(ourPni && item.serviceId === ourPni)
  );

  const areWeInvited = (newAttributes.pendingMembersV2 || []).some(
    item => item.serviceId === ourAci || (ourPni && item.serviceId === ourPni)
  );

  return {
    type: 'group-v1-migration',
    groupMigration: {
      areWeInvited,
      invitedMembers: invitedMembers.map(({ serviceId: uuid, ...rest }) => {
        return { ...rest, uuid };
      }),
      droppedMemberIds,
    },
  };
}

function getBasicMigrationBubble(): GroupChangeMessageType {
  return {
    type: 'group-v1-migration',
    groupMigration: {
      areWeInvited: false,
      invitedMembers: [],
      droppedMemberIds: [],
    },
  };
}

export async function joinGroupV2ViaLinkAndMigrate({
  approvalRequired,
  conversation,
  inviteLinkPassword,
  revision,
}: {
  approvalRequired: boolean;
  conversation: ConversationModel;
  inviteLinkPassword: string;
  revision: number;
}): Promise<void> {
  const isGroupV1 = getIsGroupV1(conversation.attributes);
  const previousGroupV1Id = conversation.get('groupId');

  if (!isGroupV1 || !previousGroupV1Id) {
    throw new Error(
      `joinGroupV2ViaLinkAndMigrate: Conversation is not GroupV1! ${conversation.idForLogging()}`
    );
  }

  // Derive GroupV2 fields
  const groupV1IdBuffer = Bytes.fromBinary(previousGroupV1Id);
  const masterKeyBuffer = deriveMasterKeyFromGroupV1(groupV1IdBuffer);
  const fields = deriveGroupFields(masterKeyBuffer);

  const groupId = Bytes.toBase64(fields.id);
  const logId = idForLogging(groupId);
  log.info(
    `joinGroupV2ViaLinkAndMigrate/${logId}: Migrating from ${conversation.idForLogging()}`
  );

  const masterKey = Bytes.toBase64(masterKeyBuffer);
  const secretParams = Bytes.toBase64(fields.secretParams);
  const publicParams = Bytes.toBase64(fields.publicParams);

  // A mini-migration, which will not show dropped/invited members
  const newAttributes = {
    ...conversation.attributes,

    // Core GroupV2 info
    revision,
    groupId,
    groupVersion: 2,
    masterKey,
    publicParams,
    secretParams,
    groupInviteLinkPassword: inviteLinkPassword,

    addedBy: undefined,
    left: true,

    // Capture previous GroupV1 data for future use
    previousGroupV1Id: conversation.get('groupId'),
    previousGroupV1Members: conversation.get('members'),

    // Clear storage ID, since we need to start over on the storage service
    storageID: undefined,

    // Clear obsolete data
    derivedGroupV2Id: undefined,
    members: undefined,
  };
  const groupChangeMessages: Array<GroupChangeMessageType> = [
    {
      type: 'group-v1-migration',
      groupMigration: {
        areWeInvited: false,
        invitedMembers: [],
        droppedMemberIds: [],
      },
    },
  ];
  await updateGroup({
    conversation,
    updates: {
      newAttributes,
      groupChangeMessages,
      newProfileKeys: new Map(),
    },
  });

  // Now things are set up, so we can go through normal channels
  await conversation.joinGroupV2ViaLink({
    inviteLinkPassword,
    approvalRequired,
  });
}

// This may be called from storage service, an out-of-band check, or an incoming message.
//   If this is kicked off via an incoming message, we want to do the right thing and hit
//   the log endpoint - the parameters beyond conversation are needed in that scenario.
export async function respondToGroupV2Migration({
  conversation,
  groupChange,
  newRevision,
  receivedAt,
  sentAt,
}: MigratePropsType): Promise<void> {
  // Ensure we have the credentials we need before attempting GroupsV2 operations
  await maybeFetchNewCredentials();

  const isGroupV1 = getIsGroupV1(conversation.attributes);
  const previousGroupV1Id = conversation.get('groupId');

  if (!isGroupV1 || !previousGroupV1Id) {
    throw new Error(
      `respondToGroupV2Migration: Conversation is not GroupV1! ${conversation.idForLogging()}`
    );
  }

  const ourAci = itemStorage.user.getCheckedAci();
  const wereWePreviouslyAMember = conversation.hasMember(ourAci);

  // Derive GroupV2 fields
  const groupV1IdBuffer = Bytes.fromBinary(previousGroupV1Id);
  const masterKeyBuffer = deriveMasterKeyFromGroupV1(groupV1IdBuffer);
  const fields = deriveGroupFields(masterKeyBuffer);

  const groupId = Bytes.toBase64(fields.id);
  const logId = idForLogging(groupId);
  log.info(
    `respondToGroupV2Migration/${logId}: Migrating from ${conversation.idForLogging()}`
  );

  const masterKey = Bytes.toBase64(masterKeyBuffer);
  const secretParams = Bytes.toBase64(fields.secretParams);
  const publicParams = Bytes.toBase64(fields.publicParams);

  const previousGroupV1Members = conversation.get('members');
  const previousGroupV1MembersIds = conversation.getMemberIds();

  // Skeleton of the new group state - not useful until we add the group's server state
  const attributes = {
    ...conversation.attributes,

    // Core GroupV2 info
    revision: 0,
    groupId,
    groupVersion: 2,
    masterKey,
    publicParams,
    secretParams,

    // Capture previous GroupV1 data for future use
    previousGroupV1Id,
    previousGroupV1Members,

    // Clear storage ID, since we need to start over on the storage service
    storageID: undefined,

    // Clear obsolete data
    derivedGroupV2Id: undefined,
    members: undefined,
  };

  let firstGroupState: Proto.Group.Params | null | undefined;
  let groupSendEndorsementsResponse: Uint8Array<ArrayBuffer> | null | undefined;

  try {
    const fetchedAt = Date.now();
    const response: GroupLogResponseType = await makeRequestWithCredentials({
      logId: `getGroupLog/${logId}`,
      publicParams,
      secretParams,
      request: options =>
        getGroupLog(
          {
            startVersion: 0,
            includeFirstState: true,
            includeLastState: false,
            maxSupportedChangeEpoch: SUPPORTED_CHANGE_EPOCH,
            cachedEndorsementsExpiration: null, // we won't have them here
          },
          options
        ),
    });
    setLastSuccessfulGroupFetch(conversation.id, fetchedAt);

    // Attempt to start with the first group state, only later processing future updates
    firstGroupState = response?.changes?.groupChanges?.[0]?.groupState;
    groupSendEndorsementsResponse = response.groupSendEndorsementsResponse;
  } catch (error) {
    if (error.code === GROUP_ACCESS_DENIED_CODE) {
      log.info(
        `respondToGroupV2Migration/${logId}: Failed to access log endpoint; fetching full group state`
      );
      try {
        const fetchedAt = Date.now();
        const groupResponse = await makeRequestWithCredentials({
          logId: `getGroup/${logId}`,
          publicParams,
          secretParams,
          request: options => getGroup(options),
        });
        setLastSuccessfulGroupFetch(conversation.id, fetchedAt);

        firstGroupState = groupResponse.group;
        groupSendEndorsementsResponse =
          groupResponse.groupSendEndorsementsResponse;
      } catch (secondError) {
        if (secondError.code === GROUP_ACCESS_DENIED_CODE) {
          log.info(
            `respondToGroupV2Migration/${logId}: Failed to access state endpoint; user is no longer part of group`
          );

          if (itemStorage.blocked.isGroupBlocked(previousGroupV1Id)) {
            await itemStorage.blocked.addBlockedGroup(groupId);
          }

          if (wereWePreviouslyAMember) {
            log.info(
              `respondToGroupV2Migration/${logId}: Upgrading group with migration/removed events`
            );
            const ourNumber = itemStorage.user.getNumber();
            await updateGroup({
              conversation,
              receivedAt,
              sentAt,
              updates: {
                newAttributes: {
                  // Because we're using attributes here, we upgrade this to a v2 group
                  ...attributes,
                  addedBy: undefined,
                  left: true,
                  members: (conversation.get('members') || []).filter(
                    item => item !== ourAci && item !== ourNumber
                  ),
                },
                groupChangeMessages: [
                  {
                    ...getBasicMigrationBubble(),
                    readStatus: ReadStatus.Read,
                    seenStatus: SeenStatus.Seen,
                  },
                  {
                    type: 'group-v2-change',
                    groupV2Change: {
                      details: [
                        {
                          type: 'member-remove' as const,
                          aci: ourAci,
                        },
                      ],
                    },
                    readStatus: ReadStatus.Read,
                    seenStatus: SeenStatus.Unseen,
                  },
                ],
                newProfileKeys: new Map(),
              },
            });
            return;
          }

          log.info(
            `respondToGroupV2Migration/${logId}: Upgrading group with migration event; no removed event`
          );
          await updateGroup({
            conversation,
            receivedAt,
            sentAt,
            updates: {
              newAttributes: attributes,
              groupChangeMessages: [
                {
                  ...getBasicMigrationBubble(),
                  readStatus: ReadStatus.Read,
                  seenStatus: SeenStatus.Seen,
                },
              ],
              newProfileKeys: new Map(),
            },
          });
          return;
        }
        throw secondError;
      }
    } else {
      throw error;
    }
  }
  if (!firstGroupState) {
    throw new Error(
      `respondToGroupV2Migration/${logId}: Couldn't get a first group state!`
    );
  }

  const groupState = decryptGroupState(
    firstGroupState,
    attributes.secretParams,
    logId
  );
  const { newAttributes, newProfileKeys } = await applyGroupState({
    group: attributes,
    groupState,
  });

  // Generate notifications into the timeline
  const groupChangeMessages: Array<GroupChangeMessageType> = [];

  groupChangeMessages.push({
    ...buildMigrationBubble(previousGroupV1MembersIds, newAttributes),
    readStatus: ReadStatus.Read,
    seenStatus: SeenStatus.Seen,
  });

  const areWeInvited = (newAttributes.pendingMembersV2 || []).some(
    item => item.serviceId === ourAci
  );
  const areWeMember = (newAttributes.membersV2 || []).some(
    item => item.aci === ourAci
  );
  if (!areWeInvited && !areWeMember) {
    // Add a message to the timeline saying the user was removed. This shouldn't happen.
    groupChangeMessages.push({
      type: 'group-v2-change',
      groupV2Change: {
        details: [
          {
            type: 'member-remove' as const,
            aci: ourAci,
          },
        ],
      },
      readStatus: ReadStatus.Read,
      seenStatus: SeenStatus.Unseen,
    });
  }

  // This buffer ensures that all migration-related messages are sorted above
  //   any initiating message. We need to do this because groupChangeMessages are
  //   already sorted via updates to sentAt inside of updateGroup().
  const SORT_BUFFER = 1000;
  await updateGroup({
    conversation,
    receivedAt,
    sentAt: sentAt ? sentAt - SORT_BUFFER : undefined,
    updates: {
      newAttributes,
      groupChangeMessages,
      newProfileKeys: profileKeysToMap(newProfileKeys),
    },
  });

  if (itemStorage.blocked.isGroupBlocked(previousGroupV1Id)) {
    await itemStorage.blocked.addBlockedGroup(groupId);
  }

  // Save these most recent updates to conversation
  await updateConversation(conversation.attributes);

  // Finally, check for any changes to the group since its initial creation using normal
  //   group update codepaths.
  await maybeUpdateGroup({
    conversation,
    groupChange,
    newRevision,
    receivedAt,
    sentAt,
  });

  if (Bytes.isNotEmpty(groupSendEndorsementsResponse)) {
    try {
      const { membersV2 } = conversation.attributes;
      strictAssert(membersV2, 'missing membersV2');

      const groupEndorsementData = decodeGroupSendEndorsementsResponse({
        groupId,
        groupSendEndorsementsResponse,
        groupSecretParamsBase64: secretParams,
        groupMembersV2: membersV2,
      });

      await DataWriter.replaceAllEndorsementsForGroup(groupEndorsementData);
    } catch (error) {
      log.warn(
        `respondToGroupV2Migration/${logId}: Problem saving group endorsements ${Errors.toLogFormat(error)}`
      );
    }
  }
}

// Fetching and applying group changes

type MaybeUpdatePropsType = Readonly<{
  conversation: ConversationModel;
  newRevision?: number;
  receivedAt?: number;
  sentAt?: number;
  serverGuid?: string;
  dropInitialJoinMessage?: boolean;
  force?: boolean;
  groupChange?: WrappedGroupChangeType;
}>;

const FIVE_MINUTES = 5 * MINUTE;

export async function waitThenMaybeUpdateGroup(
  options: MaybeUpdatePropsType,
  { viaFirstStorageSync = false } = {}
): Promise<void> {
  const { conversation } = options;
  const logId = `waitThenMaybeUpdateGroup(${conversation.idForLogging()})`;

  if (conversation.isBlocked()) {
    log.info(`${logId}: Conversation is blocked, returning early`);
    return;
  }

  // First wait to process all incoming messages on the websocket
  await window.waitForEmptyEventQueue();

  // Then make sure we haven't fetched this group too recently
  const { lastSuccessfulGroupFetch = 0 } = conversation;
  if (
    !options.force &&
    isMoreRecentThan(lastSuccessfulGroupFetch, FIVE_MINUTES)
  ) {
    const waitTime = lastSuccessfulGroupFetch + FIVE_MINUTES - Date.now();
    log.info(
      `${logId}: group update was fetched recently, skipping for ${waitTime}ms`
    );
    return;
  }

  log.info(`${logId}: group update was not fetched recently, queuing update`);

  // Then wait to process all outstanding messages for this conversation
  await conversation.queueJob('waitThenMaybeUpdateGroup', async () => {
    try {
      // And finally try to update the group
      await maybeUpdateGroup(options, { viaFirstStorageSync });
    } catch (error) {
      setLastSuccessfulGroupFetch(conversation.id, undefined);
      log.error(
        `${logId}: maybeUpdateGroup failure:`,
        Errors.toLogFormat(error)
      );
    }
  });
}

export async function maybeUpdateGroup(
  {
    conversation,
    dropInitialJoinMessage,
    groupChange,
    newRevision,
    receivedAt,
    sentAt,
    serverGuid,
  }: MaybeUpdatePropsType,
  { viaFirstStorageSync = false } = {}
): Promise<void> {
  const logId = `maybeUpdateGroup/${conversation.idForLogging()}`;

  try {
    // Ensure we have the credentials we need before attempting GroupsV2 operations
    await maybeFetchNewCredentials();

    const updates = await getGroupUpdates({
      group: conversation.attributes,
      serverPublicParamsBase64: window.getServerPublicParams(),
      newRevision,
      groupChange,
      dropInitialJoinMessage,
    });

    await updateGroup(
      { conversation, receivedAt, sentAt, serverGuid, updates },
      { viaFirstStorageSync }
    );
  } catch (error) {
    log.error(`${logId}: Failed to update group:`, Errors.toLogFormat(error));
    throw error;
  }
}

/**
 * UpdateGroup runs on the conversation's queue, and it overwrites all of conversation
 * attributes. This would be fine, except we have some time-sensitive conversation-related
 * tasks that happen off-queue, notably: updateUnread(). This is a non-exhaustive list of
 * attributes that should never be stomped by updateGroup. Attributes should be added here
 * if:
 * 1) [most importantly!] they will never be updated by updateGroup, and
 * 2) they are updated off-queue and therefore at risk of being stomped on.
 *
 * TODO (DESKTOP-7729): consider better separating conversation data to avoid this
 */
const FIELDS_UNRELATED_TO_GROUP_STATE = ['unreadCount', 'unreadMentionsOfMe'];

async function updateGroup(
  {
    conversation,
    receivedAt,
    sentAt,
    serverGuid,
    updates,
  }: {
    conversation: ConversationModel;
    receivedAt?: number;
    sentAt?: number;
    serverGuid?: string;
    updates: UpdatesResultType;
  },
  { viaFirstStorageSync = false } = {}
): Promise<void> {
  const logId = conversation.idForLogging();

  const { newAttributes, groupChangeMessages, newProfileKeys } = updates;
  const ourAci = itemStorage.user.getCheckedAci();
  const ourPni = itemStorage.user.getPni();

  const wasMemberOrPending =
    conversation.hasMember(ourAci) ||
    conversation.isMemberPending(ourAci) ||
    (ourPni && conversation.isMemberPending(ourPni));
  const isMemberOrPending =
    !newAttributes.left ||
    newAttributes.pendingMembersV2?.some(
      item => item.serviceId === ourAci || item.serviceId === ourPni
    );

  // Ensure that all generated messages are ordered properly.
  // Before the provided timestamp so update messages appear before the
  //   initiating message, or after now().
  const finalReceivedAt = receivedAt || incrementMessageCounter();
  const initialSentAt = sentAt || Date.now();

  // GroupV1 -> GroupV2 migration changes the groupId, and we need to update our id-based
  //   lookups if there's a change on that field.
  const previousId = conversation.get('groupId');
  const idChanged = previousId && previousId !== newAttributes.groupId;

  // By updating activeAt we force this conversation into the left panel. We don't want
  //   all groups to show up on link, and we don't want Unknown Group in the left pane.
  let activeAt = conversation.get('active_at') || null;
  const justDiscoveredGroupName =
    !conversation.get('name') && newAttributes.name;
  if (
    !viaFirstStorageSync &&
    (justDiscoveredGroupName || groupChangeMessages.length)
  ) {
    activeAt = initialSentAt;
  }

  // Save all synthetic messages describing group changes
  let syntheticSentAt = initialSentAt - (groupChangeMessages.length + 1);
  const timestamp = Date.now();
  const changeMessagesToSave = groupChangeMessages.map(changeMessage => {
    // We do this to preserve the order of the timeline. We only update sentAt to ensure
    //   that we don't stomp on messages received around the same time as the message
    //   which initiated this group fetch and in-conversation messages.
    syntheticSentAt += 1;

    return {
      ...changeMessage,
      ...generateMessageId(finalReceivedAt),
      schemaVersion: MAX_MESSAGE_SCHEMA,
      conversationId: conversation.id,
      received_at_ms: syntheticSentAt,
      sent_at: syntheticSentAt,
      serverGuid,
      timestamp,
    };
  });

  const contactsWithoutProfileKey = new Array<ConversationModel>();

  // Capture profile key for each member in the group, if we don't have it yet
  for (const [aci, profileKey] of newProfileKeys) {
    const contact = window.ConversationController.getOrCreate(aci, 'private');

    if (
      !isMe(contact.attributes) &&
      profileKey &&
      profileKey.length > 0 &&
      contact.get('profileKey') !== profileKey
    ) {
      contactsWithoutProfileKey.push(contact);
      drop(contact.setProfileKey(profileKey, { reason: 'updateGroup' }));
    }
  }

  let profileFetches: Promise<Array<void>> | undefined;
  if (contactsWithoutProfileKey.length !== 0) {
    log.info(
      `updateGroup/${logId}: fetching ` +
        `${contactsWithoutProfileKey.length} missing profiles`
    );

    profileFetches = Promise.all(
      contactsWithoutProfileKey.map(contact => {
        return getProfile({
          serviceId: contact.getServiceId() ?? null,
          e164: contact.get('e164') ?? null,
          groupId: newAttributes.groupId ?? null,
        });
      })
    );
  }

  // If we've been added by a blocked contact, then schedule a task to leave group
  const justAdded = !wasMemberOrPending && isMemberOrPending;
  const addedBy =
    newAttributes.pendingMembersV2?.find(
      item => item.serviceId === ourAci || item.serviceId === ourPni
    )?.addedByUserId || newAttributes.addedBy;

  if (justAdded) {
    const adder = window.ConversationController.get(addedBy);

    // Wait for empty queue to make it more likely the group update succeeds
    const waitThenLeave = async (reason: string) => {
      log.warn(
        `waitThenLeave/${logId}/${reason}: Waiting for empty event queue.`
      );
      await window.waitForEmptyEventQueue();
      log.warn(
        `waitThenLeave/${logId}/${reason}: Empty event queue, starting group leave.`
      );

      // We're guaranteed to fail if we're not up to date in the group, which we won't be
      // if we're dropping updates. So we prepare for failure.
      try {
        await conversation.leaveGroupV2();
        log.warn(`waitThenLeave/${logId}/${reason}: Leave complete.`);
      } catch (error) {
        log.error(
          `waitThenLeave/${logId}/${reason}: Failed to leave group.`,
          Errors.toLogFormat(error)
        );
      }
    };

    if (adder && adder.isBlocked()) {
      log.warn(
        `updateGroup/${logId}: Added to group by blocked user ${adder.idForLogging()}. Scheduling group leave.`
      );

      // Cannot await here, would infinitely block queue
      drop(waitThenLeave('added by blocked user'));

      // Return early to discard group changes resulting from the blocked user's action.
      return;
    }
    if (conversation.isBlocked()) {
      log.warn(
        `updateGroup/${logId}: We were added to a group we blocked. Scheduling group leave.`
      );

      // Cannot await here, would infinitely block queue
      drop(waitThenLeave('group is blocked'));

      // Return early to discard group changes resulting from unwanted group add
      return;
    }

    if (adder && isTrustedContact(adder?.attributes)) {
      conversation.enableProfileSharing({
        reason: 'addedToGroupByTrustedContact',
        viaStorageServiceSync: false,
      });
    }
  }

  // We update group membership last to ensure that all notifications are in place before
  //   the group updates happen on the model.
  if (changeMessagesToSave.length > 0) {
    try {
      if (contactsWithoutProfileKey && contactsWithoutProfileKey.length > 0) {
        await Promise.race([profileFetches, sleep(30 * SECOND)]);
        log.info(
          `updateGroup/${logId}: timed out or finished fetching ${contactsWithoutProfileKey.length} profiles`
        );
      }
    } catch (error) {
      log.error(
        `updateGroup/${logId}: failed to fetch missing profiles`,
        Errors.toLogFormat(error)
      );
    }
    await appendChangeMessages(conversation, changeMessagesToSave);
  }

  conversation.set({
    ...omit(newAttributes, FIELDS_UNRELATED_TO_GROUP_STATE),
    active_at: activeAt,
  });

  if (idChanged) {
    window.ConversationController.idUpdated(
      conversation,
      'groupId',
      previousId
    );
  }

  // Save these most recent updates to conversation
  await updateConversation(conversation.attributes);
}

// Exported for testing
export function _mergeGroupChangeMessages(
  first: MessageAttributesType | undefined,
  second: MessageAttributesType
): MessageAttributesType | undefined {
  if (!first) {
    return undefined;
  }

  if (first.type !== 'group-v2-change' || second.type !== first.type) {
    return undefined;
  }

  const { groupV2Change: firstChange } = first;
  const { groupV2Change: secondChange } = second;
  if (!firstChange || !secondChange) {
    return undefined;
  }

  if (firstChange.details.length !== 1 && secondChange.details.length !== 1) {
    return undefined;
  }

  const [firstDetail] = firstChange.details;
  const [secondDetail] = secondChange.details;

  strictAssert(firstDetail, 'Missing firstDetail');
  strictAssert(secondDetail, 'Missing secondDetail');

  let isApprovalPending: boolean;
  if (secondDetail.type === 'admin-approval-add-one') {
    isApprovalPending = true;
  } else if (
    secondDetail.type === 'admin-approval-remove-one' &&
    (secondChange.from == null || secondChange.from === secondDetail.aci)
  ) {
    isApprovalPending = false;
  } else {
    return undefined;
  }

  const { aci } = secondDetail;
  strictAssert(aci, 'admin approval message should have aci');

  let updatedDetail;
  // Member was previously added and is now removed
  if (
    !isApprovalPending &&
    firstDetail.type === 'admin-approval-add-one' &&
    firstDetail.aci === aci
  ) {
    updatedDetail = {
      type: 'admin-approval-bounce' as const,
      aci,
      times: 1,
      isApprovalPending,
    };

    // There is an existing bounce event - merge this one into it.
  } else if (
    firstDetail.type === 'admin-approval-bounce' &&
    firstDetail.aci === aci &&
    firstDetail.isApprovalPending === !isApprovalPending
  ) {
    updatedDetail = {
      type: 'admin-approval-bounce' as const,
      aci,
      times: firstDetail.times + (isApprovalPending ? 0 : 1),
      isApprovalPending,
    };
  } else {
    return undefined;
  }

  return {
    ...first,
    groupV2Change: {
      ...first.groupV2Change,
      details: [updatedDetail],
    },
  };
}

// Exported for testing
export function _isGroupChangeMessageBounceable(
  message: MessageAttributesType
): boolean {
  if (message.type !== 'group-v2-change') {
    return false;
  }

  const { groupV2Change } = message;
  if (!groupV2Change) {
    return false;
  }

  if (groupV2Change.details.length !== 1) {
    return false;
  }

  const [first] = groupV2Change.details;
  strictAssert(first, 'Missing first');
  if (
    first.type === 'admin-approval-add-one' ||
    first.type === 'admin-approval-bounce'
  ) {
    return true;
  }

  return false;
}

async function appendChangeMessages(
  conversation: ConversationModel,
  messages: ReadonlyArray<MessageAttributesType>
): Promise<void> {
  const logId = conversation.idForLogging();

  log.info(
    `appendChangeMessages/${logId}: processing ${messages.length} messages`
  );

  const ourAci = itemStorage.user.getCheckedAci();

  let lastMessage = await DataReader.getLastConversationMessage({
    conversationId: conversation.id,
  });

  if (lastMessage && !_isGroupChangeMessageBounceable(lastMessage)) {
    lastMessage = undefined;
  }

  const mergedMessages = [];
  let previousMessage = lastMessage;
  for (const message of messages) {
    const merged = _mergeGroupChangeMessages(previousMessage, message);
    if (!merged) {
      if (previousMessage && previousMessage !== lastMessage) {
        mergedMessages.push(previousMessage);
      }
      previousMessage = message;
      continue;
    }

    previousMessage = merged;
    log.info(
      `appendChangeMessages/${logId}: merged ${message.id} into ${merged.id}`
    );
  }

  if (previousMessage && previousMessage !== lastMessage) {
    mergedMessages.push(previousMessage);
  }

  // Update existing message
  if (lastMessage && mergedMessages[0]?.id === lastMessage?.id) {
    const [first, ...rest] = mergedMessages;
    strictAssert(first !== undefined, 'First message must be there');

    log.info(`appendChangeMessages/${logId}: updating ${first.id}`);
    await window.MessageCache.saveMessage(first, {
      // We don't use forceSave here because this is an update of existing
      // message.
    });

    log.info(
      `appendChangeMessages/${logId}: saving ${rest.length} new messages`
    );
    await DataWriter.saveMessages(rest, {
      ourAci,
      forceSave: true,
      postSaveUpdates,
    });
  } else {
    log.info(
      `appendChangeMessages/${logId}: saving ${mergedMessages.length} new messages`
    );
    await DataWriter.saveMessages(mergedMessages, {
      ourAci,
      forceSave: true,
      postSaveUpdates,
    });
  }

  let newMessages = 0;
  for (const changeMessage of mergedMessages) {
    const existing = window.MessageCache.getById(changeMessage.id);

    // Update existing message
    if (existing) {
      strictAssert(
        changeMessage.id === lastMessage?.id,
        'Should only update group change that was already in the database'
      );
      existing.set(changeMessage);
      continue;
    }

    const model = window.MessageCache.register(new MessageModel(changeMessage));
    drop(conversation.onNewMessage(model));
    newMessages += 1;
  }

  // We updated the message, but didn't add new ones - refresh left pane
  if (!newMessages && mergedMessages.length > 0) {
    await conversation.updateLastMessage();
    conversation.throttledUpdateUnread();
  }
}

type GetGroupUpdatesType = Readonly<{
  dropInitialJoinMessage?: boolean;
  group: ConversationAttributesType;
  serverPublicParamsBase64: string;
  newRevision?: number;
  groupChange?: WrappedGroupChangeType;
}>;

async function getGroupUpdates({
  dropInitialJoinMessage,
  group,
  serverPublicParamsBase64,
  newRevision,
  groupChange: wrappedGroupChange,
}: GetGroupUpdatesType): Promise<UpdatesResultType> {
  const logId = idForLogging(group.groupId);

  log.info(`getGroupUpdates/${logId}: Starting...`);

  const currentRevision = group.revision;
  const isFirstFetch = !isNumber(group.revision);
  const ourAci = itemStorage.user.getCheckedAci();

  const isInitialCreationMessage = isFirstFetch && newRevision === 0;
  const weAreAwaitingApproval = (group.pendingAdminApprovalV2 || []).some(
    item => item.aci === ourAci
  );
  const isOneVersionUp =
    isNumber(currentRevision) &&
    isNumber(newRevision) &&
    newRevision === currentRevision + 1;

  if (
    window.Flags.GV2_ENABLE_SINGLE_CHANGE_PROCESSING &&
    wrappedGroupChange &&
    isNumber(newRevision) &&
    (isInitialCreationMessage || weAreAwaitingApproval || isOneVersionUp)
  ) {
    log.info(`getGroupUpdates/${logId}: Processing just one change`);

    const groupChangeBuffer = Bytes.fromBase64(wrappedGroupChange.base64);
    const groupChange = Proto.GroupChange.decode(groupChangeBuffer);
    const isChangeSupported =
      !isNumber(groupChange.changeEpoch) ||
      groupChange.changeEpoch <= SUPPORTED_CHANGE_EPOCH;

    if (isChangeSupported) {
      const { isTrusted } = wrappedGroupChange;
      let isUntrustedChangeVerified = false;

      if (!isTrusted) {
        strictAssert(
          groupChange.serverSignature,
          'Server signature must be present in untrusted group change'
        );
        strictAssert(
          groupChange.actions,
          'Actions must be present in untrusted group change'
        );
        try {
          verifyNotarySignature(
            serverPublicParamsBase64,
            groupChange.actions,
            groupChange.serverSignature
          );
        } catch (error) {
          log.warn(
            `getGroupUpdates/${logId}: verifyNotarySignature failed, ` +
              'dropping the message',
            Errors.toLogFormat(error)
          );
          return {
            newAttributes: group,
            groupChangeMessages: [],
            newProfileKeys: new Map(),
          };
        }

        const { groupId: groupIdBytes } = Proto.GroupChange.Actions.decode(
          groupChange.actions || new Uint8Array(0)
        );
        const actionsGroupId: string | undefined =
          groupIdBytes && groupIdBytes.length !== 0
            ? Bytes.toBase64(groupIdBytes)
            : undefined;
        if (actionsGroupId && actionsGroupId === group.groupId) {
          isUntrustedChangeVerified = true;
        } else if (!actionsGroupId) {
          log.warn(
            `getGroupUpdates/${logId}: Missing groupId in group change actions`
          );
        } else {
          log.warn(
            `getGroupUpdates/${logId}: Incorrect groupId in group change actions`
          );
        }
      }

      if (isTrusted || isUntrustedChangeVerified) {
        return updateGroupViaSingleChange({
          group,
          newRevision,
          groupChange,
        });
      }
    }

    log.info(
      `getGroupUpdates/${logId}: Failing over; group change unsupported`
    );
  }

  const areWeMember = (group.membersV2 || []).some(item => item.aci === ourAci);
  const isReJoin = !isFirstFetch && !areWeMember;

  if (window.Flags.GV2_ENABLE_CHANGE_PROCESSING) {
    try {
      return await updateGroupViaLogs({
        group,
        newRevision,
      });
    } catch (error) {
      const nextStep = isReJoin
        ? 'attempting to fetch from re-join revision'
        : 'fetching full state';

      if (error.code === TEMPORAL_AUTH_REJECTED_CODE) {
        // We will fail over to the updateGroupViaState call below
        log.info(
          `getGroupUpdates/${logId}: Temporal credential failure, now ${nextStep}`
        );
      } else if (error.code === GROUP_ACCESS_DENIED_CODE) {
        // We will fail over to the updateGroupViaState call below
        log.info(
          `getGroupUpdates/${logId}: Log access denied, now ${nextStep}`
        );
      } else {
        throw error;
      }
    }
  }

  if (isReJoin && window.Flags.GV2_ENABLE_CHANGE_PROCESSING) {
    try {
      return await updateGroupViaLogs({
        group,
        newRevision,
        isReJoin,
      });
    } catch (error) {
      if (error.code === TEMPORAL_AUTH_REJECTED_CODE) {
        // We will fail over to the updateGroupViaState call below
        log.info(
          `getGroupUpdates/${logId}: Temporal credential failure, now fetching full state`
        );
      } else if (error.code === GROUP_ACCESS_DENIED_CODE) {
        // We will fail over to the updateGroupViaState call below
        log.info(
          `getGroupUpdates/${logId}: Log access denied, now fetching full state`
        );
      } else {
        throw error;
      }
    }
  }

  if (window.Flags.GV2_ENABLE_STATE_PROCESSING) {
    try {
      return await updateGroupViaState({
        dropInitialJoinMessage,
        group,
      });
    } catch (error) {
      if (error.code === TEMPORAL_AUTH_REJECTED_CODE) {
        log.info(
          `getGroupUpdates/${logId}: Temporal credential failure. Failing; we don't know if we have access or not.`
        );
        throw error;
      } else if (error.code === GROUP_ACCESS_DENIED_CODE) {
        // We will fail over to the updateGroupViaPreJoinInfo call below
        log.info(
          `getGroupUpdates/${logId}: Failed to get group state. Attempting to fetch pre-join information.`
        );
      } else {
        throw error;
      }
    }
  }

  if (window.Flags.GV2_ENABLE_PRE_JOIN_FETCH) {
    try {
      return await updateGroupViaPreJoinInfo({
        group,
      });
    } catch (error) {
      if (error.code === GROUP_ACCESS_DENIED_CODE) {
        return generateLeftGroupChanges(group);
      }
      if (error.code === GROUP_NONEXISTENT_CODE) {
        return generateLeftGroupChanges(group);
      }

      // If we get another temporal failure, we'll fail and try again later.
      throw error;
    }
  }

  log.warn(
    `getGroupUpdates/${logId}: No processing was legal! Returning empty changeset.`
  );
  return {
    newAttributes: group,
    groupChangeMessages: [],
    newProfileKeys: new Map(),
  };
}

async function updateGroupViaPreJoinInfo({
  group,
}: {
  group: ConversationAttributesType;
}): Promise<UpdatesResultType> {
  const logId = idForLogging(group.groupId);
  const ourAci = itemStorage.user.getCheckedAci();

  const { publicParams, secretParams } = group;
  if (!secretParams) {
    throw new Error(
      'updateGroupViaPreJoinInfo: group was missing secretParams!'
    );
  }
  if (!publicParams) {
    throw new Error(
      'updateGroupViaPreJoinInfo: group was missing publicParams!'
    );
  }

  // No password, but if we're already pending approval, we can access this without it.
  const inviteLinkPassword = undefined;
  const preJoinInfo = await makeRequestWithCredentials({
    logId: `getPreJoinInfo/${logId}`,
    publicParams,
    secretParams,
    request: options => getGroupFromLink(inviteLinkPassword, options),
  });

  const approvalRequired =
    preJoinInfo.addFromInviteLink ===
    Proto.AccessControl.AccessRequired.ADMINISTRATOR;

  // If the group doesn't require approval to join via link, then we should never have
  //   gotten here.
  if (!approvalRequired) {
    return generateLeftGroupChanges(group);
  }

  let newAttributes: ConversationAttributesType = {
    ...group,
    description: decryptGroupDescription(
      dropNull(preJoinInfo.description),
      secretParams
    ),
    name: decryptGroupTitle(dropNull(preJoinInfo.title), secretParams),
    left: true,
    members: group.members || [],
    pendingMembersV2: group.pendingMembersV2 || [],
    pendingAdminApprovalV2: [
      {
        aci: ourAci,
        timestamp: Date.now(),
      },
    ],
    revision: dropNull(preJoinInfo.version),

    temporaryMemberCount: preJoinInfo.memberCount || 1,
  };

  newAttributes = {
    ...newAttributes,
    ...(await applyNewAvatar({
      newAvatarUrl: dropNull(preJoinInfo.avatar),
      attributes: newAttributes,
      logId,
    })),
  };

  return {
    newAttributes,
    groupChangeMessages: extractDiffs({
      old: group,
      current: newAttributes,
      dropInitialJoinMessage: false,
    }),
    newProfileKeys: new Map(),
  };
}

async function updateGroupViaState({
  dropInitialJoinMessage,
  group,
}: {
  dropInitialJoinMessage?: boolean;
  group: ConversationAttributesType;
}): Promise<UpdatesResultType> {
  const { id, publicParams, secretParams } = group;
  const logId = `updateGroupViaState/${idForLogging(group.groupId)}`;

  strictAssert(secretParams, `${logId}: Missing secretParams`);
  strictAssert(publicParams, `${logId}: Missing publicParams`);

  const fetchedAt = Date.now();
  const groupResponse = await makeRequestWithCredentials({
    logId: `getGroup/${logId}`,
    publicParams,
    secretParams,
    request: requestOptions => getGroup(requestOptions),
  });
  setLastSuccessfulGroupFetch(id, fetchedAt);

  const { group: groupState, groupSendEndorsementsResponse } = groupResponse;
  strictAssert(groupState, 'updateGroupViaState: Group state must be present');

  const decryptedGroupState = decryptGroupState(
    groupState,
    secretParams,
    logId
  );

  const oldVersion = group.revision;
  const newVersion = decryptedGroupState.version;
  log.info(
    `getCurrentGroupState/${logId}: Applying full group state, from version ${oldVersion} to ${newVersion}.`
  );
  const { newAttributes, newProfileKeys } = await applyGroupState({
    group,
    groupState: decryptedGroupState,
  });

  // If we're not in the group, we won't receive endorsements
  if (Bytes.isNotEmpty(groupSendEndorsementsResponse)) {
    try {
      // Use the latest state of the group after applying changes
      const { groupId, membersV2 } = newAttributes;
      strictAssert(groupId, 'updateGroupViaState: Group must have groupId');
      strictAssert(membersV2, 'updateGroupViaState: Group must have membersV2');

      log.info(`getCurrentGroupState/${logId}: Saving group endorsements`);

      const groupEndorsementData = decodeGroupSendEndorsementsResponse({
        groupId,
        groupSendEndorsementsResponse,
        groupSecretParamsBase64: secretParams,
        groupMembersV2: membersV2,
      });

      await DataWriter.replaceAllEndorsementsForGroup(groupEndorsementData);
    } catch (error) {
      log.warn(
        `updateGroupViaState/${logId}: Problem saving group endorsements ${Errors.toLogFormat(error)}`
      );
    }
  }

  return {
    newAttributes,
    groupChangeMessages: extractDiffs({
      old: group,
      current: newAttributes,
      dropInitialJoinMessage,
    }),
    newProfileKeys: profileKeysToMap(newProfileKeys),
  };
}

async function updateGroupViaSingleChange({
  group,
  groupChange,
  newRevision,
}: {
  group: ConversationAttributesType;
  groupChange: Proto.GroupChange.Params;
  newRevision: number;
}): Promise<UpdatesResultType> {
  const previouslyKnewAboutThisGroup =
    isNumber(group.revision) && group.membersV2?.length;
  const wasInGroup = !group.left;

  setLastSuccessfulGroupFetch(group.id, undefined);

  const singleChangeResult: UpdatesResultType = await integrateGroupChange({
    group,
    groupChange,
    newRevision,
  });

  const nowInGroup = !singleChangeResult.newAttributes.left;

  // If we were just added to the group (for example, via a join link), we go fetch the
  //   entire group state to make sure we're up to date. Note: we fetch the group state
  //   via the log endpoint to stay at newRevision.
  if (!wasInGroup && nowInGroup) {
    const logId = idForLogging(group.groupId);
    log.info(
      `updateGroupViaSingleChange/${logId}: Just joined group; fetching entire state for revision ${newRevision}.`
    );
    const {
      newAttributes,
      newProfileKeys,
      groupChangeMessages: catchupMessages,
    } = await updateGroupViaLogs({
      group: singleChangeResult.newAttributes,
      newRevision,
    });

    const groupChangeMessages = [...singleChangeResult.groupChangeMessages];
    // If we've just been added to a group we were previously in, we do want to show
    //   a summary instead of nothing.
    if (
      groupChangeMessages.length > 0 &&
      previouslyKnewAboutThisGroup &&
      catchupMessages.length > 0
    ) {
      groupChangeMessages.push({
        type: 'group-v2-change',
        groupV2Change: {
          details: [
            {
              type: 'summary',
            },
          ],
        },
        readStatus: ReadStatus.Read,
        // For simplicity, since we don't know who this change is from here, always Seen
        seenStatus: SeenStatus.Seen,
      });
    }

    // We discard any change events that come out of this full group fetch, but we do
    //   keep the final group attributes generated, as well as any new members.
    return {
      groupChangeMessages,
      newProfileKeys: new Map([
        ...singleChangeResult.newProfileKeys,
        ...newProfileKeys,
      ]),
      newAttributes,
    };
  }

  return singleChangeResult;
}

function getLastRevisionFromChanges(
  changes: ReadonlyArray<Proto.GroupChanges.Params>
): number | undefined {
  for (let i = changes.length - 1; i >= 0; i -= 1) {
    const change = changes[i];
    if (!change) {
      continue;
    }

    const { groupChanges } = change;
    if (!groupChanges) {
      continue;
    }

    for (let j = groupChanges.length - 1; j >= 0; j -= 1) {
      const groupChange = groupChanges[j];
      if (!groupChange) {
        continue;
      }

      const { groupState } = groupChange;
      if (!groupState) {
        continue;
      }

      const { version } = groupState;
      if (isNumber(version)) {
        return version;
      }
    }
  }

  return undefined;
}

async function updateGroupViaLogs({
  group,
  newRevision,
  isReJoin,
}: {
  group: ConversationAttributesType;
  newRevision: number | undefined;
  isReJoin?: boolean;
}): Promise<UpdatesResultType> {
  const logId = idForLogging(group.groupId);
  const { publicParams, secretParams } = group;
  if (!publicParams) {
    throw new Error('updateGroupViaLogs: group was missing publicParams!');
  }
  if (!secretParams) {
    throw new Error('updateGroupViaLogs: group was missing secretParams!');
  }

  const currentRevision = isReJoin ? undefined : group.revision;
  let includeFirstState = true;

  log.info(
    `updateGroupViaLogs/${logId}: Getting group delta from ` +
      `${currentRevision ?? '?'} to ${newRevision ?? '?'} for group ` +
      `groupv2(${group.groupId})...`
  );

  // The range is inclusive so make sure that we always request the revision
  // that we are currently at since we might want the latest full state in
  // `integrateGroupChanges`.
  let revisionToFetch = isNumber(currentRevision) ? currentRevision : undefined;

  const { groupId } = group;
  strictAssert(groupId != null, 'Group must have groupId');

  let cachedEndorsementsExpiration =
    await DataReader.getGroupSendCombinedEndorsementExpiration(groupId);

  if (cachedEndorsementsExpiration != null) {
    const result = validateGroupSendEndorsementsExpiration(
      cachedEndorsementsExpiration * 1000
    );
    if (!result.valid) {
      log.info(
        `updateGroupViaLogs/${logId}: Endorsements are expired (${result.reason}), fetching new endorsements`
      );
    }
    cachedEndorsementsExpiration = null;
  }

  let response: GroupLogResponseType;
  let groupSendEndorsementsResponse: Uint8Array<ArrayBuffer> | null = null;
  const changes: Array<Proto.GroupChanges.Params> = [];
  do {
    const fetchedAt = Date.now();
    // oxlint-disable-next-line no-await-in-loop
    response = await makeRequestWithCredentials({
      logId: `getGroupLog/${logId}`,
      publicParams,
      secretParams,

      // oxlint-disable-next-line no-loop-func
      request: requestOptions =>
        getGroupLog(
          {
            startVersion: revisionToFetch,
            includeFirstState,
            includeLastState: true,
            maxSupportedChangeEpoch: SUPPORTED_CHANGE_EPOCH,
            cachedEndorsementsExpiration,
          },
          requestOptions
        ),
    });
    setLastSuccessfulGroupFetch(group.id, fetchedAt);

    // When the log is long enough that it needs to be paginated, the server is
    // not stateful enough to only give us endorsements when we need them.
    // In this case we need to delete all endorsements and send `0` to get
    // endorsements from the next page.
    if (response.paginated && cachedEndorsementsExpiration != null) {
      log.info(
        'updateGroupViaLogs: Received paginated response, deleting group endorsements'
      );
      // oxlint-disable-next-line no-await-in-loop
      await DataWriter.deleteAllEndorsementsForGroup(groupId);
      cachedEndorsementsExpiration = null; // gets sent as 0 in header
    }

    // Note: We should only get this on the final page
    if (response.groupSendEndorsementsResponse != null) {
      groupSendEndorsementsResponse = response.groupSendEndorsementsResponse;
    }

    changes.push(response.changes);
    if (response.paginated && response.end) {
      revisionToFetch = response.end + 1;
    }

    includeFirstState = false;
  } while (
    response.paginated &&
    response.end &&
    (newRevision === undefined || response.end < newRevision)
  );

  // Would be nice to cache the unused groupChanges here, to reduce server roundtrips

  const updates = await integrateGroupChanges({
    changes,
    group,
    newRevision,
  });

  const currentVersion = response.paginated
    ? response.currentRevision
    : getLastRevisionFromChanges(changes);
  const isAtLatestVersion =
    isNumber(currentVersion) &&
    updates.newAttributes.revision === currentVersion;

  if (isAtLatestVersion && Bytes.isNotEmpty(groupSendEndorsementsResponse)) {
    try {
      log.info(`updateGroupViaLogs/${logId}: Saving group endorsements`);
      // Use the latest state of the group after applying changes
      const { membersV2 } = updates.newAttributes;
      strictAssert(
        membersV2 != null,
        'updateGroupViaLogs: Group must have membersV2'
      );

      const groupEndorsementData = decodeGroupSendEndorsementsResponse({
        groupId,
        groupSendEndorsementsResponse,
        groupMembersV2: membersV2,
        groupSecretParamsBase64: secretParams,
      });

      await DataWriter.replaceAllEndorsementsForGroup(groupEndorsementData);
    } catch (error) {
      log.warn(
        `updateGroupViaLogs/${logId}: Problem saving group endorsements ${Errors.toLogFormat(error)}`
      );
    }
  }

  return updates;
}

async function generateLeftGroupChanges(
  group: ConversationAttributesType
): Promise<UpdatesResultType> {
  const logId = idForLogging(group.groupId);
  log.info(`generateLeftGroupChanges/${logId}: Starting...`);
  const ourAci = itemStorage.user.getCheckedAci();
  const ourPni = itemStorage.user.getCheckedPni();

  const { masterKey, groupInviteLinkPassword } = group;
  let { revision } = group;

  try {
    if (masterKey && groupInviteLinkPassword) {
      log.info(
        `generateLeftGroupChanges/${logId}: Have invite link. Attempting to fetch latest revision with it.`
      );
      const preJoinInfo = await getPreJoinGroupInfo(
        groupInviteLinkPassword,
        masterKey
      );

      revision = dropNull(preJoinInfo.version);
    }
  } catch (error) {
    log.warn(
      'generateLeftGroupChanges: Failed to fetch latest revision via group link. Code:',
      error.code
    );
  }

  const newAttributes: ConversationAttributesType = {
    ...group,
    addedBy: undefined,
    membersV2: (group.membersV2 || []).filter(member => member.aci !== ourAci),
    pendingMembersV2: (group.pendingMembersV2 || []).filter(
      member => member.serviceId !== ourAci && member.serviceId !== ourPni
    ),
    pendingAdminApprovalV2: (group.pendingAdminApprovalV2 || []).filter(
      member => member.aci !== ourAci
    ),
    left: true,
    revision,
  };

  return {
    newAttributes,
    groupChangeMessages: extractDiffs({
      current: newAttributes,
      old: group,
    }),
    newProfileKeys: new Map(),
  };
}

function getGroupCredentials({
  authCredentialBase64,
  groupPublicParamsBase64,
  groupSecretParamsBase64,
  serverPublicParamsBase64,
}: {
  authCredentialBase64: string;
  groupPublicParamsBase64: string;
  groupSecretParamsBase64: string;
  serverPublicParamsBase64: string;
}): GroupCredentialsType {
  const authOperations = getClientZkAuthOperations(serverPublicParamsBase64);

  const presentation = getAuthCredentialPresentation(
    authOperations,
    authCredentialBase64,
    groupSecretParamsBase64
  );

  return {
    groupPublicParamsHex: Bytes.toHex(
      Bytes.fromBase64(groupPublicParamsBase64)
    ),
    authCredentialPresentationHex: Bytes.toHex(presentation),
  };
}

async function integrateGroupChanges({
  group,
  newRevision,
  changes,
}: {
  group: ConversationAttributesType;
  newRevision: number | undefined;
  changes: ReadonlyArray<Proto.GroupChanges.Params>;
}): Promise<UpdatesResultType> {
  const logId = idForLogging(group.groupId);
  let attributes = group;
  const finalMessages: Array<Array<GroupChangeMessageType>> = [];
  const finalNewProfileKeys = new Map<AciString, string>();

  for (const change of changes) {
    const { groupChanges } = change;

    if (!groupChanges) {
      continue;
    }

    for (const changeState of groupChanges) {
      const { groupChange, groupState } = changeState;

      if (!groupChange && !groupState) {
        log.warn(
          'integrateGroupChanges: item had neither groupState nor groupChange. Skipping.'
        );
        continue;
      }

      try {
        const {
          newAttributes,
          groupChangeMessages,
          newProfileKeys,
          // oxlint-disable-next-line no-await-in-loop
        } = await integrateGroupChange({
          group: attributes,
          newRevision,
          groupChange: dropNull(groupChange),
          groupState: dropNull(groupState),
        });

        attributes = newAttributes;
        finalMessages.push(groupChangeMessages);
        for (const [aci, profileKey] of newProfileKeys) {
          finalNewProfileKeys.set(aci, profileKey);
        }
      } catch (error) {
        log.error(
          `integrateGroupChanges/${logId}: Failed to apply change log, continuing to apply remaining change logs.`,
          Errors.toLogFormat(error)
        );
      }
    }
  }

  // If this is our first fetch, we will collapse this down to one set of messages
  const isFirstFetch = !isNumber(group.revision);
  // ...but only if there has been more than one revision since creation
  const moreThanOneVersion = Boolean(attributes.revision);

  if (isFirstFetch && moreThanOneVersion) {
    // The first array in finalMessages is from the first revision we could process. It
    //   should contain a message about how we joined the group.
    const joinMessage = finalMessages[0]?.[0];
    const alreadyHaveJoinMessage = joinMessage != null;

    // There have been other changes since that first revision, so we generate diffs for
    //   the whole of the change since then, likely without the initial join message.
    const otherMessages = extractDiffs({
      old: group,
      current: attributes,
      dropInitialJoinMessage: alreadyHaveJoinMessage,
    });

    const groupChangeMessages =
      joinMessage != null ? [joinMessage, ...otherMessages] : otherMessages;

    return {
      newAttributes: attributes,
      groupChangeMessages,
      newProfileKeys: finalNewProfileKeys,
    };
  }

  return {
    newAttributes: attributes,
    groupChangeMessages: flatten(finalMessages),
    newProfileKeys: finalNewProfileKeys,
  };
}

async function integrateGroupChange({
  group,
  groupChange,
  groupState,
  newRevision,
}: {
  group: ConversationAttributesType;
  groupChange?: Proto.GroupChange.Params;
  groupState?: Proto.Group.Params;
  newRevision: number | undefined;
}): Promise<UpdatesResultType> {
  const logId = idForLogging(group.groupId);
  if (!group.secretParams) {
    throw new Error(
      `integrateGroupChange/${logId}: Group was missing secretParams!`
    );
  }

  if (!groupChange && !groupState) {
    throw new Error(
      `integrateGroupChange/${logId}: Neither groupChange nor groupState received!`
    );
  }

  const isFirstFetch = !isNumber(group.revision);
  const ourAci = itemStorage.user.getCheckedAci();
  const weAreAwaitingApproval = (group.pendingAdminApprovalV2 || []).some(
    item => item.aci === ourAci
  );
  const weAreInGroup = (group.membersV2 || []).some(
    item => item.aci === ourAci
  );
  const isReJoin = !isFirstFetch && !weAreInGroup;

  // These need to be populated from the groupChange. But we might not get one!
  let isChangeSupported = false;
  let isSameVersion = false;
  let isMoreThanOneVersionUp = false;
  let groupChangeActions: undefined | Proto.GroupChange.Actions;
  let decryptedChangeActions: undefined | DecryptedGroupChangeActions;
  let sourceServiceId: undefined | ServiceIdString;

  if (groupChange) {
    groupChangeActions = Proto.GroupChange.Actions.decode(
      groupChange.actions || new Uint8Array(0)
    );

    // Version is higher that what we have in the incoming message
    if (
      groupChangeActions.version &&
      newRevision !== undefined &&
      groupChangeActions.version > newRevision
    ) {
      log.info(
        `integrateGroupChange/${logId}: Skipping ` +
          `${groupChangeActions.version}, newRevision is ${newRevision}`
      );
      return {
        newAttributes: group,
        groupChangeMessages: [],
        newProfileKeys: new Map(),
      };
    }

    decryptedChangeActions = decryptGroupChange(
      groupChangeActions,
      group.secretParams,
      logId
    );

    strictAssert(
      decryptedChangeActions !== undefined,
      'Should have decrypted group actions'
    );
    ({ sourceServiceId } = decryptedChangeActions);
    strictAssert(sourceServiceId, 'Should have source service id');

    isChangeSupported =
      !isNumber(groupChange.changeEpoch) ||
      groupChange.changeEpoch <= SUPPORTED_CHANGE_EPOCH;

    // Version is lower or the same as what we currently have
    if (group.revision !== undefined && groupChangeActions.version) {
      if (groupChangeActions.version < group.revision) {
        log.info(
          `integrateGroupChange/${logId}: Skipping stale version` +
            `${groupChangeActions.version}, current ` +
            `revision is ${group.revision}`
        );
        return {
          newAttributes: group,
          groupChangeMessages: [],
          newProfileKeys: new Map(),
        };
      }
      if (groupChangeActions.version === group.revision) {
        isSameVersion = true;
      } else if (
        groupChangeActions.version !== group.revision + 1 ||
        (!isNumber(group.revision) && groupChangeActions.version > 0)
      ) {
        isMoreThanOneVersionUp = true;
      }
    }
  }

  let attributes = group;
  const aggregatedChangeMessages = [];
  const finalNewProfileKeys = new Map<AciString, string>();

  const canApplyChange =
    groupChange &&
    isChangeSupported &&
    !isSameVersion &&
    !isFirstFetch &&
    (!isMoreThanOneVersionUp || weAreAwaitingApproval);

  // Apply the change first
  if (canApplyChange) {
    if (!sourceServiceId || !groupChangeActions || !decryptedChangeActions) {
      throw new Error(
        `integrateGroupChange/${logId}: Missing necessary information that should have come from group actions`
      );
    }

    log.info(
      `integrateGroupChange/${logId}: Applying group change actions, ` +
        `from version ${group.revision} to ${groupChangeActions.version}`
    );

    const { newAttributes, newProfileKeys, promotedAciToPniMap } =
      await applyGroupChange({
        group,
        actions: decryptedChangeActions,
        sourceServiceId,
      });

    const groupChangeMessages = extractDiffs({
      old: attributes,
      current: newAttributes,
      sourceServiceId,
      promotedAciToPniMap,
    });

    attributes = newAttributes;
    aggregatedChangeMessages.push(groupChangeMessages);
    for (const [aci, profileKey] of profileKeysToMap(newProfileKeys)) {
      finalNewProfileKeys.set(aci, profileKey);
    }
  }

  // Apply the group state afterwards to verify that we didn't miss anything
  if (groupState) {
    log.info(
      `integrateGroupChange/${logId}: Applying full group state, ` +
        `from version ${group.revision} to ${groupState.version}`,
      {
        isChangePresent: Boolean(groupChange),
        isChangeSupported,
        isFirstFetch,
        isReJoin,
        isSameVersion,
        isMoreThanOneVersionUp,
        weAreAwaitingApproval,
      }
    );

    const decryptedGroupState = decryptGroupState(
      groupState,
      group.secretParams,
      logId
    );

    const {
      newAttributes,
      newProfileKeys: newProfileKeysList,
      otherChanges,
    } = await applyGroupState({
      group: attributes,
      groupState: decryptedGroupState,
      sourceServiceId: isFirstFetch || isReJoin ? sourceServiceId : undefined,
    });

    const groupChangeMessages = extractDiffs({
      old: attributes,
      current: newAttributes,
      sourceServiceId: isFirstFetch || isReJoin ? sourceServiceId : undefined,
      isReJoin,
    });

    const newProfileKeys = profileKeysToMap(newProfileKeysList);

    if (
      canApplyChange &&
      (groupChangeMessages.length !== 0 ||
        newProfileKeys.size !== 0 ||
        otherChanges)
    ) {
      assertDev(
        groupChangeMessages.length === 0,
        'Fallback group state processing should not kick in'
      );

      log.warn(
        `integrateGroupChange/${logId}: local state was different from ` +
          'the remote final state. ' +
          `Got ${groupChangeMessages.length} change messages, ` +
          `${newProfileKeys.size} updated members, and ` +
          `otherChanges=${otherChanges}`
      );
    }

    attributes = newAttributes;
    aggregatedChangeMessages.push(groupChangeMessages);
    for (const [aci, profileKey] of newProfileKeys) {
      finalNewProfileKeys.set(aci, profileKey);
    }
  } else {
    strictAssert(
      canApplyChange,
      `integrateGroupChange/${logId}: No group state, but we can't apply changes!`
    );
  }

  return {
    newAttributes: attributes,
    groupChangeMessages: aggregatedChangeMessages.flat(),
    newProfileKeys: finalNewProfileKeys,
  };
}

function normalizeTextField(text: string | null | undefined): string {
  return text?.trim() ?? '';
}

function extractDiffs({
  current,
  dropInitialJoinMessage,
  isReJoin,
  old,
  promotedAciToPniMap,
  sourceServiceId,
}: {
  current: ConversationAttributesType;
  dropInitialJoinMessage?: boolean;
  isReJoin?: boolean;
  old: ConversationAttributesType;
  promotedAciToPniMap?: ReadonlyMap<AciString, PniString>;
  sourceServiceId?: ServiceIdString;
}): Array<GroupChangeMessageType> {
  const logId = idForLogging(old.groupId);
  const details: Array<GroupV2ChangeDetailType> = [];
  const ourAci = itemStorage.user.getCheckedAci();
  const ourPni = itemStorage.user.getPni();
  const ACCESS_ENUM = Proto.AccessControl.AccessRequired;

  let areWeInGroup = false;
  let serviceIdKindInvitedToGroup: ServiceIdKind | undefined;
  let areWePendingApproval = false;
  let whoInvitedUsUserId = null;

  function isUs(serviceId: ServiceIdString): boolean {
    return serviceId === ourAci || serviceId === ourPni;
  }
  function keepOnlyOurAdds(
    list: Array<GroupV2ChangeDetailType>
  ): Array<GroupV2ChangeDetailType> {
    return list.filter(
      item =>
        (item.type === 'member-add-from-invite' && isUs(item.aci)) ||
        (item.type === 'member-add-from-link' && isUs(item.aci)) ||
        (item.type === 'member-add-from-admin-approval' && isUs(item.aci)) ||
        (item.type === 'member-add' && isUs(item.aci))
    );
  }

  // access control

  if (
    current.accessControl &&
    old.accessControl &&
    old.accessControl.attributes !== undefined &&
    old.accessControl.attributes !== current.accessControl.attributes
  ) {
    details.push({
      type: 'access-attributes',
      newPrivilege: current.accessControl.attributes,
    });
  }
  if (
    current.accessControl &&
    old.accessControl &&
    old.accessControl.members !== undefined &&
    old.accessControl.members !== current.accessControl.members
  ) {
    details.push({
      type: 'access-members',
      newPrivilege: current.accessControl.members,
    });
  }
  if (
    current.accessControl &&
    old.accessControl &&
    old.accessControl.memberLabel !== undefined &&
    old.accessControl.memberLabel !==
      Proto.AccessControl.AccessRequired.UNKNOWN &&
    old.accessControl.memberLabel !== current.accessControl.memberLabel
  ) {
    details.push({
      type: 'access-member-label',
      newPrivilege:
        current.accessControl.memberLabel ??
        Proto.AccessControl.AccessRequired.UNKNOWN,
    });
  }

  const linkPreviouslyEnabled = isAccessControlEnabled(
    old.accessControl?.addFromInviteLink
  );
  const linkCurrentlyEnabled = isAccessControlEnabled(
    current.accessControl?.addFromInviteLink
  );

  if (!linkPreviouslyEnabled && linkCurrentlyEnabled) {
    details.push({
      type: 'group-link-add',
      privilege: current.accessControl?.addFromInviteLink || ACCESS_ENUM.ANY,
    });
  } else if (linkPreviouslyEnabled && !linkCurrentlyEnabled) {
    details.push({
      type: 'group-link-remove',
    });
  } else if (
    linkPreviouslyEnabled &&
    linkCurrentlyEnabled &&
    old.accessControl?.addFromInviteLink !==
      current.accessControl?.addFromInviteLink
  ) {
    details.push({
      type: 'access-invite-link',
      newPrivilege: current.accessControl?.addFromInviteLink || ACCESS_ENUM.ANY,
    });
  }

  // avatar

  if (old.avatar?.url !== current.avatar?.url) {
    details.push({
      type: 'avatar',
      removed: !current.avatar,
    });
  }

  // name
  const oldName = normalizeTextField(old.name);
  const newName = normalizeTextField(current.name);
  if (oldName !== newName) {
    details.push({
      type: 'title',
      newTitle: newName,
    });
  }

  // groupInviteLinkPassword

  // Note: we only capture link resets here. Enable/disable are controlled by the
  //   accessControl.addFromInviteLink
  if (
    old.groupInviteLinkPassword &&
    current.groupInviteLinkPassword &&
    old.groupInviteLinkPassword !== current.groupInviteLinkPassword
  ) {
    details.push({
      type: 'group-link-reset',
    });
  }

  // description
  const oldDescription = normalizeTextField(old.description);
  const newDescription = normalizeTextField(current.description);
  if (oldDescription !== newDescription) {
    details.push({
      type: 'description',
      removed: !newDescription,
      description: newDescription,
    });
  }

  // No disappearing message timer check here - see below

  // membersV2

  const oldMemberLookup = new Map<AciString, GroupV2MemberType>(
    (old.membersV2 || []).map(member => [member.aci, member])
  );
  const didWeStartInGroup = oldMemberLookup.has(ourAci);

  const oldPendingMemberLookup = new Map<
    ServiceIdString,
    GroupV2PendingMemberType
  >((old.pendingMembersV2 || []).map(member => [member.serviceId, member]));
  const oldPendingAdminApprovalLookup = new Map<
    AciString,
    GroupV2PendingAdminApprovalType
  >((old.pendingAdminApprovalV2 || []).map(member => [member.aci, member]));
  const currentPendingMemberSet = new Set<ServiceIdString>(
    (current.pendingMembersV2 || []).map(member => member.serviceId)
  );

  const aciToPniMap = new Map(promotedAciToPniMap?.entries());
  if (ourAci && ourPni) {
    aciToPniMap.set(ourAci, ourPni);
  }

  const pniToAciMap = new Map<PniString, AciString>();
  for (const [aci, pni] of aciToPniMap) {
    pniToAciMap.set(pni, aci);
  }

  (current.membersV2 || []).forEach(currentMember => {
    const { aci } = currentMember;
    const uuidIsUs = isUs(aci);

    if (uuidIsUs) {
      areWeInGroup = true;
    }

    const oldMember = oldMemberLookup.get(aci);
    if (!oldMember) {
      let pendingMember = oldPendingMemberLookup.get(aci);
      const pni = aciToPniMap.get(aci);
      if (!pendingMember && pni) {
        pendingMember = oldPendingMemberLookup.get(pni);

        // Someone's ACI just joined (wasn't a member before) and their PNI
        // disappeared from the invite list. Treat this as a promotion from PNI
        // to ACI and pretend that the PNI wasn't pending so that we won't
        // generate a pending-add-one notification below.
        if (pendingMember && !currentPendingMemberSet.has(pni)) {
          oldPendingMemberLookup.delete(pni);
        }
      }

      if (pendingMember) {
        details.push({
          type: 'member-add-from-invite',
          aci,
          pni,
          inviter: pendingMember.addedByUserId,
        });
      } else if (currentMember.joinedFromLink) {
        details.push({
          type: 'member-add-from-link',
          aci,
        });
      } else if (currentMember.approvedByAdmin) {
        details.push({
          type: 'member-add-from-admin-approval',
          aci,
        });
      } else {
        details.push({
          type: 'member-add',
          aci,
        });
      }
    } else if (oldMember.role !== currentMember.role) {
      details.push({
        type: 'member-privilege',
        aci,
        newPrivilege: currentMember.role,
      });
    }

    // We don't want to generate an admin-approval-remove event for this newly-added
    //   member. But we don't know for sure if this is an admin approval; for that we
    //   consulted the approvedByAdmin flag saved on the member.
    oldPendingAdminApprovalLookup.delete(aci);

    // If we capture a pending remove here, it's an 'accept invitation', and we don't
    //   want to generate a pending-remove event for it
    oldPendingMemberLookup.delete(aci);

    // This deletion makes it easier to capture removals
    oldMemberLookup.delete(aci);
  });

  const removedMemberIds = Array.from(oldMemberLookup.keys());
  removedMemberIds.forEach(aci => {
    details.push({
      type: 'member-remove',
      aci,
    });
  });

  // pendingMembersV2

  let lastPendingServiceId: ServiceIdString | undefined;
  let pendingCount = 0;
  (current.pendingMembersV2 || []).forEach(currentPendingMember => {
    const { serviceId } = currentPendingMember;
    const oldPendingMember = oldPendingMemberLookup.get(serviceId);

    if (isUs(serviceId)) {
      if (serviceId === ourAci) {
        serviceIdKindInvitedToGroup = ServiceIdKind.ACI;
      } else if (serviceIdKindInvitedToGroup === undefined) {
        serviceIdKindInvitedToGroup = ServiceIdKind.PNI;
      }

      whoInvitedUsUserId = currentPendingMember.addedByUserId;
    }

    if (!oldPendingMember) {
      lastPendingServiceId = serviceId;
      pendingCount += 1;
    }

    // This deletion makes it easier to capture removals
    oldPendingMemberLookup.delete(serviceId);
  });

  if (pendingCount > 1) {
    details.push({
      type: 'pending-add-many',
      count: pendingCount,
    });
  } else if (pendingCount === 1) {
    if (lastPendingServiceId) {
      details.push({
        type: 'pending-add-one',
        serviceId: lastPendingServiceId,
      });
    } else {
      log.warn(
        `extractDiffs/${logId}: pendingCount was 1, no last conversationId available`
      );
    }
  }

  // Note: The only members left over here should be people who were moved from the
  //   pending list but also not added to the group at the same time.
  const removedPendingMemberIds = Array.from(oldPendingMemberLookup.keys());
  if (removedPendingMemberIds.length > 1) {
    const firstUuid = removedPendingMemberIds[0];
    strictAssert(firstUuid, 'Missing firstUuid');
    const firstRemovedMember = oldPendingMemberLookup.get(firstUuid);
    strictAssert(
      firstRemovedMember !== undefined,
      'First removed member not found'
    );
    const inviter = firstRemovedMember.addedByUserId;
    const allSameInviter = removedPendingMemberIds.every(
      id => oldPendingMemberLookup.get(id)?.addedByUserId === inviter
    );
    details.push({
      type: 'pending-remove-many',
      count: removedPendingMemberIds.length,
      inviter: allSameInviter ? inviter : undefined,
    });
  } else if (removedPendingMemberIds.length === 1) {
    const serviceId = removedPendingMemberIds[0];
    strictAssert(serviceId, 'Missing serviceId');
    const removedMember = oldPendingMemberLookup.get(serviceId);
    strictAssert(removedMember !== undefined, 'Removed member not found');

    details.push({
      type: 'pending-remove-one',
      serviceId,
      inviter: removedMember.addedByUserId,
    });
  }

  // pendingAdminApprovalV2

  (current.pendingAdminApprovalV2 || []).forEach(
    currentPendingAdminAprovalMember => {
      const { aci } = currentPendingAdminAprovalMember;
      const oldPendingMember = oldPendingAdminApprovalLookup.get(aci);

      if (aci === ourAci) {
        areWePendingApproval = true;
      }

      if (!oldPendingMember) {
        details.push({
          type: 'admin-approval-add-one',
          aci,
        });
      }

      // This deletion makes it easier to capture removals
      oldPendingAdminApprovalLookup.delete(aci);
    }
  );

  // Note: The only members left over here should be people who were moved from the
  //   pendingAdminApproval list but also not added to the group at the same time.
  const removedPendingAdminApprovalIds = Array.from(
    oldPendingAdminApprovalLookup.keys()
  );
  removedPendingAdminApprovalIds.forEach(aci => {
    details.push({
      type: 'admin-approval-remove-one',
      aci,
    });
  });

  // announcementsOnly

  if (Boolean(old.announcementsOnly) !== Boolean(current.announcementsOnly)) {
    details.push({
      type: 'announcements-only',
      announcementsOnly: Boolean(current.announcementsOnly),
    });
  }

  // terminated

  if (Boolean(old.terminated) !== Boolean(current.terminated)) {
    strictAssert(
      current.terminated,
      'extractDiffs/terminated: terminated can only be set from false to true'
    );
    details.push({
      type: 'terminated',
    });
  }

  // Note: currently no diff generated for bannedMembersV2 changes

  // final processing

  let message: GroupChangeMessageType | undefined;
  let timerNotification: GroupChangeMessageType | undefined;

  const firstUpdate = !isNumber(old.revision);
  const isFromUs = ourAci === sourceServiceId;
  const justJoinedGroup = !firstUpdate && !didWeStartInGroup && areWeInGroup;

  const from =
    (sourceServiceId &&
      isPniString(sourceServiceId) &&
      pniToAciMap.get(sourceServiceId)) ||
    sourceServiceId;

  // Here we hardcode initial messages if this is our first time processing data for this
  //   group. Ideally we can collapse it down to just one of: 'you were added',
  //   'you were invited', or 'you created.'
  if (firstUpdate && serviceIdKindInvitedToGroup !== undefined) {
    // Note, we will add 'you were invited' to group even if dropInitialJoinMessage = true
    message = {
      type: 'group-v2-change',
      groupV2Change: {
        from: whoInvitedUsUserId || from,
        details: [
          {
            type: 'pending-add-one',
            serviceId: itemStorage.user.getCheckedServiceId(
              serviceIdKindInvitedToGroup
            ),
          },
        ],
      },
      readStatus: ReadStatus.Read,
      seenStatus: isFromUs ? SeenStatus.Seen : SeenStatus.Unseen,
    };
  } else if (firstUpdate && areWePendingApproval) {
    message = {
      type: 'group-v2-change',
      groupV2Change: {
        from: ourAci,
        details: [
          {
            type: 'admin-approval-add-one',
            aci: ourAci,
          },
        ],
      },
    };
  } else if (firstUpdate && dropInitialJoinMessage) {
    // None of the rest of the messages should be added if dropInitialJoinMessage = true
    message = undefined;
  } else if (
    firstUpdate &&
    current.revision === 0 &&
    sourceServiceId === ourAci
  ) {
    message = {
      type: 'group-v2-change',
      groupV2Change: {
        from,
        details: [
          {
            type: 'create',
          },
        ],
      },
      readStatus: ReadStatus.Read,
      seenStatus: isFromUs ? SeenStatus.Seen : SeenStatus.Unseen,
    };
  } else if (firstUpdate && areWeInGroup) {
    const filteredDetails = keepOnlyOurAdds(details);

    strictAssert(
      filteredDetails.length === 1,
      'extractDiffs/firstUpdate: Should be only one self-add!'
    );

    message = {
      type: 'group-v2-change',
      groupV2Change: {
        from,
        details: filteredDetails,
      },
      readStatus: ReadStatus.Read,
      seenStatus: isFromUs ? SeenStatus.Seen : SeenStatus.Unseen,
    };
  } else if (firstUpdate && current.revision === 0) {
    message = {
      type: 'group-v2-change',
      groupV2Change: {
        from,
        details: [
          {
            type: 'create',
          },
        ],
      },
      readStatus: ReadStatus.Read,
      seenStatus: isFromUs ? SeenStatus.Seen : SeenStatus.Unseen,
    };
  } else if (justJoinedGroup) {
    const filteredDetails = keepOnlyOurAdds(details);

    strictAssert(
      filteredDetails.length === 1,
      'extractDiffs/justJoinedGroup: Should be only one self-add!'
    );

    // If we've dropped other changes, we collapse them into a single summary
    if (details.length > 1) {
      filteredDetails.push({
        type: 'summary',
      });
    }

    message = {
      type: 'group-v2-change',
      sourceServiceId,
      groupV2Change: {
        from,
        details: filteredDetails,
      },
      readStatus: ReadStatus.Read,
      seenStatus: isFromUs ? SeenStatus.Seen : SeenStatus.Unseen,
    };
  } else if (details.length > 0) {
    let readStatus: ReadStatus;
    let seenStatus: SeenStatus;
    if (!isFromUs && details.find(detail => detail.type === 'terminated')) {
      // When a group is terminated, we want the unread count for this conversation
      // to go up, and we want the conversation list badged
      readStatus = ReadStatus.Unread;
      seenStatus = SeenStatus.Unseen;
    } else {
      readStatus = ReadStatus.Read;
      seenStatus = isFromUs ? SeenStatus.Seen : SeenStatus.Unseen;
    }

    message = {
      type: 'group-v2-change',
      sourceServiceId,
      groupV2Change: {
        from,
        details,
      },
      readStatus,
      seenStatus,
    };
  }

  // This is checked differently, because it needs to be its own entry in the timeline,
  //   with its own icon, etc.
  if (
    // Turn on or turned off
    Boolean(old.expireTimer) !== Boolean(current.expireTimer) ||
    // Still on, but changed value
    (Boolean(old.expireTimer) &&
      Boolean(current.expireTimer) &&
      old.expireTimer !== current.expireTimer)
  ) {
    const expireTimer = current.expireTimer || DurationInSeconds.ZERO;
    log.info(
      `extractDiffs/${logId}: generating change notification for new ${expireTimer} timer`
    );
    timerNotification = {
      type: 'timer-notification',
      sourceServiceId: isReJoin ? undefined : sourceServiceId,
      flags: Proto.DataMessage.Flags.EXPIRATION_TIMER_UPDATE,
      expirationTimerUpdate: {
        expireTimer,
        sourceServiceId: isReJoin ? undefined : sourceServiceId,
      },
      readStatus: ReadStatus.Read,
      seenStatus: isFromUs ? SeenStatus.Seen : SeenStatus.Unseen,
    };
  }

  const result = compact([message, timerNotification]);

  log.info(
    `extractDiffs/${logId} complete, generated ${result.length} change messages`
  );

  return result;
}

function profileKeysToMap(items: ReadonlyArray<GroupChangeMemberType>) {
  const map = new Map<AciString, string>();
  for (const { aci, profileKey } of items) {
    map.set(aci, Bytes.toBase64(profileKey));
  }
  return map;
}

type GroupChangeMemberType = {
  profileKey: Uint8Array<ArrayBuffer>;
  aci: AciString;
};
type GroupApplyResultType = {
  newAttributes: ConversationAttributesType;
  newProfileKeys: Array<GroupChangeMemberType>;
  otherChanges: boolean;
};

type GroupApplyChangeResultType = GroupApplyResultType & {
  promotedAciToPniMap: Map<AciString, PniString>;
};

async function applyGroupChange({
  actions,
  group,
  sourceServiceId,
}: {
  actions: DecryptedGroupChangeActions;
  group: ConversationAttributesType;
  sourceServiceId: ServiceIdString;
}): Promise<GroupApplyChangeResultType> {
  const logId = idForLogging(group.groupId);
  const ourAci = itemStorage.user.getCheckedAci();

  const version = actions.version || 0;
  let result = { ...group };
  const newProfileKeys: Array<GroupChangeMemberType> = [];
  const promotedAciToPniMap = new Map<AciString, PniString>();

  const members: Record<AciString, GroupV2MemberType> = fromPairs(
    (result.membersV2 || []).map(member => [member.aci, member])
  );
  const pendingMembers: Record<ServiceIdString, GroupV2PendingMemberType> =
    fromPairs(
      (result.pendingMembersV2 || []).map(member => [member.serviceId, member])
    );
  const pendingAdminApprovalMembers: Record<
    AciString,
    GroupV2PendingAdminApprovalType
  > = fromPairs(
    (result.pendingAdminApprovalV2 || []).map(member => [member.aci, member])
  );
  const bannedMembers = new Map<ServiceIdString, GroupV2BannedMemberType>(
    (result.bannedMembersV2 || []).map(member => [member.serviceId, member])
  );

  if (result.temporaryMemberCount) {
    log.warn(
      `applyGroupChange(${logId}): temporaryMemberCount is set, and should not be!`
    );
  }

  // version?: number;
  result.revision = version;

  // addMembers?: Array<GroupChange.Actions.AddMemberAction>;
  (actions.addMembers || []).forEach(addMember => {
    const { added } = addMember;
    if (!added || !added.userId) {
      throw new Error('applyGroupChange: addMember.added is missing');
    }

    const addedUuid = added.userId;

    if (members[addedUuid]) {
      log.warn(
        `applyGroupChange/${logId}: Attempt to add member failed; already in members.`
      );
      return;
    }

    members[addedUuid] = {
      aci: addedUuid,
      role: added.role || MemberRole.DEFAULT,
      joinedAtVersion: version,
      joinedFromLink: addMember.joinFromInviteLink || false,
    };

    if (pendingMembers[addedUuid]) {
      log.warn(
        `applyGroupChange/${logId}: Removing newly-added member from pendingMembers.`
      );
      delete pendingMembers[addedUuid];
    }

    // Capture who added us
    if (addedUuid === ourAci && pendingAdminApprovalMembers[ourAci]) {
      result.addedBy = ourAci;
    } else if (addedUuid === ourAci && sourceServiceId) {
      result.addedBy = sourceServiceId;
    }

    if (pendingAdminApprovalMembers[addedUuid]) {
      log.warn(
        `applyGroupChange/${logId}: Removing newly-added member from pendingAdminApprovalMembers.`
      );
      delete pendingAdminApprovalMembers[addedUuid];
    }

    if (added.profileKey) {
      newProfileKeys.push({
        profileKey: added.profileKey,
        aci: added.userId,
      });
    }
  });

  // deleteMembers?: Array<GroupChange.Actions.DeleteMemberAction>;
  (actions.deleteMembers || []).forEach(deleteMember => {
    const { deletedUserId } = deleteMember;
    if (!deletedUserId) {
      throw new Error(
        'applyGroupChange: deleteMember.deletedUserId is missing'
      );
    }

    if (members[deletedUserId]) {
      delete members[deletedUserId];
    } else {
      log.warn(
        `applyGroupChange/${logId}: Attempt to remove member failed; was not in members.`
      );
    }
  });

  // modifyMemberRoles?: Array<GroupChange.Actions.ModifyMemberRoleAction>;
  (actions.modifyMemberRoles || []).forEach(modifyMemberRole => {
    const { role, userId } = modifyMemberRole;
    if (!role || !userId) {
      throw new Error('applyGroupChange: modifyMemberRole had a missing value');
    }

    if (members[userId]) {
      members[userId] = {
        ...members[userId],
        role,
      };
    } else {
      throw new Error(
        'applyGroupChange: modifyMemberRole tried to modify nonexistent member'
      );
    }
  });

  // modifyMemberLabels?: Array<GroupChange.Actions.ModifyMemberLabelAction>;
  (actions.modifyMemberLabels || []).forEach(modifyMemberLabel => {
    const { userId, labelEmoji, labelString } = modifyMemberLabel;
    if (!userId) {
      throw new Error(
        'applyGroupChange: modifyMemberLabel had a missing userId'
      );
    }

    if (members[userId]) {
      members[userId] = {
        ...members[userId],
        labelEmoji,
        labelString,
      };
    } else {
      throw new Error(
        'applyGroupChange: modifyMemberLabel tried to modify nonexistent member'
      );
    }
  });

  // modifyMemberProfileKeys?:
  // Array<GroupChange.Actions.ModifyMemberProfileKeyAction>;
  (actions.modifyMemberProfileKeys || []).forEach(modifyMemberProfileKey => {
    const { profileKey, aci } = modifyMemberProfileKey;
    if (!profileKey || !aci) {
      throw new Error(
        'applyGroupChange: modifyMemberProfileKey had a missing value'
      );
    }

    if (aci === sourceServiceId || !hasProfileKey(aci)) {
      newProfileKeys.push({
        profileKey,
        aci,
      });
    } else {
      log.warn(
        `applyGroupChange/${logId}: Attempt to modify member profile key ` +
          'failed; sourceServiceId is not the same as change aci'
      );
    }
  });

  // addMembersPendingProfileKey?: Array<
  //   GroupChange.Actions.AddMemberPendingProfileKeyAction
  // >;
  (actions.addMembersPendingProfileKey || []).forEach(
    addMemberPendingProfileKey => {
      const { added } = addMemberPendingProfileKey;
      if (!added || !added.member || !added.member.userId) {
        throw new Error(
          'applyGroupChange: addMemberPendingProfileKey had a missing value'
        );
      }

      const addedUserId = added.member.userId;

      if (isAciString(addedUserId) && members[addedUserId]) {
        log.warn(
          `applyGroupChange/${logId}: Attempt to addMemberPendingProfileKey failed; was already in members.`
        );
        return;
      }
      if (pendingMembers[addedUserId]) {
        log.warn(
          `applyGroupChange/${logId}: Attempt to addMemberPendingProfileKey failed; was already in pendingMembers.`
        );
        return;
      }

      pendingMembers[addedUserId] = {
        serviceId: addedUserId,
        addedByUserId: added.addedByUserId,
        timestamp: added.timestamp,
        role: added.member.role || MemberRole.DEFAULT,
      };
    }
  );

  // deleteMembersPendingProfileKey?: Array<
  //   GroupChange.Actions.DeleteMemberPendingProfileKeyAction
  // >;
  (actions.deleteMembersPendingProfileKey || []).forEach(
    deleteMemberPendingProfileKey => {
      const { deletedUserId } = deleteMemberPendingProfileKey;
      if (!deletedUserId) {
        throw new Error(
          'applyGroupChange: deleteMemberPendingProfileKey.deletedUserId is null!'
        );
      }

      if (pendingMembers[deletedUserId]) {
        delete pendingMembers[deletedUserId];
      } else {
        log.warn(
          `applyGroupChange/${logId}: Attempt to deleteMemberPendingProfileKey failed; was not in pendingMembers.`
        );
      }
    }
  );

  // promoteMembersPendingProfileKey?: Array<
  //   GroupChange.Actions.PromoteMemberPendingProfileKeyAction
  // >;
  (actions.promoteMembersPendingProfileKey || []).forEach(
    promoteMemberPendingProfileKey => {
      const { profileKey, aci } = promoteMemberPendingProfileKey;
      if (!profileKey || !aci) {
        throw new Error(
          'applyGroupChange: promoteMemberPendingProfileKey had a missing value'
        );
      }

      const previousRecord = pendingMembers[aci];

      if (pendingMembers[aci]) {
        delete pendingMembers[aci];
      } else {
        log.warn(
          `applyGroupChange/${logId}: Attempt to promoteMemberPendingProfileKey failed; was not in pendingMembers.`
        );
        return;
      }

      if (members[aci]) {
        log.warn(
          `applyGroupChange/${logId}: Attempt to promoteMemberPendingProfileKey failed; was already in members.`
        );
        return;
      }

      members[aci] = {
        aci,
        joinedAtVersion: version,
        role: previousRecord?.role || MemberRole.DEFAULT,
      };

      newProfileKeys.push({
        profileKey,
        aci,
      });
    }
  );

  // promoteMembersPendingPniAciProfileKey?: Array<
  //   GroupChange.Actions.PromoteMemberPendingPniAciProfileKeyAction
  // >;
  (actions.promoteMembersPendingPniAciProfileKey || []).forEach(
    promotePendingMember => {
      const { profileKey, aci, pni } = promotePendingMember;
      if (!profileKey || !aci || !pni) {
        throw new Error(
          'applyGroupChange: promotePendingMember had a missing value'
        );
      }

      const previousRecord = pendingMembers[pni];

      promotedAciToPniMap.set(aci, pni);

      if (pendingMembers[pni]) {
        delete pendingMembers[pni];
      } else {
        log.warn(
          `applyGroupChange/${logId}: Attempt to promote pendingMember failed; was not in pendingMembers.`
        );
      }

      if (members[aci]) {
        log.warn(
          `applyGroupChange/${logId}: Attempt to promote pendingMember failed; was already in members.`
        );
        return;
      }

      members[aci] = {
        aci,
        joinedAtVersion: version,
        role: previousRecord?.role || MemberRole.DEFAULT,
      };

      newProfileKeys.push({
        profileKey,
        aci,
      });
    }
  );

  // modifyTitle?: GroupChange.Actions.ModifyTitleAction;
  if (actions.modifyTitle) {
    const title = actions.modifyTitle.title?.content?.title;
    if (title != null) {
      result.name = title.trim();
    } else {
      log.warn(
        `applyGroupChange/${logId}: Clearing group title due to missing data.`
      );
      result.name = undefined;
    }
  }

  // modifyAvatar?: GroupChange.Actions.ModifyAvatarAction;
  if (actions.modifyAvatar) {
    const { avatar } = actions.modifyAvatar;
    result = {
      ...result,
      ...(await applyNewAvatar({
        newAvatarUrl: dropNull(avatar),
        attributes: result,
        logId,
      })),
    };
  }

  // modifyDisappearingMessageTimer?:
  //   GroupChange.Actions.ModifyDisappearingMessageTimerAction;
  if (actions.modifyDisappearingMessageTimer) {
    const disappearingMessagesDuration =
      actions.modifyDisappearingMessageTimer.timer?.content
        ?.disappearingMessagesDuration;
    if (disappearingMessagesDuration != null) {
      result.expireTimer = DurationInSeconds.fromSeconds(
        disappearingMessagesDuration
      );
    } else {
      log.warn(
        `applyGroupChange/${logId}: Clearing group expireTimer due to missing data.`
      );
      result.expireTimer = undefined;
    }
  }

  const accessControl = {
    members: AccessRequired.MEMBER,
    attributes: AccessRequired.MEMBER,
    addFromInviteLink: AccessRequired.UNSATISFIABLE,
    memberLabel: AccessRequired.MEMBER,
    // Create a copy with defaults
    ...result.accessControl,
  };

  // modifyAttributesAccess?:
  // GroupChange.Actions.ModifyAttributesAccessControlAction;

  if (actions.modifyAttributesAccess) {
    const access = actions.modifyAttributesAccess?.attributesAccess;
    accessControl.attributes = isValidAccess(access)
      ? access
      : AccessRequired.MEMBER;
  }

  // modifyMemberAccess?: GroupChange.Actions.ModifyMembersAccessControlAction;
  if (actions.modifyMemberAccess) {
    const access = actions.modifyMemberAccess?.membersAccess;
    accessControl.members = isValidAccess(access)
      ? access
      : AccessRequired.MEMBER;
  }

  // modifyAddFromInviteLinkAccess?:
  //   GroupChange.Actions.ModifyAddFromInviteLinkAccessControlAction;
  if (actions.modifyAddFromInviteLinkAccess) {
    const linkAccess =
      actions.modifyAddFromInviteLinkAccess?.addFromInviteLinkAccess;
    accessControl.addFromInviteLink = isValidLinkAccess(linkAccess)
      ? linkAccess
      : AccessRequired.UNSATISFIABLE;
  }

  // modify_member_label_access?:
  //   GroupChange.Actions.ModifyMemberLabelAccessControlAction;
  if (actions.modifyMemberLabelAccess) {
    const access = actions.modifyMemberLabelAccess?.memberLabelAccess;
    accessControl.memberLabel = isValidAccess(access)
      ? access
      : AccessRequired.MEMBER;
  }

  result.accessControl = accessControl;

  // addMembersPendingAdminApproval?: Array<
  //   GroupChange.Actions.AddMemberPendingAdminApprovalAction
  // >;
  (actions.addMembersPendingAdminApproval || []).forEach(
    addMemberPendingAdminApproval => {
      const { added } = addMemberPendingAdminApproval;
      if (!added) {
        throw new Error(
          'applyGroupChange: modifyMemberProfileKey had a missing value'
        );
      }

      if (members[added.userId]) {
        log.warn(
          `applyGroupChange/${logId}: Attempt to addMemberPendingAdminApproval failed; was already in members.`
        );
        return;
      }
      if (pendingMembers[added.userId]) {
        log.warn(
          `applyGroupChange/${logId}: Attempt to addMemberPendingAdminApprovals failed; was already in pendingMembers.`
        );
        return;
      }
      if (pendingAdminApprovalMembers[added.userId]) {
        log.warn(
          `applyGroupChange/${logId}: Attempt to addMemberPendingAdminApprovals failed; was already in pendingAdminApprovalMembers.`
        );
        return;
      }

      pendingAdminApprovalMembers[added.userId] = {
        aci: added.userId,
        timestamp: added.timestamp,
      };

      if (added.profileKey) {
        newProfileKeys.push({
          profileKey: added.profileKey,
          aci: added.userId,
        });
      }
    }
  );

  // deleteMembersPendingAdminApproval?: Array<
  //   GroupChange.Actions.DeleteMemberPendingAdminApprovalAction
  // >;
  (actions.deleteMembersPendingAdminApproval || []).forEach(
    deleteMemberPendingAdminApproval => {
      const { deletedUserId } = deleteMemberPendingAdminApproval;
      if (!deletedUserId) {
        throw new Error(
          'applyGroupChange: deleteMemberPendingAdminApproval.deletedUserId is null!'
        );
      }

      if (pendingAdminApprovalMembers[deletedUserId]) {
        delete pendingAdminApprovalMembers[deletedUserId];
      } else {
        log.warn(
          `applyGroupChange/${logId}: Attempt to deleteMemberPendingAdminApproval failed; was not in pendingAdminApprovalMembers.`
        );
      }
    }
  );

  // promoteMemberPendingAdminApprovals?: Array<
  //   GroupChange.Actions.PromoteMemberPendingAdminApprovalAction
  // >;
  (actions.promoteMembersPendingAdminApproval || []).forEach(
    promoteMemberPendingAdminApproval => {
      const { userId, role } = promoteMemberPendingAdminApproval;
      if (!userId) {
        throw new Error(
          'applyGroupChange: promoteMemberPendingAdminApproval had a missing value'
        );
      }

      if (pendingAdminApprovalMembers[userId]) {
        delete pendingAdminApprovalMembers[userId];
      } else {
        log.warn(
          `applyGroupChange/${logId}: Attempt to promoteMemberPendingAdminApproval failed; was not in pendingAdminApprovalMembers.`
        );
        return;
      }
      if (pendingMembers[userId]) {
        delete pendingAdminApprovalMembers[userId];
        log.warn(
          `applyGroupChange/${logId}: promoteMemberPendingAdminApproval removed from from pendingMembers.`
        );
      }

      if (members[userId]) {
        log.warn(
          `applyGroupChange/${logId}: Attempt to promoteMemberPendingAdminApproval failed; was already in members.`
        );
        return;
      }

      // If we had requested to join, and are approved, we added ourselves
      if (userId === ourAci) {
        result.addedBy = ourAci;
      }

      members[userId] = {
        aci: userId,
        joinedAtVersion: version,
        role: role || MemberRole.DEFAULT,
        approvedByAdmin: true,
      };
    }
  );

  // modifyInviteLinkPassword?: GroupChange.Actions.ModifyInviteLinkPasswordAction;
  if (actions.modifyInviteLinkPassword) {
    const { inviteLinkPassword } = actions.modifyInviteLinkPassword;
    if (inviteLinkPassword) {
      result.groupInviteLinkPassword = inviteLinkPassword;
    } else {
      result.groupInviteLinkPassword = undefined;
    }
  }

  // modifyDescription?: GroupChange.Actions.ModifyDescriptionAction;
  if (actions.modifyDescription) {
    const description =
      actions.modifyDescription.description?.content?.descriptionText;
    if (description != null) {
      result.description = description.trim();
    } else {
      log.warn(
        `applyGroupChange/${logId}: Clearing group description due to missing data.`
      );
      result.description = undefined;
    }
  }

  if (actions.modifyAnnouncementsOnly) {
    const { announcementsOnly } = actions.modifyAnnouncementsOnly;
    result.announcementsOnly = announcementsOnly;
  }

  if (actions.terminateGroup) {
    result.terminated = true;
  }

  if (actions.addMembersBanned && actions.addMembersBanned.length > 0) {
    actions.addMembersBanned.forEach(member => {
      if (bannedMembers.has(member.serviceId)) {
        log.warn(
          `applyGroupChange/${logId}: Attempt to add banned member failed; was already in banned list.`
        );
        return;
      }

      bannedMembers.set(member.serviceId, member);
    });
  }

  if (actions.deleteMembersBanned && actions.deleteMembersBanned.length > 0) {
    actions.deleteMembersBanned.forEach(serviceId => {
      if (!bannedMembers.has(serviceId)) {
        log.warn(
          `applyGroupChange/${logId}: Attempt to remove banned member failed; was not in banned list.`
        );
        return;
      }

      bannedMembers.delete(serviceId);
    });
  }

  if (ourAci) {
    result.left = !members[ourAci];
  }
  if (result.left) {
    result.addedBy = undefined;
  }

  // Go from lookups back to arrays
  result.membersV2 = values(members);
  result.pendingMembersV2 = values(pendingMembers);
  result.pendingAdminApprovalV2 = values(pendingAdminApprovalMembers);
  result.bannedMembersV2 = Array.from(bannedMembers.values());

  return {
    newAttributes: result,
    newProfileKeys,
    otherChanges: false,
    promotedAciToPniMap,
  };
}

export async function decryptGroupAvatar(
  avatarKey: string,
  secretParamsBase64: string
): Promise<Uint8Array<ArrayBuffer>> {
  const ciphertext = await getGroupAvatar(avatarKey);
  const clientZkGroupCipher = getClientZkGroupCipher(secretParamsBase64);
  const plaintext = decryptGroupBlob(clientZkGroupCipher, ciphertext);
  const blob = Proto.GroupAttributeBlob.decode(plaintext);

  if (blob.content?.avatar == null) {
    throw new Error(
      // oxlint-disable-next-line typescript/no-base-to-string, typescript/restrict-template-expressions
      `decryptGroupAvatar: Returned blob had incorrect content: ${blob.content}`
    );
  }

  return blob.content.avatar;
}

// Overwriting result.avatar as part of functionality
export async function applyNewAvatar(
  options:
    | {
        newAvatarUrl?: string | undefined;
        attributes: Pick<ConversationAttributesType, 'avatar' | 'secretParams'>;
        logId: string;
        forceDownload: true;
      }
    | {
        newAvatarUrl?: string | undefined;
        attributes: ConversationAttributesType;
        logId: string;
        forceDownload?: false | undefined;
      }
): Promise<Pick<ConversationAttributesType, 'avatar' | 'remoteAvatarUrl'>> {
  const { newAvatarUrl, attributes, logId, forceDownload } = options;
  const result: Pick<ConversationAttributesType, 'avatar' | 'remoteAvatarUrl'> =
    {};
  try {
    // Avatar has been dropped
    if (!newAvatarUrl && attributes.avatar) {
      if (attributes.avatar.path) {
        await maybeDeleteAttachmentFile(attributes.avatar.path);
      }
      result.avatar = undefined;
    }

    const avatarUrlToUse =
      newAvatarUrl ||
      ('remoteAvatarUrl' in attributes
        ? attributes.remoteAvatarUrl
        : undefined);

    // Group has avatar; has it changed?
    if (
      avatarUrlToUse &&
      (!attributes.avatar?.path || attributes.avatar.url !== avatarUrlToUse)
    ) {
      if (!attributes.secretParams) {
        throw new Error('applyNewAvatar: group was missing secretParams!');
      }

      if (
        !forceDownload &&
        (areWePending(attributes) || !isConversationAccepted(attributes))
      ) {
        result.remoteAvatarUrl = avatarUrlToUse;
        return result;
      }

      const data: Uint8Array<ArrayBuffer> = await decryptGroupAvatar(
        avatarUrlToUse,
        attributes.secretParams
      );

      const hash = computeHash(data);

      if (attributes.avatar?.hash === hash) {
        log.info(
          `applyNewAvatar/${logId}: Hash is the same, but url was different. Saving new url.`
        );
        result.avatar = {
          ...attributes.avatar,
          url: avatarUrlToUse,
        };
        return result;
      }

      if (attributes.avatar?.path) {
        await maybeDeleteAttachmentFile(attributes.avatar.path);
      }
      const local = await writeNewAttachmentData(data);
      result.avatar = {
        url: avatarUrlToUse,
        ...local,
        hash,
      };
    }
  } catch (error) {
    log.warn(
      `applyNewAvatar/${logId} Failed to handle avatar, clearing it`,
      Errors.toLogFormat(error)
    );
    if (result.avatar && result.avatar.path) {
      await maybeDeleteAttachmentFile(result.avatar.path);
    }
    result.avatar = undefined;
  }
  return result;
}

function profileKeyHasChanged(
  userId: ServiceIdString,
  newProfileKey: Uint8Array<ArrayBuffer>
) {
  const conversation = window.ConversationController.get(userId);
  if (!conversation) {
    return true;
  }

  const existingBase64 = conversation.get('profileKey');
  if (!existingBase64) {
    return true;
  }

  const newBase64 = Bytes.toBase64(newProfileKey);

  return newBase64 !== existingBase64;
}

function hasProfileKey(userId: ServiceIdString) {
  const conversation = window.ConversationController.get(userId);
  if (!conversation) {
    return false;
  }

  const existingBase64 = conversation.get('profileKey');
  return existingBase64 !== undefined;
}

async function applyGroupState({
  group,
  groupState,
  sourceServiceId,
}: {
  group: ConversationAttributesType;
  groupState: DecryptedGroupState;
  sourceServiceId?: ServiceIdString;
}): Promise<GroupApplyResultType> {
  const logId = idForLogging(group.groupId);
  const version = groupState.version || 0;
  let result = { ...group };
  const newProfileKeys: Array<GroupChangeMemberType> = [];

  // Used to capture changes not already expressed in group notifications or profile keys
  let otherChanges = false;

  // Used to detect changes in these lists
  const members: Record<string, GroupV2MemberType> = fromPairs(
    (result.membersV2 || []).map(member => [member.aci, member])
  );
  const pendingMembers: Record<string, GroupV2PendingMemberType> = fromPairs(
    (result.pendingMembersV2 || []).map(member => [member.serviceId, member])
  );
  const pendingAdminApprovalMembers: Record<
    string,
    GroupV2PendingAdminApprovalType
  > = fromPairs(
    (result.pendingAdminApprovalV2 || []).map(member => [member.aci, member])
  );
  const bannedMembers = new Map<string, GroupV2BannedMemberType>(
    (result.bannedMembersV2 || []).map(member => [member.serviceId, member])
  );

  // version
  result.revision = version;

  // title
  // Note: During decryption, title becomes a GroupAttributeBlob
  const title = groupState.title?.content?.title;
  if (title != null) {
    result.name = title.trim();
  } else {
    result.name = undefined;
  }

  // disappearingMessagesTimer
  // Note: during decryption, disappearingMessageTimer becomes a GroupAttributeBlob
  const disappearingMessagesDuration =
    groupState.disappearingMessagesTimer?.content?.disappearingMessagesDuration;
  if (disappearingMessagesDuration != null) {
    result.expireTimer = DurationInSeconds.fromSeconds(
      disappearingMessagesDuration
    );
  } else {
    result.expireTimer = undefined;
  }

  // accessControl
  const { accessControl } = groupState;
  result.accessControl = {
    attributes:
      (accessControl && accessControl.attributes) || AccessRequired.MEMBER,
    members: (accessControl && accessControl.members) || AccessRequired.MEMBER,
    addFromInviteLink:
      (accessControl && accessControl.addFromInviteLink) ||
      AccessRequired.UNSATISFIABLE,
    memberLabel:
      (accessControl && accessControl.memberLabel) || AccessRequired.MEMBER,
  };

  // Optimization: we assume we have left the group unless we are found in members
  result.left = true;
  const ourAci = itemStorage.user.getCheckedAci();

  // members
  const wasPreviouslyAMember = (result.membersV2 || []).some(
    item => item.aci !== ourAci
  );
  if (groupState.members) {
    result.membersV2 = groupState.members.map(member => {
      if (member.userId === ourAci) {
        result.left = false;

        // Capture who added us if we were previously not in group
        if (pendingAdminApprovalMembers[ourAci] && !wasPreviouslyAMember) {
          result.addedBy = sourceServiceId;
        } else if (
          sourceServiceId &&
          !wasPreviouslyAMember &&
          isNumber(member.joinedAtVersion) &&
          member.joinedAtVersion === version
        ) {
          result.addedBy = sourceServiceId;
        }
      }

      if (!isValidRole(member.role)) {
        throw new Error(
          `applyGroupState: Member had invalid role ${member.role}`
        );
      }

      const previousMember = members[member.userId];
      if (member.profileKey && !hasProfileKey(member.userId)) {
        newProfileKeys.push({
          profileKey: member.profileKey,
          aci: member.userId,
        });
      } else if (
        member.profileKey &&
        profileKeyHasChanged(member.userId, member.profileKey)
      ) {
        log.warn(
          `applyGroupState(${logId}): Member ${member.userId} had different profileKey`
        );
        otherChanges = true;
      } else if (!previousMember) {
        otherChanges = true;
      }

      if (
        previousMember &&
        previousMember.joinedAtVersion !== member.joinedAtVersion
      ) {
        otherChanges = true;
        log.warn(
          `applyGroupState(${logId}): Member ${member.userId} had different joinedAtVersion`
        );
      }
      // Note: role changes will be reflected in group update messages

      return {
        role: member.role || MemberRole.DEFAULT,
        joinedAtVersion: member.joinedAtVersion,
        aci: member.userId,
        labelEmoji: member.labelEmoji,
        labelString: member.labelString,
      };
    });
  }

  // membersPendingProfileKey
  if (groupState.membersPendingProfileKey) {
    result.pendingMembersV2 = groupState.membersPendingProfileKey.map(
      member => {
        if (!member.member || !member.member.userId) {
          throw new Error(
            'applyGroupState: Member pending profile key did not have an associated userId'
          );
        }

        if (!member.addedByUserId) {
          throw new Error(
            'applyGroupState: Member pending profile key did not have an addedByUserID'
          );
        }

        if (!isValidRole(member.member.role)) {
          throw new Error(
            `applyGroupState: Member pending profile key had invalid role ${member.member.role}`
          );
        }

        const previousMember = pendingMembers[member.member.userId];
        otherChanges = true;

        if (
          previousMember &&
          previousMember.addedByUserId !== member.addedByUserId
        ) {
          otherChanges = true;
          log.warn(
            `applyGroupState(${logId}): Member ${member.member.userId} had different addedByUserId`
          );
        }
        if (previousMember && previousMember.timestamp !== member.timestamp) {
          otherChanges = true;
          log.warn(
            `applyGroupState(${logId}): Member ${member.member.userId} had different timestamp`
          );
        }
        if (previousMember && previousMember.role !== member.member.role) {
          otherChanges = true;
          log.warn(
            `applyGroupState(${logId}): Member ${member.member.userId} had different role`
          );
        }

        return {
          addedByUserId: member.addedByUserId,
          serviceId: member.member.userId,
          timestamp: member.timestamp,
          role: member.member.role || MemberRole.DEFAULT,
        };
      }
    );
  }

  // membersPendingAdminApproval
  if (groupState.membersPendingAdminApproval) {
    result.pendingAdminApprovalV2 = groupState.membersPendingAdminApproval.map(
      member => {
        const previousMember = pendingAdminApprovalMembers[member.userId];
        if (member.profileKey && !hasProfileKey(member.userId)) {
          newProfileKeys.push({
            profileKey: member.profileKey,
            aci: member.userId,
          });
        } else if (
          member.profileKey &&
          profileKeyHasChanged(member.userId, member.profileKey)
        ) {
          log.warn(
            `applyGroupState(${logId}): Member ${member.userId} had different profileKey`
          );
          otherChanges = true;
        } else if (!previousMember) {
          otherChanges = true;
        }

        if (previousMember && previousMember.timestamp !== member.timestamp) {
          otherChanges = true;
          log.warn(
            `applyGroupState(${logId}): Member ${member.userId} had different timestamp`
          );
        }

        return {
          aci: member.userId,
          timestamp: member.timestamp,
        };
      }
    );
  }

  // inviteLinkPassword
  const { inviteLinkPassword } = groupState;
  if (inviteLinkPassword) {
    result.groupInviteLinkPassword = inviteLinkPassword;
  } else {
    result.groupInviteLinkPassword = undefined;
  }

  // description
  const description = groupState.description?.content?.descriptionText;
  if (description != null) {
    result.description = description.trim();
  } else {
    result.description = undefined;
  }

  // announcementsOnly
  result.announcementsOnly = groupState.announcementsOnly;

  // terminated
  result.terminated = groupState.terminated;

  // membersBanned
  result.bannedMembersV2 = groupState.membersBanned?.map(member => {
    const previousMember = bannedMembers.get(member.serviceId);
    if (!previousMember) {
      otherChanges = true;
    }
    if (previousMember && previousMember.timestamp !== member.timestamp) {
      otherChanges = true;
      log.warn(
        `applyGroupState(${logId}): Member ${member.serviceId} had different timestamp`
      );
    }

    return member;
  });

  // avatar
  result = {
    ...result,
    ...(await applyNewAvatar({
      newAvatarUrl: dropNull(groupState.avatarUrl),
      attributes: result,
      logId,
    })),
  };

  if (result.left) {
    result.addedBy = undefined;
  }

  if (result.temporaryMemberCount) {
    log.info(`applyGroupState(${logId}): Clearing temporaryMemberCount`);
    result.temporaryMemberCount = undefined;
  }

  return {
    newAttributes: result,
    newProfileKeys,
    otherChanges,
  };
}

function isValidRole(
  role?: Proto.Member.Params['role']
): role is MemberRole.ADMINISTRATOR | MemberRole.DEFAULT {
  return role === MemberRole.ADMINISTRATOR || role === MemberRole.DEFAULT;
}

function isValidAccess(
  access?: Proto.AccessControl.Params['attributes']
): access is AccessRequired.ADMINISTRATOR | AccessRequired.MEMBER {
  // TODO(DESKTOP-9868)
  return (
    access === AccessRequired.ADMINISTRATOR ||
    access === AccessRequired.MEMBER ||
    access === AccessRequired.UNKNOWN
  );
}

function isValidLinkAccess(
  access?: Proto.AccessControl.Params['addFromInviteLink']
): access is
  | AccessRequired.UNKNOWN
  | AccessRequired.ANY
  | AccessRequired.ADMINISTRATOR
  | AccessRequired.UNSATISFIABLE {
  // TODO(DESKTOP-9868)
  return (
    access === AccessRequired.UNKNOWN ||
    access === AccessRequired.ANY ||
    access === AccessRequired.ADMINISTRATOR ||
    access === AccessRequired.UNSATISFIABLE
  );
}

function isValidProfileKey(buffer?: Uint8Array<ArrayBuffer>): boolean {
  return Boolean(buffer && buffer.length === 32);
}

function normalizeTimestamp(timestamp: bigint | null | undefined): number {
  if (!timestamp) {
    return 0;
  }

  const asNumber = toNumber(timestamp);

  const now = Date.now();
  if (!asNumber || asNumber > now) {
    return now;
  }

  return asNumber;
}

type DecryptedModifyMemberLabelAction = {
  userId: AciString;
  labelEmoji?: string;
  labelString?: string;
};

type DecryptedGroupChangeActions = {
  version?: number;
  sourceServiceId?: ServiceIdString;
  addMembers?: ReadonlyArray<{
    added: DecryptedMember;
    joinFromInviteLink: boolean;
  }>;
  deleteMembers?: ReadonlyArray<{
    deletedUserId: AciString;
  }>;
  modifyMemberRoles?: ReadonlyArray<{
    userId: AciString;
    role: MemberRole;
  }>;
  modifyMemberLabels?: ReadonlyArray<DecryptedModifyMemberLabelAction>;
  modifyMemberProfileKeys?: ReadonlyArray<{
    profileKey: Uint8Array<ArrayBuffer>;
    aci: AciString;
  }>;
  addMembersPendingProfileKey?: ReadonlyArray<{
    added: DecryptedMemberPendingProfileKey;
  }>;
  deleteMembersPendingProfileKey?: ReadonlyArray<{
    // This might be a PNI
    deletedUserId: ServiceIdString;
  }>;
  promoteMembersPendingProfileKey?: ReadonlyArray<{
    profileKey: Uint8Array<ArrayBuffer>;
    aci: AciString;
  }>;
  promoteMembersPendingPniAciProfileKey?: ReadonlyArray<{
    profileKey: Uint8Array<ArrayBuffer>;
    aci: AciString;
    pni: PniString;
  }>;
  modifyTitle?: {
    title?: Proto.GroupAttributeBlob;
  };
  modifyDisappearingMessageTimer?: {
    timer?: Proto.GroupAttributeBlob;
  };
  addMembersPendingAdminApproval?: ReadonlyArray<{
    added: DecryptedMemberPendingAdminApproval;
  }>;
  deleteMembersPendingAdminApproval?: ReadonlyArray<{
    deletedUserId: AciString;
  }>;
  promoteMembersPendingAdminApproval?: ReadonlyArray<{
    userId: AciString;
    role: MemberRole;
  }>;
  modifyInviteLinkPassword?: {
    inviteLinkPassword?: string;
  };
  modifyDescription?: {
    description?: Proto.GroupAttributeBlob;
  };
  modifyAnnouncementsOnly?: {
    announcementsOnly: boolean;
  };
  addMembersBanned?: ReadonlyArray<GroupV2BannedMemberType>;
  // This might be a PNI
  deleteMembersBanned?: ReadonlyArray<ServiceIdString>;
  modifyAttributesAccess?: {
    attributesAccess: AccessRequired;
  };
  modifyMemberAccess?: {
    membersAccess: AccessRequired;
  };
  modifyAddFromInviteLinkAccess?: {
    addFromInviteLinkAccess: AccessRequired;
  };
  modifyMemberLabelAccess?: {
    memberLabelAccess: AccessRequired;
  };
  modifyAvatar?: {
    avatar: string;
  };
  terminateGroup?: Record<string, never>;
};

function decryptGroupChange(
  actions: Readonly<Proto.GroupChange.Actions>,
  groupSecretParams: string,
  logId: string
): DecryptedGroupChangeActions {
  const result: DecryptedGroupChangeActions = {
    version: dropNull(actions.version),
  };

  const clientZkGroupCipher = getClientZkGroupCipher(groupSecretParams);

  if (actions.sourceUserId && actions.sourceUserId.length !== 0) {
    try {
      result.sourceServiceId = decryptServiceId(
        clientZkGroupCipher,
        actions.sourceUserId
      );
    } catch (error) {
      log.warn(
        `decryptGroupChange/${logId}: Unable to decrypt sourceServiceId.`,
        Errors.toLogFormat(error)
      );
    }

    if (!result.sourceServiceId || !isServiceIdString(result.sourceServiceId)) {
      log.warn(
        `decryptGroupChange/${logId}: Invalid sourceServiceId. Clearing sourceServiceId.`
      );
      result.sourceServiceId = undefined;
    }
  } else {
    throw new Error('decryptGroupChange: Missing sourceServiceId');
  }

  // addMembers?: Array<GroupChange.Actions.AddMemberAction>;
  result.addMembers = compact(
    (actions.addMembers || []).map(addMember => {
      strictAssert(
        addMember.added,
        'decryptGroupChange: AddMember was missing added field!'
      );

      const decrypted = decryptMember(
        clientZkGroupCipher,
        addMember.added,
        logId
      );
      if (!decrypted) {
        return null;
      }

      return {
        added: decrypted,
        joinFromInviteLink: addMember.joinFromInviteLink,
      };
    })
  );

  // deleteMembers?: Array<GroupChange.Actions.DeleteMemberAction>;
  result.deleteMembers = compact(
    (actions.deleteMembers || []).map(deleteMember => {
      const { deletedUserId } = deleteMember;
      strictAssert(
        Bytes.isNotEmpty(deletedUserId),
        'decryptGroupChange: deleteMember.deletedUserId was missing'
      );

      let userId: AciString;
      try {
        userId = decryptAci(clientZkGroupCipher, deletedUserId);
      } catch (error) {
        log.warn(
          `decryptGroupChange/${logId}: Unable to decrypt deleteMembers.deletedUserId. Dropping member.`,
          Errors.toLogFormat(error)
        );
        return null;
      }

      return { deletedUserId: userId };
    })
  );

  // modifyMemberRoles?: Array<GroupChange.Actions.ModifyMemberRoleAction>;
  result.modifyMemberRoles = compact(
    (actions.modifyMemberRoles || []).map(modifyMember => {
      strictAssert(
        Bytes.isNotEmpty(modifyMember.userId),
        'decryptGroupChange: modifyMemberRole.userId was missing'
      );

      let userId: AciString;
      try {
        userId = decryptAci(clientZkGroupCipher, modifyMember.userId);
      } catch (error) {
        log.warn(
          `decryptGroupChange/${logId}: Unable to decrypt modifyMemberRole.userId. Dropping member.`,
          Errors.toLogFormat(error)
        );
        return null;
      }

      const { role } = modifyMember;
      if (!isValidRole(role)) {
        throw new Error(
          `decryptGroupChange: modifyMemberRole had invalid role ${modifyMember.role}`
        );
      }

      return {
        role,
        userId,
      };
    })
  );

  // modifyMemberLabels?: Array<GroupChange.Actions.ModifyMemberLabelAction>
  result.modifyMemberLabels = compact(
    (actions.modifyMemberLabels || []).map(modifyMemberLabel =>
      decryptModifyMemberLabelAction(
        clientZkGroupCipher,
        modifyMemberLabel,
        logId
      )
    )
  );

  // modifyMemberProfileKeys?: Array<
  //   GroupChange.Actions.ModifyMemberProfileKeyAction
  // >;
  result.modifyMemberProfileKeys = compact(
    (actions.modifyMemberProfileKeys || []).map(modifyMemberProfileKey => {
      let { userId, profileKey: encryptedProfileKey } = modifyMemberProfileKey;

      // TODO: DESKTOP-3816
      if (Bytes.isEmpty(userId) || Bytes.isEmpty(encryptedProfileKey)) {
        const { presentation } = modifyMemberProfileKey;

        strictAssert(
          Bytes.isNotEmpty(presentation),
          'decryptGroupChange: modifyMemberProfileKeys.presentation was missing'
        );

        const decodedPresentation =
          decodeProfileKeyCredentialPresentation(presentation);

        ({ userId, profileKey: encryptedProfileKey } = decodedPresentation);
      }

      strictAssert(
        Bytes.isNotEmpty(userId),
        'decryptGroupChange: modifyMemberProfileKeys.userId was missing'
      );
      strictAssert(
        Bytes.isNotEmpty(encryptedProfileKey),
        'decryptGroupChange: modifyMemberProfileKeys.profileKey was missing'
      );

      let aci: AciString;
      let profileKey: Uint8Array<ArrayBuffer>;
      try {
        aci = decryptAci(clientZkGroupCipher, userId);

        profileKey = decryptProfileKey(
          clientZkGroupCipher,
          encryptedProfileKey,
          aci
        );
      } catch (error) {
        log.warn(
          `decryptGroupChange/${logId}: Unable to decrypt ` +
            'modifyMemberProfileKeys.userId/profileKey. Dropping member.',
          Errors.toLogFormat(error)
        );
        return null;
      }

      if (!isValidProfileKey(profileKey)) {
        throw new Error(
          'decryptGroupChange: modifyMemberProfileKey had invalid profileKey'
        );
      }

      return { aci, profileKey };
    })
  );

  // addPendingMembers?: Array<
  //   GroupChange.Actions.AddMemberPendingProfileKeyAction
  // >;
  result.addMembersPendingProfileKey = compact(
    (actions.addMembersPendingProfileKey || []).map(
      addMemberPendingProfileKey => {
        strictAssert(
          addMemberPendingProfileKey.added,
          'decryptGroupChange: addMemberPendingProfileKey was missing added field!'
        );
        const decrypted = decryptMemberPendingProfileKey(
          clientZkGroupCipher,
          addMemberPendingProfileKey.added,
          logId
        );
        if (!decrypted) {
          return null;
        }

        return {
          added: decrypted,
        };
      }
    )
  );

  // deletePendingMembers?: Array<
  //   GroupChange.Actions.DeleteMemberPendingProfileKeyAction
  // >;
  result.deleteMembersPendingProfileKey = compact(
    (actions.deleteMembersPendingProfileKey || []).map(
      deleteMemberPendingProfileKey => {
        const { deletedUserId } = deleteMemberPendingProfileKey;
        strictAssert(
          Bytes.isNotEmpty(deletedUserId),
          'decryptGroupChange: deleteMemberPendingProfileKey.deletedUserId was missing'
        );
        let userId: ServiceIdString;
        try {
          userId = decryptServiceId(clientZkGroupCipher, deletedUserId);
        } catch (error) {
          log.warn(
            `decryptGroupChange/${logId}: Unable to decrypt deleteMemberPendingProfileKey.deletedUserId. Dropping member.`,
            Errors.toLogFormat(error)
          );
          return null;
        }

        if (!isServiceIdString(userId)) {
          log.warn(
            `decryptGroupChange/${logId}: Dropping deleteMemberPendingProfileKey due to invalid deletedUserId`
          );

          return null;
        }

        return {
          deletedUserId: userId,
        };
      }
    )
  );

  // promotePendingMembers?: Array<
  //   GroupChange.Actions.PromoteMemberPendingProfileKeyAction
  // >;
  result.promoteMembersPendingProfileKey = compact(
    (actions.promoteMembersPendingProfileKey || []).map(
      promoteMemberPendingProfileKey => {
        let { userId, profileKey: encryptedProfileKey } =
          promoteMemberPendingProfileKey;

        // TODO: DESKTOP-3816
        if (Bytes.isEmpty(userId) || Bytes.isEmpty(encryptedProfileKey)) {
          const { presentation } = promoteMemberPendingProfileKey;

          strictAssert(
            Bytes.isNotEmpty(presentation),
            'decryptGroupChange: promoteMemberPendingProfileKey.presentation was missing'
          );

          const decodedPresentation =
            decodeProfileKeyCredentialPresentation(presentation);

          ({ userId, profileKey: encryptedProfileKey } = decodedPresentation);
        }

        strictAssert(
          Bytes.isNotEmpty(userId),
          'decryptGroupChange: promoteMemberPendingProfileKey.userId was missing'
        );
        strictAssert(
          Bytes.isNotEmpty(encryptedProfileKey),
          'decryptGroupChange: promoteMemberPendingProfileKey.profileKey was missing'
        );

        let aci: AciString;
        let profileKey: Uint8Array<ArrayBuffer>;
        try {
          aci = decryptAci(clientZkGroupCipher, userId);

          profileKey = decryptProfileKey(
            clientZkGroupCipher,
            encryptedProfileKey,
            aci
          );
        } catch (error) {
          log.warn(
            `decryptGroupChange/${logId}: Unable to decrypt ` +
              'promoteMemberPendingProfileKey.userId/profileKey. Dropping member.',
            Errors.toLogFormat(error)
          );
          return null;
        }

        if (!isValidProfileKey(profileKey)) {
          throw new Error(
            'decryptGroupChange: promoteMemberPendingProfileKey had invalid profileKey'
          );
        }

        return { aci, profileKey };
      }
    )
  );

  // promoteMembersPendingPniAciProfileKey?: Array<
  //   GroupChange.Actions.PromoteMemberPendingPniAciProfileKeyAction
  // >;
  result.promoteMembersPendingPniAciProfileKey = compact(
    (actions.promoteMembersPendingPniAciProfileKey || []).map(
      promotePendingMember => {
        strictAssert(
          Bytes.isNotEmpty(promotePendingMember.userId),
          'decryptGroupChange: ' +
            'promoteMembersPendingPniAciProfileKey.userId was missing'
        );
        strictAssert(
          Bytes.isNotEmpty(promotePendingMember.pni),
          'decryptGroupChange: ' +
            'promoteMembersPendingPniAciProfileKey.pni was missing'
        );
        strictAssert(
          Bytes.isNotEmpty(promotePendingMember.profileKey),
          'decryptGroupChange: ' +
            'promoteMembersPendingPniAciProfileKey.profileKey was missing'
        );

        let aci: AciString;
        let pni: PniString;
        let profileKey: Uint8Array<ArrayBuffer>;
        try {
          aci = decryptAci(clientZkGroupCipher, promotePendingMember.userId);
          pni = decryptPni(clientZkGroupCipher, promotePendingMember.pni);

          profileKey = decryptProfileKey(
            clientZkGroupCipher,
            promotePendingMember.profileKey,
            aci
          );
        } catch (error) {
          log.warn(
            `decryptGroupChange/${logId}: Unable to decrypt promoteMembersPendingPniAciProfileKey. Dropping member.`,
            Errors.toLogFormat(error)
          );
          return null;
        }

        if (!isValidProfileKey(profileKey)) {
          throw new Error(
            'decryptGroupChange: promoteMembersPendingPniAciProfileKey ' +
              'had invalid profileKey'
          );
        }

        return {
          aci,
          pni,
          profileKey,
        };
      }
    )
  );

  // modifyTitle?: GroupChange.Actions.ModifyTitleAction;
  if (actions.modifyTitle) {
    const { title } = actions.modifyTitle;

    if (Bytes.isNotEmpty(title)) {
      try {
        result.modifyTitle = {
          title: Proto.GroupAttributeBlob.decode(
            decryptGroupBlob(clientZkGroupCipher, title)
          ),
        };
      } catch (error) {
        log.warn(
          `decryptGroupChange/${logId}: Unable to decrypt modifyTitle.title`,
          Errors.toLogFormat(error)
        );
      }
    } else {
      result.modifyTitle = {};
    }
  }

  // modifyAvatar?: GroupChange.Actions.ModifyAvatarAction;
  // Note: decryption happens during application of the change, on download of the avatar
  if (actions.modifyAvatar != null) {
    result.modifyAvatar = {
      avatar: actions.modifyAvatar.avatar,
    };
  }

  // modifyDisappearingMessageTimer?:
  // GroupChange.Actions.ModifyDisappearingMessageTimerAction;
  if (actions.modifyDisappearingMessageTimer) {
    const { timer } = actions.modifyDisappearingMessageTimer;

    if (Bytes.isNotEmpty(timer)) {
      try {
        result.modifyDisappearingMessageTimer = {
          timer: Proto.GroupAttributeBlob.decode(
            decryptGroupBlob(clientZkGroupCipher, timer)
          ),
        };
      } catch (error) {
        log.warn(
          `decryptGroupChange/${logId}: Unable to decrypt modifyDisappearingMessageTimer.timer`,
          Errors.toLogFormat(error)
        );
      }
    } else {
      result.modifyDisappearingMessageTimer = {};
    }
  }

  // modifyAttributesAccess?:
  // GroupChange.Actions.ModifyAttributesAccessControlAction;
  if (actions.modifyAttributesAccess) {
    const { attributesAccess } = actions.modifyAttributesAccess;
    strictAssert(
      isValidAccess(attributesAccess),
      `decryptGroupChange: modifyAttributesAccess.attributesAccess was not valid: ${actions.modifyAttributesAccess.attributesAccess}`
    );

    result.modifyAttributesAccess = {
      attributesAccess,
    };
  }

  // modifyMemberAccess?: GroupChange.Actions.ModifyMembersAccessControlAction;
  if (actions.modifyMemberAccess) {
    const { membersAccess } = actions.modifyMemberAccess;
    strictAssert(
      isValidAccess(membersAccess),
      `decryptGroupChange: modifyMemberAccess.membersAccess was not valid: ${actions.modifyMemberAccess.membersAccess}`
    );

    result.modifyMemberAccess = {
      membersAccess,
    };
  }

  // modifyAddFromInviteLinkAccess?:
  //   GroupChange.Actions.ModifyAddFromInviteLinkAccessControlAction;
  if (actions.modifyAddFromInviteLinkAccess) {
    const { addFromInviteLinkAccess } = actions.modifyAddFromInviteLinkAccess;
    strictAssert(
      isValidLinkAccess(addFromInviteLinkAccess),
      `decryptGroupChange: modifyAddFromInviteLinkAccess.addFromInviteLinkAccess was not valid: ${actions.modifyAddFromInviteLinkAccess.addFromInviteLinkAccess}`
    );

    result.modifyAddFromInviteLinkAccess = {
      addFromInviteLinkAccess,
    };
  }

  // modifyMemberLabelAccess?: GroupChange.Actions.ModifyMemberLabelAccessControlAction;
  if (actions.modifyMemberLabelAccess) {
    const { memberLabelAccess } = actions.modifyMemberLabelAccess;
    strictAssert(
      isValidAccess(memberLabelAccess),
      `decryptGroupChange: modifyMemberLabelAccess.memberLabelAccess was not valid: ${actions.modifyMemberLabelAccess.memberLabelAccess}`
    );

    result.modifyMemberLabelAccess = {
      memberLabelAccess,
    };
  }

  // addMemberPendingAdminApprovals?: Array<
  //   GroupChange.Actions.AddMemberPendingAdminApprovalAction
  // >;
  result.addMembersPendingAdminApproval = compact(
    (actions.addMembersPendingAdminApproval || []).map(
      addMemberPendingAdminApproval => {
        const { added } = addMemberPendingAdminApproval;
        strictAssert(
          added,
          'decryptGroupChange: addMemberPendingAdminApproval was missing added field!'
        );

        const decrypted = decryptMemberPendingAdminApproval(
          clientZkGroupCipher,
          added,
          logId
        );
        if (!decrypted) {
          log.warn(
            `decryptGroupChange/${logId}: Unable to decrypt addMemberPendingAdminApproval.added. Dropping member.`
          );
          return null;
        }

        return { added: decrypted };
      }
    )
  );

  // deleteMemberPendingAdminApprovals?: Array<
  //   GroupChange.Actions.DeleteMemberPendingAdminApprovalAction
  // >;
  result.deleteMembersPendingAdminApproval = compact(
    (actions.deleteMembersPendingAdminApproval || []).map(
      deleteMemberPendingAdminApproval => {
        const { deletedUserId } = deleteMemberPendingAdminApproval;
        strictAssert(
          Bytes.isNotEmpty(deletedUserId),
          'decryptGroupChange: deleteMemberPendingAdminApproval.deletedUserId was missing'
        );

        let aci: AciString;
        try {
          aci = decryptAci(clientZkGroupCipher, deletedUserId);
        } catch (error) {
          log.warn(
            `decryptGroupChange/${logId}: Unable to decrypt deleteMemberPendingAdminApproval.deletedUserId. Dropping member.`,
            Errors.toLogFormat(error)
          );
          return null;
        }

        return { deletedUserId: aci };
      }
    )
  );

  // promoteMemberPendingAdminApprovals?: Array<
  //   GroupChange.Actions.PromoteMemberPendingAdminApprovalAction
  // >;
  result.promoteMembersPendingAdminApproval = compact(
    (actions.promoteMembersPendingAdminApproval || []).map(
      promoteMemberPendingAdminApproval => {
        const { userId } = promoteMemberPendingAdminApproval;
        strictAssert(
          Bytes.isNotEmpty(userId),
          'decryptGroupChange: promoteMemberPendingAdminApproval.userId was missing'
        );

        let decryptedUserId: AciString;
        try {
          decryptedUserId = decryptAci(clientZkGroupCipher, userId);
        } catch (error) {
          log.warn(
            `decryptGroupChange/${logId}: Unable to decrypt promoteMemberPendingAdminApproval.userId. Dropping member.`,
            Errors.toLogFormat(error)
          );
          return null;
        }

        const { role } = promoteMemberPendingAdminApproval;
        if (!isValidRole(role)) {
          throw new Error(
            `decryptGroupChange: promoteMemberPendingAdminApproval had invalid role ${promoteMemberPendingAdminApproval.role}`
          );
        }

        return { role, userId: decryptedUserId };
      }
    )
  );

  // modifyInviteLinkPassword?: GroupChange.Actions.ModifyInviteLinkPasswordAction;
  if (actions.modifyInviteLinkPassword) {
    const { inviteLinkPassword: password } = actions.modifyInviteLinkPassword;
    if (Bytes.isNotEmpty(password)) {
      result.modifyInviteLinkPassword = {
        inviteLinkPassword: Bytes.toBase64(password),
      };
    } else {
      result.modifyInviteLinkPassword = {};
    }
  }

  // modifyDescription?: GroupChange.Actions.ModifyDescriptionAction;
  if (actions.modifyDescription) {
    const { description } = actions.modifyDescription;
    if (Bytes.isNotEmpty(description)) {
      try {
        result.modifyDescription = {
          description: Proto.GroupAttributeBlob.decode(
            decryptGroupBlob(clientZkGroupCipher, description)
          ),
        };
      } catch (error) {
        log.warn(
          `decryptGroupChange/${logId}: Unable to decrypt modifyDescription.description`,
          Errors.toLogFormat(error)
        );
      }
    } else {
      result.modifyDescription = {};
    }
  }

  // modifyAnnouncementsOnly
  if (actions.modifyAnnouncementsOnly) {
    const { announcementsOnly } = actions.modifyAnnouncementsOnly;
    result.modifyAnnouncementsOnly = {
      announcementsOnly,
    };
  }

  // modifyTerminated
  if (actions.terminateGroup) {
    result.terminateGroup = {};
  }

  // addMembersBanned
  if (actions.addMembersBanned && actions.addMembersBanned.length > 0) {
    result.addMembersBanned = actions.addMembersBanned
      .map(item => {
        if (!item.added || !item.added.userId) {
          log.warn(
            `decryptGroupChange/${logId}: addMembersBanned had a blank entry`
          );
          return null;
        }
        const serviceId = decryptServiceId(
          clientZkGroupCipher,
          item.added.userId
        );
        const timestamp = normalizeTimestamp(item.added.timestamp);

        return { serviceId, timestamp };
      })
      .filter(isNotNil);
  }

  // deleteMembersBanned
  if (actions.deleteMembersBanned && actions.deleteMembersBanned.length > 0) {
    result.deleteMembersBanned = actions.deleteMembersBanned
      .map(item => {
        if (!item.deletedUserId) {
          log.warn(
            `decryptGroupChange/${logId}: deleteMembersBanned had a blank entry`
          );
          return null;
        }
        return decryptServiceId(clientZkGroupCipher, item.deletedUserId);
      })
      .filter(isNotNil);
  }

  return result;
}

export function decryptGroupTitle(
  title: Uint8Array<ArrayBuffer> | undefined,
  secretParams: string
): string | undefined {
  const clientZkGroupCipher = getClientZkGroupCipher(secretParams);
  if (!title || !title.length) {
    return undefined;
  }
  const blob = Proto.GroupAttributeBlob.decode(
    decryptGroupBlob(clientZkGroupCipher, title)
  );

  return blob.content?.title;
}

export function decryptGroupDescription(
  description: Uint8Array<ArrayBuffer> | undefined,
  secretParams: string
): string | undefined {
  const clientZkGroupCipher = getClientZkGroupCipher(secretParams);
  if (!description || !description.length) {
    return undefined;
  }

  const blob = Proto.GroupAttributeBlob.decode(
    decryptGroupBlob(clientZkGroupCipher, description)
  );

  return blob?.content?.descriptionText;
}

function decryptModifyMemberLabelAction(
  clientZkGroupCipher: ClientZkGroupCipher,
  modifyMember: Readonly<Proto.GroupChange.Actions.ModifyMemberLabelAction.Params>,
  logId: string
): DecryptedModifyMemberLabelAction | undefined {
  const { userId, labelEmoji, labelString } = modifyMember;

  // userId
  strictAssert(
    Bytes.isNotEmpty(userId),
    'decryptModifyMemberLabelAction: Missing userId'
  );

  let decryptedUserId: AciString;
  try {
    decryptedUserId = decryptAci(clientZkGroupCipher, userId);
  } catch (error) {
    log.warn(
      `decryptModifyMemberLabelAction/${logId}: Unable to decrypt pending member userId. Dropping member.`,
      Errors.toLogFormat(error)
    );
    return undefined;
  }

  // labelEmoji
  let decryptedLabelEmoji: string | undefined;
  if (Bytes.isNotEmpty(labelEmoji)) {
    try {
      decryptedLabelEmoji = Bytes.toString(
        decryptGroupBlob(clientZkGroupCipher, labelEmoji)
      );
    } catch (error) {
      log.warn(
        `decryptMemberPendingAdminApproval/${logId}: Unable to decrypt labelEmoji. Dropping it.`,
        Errors.toLogFormat(error)
      );
    }
  }

  // labelString
  let decryptedLabelString: string | undefined;
  if (Bytes.isNotEmpty(labelString)) {
    try {
      decryptedLabelString = Bytes.toString(
        decryptGroupBlob(clientZkGroupCipher, labelString)
      );
    } catch (error) {
      log.warn(
        `decryptMemberPendingAdminApproval/${logId}: Unable to decrypt labelString. Dropping it.`,
        Errors.toLogFormat(error)
      );
    }
  }

  return {
    userId: decryptedUserId,
    labelEmoji: decryptedLabelEmoji,
    labelString: decryptedLabelString,
  };
}

type DecryptedGroupState = {
  title?: Proto.GroupAttributeBlob;
  disappearingMessagesTimer?: Proto.GroupAttributeBlob;
  accessControl?: {
    attributes: number;
    members: number;
    addFromInviteLink: number;
    memberLabel: number;
  };
  version?: number;
  members?: ReadonlyArray<DecryptedMember>;
  membersPendingProfileKey?: ReadonlyArray<DecryptedMemberPendingProfileKey>;
  membersPendingAdminApproval?: ReadonlyArray<DecryptedMemberPendingAdminApproval>;
  inviteLinkPassword?: string;
  description?: Proto.GroupAttributeBlob;
  avatarUrl?: string;
  announcementsOnly?: boolean;
  membersBanned?: Array<GroupV2BannedMemberType>;
  terminated?: boolean;
};

function decryptGroupState(
  groupState: Readonly<Proto.Group.Params>,
  groupSecretParams: string,
  logId: string
): DecryptedGroupState {
  const clientZkGroupCipher = getClientZkGroupCipher(groupSecretParams);
  const result: DecryptedGroupState = {};

  // title
  if (Bytes.isNotEmpty(groupState.title)) {
    try {
      result.title = Proto.GroupAttributeBlob.decode(
        decryptGroupBlob(clientZkGroupCipher, groupState.title)
      );
    } catch (error) {
      log.warn(
        `decryptGroupState/${logId}: Unable to decrypt title. Clearing it.`,
        Errors.toLogFormat(error)
      );
    }
  }

  // avatar
  // Note: decryption happens during application of the change, on download of the avatar

  // disappearing message timer
  if (
    groupState.disappearingMessagesTimer &&
    groupState.disappearingMessagesTimer.length
  ) {
    try {
      result.disappearingMessagesTimer = Proto.GroupAttributeBlob.decode(
        decryptGroupBlob(
          clientZkGroupCipher,
          groupState.disappearingMessagesTimer
        )
      );
    } catch (error) {
      log.warn(
        `decryptGroupState/${logId}: Unable to decrypt disappearing message timer. Clearing it.`,
        Errors.toLogFormat(error)
      );
    }
  }

  // accessControl
  {
    const { accessControl } = groupState;
    strictAssert(accessControl, 'No accessControl field found');

    const attributes =
      accessControl.attributes ?? Proto.AccessControl.AccessRequired.UNKNOWN;
    const members =
      accessControl.members ?? Proto.AccessControl.AccessRequired.UNKNOWN;
    const addFromInviteLink =
      accessControl.addFromInviteLink ??
      Proto.AccessControl.AccessRequired.UNKNOWN;
    const memberLabel =
      accessControl.memberLabel ?? Proto.AccessControl.AccessRequired.UNKNOWN;

    strictAssert(
      isValidAccess(attributes),
      `decryptGroupState: Access control for attributes is invalid: ${attributes}`
    );
    strictAssert(
      isValidAccess(members),
      `decryptGroupState: Access control for members is invalid: ${members}`
    );
    strictAssert(
      isValidLinkAccess(addFromInviteLink),
      `decryptGroupState: Access control for invite link is invalid: ${addFromInviteLink}`
    );
    strictAssert(
      isValidAccess(memberLabel),
      `decryptGroupState: Access control for member label is invalid: ${memberLabel}`
    );

    result.accessControl = {
      attributes,
      members,
      addFromInviteLink,
      memberLabel,
    };
  }

  // version
  const version = groupState.version ?? 0;
  strictAssert(
    isNumber(version),
    `decryptGroupState: Expected version to be a number or null; it was ${groupState.version}`
  );
  result.version = version;

  // members
  if (groupState.members) {
    result.members = compact(
      groupState.members.map((member: Proto.Member.Params) =>
        decryptMember(clientZkGroupCipher, member, logId)
      )
    );
  }

  // membersPendingProfileKey
  if (groupState.membersPendingProfileKey) {
    result.membersPendingProfileKey = compact(
      groupState.membersPendingProfileKey.map(
        (member: Proto.MemberPendingProfileKey.Params) =>
          decryptMemberPendingProfileKey(clientZkGroupCipher, member, logId)
      )
    );
  }

  // membersPendingAdminApproval
  if (groupState.membersPendingAdminApproval) {
    result.membersPendingAdminApproval = compact(
      groupState.membersPendingAdminApproval.map(
        (member: Proto.MemberPendingAdminApproval.Params) =>
          decryptMemberPendingAdminApproval(clientZkGroupCipher, member, logId)
      )
    );
  }

  // inviteLinkPassword
  if (Bytes.isNotEmpty(groupState.inviteLinkPassword)) {
    result.inviteLinkPassword = Bytes.toBase64(groupState.inviteLinkPassword);
  }

  // description
  if (Bytes.isNotEmpty(groupState.description)) {
    try {
      result.description = Proto.GroupAttributeBlob.decode(
        decryptGroupBlob(clientZkGroupCipher, groupState.description)
      );
    } catch (error) {
      log.warn(
        `decryptGroupState/${logId}: Unable to decrypt description. Clearing it.`,
        Errors.toLogFormat(error)
      );
    }
  }

  // announcementsOnly
  const { announcementsOnly } = groupState;
  result.announcementsOnly = Boolean(announcementsOnly);

  // terminated
  const { terminated } = groupState;
  result.terminated = Boolean(terminated);

  // membersBanned
  const { membersBanned } = groupState;
  if (membersBanned && membersBanned.length > 0) {
    result.membersBanned = membersBanned
      .map(item => {
        if (!item.userId) {
          log.warn(
            `decryptGroupState/${logId}: membersBanned had a blank entry`
          );
          return null;
        }
        const serviceId = decryptServiceId(clientZkGroupCipher, item.userId);
        const timestamp = toNumber(item.timestamp) ?? 0;

        return { serviceId, timestamp };
      })
      .filter(isNotNil);
  } else {
    result.membersBanned = [];
  }

  result.avatarUrl = dropNull(groupState.avatarUrl);

  return result;
}

type DecryptedMember = Readonly<{
  userId: AciString;
  profileKey: Uint8Array<ArrayBuffer>;
  role: MemberRole;
  joinedAtVersion: number;
  labelEmoji?: string;
  labelString?: string;
}>;

function decryptMember(
  clientZkGroupCipher: ClientZkGroupCipher,
  member: Readonly<Proto.Member.Params>,
  logId: string
): DecryptedMember | undefined {
  // userId
  strictAssert(
    Bytes.isNotEmpty(member.userId),
    'decryptMember: Member had missing userId'
  );

  let userId: AciString;
  try {
    userId = decryptAci(clientZkGroupCipher, member.userId);
  } catch (error) {
    log.warn(
      `decryptMember/${logId}: Unable to decrypt member userid. Dropping member.`,
      Errors.toLogFormat(error)
    );
    return undefined;
  }

  // profileKey
  strictAssert(
    Bytes.isNotEmpty(member.profileKey),
    'decryptMember: Member had missing profileKey'
  );
  const profileKey = decryptProfileKey(
    clientZkGroupCipher,
    member.profileKey,
    userId
  );

  if (!isValidProfileKey(profileKey)) {
    throw new Error('decryptMember: Member had invalid profileKey');
  }

  // role
  const { role } = member;

  if (!isValidRole(role)) {
    throw new Error(`decryptMember: Member had invalid role ${role}`);
  }

  // labelEmoji
  let decryptedLabelEmoji: string | undefined;
  if (Bytes.isNotEmpty(member.labelEmoji)) {
    try {
      decryptedLabelEmoji = Bytes.toString(
        decryptGroupBlob(clientZkGroupCipher, member.labelEmoji)
      );
    } catch (error) {
      log.warn(
        `decryptMemberPendingAdminApproval/${logId}: Unable to decrypt labelEmoji. Dropping it.`,
        Errors.toLogFormat(error)
      );
    }
  }

  // labelString
  let decryptedLabelString: string | undefined;
  if (Bytes.isNotEmpty(member.labelString)) {
    try {
      decryptedLabelString = Bytes.toString(
        decryptGroupBlob(clientZkGroupCipher, member.labelString)
      );
    } catch (error) {
      log.warn(
        `decryptMemberPendingAdminApproval/${logId}: Unable to decrypt labelString. Dropping it.`,
        Errors.toLogFormat(error)
      );
    }
  }

  return {
    userId,
    profileKey,
    role,
    joinedAtVersion: dropNull(member.joinedAtVersion) ?? 0,
    labelEmoji: decryptedLabelEmoji,
    labelString: decryptedLabelString,
  };
}

type DecryptedMemberPendingProfileKey = {
  addedByUserId: AciString;
  timestamp: number;
  member: {
    userId: ServiceIdString;
    role?: MemberRole;
  };
};

function decryptMemberPendingProfileKey(
  clientZkGroupCipher: ClientZkGroupCipher,
  member: Readonly<Proto.MemberPendingProfileKey.Params>,
  logId: string
): DecryptedMemberPendingProfileKey | undefined {
  // addedByUserId
  strictAssert(
    Bytes.isNotEmpty(member.addedByUserId),
    'decryptMemberPendingProfileKey: Member had missing addedByUserId'
  );

  let addedByUserId: AciString;
  try {
    addedByUserId = decryptAci(clientZkGroupCipher, member.addedByUserId);
  } catch (error) {
    log.warn(
      `decryptMemberPendingProfileKey/${logId}: Unable to decrypt pending member addedByUserId. Dropping member.`,
      Errors.toLogFormat(error)
    );
    return undefined;
  }

  // timestamp
  const timestamp = normalizeTimestamp(member.timestamp);

  if (!member.member) {
    log.warn(
      `decryptMemberPendingProfileKey/${logId}: Dropping pending member due to missing member details`
    );

    return undefined;
  }

  const { userId, profileKey } = member.member;
  strictAssert(
    Bytes.isEmpty(profileKey),
    'decryptMemberPendingProfileKey: member has profileKey'
  );

  // userId
  strictAssert(
    Bytes.isNotEmpty(userId),
    'decryptMemberPendingProfileKey: Member had missing member.userId'
  );

  let decryptedUserId: ServiceIdString;
  try {
    decryptedUserId = decryptServiceId(clientZkGroupCipher, userId);
  } catch (error) {
    log.warn(
      `decryptMemberPendingProfileKey/${logId}: Unable to decrypt pending member userId. Dropping member.`,
      Errors.toLogFormat(error)
    );
    return undefined;
  }

  // role
  const { role } = member.member;

  strictAssert(
    isValidRole(role),
    `decryptMemberPendingProfileKey: Member had invalid role ${role}`
  );

  return {
    addedByUserId,
    timestamp,
    member: {
      userId: decryptedUserId,
      role,
    },
  };
}

type DecryptedMemberPendingAdminApproval = {
  userId: AciString;
  profileKey?: Uint8Array<ArrayBuffer>;
  timestamp: number;
};

function decryptMemberPendingAdminApproval(
  clientZkGroupCipher: ClientZkGroupCipher,
  member: Readonly<Proto.MemberPendingAdminApproval.Params>,
  logId: string
): DecryptedMemberPendingAdminApproval | undefined {
  // timestamp
  const timestamp = normalizeTimestamp(member.timestamp);

  const { userId, profileKey } = member;

  // userId
  strictAssert(
    Bytes.isNotEmpty(userId),
    'decryptMemberPendingAdminApproval: Missing userId'
  );

  let decryptedUserId: AciString;
  try {
    decryptedUserId = decryptAci(clientZkGroupCipher, userId);
  } catch (error) {
    log.warn(
      `decryptMemberPendingAdminApproval/${logId}: Unable to decrypt pending member userId. Dropping member.`,
      Errors.toLogFormat(error)
    );
    return undefined;
  }

  // profileKey
  let decryptedProfileKey: Uint8Array<ArrayBuffer> | undefined;
  if (Bytes.isNotEmpty(profileKey)) {
    try {
      decryptedProfileKey = decryptProfileKey(
        clientZkGroupCipher,
        profileKey,
        decryptedUserId
      );
    } catch (error) {
      log.warn(
        `decryptMemberPendingAdminApproval/${logId}: Unable to decrypt profileKey. Dropping profileKey.`,
        Errors.toLogFormat(error)
      );
    }

    if (!isValidProfileKey(decryptedProfileKey)) {
      log.warn(
        `decryptMemberPendingAdminApproval/${logId}: Dropping profileKey, since it was invalid`
      );

      decryptedProfileKey = undefined;
    }
  }

  return {
    timestamp,
    userId: decryptedUserId,
    profileKey: decryptedProfileKey,
  };
}

export function getMembershipList(
  conversationId: string
): Array<{ aci: AciString; uuidCiphertext: Uint8Array<ArrayBuffer> }> {
  const conversation = window.ConversationController.get(conversationId);
  if (!conversation) {
    throw new Error('getMembershipList: cannot find conversation');
  }

  const secretParams = conversation.get('secretParams');
  if (!secretParams) {
    throw new Error('getMembershipList: no secretParams');
  }

  const clientZkGroupCipher = getClientZkGroupCipher(secretParams);

  return conversation.getMembers().map(member => {
    const aci = member.getCheckedAci('getMembershipList: member has no aci');

    const uuidCiphertext = encryptServiceId(clientZkGroupCipher, aci);
    return { aci, uuidCiphertext };
  });
}

function setLastSuccessfulGroupFetch(
  conversationId: string,
  timestamp: number | undefined
): void {
  const conversation = window.ConversationController.get(conversationId);
  strictAssert(
    conversation,
    `setLastSuccessfulGroupFetch/${idForLogging(conversationId)}: Conversation must exist`
  );
  conversation.lastSuccessfulGroupFetch = timestamp;
}
