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

import lodash from 'lodash';
import { createSelector } from 'reselect';
import getDirection from 'direction';
import emojiRegex from 'emoji-regex';
import LinkifyIt from 'linkify-it';
import type { ReadonlyDeep } from 'type-fest';

import type {
  LastMessageStatus,
  ReadonlyMessageAttributesType,
  CustomError,
  MessageReactionType,
  QuotedAttachmentType,
  ShallowChallengeError,
} from '../../model-types.d.ts';

import type {
  Contact as SmartMessageDetailContact,
  OwnProps as SmartMessageDetailPropsType,
} from '../smart/MessageDetail.preload.js';
import type { TimelineItemType } from '../../components/conversation/TimelineItem.dom.js';
import type { PropsData } from '../../components/conversation/Message.dom.js';
import type { PropsData as TimelineMessagePropsData } from '../../components/conversation/TimelineMessage.dom.js';
import { TextDirection } from '../../components/conversation/Message.dom.js';
import type { PropsData as TimerNotificationProps } from '../../components/conversation/TimerNotification.dom.js';
import type { PropsData as ChangeNumberNotificationProps } from '../../components/conversation/ChangeNumberNotification.dom.js';
import type { PropsData as JoinedSignalNotificationProps } from '../../components/conversation/JoinedSignalNotification.dom.js';
import type { PropsData as SafetyNumberNotificationProps } from '../../components/conversation/SafetyNumberNotification.dom.js';
import type { PropsData as VerificationNotificationProps } from '../../components/conversation/VerificationNotification.dom.js';
import type { PropsData as TitleTransitionNotificationProps } from '../../components/conversation/TitleTransitionNotification.dom.js';
import type { PropsDataType as GroupsV2Props } from '../../components/conversation/GroupV2Change.dom.js';
import type { PropsDataType as GroupV1MigrationPropsType } from '../../components/conversation/GroupV1Migration.dom.js';
import type { PropsDataType as DeliveryIssuePropsType } from '../../components/conversation/DeliveryIssueNotification.dom.js';
import type { PropsType as PaymentEventNotificationPropsType } from '../../components/conversation/PaymentEventNotification.dom.js';
import type { PropsDataType as ConversationMergePropsType } from '../../components/conversation/ConversationMergeNotification.dom.js';
import type { PropsDataType as PhoneNumberDiscoveryPropsType } from '../../components/conversation/PhoneNumberDiscoveryNotification.dom.js';
import type {
  PropsData as GroupNotificationProps,
  ChangeType,
} from '../../components/conversation/GroupNotification.dom.js';
import type { PropsType as ProfileChangeNotificationPropsType } from '../../components/conversation/ProfileChangeNotification.dom.js';

import {
  getSafeDomain,
  isCallLink,
  isStickerPack,
} from '../../types/LinkPreview.std.js';
import type {
  AciString,
  PniString,
  ServiceIdString,
} from '../../types/ServiceId.std.js';

import type { EmbeddedContactForUIType } from '../../types/EmbeddedContact.std.js';
import { embeddedContactSelector } from '../../types/EmbeddedContact.std.js';
import type { HydratedBodyRangesType } from '../../types/BodyRange.std.js';
import { hydrateRanges } from '../../util/BodyRange.node.js';
import type { AssertProps, LocalizerType } from '../../types/Util.std.js';
import type { LinkPreviewForUIType } from '../../types/message/LinkPreviews.std.js';
import { getMentionsRegex } from '../../types/Message.std.js';
import { SignalService as Proto } from '../../protobuf/index.std.js';
import type {
  AttachmentForUIType,
  AttachmentType,
} from '../../types/Attachment.std.js';
import {
  isVoiceMessage,
  isIncremental,
  defaultBlurHash,
} from '../../util/Attachment.std.js';
import type { MessageAttachmentType } from '../../types/AttachmentDownload.std.js';
import { type DefaultConversationColorType } from '../../types/Colors.std.js';
import { ReadStatus } from '../../messages/MessageReadStatus.std.js';

import type { CallingNotificationType } from '../../util/callingNotification.std.js';
import { getRecipients } from '../../util/getRecipients.dom.js';
import { getOwn } from '../../util/getOwn.std.js';
import { isNotNil } from '../../util/isNotNil.std.js';
import {
  canSendDeleteForEveryone,
  canRetrySendDeleteForEveryone,
} from '../../util/canDeleteForEveryone.preload.js';
import { isAdminDeleteSendEnabled } from '../../util/isAdminDeleteEnabled.dom.js';
import { isAciString } from '../../util/isAciString.std.js';
import { isSignalConversation } from '../../util/isSignalConversation.dom.js';
import * as iterables from '../../util/iterables.std.js';
import { strictAssert } from '../../util/assert.std.js';
import { canEditMessage } from '../../util/canEditMessage.dom.js';
import {
  getLocalAttachmentUrl,
  AttachmentDisposition,
} from '../../util/getLocalAttachmentUrl.std.js';
import { isVoiceMessagePlayed } from '../../util/isVoiceMessagePlayed.std.js';
import { isPermanentlyUndownloadable } from '../../jobs/AttachmentDownloadManager.preload.js';

import { getAccountSelector } from './accounts.std.js';
import {
  getDefaultConversationColor,
  getHasUnidentifiedDeliveryIndicators,
} from './items.dom.js';
import {
  getConversationSelector,
  getSelectedMessageIds,
  getTargetedMessage,
  isMissingRequiredProfileSharing,
  getMessages,
  getCachedConversationMemberColorsSelector,
  getContactNameColor,
  getPinnedMessagesMessageIds,
} from './conversations.dom.js';
import {
  getIntl,
  getRegionCode,
  getUserACI,
  getUserPNI,
  getUserConversationId,
  getUserNumber,
} from './user.std.js';

import type {
  ConversationType,
  MessageWithUIFieldsType,
} from '../ducks/conversations.preload.js';

import type { AccountSelectorType } from './accounts.std.js';
import type { CallSelectorType, CallStateType } from './calling.std.js';
import type { GetConversationByIdType } from './conversations.dom.js';
import {
  SendStatus,
  isDelivered,
  isFailed,
  isRead,
  isSent,
  isViewed,
  isMessageJustForMe,
  someRecipientSendStatus,
  getHighestSuccessfulRecipientStatus,
  someSendStatus,
} from '../../messages/MessageSendState.std.js';
import { createLogger } from '../../logging/log.std.js';
import { getConversationColorAttributes } from '../../util/getConversationColorAttributes.std.js';
import { DurationInSeconds } from '../../util/durations/index.std.js';
import { getStoryReplyText } from '../../util/getStoryReplyText.std.js';
import type { MessageAttributesWithPaymentEvent } from '../../messages/payments.std.js';
import {
  isIncoming,
  isOutgoing,
  isPoll,
  isStory,
} from '../../messages/helpers.std.js';
import { messageHasPaymentEvent } from '../../messages/payments.std.js';

import { calculateExpirationTimestamp } from '../../util/expirationTimer.std.js';
import type { AnyPaymentEvent } from '../../types/Payment.std.js';
import { isPaymentNotificationEvent } from '../../types/Payment.std.js';
import type {
  MessagePollVoteType,
  PollMessageAttribute,
} from '../../types/Polls.dom.js';
import {
  getTitleNoDefault,
  getTitle,
  getNumber,
  renderNumber,
} from '../../util/getTitle.preload.js';
import { getMessageSentTimestamp } from '../../util/getMessageSentTimestamp.std.js';
import type { CallHistorySelectorType } from './callHistory.std.js';
import { CallMode, CallDirection } from '../../types/CallDisposition.std.js';
import { getCallIdFromEra } from '../../util/callDisposition.preload.js';
import { LONG_MESSAGE } from '../../types/MIME.std.js';
import type { MessageRequestResponseNotificationData } from '../../components/conversation/MessageRequestResponseNotification.dom.js';
import type { PinnedMessageNotificationData } from '../../components/conversation/pinned-messages/PinnedMessageNotification.dom.js';
import { isPinnedMessagesSendEnabled } from '../../util/isPinnedMessagesEnabled.dom.js';

const { groupBy, isEmpty, isNumber, isObject, map } = lodash;

const log = createLogger('message');

export { isIncoming, isOutgoing, isStory };
const linkify = new LinkifyIt();

type FormattedContact = Partial<ConversationType> &
  Pick<
    ConversationType,
    'acceptedMessageRequest' | 'id' | 'isMe' | 'title' | 'type'
  >;
export type PropsForMessage = Omit<TimelineMessagePropsData, 'interactionMode'>;
export type MessagePropsType = Omit<
  PropsForMessage,
  'renderingContext' | 'menu' | 'contextMenu'
>;
type PropsForUnsupportedMessage = {
  canProcessNow: boolean;
  contact: FormattedContact;
};

export type GetPropsForBubbleOptions = Readonly<{
  conversationSelector: GetConversationByIdType;
  ourConversationId?: string;
  ourNumber?: string;
  ourAci: AciString | undefined;
  ourPni: PniString | undefined;
  targetedMessageId?: string;
  targetedMessageCounter?: number;
  pinnedMessagesMessageIds: ReadonlyArray<string> | null;
  selectedMessageIds: ReadonlyArray<string> | undefined;
  regionCode?: string;
  callSelector: CallSelectorType;
  callHistorySelector: CallHistorySelectorType;
  activeCall?: CallStateType;
  accountSelector: AccountSelectorType;
  contactNameColors: Map<string, string>;
  defaultConversationColor: DefaultConversationColorType;
}>;

export function hasErrors(
  message: Pick<MessageWithUIFieldsType, 'errors'>
): boolean {
  return message.errors ? message.errors.length > 0 : false;
}

export function getSource(
  message: Pick<ReadonlyMessageAttributesType, 'type' | 'source'>,
  ourNumber: string | undefined
): string | undefined {
  if (isIncoming(message)) {
    return message.source;
  }
  if (!isOutgoing(message)) {
    log.warn('getSource: Called for non-incoming/non-outoing message');
  }

  return ourNumber;
}

export function getSourceDevice(
  message: MessageWithUIFieldsType,
  ourDeviceId: number
): string | number | undefined {
  const { sourceDevice } = message;

  if (isIncoming(message)) {
    return sourceDevice;
  }
  if (!isOutgoing(message)) {
    log.warn('getSourceDevice: Called for non-incoming/non-outoing message');
  }

  return sourceDevice || ourDeviceId;
}

export function getSourceServiceId(
  message: Pick<ReadonlyMessageAttributesType, 'type' | 'sourceServiceId'>,
  ourAci: AciString | undefined
): ServiceIdString | undefined {
  if (isIncoming(message)) {
    return message.sourceServiceId;
  }
  if (!isOutgoing(message)) {
    log.warn('getSourceServiceId: Called for non-incoming/non-outoing message');
  }

  return ourAci;
}

export type GetContactOptions = Pick<
  GetPropsForBubbleOptions,
  'conversationSelector' | 'ourConversationId' | 'ourNumber' | 'ourAci'
>;

export function getAuthorId(
  message: MessageWithUIFieldsType,
  {
    conversationSelector,
    ourConversationId,
    ourNumber,
    ourAci,
  }: GetContactOptions
): string | undefined {
  const source = getSource(message, ourNumber);
  const sourceServiceId = getSourceServiceId(message, ourAci);

  if (!source && !sourceServiceId) {
    return ourConversationId;
  }

  const conversation = conversationSelector(sourceServiceId || source);
  return conversation.id;
}

// TODO: DESKTOP-2145
export function getContact(
  message: MessageWithUIFieldsType,
  {
    conversationSelector,
    ourConversationId,
    ourNumber,
    ourAci,
  }: GetContactOptions
): ConversationType {
  const source = getSource(message, ourNumber);
  const sourceServiceId = getSourceServiceId(message, ourAci);

  if (!source && !sourceServiceId) {
    return conversationSelector(ourConversationId);
  }

  return conversationSelector(sourceServiceId || source);
}

export function getConversation(
  message: Pick<MessageWithUIFieldsType, 'conversationId'>,
  conversationSelector: GetConversationByIdType
): ConversationType {
  return conversationSelector(message.conversationId);
}

// Message

export const getAttachmentsForMessage = (
  message: MessageWithUIFieldsType
): Array<AttachmentType> => {
  const { sticker, attachments = [] } = message;
  if (sticker && sticker.data) {
    const { data } = sticker;

    return [
      {
        ...data,
        // We want to show the blurhash for stickers, not the spinner
        pending: false,
        // Stickers are not guaranteed to have a blurhash (e.g. if imported but
        // undownloaded from backup), so we want to make sure we have something to show
        blurHash: data.blurHash ?? defaultBlurHash(),
        url: data.path ? getLocalAttachmentUrl(data) : undefined,
      },
    ];
  }
  return (
    attachments
      // Long message attachments are removed from message.attachments quickly,
      // but in case they are still around, let's make sure not to show them
      .filter(attachment => attachment.contentType !== LONG_MESSAGE)
      .map(attachment =>
        getPropsForAttachment(attachment, 'attachment', message)
      )
      .filter(isNotNil)
  );
};

export const processBodyRanges = (
  { bodyRanges }: Pick<MessageWithUIFieldsType, 'bodyRanges'>,
  options: { conversationSelector: GetConversationByIdType }
): HydratedBodyRangesType | undefined => {
  if (!bodyRanges) {
    return undefined;
  }

  return hydrateRanges(bodyRanges, options.conversationSelector)?.sort(
    (a, b) => b.start - a.start
  );
};

const getAuthorForMessage = (
  message: MessageWithUIFieldsType,
  options: GetContactOptions
): PropsData['author'] => {
  const {
    avatarPlaceholderGradient,
    acceptedMessageRequest,
    avatarUrl,
    badges,
    color,
    firstName,
    hasAvatar,
    id,
    isMe,
    name,
    phoneNumber,
    profileName,
    title,
  } = getContact(message, options);

  const unsafe = {
    avatarPlaceholderGradient,
    acceptedMessageRequest,
    avatarUrl,
    badges,
    color,
    firstName,
    hasAvatar,
    id,
    isMe,
    name,
    phoneNumber,
    profileName,
    title,
  };

  const safe: AssertProps<PropsData['author'], typeof unsafe> = unsafe;

  return safe;
};

const getPreviewsForMessage = (
  message: MessageWithUIFieldsType
): Array<LinkPreviewForUIType> => {
  const { preview: previews = [] } = message;
  return previews.map(preview => ({
    ...preview,
    isStickerPack: isStickerPack(preview.url),
    isCallLink: isCallLink(preview.url),
    domain: getSafeDomain(preview.url),
    image: preview.image
      ? getPropsForAttachment(preview.image, 'preview', message)
      : undefined,
  }));
};

const getReactionsForMessage = (
  { reactions = [] }: MessageWithUIFieldsType,
  { conversationSelector }: { conversationSelector: GetConversationByIdType }
) => {
  const reactionBySender = new Map<string, MessageReactionType>();
  for (const reaction of reactions) {
    const existingReaction = reactionBySender.get(reaction.fromId);
    if (!existingReaction || reaction.timestamp > existingReaction.timestamp) {
      reactionBySender.set(reaction.fromId, reaction);
    }
  }

  const reactionsWithEmpties = reactionBySender.values();
  const reactionsWithEmoji = iterables.filter(
    reactionsWithEmpties,
    re => re.emoji
  );
  const formattedReactions = iterables.map(reactionsWithEmoji, re => {
    const c = conversationSelector(re.fromId);

    type From = NonNullable<PropsData['reactions']>[0]['from'];

    const {
      acceptedMessageRequest,
      avatarUrl,
      badges,
      color,
      id,
      isMe,
      name,
      phoneNumber,
      profileName,
      title,
    } = c;

    const unsafe = {
      acceptedMessageRequest,
      avatarUrl,
      badges,
      color,
      id,
      isMe,
      name,
      phoneNumber,
      profileName,
      title,
    };

    const from: AssertProps<From, typeof unsafe> = unsafe;

    strictAssert(re.emoji, 'Expected all reactions to have an emoji');

    return {
      emoji: re.emoji,
      timestamp: re.timestamp,
      from,
    };
  });

  return [...formattedReactions];
};

export type PollVoteWithUserType = {
  optionIndexes: ReadonlyArray<number>;
  timestamp: number;
  isMe: boolean;
  from: Pick<
    ConversationType,
    | 'acceptedMessageRequest'
    | 'avatarUrl'
    | 'badges'
    | 'color'
    | 'id'
    | 'isMe'
    | 'name'
    | 'phoneNumber'
    | 'profileName'
    | 'title'
  >;
};

export type PollWithResolvedVotersType = PollMessageAttribute & {
  votesByOption: Map<number, ReadonlyArray<PollVoteWithUserType>>;
  totalNumVotes: number;
  uniqueVoters: number;
  pendingVoteDiff?: Map<number, 'PENDING_VOTE' | 'PENDING_UNVOTE'>;
};

const getPollForMessage = (
  message: MessageWithUIFieldsType,
  {
    conversationSelector,
    ourConversationId,
  }: {
    conversationSelector: GetConversationByIdType;
    ourConversationId?: string;
  }
): PollWithResolvedVotersType | undefined => {
  const { poll } = message;
  if (!poll) {
    return undefined;
  }

  if (!poll.votes || poll.votes.length === 0) {
    return {
      ...poll,
      votesByOption: new Map(),
      totalNumVotes: 0,
      uniqueVoters: 0,
    };
  }

  let successfulVote: MessagePollVoteType | undefined;
  let pendingVote: MessagePollVoteType | undefined;

  for (const vote of poll.votes) {
    if (vote.fromConversationId === ourConversationId) {
      if (
        vote.sendStateByConversationId &&
        Object.keys(vote.sendStateByConversationId).length > 0
      ) {
        pendingVote = vote;
      } else {
        successfulVote = vote;
      }
    }
  }

  // Compute diff between successful and pending vote
  let pendingVoteDiff:
    | Map<number, 'PENDING_VOTE' | 'PENDING_UNVOTE'>
    | undefined;
  if (pendingVote) {
    pendingVoteDiff = new Map();
    const successfulIndexes = new Set(successfulVote?.optionIndexes ?? []);
    const pendingIndexes = new Set(pendingVote.optionIndexes);

    for (const index of pendingIndexes) {
      if (!successfulIndexes.has(index)) {
        pendingVoteDiff.set(index, 'PENDING_VOTE');
      }
    }

    for (const index of successfulIndexes) {
      if (!pendingIndexes.has(index)) {
        pendingVoteDiff.set(index, 'PENDING_UNVOTE');
      }
    }
  }

  // Filter out pending votes from the votes we'll display
  const votesToProcess = poll.votes.filter(
    vote => !vote.sendStateByConversationId
  );

  // Deduplicate votes by sender - keep only the newest vote per sender
  // (highest voteCount, or newest timestamp if voteCount is equal)
  const voteByFrom = new Map<string, MessagePollVoteType>();
  for (const vote of votesToProcess) {
    const existingVote = voteByFrom.get(vote.fromConversationId);
    if (
      !existingVote ||
      vote.voteCount > existingVote.voteCount ||
      (vote.voteCount === existingVote.voteCount &&
        vote.timestamp > existingVote.timestamp)
    ) {
      voteByFrom.set(vote.fromConversationId, vote);
    }
  }

  const resolvedVotes: ReadonlyArray<PollVoteWithUserType> = Array.from(
    voteByFrom.values()
  ).map(vote => {
    const voter = conversationSelector(vote.fromConversationId);

    const from: PollVoteWithUserType['from'] = {
      acceptedMessageRequest: voter.acceptedMessageRequest,
      avatarUrl: voter.avatarUrl,
      badges: voter.badges,
      color: voter.color,
      id: voter.id,
      isMe: voter.isMe,
      name: voter.name,
      phoneNumber: voter.phoneNumber,
      profileName: voter.profileName,
      title: voter.title,
    };

    return {
      optionIndexes: vote.optionIndexes,
      timestamp: vote.timestamp,
      isMe: voter.id === ourConversationId,
      from,
    };
  });

  const votesByOption = new Map<number, Array<PollVoteWithUserType>>();
  const uniqueVoterIds = new Set();
  let totalNumVotes = 0;

  for (const vote of resolvedVotes) {
    for (const optionIndex of vote.optionIndexes) {
      if (!votesByOption.has(optionIndex)) {
        votesByOption.set(optionIndex, []);
      }
      const votes = votesByOption.get(optionIndex);
      strictAssert(!!votes, 'votes should exist');
      votes.push(vote);
      uniqueVoterIds.add(vote.from.id);
      totalNumVotes += 1;
    }
  }

  return {
    ...poll,
    votesByOption,
    totalNumVotes,
    uniqueVoters: uniqueVoterIds.size,
    pendingVoteDiff,
  };
};

const getPropsForStoryReplyContext = (
  message: Pick<
    MessageWithUIFieldsType,
    'body' | 'conversationId' | 'storyReaction' | 'storyReplyContext'
  >,
  {
    conversationSelector,
    ourConversationId,
    defaultConversationColor,
  }: {
    conversationSelector: GetConversationByIdType;
    ourConversationId?: string;
    defaultConversationColor: DefaultConversationColorType;
  }
): PropsData['storyReplyContext'] => {
  const { storyReaction, storyReplyContext } = message;
  if (!storyReplyContext) {
    return undefined;
  }

  const contact = conversationSelector(storyReplyContext.authorAci);

  const authorTitle = contact.firstName || contact.title;
  const isFromMe = contact.id === ourConversationId;

  const conversation = getConversation(message, conversationSelector);

  const { conversationColor, customColor } = getConversationColorAttributes(
    conversation,
    defaultConversationColor
  );

  return {
    authorTitle,
    conversationColor,
    customColor,
    emoji: storyReaction?.emoji,
    isFromMe,
    rawAttachment: storyReplyContext.attachment
      ? processQuoteAttachment(storyReplyContext.attachment)
      : undefined,
    storyId: storyReplyContext.messageId,
    text: getStoryReplyText(
      window.SignalContext.i18n,
      storyReplyContext.attachment
    ),
  };
};

export const getPropsForQuote = (
  message: ReadonlyDeep<
    Pick<MessageWithUIFieldsType, 'conversationId' | 'quote'>
  >,
  {
    conversationSelector,
    ourConversationId,
    defaultConversationColor,
  }: {
    conversationSelector: GetConversationByIdType;
    ourConversationId?: string;
    defaultConversationColor: DefaultConversationColorType;
  }
): PropsData['quote'] => {
  const { quote } = message;
  if (!quote) {
    return undefined;
  }

  const {
    author,
    authorAci,
    id: sentAt,
    isViewOnce,
    isGiftBadge: isTargetGiftBadge,
    referencedMessageNotFound,
    payment,
    text = '',
  } = quote;

  const contact = conversationSelector(authorAci || author);

  const authorId = contact.id;
  const authorName = contact.name;
  const authorPhoneNumber = contact.phoneNumber;
  const authorProfileName = contact.profileName;
  const authorTitle = contact.title;
  const isFromMe = authorId === ourConversationId;

  const firstAttachment = quote.attachments && quote.attachments[0];
  const conversation = getConversation(message, conversationSelector);
  const authorMembership = conversation.memberships?.find(
    membership => membership.aci === contact.serviceId
  );
  const authorLabel =
    authorMembership && authorMembership.labelString
      ? {
          labelEmoji: authorMembership.labelEmoji,
          labelString: authorMembership.labelString,
        }
      : undefined;

  const { conversationColor, customColor } = getConversationColorAttributes(
    conversation,
    defaultConversationColor
  );

  return {
    authorId,
    authorLabel,
    authorName,
    authorPhoneNumber,
    authorProfileName,
    authorTitle,
    bodyRanges: processBodyRanges(quote, { conversationSelector }),
    conversationColor,
    conversationTitle: conversation.title,
    customColor,
    isFromMe,
    rawAttachment: firstAttachment
      ? processQuoteAttachment(firstAttachment)
      : undefined,
    payment,
    isGiftBadge: Boolean(isTargetGiftBadge),
    isViewOnce,
    referencedMessageNotFound,
    sentAt: Number(sentAt),
    text,
  };
};

export type GetPropsForMessageOptions = Pick<
  GetPropsForBubbleOptions,
  | 'activeCall'
  | 'conversationSelector'
  | 'ourConversationId'
  | 'ourAci'
  | 'ourPni'
  | 'ourNumber'
  | 'targetedMessageId'
  | 'targetedMessageCounter'
  | 'pinnedMessagesMessageIds'
  | 'selectedMessageIds'
  | 'regionCode'
  | 'accountSelector'
  | 'contactNameColors'
  | 'defaultConversationColor'
>;

function getTextAttachment(
  message: MessageWithUIFieldsType
): AttachmentType | undefined {
  return (
    message.bodyAttachment &&
    getPropsForAttachment(message.bodyAttachment, 'long-message', message)
  );
}

function getPayment(
  message: MessageWithUIFieldsType
): AnyPaymentEvent | undefined {
  return message.payment;
}

export function cleanBodyForDirectionCheck(text: string): string {
  const MENTIONS_REGEX = getMentionsRegex();
  const EMOJI_REGEX = emojiRegex();
  const initial = text.replace(MENTIONS_REGEX, '').replace(EMOJI_REGEX, '');

  const linkMatches = linkify.match(initial);

  if (!linkMatches || linkMatches.length === 0) {
    return initial;
  }

  let result = '';
  let lastIndex = 0;

  linkMatches.forEach(match => {
    if (lastIndex < match.index) {
      result += initial.slice(lastIndex, match.index);
    }

    // drop the actual contents of the match

    lastIndex = match.lastIndex;
  });

  if (lastIndex < initial.length) {
    result += initial.slice(lastIndex);
  }

  return result;
}

function getTextDirection(body?: string): TextDirection {
  if (!body) {
    return TextDirection.None;
  }

  const cleaned = cleanBodyForDirectionCheck(body);
  const direction = getDirection(cleaned);
  switch (direction) {
    case 'ltr':
      return TextDirection.LeftToRight;
    case 'rtl':
      return TextDirection.RightToLeft;
    case 'neutral':
      return TextDirection.Default;
    default: {
      const unexpected: never = direction;
      log.warn(`getTextDirection: unexpected direction ${unexpected}`);
      return TextDirection.None;
    }
  }
}

export const getPropsForMessage = (
  message: MessageWithUIFieldsType,
  options: GetPropsForMessageOptions
): MessagePropsType => {
  const attachmentDroppedDueToSize = message.attachments?.some(
    item => item.wasTooBig
  );
  const attachments = getAttachmentsForMessage(message);
  const bodyRanges = processBodyRanges(message, options);
  const author = getAuthorForMessage(message, options);
  const previews = getPreviewsForMessage(message);
  const reactions = getReactionsForMessage(message, options);
  const quote = getPropsForQuote(message, options);
  const storyReplyContext = getPropsForStoryReplyContext(message, options);
  const textAttachment = getTextAttachment(message);
  const payment = getPayment(message);

  const {
    activeCall,
    accountSelector,
    conversationSelector,
    ourConversationId,
    ourNumber,
    ourAci,
    regionCode,
    targetedMessageId,
    targetedMessageCounter,
    pinnedMessagesMessageIds,
    selectedMessageIds,
    contactNameColors,
    defaultConversationColor,
  } = options;

  const { expireTimer, expirationStartTimestamp, conversationId } = message;
  const expirationLength = expireTimer
    ? DurationInSeconds.toMillis(expireTimer)
    : undefined;

  const conversation = getConversation(message, conversationSelector);
  const isGroup = conversation.type === 'group';
  const { sticker } = message;

  const isMessageTapToView = isTapToView(message);
  const activeCallConversationId = activeCall?.conversationId;

  const isTargeted = message.id === targetedMessageId;
  const isPinned = pinnedMessagesMessageIds?.includes(message.id) ?? false;
  const isSelected = selectedMessageIds?.includes(message.id) ?? false;
  const isSelectMode = selectedMessageIds != null;

  const selectedReaction = (
    (message.reactions || []).find(re => re.fromId === ourConversationId) || {}
  ).emoji;

  const authorId = getAuthorId(message, {
    conversationSelector,
    ourConversationId,
    ourNumber,
    ourAci,
  });
  const contactNameColor = getContactNameColor(contactNameColors, authorId);
  const sourceServiceId = getSourceServiceId(message, ourAci);
  const sourceMember = conversation.memberships?.find(
    membership => membership.aci === sourceServiceId
  );
  const contactLabel = sourceMember?.labelString
    ? {
        labelString: sourceMember.labelString,
        labelEmoji: sourceMember.labelEmoji,
      }
    : undefined;

  const { conversationColor, customColor } = getConversationColorAttributes(
    conversation,
    defaultConversationColor
  );

  return {
    attachments: attachments?.map(attachment =>
      getPropsForAttachment(attachment, 'attachment', message)
    ),
    attachmentDroppedDueToSize,
    author,
    bodyRanges,
    activeCallConversationId,
    previews,
    quote,
    reactions,
    storyReplyContext,
    textAttachment:
      textAttachment == null
        ? undefined
        : getPropsForAttachment(textAttachment, 'long-message', message),
    payment,
    canCopy: canCopy(message),
    canEditMessage: canEditMessage(message),
    canDeleteForEveryone: canDeleteForEveryoneInSelector(message, {
      conversation,
      ourAci,
    }),
    canDownload: canDownload(message, conversationSelector),
    canEndPoll: canEndPoll(message),
    canForward: canForward(message),
    canPinMessage: canPinMessage(conversation, message),
    canReact: canReact(message, ourConversationId, conversationSelector),
    canReply: canReply(message, ourConversationId, conversationSelector),
    canRetry: hasErrors(message),
    canRetryDeleteForEveryone: canRetryDeleteForEveryone(message, {
      conversation,
      ourAci,
    }),
    contact: getPropsForEmbeddedContact(message, regionCode, accountSelector),
    contactLabel,
    contactNameColor,
    conversationColor,
    conversationId,
    conversationTitle: conversation.title,
    conversationType: isGroup ? 'group' : 'direct',
    customColor,
    deletedForEveryone: message.deletedForEveryone || false,
    deletedForEveryoneByAdmin: getDeletedForEveryoneByAdmin(message, {
      conversationSelector,
      contactNameColors,
      ourAci,
    }),
    direction: isIncoming(message) ? 'incoming' : 'outgoing',
    displayLimit: message.displayLimit,
    expirationLength,
    expirationTimestamp: calculateExpirationTimestamp({
      expireTimer,
      expirationStartTimestamp,
    }),
    giftBadge: message.giftBadge,
    poll: getPollForMessage(message, {
      conversationSelector: options.conversationSelector,
      ourConversationId,
    }),
    id: message.id,
    isBlocked: conversation.isBlocked || false,
    isEditedMessage: Boolean(message.editHistory),
    isMessageRequestAccepted: conversation?.acceptedMessageRequest ?? true,
    isPinned,
    isSelected,
    isSelectMode,
    isSMS: message.sms === true,
    isSpoilerExpanded: message.isSpoilerExpanded,
    isSticker: Boolean(sticker),
    isTargeted,
    isTargetedCounter: isTargeted ? targetedMessageCounter : undefined,
    isTapToView: isMessageTapToView,
    isTapToViewError:
      isMessageTapToView && isIncoming(message) && message.isTapToViewInvalid,
    isTapToViewExpired:
      isMessageTapToView && isIncoming(message) && message.isErased,
    isVoiceMessagePlayed: isVoiceMessagePlayed(message, ourConversationId),
    readStatus: message.readStatus ?? ReadStatus.Read,
    selectedReaction,
    status: getMessagePropStatus(message, ourConversationId),
    text: message.body,
    textDirection: getTextDirection(message.body),
    timestamp: getMessageSentTimestamp(message, { includeEdits: false, log }),
    receivedAtMS: message.received_at_ms,
  };
};

// This is getPropsForMessage but wrapped in reselect's createSelector so that
// we can derive all of the selector dependencies that getPropsForMessage
// requires and you won't have to pass them in. For use within a smart/connected
// component that has access to selectors.
export const getMessagePropsSelector = createSelector(
  getConversationSelector,
  getUserConversationId,
  getUserACI,
  getUserPNI,
  getUserNumber,
  getRegionCode,
  getAccountSelector,
  getCachedConversationMemberColorsSelector,
  getTargetedMessage,
  getPinnedMessagesMessageIds,
  getSelectedMessageIds,
  getDefaultConversationColor,
  (
    conversationSelector,
    ourConversationId,
    ourAci,
    ourPni,
    ourNumber,
    regionCode,
    accountSelector,
    cachedConversationMemberColorsSelector,
    targetedMessage,
    pinnedMessagesMessageIds,
    selectedMessageIds,
    defaultConversationColor
  ) =>
    (message: MessageWithUIFieldsType) => {
      const contactNameColors = cachedConversationMemberColorsSelector(
        message.conversationId
      );
      return getPropsForMessage(message, {
        accountSelector,
        contactNameColors,
        conversationSelector,
        ourConversationId,
        ourNumber,
        ourAci,
        ourPni,
        regionCode,
        targetedMessageCounter: targetedMessage?.counter,
        targetedMessageId: targetedMessage?.id,
        pinnedMessagesMessageIds,
        selectedMessageIds,
        defaultConversationColor,
      });
    }
);

// Top-level prop generation for the message bubble
export function getPropsForBubble(
  message: MessageWithUIFieldsType,
  options: GetPropsForBubbleOptions
): TimelineItemType {
  const { received_at_ms: receivedAt, timestamp: messageTimestamp } = message;
  const timestamp = receivedAt || messageTimestamp;

  if (isUnsupportedMessage(message)) {
    return {
      type: 'unsupportedMessage',
      data: getPropsForUnsupportedMessage(message, options),
      timestamp,
    };
  }
  if (isGroupV2Change(message)) {
    return {
      type: 'groupV2Change',
      data: getPropsForGroupV2Change(message, options),
      timestamp,
    };
  }
  if (isGroupV1Migration(message)) {
    return {
      type: 'groupV1Migration',
      data: getPropsForGroupV1Migration(message, options),
      timestamp,
    };
  }
  if (isExpirationTimerUpdate(message)) {
    return {
      type: 'timerNotification',
      data: getPropsForTimerNotification(message, options),
      timestamp,
    };
  }
  if (isKeyChange(message)) {
    return {
      type: 'safetyNumberNotification',
      data: getPropsForSafetyNumberNotification(message, options),
      timestamp,
    };
  }
  if (isVerifiedChange(message)) {
    return {
      type: 'verificationNotification',
      data: getPropsForVerificationNotification(message, options),
      timestamp,
    };
  }
  if (isGroupUpdate(message)) {
    return {
      type: 'groupNotification',
      data: getPropsForGroupNotification(message, options),
      timestamp,
    };
  }
  if (isEndSession(message)) {
    return {
      type: 'resetSessionNotification',
      data: null,
      timestamp,
    };
  }
  if (isCallHistory(message)) {
    return {
      type: 'callHistory',
      data: getPropsForCallHistory(message, options),
      timestamp,
    };
  }
  if (isPinnedMessageNotification(message)) {
    return {
      type: 'pinnedMessage',
      data: getPropsForPinnedMessageNotification(message, options),
      timestamp,
    };
  }
  if (isProfileChange(message)) {
    return {
      type: 'profileChange',
      data: getPropsForProfileChange(message, options),
      timestamp,
    };
  }
  if (isPollTerminate(message)) {
    return {
      type: 'pollTerminate',
      data: getPropsForPollTerminate(message, options),
      timestamp,
    };
  }
  if (isUniversalTimerNotification(message)) {
    return {
      type: 'universalTimerNotification',
      data: null,
      timestamp,
    };
  }
  if (isContactRemovedNotification(message)) {
    return {
      type: 'contactRemovedNotification',
      data: null,
      timestamp,
    };
  }
  if (isChangeNumberNotification(message)) {
    return {
      type: 'changeNumberNotification',
      data: getPropsForChangeNumberNotification(message, options),
      timestamp,
    };
  }
  if (isJoinedSignalNotification(message)) {
    return {
      type: 'joinedSignalNotification',
      data: getPropsForJoinedSignalNotification(message),
      timestamp,
    };
  }
  if (isTitleTransitionNotification(message)) {
    return {
      type: 'titleTransitionNotification',
      data: getPropsForTitleTransitionNotification(message),
      timestamp,
    };
  }
  if (isChatSessionRefreshed(message)) {
    return {
      type: 'chatSessionRefreshed',
      data: null,
      timestamp,
    };
  }
  if (isDeliveryIssue(message)) {
    return {
      type: 'deliveryIssue',
      data: getPropsForDeliveryIssue(message, options),
      timestamp,
    };
  }
  if (isConversationMerge(message)) {
    return {
      type: 'conversationMerge',
      data: getPropsForConversationMerge(message, options),
      timestamp,
    };
  }
  if (isPhoneNumberDiscovery(message)) {
    return {
      type: 'phoneNumberDiscovery',
      data: getPropsForPhoneNumberDiscovery(message, options),
      timestamp,
    };
  }

  if (
    messageHasPaymentEvent(message) &&
    !isPaymentNotificationEvent(message.payment)
  ) {
    return {
      type: 'paymentEvent',
      data: getPropsForPaymentEvent(message, options),
      timestamp,
    };
  }

  if (isMessageRequestResponse(message)) {
    return {
      type: 'messageRequestResponse',
      data: getPropsForMessageRequestResponse(message),
      timestamp,
    };
  }

  const data = getPropsForMessage(message, options);

  return {
    type: 'message' as const,
    data,
    timestamp: data.timestamp,
  };
}

export function isNormalBubble(message: MessageWithUIFieldsType): boolean {
  return (
    !isCallHistory(message) &&
    !isChatSessionRefreshed(message) &&
    !isContactRemovedNotification(message) &&
    !isConversationMerge(message) &&
    !isEndSession(message) &&
    !isExpirationTimerUpdate(message) &&
    !isGroupUpdate(message) &&
    !isGroupV1Migration(message) &&
    !isGroupV2Change(message) &&
    !isKeyChange(message) &&
    !isPhoneNumberDiscovery(message) &&
    !isTitleTransitionNotification(message) &&
    !isPinnedMessageNotification(message) &&
    !isProfileChange(message) &&
    !isUniversalTimerNotification(message) &&
    !isUnsupportedMessage(message) &&
    !isVerifiedChange(message) &&
    !isChangeNumberNotification(message) &&
    !isJoinedSignalNotification(message) &&
    !isDeliveryIssue(message) &&
    !isMessageRequestResponse(message)
  );
}

function getPropsForPaymentEvent(
  message: MessageAttributesWithPaymentEvent,
  { conversationSelector }: GetPropsForBubbleOptions
): Omit<PaymentEventNotificationPropsType, 'i18n'> {
  return {
    sender: conversationSelector(message.sourceServiceId),
    conversation: getConversation(message, conversationSelector),
    event: message.payment,
  };
}

// Unsupported Message

export function isUnsupportedMessage(
  message: MessageWithUIFieldsType
): boolean {
  const versionAtReceive = message.supportedVersionAtReceive;
  const requiredVersion = message.requiredProtocolVersion;

  return (
    isNumber(versionAtReceive) &&
    isNumber(requiredVersion) &&
    versionAtReceive < requiredVersion
  );
}

function getPropsForUnsupportedMessage(
  message: MessageWithUIFieldsType,
  options: GetContactOptions
): PropsForUnsupportedMessage {
  const CURRENT_PROTOCOL_VERSION = Proto.DataMessage.ProtocolVersion.CURRENT;

  const requiredVersion = message.requiredProtocolVersion;
  const canProcessNow = Boolean(
    CURRENT_PROTOCOL_VERSION &&
    requiredVersion &&
    CURRENT_PROTOCOL_VERSION >= requiredVersion
  );

  return {
    canProcessNow,
    contact: getContact(message, options),
  };
}

// GroupV2 Change

export function isGroupV2Change(message: MessageWithUIFieldsType): boolean {
  return Boolean(message.groupV2Change);
}

function getPropsForGroupV2Change(
  message: MessageWithUIFieldsType,
  { conversationSelector, ourAci, ourPni }: GetPropsForBubbleOptions
): GroupsV2Props {
  const change = message.groupV2Change;

  if (!change) {
    throw new Error('getPropsForGroupV2Change: Change is missing!');
  }

  const conversation = getConversation(message, conversationSelector);

  return {
    areWeAdmin: Boolean(conversation.areWeAdmin),
    conversationId: conversation.id,
    groupName: conversation?.type === 'group' ? conversation?.name : undefined,
    groupMemberships: conversation.memberships,
    groupBannedMemberships: conversation.bannedMemberships,
    ourAci,
    ourPni,
    change,
  };
}

// GroupV1 Migration

export function isGroupV1Migration(message: MessageWithUIFieldsType): boolean {
  return message.type === 'group-v1-migration';
}

function getPropsForGroupV1Migration(
  message: MessageWithUIFieldsType,
  { conversationSelector }: GetPropsForBubbleOptions
): GroupV1MigrationPropsType {
  const migration = message.groupMigration;
  if (!migration) {
    // Backwards-compatibility with data schema in early betas
    const invitedGV2Members = message.invitedGV2Members || [];
    const droppedGV2MemberIds = message.droppedGV2MemberIds || [];

    const invitedMembers = invitedGV2Members.map(item =>
      conversationSelector(item.uuid)
    );
    const droppedMembers = droppedGV2MemberIds.map(conversationId =>
      conversationSelector(conversationId)
    );

    return {
      areWeInvited: false,
      conversationId: message.conversationId,
      droppedMembers,
      invitedMembers,
      droppedMemberCount: droppedMembers.length,
      invitedMemberCount: invitedMembers.length,
    };
  }

  const {
    areWeInvited,
    droppedMemberIds,
    invitedMembers: rawInvitedMembers,
    droppedMemberCount: rawDroppedMemberCount,
    invitedMemberCount: rawInvitedMemberCount,
  } = migration;
  const droppedMembers = droppedMemberIds
    ? droppedMemberIds.map(conversationId =>
        conversationSelector(conversationId)
      )
    : undefined;
  const invitedMembers = rawInvitedMembers
    ? rawInvitedMembers.map(item => conversationSelector(item.uuid))
    : undefined;

  const droppedMemberCount =
    rawDroppedMemberCount ?? droppedMemberIds?.length ?? 0;
  const invitedMemberCount =
    rawInvitedMemberCount ?? invitedMembers?.length ?? 0;

  return {
    areWeInvited,
    conversationId: message.conversationId,
    droppedMembers,
    invitedMembers,
    droppedMemberCount,
    invitedMemberCount,
  };
}

// Note: props are null!

// Expiration Timer Update

export function isExpirationTimerUpdate(
  message: Pick<MessageWithUIFieldsType, 'flags'>
): boolean {
  const flag = Proto.DataMessage.Flags.EXPIRATION_TIMER_UPDATE;
  // eslint-disable-next-line no-bitwise
  return Boolean(message.flags && message.flags & flag);
}

function getPropsForTimerNotification(
  message: MessageWithUIFieldsType,
  { ourConversationId, conversationSelector }: GetPropsForBubbleOptions
): TimerNotificationProps {
  const timerUpdate = message.expirationTimerUpdate;
  if (!timerUpdate) {
    throw new Error(
      'getPropsForTimerNotification: missing expirationTimerUpdate!'
    );
  }

  const { expireTimer, fromSync, source, sourceServiceId } = timerUpdate;
  const disabled = !expireTimer;
  const sourceId = sourceServiceId || source;
  const { id: formattedContactId, title } = conversationSelector(sourceId);

  // Pacify typescript
  type MaybeExpireTimerType =
    | { disabled: true }
    | {
        disabled: false;
        expireTimer: DurationInSeconds;
      };

  const maybeExpireTimer: MaybeExpireTimerType = disabled
    ? {
        disabled: true,
      }
    : {
        disabled: false,
        expireTimer,
      };

  const basicProps = {
    title,
    ...maybeExpireTimer,
    type: 'fromOther' as const,
  };

  if (fromSync) {
    return {
      ...basicProps,
      type: 'fromSync' as const,
    };
  }
  if (formattedContactId === ourConversationId) {
    return {
      ...basicProps,
      type: 'fromMe' as const,
    };
  }
  if (!sourceId) {
    return {
      ...basicProps,
      type: 'fromMember' as const,
    };
  }

  return basicProps;
}

// Key Change

export function isKeyChange(message: MessageWithUIFieldsType): boolean {
  return message.type === 'keychange';
}

function getPropsForSafetyNumberNotification(
  message: MessageWithUIFieldsType,
  { conversationSelector }: GetPropsForBubbleOptions
): SafetyNumberNotificationProps {
  const conversation = getConversation(message, conversationSelector);
  const isGroup = conversation?.type === 'group';
  const identifier = message.key_changed;

  if (isGroup && !identifier) {
    throw new Error(
      'getPropsForSafetyNumberNotification: isGroup = true, but no identifier!'
    );
  }

  const contact = identifier ? conversationSelector(identifier) : conversation;

  return {
    isGroup,
    contact,
  };
}

// Verified Change

export function isVerifiedChange(message: MessageWithUIFieldsType): boolean {
  return message.type === 'verified-change';
}

function getPropsForVerificationNotification(
  message: MessageWithUIFieldsType,
  { conversationSelector }: GetPropsForBubbleOptions
): VerificationNotificationProps {
  const type = message.verified ? 'markVerified' : 'markNotVerified';
  const isLocal = message.local || false;
  const identifier = message.verifiedChanged;

  return {
    type,
    isLocal,
    contact: conversationSelector(identifier),
  };
}

// Gift Badge

export function isGiftBadge(
  message: Pick<MessageWithUIFieldsType, 'giftBadge'>
): boolean {
  return Boolean(message.giftBadge);
}

// Group Update (V1)

export function isGroupUpdate(
  message: Pick<MessageWithUIFieldsType, 'group_update'>
): boolean {
  return Boolean(message.group_update);
}

function getPropsForGroupNotification(
  message: MessageWithUIFieldsType,
  options: GetContactOptions
): GroupNotificationProps {
  const groupUpdate = message.group_update;
  if (!groupUpdate) {
    throw new Error(
      'getPropsForGroupNotification: Message missing group_update'
    );
  }

  const { conversationSelector } = options;

  const changes = [];

  if (
    !groupUpdate.avatarUpdated &&
    !groupUpdate.left &&
    !groupUpdate.joined &&
    !groupUpdate.name
  ) {
    changes.push({
      type: 'general' as ChangeType,
    });
  }

  if (groupUpdate.joined?.length) {
    changes.push({
      type: 'add' as ChangeType,
      contacts: map(
        Array.isArray(groupUpdate.joined)
          ? groupUpdate.joined
          : [groupUpdate.joined],
        identifier => conversationSelector(identifier)
      ),
    });
  }

  if (groupUpdate.left === 'You') {
    changes.push({
      type: 'remove' as ChangeType,
    });
  } else if (groupUpdate.left) {
    changes.push({
      type: 'remove' as ChangeType,
      contacts: map(
        Array.isArray(groupUpdate.left) ? groupUpdate.left : [groupUpdate.left],
        identifier => conversationSelector(identifier)
      ),
    });
  }

  if (groupUpdate.name) {
    changes.push({
      type: 'name' as ChangeType,
      newName: groupUpdate.name,
    });
  }

  if (groupUpdate.avatarUpdated) {
    changes.push({
      type: 'avatar' as ChangeType,
    });
  }

  const from = getContact(message, options);

  return {
    from,
    changes,
  };
}

// End Session

export function isEndSession(
  message: Pick<MessageWithUIFieldsType, 'flags'>
): boolean {
  const flag = Proto.DataMessage.Flags.END_SESSION;
  // eslint-disable-next-line no-bitwise
  return Boolean(message.flags && message.flags & flag);
}

// Call History

export function isCallHistory(message: MessageWithUIFieldsType): boolean {
  return message.type === 'call-history';
}

export type GetPropsForCallHistoryOptions = Pick<
  GetPropsForBubbleOptions,
  | 'callSelector'
  | 'activeCall'
  | 'callHistorySelector'
  | 'conversationSelector'
  | 'ourConversationId'
  | 'selectedMessageIds'
  | 'targetedMessageId'
>;

const emptyCallNotification: CallingNotificationType = {
  callHistory: null,
  callCreator: null,
  activeConversationId: null,
  groupCallEnded: null,
  maxDevices: Infinity,
  deviceCount: 0,
  isSelectMode: false,
  isTargeted: false,
};

export function getPropsForCallHistory(
  message: MessageWithUIFieldsType,
  {
    callSelector,
    callHistorySelector,
    activeCall,
    conversationSelector,
    ourConversationId,
    selectedMessageIds,
    targetedMessageId,
  }: GetPropsForCallHistoryOptions
): CallingNotificationType {
  const { callId } = message;
  if (callId == null) {
    log.error('getPropsForCallHistory: Missing callId');
    return emptyCallNotification;
  }
  const callHistory = callHistorySelector(callId);
  if (callHistory == null) {
    log.error('getPropsForCallHistory: Missing callHistory');
    return emptyCallNotification;
  }

  const activeConversationId = activeCall?.conversationId ?? null;

  const conversation = conversationSelector(callHistory.peerId);
  strictAssert(
    conversation != null,
    'getPropsForCallHistory: Missing conversation'
  );

  const isSelectMode = selectedMessageIds != null;

  let callCreator: ConversationType | null = null;
  if (callHistory.direction === CallDirection.Outgoing) {
    callCreator = conversationSelector(ourConversationId);
  } else if (callHistory.ringerId) {
    callCreator = conversationSelector(callHistory.ringerId);
  }

  if (callHistory.mode === CallMode.Direct) {
    return {
      callHistory,
      callCreator,
      activeConversationId,
      groupCallEnded: false,
      deviceCount: 0,
      maxDevices: Infinity,
      isSelectMode,
      isTargeted: message.id === targetedMessageId,
    };
  }

  // This could be a later call in the conversation
  const conversationCall = callSelector(conversation.id);

  if (conversationCall != null) {
    strictAssert(
      conversationCall?.callMode === CallMode.Group,
      'getPropsForCallHistory: Call was expected to be a group call'
    );
  }

  const conversationCallId =
    conversationCall?.peekInfo?.eraId != null &&
    getCallIdFromEra(conversationCall.peekInfo.eraId);

  const deviceCount = conversationCall?.peekInfo?.deviceCount ?? 0;
  const maxDevices = conversationCall?.peekInfo?.maxDevices ?? Infinity;

  return {
    callHistory,
    callCreator,
    activeConversationId,
    groupCallEnded: callId !== conversationCallId || deviceCount === 0,
    deviceCount,
    maxDevices,
    isSelectMode,
    isTargeted: message.id === targetedMessageId,
  };
}

// Pinned Message Notification

export function isPinnedMessageNotification(
  message: MessageWithUIFieldsType
): boolean {
  return message.type === 'pinned-message-notification';
}

export function getPropsForPinnedMessageNotification(
  message: MessageWithUIFieldsType,
  { conversationSelector }: GetPropsForBubbleOptions
): PinnedMessageNotificationData {
  strictAssert(message.pinMessage, 'Missing pinMessage');
  return {
    sender: conversationSelector(message.sourceServiceId),
    pinMessage: message.pinMessage,
  };
}

// Profile Change

export function isProfileChange(message: MessageWithUIFieldsType): boolean {
  return message.type === 'profile-change';
}

function getPropsForProfileChange(
  message: MessageWithUIFieldsType,
  { conversationSelector }: GetPropsForBubbleOptions
): ProfileChangeNotificationPropsType {
  const change = message.profileChange;
  const { changedId } = message;
  const changedContact = conversationSelector(changedId);

  if (!change) {
    throw new Error('getPropsForProfileChange: profileChange is undefined');
  }

  return {
    changedContact,
    change,
  } as ProfileChangeNotificationPropsType;
}

// Poll Terminate

export function isPollTerminate(message: MessageWithUIFieldsType): boolean {
  return message.type === 'poll-terminate';
}

function getPropsForPollTerminate(
  message: MessageWithUIFieldsType,
  { conversationSelector }: GetPropsForBubbleOptions
) {
  const { pollTerminateNotification, sourceServiceId, conversationId } =
    message;

  if (!pollTerminateNotification) {
    throw new Error(
      'getPropsForPollTerminate: pollTerminateNotification is undefined'
    );
  }

  const sender = conversationSelector(sourceServiceId);

  return {
    sender,
    pollQuestion: pollTerminateNotification.question,
    pollMessageId: pollTerminateNotification.pollMessageId,
    conversationId,
  };
}

// Message Request Response Event

export function isMessageRequestResponse(
  message: ReadonlyMessageAttributesType
): boolean {
  return message.type === 'message-request-response-event';
}

function getPropsForMessageRequestResponse(
  message: ReadonlyMessageAttributesType
): MessageRequestResponseNotificationData {
  const { messageRequestResponseEvent } = message;
  if (!messageRequestResponseEvent) {
    throw new Error('getPropsForMessageRequestResponse: event is missing!');
  }
  return { messageRequestResponseEvent };
}

// Universal Timer Notification

// Note: smart, so props not generated here

export function isUniversalTimerNotification(
  message: MessageWithUIFieldsType
): boolean {
  return message.type === 'universal-timer-notification';
}

// Contact Removed Notification

// Note: smart, so props not generated here

export function isContactRemovedNotification(
  message: MessageWithUIFieldsType
): boolean {
  return message.type === 'contact-removed-notification';
}

// Change Number Notification

export function isChangeNumberNotification(
  message: MessageWithUIFieldsType
): boolean {
  return message.type === 'change-number-notification';
}

function getPropsForChangeNumberNotification(
  message: MessageWithUIFieldsType,
  { conversationSelector }: GetPropsForBubbleOptions
): ChangeNumberNotificationProps {
  return {
    sender: conversationSelector(message.sourceServiceId),
    timestamp: message.sent_at,
  };
}

// Joined Signal Notification

export function isJoinedSignalNotification(
  message: MessageWithUIFieldsType
): boolean {
  return message.type === 'joined-signal-notification';
}

function getPropsForJoinedSignalNotification(
  message: MessageWithUIFieldsType
): JoinedSignalNotificationProps {
  return {
    timestamp: message.sent_at,
  };
}

// Title Transition Notification

export function isTitleTransitionNotification(
  message: MessageWithUIFieldsType
): boolean {
  return (
    message.type === 'title-transition-notification' &&
    message.titleTransition != null
  );
}

function getPropsForTitleTransitionNotification(
  message: MessageWithUIFieldsType
): TitleTransitionNotificationProps {
  strictAssert(
    message.titleTransition != null,
    'Invalid attributes for title-transition-notification'
  );
  const { renderInfo } = message.titleTransition;
  const oldTitle = getTitle(renderInfo);
  return {
    oldTitle,
  };
}

// Chat Session Refreshed

export function isChatSessionRefreshed(
  message: MessageWithUIFieldsType
): boolean {
  return message.type === 'chat-session-refreshed';
}

// Note: props are null

export function isConversationMerge(message: MessageWithUIFieldsType): boolean {
  return message.type === 'conversation-merge';
}
export function getPropsForConversationMerge(
  message: MessageWithUIFieldsType,
  { conversationSelector }: GetPropsForBubbleOptions
): ConversationMergePropsType {
  const { conversationMerge } = message;
  if (!conversationMerge) {
    throw new Error(
      'getPropsForConversationMerge: message is missing conversationMerge!'
    );
  }

  const conversation = getConversation(message, conversationSelector);
  const conversationTitle = conversation.title;

  const { renderInfo } = conversationMerge;
  const obsoleteConversationTitle = getTitleNoDefault(renderInfo);
  const obsoleteConversationNumber = getNumber(renderInfo);

  return {
    conversationTitle,
    obsoleteConversationTitle,
    obsoleteConversationNumber,
  };
}

export function isPhoneNumberDiscovery(
  message: MessageWithUIFieldsType
): boolean {
  return message.type === 'phone-number-discovery';
}
export function getPropsForPhoneNumberDiscovery(
  message: MessageWithUIFieldsType,
  { conversationSelector }: GetPropsForBubbleOptions
): PhoneNumberDiscoveryPropsType {
  const { phoneNumberDiscovery } = message;
  if (!phoneNumberDiscovery) {
    throw new Error(
      'getPropsForPhoneNumberDiscovery: message is missing phoneNumberDiscovery!'
    );
  }

  const conversation = getConversation(message, conversationSelector);
  const conversationTitle = conversation.title;
  const { e164 } = phoneNumberDiscovery;

  return {
    conversationId: conversation.id,
    conversationTitle,
    phoneNumber: renderNumber(e164) ?? e164,
  };
}

// Delivery Issue

export function isDeliveryIssue(message: MessageWithUIFieldsType): boolean {
  return message.type === 'delivery-issue';
}

function getPropsForDeliveryIssue(
  message: MessageWithUIFieldsType,
  { conversationSelector }: GetPropsForBubbleOptions
): DeliveryIssuePropsType {
  const sender = conversationSelector(message.sourceServiceId);
  const conversation = getConversation(message, conversationSelector);

  return {
    sender,
    inGroup: conversation.type === 'group',
  };
}

// Other utility functions

export function isTapToView(message: MessageWithUIFieldsType): boolean {
  // If a message is deleted for everyone, that overrides all other styling
  if (message.deletedForEveryone) {
    return false;
  }

  return Boolean(message.isViewOnce || message.messageTimer);
}

export function getMessagePropStatus(
  message: Pick<
    MessageWithUIFieldsType,
    | 'deletedForEveryone'
    | 'deletedForEveryoneFailed'
    | 'deletedForEveryoneSendStatus'
    | 'errors'
    | 'sendStateByConversationId'
    | 'type'
  >,
  ourConversationId: string | undefined
): LastMessageStatus | undefined {
  const {
    deletedForEveryone,
    deletedForEveryoneFailed,
    deletedForEveryoneSendStatus,
    sendStateByConversationId = {},
  } = message;

  // Note: we only do anything here if deletedForEveryoneSendStatus exists, because old
  //   messages deleted for everyone won't have send status.
  if (deletedForEveryone && deletedForEveryoneSendStatus) {
    if (deletedForEveryoneFailed) {
      const anySuccessfulSends = Object.values(
        deletedForEveryoneSendStatus
      ).some(item => item);

      return anySuccessfulSends ? 'partial-sent' : 'error';
    }
    const missingSends = Object.values(deletedForEveryoneSendStatus).some(
      item => !item
    );
    if (missingSends) {
      return 'sending';
    }
  }

  if (!isOutgoing(message)) {
    return hasErrors(message) ? 'error' : undefined;
  }

  if (getLastChallengeError(message)) {
    return 'paused';
  }

  if (
    ourConversationId &&
    isMessageJustForMe(sendStateByConversationId, ourConversationId)
  ) {
    const status =
      sendStateByConversationId[ourConversationId]?.status ??
      SendStatus.Pending;
    const sent = isSent(status);
    if (
      hasErrors(message) ||
      someSendStatus(sendStateByConversationId, isFailed)
    ) {
      return sent ? 'partial-sent' : 'error';
    }
    return sent ? 'viewed' : 'sending';
  }

  const highestSuccessfulStatus = getHighestSuccessfulRecipientStatus(
    sendStateByConversationId,
    ourConversationId
  );

  if (
    hasErrors(message) ||
    someSendStatus(sendStateByConversationId, isFailed)
  ) {
    return isSent(highestSuccessfulStatus) ? 'partial-sent' : 'error';
  }
  if (isViewed(highestSuccessfulStatus)) {
    return 'viewed';
  }
  if (isRead(highestSuccessfulStatus)) {
    return 'read';
  }
  if (isDelivered(highestSuccessfulStatus)) {
    return 'delivered';
  }
  if (isSent(highestSuccessfulStatus)) {
    return 'sent';
  }
  return 'sending';
}

function getDeletedForEveryoneByAdmin(
  message: MessageWithUIFieldsType,
  options: {
    conversationSelector: GetConversationByIdType;
    contactNameColors: Map<string, string>;
    ourAci: AciString | undefined;
  }
): PropsData['deletedForEveryoneByAdmin'] {
  const { deletedForEveryoneByAdminAci } = message;
  const { conversationSelector } = options;
  if (deletedForEveryoneByAdminAci == null) {
    return;
  }
  // If the admin deleted their own message, display it like a normal delete
  const messageAuthorAci = getSourceServiceId(message, options.ourAci);
  if (deletedForEveryoneByAdminAci === messageAuthorAci) {
    return;
  }
  const adminConversationId = window.ConversationController.getConversationId(
    deletedForEveryoneByAdminAci
  );
  if (adminConversationId == null) {
    return;
  }
  const adminConversation = conversationSelector(adminConversationId);
  return {
    conversationId: adminConversationId,
    title: adminConversation.title,
    contactNameColor: getContactNameColor(
      options.contactNameColors,
      adminConversationId
    ),
    isMe: adminConversation.isMe,
  };
}

export function getPropsForEmbeddedContact(
  message: MessageWithUIFieldsType,
  regionCode: string | undefined,
  accountSelector: (identifier?: string) => ServiceIdString | undefined
): ReadonlyDeep<EmbeddedContactForUIType> | undefined {
  const contacts = message.contact;
  if (!contacts || !contacts.length) {
    return undefined;
  }

  const firstContact = contacts[0];
  const numbers = firstContact?.number;
  const firstNumber = numbers && numbers[0] ? numbers[0].value : undefined;

  return embeddedContactSelector(firstContact, {
    regionCode,
    firstNumber,
    serviceId: accountSelector(firstNumber),
  });
}

export function getPropsForAttachment(
  attachment: AttachmentType,
  disposition: MessageAttachmentType,
  message: Pick<ReadonlyMessageAttributesType, 'type'>
): AttachmentForUIType {
  const { path, pending, screenshot, thumbnail, thumbnailFromBackup } =
    attachment;

  return {
    ...attachment,
    isVoiceMessage: isVoiceMessage(attachment),
    pending,
    url: path ? getLocalAttachmentUrl(attachment) : undefined,
    incrementalUrl:
      isIncremental(attachment) && attachment.downloadPath
        ? getLocalAttachmentUrl(attachment, {
            disposition: AttachmentDisposition.Download,
          })
        : undefined,
    thumbnailFromBackup: thumbnailFromBackup?.path
      ? {
          ...thumbnailFromBackup,
          url: getLocalAttachmentUrl(thumbnailFromBackup),
        }
      : undefined,
    screenshot: screenshot?.path
      ? {
          ...screenshot,
          url: getLocalAttachmentUrl({
            // Legacy v1 screenshots
            size: 0,

            ...screenshot,
          }),
        }
      : undefined,
    thumbnail: thumbnail?.path
      ? {
          ...thumbnail,
          url: getLocalAttachmentUrl(thumbnail),
        }
      : undefined,
    isPermanentlyUndownloadable: isPermanentlyUndownloadable(
      attachment,
      disposition,
      message
    ),
  };
}

function processQuoteAttachment(attachment: QuotedAttachmentType) {
  const { thumbnail } = attachment;

  return {
    ...attachment,
    isVoiceMessage: isVoiceMessage(attachment),
    thumbnail: thumbnail
      ? {
          ...thumbnail,
          url: thumbnail?.path ? getLocalAttachmentUrl(thumbnail) : undefined,
        }
      : undefined,
  };
}

function canReplyOrReact(
  message: Pick<
    MessageWithUIFieldsType,
    | 'canReplyToStory'
    | 'deletedForEveryone'
    | 'payment'
    | 'sendStateByConversationId'
    | 'sms'
    | 'type'
  >,
  ourConversationId: string | undefined,
  conversation: undefined | Readonly<ConversationType>
): boolean {
  const { deletedForEveryone, sendStateByConversationId } = message;

  if (!conversation) {
    return false;
  }

  if (conversation.isGroupV1AndDisabled) {
    return false;
  }

  if (isMissingRequiredProfileSharing(conversation)) {
    return false;
  }

  if (
    !conversation.acceptedMessageRequest &&
    conversation.removalStage !== 'justNotification'
  ) {
    return false;
  }

  if (conversation.isBlocked) {
    return false;
  }

  if (deletedForEveryone) {
    return false;
  }

  if (isSignalConversation(conversation)) {
    return false;
  }

  if (message.sms) {
    return false;
  }

  if (isOutgoing(message)) {
    return (
      isMessageJustForMe(sendStateByConversationId ?? {}, ourConversationId) ||
      someRecipientSendStatus(
        sendStateByConversationId ?? {},
        ourConversationId,
        isSent
      )
    );
  }

  // If we get past all the other checks above then we can always reply or
  // react if the message type is "incoming" | "story"
  if (isIncoming(message)) {
    return true;
  }

  if (isStory(message)) {
    return (
      Boolean(message.canReplyToStory) && conversation.id !== ourConversationId
    );
  }

  // Fail safe.
  return false;
}

export function canReply(
  message: Pick<
    MessageWithUIFieldsType,
    | 'canReplyToStory'
    | 'conversationId'
    | 'deletedForEveryone'
    | 'sendStateByConversationId'
    | 'sms'
    | 'type'
  >,
  ourConversationId: string | undefined,
  conversationSelector: GetConversationByIdType
): boolean {
  const conversation = getConversation(message, conversationSelector);
  if (
    !conversation ||
    (conversation.announcementsOnly && !conversation.areWeAdmin)
  ) {
    return false;
  }
  return canReplyOrReact(message, ourConversationId, conversation);
}

export function canReact(
  message: Pick<
    MessageWithUIFieldsType,
    | 'conversationId'
    | 'deletedForEveryone'
    | 'sendStateByConversationId'
    | 'sms'
    | 'type'
  >,
  ourConversationId: string | undefined,
  conversationSelector: GetConversationByIdType
): boolean {
  const conversation = getConversation(message, conversationSelector);
  return canReplyOrReact(message, ourConversationId, conversation);
}

export function canCopy(
  message: Pick<MessageWithUIFieldsType, 'body' | 'deletedForEveryone'>
): boolean {
  return !message.deletedForEveryone && Boolean(message.body);
}

type CanDeleteForEveryoneConversation = Pick<
  ConversationType,
  'id' | 'e164' | 'serviceId' | 'groupId' | 'groupVersion' | 'areWeAdmin'
>;

type MessageCanDeleteForEveryoneResult = Readonly<{
  canDeleteForEveryone: boolean;
  needsAdminDelete: boolean;
  isDeletingOwnMessage: boolean;
}>;

function getMessageCanDeleteForEveryone(
  message: Pick<
    MessageWithUIFieldsType,
    | 'type'
    | 'deletedForEveryone'
    | 'sent_at'
    | 'serverTimestamp'
    | 'sendStateByConversationId'
    | 'sourceServiceId'
    | 'sms'
  >,
  options: {
    conversation: CanDeleteForEveryoneConversation;
    ourAci: AciString | undefined;
  }
): MessageCanDeleteForEveryoneResult {
  const { conversation, ourAci } = options;
  const isDeletingOwnMessage = isOutgoing(message);

  if (!ourAci) {
    return {
      canDeleteForEveryone: false,
      needsAdminDelete: false,
      isDeletingOwnMessage,
    };
  }

  // For outgoing messages, we must have actually sent to someone
  if (
    isDeletingOwnMessage &&
    !someSendStatus(message.sendStateByConversationId ?? {}, isSent)
  ) {
    return {
      canDeleteForEveryone: false,
      needsAdminDelete: false,
      isDeletingOwnMessage,
    };
  }

  const messageAuthorAci = isDeletingOwnMessage
    ? ourAci
    : message.sourceServiceId;
  if (!messageAuthorAci || !isAciString(messageAuthorAci)) {
    return {
      canDeleteForEveryone: false,
      needsAdminDelete: false,
      isDeletingOwnMessage,
    };
  }

  const result = canSendDeleteForEveryone({
    targetMessage: message,
    targetConversation: conversation,
    ourAci,
    isDeleterGroupAdmin: Boolean(conversation.areWeAdmin),
  });

  if (!result.ok) {
    return {
      canDeleteForEveryone: false,
      needsAdminDelete: false,
      isDeletingOwnMessage,
    };
  }

  if (result.needsAdminDelete) {
    if (!isAdminDeleteSendEnabled()) {
      return {
        canDeleteForEveryone: false,
        needsAdminDelete: false,
        isDeletingOwnMessage,
      };
    }
    return {
      canDeleteForEveryone: true,
      needsAdminDelete: true,
      isDeletingOwnMessage,
    };
  }

  return {
    canDeleteForEveryone: true,
    needsAdminDelete: false,
    isDeletingOwnMessage,
  };
}

function canDeleteForEveryoneInSelector(
  message: Pick<
    MessageWithUIFieldsType,
    | 'type'
    | 'deletedForEveryone'
    | 'sent_at'
    | 'serverTimestamp'
    | 'sendStateByConversationId'
    | 'sourceServiceId'
    | 'sms'
  >,
  options: {
    conversation: CanDeleteForEveryoneConversation;
    ourAci: AciString | undefined;
  }
): boolean {
  return getMessageCanDeleteForEveryone(message, options).canDeleteForEveryone;
}

export type MessagesCanDeleteForEveryoneResult = Readonly<{
  canDeleteForEveryone: boolean;
  needsAdminDelete: boolean;
  isDeletingOwnMessages: boolean;
}>;

export const getMessagesCanDeleteForEveryone = createSelector(
  [
    getMessages,
    (
      _state,
      options: {
        messageIds: ReadonlyArray<string>;
        conversation: CanDeleteForEveryoneConversation;
        ourAci: AciString | undefined;
      }
    ) => options,
  ],
  (messagesLookup, options): MessagesCanDeleteForEveryoneResult => {
    let canDeleteForEveryone = true;
    let needsAdminDelete = false;
    let isDeletingOwnMessages = true;

    for (const messageId of options.messageIds) {
      const message = getOwn(messagesLookup, messageId);
      if (message == null) {
        log.warn(`Message ${messageId} not found, maybe it was deleted`);
        continue;
      }
      const messageResult = getMessageCanDeleteForEveryone(message, options);
      if (!messageResult.canDeleteForEveryone) {
        canDeleteForEveryone = false;
      } else if (messageResult.needsAdminDelete) {
        needsAdminDelete = true;
      }
      if (!messageResult.isDeletingOwnMessage) {
        isDeletingOwnMessages = false;
      }
    }

    if (!canDeleteForEveryone) {
      needsAdminDelete = false;
    }

    return { canDeleteForEveryone, needsAdminDelete, isDeletingOwnMessages };
  }
);

export function canRetryDeleteForEveryone(
  message: Pick<
    MessageWithUIFieldsType,
    | 'type'
    | 'sourceServiceId'
    | 'deletedForEveryone'
    | 'deletedForEveryoneByAdminAci'
    | 'deletedForEveryoneFailed'
    | 'sent_at'
    | 'serverTimestamp'
    | 'sms'
  >,
  options: {
    conversation: CanDeleteForEveryoneConversation;
    ourAci: AciString | undefined;
  }
): boolean {
  const { conversation, ourAci } = options;
  if (!ourAci) {
    return false;
  }
  const isAdminDelete = message.deletedForEveryoneByAdminAci != null;
  const result = canRetrySendDeleteForEveryone({
    targetMessage: message,
    targetConversation: conversation,
    isAdminDelete,
    isDeleterGroupAdmin: Boolean(conversation.areWeAdmin),
    ourAci,
  });
  return result.ok;
}

export function canEndPoll(
  message: Pick<MessageWithUIFieldsType, 'type' | 'poll'>
): boolean {
  if (message.type !== 'outgoing') {
    return false;
  }

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

  if (poll.terminatedAt != null) {
    return false;
  }

  return true;
}

export function canDownload(
  message: MessageWithUIFieldsType,
  conversationSelector: GetConversationByIdType
): boolean {
  const conversation = getConversation(message, conversationSelector);
  const isAccepted = Boolean(
    conversation && conversation.acceptedMessageRequest
  );
  if (isIncoming(message) && !isAccepted) {
    return false;
  }

  if (message.sticker) {
    return false;
  }

  if (isTapToView(message)) {
    return false;
  }

  // Ensure that all attachments are downloadable
  const { attachments } = message;
  if (attachments && attachments.length) {
    return attachments.every(attachment => Boolean(attachment.path));
  }

  return false;
}

export function canForward(message: ReadonlyMessageAttributesType): boolean {
  return (
    !isTapToView(message) &&
    !message.deletedForEveryone &&
    !message.giftBadge &&
    !getPayment(message) &&
    !isPoll(message)
  );
}

export function canPinMessages(conversation: ConversationType): boolean {
  if (!isPinnedMessagesSendEnabled()) {
    return false;
  }
  return (
    conversation.type === 'direct' || conversation.canEditGroupInfo === true
  );
}

export function canPinMessage(
  conversation: ConversationType,
  message: ReadonlyMessageAttributesType
): boolean {
  if (!canPinMessages(conversation)) {
    return false;
  }
  if (message.deletedForEveryone) {
    return false;
  }
  if (isGiftBadge(message)) {
    return false;
  }
  return true;
}

export function getLastChallengeError(
  message: Pick<MessageWithUIFieldsType, 'errors'>
): ShallowChallengeError | undefined {
  const { errors } = message;
  if (!errors) {
    return undefined;
  }

  const challengeErrors = errors
    .filter((error): error is ShallowChallengeError => {
      return (
        error.name === 'SendMessageChallengeError' &&
        isNumber(error.retryAfter) &&
        isObject(error.data)
      );
    })
    .sort((a, b) => a.retryAfter - b.retryAfter);

  return challengeErrors.pop();
}

const OUTGOING_KEY_ERROR = 'OutgoingIdentityKeyError';

function getMessageDetailErrors(
  message: MessageWithUIFieldsType,
  i18n: LocalizerType
): ReadonlyArray<CustomError> {
  const messageErrors = message.errors ?? [];

  // This will make the error message for outgoing key errors a bit nicer
  return messageErrors.map(error => {
    if (error.name === OUTGOING_KEY_ERROR) {
      return { ...error, message: i18n('icu:newIdentity') };
    }
    return error;
  });
}

function getDeleteDetailRecipients(
  message: MessageWithUIFieldsType,
  options: { conversationSelector: GetConversationByIdType }
): ReadonlyArray<SmartMessageDetailContact> {
  const { deletedForEveryoneSendStatus = {} } = message;
  const { conversationSelector } = options;

  return Object.keys(deletedForEveryoneSendStatus).map(conversationId => {
    const isSentToRecipient = deletedForEveryoneSendStatus[conversationId];
    let status: SendStatus;
    if (isSentToRecipient) {
      status = SendStatus.Sent;
    } else if (message.deletedForEveryoneFailed) {
      status = SendStatus.Failed;
    } else {
      status = SendStatus.Pending;
    }

    return {
      ...conversationSelector(conversationId),
      status,
      errors: [],
      isOutgoingKeyError: false,
      isUnidentifiedDelivery: false,
    };
  });
}

function getMessageDetailRecipients(
  message: MessageWithUIFieldsType,
  allErrors: ReadonlyArray<CustomError>,
  options: {
    conversationSelector: GetConversationByIdType;
    hasUnidentifiedDeliveryIndicators: boolean;
    ourAci: AciString | undefined;
    ourConversationId: string;
    ourNumber: string | undefined;
  }
): ReadonlyArray<SmartMessageDetailContact> {
  const {
    conversationSelector,
    hasUnidentifiedDeliveryIndicators,
    ourAci,
    ourConversationId,
    ourNumber,
  } = options;

  const {
    sendStateByConversationId = {},
    unidentifiedDeliveries = [],
    unidentifiedDeliveryReceived,
  } = message;

  const unidentifiedDeliveriesSet = new Set(
    unidentifiedDeliveries.map(identifier =>
      window.ConversationController.getConversationId(identifier)
    )
  );

  let conversationIds: Array<string>;
  if (isIncoming(message)) {
    conversationIds = [
      getAuthorId(message, {
        conversationSelector,
        ourConversationId,
        ourNumber,
        ourAci,
      }),
    ].filter(isNotNil);
  } else if (!isEmpty(sendStateByConversationId)) {
    if (isMessageJustForMe(sendStateByConversationId, ourConversationId)) {
      conversationIds = [ourConversationId];
    } else {
      conversationIds = Object.keys(sendStateByConversationId).filter(
        id => id !== ourConversationId
      );
    }
  } else {
    const messageConversation = window.ConversationController.get(
      message.conversationId
    );
    const conversationRecipients = messageConversation
      ? getRecipients(messageConversation.attributes) || []
      : [];
    // Older messages don't have the recipients included on the message, so we fall
    //    back to the conversation's current recipients
    conversationIds = conversationRecipients
      .map((id: string) => window.ConversationController.getConversationId(id))
      .filter(isNotNil);
  }

  const errorsGroupedById = groupBy(allErrors, error => {
    const serviceId = error.serviceId || error.number;
    if (!serviceId) {
      return null;
    }

    return window.ConversationController.getConversationId(serviceId);
  });

  return conversationIds.map(id => {
    const errorsForContact = getOwn(errorsGroupedById, id);
    const isOutgoingKeyError = Boolean(
      errorsForContact?.some(error => error.name === OUTGOING_KEY_ERROR)
    );

    let isUnidentifiedDelivery = false;
    if (hasUnidentifiedDeliveryIndicators) {
      isUnidentifiedDelivery = isIncoming(message)
        ? Boolean(unidentifiedDeliveryReceived)
        : unidentifiedDeliveriesSet.has(id);
    }

    const sendState = getOwn(sendStateByConversationId, id);

    let status = sendState?.status;

    // If a message was only sent to yourself (Note to Self or a lonely group), it
    // is shown read.
    if (id === ourConversationId && status && isSent(status)) {
      status = SendStatus.Read;
    }

    const statusUpdatedAt = sendState?.updatedAt;
    const statusTimestamp =
      statusUpdatedAt === message.timestamp ? undefined : statusUpdatedAt;

    return {
      ...conversationSelector(id),
      errors: errorsForContact,
      isOutgoingKeyError,
      isUnidentifiedDelivery,
      status,
      statusTimestamp,
    };
  });
}

export const getMessageDetailsSelector = createSelector(
  getAccountSelector,
  getCachedConversationMemberColorsSelector,
  getConversationSelector,
  getIntl,
  getRegionCode,
  getMessages,
  getUserACI,
  getUserPNI,
  getUserConversationId,
  getUserNumber,
  getPinnedMessagesMessageIds,
  getSelectedMessageIds,
  getDefaultConversationColor,
  getHasUnidentifiedDeliveryIndicators,
  (
    accountSelector,
    cachedConversationMemberColorsSelector,
    conversationSelector,
    i18n,
    regionCode,
    messageLookup,
    ourAci,
    ourPni,
    ourConversationId,
    ourNumber,
    pinnedMessagesMessageIds,
    selectedMessageIds,
    defaultConversationColor,
    hasUnidentifiedDeliveryIndicators
  ): ((messageId: string) => SmartMessageDetailPropsType | undefined) =>
    (messageId: string) => {
      if (!messageLookup || !ourConversationId) {
        return;
      }

      const message = messageLookup[messageId];
      if (!message) {
        return;
      }

      let errors: ReadonlyArray<CustomError>;
      let contacts: ReadonlyArray<SmartMessageDetailContact>;

      if (message.deletedForEveryone) {
        errors = [];
        contacts = getDeleteDetailRecipients(message, {
          conversationSelector,
        });
      } else {
        const allErrors = getMessageDetailErrors(message, i18n);
        // If an error has a specific number it's associated with, we'll show
        // it next to that contact. Otherwise, it will be a standalone entry.
        errors = allErrors.filter(error =>
          Boolean(error.serviceId || error.number)
        );
        contacts = getMessageDetailRecipients(message, allErrors, {
          conversationSelector,
          hasUnidentifiedDeliveryIndicators,
          ourAci,
          ourConversationId,
          ourNumber,
        });
      }

      return {
        contacts,
        errors,
        message: getPropsForMessage(message, {
          accountSelector,
          contactNameColors: cachedConversationMemberColorsSelector(
            message.conversationId
          ),
          conversationSelector,
          ourAci,
          ourPni,
          ourConversationId,
          ourNumber,
          regionCode,
          pinnedMessagesMessageIds,
          selectedMessageIds,
          defaultConversationColor,
        }),
        receivedAt: Number(message.received_at_ms || message.received_at),
      };
    }
);
