Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions _locales/en/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -6980,6 +6980,14 @@
"messageformat": "For example, :-) will be converted to <emojify>🙂</emojify>",
"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"
Expand Down
12 changes: 12 additions & 0 deletions ts/components/Preferences.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ export type PropsDataType = {
hasAudioNotifications?: boolean;
hasAutoConvertEmoji: boolean;
hasAutoDownloadUpdate: boolean;
hasMultipleEmojiReactions: boolean;
hasAutoLaunch: boolean | undefined;
hasCallNotifications: boolean;
hasCallRingtoneNotification: boolean;
Expand Down Expand Up @@ -274,6 +275,7 @@ type PropsFunctionType = {
// Change handlers
onAudioNotificationsChange: CheckboxChangeHandlerType;
onAutoConvertEmojiChange: CheckboxChangeHandlerType;
onMultipleEmojiReactionsChange: CheckboxChangeHandlerType;
onAutoDownloadAttachmentChange: (
setting: AutoDownloadAttachmentType
) => unknown;
Expand Down Expand Up @@ -392,6 +394,7 @@ export function Preferences({
hasAudioNotifications,
hasAutoConvertEmoji,
hasAutoDownloadUpdate,
hasMultipleEmojiReactions,
hasAutoLaunch,
hasCallNotifications,
hasCallRingtoneNotification,
Expand Down Expand Up @@ -434,6 +437,7 @@ export function Preferences({
notificationContent,
onAudioNotificationsChange,
onAutoConvertEmojiChange,
onMultipleEmojiReactionsChange,
onAutoDownloadAttachmentChange,
onAutoDownloadUpdateChange,
onAutoLaunchChange,
Expand Down Expand Up @@ -1168,6 +1172,14 @@ export function Preferences({
name="autoConvertEmoji"
onChange={onAutoConvertEmojiChange}
/>
<Checkbox
checked={hasMultipleEmojiReactions}
description={i18n('icu:Preferences__multiple-emoji-reactions--description')}
label={i18n('icu:Preferences__multiple-emoji-reactions--title')}
moduleClassName="Preferences__checkbox"
name="multipleEmojiReactions"
onChange={onMultipleEmojiReactionsChange}
/>
<SettingsRow>
<Control
left={i18n('icu:Preferences__EmojiSkinToneDefaultSetting__Label')}
Expand Down
9 changes: 8 additions & 1 deletion ts/components/conversation/TimelineMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ export function TimelineMessage(props: Props): JSX.Element {
copyMessageText,
pushPanelForConversation,
reactToMessage,
reactions,
renderEmojiPicker,
renderReactionPicker,
retryDeleteForEveryone,
Expand Down Expand Up @@ -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,
Expand Down
52 changes: 42 additions & 10 deletions ts/messageModifiers/Reactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<MessageReactionType>;
Expand Down Expand Up @@ -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) {
Expand Down
73 changes: 72 additions & 1 deletion ts/reactions/enqueueReactionForSend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions ts/reactions/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
) {
Expand Down
12 changes: 2 additions & 10 deletions ts/state/selectors/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -405,17 +405,9 @@ 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();
// 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 => {
Expand Down
6 changes: 6 additions & 0 deletions ts/state/smart/Preferences.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -754,6 +758,7 @@ export function SmartPreferences(): JSX.Element | null {
hasAudioNotifications={hasAudioNotifications}
hasAutoConvertEmoji={hasAutoConvertEmoji}
hasAutoDownloadUpdate={hasAutoDownloadUpdate}
hasMultipleEmojiReactions={hasMultipleEmojiReactions}
hasAutoLaunch={hasAutoLaunch}
hasCallNotifications={hasCallNotifications}
hasCallRingtoneNotification={hasCallRingtoneNotification}
Expand Down Expand Up @@ -799,6 +804,7 @@ export function SmartPreferences(): JSX.Element | null {
notificationContent={notificationContent}
onAudioNotificationsChange={onAudioNotificationsChange}
onAutoConvertEmojiChange={onAutoConvertEmojiChange}
onMultipleEmojiReactionsChange={onMultipleEmojiReactionsChange}
onAutoDownloadAttachmentChange={onAutoDownloadAttachmentChange}
onAutoDownloadUpdateChange={onAutoDownloadUpdateChange}
onAutoLaunchChange={onAutoLaunchChange}
Expand Down
1 change: 1 addition & 0 deletions ts/types/Storage.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;
'blocked-uuids': ReadonlyArray<ServiceIdString>;
Expand Down