diff --git a/apps/comments-ui/src/App.tsx b/apps/comments-ui/src/App.tsx index 1ba06164be..efe413e295 100644 --- a/apps/comments-ui/src/App.tsx +++ b/apps/comments-ui/src/App.tsx @@ -30,7 +30,8 @@ const App: React.FC = ({scriptTag}) => { labs: {}, order: 'count__likes desc, created_at desc', adminApi: null, - commentsIsLoading: false + commentsIsLoading: false, + commentIdToHighlight: null }); const iframeRef = React.createRef(); @@ -76,7 +77,7 @@ const App: React.FC = ({scriptTag}) => { // allow for async actions within it's updater function so this is the best option. return new Promise((resolve) => { setState((state) => { - ActionHandler({action, data, state, api, adminApi: state.adminApi!, options}).then((updatedState) => { + ActionHandler({action, data, state, api, adminApi: state.adminApi!, options, dispatchAction: dispatchAction as DispatchActionType}).then((updatedState) => { const newState = {...updatedState}; resolve(newState); setState(newState); @@ -175,7 +176,8 @@ const App: React.FC = ({scriptTag}) => { commentCount: count, order, labs: labs, - commentsIsLoading: false + commentsIsLoading: false, + commentIdToHighlight: null }; setState(state); diff --git a/apps/comments-ui/src/AppContext.ts b/apps/comments-ui/src/AppContext.ts index 164002fd8f..7b32c2d21f 100644 --- a/apps/comments-ui/src/AppContext.ts +++ b/apps/comments-ui/src/AppContext.ts @@ -83,6 +83,7 @@ export type EditableAppContext = { order: string, adminApi: AdminApi | null, commentsIsLoading?: boolean + commentIdToHighlight: string | null } export type TranslationFunction = (key: string, replacements?: Record) => string; @@ -119,3 +120,4 @@ export const useLabs = () => { return {}; } }; + diff --git a/apps/comments-ui/src/actions.ts b/apps/comments-ui/src/actions.ts index 53e1fc44e1..fc298cfa39 100644 --- a/apps/comments-ui/src/actions.ts +++ b/apps/comments-ui/src/actions.ts @@ -1,4 +1,4 @@ -import {AddComment, Comment, CommentsOptions, EditableAppContext, OpenCommentForm} from './AppContext'; +import {AddComment, Comment, CommentsOptions, DispatchActionType, EditableAppContext, OpenCommentForm} from './AppContext'; import {AdminApi} from './utils/adminApi'; import {GhostApi} from './utils/api'; import {Page} from './pages'; @@ -408,6 +408,29 @@ async function openCommentForm({data: newForm, api, state}: {data: OpenCommentFo }; } +function setHighlightComment({data: commentId}: {data: string | null}) { + return { + commentIdToHighlight: commentId + }; +} + +function highlightComment({ + data: {commentId}, + dispatchAction + +}: { + data: { commentId: string | null }; + state: EditableAppContext; + dispatchAction: DispatchActionType; +}) { + setTimeout(() => { + dispatchAction('setHighlightComment', null); + }, 3000); + return { + commentIdToHighlight: commentId + }; +} + function setCommentFormHasUnsavedChanges({data: {id, hasUnsavedChanges}, state}: {data: {id: string, hasUnsavedChanges: boolean}, state: EditableAppContext}) { const updatedForms = state.openCommentForms.map((f) => { if (f.id === id) { @@ -449,7 +472,9 @@ export const Actions = { loadMoreReplies, updateMember, setOrder, - openCommentForm + openCommentForm, + highlightComment, + setHighlightComment }; export type ActionType = keyof typeof Actions; @@ -459,10 +484,10 @@ export function isSyncAction(action: string): action is SyncActionType { } /** Handle actions in the App, returns updated state */ -export async function ActionHandler({action, data, state, api, adminApi, options}: {action: ActionType, data: any, state: EditableAppContext, options: CommentsOptions, api: GhostApi, adminApi: AdminApi}): Promise> { +export async function ActionHandler({action, data, state, api, adminApi, options, dispatchAction}: {action: ActionType, data: any, state: EditableAppContext, options: CommentsOptions, api: GhostApi, adminApi: AdminApi, dispatchAction: DispatchActionType}): Promise> { const handler = Actions[action]; if (handler) { - return await handler({data, state, api, adminApi, options} as any) || {}; + return await handler({data, state, api, adminApi, options, dispatchAction} as any) || {}; } return {}; } diff --git a/apps/comments-ui/src/components/content/Comment.tsx b/apps/comments-ui/src/components/content/Comment.tsx index d1f962a278..4c0f95bc83 100644 --- a/apps/comments-ui/src/components/content/Comment.tsx +++ b/apps/comments-ui/src/components/content/Comment.tsx @@ -93,7 +93,7 @@ type PublishedCommentProps = CommentProps & { openEditMode: () => void; } const PublishedComment: React.FC = ({comment, parent, openEditMode}) => { - const {dispatchAction, openCommentForms, admin} = useAppContext(); + const {dispatchAction, openCommentForms, admin, commentIdToHighlight} = useAppContext(); const labs = useLabs(); // Determine if the comment should be displayed with reduced opacity @@ -149,7 +149,7 @@ const PublishedComment: React.FC = ({comment, parent, ope ) : ( <> - + = ({comment, className = ''}) => { - const {comments, t} = useAppContext(); + const {comments, t, dispatchAction} = useAppContext(); const labs = useLabs(); const createdAtRelative = useRelativeTime(comment.created_at); const {member} = useAppContext(); @@ -310,6 +310,7 @@ const CommentHeader: React.FC = ({comment, className = ''}) const element = (e.target as HTMLElement).ownerDocument.getElementById(comment.in_reply_to_id); if (element) { + dispatchAction('highlightComment', {commentId: comment.in_reply_to_id}); element.scrollIntoView({behavior: 'smooth', block: 'center'}); } }; @@ -328,7 +329,7 @@ const CommentHeader: React.FC = ({comment, className = ''}) {(isReplyToReply &&
- {t('Replied to')}{inReplyToSnippet} + {t('Replied to')}{inReplyToSnippet}
)} @@ -338,13 +339,38 @@ const CommentHeader: React.FC = ({comment, className = ''}) type CommentBodyProps = { html: string; className?: string; + isHighlighted?: boolean; } -const CommentBody: React.FC = ({html, className = ''}) => { - const dangerouslySetInnerHTML = {__html: html}; +const CommentBody: React.FC = ({html, className = '', isHighlighted}) => { + let commentHtml = html; + + if (isHighlighted) { + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + + const paragraphs = doc.querySelectorAll('p'); + + paragraphs.forEach((p) => { + const mark = doc.createElement('mark'); + mark.className = + 'animate-[highlight_2.5s_ease-out] [animation-delay:1s] bg-yellow-300/40 -my-0.5 py-0.5 dark:text-white/85 dark:bg-yellow-500/40'; + + while (p.firstChild) { + mark.appendChild(p.firstChild); + } + p.appendChild(mark); + }); + + // Serialize the modified html back to a string + commentHtml = doc.body.innerHTML; + } + + const dangerouslySetInnerHTML = {__html: commentHtml}; + return (
-

+

); }; diff --git a/apps/comments-ui/tailwind.config.js b/apps/comments-ui/tailwind.config.js index 00c0098148..e3192c8651 100644 --- a/apps/comments-ui/tailwind.config.js +++ b/apps/comments-ui/tailwind.config.js @@ -168,6 +168,19 @@ module.exports = { '0 57.7px 33.4px rgba(0, 0, 0, 0.072)', '0 138px 80px rgba(0, 0, 0, 0.1)' ] + }, + animation: { + heartbeat: 'heartbeat 0.35s ease-in-out forwards', + highlight: 'highlight 1s steps(1) forwards' + }, + keyframes: { + heartbeat: { + '0%, 100%': {transform: 'scale(1)'}, + '50%': {transform: 'scale(1.3)'} + }, + highlight: { + '100%': {backgroundColor: 'transparent'} + } } }, content: [ diff --git a/apps/comments-ui/test/e2e/actions.test.ts b/apps/comments-ui/test/e2e/actions.test.ts index 4f5f06e29b..8df9642140 100644 --- a/apps/comments-ui/test/e2e/actions.test.ts +++ b/apps/comments-ui/test/e2e/actions.test.ts @@ -200,10 +200,13 @@ test.describe('Actions', async () => { // Should indicate this was a reply to a reply await expect(frame.getByTestId('comment-in-reply-to')).toHaveText('This is a reply to 1'); + + return {frame}; } test('Can reply to a reply', async ({page}) => { mockedApi.addComment({ + id: '1', html: '

This is comment 1

', replies: [ mockedApi.buildReply({ @@ -215,6 +218,88 @@ test.describe('Actions', async () => { await testReplyToReply(page); }); + test('Can highlight reply when clicking on reply to: snippet', async ({page}) => { + mockedApi.addComment({ + html: '

This is comment 1

', + replies: [ + mockedApi.buildReply({ + id: '2', + html: '

This is a reply to 1

' + }), + mockedApi.buildReply({ + id: '3', + html: '

This is a reply to a reply

', + in_reply_to_id: '2', + in_reply_to_snippet: 'This is a reply to 1' + }) + ] + }); + + const {frame} = await initializeTest(page, {labs: true}); + + await frame.getByTestId('comment-in-reply-to').click(); + + // get the first reply which contains This is a reply to 1 + const commentComponent = frame.getByTestId('comment-component').nth(1); + + const replyComment = commentComponent.getByTestId('comment-content').nth(0); + + // check that replyComment contains the text This is a reply to 1 + await expect(replyComment).toHaveText('This is a reply to 1'); + + const markElement = await replyComment.locator('mark'); + await expect(markElement).toBeVisible(); + + // Check that the mark element has the expected classes + await expect(markElement).toHaveClass(/animate-\[highlight_2\.5s_ease-out\]/); + await expect(markElement).toHaveClass(/\[animation-delay:1s\]/); + await expect(markElement).toHaveClass(/bg-yellow-300\/40/); + await expect(markElement).toHaveClass(/dark:bg-yellow-500\/40/); + }); + + test('Reply highlight disappears after a bit', async ({page}) => { + mockedApi.addComment({ + html: '

This is comment 1

', + replies: [ + mockedApi.buildReply({ + id: '2', + html: '

This is a reply to 1

' + }), + mockedApi.buildReply({ + id: '3', + html: '

This is a reply to a reply

', + in_reply_to_id: '2', + in_reply_to_snippet: 'This is a reply to 1' + }) + ] + }); + + const {frame} = await initializeTest(page, {labs: true}); + + await frame.getByTestId('comment-in-reply-to').click(); + + // get the first reply which contains This is a reply to 1 + const commentComponent = frame.getByTestId('comment-component').nth(1); + + const replyComment = commentComponent.getByTestId('comment-content').nth(0); + + // check that replyComment contains the text This is a reply to 1 + await expect(replyComment).toHaveText('This is a reply to 1'); + + const markElement = await replyComment.locator('mark'); + await expect(markElement).toBeVisible(); + + // Check that the mark element has the expected classes + await expect(markElement).toHaveClass(/animate-\[highlight_2\.5s_ease-out\]/); + await expect(markElement).toHaveClass(/\[animation-delay:1s\]/); + await expect(markElement).toHaveClass(/bg-yellow-300\/40/); + await expect(markElement).toHaveClass(/dark:bg-yellow-500\/40/); + + const timeout = 3000; + await page.waitForTimeout(timeout); + await expect(markElement).not.toBeVisible(); + }); + test('Can reply to a reply with a deleted parent comment', async function ({page}) { mockedApi.addComment({ html: '

This is comment 1

',