Browse Source

Merge Wagtail Comment Frontend (#6953)

* Copy code from wagtail-comment-frontend

Exact copy of the src directory from:
https://github.com/jacobtoppm/wagtail-comment-frontend/commit/4486c2fc32cecf36dbcab7c3a7bb842a05d9db98

* Integrate commenting code

* Linting
Karl Hobley 4 years ago
parent
commit
bbbc31ff60
45 changed files with 3892 additions and 14 deletions
  1. 1 0
      client/scss/styles.scss
  2. 65 0
      client/src/components/CommentApp/__fixtures__/state.tsx
  3. 147 0
      client/src/components/CommentApp/actions/comments.ts
  4. 4 0
      client/src/components/CommentApp/actions/index.ts
  5. 19 0
      client/src/components/CommentApp/actions/settings.ts
  6. 1 1
      client/src/components/CommentApp/comments.js
  7. 147 0
      client/src/components/CommentApp/components/Comment/index.stories.tsx
  8. 596 0
      client/src/components/CommentApp/components/Comment/index.tsx
  9. 100 0
      client/src/components/CommentApp/components/Comment/style.scss
  10. 83 0
      client/src/components/CommentApp/components/CommentHeader/index.tsx
  11. 136 0
      client/src/components/CommentApp/components/CommentHeader/style.scss
  12. 184 0
      client/src/components/CommentApp/components/CommentReply/index.stories.tsx
  13. 346 0
      client/src/components/CommentApp/components/CommentReply/index.tsx
  14. 89 0
      client/src/components/CommentApp/components/CommentReply/style.scss
  15. 198 0
      client/src/components/CommentApp/components/Form/index.tsx
  16. 35 0
      client/src/components/CommentApp/components/TopBar/index.stories.tsx
  17. 42 0
      client/src/components/CommentApp/components/TopBar/index.tsx
  18. 21 0
      client/src/components/CommentApp/components/TopBar/style.scss
  19. 23 0
      client/src/components/CommentApp/components/widgets/Checkbox/index.stories.tsx
  20. 44 0
      client/src/components/CommentApp/components/widgets/Checkbox/index.tsx
  21. 62 0
      client/src/components/CommentApp/components/widgets/Checkbox/style.scss
  22. 33 0
      client/src/components/CommentApp/components/widgets/Radio/index.stories.tsx
  23. 48 0
      client/src/components/CommentApp/components/widgets/Radio/index.tsx
  24. 60 0
      client/src/components/CommentApp/components/widgets/Radio/style.scss
  25. 2 0
      client/src/components/CommentApp/custom.d.ts
  26. 1 0
      client/src/components/CommentApp/icons/check-solid.svg
  27. 1 0
      client/src/components/CommentApp/icons/ellipsis-v-solid.svg
  28. 158 0
      client/src/components/CommentApp/main.scss
  29. 341 0
      client/src/components/CommentApp/main.tsx
  30. 29 0
      client/src/components/CommentApp/selectors/index.ts
  31. 25 0
      client/src/components/CommentApp/selectors/selectors.test.ts
  32. 214 0
      client/src/components/CommentApp/state/comments.test.ts
  33. 242 0
      client/src/components/CommentApp/state/comments.ts
  34. 14 0
      client/src/components/CommentApp/state/index.ts
  35. 29 0
      client/src/components/CommentApp/state/settings.ts
  36. 3 0
      client/src/components/CommentApp/state/utils.ts
  37. 3 0
      client/src/components/CommentApp/utils/annotation.ts
  38. 187 0
      client/src/components/CommentApp/utils/layout.ts
  39. 7 0
      client/src/components/CommentApp/utils/maps.ts
  40. 10 0
      client/src/components/CommentApp/utils/sequences.ts
  41. 130 0
      client/src/components/CommentApp/utils/storybook.tsx
  42. 3 3
      client/src/components/Draftail/CommentableEditor/CommentableEditor.tsx
  43. 5 8
      package-lock.json
  44. 2 1
      package.json
  45. 2 1
      tsconfig.json

+ 1 - 0
client/scss/styles.scss

@@ -98,6 +98,7 @@ These are classes for components.
 @import '../src/components/LoadingSpinner/LoadingSpinner';
 @import '../src/components/PublicationStatus/PublicationStatus';
 @import '../src/components/Explorer/Explorer';
+@import '../src/components/CommentApp/main';
 
 // Legacy
 @import 'components/icons';

+ 65 - 0
client/src/components/CommentApp/__fixtures__/state.tsx

@@ -0,0 +1,65 @@
+import type { Comment, CommentReply, CommentsState } from '../state/comments';
+
+
+const remoteReply: CommentReply = {
+  localId: 2,
+  remoteId: 2,
+  mode: 'default',
+  author: { id: 1, name: 'test user' },
+  date: 0,
+  text: 'a reply',
+  newText: '',
+  deleted: false,
+};
+
+const localReply: CommentReply = {
+  localId: 3,
+  remoteId: null,
+  mode: 'default',
+  author: { id: 1, name: 'test user' },
+  date: 0,
+  text: 'another reply',
+  newText: '',
+  deleted: false,
+};
+
+const remoteComment: Comment = {
+  contentpath: 'test_contentpath',
+  position: '',
+  localId: 1,
+  annotation: null,
+  remoteId: 1,
+  mode: 'default',
+  deleted: false,
+  author: { id: 1, name: 'test user' },
+  date: 0,
+  text: 'test text',
+  newReply: '',
+  newText: '',
+  remoteReplyCount: 1,
+  replies: new Map([[remoteReply.localId, remoteReply], [localReply.localId, localReply]]),
+};
+
+const localComment: Comment = {
+  contentpath: 'test_contentpath_2',
+  position: '',
+  localId: 4,
+  annotation: null,
+  remoteId: null,
+  mode: 'default',
+  deleted: false,
+  author: { id: 1, name: 'test user' },
+  date: 0,
+  text: 'unsaved comment',
+  newReply: '',
+  newText: '',
+  replies: new Map(),
+  remoteReplyCount: 0,
+};
+
+export const basicCommentsState: CommentsState = {
+  focusedComment: 1,
+  pinnedComment: 1,
+  remoteCommentCount: 1,
+  comments: new Map([[remoteComment.localId, remoteComment], [localComment.localId, localComment]]),
+};

+ 147 - 0
client/src/components/CommentApp/actions/comments.ts

@@ -0,0 +1,147 @@
+import type {
+  Comment,
+  CommentUpdate,
+  CommentReply,
+  CommentReplyUpdate,
+} from '../state/comments';
+
+export const ADD_COMMENT = 'add-comment';
+export const UPDATE_COMMENT = 'update-comment';
+export const DELETE_COMMENT = 'delete-comment';
+export const SET_FOCUSED_COMMENT = 'set-focused-comment';
+export const SET_PINNED_COMMENT = 'set-pinned-comment';
+
+export const ADD_REPLY = 'add-reply';
+export const UPDATE_REPLY = 'update-reply';
+export const DELETE_REPLY = 'delete-reply';
+
+export interface AddCommentAction {
+  type: typeof ADD_COMMENT;
+  comment: Comment;
+}
+
+export interface UpdateCommentAction {
+  type: typeof UPDATE_COMMENT;
+  commentId: number;
+  update: CommentUpdate;
+}
+
+export interface DeleteCommentAction {
+  type: typeof DELETE_COMMENT;
+  commentId: number;
+}
+
+export interface SetFocusedCommentAction {
+  type: typeof SET_FOCUSED_COMMENT;
+  commentId: number | null;
+  updatePinnedComment: boolean;
+}
+
+export interface AddReplyAction {
+  type: typeof ADD_REPLY;
+  commentId: number;
+  reply: CommentReply;
+}
+
+export interface UpdateReplyAction {
+  type: typeof UPDATE_REPLY;
+  commentId: number;
+  replyId: number;
+  update: CommentReplyUpdate;
+}
+
+export interface DeleteReplyAction {
+  type: typeof DELETE_REPLY;
+  commentId: number;
+  replyId: number;
+}
+
+export type Action =
+  | AddCommentAction
+  | UpdateCommentAction
+  | DeleteCommentAction
+  | SetFocusedCommentAction
+  | AddReplyAction
+  | UpdateReplyAction
+  | DeleteReplyAction;
+
+export function addComment(comment: Comment): AddCommentAction {
+  return {
+    type: ADD_COMMENT,
+    comment,
+  };
+}
+
+export function updateComment(
+  commentId: number,
+  update: CommentUpdate
+): UpdateCommentAction {
+  return {
+    type: UPDATE_COMMENT,
+    commentId,
+    update,
+  };
+}
+
+export function deleteComment(commentId: number): DeleteCommentAction {
+  return {
+    type: DELETE_COMMENT,
+    commentId,
+  };
+}
+
+export function setFocusedComment(
+  commentId: number | null,
+  { updatePinnedComment } = { updatePinnedComment: false }
+): SetFocusedCommentAction {
+  return {
+    type: SET_FOCUSED_COMMENT,
+    commentId,
+    updatePinnedComment
+  };
+}
+
+export function addReply(
+  commentId: number,
+  reply: CommentReply
+): AddReplyAction {
+  return {
+    type: ADD_REPLY,
+    commentId,
+    reply,
+  };
+}
+
+export function updateReply(
+  commentId: number,
+  replyId: number,
+  update: CommentReplyUpdate
+): UpdateReplyAction {
+  return {
+    type: UPDATE_REPLY,
+    commentId,
+    replyId,
+    update,
+  };
+}
+
+export function deleteReply(
+  commentId: number,
+  replyId: number
+): DeleteReplyAction {
+  return {
+    type: DELETE_REPLY,
+    commentId,
+    replyId,
+  };
+}
+
+export const commentActionFunctions = {
+  addComment,
+  updateComment,
+  deleteComment,
+  setFocusedComment,
+  addReply,
+  updateReply,
+  deleteReply,
+};

+ 4 - 0
client/src/components/CommentApp/actions/index.ts

@@ -0,0 +1,4 @@
+import type { Action as CommentsAction } from './comments';
+import type { Action as SettingsActon } from './settings';
+
+export type Action = CommentsAction | SettingsActon;

+ 19 - 0
client/src/components/CommentApp/actions/settings.ts

@@ -0,0 +1,19 @@
+import type { SettingsStateUpdate } from '../state/settings';
+
+export const UPDATE_GLOBAL_SETTINGS = 'update-global-settings';
+
+export interface UpdateGlobalSettingsAction {
+  type: typeof UPDATE_GLOBAL_SETTINGS;
+  update: SettingsStateUpdate;
+}
+
+export type Action = UpdateGlobalSettingsAction;
+
+export function updateGlobalSettings(
+  update: SettingsStateUpdate
+): UpdateGlobalSettingsAction {
+  return {
+    type: UPDATE_GLOBAL_SETTINGS,
+    update,
+  };
+}

+ 1 - 1
client/src/components/CommentApp/comments.js

@@ -1,4 +1,4 @@
-import { initCommentApp } from 'wagtail-comment-frontend';
+import { initCommentApp } from './main';
 import { STRINGS } from '../../config/wagtailConfig';
 
 function initComments() {

+ 147 - 0
client/src/components/CommentApp/components/Comment/index.stories.tsx

@@ -0,0 +1,147 @@
+import React from 'react';
+import { createStore } from 'redux';
+
+import { Store, reducer } from '../../state';
+
+import {
+  RenderCommentsForStorybook,
+  addTestComment,
+} from '../../utils/storybook';
+
+export default { title: 'Comment' };
+
+export function addNewComment() {
+  const store: Store = createStore(reducer);
+
+  addTestComment(store, {
+    mode: 'creating',
+    focused: true,
+  });
+
+  return <RenderCommentsForStorybook store={store} />;
+}
+
+export function comment() {
+  const store: Store = createStore(reducer);
+
+  addTestComment(store, {
+    mode: 'default',
+    text: 'An example comment',
+  });
+
+  return <RenderCommentsForStorybook store={store} />;
+}
+
+export function commentFromSomeoneElse() {
+  const store: Store = createStore(reducer);
+
+  addTestComment(store, {
+    mode: 'default',
+    text: 'An example comment',
+    author: {
+      id: 2,
+      name: 'Someone else',
+      avatarUrl: 'https://gravatar.com/avatar/31c3d5cc27d1faa321c2413589e8a53f?s=200&d=robohash&r=x',
+    },
+  });
+
+  return <RenderCommentsForStorybook store={store} />;
+}
+
+export function commentFromSomeoneElseWithoutAvatar() {
+  const store: Store = createStore(reducer);
+
+  addTestComment(store, {
+    mode: 'default',
+    text: 'An example comment',
+    author: {
+      id: 2,
+      name: 'Someone else',
+    },
+  });
+
+  return <RenderCommentsForStorybook store={store} />;
+}
+
+export function commentFromSomeoneWithAReallyLongName() {
+  const store: Store = createStore(reducer);
+
+  addTestComment(store, {
+    mode: 'default',
+    text: 'An example comment',
+    author: {
+      id: 1,
+      name: 'This person has a really long name and it should wrap to the next line',
+      avatarUrl: 'https://gravatar.com/avatar/31c3d5cc27d1faa321c2413589e8a53f?s=200&d=robohash&r=x',
+
+    },
+  });
+
+  return <RenderCommentsForStorybook store={store} />;
+}
+
+export function focused() {
+  const store: Store = createStore(reducer);
+
+  addTestComment(store, {
+    mode: 'default',
+    text: 'An example comment',
+    focused: true,
+  });
+
+  return <RenderCommentsForStorybook store={store} />;
+}
+
+export function saving() {
+  const store: Store = createStore(reducer);
+
+  addTestComment(store, {
+    mode: 'saving',
+    text: 'An example comment',
+  });
+
+  return <RenderCommentsForStorybook store={store} />;
+}
+
+export function saveError() {
+  const store: Store = createStore(reducer);
+
+  addTestComment(store, {
+    mode: 'save_error',
+    text: 'An example comment',
+  });
+
+  return <RenderCommentsForStorybook store={store} />;
+}
+
+export function deleteConfirm() {
+  const store: Store = createStore(reducer);
+
+  addTestComment(store, {
+    mode: 'delete_confirm',
+    text: 'An example comment',
+  });
+
+  return <RenderCommentsForStorybook store={store} />;
+}
+
+export function deleting() {
+  const store: Store = createStore(reducer);
+
+  addTestComment(store, {
+    mode: 'deleting',
+    text: 'An example comment',
+  });
+
+  return <RenderCommentsForStorybook store={store} />;
+}
+
+export function deleteError() {
+  const store: Store = createStore(reducer);
+  addTestComment(store, {
+    mode: 'delete_error',
+    text: 'An example comment',
+  });
+
+  return <RenderCommentsForStorybook store={store} />;
+}

+ 596 - 0
client/src/components/CommentApp/components/Comment/index.tsx

@@ -0,0 +1,596 @@
+/* eslint-disable react/prop-types */
+
+import React from 'react';
+import ReactDOM from 'react-dom';
+
+import type { Store } from '../../state';
+import { Author, Comment, newCommentReply } from '../../state/comments';
+import {
+  updateComment,
+  deleteComment,
+  setFocusedComment,
+  addReply
+} from '../../actions/comments';
+import { LayoutController } from '../../utils/layout';
+import { getNextReplyId } from '../../utils/sequences';
+import CommentReplyComponent from '../CommentReply';
+import type { TranslatableStrings } from '../../main';
+import { CommentHeader }  from '../CommentHeader';
+
+async function saveComment(comment: Comment, store: Store) {
+  store.dispatch(
+    updateComment(comment.localId, {
+      mode: 'saving',
+    })
+  );
+
+  try {
+    store.dispatch(
+      updateComment(comment.localId, {
+        mode: 'default',
+        text: comment.newText,
+        remoteId: comment.remoteId,
+        author: comment.author,
+        date: comment.date,
+      })
+    );
+  } catch (err) {
+    /* eslint-disable-next-line no-console */
+    console.error(err);
+    store.dispatch(
+      updateComment(comment.localId, {
+        mode: 'save_error',
+      })
+    );
+  }
+}
+
+async function doDeleteComment(comment: Comment, store: Store) {
+  store.dispatch(
+    updateComment(comment.localId, {
+      mode: 'deleting',
+    })
+  );
+
+  try {
+    store.dispatch(deleteComment(comment.localId));
+  } catch (err) {
+    /* eslint-disable-next-line no-console */
+    console.error(err);
+    store.dispatch(
+      updateComment(comment.localId, {
+        mode: 'delete_error',
+      })
+    );
+  }
+}
+
+export interface CommentProps {
+  store: Store;
+  comment: Comment;
+  isFocused: boolean;
+  layout: LayoutController;
+  user: Author | null;
+  strings: TranslatableStrings;
+}
+
+export default class CommentComponent extends React.Component<CommentProps> {
+  renderReplies({ hideNewReply = false } = {}): React.ReactFragment {
+    const { comment, isFocused, store, user, strings } = this.props;
+
+    if (!comment.remoteId) {
+      // Hide replies UI if the comment itself isn't saved yet
+      return <></>;
+    }
+
+    const onChangeNewReply = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
+      e.preventDefault();
+
+      store.dispatch(
+        updateComment(comment.localId, {
+          newReply: e.target.value,
+        })
+      );
+    };
+
+    const sendReply = async (e: React.FormEvent) => {
+      e.preventDefault();
+
+      const replyId = getNextReplyId();
+      const reply = newCommentReply(replyId, user, Date.now(), {
+        text: comment.newReply,
+        mode: 'default',
+      });
+      store.dispatch(addReply(comment.localId, reply));
+
+      store.dispatch(
+        updateComment(comment.localId, {
+          newReply: '',
+        })
+      );
+    };
+
+    const onClickCancelReply = (e: React.MouseEvent) => {
+      e.preventDefault();
+
+      store.dispatch(
+        updateComment(comment.localId, {
+          newReply: '',
+        })
+      );
+    };
+
+    const replies: React.ReactNode[] = [];
+    let replyBeingEdited = false;
+    for (const reply of comment.replies.values()) {
+      if (reply.mode === 'saving' || reply.mode === 'editing') {
+        replyBeingEdited = true;
+      }
+
+      if (!reply.deleted) {
+        replies.push(
+          <CommentReplyComponent
+            key={reply.localId}
+            store={store}
+            user={user}
+            comment={comment}
+            reply={reply}
+            strings={strings}
+          />
+        );
+      }
+    }
+
+    // Hide new reply if a reply is being edited as well
+    const newReplyHidden = hideNewReply || replyBeingEdited;
+
+    let replyActions = <></>;
+    if (!newReplyHidden && isFocused && comment.newReply.length > 0) {
+      replyActions = (
+        <div className="comment__reply-actions">
+          <button
+            type="submit"
+            className="comment__button comment__button--primary"
+          >
+            {strings.REPLY}
+          </button>
+          <button
+            type="button"
+            onClick={onClickCancelReply}
+            className="comment__button"
+          >
+            {strings.CANCEL}
+          </button>
+        </div>
+      );
+    }
+
+    let replyTextarea = <></>;
+    if (!newReplyHidden && (isFocused || comment.newReply)) {
+      replyTextarea = (
+        <textarea
+          className="comment__reply-input"
+          placeholder="Enter your reply..."
+          value={comment.newReply}
+          onChange={onChangeNewReply}
+          style={{ resize: 'none' }}
+        />
+      );
+    }
+
+    return (
+      <>
+        <ul className="comment__replies">{replies}</ul>
+        <form onSubmit={sendReply}>
+          {replyTextarea}
+          {replyActions}
+        </form>
+      </>
+    );
+  }
+
+  renderCreating(): React.ReactFragment {
+    const { comment, store, strings } = this.props;
+
+    const onChangeText = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
+      e.preventDefault();
+
+      store.dispatch(
+        updateComment(comment.localId, {
+          newText: e.target.value,
+        })
+      );
+    };
+
+    const onSave = async (e: React.FormEvent) => {
+      e.preventDefault();
+      await saveComment(comment, store);
+    };
+
+    const onCancel = (e: React.MouseEvent) => {
+      e.preventDefault();
+
+      store.dispatch(deleteComment(comment.localId));
+    };
+
+    return (
+      <>
+        <CommentHeader commentReply={comment} store={store} strings={strings} />
+        <form onSubmit={onSave}>
+          <textarea
+            className="comment__input"
+            value={comment.newText}
+            onChange={onChangeText}
+            style={{ resize: 'none' }}
+            placeholder="Enter your comments..."
+          />
+          <div className="comment__actions">
+            <button
+              type="submit"
+              className="comment__button comment__button--primary"
+            >
+              {strings.COMMENT}
+            </button>
+            <button
+              type="button"
+              onClick={onCancel}
+              className="comment__button"
+            >
+              {strings.CANCEL}
+            </button>
+          </div>
+        </form>
+      </>
+    );
+  }
+
+  renderEditing(): React.ReactFragment {
+    const { comment, store, strings } = this.props;
+
+    const onChangeText = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
+      e.preventDefault();
+
+      store.dispatch(
+        updateComment(comment.localId, {
+          newText: e.target.value,
+        })
+      );
+    };
+
+    const onSave = async (e: React.FormEvent) => {
+      e.preventDefault();
+
+      await saveComment(comment, store);
+    };
+
+    const onCancel = (e: React.MouseEvent) => {
+      e.preventDefault();
+
+      store.dispatch(
+        updateComment(comment.localId, {
+          mode: 'default',
+          newText: comment.text,
+        })
+      );
+    };
+
+    return (
+      <>
+        <CommentHeader commentReply={comment} store={store} strings={strings} />
+        <form onSubmit={onSave}>
+          <textarea
+            className="comment__input"
+            value={comment.newText}
+            onChange={onChangeText}
+            style={{ resize: 'none' }}
+          />
+          <div className="comment__actions">
+            <button
+              type="submit"
+              className="comment__button comment__button--primary"
+            >
+              {strings.SAVE}
+            </button>
+            <button
+              type="button"
+              onClick={onCancel}
+              className="comment__button"
+            >
+              {strings.CANCEL}
+            </button>
+          </div>
+        </form>
+        {this.renderReplies({ hideNewReply: true })}
+      </>
+    );
+  }
+
+  renderSaving(): React.ReactFragment {
+    const { comment, store, strings } = this.props;
+
+    return (
+      <>
+        <CommentHeader commentReply={comment} store={store} strings={strings} />
+        <p className="comment__text">{comment.text}</p>
+        <div className="comment__progress">{strings.SAVING}</div>
+        {this.renderReplies({ hideNewReply: true })}
+      </>
+    );
+  }
+
+  renderSaveError(): React.ReactFragment {
+    const { comment, store, strings } = this.props;
+
+    const onClickRetry = async (e: React.MouseEvent) => {
+      e.preventDefault();
+
+      await saveComment(comment, store);
+    };
+
+    return (
+      <>
+        <CommentHeader commentReply={comment} store={store} strings={strings} />
+        <p className="comment__text">{comment.text}</p>
+        {this.renderReplies({ hideNewReply: true })}
+        <div className="comment__error">
+          {strings.SAVE_ERROR}
+          <button
+            type="button"
+            className="comment__button"
+            onClick={onClickRetry}
+          >
+            {strings.RETRY}
+          </button>
+        </div>
+      </>
+    );
+  }
+
+  renderDeleteConfirm(): React.ReactFragment {
+    const { comment, store, strings } = this.props;
+
+    const onClickDelete = async (e: React.MouseEvent) => {
+      e.preventDefault();
+
+      await doDeleteComment(comment, store);
+    };
+
+    const onClickCancel = (e: React.MouseEvent) => {
+      e.preventDefault();
+
+      store.dispatch(
+        updateComment(comment.localId, {
+          mode: 'default',
+        })
+      );
+    };
+
+    return (
+      <>
+        <CommentHeader commentReply={comment} store={store} strings={strings} />
+        <p className="comment__text">{comment.text}</p>
+        <div className="comment__confirm-delete">
+          {strings.CONFIRM_DELETE_COMMENT}
+          <button
+            type="button"
+            className="comment__button comment__button--red"
+            onClick={onClickDelete}
+          >
+            {strings.DELETE}
+          </button>
+          <button
+            type="button"
+            className="comment__button"
+            onClick={onClickCancel}
+          >
+            {strings.CANCEL}
+          </button>
+        </div>
+        {this.renderReplies({ hideNewReply: true })}
+      </>
+    );
+  }
+
+  renderDeleting(): React.ReactFragment {
+    const { comment, store, strings } = this.props;
+
+    return (
+      <>
+        <CommentHeader commentReply={comment} store={store} strings={strings} />
+        <p className="comment__text">{comment.text}</p>
+        <div className="comment__progress">{strings.DELETING}</div>
+        {this.renderReplies({ hideNewReply: true })}
+      </>
+    );
+  }
+
+  renderDeleteError(): React.ReactFragment {
+    const { comment, store, strings } = this.props;
+
+    const onClickRetry = async (e: React.MouseEvent) => {
+      e.preventDefault();
+
+      await doDeleteComment(comment, store);
+    };
+
+    const onClickCancel = async (e: React.MouseEvent) => {
+      e.preventDefault();
+
+      store.dispatch(
+        updateComment(comment.localId, {
+          mode: 'default',
+        })
+      );
+    };
+
+    return (
+      <>
+        <CommentHeader commentReply={comment} store={store} strings={strings} />
+        <p className="comment__text">{comment.text}</p>
+        {this.renderReplies({ hideNewReply: true })}
+        <div className="comment__error">
+          {strings.DELETE_ERROR}
+          <button
+            type="button"
+            className="comment__button"
+            onClick={onClickCancel}
+          >
+            {strings.CANCEL}
+          </button>
+          <button
+            type="button"
+            className="comment__button"
+            onClick={onClickRetry}
+          >
+            {strings.RETRY}
+          </button>
+        </div>
+      </>
+    );
+  }
+
+  renderDefault(): React.ReactFragment {
+    const { comment, store, strings } = this.props;
+
+    // Show edit/delete buttons if this comment was authored by the current user
+    let onEdit;
+    let onDelete;
+    if (comment.author === null || this.props.user && this.props.user.id === comment.author.id) {
+      onEdit = () => {
+        store.dispatch(
+          updateComment(comment.localId, {
+            mode: 'editing',
+            newText: comment.text,
+          })
+        );
+      };
+
+      onDelete = () => {
+        store.dispatch(
+          updateComment(comment.localId, {
+            mode: 'delete_confirm',
+          })
+        );
+      };
+    }
+
+    return (
+      <>
+        <CommentHeader
+          commentReply={comment}
+          store={store}
+          strings={strings}
+          onResolve={doDeleteComment}
+          onEdit={onEdit}
+          onDelete={onDelete}
+        />
+        <p className="comment__text">{comment.text}</p>
+        {this.renderReplies()}
+      </>
+    );
+  }
+
+  render() {
+    let inner: React.ReactFragment;
+
+    switch (this.props.comment.mode) {
+    case 'creating':
+      inner = this.renderCreating();
+      break;
+
+    case 'editing':
+      inner = this.renderEditing();
+      break;
+
+    case 'saving':
+      inner = this.renderSaving();
+      break;
+
+    case 'save_error':
+      inner = this.renderSaveError();
+      break;
+
+    case 'delete_confirm':
+      inner = this.renderDeleteConfirm();
+      break;
+
+    case 'deleting':
+      inner = this.renderDeleting();
+      break;
+
+    case 'delete_error':
+      inner = this.renderDeleteError();
+      break;
+
+    default:
+      inner = this.renderDefault();
+      break;
+    }
+
+    const onClick = () => {
+      this.props.store.dispatch(setFocusedComment(this.props.comment.localId));
+    };
+
+    const onDoubleClick = () => {
+      this.props.store.dispatch(setFocusedComment(this.props.comment.localId, { updatePinnedComment: true }));
+    };
+
+    const top = this.props.layout.getCommentPosition(
+      this.props.comment.localId
+    );
+    const right = this.props.isFocused ? 50 : 0;
+    return (
+      <li
+        key={this.props.comment.localId}
+        className={`comment comment--mode-${this.props.comment.mode} ${this.props.isFocused ? 'comment--focused' : ''}`}
+        style={{
+          position: 'absolute',
+          top: `${top}px`,
+          right: `${right}px`,
+        }}
+        data-comment-id={this.props.comment.localId}
+        onClick={onClick}
+        onDoubleClick={onDoubleClick}
+      >
+        {inner}
+      </li>
+    );
+  }
+
+  componentDidMount() {
+    const element = ReactDOM.findDOMNode(this);
+
+    if (element instanceof HTMLElement) {
+      // If this is a new comment, focus in the edit box
+      if (this.props.comment.mode === 'creating') {
+        const textAreaElement = element.querySelector('textarea');
+
+        if (textAreaElement instanceof HTMLTextAreaElement) {
+          textAreaElement.focus();
+        }
+      }
+
+      this.props.layout.setCommentElement(this.props.comment.localId, element);
+      this.props.layout.setCommentHeight(
+        this.props.comment.localId,
+        element.offsetHeight
+      );
+    }
+  }
+
+  componentWillUnmount() {
+    this.props.layout.setCommentElement(this.props.comment.localId, null);
+  }
+
+  componentDidUpdate() {
+    const element = ReactDOM.findDOMNode(this);
+
+    // Keep height up to date so that other comments will be moved out of the way
+    if (element instanceof HTMLElement) {
+      this.props.layout.setCommentHeight(
+        this.props.comment.localId,
+        element.offsetHeight
+      );
+    }
+  }
+}

+ 100 - 0
client/src/components/CommentApp/components/Comment/style.scss

@@ -0,0 +1,100 @@
+.comment {
+    @include box;
+
+    font-size: 1.5em;
+    width: 100%;
+    max-width: 400px;
+    display: block;
+    transition: top 0.5s ease 0s, right 0.5s ease 0s, height 0.5s ease 0s;
+    pointer-events: auto;
+
+    &__text {
+        color: $color-box-text;
+        font-size: 0.8em;
+        margin-top: 32px;
+        margin-bottom: 0;
+
+        &--mode-deleting {
+            color: $color-grey-1;
+        }
+    }
+
+    form {
+        padding-top: 20px;
+    }
+
+    &--mode-deleting &__text {
+        color: $color-grey-3;
+    }
+
+    &__replies {
+        list-style-type: none;
+        padding: 0;
+    }
+
+    &__button {
+        @include button;
+    }
+
+    &__actions &__button,
+    &__confirm-delete &__button,
+    &__reply-actions &__button {
+        margin-right: 10px;
+        margin-top: 10px;
+    }
+
+    &__confirm-delete,
+    &__error {
+        color: $color-box-text;
+        font-weight: bold;
+        font-size: 0.8em;
+
+        button {
+            margin-left: 10px;
+            /* stylelint-disable-next-line declaration-no-important */
+            margin-right: 0 !important;
+        }
+    }
+
+    &__error {
+        color: $color-white;
+        background-color: $color-red-dark;
+        border-radius: 3px;
+        padding: 5px;
+        padding-left: 10px;
+        height: 26px;
+        line-height: 26px;
+        vertical-align: middle;
+
+        button {
+            height: 26px;
+            float: right;
+            margin-left: 5px;
+            color: $color-white;
+            background-color: $color-red-very-dark;
+            border-color: $color-red-very-dark;
+            padding: 2px;
+            padding-left: 10px;
+            padding-right: 10px;
+            font-size: 0.65em;
+            font-weight: bold;
+        }
+
+        &::after {
+            display: block;
+            content: '';
+            clear: both;
+        }
+    }
+
+    &__progress {
+        margin-top: 20px;
+        font-weight: bold;
+        font-size: 0.8em;
+    }
+
+    &__reply-input {
+        /* stylelint-disable-next-line declaration-no-important */
+        margin-top: 20px !important;
+    }
+}

+ 83 - 0
client/src/components/CommentApp/components/CommentHeader/index.tsx

@@ -0,0 +1,83 @@
+/* eslint-disable react/prop-types */
+
+import dateFormat from 'dateformat';
+import React, { FunctionComponent } from 'react';
+import type { Store } from '../../state';
+import { TranslatableStrings } from '../../main';
+
+import { Author } from '../../state/comments';
+
+
+interface CommentReply {
+  author: Author | null;
+  date: number;
+}
+
+interface CommentHeaderProps {
+  commentReply: CommentReply;
+  store: Store;
+  strings: TranslatableStrings;
+  onResolve?(commentReply: CommentReply, store: Store): void;
+  onEdit?(commentReply: CommentReply, store: Store): void;
+  onDelete?(commentReply: CommentReply, store: Store): void;
+}
+
+export const CommentHeader: FunctionComponent<CommentHeaderProps> = ({
+  commentReply, store, strings, onResolve, onEdit, onDelete
+}) => {
+  const { author, date } = commentReply;
+
+  const onClickResolve = (e: React.MouseEvent) => {
+    e.preventDefault();
+
+    if (onResolve) {
+      onResolve(commentReply, store);
+    }
+  };
+
+  const onClickEdit = async (e: React.MouseEvent) => {
+    e.preventDefault();
+
+    if (onEdit) {
+      onEdit(commentReply, store);
+    }
+  };
+
+  const onClickDelete = async (e: React.MouseEvent) => {
+    e.preventDefault();
+
+    if (onDelete) {
+      onDelete(commentReply, store);
+    }
+  };
+
+  return (
+    <div className="comment-header">
+      <div className="comment-header__actions">
+        {onResolve &&
+          <div className="comment-header__action comment-header__action--resolve">
+            <button type="button" aria-label={strings.RESOLVE} onClick={onClickResolve}>
+            </button>
+          </div>
+        }
+        {(onEdit || onDelete) &&
+          <div className="comment-header__action comment-header__action--more">
+            <details>
+              <summary aria-label={strings.MORE_ACTIONS} aria-haspopup="menu" role="button">
+              </summary>
+
+              <div className="comment-header__more-actions">
+                {onEdit && <button type="button" role="menuitem" onClick={onClickEdit}>{strings.EDIT}</button>}
+                {onDelete && <button type="button" role="menuitem" onClick={onClickDelete}>{strings.DELETE}</button>}
+              </div>
+            </details>
+          </div>
+        }
+      </div>
+      {author && author.avatarUrl &&
+        <img className="comment-header__avatar" src={author.avatarUrl} role="presentation" />}
+      <p className="comment-header__author">{author ? author.name : ''}</p>
+      <p className="comment-header__date">{dateFormat(date, 'h:MM mmmm d')}</p>
+    </div>
+  );
+};

+ 136 - 0
client/src/components/CommentApp/components/CommentHeader/style.scss

@@ -0,0 +1,136 @@
+.comment-header {
+    position: relative;
+
+    &__avatar {
+        position: absolute;
+        width: 40px;
+        height: 40px;
+        border-radius: 20px;
+    }
+
+    &__author,
+    &__date {
+        max-width: calc(100% - 160px);  // Leave room for actions to the right and avatar to the left
+        margin: 0;
+        margin-left: 57px;
+        font-size: 0.7em;
+    }
+
+    &__date {
+        color: $color-grey-25;
+    }
+
+    &__actions {
+        position: absolute;
+        right: 0;
+    }
+
+    &__action {
+        float: left;
+        margin-left: 10px;
+        border-radius: 5px;
+
+        &:hover {
+            background-color: $color-grey-7;
+        }
+
+        > button,
+        > details > summary {
+            // Hides triangle on Firefox
+            list-style-type: none;
+            // Hides triangle on Chrome
+            &::-webkit-details-marker { display: none; }
+            width: 50px;
+            height: 50px;
+            position: relative;
+            background-color: unset;
+            border: unset;
+            -moz-outline-radius: 10px;
+
+            &::before {
+                content: '';
+                position: absolute;
+                top: 0;
+                left: 0;
+                width: 50px;
+                height: 50px;
+                mask-position: center;
+                mask-size: 25px 25px;
+                mask-repeat: no-repeat;
+            }
+
+            &:hover {
+                cursor: pointer;
+            }
+        }
+
+        > details {
+            position: relative;
+
+            > div {
+                position: absolute;
+                right: 0;
+                top: 60px;
+            }
+        }
+
+        &--resolve {
+            > button::before,
+            > details > summary::before {
+                background-color: $color-teal;
+                mask-image: url('./icons/check-solid.svg');
+            }
+        }
+
+        &--more {
+            > button::before,
+            > details > summary::before {
+                background-color: $color-grey-25;
+                background-position: center;
+                mask-image: url('./icons/ellipsis-v-solid.svg');
+            }
+        }
+    }
+
+    &__more-actions {
+        background-color: #333;
+        padding: 0.75rem 1rem;
+        min-width: 8rem;
+        text-transform: none;
+        position: absolute;
+        z-index: 1000;
+        list-style: none;
+        text-align: left;
+
+        &:before {
+            content: '';
+            border: 0.35rem solid transparent;
+            border-bottom-color: #333;
+            display: block;
+            position: absolute;
+            bottom: 100%;
+            right: 1rem;
+        }
+
+        button {
+            display: block;
+            background: unset;
+            border: unset;
+            color: #fff;
+            padding: 10px;
+            font-size: 20px;
+            width: 120px;
+            text-align: left;
+
+            &:hover {
+                color: #aaa;
+                cursor: pointer;
+            }
+        }
+    }
+}
+
+.comment--mode-deleting .comment-header,
+.comment-reply--mode-deleting .comment-header {
+    opacity: 0.5;
+}

+ 184 - 0
client/src/components/CommentApp/components/CommentReply/index.stories.tsx

@@ -0,0 +1,184 @@
+import React from 'react';
+import { createStore } from 'redux';
+
+import { Store, reducer } from '../../state';
+
+import {
+  RenderCommentsForStorybook,
+  addTestComment,
+  addTestReply,
+} from '../../utils/storybook';
+
+export default { title: 'CommentReply' };
+
+export function reply() {
+  const store: Store = createStore(reducer);
+
+  const commentId = addTestComment(store, {
+    mode: 'default',
+    text: 'An example comment',
+  });
+
+  addTestReply(store, commentId, {
+    mode: 'default',
+    text: 'An example reply',
+  });
+
+  return <RenderCommentsForStorybook store={store} />;
+}
+
+export function replyFromSomeoneElse() {
+  const store: Store = createStore(reducer);
+
+  const commentId = addTestComment(store, {
+    mode: 'default',
+    text: 'An example comment',
+  });
+
+  addTestReply(store, commentId, {
+    mode: 'default',
+    text: 'An example reply',
+    author: {
+      id: 2,
+      name: 'Someone else',
+      avatarUrl: 'https://gravatar.com/avatar/31c3d5cc27d1faa321c2413589e8a53f?s=200&d=robohash&r=x',
+    },
+  });
+
+  return <RenderCommentsForStorybook store={store} />;
+}
+
+export function focused() {
+  const store: Store = createStore(reducer);
+
+  const commentId = addTestComment(store, {
+    mode: 'default',
+    text: 'An example comment',
+    focused: true,
+  });
+
+  addTestReply(store, commentId, {
+    mode: 'default',
+    text: 'An example reply',
+  });
+
+  return <RenderCommentsForStorybook store={store} />;
+}
+
+export function editing() {
+  const store: Store = createStore(reducer);
+
+  const commentId = addTestComment(store, {
+    mode: 'default',
+    text: 'An example comment',
+    focused: true,
+  });
+
+  addTestReply(store, commentId, {
+    mode: 'editing',
+    text: 'An example reply',
+  });
+
+  return <RenderCommentsForStorybook store={store} />;
+}
+
+export function saving() {
+  const store: Store = createStore(reducer);
+
+  const commentId = addTestComment(store, {
+    mode: 'default',
+    text: 'An example comment',
+    focused: true,
+  });
+
+  addTestReply(store, commentId, {
+    mode: 'saving',
+    text: 'An example reply',
+  });
+  return <RenderCommentsForStorybook store={store} />;
+}
+
+export function saveError() {
+  const store: Store = createStore(reducer);
+
+  const commentId = addTestComment(store, {
+    mode: 'default',
+    text: 'An example comment',
+    focused: true,
+  });
+
+  addTestReply(store, commentId, {
+    mode: 'save_error',
+    text: 'An example reply',
+  });
+
+  return <RenderCommentsForStorybook store={store} />;
+}
+
+export function deleteConfirm() {
+  const store: Store = createStore(reducer);
+
+  const commentId = addTestComment(store, {
+    mode: 'default',
+    text: 'An example comment',
+    focused: true,
+  });
+
+  addTestReply(store, commentId, {
+    mode: 'delete_confirm',
+    text: 'An example reply',
+  });
+
+  return <RenderCommentsForStorybook store={store} />;
+}
+
+export function deleting() {
+  const store: Store = createStore(reducer);
+
+  const commentId = addTestComment(store, {
+    mode: 'default',
+    text: 'An example comment',
+    focused: true,
+  });
+
+  addTestReply(store, commentId, {
+    mode: 'deleting',
+    text: 'An example reply',
+  });
+
+  return <RenderCommentsForStorybook store={store} />;
+}
+
+export function deleteError() {
+  const store: Store = createStore(reducer);
+
+  const commentId = addTestComment(store, {
+    mode: 'default',
+    text: 'An example comment',
+    focused: true,
+  });
+
+  addTestReply(store, commentId, {
+    mode: 'delete_error',
+    text: 'An example reply',
+  });
+
+  return <RenderCommentsForStorybook store={store} />;
+}
+
+export function deleted() {
+  const store: Store = createStore(reducer);
+
+  const commentId = addTestComment(store, {
+    mode: 'default',
+    text: 'An example comment',
+    focused: true,
+  });
+
+  addTestReply(store, commentId, {
+    mode: 'deleted',
+    text: 'An example reply',
+  });
+
+  return <RenderCommentsForStorybook store={store} />;
+}

+ 346 - 0
client/src/components/CommentApp/components/CommentReply/index.tsx

@@ -0,0 +1,346 @@
+/* eslint-disable react/prop-types */
+
+import React from 'react';
+
+import type { Store } from '../../state';
+import type { Comment, CommentReply, Author } from '../../state/comments';
+import { updateReply, deleteReply } from '../../actions/comments';
+import type { TranslatableStrings } from '../../main';
+import { CommentHeader }  from '../CommentHeader';
+
+export async function saveCommentReply(
+  comment: Comment,
+  reply: CommentReply,
+  store: Store
+) {
+  store.dispatch(
+    updateReply(comment.localId, reply.localId, {
+      mode: 'saving',
+    })
+  );
+
+  try {
+    store.dispatch(
+      updateReply(comment.localId, reply.localId, {
+        mode: 'default',
+        text: reply.newText,
+        author: reply.author,
+      })
+    );
+  } catch (err) {
+    /* eslint-disable-next-line no-console */
+    console.error(err);
+    store.dispatch(
+      updateReply(comment.localId, reply.localId, {
+        mode: 'save_error',
+      })
+    );
+  }
+}
+
+async function deleteCommentReply(
+  comment: Comment,
+  reply: CommentReply,
+  store: Store
+) {
+  store.dispatch(
+    updateReply(comment.localId, reply.localId, {
+      mode: 'deleting',
+    })
+  );
+
+  try {
+    store.dispatch(deleteReply(comment.localId, reply.localId));
+  } catch (err) {
+    store.dispatch(
+      updateReply(comment.localId, reply.localId, {
+        mode: 'delete_error',
+      })
+    );
+  }
+}
+
+export interface CommentReplyProps {
+  comment: Comment;
+  reply: CommentReply;
+  store: Store;
+  user: Author | null;
+  strings: TranslatableStrings;
+}
+
+export default class CommentReplyComponent extends React.Component<CommentReplyProps> {
+  renderEditing(): React.ReactFragment {
+    const { comment, reply, store, strings } = this.props;
+
+    const onChangeText = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
+      e.preventDefault();
+
+      store.dispatch(
+        updateReply(comment.localId, reply.localId, {
+          newText: e.target.value,
+        })
+      );
+    };
+
+    const onSave = async (e: React.FormEvent) => {
+      e.preventDefault();
+      await saveCommentReply(comment, reply, store);
+    };
+
+    const onCancel = (e: React.MouseEvent) => {
+      e.preventDefault();
+
+      store.dispatch(
+        updateReply(comment.localId, reply.localId, {
+          mode: 'default',
+          newText: reply.text,
+        })
+      );
+    };
+
+    return (
+      <>
+        <CommentHeader commentReply={reply} store={store} strings={strings} />
+        <form onSubmit={onSave}>
+          <textarea
+            className="comment-reply__input"
+            value={reply.newText}
+            onChange={onChangeText}
+            style={{ resize: 'none' }}
+          />
+          <div className="comment-reply__actions">
+            <button
+              type="submit"
+              className="comment-reply__button comment-reply__button--primary"
+            >
+              {strings.SAVE}
+            </button>
+            <button
+              type="button"
+              className="comment-reply__button"
+              onClick={onCancel}
+            >
+              {strings.CANCEL}
+            </button>
+          </div>
+        </form>
+      </>
+    );
+  }
+
+  renderSaving(): React.ReactFragment {
+    const { reply, store, strings } = this.props;
+
+    return (
+      <>
+        <CommentHeader commentReply={reply} store={store} strings={strings} />
+        <p className="comment-reply__text">{reply.text}</p>
+        <div className="comment-reply__progress">{strings.SAVING}</div>
+      </>
+    );
+  }
+
+  renderSaveError(): React.ReactFragment {
+    const { comment, reply, store, strings } = this.props;
+
+    const onClickRetry = async (e: React.MouseEvent) => {
+      e.preventDefault();
+
+      await saveCommentReply(comment, reply, store);
+    };
+
+    return (
+      <>
+        <CommentHeader commentReply={reply} store={store} strings={strings} />
+        <p className="comment-reply__text">{reply.text}</p>
+        <div className="comment-reply__error">
+          {strings.SAVE_ERROR}
+          <button
+            type="button"
+            className="comment-reply__button"
+            onClick={onClickRetry}
+          >
+            {strings.RETRY}
+          </button>
+        </div>
+      </>
+    );
+  }
+
+  renderDeleteConfirm(): React.ReactFragment {
+    const { comment, reply, store, strings } = this.props;
+
+    const onClickDelete = async (e: React.MouseEvent) => {
+      e.preventDefault();
+
+      await deleteCommentReply(comment, reply, store);
+    };
+
+    const onClickCancel = (e: React.MouseEvent) => {
+      e.preventDefault();
+
+      store.dispatch(
+        updateReply(comment.localId, reply.localId, {
+          mode: 'default',
+        })
+      );
+    };
+
+    return (
+      <>
+        <CommentHeader commentReply={reply} store={store} strings={strings} />
+        <p className="comment-reply__text">{reply.text}</p>
+        <div className="comment-reply__confirm-delete">
+          {strings.CONFIRM_DELETE_COMMENT}
+          <button
+            type="button"
+            className="comment-reply__button comment-reply__button--red"
+            onClick={onClickDelete}
+          >
+            {strings.DELETE}
+          </button>
+          <button
+            type="button"
+            className="comment-reply__button"
+            onClick={onClickCancel}
+          >
+            {strings.CANCEL}
+          </button>
+        </div>
+      </>
+    );
+  }
+
+  renderDeleting(): React.ReactFragment {
+    const { reply, store, strings } = this.props;
+
+    return (
+      <>
+        <CommentHeader commentReply={reply} store={store} strings={strings} />
+        <p className="comment-reply__text">{reply.text}</p>
+        <div className="comment-reply__progress">{strings.DELETING}</div>
+      </>
+    );
+  }
+
+  renderDeleteError(): React.ReactFragment {
+    const { comment, reply, store, strings } = this.props;
+
+    const onClickRetry = async (e: React.MouseEvent) => {
+      e.preventDefault();
+
+      await deleteCommentReply(comment, reply, store);
+    };
+
+    const onClickCancel = async (e: React.MouseEvent) => {
+      e.preventDefault();
+
+      store.dispatch(
+        updateReply(comment.localId, reply.localId, {
+          mode: 'default',
+        })
+      );
+    };
+
+    return (
+      <>
+        <CommentHeader commentReply={reply} store={store} strings={strings} />
+        <p className="comment-reply__text">{reply.text}</p>
+        <div className="comment-reply__error">
+          {strings.DELETE_ERROR}
+          <button
+            type="button"
+            className="comment-reply__button"
+            onClick={onClickCancel}
+          >
+            {strings.CANCEL}
+          </button>
+          <button
+            type="button"
+            className="comment-reply__button"
+            onClick={onClickRetry}
+          >
+            {strings.RETRY}
+          </button>
+        </div>
+      </>
+    );
+  }
+
+  renderDefault(): React.ReactFragment {
+    const { comment, reply, store, strings } = this.props;
+
+    // Show edit/delete buttons if this reply was authored by the current user
+    let onEdit;
+    let onDelete;
+    if (reply.author === null || this.props.user && this.props.user.id === reply.author.id) {
+      onEdit = () => {
+        store.dispatch(
+          updateReply(comment.localId, reply.localId, {
+            mode: 'editing',
+            newText: reply.text,
+          })
+        );
+      };
+
+      onDelete = () => {
+        store.dispatch(
+          updateReply(comment.localId, reply.localId, {
+            mode: 'delete_confirm',
+          })
+        );
+      };
+    }
+
+    return (
+      <>
+        <CommentHeader commentReply={reply} store={store} strings={strings} onEdit={onEdit} onDelete={onDelete} />
+        <p className="comment-reply__text">{reply.text}</p>
+      </>
+    );
+  }
+
+  render() {
+    let inner: React.ReactFragment;
+
+    switch (this.props.reply.mode) {
+    case 'editing':
+      inner = this.renderEditing();
+      break;
+
+    case 'saving':
+      inner = this.renderSaving();
+      break;
+
+    case 'save_error':
+      inner = this.renderSaveError();
+      break;
+
+    case 'delete_confirm':
+      inner = this.renderDeleteConfirm();
+      break;
+
+    case 'deleting':
+      inner = this.renderDeleting();
+      break;
+
+    case 'delete_error':
+      inner = this.renderDeleteError();
+      break;
+
+    default:
+      inner = this.renderDefault();
+      break;
+    }
+
+    return (
+      <li
+        key={this.props.reply.localId}
+        className={`comment-reply comment-reply--mode-${this.props.reply.mode}`}
+        data-reply-id={this.props.reply.localId}
+      >
+        {inner}
+      </li>
+    );
+  }
+}

+ 89 - 0
client/src/components/CommentApp/components/CommentReply/style.scss

@@ -0,0 +1,89 @@
+.comment-reply {
+    margin-top: 40px;
+    pointer-events: auto;
+    position: relative;
+
+    &__text {
+        color: $color-box-text;
+        font-size: 0.8em;
+        margin-top: 32px;
+        margin-bottom: 0;
+
+        &--mode-deleting {
+            color: $color-grey-1;
+        }
+    }
+
+    &--mode-deleting &__avatar {
+        opacity: 0.5;
+    }
+
+    &--mode-deleting &__text {
+        color: $color-grey-3;
+    }
+
+    &__button {
+        @include button;
+    }
+
+    &__actions,
+    &__confirm-delete,
+    &__progress,
+    &__error {
+        &::after {
+            display: block;
+            content: '';
+            clear: both;
+        }
+    }
+
+    &__actions &__button,
+    &__confirm-delete &__button {
+        margin-top: 5px;
+        margin-right: 5px;
+    }
+
+    &__confirm-delete,
+    &__error {
+        color: $color-box-text;
+        font-weight: bold;
+        font-size: 0.8em;
+
+        button {
+            margin-left: 10px;
+            /* stylelint-disable-next-line declaration-no-important */
+            margin-right: 0 !important;
+        }
+    }
+
+    &__error {
+        color: $color-white;
+        background-color: $color-red-dark;
+        border-radius: 3px;
+        padding: 5px;
+        padding-left: 10px;
+        height: 26px;
+        line-height: 26px;
+        vertical-align: middle;
+
+        button {
+            height: 26px;
+            float: right;
+            margin-left: 5px;
+            color: $color-white;
+            background-color: $color-red-very-dark;
+            border-color: $color-red-very-dark;
+            padding: 2px;
+            padding-left: 10px;
+            padding-right: 10px;
+            font-size: 0.65em;
+            font-weight: bold;
+        }
+    }
+
+    &__progress {
+        margin-top: 20px;
+        font-weight: bold;
+        font-size: 0.8em;
+    }
+}

+ 198 - 0
client/src/components/CommentApp/components/Form/index.tsx

@@ -0,0 +1,198 @@
+import React from 'react';
+
+import type { Comment, CommentReply } from '../../state/comments';
+
+interface PrefixedHiddenInputProps {
+  prefix: string;
+  value: number | string | null;
+  fieldName: string;
+}
+
+function PrefixedHiddenInput({
+  prefix,
+  value,
+  fieldName,
+}: PrefixedHiddenInputProps) {
+  return (
+    <input
+      type="hidden"
+      name={`${prefix}-${fieldName}`}
+      value={value === null ? '' : value}
+      id={`id_${prefix}-${fieldName}`}
+    />
+  );
+}
+
+export interface CommentReplyFormComponentProps {
+  reply: CommentReply;
+  prefix: string;
+  formNumber: number;
+}
+
+export function CommentReplyFormComponent({
+  reply,
+  formNumber,
+  prefix,
+}: CommentReplyFormComponentProps) {
+  const fullPrefix = `${prefix}-${formNumber}`;
+  return (
+    <fieldset>
+      <PrefixedHiddenInput
+        fieldName="DELETE"
+        value={reply.deleted ? 1 : ''}
+        prefix={fullPrefix}
+      />
+      <PrefixedHiddenInput
+        fieldName="id"
+        value={reply.remoteId}
+        prefix={fullPrefix}
+      />
+      <PrefixedHiddenInput
+        fieldName="text"
+        value={reply.text}
+        prefix={fullPrefix}
+      />
+    </fieldset>
+  );
+}
+
+export interface CommentReplyFormSetProps {
+  replies: CommentReply[];
+  prefix: string;
+  remoteReplyCount: number;
+}
+
+export function CommentReplyFormSetComponent({
+  replies,
+  prefix,
+  remoteReplyCount,
+}: CommentReplyFormSetProps) {
+  const fullPrefix = `${prefix}-replies`;
+
+  const commentForms = replies.map((reply, formNumber) => (
+    <CommentReplyFormComponent
+      key={reply.localId}
+      formNumber={formNumber}
+      reply={reply}
+      prefix={fullPrefix}
+    />
+  ));
+
+  return (
+    <>
+      <PrefixedHiddenInput
+        fieldName="TOTAL_FORMS"
+        value={replies.length}
+        prefix={fullPrefix}
+      />
+      <PrefixedHiddenInput
+        fieldName="INITIAL_FORMS"
+        value={remoteReplyCount}
+        prefix={fullPrefix}
+      />
+      <PrefixedHiddenInput
+        fieldName="MIN_NUM_FORMS"
+        value="0"
+        prefix={fullPrefix}
+      />
+      <PrefixedHiddenInput
+        fieldName="MAX_NUM_FORMS"
+        value=""
+        prefix={fullPrefix}
+      />
+      {commentForms}
+    </>
+  );
+}
+
+export interface CommentFormProps {
+  comment: Comment;
+  formNumber: number;
+  prefix: string;
+}
+
+export function CommentFormComponent({
+  comment,
+  formNumber,
+  prefix,
+}: CommentFormProps) {
+  const fullPrefix = `${prefix}-${formNumber}`;
+
+  return (
+    <fieldset>
+      <PrefixedHiddenInput
+        fieldName="DELETE"
+        value={comment.deleted ? 1 : ''}
+        prefix={fullPrefix}
+      />
+      <PrefixedHiddenInput
+        fieldName="id"
+        value={comment.remoteId}
+        prefix={fullPrefix}
+      />
+      <PrefixedHiddenInput
+        fieldName="contentpath"
+        value={comment.contentpath}
+        prefix={fullPrefix}
+      />
+      <PrefixedHiddenInput
+        fieldName="text"
+        value={comment.text}
+        prefix={fullPrefix}
+      />
+      <PrefixedHiddenInput
+        fieldName="position"
+        value={comment.position}
+        prefix={fullPrefix}
+      />
+      <CommentReplyFormSetComponent
+        replies={Array.from(comment.replies.values())}
+        prefix={fullPrefix}
+        remoteReplyCount={comment.remoteReplyCount}
+      />
+    </fieldset>
+  );
+}
+
+export interface CommentFormSetProps {
+  comments: Comment[];
+  remoteCommentCount: number;
+}
+
+export function CommentFormSetComponent({
+  comments,
+  remoteCommentCount,
+}: CommentFormSetProps) {
+  const prefix = 'comments';
+
+  const commentForms = comments.map((comment, formNumber) => (
+    <CommentFormComponent
+      key={comment.localId}
+      comment={comment}
+      formNumber={formNumber}
+      prefix={prefix}
+    />
+  ));
+
+  return (
+    <>
+      <PrefixedHiddenInput
+        fieldName="TOTAL_FORMS"
+        value={comments.length}
+        prefix={prefix}
+      />
+      <PrefixedHiddenInput
+        fieldName="INITIAL_FORMS"
+        value={remoteCommentCount}
+        prefix={prefix}
+      />
+      <PrefixedHiddenInput
+        fieldName="MIN_NUM_FORMS"
+        value="0"
+        prefix={prefix}
+      />
+      <PrefixedHiddenInput fieldName="MAX_NUM_FORMS" value="" prefix={prefix} />
+      {commentForms}
+    </>
+  );
+}

+ 35 - 0
client/src/components/CommentApp/components/TopBar/index.stories.tsx

@@ -0,0 +1,35 @@
+import React from 'react';
+import { createStore } from 'redux';
+
+import { Store, reducer } from '../../state';
+import { Styling } from '../../utils/storybook';
+
+import TopBarComponent from './index';
+
+import { defaultStrings } from '../../main';
+
+export default { title: 'TopBar' };
+
+function RenderTopBarForStorybook({ store }: { store: Store }) {
+  const [state, setState] = React.useState(store.getState());
+  store.subscribe(() => {
+    setState(store.getState());
+  });
+
+  return (
+    <>
+      <Styling />
+      <TopBarComponent
+        store={store}
+        strings={defaultStrings}
+        {...state.settings}
+      />
+    </>
+  );
+}
+
+export function topBar() {
+  const store: Store = createStore(reducer);
+
+  return <RenderTopBarForStorybook store={store} />;
+}

+ 42 - 0
client/src/components/CommentApp/components/TopBar/index.tsx

@@ -0,0 +1,42 @@
+import React from 'react';
+
+import type { Store } from '../../state';
+import { updateGlobalSettings } from '../../actions/settings';
+
+import Checkbox from '../widgets/Checkbox';
+import type { TranslatableStrings } from '../../main';
+
+export interface TopBarProps {
+  commentsEnabled: boolean;
+  store: Store;
+  strings: TranslatableStrings;
+}
+
+export default function TopBarComponent({
+  commentsEnabled,
+  store,
+  strings,
+}: TopBarProps) {
+  const onChangeCommentsEnabled = (checked: boolean) => {
+    store.dispatch(
+      updateGlobalSettings({
+        commentsEnabled: checked,
+      })
+    );
+  };
+
+  return (
+    <div className="comments-topbar">
+      <ul className="comments-topbar__settings">
+        <li>
+          <Checkbox
+            id="show-comments"
+            label={strings.SHOW_COMMENTS}
+            onChange={onChangeCommentsEnabled}
+            checked={commentsEnabled}
+          />
+        </li>
+      </ul>
+    </div>
+  );
+}

+ 21 - 0
client/src/components/CommentApp/components/TopBar/style.scss

@@ -0,0 +1,21 @@
+.comments-topbar {
+    width: 100%;
+    height: 50px;
+    line-height: 50px;
+    background-color: $color-box-background;
+    color: $color-white;
+    padding: 5px;
+    font-family: 'Open Sans', sans-serif;
+    position: sticky;
+    top: 0;
+
+    &__settings {
+        list-style-type: none;
+        margin: 0;
+
+        li {
+            float: left;
+            margin-left: 20px;
+        }
+    }
+}

+ 23 - 0
client/src/components/CommentApp/components/widgets/Checkbox/index.stories.tsx

@@ -0,0 +1,23 @@
+import React from 'react';
+
+import { Styling } from '../../../utils/storybook';
+
+import Checkbox from '.';
+
+export default { title: 'Checkbox' };
+
+export function checkbox() {
+  const [checked, setChecked] = React.useState(false);
+
+  return (
+    <>
+      <Styling />
+      <Checkbox
+        id="id"
+        label="Checkbox"
+        checked={checked}
+        onChange={setChecked}
+      />
+    </>
+  );
+}

+ 44 - 0
client/src/components/CommentApp/components/widgets/Checkbox/index.tsx

@@ -0,0 +1,44 @@
+import React from 'react';
+
+export interface CheckboxProps {
+  id: string;
+  label: string;
+  checked: boolean;
+  disabled?: boolean;
+  onChange?: (checked: boolean) => any;
+}
+
+const Checkbox = (props: CheckboxProps) => {
+  const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+    if (props.onChange) {
+      props.onChange(e.target.checked);
+    }
+  };
+
+  if (props.disabled) {
+    return (
+      <div className="checkbox">
+        <input
+          id={props.id}
+          type="checkbox"
+          checked={props.checked}
+          disabled={true}
+        />
+        <label htmlFor={props.id}>{props.label}</label>
+      </div>
+    );
+  }
+  return (
+    <div className="checkbox">
+      <input
+        id={props.id}
+        type="checkbox"
+        onChange={onChange}
+        checked={props.checked}
+      />
+      <label htmlFor={props.id}>{props.label}</label>
+    </div>
+  );
+};
+
+export default Checkbox;

+ 62 - 0
client/src/components/CommentApp/components/widgets/Checkbox/style.scss

@@ -0,0 +1,62 @@
+$checkbox-size: 26px;
+$checkbox-check-size: $checkbox-size * 0.75;
+$checkbox-check-padding: ($checkbox-size - $checkbox-check-size) / 2;
+
+.checkbox {
+    display: inline-block;
+    line-height: $checkbox-size;
+    position: relative;
+
+    label {
+        display: inline-block;
+        text-align: right;
+        padding-right: $checkbox-size + 10px;
+        font-size: 0.8em;
+        font-weight: bold;
+        padding-top: 8px;
+        cursor: pointer;
+    }
+
+    input[type='checkbox'] {
+        opacity: 0;
+    }
+
+    label::before {
+        content: '';
+        display: inline-block;
+        height: $checkbox-size;
+        width: $checkbox-size;
+        margin-left: 5px;
+        background-color: $color-white;
+        border: 1px solid #333;
+        border-radius: 3px;
+        position: absolute;
+        top: 3px;
+        right: 0;
+    }
+
+    label::after {
+        content: '';
+        display: inline-block;
+        background-color: $color-box-background;
+        mask-image: url('./icons/check-solid.svg');
+        mask-repeat: no-repeat;
+        width: $checkbox-check-size;
+        height: 100%;
+        position: absolute;
+        right: $checkbox-check-padding * 1.25;
+        top: $checkbox-check-padding * 2.5;
+    }
+
+    input[type='checkbox'] + label::after {
+        visibility: hidden;
+    }
+
+    input[type='checkbox']:checked + label::after {
+        visibility: visible;
+    }
+
+    input[type='checkbox']:focus + label::before {
+        @include focus-outline;
+    }
+}

+ 33 - 0
client/src/components/CommentApp/components/widgets/Radio/index.stories.tsx

@@ -0,0 +1,33 @@
+import React from 'react';
+
+import { Styling } from '../../../utils/storybook';
+
+import Radio from '.';
+
+export default { title: 'Radio' };
+
+export function radio() {
+  const [value, setValue] = React.useState<string | null>(null);
+
+  return (
+    <>
+      <Styling />
+      <Radio
+        id="option-1"
+        name="test"
+        value="option-1"
+        label="Option one"
+        checked={value === 'option-1'}
+        onChange={setValue}
+      />
+      <Radio
+        id="option-2"
+        name="test"
+        value="option-2"
+        label="Option two"
+        checked={value === 'option-2'}
+        onChange={setValue}
+      />
+    </>
+  );
+}

+ 48 - 0
client/src/components/CommentApp/components/widgets/Radio/index.tsx

@@ -0,0 +1,48 @@
+import React from 'react';
+
+export interface RadioProps {
+  id: string;
+  name: string;
+  value: string;
+  label: string;
+  checked: boolean;
+  disabled?: boolean;
+  onChange?: (value: string) => any;
+}
+
+const Radio = (props: RadioProps) => {
+  const onChange = () => {
+    if (props.onChange) {
+      props.onChange(props.value);
+    }
+  };
+
+  if (props.disabled) {
+    return (
+      <div className="radio">
+        <input
+          id={props.id}
+          type="radio"
+          name={props.name}
+          checked={props.checked}
+          disabled={true}
+        />
+        <label htmlFor={props.id}>{props.label}</label>
+      </div>
+    );
+  }
+  return (
+    <div className="radio">
+      <input
+        id={props.id}
+        type="radio"
+        name={props.name}
+        onChange={onChange}
+        checked={props.checked}
+      />
+      <label htmlFor={props.id}>{props.label}</label>
+    </div>
+  );
+};
+
+export default Radio;

+ 60 - 0
client/src/components/CommentApp/components/widgets/Radio/style.scss

@@ -0,0 +1,60 @@
+$radio-size: 26px;
+$radio-dot-size: $radio-size * 0.4;
+
+.radio {
+    display: block;
+    line-height: $radio-size;
+    position: relative;
+
+    label {
+        display: inline-block;
+        text-align: right;
+        padding-left: 10px;
+        font-size: 0.8em;
+        font-weight: bold;
+        cursor: pointer;
+    }
+
+    input[type='radio'] {
+        opacity: 0;
+    }
+
+    label::before {
+        content: '';
+        display: inline-block;
+        height: $radio-size;
+        width: $radio-size;
+        background-color: $color-white;
+        border: 2px solid #333;
+        border-radius: 500rem;
+        position: absolute;
+        left: 0;
+        top: 0;
+        box-sizing: border-box;
+    }
+
+    label::after {
+        content: '';
+        display: inline-block;
+        background-color: $color-box-background;
+        border: 0;
+        border-radius: 500rem;
+        width: $radio-dot-size;
+        height: $radio-dot-size;
+        position: absolute;
+        left: $radio-size / 2 - $radio-dot-size / 2;
+        top: $radio-size / 2 - $radio-dot-size / 2;
+    }
+
+    input[type='radio'] + label::after {
+        visibility: hidden;
+    }
+
+    input[type='radio']:checked + label::after {
+        visibility: visible;
+    }
+
+    input[type='radio']:focus + label::before {
+        @include focus-outline;
+    }
+}

+ 2 - 0
client/src/components/CommentApp/custom.d.ts

@@ -0,0 +1,2 @@
+declare module 'react-shadow';
+declare module '*.scss';

+ 1 - 0
client/src/components/CommentApp/icons/check-solid.svg

@@ -0,0 +1 @@
+<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="check" class="svg-inline--fa fa-check fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M173.898 439.404l-166.4-166.4c-9.997-9.997-9.997-26.206 0-36.204l36.203-36.204c9.997-9.998 26.207-9.998 36.204 0L192 312.69 432.095 72.596c9.997-9.997 26.207-9.997 36.204 0l36.203 36.204c9.997 9.997 9.997 26.206 0 36.204l-294.4 294.401c-9.998 9.997-26.207 9.997-36.204-.001z"></path></svg>

+ 1 - 0
client/src/components/CommentApp/icons/ellipsis-v-solid.svg

@@ -0,0 +1 @@
+<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="ellipsis-v" class="svg-inline--fa fa-ellipsis-v fa-w-6" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 512"><path fill="currentColor" d="M96 184c39.8 0 72 32.2 72 72s-32.2 72-72 72-72-32.2-72-72 32.2-72 72-72zM24 80c0 39.8 32.2 72 72 72s72-32.2 72-72S135.8 8 96 8 24 40.2 24 80zm0 352c0 39.8 32.2 72 72 72s72-32.2 72-72-32.2-72-72-72-72 32.2-72 72z"></path></svg>

+ 158 - 0
client/src/components/CommentApp/main.scss

@@ -0,0 +1,158 @@
+$color-teal: #007d7e;
+$color-teal-darker: darken(adjust-hue($color-teal, 1), 4);
+$color-teal-dark: darken(adjust-hue($color-teal, 1), 7);
+
+$color-blue: #71b2d4;
+$color-red: #cd3238;
+$color-red-dark: #b4191f;
+$color-red-very-dark: #901419;
+$color-orange: #e9b04d;
+$color-orange-dark: #bb5b03;
+$color-green: #189370;
+$color-green-dark: #157b57;
+$color-salmon: #f37e77;
+$color-salmon-light: #fcf2f2;
+$color-white: #fff;
+$color-black: #000;
+
+// darker to lighter
+$color-grey-1: darken($color-white, 80);
+$color-grey-2: darken($color-white, 70);
+$color-grey-25: #626262;
+$color-grey-3: darken($color-white, 15);
+$color-grey-4: darken($color-white, 10);
+$color-grey-5: darken($color-white, 2);
+$color-grey-7: #f2f2f2;
+$color-grey-8: #fbfbfb;
+
+$color-fieldset-hover: $color-grey-5;
+$color-input-border: $color-grey-4;
+$color-input-focus: lighten(desaturate($color-teal, 40), 72);
+$color-input-focus-border: lighten(saturate($color-teal, 12), 10);
+$color-input-error-bg: lighten(saturate($color-red, 28), 45);
+
+$color-link: $color-teal-darker;
+$color-link-hover: $color-teal-dark;
+
+// The focus outline color is defined without reusing a named color variable
+// because it shouldn’t be reused for anything else in the UI.
+$color-focus-outline: #ffbf47;
+
+$color-text-base: darken($color-white, 85);
+$color-text-input: darken($color-white, 90);
+
+// Color states
+$color-state-live: #59b524;
+$color-state-draft: #808080;
+$color-state-absent: #ff8f11;
+$color-state-live-draft: #43b1b0;
+
+$color-box-background: $color-white;
+$color-box-border: $color-grey-3;
+$color-box-border-focused: $color-grey-2;
+$color-box-text: $color-black;
+$color-textarea-background: $color-grey-8;
+$color-textarea-background-focused: #f2fcfc;
+$color-textarea-border: #ccc;
+$color-textarea-border-focused: #00b0b1;
+$color-textarea-placeholder-text: $color-grey-2;
+
+@mixin focus-outline {
+    outline: $color-focus-outline solid 3px;
+}
+
+@mixin box {
+    background-color: $color-box-background;
+    border: 1px solid $color-box-border;
+    padding: 25px;
+    font-size: 16px;
+    border-radius: 10px;
+    color: $color-box-text;
+
+    &--focused {
+        border: 1px solid $color-box-border-focused;
+        box-shadow: 0 0 15px 0 rgba(0, 0, 0, 0.1);
+    }
+
+    textarea {
+        font-family: 'Open Sans', sans-serif;
+        font-size: 0.8em;
+        margin: 0;
+        margin-top: 5px;
+        padding: 10px;
+        width: 100%;
+        background-color: $color-textarea-background;
+        border: 2px solid $color-textarea-border;
+        box-sizing: border-box;
+        border-radius: 7px;
+        -moz-outline-radius: 10px;
+        color: $color-box-text;
+
+        &::placeholder {
+            font-style: italic;
+            color: $color-textarea-placeholder-text;
+            opacity: 1;
+        }
+
+        &:focus {
+            background-color: $color-textarea-background-focused;
+            border-color: $color-textarea-border-focused;
+            outline: unset;
+        }
+    }
+
+    *:focus {
+        @include focus-outline;
+    }
+}
+
+@mixin button {
+    background-color: inherit;
+    border: 1px solid $color-grey-3;
+    border-radius: 5px;
+    -moz-outline-radius: 7px;
+    color: $color-teal;
+    cursor: pointer;
+    text-transform: uppercase;
+    font-family: inherit;
+    font-size: 16px;
+    font-weight: bold;
+    height: 35px;
+    padding-left: 10px;
+    padding-right: 10px;
+
+    &--primary {
+        color: $color-white;
+        border: 1px solid $color-teal;
+        background-color: $color-teal;
+    }
+
+    &--red {
+        color: $color-white;
+        border: 1px solid $color-red-very-dark;
+        background-color: $color-red-very-dark;
+    }
+
+    // Disable Firefox's focus styling becase we add our own.
+    &::-moz-focus-inner {
+        border: 0;
+    }
+}
+
+.comments-list {
+    height: 100%;
+    width: 400px;
+    position: absolute;
+    top: 30px;
+    right: 30px;
+    z-index: 1000;
+    font-family: 'Open Sans', sans-serif;
+    pointer-events: none;
+}
+
+@import 'components/CommentHeader/style';
+@import 'components/Comment/style';
+@import 'components/CommentReply/style';
+@import 'components/TopBar/style';
+@import 'components/widgets/Checkbox/style';
+@import 'components/widgets/Radio/style';

+ 341 - 0
client/src/components/CommentApp/main.tsx

@@ -0,0 +1,341 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+import { createStore } from 'redux';
+
+import type { Annotation } from './utils/annotation';
+import { LayoutController } from './utils/layout';
+import { getOrDefault } from './utils/maps';
+import { getNextCommentId, getNextReplyId } from './utils/sequences';
+import { Store, reducer } from './state';
+import { Comment, newCommentReply, newComment, Author } from './state/comments';
+import {
+  addComment,
+  addReply,
+  setFocusedComment,
+  updateComment,
+  commentActionFunctions
+} from './actions/comments';
+import { updateGlobalSettings } from './actions/settings';
+import {
+  selectComments,
+  selectCommentsForContentPathFactory,
+  selectCommentFactory,
+  selectEnabled,
+  selectFocused
+} from './selectors';
+import CommentComponent from './components/Comment';
+import { CommentFormSetComponent } from './components/Form';
+import TopBarComponent from './components/TopBar';
+import { INITIAL_STATE as INITIAL_SETTINGS_STATE } from './state/settings';
+
+export interface TranslatableStrings {
+  COMMENT: string;
+  SAVE: string;
+  SAVING: string;
+  CANCEL: string;
+  DELETE: string;
+  DELETING: string;
+  SHOW_COMMENTS: string;
+  EDIT: string;
+  REPLY: string;
+  RESOLVE: string;
+  RETRY: string;
+  DELETE_ERROR: string;
+  CONFIRM_DELETE_COMMENT: string;
+  SAVE_ERROR: string;
+  MORE_ACTIONS: string;
+}
+
+export const defaultStrings = {
+  COMMENT: 'Comment',
+  SAVE: 'Save',
+  SAVING: 'Saving...',
+  CANCEL: 'Cancel',
+  DELETE: 'Delete',
+  DELETING: 'Deleting...',
+  SHOW_COMMENTS: 'Show comments',
+  EDIT: 'Edit',
+  REPLY: 'Reply',
+  RESOLVE: 'Resolve',
+  RETRY: 'Retry',
+  DELETE_ERROR: 'Delete error',
+  CONFIRM_DELETE_COMMENT: 'Are you sure?',
+  SAVE_ERROR: 'Save error',
+  MORE_ACTIONS: 'More actions',
+};
+
+/* eslint-disable camelcase */
+// This is done as this is serialized pretty directly from the Django model
+export interface InitialCommentReply {
+  pk: number;
+  user: any;
+  text: string;
+  created_at: string;
+  updated_at: string;
+}
+
+export interface InitialComment {
+  pk: number;
+  user: any;
+  text: string;
+  created_at: string;
+  updated_at: string;
+  replies: InitialCommentReply[];
+  contentpath: string;
+  position: string;
+}
+/* eslint-enable */
+
+// eslint-disable-next-line camelcase
+const getAuthor = (authors: Map<string, {name: string, avatar_url: string}>, id: any): Author => {
+  const authorData = getOrDefault(authors, String(id), { name: '', avatar_url: '' });
+
+  return {
+    id,
+    name: authorData.name,
+    avatarUrl: authorData.avatar_url,
+  };
+};
+
+function renderCommentsUi(
+  store: Store,
+  layout: LayoutController,
+  comments: Comment[],
+  strings: TranslatableStrings
+): React.ReactElement {
+  const state = store.getState();
+  const { commentsEnabled, user } = state.settings;
+  const focusedComment = state.comments.focusedComment;
+  let commentsToRender = comments;
+
+  if (!commentsEnabled || !user) {
+    commentsToRender = [];
+  }
+  // Hide all resolved/deleted comments
+  commentsToRender = commentsToRender.filter(({ deleted }) => !deleted);
+  const commentsRendered = commentsToRender.map((comment) => (
+    <CommentComponent
+      key={comment.localId}
+      store={store}
+      layout={layout}
+      user={user}
+      comment={comment}
+      isFocused={comment.localId === focusedComment}
+      strings={strings}
+    />
+  ));
+  return (
+    <>
+      <TopBarComponent
+        commentsEnabled={commentsEnabled}
+        store={store}
+        strings={strings}
+      />
+      <ol className="comments-list">{commentsRendered}</ol>
+    </>
+  );
+  /* eslint-enable react/no-danger */
+}
+
+export class CommentApp {
+  store: Store;
+  layout: LayoutController;
+  utils = {
+    selectCommentsForContentPathFactory,
+    selectCommentFactory
+  }
+  selectors = {
+    selectComments,
+    selectEnabled,
+    selectFocused
+  }
+  actions = commentActionFunctions;
+
+  constructor() {
+    this.store = createStore(reducer, {
+      settings: INITIAL_SETTINGS_STATE
+    });
+    this.layout = new LayoutController();
+  }
+  // eslint-disable-next-line camelcase
+  setUser(userId: any, authors: Map<string, {name: string, avatar_url: string}>) {
+    this.store.dispatch(
+      updateGlobalSettings({
+        user: getAuthor(authors, userId)
+      })
+    );
+  }
+  updateAnnotation(
+    annotation: Annotation,
+    commentId: number
+  ) {
+    this.attachAnnotationLayout(annotation, commentId);
+    this.store.dispatch(
+      updateComment(
+        commentId,
+        { annotation: annotation }
+      )
+    );
+  }
+  attachAnnotationLayout(
+    annotation: Annotation,
+    commentId: number
+  ) {
+    // Attach an annotation to an existing comment in the layout
+
+    // const layout engine know the annotation so it would position the comment correctly
+    this.layout.setCommentAnnotation(commentId, annotation);
+  }
+  makeComment(annotation: Annotation, contentpath: string, position = '') {
+    const commentId = getNextCommentId();
+
+    this.attachAnnotationLayout(annotation, commentId);
+
+    // Create the comment
+    this.store.dispatch(
+      addComment(
+        newComment(
+          contentpath,
+          position,
+          commentId,
+          annotation,
+          this.store.getState().settings.user,
+          Date.now(),
+          {
+            mode: 'creating',
+          }
+        )
+      )
+    );
+
+    // Focus and pin the comment
+    this.store.dispatch(setFocusedComment(commentId, { updatePinnedComment: true }));
+    return commentId;
+  }
+  renderApp(
+    element: HTMLElement,
+    outputElement: HTMLElement,
+    userId: any,
+    initialComments: InitialComment[],
+    // eslint-disable-next-line camelcase
+    authors: Map<string, {name: string, avatar_url: string}>,
+    translationStrings: TranslatableStrings | null
+  ) {
+    let pinnedComment: number | null = null;
+    this.setUser(userId, authors);
+
+    const strings = translationStrings || defaultStrings;
+
+    // Check if there is "comment" query parameter.
+    // If this is set, the user has clicked on a "View on frontend" link of an
+    // individual comment. We should focus this comment and scroll to it
+    const urlParams = new URLSearchParams(window.location.search);
+    let initialFocusedCommentId: number | null = null;
+    const commentParams = urlParams.get('comment');
+    if (commentParams) {
+      initialFocusedCommentId = parseInt(commentParams, 10);
+    }
+
+    const render = () => {
+      const state = this.store.getState();
+      const commentList: Comment[] = Array.from(state.comments.comments.values());
+
+      ReactDOM.render(
+        <CommentFormSetComponent
+          comments={commentList}
+          remoteCommentCount={state.comments.remoteCommentCount}
+        />,
+        outputElement
+      );
+
+      // Check if the pinned comment has changed
+      if (state.comments.pinnedComment !== pinnedComment) {
+        // Tell layout controller about the pinned comment
+        // so it is moved alongside its annotation
+        this.layout.setPinnedComment(state.comments.pinnedComment);
+
+        pinnedComment = state.comments.pinnedComment;
+      }
+
+      ReactDOM.render(
+        renderCommentsUi(this.store, this.layout, commentList, strings),
+        element,
+        () => {
+          // Render again if layout has changed (eg, a comment was added, deleted or resized)
+          // This will just update the "top" style attributes in the comments to get them to move
+          if (this.layout.refresh()) {
+            ReactDOM.render(
+              renderCommentsUi(this.store, this.layout, commentList, strings),
+              element
+            );
+          }
+        }
+      );
+    };
+
+    // Fetch existing comments
+    for (const comment of initialComments) {
+      const commentId = getNextCommentId();
+
+      // Create comment
+      this.store.dispatch(
+        addComment(
+          newComment(
+            comment.contentpath,
+            comment.position,
+            commentId,
+            null,
+            getAuthor(authors, comment.user),
+            Date.parse(comment.created_at),
+            {
+              remoteId: comment.pk,
+              text: comment.text,
+            }
+          )
+        )
+      );
+
+      // Create replies
+      for (const reply of comment.replies) {
+        this.store.dispatch(
+          addReply(
+            commentId,
+            newCommentReply(
+              getNextReplyId(),
+              getAuthor(authors, reply.user),
+              Date.parse(reply.created_at),
+              { remoteId: reply.pk, text: reply.text }
+            )
+          )
+        );
+      }
+
+      // If this is the initial focused comment. Focus and pin it
+      // TODO: Scroll to this comment
+      if (initialFocusedCommentId && comment.pk === initialFocusedCommentId) {
+        this.store.dispatch(setFocusedComment(commentId, { updatePinnedComment: true }));
+      }
+    }
+
+    render();
+
+    this.store.subscribe(render);
+
+    // Unfocus when document body is clicked
+    document.body.addEventListener('click', (e) => {
+      if (e.target instanceof HTMLElement) {
+        // ignore if click target is a comment or an annotation
+        if (!e.target.closest('#comments, [data-annotation]')) {
+          // Running store.dispatch directly here seems to prevent the event from being handled anywhere else
+          setTimeout(() => {
+            this.store.dispatch(setFocusedComment(null, { updatePinnedComment: true }));
+          }, 1);
+        }
+      }
+    });
+  }
+}
+
+export function initCommentApp() {
+  return new CommentApp();
+}

+ 29 - 0
client/src/components/CommentApp/selectors/index.ts

@@ -0,0 +1,29 @@
+import { createSelector } from 'reselect';
+import type { Comment } from '../state/comments';
+import type { State } from '../state';
+
+export const selectComments = (state: State) => state.comments.comments;
+export const selectFocused = (state: State) => state.comments.focusedComment;
+
+export function selectCommentsForContentPathFactory(contentpath: string) {
+  return createSelector(selectComments, (comments) =>
+    [...comments.values()].filter(
+      (comment: Comment) =>
+        comment.contentpath === contentpath && !comment.deleted
+    )
+  );
+}
+
+export function selectCommentFactory(localId: number) {
+  return createSelector(selectComments, (comments) => {
+    const comment = comments.get(localId);
+    if (comment !== undefined && comment.deleted) {
+      return undefined;
+    }
+    return comment;
+  }
+
+  );
+}
+
+export const selectEnabled = (state: State) => state.settings.commentsEnabled;

+ 25 - 0
client/src/components/CommentApp/selectors/selectors.test.ts

@@ -0,0 +1,25 @@
+import { basicCommentsState } from '../__fixtures__/state';
+import { INITIAL_STATE } from '../state/settings';
+
+import { selectCommentsForContentPathFactory } from './index';
+
+test('Select comments for contentpath', () => {
+  // test that the selectCommentsForContentPathFactory can generate selectors for the two
+  // contentpaths in basicCommentsState
+  const state = {
+    comments: basicCommentsState,
+    settings: INITIAL_STATE,
+  };
+  const testContentPathSelector = selectCommentsForContentPathFactory(
+    'test_contentpath'
+  );
+  const testContentPathSelector2 = selectCommentsForContentPathFactory(
+    'test_contentpath_2'
+  );
+  const selectedComments = testContentPathSelector(state);
+  expect(selectedComments.length).toBe(1);
+  expect(selectedComments[0].contentpath).toBe('test_contentpath');
+  const otherSelectedComments = testContentPathSelector2(state);
+  expect(otherSelectedComments.length).toBe(1);
+  expect(otherSelectedComments[0].contentpath).toBe('test_contentpath_2');
+});

+ 214 - 0
client/src/components/CommentApp/state/comments.test.ts

@@ -0,0 +1,214 @@
+import { basicCommentsState } from '../__fixtures__/state';
+import {
+  Comment,
+  CommentReply,
+  CommentReplyUpdate,
+  CommentUpdate,
+  reducer,
+} from './comments';
+import { createStore } from 'redux';
+
+import * as actions from '../actions/comments';
+
+test('Initial comments state empty', () => {
+  const state = createStore(reducer).getState();
+  expect(state.focusedComment).toBe(null);
+  expect(state.pinnedComment).toBe(null);
+  expect(state.comments.size).toBe(0);
+  expect(state.remoteCommentCount).toBe(0);
+});
+
+test('New comment added to state', () => {
+  const newComment: Comment = {
+    contentpath: 'test_contentpath',
+    position: '',
+    localId: 5,
+    annotation: null,
+    remoteId: null,
+    mode: 'default',
+    deleted: false,
+    author: { id: 1, name: 'test user' },
+    date: 0,
+    text: 'new comment',
+    newReply: '',
+    newText: '',
+    remoteReplyCount: 0,
+    replies: new Map(),
+  };
+  const commentAction = actions.addComment(newComment);
+  const newState = reducer(basicCommentsState, commentAction);
+  expect(newState.comments.get(newComment.localId)).toBe(newComment);
+  expect(newState.remoteCommentCount).toBe(
+    basicCommentsState.remoteCommentCount
+  );
+});
+
+test('Remote comment added to state', () => {
+  const newComment: Comment = {
+    contentpath: 'test_contentpath',
+    position: '',
+    localId: 5,
+    annotation: null,
+    remoteId: 10,
+    mode: 'default',
+    deleted: false,
+    author: { id: 1, name: 'test user' },
+    date: 0,
+    text: 'new comment',
+    newReply: '',
+    newText: '',
+    remoteReplyCount: 0,
+    replies: new Map(),
+  };
+  const commentAction = actions.addComment(newComment);
+  const newState = reducer(basicCommentsState, commentAction);
+  expect(newState.comments.get(newComment.localId)).toBe(newComment);
+  expect(newState.remoteCommentCount).toBe(
+    basicCommentsState.remoteCommentCount + 1
+  );
+});
+
+test('Existing comment updated', () => {
+  const commentUpdate: CommentUpdate = {
+    mode: 'editing',
+  };
+  const updateAction = actions.updateComment(1, commentUpdate);
+  const newState = reducer(basicCommentsState, updateAction);
+  const comment = newState.comments.get(1);
+  expect(comment).toBeDefined();
+  if (comment) {
+    expect(comment.mode).toBe('editing');
+  }
+});
+
+test('Local comment deleted', () => {
+  // Test that deleting a comment without a remoteId removes it from the state entirely
+  const deleteAction = actions.deleteComment(4);
+  const newState = reducer(basicCommentsState, deleteAction);
+  expect(newState.comments.has(4)).toBe(false);
+});
+
+test('Remote comment deleted', () => {
+  // Test that deleting a comment without a remoteId does not remove it from the state, but marks it as deleted
+  const deleteAction = actions.deleteComment(1);
+  const newState = reducer(basicCommentsState, deleteAction);
+  const comment = newState.comments.get(1);
+  expect(comment).toBeDefined();
+  if (comment) {
+    expect(comment.deleted).toBe(true);
+  }
+  expect(newState.focusedComment).toBe(null);
+  expect(newState.pinnedComment).toBe(null);
+  expect(newState.remoteCommentCount).toBe(
+    basicCommentsState.remoteCommentCount
+  );
+});
+
+test('Comment focused', () => {
+  const focusAction = actions.setFocusedComment(4);
+  const newState = reducer(basicCommentsState, focusAction);
+  expect(newState.focusedComment).toBe(4);
+});
+
+test('Invalid comment not focused', () => {
+  const focusAction = actions.setFocusedComment(9000, { updatePinnedComment: true });
+  const newState = reducer(basicCommentsState, focusAction);
+  expect(newState.focusedComment).toBe(basicCommentsState.focusedComment);
+  expect(newState.pinnedComment).toBe(basicCommentsState.pinnedComment);
+});
+
+test('Reply added', () => {
+  const reply: CommentReply = {
+    localId: 10,
+    remoteId: null,
+    mode: 'default',
+    author: { id: 1, name: 'test user' },
+    date: 0,
+    text: 'a new reply',
+    newText: '',
+    deleted: false,
+  };
+  const addAction = actions.addReply(1, reply);
+  const newState = reducer(basicCommentsState, addAction);
+  const comment = newState.comments.get(1);
+  expect(comment).toBeDefined();
+  if (comment) {
+    const stateReply = comment.replies.get(10);
+    expect(stateReply).toBeDefined();
+    if (stateReply) {
+      expect(stateReply).toBe(reply);
+    }
+  }
+});
+
+test('Remote reply added', () => {
+  const reply: CommentReply = {
+    localId: 10,
+    remoteId: 1,
+    mode: 'default',
+    author: { id: 1, name: 'test user' },
+    date: 0,
+    text: 'a new reply',
+    newText: '',
+    deleted: false,
+  };
+  const addAction = actions.addReply(1, reply);
+  const newState = reducer(basicCommentsState, addAction);
+  const originalComment = basicCommentsState.comments.get(1);
+  const comment = newState.comments.get(1);
+  expect(comment).toBeDefined();
+  if (comment) {
+    const stateReply = comment.replies.get(reply.localId);
+    expect(stateReply).toBeDefined();
+    expect(stateReply).toBe(reply);
+    if (originalComment) {
+      expect(comment.remoteReplyCount).toBe(originalComment.remoteReplyCount + 1);
+    }
+  }
+});
+
+test('Reply updated', () => {
+  const replyUpdate: CommentReplyUpdate = {
+    mode: 'editing',
+  };
+  const updateAction = actions.updateReply(1, 2, replyUpdate);
+  const newState = reducer(basicCommentsState, updateAction);
+  const comment = newState.comments.get(1);
+  expect(comment).toBeDefined();
+  if (comment) {
+    const reply = comment.replies.get(2);
+    expect(reply).toBeDefined();
+    if (reply) {
+      expect(reply.mode).toBe('editing');
+    }
+  }
+});
+
+test('Local reply deleted', () => {
+  // Test that the delete action deletes a reply that hasn't yet been saved to the db from the state entirely
+  const deleteAction = actions.deleteReply(1, 3);
+  const newState = reducer(basicCommentsState, deleteAction);
+  const comment = newState.comments.get(1);
+  expect(comment).toBeDefined();
+  if (comment) {
+    expect(comment.replies.has(3)).toBe(false);
+  }
+});
+
+test('Remote reply deleted', () => {
+  // Test that the delete action deletes a reply that has been saved to the db by marking it as deleted instead
+  const deleteAction = actions.deleteReply(1, 2);
+  const newState = reducer(basicCommentsState, deleteAction);
+  const comment = newState.comments.get(1);
+  const originalComment = basicCommentsState.comments.get(1);
+  expect(comment).toBeDefined();
+  expect(originalComment).toBeDefined();
+  if (comment && originalComment) {
+    expect(comment.remoteReplyCount).toBe(originalComment.remoteReplyCount);
+    const reply = comment.replies.get(2);
+    expect(reply).toBeDefined();
+    if (reply) {
+      expect(reply.deleted).toBe(true);
+    }
+  }
+});

+ 242 - 0
client/src/components/CommentApp/state/comments.ts

@@ -0,0 +1,242 @@
+import type { Annotation } from '../utils/annotation';
+import * as actions from '../actions/comments';
+import { update } from './utils';
+import produce, { enableMapSet, enableES5 } from 'immer';
+
+enableES5();
+enableMapSet();
+
+export interface Author {
+  id: any;
+  name: string;
+  avatarUrl?: string;
+}
+
+export type CommentReplyMode =
+  | 'default'
+  | 'editing'
+  | 'saving'
+  | 'delete_confirm'
+  | 'deleting'
+  | 'deleted'
+  | 'save_error'
+  | 'delete_error';
+
+export interface CommentReply {
+  localId: number;
+  remoteId: number | null;
+  mode: CommentReplyMode;
+  author: Author | null;
+  date: number;
+  text: string;
+  newText: string;
+  deleted: boolean;
+}
+
+export interface NewReplyOptions {
+  remoteId?: number | null;
+  mode?: CommentReplyMode;
+  text?: string;
+}
+
+export function newCommentReply(
+  localId: number,
+  author: Author | null,
+  date: number,
+  {
+    remoteId = null,
+    mode = 'default',
+    text = '',
+  }: NewReplyOptions
+): CommentReply {
+  return {
+    localId,
+    remoteId,
+    mode,
+    author,
+    date,
+    text,
+    newText: '',
+    deleted: false,
+  };
+}
+
+export type CommentReplyUpdate = Partial<CommentReply>;
+
+export type CommentMode =
+  | 'default'
+  | 'creating'
+  | 'editing'
+  | 'saving'
+  | 'delete_confirm'
+  | 'deleting'
+  | 'deleted'
+  | 'save_error'
+  | 'delete_error';
+
+export interface Comment {
+  contentpath: string;
+  localId: number;
+  annotation: Annotation | null;
+  position: string;
+  remoteId: number | null;
+  mode: CommentMode;
+  deleted: boolean;
+  author: Author | null;
+  date: number;
+  text: string;
+  replies: Map<number, CommentReply>;
+  newReply: string;
+  newText: string;
+  remoteReplyCount: number;
+}
+
+export interface NewCommentOptions {
+  remoteId?: number | null;
+  mode?: CommentMode;
+  text?: string;
+  replies?: Map<number, CommentReply>;
+}
+
+export function newComment(
+  contentpath: string,
+  position: string,
+  localId: number,
+  annotation: Annotation | null,
+  author: Author | null,
+  date: number,
+  {
+    remoteId = null,
+    mode = 'default',
+    text = '',
+    replies = new Map(),
+  }: NewCommentOptions
+): Comment {
+  return {
+    contentpath,
+    position,
+    localId,
+    annotation,
+    remoteId,
+    mode,
+    author,
+    date,
+    text,
+    replies,
+    newReply: '',
+    newText: '',
+    deleted: false,
+    remoteReplyCount: Array.from(replies.values()).reduce(
+      (n, reply) => (reply.remoteId !== null ? n + 1 : n),
+      0
+    ),
+  };
+}
+
+export type CommentUpdate = Partial<Comment>;
+
+export interface CommentsState {
+  comments: Map<number, Comment>;
+  focusedComment: number | null;
+  pinnedComment: number | null;
+  // This is redundant, but stored for efficiency as it will change only as the app adds its loaded comments
+  remoteCommentCount: number;
+}
+
+const INITIAL_STATE: CommentsState = {
+  comments: new Map(),
+  focusedComment: null,
+  pinnedComment: null,
+  remoteCommentCount: 0,
+};
+
+export const reducer = produce((draft: CommentsState, action: actions.Action) => {
+  /* eslint-disable no-param-reassign */
+  switch (action.type) {
+  case actions.ADD_COMMENT: {
+    draft.comments.set(action.comment.localId, action.comment);
+    if (action.comment.remoteId) {
+      draft.remoteCommentCount += 1;
+    }
+    break;
+  }
+  case actions.UPDATE_COMMENT: {
+    const comment = draft.comments.get(action.commentId);
+    if (comment) {
+      update(comment, action.update);
+    }
+    break;
+  }
+  case actions.DELETE_COMMENT: {
+    const comment = draft.comments.get(action.commentId);
+    if (!comment) {
+      break;
+    } else if (!comment.remoteId) {
+      // If the comment doesn't exist in the database, there's no need to keep it around locally
+      draft.comments.delete(action.commentId);
+    } else {
+      comment.deleted = true;
+    }
+
+    // Unset focusedComment if the focused comment is the one being deleted
+    if (draft.focusedComment === action.commentId) {
+      draft.focusedComment = null;
+    }
+    if (draft.pinnedComment === action.commentId) {
+      draft.pinnedComment = null;
+    }
+    break;
+  }
+  case actions.SET_FOCUSED_COMMENT: {
+    if ((action.commentId === null) || (draft.comments.has(action.commentId))) {
+      draft.focusedComment = action.commentId;
+      if (action.updatePinnedComment) {
+        draft.pinnedComment = action.commentId;
+      }
+    }
+    break;
+  }
+  case actions.ADD_REPLY: {
+    const comment = draft.comments.get(action.commentId);
+    if (!comment) {
+      break;
+    }
+    if (action.reply.remoteId) {
+      comment.remoteReplyCount += 1;
+    }
+    comment.replies.set(action.reply.localId, action.reply);
+    break;
+  }
+  case actions.UPDATE_REPLY: {
+    const comment = draft.comments.get(action.commentId);
+    if (!comment) {
+      break;
+    }
+    const reply = comment.replies.get(action.replyId);
+    if (!reply) {
+      break;
+    }
+    update(reply, action.update);
+    break;
+  }
+  case actions.DELETE_REPLY: {
+    const comment = draft.comments.get(action.commentId);
+    if (!comment) {
+      break;
+    }
+    const reply = comment.replies.get(action.replyId);
+    if (!reply) {
+      break;
+    }
+    if (!reply.remoteId) {
+      // The reply doesn't exist in the database, so we don't need to store it locally
+      comment.replies.delete(reply.localId);
+    } else {
+      reply.deleted = true;
+    }
+    break;
+  }
+  default:
+    break;
+  }
+}, INITIAL_STATE);

+ 14 - 0
client/src/components/CommentApp/state/index.ts

@@ -0,0 +1,14 @@
+import { combineReducers, Store as reduxStore } from 'redux';
+
+import { reducer as commentsReducer } from './comments';
+import { reducer as settingsReducer } from './settings';
+import type { Action } from '../actions';
+
+export type State = ReturnType<typeof reducer>;
+
+export const reducer = combineReducers({
+  comments: commentsReducer,
+  settings: settingsReducer,
+});
+
+export type Store = reduxStore<State, Action>;

+ 29 - 0
client/src/components/CommentApp/state/settings.ts

@@ -0,0 +1,29 @@
+import * as actions from '../actions/settings';
+import type { Author } from './comments';
+import { update } from './utils';
+import produce from 'immer';
+
+export interface SettingsState {
+  user: Author | null;
+  commentsEnabled: boolean;
+  showResolvedComments: boolean;
+}
+
+export type SettingsStateUpdate = Partial<SettingsState>;
+
+// Reducer with initial state
+export const INITIAL_STATE: SettingsState = {
+  user: null,
+  commentsEnabled: true,
+  showResolvedComments: false,
+};
+
+export const reducer = produce((draft: SettingsState, action: actions.Action) => {
+  switch (action.type) {
+  case actions.UPDATE_GLOBAL_SETTINGS:
+    update(draft, action.update);
+    break;
+  default:
+    break;
+  }
+}, INITIAL_STATE);

+ 3 - 0
client/src/components/CommentApp/state/utils.ts

@@ -0,0 +1,3 @@
+export function update<T>(base: T, updatePartial: Partial<T>): T {
+  return Object.assign(base, updatePartial);
+}

+ 3 - 0
client/src/components/CommentApp/utils/annotation.ts

@@ -0,0 +1,3 @@
+export interface Annotation {
+  getDesiredPosition(focused: boolean): number;
+}

+ 187 - 0
client/src/components/CommentApp/utils/layout.ts

@@ -0,0 +1,187 @@
+import type { Annotation } from './annotation';
+import { getOrDefault } from './maps';
+
+const GAP = 20.0; // Gap between comments in pixels
+const TOP_MARGIN = 100.0; // Spacing from the top to the first comment in pixels
+const OFFSET = -50; // How many pixels from the annotation position should the comments be placed?
+
+export class LayoutController {
+  commentElements: Map<number, HTMLElement> = new Map();
+  commentAnnotations: Map<number, Annotation> = new Map();
+  commentDesiredPositions: Map<number, number> = new Map();
+  commentHeights: Map<number, number> = new Map();
+  pinnedComment: number | null = null;
+  commentCalculatedPositions: Map<number, number> = new Map();
+  isDirty = false;
+
+  setCommentElement(commentId: number, element: HTMLElement | null) {
+    if (element !== null) {
+      this.commentElements.set(commentId, element);
+    } else {
+      this.commentElements.delete(commentId);
+    }
+
+    this.isDirty = true;
+  }
+
+  setCommentAnnotation(commentId: number, annotation: Annotation) {
+    this.commentAnnotations.set(commentId, annotation);
+    this.updateDesiredPosition(commentId);
+    this.isDirty = true;
+  }
+
+  setCommentHeight(commentId: number, height: number) {
+    if (this.commentHeights.get(commentId) !== height) {
+      this.commentHeights.set(commentId, height);
+      this.isDirty = true;
+    }
+  }
+
+  setPinnedComment(commentId: number | null) {
+    this.pinnedComment = commentId;
+    this.isDirty = true;
+  }
+
+  updateDesiredPosition(commentId: number) {
+    const annotation = this.commentAnnotations.get(commentId);
+
+    if (!annotation) {
+      return;
+    }
+
+    this.commentDesiredPositions.set(
+      commentId,
+      annotation.getDesiredPosition(commentId === this.pinnedComment) + OFFSET
+    );
+  }
+
+  refreshDesiredPositions() {
+    this.commentAnnotations.forEach((_, commentId) =>
+      this.updateDesiredPosition(commentId)
+    );
+  }
+
+  refresh() {
+    const oldDesiredPositions = new Map(this.commentDesiredPositions);
+    this.refreshDesiredPositions();
+    // It's not great to be recalculating all positions so regularly, but Wagtail's FE widgets
+    // aren't very constrained so could change layout in any number of ways. If we have a stable FE
+    // widget framework in the future, this could be used to trigger the position refresh more
+    // intelligently, or alternatively once comments is incorporated into the page form, a
+    // MutationObserver could potentially track most types of changes.
+    if (this.commentDesiredPositions !== oldDesiredPositions) {
+      this.isDirty = true;
+    }
+
+    if (!this.isDirty) {
+      return false;
+    }
+
+    interface Block {
+      position: number;
+      height: number;
+      comments: number[];
+      containsPinnedComment: boolean;
+      pinnedCommentPosition: number;
+    }
+
+    // Build list of blocks (starting with one for each comment)
+    let blocks: Block[] = Array.from(this.commentElements.keys()).map(
+      (commentId) => ({
+        position: getOrDefault(this.commentDesiredPositions, commentId, 0),
+        height: getOrDefault(this.commentHeights, commentId, 0),
+        comments: [commentId],
+        containsPinnedComment:
+            this.pinnedComment !== null && commentId === this.pinnedComment,
+        pinnedCommentPosition: 0,
+      })
+    );
+
+    // Sort blocks
+    blocks.sort(
+      (block, comparisonBlock) => block.position - comparisonBlock.position
+    );
+
+    // Resolve overlapping blocks
+    let overlaps = true;
+    while (overlaps) {
+      overlaps = false;
+      const newBlocks: Block[] = [];
+      let previousBlock: Block | null = null;
+      const pinnedCommentPosition = this.pinnedComment ?
+        this.commentDesiredPositions.get(this.pinnedComment) : undefined;
+
+      for (const block of blocks) {
+        if (previousBlock) {
+          if (
+            previousBlock.position + previousBlock.height + GAP >
+            block.position
+          ) {
+            overlaps = true;
+
+            // Merge the blocks
+            previousBlock.comments.push(...block.comments);
+
+            if (block.containsPinnedComment) {
+              previousBlock.containsPinnedComment = true;
+              previousBlock.pinnedCommentPosition =
+                block.pinnedCommentPosition + previousBlock.height;
+            }
+            previousBlock.height += block.height;
+
+            // Make sure comments don't disappear off the top of the page
+            // But only if a comment isn't focused
+            if (
+              !this.pinnedComment &&
+              previousBlock.position < TOP_MARGIN + OFFSET
+            ) {
+              previousBlock.position =
+                TOP_MARGIN + previousBlock.height - OFFSET;
+            }
+
+            // If this block contains the focused comment, position it so
+            // the focused comment is in it's desired position
+            if (
+              pinnedCommentPosition &&
+              previousBlock.containsPinnedComment
+            ) {
+              previousBlock.position =
+                pinnedCommentPosition -
+                previousBlock.pinnedCommentPosition;
+            }
+
+            continue;
+          }
+        }
+
+        newBlocks.push(block);
+        previousBlock = block;
+      }
+
+      blocks = newBlocks;
+    }
+
+    // Write positions
+    blocks.forEach((block) => {
+      let currentPosition = block.position;
+      block.comments.forEach((commentId) => {
+        this.commentCalculatedPositions.set(commentId, currentPosition);
+        const height = this.commentHeights.get(commentId);
+        if (height) {
+          currentPosition += height + GAP;
+        }
+      });
+    });
+
+    this.isDirty = false;
+
+    return true;
+  }
+
+  getCommentPosition(commentId: number) {
+    if (this.commentCalculatedPositions.has(commentId)) {
+      return this.commentCalculatedPositions.get(commentId);
+    }
+    return this.commentDesiredPositions.get(commentId);
+  }
+}

+ 7 - 0
client/src/components/CommentApp/utils/maps.ts

@@ -0,0 +1,7 @@
+export function getOrDefault<K, V>(map: Map<K, V>, key: K, defaultValue: V) {
+  const value = map.get(key);
+  if (typeof value === 'undefined') {
+    return defaultValue;
+  }
+  return value;
+}

+ 10 - 0
client/src/components/CommentApp/utils/sequences.ts

@@ -0,0 +1,10 @@
+let nextCommentId = 1;
+let nextReplyId = 1;
+
+export function getNextCommentId() {
+  return nextCommentId++;
+}
+
+export function getNextReplyId() {
+  return nextReplyId++;
+}

+ 130 - 0
client/src/components/CommentApp/utils/storybook.tsx

@@ -0,0 +1,130 @@
+import React from 'react';
+
+import { Store } from '../state';
+import {
+  addComment,
+  setFocusedComment,
+  addReply,
+} from '../actions/comments';
+import {
+  Author,
+  Comment,
+  NewCommentOptions,
+  newComment,
+  newCommentReply,
+  NewReplyOptions,
+} from '../state/comments';
+import { LayoutController } from '../utils/layout';
+import { getNextCommentId } from './sequences';
+import { defaultStrings } from '../main';
+
+import CommentComponent from '../components/Comment/index';
+
+export function RenderCommentsForStorybook({
+  store,
+  author,
+}: {
+  store: Store;
+  author?: Author;
+}) {
+  const [state, setState] = React.useState(store.getState());
+  store.subscribe(() => {
+    setState(store.getState());
+  });
+
+  const layout = new LayoutController();
+
+  const commentsToRender: Comment[] = Array.from(
+    state.comments.comments.values()
+  );
+
+  const commentsRendered = commentsToRender.map((comment) => (
+    <CommentComponent
+      key={comment.localId}
+      store={store}
+      layout={layout}
+      user={
+        author || {
+          id: 1,
+          name: 'Admin',
+          avatarUrl: 'https://gravatar.com/avatar/e31ec811942afbf7b9ce0ac5affe426f?s=200&d=robohash&r=x',
+        }
+      }
+      comment={comment}
+      isFocused={comment.localId === state.comments.focusedComment}
+      strings={defaultStrings}
+    />
+  ));
+
+  return (
+    <ol className="comments-list">{commentsRendered}</ol>
+  );
+}
+
+interface AddTestCommentOptions extends NewCommentOptions {
+  focused?: boolean;
+  author?: Author;
+}
+
+export function addTestComment(
+  store: Store,
+  options: AddTestCommentOptions
+): number {
+  const commentId = getNextCommentId();
+
+  const addCommentOptions = options;
+
+  const author = options.author || {
+    id: 1,
+    name: 'Admin',
+    avatarUrl: 'https://gravatar.com/avatar/e31ec811942afbf7b9ce0ac5affe426f?s=200&d=robohash&r=x',
+  };
+
+  // We must have a remoteId unless the comment is being created
+  if (options.mode !== 'creating' && options.remoteId === undefined) {
+    addCommentOptions.remoteId = commentId;
+  }
+
+  // Comment must be focused if the mode is anything other than default
+  if (options.mode !== 'default' && options.focused === undefined) {
+    addCommentOptions.focused = true;
+  }
+
+  store.dispatch(
+    addComment(
+      newComment('test', '', commentId, null, author, Date.now(), addCommentOptions)
+    )
+  );
+
+  if (options.focused) {
+    store.dispatch(setFocusedComment(commentId, { updatePinnedComment: true }));
+  }
+
+  return commentId;
+}
+
+interface AddTestReplyOptions extends NewReplyOptions {
+  focused?: boolean;
+  author?: Author;
+}
+
+export function addTestReply(
+  store: Store,
+  commentId: number,
+  options: AddTestReplyOptions
+) {
+  const addReplyOptions = options;
+  const author = options.author || {
+    id: 1,
+    name: 'Admin',
+    avatarUrl: 'https://gravatar.com/avatar/e31ec811942afbf7b9ce0ac5affe426f?s=200&d=robohash&r=x',
+  };
+
+  if (!options.remoteId) {
+    addReplyOptions.remoteId = 1;
+  }
+
+  store.dispatch(
+    addReply(commentId, newCommentReply(1, author, Date.now(), addReplyOptions))
+  );
+}

+ 3 - 3
client/src/components/Draftail/CommentableEditor/CommentableEditor.tsx

@@ -1,6 +1,6 @@
-import type { CommentApp } from 'wagtail-comment-frontend';
-import type { Annotation } from 'wagtail-comment-frontend/src/utils/annotation';
-import type { Comment } from 'wagtail-comment-frontend/src/state/comments';
+import type { CommentApp } from '../../CommentApp/main';
+import type { Annotation } from '../../CommentApp/utils/annotation';
+import type { Comment } from '../../CommentApp/state/comments';
 import {
   DraftailEditor,
   ToolbarButton,

+ 5 - 8
package-lock.json

@@ -7311,6 +7311,11 @@
       "integrity": "sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw==",
       "dev": true
     },
+    "immer": {
+      "version": "9.0.1",
+      "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.1.tgz",
+      "integrity": "sha512-7CCw1DSgr8kKYXTYOI1qMM/f5qxT5vIVMeGLDCDX8CSxsggr1Sjdoha4OhsP0AZ1UvWbyZlILHvLjaynuu02Mg=="
+    },
     "immutable": {
       "version": "3.7.6",
       "resolved": "https://registry.npmjs.org/immutable/-/immutable-3.7.6.tgz",
@@ -16129,14 +16134,6 @@
         "xml-name-validator": "^3.0.0"
       }
     },
-    "wagtail-comment-frontend": {
-      "version": "0.0.6",
-      "resolved": "https://registry.npmjs.org/wagtail-comment-frontend/-/wagtail-comment-frontend-0.0.6.tgz",
-      "integrity": "sha512-qdkNNK9YtEP/JOZmB2TMCag/Fq4u66OZKRsgs7zGJozbShtT+a+CMtO7lpLjRnmez6+33KCzdkaOJ1dfTXeLsg==",
-      "requires": {
-        "reselect": "^4.0.0"
-      }
-    },
     "walker": {
       "version": "1.0.7",
       "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.7.tgz",

+ 2 - 1
package.json

@@ -95,6 +95,7 @@
     "draftjs-filters": "^2.5.0",
     "element-closest": "^2.0.2",
     "focus-trap-react": "^3.1.0",
+    "immer": "^9.0.1",
     "postcss-calc": "^7.0.5",
     "prop-types": "^15.6.2",
     "react": "^16.14.0",
@@ -103,9 +104,9 @@
     "react-transition-group": "^1.1.3",
     "redux": "^4.0.0",
     "redux-thunk": "^2.3.0",
+    "reselect": "^4.0.0",
     "telepath-unpack": "^0.0.3",
     "uuid": "^8.3.2",
-    "wagtail-comment-frontend": "0.0.6",
     "whatwg-fetch": "^2.0.3"
   },
   "scripts": {

+ 2 - 1
tsconfig.json

@@ -7,7 +7,8 @@
         "noUnusedParameters": true,
         "strictNullChecks": true,
         "esModuleInterop": true,
-        "allowJs": true
+        "allowJs": true,
+        "downlevelIteration": true
     },
     "files": [
         "client/src/index.ts",