diff --git a/CHANGELOG.md b/CHANGELOG.md index d735f3f69..ebb8217e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [1.132.0] - Not released +### Added +- Ability to "unlock" and preview comments from the banned users and with reply + to the banned users. ### Fixed - Fixed a bug where the first partial opening of comments ("show last N") would open one extra comment. diff --git a/src/components/alternative-text.jsx b/src/components/alternative-text.jsx new file mode 100644 index 000000000..1606a1305 --- /dev/null +++ b/src/components/alternative-text.jsx @@ -0,0 +1,29 @@ +import cn from 'classnames'; +import { faTimes } from '@fortawesome/free-solid-svg-icons'; +import { Icon } from './fontawesome-icons'; +import { ButtonLink } from './button-link'; +import style from './alternative-text.module.scss'; + +export function AlternativeText({ + status, + icon, + isError = false, + inComment = false, + close, + children, +}) { + return ( +
+
+ + + + {status} + + + +
+
{children}
+
+ ); +} diff --git a/src/components/translated-text.module.scss b/src/components/alternative-text.module.scss similarity index 100% rename from src/components/translated-text.module.scss rename to src/components/alternative-text.module.scss diff --git a/src/components/comment-likers.js b/src/components/comment-likers.js index a85be299f..51a5d7a32 100644 --- a/src/components/comment-likers.js +++ b/src/components/comment-likers.js @@ -8,10 +8,11 @@ import { initialAsyncState } from '../redux/async-helpers'; * Load and return all likers of the given comment * * @param {string} id + * @param {boolean} suppress do not load likes (when we know they aren't available) */ -export function useCommentLikers(id) { +export function useCommentLikers(id, suppress = false) { const dispatch = useDispatch(); - useEffect(() => void dispatch(getCommentLikes(id)), [dispatch, id]); + useEffect(() => void (!suppress && dispatch(getCommentLikes(id))), [suppress, dispatch, id]); return useSelector((state) => { const { status = initialAsyncState, likes = [] } = state.commentLikes[id] || {}; return { diff --git a/src/components/post/post-comment-more-menu.jsx b/src/components/post/post-comment-more-menu.jsx index 94103bc29..2e9b80027 100644 --- a/src/components/post/post-comment-more-menu.jsx +++ b/src/components/post/post-comment-more-menu.jsx @@ -9,6 +9,7 @@ import { faEdit, faHeartBroken, faLink, + faLockOpen, faUserFriends, } from '@fortawesome/free-solid-svg-icons'; import { faHeart, faClock, faTrashAlt } from '@fortawesome/free-regular-svg-icons'; @@ -35,6 +36,7 @@ export const PostCommentMoreMenu = forwardRef(function PostCommentMore( doLike, doUnlike, doShowLikes, + doUnlock, getBackwardIdx, createdAt, updatedAt, @@ -46,7 +48,7 @@ export const PostCommentMoreMenu = forwardRef(function PostCommentMore( }, menuRef, ) { - const { status, likers } = useCommentLikers(id); + const { status, likers } = useCommentLikers(id, isHidden); const myUsername = useSelector((state) => state.user.username); const bIdx = getBackwardIdx(); const arrows = bIdx <= 4 ? '^'.repeat(bIdx) : `^${bIdx}`; @@ -113,6 +115,13 @@ export const PostCommentMoreMenu = forwardRef(function PostCommentMore( !isHidden && ( ), + doUnlock && ( +
+ + Show comment + +
+ ), ], [ doDelete && authorUsername !== myUsername && ( diff --git a/src/components/post/post-comment.jsx b/src/components/post/post-comment.jsx index 344d3b8e9..4cc1d5955 100644 --- a/src/components/post/post-comment.jsx +++ b/src/components/post/post-comment.jsx @@ -5,7 +5,10 @@ import classnames from 'classnames'; import { connect } from 'react-redux'; import { preventDefault, confirmFirst } from '../../utils'; -import { READMORE_STYLE_COMPACT } from '../../utils/frontend-preferences-options'; +import { + HIDDEN_AUTHOR_BANNED, + READMORE_STYLE_COMPACT, +} from '../../utils/frontend-preferences-options'; import { commentReadmoreConfig } from '../../utils/readmore-config'; import { defaultCommentState } from '../../redux/reducers/comment-edit'; @@ -22,6 +25,7 @@ import { Separated } from '../separated'; import { TranslatedText } from '../translated-text'; import { initialAsyncState } from '../../redux/async-helpers'; import { existingCommentURI, newCommentURI } from '../../services/drafts'; +import { UnlockedHiddenComment } from '../unlocked-hidden-comment'; import { PostCommentMore } from './post-comment-more'; import { PostCommentPreview } from './post-comment-preview'; import { CommentProvider } from './post-comment-provider'; @@ -36,6 +40,7 @@ class PostComment extends Component { previewSeqNumber: 0, previewLeft: 0, previewTop: 0, + unlockHidden: false, }; scrollToComment = () => { @@ -158,9 +163,13 @@ class PostComment extends Component { canLike: !ownComment && !this.isHidden(), canReply: !this.isHidden() && this.props.canAddComment, canDelete: this.props.isEditable || this.props.isDeletable, + canUnlock: this.props.hideType === HIDDEN_AUTHOR_BANNED || this.props.isReplyToBanned, }; } + unlockHidden = () => this.setState({ unlockHidden: true }); + lockHidden = () => this.setState({ unlockHidden: false }); + // We use this strange data structure because there can be more than one // PostCommentMore element created in the comment (see Expandable/bonusInfo). _moreMenuOpeners = []; @@ -171,7 +180,7 @@ class PostComment extends Component { onMoreMenuOpened = (moreMenuOpened) => this.setState({ moreMenuOpened }); commentTail() { - const { canLike, canReply, canDelete } = this.possibleActions(); + const { canLike, canReply, canDelete, canUnlock } = this.possibleActions(); return ( + {this.state.unlockHidden && ( + + )} {this.hiddenBody()} {commentTail} diff --git a/src/components/translated-text.jsx b/src/components/translated-text.jsx index 1a2738a6d..b4da4f0a1 100644 --- a/src/components/translated-text.jsx +++ b/src/components/translated-text.jsx @@ -1,16 +1,12 @@ import ISO6391 from 'iso-639-1'; import { useDispatch, useSelector } from 'react-redux'; -import cn from 'classnames'; import { useCallback } from 'react'; -import { faTimes } from '@fortawesome/free-solid-svg-icons'; import { initialAsyncState } from '../redux/async-helpers'; import { resetTranslation } from '../redux/action-creators'; import { useServerValue } from './hooks/server-info'; import PieceOfText from './piece-of-text'; -import { Icon } from './fontawesome-icons'; import { faTranslate } from './fontawesome-custom-icons'; -import { ButtonLink } from './button-link'; -import style from './translated-text.module.scss'; +import { AlternativeText } from './alternative-text'; const selectTranslationService = (serverInfo) => serverInfo.textTranslation.serviceTitle; @@ -27,29 +23,31 @@ export function TranslatedText({ type, id, userHover, arrowHover, arrowClick }) } if (status.loading) { return ( - ); } if (status.error) { return ( - ); } return ( - - - ); -} - -function Layout({ status, isError = false, inComment = false, reset, children }) { - return ( -
-
- - - - {status} - - - -
-
{children}
-
+ ); } diff --git a/src/components/unlocked-hidden-comment.jsx b/src/components/unlocked-hidden-comment.jsx new file mode 100644 index 000000000..4a581d7b7 --- /dev/null +++ b/src/components/unlocked-hidden-comment.jsx @@ -0,0 +1,87 @@ +import { useDispatch, useSelector } from 'react-redux'; +import { faLockOpen } from '@fortawesome/free-solid-svg-icons'; +import { useEffect } from 'react'; +import { COMMENT_VISIBLE, HIDDEN_AUTHOR_BANNED } from '../utils/frontend-preferences-options'; +import { initialAsyncState } from '../redux/async-helpers'; +import { unlockComment } from '../redux/action-creators'; +import PieceOfText from './piece-of-text'; +import { AlternativeText } from './alternative-text'; +import UserName from './user-name'; + +export function UnlockedHiddenComment({ id, userHover, arrowHover, arrowClick, close }) { + const dispatch = useDispatch(); + const comment = useSelector((state) => state.comments[id]); + + const status = useSelector((store) => store.unlockedCommentStates[id] ?? initialAsyncState); + const unlockedComment = useSelector((store) => store.unlockedComments[id]); + + useEffect(() => { + if (comment.hideType === HIDDEN_AUTHOR_BANNED && status.initial) { + dispatch(unlockComment(id)); + } + }, [comment.hideType, dispatch, id, status.initial]); + + let title; + if (comment.hideType === COMMENT_VISIBLE) { + title = `Comment with reply to blocked user:`; + } else if (comment.hideType === HIDDEN_AUTHOR_BANNED) { + if (status.loading) { + title = `Loading comment from a blocked user...`; + } else if (status.error) { + title = `Error loading comment: ${status.errorText}`; + } else { + title = `Comment from a blocked user:`; + } + } + + let content = null; + if (comment.hideType === COMMENT_VISIBLE) { + content = ( + + ); + } else if (comment.hideType === HIDDEN_AUTHOR_BANNED) { + content = ( + + ); + } + + return ( + + {content} + + ); +} + +function CommentContent({ comment, userHover, arrowHover, arrowClick }) { + const allUsers = useSelector((state) => state.users); + const author = allUsers[comment?.createdBy]; + return ( + comment && ( + <> + + {author && ( + <> + {' '} + - + + )} + + ) + ); +} diff --git a/src/redux/action-creators.js b/src/redux/action-creators.js index 78dfa5200..165bf557e 100644 --- a/src/redux/action-creators.js +++ b/src/redux/action-creators.js @@ -1387,3 +1387,11 @@ export function notifyOfAllComments(postId, enabled) { payload: { postId, enabled }, }; } + +export function unlockComment(id) { + return { + type: ActionTypes.UNLOCK_COMMENT, + apiRequest: Api.unlockComment, + payload: { id }, + }; +} diff --git a/src/redux/action-types.js b/src/redux/action-types.js index 18466a850..945763000 100644 --- a/src/redux/action-types.js +++ b/src/redux/action-types.js @@ -179,3 +179,4 @@ export const TRANSLATE_TEXT = 'TRANSLATE_TEXT'; export const GET_BACKLINKS = 'GET_BACKLINKS'; export const NOTIFY_OF_ALL_COMMENTS = 'NOTIFY_OF_ALL_COMMENTS'; export const SET_ORBIT = 'SET_ORBIT'; +export const UNLOCK_COMMENT = 'UNLOCK_COMMENT'; diff --git a/src/redux/reducers.js b/src/redux/reducers.js index 6133dba0b..17c9715e3 100644 --- a/src/redux/reducers.js +++ b/src/redux/reducers.js @@ -2181,3 +2181,5 @@ export const calendarMonthDays = fromResponse( export { userStatsStatus, userStats } from './reducers/dynamic-user-stats'; export { translationStates, translationResults } from './reducers/translation'; + +export { unlockedCommentStates, unlockedComments } from './reducers/unlocked-comments'; diff --git a/src/redux/reducers/unlocked-comments.js b/src/redux/reducers/unlocked-comments.js new file mode 100644 index 000000000..f80785f2c --- /dev/null +++ b/src/redux/reducers/unlocked-comments.js @@ -0,0 +1,14 @@ +import { UNLOCK_COMMENT } from '../action-types'; +import { asyncResultsMap, asyncStatesMap } from '../async-helpers'; +import { setOnLocationChange } from './helpers'; + +const resetOnLocationChange = setOnLocationChange({}); + +export const unlockedCommentStates = asyncStatesMap(UNLOCK_COMMENT, {}, resetOnLocationChange); +export const unlockedComments = asyncResultsMap( + UNLOCK_COMMENT, + { + transformer: (action) => action.payload.comments, + }, + resetOnLocationChange, +); diff --git a/src/services/api.js b/src/services/api.js index 43069d75d..449ac312b 100644 --- a/src/services/api.js +++ b/src/services/api.js @@ -820,6 +820,10 @@ export function getCommentsByIds({ commentIds }) { return fetch(`${apiRoot}/v2/comments/byIds`, postRequestOptions('POST', { commentIds })); } +export function unlockComment({ id }) { + return fetch(`${apiRoot}/v2/comments/${id}?unlock-banned`, getRequestOptions()); +} + export function translateText({ type, id, lang }) { const part = type === 'post' ? 'posts' : 'comments'; const qs = lang ? `?lang=${lang}` : '';