diff --git a/.github/workflows/migration-review.yml b/.github/workflows/migration-review.yml index 885fa98f7e51..9103b5d119c4 100644 --- a/.github/workflows/migration-review.yml +++ b/.github/workflows/migration-review.yml @@ -30,6 +30,7 @@ jobs: ### General requirements + - [ ] :warning: Tested on the staging database servers - [ ] Satisfies idempotency requirement (both `up()` and `down()`) - [ ] Does not reference models - [ ] Filename is in the correct format (and correctly ordered) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 62433c0b3364..c241a75cefb3 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -21,7 +21,7 @@ jobs: If we’ve missed reviewing your PR & you’re still interested in working on it, please let us know. Otherwise this PR will be closed shortly, but can always be reopened later. Thank you for understanding 🙂 exempt-issue-labels: 'feature,pinned' exempt-pr-labels: 'feature,pinned' - days-before-stale: 120 + days-before-stale: 113 days-before-pr-stale: -1 stale-issue-label: 'stale' stale-pr-label: 'stale' diff --git a/apps/admin-x-activitypub/package.json b/apps/admin-x-activitypub/package.json index 8013e68ab077..00b4f5dec72e 100644 --- a/apps/admin-x-activitypub/package.json +++ b/apps/admin-x-activitypub/package.json @@ -1,6 +1,6 @@ { "name": "@tryghost/admin-x-activitypub", - "version": "0.3.34", + "version": "0.3.35", "license": "MIT", "repository": { "type": "git", diff --git a/apps/admin-x-activitypub/src/components/Activities.tsx b/apps/admin-x-activitypub/src/components/Activities.tsx index c80f40487ff8..9a86906c2584 100644 --- a/apps/admin-x-activitypub/src/components/Activities.tsx +++ b/apps/admin-x-activitypub/src/components/Activities.tsx @@ -1,95 +1,185 @@ import React, {useEffect, useRef} from 'react'; import NiceModal from '@ebay/nice-modal-react'; -import {ActorProperties} from '@tryghost/admin-x-framework/api/activitypub'; -import {LoadingIndicator, NoValueLabel} from '@tryghost/admin-x-design-system'; +import {Activity, ActorProperties, ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub'; +import {Button, LoadingIndicator, NoValueLabel} from '@tryghost/admin-x-design-system'; -import APAvatar, {AvatarBadge} from './global/APAvatar'; -import ActivityItem, {type Activity} from './activities/ActivityItem'; +import APAvatar from './global/APAvatar'; import ArticleModal from './feed/ArticleModal'; import MainNavigation from './navigation/MainNavigation'; +import NotificationItem from './activities/NotificationItem'; import Separator from './global/Separator'; import ViewProfileModal from './modals/ViewProfileModal'; import getUsername from '../utils/get-username'; import stripHtml from '../utils/strip-html'; +import truncate from '../utils/truncate'; import {GET_ACTIVITIES_QUERY_KEY_NOTIFICATIONS, useActivitiesForUser} from '../hooks/useActivityPubQueries'; +import {type NotificationType} from './activities/NotificationIcon'; +import {useRouting} from '@tryghost/admin-x-framework/routing'; interface ActivitiesProps {} // eslint-disable-next-line no-shadow -enum ACTVITY_TYPE { +enum ACTIVITY_TYPE { CREATE = 'Create', LIKE = 'Like', FOLLOW = 'Follow' } -const getActivityDescription = (activity: Activity): string => { - switch (activity.type) { - case ACTVITY_TYPE.CREATE: - if (activity.object?.inReplyTo && typeof activity.object?.inReplyTo !== 'string') { - return `Replied to your article "${activity.object.inReplyTo.name}"`; - } - - return ''; - case ACTVITY_TYPE.FOLLOW: - return 'Followed you'; - case ACTVITY_TYPE.LIKE: - if (activity.object && activity.object.type === 'Article') { - return `Liked your article "${activity.object.name}"`; - } else if (activity.object && activity.object.type === 'Note') { - return `${stripHtml(activity.object.content)}`; - } - } - - return ''; -}; +interface GroupedActivity { + type: ACTIVITY_TYPE; + actors: ActorProperties[]; + object: ObjectProperties; + id?: string; +} -const getExtendedDescription = (activity: Activity): JSX.Element | null => { +const getExtendedDescription = (activity: GroupedActivity): JSX.Element | null => { // If the activity is a reply - if (Boolean(activity.type === ACTVITY_TYPE.CREATE && activity.object?.inReplyTo)) { + if (Boolean(activity.type === ACTIVITY_TYPE.CREATE && activity.object?.inReplyTo)) { return (
); + } else if (activity.type === ACTIVITY_TYPE.LIKE && !activity.object?.name && activity.object?.content) { + return ( + + ); } return null; }; -const getActivityUrl = (activity: Activity): string | null => { - if (activity.object) { - return activity.object.url || null; +const getActivityBadge = (activity: GroupedActivity): NotificationType => { + switch (activity.type) { + case ACTIVITY_TYPE.CREATE: + return 'reply'; + case ACTIVITY_TYPE.FOLLOW: + return 'follow'; + case ACTIVITY_TYPE.LIKE: + if (activity.object) { + return 'like'; + } } - return null; + return 'like'; }; -const getActorUrl = (activity: Activity): string | null => { - if (activity.actor) { - return activity.actor.url; - } +const groupActivities = (activities: Activity[]): GroupedActivity[] => { + const groups: {[key: string]: GroupedActivity} = {}; - return null; + // Activities are already sorted by time from the API + activities.forEach((activity) => { + let groupKey = ''; + + switch (activity.type) { + case ACTIVITY_TYPE.FOLLOW: + // Group follows that are next to each other in the array + groupKey = `follow_${activity.type}`; + break; + case ACTIVITY_TYPE.LIKE: + if (activity.object?.id) { + // Group likes by the target object + groupKey = `like_${activity.object.id}`; + } + break; + case ACTIVITY_TYPE.CREATE: + // Don't group creates/replies + groupKey = `create_${activity.id}`; + break; + } + + if (!groups[groupKey]) { + groups[groupKey] = { + type: activity.type as ACTIVITY_TYPE, + actors: [], + object: activity.object, + id: activity.id + }; + } + + // Add actor if not already in the group + if (!groups[groupKey].actors.find(a => a.id === activity.actor.id)) { + groups[groupKey].actors.push(activity.actor); + } + }); + + // Return in same order as original activities + return Object.values(groups); }; -const getActivityBadge = (activity: Activity): AvatarBadge => { - switch (activity.type) { - case ACTVITY_TYPE.CREATE: - return 'comment-fill'; - case ACTVITY_TYPE.FOLLOW: - return 'user-fill'; - case ACTVITY_TYPE.LIKE: - if (activity.object) { - return 'heart-fill'; +const getGroupDescription = (group: GroupedActivity): JSX.Element => { + const actorNames = group.actors.map(actor => actor.name); + const [firstActor, secondActor, ...otherActors] = actorNames; + const hasOthers = otherActors.length > 0; + + let actorText = <>>; + + switch (group.type) { + case ACTIVITY_TYPE.FOLLOW: + actorText = ( + <> + {firstActor} + {secondActor && ` and `} + {secondActor && {secondActor}} + {hasOthers && ' and others'} + > + ); + + return ( + <> + {actorText} started following you + > + ); + case ACTIVITY_TYPE.LIKE: + const postType = group.object?.type === 'Article' ? 'post' : 'note'; + actorText = ( + <> + {firstActor} + {secondActor && ( + <> + {hasOthers ? ', ' : ' and '} + {secondActor} + > + )} + {hasOthers && ' and others'} + > + ); + + return ( + <> + {actorText} liked your {postType}{' '} + {group.object?.name || ''} + > + ); + case ACTIVITY_TYPE.CREATE: + if (group.object?.inReplyTo && typeof group.object?.inReplyTo !== 'string') { + const content = stripHtml(group.object.inReplyTo.name); + return <>{group.actors[0].name} replied to your post {truncate(content, 80)}>; } } + return <>>; }; const Activities: React.FC--gh-font-heading
, --gh-font-body
',
+ regex: {},
+ failures: [
+ {
+ ref: 'styles'
+ }
+ ],
+ code: 'GS051-CUSTOM-FONTS'
+ }]
+ }]
+ }
+ }
+ }});
+
+ await page.goto('/');
+
+ const themeSection = page.getByTestId('theme');
+
+ await themeSection.getByRole('button', {name: 'Change theme'}).click();
+
+ const modal = page.getByTestId('theme-modal');
+
+ await modal.getByRole('button', {name: /Casper/}).click();
+
+ await expect(modal.getByRole('button', {name: 'Activate Casper'})).toBeVisible();
+
+ await expect(page.locator('iframe[title="Theme preview"]')).toHaveAttribute('src', 'https://demo.ghost.io/');
+
+ await modal.getByRole('button', {name: 'Change theme'}).click();
+
+ await modal.getByRole('button', {name: 'Close'}).click();
+
+ const designSection = page.getByTestId('design');
+
+ await designSection.getByRole('button', {name: 'Customize'}).click();
+
+ const designModal = page.getByTestId('design-modal');
+
+ await designModal.getByRole('tab', {name: 'Theme'}).click();
+
+ const titleFontCustomThemeSetting = designModal.getByLabel('Title font');
+ await expect(titleFontCustomThemeSetting).toBeVisible();
+
+ const bodyFontCustomThemeSetting = designModal.getByLabel('Body font');
+ await expect(bodyFontCustomThemeSetting).toBeVisible();
+ });
});
diff --git a/apps/admin-x-settings/test/acceptance/site/theme.test.ts b/apps/admin-x-settings/test/acceptance/site/theme.test.ts
index 7e2b2917d5de..0f7fee563e8c 100644
--- a/apps/admin-x-settings/test/acceptance/site/theme.test.ts
+++ b/apps/admin-x-settings/test/acceptance/site/theme.test.ts
@@ -22,7 +22,19 @@ test.describe('Theme settings', async () => {
active: true,
templates: []
}]
- }}
+ }},
+ activeTheme: {
+ method: 'GET',
+ path: '/themes/active/',
+ response: {
+ themes: [{
+ name: 'casper',
+ package: {},
+ active: true,
+ templates: []
+ }]
+ }
+ }
}});
await page.goto('/');
diff --git a/apps/comments-ui/package.json b/apps/comments-ui/package.json
index 958c4172ab44..f3598a6dffc6 100644
--- a/apps/comments-ui/package.json
+++ b/apps/comments-ui/package.json
@@ -1,6 +1,6 @@
{
"name": "@tryghost/comments-ui",
- "version": "0.24.0",
+ "version": "0.24.1",
"license": "MIT",
"repository": "git@github.com:TryGhost/comments-ui.git",
"author": "Ghost Foundation",
diff --git a/apps/comments-ui/src/App.tsx b/apps/comments-ui/src/App.tsx
index ee9f97133c49..efe413e295cc 100644
--- a/apps/comments-ui/src/App.tsx
+++ b/apps/comments-ui/src/App.tsx
@@ -29,7 +29,9 @@ const App: React.FCFirst reply
' + }); + const reply2 = buildComment({ + in_reply_to_id: reply1.id, in_reply_to_snippet: 'First reply', - html: 'Second reply
', - count: {likes: 0} - }; + html: 'Second reply
' + }); + const parent = buildComment({ + replies: [reply1, reply2] + }); + const appContext = {comments: [parent], labs: {commentImprovements: true}}; - contextualRender(First reply
' + }); + const reply2 = buildComment({ + in_reply_to_id: reply1.id, + in_reply_to_snippet: 'First reply', + html: 'Second reply
' + }); + const parent = buildComment({ + replies: [reply1, reply2] + }); + const appContext = {comments: [parent], labs: {commentImprovements: true}}; + + contextualRender(First reply
', + status: 'deleted' + }); + const reply2 = buildComment({ + in_reply_to_id: reply1.id, + in_reply_to_snippet: 'First reply', + html: 'Second reply
' + }); + const parent = buildComment({ + replies: [reply1, reply2] + }); + const appContext = {comments: [parent], labs: {commentImprovements: true}}; + + contextualRender(Second reply
' + }); + const parent = buildComment({ + replies: [reply2] + }); + const appContext = {comments: [parent], labs: {commentImprovements: true}}; + + contextualRender(