Use proxy-compare for message bubbles
This commit is contained in:
parent
f92f81dfd6
commit
55a1c5f6c5
11 changed files with 442 additions and 562 deletions
|
@ -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",
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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,
|
||||||
|
|
72
ts/state/selectors/timeline.ts
Normal file
72
ts/state/selectors/timeline.ts
Normal 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);
|
||||||
|
};
|
|
@ -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,
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
78
ts/util/proxyMemoize.ts
Normal 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;
|
||||||
|
};
|
||||||
|
}
|
|
@ -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"
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue