Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/admin-x-settings/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
"@codemirror/lang-html": "6.4.11",
"@tryghost/color-utils": "0.2.10",
"@tryghost/i18n": "0.0.0",
"@tryghost/kg-unsplash-selector": "0.3.6",
"@tryghost/kg-unsplash-selector": "0.3.10",
"@tryghost/limit-service": "1.4.1",
"@tryghost/nql": "0.12.7",
"@tryghost/timezone-data": "0.4.12",
Expand Down Expand Up @@ -84,4 +84,4 @@
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ const features: Feature[] = [{
title: 'Featurebase Feedback',
description: 'Display a Feedback menu item in the admin sidebar. Requires the new admin experience.',
flag: 'featurebaseFeedback'
}, {
title: 'Transistor',
description: 'Enable Transistor podcast integration',
flag: 'transistor'
}];

const AlphaFeatures: React.FC = () => {
Expand Down
2 changes: 1 addition & 1 deletion apps/admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"@tryghost/activitypub": "*",
"@tryghost/admin-x-framework": "*",
"@tryghost/admin-x-settings": "*",
"@tryghost/koenig-lexical": "1.7.2",
"@tryghost/koenig-lexical": "1.7.6",
"@tryghost/posts": "*",
"@tryghost/shade": "*",
"@tryghost/stats": "*",
Expand Down
47 changes: 29 additions & 18 deletions apps/posts/src/views/comments/comments.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {useFilterState} from './hooks/use-filter-state';
import {useKnownFilterValues} from './hooks/use-known-filter-values';

const Comments: React.FC = () => {
const {filters, nql, setFilters} = useFilterState();
const {filters, nql, setFilters, clearFilters, isSingleIdFilter} = useFilterState();
const {data: configData} = useBrowseConfig();
const commentPermalinksEnabled = configData?.config?.labs?.commentPermalinks === true;

Expand All @@ -23,7 +23,7 @@ const Comments: React.FC = () => {
return [...filtered, createFilter(field, operator, [value])];
}, {replace: false});
}, [setFilters]);

const {
data,
isError,
Expand All @@ -41,12 +41,14 @@ const Comments: React.FC = () => {
return (
<CommentsLayout>
<CommentsHeader>
<CommentsFilters
filters={filters}
knownMembers={knownMembers}
knownPosts={knownPosts}
onFiltersChange={setFilters}
/>
{!isSingleIdFilter && (
<CommentsFilters
filters={filters}
knownMembers={knownMembers}
knownPosts={knownPosts}
onFiltersChange={setFilters}
/>
)}
</CommentsHeader>
<CommentsContent>
{(isFetching && !isFetchingNextPage) ? (
Expand Down Expand Up @@ -74,16 +76,25 @@ const Comments: React.FC = () => {
</EmptyIndicator>
</div>
) : (
<CommentsList
commentPermalinksEnabled={commentPermalinksEnabled}
fetchNextPage={fetchNextPage}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
isLoading={isFetching && !isFetchingNextPage}
items={data?.comments ?? []}
totalItems={data?.meta?.pagination?.total ?? 0}
onAddFilter={handleAddFilter}
/>
<>
<CommentsList
commentPermalinksEnabled={commentPermalinksEnabled}
fetchNextPage={fetchNextPage}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
isLoading={isFetching && !isFetchingNextPage}
items={data?.comments ?? []}
totalItems={data?.meta?.pagination?.total ?? 0}
onAddFilter={handleAddFilter}
/>
{isSingleIdFilter && (
<div className="flex justify-center py-8">
<Button variant="outline" onClick={() => clearFilters({replace: false})}>
Show all comments
</Button>
</div>
)}
</>
)}
</CommentsContent>
</CommentsLayout>
Expand Down
48 changes: 32 additions & 16 deletions apps/posts/src/views/comments/hooks/use-filter-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,53 +5,57 @@ import type {Filter} from '@tryghost/shade';
/**
* Comment filter field keys - single source of truth for filter definitions
*/
export const COMMENT_FILTER_FIELDS = ['status', 'created_at', 'body', 'post', 'author', 'reported'] as const;
export const COMMENT_FILTER_FIELDS = ['id', 'status', 'created_at', 'body', 'post', 'author', 'reported'] as const;

export type CommentFilterField = typeof COMMENT_FILTER_FIELDS[number];

export function buildNqlFilter(filters: Filter[]): string | undefined {
const parts: string[] = [];

for (const filter of filters) {
if (!filter.values[0]) {
continue;
}

switch (filter.field) {
case 'status':
case 'id':
parts.push(`id:'${filter.values[0]}'`);
break;

case 'status':
parts.push(`status:${filter.values[0]}`);
break;

case 'created_at':
case 'created_at':
if (filter.operator === 'before' && filter.values[0]) {
parts.push(`created_at:<'${filter.values[0]}'`);
} else if (filter.operator === 'after' && filter.values[0]) {
parts.push(`created_at:>'${filter.values[0]}'`);
} else if (filter.operator === 'is' && filter.values[0]) {
// Match all items from the selected day in the user's timezone
const dateValue = String(filter.values[0]); // Format: YYYY-MM-DD

// Create Date objects in user's local timezone, then convert to UTC
const startOfDay = new Date(dateValue + 'T00:00:00').toISOString();
const endOfDay = new Date(dateValue + 'T23:59:59.999').toISOString();

parts.push(`created_at:>='${startOfDay}'+created_at:<='${endOfDay}'`);
}
break;

case 'body':
case 'body':
const value = filter.values[0] as string;
// Escape single quotes in the value
const escapedValue = value.replace(/'/g, '\\\'');

if (filter.operator === 'contains') {
parts.push(`html:~'${escapedValue}'`);
} else if (filter.operator === 'not_contains') {
parts.push(`html:-~'${escapedValue}'`);
}
break;

case 'post':
case 'post':
if (filter.operator === 'is_not') {
parts.push(`post_id:-${filter.values[0]}`);
} else {
Expand All @@ -60,7 +64,7 @@ export function buildNqlFilter(filters: Filter[]): string | undefined {
}
break;

case 'author':
case 'author':
if (filter.operator === 'is_not') {
parts.push(`member_id:-${filter.values[0]}`);
} else {
Expand All @@ -78,7 +82,7 @@ export function buildNqlFilter(filters: Filter[]): string | undefined {
break;
}
}

return parts.length ? parts.join('+') : undefined;
}
/**
Expand Down Expand Up @@ -150,16 +154,23 @@ interface SetFiltersOptions {
replace?: boolean;
}

interface ClearFiltersOptions {
/** Whether to replace the current history entry (default: true) */
replace?: boolean;
}

interface UseFilterStateReturn {
filters: Filter[];
nql: string | undefined;
setFilters: (action: SetFiltersAction, options?: SetFiltersOptions) => void;
clearFilters: () => void;
clearFilters: (options?: ClearFiltersOptions) => void;
/** True when the only active filter is a single comment ID (used for deep linking) */
isSingleIdFilter: boolean;
}

/**
* Hook to sync comment filter state with URL query parameters
*
*
* URL format: ?status=is:published&author=is:member-id&body=contains:search+term
*/
export function useFilterState(): UseFilterStateReturn {
Expand All @@ -181,11 +192,16 @@ export function useFilterState(): UseFilterStateReturn {
}, [filters, setSearchParams]);

// Clear all filter params from URL
const clearFilters = useCallback(() => {
setSearchParams(new URLSearchParams(), {replace: true});
const clearFilters = useCallback(({replace = true}: {replace?: boolean} = {}) => {
setSearchParams(new URLSearchParams(), {replace});
}, [setSearchParams]);

const nql = useMemo(() => buildNqlFilter(filters), [filters]);

return {filters, nql, setFilters, clearFilters};
// Check if the only active filter is a single comment ID (used for deep linking)
const isSingleIdFilter = useMemo(() => {
return filters.length === 1 && filters[0].field === 'id';
}, [filters]);

return {filters, nql, setFilters, clearFilters, isSingleIdFilter};
}
40 changes: 40 additions & 0 deletions e2e/tests/admin/comments/comment-moderation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,4 +75,44 @@ test.describe('Ghost Admin - Comment Moderation', () => {
await expect(postPage).toHaveURL(new RegExp(`/${post.slug}/.*#ghost-comments-${comment.id}`));
});
});

test.describe('deep linking', () => {
test.use({labs: {commentModeration: true}});

test('can deep link to a specific comment by id', async ({page}) => {
const post = await postFactory.create({status: 'published'});
const member = await memberFactory.create();

// Create target comment and another comment
const targetComment = await commentFactory.create({
post_id: post.id,
member_id: member.id,
html: '<p>This is the target comment</p>'
});
await commentFactory.create({
post_id: post.id,
member_id: member.id,
html: '<p>This is another comment</p>'
});

const commentsPage = new CommentsPage(page);
await page.goto(`/ghost/#/comments?id=is:${targetComment.id}`);
await commentsPage.waitForComments();

await expect(commentsPage.getCommentRowByText('This is the target comment')).toBeVisible();
await expect(commentsPage.getCommentRowByText('This is another comment')).not.toBeVisible();

await expect(page.getByRole('button', {name: 'Filter'})).not.toBeVisible();

const showAllButton = page.getByRole('button', {name: 'Show all comments'});
await expect(showAllButton).toBeVisible();

await showAllButton.click();
await expect(commentsPage.getCommentRowByText('This is the target comment')).toBeVisible();
await expect(commentsPage.getCommentRowByText('This is another comment')).toBeVisible();

await expect(page.getByRole('button', {name: 'Filter'})).toBeVisible();
await expect(showAllButton).not.toBeVisible();
});
});
});
10 changes: 5 additions & 5 deletions ghost/admin/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "ghost-admin",
"version": "6.12.1",
"version": "6.13.1",
"description": "Ember.js admin client for Ghost",
"author": "Ghost Foundation",
"homepage": "http://ghost.org",
Expand Down Expand Up @@ -47,9 +47,9 @@
"@tryghost/color-utils": "0.2.10",
"@tryghost/ember-promise-modals": "2.0.1",
"@tryghost/helpers": "1.1.97",
"@tryghost/kg-clean-basic-html": "4.2.7",
"@tryghost/kg-converters": "1.1.7",
"@tryghost/koenig-lexical": "1.7.2",
"@tryghost/kg-clean-basic-html": "4.2.11",
"@tryghost/kg-converters": "1.1.11",
"@tryghost/koenig-lexical": "1.7.6",
"@tryghost/limit-service": "1.4.1",
"@tryghost/members-csv": "2.0.3",
"@tryghost/nql": "0.12.7",
Expand Down Expand Up @@ -215,4 +215,4 @@
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,8 @@ class CommentsServiceEmails {

const memberName = member.get('name') || 'Anonymous';

const commentModerationEnabled = this.labs.isSet('commentModeration');

const templateData = {
siteTitle: this.settingsCache.get('title'),
siteUrl: this.urlUtils.getSiteUrl(),
Expand All @@ -175,7 +177,9 @@ class CommentsServiceEmails {
accentColor: this.settingsCache.get('accent_color'),
fromEmail: this.notificationFromAddress,
toEmail: to,
staffUrl: this.urlUtils.urlJoin(this.urlUtils.urlFor('admin', true), '#', `/settings/staff/${owner.get('slug')}/email-notifications`)
staffUrl: this.urlUtils.urlJoin(this.urlUtils.urlFor('admin', true), '#', `/settings/staff/${owner.get('slug')}/email-notifications`),
commentModerationEnabled,
moderationUrl: this.urlUtils.urlJoin(this.urlUtils.urlFor('admin', true), '#', `/comments/?id=is:${comment.get('id')}`)
};

const {html, text} = await this.commentsServiceEmailRenderer.renderEmailTemplate('report', templateData);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@
<tr>
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 14px; vertical-align: top;">
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 20px; color: #15212A; font-weight: bold; line-height: 25px; margin: 0; margin-bottom: 15px;">Hey there,</p>
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; color: #3A464C; font-weight: normal; margin: 0; line-height: 25px; margin-bottom: 32px;">{{reporter}} has reported the comment below on <a href="{{postUrl}}" target="_blank" style="font-weight: bold; text-decoration: underline; color: #15212A;">{{postTitle}}</a>. This comment will remain visible until you choose to remove it, which can be done directly on the post.</p>
<p style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; color: #3A464C; font-weight: normal; margin: 0; line-height: 25px; margin-bottom: 32px;">{{reporter}} has reported the comment below on <a href="{{postUrl}}" target="_blank" style="font-weight: bold; text-decoration: underline; color: #15212A;">{{postTitle}}</a>.{{#unless commentModerationEnabled}} This comment will remain visible until you choose to remove it, which can be done directly on the post.{{/unless}}</p>

<table width="100" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; table-layout: fixed; width: 100%; min-width: 100%; box-sizing: border-box; background: #F9F9FA; border-radius: 7px;">
<tbody>
Expand Down Expand Up @@ -152,7 +152,11 @@
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: auto;">
<tbody>
<tr>
{{#if commentModerationEnabled}}
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; vertical-align: top; background-color: {{accentColor}}; border-radius: 5px; text-align: center;"> <a href="{{moderationUrl}}" target="_blank" style="display: inline-block; color: #ffffff; background-color: {{accentColor}}; border: solid 1px {{accentColor}}; border-radius: 5px; box-sizing: border-box; cursor: pointer; text-decoration: none; font-size: 16px; font-weight: normal; margin: 0; padding: 9px 22px 10px; border-color: {{accentColor}};">Review comment</a> </td>
{{else}}
<td style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; font-size: 16px; vertical-align: top; background-color: {{accentColor}}; border-radius: 5px; text-align: center;"> <a href="{{postUrl}}" target="_blank" style="display: inline-block; color: #ffffff; background-color: {{accentColor}}; border: solid 1px {{accentColor}}; border-radius: 5px; box-sizing: border-box; cursor: pointer; text-decoration: none; font-size: 16px; font-weight: normal; margin: 0; padding: 9px 22px 10px; border-color: {{accentColor}};">View comment</a> </td>
{{/if}}
</tr>
</tbody>
</table>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
module.exports = function (data) {
let visibilityNote = 'This comment will remain visible until you choose to remove it.';
if (!data.commentModerationEnabled) {
visibilityNote = 'This comment will remain visible until you choose to remove it, which can be done directly on the post.';
}

let actionLinks = data.postUrl;
if (data.commentModerationEnabled) {
actionLinks = `View comment: ${data.postUrl}\nModerate comment: ${data.moderationUrl}`;
}

// Be careful when you indent the email, because whitespaces are visible in emails!
return `Hey there,

${data.reporter} has reported the comment below on ${data.postTitle}. This comment will remain visible until you choose to remove it, which can be done directly on the post.
${data.reporter} has reported the comment below on ${data.postTitle}. ${visibilityNote}

${data.memberName} (${data.memberEmail}):
${data.commentText}

${data.postUrl}
${actionLinks}

---

Expand Down
3 changes: 2 additions & 1 deletion ghost/core/core/shared/labs.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ const PRIVATE_FEATURES = [
'commentModeration',
'commentPermalinks',
'indexnow',
'featurebaseFeedback'
'featurebaseFeedback',
'transistor'
];

module.exports.GA_KEYS = [...GA_FEATURES];
Expand Down
Loading
Loading