diff --git a/CHANGELOG.md b/CHANGELOG.md index f762ef469a..fb42bdd361 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,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) * Private channel creation and modification is limited to admins [#3256](https://github.com/TryQuiet/quiet/issues/3256) ### 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 3ea54824bb..728e0651c4 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' @@ -286,6 +287,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 11799e87cf..fb78b82214 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 { @@ -99,3 +100,8 @@ export interface TestMessage { identity: Identity verifyAutomatically: boolean } +export interface ReactionPayload { + targetMessageId: string + emoji: string + action: 'add' | 'remove' +}