[MM-42586] Reaction toggle behavior (#25412)

* add toggle_reaction action

* change to use toggle_reaction action instead of add_reaction

* add submit_reaction action

* add a selector to check if a reaction has already been added

* update test

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
KyeongSoo Kim 2023-11-17 20:07:03 +09:00 committed by GitHub
parent 2184876c77
commit 34ce0d00d4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 210 additions and 75 deletions

View File

@ -12,10 +12,12 @@ import * as Actions from 'actions/post_actions';
import mockStore from 'tests/test_store';
import {Constants, ActionTypes, RHSStates} from 'utils/constants';
import * as PostUtils from 'utils/post_utils';
import type {GlobalState} from 'types/store';
jest.mock('mattermost-redux/actions/posts', () => ({
removeReaction: (...args: any[]) => ({type: 'MOCK_REMOVE_REACTION', args}),
addReaction: (...args: any[]) => ({type: 'MOCK_ADD_REACTION', args}),
createPost: (...args: any[]) => ({type: 'MOCK_CREATE_POST', args}),
createPostImmediately: (...args: any[]) => ({type: 'MOCK_CREATE_POST_IMMEDIATELY', args}),
@ -48,6 +50,8 @@ jest.mock('utils/user_agent', () => ({
isDesktopApp: jest.fn().mockReturnValue(false),
}));
const mockMakeGetIsReactionAlreadyAddedToPost = jest.spyOn(PostUtils, 'makeGetIsReactionAlreadyAddedToPost');
const POST_CREATED_TIME = Date.now();
// This mocks the Date.now() function so it returns a constant value
@ -433,6 +437,84 @@ describe('Actions.Posts', () => {
});
});
describe('submitReaction', () => {
describe('addReaction', () => {
test('should add reaction when the action is + and the reaction is not added', async () => {
const testStore = mockStore(initialState);
mockMakeGetIsReactionAlreadyAddedToPost.mockReturnValueOnce(() => false);
testStore.dispatch(Actions.submitReaction('post_id_1', '+', 'emoji_name_1'));
expect(testStore.getActions()).toEqual([
{args: ['post_id_1', 'emoji_name_1'], type: 'MOCK_ADD_REACTION'},
{args: ['emoji_name_1'], type: 'MOCK_ADD_RECENT_EMOJI'},
]);
});
test('should take no action when the action is + and the reaction has already been added', async () => {
const testStore = mockStore(initialState);
mockMakeGetIsReactionAlreadyAddedToPost.mockReturnValueOnce(() => true);
testStore.dispatch(Actions.submitReaction('post_id_1', '+', 'emoji_name_1'));
expect(testStore.getActions()).toEqual([]);
});
});
describe('removeReaction', () => {
test('should remove reaction when the action is - and the reaction has already been added', async () => {
const testStore = mockStore(initialState);
mockMakeGetIsReactionAlreadyAddedToPost.mockReturnValueOnce(() => true);
testStore.dispatch(Actions.submitReaction('post_id_1', '-', 'emoji_name_1'));
expect(testStore.getActions()).toEqual([
{args: ['post_id_1', 'emoji_name_1'], type: 'MOCK_REMOVE_REACTION'},
]);
});
test('should take no action when the action is - and the reaction is not added', async () => {
const testStore = mockStore(initialState);
mockMakeGetIsReactionAlreadyAddedToPost.mockReturnValueOnce(() => false);
testStore.dispatch(Actions.submitReaction('post_id_1', '-', 'emoji_name_1'));
expect(testStore.getActions()).toEqual([]);
});
});
});
describe('toggleReaction', () => {
test('should add reaction when the reaction is not added', async () => {
const testStore = mockStore(initialState);
mockMakeGetIsReactionAlreadyAddedToPost.mockReturnValueOnce(() => false);
testStore.dispatch(Actions.toggleReaction('post_id_1', 'emoji_name_1'));
expect(testStore.getActions()).toEqual([
{args: ['post_id_1', 'emoji_name_1'], type: 'MOCK_ADD_REACTION'},
{args: ['emoji_name_1'], type: 'MOCK_ADD_RECENT_EMOJI'},
]);
});
test('should remove reaction when the reaction has already been added', async () => {
const testStore = mockStore(initialState);
mockMakeGetIsReactionAlreadyAddedToPost.mockReturnValueOnce(() => true);
testStore.dispatch(Actions.toggleReaction('post_id_1', 'emoji_name_1'));
expect(testStore.getActions()).toEqual([
{args: ['post_id_1', 'emoji_name_1'], type: 'MOCK_REMOVE_REACTION'},
]);
});
});
test('addReaction', async () => {
const testStore = mockStore(initialState);

View File

@ -34,6 +34,7 @@ import {
StoragePrefixes,
} from 'utils/constants';
import {matchEmoticons} from 'utils/emoticons';
import {makeGetIsReactionAlreadyAddedToPost} from 'utils/post_utils';
import * as UserAgent from 'utils/user_agent';
import type {GlobalState} from 'types/store';
@ -140,6 +141,36 @@ function storeCommentDraft(rootPostId: string, draft: null) {
};
}
export function submitReaction(postId: string, action: string, emojiName: string) {
return (dispatch: DispatchFunc, getState: GetStateFunc) => {
const state = getState() as GlobalState;
const getIsReactionAlreadyAddedToPost = makeGetIsReactionAlreadyAddedToPost();
const isReactionAlreadyAddedToPost = getIsReactionAlreadyAddedToPost(state, postId, emojiName);
if (action === '+' && !isReactionAlreadyAddedToPost) {
dispatch(addReaction(postId, emojiName));
} else if (action === '-' && isReactionAlreadyAddedToPost) {
dispatch(PostActions.removeReaction(postId, emojiName));
}
return {data: true};
};
}
export function toggleReaction(postId: string, emojiName: string) {
return (dispatch: DispatchFunc, getState: GetStateFunc) => {
const state = getState() as GlobalState;
const getIsReactionAlreadyAddedToPost = makeGetIsReactionAlreadyAddedToPost();
const isReactionAlreadyAddedToPost = getIsReactionAlreadyAddedToPost(state, postId, emojiName);
if (isReactionAlreadyAddedToPost) {
return dispatch(PostActions.removeReaction(postId, emojiName));
}
return dispatch(addReaction(postId, emojiName));
};
}
export function addReaction(postId: string, emojiName: string) {
return (dispatch: DispatchFunc) => {
dispatch(PostActions.addReaction(postId, emojiName));

View File

@ -2,7 +2,6 @@
// See LICENSE.txt for license information.
import {
removeReaction,
addMessageIntoHistory,
moveHistoryIndexBack,
} from 'mattermost-redux/actions/posts';
@ -17,7 +16,6 @@ import {
updateCommentDraft,
makeOnMoveHistoryIndex,
submitPost,
submitReaction,
submitCommand,
makeOnSubmit,
makeOnEditLatestPost,
@ -63,6 +61,7 @@ jest.mock('actions/hooks', () => ({
}));
jest.mock('actions/post_actions', () => ({
submitReaction: (...args) => ({type: 'MOCK_SUBMIT_REACTION', args}),
addReaction: (...args) => ({type: 'MOCK_ADD_REACTION', args}),
createPost: jest.fn(() => ({type: 'MOCK_CREATE_POST'})),
setEditingPost: (...args) => ({type: 'MOCK_SET_EDITING_POST', args}),
@ -293,25 +292,6 @@ describe('rhs view actions', () => {
});
});
describe('submitReaction', () => {
test('it adds a reaction when action is +', () => {
store.dispatch(submitReaction('post_id_1', '+', 'emoji_name_1'));
const testStore = mockStore(initialState);
testStore.dispatch(PostActions.addReaction('post_id_1', 'emoji_name_1'));
expect(store.getActions()).toEqual(testStore.getActions());
});
test('it removes a reaction when action is -', () => {
store.dispatch(submitReaction('post_id_1', '-', 'emoji_name_1'));
const testStore = mockStore(initialState);
testStore.dispatch(removeReaction('post_id_1', 'emoji_name_1'));
expect(store.getActions()).toEqual(testStore.getActions());
});
});
describe('submitCommand', () => {
const args = {
channel_id: channelId,
@ -400,7 +380,7 @@ describe('rhs view actions', () => {
}));
const testStore = mockStore(initialState);
testStore.dispatch(submitReaction(latestPostId, '+', 'smile'));
testStore.dispatch(PostActions.submitReaction(latestPostId, '+', 'smile'));
expect(store.getActions()).toEqual(
expect.arrayContaining(testStore.getActions()),

View File

@ -4,7 +4,6 @@
import type {Post} from '@mattermost/types/posts';
import {
removeReaction,
addMessageIntoHistory,
moveHistoryIndexBack,
moveHistoryIndexForward,
@ -106,17 +105,6 @@ export function submitPost(channelId: string, rootId: string, draft: PostDraft)
};
}
export function submitReaction(postId: string, action: string, emojiName: string) {
return (dispatch: DispatchFunc) => {
if (action === '+') {
dispatch(PostActions.addReaction(postId, emojiName));
} else if (action === '-') {
dispatch(removeReaction(postId, emojiName));
}
return {data: true};
};
}
export function submitCommand(channelId: string, rootId: string, draft: PostDraft) {
return async (dispatch: DispatchFunc, getState: GetStateFunc) => {
const state = getState();
@ -170,7 +158,7 @@ export function makeOnSubmit(channelId: string, rootId: string, latestPostId: st
const emojiMap = new EmojiMap(emojis);
if (isReaction && emojiMap.has(isReaction[2])) {
dispatch(submitReaction(latestPostId, isReaction[1], isReaction[2]));
dispatch(PostActions.submitReaction(latestPostId, isReaction[1], isReaction[2]));
} else if (message.indexOf('/') === 0 && !options.ignoreSlash) {
try {
await dispatch(submitCommand(channelId, rootId, draft));

View File

@ -76,6 +76,7 @@ const baseProp: Props = {
addMessageIntoHistory: jest.fn(),
moveHistoryIndexBack: jest.fn(),
moveHistoryIndexForward: jest.fn(),
submitReaction: jest.fn(),
addReaction: jest.fn(),
removeReaction: jest.fn(),
clearDraftUploads: jest.fn(),
@ -728,13 +729,13 @@ describe('components/advanced_create_post', () => {
});
it('onSubmit test for addReaction message', async () => {
const addReaction = jest.fn();
const submitReaction = jest.fn();
const wrapper = shallow(
advancedCreatePost({
actions: {
...baseProp.actions,
addReaction,
submitReaction,
},
}),
);
@ -744,17 +745,17 @@ describe('components/advanced_create_post', () => {
});
await (wrapper.instance() as AdvancedCreatePost).handleSubmit(submitEvent);
expect(addReaction).toHaveBeenCalledWith('a', 'smile');
expect(submitReaction).toHaveBeenCalledWith('a', '+', 'smile');
});
it('onSubmit test for removeReaction message', () => {
const removeReaction = jest.fn();
it('onSubmit test for removeReaction message', async () => {
const submitReaction = jest.fn();
const wrapper = shallow(
advancedCreatePost({
actions: {
...baseProp.actions,
removeReaction,
submitReaction,
},
}),
);
@ -763,9 +764,8 @@ describe('components/advanced_create_post', () => {
message: '-:smile:',
});
const form = wrapper.find('#create_post');
form.simulate('Submit', {preventDefault: jest.fn()});
expect(removeReaction).toHaveBeenCalledWith('a', 'smile');
await (wrapper.instance() as AdvancedCreatePost).handleSubmit(submitEvent);
expect(submitReaction).toHaveBeenCalledWith('a', '-', 'smile');
});
/*it('check for postError state on handlePostError callback', () => {

View File

@ -174,6 +174,8 @@ export type Props = {
// func called for navigation through messages by Down arrow
moveHistoryIndexForward: (index: string) => Promise<void>;
submitReaction: (postId: string, action: string, emojiName: string) => void;
// func called for adding a reaction
addReaction: (postId: string, emojiName: string) => void;
@ -799,10 +801,8 @@ class AdvancedCreatePost extends React.PureComponent<Props, State> {
const emojiName = isReaction[2];
const postId = this.props.latestReplyablePostId;
if (postId && action === '+') {
this.props.actions.addReaction(postId, emojiName);
} else if (postId && action === '-') {
this.props.actions.removeReaction(postId, emojiName);
if (postId) {
this.props.actions.submitReaction(postId, action, emojiName);
}
this.props.actions.setDraft(StoragePrefixes.DRAFT + channelId, null, channelId);

View File

@ -36,7 +36,7 @@ import type {ActionResult, GetStateFunc, DispatchFunc} from 'mattermost-redux/ty
import {executeCommand} from 'actions/command';
import {runMessageWillBePostedHooks, runSlashCommandWillBePostedHooks} from 'actions/hooks';
import {addReaction, createPost, setEditingPost, emitShortcutReactToLastPostFrom} from 'actions/post_actions';
import {addReaction, createPost, setEditingPost, emitShortcutReactToLastPostFrom, submitReaction} from 'actions/post_actions';
import {actionOnGlobalItemsWithPrefix} from 'actions/storage';
import {scrollPostListToBottom} from 'actions/views/channel';
import {removeDraft, updateDraft} from 'actions/views/drafts';
@ -153,6 +153,7 @@ type Actions = {
addReaction: (postId: string, emojiName: string) => void;
onSubmitPost: (post: Post, fileInfos: FileInfo[]) => void;
removeReaction: (postId: string, emojiName: string) => void;
submitReaction: (postId: string, action: string, emojiName: string) => void;
clearDraftUploads: () => void;
runMessageWillBePostedHooks: (originalPost: Post) => ActionResult;
runSlashCommandWillBePostedHooks: (originalMessage: string, originalArgs: CommandArgs) => ActionResult;
@ -202,6 +203,7 @@ function mapDispatchToProps(dispatch: Dispatch) {
onSubmitPost,
moveHistoryIndexBack,
moveHistoryIndexForward,
submitReaction,
addReaction,
removeReaction,
setDraft,

View File

@ -7,7 +7,7 @@ import type {ActionCreatorsMapObject, Dispatch} from 'redux';
import type {Action} from 'mattermost-redux/types/actions';
import {addReaction} from 'actions/post_actions';
import {toggleReaction} from 'actions/post_actions';
import PostReaction from './post_reaction';
import type {Props} from './post_reaction';
@ -15,7 +15,7 @@ import type {Props} from './post_reaction';
function mapDispatchToProps(dispatch: Dispatch) {
return {
actions: bindActionCreators<ActionCreatorsMapObject<Action>, Props['actions']>({
addReaction,
toggleReaction,
}, dispatch),
};
}

View File

@ -18,7 +18,7 @@ describe('components/post_view/PostReaction', () => {
showEmojiPicker: false,
toggleEmojiPicker: jest.fn(),
actions: {
addReaction: jest.fn(),
toggleReaction: jest.fn(),
},
};
@ -27,13 +27,13 @@ describe('components/post_view/PostReaction', () => {
expect(wrapper).toMatchSnapshot();
});
test('should call addReaction and toggleEmojiPicker on handleAddEmoji', () => {
test('should call toggleReaction and toggleEmojiPicker on handleToggleEmoji', () => {
const wrapper = shallow(<PostReaction {...baseProps}/>);
const instance = wrapper.instance() as PostReaction;
instance.handleAddEmoji({name: 'smile'} as Emoji);
expect(baseProps.actions.addReaction).toHaveBeenCalledTimes(1);
expect(baseProps.actions.addReaction).toHaveBeenCalledWith('post_id_1', 'smile');
instance.handleToggleEmoji({name: 'smile'} as Emoji);
expect(baseProps.actions.toggleReaction).toHaveBeenCalledTimes(1);
expect(baseProps.actions.toggleReaction).toHaveBeenCalledWith('post_id_1', 'smile');
expect(baseProps.toggleEmojiPicker).toHaveBeenCalledTimes(1);
});
});

View File

@ -30,7 +30,7 @@ export type Props = {
showEmojiPicker: boolean;
toggleEmojiPicker: (e?: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
actions: {
addReaction: (postId: string, emojiName: string) => (dispatch: Dispatch) => {data: boolean};
toggleReaction: (postId: string, emojiName: string) => (dispatch: Dispatch) => {data: boolean};
};
}
@ -45,10 +45,10 @@ export default class PostReaction extends React.PureComponent<Props, State> {
showEmojiPicker: false,
};
handleAddEmoji = (emoji: Emoji): void => {
handleToggleEmoji = (emoji: Emoji): void => {
this.setState({showEmojiPicker: false});
const emojiName = 'short_name' in emoji ? emoji.short_name : emoji.name;
this.props.actions.addReaction(this.props.postId, emojiName);
this.props.actions.toggleReaction(this.props.postId, emojiName);
this.props.toggleEmojiPicker();
};
@ -79,7 +79,7 @@ export default class PostReaction extends React.PureComponent<Props, State> {
show={showEmojiPicker}
target={this.props.getDotMenuRef}
onHide={this.props.toggleEmojiPicker}
onEmojiClick={this.handleAddEmoji}
onEmojiClick={this.handleToggleEmoji}
topOffset={TOP_OFFSET}
spaceRequiredAbove={spaceRequiredAbove}
spaceRequiredBelow={spaceRequiredBelow}

View File

@ -9,7 +9,7 @@ import type {Emoji} from '@mattermost/types/emojis';
import type {GenericAction} from 'mattermost-redux/types/actions';
import {addReaction} from 'actions/post_actions';
import {toggleReaction} from 'actions/post_actions';
import {getEmojiMap} from 'selectors/emojis';
import {getCurrentLocale} from 'selectors/i18n';
@ -20,7 +20,7 @@ import PostReaction from './post_recent_reactions';
function mapDispatchToProps(dispatch: Dispatch<GenericAction>) {
return {
actions: bindActionCreators({
addReaction,
toggleReaction,
}, dispatch),
};
}

View File

@ -27,7 +27,7 @@ type Props = {
size: number;
defaultEmojis: Emoji[];
actions: {
addReaction: (postId: string, emojiName: string) => void;
toggleReaction: (postId: string, emojiName: string) => void;
};
}
@ -41,9 +41,9 @@ export default class PostRecentReactions extends React.PureComponent<Props, Stat
size: 3,
};
handleAddEmoji = (emoji: Emoji): void => {
handleToggleEmoji = (emoji: Emoji): void => {
const emojiName = 'short_name' in emoji ? emoji.short_name : emoji.name;
this.props.actions.addReaction(this.props.postId, emojiName);
this.props.actions.toggleReaction(this.props.postId, emojiName);
};
complementEmojis = (emojis: Emoji[]): (Emoji[]) => {
@ -108,7 +108,7 @@ export default class PostRecentReactions extends React.PureComponent<Props, Stat
<React.Fragment>
<EmojiItem
emoji={emoji}
onItemClick={this.handleAddEmoji}
onItemClick={this.handleToggleEmoji}
order={n}
/>
</React.Fragment>

View File

@ -11,7 +11,7 @@ import {getChannel} from 'mattermost-redux/selectors/entities/channels';
import {canAddReactions} from 'mattermost-redux/selectors/entities/reactions';
import type {GenericAction} from 'mattermost-redux/types/actions';
import {addReaction} from 'actions/post_actions';
import {toggleReaction} from 'actions/post_actions';
import {makeGetUniqueReactionsToPost} from 'utils/post_utils';
@ -43,7 +43,7 @@ function makeMapStateToProps() {
function mapDispatchToProps(dispatch: Dispatch<GenericAction>) {
return {
actions: bindActionCreators({
addReaction,
toggleReaction,
}, dispatch),
};
}

View File

@ -51,7 +51,7 @@ type Props = {
/**
* Function to add a reaction to the post
*/
addReaction: (postId: string, emojiName: string) => void;
toggleReaction: (postId: string, emojiName: string) => void;
};
};
@ -91,7 +91,7 @@ export default class ReactionList extends React.PureComponent<Props, State> {
handleEmojiClick = (emoji: Emoji): void => {
this.setState({showEmojiPicker: false});
const emojiName = isSystemEmoji(emoji) ? emoji.short_names[0] : emoji.name;
this.props.actions.addReaction(this.props.post.id, emojiName);
this.props.actions.toggleReaction(this.props.post.id, emojiName);
};
hideEmojiPicker = (): void => {

View File

@ -27,7 +27,7 @@ describe('components/ReactionList', () => {
const teamId = 'teamId';
const actions = {
addReaction: jest.fn(),
toggleReaction: jest.fn(),
};
const baseProps = {

View File

@ -1240,10 +1240,10 @@ describe('PostUtils.isWithinCodeBlock', () => {
it('should handle whitespace within and around code blocks', () => {
const [caretPosition, message] = getCaretAndMsg(`
|${TRIPLE_BACKTICKS}
| Test text asd 1
| ${CARET_MARKER}
|${TRIPLE_BACKTICKS}
|${TRIPLE_BACKTICKS}
| Test text asd 1
| ${CARET_MARKER}
|${TRIPLE_BACKTICKS}
`);
expect(PostUtils.isWithinCodeBlock(message, caretPosition)).toBe(true);
});
@ -1319,3 +1319,37 @@ describe('PostUtils.getUserOrGroupFromMentionName', () => {
expect(result).toEqual(expected);
});
});
describe('makeGetIsReactionAlreadyAddedToPost', () => {
const currentUserId = 'current_user_id';
const baseState = {
entities: {
users: {
currentUserId,
},
posts: {
reactions: {
post_id_1: {
'current_user_id-smile': {
emoji_name: 'smile',
user_id: currentUserId,
post_id: 'post_id_1',
},
},
},
},
general: {
config: {},
},
emojis: {},
}} as unknown as GlobalState;
test('should return true if the post has an emoji that the user has reacted to.', () => {
const getIsReactionAlreadyAddedToPost = PostUtils.makeGetIsReactionAlreadyAddedToPost();
expect(getIsReactionAlreadyAddedToPost(baseState, 'post_id_1', 'sad')).toBeFalsy();
expect(getIsReactionAlreadyAddedToPost(baseState, 'post_id_1', 'smile')).toBeTruthy();
});
});

View File

@ -710,6 +710,24 @@ export function makeGetUniqueReactionsToPost(): (state: GlobalState, postId: Pos
);
}
export function makeGetIsReactionAlreadyAddedToPost(): (state: GlobalState, postId: Post['id'], emojiName: string) => boolean {
const getUniqueReactionsToPost = makeGetUniqueReactionsToPost();
return createSelector(
'makeGetIsReactionAlreadyAddedToPost',
(state: GlobalState, postId: string) => getUniqueReactionsToPost(state, postId),
getCurrentUserId,
(state: GlobalState, postId: string, emojiName: string) => emojiName,
(reactions, currentUserId, emojiName) => {
const reactionsForPost = reactions || {};
const isReactionAlreadyAddedToPost = Object.values(reactionsForPost).some((reaction) => reaction.user_id === currentUserId && reaction.emoji_name === emojiName);
return isReactionAlreadyAddedToPost;
},
);
}
export function getMentionDetails(usersByUsername: Record<string, UserProfile | Group>, mentionName: string): UserProfile | Group | undefined {
let mentionNameToLowerCase = mentionName.toLowerCase();