Use proxy-compare for message bubbles

This commit is contained in:
Fedor Indutny 2022-12-22 16:32:03 -08:00 committed by GitHub
parent f92f81dfd6
commit 55a1c5f6c5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 442 additions and 562 deletions

View file

@ -1,18 +1,8 @@
// Copyright 2021-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import {
groupBy,
identity,
isEmpty,
isEqual,
isNumber,
isObject,
map,
omit,
pick,
} from 'lodash';
import { createSelector, createSelectorCreator } from 'reselect';
import { groupBy, isEmpty, isNumber, isObject, map, omit } from 'lodash';
import { createSelector } from 'reselect';
import filesize from 'filesize';
import getDirection from 'direction';
import emojiRegex from 'emoji-regex';
@ -66,7 +56,7 @@ import { isVoiceMessage, canBeDownloaded } from '../../types/Attachment';
import { ReadStatus } from '../../messages/MessageReadStatus';
import type { CallingNotificationType } from '../../util/callingNotification';
import { memoizeByRoot } from '../../util/memoizeByRoot';
import { proxyMemoize } from '../../util/proxyMemoize';
import { missingCaseError } from '../../util/missingCaseError';
import { getRecipients } from '../../util/getRecipients';
import { getOwn } from '../../util/getOwn';
@ -280,13 +270,11 @@ export function getConversation(
// Message
export const getAttachmentsForMessage = createSelectorCreator(memoizeByRoot)(
// `memoizeByRoot` requirement
identity,
({ sticker }: MessageWithUIFieldsType) => sticker,
({ attachments }: MessageWithUIFieldsType) => attachments,
(_, sticker, attachments = []): Array<AttachmentType> => {
export const getAttachmentsForMessage = proxyMemoize(
({
sticker,
attachments = [],
}: MessageWithUIFieldsType): Array<AttachmentType> => {
if (sticker && sticker.data) {
const { data } = sticker;
@ -311,16 +299,16 @@ export const getAttachmentsForMessage = createSelectorCreator(memoizeByRoot)(
.filter(attachment => !attachment.error || canBeDownloaded(attachment))
.map(attachment => getPropsForAttachment(attachment))
.filter(isNotNil);
},
{
name: 'getAttachmentsForMessage',
}
);
export const processBodyRanges = createSelectorCreator(memoizeByRoot, isEqual)(
// `memoizeByRoot` requirement
identity,
export const processBodyRanges = proxyMemoize(
(
{ bodyRanges }: Pick<MessageWithUIFieldsType, 'bodyRanges'>,
{ conversationSelector }: { conversationSelector: GetConversationByIdType }
options: { conversationSelector: GetConversationByIdType }
): HydratedBodyRangesType | undefined => {
if (!bodyRanges) {
return undefined;
@ -329,6 +317,7 @@ export const processBodyRanges = createSelectorCreator(memoizeByRoot, isEqual)(
return bodyRanges
.filter(range => range.mentionUuid)
.map(range => {
const { conversationSelector } = options;
const conversation = conversationSelector(range.mentionUuid);
return {
@ -339,16 +328,83 @@ export const processBodyRanges = createSelectorCreator(memoizeByRoot, isEqual)(
})
.sort((a, b) => b.start - a.start);
},
(_, ranges): undefined | HydratedBodyRangesType => ranges
{
name: 'processBodyRanges',
}
);
const getAuthorForMessage = createSelectorCreator(memoizeByRoot)(
// `memoizeByRoot` requirement
identity,
const getAuthorForMessage = (
message: MessageWithUIFieldsType,
options: GetContactOptions
): PropsData['author'] => {
const {
acceptedMessageRequest,
avatarPath,
badges,
color,
id,
isMe,
name,
phoneNumber,
profileName,
sharedGroupNames,
title,
unblurredAvatarPath,
} = getContact(message, options);
getContact,
const unsafe = {
acceptedMessageRequest,
avatarPath,
badges,
color,
id,
isMe,
name,
phoneNumber,
profileName,
sharedGroupNames,
title,
unblurredAvatarPath,
};
const safe: AssertProps<PropsData['author'], typeof unsafe> = unsafe;
return safe;
};
const getPreviewsForMessage = ({
preview: previews = [],
}: MessageWithUIFieldsType): Array<LinkPreviewType> => {
return previews.map(preview => ({
...preview,
isStickerPack: isStickerPack(preview.url),
domain: getDomain(preview.url),
image: preview.image ? getPropsForAttachment(preview.image) : 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'];
(_, convo: ConversationType): PropsData['author'] => {
const {
acceptedMessageRequest,
avatarPath,
@ -361,8 +417,7 @@ const getAuthorForMessage = createSelectorCreator(memoizeByRoot)(
profileName,
sharedGroupNames,
title,
unblurredAvatarPath,
} = convo;
} = c;
const unsafe = {
acceptedMessageRequest,
@ -376,155 +431,65 @@ const getAuthorForMessage = createSelectorCreator(memoizeByRoot)(
profileName,
sharedGroupNames,
title,
unblurredAvatarPath,
};
const safe: AssertProps<PropsData['author'], typeof unsafe> = unsafe;
const from: AssertProps<From, typeof unsafe> = unsafe;
return safe;
}
);
const getCachedAuthorForMessage = createSelectorCreator(memoizeByRoot, isEqual)(
// `memoizeByRoot` requirement
identity,
getAuthorForMessage,
(_, author): PropsData['author'] => author
);
export const getPreviewsForMessage = createSelectorCreator(memoizeByRoot)(
// `memoizeByRoot` requirement
identity,
({ preview }: MessageWithUIFieldsType) => preview,
(_, previews = []): Array<LinkPreviewType> => {
return previews.map(preview => ({
...preview,
isStickerPack: isStickerPack(preview.url),
domain: getDomain(preview.url),
image: preview.image ? getPropsForAttachment(preview.image) : undefined,
}));
}
);
export const getReactionsForMessage = createSelectorCreator(
memoizeByRoot,
isEqual
)(
// `memoizeByRoot` requirement
identity,
(
{ 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 unsafe = pick(c, [
'acceptedMessageRequest',
'avatarPath',
'badges',
'color',
'id',
'isMe',
'name',
'phoneNumber',
'profileName',
'sharedGroupNames',
'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];
},
(_, reactions): PropsData['reactions'] => reactions
);
export const getPropsForStoryReplyContext = createSelectorCreator(
memoizeByRoot,
isEqual
)(
// `memoizeByRoot` requirement
identity,
(
message: Pick<
MessageWithUIFieldsType,
'body' | 'conversationId' | 'storyReaction' | 'storyReplyContext'
>,
{
conversationSelector,
ourConversationId,
}: {
conversationSelector: GetConversationByIdType;
ourConversationId?: string;
}
): PropsData['storyReplyContext'] => {
const { storyReaction, storyReplyContext } = message;
if (!storyReplyContext) {
return undefined;
}
const contact = conversationSelector(storyReplyContext.authorUuid);
const authorTitle = contact.firstName || contact.title;
const isFromMe = contact.id === ourConversationId;
const conversation = getConversation(message, conversationSelector);
const { conversationColor, customColor } =
getConversationColorAttributes(conversation);
strictAssert(re.emoji, 'Expected all reactions to have an emoji');
return {
authorTitle,
conversationColor,
customColor,
emoji: storyReaction?.emoji,
isFromMe,
rawAttachment: storyReplyContext.attachment
? processQuoteAttachment(storyReplyContext.attachment)
: undefined,
storyId: storyReplyContext.messageId,
text: getStoryReplyText(window.i18n, storyReplyContext.attachment),
emoji: re.emoji,
timestamp: re.timestamp,
from,
};
},
});
(_, storyReplyContext): PropsData['storyReplyContext'] => storyReplyContext
);
return [...formattedReactions];
};
export const getPropsForQuote = createSelectorCreator(memoizeByRoot, isEqual)(
// `memoizeByRoot` requirement
identity,
const getPropsForStoryReplyContext = (
message: Pick<
MessageWithUIFieldsType,
'body' | 'conversationId' | 'storyReaction' | 'storyReplyContext'
>,
{
conversationSelector,
ourConversationId,
}: {
conversationSelector: GetConversationByIdType;
ourConversationId?: string;
}
): PropsData['storyReplyContext'] => {
const { storyReaction, storyReplyContext } = message;
if (!storyReplyContext) {
return undefined;
}
const contact = conversationSelector(storyReplyContext.authorUuid);
const authorTitle = contact.firstName || contact.title;
const isFromMe = contact.id === ourConversationId;
const conversation = getConversation(message, conversationSelector);
const { conversationColor, customColor } =
getConversationColorAttributes(conversation);
return {
authorTitle,
conversationColor,
customColor,
emoji: storyReaction?.emoji,
isFromMe,
rawAttachment: storyReplyContext.attachment
? processQuoteAttachment(storyReplyContext.attachment)
: undefined,
storyId: storyReplyContext.messageId,
text: getStoryReplyText(window.i18n, storyReplyContext.attachment),
};
};
export const getPropsForQuote = proxyMemoize(
(
message: Pick<
MessageWithUIFieldsType,
@ -591,8 +556,9 @@ export const getPropsForQuote = createSelectorCreator(memoizeByRoot, isEqual)(
text,
};
},
(_, quote): PropsData['quote'] => quote
{
name: 'getPropsForQuote',
}
);
export type GetPropsForMessageOptions = Pick<
@ -609,136 +575,6 @@ export type GetPropsForMessageOptions = Pick<
| 'contactNameColorSelector'
>;
type ShallowPropsType = Pick<
PropsForMessage,
| 'canDeleteForEveryone'
| 'canDownload'
| 'canReact'
| 'canReply'
| 'canRetry'
| 'canRetryDeleteForEveryone'
| 'contact'
| 'contactNameColor'
| 'conversationColor'
| 'conversationId'
| 'conversationTitle'
| 'conversationType'
| 'customColor'
| 'deletedForEveryone'
| 'direction'
| 'displayLimit'
| 'expirationLength'
| 'expirationTimestamp'
| 'giftBadge'
| 'id'
| 'isBlocked'
| 'isMessageRequestAccepted'
| 'isSelected'
| 'isSelectedCounter'
| 'isSticker'
| 'isTapToView'
| 'isTapToViewError'
| 'isTapToViewExpired'
| 'readStatus'
| 'selectedReaction'
| 'status'
| 'text'
| 'textDirection'
| 'timestamp'
>;
const getShallowPropsForMessage = createSelectorCreator(memoizeByRoot, isEqual)(
// `memoizeByRoot` requirement
identity,
(
message: MessageWithUIFieldsType,
{
accountSelector,
conversationSelector,
ourConversationId,
ourNumber,
ourACI,
regionCode,
selectedMessageId,
selectedMessageCounter,
contactNameColorSelector,
}: GetPropsForMessageOptions
): ShallowPropsType => {
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 isSelected = message.id === selectedMessageId;
const selectedReaction = (
(message.reactions || []).find(re => re.fromId === ourConversationId) ||
{}
).emoji;
const authorId = getContactId(message, {
conversationSelector,
ourConversationId,
ourNumber,
ourACI,
});
const contactNameColor = contactNameColorSelector(conversationId, authorId);
const { conversationColor, customColor } =
getConversationColorAttributes(conversation);
return {
canDeleteForEveryone: canDeleteForEveryone(message),
canDownload: canDownload(message, conversationSelector),
canReact: canReact(message, ourConversationId, conversationSelector),
canReply: canReply(message, ourConversationId, conversationSelector),
canRetry: hasErrors(message),
canRetryDeleteForEveryone: canRetryDeleteForEveryone(message),
contact: getPropsForEmbeddedContact(message, regionCode, accountSelector),
contactNameColor,
conversationColor,
conversationId,
conversationTitle: conversation.title,
conversationType: isGroup ? 'group' : 'direct',
customColor,
deletedForEveryone: message.deletedForEveryone || false,
direction: isIncoming(message) ? 'incoming' : 'outgoing',
displayLimit: message.displayLimit,
expirationLength,
expirationTimestamp: calculateExpirationTimestamp({
expireTimer,
expirationStartTimestamp,
}),
giftBadge: message.giftBadge,
id: message.id,
isBlocked: conversation.isBlocked || false,
isMessageRequestAccepted: conversation?.acceptedMessageRequest ?? true,
isSelected,
isSelectedCounter: isSelected ? selectedMessageCounter : undefined,
isSticker: Boolean(sticker),
isTapToView: isMessageTapToView,
isTapToViewError:
isMessageTapToView && isIncoming(message) && message.isTapToViewInvalid,
isTapToViewExpired: isMessageTapToView && message.isErased,
readStatus: message.readStatus ?? ReadStatus.Read,
selectedReaction,
status: getMessagePropStatus(message, ourConversationId),
text: message.body,
textDirection: getTextDirection(message.body),
timestamp: message.sent_at,
};
},
(_: unknown, props: ShallowPropsType) => props
);
function getTextAttachment(
message: MessageWithUIFieldsType
): AttachmentType | undefined {
@ -806,51 +642,116 @@ function getTextDirection(body?: string): TextDirection {
}
}
export const getPropsForMessage: (
message: MessageWithUIFieldsType,
options: GetPropsForMessageOptions
) => Omit<PropsForMessage, 'renderingContext' | 'menu' | 'contextMenu'> =
createSelectorCreator(memoizeByRoot)(
// `memoizeByRoot` requirement
identity,
export const getPropsForMessage = proxyMemoize(
(
message: MessageWithUIFieldsType,
options: GetPropsForMessageOptions
): Omit<PropsForMessage, 'renderingContext' | 'menu' | 'contextMenu'> => {
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);
getAttachmentsForMessage,
processBodyRanges,
getCachedAuthorForMessage,
getPreviewsForMessage,
getReactionsForMessage,
getPropsForQuote,
getPropsForStoryReplyContext,
getTextAttachment,
getPayment,
getShallowPropsForMessage,
(
_,
attachments: Array<AttachmentType>,
bodyRanges: HydratedBodyRangesType | undefined,
author: PropsData['author'],
previews: Array<LinkPreviewType>,
reactions: PropsData['reactions'],
quote: PropsData['quote'],
storyReplyContext: PropsData['storyReplyContext'],
textAttachment: PropsData['textAttachment'],
payment: PropsData['payment'],
shallowProps: ShallowPropsType
): Omit<PropsForMessage, 'renderingContext' | 'menu' | 'contextMenu'> => {
return {
attachments,
author,
bodyRanges,
previews,
quote,
reactions,
storyReplyContext,
textAttachment,
payment,
...shallowProps,
};
}
);
const {
accountSelector,
conversationSelector,
ourConversationId,
ourNumber,
ourACI,
regionCode,
selectedMessageId,
selectedMessageCounter,
contactNameColorSelector,
} = 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 isSelected = message.id === selectedMessageId;
const selectedReaction = (
(message.reactions || []).find(re => re.fromId === ourConversationId) ||
{}
).emoji;
const authorId = getContactId(message, {
conversationSelector,
ourConversationId,
ourNumber,
ourACI,
});
const contactNameColor = contactNameColorSelector(conversationId, authorId);
const { conversationColor, customColor } =
getConversationColorAttributes(conversation);
return {
attachments,
author,
bodyRanges,
previews,
quote,
reactions,
storyReplyContext,
textAttachment,
payment,
canDeleteForEveryone: canDeleteForEveryone(message),
canDownload: canDownload(message, conversationSelector),
canReact: canReact(message, ourConversationId, conversationSelector),
canReply: canReply(message, ourConversationId, conversationSelector),
canRetry: hasErrors(message),
canRetryDeleteForEveryone: canRetryDeleteForEveryone(message),
contact: getPropsForEmbeddedContact(message, regionCode, accountSelector),
contactNameColor,
conversationColor,
conversationId,
conversationTitle: conversation.title,
conversationType: isGroup ? 'group' : 'direct',
customColor,
deletedForEveryone: message.deletedForEveryone || false,
direction: isIncoming(message) ? 'incoming' : 'outgoing',
displayLimit: message.displayLimit,
expirationLength,
expirationTimestamp: calculateExpirationTimestamp({
expireTimer,
expirationStartTimestamp,
}),
giftBadge: message.giftBadge,
id: message.id,
isBlocked: conversation.isBlocked || false,
isMessageRequestAccepted: conversation?.acceptedMessageRequest ?? true,
isSelected,
isSelectedCounter: isSelected ? selectedMessageCounter : undefined,
isSticker: Boolean(sticker),
isTapToView: isMessageTapToView,
isTapToViewError:
isMessageTapToView && isIncoming(message) && message.isTapToViewInvalid,
isTapToViewExpired: isMessageTapToView && message.isErased,
readStatus: message.readStatus ?? ReadStatus.Read,
selectedReaction,
status: getMessagePropStatus(message, ourConversationId),
text: message.body,
textDirection: getTextDirection(message.body),
timestamp: message.sent_at,
};
},
{
name: 'getPropsForMessage',
}
);
// This is getPropsForMessage but wrapped in reselect's createSelector so that
// we can derive all of the selector dependencies that getPropsForMessage
@ -893,19 +794,6 @@ export const getMessagePropsSelector = createSelector(
}
);
export const getBubblePropsForMessage = createSelectorCreator(memoizeByRoot)(
// `memoizeByRoot` requirement
identity,
getPropsForMessage,
(_, data): TimelineItemType => ({
type: 'message' as const,
data,
timestamp: data.timestamp,
})
);
// Top-level prop generation for the message bubble
export function getPropsForBubble(
message: MessageWithUIFieldsType,
@ -1038,7 +926,13 @@ export function getPropsForBubble(
};
}
return getBubblePropsForMessage(message, options);
const data = getPropsForMessage(message, options);
return {
type: 'message' as const,
data,
timestamp: data.timestamp,
};
}
function getPropsForPaymentEvent(
@ -1047,7 +941,7 @@ function getPropsForPaymentEvent(
): Omit<PaymentEventNotificationPropsType, 'i18n'> {
return {
sender: conversationSelector(message.sourceUuid),
conversation: conversationSelector(message.conversationId),
conversation: getConversation(message, conversationSelector),
event: message.payment,
};
}
@ -1583,7 +1477,7 @@ function getPropsForDeliveryIssue(
{ conversationSelector }: GetPropsForBubbleOptions
): DeliveryIssuePropsType {
const sender = conversationSelector(message.sourceUuid);
const conversation = conversationSelector(message.conversationId);
const conversation = getConversation(message, conversationSelector);
return {
sender,