@@ -102,6 +129,18 @@ const Reply = ({ responseId }) => {
confirmButtonVariant="danger"
/>
)}
+ {/* Universal Mute Modal Manager - handles all mute/unmute modals */}
+
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 */}
+
[DENIED, LOADED].includes(courseStatus);
+
/**
* Get HTTP Error status from generic error.
* @param error Generic caught error.
@@ -60,13 +62,17 @@ export function useCommentsPagePath() {
* @returns {boolean}
*/
export function checkPermissions(content, action) {
- if (content.editableFields.includes(action)) {
+ if (content.editableFields?.includes(action)) {
return true;
}
// For delete action we check `content.canDelete`
if (action === ContentActions.DELETE) {
return true;
}
+ // For mute actions we check `content.canMute`
+ if (action === ContentActions.MUTE_USER || action === ContentActions.UNMUTE_USER) {
+ return Boolean(content.canMute);
+ }
return false;
}
@@ -177,6 +183,22 @@ export const ACTIONS_LIST = [
label: messages.unreportAction,
conditions: { abuseFlagged: true },
},
+ {
+ id: 'mute',
+ action: ContentActions.MUTE_USER,
+ icon: RemoveCircleOutline,
+ label: messages.muteAction,
+ hasChevron: true,
+ conditions: { canMute: true, isMuted: false },
+ },
+ {
+ id: 'unmute',
+ action: ContentActions.UNMUTE_USER,
+ icon: RemoveCircleOutline,
+ label: messages.unmuteAction,
+ hasChevron: true,
+ conditions: { canMute: true, isMuted: true },
+ },
{
id: 'delete',
action: ContentActions.DELETE,
@@ -188,7 +210,55 @@ export const ACTIONS_LIST = [
export function useActions(contentType, id) {
const { postType } = useContext(PostCommentsContext);
- const content = { ...useSelector(ContentSelectors[contentType](id)), postType };
+ const baseContent = useSelector(ContentSelectors[contentType](id));
+ const userIsStaff = useSelector(state => state.config.isCourseStaff || state.config.isUserAdmin);
+ const userHasModerationPrivileges = useSelector(state => state.config.hasModerationPrivileges);
+ const currentUser = useSelector(state => state.config.username);
+ const mutedUsers = useSelector(state => state.config.mutedUsers || []);
+ const personalMutedUsers = useSelector(state => state.config.personalMutedUsers || []);
+ const courseWideMutedUsers = useSelector(state => state.config.courseWideMutedUsers || []);
+
+ // Calculate if current user can mute the post author
+ const canMute = useMemo(() => {
+ if (!baseContent?.author) {
+ return false;
+ }
+
+ // Users cannot mute themselves
+ if (currentUser === baseContent.author) {
+ return false;
+ }
+
+ // Staff/moderators can mute learners but not other staff
+ if (userIsStaff || userHasModerationPrivileges) {
+ // Check if post author is staff (this would need to come from post data)
+ // For now, assume non-staff authors can be muted by staff
+ return !baseContent.authorLabel
+ || !['Staff', 'Moderator', 'Community TA'].includes(baseContent.authorLabel);
+ }
+
+ // Learners can mute other learners but not staff
+ if (baseContent.authorLabel
+ && ['Staff', 'Moderator', 'Community TA'].includes(baseContent.authorLabel)) {
+ return false;
+ }
+
+ return true;
+ }, [baseContent, userIsStaff, userHasModerationPrivileges, currentUser]);
+
+ // Check if user is muted
+ const isMuted = useMemo(() => {
+ if (!baseContent?.author) {
+ return false;
+ }
+ return mutedUsers.includes(baseContent.author)
+ || personalMutedUsers.includes(baseContent.author)
+ || courseWideMutedUsers.includes(baseContent.author);
+ }, [baseContent, mutedUsers, personalMutedUsers, courseWideMutedUsers]);
+
+ const content = {
+ ...baseContent, postType, canMute, isMuted,
+ };
const checkConditions = useCallback((item, conditions) => (
conditions
@@ -315,8 +385,6 @@ export function getAuthorLabel(intl, authorLabel) {
return authorLabelMappings[authorLabel] || {};
}
-export const isCourseStatusValid = (courseStatus) => [DENIED, LOADED].includes(courseStatus);
-
export const extractContent = (content) => {
if (typeof content === 'object') {
return content.target.getContent();