Skip to content

Commit c386fa2

Browse files
committed
✨ Added pinned comment moderation
Staff need a way to elevate important discussion without changing admin moderation order. This adds top-level comment pinning with public pinned-first ordering, staff pin/unpin controls, visual pinned badges, API support, schema changes, and focused coverage for the backend and UI paths.
1 parent 0c4d441 commit c386fa2

114 files changed

Lines changed: 1542 additions & 269 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/admin-x-framework/src/api/comments.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export type Comment = {
1111
id: string;
1212
html: string | null;
1313
status: 'published' | 'hidden' | 'deleted';
14+
pinned: boolean;
1415
created_at: string;
1516
updated_at: string;
1617
post_id: string;
@@ -138,6 +139,34 @@ export const useDeleteComment = createMutation<CommentsResponseType, {id: string
138139
}
139140
});
140141

142+
export const usePinComment = createMutation<CommentsResponseType, {id: string}>({
143+
method: 'PUT',
144+
path: ({id}) => `/comments/${id}/`,
145+
body: ({id}) => ({
146+
comments: [{
147+
id,
148+
pinned: true
149+
}]
150+
}),
151+
invalidateQueries: {
152+
dataType
153+
}
154+
});
155+
156+
export const useUnpinComment = createMutation<CommentsResponseType, {id: string}>({
157+
method: 'PUT',
158+
path: ({id}) => `/comments/${id}/`,
159+
body: ({id}) => ({
160+
comments: [{
161+
id,
162+
pinned: false
163+
}]
164+
}),
165+
invalidateQueries: {
166+
dataType
167+
}
168+
});
169+
141170
export const useCommentReplies = createQueryWithId<CommentsResponseType>({
142171
dataType,
143172
path: (id: string) => `/comments/${id}/replies/`,

apps/comments-ui/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@tryghost/comments-ui",
3-
"version": "1.4.10",
3+
"version": "1.4.11",
44
"license": "MIT",
55
"repository": "https://github.com/TryGhost/Ghost",
66
"author": "Ghost Foundation",

apps/comments-ui/src/actions.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,24 @@ async function showComment({state, api, data: comment}: {state: EditableAppConte
214214
};
215215
}
216216

217+
async function pinComment({state, data: comment, dispatchAction}: {state: EditableAppContext, data: {id: string}, dispatchAction: DispatchActionType}) {
218+
if (state.adminApi) {
219+
await state.adminApi.pinComment({id: comment.id});
220+
dispatchAction('setOrder', {order: state.order});
221+
}
222+
223+
return null;
224+
}
225+
226+
async function unpinComment({state, data: comment, dispatchAction}: {state: EditableAppContext, data: {id: string}, dispatchAction: DispatchActionType}) {
227+
if (state.adminApi) {
228+
await state.adminApi.unpinComment({id: comment.id});
229+
dispatchAction('setOrder', {order: state.order});
230+
}
231+
232+
return null;
233+
}
234+
217235
async function updateCommentLikeState({state, data: comment}: {state: EditableAppContext, data: {id: string, liked: boolean}}) {
218236
return {
219237
comments: state.comments.map((c) => {
@@ -501,6 +519,8 @@ export const Actions = {
501519
addComment,
502520
editComment,
503521
hideComment,
522+
pinComment,
523+
unpinComment,
504524
deleteComment,
505525
showComment,
506526
likeComment,

apps/comments-ui/src/app-context.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export type Comment = {
2121
in_reply_to_snippet: string,
2222
replies: Comment[],
2323
status: string,
24+
pinned: boolean,
2425
liked: boolean,
2526
count: {
2627
replies: number,

apps/comments-ui/src/components/content/comment.tsx

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import ReplyButton from './buttons/reply-button';
88
import ReplyForm from './forms/reply-form';
99
import {Avatar, BlankAvatar} from './avatar';
1010
import {Comment, OpenCommentForm, useAppContext} from '../../app-context';
11+
import {ReactComponent as PinIcon} from '../../images/icons/pin.svg';
12+
import {ReactComponent as PinOffIcon} from '../../images/icons/pin-off.svg';
1113
import {Transition} from '@headlessui/react';
1214
import {buildCommentPermalink, findCommentById, formatExplicitTime, getCommentInReplyToSnippet, getMemberNameFromComment} from '../../utils/helpers';
1315
import {useRelativeTime} from '../../utils/hooks';
@@ -128,7 +130,7 @@ const PublishedComment: React.FC<PublishedCommentProps> = ({comment, parent, ope
128130
const avatar = (<Avatar member={comment.member} />);
129131

130132
return (
131-
<CommentLayout avatar={avatar} className={hiddenClass} hasReplies={hasReplies} memberUuid={comment.member?.uuid}>
133+
<CommentLayout avatar={avatar} className={hiddenClass} hasReplies={hasReplies} isPinned={comment.pinned} memberUuid={comment.member?.uuid}>
132134
<div>
133135
{isInEditMode ? (
134136
<>
@@ -183,9 +185,10 @@ const UnpublishedComment: React.FC<UnpublishedCommentProps> = ({comment, openEdi
183185
const showMoreButton = isAdmin && comment.status === 'hidden';
184186

185187
return (
186-
<CommentLayout avatar={avatar} hasReplies={hasReplies}>
188+
<CommentLayout avatar={avatar} hasReplies={hasReplies} isPinned={comment.pinned}>
187189
<div className="mt-[-3px] flex items-start">
188190
<div className="flex h-10 flex-row items-center gap-4 pb-[8px] pr-4">
191+
<PinnedLabel comment={comment} />
189192
<p className="text-md mt-[4px] font-sans leading-normal text-neutral-900/40 sm:text-lg dark:text-white/60">
190193
{notPublishedMessage}
191194
</p>
@@ -228,6 +231,44 @@ const EditedInfo: React.FC<{comment: Comment}> = ({comment}) => {
228231
</span>
229232
);
230233
};
234+
235+
const PinnedLabel: React.FC<{comment: Comment}> = ({comment}) => {
236+
const {dispatchAction, isAdmin, t} = useAppContext();
237+
238+
if (!comment.pinned) {
239+
return null;
240+
}
241+
242+
const labelClassName = 'inline-flex items-center gap-1 rounded-full border border-amber-300/70 bg-amber-50 px-2 py-0.5 font-sans text-xs font-medium leading-none text-amber-800 dark:border-amber-400/30 dark:bg-amber-400/10 dark:text-amber-100';
243+
244+
if (isAdmin) {
245+
const handleUnpinClick = (event: React.MouseEvent<HTMLButtonElement>) => {
246+
event.stopPropagation();
247+
dispatchAction('unpinComment', comment);
248+
};
249+
250+
return (
251+
<button aria-label={t('Unpin comment')} className={`${labelClassName} group hover:border-amber-400 hover:bg-amber-100 dark:hover:border-amber-400/50 dark:hover:bg-amber-400/20`} data-testid="pinned-comment-label" type="button" onClick={handleUnpinClick}>
252+
<span className="grid size-3 shrink-0">
253+
<PinIcon aria-hidden="true" className="col-start-1 row-start-1 size-3 group-hover:opacity-0 group-focus-visible:opacity-0" />
254+
<PinOffIcon aria-hidden="true" className="col-start-1 row-start-1 size-3 opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100" />
255+
</span>
256+
<span className="grid justify-items-start text-left">
257+
<span className="col-start-1 row-start-1 group-hover:opacity-0 group-focus-visible:opacity-0">{t('Pinned')}</span>
258+
<span className="col-start-1 row-start-1 opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100">{t('Unpin')}</span>
259+
</span>
260+
</button>
261+
);
262+
}
263+
264+
return (
265+
<span className={labelClassName} data-testid="pinned-comment-label">
266+
<PinIcon aria-hidden="true" className="size-3" />
267+
{t('Pinned')}
268+
</span>
269+
);
270+
};
271+
231272
const RepliesContainer: React.FC<RepliesProps & {className?: string}> = ({comment, className = ''}) => {
232273
const hasReplies = comment.replies && comment.replies.length > 0;
233274

@@ -322,6 +363,11 @@ const CommentHeader: React.FC<CommentHeaderProps> = ({comment, className = ''})
322363
<span>
323364
<MemberExpertise comment={comment}/>
324365
{timestampElement}
366+
{comment.pinned && (
367+
<span className="ml-2 inline-flex align-middle">
368+
<PinnedLabel comment={comment} />
369+
</span>
370+
)}
325371
<EditedInfo comment={comment} />
326372
</span>
327373
</div>
@@ -433,10 +479,18 @@ type CommentLayoutProps = {
433479
hasReplies: boolean;
434480
className?: string;
435481
memberUuid?: string;
482+
isPinned?: boolean;
436483
}
437-
const CommentLayout: React.FC<CommentLayoutProps> = ({children, avatar, hasReplies, className = '', memberUuid = ''}) => {
484+
485+
const COMMENT_GAP_CLASS_NAME = 'mb-7';
486+
const PINNED_COMMENT_GAP_CLASS_NAME = 'mb-4';
487+
const PINNED_COMMENT_BOX_CLASS_NAME = 'bg-amber-50/70 px-3 py-3 dark:bg-amber-400/10';
488+
489+
const CommentLayout: React.FC<CommentLayoutProps> = ({children, avatar, hasReplies, className = '', memberUuid = '', isPinned = false}) => {
490+
const bottomMarginClassName = isPinned ? PINNED_COMMENT_GAP_CLASS_NAME : hasReplies ? 'mb-0' : COMMENT_GAP_CLASS_NAME;
491+
438492
return (
439-
<div className={`flex w-full flex-row ${hasReplies === true ? 'mb-0' : 'mb-7'}`} data-member-uuid={memberUuid} data-testid="comment-component">
493+
<div className={`flex w-full flex-row rounded-lg ${isPinned ? PINNED_COMMENT_BOX_CLASS_NAME : ''} ${bottomMarginClassName}`} data-member-uuid={memberUuid} data-pinned={isPinned ? 'true' : undefined} data-testid="comment-component">
440494
<div className="mr-2 flex flex-col items-center justify-start sm:mr-3">
441495
<div className={`flex-0 mb-3 sm:mb-4 ${className}`}>
442496
{avatar}

apps/comments-ui/src/components/content/context-menus/admin-context-menu.tsx

Lines changed: 70 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,101 @@
11
import {Comment, useAppContext, useLabs} from '../../../app-context';
2+
import {ReactComponent as ExternalLinkIcon} from '../../../images/icons/external-link.svg';
3+
import {ReactComponent as EyeIcon} from '../../../images/icons/eye.svg';
4+
import {ReactComponent as EyeOffIcon} from '../../../images/icons/eye-off.svg';
5+
import {ReactComponent as PencilIcon} from '../../../images/icons/pencil.svg';
6+
import {ReactComponent as PinIcon} from '../../../images/icons/pin.svg';
7+
import {ReactComponent as PinOffIcon} from '../../../images/icons/pin-off.svg';
8+
import {ReactComponent as TrashIcon} from '../../../images/icons/trash.svg';
29

310
type Props = {
411
comment: Comment;
512
close: () => void;
13+
showAuthorActions?: boolean;
14+
toggleEdit?: () => void;
615
};
7-
const AdminContextMenu: React.FC<Props> = ({comment, close}) => {
16+
const AdminContextMenu: React.FC<Props> = ({comment, close, showAuthorActions = false, toggleEdit}) => {
817
const {dispatchAction, t, adminUrl} = useAppContext();
918
const labs = useLabs();
1019

11-
const hideComment = () => {
12-
dispatchAction('hideComment', comment);
20+
const closeAfter = (action: () => void) => () => {
21+
action();
1322
close();
1423
};
1524

16-
const showComment = () => {
17-
dispatchAction('showComment', comment);
18-
close();
19-
};
25+
const editComment = toggleEdit && closeAfter(toggleEdit);
26+
const deleteComment = closeAfter(() => {
27+
dispatchAction('openPopup', {
28+
type: 'deletePopup',
29+
comment
30+
});
31+
});
32+
const hideComment = closeAfter(() => dispatchAction('hideComment', comment));
33+
const showComment = closeAfter(() => dispatchAction('showComment', comment));
34+
const pinComment = closeAfter(() => dispatchAction('pinComment', comment));
35+
const unpinComment = closeAfter(() => dispatchAction('unpinComment', comment));
2036

2137
const isHidden = comment.status !== 'published';
38+
const canPin = !comment.parent_id && comment.status !== 'deleted';
2239
const adminCommentUrl = adminUrl ? `${adminUrl}#/comments/?id=is:${comment.id}` : null;
40+
const baseItemClassName = 'flex w-full items-center gap-3 rounded px-3 py-2 text-left text-[14px] leading-5 transition-colors hover:bg-neutral-100 dark:hover:bg-neutral-700';
41+
const itemClassName = `${baseItemClassName} text-neutral-900 dark:text-white`;
42+
const destructiveItemClassName = `${baseItemClassName} text-red-600 dark:text-red-500`;
43+
const iconClassName = 'size-4 shrink-0';
2344

2445
return (
2546
<div className="flex w-full flex-col gap-0.5">
26-
{
27-
isHidden ?
28-
<button className="w-full rounded px-2.5 py-1.5 text-left text-[14px] transition-colors hover:bg-neutral-100 dark:hover:bg-neutral-700" data-testid="show-button" type="button" onClick={showComment}>
29-
<span className="hidden sm:inline">{t('Show comment')}</span><span className="sm:hidden">{t('Show')}</span>
30-
</button>
31-
:
32-
<button className="w-full rounded px-2.5 py-1.5 text-left text-[14px] text-red-600 transition-colors hover:bg-neutral-100 dark:text-red-500 dark:hover:bg-neutral-700" data-testid="hide-button" type="button" onClick={hideComment}>
33-
<span className="hidden sm:inline">{t('Hide comment')}</span><span className="sm:hidden">{t('Hide')}</span>
34-
</button>
35-
}
3647
{labs?.commentModeration && adminCommentUrl && (
3748
<a
38-
className="w-full rounded px-2.5 py-1.5 text-left text-[14px] transition-colors hover:bg-neutral-100 dark:hover:bg-neutral-700"
49+
className={itemClassName}
3950
data-testid="view-in-admin-button"
4051
href={adminCommentUrl}
4152
rel="noopener noreferrer"
4253
target="_blank"
4354
onClick={close}
4455
>
45-
{t('View in admin')}
56+
<ExternalLinkIcon aria-hidden="true" className={iconClassName} />
57+
<span>{t('View in admin')}</span>
4658
</a>
4759
)}
60+
{canPin && (
61+
comment.pinned ?
62+
<button className={itemClassName} data-testid="unpin-button" type="button" onClick={unpinComment}>
63+
<PinOffIcon aria-hidden="true" className={iconClassName} />
64+
<span>{t('Unpin comment')}</span>
65+
</button>
66+
:
67+
<button className={itemClassName} data-testid="pin-button" type="button" onClick={pinComment}>
68+
<PinIcon aria-hidden="true" className={iconClassName} />
69+
<span>{t('Pin comment')}</span>
70+
</button>
71+
)}
72+
{
73+
isHidden ?
74+
<button className={itemClassName} data-testid="show-button" type="button" onClick={showComment}>
75+
<EyeIcon aria-hidden="true" className={iconClassName} />
76+
<span>{t('Show comment')}</span>
77+
</button>
78+
:
79+
<button className={itemClassName} data-testid="hide-button" type="button" onClick={hideComment}>
80+
<EyeOffIcon aria-hidden="true" className={iconClassName} />
81+
<span>{t('Hide comment')}</span>
82+
</button>
83+
}
84+
{showAuthorActions && (
85+
<div className="my-1 border-t border-neutral-200 dark:border-neutral-700" />
86+
)}
87+
{showAuthorActions && editComment && (
88+
<button className={itemClassName} data-testid="edit" type="button" onClick={editComment}>
89+
<PencilIcon aria-hidden="true" className={iconClassName} />
90+
<span>{t('Edit')}</span>
91+
</button>
92+
)}
93+
{showAuthorActions && (
94+
<button className={destructiveItemClassName} data-testid="delete" type="button" onClick={deleteComment}>
95+
<TrashIcon aria-hidden="true" className={iconClassName} />
96+
<span>{t('Delete')}</span>
97+
</button>
98+
)}
4899
</div>
49100
);
50101
};

apps/comments-ui/src/components/content/context-menus/author-context-menu.tsx

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import React from 'react';
22
import {Comment, useAppContext} from '../../../app-context';
3+
import {ReactComponent as PencilIcon} from '../../../images/icons/pencil.svg';
4+
import {ReactComponent as TrashIcon} from '../../../images/icons/trash.svg';
35

46
type Props = {
57
comment: Comment;
@@ -17,13 +19,20 @@ const AuthorContextMenu: React.FC<Props> = ({comment, close, toggleEdit}) => {
1719
close();
1820
};
1921

22+
const baseItemClassName = 'flex w-full items-center gap-3 rounded px-3 py-2 text-left text-[14px] leading-5 transition-colors hover:bg-neutral-100 dark:hover:bg-neutral-700';
23+
const itemClassName = `${baseItemClassName} text-neutral-900 dark:text-white`;
24+
const destructiveItemClassName = `${baseItemClassName} text-red-600 dark:text-red-500`;
25+
const iconClassName = 'size-4 shrink-0';
26+
2027
return (
2128
<div className="flex w-full flex-col gap-0.5">
22-
<button className="w-full rounded px-2.5 py-1.5 text-left text-[14px] transition-colors hover:bg-neutral-100 dark:hover:bg-neutral-700" data-testid="edit" type="button" onClick={toggleEdit}>
23-
{t('Edit')}
29+
<button className={itemClassName} data-testid="edit" type="button" onClick={toggleEdit}>
30+
<PencilIcon aria-hidden="true" className={iconClassName} />
31+
<span>{t('Edit')}</span>
2432
</button>
25-
<button className="w-full rounded px-2.5 py-1.5 text-left text-[14px] text-red-600 transition-colors hover:bg-neutral-100 dark:text-red-500 dark:hover:bg-neutral-700" data-testid="delete" type="button" onClick={deleteComment}>
26-
{t('Delete')}
33+
<button className={destructiveItemClassName} data-testid="delete" type="button" onClick={deleteComment}>
34+
<TrashIcon aria-hidden="true" className={iconClassName} />
35+
<span>{t('Delete')}</span>
2736
</button>
2837
</div>
2938
);

apps/comments-ui/src/components/content/context-menus/comment-context-menu.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -65,16 +65,16 @@ const CommentContextMenu: React.FC<Props> = ({comment, close, toggleEdit}) => {
6565
event.stopPropagation();
6666
};
6767

68+
const menuClassName = 'absolute z-10 w-52 rounded-lg bg-white p-1 font-sans text-sm shadow-lg outline-0 dark:bg-neutral-800 dark:text-white';
69+
6870
let contextMenu = null;
6971
if (comment.status === 'published') {
70-
if (isAuthor) {
72+
if (isAdmin) {
73+
contextMenu = <AdminContextMenu close={close} comment={comment} showAuthorActions={!!isAuthor} toggleEdit={toggleEdit}/>;
74+
} else if (isAuthor) {
7175
contextMenu = <AuthorContextMenu close={close} comment={comment} toggleEdit={toggleEdit} />;
7276
} else {
73-
if (isAdmin) {
74-
contextMenu = <AdminContextMenu close={close} comment={comment}/>;
75-
} else {
76-
contextMenu = <NotAuthorContextMenu close={close} comment={comment}/>;
77-
}
77+
contextMenu = <NotAuthorContextMenu close={close} comment={comment}/>;
7878
}
7979
} else {
8080
if (isAdmin) {
@@ -86,7 +86,7 @@ const CommentContextMenu: React.FC<Props> = ({comment, close, toggleEdit}) => {
8686

8787
return (
8888
<div ref={element} className="relative" data-testid="comment-context-menu" onClick={stopPropagation}>
89-
<div ref={innerElement} className={`absolute z-10 min-w-min whitespace-nowrap rounded bg-white p-1 font-sans text-sm shadow-lg outline-0 sm:min-w-[80px] dark:bg-neutral-800 dark:text-white`} data-testid="comment-context-menu-inner">
89+
<div ref={innerElement} className={menuClassName} data-testid="comment-context-menu-inner">
9090
{contextMenu}
9191
</div>
9292
</div>

0 commit comments

Comments
 (0)