From 2964f00728f76cb0d5934e5f59cfe5d607d1f2a5 Mon Sep 17 00:00:00 2001 From: Hatsune Mikku <224295387+mikku69420@users.noreply.github.com> Date: Sat, 2 Aug 2025 19:18:00 +0100 Subject: [PATCH 01/14] Update ts/reactions/util.ts Only replace reactions with the same emoji from the same user --- ts/reactions/util.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ts/reactions/util.ts b/ts/reactions/util.ts index cabb80dcd09..2eae7974a80 100644 --- a/ts/reactions/util.ts +++ b/ts/reactions/util.ts @@ -108,7 +108,9 @@ export function isNewReactionReplacingPrevious( reaction: MessageReactionType, newReaction: MessageReactionType ): boolean { - return reaction.fromId === newReaction.fromId; + // Only replace reactions with the same emoji from the same user + // This allows multiple different emoji reactions from the same user + return reaction.fromId === newReaction.fromId && reaction.emoji === newReaction.emoji;; } export const markOutgoingReactionFailed = ( From 667ad0c2af4801dbe545f3aac2c4f55ecaec8b0a Mon Sep 17 00:00:00 2001 From: Hatsune Mikku <224295387+mikku69420@users.noreply.github.com> Date: Sat, 2 Aug 2025 19:20:57 +0100 Subject: [PATCH 02/14] Update ts/state/selectors/message.ts Just show all reactions with emojis - no grouping by user so not to interfere with the multiples --- ts/state/selectors/message.ts | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/ts/state/selectors/message.ts b/ts/state/selectors/message.ts index 172e051438d..c08a9a5f92c 100644 --- a/ts/state/selectors/message.ts +++ b/ts/state/selectors/message.ts @@ -405,17 +405,9 @@ const getReactionsForMessage = ( { reactions = [] }: MessageWithUIFieldsType, { conversationSelector }: { conversationSelector: GetConversationByIdType } ) => { - const reactionBySender = new Map(); - 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(); + // Just show all reactions with emojis - no grouping by user const reactionsWithEmoji = iterables.filter( - reactionsWithEmpties, + reactions, re => re.emoji ); const formattedReactions = iterables.map(reactionsWithEmoji, re => { From d98bae9d49f5d955d265851e77454565c74a3bd4 Mon Sep 17 00:00:00 2001 From: Hatsune Mikku <224295387+mikku69420@users.noreply.github.com> Date: Sat, 2 Aug 2025 19:27:20 +0100 Subject: [PATCH 03/14] Update ts/components/conversation/TimelineMessage.tsx added code to check if you already reacted in order to allow the toggling behaviour --- ts/components/conversation/TimelineMessage.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/ts/components/conversation/TimelineMessage.tsx b/ts/components/conversation/TimelineMessage.tsx index 74c0f3830e6..b3bf42aa497 100644 --- a/ts/components/conversation/TimelineMessage.tsx +++ b/ts/components/conversation/TimelineMessage.tsx @@ -121,6 +121,7 @@ export function TimelineMessage(props: Props): JSX.Element { copyMessageText, pushPanelForConversation, reactToMessage, + reactions, renderEmojiPicker, renderReactionPicker, retryDeleteForEveryone, @@ -336,9 +337,15 @@ export function TimelineMessage(props: Props): JSX.Element { onClose: toggleReactionPicker, onPick: emoji => { toggleReactionPicker(true); + // Check if current user already has this specific emoji reaction + const ourId = window.ConversationController.getOurConversationIdOrThrow(); + const alreadyReacted = (reactions || []).some( + reaction => reaction.fromId === ourId && reaction.emoji === emoji + ); + reactToMessage(id, { emoji, - remove: emoji === selectedReaction, + remove: alreadyReacted, // Toggle: remove if already reacted, add if not }); }, renderEmojiPicker, From 1e7ee08b2e00e86fe2f6cb43df250cff7a5e158c Mon Sep 17 00:00:00 2001 From: Hatsune Mikku <224295387+mikku69420@users.noreply.github.com> Date: Sat, 2 Aug 2025 19:31:50 +0100 Subject: [PATCH 04/14] Update ts/messageModifiers/Reactions.ts Keep emoji for both add and remove to enable the multiple emoji reactions and not end up with undefined --- ts/messageModifiers/Reactions.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ts/messageModifiers/Reactions.ts b/ts/messageModifiers/Reactions.ts index 249525bfa1f..c2149bda391 100644 --- a/ts/messageModifiers/Reactions.ts +++ b/ts/messageModifiers/Reactions.ts @@ -350,7 +350,7 @@ export async function handleReaction( ); const newReaction: MessageReactionType = { - emoji: reaction.remove ? undefined : reaction.emoji, + emoji: reaction.emoji, // Keep emoji for both add and remove to enable proper filtering fromId: reaction.fromId, targetTimestamp: reaction.targetTimestamp, timestamp: reaction.timestamp, @@ -505,7 +505,7 @@ export async function handleReaction( } reactions = oldReactions.filter( - re => !isNewReactionReplacingPrevious(re, reaction) + re => !isNewReactionReplacingPrevious(re, newReaction) ); reactions.push(reactionToAdd); message.set({ reactions }); From 2e0eaa2b77a8a14dccc242a9b147e29a95ce2a40 Mon Sep 17 00:00:00 2001 From: Hatsune Mikku <224295387+mikku69420@users.noreply.github.com> Date: Sat, 2 Aug 2025 19:43:47 +0100 Subject: [PATCH 05/14] Update _locales/en/messages.json add messages for config option for multiple emoji reactions, only EN locale, not sure how i should go about for the rest of them --- _locales/en/messages.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 7310e511bd1..3f2e852c635 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -6980,6 +6980,14 @@ "messageformat": "For example, :-) will be converted to 🙂", "description": "Description for the auto convert emoji setting" }, + "icu:Preferences__multiple-emoji-reactions--title": { + "messageformat": "Allow multiple emoji reactions per message", + "description": "Title for the multiple emoji reactions setting" + }, + "icu:Preferences__multiple-emoji-reactions--description": { + "messageformat": "React with multiple different emojis to the same message", + "description": "Description for the multiple emoji reactions setting" +}, "icu:Preferences--advanced": { "messageformat": "Advanced", "description": "Title for advanced settings" From 1acd19b92e895c00e429fa9fe7e4f2d6b336849e Mon Sep 17 00:00:00 2001 From: Hatsune Mikku <224295387+mikku69420@users.noreply.github.com> Date: Sat, 2 Aug 2025 19:48:07 +0100 Subject: [PATCH 06/14] Update ts/components/Preferences.tsx added preferences option for Multiple Emoji Reactions --- ts/components/Preferences.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/ts/components/Preferences.tsx b/ts/components/Preferences.tsx index f21caa7444e..bbdabe8fa5a 100644 --- a/ts/components/Preferences.tsx +++ b/ts/components/Preferences.tsx @@ -134,6 +134,7 @@ export type PropsDataType = { hasAudioNotifications?: boolean; hasAutoConvertEmoji: boolean; hasAutoDownloadUpdate: boolean; + hasMultipleEmojiReactions: boolean; hasAutoLaunch: boolean | undefined; hasCallNotifications: boolean; hasCallRingtoneNotification: boolean; @@ -274,6 +275,7 @@ type PropsFunctionType = { // Change handlers onAudioNotificationsChange: CheckboxChangeHandlerType; onAutoConvertEmojiChange: CheckboxChangeHandlerType; + onMultipleEmojiReactionsChange: CheckboxChangeHandlerType; onAutoDownloadAttachmentChange: ( setting: AutoDownloadAttachmentType ) => unknown; @@ -392,6 +394,7 @@ export function Preferences({ hasAudioNotifications, hasAutoConvertEmoji, hasAutoDownloadUpdate, + hasMultipleEmojiReactions, hasAutoLaunch, hasCallNotifications, hasCallRingtoneNotification, @@ -434,6 +437,7 @@ export function Preferences({ notificationContent, onAudioNotificationsChange, onAutoConvertEmojiChange, + onMultipleEmojiReactionsChange, onAutoDownloadAttachmentChange, onAutoDownloadUpdateChange, onAutoLaunchChange, @@ -1168,6 +1172,14 @@ export function Preferences({ name="autoConvertEmoji" onChange={onAutoConvertEmojiChange} /> + Date: Sat, 2 Aug 2025 19:50:45 +0100 Subject: [PATCH 07/14] Update ts/reactions/util.ts have default behaviour by default and if the preferences option is enabled then do the multiple emoji reactions --- ts/reactions/util.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/ts/reactions/util.ts b/ts/reactions/util.ts index 2eae7974a80..fbeb81038b4 100644 --- a/ts/reactions/util.ts +++ b/ts/reactions/util.ts @@ -108,9 +108,16 @@ export function isNewReactionReplacingPrevious( reaction: MessageReactionType, newReaction: MessageReactionType ): boolean { - // Only replace reactions with the same emoji from the same user - // This allows multiple different emoji reactions from the same user - return reaction.fromId === newReaction.fromId && reaction.emoji === newReaction.emoji;; + const hasMultipleEmojiReactions = window.storage.get('multipleEmojiReactions', false); + + if (hasMultipleEmojiReactions) { + // Only replace reactions with the same emoji from the same user + // This allows multiple different emoji reactions from the same user + return reaction.fromId === newReaction.fromId && reaction.emoji === newReaction.emoji; + } + + // Default behavior: replace all reactions from the same user + return reaction.fromId === newReaction.fromId; } export const markOutgoingReactionFailed = ( From 31c0caa9b91c3bd549a1a539b4df3d95a8677c62 Mon Sep 17 00:00:00 2001 From: Hatsune Mikku <224295387+mikku69420@users.noreply.github.com> Date: Sat, 2 Aug 2025 19:54:08 +0100 Subject: [PATCH 08/14] Update ts/state/selectors/message.ts updated code to have both old and new behaviour depending on the preferences option --- ts/state/selectors/message.ts | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/ts/state/selectors/message.ts b/ts/state/selectors/message.ts index c08a9a5f92c..8468eb8f628 100644 --- a/ts/state/selectors/message.ts +++ b/ts/state/selectors/message.ts @@ -405,12 +405,33 @@ const getReactionsForMessage = ( { reactions = [] }: MessageWithUIFieldsType, { conversationSelector }: { conversationSelector: GetConversationByIdType } ) => { - // Just show all reactions with emojis - no grouping by user - const reactionsWithEmoji = iterables.filter( - reactions, - re => re.emoji - ); - const formattedReactions = iterables.map(reactionsWithEmoji, re => { + const hasMultipleEmojiReactions = window.storage.get('multipleEmojiReactions', false); + + let reactionsToFormat: Iterable; + + if (hasMultipleEmojiReactions) { + // Just show all reactions with emojis - no grouping by user + reactionsToFormat = iterables.filter( + reactions, + re => re.emoji + ); + } else { + // Default behavior: group by sender, keeping only the latest reaction + const reactionBySender = new Map(); + 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(); + reactionsToFormat = iterables.filter( + reactionsWithEmpties, + re => re.emoji + ); + } + + const formattedReactions = iterables.map(reactionsToFormat, re => { const c = conversationSelector(re.fromId); type From = NonNullable[0]['from']; From 72772e7cbb73dd25d53e150d2c4a7e35256d5109 Mon Sep 17 00:00:00 2001 From: Hatsune Mikku <224295387+mikku69420@users.noreply.github.com> Date: Sat, 2 Aug 2025 19:57:53 +0100 Subject: [PATCH 09/14] Update ts/state/smart/Preferences.tsx add preference option for multipleEmojiReactions --- ts/state/smart/Preferences.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ts/state/smart/Preferences.tsx b/ts/state/smart/Preferences.tsx index 450a3efecf8..27ecbb872a0 100644 --- a/ts/state/smart/Preferences.tsx +++ b/ts/state/smart/Preferences.tsx @@ -564,6 +564,10 @@ export function SmartPreferences(): JSX.Element | null { 'autoConvertEmoji', true ); + const [hasMultipleEmojiReactions, onMultipleEmojiReactionsChange] = createItemsAccess( + 'multipleEmojiReactions', + false + ); const [hasAutoDownloadUpdate, onAutoDownloadUpdateChange] = createItemsAccess( 'auto-download-update', true @@ -754,6 +758,7 @@ export function SmartPreferences(): JSX.Element | null { hasAudioNotifications={hasAudioNotifications} hasAutoConvertEmoji={hasAutoConvertEmoji} hasAutoDownloadUpdate={hasAutoDownloadUpdate} + hasMultipleEmojiReactions={hasMultipleEmojiReactions} hasAutoLaunch={hasAutoLaunch} hasCallNotifications={hasCallNotifications} hasCallRingtoneNotification={hasCallRingtoneNotification} @@ -799,6 +804,7 @@ export function SmartPreferences(): JSX.Element | null { notificationContent={notificationContent} onAudioNotificationsChange={onAudioNotificationsChange} onAutoConvertEmojiChange={onAutoConvertEmojiChange} + onMultipleEmojiReactionsChange={onMultipleEmojiReactionsChange} onAutoDownloadAttachmentChange={onAutoDownloadAttachmentChange} onAutoDownloadUpdateChange={onAutoDownloadUpdateChange} onAutoLaunchChange={onAutoLaunchChange} From 6c39df843f6f01144e6345f01c7867f930c44639 Mon Sep 17 00:00:00 2001 From: Hatsune Mikku <224295387+mikku69420@users.noreply.github.com> Date: Sat, 2 Aug 2025 19:59:21 +0100 Subject: [PATCH 10/14] Update ts/types/Storage.d.ts added the multipleEmojiReactions boolean for the preferences of having Multiple Emoji Reactions enabled or not --- ts/types/Storage.d.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/ts/types/Storage.d.ts b/ts/types/Storage.d.ts index c652711f1d9..9d3ba020ea8 100644 --- a/ts/types/Storage.d.ts +++ b/ts/types/Storage.d.ts @@ -61,6 +61,7 @@ export type StorageAccessType = { 'auto-download-update': boolean; 'auto-download-attachment': AutoDownloadAttachmentType; autoConvertEmoji: boolean; + multipleEmojiReactions: boolean; 'badge-count-muted-conversations': boolean; 'blocked-groups': ReadonlyArray; 'blocked-uuids': ReadonlyArray; From 5dbfb89226370920c90109fca3f1b83a125881d2 Mon Sep 17 00:00:00 2001 From: mikku69420 Date: Mon, 4 Aug 2025 09:34:42 +0100 Subject: [PATCH 11/14] Fix multiple emoji reactions behavior for single reaction mode Instead of handling single-reaction behavior during receipt (which causes inconsistencies), handle it during sending by clearing existing reactions before adding new ones when multipleEmojiReactions is disabled. Changes: - Reverted "replace on receive" logic in reaction handlers - Added "clear before send" logic in enqueueReactionForSend - When single reaction mode is active, removes all existing reactions from the user before adding the new one - Ensures consistent behavior regardless of other users' settings This approach is protocol-compatible and avoids the ambiguity of trying to guess user intent during reaction removal. --- ts/messageModifiers/Reactions.ts | 4 +- ts/reactions/enqueueReactionForSend.ts | 63 +++++++++++++++++++++++++- ts/reactions/util.ts | 9 ---- ts/state/selectors/message.ts | 35 +++++--------- 4 files changed, 75 insertions(+), 36 deletions(-) diff --git a/ts/messageModifiers/Reactions.ts b/ts/messageModifiers/Reactions.ts index c2149bda391..249525bfa1f 100644 --- a/ts/messageModifiers/Reactions.ts +++ b/ts/messageModifiers/Reactions.ts @@ -350,7 +350,7 @@ export async function handleReaction( ); const newReaction: MessageReactionType = { - emoji: reaction.emoji, // Keep emoji for both add and remove to enable proper filtering + emoji: reaction.remove ? undefined : reaction.emoji, fromId: reaction.fromId, targetTimestamp: reaction.targetTimestamp, timestamp: reaction.timestamp, @@ -505,7 +505,7 @@ export async function handleReaction( } reactions = oldReactions.filter( - re => !isNewReactionReplacingPrevious(re, newReaction) + re => !isNewReactionReplacingPrevious(re, reaction) ); reactions.push(reactionToAdd); message.set({ reactions }); diff --git a/ts/reactions/enqueueReactionForSend.ts b/ts/reactions/enqueueReactionForSend.ts index ebff115e3e8..23015ee0656 100644 --- a/ts/reactions/enqueueReactionForSend.ts +++ b/ts/reactions/enqueueReactionForSend.ts @@ -123,11 +123,72 @@ export async function enqueueReactionForSend({ }); } + const ourId = window.ConversationController.getOurConversationIdOrThrow(); + const hasMultipleEmojiReactions = window.storage.get('multipleEmojiReactions', false); + + // If adding a reaction and multiple reactions are disabled, + // first remove all our existing reactions + if (!remove && !hasMultipleEmojiReactions) { + const existingReactions = message.get('reactions') || []; + const ourReactions = existingReactions.filter(r => r.fromId === ourId); + + log.info('Single reaction mode - removing existing reactions before adding new one:', { + existingCount: ourReactions.length, + existingEmojis: ourReactions.map(r => r.emoji), + newEmoji: emoji + }); + + // Remove all our existing reactions first + for (const existingReaction of ourReactions) { + // Skip if it's the same emoji we're about to add + if (existingReaction.emoji === emoji) { + continue; + } + + const removeReaction: ReactionAttributesType = { + envelopeId: generateUuid(), + removeFromMessageReceiverCache: noop, + emoji: existingReaction.emoji, + fromId: ourId, + remove: true, + source: ReactionSource.FromThisDevice, + generatedMessageForStoryReaction: storyMessage ? new MessageModel({ + ...generateMessageId(incrementMessageCounter()), + type: 'outgoing', + conversationId: targetConversation.id, + sent_at: timestamp - 1, + received_at_ms: timestamp - 1, + timestamp: timestamp - 1, + expireTimer, + sendStateByConversationId: zipObject( + targetConversation.getMemberConversationIds(), + repeat({ + status: SendStatus.Pending, + updatedAt: Date.now(), + }) + ), + storyId: message.id, + storyReaction: { + emoji: existingReaction.emoji, + targetAuthorAci, + targetTimestamp, + }, + }) : undefined, + targetAuthorAci, + targetTimestamp, + receivedAtDate: timestamp - 1, + timestamp: timestamp - 1, // Ensure removal happens before addition + }; + + await handleReaction(message, removeReaction, { storyMessage }); + } + } + const reaction: ReactionAttributesType = { envelopeId: generateUuid(), removeFromMessageReceiverCache: noop, emoji, - fromId: window.ConversationController.getOurConversationIdOrThrow(), + fromId: ourId, remove, source: ReactionSource.FromThisDevice, generatedMessageForStoryReaction: storyReactionMessage, diff --git a/ts/reactions/util.ts b/ts/reactions/util.ts index fbeb81038b4..cabb80dcd09 100644 --- a/ts/reactions/util.ts +++ b/ts/reactions/util.ts @@ -108,15 +108,6 @@ export function isNewReactionReplacingPrevious( reaction: MessageReactionType, newReaction: MessageReactionType ): boolean { - const hasMultipleEmojiReactions = window.storage.get('multipleEmojiReactions', false); - - if (hasMultipleEmojiReactions) { - // Only replace reactions with the same emoji from the same user - // This allows multiple different emoji reactions from the same user - return reaction.fromId === newReaction.fromId && reaction.emoji === newReaction.emoji; - } - - // Default behavior: replace all reactions from the same user return reaction.fromId === newReaction.fromId; } diff --git a/ts/state/selectors/message.ts b/ts/state/selectors/message.ts index 8468eb8f628..172e051438d 100644 --- a/ts/state/selectors/message.ts +++ b/ts/state/selectors/message.ts @@ -405,33 +405,20 @@ const getReactionsForMessage = ( { reactions = [] }: MessageWithUIFieldsType, { conversationSelector }: { conversationSelector: GetConversationByIdType } ) => { - const hasMultipleEmojiReactions = window.storage.get('multipleEmojiReactions', false); - - let reactionsToFormat: Iterable; - - if (hasMultipleEmojiReactions) { - // Just show all reactions with emojis - no grouping by user - reactionsToFormat = iterables.filter( - reactions, - re => re.emoji - ); - } else { - // Default behavior: group by sender, keeping only the latest reaction - const reactionBySender = new Map(); - for (const reaction of reactions) { - const existingReaction = reactionBySender.get(reaction.fromId); - if (!existingReaction || reaction.timestamp > existingReaction.timestamp) { - reactionBySender.set(reaction.fromId, reaction); - } + const reactionBySender = new Map(); + 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(); - reactionsToFormat = iterables.filter( - reactionsWithEmpties, - re => re.emoji - ); } - const formattedReactions = iterables.map(reactionsToFormat, re => { + 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[0]['from']; From 936558001edbd89d7112ce7ab8edcda30333f908 Mon Sep 17 00:00:00 2001 From: mikku69420 Date: Mon, 4 Aug 2025 11:51:32 +0100 Subject: [PATCH 12/14] When multipleEmojiReactions setting is disabled, the app now correctly: - Removes existing reactions before adding a new one from the same user - Handles toggle behavior (clicking same emoji twice removes it) - Prevents duplicate reactions from accumulating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changes: - enqueueReactionForSend.ts: Add logic to remove existing reactions in single reaction mode - Reactions.ts: Fix reaction handling for both add and remove operations - util.ts: Check multipleEmojiReactions setting in markOutgoingReactionSent - message.ts: Simplify reaction display logic (duplicate prevention at storage level) - sendReaction.ts: Clean up reaction state management This ensures reactions work as expected when multiple reactions are disabled, maintaining backwards compatibility while fixing the accumulation bug that I experienced in the new approach. Testing Steps used: 1. With multipleEmojiReactions enabled : - user A reacts with 👍 → adds 👍 - user A reacts with ❤️ → adds ❤️ (now you have both 👍 and ❤️) - user A reacts with 😂 → adds 😂 (now you have 👍, ❤️, and 😂) - user A clicks 👍 again → removes only 👍 (you still have ❤️ and 😂) 2. With multipleEmojiReactions disabled (legacy behavior): - user B reacts with 👍 → adds 👍 - user B reacts ❤️ → replaces 👍 with ❤️ (only ❤️ shown) - user B reacts 😂 → replaces ❤️ with 😂 (only 😂 shown) - user B clicks 😂 again → removes 😂 (no reactions) What Each User Sees: You (with patch) send multiple reactions: - You see: All your reactions (👍, ❤️, 😂) - Legacy users see: Only your latest reaction (😂) Legacy user sends reactions to you: - They see: Only their latest reaction (normal behavior) - You see: Only their latest reaction (they can't send multiple) Another patched user sends multiple reactions: - They see: All their reactions - You see: All their reactions - Legacy users see: Only the sender's latest reaction --- ts/jobs/helpers/sendReaction.ts | 3 +- ts/messageModifiers/Reactions.ts | 41 +++++++++++++++++++------- ts/reactions/enqueueReactionForSend.ts | 12 +++++++- ts/reactions/util.ts | 2 ++ ts/state/selectors/message.ts | 12 ++------ 5 files changed, 47 insertions(+), 23 deletions(-) diff --git a/ts/jobs/helpers/sendReaction.ts b/ts/jobs/helpers/sendReaction.ts index e0dbc15fe7d..8d695118496 100644 --- a/ts/jobs/helpers/sendReaction.ts +++ b/ts/jobs/helpers/sendReaction.ts @@ -353,8 +353,9 @@ export async function sendReaction( pendingReaction, successfulConversationIds ); + setReactions(message, newReactions); - + if (!didFullySend) { throw new Error('reaction did not fully send'); } diff --git a/ts/messageModifiers/Reactions.ts b/ts/messageModifiers/Reactions.ts index 249525bfa1f..08e83cfed84 100644 --- a/ts/messageModifiers/Reactions.ts +++ b/ts/messageModifiers/Reactions.ts @@ -350,7 +350,7 @@ export async function handleReaction( ); const newReaction: MessageReactionType = { - emoji: reaction.remove ? undefined : reaction.emoji, + emoji: reaction.emoji, fromId: reaction.fromId, targetTimestamp: reaction.targetTimestamp, timestamp: reaction.timestamp, @@ -451,11 +451,21 @@ export async function handleReaction( 'from this device' ); - const reactions = reactionUtil.addOutgoingReaction( - message.get('reactions') || [], - newReaction - ); - message.set({ reactions }); + if (reaction.remove) { + // Handle removal for reactions from this device + const oldReactions = message.get('reactions') || []; + const reactions = oldReactions.filter( + re => !(re.fromId === reaction.fromId && re.emoji === reaction.emoji) + ); + message.set({ reactions }); + } else { + // Handle addition for reactions from this device + const reactions = reactionUtil.addOutgoingReaction( + message.get('reactions') || [], + newReaction + ); + message.set({ reactions }); + } } else { const oldReactions = message.get('reactions') || []; let reactions: Array; @@ -474,7 +484,7 @@ export async function handleReaction( 'handleReaction: removing reaction for message', getMessageIdForLogging(message.attributes) ); - + if (isFromSync) { reactions = oldReactions.filter( re => @@ -504,10 +514,19 @@ export async function handleReaction( reactionToAdd = newReaction; } - reactions = oldReactions.filter( - re => !isNewReactionReplacingPrevious(re, reaction) - ); - reactions.push(reactionToAdd); + const hasMultipleEmojiReactions = window.storage.get('multipleEmojiReactions', false); + + if (hasMultipleEmojiReactions) { + // In multiple reaction mode, just add the new reaction + reactions = [...oldReactions, reactionToAdd]; + } else { + // In single reaction mode, replace previous reactions from same sender + reactions = oldReactions.filter( + re => !isNewReactionReplacingPrevious(re, reaction) + ); + reactions.push(reactionToAdd); + } + message.set({ reactions }); if (isOutgoing(message.attributes) && isFromSomeoneElse) { diff --git a/ts/reactions/enqueueReactionForSend.ts b/ts/reactions/enqueueReactionForSend.ts index 23015ee0656..2b71b26a445 100644 --- a/ts/reactions/enqueueReactionForSend.ts +++ b/ts/reactions/enqueueReactionForSend.ts @@ -126,10 +126,20 @@ export async function enqueueReactionForSend({ const ourId = window.ConversationController.getOurConversationIdOrThrow(); const hasMultipleEmojiReactions = window.storage.get('multipleEmojiReactions', false); + // Check if we already have this emoji (for toggle behavior) + const existingReactions = message.get('reactions') || []; + const alreadyHasThisEmoji = existingReactions.some( + r => r.fromId === ourId && r.emoji === emoji + ); + + // If we already have this emoji and not explicitly removing, toggle it off + if (!remove && alreadyHasThisEmoji) { + remove = true; + } + // If adding a reaction and multiple reactions are disabled, // first remove all our existing reactions if (!remove && !hasMultipleEmojiReactions) { - const existingReactions = message.get('reactions') || []; const ourReactions = existingReactions.filter(r => r.fromId === ourId); log.info('Single reaction mode - removing existing reactions before adding new one:', { diff --git a/ts/reactions/util.ts b/ts/reactions/util.ts index cabb80dcd09..fa2471fea58 100644 --- a/ts/reactions/util.ts +++ b/ts/reactions/util.ts @@ -140,12 +140,14 @@ export const markOutgoingReactionSent = ( } const isFullySent = Object.values(newIsSentByConversationId).every(identity); + const hasMultipleEmojiReactions = window.storage.get('multipleEmojiReactions', false); for (const re of reactions) { if (!isReactionEqual(re, reaction)) { let shouldKeep = true; if ( isFullySent && + !hasMultipleEmojiReactions && isNewReactionReplacingPrevious(re, reaction) && re.timestamp <= reaction.timestamp ) { diff --git a/ts/state/selectors/message.ts b/ts/state/selectors/message.ts index 172e051438d..2764da3561c 100644 --- a/ts/state/selectors/message.ts +++ b/ts/state/selectors/message.ts @@ -405,17 +405,9 @@ const getReactionsForMessage = ( { reactions = [] }: MessageWithUIFieldsType, { conversationSelector }: { conversationSelector: GetConversationByIdType } ) => { - const reactionBySender = new Map(); - 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(); + // Just display all reactions - duplicates are now prevented at storage level const reactionsWithEmoji = iterables.filter( - reactionsWithEmpties, + reactions, re => re.emoji ); const formattedReactions = iterables.map(reactionsWithEmoji, re => { From fb9ada6161e82b287abdc7130ab82532f3e34c0e Mon Sep 17 00:00:00 2001 From: mikku69420 Date: Mon, 4 Aug 2025 12:04:23 +0100 Subject: [PATCH 13/14] spaces ! --- ts/jobs/helpers/sendReaction.ts | 3 +-- ts/messageModifiers/Reactions.ts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/ts/jobs/helpers/sendReaction.ts b/ts/jobs/helpers/sendReaction.ts index 8d695118496..e0dbc15fe7d 100644 --- a/ts/jobs/helpers/sendReaction.ts +++ b/ts/jobs/helpers/sendReaction.ts @@ -353,9 +353,8 @@ export async function sendReaction( pendingReaction, successfulConversationIds ); - setReactions(message, newReactions); - + if (!didFullySend) { throw new Error('reaction did not fully send'); } diff --git a/ts/messageModifiers/Reactions.ts b/ts/messageModifiers/Reactions.ts index 08e83cfed84..6a62a0ba130 100644 --- a/ts/messageModifiers/Reactions.ts +++ b/ts/messageModifiers/Reactions.ts @@ -484,7 +484,7 @@ export async function handleReaction( 'handleReaction: removing reaction for message', getMessageIdForLogging(message.attributes) ); - + if (isFromSync) { reactions = oldReactions.filter( re => From 7472bd54026987518684da68182f5a03fa7cf8e5 Mon Sep 17 00:00:00 2001 From: mikku69420 Date: Mon, 4 Aug 2025 13:13:00 +0100 Subject: [PATCH 14/14] Improve reaction handling during multipleEmojiReactions transition period When receiver has multipleEmojiReactions enabled, detect if sender might be in single reaction mode by checking if they're replacing their existing reactions. This prevents the emoji accumulation issue during transition period where some users have the feature enabled and other don't. Changes: - In multiple reaction mode, check if sender is replacing reactions - If sender has existing reactions and sends a different emoji, replace their previous reactions instead of accumulating --- ts/messageModifiers/Reactions.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/ts/messageModifiers/Reactions.ts b/ts/messageModifiers/Reactions.ts index 6a62a0ba130..a76f2bfa811 100644 --- a/ts/messageModifiers/Reactions.ts +++ b/ts/messageModifiers/Reactions.ts @@ -517,8 +517,21 @@ export async function handleReaction( const hasMultipleEmojiReactions = window.storage.get('multipleEmojiReactions', false); if (hasMultipleEmojiReactions) { - // In multiple reaction mode, just add the new reaction - reactions = [...oldReactions, reactionToAdd]; + // In multiple reaction mode, allow multiple reactions per sender + // But check if the sender might be in single reaction mode + const senderReactions = oldReactions.filter(re => re.fromId === reaction.fromId); + + // If sender already has reactions and is adding a new different one, + // they might be in single reaction mode, so replace their previous reactions + if (senderReactions.length > 0 && !senderReactions.some(re => re.emoji === reactionToAdd.emoji)) { + reactions = oldReactions.filter( + re => !isNewReactionReplacingPrevious(re, reaction) + ); + reactions.push(reactionToAdd); + } else { + // Normal multiple reaction mode - just add + reactions = [...oldReactions, reactionToAdd]; + } } else { // In single reaction mode, replace previous reactions from same sender reactions = oldReactions.filter(