From b01e526f413eee29ab7a4a1345fd534e2d76c1d1 Mon Sep 17 00:00:00 2001 From: naincy128 Date: Tue, 16 Dec 2025 09:44:37 +0000 Subject: [PATCH] feat: implement mute/unmute feature --- src/data/constants.js | 3 + src/discussions/common/ActionsDropdown.jsx | 30 +- src/discussions/common/MuteConfirmModal.jsx | 98 +++++ .../common/MuteConfirmModal.test.jsx | 112 +++++ src/discussions/common/MuteModalManager.jsx | 275 ++++++++++++ .../common/MuteModalManager.test.jsx | 248 +++++++++++ src/discussions/common/MuteUserModal.jsx | 195 +++++++++ .../common/StaffMuteModalManager.jsx | 87 ++++ .../common/StaffMuteModalManager.test.jsx | 74 ++++ .../common/StaffMuteOptionsModal.jsx | 194 +++++++++ .../common/StaffMuteOptionsModal.test.jsx | 118 ++++++ src/discussions/common/UserActions.jsx | 142 +++++++ src/discussions/common/index.js | 5 + src/discussions/data/api.js | 71 ++++ src/discussions/data/selectors.js | 10 + src/discussions/data/slices.js | 80 ++++ src/discussions/data/thunks.js | 114 ++++- src/discussions/learners/LearnersView.jsx | 393 +++++++++++++++++- src/discussions/learners/data/selectors.js | 11 + src/discussions/learners/messages.js | 20 + src/discussions/messages.js | 19 +- src/discussions/pages/MutedUsersPage.jsx | 234 +++++++++++ .../comments/comment/Comment.jsx | 134 +++++- .../post-comments/comments/comment/Reply.jsx | 43 +- .../post-comments/data/selectors.js | 31 +- src/discussions/posts/data/selectors.js | 47 ++- src/discussions/posts/post/Post.jsx | 46 +- src/discussions/posts/post/messages.js | 53 +++ src/discussions/utils.js | 80 +++- 29 files changed, 2938 insertions(+), 29 deletions(-) create mode 100644 src/discussions/common/MuteConfirmModal.jsx create mode 100644 src/discussions/common/MuteConfirmModal.test.jsx create mode 100644 src/discussions/common/MuteModalManager.jsx create mode 100644 src/discussions/common/MuteModalManager.test.jsx create mode 100644 src/discussions/common/MuteUserModal.jsx create mode 100644 src/discussions/common/StaffMuteModalManager.jsx create mode 100644 src/discussions/common/StaffMuteModalManager.test.jsx create mode 100644 src/discussions/common/StaffMuteOptionsModal.jsx create mode 100644 src/discussions/common/StaffMuteOptionsModal.test.jsx create mode 100644 src/discussions/common/UserActions.jsx create mode 100644 src/discussions/pages/MutedUsersPage.jsx diff --git a/src/data/constants.js b/src/data/constants.js index 269212d89..81426bcdb 100644 --- a/src/data/constants.js +++ b/src/data/constants.js @@ -60,6 +60,9 @@ export const ContentActions = { VOTE: 'voted', DELETE_COURSE_POSTS: 'delete-course-posts', DELETE_ORG_POSTS: 'delete-org-posts', + MUTE_USER: 'mute_user', + UNMUTE_USER: 'unmute_user', + MUTE_AND_REPORT: 'mute_and_report', }; /** diff --git a/src/discussions/common/ActionsDropdown.jsx b/src/discussions/common/ActionsDropdown.jsx index 359125081..1533fc35d 100644 --- a/src/discussions/common/ActionsDropdown.jsx +++ b/src/discussions/common/ActionsDropdown.jsx @@ -6,7 +6,7 @@ import PropTypes from 'prop-types'; import { Button, Dropdown, Icon, IconButton, ModalPopup, useToggle, } from '@openedx/paragon'; -import { MoreHoriz } from '@openedx/paragon/icons'; +import { ChevronRight, MoreHoriz } from '@openedx/paragon/icons'; import { useSelector } from 'react-redux'; import { useIntl } from '@edx/frontend-platform/i18n'; @@ -69,6 +69,24 @@ const ActionsDropdown = ({ className="bg-white shadow d-flex flex-column mt-1" data-testid="actions-dropdown-modal-popup" > + {actions.map(action => ( {(action.action === ContentActions.DELETE) && } @@ -85,11 +103,17 @@ const ActionsDropdown = ({ > - + {intl.formatMessage(action.label)} + {action.hasChevron && ( + + )} ))} diff --git a/src/discussions/common/MuteConfirmModal.jsx b/src/discussions/common/MuteConfirmModal.jsx new file mode 100644 index 000000000..aa3bf73ea --- /dev/null +++ b/src/discussions/common/MuteConfirmModal.jsx @@ -0,0 +1,98 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { + ActionRow, + Button, + ModalDialog, +} from '@openedx/paragon'; + +/** + * Configurable confirmation modal for mute/unmute actions. + * Consolidates the functionality of 4 separate confirmation modals: + * - MutePersonalConfirmModal + * - MuteCourseWideConfirmModal + * - UnmutePersonalConfirmModal + * - UnmuteCourseWideConfirmModal + */ +const MuteConfirmModal = ({ + isOpen, + onClose, + onConfirm, + username, + type = 'mute-personal', +}) => { + const handleConfirm = () => { + onConfirm(); + onClose(); + }; + + // Configuration for different modal types + const modalConfig = { + 'mute-personal': { + title: 'Mute this user?', + description: `Are you sure you want to mute ${username}? Their discussion activity will be hidden from your view, but still visible to other learners and staff.`, + buttonText: 'Mute', + buttonVariant: 'danger', + }, + 'mute-coursewide': { + title: 'Mute this user?', + description: `Are you sure you want to mute ${username} course-wide? Their discussion activity will be hidden from all learners and staff.`, + buttonText: 'Mute', + buttonVariant: 'danger', + }, + 'unmute-personal': { + title: 'Unmute this user?', + description: `Are you sure you want to unmute ${username}? Their discussion activity will become visible to you again.`, + buttonText: 'Unmute', + buttonVariant: 'primary', + }, + 'unmute-coursewide': { + title: 'Unmute this user course-wide?', + description: `Are you sure you want to unmute ${username} course-wide? Their discussion activity will become visible to all learners and staff.`, + buttonText: 'Unmute', + buttonVariant: 'primary', + }, + }; + + const config = modalConfig[type] || modalConfig['mute-personal']; + + return ( + + + + {config.title} + + + +

{config.description}

+
+ + + + Cancel + + + + +
+ ); +}; + +MuteConfirmModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + onConfirm: PropTypes.func.isRequired, + username: PropTypes.string.isRequired, + type: PropTypes.oneOf(['mute-personal', 'mute-coursewide', 'unmute-personal', 'unmute-coursewide']).isRequired, +}; + +export default MuteConfirmModal; diff --git a/src/discussions/common/MuteConfirmModal.test.jsx b/src/discussions/common/MuteConfirmModal.test.jsx new file mode 100644 index 000000000..05321deff --- /dev/null +++ b/src/discussions/common/MuteConfirmModal.test.jsx @@ -0,0 +1,112 @@ +import React from 'react'; + +import { fireEvent, render, screen } from '@testing-library/react'; + +import { IntlProvider } from '@edx/frontend-platform/i18n'; + +import MuteConfirmModal from './MuteConfirmModal'; + +const renderWithIntl = (component) => render( + + {component} + , +); + +const mockProps = { + isOpen: true, + onClose: jest.fn(), + onConfirm: jest.fn(), + username: 'testuser', +}; + +describe('MuteConfirmModal', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Mute Personal Modal', () => { + it('renders correctly for personal mute', () => { + renderWithIntl( + , + ); + + expect(screen.getByText('Mute this user?')).toBeInTheDocument(); + expect(screen.getByText(/Are you sure you want to mute testuser\? Their discussion activity will be hidden from your view/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Mute' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); + }); + + it('calls onConfirm and onClose when mute is clicked', () => { + renderWithIntl( + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Mute' })); + expect(mockProps.onConfirm).toHaveBeenCalledTimes(1); + expect(mockProps.onClose).toHaveBeenCalledTimes(1); + }); + }); + + describe('Mute Course-wide Modal', () => { + it('renders correctly for course-wide mute', () => { + renderWithIntl( + , + ); + + expect(screen.getByText('Mute this user?')).toBeInTheDocument(); + expect(screen.getByText(/Are you sure you want to mute testuser course-wide\? Their discussion activity will be hidden from all learners/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Mute' })).toBeInTheDocument(); + }); + }); + + describe('Unmute Personal Modal', () => { + it('renders correctly for personal unmute', () => { + renderWithIntl( + , + ); + + expect(screen.getByText('Unmute this user?')).toBeInTheDocument(); + expect(screen.getByText(/Are you sure you want to unmute testuser\? Their discussion activity will become visible to you again/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Unmute' })).toBeInTheDocument(); + }); + }); + + describe('Unmute Course-wide Modal', () => { + it('renders correctly for course-wide unmute', () => { + renderWithIntl( + , + ); + + expect(screen.getByText('Unmute this user course-wide?')).toBeInTheDocument(); + expect(screen.getByText(/Are you sure you want to unmute testuser course-wide\? Their discussion activity will become visible to all learners/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Unmute' })).toBeInTheDocument(); + }); + }); + + it('calls onClose when cancel is clicked', () => { + renderWithIntl( + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(mockProps.onClose).toHaveBeenCalledTimes(1); + expect(mockProps.onConfirm).not.toHaveBeenCalled(); + }); + + it('does not render when closed', () => { + renderWithIntl( + , + ); + + expect(screen.queryByText('Mute this user?')).not.toBeInTheDocument(); + }); + + it('defaults to mute-personal config when invalid type provided', () => { + renderWithIntl( + , + ); + + expect(screen.getByText('Mute this user?')).toBeInTheDocument(); + expect(screen.getByText(/Are you sure you want to mute testuser\? Their discussion activity will be hidden from your view/)).toBeInTheDocument(); + }); +}); diff --git a/src/discussions/common/MuteModalManager.jsx b/src/discussions/common/MuteModalManager.jsx new file mode 100644 index 000000000..cbad22154 --- /dev/null +++ b/src/discussions/common/MuteModalManager.jsx @@ -0,0 +1,275 @@ +import React, { useCallback } from 'react'; +import PropTypes from 'prop-types'; + +import { + ActionRow, + Button, + ModalDialog, + useToggle, +} from '@openedx/paragon'; +import { useDispatch, useSelector } from 'react-redux'; + +import { useIntl } from '@edx/frontend-platform/i18n'; + +import { + fetchMutedUsersThunk, muteAndReportUserThunk, muteUserThunk, unmuteUserThunk, +} from '../data/thunks'; +import { fetchThreads } from '../posts/data/thunks'; +import MuteConfirmModal from './MuteConfirmModal'; +import StaffMuteOptionsModal from './StaffMuteOptionsModal'; + +/** + * Universal Mute Modal Manager + * + * Consolidates all mute/unmute modal functionality for Post, Comment, and Reply components. + * Handles both learner and staff modal flows with configuration-based approach. + * + * Features: + * - Learner mute modal with mute + report options + * - Staff mute options modal with confirmation flow + * - Unmute modal for learners + * - Reusable across different content types + */ +const MuteModalManager = ({ + // Modal visibility states + showLearnerMuteModal, + showStaffMuteModal, + showUnmuteModal, + + // Modal close handlers + onCloseLearnerMuteModal, + onCloseStaffMuteModal, + onCloseUnmuteModal, + + // User info + username, + + // Additional props for learner modals + contentId, + messages, // Message definitions for translations +}) => { + const dispatch = useDispatch(); + const intl = useIntl(); + const courseId = useSelector(state => state.config.courseId); + + // Staff confirmation modal state + const [confirmModalOpen, openConfirmModal, closeConfirmModal] = useToggle(false); + const [confirmModalType, setConfirmModalType] = React.useState('mute-personal'); + + // Helper function to refresh discussions after mute action + const refreshDiscussions = useCallback(() => { + // Refresh posts/threads + dispatch(fetchThreads(courseId, { + orderBy: 'last_activity_at', + page: 1, + pageSize: 20, + })); + // Refresh muted users list + dispatch(fetchMutedUsersThunk(courseId)); + }, [dispatch, courseId]); + + // Learner mute handlers + const handleLearnerMute = useCallback(async () => { + await dispatch(muteUserThunk(username, false)); + refreshDiscussions(); + onCloseLearnerMuteModal(); + }, [dispatch, username, onCloseLearnerMuteModal, refreshDiscussions]); + + const handleLearnerMuteAndReport = useCallback(async () => { + await dispatch(muteAndReportUserThunk(username, contentId)); + refreshDiscussions(); + onCloseLearnerMuteModal(); + }, [dispatch, username, contentId, onCloseLearnerMuteModal, refreshDiscussions]); + + // Unmute handler + const handleUnmute = useCallback(async () => { + await dispatch(unmuteUserThunk(username, false)); + refreshDiscussions(); + onCloseUnmuteModal(); + }, [dispatch, username, onCloseUnmuteModal, refreshDiscussions]); + + // Staff option selection handler + const handleStaffOptionSelect = useCallback((option) => { + // Close staff options modal first + onCloseStaffMuteModal(); + + // Map option to confirmation modal type + const modalTypeMap = { + 'mute-personal': 'mute-personal', + 'mute-coursewide': 'mute-coursewide', + 'unmute-personal': 'unmute-personal', + 'unmute-coursewide': 'unmute-coursewide', + }; + + const modalType = modalTypeMap[option]; + if (modalType) { + setConfirmModalType(modalType); + openConfirmModal(); + } + }, [onCloseStaffMuteModal, openConfirmModal]); + + // Staff confirmation handler + const handleStaffConfirm = useCallback(async () => { + let muteAction; + switch (confirmModalType) { + case 'mute-personal': + muteAction = dispatch(muteUserThunk(username, false)); + break; + case 'mute-coursewide': + muteAction = dispatch(muteUserThunk(username, true)); + break; + case 'unmute-personal': + muteAction = dispatch(unmuteUserThunk(username, false)); + break; + case 'unmute-coursewide': + muteAction = dispatch(unmuteUserThunk(username, true)); + break; + default: + break; + } + + if (muteAction) { + await muteAction; + refreshDiscussions(); + } + + closeConfirmModal(); + }, [confirmModalType, dispatch, username, closeConfirmModal, refreshDiscussions]); + + return ( + <> + {/* Learner Mute Modal */} + + + + {intl.formatMessage(messages.learnerMuteTitle)} + + + +

{intl.formatMessage(messages.learnerMuteDescription, { username })}

+
+ + + + Cancel + + + + + +
+ + {/* Staff Mute Options Modal */} + + + {/* Staff Confirmation Modal */} + + + {/* Unmute Modal for Learners */} + + + + {intl.formatMessage(messages.unmuteTitle)} + + + +

{intl.formatMessage(messages.unmuteDescription, { username })}

+
+ + + + Cancel + + + + +
+ + ); +}; + +MuteModalManager.propTypes = { + // Modal visibility states + showLearnerMuteModal: PropTypes.bool.isRequired, + showStaffMuteModal: PropTypes.bool.isRequired, + showUnmuteModal: PropTypes.bool.isRequired, + + // Modal close handlers + onCloseLearnerMuteModal: PropTypes.func.isRequired, + onCloseStaffMuteModal: PropTypes.func.isRequired, + onCloseUnmuteModal: PropTypes.func.isRequired, + + // User info + username: PropTypes.string.isRequired, + + // Additional props for learner modals + contentId: PropTypes.string.isRequired, + messages: PropTypes.shape({ + learnerMuteTitle: PropTypes.shape({ + id: PropTypes.string.isRequired, + defaultMessage: PropTypes.string.isRequired, + description: PropTypes.string.isRequired, + }).isRequired, + learnerMuteDescription: PropTypes.shape({ + id: PropTypes.string.isRequired, + defaultMessage: PropTypes.string.isRequired, + description: PropTypes.string.isRequired, + }).isRequired, + learnerMuteButton: PropTypes.shape({ + id: PropTypes.string.isRequired, + defaultMessage: PropTypes.string.isRequired, + description: PropTypes.string.isRequired, + }).isRequired, + learnerMuteAndReportButton: PropTypes.shape({ + id: PropTypes.string.isRequired, + defaultMessage: PropTypes.string.isRequired, + description: PropTypes.string.isRequired, + }).isRequired, + unmuteTitle: PropTypes.shape({ + id: PropTypes.string.isRequired, + defaultMessage: PropTypes.string.isRequired, + description: PropTypes.string.isRequired, + }).isRequired, + unmuteDescription: PropTypes.shape({ + id: PropTypes.string.isRequired, + defaultMessage: PropTypes.string.isRequired, + description: PropTypes.string.isRequired, + }).isRequired, + unmuteButton: PropTypes.shape({ + id: PropTypes.string.isRequired, + defaultMessage: PropTypes.string.isRequired, + description: PropTypes.string.isRequired, + }).isRequired, + }).isRequired, +}; + +export default MuteModalManager; diff --git a/src/discussions/common/MuteModalManager.test.jsx b/src/discussions/common/MuteModalManager.test.jsx new file mode 100644 index 000000000..4e7f87bb7 --- /dev/null +++ b/src/discussions/common/MuteModalManager.test.jsx @@ -0,0 +1,248 @@ +import React from 'react'; + +import { fireEvent, render, screen } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { createStore } from 'redux'; + +import { IntlProvider } from '@edx/frontend-platform/i18n'; + +import MuteModalManager from './MuteModalManager'; + +// Mock the thunks +const mockMuteUserThunk = jest.fn(() => ({ type: 'MUTE_USER' })); +const mockUnmuteUserThunk = jest.fn(() => ({ type: 'UNMUTE_USER' })); +const mockMuteAndReportUserThunk = jest.fn(() => ({ type: 'MUTE_AND_REPORT_USER' })); + +jest.mock('../data/thunks', () => ({ + muteUserThunk: mockMuteUserThunk, + unmuteUserThunk: mockUnmuteUserThunk, + muteAndReportUserThunk: mockMuteAndReportUserThunk, +})); + +const mockStore = createStore(() => ({ + config: { + personalMutedUsers: [], + courseWideMutedUsers: [], + userIsStaff: true, + userHasModerationPrivileges: true, + }, +})); + +const mockMessages = { + learnerMuteTitle: { + id: 'test.learnerMuteTitle', + defaultMessage: 'Mute this user?', + }, + learnerMuteDescription: { + id: 'test.learnerMuteDescription', + defaultMessage: 'Are you sure you want to mute {username}?', + }, + learnerMuteButton: { + id: 'test.learnerMuteButton', + defaultMessage: 'Mute', + }, + learnerMuteAndReportButton: { + id: 'test.learnerMuteAndReportButton', + defaultMessage: 'Mute and report', + }, + unmuteTitle: { + id: 'test.unmuteTitle', + defaultMessage: 'Unmute this user?', + }, + unmuteDescription: { + id: 'test.unmuteDescription', + defaultMessage: 'Are you sure you want to unmute {username}?', + }, + unmuteButton: { + id: 'test.unmuteButton', + defaultMessage: 'Unmute', + }, +}; + +const mockProps = { + showLearnerMuteModal: false, + showStaffMuteModal: false, + showUnmuteModal: false, + onCloseLearnerMuteModal: jest.fn(), + onCloseStaffMuteModal: jest.fn(), + onCloseUnmuteModal: jest.fn(), + username: 'testuser', + contentId: 'test-content-id', + messages: mockMessages, +}; + +const renderWithProvider = (component) => render( + + + {component} + + , +); + +describe('MuteModalManager', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Learner Mute Modal', () => { + it('renders learner mute modal when showLearnerMuteModal is true', () => { + renderWithProvider( + , + ); + + expect(screen.getByText('Mute this user?')).toBeInTheDocument(); + expect(screen.getByText('Are you sure you want to mute testuser?')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Mute' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Mute and report' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); + }); + + it('calls onCloseLearnerMuteModal when cancel button is clicked', () => { + renderWithProvider( + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(mockProps.onCloseLearnerMuteModal).toHaveBeenCalledTimes(1); + }); + + it('handles learner mute action correctly', () => { + renderWithProvider( + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Mute' })); + expect(mockMuteUserThunk).toHaveBeenCalledWith('testuser', false); + expect(mockProps.onCloseLearnerMuteModal).toHaveBeenCalledTimes(1); + }); + + it('handles learner mute and report action correctly', () => { + renderWithProvider( + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Mute and report' })); + expect(mockMuteAndReportUserThunk).toHaveBeenCalledWith('testuser', 'test-content-id'); + expect(mockProps.onCloseLearnerMuteModal).toHaveBeenCalledTimes(1); + }); + }); + + describe('Staff Mute Modal', () => { + it('renders staff mute options modal when showStaffMuteModal is true', () => { + renderWithProvider( + , + ); + + expect(screen.getByText('Mute user (for me)')).toBeInTheDocument(); + expect(screen.getByText('Mute user course-wide')).toBeInTheDocument(); + expect(screen.getByText('Unmute user (for me)')).toBeInTheDocument(); + expect(screen.getByText('Unmute user course-wide')).toBeInTheDocument(); + }); + + it('opens confirmation modal when staff option is selected', () => { + renderWithProvider( + , + ); + + // Click personal mute option + fireEvent.click(screen.getByText('Mute user (for me)')); + + // Should close staff modal and show confirmation + expect(mockProps.onCloseStaffMuteModal).toHaveBeenCalledTimes(1); + + // Confirmation modal should appear + expect(screen.getByText('Mute this user?')).toBeInTheDocument(); + }); + }); + + describe('Unmute Modal', () => { + it('renders unmute modal when showUnmuteModal is true', () => { + renderWithProvider( + , + ); + + expect(screen.getByText('Unmute this user?')).toBeInTheDocument(); + expect(screen.getByText('Are you sure you want to unmute testuser?')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Unmute' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); + }); + + it('calls onCloseUnmuteModal when cancel button is clicked', () => { + renderWithProvider( + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(mockProps.onCloseUnmuteModal).toHaveBeenCalledTimes(1); + }); + + it('handles unmute action correctly', () => { + renderWithProvider( + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Unmute' })); + expect(mockUnmuteUserThunk).toHaveBeenCalledWith('testuser', false); + expect(mockProps.onCloseUnmuteModal).toHaveBeenCalledTimes(1); + }); + }); + + describe('Integration', () => { + it('handles multiple modals correctly', () => { + // Test that only one modal is shown at a time + const { rerender } = renderWithProvider( + , + ); + + // Both modals should be present but only visible ones show content + expect(screen.getByText('Mute this user?')).toBeInTheDocument(); + expect(screen.getByText('Mute user (for me)')).toBeInTheDocument(); + + // Test switching between modals + rerender( + + + + + , + ); + + expect(screen.getByText('Unmute this user?')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/discussions/common/MuteUserModal.jsx b/src/discussions/common/MuteUserModal.jsx new file mode 100644 index 000000000..e12928973 --- /dev/null +++ b/src/discussions/common/MuteUserModal.jsx @@ -0,0 +1,195 @@ +import React, { useCallback, useState } from 'react'; +import PropTypes from 'prop-types'; + +import { + Button, + Form, + Icon, + StandardModal, +} from '@openedx/paragon'; +import { VolumeOff } from '@openedx/paragon/icons'; +import { useSelector } from 'react-redux'; + +import { useIntl } from '@edx/frontend-platform/i18n'; + +import { RequestStatus } from '../../data/constants'; +import { selectUserHasModerationPrivileges, selectUserIsStaff } from '../data/selectors'; + +const MuteUserModal = ({ + isOpen, + onClose, + username, + postId, + onMute, + onMuteAndReport, +}) => { + const intl = useIntl(); + const [muteType, setMuteType] = useState('personal'); + const [includeReport, setIncludeReport] = useState(false); + const userIsStaff = useSelector(selectUserIsStaff); + const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges); + const muteStatus = useSelector(state => state.config.muteStatus); + + const isStaffOrModerator = userIsStaff || userHasModerationPrivileges; + const isLoading = muteStatus === RequestStatus.IN_PROGRESS; + + const handleMute = useCallback(() => { + if (includeReport && !isStaffOrModerator) { + // Learner: mute + report + onMuteAndReport(username, postId); + } else { + // Staff: regular mute (personal or course-wide) + const isCourseWide = muteType === 'coursewide'; + onMute(username, isCourseWide); + } + onClose(); + }, [muteType, includeReport, username, postId, onMute, onMuteAndReport, onClose, isStaffOrModerator]); + + const handleClose = useCallback(() => { + setMuteType('personal'); + setIncludeReport(false); + onClose(); + }, [onClose]); + + return ( + + + {intl.formatMessage({ + id: 'discussions.mute.modal.title', + defaultMessage: 'Mute User', + })} + + )} + isOpen={isOpen} + onClose={handleClose} + size="md" + hasCloseButton + footerNode={( +
+ + +
+ )} + > +
+

+ {intl.formatMessage({ + id: 'discussions.mute.modal.description', + defaultMessage: 'You are about to mute {username}. This will hide their posts and comments.', + }, { username })} +

+ + {isStaffOrModerator ? ( + // Staff options +
+ + + {intl.formatMessage({ + id: 'discussions.mute.modal.muteType.label', + defaultMessage: 'Mute Type', + })} + + setMuteType(e.target.value)} + > + +
+
+ {intl.formatMessage({ + id: 'discussions.mute.modal.personal.title', + defaultMessage: 'Personal Mute', + })} +
+
+ {intl.formatMessage({ + id: 'discussions.mute.modal.personal.description', + defaultMessage: 'Only you will not see this user\'s posts and comments', + })} +
+
+
+ +
+
+ {intl.formatMessage({ + id: 'discussions.mute.modal.coursewide.title', + defaultMessage: 'Course-wide Mute', + })} +
+
+ {intl.formatMessage({ + id: 'discussions.mute.modal.coursewide.description', + defaultMessage: 'All learners will not see this user\'s posts and comments', + })} +
+
+
+
+
+
+ ) : ( + // Learner options +
+ + setIncludeReport(e.target.checked)} + > + + {intl.formatMessage({ + id: 'discussions.mute.modal.report.label', + defaultMessage: 'Also report this user for inappropriate behavior', + })} + + + +
+ )} +
+
+ ); +}; + +MuteUserModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + username: PropTypes.string.isRequired, + postId: PropTypes.string, + onMute: PropTypes.func.isRequired, + onMuteAndReport: PropTypes.func.isRequired, +}; + +MuteUserModal.defaultProps = { + postId: null, +}; + +export default MuteUserModal; diff --git a/src/discussions/common/StaffMuteModalManager.jsx b/src/discussions/common/StaffMuteModalManager.jsx new file mode 100644 index 000000000..4a95a11d7 --- /dev/null +++ b/src/discussions/common/StaffMuteModalManager.jsx @@ -0,0 +1,87 @@ +import React, { useCallback } from 'react'; +import PropTypes from 'prop-types'; + +import { useToggle } from '@openedx/paragon'; +import { useDispatch } from 'react-redux'; + +import { muteUserThunk, unmuteUserThunk } from '../data/thunks'; +import MuteConfirmModal from './MuteConfirmModal'; +import StaffMuteOptionsModal from './StaffMuteOptionsModal'; + +const StaffMuteModalManager = ({ + isOpen, + onClose, + username, +}) => { + const dispatch = useDispatch(); + + // State for the 4 confirmation modals - using single modal state and type tracking + const [confirmModalOpen, openConfirmModal, closeConfirmModal] = useToggle(false); + const [confirmModalType, setConfirmModalType] = React.useState('mute-personal'); + + // Handle option selection from StaffMuteOptionsModal + const handleOptionSelect = useCallback((option) => { + // Map option to modal type and open confirmation modal + const modalTypeMap = { + 'mute-personal': 'mute-personal', + 'mute-coursewide': 'mute-coursewide', + 'unmute-personal': 'unmute-personal', + 'unmute-coursewide': 'unmute-coursewide', + }; + + const modalType = modalTypeMap[option]; + if (modalType) { + setConfirmModalType(modalType); + openConfirmModal(); + } + }, [openConfirmModal]); + + // Unified confirmation handler + const handleConfirm = useCallback(() => { + switch (confirmModalType) { + case 'mute-personal': + dispatch(muteUserThunk(username, false)); + break; + case 'mute-coursewide': + dispatch(muteUserThunk(username, true)); + break; + case 'unmute-personal': + dispatch(unmuteUserThunk(username, false)); + break; + case 'unmute-coursewide': + dispatch(unmuteUserThunk(username, true)); + break; + default: + break; + } + }, [confirmModalType, dispatch, username]); + + return ( + <> + {/* Main options modal */} + + + {/* Unified confirmation modal */} + + + ); +}; + +StaffMuteModalManager.propTypes = { + isOpen: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + username: PropTypes.string.isRequired, +}; + +export default StaffMuteModalManager; diff --git a/src/discussions/common/StaffMuteModalManager.test.jsx b/src/discussions/common/StaffMuteModalManager.test.jsx new file mode 100644 index 000000000..754171995 --- /dev/null +++ b/src/discussions/common/StaffMuteModalManager.test.jsx @@ -0,0 +1,74 @@ +import React from 'react'; + +import { fireEvent, render, screen } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { createStore } from 'redux'; + +import { IntlProvider } from '@edx/frontend-platform/i18n'; + +import StaffMuteModalManager from './StaffMuteModalManager'; + +import '@testing-library/jest-dom'; + +// Mock the Redux store +const mockStore = createStore(() => ({ + config: { + personalMutedUsers: [], + courseWideMutedUsers: [], + }, +})); + +// Mock the thunk actions +jest.mock('../data/thunks', () => ({ + muteUserThunk: jest.fn(() => ({ type: 'MOCK_MUTE_USER' })), + unmuteUserThunk: jest.fn(() => ({ type: 'MOCK_UNMUTE_USER' })), +})); + +const mockProps = { + isOpen: true, + onClose: jest.fn(), + username: 'testuser', +}; + +const renderWithProvider = (component) => render( + + + {component} + + , +); + +describe('StaffMuteModalManager', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the main StaffMuteOptionsModal when open', () => { + renderWithProvider(); + + expect(screen.getByText('Mute user (for me)')).toBeInTheDocument(); + expect(screen.getByText('Mute user course-wide')).toBeInTheDocument(); + expect(screen.getByText('Unmute user (for me)')).toBeInTheDocument(); + expect(screen.getByText('Unmute user course-wide')).toBeInTheDocument(); + }); + + it('opens MutePersonalConfirmModal when personal mute option is selected', () => { + renderWithProvider(); + + // Click the personal mute option + fireEvent.click(screen.getByText('Mute user (for me)')); + + // Should show the confirmation modal + expect(screen.getByText('Are you sure you want to mute testuser? Their discussion activity will be hidden from your view, but still visible to other learners and staff.')).toBeInTheDocument(); + }); + + it('opens MuteCourseWideConfirmModal when course-wide mute option is selected', () => { + renderWithProvider(); + + // Click the course-wide mute option + fireEvent.click(screen.getByText('Mute user course-wide')); + + // Should show the confirmation modal + expect(screen.getByText('Are you sure you want to mute testuser course-wide? Their discussion activity will be hidden from all learners and staff.')).toBeInTheDocument(); + }); +}); diff --git a/src/discussions/common/StaffMuteOptionsModal.jsx b/src/discussions/common/StaffMuteOptionsModal.jsx new file mode 100644 index 000000000..f53eb6414 --- /dev/null +++ b/src/discussions/common/StaffMuteOptionsModal.jsx @@ -0,0 +1,194 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { + Button, ModalDialog, +} from '@openedx/paragon'; +import { useSelector } from 'react-redux'; + +import { selectCourseWideMutedUsers, selectPersonalMutedUsers } from '../data/selectors'; + +const StaffMuteOptionsModal = ({ + isOpen, + onClose, + onSelectOption, + username, +}) => { + const personalMutedUsers = useSelector(selectPersonalMutedUsers); + const courseWideMutedUsers = useSelector(selectCourseWideMutedUsers); + + // Add custom CSS to match exact UI design + const customButtonCSS = ` + .staff-mute-modal { + width: 320px !important; + max-width: 320px !important; + } + .staff-mute-modal .pgn__modal-dialog { + max-width: 320px !important; + width: 320px !important; + margin: 1.75rem auto !important; + } + .staff-mute-modal .pgn__modal-body { + padding: 16px 24px !important; + overflow: visible !important; + } + .custom-mute-button { + border: none !important; + outline: none !important; + box-shadow: none !important; + background: transparent !important; + text-decoration: none !important; + font-size: 14px !important; + font-weight: 400 !important; + padding: 8px 0 !important; + margin: 0 !important; + width: 100% !important; + text-align: left !important; + color: #000 !important; + min-height: auto !important; + height: auto !important; + cursor: pointer !important; + } + .custom-mute-button:hover { + background: rgba(0, 0, 0, 0.05) !important; + border: none !important; + outline: none !important; + box-shadow: none !important; + text-decoration: none !important; + } + .custom-mute-button:focus { + background: rgba(0, 0, 0, 0.05) !important; + border: none !important; + outline: none !important; + box-shadow: none !important; + text-decoration: none !important; + } + .custom-mute-button:focus-visible { + border: none !important; + outline: none !important; + box-shadow: none !important; + background: rgba(0, 0, 0, 0.05) !important; + } + .custom-mute-button:active { + background: rgba(0, 0, 0, 0.08) !important; + border: none !important; + outline: none !important; + box-shadow: none !important; + text-decoration: none !important; + } + .custom-mute-button.text-muted { + color: #9CA3AF !important; + cursor: not-allowed !important; + opacity: 0.6 !important; + } + .custom-mute-button.text-muted:hover { + background: transparent !important; + } + .custom-mute-button:disabled { + border: none !important; + outline: none !important; + box-shadow: none !important; + cursor: not-allowed !important; + } + `; + + // Check if user is currently muted + const isPersonallyMuted = personalMutedUsers.includes(username); + const isCourseWideMuted = courseWideMutedUsers.includes(username); + + const handleOptionClick = (option, isDisabled) => { + if (isDisabled) { return; } + onSelectOption(option); + onClose(); + }; + + // Option data with conditional styling + const options = [ + { + id: 'mute-personal', + label: 'Mute user (for me)', + isDisabled: isPersonallyMuted || isCourseWideMuted, + }, + { + id: 'mute-coursewide', + label: 'Mute user course-wide', + isDisabled: isPersonallyMuted || isCourseWideMuted, + }, + { + id: 'unmute-personal', + label: 'Unmute user (for me)', + isDisabled: !isPersonallyMuted && !isCourseWideMuted, + }, + { + id: 'unmute-coursewide', + label: 'Unmute user course-wide', + isDisabled: !isCourseWideMuted, + }, + ]; + + const customModalStyle = { + borderRadius: '4px', + background: 'var(--Extras-White, #FFF)', + boxShadow: '0 2px 4px 0 rgba(0, 0, 0, 0.15), 0 2px 8px 0 rgba(0, 0, 0, 0.15)', + }; + + const optionsContainerStyle = { + display: 'flex', + flexDirection: 'column', + alignItems: 'stretch', + gap: '0px', + width: '100%', + }; + + return ( + <> + + + +
+ {options.map((option, index) => ( + + + {index === 1 && ( +
+ )} + + ))} +
+ + + + ); +}; + +StaffMuteOptionsModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onClose: PropTypes.func.isRequired, + onSelectOption: PropTypes.func.isRequired, + username: PropTypes.string.isRequired, +}; + +export default StaffMuteOptionsModal; diff --git a/src/discussions/common/StaffMuteOptionsModal.test.jsx b/src/discussions/common/StaffMuteOptionsModal.test.jsx new file mode 100644 index 000000000..4c48b3822 --- /dev/null +++ b/src/discussions/common/StaffMuteOptionsModal.test.jsx @@ -0,0 +1,118 @@ +import React from 'react'; + +import { configureStore } from '@reduxjs/toolkit'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { Provider } from 'react-redux'; + +import StaffMuteOptionsModal from './StaffMuteOptionsModal'; + +// Mock store with initial state +const createMockStore = (personalMutedUsers = [], courseWideMutedUsers = []) => configureStore({ + reducer: { + config: (state = { + personalMutedUsers, + courseWideMutedUsers, + }) => state, + }, +}); + +const defaultProps = { + isOpen: true, + onClose: jest.fn(), + onSelectOption: jest.fn(), + username: 'testuser', +}; + +describe('StaffMuteOptionsModal', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders with all four options', () => { + const store = createMockStore(); + render( + + + , + ); + + expect(screen.getByText('Mute user (for me)')).toBeInTheDocument(); + expect(screen.getByText('Mute user course-wide')).toBeInTheDocument(); + expect(screen.getByText('Unmute user (for me)')).toBeInTheDocument(); + expect(screen.getByText('Unmute user course-wide')).toBeInTheDocument(); + }); + + it('disables unmute options when user is not muted', () => { + const store = createMockStore([], []); // No muted users + render( + + + , + ); + + const unmutePersonalBtn = screen.getByText('Unmute user (for me)'); + const unmuteCourseWideBtn = screen.getByText('Unmute user course-wide'); + + expect(unmutePersonalBtn).toBeDisabled(); + expect(unmuteCourseWideBtn).toBeDisabled(); + }); + + it('disables mute options when user is already muted', () => { + const store = createMockStore(['testuser'], []); // User is personally muted + render( + + + , + ); + + const mutePersonalBtn = screen.getByText('Mute user (for me)'); + expect(mutePersonalBtn).toBeDisabled(); + }); + + it('calls onSelectOption with correct option when clicked', () => { + const store = createMockStore([], []); + const mockOnSelectOption = jest.fn(); + + render( + + + , + ); + + const mutePersonalBtn = screen.getByText('Mute user (for me)'); + fireEvent.click(mutePersonalBtn); + + expect(mockOnSelectOption).toHaveBeenCalledWith('mute-personal'); + }); + + it('does not render when isOpen is false', () => { + const store = createMockStore(); + render( + + + , + ); + + expect(screen.queryByText('Mute user (for me)')).not.toBeInTheDocument(); + }); + + it('calls onClose when escape key is pressed', () => { + const store = createMockStore(); + const mockOnClose = jest.fn(); + + render( + + + , + ); + + fireEvent.keyDown(document, { key: 'Escape' }); + expect(mockOnClose).toHaveBeenCalled(); + }); +}); diff --git a/src/discussions/common/UserActions.jsx b/src/discussions/common/UserActions.jsx new file mode 100644 index 000000000..d605e12ad --- /dev/null +++ b/src/discussions/common/UserActions.jsx @@ -0,0 +1,142 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import PropTypes from 'prop-types'; + +import { useDispatch, useSelector } from 'react-redux'; + +import { ContentActions } from '../../data/constants'; +import { selectUserHasModerationPrivileges, selectUserIsStaff } from '../data/selectors'; +import { muteAndReportUserThunk, muteUserThunk, unmuteUserThunk } from '../data/thunks'; +import { useActions } from '../utils'; +import ActionsDropdown from './ActionsDropdown'; +import MuteUserModal from './MuteUserModal'; +import StaffMuteOptionsModal from './StaffMuteOptionsModal'; + +const UserActions = ({ + contentType, + id, + username, + disabled = false, + dropDownIconSize = false, + iconSize = 'sm', +}) => { + const dispatch = useDispatch(); + const [showMuteModal, setShowMuteModal] = useState(false); + const userIsStaff = useSelector(selectUserIsStaff); + const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges); + const actions = useActions(contentType, id); + + // Get available actions and filter for mute-related ones + const hasMuteAction = useMemo(() => ( + actions.some(action => action.action === ContentActions.MUTE_USER) + ), [actions]); + + const hasUnmuteAction = useMemo(() => ( + actions.some(action => action.action === ContentActions.UNMUTE_USER) + ), [actions]); + + const handleMute = useCallback((targetUsername, isCourseWide = false) => { + dispatch(muteUserThunk(targetUsername, isCourseWide)); + }, [dispatch]); + + const handleUnmute = useCallback(() => { + // For direct unmute (learners), default to personal unmute + dispatch(unmuteUserThunk(username, false)); + }, [dispatch, username]); + + const handleMuteAndReport = useCallback((targetUsername, postId) => { + dispatch(muteAndReportUserThunk(targetUsername, postId)); + }, [dispatch]); + + const handleShowMuteModal = useCallback(() => { + setShowMuteModal(true); + }, []); + + const handleStaffMuteOption = useCallback((option) => { + switch (option) { + case 'mute-personal': + dispatch(muteUserThunk(username, false)); + break; + case 'mute-coursewide': + dispatch(muteUserThunk(username, true)); + break; + case 'unmute-personal': + dispatch(unmuteUserThunk(username, false)); + break; + case 'unmute-coursewide': + dispatch(unmuteUserThunk(username, true)); + break; + default: + break; + } + }, [dispatch, username]); + + const handleCloseMuteModal = useCallback(() => { + setShowMuteModal(false); + }, []); + + const isStaffOrModerator = userIsStaff || userHasModerationPrivileges; + + const actionHandlers = useMemo(() => { + const handlers = {}; + + // Handle mute action + if (hasMuteAction) { + handlers[ContentActions.MUTE_USER] = handleShowMuteModal; + } + + // Handle unmute action + if (hasUnmuteAction) { + handlers[ContentActions.UNMUTE_USER] = isStaffOrModerator + ? handleShowMuteModal // Staff can see modal with unmute options + : handleUnmute; // Learners get direct unmute + } + + // Mute and report for learners + if (!isStaffOrModerator && hasMuteAction) { + handlers[ContentActions.MUTE_AND_REPORT] = handleShowMuteModal; + } + + return handlers; + }, [hasMuteAction, hasUnmuteAction, isStaffOrModerator, handleShowMuteModal, handleUnmute]); + + return ( + <> + + {isStaffOrModerator ? ( + + ) : ( + + )} + + ); +}; + +UserActions.propTypes = { + contentType: PropTypes.oneOf(['POST', 'COMMENT']).isRequired, + id: PropTypes.string.isRequired, + username: PropTypes.string.isRequired, + disabled: PropTypes.bool, + dropDownIconSize: PropTypes.bool, + iconSize: PropTypes.string, +}; + +export default UserActions; diff --git a/src/discussions/common/index.js b/src/discussions/common/index.js index ffc87a2b9..fd465eba0 100644 --- a/src/discussions/common/index.js +++ b/src/discussions/common/index.js @@ -4,3 +4,8 @@ export { default as AuthorLabel } from './AuthorLabel'; export { default as AutoSpamAlertBanner } from './AutoSpamAlertBanner'; export { default as Confirmation } from './Confirmation'; export { default as EndorsedAlertBanner } from './EndorsedAlertBanner'; +export { default as MuteConfirmModal } from './MuteConfirmModal'; +export { default as MuteModalManager } from './MuteModalManager'; +export { default as MuteUserModal } from './MuteUserModal'; +export { default as StaffMuteOptionsModal } from './StaffMuteOptionsModal'; +export { default as UserActions } from './UserActions'; diff --git a/src/discussions/data/api.js b/src/discussions/data/api.js index 9ddc21fcc..3368e4887 100644 --- a/src/discussions/data/api.js +++ b/src/discussions/data/api.js @@ -27,3 +27,74 @@ export async function getDiscussionsSettings(courseId) { const { data } = await getAuthenticatedHttpClient().get(url); return data; } + +/** + * Mute a user in discussions + * @param {string} courseId + * @param {string} username + * @param {boolean} isCourseWide + * @returns {Promise<{}>} + */ +export async function muteUser(courseId, username, isCourseWide = false) { + const url = `${getConfig().LMS_BASE_URL}/api/discussion/v1/moderation/forum-mute/${courseId}`; + const { data } = await getAuthenticatedHttpClient().post(url, { + username, + is_course_wide: isCourseWide, + }); + return data; +} + +/** + * Unmute a user in discussions + * @param {string} courseId + * @param {string} username + * @param {boolean} isCourseWide + * @returns {Promise<{}>} + */ +export async function unmuteUser(courseId, username, isCourseWide = false) { + const url = `${getConfig().LMS_BASE_URL}/api/discussion/v1/moderation/forum-unmute/${courseId}`; + const { data } = await getAuthenticatedHttpClient().post(url, { + username, + is_course_wide: isCourseWide, + }); + return data; +} + +/** + * Mute and report a user in discussions + * @param {string} courseId + * @param {string} username + * @param {string} postId + * @returns {Promise<{}>} + */ +export async function muteAndReportUser(courseId, username, postId) { + const url = `${getConfig().LMS_BASE_URL}/api/discussion/v1/moderation/forum-mute-and-report/${courseId}`; + const { data } = await getAuthenticatedHttpClient().post(url, { + username, + post_id: postId, + }); + return data; +} + +/** + * Get list of muted users + * @param {string} courseId + * @returns {Promise<{}>} + */ +export async function getMutedUsers(courseId) { + const url = `${getConfig().LMS_BASE_URL}/api/discussion/v1/moderation/forum-muted-users/${courseId}`; + const { data } = await getAuthenticatedHttpClient().get(url); + return data; +} + +/** + * Check if a user is muted + * @param {string} courseId + * @param {string} username + * @returns {Promise<{}>} + */ +export async function checkMuteStatus(courseId, userId) { + const url = `${getConfig().LMS_BASE_URL}/api/discussion/v1/moderation/forum-mute-status/${courseId}/${userId}`; + const { data } = await getAuthenticatedHttpClient().get(url); + return data; +} diff --git a/src/discussions/data/selectors.js b/src/discussions/data/selectors.js index d9f102a71..4f8b0e3ff 100644 --- a/src/discussions/data/selectors.js +++ b/src/discussions/data/selectors.js @@ -59,6 +59,16 @@ export const selectModerationSettings = state => ({ export const selectDiscussionProvider = state => state.config.provider; +export const selectMutedUsers = state => state.config.mutedUsers || []; + +export const selectPersonalMutedUsers = state => state.config.personalMutedUsers || []; + +export const selectCourseWideMutedUsers = state => state.config.courseWideMutedUsers || []; + +export const selectMuteStatus = state => state.config.muteStatus; + +export const selectMuteError = state => state.config.muteError; + export function selectAreThreadsFiltered(state) { const { filters } = state.threads; diff --git a/src/discussions/data/slices.js b/src/discussions/data/slices.js index b6bc2e7ff..00862292d 100644 --- a/src/discussions/data/slices.js +++ b/src/discussions/data/slices.js @@ -6,6 +6,7 @@ const configSlice = createSlice({ name: 'config', initialState: { status: RequestStatus.IN_PROGRESS, + courseId: null, allowAnonymous: false, allowAnonymousToPeers: false, userRoles: [], @@ -32,6 +33,11 @@ const configSlice = createSlice({ enableInContext: false, isEmailVerified: false, contentCreationRateLimited: false, + mutedUsers: [], + personalMutedUsers: [], + courseWideMutedUsers: [], + muteStatus: RequestStatus.IDLE, + muteError: null, }, reducers: { fetchConfigRequest: (state) => ( @@ -63,6 +69,71 @@ const configSlice = createSlice({ contentCreationRateLimited: true, } ), + // Mute user actions + muteUserRequest: (state) => ({ + ...state, + muteStatus: RequestStatus.IN_PROGRESS, + muteError: null, + }), + muteUserSuccess: (state, { payload }) => { + const { username, isCourseWide, mutedUsers } = payload; + const newState = { ...state }; + if (isCourseWide) { + newState.courseWideMutedUsers = [...state.courseWideMutedUsers, username]; + } else { + newState.personalMutedUsers = [...state.personalMutedUsers, username]; + } + if (mutedUsers) { + newState.mutedUsers = mutedUsers; + } + newState.muteStatus = RequestStatus.SUCCESSFUL; + return newState; + }, + muteUserFailed: (state, { payload }) => ({ + ...state, + muteStatus: RequestStatus.FAILED, + muteError: payload, + }), + unmuteUserRequest: (state) => ({ + ...state, + muteStatus: RequestStatus.IN_PROGRESS, + muteError: null, + }), + unmuteUserSuccess: (state, { payload }) => { + const { username, isCourseWide, mutedUsers } = payload; + const newState = { ...state }; + if (isCourseWide) { + newState.courseWideMutedUsers = state.courseWideMutedUsers.filter(user => user !== username); + } else { + newState.personalMutedUsers = state.personalMutedUsers.filter(user => user !== username); + } + if (mutedUsers) { + newState.mutedUsers = mutedUsers; + } + newState.muteStatus = RequestStatus.SUCCESSFUL; + return newState; + }, + unmuteUserFailed: (state, { payload }) => ({ + ...state, + muteStatus: RequestStatus.FAILED, + muteError: payload, + }), + fetchMutedUsersRequest: (state) => ({ + ...state, + muteStatus: RequestStatus.IN_PROGRESS, + }), + fetchMutedUsersSuccess: (state, { payload }) => ({ + ...state, + mutedUsers: payload.mutedUsers || [], + personalMutedUsers: payload.personalMutedUsers || [], + courseWideMutedUsers: payload.courseWideMutedUsers || [], + muteStatus: RequestStatus.SUCCESSFUL, + }), + fetchMutedUsersFailed: (state, { payload }) => ({ + ...state, + muteStatus: RequestStatus.FAILED, + muteError: payload, + }), }, }); @@ -72,6 +143,15 @@ export const { fetchConfigRequest, fetchConfigSuccess, setContentCreationRateLimited, + muteUserRequest, + muteUserSuccess, + muteUserFailed, + unmuteUserRequest, + unmuteUserSuccess, + unmuteUserFailed, + fetchMutedUsersRequest, + fetchMutedUsersSuccess, + fetchMutedUsersFailed, } = configSlice.actions; export const configReducer = configSlice.reducer; diff --git a/src/discussions/data/thunks.js b/src/discussions/data/thunks.js index f57b09974..88414ff3d 100644 --- a/src/discussions/data/thunks.js +++ b/src/discussions/data/thunks.js @@ -8,9 +8,28 @@ import { import { setSortedBy } from '../learners/data'; import { setStatusFilter } from '../posts/data'; import { getHttpErrorStatus } from '../utils'; -import { getDiscussionsConfig, getDiscussionsSettings } from './api'; import { - fetchConfigDenied, fetchConfigFailed, fetchConfigRequest, fetchConfigSuccess, + getDiscussionsConfig, + getDiscussionsSettings, + getMutedUsers, + muteAndReportUser, + muteUser, + unmuteUser, +} from './api'; +import { + fetchConfigDenied, + fetchConfigFailed, + fetchConfigRequest, + fetchConfigSuccess, + fetchMutedUsersFailed, + fetchMutedUsersRequest, + fetchMutedUsersSuccess, + muteUserFailed, + muteUserRequest, + muteUserSuccess, + unmuteUserFailed, + unmuteUserRequest, + unmuteUserSuccess, } from './slices'; /** @@ -37,6 +56,7 @@ export default function fetchCourseConfig(courseId) { dispatch(fetchConfigSuccess(camelCaseObject({ ...config, + courseId, enable_in_context: config.provider === DiscussionProvider.OPEN_EDX, }))); dispatch(setSortedBy(learnerSort)); @@ -51,3 +71,93 @@ export default function fetchCourseConfig(courseId) { } }; } + +/** + * Mute a user in discussions + * @param {string} username + * @param {boolean} isCourseWide + * @returns {(function(*): Promise)|*} + */ +export function muteUserThunk(username, isCourseWide = false) { + return async (dispatch, getState) => { + const { courseId } = getState().config; + try { + dispatch(muteUserRequest()); + const response = await muteUser(courseId, username, isCourseWide); + dispatch(muteUserSuccess({ + username, + isCourseWide, + mutedUsers: response.muted_users, + })); + } catch (error) { + dispatch(muteUserFailed(error.message)); + logError(error); + } + }; +} + +/** + * Unmute a user in discussions + * @param {string} username + * @param {boolean} isCourseWide + * @returns {(function(*): Promise)|*} + */ +export function unmuteUserThunk(username, isCourseWide = false) { + return async (dispatch, getState) => { + const { courseId } = getState().config; + try { + dispatch(unmuteUserRequest()); + const response = await unmuteUser(courseId, username, isCourseWide); + dispatch(unmuteUserSuccess({ + username, + isCourseWide, + mutedUsers: response.muted_users, + })); + } catch (error) { + dispatch(unmuteUserFailed(error.message)); + logError(error); + } + }; +} + +/** + * Mute and report a user in discussions + * @param {string} username + * @param {string} postId + * @returns {(function(*): Promise)|*} + */ +export function muteAndReportUserThunk(username, postId) { + return async (dispatch, getState) => { + const { courseId } = getState().config; + try { + dispatch(muteUserRequest()); + const response = await muteAndReportUser(courseId, username, postId); + dispatch(muteUserSuccess({ + username, + isCourseWide: false, + mutedUsers: response.muted_users, + })); + } catch (error) { + dispatch(muteUserFailed(error.message)); + logError(error); + } + }; +} + +/** + * Fetch list of muted users + * @param {string} courseId + * @returns {(function(*): Promise)|*} + */ +export function fetchMutedUsersThunk(courseId) { + return async (dispatch) => { + try { + dispatch(fetchMutedUsersRequest()); + const response = await getMutedUsers(courseId); + dispatch(fetchMutedUsersSuccess(camelCaseObject(response))); + } catch (error) { + dispatch(fetchMutedUsersFailed(error.message)); + logError(error); + } + }; +} diff --git a/src/discussions/learners/LearnersView.jsx b/src/discussions/learners/LearnersView.jsx index f80e52216..cfec9195d 100644 --- a/src/discussions/learners/LearnersView.jsx +++ b/src/discussions/learners/LearnersView.jsx @@ -8,13 +8,15 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import SearchInfo from '../../components/SearchInfo'; import { RequestStatus } from '../../data/constants'; -import { selectConfigLoadingStatus } from '../data/selectors'; +import { selectConfigLoadingStatus, selectUserIsStaff } from '../data/selectors'; import NoResults from '../posts/NoResults'; import { learnersLoadingStatus, selectAllLearners, + selectCourseWideMutedUsers, selectLearnerNextPage, selectLearnerSorting, + selectPersonalMutedUsers, selectUsernameSearch, } from './data/selectors'; import { setUsernameSearch } from './data/slices'; @@ -32,6 +34,14 @@ const LearnersView = () => { const usernameSearch = useSelector(selectUsernameSearch()); const courseConfigLoadingStatus = useSelector(selectConfigLoadingStatus); const learners = useSelector(selectAllLearners); + const userIsStaff = useSelector(selectUserIsStaff); + const personalMutedUsers = useSelector(selectPersonalMutedUsers()); + const courseWideMutedUsers = useSelector(selectCourseWideMutedUsers()); + + // State for managing section expansion + const [isMutedCourseWideExpanded, setIsMutedCourseWideExpanded] = React.useState(false); + const [isMutedForMeExpanded, setIsMutedForMeExpanded] = React.useState(false); + const [isMutedExpanded, setIsMutedExpanded] = React.useState(false); useEffect(() => { if (usernameSearch) { @@ -77,6 +87,387 @@ const LearnersView = () => { /> )}
+ {learners.length > 0 && !usernameSearch && ( + <> + {/* Top divider */} +
+ + {/* Conditional sections based on user role */} + {userIsStaff ? ( + <> + {/* Staff-only sections */} + {/* Muted course-wide section */} +
setIsMutedCourseWideExpanded(!isMutedCourseWideExpanded)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setIsMutedCourseWideExpanded(!isMutedCourseWideExpanded); + } + }} + role="button" + tabIndex={0} + aria-expanded={isMutedCourseWideExpanded} + aria-label="Toggle course-wide muted users section" + > +
+
+ + {intl.formatMessage(messages.mutedCourseWide)} + + + + +
+
+
+ + + +
+
+ + {/* Muted course-wide learners list */} + {isMutedCourseWideExpanded && ( +
+ {courseWideMutedUsers.length > 0 ? ( + courseWideMutedUsers.map(username => ( +
+ +
+ )) + ) : ( +
+ No course-wide muted learners +
+ )} +
+ )} + + {/* Divider */} +
+ + {/* Muted (for me) section */} +
setIsMutedForMeExpanded(!isMutedForMeExpanded)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setIsMutedForMeExpanded(!isMutedForMeExpanded); + } + }} + role="button" + tabIndex={0} + aria-expanded={isMutedForMeExpanded} + aria-label="Toggle personally muted users section" + > +
+
+ + {intl.formatMessage(messages.mutedForMe)} + + + + +
+
+
+ + + +
+
+ + {/* Muted (for me) learners list */} + {isMutedForMeExpanded && ( +
+ {personalMutedUsers.length > 0 ? ( + personalMutedUsers.map(username => ( +
+ +
+ )) + ) : ( +
+ No personally muted learners +
+ )} +
+ )} + + ) : ( + <> + {/* Learner-only section */} + {/* Muted section */} +
setIsMutedExpanded(!isMutedExpanded)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setIsMutedExpanded(!isMutedExpanded); + } + }} + role="button" + tabIndex={0} + aria-expanded={isMutedExpanded} + aria-label="Toggle muted users section" + > +
+
+ + {intl.formatMessage(messages.muted)} + + + + +
+
+
+ + + +
+
+ + {/* Muted learners list */} + {isMutedExpanded && ( +
+ {personalMutedUsers.length > 0 ? ( + personalMutedUsers.map(username => ( +
+ +
+ )) + ) : ( +
+ No muted learners +
+ )} +
+ )} + + )} + + {/* Divider */} +
+ + {/* All other learners section */} +
+ + {intl.formatMessage(messages.allOtherLearners)} + +
+ + )} {renderLearnersList} {loadingStatus === RequestStatus.IN_PROGRESS ? (
diff --git a/src/discussions/learners/data/selectors.js b/src/discussions/learners/data/selectors.js index 107634456..b24474b93 100644 --- a/src/discussions/learners/data/selectors.js +++ b/src/discussions/learners/data/selectors.js @@ -18,3 +18,14 @@ export const selectLearnerAvatar = author => state => ( ); export const selectBulkDeleteStats = () => state => state.learners.bulkDeleteStats; + +// Muted users selectors +export const selectPersonalMutedUsers = () => state => state.config.personalMutedUsers || []; + +export const selectCourseWideMutedUsers = () => state => state.config.courseWideMutedUsers || []; + +export const selectAllMutedUsers = () => state => [ + ...(state.config.personalMutedUsers || []), + ...(state.config.courseWideMutedUsers || []), + ...(state.config.mutedUsers || []), +]; diff --git a/src/discussions/learners/messages.js b/src/discussions/learners/messages.js index 38403e56e..f076ed5dd 100644 --- a/src/discussions/learners/messages.js +++ b/src/discussions/learners/messages.js @@ -101,6 +101,26 @@ const messages = defineMessages({ defaultMessage: 'This action cannot be undone.', description: 'Bold disclaimer description for delete confirmation dialog', }, + allOtherLearners: { + id: 'discussions.learner.allOtherLearners', + defaultMessage: 'All other learners', + description: 'Heading text for the list of all other learners', + }, + mutedCourseWide: { + id: 'discussions.learner.mutedCourseWide', + defaultMessage: 'Muted course-wide', + description: 'Heading text for the list of course-wide muted learners', + }, + mutedForMe: { + id: 'discussions.learner.mutedForMe', + defaultMessage: 'Muted (for me)', + description: 'Heading text for the list of personally muted learners', + }, + muted: { + id: 'discussions.learners.muted', + defaultMessage: 'Muted', + description: 'Text for muted section header for learners', + }, }); export default messages; diff --git a/src/discussions/messages.js b/src/discussions/messages.js index 3589aaf0d..39c8f04f3 100644 --- a/src/discussions/messages.js +++ b/src/discussions/messages.js @@ -49,12 +49,27 @@ const messages = defineMessages({ reportAction: { id: 'discussions.actions.report', defaultMessage: 'Report', - description: 'Action to report a post or comment', + description: 'Action to report a post', }, unreportAction: { id: 'discussions.actions.unreport', defaultMessage: 'Unreport', - description: 'Action to unreport a post or comment', + description: 'Action to unreport a post', + }, + muteAction: { + id: 'discussions.actions.mute', + defaultMessage: 'Mute', + description: 'Action to mute a user', + }, + unmuteAction: { + id: 'discussions.actions.unmute', + defaultMessage: 'Unmute', + description: 'Action to unmute a user', + }, + muteAndReportAction: { + id: 'discussions.actions.muteAndReport', + defaultMessage: 'Mute & Report', + description: 'Action to mute and report a user', }, endorseAction: { id: 'discussions.actions.endorse', diff --git a/src/discussions/pages/MutedUsersPage.jsx b/src/discussions/pages/MutedUsersPage.jsx new file mode 100644 index 000000000..ba0df7570 --- /dev/null +++ b/src/discussions/pages/MutedUsersPage.jsx @@ -0,0 +1,234 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; + +import { + Button, + Card, + Container, + Nav, + Tab, + Table, +} from '@openedx/paragon'; +import { useDispatch, useSelector } from 'react-redux'; +import { useParams } from 'react-router-dom'; + +import { useIntl } from '@edx/frontend-platform/i18n'; + +import { RequestStatus } from '../../data/constants'; +import { selectUserHasModerationPrivileges, selectUserIsStaff } from '../data/selectors'; +import { fetchMutedUsersThunk, unmuteUserThunk } from '../data/thunks'; + +// Custom minus icon component +const MinusIcon = ({ className, size }) => { + const getIconSize = () => { + if (size === 'lg') { + return '24px'; + } + if (size === 'sm') { + return '16px'; + } + return '20px'; + }; + return ( + + + + ); +}; + +MinusIcon.propTypes = { + className: PropTypes.string, + size: PropTypes.string, +}; + +MinusIcon.defaultProps = { + className: '', + size: 'md', +}; + +const MutedUsersPage = () => { + const intl = useIntl(); + const dispatch = useDispatch(); + const { courseId } = useParams(); + const [activeTab, setActiveTab] = useState('personal'); + + const userIsStaff = useSelector(selectUserIsStaff); + const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges); + const personalMutedUsers = useSelector(state => state.config.personalMutedUsers || []); + const courseWideMutedUsers = useSelector(state => state.config.courseWideMutedUsers || []); + const muteStatus = useSelector(state => state.config.muteStatus); + + const isStaffOrModerator = userIsStaff || userHasModerationPrivileges; + const isLoading = muteStatus === RequestStatus.IN_PROGRESS; + + useEffect(() => { + dispatch(fetchMutedUsersThunk(courseId)); + }, [dispatch, courseId]); + + const handleUnmute = useCallback((username, isCourseWide) => { + dispatch(unmuteUserThunk(username, isCourseWide)); + }, [dispatch]); + + const renderUserTable = useCallback((users, isCourseWide = false) => { + if (users.length === 0) { + return ( +
+ +

+ {intl.formatMessage({ + id: 'discussions.muted.users.empty', + defaultMessage: 'No muted users', + })} +

+
+ ); + } + + return ( + + + + + + + + + {users.map(username => ( + + + + + ))} + +
+ {intl.formatMessage({ + id: 'discussions.muted.users.username', + defaultMessage: 'Username', + })} + + {intl.formatMessage({ + id: 'discussions.muted.users.actions', + defaultMessage: 'Actions', + })} +
+
+ + {username} +
+
+ +
+ ); + }, [handleUnmute, isLoading, intl]); + + return ( + + + +
+ +

+ {intl.formatMessage({ + id: 'discussions.muted.users.title', + defaultMessage: 'Muted Users', + })} +

+
+
+ + {isStaffOrModerator ? ( + + + + +
+

+ {intl.formatMessage({ + id: 'discussions.muted.users.personal.description', + defaultMessage: 'Users you have personally muted. Only you cannot see their posts and comments.', + })} +

+ {renderUserTable(personalMutedUsers, false)} +
+
+ +
+

+ {intl.formatMessage({ + id: 'discussions.muted.users.coursewide.description', + defaultMessage: 'Users muted course-wide. All learners cannot see their posts and comments.', + })} +

+ {renderUserTable(courseWideMutedUsers, true)} +
+
+
+
+ ) : ( + // Learner view - only personal mutes +
+

+ {intl.formatMessage({ + id: 'discussions.muted.users.learner.description', + defaultMessage: 'Users you have muted. You cannot see their posts and comments.', + })} +

+ {renderUserTable(personalMutedUsers, false)} +
+ )} +
+
+
+ ); +}; + +export default MutedUsersPage; diff --git a/src/discussions/post-comments/comments/comment/Comment.jsx b/src/discussions/post-comments/comments/comment/Comment.jsx index c2d96be80..c2ac75684 100644 --- a/src/discussions/post-comments/comments/comment/Comment.jsx +++ b/src/discussions/post-comments/comments/comment/Comment.jsx @@ -7,7 +7,9 @@ import React, { } from 'react'; import PropTypes from 'prop-types'; -import { Button, useToggle } from '@openedx/paragon'; +import { + ActionRow, Button, ModalDialog, useToggle, +} from '@openedx/paragon'; import classNames from 'classnames'; import { useDispatch, useSelector } from 'react-redux'; @@ -20,15 +22,22 @@ import { AutoSpamAlertBanner, Confirmation, EndorsedAlertBanner, + MuteModalManager, } from '../../../common'; import DiscussionContext from '../../../common/context'; import HoverCard from '../../../common/HoverCard'; import withPostingRestrictions from '../../../common/withPostingRestrictions'; import { ContentTypes } from '../../../data/constants'; import { useUserPostingEnabled } from '../../../data/hooks'; -import { selectContentCreationRateLimited, selectShouldShowEmailConfirmation } from '../../../data/selectors'; +import { + selectContentCreationRateLimited, + selectShouldShowEmailConfirmation, + selectUserHasModerationPrivileges, + selectUserIsStaff, +} from '../../../data/selectors'; import { fetchThread } from '../../../posts/data/thunks'; import LikeButton from '../../../posts/post/LikeButton'; +import postMessages from '../../../posts/post/messages'; import { useActions } from '../../../utils'; import { selectCommentCurrentPage, @@ -67,6 +76,11 @@ const Comment = ({ const [isReplying, setReplying] = useState(false); const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false); const [isReporting, showReportConfirmation, hideReportConfirmation] = useToggle(false); + const [isLearnerMuting, showLearnerMuteModal, hideLearnerMuteModal] = useToggle(false); + const [isStaffMuting, showStaffMuteModal, hideStaffMuteModal] = useToggle(false); + const [isUnmuting, showUnmuteModal, hideUnmuteModal] = useToggle(false); + const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges); + const userIsStaff = useSelector(selectUserIsStaff); const inlineReplies = useSelector(selectCommentResponses(id)); const inlineRepliesIds = useSelector(selectCommentResponsesIds(id)); const hasMorePages = useSelector(selectCommentHasMorePages(id)); @@ -123,12 +137,57 @@ const Comment = ({ await dispatch(editComment(id, { voted: !voted })); }, [id, voted]); + const handleCommentMute = useCallback(() => { + // TODO: Implement actual mute functionality + // eslint-disable-next-line no-console + console.log(`Muting user: ${author}`); + // This would typically call an API to mute the user + // dispatch(muteUser(author)); + }, [author]); + + const handleLearnerMute = useCallback(() => { + handleCommentMute(); + hideLearnerMuteModal(); + }, [handleCommentMute, hideLearnerMuteModal]); + + const handleLearnerMuteAndReport = useCallback(() => { + handleCommentMute(); + handleReportConfirmation(); + hideLearnerMuteModal(); + }, [handleCommentMute, handleReportConfirmation, hideLearnerMuteModal]); + + const handleUnmute = useCallback(() => { + // TODO: Implement actual unmute functionality + // eslint-disable-next-line no-console + console.log(`Unmuting user: ${author}`); + // This would typically call an API to unmute the user + // dispatch(unmuteUser(author)); + hideUnmuteModal(); + }, [author, hideUnmuteModal]); + + const showMuteModal = useCallback(() => { + if (userHasModerationPrivileges || userIsStaff) { + showStaffMuteModal(); + } else { + showLearnerMuteModal(); + } + }, [userHasModerationPrivileges, userIsStaff, showStaffMuteModal, showLearnerMuteModal]); + const actionHandlers = useMemo(() => ({ [ContentActions.EDIT_CONTENT]: handleEditContent, [ContentActions.ENDORSE]: handleCommentEndorse, [ContentActions.DELETE]: showDeleteConfirmation, [ContentActions.REPORT]: handleAbusedFlag, - }), [handleEditContent, handleCommentEndorse, showDeleteConfirmation, handleAbusedFlag]); + [ContentActions.MUTE_USER]: showMuteModal, + [ContentActions.UNMUTE_USER]: showUnmuteModal, + }), [ + handleEditContent, + handleCommentEndorse, + showDeleteConfirmation, + handleAbusedFlag, + showMuteModal, + showUnmuteModal, + ]); const handleLoadMoreComments = useCallback(() => ( dispatch(fetchCommentResponses(id, { @@ -183,6 +242,75 @@ const Comment = ({ confirmButtonVariant="danger" /> )} + {/* Learner Mute Modal */} + + + + {intl.formatMessage(postMessages.learnerMuteTitle)} + + + +

{intl.formatMessage(postMessages.learnerMuteDescription, { username: author })}

+
+ + + + Cancel + + + + + +
+ {/* Staff Mute Modal Manager */} + + {/* Unmute Modal */} + + + + {intl.formatMessage(postMessages.unmuteTitle)} + + + +

{intl.formatMessage(postMessages.unmuteDescription, { username: author })}

+
+ + + + Cancel + + + + +
{ const [isEditing, setEditing] = useState(false); const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false); const [isReporting, showReportConfirmation, hideReportConfirmation] = useToggle(false); + const [isLearnerMuting, showLearnerMuteModal, hideLearnerMuteModal] = useToggle(false); + const [isStaffMuting, showStaffMuteModal, hideStaffMuteModal] = useToggle(false); + const [isUnmuting, showUnmuteModal, hideUnmuteModal] = useToggle(false); + const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges); + const userIsStaff = useSelector(selectUserIsStaff); const colorClass = AvatarOutlineAndLabelColors[authorLabel]; // If isSpam is not provided in the API response, default to false const isSpamFlagged = isSpam || false; @@ -74,12 +84,29 @@ const Reply = ({ responseId }) => { setEditing(false); }, []); + const showMuteModal = useCallback(() => { + if (userHasModerationPrivileges || userIsStaff) { + showStaffMuteModal(); + } else { + showLearnerMuteModal(); + } + }, [userHasModerationPrivileges, userIsStaff, showStaffMuteModal, showLearnerMuteModal]); + const actionHandlers = useMemo(() => ({ [ContentActions.EDIT_CONTENT]: handleEditContent, [ContentActions.ENDORSE]: handleReplyEndorse, [ContentActions.DELETE]: showDeleteConfirmation, [ContentActions.REPORT]: handleAbusedFlag, - }), [handleEditContent, handleReplyEndorse, showDeleteConfirmation, handleAbusedFlag]); + [ContentActions.MUTE_USER]: showMuteModal, + [ContentActions.UNMUTE_USER]: showUnmuteModal, + }), [ + handleEditContent, + handleReplyEndorse, + showDeleteConfirmation, + handleAbusedFlag, + showMuteModal, + showUnmuteModal, + ]); return (
@@ -102,6 +129,18 @@ const Reply = ({ responseId }) => { confirmButtonVariant="danger" /> )} + {/* Universal Mute Modal Manager - handles all mute/unmute modals */} + {hasAnyAlert && (
diff --git a/src/discussions/post-comments/data/selectors.js b/src/discussions/post-comments/data/selectors.js index f048733a6..cd51576c0 100644 --- a/src/discussions/post-comments/data/selectors.js +++ b/src/discussions/post-comments/data/selectors.js @@ -1,8 +1,29 @@ import { createSelector } from '@reduxjs/toolkit'; const selectCommentsById = state => state.comments.commentsById; + +// Helper to filter out comments from muted users +const filterMutedComments = (comments, mutedUsers, personalMutedUsers, courseWideMutedUsers) => { + const allMutedUsers = [ + ...(mutedUsers || []), + ...(personalMutedUsers || []), + ...(courseWideMutedUsers || []), + ]; + + if (allMutedUsers.length === 0) { + return comments; + } + + return comments.filter(comment => comment && !allMutedUsers.includes(comment.author)); +}; + const mapIdToComment = (ids, comments) => ids.map(id => comments[id]); +const mapIdToFilteredComment = (ids, comments, mutedUsers, personalMutedUsers, courseWideMutedUsers) => { + const allComments = mapIdToComment(ids, comments); + return filterMutedComments(allComments, mutedUsers, personalMutedUsers, courseWideMutedUsers); +}; + export const selectCommentOrResponseById = commentOrResponseId => createSelector( selectCommentsById, comments => comments[commentOrResponseId], @@ -12,8 +33,11 @@ export const selectThreadComments = (threadId) => createSelector( [ state => state.comments.commentsInThreads[threadId] || [], selectCommentsById, + state => state.config.mutedUsers, + state => state.config.personalMutedUsers, + state => state.config.courseWideMutedUsers, ], - mapIdToComment, + (ids, comments, mutedUsers, personalMutedUsers, courseWideMutedUsers) => mapIdToFilteredComment(ids, comments, mutedUsers, personalMutedUsers, courseWideMutedUsers), ); export const selectCommentResponsesIds = commentId => ( @@ -24,8 +48,11 @@ export const selectCommentResponses = commentId => createSelector( [ state => state.comments.commentsInComments[commentId] || [], selectCommentsById, + state => state.config.mutedUsers, + state => state.config.personalMutedUsers, + state => state.config.courseWideMutedUsers, ], - mapIdToComment, + (ids, comments, mutedUsers, personalMutedUsers, courseWideMutedUsers) => mapIdToFilteredComment(ids, comments, mutedUsers, personalMutedUsers, courseWideMutedUsers), ); export const selectThreadHasMorePages = (threadId) => ( diff --git a/src/discussions/posts/data/selectors.js b/src/discussions/posts/data/selectors.js index b825f93a6..732c2439e 100644 --- a/src/discussions/posts/data/selectors.js +++ b/src/discussions/posts/data/selectors.js @@ -3,16 +3,39 @@ import camelCase from 'lodash/camelCase'; const selectThreads = state => state.threads.threadsById; +// Helper to filter out threads from muted users +const filterMutedThreads = (threads, mutedUsers, personalMutedUsers, courseWideMutedUsers) => { + const allMutedUsers = [ + ...(mutedUsers || []), + ...(personalMutedUsers || []), + ...(courseWideMutedUsers || []), + ]; + + if (allMutedUsers.length === 0) { + return threads; + } + + return threads.filter(thread => thread && !allMutedUsers.includes(thread.author)); +}; + const mapIdsToThreads = (ids, threads) => ids.map(id => threads?.[id]); +const mapIdsToFilteredThreads = (ids, threads, mutedUsers, personalMutedUsers, courseWideMutedUsers) => { + const allThreads = mapIdsToThreads(ids, threads); + return filterMutedThreads(allThreads, mutedUsers, personalMutedUsers, courseWideMutedUsers); +}; + export const selectPostEditorVisible = state => state.threads.postEditorVisible; export const selectTopicThreads = topicIds => createSelector( [ state => (topicIds || []).flatMap(topicId => state.threads.threadsInTopic[topicId] || []), selectThreads, + state => state.config.mutedUsers, + state => state.config.personalMutedUsers, + state => state.config.courseWideMutedUsers, ], - mapIdsToThreads, + (ids, threads, mutedUsers, personalMutedUsers, courseWideMutedUsers) => mapIdsToFilteredThreads(ids, threads, mutedUsers, personalMutedUsers, courseWideMutedUsers), ); export const selectTopicThreadsIds = topicIds => state => ( @@ -20,8 +43,13 @@ export const selectTopicThreadsIds = topicIds => state => ( ); export const selectThreadsByIds = ids => createSelector( - [selectThreads], - (threads) => mapIdsToThreads(ids, threads), + [ + selectThreads, + state => state.config.mutedUsers, + state => state.config.personalMutedUsers, + state => state.config.courseWideMutedUsers, + ], + (threads, mutedUsers, personalMutedUsers, courseWideMutedUsers) => mapIdsToFilteredThreads(ids, threads, mutedUsers, personalMutedUsers, courseWideMutedUsers), ); export const selectThread = threadId => createSelector( @@ -33,16 +61,25 @@ export const selectAllThreadsOnPage = (page) => createSelector( [ state => state.threads.pages[page] || [], selectThreads, + state => state.config.mutedUsers, + state => state.config.personalMutedUsers, + state => state.config.courseWideMutedUsers, ], - mapIdsToThreads, + (ids, threads, mutedUsers, personalMutedUsers, courseWideMutedUsers) => mapIdsToFilteredThreads(ids, threads, mutedUsers, personalMutedUsers, courseWideMutedUsers), ); export const selectAllThreads = createSelector( [ state => state.threads.pages, selectThreads, + state => state.config.mutedUsers, + state => state.config.personalMutedUsers, + state => state.config.courseWideMutedUsers, ], - (pages, threads) => pages.flatMap(ids => mapIdsToThreads(ids, threads)), + (pages, threads, mutedUsers, personalMutedUsers, courseWideMutedUsers) => { + const allIds = pages.flatMap(ids => ids); + return mapIdsToFilteredThreads(allIds, threads, mutedUsers, personalMutedUsers, courseWideMutedUsers); + }, ); export const selectAllThreadsIds = createSelector( diff --git a/src/discussions/posts/post/Post.jsx b/src/discussions/posts/post/Post.jsx index 965a81c67..1f88102c1 100644 --- a/src/discussions/posts/post/Post.jsx +++ b/src/discussions/posts/post/Post.jsx @@ -13,12 +13,19 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import HTMLLoader from '../../../components/HTMLLoader'; import { ContentActions, getFullUrl } from '../../../data/constants'; import { selectorForUnitSubsection, selectTopicContext } from '../../../data/selectors'; -import { AlertBanner, AutoSpamAlertBanner, Confirmation } from '../../common'; +import { + AlertBanner, AutoSpamAlertBanner, Confirmation, MuteModalManager, +} from '../../common'; import DiscussionContext from '../../common/context'; import HoverCard from '../../common/HoverCard'; import withPostingRestrictions from '../../common/withPostingRestrictions'; import { ContentTypes } from '../../data/constants'; -import { selectContentCreationRateLimited, selectShouldShowEmailConfirmation, selectUserHasModerationPrivileges } from '../../data/selectors'; +import { + selectContentCreationRateLimited, + selectShouldShowEmailConfirmation, + selectUserHasModerationPrivileges, + selectUserIsStaff, +} from '../../data/selectors'; import { selectTopic } from '../../topics/data/selectors'; import { truncatePath } from '../../utils'; import { selectThread } from '../data/selectors'; @@ -47,7 +54,11 @@ const Post = ({ handleAddResponseButton, openRestrictionDialogue }) => { const [isDeleting, showDeleteConfirmation, hideDeleteConfirmation] = useToggle(false); const [isReporting, showReportConfirmation, hideReportConfirmation] = useToggle(false); const [isClosing, showClosePostModal, hideClosePostModal] = useToggle(false); + const [isLearnerMuting, showLearnerMuteModal, hideLearnerMuteModal] = useToggle(false); + const [isStaffMuting, showStaffMuteModal, hideStaffMuteModal] = useToggle(false); + const [isUnmuting, showUnmuteModal, hideUnmuteModal] = useToggle(false); const userHasModerationPrivileges = useSelector(selectUserHasModerationPrivileges); + const userIsStaff = useSelector(selectUserIsStaff); const shouldShowEmailConfirmation = useSelector(selectShouldShowEmailConfirmation); const contentCreationRateLimited = useSelector(selectContentCreationRateLimited); // If isSpam is not provided in the API response, default to false @@ -103,6 +114,14 @@ const Post = ({ handleAddResponseButton, openRestrictionDialogue }) => { } }, [abuseFlagged, postId, showReportConfirmation]); + const showMuteModal = useCallback(() => { + if (userHasModerationPrivileges || userIsStaff) { + showStaffMuteModal(); + } else { + showLearnerMuteModal(); + } + }, [userHasModerationPrivileges, userIsStaff, showStaffMuteModal, showLearnerMuteModal]); + const actionHandlers = useMemo(() => ({ [ContentActions.EDIT_CONTENT]: handlePostContentEdit, [ContentActions.DELETE]: showDeleteConfirmation, @@ -110,8 +129,17 @@ const Post = ({ handleAddResponseButton, openRestrictionDialogue }) => { [ContentActions.COPY_LINK]: handlePostCopyLink, [ContentActions.PIN]: handlePostPin, [ContentActions.REPORT]: handlePostReport, + [ContentActions.MUTE_USER]: showMuteModal, + [ContentActions.UNMUTE_USER]: showUnmuteModal, }), [ - handlePostClose, handlePostContentEdit, handlePostCopyLink, handlePostPin, handlePostReport, showDeleteConfirmation, + handlePostClose, + handlePostContentEdit, + handlePostCopyLink, + handlePostPin, + handlePostReport, + showMuteModal, + showUnmuteModal, + showDeleteConfirmation, ]); const handleClosePostConfirmation = useCallback((closeReasonCode) => { @@ -157,6 +185,18 @@ const Post = ({ handleAddResponseButton, openRestrictionDialogue }) => { confirmButtonVariant="danger" /> )} + {/* Universal Mute Modal Manager - handles all mute/unmute modals */} +