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"
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}
/>
+
{
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,
diff --git a/ts/messageModifiers/Reactions.ts b/ts/messageModifiers/Reactions.ts
index 249525bfa1f..a76f2bfa811 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;
@@ -504,10 +514,32 @@ 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, 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(
+ 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 ebff115e3e8..2b71b26a445 100644
--- a/ts/reactions/enqueueReactionForSend.ts
+++ b/ts/reactions/enqueueReactionForSend.ts
@@ -123,11 +123,82 @@ 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 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 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 => {
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}
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;