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;