From cf6884d098bc3c1b2b268d13af210ce4d6d22f26 Mon Sep 17 00:00:00 2001 From: Ronald Langeveld Date: Thu, 21 Nov 2024 18:52:36 +0800 Subject: [PATCH] Wired up Admin Comment Endpoints to UI (#21661) ref PLG-227 - Updated logic that allows Admin Users on comments to interact with some endpoints from a specific admin-only route. - It pulls 2 admin specific routes: - 1. an admin specific 'browse' route that includes hidden comments that would otherwise be hidden from regular users and members. - 2. A specific replies route, that would also include hidden comments - This was needed in order to get accurate pagination. - Additionally, it wires up the routes via `message-handler` that deal with the potential cors issues. - Includes style updates --------- Co-authored-by: Sanne de Vries Co-authored-by: Kevin Ansfield --- apps/comments-ui/src/App.tsx | 28 +++- apps/comments-ui/src/AppContext.ts | 4 +- apps/comments-ui/src/actions.ts | 42 +++-- .../src/components/content/Avatar.tsx | 2 + .../src/components/content/Comment.tsx | 145 +++++++++++++----- .../content/buttons/ReplyButton.tsx | 11 +- apps/comments-ui/src/utils/adminApi.ts | 47 ++++++ apps/comments-ui/test/e2e/auth-frame.test.ts | 61 +++++++- .../test/e2e/autoclose-forms.test.ts | 7 +- apps/comments-ui/test/e2e/content.test.ts | 144 +++++++++++++++++ .../src/admin-auth/message-handler.js | 16 +- ghost/core/core/server/models/comment.js | 1 - ghost/i18n/locales/af/comments.json | 1 + ghost/i18n/locales/ar/comments.json | 1 + ghost/i18n/locales/bg/comments.json | 1 + ghost/i18n/locales/bn/comments.json | 1 + ghost/i18n/locales/bs/comments.json | 1 + ghost/i18n/locales/ca/comments.json | 1 + ghost/i18n/locales/cs/comments.json | 1 + ghost/i18n/locales/da/comments.json | 1 + ghost/i18n/locales/de-CH/comments.json | 1 + ghost/i18n/locales/de/comments.json | 1 + ghost/i18n/locales/el/comments.json | 1 + ghost/i18n/locales/en/comments.json | 1 + ghost/i18n/locales/eo/comments.json | 1 + ghost/i18n/locales/es/comments.json | 1 + ghost/i18n/locales/et/comments.json | 1 + ghost/i18n/locales/fa/comments.json | 1 + ghost/i18n/locales/fi/comments.json | 1 + ghost/i18n/locales/fr/comments.json | 1 + ghost/i18n/locales/gd/comments.json | 1 + ghost/i18n/locales/he/comments.json | 1 + ghost/i18n/locales/hi/comments.json | 1 + ghost/i18n/locales/hr/comments.json | 1 + ghost/i18n/locales/hu/comments.json | 1 + ghost/i18n/locales/id/comments.json | 1 + ghost/i18n/locales/is/comments.json | 1 + ghost/i18n/locales/it/comments.json | 1 + ghost/i18n/locales/ja/comments.json | 1 + ghost/i18n/locales/ko/comments.json | 1 + ghost/i18n/locales/kz/comments.json | 1 + ghost/i18n/locales/lt/comments.json | 1 + ghost/i18n/locales/mk/comments.json | 1 + ghost/i18n/locales/mn/comments.json | 1 + ghost/i18n/locales/ms/comments.json | 1 + ghost/i18n/locales/ne/comments.json | 1 + ghost/i18n/locales/nl/comments.json | 1 + ghost/i18n/locales/nn/comments.json | 1 + ghost/i18n/locales/no/comments.json | 1 + ghost/i18n/locales/pl/comments.json | 1 + ghost/i18n/locales/pt-BR/comments.json | 1 + ghost/i18n/locales/pt/comments.json | 1 + ghost/i18n/locales/ro/comments.json | 1 + ghost/i18n/locales/ru/comments.json | 1 + ghost/i18n/locales/si/comments.json | 1 + ghost/i18n/locales/sk/comments.json | 1 + ghost/i18n/locales/sl/comments.json | 1 + ghost/i18n/locales/sq/comments.json | 1 + ghost/i18n/locales/sr-Cyrl/comments.json | 1 + ghost/i18n/locales/sr/comments.json | 1 + ghost/i18n/locales/sv/comments.json | 1 + ghost/i18n/locales/sw/comments.json | 1 + ghost/i18n/locales/ta/comments.json | 1 + ghost/i18n/locales/th/comments.json | 1 + ghost/i18n/locales/tr/comments.json | 1 + ghost/i18n/locales/uk/comments.json | 1 + ghost/i18n/locales/ur/comments.json | 1 + ghost/i18n/locales/uz/comments.json | 1 + ghost/i18n/locales/vi/comments.json | 1 + ghost/i18n/locales/zh-Hant/comments.json | 1 + ghost/i18n/locales/zh/comments.json | 1 + 71 files changed, 493 insertions(+), 74 deletions(-) create mode 100644 apps/comments-ui/test/e2e/content.test.ts diff --git a/apps/comments-ui/src/App.tsx b/apps/comments-ui/src/App.tsx index 56cbb91533..f3a81b139a 100644 --- a/apps/comments-ui/src/App.tsx +++ b/apps/comments-ui/src/App.tsx @@ -7,9 +7,9 @@ import React, {useCallback, useEffect, useMemo, useState} from 'react'; import i18nLib from '@tryghost/i18n'; import setupGhostApi from './utils/api'; import {ActionHandler, SyncActionHandler, isSyncAction} from './actions'; -import {AdminApi, setupAdminAPI} from './utils/adminApi'; import {AppContext, DispatchActionType, EditableAppContext, LabsContextType} from './AppContext'; import {CommentsFrame} from './components/Frame'; +import {setupAdminAPI} from './utils/adminApi'; import {useOptions} from './utils/options'; type AppProps = { @@ -28,7 +28,8 @@ const App: React.FC = ({scriptTag}) => { openCommentForms: [], popup: null, labs: {}, - order: 'count__likes desc, created_at desc' + order: 'count__likes desc, created_at desc', + adminApi: null }); const iframeRef = React.createRef(); @@ -41,7 +42,7 @@ const App: React.FC = ({scriptTag}) => { }); }, [options]); - const [adminApi, setAdminApi] = useState(null); + // const [adminApi, setAdminApi] = useState(null); const setState = useCallback((newState: Partial | ((state: EditableAppContext) => Partial)) => { setFullState((state) => { @@ -61,7 +62,7 @@ const App: React.FC = ({scriptTag}) => { // because updates to state may be asynchronous // so calling dispatchAction('counterUp') multiple times, may yield unexpected results if we don't use a callback function setState((state) => { - return SyncActionHandler({action, data, state, api, adminApi: adminApi!, options}); + return SyncActionHandler({action, data, state, api, adminApi: state.adminApi!, options}); }); return; } @@ -70,14 +71,14 @@ const App: React.FC = ({scriptTag}) => { // without creating infinite rerenders because dispatchAction needs to change on every state change // So state shouldn't be a dependency of dispatchAction setState((state) => { - ActionHandler({action, data, state, api, adminApi: adminApi!, options}).then((updatedState) => { + ActionHandler({action, data, state, api, adminApi: state.adminApi!, options}).then((updatedState) => { setState({...updatedState}); }).catch(console.error); // eslint-disable-line no-console // No immediate changes return {}; }); - }, [api, adminApi, options]); // Do not add state or context as a dependency here -> infinite render loop + }, [api, options]); // Do not add state or context as a dependency here -> infinite render loop const i18n = useMemo(() => { return i18nLib(options.locale, 'comments'); @@ -92,7 +93,7 @@ const App: React.FC = ({scriptTag}) => { }; const initAdminAuth = async () => { - if (adminApi || !options.adminUrl) { + if (state.adminApi || !options.adminUrl) { return; } @@ -100,11 +101,21 @@ const App: React.FC = ({scriptTag}) => { const adminApi = setupAdminAPI({ adminUrl: options.adminUrl }); - setAdminApi(adminApi); let admin = null; try { admin = await adminApi.getUser(); + if (admin && state.labs.commentImprovements) { + // this is a bit of a hack, but we need to fetch the comments fully populated if the user is an admin + const adminComments = await adminApi.browse({page: 1, postId: options.postId, order: state.order}); + setState({ + ...state, + adminApi: adminApi, + comments: adminComments.comments, + pagination: adminComments.meta.pagination, + commentCount: adminComments.meta.pagination.total + }); + } } catch (e) { // Loading of admin failed. Could be not signed in, or a different error (not important) // eslint-disable-next-line no-console @@ -112,6 +123,7 @@ const App: React.FC = ({scriptTag}) => { } setState({ + adminApi: adminApi, admin }); } catch (e) { diff --git a/apps/comments-ui/src/AppContext.ts b/apps/comments-ui/src/AppContext.ts index 56bb56f7d8..4d70952fcc 100644 --- a/apps/comments-ui/src/AppContext.ts +++ b/apps/comments-ui/src/AppContext.ts @@ -1,6 +1,7 @@ // Ref: https://reactjs.org/docs/context.html import React, {useContext} from 'react'; import {ActionType, Actions, SyncActionType, SyncActions} from './actions'; +import {AdminApi} from './utils/adminApi'; import {Page} from './pages'; export type Member = { @@ -79,7 +80,8 @@ export type EditableAppContext = { openCommentForms: OpenCommentForm[], popup: Page | null, labs: LabsContextType, - order: string + order: string, + adminApi: AdminApi | null } export type TranslationFunction = (key: string, replacements?: Record) => string; diff --git a/apps/comments-ui/src/actions.ts b/apps/comments-ui/src/actions.ts index fbf71937dc..5a88266f8a 100644 --- a/apps/comments-ui/src/actions.ts +++ b/apps/comments-ui/src/actions.ts @@ -8,7 +8,12 @@ async function loadMoreComments({state, api, options, order}: {state: EditableAp if (state.pagination && state.pagination.page) { page = state.pagination.page + 1; } - const data = await api.comments.browse({page, postId: options.postId, order: order || state.order}); + let data; + if (state.admin && state.adminApi && state.labs.commentImprovements) { + data = await state.adminApi.browse({page, postId: options.postId, order: order || state.order}); + } else { + data = await api.comments.browse({page, postId: options.postId, order: order || state.order}); + } // Note: we store the comments from new to old, and show them in reverse order return { @@ -17,8 +22,13 @@ async function loadMoreComments({state, api, options, order}: {state: EditableAp }; } -async function setOrder({data: {order}, options, api}: {state: EditableAppContext, data: {order: string}, options: CommentsOptions, api: GhostApi}) { - const data = await api.comments.browse({page: 1, postId: options.postId, order: order}); +async function setOrder({state, data: {order}, options, api}: {state: EditableAppContext, data: {order: string}, options: CommentsOptions, api: GhostApi}) { + let data; + + if (state.admin && state.adminApi && state.labs.commentImprovements) { + data = await state.adminApi.browse({page: 1, postId: options.postId, order}); + } + data = await api.comments.browse({page: 1, postId: options.postId, order: order}); return { comments: [...data.comments], @@ -27,8 +37,13 @@ async function setOrder({data: {order}, options, api}: {state: EditableAppContex }; } -async function loadMoreReplies({state, api, data: {comment, limit}}: {state: EditableAppContext, api: GhostApi, data: {comment: any, limit?: number | 'all'}}): Promise> { - const data = await api.comments.replies({commentId: comment.id, afterReplyId: comment.replies[comment.replies.length - 1]?.id, limit}); +async function loadMoreReplies({state, api, data: {comment, limit}, isReply}: {state: EditableAppContext, api: GhostApi, data: {comment: any, limit?: number | 'all'}, isReply: boolean}): Promise> { + let data; + if (state.admin && state.adminApi && state.labs.commentImprovements && !isReply) { // we don't want the admin api to load reply data for replying to a reply, so we pass isReply: true + data = await state.adminApi.replies({commentId: comment.id, afterReplyId: comment.replies[comment.replies.length - 1]?.id, limit}); + } else { + data = await api.comments.replies({commentId: comment.id, afterReplyId: comment.replies[comment.replies.length - 1]?.id, limit}); + } // Note: we store the comments from new to old, and show them in reverse order return { @@ -84,9 +99,10 @@ async function addReply({state, api, data: {reply, parent}}: {state: EditableApp }; } -async function hideComment({state, adminApi, data: comment}: {state: EditableAppContext, adminApi: any, data: {id: string}}) { - await adminApi.hideComment(comment.id); - +async function hideComment({state, data: comment}: {state: EditableAppContext, adminApi: any, data: {id: string}}) { + if (state.adminApi) { + await state.adminApi.hideComment(comment.id); + } return { comments: state.comments.map((c) => { const replies = c.replies.map((r) => { @@ -117,9 +133,10 @@ async function hideComment({state, adminApi, data: comment}: {state: EditableApp }; } -async function showComment({state, api, adminApi, data: comment}: {state: EditableAppContext, api: GhostApi, adminApi: any, data: {id: string}}) { - await adminApi.showComment(comment.id); - +async function showComment({state, api, data: comment}: {state: EditableAppContext, api: GhostApi, adminApi: any, data: {id: string}}) { + if (state.adminApi) { + await state.adminApi.showComment(comment.id); + } // We need to refetch the comment, to make sure we have an up to date HTML content // + all relations are loaded as the current member (not the admin) const data = await api.comments.read(comment.id); @@ -355,7 +372,8 @@ async function openCommentForm({data: newForm, api, state}: {data: OpenCommentFo const topLevelCommentId = newForm.parent_id || newForm.id; if (newForm.type === 'reply' && !state.openCommentForms.some(f => f.id === topLevelCommentId || f.parent_id === topLevelCommentId)) { const comment = state.comments.find(c => c.id === topLevelCommentId); - const newCommentsState = await loadMoreReplies({state, api, data: {comment, limit: 'all'}}); + // we don't want the admin api to load reply data for replying to a reply, so we pass isReply: true + const newCommentsState = await loadMoreReplies({state, api, data: {comment, limit: 'all'}, isReply: true}); otherStateChanges = {...otherStateChanges, ...newCommentsState}; } diff --git a/apps/comments-ui/src/components/content/Avatar.tsx b/apps/comments-ui/src/components/content/Avatar.tsx index f419210735..d12dca1597 100644 --- a/apps/comments-ui/src/components/content/Avatar.tsx +++ b/apps/comments-ui/src/components/content/Avatar.tsx @@ -19,8 +19,10 @@ export const BlankAvatar = () => { type AvatarProps = { comment?: Comment; + isHidden?: boolean; }; export const Avatar: React.FC = ({comment}) => { + // #TODO greyscale the avatar image when it's hidden const {member, avatarSaturation, t} = useAppContext(); const dimensionClasses = getDimensionClasses(); diff --git a/apps/comments-ui/src/components/content/Comment.tsx b/apps/comments-ui/src/components/content/Comment.tsx index 1ec1dcd5a6..2fc5b715a1 100644 --- a/apps/comments-ui/src/components/content/Comment.tsx +++ b/apps/comments-ui/src/components/content/Comment.tsx @@ -7,7 +7,7 @@ import ReplyForm from './forms/ReplyForm'; import {Avatar, BlankAvatar} from './Avatar'; import {Comment, OpenCommentForm, useAppContext, useLabs} from '../../AppContext'; import {Transition} from '@headlessui/react'; -import {formatExplicitTime, getCommentInReplyToSnippet, getMemberNameFromComment, isCommentPublished} from '../../utils/helpers'; +import {formatExplicitTime, getCommentInReplyToSnippet, getMemberNameFromComment} from '../../utils/helpers'; import {useCallback} from 'react'; import {useRelativeTime} from '../../utils/hooks'; @@ -50,10 +50,34 @@ const EditableComment: React.FC = ({comment, parent}) => { }; type CommentProps = AnimatedCommentProps; -export const CommentComponent: React.FC = ({comment, parent}) => { - const {dispatchAction} = useAppContext(); +const useCommentVisibility = (comment: Comment, admin: boolean, labs: {commentImprovements?: boolean}) => { + const hasReplies = comment.replies && comment.replies.length > 0; + const isDeleted = comment.status === 'deleted'; + const isHidden = comment.status === 'hidden'; - const isPublished = isCommentPublished(comment); + if (labs?.commentImprovements) { + return { + // Show deleted message only when comment has replies (regardless of admin status) + showDeletedMessage: isDeleted && hasReplies, + // Show hidden message for non-admins when comment has replies + showHiddenMessage: hasReplies && isHidden && !admin, + // Show comment content if not deleted AND (is published OR admin viewing hidden) + showCommentContent: !isDeleted && (admin || comment.status === 'published') + }; + } + + // Original behavior when labs is false + return { + showDeletedMessage: false, + showHiddenMessage: false, + showCommentContent: comment.status === 'published' + }; +}; + +export const CommentComponent: React.FC = ({comment, parent}) => { + const {dispatchAction, admin} = useAppContext(); + const labs = useLabs(); + const {showDeletedMessage, showHiddenMessage, showCommentContent} = useCommentVisibility(comment, admin, labs); const openEditMode = useCallback(() => { const newForm: OpenCommentForm = { @@ -66,18 +90,27 @@ export const CommentComponent: React.FC = ({comment, parent}) => { dispatchAction('openCommentForm', newForm); }, [comment.id, dispatchAction]); - if (isPublished) { - return (); - } else { - return (); + if (showDeletedMessage) { + return ; + } else if (showCommentContent && !showHiddenMessage) { + return ; + } else if (!labs.commentImprovements && comment.status !== 'published' || showHiddenMessage) { + return ; } + + return null; }; type PublishedCommentProps = CommentProps & { openEditMode: () => void; } const PublishedComment: React.FC = ({comment, parent, openEditMode}) => { - const {dispatchAction, openCommentForms} = useAppContext(); + const {dispatchAction, openCommentForms, admin} = useAppContext(); + const labs = useLabs(); + + // Determine if the comment should be displayed with reduced opacity + const isHidden = labs.commentImprovements && admin && comment.status === 'hidden'; + const hiddenClass = isHidden ? 'opacity-30' : ''; // currently a reply-to-reply form is displayed inside the top-level PublishedComment component // so we need to check for a match of either the comment id or the parent id @@ -114,11 +147,16 @@ const PublishedComment: React.FC = ({comment, parent, ope const avatar = (); return ( - - - - - + + + + {displayReplyForm && } @@ -130,28 +168,34 @@ type UnpublishedCommentProps = { openEditMode: () => void; } const UnpublishedComment: React.FC = ({comment, openEditMode}) => { - const {t} = useAppContext(); - let notPublishedMessage:string = ''; + const {t, labs, admin} = useAppContext(); - const avatar = (); + const avatar = (labs.commentImprovements && admin && comment.status !== 'deleted') ? + : + ; const hasReplies = comment.replies && comment.replies.length > 0; - if (comment.status === 'hidden') { - notPublishedMessage = t('This comment has been hidden.'); - } else if (comment.status === 'deleted') { - notPublishedMessage = t('This comment has been removed.'); - } + const notPublishedMessage = comment.status === 'hidden' ? + t('This comment has been hidden.') : + comment.status === 'deleted' ? + t('This comment has been removed.') : + ''; + + // Only show MoreButton for hidden (not deleted) comments when admin + const showMoreButton = admin && comment.status === 'hidden'; return (
-

+

{notPublishedMessage}

-
- -
+ {showMoreButton && ( +
+ +
+ )}
@@ -185,8 +229,7 @@ const EditedInfo: React.FC<{comment: Comment}> = ({comment}) => { ); }; - -const RepliesContainer: React.FC = ({comment}) => { +const RepliesContainer: React.FC = ({comment, className = ''}) => { const hasReplies = comment.replies && comment.replies.length > 0; if (!hasReplies) { @@ -194,7 +237,7 @@ const RepliesContainer: React.FC = ({comment}) => { } return ( -
+
); @@ -226,7 +269,12 @@ const AuthorName: React.FC<{comment: Comment}> = ({comment}) => { ); }; -const CommentHeader: React.FC<{comment: Comment}> = ({comment}) => { +type CommentHeaderProps = { + comment: Comment; + className?: string; +} + +const CommentHeader: React.FC = ({comment, className = ''}) => { const {t} = useAppContext(); const labs = useLabs(); const createdAtRelative = useRelativeTime(comment.created_at); @@ -249,7 +297,7 @@ const CommentHeader: React.FC<{comment: Comment}> = ({comment}) => { return ( <> -
+
@@ -268,10 +316,15 @@ const CommentHeader: React.FC<{comment: Comment}> = ({comment}) => { ); }; -const CommentBody: React.FC<{html: string}> = ({html}) => { +type CommentBodyProps = { + html: string; + className?: string; +} + +const CommentBody: React.FC = ({html, className = ''}) => { const dangerouslySetInnerHTML = {__html: html}; return ( -
+

); @@ -283,26 +336,35 @@ type CommentMenuProps = { highlightReplyButton: boolean; openEditMode: () => void; parent?: Comment; + className?: string; }; -const CommentMenu: React.FC = ({comment, openReplyForm, highlightReplyButton, openEditMode, parent}) => { - // If this comment is from the current member, always override member - // with the member from the context, so we update the expertise in existing comments when we change it - const {member, commentsEnabled} = useAppContext(); +const CommentMenu: React.FC = ({comment, openReplyForm, highlightReplyButton, openEditMode, parent, className = ''}) => { + const {member, commentsEnabled, t, admin} = useAppContext(); const labs = useLabs(); const paidOnly = commentsEnabled === 'paid'; const isPaidMember = member && !!member.paid; const canReply = member && (isPaidMember || !paidOnly) && (labs.commentImprovements ? true : !parent); + const isHiddenForAdmin = labs.commentImprovements && admin && comment.status === 'hidden'; + + if (isHiddenForAdmin) { + return ( +
+ {t('Hidden for members')} + {} +
+ ); + } return ( labs.commentImprovements ? ( -
+
{} {} {}
) : ( -
+
{} {(canReply && )} {} @@ -327,12 +389,13 @@ type CommentLayoutProps = { children: React.ReactNode; avatar: React.ReactNode; hasReplies: boolean; + className?: string; } -const CommentLayout: React.FC = ({children, avatar, hasReplies}) => { +const CommentLayout: React.FC = ({children, avatar, hasReplies, className = ''}) => { return (
-
+
{avatar}
diff --git a/apps/comments-ui/src/components/content/buttons/ReplyButton.tsx b/apps/comments-ui/src/components/content/buttons/ReplyButton.tsx index df566370a8..5c398cfdde 100644 --- a/apps/comments-ui/src/components/content/buttons/ReplyButton.tsx +++ b/apps/comments-ui/src/components/content/buttons/ReplyButton.tsx @@ -6,6 +6,7 @@ type Props = { isReplying: boolean; openReplyForm: () => void; }; + const ReplyButton: React.FC = ({disabled, isReplying, openReplyForm}) => { const {member, t, dispatchAction, commentsEnabled} = useAppContext(); const labs = useLabs(); @@ -29,11 +30,11 @@ const ReplyButton: React.FC = ({disabled, isReplying, openReplyForm}) => } return ( -