Store all story reactions as messages

This commit is contained in:
Fedor Indutny 2022-11-02 16:48:38 -07:00 committed by GitHub
parent f13611712c
commit 54aa0d39b9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 237 additions and 223 deletions

View file

@ -111,7 +111,7 @@ export type PropsType = {
preferredReactionEmoji: Array<string>; preferredReactionEmoji: Array<string>;
recentEmojis?: Array<string>; recentEmojis?: Array<string>;
renderEmojiPicker: (props: RenderEmojiPickerProps) => JSX.Element; renderEmojiPicker: (props: RenderEmojiPickerProps) => JSX.Element;
replies: Array<ReplyType>; replies: ReadonlyArray<ReplyType>;
skinTone?: number; skinTone?: number;
sortedGroupMembers?: Array<ConversationType>; sortedGroupMembers?: Array<ConversationType>;
storyPreviewAttachment?: AttachmentType; storyPreviewAttachment?: AttachmentType;

View file

@ -4,6 +4,7 @@
import { isNumber } from 'lodash'; import { isNumber } from 'lodash';
import * as Errors from '../../types/errors'; import * as Errors from '../../types/errors';
import { strictAssert } from '../../util/assert';
import type { MessageModel } from '../../models/messages'; import type { MessageModel } from '../../models/messages';
import { getMessageById } from '../../messages/getMessageById'; import { getMessageById } from '../../messages/getMessageById';
import type { ConversationModel } from '../../models/conversations'; import type { ConversationModel } from '../../models/conversations';
@ -11,12 +12,14 @@ import { isGroup, isGroupV2, isMe } from '../../util/whatTypeOfConversation';
import { getSendOptions } from '../../util/getSendOptions'; import { getSendOptions } from '../../util/getSendOptions';
import { SignalService as Proto } from '../../protobuf'; import { SignalService as Proto } from '../../protobuf';
import { handleMessageSend } from '../../util/handleMessageSend'; import { handleMessageSend } from '../../util/handleMessageSend';
import { findAndFormatContact } from '../../util/findAndFormatContact';
import type { CallbackResultType } from '../../textsecure/Types.d'; import type { CallbackResultType } from '../../textsecure/Types.d';
import { isSent } from '../../messages/MessageSendState'; import { isSent } from '../../messages/MessageSendState';
import { isOutgoing } from '../../state/selectors/message'; import { isOutgoing, canReact } from '../../state/selectors/message';
import type { import type {
AttachmentType, AttachmentType,
ContactWithHydratedAvatar, ContactWithHydratedAvatar,
ReactionType,
} from '../../textsecure/SendMessage'; } from '../../textsecure/SendMessage';
import type { LinkPreviewType } from '../../types/message/LinkPreviews'; import type { LinkPreviewType } from '../../types/message/LinkPreviews';
import type { BodyRangesType, StoryContextType } from '../../types/Util'; import type { BodyRangesType, StoryContextType } from '../../types/Util';
@ -149,9 +152,37 @@ export async function sendNormalMessage(
preview, preview,
quote, quote,
sticker, sticker,
storyMessage,
storyContext, storyContext,
reaction,
} = await getMessageSendData({ log, message }); } = await getMessageSendData({ log, message });
if (reaction) {
strictAssert(
storyMessage,
'Only story reactions can be sent as normal messages'
);
const ourConversationId =
window.ConversationController.getOurConversationIdOrThrow();
if (
!canReact(
storyMessage.attributes,
ourConversationId,
findAndFormatContact
)
) {
log.info(
`could not react to ${messageId}. Removing this pending reaction`
);
await markMessageFailed(message, [
new Error('Could not react to story'),
]);
return;
}
}
let messageSendPromise: Promise<CallbackResultType | void>; let messageSendPromise: Promise<CallbackResultType | void>;
if (recipientIdentifiersWithoutMe.length === 0) { if (recipientIdentifiersWithoutMe.length === 0) {
@ -185,6 +216,7 @@ export async function sendNormalMessage(
sticker, sticker,
// No storyContext; you can't reply to your own stories // No storyContext; you can't reply to your own stories
timestamp: messageTimestamp, timestamp: messageTimestamp,
reaction,
}); });
messageSendPromise = message.sendSyncMessageOnly(dataMessage, saveErrors); messageSendPromise = message.sendSyncMessageOnly(dataMessage, saveErrors);
} else { } else {
@ -228,6 +260,7 @@ export async function sendNormalMessage(
quote, quote,
sticker, sticker,
storyContext, storyContext,
reaction,
timestamp: messageTimestamp, timestamp: messageTimestamp,
mentions, mentions,
}, },
@ -280,9 +313,9 @@ export async function sendNormalMessage(
preview, preview,
profileKey, profileKey,
quote, quote,
reaction: undefined,
sticker, sticker,
storyContext, storyContext,
reaction,
timestamp: messageTimestamp, timestamp: messageTimestamp,
// Note: 1:1 story replies should not set story=true - they aren't group sends // Note: 1:1 story replies should not set story=true - they aren't group sends
urgent: true, urgent: true,
@ -436,6 +469,8 @@ async function getMessageSendData({
preview: Array<LinkPreviewType>; preview: Array<LinkPreviewType>;
quote: QuotedMessageType | null; quote: QuotedMessageType | null;
sticker: StickerWithHydratedData | undefined; sticker: StickerWithHydratedData | undefined;
reaction: ReactionType | undefined;
storyMessage?: MessageModel;
storyContext?: StoryContextType; storyContext?: StoryContextType;
}> { }> {
const { const {
@ -488,6 +523,8 @@ async function getMessageSendData({
} }
); );
const storyReaction = message.get('storyReaction');
return { return {
attachments, attachments,
body, body,
@ -499,12 +536,19 @@ async function getMessageSendData({
preview, preview,
quote, quote,
sticker, sticker,
storyMessage,
storyContext: storyMessage storyContext: storyMessage
? { ? {
authorUuid: storyMessage.get('sourceUuid'), authorUuid: storyMessage.get('sourceUuid'),
timestamp: storyMessage.get('sent_at'), timestamp: storyMessage.get('sent_at'),
} }
: undefined, : undefined,
reaction: storyReaction
? {
...storyReaction,
remove: false,
}
: undefined,
}; };
} }

View file

@ -4,6 +4,7 @@
import { isNumber } from 'lodash'; import { isNumber } from 'lodash';
import * as Errors from '../../types/errors'; import * as Errors from '../../types/errors';
import { strictAssert } from '../../util/assert';
import { repeat, zipObject } from '../../util/iterables'; import { repeat, zipObject } from '../../util/iterables';
import type { CallbackResultType } from '../../textsecure/Types.d'; import type { CallbackResultType } from '../../textsecure/Types.d';
import type { MessageModel } from '../../models/messages'; import type { MessageModel } from '../../models/messages';
@ -63,11 +64,16 @@ export async function sendReaction(
return; return;
} }
strictAssert(
!isStory(message.attributes),
'Story reactions should be handled by sendStoryReaction'
);
const { pendingReaction, emojiToRemove } = const { pendingReaction, emojiToRemove } =
reactionUtil.getNewestPendingOutgoingReaction( reactionUtil.getNewestPendingOutgoingReaction(
getReactions(message), getReactions(message),
ourConversationId ourConversationId
); );
if (!pendingReaction) { if (!pendingReaction) {
log.info(`no pending reaction for ${messageId}. Doing nothing`); log.info(`no pending reaction for ${messageId}. Doing nothing`);
return; return;
@ -153,17 +159,7 @@ export async function sendReaction(
), ),
}); });
if (
isStory(message.attributes) &&
isDirectConversation(conversation.attributes)
) {
ephemeralMessageForReactionSend.set({
storyId: message.id,
storyReactionEmoji: reactionForSend.emoji,
});
} else {
ephemeralMessageForReactionSend.doNotSave = true; ephemeralMessageForReactionSend.doNotSave = true;
}
let didFullySend: boolean; let didFullySend: boolean;
const successfulConversationIds = new Set<string>(); const successfulConversationIds = new Set<string>();
@ -233,12 +229,6 @@ export async function sendReaction(
groupId: undefined, groupId: undefined,
profileKey, profileKey,
options: sendOptions, options: sendOptions,
storyContext: isStory(message.attributes)
? {
authorUuid: message.get('sourceUuid'),
timestamp: message.get('sent_at'),
}
: undefined,
urgent: true, urgent: true,
includePniSignatureMessage: true, includePniSignatureMessage: true,
}); });
@ -271,12 +261,6 @@ export async function sendReaction(
timestamp: pendingReaction.timestamp, timestamp: pendingReaction.timestamp,
expireTimer, expireTimer,
profileKey, profileKey,
storyContext: isStory(message.attributes)
? {
authorUuid: message.get('sourceUuid'),
timestamp: message.get('sent_at'),
}
: undefined,
}, },
messageId, messageId,
sendOptions, sendOptions,
@ -346,8 +330,7 @@ export async function sendReaction(
const newReactions = reactionUtil.markOutgoingReactionSent( const newReactions = reactionUtil.markOutgoingReactionSent(
getReactions(message), getReactions(message),
pendingReaction, pendingReaction,
successfulConversationIds, successfulConversationIds
message.attributes
); );
setReactions(message, newReactions); setReactions(message, newReactions);
@ -372,8 +355,9 @@ export async function sendReaction(
} }
} }
const getReactions = (message: MessageModel): Array<MessageReactionType> => const getReactions = (
message.get('reactions') || []; message: MessageModel
): ReadonlyArray<MessageReactionType> => message.get('reactions') || [];
const setReactions = ( const setReactions = (
message: MessageModel, message: MessageModel,

View file

@ -169,14 +169,15 @@ export class Reactions extends Collection<ReactionModel> {
); );
// Use the generated message in ts/background.ts to create a message // Use the generated message in ts/background.ts to create a message
// if the reaction is targetted at a story on a 1:1 conversation. // if the reaction is targetted at a story.
if ( if (isStory(targetMessage)) {
isStory(targetMessage) &&
isDirectConversation(targetConversation.attributes)
) {
generatedMessage.set({ generatedMessage.set({
storyId: targetMessage.id, storyId: targetMessage.id,
storyReactionEmoji: reaction.get('emoji'), storyReaction: {
emoji: reaction.get('emoji'),
targetAuthorUuid: reaction.get('targetAuthorUuid'),
targetTimestamp: reaction.get('targetTimestamp'),
},
}); });
// Note: generatedMessage comes with an id, so we have to force this save // Note: generatedMessage comes with an id, so we have to force this save

8
ts/model-types.d.ts vendored
View file

@ -146,7 +146,7 @@ export type MessageAttributesType = {
messageTimer?: unknown; messageTimer?: unknown;
profileChange?: ProfileNameChangeType; profileChange?: ProfileNameChangeType;
quote?: QuotedMessageType; quote?: QuotedMessageType;
reactions?: Array<MessageReactionType>; reactions?: ReadonlyArray<MessageReactionType>;
requiredProtocolVersion?: number; requiredProtocolVersion?: number;
retryOptions?: RetryOptions; retryOptions?: RetryOptions;
sourceDevice?: number; sourceDevice?: number;
@ -184,7 +184,11 @@ export type MessageAttributesType = {
unidentifiedDeliveries?: Array<string>; unidentifiedDeliveries?: Array<string>;
contact?: Array<EmbeddedContactType>; contact?: Array<EmbeddedContactType>;
conversationId: string; conversationId: string;
storyReactionEmoji?: string; storyReaction?: {
emoji: string;
targetAuthorUuid: string;
targetTimestamp: number;
};
giftBadge?: { giftBadge?: {
expiration: number; expiration: number;
level: number; level: number;

View file

@ -33,6 +33,7 @@ import { isNormalNumber } from '../util/isNormalNumber';
import { softAssert, strictAssert } from '../util/assert'; import { softAssert, strictAssert } from '../util/assert';
import { missingCaseError } from '../util/missingCaseError'; import { missingCaseError } from '../util/missingCaseError';
import { dropNull } from '../util/dropNull'; import { dropNull } from '../util/dropNull';
import { incrementMessageCounter } from '../util/incrementMessageCounter';
import type { ConversationModel } from './conversations'; import type { ConversationModel } from './conversations';
import type { import type {
OwnProps as SmartMessageDetailPropsType, OwnProps as SmartMessageDetailPropsType,
@ -957,7 +958,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
const { text, emoji } = this.getNotificationData(); const { text, emoji } = this.getNotificationData();
const { attributes } = this; const { attributes } = this;
if (attributes.storyReactionEmoji) { if (attributes.storyReaction) {
if (attributes.type === 'outgoing') { if (attributes.type === 'outgoing') {
const name = this.getConversation()?.get('profileName'); const name = this.getConversation()?.get('profileName');
@ -965,25 +966,25 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
return window.i18n( return window.i18n(
'Quote__story-reaction-notification--outgoing--nameless', 'Quote__story-reaction-notification--outgoing--nameless',
{ {
emoji: attributes.storyReactionEmoji, emoji: attributes.storyReaction.emoji,
} }
); );
} }
return window.i18n('Quote__story-reaction-notification--outgoing', { return window.i18n('Quote__story-reaction-notification--outgoing', {
emoji: attributes.storyReactionEmoji, emoji: attributes.storyReaction.emoji,
name, name,
}); });
} }
if (attributes.type === 'incoming') { if (attributes.type === 'incoming') {
return window.i18n('Quote__story-reaction-notification--incoming', { return window.i18n('Quote__story-reaction-notification--incoming', {
emoji: attributes.storyReactionEmoji, emoji: attributes.storyReaction.emoji,
}); });
} }
if (!window.Signal.OS.isLinux()) { if (!window.Signal.OS.isLinux()) {
return attributes.storyReactionEmoji; return attributes.storyReaction.emoji;
} }
return window.i18n('Quote__story-reaction--single'); return window.i18n('Quote__story-reaction--single');
@ -3258,12 +3259,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
} }
const previousLength = (this.get('reactions') || []).length; const previousLength = (this.get('reactions') || []).length;
if (reaction.get('source') === ReactionSource.FromThisDevice) { const newReaction: MessageReactionType = {
log.info(
`handleReaction: sending reaction to ${this.idForLogging()} from this device`
);
const newReaction = {
emoji: reaction.get('remove') ? undefined : reaction.get('emoji'), emoji: reaction.get('remove') ? undefined : reaction.get('emoji'),
fromId: reaction.get('fromId'), fromId: reaction.get('fromId'),
targetAuthorUuid: reaction.get('targetAuthorUuid'), targetAuthorUuid: reaction.get('targetAuthorUuid'),
@ -3275,17 +3271,50 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
), ),
}; };
const isFromThisDevice =
reaction.get('source') === ReactionSource.FromThisDevice;
const isFromSync = reaction.get('source') === ReactionSource.FromSync;
const isFromSomeoneElse =
reaction.get('source') === ReactionSource.FromSomeoneElse;
strictAssert(
isFromThisDevice || isFromSync || isFromSomeoneElse,
'Reaction can only be from this device, from sync, or from someone else'
);
if (isStory(this.attributes)) {
if (isFromThisDevice) {
log.info(
'handleReaction: sending story reaction to ' +
`${this.idForLogging()} from this device`
);
} else if (isFromSync) {
log.info(
'handleReaction: receiving story reaction to ' +
`${this.idForLogging()} from another device`
);
} else {
log.info(
'handleReaction: receiving story reaction to ' +
`${this.idForLogging()} from someone else`
);
conversation.notify(this, reaction);
}
} else if (isFromThisDevice) {
log.info(
`handleReaction: sending reaction to ${this.idForLogging()} ` +
'from this device'
);
const reactions = reactionUtil.addOutgoingReaction( const reactions = reactionUtil.addOutgoingReaction(
this.get('reactions') || [], this.get('reactions') || [],
newReaction, newReaction
isStory(this.attributes)
); );
this.set({ reactions }); this.set({ reactions });
} else { } else {
const oldReactions = this.get('reactions') || []; const oldReactions = this.get('reactions') || [];
let reactions: Array<MessageReactionType>; let reactions: Array<MessageReactionType>;
const oldReaction = oldReactions.find(re => const oldReaction = oldReactions.find(re =>
isNewReactionReplacingPrevious(re, reaction.attributes, this.attributes) isNewReactionReplacingPrevious(re, reaction.attributes)
); );
if (oldReaction) { if (oldReaction) {
this.clearNotifications(oldReaction); this.clearNotifications(oldReaction);
@ -3297,23 +3326,15 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
this.idForLogging() this.idForLogging()
); );
if (reaction.get('source') === ReactionSource.FromSync) { if (isFromSync) {
reactions = oldReactions.filter( reactions = oldReactions.filter(
re => re =>
!isNewReactionReplacingPrevious( !isNewReactionReplacingPrevious(re, reaction.attributes) ||
re, re.timestamp > reaction.get('timestamp')
reaction.attributes,
this.attributes
) || re.timestamp > reaction.get('timestamp')
); );
} else { } else {
reactions = oldReactions.filter( reactions = oldReactions.filter(
re => re => !isNewReactionReplacingPrevious(re, reaction.attributes)
!isNewReactionReplacingPrevious(
re,
reaction.attributes,
this.attributes
)
); );
} }
this.set({ reactions }); this.set({ reactions });
@ -3331,7 +3352,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
); );
let reactionToAdd: MessageReactionType; let reactionToAdd: MessageReactionType;
if (reaction.get('source') === ReactionSource.FromSync) { if (isFromSync) {
const ourReactions = [ const ourReactions = [
reaction.toJSON(), reaction.toJSON(),
...oldReactions.filter(re => re.fromId === reaction.get('fromId')), ...oldReactions.filter(re => re.fromId === reaction.get('fromId')),
@ -3342,20 +3363,12 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
} }
reactions = oldReactions.filter( reactions = oldReactions.filter(
re => re => !isNewReactionReplacingPrevious(re, reaction.attributes)
!isNewReactionReplacingPrevious(
re,
reaction.attributes,
this.attributes
)
); );
reactions.push(reactionToAdd); reactions.push(reactionToAdd);
this.set({ reactions }); this.set({ reactions });
if ( if (isOutgoing(this.attributes) && isFromSomeoneElse) {
isOutgoing(this.attributes) &&
reaction.get('source') === ReactionSource.FromSomeoneElse
) {
conversation.notify(this, reaction); conversation.notify(this, reaction);
} }
@ -3378,13 +3391,62 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
`Went from ${previousLength} to ${currentLength} reactions.` `Went from ${previousLength} to ${currentLength} reactions.`
); );
if (reaction.get('source') === ReactionSource.FromThisDevice) { if (isFromThisDevice) {
const jobData: ConversationQueueJobData = { let jobData: ConversationQueueJobData;
if (isStory(this.attributes)) {
strictAssert(
newReaction.emoji !== undefined,
'New story reaction must have an emoji'
);
const reactionMessage = new window.Whisper.Message({
id: UUID.generate().toString(),
type: 'outgoing',
conversationId: conversation.id,
sent_at: newReaction.timestamp,
received_at: incrementMessageCounter(),
received_at_ms: newReaction.timestamp,
timestamp: newReaction.timestamp,
sendStateByConversationId: zipObject(
Object.keys(newReaction.isSentByConversationId || {}),
repeat({
status: SendStatus.Pending,
updatedAt: Date.now(),
})
),
storyId: this.id,
storyReaction: {
emoji: newReaction.emoji,
targetAuthorUuid: newReaction.targetAuthorUuid,
targetTimestamp: newReaction.targetTimestamp,
},
});
await Promise.all([
await window.Signal.Data.saveMessage(reactionMessage.attributes, {
ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(),
forceSave: true,
}),
reactionMessage.hydrateStoryContext(this),
]);
conversation.addSingleMessage(
window.MessageController.register(reactionMessage.id, reactionMessage)
);
jobData = {
type: conversationQueueJobEnum.enum.NormalMessage,
conversationId: conversation.id,
messageId: reactionMessage.id,
revision: conversation.get('revision'),
};
} else {
jobData = {
type: conversationQueueJobEnum.enum.Reaction, type: conversationQueueJobEnum.enum.Reaction,
conversationId: conversation.id, conversationId: conversation.id,
messageId: this.id, messageId: this.id,
revision: conversation.get('revision'), revision: conversation.get('revision'),
}; };
}
if (shouldPersist) { if (shouldPersist) {
await conversationJobQueue.add(jobData, async jobToInsert => { await conversationJobQueue.add(jobData, async jobToInsert => {
log.info( log.info(
@ -3400,7 +3462,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
} else { } else {
await conversationJobQueue.add(jobData); await conversationJobQueue.add(jobData);
} }
} else if (shouldPersist) { } else if (shouldPersist && !isStory(this.attributes)) {
await window.Signal.Data.saveMessage(this.attributes, { await window.Signal.Data.saveMessage(this.attributes, {
ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(), ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(),
}); });

View file

@ -2,12 +2,8 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { findLastIndex, has, identity, omit, negate } from 'lodash'; import { findLastIndex, has, identity, omit, negate } from 'lodash';
import type { import type { MessageReactionType } from '../model-types.d';
MessageAttributesType,
MessageReactionType,
} from '../model-types.d';
import { areObjectEntriesEqual } from '../util/areObjectEntriesEqual'; import { areObjectEntriesEqual } from '../util/areObjectEntriesEqual';
import { isStory } from '../state/selectors/message';
const isReactionEqual = ( const isReactionEqual = (
a: undefined | Readonly<MessageReactionType>, a: undefined | Readonly<MessageReactionType>,
@ -35,13 +31,8 @@ const isOutgoingReactionCompletelyUnsent = ({
export function addOutgoingReaction( export function addOutgoingReaction(
oldReactions: ReadonlyArray<MessageReactionType>, oldReactions: ReadonlyArray<MessageReactionType>,
newReaction: Readonly<MessageReactionType>, newReaction: Readonly<MessageReactionType>
isStoryMessage = false ): ReadonlyArray<MessageReactionType> {
): Array<MessageReactionType> {
if (isStoryMessage) {
return [...oldReactions, newReaction];
}
const pendingOutgoingReactions = new Set( const pendingOutgoingReactions = new Set(
oldReactions.filter(isOutgoingReactionPending) oldReactions.filter(isOutgoingReactionPending)
); );
@ -115,14 +106,13 @@ export function* getUnsentConversationIds({
// sender for stories. // sender for stories.
export function isNewReactionReplacingPrevious( export function isNewReactionReplacingPrevious(
reaction: MessageReactionType, reaction: MessageReactionType,
newReaction: MessageReactionType, newReaction: MessageReactionType
messageAttributes: MessageAttributesType
): boolean { ): boolean {
return !isStory(messageAttributes) && reaction.fromId === newReaction.fromId; return reaction.fromId === newReaction.fromId;
} }
export const markOutgoingReactionFailed = ( export const markOutgoingReactionFailed = (
reactions: Array<MessageReactionType>, reactions: ReadonlyArray<MessageReactionType>,
reaction: Readonly<MessageReactionType> reaction: Readonly<MessageReactionType>
): Array<MessageReactionType> => ): Array<MessageReactionType> =>
isOutgoingReactionCompletelyUnsent(reaction) || !reaction.emoji isOutgoingReactionCompletelyUnsent(reaction) || !reaction.emoji
@ -136,8 +126,7 @@ export const markOutgoingReactionFailed = (
export const markOutgoingReactionSent = ( export const markOutgoingReactionSent = (
reactions: ReadonlyArray<MessageReactionType>, reactions: ReadonlyArray<MessageReactionType>,
reaction: Readonly<MessageReactionType>, reaction: Readonly<MessageReactionType>,
conversationIdsSentTo: Iterable<string>, conversationIdsSentTo: Iterable<string>
messageAttributes: MessageAttributesType
): Array<MessageReactionType> => { ): Array<MessageReactionType> => {
const result: Array<MessageReactionType> = []; const result: Array<MessageReactionType> = [];
@ -154,10 +143,14 @@ export const markOutgoingReactionSent = (
for (const re of reactions) { for (const re of reactions) {
if (!isReactionEqual(re, reaction)) { if (!isReactionEqual(re, reaction)) {
const shouldKeep = !isFullySent let shouldKeep = true;
? true if (
: !isNewReactionReplacingPrevious(re, reaction, messageAttributes) || isFullySent &&
re.timestamp > reaction.timestamp; isNewReactionReplacingPrevious(re, reaction) &&
re.timestamp <= reaction.timestamp
) {
shouldKeep = false;
}
if (shouldKeep) { if (shouldKeep) {
result.push(re); result.push(re);
} }

View file

@ -445,7 +445,7 @@ export const getPropsForStoryReplyContext = createSelectorCreator(
( (
message: Pick< message: Pick<
MessageWithUIFieldsType, MessageWithUIFieldsType,
'body' | 'conversationId' | 'storyReactionEmoji' | 'storyReplyContext' 'body' | 'conversationId' | 'storyReaction' | 'storyReplyContext'
>, >,
{ {
conversationSelector, conversationSelector,
@ -455,7 +455,7 @@ export const getPropsForStoryReplyContext = createSelectorCreator(
ourConversationId?: string; ourConversationId?: string;
} }
): PropsData['storyReplyContext'] => { ): PropsData['storyReplyContext'] => {
const { storyReactionEmoji, storyReplyContext } = message; const { storyReaction, storyReplyContext } = message;
if (!storyReplyContext) { if (!storyReplyContext) {
return undefined; return undefined;
} }
@ -474,7 +474,7 @@ export const getPropsForStoryReplyContext = createSelectorCreator(
authorTitle, authorTitle,
conversationColor, conversationColor,
customColor, customColor,
emoji: storyReactionEmoji, emoji: storyReaction?.emoji,
isFromMe, isFromMe,
rawAttachment: storyReplyContext.attachment rawAttachment: storyReplyContext.attachment
? processQuoteAttachment(storyReplyContext.attachment) ? processQuoteAttachment(storyReplyContext.attachment)

View file

@ -6,7 +6,6 @@ import { pick } from 'lodash';
import type { GetConversationByIdType } from './conversations'; import type { GetConversationByIdType } from './conversations';
import type { ConversationType } from '../ducks/conversations'; import type { ConversationType } from '../ducks/conversations';
import type { MessageReactionType } from '../../model-types.d';
import type { AttachmentType } from '../../types/Attachment'; import type { AttachmentType } from '../../types/Attachment';
import type { import type {
ConversationStoryType, ConversationStoryType,
@ -64,10 +63,6 @@ export const getAddStoryData = createSelector(
({ addStoryData }): AddStoryData => addStoryData ({ addStoryData }): AddStoryData => addStoryData
); );
function getReactionUniqueId(reaction: MessageReactionType): string {
return `${reaction.fromId}:${reaction.targetAuthorUuid}:${reaction.timestamp}`;
}
function sortByRecencyAndUnread( function sortByRecencyAndUnread(
storyA: ConversationStoryType, storyA: ConversationStoryType,
storyB: ConversationStoryType storyB: ConversationStoryType
@ -273,34 +268,12 @@ export const getStoryReplies = createSelector(
conversationSelector, conversationSelector,
contactNameColorSelector, contactNameColorSelector,
me, me,
{ stories, replyState }: Readonly<StoriesStateType> { replyState }: Readonly<StoriesStateType>
): ReplyStateType | undefined => { ): ReplyStateType | undefined => {
if (!replyState) { if (!replyState) {
return; return;
} }
const foundStory = stories.find(
story => story.messageId === replyState.messageId
);
const reactions = foundStory
? (foundStory.reactions || []).map(reaction => {
const conversation = conversationSelector(reaction.fromId);
return {
author: getAvatarData(conversation),
contactNameColor: contactNameColorSelector(
foundStory.conversationId,
conversation.id
),
conversationId: reaction.fromId,
id: getReactionUniqueId(reaction),
reactionEmoji: reaction.emoji,
timestamp: reaction.timestamp,
};
})
: [];
const replies = replyState.replies.map(reply => { const replies = replyState.replies.map(reply => {
const conversation = const conversation =
reply.type === 'outgoing' reply.type === 'outgoing'
@ -316,6 +289,7 @@ export const getStoryReplies = createSelector(
'id', 'id',
'timestamp', 'timestamp',
]), ]),
reactionEmoji: reply.storyReaction?.emoji,
contactNameColor: contactNameColorSelector( contactNameColor: contactNameColorSelector(
reply.conversationId, reply.conversationId,
conversation.id conversation.id
@ -325,13 +299,9 @@ export const getStoryReplies = createSelector(
}; };
}); });
const combined = [...replies, ...reactions].sort((a, b) =>
a.timestamp > b.timestamp ? 1 : -1
);
return { return {
messageId: replyState.messageId, messageId: replyState.messageId,
replies: combined, replies,
}; };
} }
); );

View file

@ -4,10 +4,7 @@
import { assert } from 'chai'; import { assert } from 'chai';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { omit } from 'lodash'; import { omit } from 'lodash';
import type { import type { MessageReactionType } from '../../model-types.d';
MessageAttributesType,
MessageReactionType,
} from '../../model-types.d';
import { isEmpty } from '../../util/iterables'; import { isEmpty } from '../../util/iterables';
import { import {
@ -51,18 +48,6 @@ describe('reaction utilities', () => {
const newReactions = addOutgoingReaction(oldReactions, reaction); const newReactions = addOutgoingReaction(oldReactions, reaction);
assert.deepStrictEqual(newReactions, [oldReactions[1], reaction]); assert.deepStrictEqual(newReactions, [oldReactions[1], reaction]);
}); });
it('does not remove any pending reactions if its a story', () => {
const oldReactions = [
{ ...rxn('😭', { isPending: true }), timestamp: 3 },
{ ...rxn('💬'), fromId: uuid() },
{ ...rxn('🥀', { isPending: true }), timestamp: 1 },
{ ...rxn('🌹', { isPending: true }), timestamp: 2 },
];
const reaction = rxn('😀');
const newReactions = addOutgoingReaction(oldReactions, reaction, true);
assert.deepStrictEqual(newReactions, [...oldReactions, reaction]);
});
}); });
describe('getNewestPendingOutgoingReaction', () => { describe('getNewestPendingOutgoingReaction', () => {
@ -214,36 +199,21 @@ describe('reaction utilities', () => {
const reactions = [star, none, { ...rxn('🔕'), timestamp: 1 }]; const reactions = [star, none, { ...rxn('🔕'), timestamp: 1 }];
function getMessage(): MessageAttributesType {
const now = Date.now();
return {
conversationId: uuid(),
id: uuid(),
received_at: now,
sent_at: now,
timestamp: now,
type: 'incoming',
};
}
it("does nothing if the reaction isn't in the list", () => { it("does nothing if the reaction isn't in the list", () => {
const result = markOutgoingReactionSent( const result = markOutgoingReactionSent(
reactions, reactions,
rxn('🥀', { isPending: true }), rxn('🥀', { isPending: true }),
[uuid()], [uuid()]
getMessage()
); );
assert.deepStrictEqual(result, reactions); assert.deepStrictEqual(result, reactions);
}); });
it('updates reactions to be partially sent', () => { it('updates reactions to be partially sent', () => {
[star, none].forEach(reaction => { [star, none].forEach(reaction => {
const result = markOutgoingReactionSent( const result = markOutgoingReactionSent(reactions, reaction, [
reactions, uuid1,
reaction, uuid2,
[uuid1, uuid2], ]);
getMessage()
);
assert.deepStrictEqual( assert.deepStrictEqual(
result.find(re => re.emoji === reaction.emoji) result.find(re => re.emoji === reaction.emoji)
?.isSentByConversationId, ?.isSentByConversationId,
@ -257,12 +227,11 @@ describe('reaction utilities', () => {
}); });
it('removes sent state if a reaction with emoji is fully sent', () => { it('removes sent state if a reaction with emoji is fully sent', () => {
const result = markOutgoingReactionSent( const result = markOutgoingReactionSent(reactions, star, [
reactions, uuid1,
star, uuid2,
[uuid1, uuid2, uuid3], uuid3,
getMessage() ]);
);
const newReaction = result.find(re => re.emoji === '⭐️'); const newReaction = result.find(re => re.emoji === '⭐️');
assert.isDefined(newReaction); assert.isDefined(newReaction);
@ -270,12 +239,11 @@ describe('reaction utilities', () => {
}); });
it('removes a fully-sent reaction removal', () => { it('removes a fully-sent reaction removal', () => {
const result = markOutgoingReactionSent( const result = markOutgoingReactionSent(reactions, none, [
reactions, uuid1,
none, uuid2,
[uuid1, uuid2, uuid3], uuid3,
getMessage() ]);
);
assert( assert(
result.every(({ emoji }) => typeof emoji === 'string'), result.every(({ emoji }) => typeof emoji === 'string'),
@ -284,25 +252,13 @@ describe('reaction utilities', () => {
}); });
it('removes older reactions of mine', () => { it('removes older reactions of mine', () => {
const result = markOutgoingReactionSent( const result = markOutgoingReactionSent(reactions, star, [
reactions, uuid1,
star, uuid2,
[uuid1, uuid2, uuid3], uuid3,
getMessage() ]);
);
assert.isUndefined(result.find(re => re.emoji === '🔕')); assert.isUndefined(result.find(re => re.emoji === '🔕'));
}); });
it('does not remove my older reactions if they are on a story', () => {
const result = markOutgoingReactionSent(
reactions,
star,
[uuid1, uuid2, uuid3],
{ ...getMessage(), type: 'story' }
);
assert.isDefined(result.find(re => re.emoji === '🔕'));
});
}); });
}); });

View file

@ -37,7 +37,7 @@ export type ReplyType = {
export type ReplyStateType = { export type ReplyStateType = {
messageId: string; messageId: string;
replies: Array<ReplyType>; replies: ReadonlyArray<ReplyType>;
}; };
export type ConversationStoryType = { export type ConversationStoryType = {

View file

@ -22,13 +22,6 @@
"reasonCategory": "falseMatch", "reasonCategory": "falseMatch",
"updated": "2018-09-19T18:13:29.628Z" "updated": "2018-09-19T18:13:29.628Z"
}, },
{
"rule": "React-useRef",
"path": "ts/components/ModalContainer.tsx",
"line": " const containerRef = React.useRef<HTMLDivElement | null>(null);",
"reasonCategory": "usageTrusted",
"updated": "2022-10-14T16:39:48.461Z"
},
{ {
"rule": "jQuery-globalEval(", "rule": "jQuery-globalEval(",
"path": "components/mp3lameencoder/lib/Mp3LameEncoder.js", "path": "components/mp3lameencoder/lib/Mp3LameEncoder.js",
@ -9237,6 +9230,13 @@
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2021-09-21T01:40:08.534Z" "updated": "2021-09-21T01:40:08.534Z"
}, },
{
"rule": "React-useRef",
"path": "ts/components/ModalContainer.tsx",
"line": " const containerRef = React.useRef<HTMLDivElement | null>(null);",
"reasonCategory": "usageTrusted",
"updated": "2022-10-14T16:39:48.461Z"
},
{ {
"rule": "React-useRef", "rule": "React-useRef",
"path": "ts/components/ModalHost.tsx", "path": "ts/components/ModalHost.tsx",