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

@ -143,6 +143,7 @@
"pino": "8.6.1", "pino": "8.6.1",
"protobufjs": "6.11.3", "protobufjs": "6.11.3",
"proxy-agent": "5.0.0", "proxy-agent": "5.0.0",
"proxy-compare": "2.3.0",
"qrcode-generator": "1.4.4", "qrcode-generator": "1.4.4",
"quill": "1.3.7", "quill": "1.3.7",
"quill-delta": "4.0.1", "quill-delta": "4.0.1",

View file

@ -27,7 +27,6 @@ import { getOwn } from '../../util/getOwn';
import type { UUIDFetchStateType } from '../../util/uuidFetchState'; import type { UUIDFetchStateType } from '../../util/uuidFetchState';
import { deconstructLookup } from '../../util/deconstructLookup'; import { deconstructLookup } from '../../util/deconstructLookup';
import type { PropsDataType as TimelinePropsType } from '../../components/conversation/Timeline'; import type { PropsDataType as TimelinePropsType } from '../../components/conversation/Timeline';
import type { TimelineItemType } from '../../components/conversation/TimelineItem';
import { assertDev } from '../../util/assert'; import { assertDev } from '../../util/assert';
import { isConversationUnregistered } from '../../util/isConversationUnregistered'; import { isConversationUnregistered } from '../../util/isConversationUnregistered';
import { filterAndSortConversationsByRecent } from '../../util/filterAndSortConversations'; import { filterAndSortConversationsByRecent } from '../../util/filterAndSortConversations';
@ -51,15 +50,8 @@ import {
getRegionCode, getRegionCode,
getUserConversationId, getUserConversationId,
getUserNumber, getUserNumber,
getUserACI,
getUserPNI,
} from './user'; } from './user';
import { getPinnedConversationIds } from './items'; import { getPinnedConversationIds } from './items';
import { getPropsForBubble } from './message';
import type { CallSelectorType, CallStateType } from './calling';
import { getActiveCall, getCallSelector } from './calling';
import type { AccountSelectorType } from './accounts';
import { getAccountSelector } from './accounts';
import * as log from '../../logging/log'; import * as log from '../../logging/log';
import { TimelineMessageLoadingState } from '../../util/timelineUtil'; import { TimelineMessageLoadingState } from '../../util/timelineUtil';
import { isSignalConversation } from '../../util/isSignalConversation'; import { isSignalConversation } from '../../util/isSignalConversation';
@ -772,18 +764,6 @@ export const getConversationByUuidSelector = createSelector(
getOwn(conversationsByUuid, uuid) getOwn(conversationsByUuid, uuid)
); );
// A little optimization to reset our selector cache whenever high-level application data
// changes: regionCode and userNumber.
export const getCachedSelectorForMessage = createSelector(
getRegionCode,
getUserNumber,
(): typeof getPropsForBubble => {
// Note: memoizee will check all parameters provided, and only run our selector
// if any of them have changed.
return memoizee(getPropsForBubble, { max: 2000 });
}
);
const getCachedConversationMemberColorsSelector = createSelector( const getCachedConversationMemberColorsSelector = createSelector(
getConversationSelector, getConversationSelector,
getUserConversationId, getUserConversationId,
@ -855,60 +835,6 @@ export const getContactNameColorSelector = createSelector(
} }
); );
type GetMessageByIdType = (id: string) => TimelineItemType | undefined;
export const getMessageSelector = createSelector(
getCachedSelectorForMessage,
getMessages,
getSelectedMessage,
getConversationSelector,
getRegionCode,
getUserNumber,
getUserACI,
getUserPNI,
getUserConversationId,
getCallSelector,
getActiveCall,
getAccountSelector,
getContactNameColorSelector,
(
messageSelector: typeof getPropsForBubble,
messageLookup: MessageLookupType,
selectedMessage: SelectedMessageType | undefined,
conversationSelector: GetConversationByIdType,
regionCode: string | undefined,
ourNumber: string | undefined,
ourACI: UUIDStringType | undefined,
ourPNI: UUIDStringType | undefined,
ourConversationId: string | undefined,
callSelector: CallSelectorType,
activeCall: undefined | CallStateType,
accountSelector: AccountSelectorType,
contactNameColorSelector: ContactNameColorSelectorType
): GetMessageByIdType => {
return (id: string) => {
const message = messageLookup[id];
if (!message) {
return undefined;
}
return messageSelector(message, {
conversationSelector,
ourConversationId,
ourNumber,
ourACI,
ourPNI,
regionCode,
selectedMessageId: selectedMessage?.id,
selectedMessageCounter: selectedMessage?.counter,
contactNameColorSelector,
callSelector,
activeCall,
accountSelector,
});
};
}
);
export function _conversationMessagesSelector( export function _conversationMessagesSelector(
conversation: ConversationMessageType conversation: ConversationMessageType
): TimelinePropsType { ): TimelinePropsType {

View file

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

View file

@ -0,0 +1,72 @@
// Copyright 2021-2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { TimelineItemType } from '../../components/conversation/TimelineItem';
import { proxyMemoize } from '../../util/proxyMemoize';
import type { StateType } from '../reducer';
import type { MessageWithUIFieldsType } from '../ducks/conversations';
import {
getContactNameColorSelector,
getConversationSelector,
getSelectedMessage,
getMessages,
} from './conversations';
import { getAccountSelector } from './accounts';
import {
getRegionCode,
getUserConversationId,
getUserNumber,
getUserACI,
getUserPNI,
} from './user';
import { getActiveCall, getCallSelector } from './calling';
import { getPropsForBubble } from './message';
const getTimelineItemInner = proxyMemoize(
(message: MessageWithUIFieldsType, state: StateType): TimelineItemType => {
const selectedMessage = getSelectedMessage(state);
const conversationSelector = getConversationSelector(state);
const regionCode = getRegionCode(state);
const ourNumber = getUserNumber(state);
const ourACI = getUserACI(state);
const ourPNI = getUserPNI(state);
const ourConversationId = getUserConversationId(state);
const callSelector = getCallSelector(state);
const activeCall = getActiveCall(state);
const accountSelector = getAccountSelector(state);
const contactNameColorSelector = getContactNameColorSelector(state);
return getPropsForBubble(message, {
conversationSelector,
ourConversationId,
ourNumber,
ourACI,
ourPNI,
regionCode,
selectedMessageId: selectedMessage?.id,
selectedMessageCounter: selectedMessage?.counter,
contactNameColorSelector,
callSelector,
activeCall,
accountSelector,
});
},
{
name: 'getTimelineItemInner',
}
);
export const getTimelineItem = (
state: StateType,
id: string
): TimelineItemType | undefined => {
const messageLookup = getMessages(state);
const message = messageLookup[id];
if (!message) {
return undefined;
}
return getTimelineItemInner(message, state);
};

View file

@ -17,12 +17,12 @@ import type { ConversationType } from '../ducks/conversations';
import { getIntl, getTheme } from '../selectors/user'; import { getIntl, getTheme } from '../selectors/user';
import { import {
getMessages,
getConversationByUuidSelector, getConversationByUuidSelector,
getConversationMessagesSelector, getConversationMessagesSelector,
getConversationSelector, getConversationSelector,
getConversationsByTitleSelector, getConversationsByTitleSelector,
getInvitedContactsForNewlyCreatedGroup, getInvitedContactsForNewlyCreatedGroup,
getMessageSelector,
getSelectedMessage, getSelectedMessage,
} from '../selectors/conversations'; } from '../selectors/conversations';
@ -229,9 +229,8 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
const conversationMessages = getConversationMessagesSelector(state)(id); const conversationMessages = getConversationMessagesSelector(state)(id);
const selectedMessage = getSelectedMessage(state); const selectedMessage = getSelectedMessage(state);
const messageSelector = getMessageSelector(state);
const getTimestampForMessage = (messageId: string): undefined | number => const getTimestampForMessage = (messageId: string): undefined | number =>
messageSelector(messageId)?.timestamp; getMessages(state)[messageId]?.timestamp;
return { return {
id, id,

View file

@ -13,9 +13,9 @@ import { getPreferredBadgeSelector } from '../selectors/badges';
import { getIntl, getInteractionMode, getTheme } from '../selectors/user'; import { getIntl, getInteractionMode, getTheme } from '../selectors/user';
import { import {
getConversationSelector, getConversationSelector,
getMessageSelector,
getSelectedMessage, getSelectedMessage,
} from '../selectors/conversations'; } from '../selectors/conversations';
import { getTimelineItem } from '../selectors/timeline';
import { import {
areMessagesInSameGroup, areMessagesInSameGroup,
shouldCurrentMessageHideMetadata, shouldCurrentMessageHideMetadata,
@ -55,13 +55,13 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
unreadIndicatorPlacement, unreadIndicatorPlacement,
} = props; } = props;
const messageSelector = getMessageSelector(state); const item = getTimelineItem(state, messageId);
const item = messageSelector(messageId);
const previousItem = previousMessageId const previousItem = previousMessageId
? messageSelector(previousMessageId) ? getTimelineItem(state, previousMessageId)
: undefined;
const nextItem = nextMessageId
? getTimelineItem(state, nextMessageId)
: undefined; : undefined;
const nextItem = nextMessageId ? messageSelector(nextMessageId) : undefined;
const selectedMessage = getSelectedMessage(state); const selectedMessage = getSelectedMessage(state);
const isSelected = Boolean( const isSelected = Boolean(

View file

@ -1,62 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import * as sinon from 'sinon';
import { memoizeByRoot } from '../../util/memoizeByRoot';
class Root {}
describe('memoizeByRoot', () => {
it('should memoize by last passed arguments', () => {
const root = new Root();
const stub = sinon.stub();
stub.withArgs(sinon.match.same(root), 1).returns(1);
stub.withArgs(sinon.match.same(root), 2).returns(2);
const fn = memoizeByRoot(stub);
assert.strictEqual(fn(root, 1), 1);
assert.strictEqual(fn(root, 1), 1);
assert.isTrue(stub.calledOnce);
assert.strictEqual(fn(root, 2), 2);
assert.strictEqual(fn(root, 2), 2);
assert.isTrue(stub.calledTwice);
assert.strictEqual(fn(root, 1), 1);
assert.strictEqual(fn(root, 1), 1);
assert.isTrue(stub.calledThrice);
});
it('should memoize results by root', () => {
const rootA = new Root();
const rootB = new Root();
const stub = sinon.stub();
stub.withArgs(sinon.match.same(rootA), 1).returns(1);
stub.withArgs(sinon.match.same(rootA), 2).returns(2);
stub.withArgs(sinon.match.same(rootB), 1).returns(3);
stub.withArgs(sinon.match.same(rootB), 2).returns(4);
const fn = memoizeByRoot(stub);
assert.strictEqual(fn(rootA, 1), 1);
assert.strictEqual(fn(rootB, 1), 3);
assert.strictEqual(fn(rootA, 1), 1);
assert.strictEqual(fn(rootB, 1), 3);
assert.isTrue(stub.calledTwice);
assert.strictEqual(fn(rootA, 2), 2);
assert.strictEqual(fn(rootB, 2), 4);
assert.strictEqual(fn(rootA, 2), 2);
assert.strictEqual(fn(rootB, 2), 4);
assert.strictEqual(stub.callCount, 4);
assert.strictEqual(fn(rootA, 1), 1);
assert.strictEqual(fn(rootB, 1), 3);
assert.strictEqual(stub.callCount, 6);
});
});

View file

@ -1,17 +1,31 @@
// Copyright 2022 Signal Messenger, LLC // Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
export function isShallowEqual<Obj extends Record<string, unknown>>( export function isShallowEqual(a: unknown, b: unknown): boolean {
a: Obj, if (a === b) {
b: Obj return true;
): boolean { }
if (typeof a !== typeof b) {
return false;
}
if (a == null || b == null) {
return false;
}
if (typeof a !== 'object') {
return false;
}
const keys = Object.keys(a); const keys = Object.keys(a);
if (keys.length !== Object.keys(b).length) { if (keys.length !== Object.keys(b).length) {
return false; return false;
} }
for (const key of keys) { for (const key of keys) {
if (a[key] !== b[key]) { if (
(a as Record<string | number, unknown>)[key] !==
(b as Record<string | number, unknown>)[key]
) {
return false; return false;
} }
} }

View file

@ -1,47 +0,0 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { defaultMemoize } from 'reselect';
import { strictAssert } from './assert';
// The difference between the function below and `defaultMemoize` from
// `reselect` is that it supports multiple "root" states. `reselect` is designed
// to interact with a single redux store and by default it memoizes only the
// last result of the selector (matched by its arguments). This works well when
// applied to singular entities living in the redux's state, but we need to
// apply selector to multitide of conversations and messages.
//
// The way it works is that it adds an extra memoization step that uses the
// first argument ("root") as a key in a weak map, and then applies the default
// `reselect`'s memoization function to the rest of the arguments. This way
// we essentially get a weak map of selectors by the "root".
// eslint-disable-next-line @typescript-eslint/ban-types
export function memoizeByRoot<F extends Function>(
fn: F,
equalityCheck?: <T>(a: T, b: T) => boolean
): F {
// eslint-disable-next-line @typescript-eslint/ban-types
const cache = new WeakMap<object, Function>();
const wrap = (root: unknown, ...rest: Array<unknown>): unknown => {
strictAssert(
typeof root === 'object' && root != null,
'Root is not object'
);
let partial = cache.get(root);
if (!partial) {
partial = defaultMemoize((...args: Array<unknown>): unknown => {
return fn(root, ...args);
}, equalityCheck);
cache.set(root, partial);
}
return partial(...rest);
};
return wrap as unknown as F;
}

78
ts/util/proxyMemoize.ts Normal file
View file

@ -0,0 +1,78 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { createProxy, getUntracked, isChanged } from 'proxy-compare';
export type ExcludeNull<O> = Exclude<O, null>;
export type ProxyMemoizeOptions<Result> = Readonly<{
// For debugging
name: string;
equalityFn?: (prev: Result, next: Result) => boolean;
}>;
export function proxyMemoize<Params extends ReadonlyArray<object>, Result>(
fn: (...params: Params) => ExcludeNull<Result>,
{ equalityFn }: ProxyMemoizeOptions<ExcludeNull<Result>>
): (...param: Params) => ExcludeNull<Result> {
type CacheEntryType = Readonly<{
params: Params;
result: ExcludeNull<Result>;
}>;
const cache = new WeakMap<object, CacheEntryType>();
const affected = new WeakMap<object, unknown>();
const proxyCache = new WeakMap<object, unknown>();
const changedCache = new WeakMap<object, unknown>();
return (...params: Params): ExcludeNull<Result> => {
if (params.length < 1) {
throw new Error('At least one parameter is required');
}
const cacheKey = params[0];
const entry = cache.get(cacheKey);
if (entry && entry.params.length === params.length) {
let isValid = true;
for (const [i, cachedParam] of entry.params.entries()) {
// Proxy wasn't even touched - we are good to go.
const wasUsed = affected.has(cachedParam);
if (!wasUsed) {
continue;
}
if (isChanged(cachedParam, params[i], affected, changedCache)) {
isValid = false;
break;
}
}
if (isValid) {
return entry.result;
}
}
const proxies = params.map(param =>
createProxy(param, affected, proxyCache)
) as unknown as Params;
const trackedResult = fn(...proxies);
const untrackedResult = getUntracked(trackedResult);
// eslint-disable-next-line eqeqeq
let result = untrackedResult === null ? trackedResult : untrackedResult;
// Try to reuse result if custom equality check is configured.
if (entry && equalityFn && equalityFn(entry.result, result)) {
({ result } = entry);
}
cache.set(cacheKey, {
params,
result,
});
return result;
};
}

View file

@ -14369,6 +14369,11 @@ proxy-agent@5.0.0:
proxy-from-env "^1.0.0" proxy-from-env "^1.0.0"
socks-proxy-agent "^5.0.0" socks-proxy-agent "^5.0.0"
proxy-compare@2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/proxy-compare/-/proxy-compare-2.3.0.tgz#ac9633ae52918ff9c9fcc54dfe6316c7a02d20ee"
integrity sha512-c3L2CcAi7f7pvlD0D7xsF+2CQIW8C3HaYx2Pfgq8eA4HAl3GAH6/dVYsyBbYF/0XJs2ziGLrzmz5fmzPm6A0pQ==
proxy-from-env@^1.0.0, proxy-from-env@^1.1.0: proxy-from-env@^1.0.0, proxy-from-env@^1.1.0:
version "1.1.0" version "1.1.0"
resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2"