diff --git a/app/assets/stylesheets/pageflow/ui/properties.scss b/app/assets/stylesheets/pageflow/ui/properties.scss
index 98f1c56b43..c16e049483 100644
--- a/app/assets/stylesheets/pageflow/ui/properties.scss
+++ b/app/assets/stylesheets/pageflow/ui/properties.scss
@@ -57,6 +57,10 @@
--ui-box-shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
--ui-box-shadow-2xl: 0 25px 50px -12px rgb(0 0 0 / 0.25);
+ --ui-accent-color: #ffd24d;
+ --ui-accent-color-glow: #ffd24d3d;
+ --ui-on-accent-color: #795f0f;
+
--ui-on-button-color: var(--ui-primary-color);
--ui-button-border-color: var(--ui-primary-color-light);
--ui-button-hover-border-color: var(--ui-primary-color);
diff --git a/app/controllers/pageflow/review/comment_threads_controller.rb b/app/controllers/pageflow/review/comment_threads_controller.rb
new file mode 100644
index 0000000000..7ef9f3c951
--- /dev/null
+++ b/app/controllers/pageflow/review/comment_threads_controller.rb
@@ -0,0 +1,16 @@
+module Pageflow
+ module Review
+ # @api private
+ class CommentThreadsController < Pageflow::ApplicationController
+ respond_to :json
+ before_action :authenticate_user!
+
+ def index
+ entry = DraftEntry.find(params[:entry_id])
+ authorize!(:read, entry.to_model)
+
+ @comment_threads = entry.comment_threads.includes(comments: :creator)
+ end
+ end
+ end
+end
diff --git a/app/models/pageflow/comment.rb b/app/models/pageflow/comment.rb
new file mode 100644
index 0000000000..6ba052bc05
--- /dev/null
+++ b/app/models/pageflow/comment.rb
@@ -0,0 +1,15 @@
+module Pageflow
+ # @api private
+ class Comment < ApplicationRecord
+ include NestedRevisionComponent
+
+ belongs_to :comment_thread
+ belongs_to :creator, class_name: 'User'
+
+ validates :body, presence: true
+
+ def entry_for_auto_generated_perma_id
+ comment_thread.revision.entry
+ end
+ end
+end
diff --git a/app/models/pageflow/comment_thread.rb b/app/models/pageflow/comment_thread.rb
new file mode 100644
index 0000000000..c29f529ef0
--- /dev/null
+++ b/app/models/pageflow/comment_thread.rb
@@ -0,0 +1,13 @@
+module Pageflow
+ # @api private
+ class CommentThread < ApplicationRecord
+ include RevisionComponent
+
+ belongs_to :creator, class_name: 'User'
+ has_many :comments, dependent: :destroy
+
+ nested_revision_components :comments
+
+ validates :subject_type, :subject_id, presence: true
+ end
+end
diff --git a/app/models/pageflow/entry_at_revision.rb b/app/models/pageflow/entry_at_revision.rb
index 6083fbfbdc..0d42c1fd08 100644
--- a/app/models/pageflow/entry_at_revision.rb
+++ b/app/models/pageflow/entry_at_revision.rb
@@ -38,6 +38,7 @@ def initialize(entry, revision, theme: nil)
:author, :publisher, :keywords,
:published_at,
:noindex?,
+ :comment_threads,
:configuration,
:structured_data_type_name,
to: :revision)
diff --git a/app/models/pageflow/revision.rb b/app/models/pageflow/revision.rb
index 52dc874b7d..f5122009cd 100644
--- a/app/models/pageflow/revision.rb
+++ b/app/models/pageflow/revision.rb
@@ -27,6 +27,8 @@ class Revision < ApplicationRecord # rubocop:todo Style/Documentation
has_many :chapters, -> { order(CHAPTER_ORDER) }, through: :storylines
has_many :pages, -> { reorder(PAGE_ORDER) }, through: :storylines
+ has_many :comment_threads, dependent: :destroy
+
has_many :file_usages, dependent: :destroy
has_many :image_files, -> { extending WithFileUsageExtension },
diff --git a/app/views/pageflow/review/comment_threads/_comment_thread.json.jbuilder b/app/views/pageflow/review/comment_threads/_comment_thread.json.jbuilder
new file mode 100644
index 0000000000..2c77cac296
--- /dev/null
+++ b/app/views/pageflow/review/comment_threads/_comment_thread.json.jbuilder
@@ -0,0 +1,15 @@
+json.key_format!(camelize: :lower)
+
+json.call(comment_thread,
+ :id,
+ :perma_id,
+ :subject_type,
+ :subject_id,
+ :creator_id,
+ :resolved_at,
+ :created_at,
+ :updated_at)
+
+json.comments(comment_thread.comments) do |comment|
+ json.partial!('pageflow/review/comments/comment', comment:)
+end
diff --git a/app/views/pageflow/review/comment_threads/index.json.jbuilder b/app/views/pageflow/review/comment_threads/index.json.jbuilder
new file mode 100644
index 0000000000..bdd65bcfe4
--- /dev/null
+++ b/app/views/pageflow/review/comment_threads/index.json.jbuilder
@@ -0,0 +1,10 @@
+json.key_format!(camelize: :lower)
+
+json.current_user do
+ json.id current_user.id
+ json.name current_user.full_name
+end
+
+json.comment_threads(@comment_threads) do |comment_thread|
+ json.partial!('pageflow/review/comment_threads/comment_thread', comment_thread:)
+end
diff --git a/app/views/pageflow/review/comments/_comment.json.jbuilder b/app/views/pageflow/review/comments/_comment.json.jbuilder
new file mode 100644
index 0000000000..612cada537
--- /dev/null
+++ b/app/views/pageflow/review/comments/_comment.json.jbuilder
@@ -0,0 +1,11 @@
+json.key_format!(camelize: :lower)
+
+json.call(comment,
+ :id,
+ :perma_id,
+ :creator_id,
+ :body,
+ :created_at,
+ :updated_at)
+
+json.creator_name comment.creator.full_name
diff --git a/config/routes.rb b/config/routes.rb
index d7d7b6a74d..3490e675d0 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -77,6 +77,12 @@
resource :oembed, only: [:show]
end
+ namespace :review do
+ resources :entries, only: [] do
+ resources :comment_threads, only: [:index]
+ end
+ end
+
root to: redirect('/admin')
end
diff --git a/db/migrate/20260323000000_create_comment_threads_and_comments.rb b/db/migrate/20260323000000_create_comment_threads_and_comments.rb
new file mode 100644
index 0000000000..9e91ec8424
--- /dev/null
+++ b/db/migrate/20260323000000_create_comment_threads_and_comments.rb
@@ -0,0 +1,30 @@
+class CreateCommentThreadsAndComments < ActiveRecord::Migration[6.0]
+ def change
+ create_table :pageflow_comment_threads do |t|
+ t.integer :revision_id, null: false
+ t.integer :perma_id
+ t.string :subject_type, null: false
+ t.integer :subject_id, null: false
+ t.integer :creator_id, null: false
+ t.datetime :resolved_at
+ t.integer :resolved_by_id
+ t.timestamps
+ end
+
+ add_index :pageflow_comment_threads, :revision_id
+ add_index :pageflow_comment_threads, :creator_id
+ add_index :pageflow_comment_threads, [:revision_id, :subject_type, :subject_id],
+ name: 'index_comment_threads_on_revision_and_subject'
+
+ create_table :pageflow_comments do |t|
+ t.integer :comment_thread_id, null: false
+ t.integer :perma_id
+ t.integer :creator_id, null: false
+ t.text :body, null: false
+ t.timestamps
+ end
+
+ add_index :pageflow_comments, :comment_thread_id
+ add_index :pageflow_comments, :creator_id
+ end
+end
diff --git a/entry_types/scrolled/package/.eslintignore b/entry_types/scrolled/package/.eslintignore
index b74181e706..16b0dd0c46 100644
--- a/entry_types/scrolled/package/.eslintignore
+++ b/entry_types/scrolled/package/.eslintignore
@@ -8,6 +8,7 @@
/contentElements-editor.js
/contentElements-frontend.js
/contentElements-frontend.css
+/review.js
/testHelpers.js
/widgets
/.storybook/out/
diff --git a/entry_types/scrolled/package/.gitignore b/entry_types/scrolled/package/.gitignore
index ff82081d81..1872c41511 100644
--- a/entry_types/scrolled/package/.gitignore
+++ b/entry_types/scrolled/package/.gitignore
@@ -6,6 +6,8 @@
/contentElements-editor.js
/contentElements-frontend.js
/contentElements-frontend.css
+/review.css
+/review.js
/testHelpers.js
/widgets
/.storybook/out/
diff --git a/entry_types/scrolled/package/.storybook/main.js b/entry_types/scrolled/package/.storybook/main.js
index 5371ba5ca6..f556f85294 100644
--- a/entry_types/scrolled/package/.storybook/main.js
+++ b/entry_types/scrolled/package/.storybook/main.js
@@ -35,7 +35,9 @@ module.exports = {
alias: {
...config.resolve.alias,
'pageflow/frontend': path.resolve(__dirname, '../../../../package/src/frontend'),
+ 'pageflow/review': path.resolve(__dirname, '../../../../package/src/review'),
'pageflow-scrolled/frontend': path.resolve(__dirname, '../src/frontend'),
+ 'pageflow-scrolled/review': path.resolve(__dirname, '../src/review'),
'pageflow-scrolled/testHelpers': path.resolve(__dirname, '../src/testHelpers')
}
}
diff --git a/entry_types/scrolled/package/config/webpack.js b/entry_types/scrolled/package/config/webpack.js
index 48686995a9..a2b7e461c5 100644
--- a/entry_types/scrolled/package/config/webpack.js
+++ b/entry_types/scrolled/package/config/webpack.js
@@ -83,7 +83,10 @@ module.exports = {
import: ['pageflow-scrolled/frontend/inlineEditing.css']
},
'pageflow-scrolled-frontend-commenting': {
- import: ['pageflow-scrolled/frontend/commenting.css']
+ import: [
+ 'pageflow-scrolled/frontend/commenting.css',
+ 'pageflow-scrolled/review.css'
+ ]
}
}
};
diff --git a/entry_types/scrolled/package/spec/frontend/features/commentingMode-spec.js b/entry_types/scrolled/package/spec/frontend/features/commentingMode-spec.js
new file mode 100644
index 0000000000..5e9697c9cc
--- /dev/null
+++ b/entry_types/scrolled/package/spec/frontend/features/commentingMode-spec.js
@@ -0,0 +1,52 @@
+import React from 'react';
+import '@testing-library/jest-dom/extend-expect';
+import {act, waitFor} from '@testing-library/react';
+
+import {Entry} from 'frontend/Entry';
+import {usePageObjects} from 'support/pageObjects';
+import {renderInEntry} from 'support';
+import {clearExtensions} from 'frontend/extensions';
+import {loadCommentingComponents} from 'frontend/commenting';
+
+describe('commenting mode', () => {
+ usePageObjects();
+
+ beforeEach(() => {
+ jest.spyOn(window, 'fetch').mockResolvedValue({
+ ok: true,
+ json: () => Promise.resolve({currentUser: null, commentThreads: []})
+ });
+
+ loadCommentingComponents();
+ });
+
+ afterEach(() => {
+ act(() => clearExtensions());
+ window.fetch.mockRestore();
+ });
+
+ it('fetches threads from API and displays badge', async () => {
+ window.fetch.mockResolvedValue({
+ ok: true,
+ json: () => Promise.resolve({
+ currentUser: {id: 42, name: 'Alice'},
+ commentThreads: [
+ {id: 1, subjectType: 'ContentElement', subjectId: 1, comments: []}
+ ]
+ })
+ });
+
+ const {getByRole} = renderInEntry(, {
+ seed: {
+ contentElements: [{
+ typeName: 'withTestId',
+ configuration: {testId: 5}
+ }]
+ }
+ });
+
+ await waitFor(() => {
+ expect(getByRole('status')).toBeInTheDocument();
+ });
+ });
+});
diff --git a/entry_types/scrolled/package/spec/review/CommentBadge-spec.js b/entry_types/scrolled/package/spec/review/CommentBadge-spec.js
new file mode 100644
index 0000000000..e4f5662faa
--- /dev/null
+++ b/entry_types/scrolled/package/spec/review/CommentBadge-spec.js
@@ -0,0 +1,45 @@
+import React from 'react';
+import '@testing-library/jest-dom/extend-expect';
+import {render, act} from '@testing-library/react';
+
+import {ReviewStateProvider} from 'review/ReviewStateProvider';
+import {CommentBadge} from 'review/CommentBadge';
+
+describe('CommentBadge', () => {
+ it('displays thread count for subject', () => {
+ const {getByRole} = render(
+
+
+
+ );
+
+ act(() => {
+ window.dispatchEvent(new MessageEvent('message', {
+ data: {
+ type: 'REVIEW_STATE_RESET',
+ payload: {
+ currentUser: {id: 1},
+ commentThreads: [
+ {id: 1, subjectType: 'ContentElement', subjectId: 10, comments: []},
+ {id: 2, subjectType: 'ContentElement', subjectId: 10, comments: []},
+ {id: 3, subjectType: 'ContentElement', subjectId: 20, comments: []}
+ ]
+ }
+ },
+ origin: window.location.origin
+ }));
+ });
+
+ expect(getByRole('status')).toHaveTextContent('2');
+ });
+
+ it('renders nothing when no threads exist for subject', () => {
+ const {container} = render(
+
+
+
+ );
+
+ expect(container).toBeEmptyDOMElement();
+ });
+});
diff --git a/entry_types/scrolled/package/spec/review/ReviewMessageHandler-spec.js b/entry_types/scrolled/package/spec/review/ReviewMessageHandler-spec.js
new file mode 100644
index 0000000000..cb9cdb49a8
--- /dev/null
+++ b/entry_types/scrolled/package/spec/review/ReviewMessageHandler-spec.js
@@ -0,0 +1,53 @@
+import BackboneEvents from 'backbone-events-standalone';
+
+import {ReviewMessageHandler} from 'review/ReviewMessageHandler';
+
+function fakeReviewSession() {
+ const session = {};
+ Object.assign(session, BackboneEvents);
+ return session;
+}
+
+describe('ReviewMessageHandler', () => {
+ it('posts REVIEW_STATE_RESET to target window on session reset', () => {
+ const session = fakeReviewSession();
+ const targetWindow = {postMessage: jest.fn()};
+
+ ReviewMessageHandler.create({session, targetWindow});
+
+ const state = {currentUser: {id: 42}, commentThreads: [{id: 1}]};
+ session.trigger('reset', state);
+
+ expect(targetWindow.postMessage).toHaveBeenCalledWith(
+ {type: 'REVIEW_STATE_RESET', payload: state},
+ window.location.origin
+ );
+ });
+
+ it('posts REVIEW_STATE_THREAD_CHANGE to target window on session change:thread', () => {
+ const session = fakeReviewSession();
+ const targetWindow = {postMessage: jest.fn()};
+
+ ReviewMessageHandler.create({session, targetWindow});
+
+ const thread = {id: 1, comments: [{body: 'Hello'}]};
+ session.trigger('change:thread', thread);
+
+ expect(targetWindow.postMessage).toHaveBeenCalledWith(
+ {type: 'REVIEW_STATE_THREAD_CHANGE', payload: thread},
+ window.location.origin
+ );
+ });
+
+ it('can be disposed', () => {
+ const session = fakeReviewSession();
+ const targetWindow = {postMessage: jest.fn()};
+
+ const handler = ReviewMessageHandler.create({session, targetWindow});
+ handler.dispose();
+
+ session.trigger('reset', {});
+
+ expect(targetWindow.postMessage).not.toHaveBeenCalled();
+ });
+});
diff --git a/entry_types/scrolled/package/spec/review/ReviewStateProvider-spec.js b/entry_types/scrolled/package/spec/review/ReviewStateProvider-spec.js
new file mode 100644
index 0000000000..a8a5ac44f6
--- /dev/null
+++ b/entry_types/scrolled/package/spec/review/ReviewStateProvider-spec.js
@@ -0,0 +1,109 @@
+import React from 'react';
+import '@testing-library/jest-dom/extend-expect';
+import {act} from '@testing-library/react';
+import {renderHook} from '@testing-library/react-hooks';
+
+import {
+ ReviewStateProvider,
+ useCommentThreads
+} from 'review/ReviewStateProvider';
+import {
+ postReviewStateResetMessage,
+ postReviewStateThreadChangeMessage
+} from 'review/postMessage';
+
+function wrapper({children}) {
+ return {children};
+}
+
+function postReset(payload) {
+ act(() => {
+ postReviewStateResetMessage(window, payload);
+ });
+}
+
+function postThreadChange(payload) {
+ act(() => {
+ postReviewStateThreadChangeMessage(window, payload);
+ });
+}
+
+describe('ReviewStateProvider', () => {
+ it('provides empty initial state', () => {
+ const {result} = renderHook(
+ () => useCommentThreads('CE', 10),
+ {wrapper}
+ );
+
+ expect(result.current).toEqual([]);
+ });
+
+ it('updates state on reset message', async () => {
+ const {result, waitForNextUpdate} = renderHook(
+ () => useCommentThreads('CE', 10),
+ {wrapper}
+ );
+
+ postReset({
+ currentUser: {id: 42, name: 'Alice'},
+ commentThreads: [
+ {id: 1, subjectType: 'CE', subjectId: 10, comments: []},
+ {id: 2, subjectType: 'CE', subjectId: 20, comments: []}
+ ]
+ });
+
+ await waitForNextUpdate();
+
+ expect(result.current).toHaveLength(1);
+ expect(result.current[0].id).toBe(1);
+ });
+
+ it('updates single thread on thread change message', async () => {
+ const {result, waitForNextUpdate} = renderHook(
+ () => useCommentThreads('CE', 10),
+ {wrapper}
+ );
+
+ postReset({
+ currentUser: {id: 42},
+ commentThreads: [
+ {id: 1, subjectType: 'CE', subjectId: 10, comments: []}
+ ]
+ });
+
+ await waitForNextUpdate();
+ expect(result.current[0].comments).toHaveLength(0);
+
+ postThreadChange({
+ id: 1,
+ subjectType: 'CE',
+ subjectId: 10,
+ comments: [{id: 100, body: 'Hello'}]
+ });
+
+ await waitForNextUpdate();
+ expect(result.current[0].comments).toHaveLength(1);
+ });
+
+ it('ignores messages from different origins', () => {
+ const {result} = renderHook(
+ () => useCommentThreads('CE', 10),
+ {wrapper}
+ );
+
+ act(() => {
+ window.dispatchEvent(new MessageEvent('message', {
+ data: {
+ type: 'REVIEW_STATE_RESET',
+ payload: {
+ currentUser: {id: 99},
+ commentThreads: [{id: 1, subjectType: 'CE', subjectId: 10, comments: []}]
+ }
+ },
+ origin: 'https://evil.example.com'
+ }));
+ });
+
+ expect(result.current).toEqual([]);
+ });
+});
diff --git a/entry_types/scrolled/package/src/frontend/commenting/ContentElementDecorator.js b/entry_types/scrolled/package/src/frontend/commenting/ContentElementDecorator.js
new file mode 100644
index 0000000000..61ade27f92
--- /dev/null
+++ b/entry_types/scrolled/package/src/frontend/commenting/ContentElementDecorator.js
@@ -0,0 +1,12 @@
+import React from 'react';
+
+import {CommentBadge} from 'pageflow-scrolled/review';
+
+export function ContentElementDecorator({permaId, children}) {
+ return (
+ <>
+ {children}
+
+ >
+ );
+}
diff --git a/entry_types/scrolled/package/src/frontend/commenting/EntryDecorator.js b/entry_types/scrolled/package/src/frontend/commenting/EntryDecorator.js
index 11151542d5..f8a794a595 100644
--- a/entry_types/scrolled/package/src/frontend/commenting/EntryDecorator.js
+++ b/entry_types/scrolled/package/src/frontend/commenting/EntryDecorator.js
@@ -1,12 +1,34 @@
-import React from 'react';
+import React, {useEffect} from 'react';
+import {useEntryMetadata} from '../../entryState';
+import {createReviewSession} from 'pageflow/review';
+import {ReviewStateProvider, ReviewMessageHandler} from 'pageflow-scrolled/review';
import {FloatingToolbar} from './FloatingToolbar';
export function EntryDecorator(props) {
return (
- <>
+
+
{props.children}
- >
+
);
}
+
+function ReviewSessionSetup() {
+ const entryMetadata = useEntryMetadata();
+ const entryId = entryMetadata?.id;
+
+ useEffect(() => {
+ if (!entryId) return;
+
+ const session = createReviewSession({entryId});
+ const handler = ReviewMessageHandler.create({session, targetWindow: window});
+
+ session.fetch();
+
+ return () => handler.dispose();
+ }, [entryId]);
+
+ return null;
+}
diff --git a/entry_types/scrolled/package/src/frontend/commenting/index.js b/entry_types/scrolled/package/src/frontend/commenting/index.js
index c10d62008b..e62dbb8096 100644
--- a/entry_types/scrolled/package/src/frontend/commenting/index.js
+++ b/entry_types/scrolled/package/src/frontend/commenting/index.js
@@ -1,10 +1,12 @@
import {provideExtensions} from '../extensions';
import {EntryDecorator} from './EntryDecorator';
+import {ContentElementDecorator} from './ContentElementDecorator';
export function loadCommentingComponents() {
provideExtensions({
decorators: {
- Entry: EntryDecorator
+ Entry: EntryDecorator,
+ ContentElement: ContentElementDecorator
}
});
}
diff --git a/entry_types/scrolled/package/src/review/CommentBadge.js b/entry_types/scrolled/package/src/review/CommentBadge.js
new file mode 100644
index 0000000000..3356a38e0f
--- /dev/null
+++ b/entry_types/scrolled/package/src/review/CommentBadge.js
@@ -0,0 +1,21 @@
+import React from 'react';
+
+import {useCommentThreads} from './ReviewStateProvider';
+
+import CommentIcon from './images/comment.svg';
+import styles from './CommentBadge.module.css';
+
+export function CommentBadge({subjectType, subjectId}) {
+ const threads = useCommentThreads(subjectType, subjectId);
+
+ if (threads.length === 0) {
+ return null;
+ }
+
+ return (
+
+
+ {threads.length}
+
+ );
+}
diff --git a/entry_types/scrolled/package/src/review/CommentBadge.module.css b/entry_types/scrolled/package/src/review/CommentBadge.module.css
new file mode 100644
index 0000000000..caf7fd3d42
--- /dev/null
+++ b/entry_types/scrolled/package/src/review/CommentBadge.module.css
@@ -0,0 +1,27 @@
+.badge {
+ display: inline-flex;
+ align-items: center;
+ gap: space(1);
+ height: space(7);
+ padding: space(1) space(3);
+ border-radius: rounded(full);
+ background: var(--ui-accent-color);
+ color: var(--ui-on-accent-color);
+ font-family: var(--ui-font-family);
+ font-size: space(3.5);
+ font-weight: 600;
+ line-height: 0;
+ cursor: pointer;
+ border: none;
+ box-shadow: 0 0 0 space(1) var(--ui-accent-color-glow);
+ opacity: 0.9;
+}
+
+.badge:hover {
+ opacity: 1;
+}
+
+.icon {
+ width: 16px;
+ height: 16px;
+}
diff --git a/entry_types/scrolled/package/src/review/ReviewMessageHandler.js b/entry_types/scrolled/package/src/review/ReviewMessageHandler.js
new file mode 100644
index 0000000000..cef2b4d4ce
--- /dev/null
+++ b/entry_types/scrolled/package/src/review/ReviewMessageHandler.js
@@ -0,0 +1,26 @@
+import {
+ postReviewStateResetMessage,
+ postReviewStateThreadChangeMessage
+} from './postMessage';
+
+export const ReviewMessageHandler = {
+ create({session, targetWindow}) {
+ function handleReset(state) {
+ postReviewStateResetMessage(targetWindow, state);
+ }
+
+ function handleThreadChange(thread) {
+ postReviewStateThreadChangeMessage(targetWindow, thread);
+ }
+
+ session.on('reset', handleReset);
+ session.on('change:thread', handleThreadChange);
+
+ return {
+ dispose() {
+ session.off('reset', handleReset);
+ session.off('change:thread', handleThreadChange);
+ }
+ };
+ }
+};
diff --git a/entry_types/scrolled/package/src/review/ReviewStateProvider.js b/entry_types/scrolled/package/src/review/ReviewStateProvider.js
new file mode 100644
index 0000000000..4517e7392f
--- /dev/null
+++ b/entry_types/scrolled/package/src/review/ReviewStateProvider.js
@@ -0,0 +1,78 @@
+import React, {createContext, useContext, useEffect, useMemo, useReducer} from 'react';
+
+const ReviewStateContext = createContext(null);
+
+export function ReviewStateProvider({children}) {
+ const [state, dispatch] = useReducer(reducer, {
+ currentUser: null,
+ threads: {}
+ });
+
+ useEffect(() => {
+ function handleMessage(event) {
+ if (window.location.href.indexOf(event.origin) !== 0) return;
+
+ const {type, payload} = event.data;
+
+ if (type === 'REVIEW_STATE_RESET') {
+ dispatch({type: 'RESET', payload});
+ }
+ else if (type === 'REVIEW_STATE_THREAD_CHANGE') {
+ dispatch({type: 'UPSERT_THREAD', payload});
+ }
+ }
+
+ window.addEventListener('message', handleMessage);
+ return () => window.removeEventListener('message', handleMessage);
+ }, []);
+
+ const value = useMemo(() => ({
+ currentUser: state.currentUser,
+ commentThreads: Object.values(state.threads)
+ }), [state]);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useCommentThreads(subjectType, subjectId) {
+ const context = useContext(ReviewStateContext);
+ const commentThreads = context ? context.commentThreads : [];
+
+ return useMemo(
+ () => commentThreads.filter(
+ thread => thread.subjectType === subjectType &&
+ thread.subjectId === subjectId
+ ),
+ [commentThreads, subjectType, subjectId]
+ );
+}
+
+function reducer(state, action) {
+ switch (action.type) {
+ case 'RESET': {
+ const threads = {};
+ action.payload.commentThreads.forEach(thread => {
+ threads[thread.id] = thread;
+ });
+
+ return {
+ currentUser: action.payload.currentUser,
+ threads
+ };
+ }
+ case 'UPSERT_THREAD':
+ return {
+ ...state,
+ threads: {
+ ...state.threads,
+ [action.payload.id]: action.payload
+ }
+ };
+ default:
+ return state;
+ }
+}
diff --git a/entry_types/scrolled/package/src/review/images/comment.svg b/entry_types/scrolled/package/src/review/images/comment.svg
new file mode 100644
index 0000000000..d43b357c7d
--- /dev/null
+++ b/entry_types/scrolled/package/src/review/images/comment.svg
@@ -0,0 +1 @@
+
diff --git a/entry_types/scrolled/package/src/review/index.js b/entry_types/scrolled/package/src/review/index.js
new file mode 100644
index 0000000000..90a7b4acfe
--- /dev/null
+++ b/entry_types/scrolled/package/src/review/index.js
@@ -0,0 +1,3 @@
+export {ReviewStateProvider, useCommentThreads} from './ReviewStateProvider';
+export {ReviewMessageHandler} from './ReviewMessageHandler';
+export {CommentBadge} from './CommentBadge';
diff --git a/entry_types/scrolled/package/src/review/postMessage.js b/entry_types/scrolled/package/src/review/postMessage.js
new file mode 100644
index 0000000000..37224c2f13
--- /dev/null
+++ b/entry_types/scrolled/package/src/review/postMessage.js
@@ -0,0 +1,13 @@
+export function postReviewStateResetMessage(targetWindow, state) {
+ targetWindow.postMessage(
+ {type: 'REVIEW_STATE_RESET', payload: state},
+ window.location.origin
+ );
+}
+
+export function postReviewStateThreadChangeMessage(targetWindow, thread) {
+ targetWindow.postMessage(
+ {type: 'REVIEW_STATE_THREAD_CHANGE', payload: thread},
+ window.location.origin
+ );
+}
diff --git a/entry_types/scrolled/spec/features/entry_previewer/commenting_on_content_elements_spec.rb b/entry_types/scrolled/spec/features/entry_previewer/commenting_on_content_elements_spec.rb
new file mode 100644
index 0000000000..4f49809a9d
--- /dev/null
+++ b/entry_types/scrolled/spec/features/entry_previewer/commenting_on_content_elements_spec.rb
@@ -0,0 +1,36 @@
+require 'spec_helper'
+
+require 'pageflow/dom'
+require 'support/dominos/editor'
+
+RSpec.feature 'as entry previewer, commenting on content elements', js: true do
+ scenario 'sees existing comment badge in entry preview' do
+ entry = create(:entry, :published, type_name: 'scrolled',
+ with_feature: 'commenting')
+ content_element = create(:content_element,
+ revision: entry.draft,
+ type_name: 'textBlock',
+ configuration: {
+ value: [{type: 'paragraph',
+ children: [{text: 'Some text'}]}]
+ })
+
+ user = Pageflow::Dom::Admin::Page.sign_in_as(:previewer, on: entry)
+
+ create(:comment_thread,
+ revision: entry.draft,
+ creator: user,
+ subject_type: 'ContentElement',
+ subject_id: content_element.perma_id) do |thread|
+ create(:comment,
+ comment_thread: thread,
+ creator: user,
+ body: 'Please review this paragraph')
+ end
+
+ visit(pageflow.revision_path(entry.draft))
+
+ expect(page).to have_text('Some text', wait: 10)
+ expect(page).to have_css('[role="status"]', text: '1', wait: 10)
+ end
+end
diff --git a/package/.eslintignore b/package/.eslintignore
index 909d318f96..1789551973 100644
--- a/package/.eslintignore
+++ b/package/.eslintignore
@@ -3,4 +3,5 @@ node_modules
/editor.js
/frontend.js
/ui.js
+/review.js
/testHelpers.js
diff --git a/package/.gitignore b/package/.gitignore
index e5837c1feb..895b54e783 100644
--- a/package/.gitignore
+++ b/package/.gitignore
@@ -2,4 +2,5 @@
/editor.js
/frontend.js
/ui.js
+/review.js
/testHelpers.js
\ No newline at end of file
diff --git a/package/spec/review/ReviewSession-spec.js b/package/spec/review/ReviewSession-spec.js
new file mode 100644
index 0000000000..c33d298576
--- /dev/null
+++ b/package/spec/review/ReviewSession-spec.js
@@ -0,0 +1,35 @@
+import {ReviewSession} from 'review/ReviewSession';
+
+describe('ReviewSession', () => {
+ it('emits reset event with threads after fetch', async () => {
+ const request = jest.fn().mockResolvedValue({
+ currentUser: {id: 42, name: 'Alice'},
+ commentThreads: [
+ {id: 1, subjectType: 'ContentElement', subjectId: 10, comments: [
+ {id: 100, body: 'Hello', creatorId: 42, creatorName: 'Alice'}
+ ]}
+ ]
+ });
+
+ const session = new ReviewSession({entryId: 5, request});
+ const listener = jest.fn();
+ session.on('reset', listener);
+
+ await session.fetch();
+
+ expect(request).toHaveBeenCalledWith({
+ url: '/review/entries/5/comment_threads',
+ method: 'GET'
+ });
+
+ expect(listener).toHaveBeenCalledWith({
+ currentUser: {id: 42, name: 'Alice'},
+ commentThreads: [
+ expect.objectContaining({
+ id: 1,
+ comments: [expect.objectContaining({body: 'Hello'})]
+ })
+ ]
+ });
+ });
+});
diff --git a/package/spec/review/request-spec.js b/package/spec/review/request-spec.js
new file mode 100644
index 0000000000..cc8b98b8bb
--- /dev/null
+++ b/package/spec/review/request-spec.js
@@ -0,0 +1,37 @@
+import {request} from 'review/request';
+
+describe('request', () => {
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ it('performs request and returns parsed JSON', async () => {
+ window.fetch = jest.fn().mockResolvedValue({
+ ok: true,
+ json: () => Promise.resolve({some: 'data'})
+ });
+
+ const result = await request({url: '/some/path', method: 'GET'});
+
+ expect(window.fetch).toHaveBeenCalledWith('/some/path', {
+ method: 'GET',
+ credentials: 'same-origin',
+ headers: {
+ 'Accept': 'application/json'
+ }
+ });
+ expect(result).toEqual({some: 'data'});
+ });
+
+ it('throws on non-ok response', async () => {
+ window.fetch = jest.fn().mockResolvedValue({
+ ok: false,
+ status: 422,
+ statusText: 'Unprocessable Entity'
+ });
+
+ await expect(
+ request({url: '/some/path', method: 'GET'})
+ ).rejects.toThrow('422');
+ });
+});
diff --git a/package/src/review/ReviewSession.js b/package/src/review/ReviewSession.js
new file mode 100644
index 0000000000..ab372bd48a
--- /dev/null
+++ b/package/src/review/ReviewSession.js
@@ -0,0 +1,22 @@
+import BackboneEvents from 'backbone-events-standalone';
+
+export class ReviewSession {
+ constructor({entryId, request}) {
+ this._entryId = entryId;
+ this._request = request;
+ }
+
+ async fetch() {
+ const data = await this._request({
+ url: `/review/entries/${this._entryId}/comment_threads`,
+ method: 'GET'
+ });
+
+ this.trigger('reset', {
+ currentUser: data.currentUser,
+ commentThreads: data.commentThreads
+ });
+ }
+}
+
+Object.assign(ReviewSession.prototype, BackboneEvents);
diff --git a/package/src/review/index.js b/package/src/review/index.js
new file mode 100644
index 0000000000..dd4dbbe8e6
--- /dev/null
+++ b/package/src/review/index.js
@@ -0,0 +1,8 @@
+import {ReviewSession} from './ReviewSession';
+import {request} from './request';
+
+export {ReviewSession};
+
+export function createReviewSession({entryId}) {
+ return new ReviewSession({entryId, request});
+}
diff --git a/package/src/review/request.js b/package/src/review/request.js
new file mode 100644
index 0000000000..64d5bc56cd
--- /dev/null
+++ b/package/src/review/request.js
@@ -0,0 +1,15 @@
+export async function request({url, method}) {
+ const response = await fetch(url, {
+ method,
+ credentials: 'same-origin',
+ headers: {
+ 'Accept': 'application/json'
+ }
+ });
+
+ if (!response.ok) {
+ throw new Error(`${response.status} ${response.statusText}`);
+ }
+
+ return response.json();
+}
diff --git a/rollup.config.js b/rollup.config.js
index 228a24b736..210d7fc194 100644
--- a/rollup.config.js
+++ b/rollup.config.js
@@ -148,6 +148,15 @@ const pageflow = [
external,
plugins: plugins()
},
+ {
+ input: pageflowPackageRoot + '/src/review/index.js',
+ output: {
+ file: pageflowPackageRoot + '/review.js',
+ format: 'esm'
+ },
+ external,
+ plugins: plugins()
+ },
{
input: pageflowPackageRoot + '/src/ui/index.js',
output: {
@@ -282,6 +291,16 @@ const pageflowScrolled = [
}),
...ignoreJSXWarning
},
+ {
+ input: pageflowScrolledPackageRoot + '/src/review/index.js',
+ output: {
+ file: pageflowScrolledPackageRoot + '/review.js',
+ format: 'esm',
+ },
+ external,
+ plugins: plugins({extractCss: true}),
+ ...ignoreJSXWarning
+ },
{
input: pageflowScrolledPackageRoot + '/src/frontend/server.js',
output: {
diff --git a/spec/controllers/pageflow/review/comment_threads_controller_spec.rb b/spec/controllers/pageflow/review/comment_threads_controller_spec.rb
new file mode 100644
index 0000000000..41dc5b1b6f
--- /dev/null
+++ b/spec/controllers/pageflow/review/comment_threads_controller_spec.rb
@@ -0,0 +1,72 @@
+require 'spec_helper'
+
+module Pageflow
+ describe Review::CommentThreadsController do
+ routes { Engine.routes }
+ render_views
+
+ describe '#index' do
+ it 'returns threads with comments for draft revision' do
+ user = create(:user)
+ entry = create(:entry, with_previewer: user)
+
+ thread = create(:comment_thread,
+ revision: entry.draft,
+ creator: user)
+ comment = create(:comment, comment_thread: thread, creator: user)
+
+ sign_in(user, scope: :user)
+ get(:index, params: {entry_id: entry.id}, format: 'json')
+
+ expect(response.status).to eq(200)
+ expect(response.body).to include_json(
+ currentUser: {
+ id: user.id,
+ name: user.full_name
+ },
+ commentThreads: [
+ {
+ id: thread.id,
+ comments: [{id: comment.id}]
+ }
+ ]
+ )
+ end
+
+ it 'does not have N+1 queries' do
+ user = create(:user)
+ entry = create(:entry, with_previewer: user)
+
+ thread = create(:comment_thread,
+ revision: entry.draft,
+ creator: user)
+ create(:comment, comment_thread: thread, creator: user)
+ create(:comment, comment_thread: thread, creator: create(:user))
+
+ sign_in(user, scope: :user)
+
+ detect_n_plus_one_queries do
+ get(:index, params: {entry_id: entry.id}, format: 'json')
+ end
+ end
+
+ it 'requires user to be signed in' do
+ entry = create(:entry)
+
+ get(:index, params: {entry_id: entry.id}, format: 'json')
+
+ expect(response.status).to eq(401)
+ end
+
+ it 'requires read permission on entry' do
+ user = create(:user)
+ entry = create(:entry)
+
+ sign_in(user, scope: :user)
+ get(:index, params: {entry_id: entry.id}, format: 'json')
+
+ expect(response.status).to eq(403)
+ end
+ end
+ end
+end
diff --git a/spec/factories/comment_threads.rb b/spec/factories/comment_threads.rb
new file mode 100644
index 0000000000..6c4fff82ce
--- /dev/null
+++ b/spec/factories/comment_threads.rb
@@ -0,0 +1,10 @@
+module Pageflow
+ FactoryBot.define do
+ factory :comment_thread, class: CommentThread do
+ revision
+ creator factory: :user
+ subject_type { 'ContentElement' }
+ subject_id { 1 }
+ end
+ end
+end
diff --git a/spec/factories/comments.rb b/spec/factories/comments.rb
new file mode 100644
index 0000000000..7e856dd300
--- /dev/null
+++ b/spec/factories/comments.rb
@@ -0,0 +1,9 @@
+module Pageflow
+ FactoryBot.define do
+ factory :comment, class: Comment do
+ comment_thread
+ creator factory: :user
+ body { 'A comment' }
+ end
+ end
+end