From 889f20f58c443421f08d70a9dfaa7d3b56996fde Mon Sep 17 00:00:00 2001 From: Francisco Marques Date: Fri, 29 May 2026 00:09:08 +0100 Subject: [PATCH] Feat #572: add emoji reactions to messages (desktop) Users had no way to react to messages with emojis. This feature was listed in the project backlog and had an associated Figma design file that guided the implementation. Reactions are stored as ChannelMessage entries with a new MessageType.Reaction type, requiring no changes to the backend, OrbitDB layer, or socket events. The state-manager aggregates raw reaction entries into grouped state via a new Redux slice and selector. The desktop UI adds a MessageReactionBar component below each message: - Reaction pills with counters and tooltips showing who reacted - Shown on hover when no reactions exist, always visible otherwise - Quick picker with 6 curated emojis and a button for the full picker - Excess reactions beyond 5 collapsed into a "+N" expandable indicator Unit tests were added to verify that reaction messages are correctly extracted from the incoming message pipeline, that non-reaction messages are not affected, and that the toggle (add/remove) logic works as expected. --- CHANGELOG.md | 1 + .../widgets/channels/BasicMessage.tsx | 32 ++- .../widgets/channels/MessageReactionBar.tsx | 233 ++++++++++++++++++ .../src/renderer/testUtils/prepareStore.ts | 2 + packages/state-manager/src/index.ts | 10 + .../addMessages/addMessages.saga.test.ts | 36 +++ .../messages/addMessages/addMessages.saga.ts | 4 +- .../src/sagas/messages/messages.slice.ts | 2 + .../extractReactions.saga.test.ts | 85 +++++++ .../extractReactions/extractReactions.saga.ts | 36 +++ .../sagas/reactions/reactions.master.saga.ts | 25 ++ .../sagas/reactions/reactions.selectors.ts | 47 ++++ .../src/sagas/reactions/reactions.slice.ts | 38 +++ .../sendReaction/sendReaction.saga.test.ts | 103 ++++++++ .../sendReaction/sendReaction.saga.ts | 37 +++ packages/state-manager/src/sagas/reducers.ts | 2 + .../startConnection/startConnection.saga.ts | 2 + .../state-manager/src/sagas/store.keys.ts | 1 + packages/types/src/message.ts | 6 + 19 files changed, 699 insertions(+), 3 deletions(-) create mode 100644 packages/desktop/src/renderer/components/widgets/channels/MessageReactionBar.tsx create mode 100644 packages/state-manager/src/sagas/reactions/extractReactions/extractReactions.saga.test.ts create mode 100644 packages/state-manager/src/sagas/reactions/extractReactions/extractReactions.saga.ts create mode 100644 packages/state-manager/src/sagas/reactions/reactions.master.saga.ts create mode 100644 packages/state-manager/src/sagas/reactions/reactions.selectors.ts create mode 100644 packages/state-manager/src/sagas/reactions/reactions.slice.ts create mode 100644 packages/state-manager/src/sagas/reactions/sendReaction/sendReaction.saga.test.ts create mode 100644 packages/state-manager/src/sagas/reactions/sendReaction/sendReaction.saga.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c79775009f..8cf04f966a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ * Adds private channels with modifiable membership (no removals) to desktop [#3155](https://github.com/TryQuiet/quiet/issues/3155) * Adds private channels with modifiable membership (no removals) to mobile [#3155](https://github.com/TryQuiet/quiet/issues/3155) +* Adds emoji reactions to messages on desktop [#572](https://github.com/TryQuiet/quiet/issues/572) ### Fixes diff --git a/packages/desktop/src/renderer/components/widgets/channels/BasicMessage.tsx b/packages/desktop/src/renderer/components/widgets/channels/BasicMessage.tsx index cc3290304a..f3e15f7294 100644 --- a/packages/desktop/src/renderer/components/widgets/channels/BasicMessage.tsx +++ b/packages/desktop/src/renderer/components/widgets/channels/BasicMessage.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, { useState } from 'react' import { styled } from '@mui/material/styles' import type { Dictionary } from '@reduxjs/toolkit' import classNames from 'classnames' @@ -14,6 +14,7 @@ import ProfilePhoto from '../../ProfilePhoto/ProfilePhoto' import type { DisplayableMessage, DownloadStatus, MessageSendingStatus } from '@quiet/types' import { NestedMessageContent } from './NestedMessageContent' +import MessageReactionBar from './MessageReactionBar' import type { FileActionsProps } from '../../Channel/File/FileComponent/FileComponent' @@ -172,6 +173,33 @@ export interface BasicMessageProps { duplicatedUsernameModalHandleOpen: HandleOpenModalType } +interface MessageWithReactionsProps extends FileActionsProps { + message: DisplayableMessage + pending: boolean + downloadStatus?: DownloadStatus + maxAutodownloadSizeBytes: number + uploadedFileModal?: UseModalType<{ src: string }> + onMathMessageRendered?: () => void + openUrl: (url: string) => void +} + +const MessageWithReactions: React.FC = ({ message, pending, downloadStatus, ...rest }) => { + const [hovered, setHovered] = useState(false) + + return ( +
{ + e.stopPropagation() + setHovered(true) + }} + onMouseLeave={() => setHovered(false)} + > + + +
+ ) +} + export const BasicMessageComponent: React.FC = ({ messages, pendingMessages = {}, @@ -270,7 +298,7 @@ export const BasicMessageComponent: React.FC ({ + [`& .${classes.bar}`]: { + display: 'flex', + flexWrap: 'wrap', + gap: '4px', + marginTop: '4px', + }, + [`& .${classes.pill}`]: { + display: 'inline-flex', + alignItems: 'center', + gap: '4px', + padding: '2px 8px', + borderRadius: '12px', + border: `1px solid ${theme.palette.divider}`, + background: theme.palette.background.paper, + cursor: 'pointer', + fontSize: '14px', + '&:hover': { + background: theme.palette.action.hover, + }, + }, + [`& .${classes.pillActive}`]: { + border: `1px solid ${theme.palette.primary.main}`, + background: `${theme.palette.primary.main}22`, + }, + [`& .${classes.addBtn}`]: { + display: 'inline-flex', + alignItems: 'center', + padding: '2px 8px', + borderRadius: '12px', + border: `1px solid ${theme.palette.divider}`, + background: 'transparent', + cursor: 'pointer', + fontSize: '14px', + color: theme.palette.text.secondary, + '&:hover': { + background: theme.palette.action.hover, + }, + }, + [`& .${classes.quickPicker}`]: { + position: 'absolute', + bottom: '100%', + display: 'flex', + gap: '4px', + padding: '8px', + borderRadius: '8px', + background: theme.palette.background.paper, + border: `1px solid ${theme.palette.divider}`, + boxShadow: theme.shadows[4], + zIndex: 1000, + }, + [`& .${classes.hiddenPicker}`]: { + position: 'absolute', + bottom: '100%', + left: 0, + display: 'flex', + gap: '4px', + padding: '8px', + borderRadius: '8px', + background: theme.palette.background.paper, + border: `1px solid ${theme.palette.divider}`, + boxShadow: theme.shadows[4], + zIndex: 1000, + }, + [`& .${classes.quickEmoji}`]: { + fontSize: '20px', + padding: '4px', + cursor: 'pointer', + background: 'transparent', + border: 'none', + borderRadius: '4px', + '&:hover': { + background: theme.palette.action.hover, + }, + }, + [`& .${classes.picker}`]: { + position: 'fixed', + bottom: 60, + right: 15, + zIndex: 1000, + }, +})) + +interface Props { + messageId: string + hovered: boolean +} + +export const MessageReactionBar: React.FC = ({ messageId, hovered }) => { + const dispatch = useDispatch() + const [quickPickerOpen, setQuickPickerOpen] = useState(false) + const [fullPickerOpen, setFullPickerOpen] = useState(false) + const [showHidden, setShowHidden] = useState(false) + const [pickerAlign, setPickerAlign] = useState<'left' | 'right'>('left') + const addBtnRef = useRef(null) + const groups = useSelector(reactions.selectors.selectReactionsForMessage(messageId)) + const channelId = useSelector(publicChannels.selectors.currentChannelId) + const theme = useTheme() + + useEffect(() => { + if (!hovered) { + setShowHidden(false) + } + }, [hovered]) + + const react = (emoji: string) => { + if (!channelId) return + dispatch(reactions.actions.sendReaction({ targetMessageId: messageId, emoji, channelId })) + setQuickPickerOpen(false) + setFullPickerOpen(false) + setShowHidden(false) + } + + const closeAll = () => { + setQuickPickerOpen(false) + setFullPickerOpen(false) + } + + const handleOpenQuickPicker = () => { + if (addBtnRef.current) { + const rect = addBtnRef.current.getBoundingClientRect() + const spaceOnRight = window.innerWidth - rect.left + setPickerAlign(spaceOnRight < 300 ? 'right' : 'left') + } + setQuickPickerOpen(v => !v) + } + + const MAX_VISIBLE_REACTIONS = 5 + const visibleGroups = groups.slice(0, MAX_VISIBLE_REACTIONS) + const hiddenGroups = groups.slice(MAX_VISIBLE_REACTIONS) + + const showAddButton = hovered || groups.length > 0 + + return ( + +
+ {visibleGroups.map(group => ( + + + + ))} + {hiddenGroups.length > 0 && !showHidden && ( + + )} + {showHidden && + hiddenGroups.map(group => ( + + + + ))} + {showAddButton && ( + +
+ + + + {quickPickerOpen && ( +
+ {QUICK_REACTIONS.map(emoji => ( + + ))} + + + +
+ )} + {fullPickerOpen && ( +
+ react(emojiData.emoji)} + emojiStyle={EmojiStyle.NATIVE} + theme={theme.palette.mode as Theme} + /> +
+ )} +
+
+ )} +
+
+ ) +} + +export default MessageReactionBar diff --git a/packages/desktop/src/renderer/testUtils/prepareStore.ts b/packages/desktop/src/renderer/testUtils/prepareStore.ts index 29d644c21a..9be04329d8 100644 --- a/packages/desktop/src/renderer/testUtils/prepareStore.ts +++ b/packages/desktop/src/renderer/testUtils/prepareStore.ts @@ -12,6 +12,7 @@ import { Store, network, captcha, + reactions, } from '@quiet/state-manager' import { StoreKeys } from '../store/store.keys' import { combineReducers, createStore, applyMiddleware } from 'redux' @@ -37,6 +38,7 @@ export const testReducers = { [StateManagerStoreKeys.Files]: files.reducer, [StateManagerStoreKeys.Network]: network.reducer, [StateManagerStoreKeys.Captcha]: captcha.reducer, + [StateManagerStoreKeys.Reactions]: reactions.reducer, [StoreKeys.App]: appReducer, [StoreKeys.Socket]: socketReducer, [StoreKeys.Modals]: modalsReducer, diff --git a/packages/state-manager/src/index.ts b/packages/state-manager/src/index.ts index 2a3c6866c5..d97d5b6cd8 100644 --- a/packages/state-manager/src/index.ts +++ b/packages/state-manager/src/index.ts @@ -25,6 +25,9 @@ import { messagesActions, messagesReducer } from './sagas/messages/messages.slic import { messagesSelectors } from './sagas/messages/messages.selectors' +import { reactionsReducer, reactionsActions } from './sagas/reactions/reactions.slice' +import { selectReactionsForMessage } from './sagas/reactions/reactions.selectors' + import { errorsSelectors } from './sagas/errors/errors.selectors' import { errorsReducer, errorsActions } from './sagas/errors/errors.slice' @@ -129,6 +132,12 @@ export const messages = { selectors: messagesSelectors, } +export const reactions = { + reducer: reactionsReducer, + actions: reactionsActions, + selectors: { selectReactionsForMessage }, +} + export const errors = { reducer: errorsReducer, actions: errorsActions, @@ -183,6 +192,7 @@ export default { users, identity, messages, + reactions, errors, communities, connection, diff --git a/packages/state-manager/src/sagas/messages/addMessages/addMessages.saga.test.ts b/packages/state-manager/src/sagas/messages/addMessages/addMessages.saga.test.ts index a3b3d2e7ed..4027f9730f 100644 --- a/packages/state-manager/src/sagas/messages/addMessages/addMessages.saga.test.ts +++ b/packages/state-manager/src/sagas/messages/addMessages/addMessages.saga.test.ts @@ -537,4 +537,40 @@ describe('addMessagesSaga', () => { ) .run() }) + + test('ignore reaction messages', async () => { + const reactionMessage = ( + await factory.build('TestMessage', { + identity: alice, + message: { + id: Math.random().toString(36).substr(2.9), + type: MessageType.Reaction, + message: JSON.stringify({ targetMessageId: 'some-id', emoji: '👍', action: 'add' }), + createdAt: DateTime.utc().valueOf(), + channelId: generalChannel.id, + signature: '', + pubKey: '', + }, + verifyAutomatically: true, + }) + ).payload.message + + store.dispatch( + publicChannelsActions.setCurrentChannel({ + channelId: generalChannel.id, + }) + ) + + const reducer = combineReducers(testReducers) + await expectSaga( + addMessagesSaga, + messagesActions.addMessages({ + messages: [reactionMessage], + }) + ) + .withReducer(reducer) + .withState(store.getState()) + .not.put.actionType(publicChannelsActions.cacheMessages.type) + .run() + }) }) diff --git a/packages/state-manager/src/sagas/messages/addMessages/addMessages.saga.ts b/packages/state-manager/src/sagas/messages/addMessages/addMessages.saga.ts index 9b5190355c..9edfa68555 100644 --- a/packages/state-manager/src/sagas/messages/addMessages/addMessages.saga.ts +++ b/packages/state-manager/src/sagas/messages/addMessages/addMessages.saga.ts @@ -4,7 +4,7 @@ import { messagesActions } from '../messages.slice' import { messagesSelectors } from '../messages.selectors' import { publicChannelsSelectors } from '../../publicChannels/publicChannels.selectors' import { publicChannelsActions } from '../../publicChannels/publicChannels.slice' -import { type CacheMessagesPayload, type ChannelMessage } from '@quiet/types' +import { type CacheMessagesPayload, type ChannelMessage, MessageType } from '@quiet/types' import { createLogger } from '../../../utils/logger' const logger = createLogger('addMessagesSaga') @@ -13,6 +13,8 @@ export function* addMessagesSaga( action: PayloadAction['payload']> ): Generator { for (const incomingMessage of action.payload.messages) { + if (incomingMessage.type === MessageType.Reaction) continue + // Proceed only for messages from current channel // TODO: do we still need this check? const currentChannelId = yield* select(publicChannelsSelectors.currentChannelId) diff --git a/packages/state-manager/src/sagas/messages/messages.slice.ts b/packages/state-manager/src/sagas/messages/messages.slice.ts index b66445b7cb..ffc4ec936c 100644 --- a/packages/state-manager/src/sagas/messages/messages.slice.ts +++ b/packages/state-manager/src/sagas/messages/messages.slice.ts @@ -23,6 +23,7 @@ import { type WriteMessagePayload, MessageSendingStatusPayload, VerifyMessagesPayload, + MessageType, } from '@quiet/types' import { createLogger } from '../../utils/logger' @@ -82,6 +83,7 @@ export const messagesSlice = createSlice({ addMessages: (state, action: PayloadAction) => { const { messages } = action.payload for (const message of messages) { + if (message.type === MessageType.Reaction) continue if (!instanceOfChannelMessage(message)) { continue } diff --git a/packages/state-manager/src/sagas/reactions/extractReactions/extractReactions.saga.test.ts b/packages/state-manager/src/sagas/reactions/extractReactions/extractReactions.saga.test.ts new file mode 100644 index 0000000000..f7cc325de9 --- /dev/null +++ b/packages/state-manager/src/sagas/reactions/extractReactions/extractReactions.saga.test.ts @@ -0,0 +1,85 @@ +import { expectSaga } from 'redux-saga-test-plan' +import { combineReducers } from 'redux' +import { prepareStore, testReducers } from '../../../utils/tests/prepareStore' +import { extractReactionsSaga } from './extractReactions.saga' +import { messagesActions } from '../../messages/messages.slice' +import { reactionsActions } from '../reactions.slice' +import { MessageType } from '@quiet/types' +import { DateTime } from 'luxon' + +describe('extractReactionsSaga', () => { + test('extracts reaction entries from incoming messages', async () => { + const targetMessageId = 'target-message-id' + const emoji = '👍' + const userId = 'user-123' + + const reactionMessage = { + id: 'reaction-msg-id', + type: MessageType.Reaction, + message: JSON.stringify({ targetMessageId, emoji, action: 'add' }), + createdAt: DateTime.utc().valueOf(), + channelId: 'general', + userId, + } + + const { store } = prepareStore() + const reducer = combineReducers(testReducers) + + await expectSaga(extractReactionsSaga, messagesActions.addMessages({ messages: [reactionMessage as any] })) + .withReducer(reducer) + .withState(store.getState()) + .put( + reactionsActions.addReactionEntries([ + { + id: reactionMessage.id, + targetMessageId, + emoji, + action: 'add', + userId, + createdAt: reactionMessage.createdAt, + }, + ]) + ) + .run() + }) + + test('ignores non-reaction messages', async () => { + const normalMessage = { + id: 'normal-msg-id', + type: MessageType.Basic, + message: 'hello', + createdAt: DateTime.utc().valueOf(), + channelId: 'general', + userId: 'user-123', + } + + const { store } = prepareStore() + const reducer = combineReducers(testReducers) + + await expectSaga(extractReactionsSaga, messagesActions.addMessages({ messages: [normalMessage as any] })) + .withReducer(reducer) + .withState(store.getState()) + .not.put.actionType(reactionsActions.addReactionEntries.type) + .run() + }) + + test('handles malformed reaction payload gracefully', async () => { + const malformedMessage = { + id: 'bad-reaction-id', + type: MessageType.Reaction, + message: 'not-valid-json{{{', + createdAt: DateTime.utc().valueOf(), + channelId: 'general', + userId: 'user-123', + } + + const { store } = prepareStore() + const reducer = combineReducers(testReducers) + + await expectSaga(extractReactionsSaga, messagesActions.addMessages({ messages: [malformedMessage as any] })) + .withReducer(reducer) + .withState(store.getState()) + .not.put.actionType(reactionsActions.addReactionEntries.type) + .run() + }) +}) diff --git a/packages/state-manager/src/sagas/reactions/extractReactions/extractReactions.saga.ts b/packages/state-manager/src/sagas/reactions/extractReactions/extractReactions.saga.ts new file mode 100644 index 0000000000..a9685e6c4c --- /dev/null +++ b/packages/state-manager/src/sagas/reactions/extractReactions/extractReactions.saga.ts @@ -0,0 +1,36 @@ +import { put } from 'typed-redux-saga' +import { type PayloadAction } from '@reduxjs/toolkit' +import { type MessagesLoadedPayload, MessageType, type ReactionPayload } from '@quiet/types' +import { reactionsActions } from '../reactions.slice' +import { createLogger } from '../../../utils/logger' + +const logger = createLogger('extractReactionsSaga') + +export function* extractReactionsSaga(action: PayloadAction): Generator { + const reactionMessages = action.payload.messages.filter(m => m.type === MessageType.Reaction) + + if (reactionMessages.length === 0) return + + const entries = reactionMessages.flatMap(m => { + try { + const payload: ReactionPayload = JSON.parse(m.message) + return [ + { + id: m.id, + targetMessageId: payload.targetMessageId, + emoji: payload.emoji, + action: payload.action, + userId: m.userId, + createdAt: m.createdAt, + }, + ] + } catch (e) { + logger.error('Failed to parse reaction message', m.id, e) + return [] + } + }) + + if (entries.length > 0) { + yield* put(reactionsActions.addReactionEntries(entries)) + } +} diff --git a/packages/state-manager/src/sagas/reactions/reactions.master.saga.ts b/packages/state-manager/src/sagas/reactions/reactions.master.saga.ts new file mode 100644 index 0000000000..50992b19ee --- /dev/null +++ b/packages/state-manager/src/sagas/reactions/reactions.master.saga.ts @@ -0,0 +1,25 @@ +import { all, takeEvery, cancelled } from 'typed-redux-saga' +import { type Socket } from '../../types' +import { reactionsActions } from './reactions.slice' +import { messagesActions } from '../messages/messages.slice' +import { sendReactionSaga } from './sendReaction/sendReaction.saga' +import { extractReactionsSaga } from './extractReactions/extractReactions.saga' +import { createLogger } from '../../utils/logger' + +const logger = createLogger('reactionsMasterSaga') + +export function* reactionsMasterSaga(socket: Socket): Generator { + logger.info('reactionsMasterSaga starting') + try { + yield all([ + takeEvery(reactionsActions.sendReaction.type, sendReactionSaga, socket), + // Intercept incoming messages and extract reaction entries + takeEvery(messagesActions.addMessages.type, extractReactionsSaga), + ]) + } finally { + logger.info('reactionsMasterSaga stopping') + if (yield cancelled()) { + logger.info('reactionsMasterSaga cancelled') + } + } +} diff --git a/packages/state-manager/src/sagas/reactions/reactions.selectors.ts b/packages/state-manager/src/sagas/reactions/reactions.selectors.ts new file mode 100644 index 0000000000..7a3b68b1e8 --- /dev/null +++ b/packages/state-manager/src/sagas/reactions/reactions.selectors.ts @@ -0,0 +1,47 @@ +import { createSelector } from '@reduxjs/toolkit' +import { type StoreState } from '../store.types' +import { reactionsEntitySelectors } from './reactions.slice' +import { identitySelectors } from '../identity/identity.selectors' +import { userProfileSelectors } from '../users/userProfile/userProfile.selectors' + +export interface ReactionGroup { + emoji: string + count: number + nicknames: string[] + reacted: boolean +} + +const selectAllEntries = (state: StoreState) => reactionsEntitySelectors.selectAll(state.Reactions.reactions) + +export const selectReactionsForMessage = (targetMessageId: string) => + createSelector( + selectAllEntries, + identitySelectors.currentIdentity, + userProfileSelectors.userProfiles, + (entries, identity, userProfiles): ReactionGroup[] => { + const forMessage = entries + .filter(e => e.targetMessageId === targetMessageId) + .sort((a, b) => a.createdAt - b.createdAt) + + const lastAction: Record> = {} + for (const entry of forMessage) { + if (!lastAction[entry.emoji]) lastAction[entry.emoji] = {} + lastAction[entry.emoji][entry.userId] = entry.action + } + + return Object.entries(lastAction) + .map(([emoji, userActions]) => { + const activeUserIds = Object.entries(userActions) + .filter(([, action]) => action === 'add') + .map(([userId]) => userId) + return { + emoji, + count: activeUserIds.length, + nicknames: activeUserIds.map(id => userProfiles[id]?.nickname ?? id), + userIds: activeUserIds, + reacted: activeUserIds.includes(identity?.userId ?? ''), + } + }) + .filter(g => g.count > 0) + } + ) diff --git a/packages/state-manager/src/sagas/reactions/reactions.slice.ts b/packages/state-manager/src/sagas/reactions/reactions.slice.ts new file mode 100644 index 0000000000..299e4903a7 --- /dev/null +++ b/packages/state-manager/src/sagas/reactions/reactions.slice.ts @@ -0,0 +1,38 @@ +import { createSlice, createEntityAdapter, type PayloadAction } from '@reduxjs/toolkit' +import { StoreKeys } from '../store.keys' + +export interface ReactionEntry { + id: string + targetMessageId: string + emoji: string + action: 'add' | 'remove' + userId: string + createdAt: number +} + +export interface SendReactionPayload { + targetMessageId: string + emoji: string + channelId: string +} + +const reactionsAdapter = createEntityAdapter() + +export class ReactionsState { + public reactions = reactionsAdapter.getInitialState() +} + +export const reactionsSlice = createSlice({ + name: StoreKeys.Reactions, + initialState: { ...new ReactionsState() }, + reducers: { + sendReaction: (state, _action: PayloadAction) => state, + addReactionEntries: (state, action: PayloadAction) => { + reactionsAdapter.upsertMany(state.reactions, action.payload) + }, + }, +}) + +export const reactionsActions = reactionsSlice.actions +export const reactionsReducer = reactionsSlice.reducer +export const reactionsEntitySelectors = reactionsAdapter.getSelectors() diff --git a/packages/state-manager/src/sagas/reactions/sendReaction/sendReaction.saga.test.ts b/packages/state-manager/src/sagas/reactions/sendReaction/sendReaction.saga.test.ts new file mode 100644 index 0000000000..5e2c2a4cc5 --- /dev/null +++ b/packages/state-manager/src/sagas/reactions/sendReaction/sendReaction.saga.test.ts @@ -0,0 +1,103 @@ +import { setupCrypto } from '@quiet/identity' +import { type FactoryGirl } from 'factory-girl' +import { expectSaga } from 'redux-saga-test-plan' +import { combineReducers } from 'redux' +import { prepareStore, testReducers } from '../../../utils/tests/prepareStore' +import { getReduxStoreFactory } from '../../../utils/tests/factories' +import { sendReactionSaga } from './sendReaction.saga' +import { reactionsActions } from '../reactions.slice' +import { messagesActions } from '../../messages/messages.slice' +import { MessageType } from '@quiet/types' +import { type Store } from 'redux' +import { type Community, type Identity } from '@quiet/types' +import { type communitiesActions } from '../../communities/communities.slice' +import { type identityActions } from '../../identity/identity.slice' +import { publicChannelsSelectors } from '../../publicChannels/publicChannels.selectors' +import { type PublicChannel } from '@quiet/types' + +describe('sendReactionSaga', () => { + let store: Store + let factory: FactoryGirl + let community: Community + let alice: Identity + let generalChannel: PublicChannel + + beforeAll(async () => { + setupCrypto() + store = prepareStore().store + factory = await getReduxStoreFactory(store) + community = await factory.create['payload']>('Community') + alice = await factory.create('Identity', { + communityId: community.id, + nickname: 'alice', + }) + const generalChannelState = publicChannelsSelectors.generalChannel(store.getState()) + if (generalChannelState) generalChannel = generalChannelState + }) + + test('dispatches sendMessage with reaction payload on add', async () => { + const targetMessageId = 'target-msg-123' + const emoji = '👍' + + const reducer = combineReducers(testReducers) + await expectSaga( + sendReactionSaga, + {} as any, // socket not needed — sendReactionSaga only dispatches to messagesActions + reactionsActions.sendReaction({ + targetMessageId, + emoji, + channelId: generalChannel.id, + }) + ) + .withReducer(reducer) + .withState(store.getState()) + .put( + messagesActions.sendMessage({ + message: JSON.stringify({ targetMessageId, emoji, action: 'add' }), + type: MessageType.Reaction, + channelId: generalChannel.id, + }) + ) + .run() + }) + + test('dispatches sendMessage with remove action when already reacted', async () => { + const targetMessageId = 'target-msg-456' + const emoji = '😄' + + // Pre-populate store with an existing 'add' reaction entry + store.dispatch( + reactionsActions.addReactionEntries([ + { + id: 'existing-reaction-id', + targetMessageId, + emoji, + action: 'add', + userId: alice.userId, + createdAt: Date.now() - 1000, + }, + ]) + ) + + const reducer = combineReducers(testReducers) + await expectSaga( + sendReactionSaga, + {} as any, + reactionsActions.sendReaction({ + targetMessageId, + emoji, + channelId: generalChannel.id, + }) + ) + .withReducer(reducer) + .withState(store.getState()) + .put( + messagesActions.sendMessage({ + message: JSON.stringify({ targetMessageId, emoji, action: 'remove' }), + type: MessageType.Reaction, + channelId: generalChannel.id, + }) + ) + .run() + }) +}) diff --git a/packages/state-manager/src/sagas/reactions/sendReaction/sendReaction.saga.ts b/packages/state-manager/src/sagas/reactions/sendReaction/sendReaction.saga.ts new file mode 100644 index 0000000000..603765884f --- /dev/null +++ b/packages/state-manager/src/sagas/reactions/sendReaction/sendReaction.saga.ts @@ -0,0 +1,37 @@ +import { select, put } from 'typed-redux-saga' +import { type PayloadAction } from '@reduxjs/toolkit' +import { type Socket } from '../../../types' +import { type StoreState } from '../../store.types' +import { messagesActions } from '../../messages/messages.slice' +import { reactionsEntitySelectors, type SendReactionPayload } from '../reactions.slice' +import { identitySelectors } from '../../identity/identity.selectors' +import { MessageType, type ReactionPayload } from '@quiet/types' + +export function* sendReactionSaga(socket: Socket, action: PayloadAction): Generator { + const { targetMessageId, emoji, channelId } = action.payload + + const identity = yield* select(identitySelectors.currentIdentity) + if (!identity) return + + const allEntries = yield* select((state: StoreState) => reactionsEntitySelectors.selectAll(state.Reactions.reactions)) + + const lastMyEntry = allEntries + .filter(e => e.targetMessageId === targetMessageId && e.emoji === emoji && e.userId === identity.userId) + .sort((a, b) => b.createdAt - a.createdAt)[0] + + const reactionAction: 'add' | 'remove' = lastMyEntry?.action === 'add' ? 'remove' : 'add' + + const reactionPayload: ReactionPayload = { + targetMessageId, + emoji, + action: reactionAction, + } + + yield* put( + messagesActions.sendMessage({ + message: JSON.stringify(reactionPayload), + type: MessageType.Reaction, + channelId, + }) + ) +} diff --git a/packages/state-manager/src/sagas/reducers.ts b/packages/state-manager/src/sagas/reducers.ts index 40214d508d..da4f8efac7 100644 --- a/packages/state-manager/src/sagas/reducers.ts +++ b/packages/state-manager/src/sagas/reducers.ts @@ -10,6 +10,7 @@ import { settingsReducer } from './settings/settings.slice' import { StoreKeys } from './store.keys' import { usersReducer } from './users/users.slice' import { captchaReducer } from './captcha/captcha.slice' +import { reactionsReducer } from './reactions/reactions.slice' export const reducers = { [StoreKeys.PublicChannels]: publicChannelsReducer, @@ -23,4 +24,5 @@ export const reducers = { [StoreKeys.Files]: filesReducer, [StoreKeys.Network]: networkReducer, [StoreKeys.Captcha]: captchaReducer, + [StoreKeys.Reactions]: reactionsReducer, } diff --git a/packages/state-manager/src/sagas/socket/startConnection/startConnection.saga.ts b/packages/state-manager/src/sagas/socket/startConnection/startConnection.saga.ts index 7781592de2..9959311b5c 100644 --- a/packages/state-manager/src/sagas/socket/startConnection/startConnection.saga.ts +++ b/packages/state-manager/src/sagas/socket/startConnection/startConnection.saga.ts @@ -12,6 +12,7 @@ import { errorsActions } from '../../errors/errors.slice' import { identityMasterSaga } from '../../identity/identity.master.saga' import { identityActions } from '../../identity/identity.slice' import { messagesMasterSaga } from '../../messages/messages.master.saga' +import { reactionsMasterSaga } from '../../reactions/reactions.master.saga' import { filesMasterSaga } from '../../files/files.master.saga' import { messagesActions } from '../../messages/messages.slice' import { publicChannelsMasterSaga } from '../../publicChannels/publicChannels.master.saga' @@ -293,6 +294,7 @@ export function* useIO(socket: Socket): Generator { fork(errorsMasterSaga), fork(captchaMasterSaga, socket), fork(pushNotificationsMasterSaga, socket), + fork(reactionsMasterSaga, socket), ]) } finally { logger.info('useIO stopping') diff --git a/packages/state-manager/src/sagas/store.keys.ts b/packages/state-manager/src/sagas/store.keys.ts index d7a24471f4..a77c1cb1cd 100644 --- a/packages/state-manager/src/sagas/store.keys.ts +++ b/packages/state-manager/src/sagas/store.keys.ts @@ -16,6 +16,7 @@ export enum StoreKeys { Settings = 'Settings', Files = 'Files', PushNotifications = 'PushNotifications', + Reactions = 'Reactions', // For testing purposes LastAction = 'LastAction', CollectData = 'CollectData', diff --git a/packages/types/src/message.ts b/packages/types/src/message.ts index 295d5916b3..87a0821404 100644 --- a/packages/types/src/message.ts +++ b/packages/types/src/message.ts @@ -9,6 +9,7 @@ export enum MessageType { Image = 2, Info = 3, File = 4, + Reaction = 5, } export enum SendingStatus { @@ -98,3 +99,8 @@ export interface TestMessage { identity: Identity verifyAutomatically: boolean } +export interface ReactionPayload { + targetMessageId: string + emoji: string + action: 'add' | 'remove' +}