diff --git a/app/assets/stylesheets/pageflow/ui/properties.scss b/app/assets/stylesheets/pageflow/ui/properties.scss index 6b32f6fad3..9f791cf364 100644 --- a/app/assets/stylesheets/pageflow/ui/properties.scss +++ b/app/assets/stylesheets/pageflow/ui/properties.scss @@ -42,6 +42,8 @@ --ui-selection-color-lightest: hsla(197, 69%, 76%, 0.1); --ui-warning-color: #ff7400; + --ui-warning-color-lighter: hsla(27, 100%, 50%, 0.3); + --ui-warning-color-lightest: hsla(27, 100%, 50%, 0.1); --ui-error-color: #ff4d6d; --ui-error-color-light: hsla(349, 100%, 65%, 0.6); diff --git a/app/controllers/pageflow/review/comment_threads_controller.rb b/app/controllers/pageflow/review/comment_threads_controller.rb index 59e0d62f2f..ab286c7f4b 100644 --- a/app/controllers/pageflow/review/comment_threads_controller.rb +++ b/app/controllers/pageflow/review/comment_threads_controller.rb @@ -9,7 +9,7 @@ def index entry = DraftEntry.find(params[:entry_id]) authorize!(:read, entry.to_model) - @comment_threads = entry.comment_threads.includes(comments: :creator) + @comment_threads = entry.comment_threads.includes(:resolver, comments: :creator) @read_at_by_perma_id = CommentThreadRead.read_at_by_perma_id(entry: entry.to_model, user: current_user) end diff --git a/app/helpers/pageflow/admin/entries_helper.rb b/app/helpers/pageflow/admin/entries_helper.rb index 9b85aa1851..1689eb0b4a 100644 --- a/app/helpers/pageflow/admin/entries_helper.rb +++ b/app/helpers/pageflow/admin/entries_helper.rb @@ -27,7 +27,7 @@ def entry_comments_indicator(entry, summaries: entry_comment_summaries) class: 'entry_comments_indicator', data: {tooltip: entry_comments_tooltip(summary)}) do safe_join([summary.topic_count.to_s, - (content_tag(:span, '', class: 'unread_dot') if summary.new?)].compact) + (content_tag(:span, '', class: 'unread_dot') if summary.unread?)].compact) end end @@ -44,12 +44,16 @@ def entry_comments_tooltip(summary) parts = [t("#{scope}.topic_count", count: summary.topic_count)] - if summary.new_topic_count.positive? - parts << t("#{scope}.new_topic_count", count: summary.new_topic_count) + if summary.unread_topic_count.positive? + parts << t("#{scope}.unread_topic_count", count: summary.unread_topic_count) end - if summary.new_reply_count.positive? - parts << t("#{scope}.new_reply_count", count: summary.new_reply_count) + if summary.unread_reply_count.positive? + parts << t("#{scope}.unread_reply_count", count: summary.unread_reply_count) + end + + if summary.unread_resolution_count.positive? + parts << t("#{scope}.unread_resolution_count", count: summary.unread_resolution_count) end t("#{scope}.tooltip", summary: parts.join(', ')) diff --git a/app/models/pageflow/comment_thread.rb b/app/models/pageflow/comment_thread.rb index 254cd35e26..f6e5fc0e05 100644 --- a/app/models/pageflow/comment_thread.rb +++ b/app/models/pageflow/comment_thread.rb @@ -7,7 +7,9 @@ class CommentThread < ApplicationRecord belongs_to :creator, class_name: 'User' belongs_to :resolver, class_name: 'User', foreign_key: :resolved_by_id, optional: true - has_many :comments, dependent: :destroy + # Ordered because clients read position as meaning: the first comment + # is the topic, the rest are replies. + has_many :comments, -> { order(:id) }, dependent: :destroy nested_revision_components :comments diff --git a/app/models/pageflow/entry_comment_summary.rb b/app/models/pageflow/entry_comment_summary.rb index 10e1674f85..36a4aec840 100644 --- a/app/models/pageflow/entry_comment_summary.rb +++ b/app/models/pageflow/entry_comment_summary.rb @@ -1,5 +1,5 @@ module Pageflow - # Counts of comment topics and comments the user has not seen, for + # Counts of comment topics and of activity the user has not seen, for # displaying an indicator next to an entry in lists of entries. # # Built for a whole page of entries at once: rendering a list must not @@ -7,13 +7,13 @@ module Pageflow # # @api private class EntryCommentSummary - attr_reader :topic_count, :new_topic_count, :new_reply_count + attr_reader :topic_count, :unread_topic_count, :unread_reply_count, :unread_resolution_count def self.for_entries(entries, user:) entries = entries.to_a return {} if entries.empty? - threads = unresolved_threads_by_entry_id(entries) + threads = threads_by_entry_id(entries) read_at = read_at_by_entry_id(entries, user) entries.to_h do |entry| @@ -23,32 +23,39 @@ def self.for_entries(entries, user:) end end - def initialize(topic_count:, new_topic_count:, new_reply_count:) + def initialize(topic_count:, unread_topic_count:, unread_reply_count:, + unread_resolution_count: 0) @topic_count = topic_count - @new_topic_count = new_topic_count - @new_reply_count = new_reply_count + @unread_topic_count = unread_topic_count + @unread_reply_count = unread_reply_count + @unread_resolution_count = unread_resolution_count end + # Unread activity shows even where no topic is left open: the last + # one being resolved is exactly what the user should not miss. def any? - topic_count.positive? + topic_count.positive? || unread? end - def new? - new_topic_count.positive? || new_reply_count.positive? + def unread? + unread_topic_count.positive? || + unread_reply_count.positive? || + unread_resolution_count.positive? end # Comment threads live on the draft revision, so entries are reached - # through their editable revision rather than directly. - def self.unresolved_threads_by_entry_id(entries) + # through their editable revision rather than directly. Resolved ones + # come along: somebody resolving a thread is activity of its own. + def self.threads_by_entry_id(entries) entry_id_by_revision_id = Revision.editable.where(entry_id: entries.map(&:id)).pluck(:id, :entry_id).to_h CommentThread - .where(revision_id: entry_id_by_revision_id.keys, resolved_at: nil) + .where(revision_id: entry_id_by_revision_id.keys) .includes(:comments) .group_by { |thread| entry_id_by_revision_id[thread.revision_id] } end - private_class_method :unresolved_threads_by_entry_id + private_class_method :threads_by_entry_id def self.read_at_by_entry_id(entries, user) CommentThreadRead @@ -62,26 +69,45 @@ def self.read_at_by_entry_id(entries, user) private_class_method :read_at_by_entry_id def self.build(threads, read_at:, user:) - new_topics = 0 - new_replies = 0 + unread = threads.map { |thread| unread_activity(thread, read_at:, user:) } - threads.each do |thread| - first, *replies = thread.comments.sort_by(&:id) - seen_up_to = [read_at[thread.perma_id], user.unread_comments_since_at].compact.max + new(topic_count: threads.count { |thread| thread.resolved_at.nil? }, + unread_topic_count: unread.count { |kinds| kinds.include?(:topic) }, + unread_reply_count: unread.sum { |kinds| kinds.count(:reply) }, + unread_resolution_count: unread.count { |kinds| kinds.include?(:resolution) }) + end + private_class_method :build + + # What the user has not seen in a thread, as one symbol per event. + # The resolution goes by the thread's read mark like the comments do, + # having none of its own. + def self.unread_activity(thread, read_at:, user:) + seen_up_to = [read_at[thread.perma_id], user.unread_comments_since_at].compact.max + first, *replies = thread.comments.sort_by(&:id) - new_topics += 1 if first && unread?(first, seen_up_to, user) - new_replies += replies.count { |reply| unread?(reply, seen_up_to, user) } + unread_replies = replies.count do |reply| + unread?(reply.creator_id, reply.created_at, seen_up_to, user) end - new(topic_count: threads.size, new_topic_count: new_topics, new_reply_count: new_replies) + kinds = Array.new(unread_replies, :reply) + kinds << :topic if first && unread?(first.creator_id, first.created_at, seen_up_to, user) + kinds << :resolution if unread_resolution?(thread, seen_up_to, user) + kinds end - private_class_method :build + private_class_method :unread_activity + + def self.unread_resolution?(thread, seen_up_to, user) + thread.resolved_at && + unread?(thread.resolved_by_id, thread.resolved_at, seen_up_to, user) + end + private_class_method :unread_resolution? - # Mirrors the unread rule of the review interface: own comments never - # count, and neither do comments from before the user's baseline. - def self.unread?(comment, seen_up_to, user) - comment.creator_id != user.id && - (seen_up_to.nil? || comment.created_at > seen_up_to) + # Mirrors the unread rule of the review interface: the user's own + # activity never counts, and neither does anything from before their + # baseline. Kept in sync with isUnread in + # entry_types/scrolled/package/src/review/unreadActivity.js. + def self.unread?(creator_id, created_at, seen_up_to, user) + creator_id != user.id && (seen_up_to.nil? || created_at > seen_up_to) end private_class_method :unread? end diff --git a/app/views/pageflow/review/comment_threads/_comment_thread.json.jbuilder b/app/views/pageflow/review/comment_threads/_comment_thread.json.jbuilder index e9ef112fb2..6dcf044a30 100644 --- a/app/views/pageflow/review/comment_threads/_comment_thread.json.jbuilder +++ b/app/views/pageflow/review/comment_threads/_comment_thread.json.jbuilder @@ -9,9 +9,12 @@ json.call(comment_thread, :section_perma_id, :creator_id, :resolved_at, + :resolved_by_id, :created_at, :updated_at) +json.resolver_name comment_thread.resolver&.full_name + json.comments(comment_thread.comments) do |comment| json.partial!('pageflow/review/comments/comment', comment:) end diff --git a/config/locales/de.yml b/config/locales/de.yml index 8f75ebff71..34402866e4 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -829,16 +829,19 @@ de: entries: add_folder: Ordner hinzufügen comments: - new_reply_count: - one: 1 neue Antwort - other: "%{count} neue Antworten" - new_topic_count: - one: 1 neues Thema - other: "%{count} neue Themen" tooltip: "Kommentare: %{summary}" topic_count: one: 1 ungelöstes Thema other: "%{count} ungelöste Themen" + unread_reply_count: + one: 1 ungelesene Antwort + other: "%{count} ungelesene Antworten" + unread_resolution_count: + one: 1 neu gelöstes Thema + other: "%{count} neu gelöste Themen" + unread_topic_count: + one: 1 ungelesenes Thema + other: "%{count} ungelesene Themen" confirm_depublish: Soll der Beitrag wirklich depubliziert werden? confirm_duplicate: Beitrag wirklich duplizieren? confirm_restore: Soll der Beitrag wirklich auf den Stand dieser Revision zurückgesetzt werden? Vor dem Zurücksetzen wird eine automatische Sicherung des aktuellen Standes erstellt. diff --git a/config/locales/en.yml b/config/locales/en.yml index 510f61afe9..c2e30093d5 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -828,16 +828,19 @@ en: entries: add_folder: Add folder comments: - new_reply_count: - one: 1 new reply - other: "%{count} new replies" - new_topic_count: - one: 1 new topic - other: "%{count} new topics" tooltip: "Comments: %{summary}" topic_count: one: 1 unresolved topic other: "%{count} unresolved topics" + unread_reply_count: + one: 1 unread reply + other: "%{count} unread replies" + unread_resolution_count: + one: 1 newly resolved topic + other: "%{count} newly resolved topics" + unread_topic_count: + one: 1 unread topic + other: "%{count} unread topics" confirm_depublish: Depublish this story? confirm_duplicate: Duplicate this story? confirm_restore: Restore story to the selected version? A snapshot will be created, so that you can roll back later. diff --git a/entry_types/scrolled/config/locales/de.yml b/entry_types/scrolled/config/locales/de.yml index 314888e115..03af2184c6 100644 --- a/entry_types/scrolled/config/locales/de.yml +++ b/entry_types/scrolled/config/locales/de.yml @@ -1642,11 +1642,16 @@ de: main_menu: comments: Kommentare comments_view: + activity: Letzte Aktivität new_thread: Neues Thema section: Abschnitt tabs: - comments: Alle Kommentare + comments: Alle selection: Für Auswahl + comment_activity_view: + back: Kommentare + tabs: + activity: Letzte Aktivität new_thread_view: back: Kommentare tabs: @@ -1985,8 +1990,8 @@ de: hide_comments: Kommentare ausblenden show_comments: Kommentare einblenden show_comments_with_unread: - one: Kommentare einblenden (1 ungelesener Kommentar) - other: Kommentare einblenden (%{count} ungelesene Kommentare) + one: Kommentare einblenden (1 Thema mit neuer Aktivität) + other: Kommentare einblenden (%{count} Themen mit neuer Aktivität) comment_toolbar: Kommentare filter: label: Kommentare filtern @@ -1998,13 +2003,13 @@ de: zero: Keine Kommentare one: 1 Kommentar other: '%{count} Kommentare' - unread_comment_count: - one: 1 ungelesener Kommentar - other: '%{count} ungelesene Kommentare' - new_reply_count: - one: 1 neu - other: '%{count} neu' - new_replies: Neue Antworten + unread_count: + one: 1 ungelesen + other: '%{count} ungelesen' + unread_reply_count: + one: 1 ungelesen + other: '%{count} ungelesen' + unread_replies: Ungelesene Antworten select_content_element: Zum Kommentieren auswählen select_section: Abschnitt zum Kommentieren auswählen select_text_to_comment: Text zum Kommentieren auswählen @@ -2015,17 +2020,35 @@ de: save: Speichern send: Senden enter_for_new_line: Enter für neue Zeile - toggle_replies: Antworten umschalten comment_actions: Kommentaraktionen edit_comment: Bearbeiten edited: Bearbeitet %{date} resolve: Als gelöst markieren unresolve: Als ungelöst markieren + resolution: Als gelöst markiert + resolution_by: Als gelöst markiert von + thread_actions: Aktionen für Thema resolved_count: one: 1 erledigt other: '%{count} erledigt' reply_count: one: 1 Antwort other: '%{count} Antworten' + earlier_reply_count: + one: 1 weitere Antwort + other: '%{count} weitere Antworten' no_threads_yet: Noch keine Kommentare refers_to_deleted_element: Bezieht sich auf ein gelöschtes Element + activity: + toggle: Neueste Aktivität + summary: + topic: Thema begonnen + reply_count: + one: 1 Antwort + other: '%{count} Antworten' + resolution: als gelöst markiert + and: ' und ' + today: Heute + yesterday: Gestern + no_activity_yet: Noch keine Aktivität + show_more: Mehr anzeigen diff --git a/entry_types/scrolled/config/locales/en.yml b/entry_types/scrolled/config/locales/en.yml index 99c00d6ae1..7c9c5d748c 100644 --- a/entry_types/scrolled/config/locales/en.yml +++ b/entry_types/scrolled/config/locales/en.yml @@ -1624,11 +1624,16 @@ en: main_menu: comments: Comments comments_view: + activity: Latest activity new_thread: New topic section: Section tabs: - comments: All comments + comments: All selection: For selection + comment_activity_view: + back: Comments + tabs: + activity: Latest activity new_thread_view: back: Comments tabs: @@ -1813,8 +1818,8 @@ en: hide_comments: Hide comments show_comments: Show comments show_comments_with_unread: - one: Show comments (1 unread comment) - other: Show comments (%{count} unread comments) + one: Show comments (1 topic with new activity) + other: Show comments (%{count} topics with new activity) comment_toolbar: Comments filter: label: Filter comments @@ -1826,13 +1831,13 @@ en: zero: No comments one: 1 comment other: '%{count} comments' - unread_comment_count: - one: 1 unread comment - other: '%{count} unread comments' - new_reply_count: - one: 1 new - other: '%{count} new' - new_replies: New replies + unread_count: + one: 1 unread + other: '%{count} unread' + unread_reply_count: + one: 1 unread + other: '%{count} unread' + unread_replies: Unread replies select_content_element: Select to comment select_section: Select section to comment select_text_to_comment: Select text to comment @@ -1843,17 +1848,35 @@ en: save: Save send: Send enter_for_new_line: Enter for new line - toggle_replies: Toggle replies comment_actions: Comment actions edit_comment: Edit edited: Edited %{date} resolve: Mark as resolved unresolve: Mark as unresolved + resolution: Marked as resolved + resolution_by: Marked as resolved by + thread_actions: Thread actions resolved_count: one: 1 resolved other: '%{count} resolved' reply_count: one: 1 reply other: '%{count} replies' + earlier_reply_count: + one: 1 more + other: '%{count} more' no_threads_yet: No comments yet refers_to_deleted_element: Refers to a deleted element + activity: + toggle: Latest activity + summary: + topic: topic started + reply_count: + one: 1 reply + other: '%{count} replies' + resolution: marked as resolved + and: ' and ' + today: Today + yesterday: Yesterday + no_activity_yet: No activity yet + show_more: Show more diff --git a/entry_types/scrolled/package/spec/editor/controllers/PreviewMessageController-spec.js b/entry_types/scrolled/package/spec/editor/controllers/PreviewMessageController-spec.js index 1e5300948f..60477dc803 100644 --- a/entry_types/scrolled/package/spec/editor/controllers/PreviewMessageController-spec.js +++ b/entry_types/scrolled/package/spec/editor/controllers/PreviewMessageController-spec.js @@ -663,6 +663,58 @@ describe('PreviewMessageController', () => { })).resolves.toBe('/scrolled/comments?tab=selection'); }); + it('navigates to comments route with tab=selection on SELECTED while on the activity feed', async () => { + Backbone.history.fragment = 'scrolled/comments/activity'; + + const editor = factories.editorApi(); + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({contentElements: [{id: 1}]}) + }); + const iframeWindow = createIframeWindow(); + controller = new PreviewMessageController({entry, iframeWindow, editor}); + + const path = await new Promise(resolve => { + editor.on('navigate', resolve); + window.postMessage({ + type: 'SELECTED', + payload: {id: 1, type: 'contentElementComments'} + }, '*'); + }); + + expect(path).toBe('/scrolled/comments?tab=selection'); + + Backbone.history.fragment = undefined; + }); + + it('does not navigate on SELECTED for a selection made from a message', async () => { + Backbone.history.fragment = 'scrolled/comments/activity'; + + const editor = factories.editorApi(); + const entry = factories.entry(ScrolledEntry, {}, { + entryTypeSeed: normalizeSeed({contentElements: [{id: 1}]}) + }); + const iframeWindow = createIframeWindow(); + controller = new PreviewMessageController({entry, iframeWindow, editor}); + + const navigate = jest.fn(); + editor.on('navigate', navigate); + + await new Promise(resolve => { + entry.once('change:highlightedThreadId', resolve); + window.postMessage({ + type: 'SELECTED', + payload: { + id: 1, type: 'contentElementComments', highlightedThreadId: 7, + source: 'editor' + } + }, '*'); + }); + + expect(navigate).not.toHaveBeenCalled(); + + Backbone.history.fragment = undefined; + }); + it('does not navigate on SELECTED contentElementComments while on the comments route', async () => { Backbone.history.fragment = 'scrolled/comments'; diff --git a/entry_types/scrolled/package/spec/editor/controllers/SideBarController-spec.js b/entry_types/scrolled/package/spec/editor/controllers/SideBarController-spec.js index b2a437ef85..09cbc2004f 100644 --- a/entry_types/scrolled/package/spec/editor/controllers/SideBarController-spec.js +++ b/entry_types/scrolled/package/spec/editor/controllers/SideBarController-spec.js @@ -2,6 +2,7 @@ import 'editor/config'; import {SideBarController} from 'editor/controllers/SideBarController'; import {CommentsView} from 'editor/views/CommentsView'; +import {CommentActivityView} from 'editor/views/CommentActivityView'; import {factories} from 'pageflow/testHelpers'; import {useEditorGlobals} from 'support'; @@ -36,4 +37,19 @@ describe('SideBarController', () => { expect(shown.options.defaultTab).toBe('selection'); }); }); + + describe('#commentActivity', () => { + it('shows a CommentActivityView in the region', () => { + const entry = createEntry({}); + entry.reviewSession = factories.reviewSession(); + + const region = {show: jest.fn()}; + const controller = new SideBarController({region, entry}); + + controller.commentActivity(); + + const shown = region.show.mock.calls[0][0]; + expect(shown).toBeInstanceOf(CommentActivityView); + }); + }); }); diff --git a/entry_types/scrolled/package/spec/editor/views/CommentActivityView-spec.js b/entry_types/scrolled/package/spec/editor/views/CommentActivityView-spec.js new file mode 100644 index 0000000000..08c937edbe --- /dev/null +++ b/entry_types/scrolled/package/spec/editor/views/CommentActivityView-spec.js @@ -0,0 +1,199 @@ +import '@testing-library/jest-dom/extend-expect'; +import {act} from '@testing-library/react'; +import {fireEvent} from '@testing-library/dom'; + +import {editor} from 'pageflow-scrolled/editor'; + +import {CommentActivityView} from 'editor/views/CommentActivityView'; + +import {factories, useFakeTranslations, renderBackboneView} from 'pageflow/testHelpers'; +import {useEditorGlobals} from 'support'; + +describe('CommentActivityView', () => { + const {createEntry} = useEditorGlobals(); + + useFakeTranslations({ + 'pageflow_scrolled.editor.comment_activity_view.back': 'Comments', + 'pageflow_scrolled.editor.comment_activity_view.tabs.activity': 'Latest activity', + 'pageflow_scrolled.review.activity.no_activity_yet': 'No activity yet', + 'pageflow_scrolled.review.activity.today': 'Today', + 'pageflow_scrolled.review.activity.yesterday': 'Yesterday', + 'pageflow_scrolled.review.earlier_reply_count.one': '1 more', + 'pageflow_scrolled.review.earlier_reply_count.other': '%{count} more', + 'pageflow_scrolled.review.reply_count.one': '1 reply', + 'pageflow_scrolled.review.reply_count.other': '%{count} replies', + 'pageflow_scrolled.review.reply_placeholder': 'Reply...', + 'pageflow_scrolled.review.send': 'Send', + 'pageflow_scrolled.review.toggle_replies': 'Toggle replies', + 'pageflow_scrolled.review.resolve': 'Mark as resolved', + 'pageflow_scrolled.review.unresolve': 'Mark as unresolved' + }); + + function comment({id, body, createdAt, creatorName = 'Bob'}) { + return {id, creatorId: 43, creatorName, body, createdAt}; + } + + function rowOf(getByText, body) { + return getByText(body).closest('[aria-current]'); + } + + function isFollowedBy(node, other) { + return !!(node.compareDocumentPosition(other) & Node.DOCUMENT_POSITION_FOLLOWING); + } + + function entryWithThreads(commentThreads) { + const entry = createEntry({ + chapters: [ + {id: 1, permaId: 10, storylineId: 1000, position: 0, configuration: {title: 'Intro'}}, + {id: 2, permaId: 20, storylineId: 1000, position: 1, configuration: {title: 'Middle'}} + ], + sections: [ + {id: 1, permaId: 100, chapterId: 1, position: 0}, + {id: 2, permaId: 200, chapterId: 2, position: 0} + ], + contentElements: [ + {id: 1, permaId: 1000, sectionId: 1, typeName: 'textBlock'}, + {id: 2, permaId: 2000, sectionId: 2, typeName: 'textBlock'} + ] + }); + + entry.reviewSession = factories.reviewSession({commentThreads}); + return entry; + } + + it('lists activity of all chapters newest first', () => { + const entry = entryWithThreads([ + { + id: 1, permaId: 5, subjectType: 'ContentElement', subjectId: 1000, + comments: [comment({ + id: 100, body: 'Older topic', createdAt: '2026-08-17T09:00:00.000Z' + })] + }, + { + id: 2, permaId: 6, subjectType: 'ContentElement', subjectId: 2000, + comments: [comment({ + id: 200, body: 'Newer topic', createdAt: '2026-08-17T11:00:00.000Z' + })] + } + ]); + + const view = new CommentActivityView({entry, editor}); + const {getByText} = renderBackboneView(view); + + expect(isFollowedBy(getByText('Newer topic'), getByText('Older topic'))).toBe(true); + }); + + it('renders a tab label above the list', () => { + const entry = entryWithThreads([]); + + const view = new CommentActivityView({entry, editor}); + const {getByRole} = renderBackboneView(view); + + expect(getByRole('tab', {name: 'Latest activity'})).toBeInTheDocument(); + }); + + it('triggers selectCommentThread on entry when an entry is clicked', () => { + const entry = entryWithThreads([{ + id: 7, permaId: 5, subjectType: 'ContentElement', subjectId: 1000, + comments: [comment({id: 100, body: 'A topic', createdAt: '2026-08-17T09:00:00.000Z'})] + }]); + + const view = new CommentActivityView({entry, editor}); + const {getByText} = renderBackboneView(view); + + const listener = jest.fn(); + entry.on('selectCommentThread', listener); + + fireEvent.click(getByText('A topic')); + + expect(listener).toHaveBeenCalledWith(7); + }); + + it('marks the entry of the highlighted thread as current', () => { + const entry = entryWithThreads([{ + id: 7, permaId: 5, subjectType: 'ContentElement', subjectId: 1000, + comments: [comment({id: 100, body: 'A topic', createdAt: '2026-08-17T09:00:00.000Z'})] + }]); + + const view = new CommentActivityView({entry, editor}); + const {getByText} = renderBackboneView(view); + + act(() => { entry.set('highlightedThreadId', 7); }); + + expect(rowOf(getByText, 'A topic')).not.toBeNull(); + }); + + it('marks the entry of a thread already highlighted when it opens', () => { + const entry = entryWithThreads([{ + id: 7, permaId: 5, subjectType: 'ContentElement', subjectId: 1000, + comments: [comment({id: 100, body: 'A topic', createdAt: '2026-08-17T09:00:00.000Z'})] + }]); + + entry.set('highlightedThreadId', 7); + + const view = new CommentActivityView({entry, editor}); + const {getByText} = renderBackboneView(view); + + expect(rowOf(getByText, 'A topic')).not.toBeNull(); + }); + + it('keeps the clicked entry marked when the preview reports no highlight', () => { + const entry = entryWithThreads([ + { + id: 6, permaId: 5, subjectType: 'ContentElement', subjectId: 1000, + comments: [comment({ + id: 100, body: 'An element topic', creatorName: 'Bob', + createdAt: '2026-08-17T09:00:00.000Z' + })] + }, + { + id: 7, permaId: 6, subjectType: 'Section', subjectId: 200, + comments: [comment({ + id: 200, body: 'A section topic', creatorName: 'Carol', + createdAt: '2026-08-17T11:00:00.000Z' + })] + } + ]); + + const view = new CommentActivityView({entry, editor}); + const {getByText} = renderBackboneView(view); + + act(() => { entry.set('highlightedThreadId', 6); }); + + fireEvent.click(getByText('A section topic')); + + act(() => { + entry.set({ + highlightedThreadId: undefined, + selectedCommentsSubject: {subjectType: 'Section', id: 2} + }); + }); + + expect(rowOf(getByText, 'A section topic')).not.toBeNull(); + expect(rowOf(getByText, 'An element topic')).toBeNull(); + }); + + it('renders a back link', () => { + const entry = entryWithThreads([]); + + const view = new CommentActivityView({entry, editor}); + const {getByText} = renderBackboneView(view); + + expect(getByText('Comments')).toBeInTheDocument(); + }); + + it('navigates to the comments view when the back link is clicked', () => { + const entry = entryWithThreads([]); + + const view = new CommentActivityView({entry, editor}); + const {getByText} = renderBackboneView(view); + + const navigate = jest.spyOn(editor, 'navigate').mockImplementation(() => {}); + + fireEvent.click(getByText('Comments')); + + expect(navigate).toHaveBeenCalledWith('/scrolled/comments', {trigger: true}); + + navigate.mockRestore(); + }); +}); diff --git a/entry_types/scrolled/package/spec/editor/views/CommentsView-spec.js b/entry_types/scrolled/package/spec/editor/views/CommentsView-spec.js index f61bd3e9f0..f4b89e97c7 100644 --- a/entry_types/scrolled/package/spec/editor/views/CommentsView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/CommentsView-spec.js @@ -3,6 +3,7 @@ import '@testing-library/jest-dom/extend-expect'; import {editor} from 'pageflow-scrolled/editor'; import {CommentsView} from 'editor/views/CommentsView'; +import styles from 'editor/views/CommentsView.module.css'; import {factories, useFakeTranslations, renderBackboneView} from 'pageflow/testHelpers'; import {useEditorGlobals} from 'support'; @@ -25,9 +26,10 @@ describe('CommentsView', () => { 'pageflow_scrolled.review.add_comment_placeholder': 'Add a comment...', 'pageflow_scrolled.review.send': 'Send', 'pageflow_scrolled.editor.content_elements.textBlock.name': 'Text', - 'pageflow_scrolled.editor.comments_view.tabs.comments': 'All comments', + 'pageflow_scrolled.editor.comments_view.tabs.comments': 'All', 'pageflow_scrolled.editor.comments_view.tabs.selection': 'For selection', 'pageflow_scrolled.editor.comments_view.new_thread': 'New topic', + 'pageflow_scrolled.editor.comments_view.activity': 'Latest activity', 'pageflow.editor.templates.back_button_decorator.outline': 'Outline' }); @@ -54,7 +56,7 @@ describe('CommentsView', () => { const {getAllByRole} = renderBackboneView(view); const labels = getAllByRole('tab').map(t => t.textContent); - expect(labels).toEqual(['All comments', 'For selection']); + expect(labels).toEqual(['All', 'For selection']); }); it('shows the all-comments tab by default', () => { @@ -193,6 +195,63 @@ describe('CommentsView', () => { }); }); + describe('activity link', () => { + it('navigates to the activity route when clicked', () => { + const entry = setupEntry(); + + const view = new CommentsView({entry, editor}); + const {getByRole} = renderBackboneView(view); + + const navigate = jest.spyOn(editor, 'navigate').mockImplementation(() => {}); + + fireEvent.click(getByRole('button', {name: 'Latest activity'})); + + expect(navigate).toHaveBeenCalledWith( + '/scrolled/comments/activity', {trigger: true} + ); + + navigate.mockRestore(); + }); + + it('points at unseen comments while the entry holds some', () => { + const entry = setupEntry(); + entry.set('hasUnreadComments', true); + + const view = new CommentsView({entry, editor}); + const {getByRole} = renderBackboneView(view); + + expect(getByRole('button', {name: 'Latest activity'})) + .toHaveClass(styles.indicator); + }); + + it('does not point at unseen comments without any', () => { + const entry = setupEntry(); + + const view = new CommentsView({entry, editor}); + const {getByRole} = renderBackboneView(view); + + expect(getByRole('button', {name: 'Latest activity'})) + .not.toHaveClass(styles.indicator); + }); + + it('follows entry.hasUnreadComments while it changes', () => { + const entry = setupEntry(); + + const view = new CommentsView({entry, editor}); + const {getByRole} = renderBackboneView(view); + + act(() => { entry.set('hasUnreadComments', true); }); + + expect(getByRole('button', {name: 'Latest activity'})) + .toHaveClass(styles.indicator); + + act(() => { entry.set('hasUnreadComments', false); }); + + expect(getByRole('button', {name: 'Latest activity'})) + .not.toHaveClass(styles.indicator); + }); + }); + it('does not trigger selectNewThread when clicking the button while disabled', () => { const entry = unselectedEntry(createEntry); diff --git a/entry_types/scrolled/package/spec/editor/views/EntryCommentsView-spec.js b/entry_types/scrolled/package/spec/editor/views/EntryCommentsView-spec.js index 25f149252e..8819388239 100644 --- a/entry_types/scrolled/package/spec/editor/views/EntryCommentsView-spec.js +++ b/entry_types/scrolled/package/spec/editor/views/EntryCommentsView-spec.js @@ -10,6 +10,7 @@ import styles from 'editor/views/EntryCommentsView.module.css'; import {factories, useFakeTranslations, renderBackboneView} from 'pageflow/testHelpers'; import {useEditorGlobals} from 'support'; +import {simulateScrollingIntoView} from 'support/fakeIntersectionObserver'; describe('EntryCommentsView', () => { const {createEntry} = useEditorGlobals(); @@ -30,7 +31,92 @@ describe('EntryCommentsView', () => { 'pageflow_scrolled.editor.comments_view.section': 'Section', 'pageflow_scrolled.editor.chapter_item.chapter': 'Chapter', 'pageflow_scrolled.editor.chapter_item.excursion': 'Excursion', - 'pageflow_scrolled.review.refers_to_deleted_element': 'Refers to a deleted element' + 'pageflow_scrolled.review.refers_to_deleted_element': 'Refers to a deleted element', + 'pageflow_scrolled.review.reply_count.one': '1 reply', + 'pageflow_scrolled.review.reply_count.other': '%{count} replies' + }); + + describe('marking read', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + function renderList({highlightedThreadId} = {}) { + const entry = createEntry({ + chapters: [ + {id: 1, permaId: 10, storylineId: 1000, position: 0, configuration: {title: 'Intro'}} + ], + sections: [{id: 1, permaId: 100, chapterId: 1, position: 0}], + contentElements: [{id: 1, permaId: 1000, sectionId: 1, typeName: 'image'}] + }); + entry.reviewSession = factories.reviewSession({ + currentUser: {id: 42, name: 'Alice'}, + commentThreads: [{ + id: 1, permaId: 5, subjectType: 'ContentElement', subjectId: 1000, + comments: [{id: 100, body: 'A comment', creatorId: 43, creatorName: 'Bob', + createdAt: '2026-08-17T11:00:00.000Z'}] + }] + }); + entry.set('highlightedThreadId', highlightedThreadId); + + const postMessage = jest.spyOn(window.top, 'postMessage').mockImplementation(() => {}); + const view = new EntryCommentsView({entry, editor}); + + return {...renderBackboneView(view), element: view.el, postMessage}; + } + + function markReadMessages(postMessage) { + return postMessage.mock.calls.filter(([message]) => message.type === 'MARK_THREADS_READ'); + } + + it('leaves a thread the reviewer only scrolled past unread', () => { + const {element, postMessage} = renderList(); + + simulateScrollingIntoView(element); + act(() => jest.advanceTimersByTime(1000)); + + expect(markReadMessages(postMessage)).toEqual([]); + }); + + it('marks a highlighted thread read', () => { + const {element, postMessage} = renderList({highlightedThreadId: 1}); + + simulateScrollingIntoView(element); + act(() => jest.advanceTimersByTime(1000)); + + expect(markReadMessages(postMessage)).toHaveLength(1); + }); + }); + + it('keeps replies of a lone thread collapsed', () => { + const entry = createEntry({ + chapters: [ + {id: 1, permaId: 10, storylineId: 1000, position: 0, configuration: {title: 'Intro'}} + ], + sections: [{id: 1, permaId: 100, chapterId: 1, position: 0}], + contentElements: [{id: 1, permaId: 1000, sectionId: 1, typeName: 'image'}] + }); + entry.reviewSession = factories.reviewSession({ + commentThreads: [{ + id: 1, subjectType: 'ContentElement', subjectId: 1000, + comments: [ + {id: 100, body: 'A comment', creatorName: 'Alice'}, + {id: 101, body: 'A reply', creatorName: 'Bob'} + ] + }] + }); + + const view = new EntryCommentsView({entry, editor}); + const {getByText, queryByText} = renderBackboneView(view); + + expect(getByText('A comment')).toBeInTheDocument(); + expect(getByText('1 reply')).toBeInTheDocument(); + expect(queryByText('A reply')).not.toBeInTheDocument(); }); it('renders a chapter heading with number and title above its groups', () => { @@ -308,6 +394,28 @@ describe('EntryCommentsView', () => { expect(getByText('on other').closest('[aria-current="true"]')).toBeNull(); }); + it('keeps resolved threads folded away when an element is selected', () => { + const entry = createEntry({ + contentElements: [{id: 1, permaId: 10, typeName: 'image'}] + }); + entry.set('selectedCommentsSubject', {subjectType: 'ContentElement', id: 1}); + entry.reviewSession = factories.reviewSession({ + commentThreads: [ + {id: 1, subjectType: 'ContentElement', subjectId: 10, + comments: [{id: 10, body: 'still open', creatorName: 'A'}]}, + {id: 2, subjectType: 'ContentElement', subjectId: 10, + resolvedAt: '2026-08-17T10:00:00.000Z', + comments: [{id: 20, body: 'already resolved', creatorName: 'B'}]} + ] + }); + + const view = new EntryCommentsView({entry, editor}); + const {getByText, queryByText} = renderBackboneView(view); + + expect(getByText('still open')).toBeInTheDocument(); + expect(queryByText('already resolved')).not.toBeInTheDocument(); + }); + it("falls back to highlightedThreadId when the selected element has commentThreadIdsAtSelection", () => { const entry = createEntry({ contentElements: [{id: 1, permaId: 10, typeName: 'textBlock'}] diff --git a/entry_types/scrolled/package/spec/frontend/commenting/features/commentActivity-spec.js b/entry_types/scrolled/package/spec/frontend/commenting/features/commentActivity-spec.js new file mode 100644 index 0000000000..102ffae934 --- /dev/null +++ b/entry_types/scrolled/package/spec/frontend/commenting/features/commentActivity-spec.js @@ -0,0 +1,195 @@ +import '@testing-library/jest-dom/extend-expect'; +import {fireEvent} from '@testing-library/react'; + +import {renderEntry, useCommentingPageObjects} from 'support/pageObjects/commenting'; +import activityStyles from 'frontend/commenting/ActivityButton.module.css'; +import badgeStyles from 'review/Badge.module.css'; + +describe('comment activity', () => { + useCommentingPageObjects(); + + beforeEach(() => { + window.HTMLElement.prototype.scrollIntoView = jest.fn(); + }); + + const currentUser = {id: 42, name: 'Alice'}; + + function renderEntryWithThreads(commentThreads) { + return renderEntry({ + seed: { + contentElements: [ + {typeName: 'withTestId', configuration: {testId: 5}}, + {typeName: 'withTestId', configuration: {testId: 6}} + ] + }, + commenting: {currentUser, commentThreads} + }); + } + + function renderEntryWithTwoThreads() { + return renderEntryWithThreads([ + {id: 1, permaId: 5, subjectType: 'ContentElement', subjectId: 1, + comments: [{id: 10, body: 'First topic', creatorName: 'Bob', creatorId: 2, + createdAt: '2026-08-17T09:00:00.000Z'}]}, + {id: 2, permaId: 6, subjectType: 'ContentElement', subjectId: 2, + comments: [{id: 11, body: 'Second topic', creatorName: 'Bob', creatorId: 2, + createdAt: '2026-08-17T11:00:00.000Z'}]} + ]); + } + + it('opens the feed from the toolbar', () => { + const entry = renderEntryWithTwoThreads(); + + expect(entry.queryActivityPanel()).toBeNull(); + + fireEvent.click(entry.getActivityButton()); + + expect(entry.getActivityPanel()).toBeInTheDocument(); + }); + + it('renders the feed above the navigation widgets', () => { + const entry = renderEntryWithTwoThreads(); + + fireEvent.click(entry.getActivityButton()); + + expect(document.getElementById('floating-ui-above-navigation-widgets')) + .toContainElement(entry.getActivityPanel()); + }); + + it('lists the activity of every subject newest first', () => { + const entry = renderEntryWithTwoThreads(); + + fireEvent.click(entry.getActivityButton()); + + const panel = entry.getActivityPanel(); + + expect(panel).toHaveTextContent('First topic'); + expect(panel).toHaveTextContent('Second topic'); + }); + + it('closes an open thread popover when the feed opens', () => { + const entry = renderEntryWithTwoThreads(); + + fireEvent.click(entry.getAllCommentBadges()[0]); + expect(entry.getAllByText('First topic')).toHaveLength(1); + + fireEvent.click(entry.getActivityButton()); + + expect(entry.getAllByText('First topic')).toHaveLength(1); + expect(entry.getActivityPanel()).toHaveTextContent('First topic'); + }); + + it('closes the feed again from the toolbar', () => { + const entry = renderEntryWithTwoThreads(); + + fireEvent.click(entry.getActivityButton()); + fireEvent.click(entry.getActivityButton()); + + expect(entry.queryActivityPanel()).toBeNull(); + }); + + it('closes the feed on a click outside', () => { + const entry = renderEntryWithTwoThreads(); + + fireEvent.click(entry.getActivityButton()); + fireEvent.mouseDown(document.body); + + expect(entry.queryActivityPanel()).toBeNull(); + }); + + it('keeps the feed open while it is being used', () => { + const entry = renderEntryWithTwoThreads(); + + fireEvent.click(entry.getActivityButton()); + fireEvent.mouseDown(entry.getActivityPanel()); + + expect(entry.getActivityPanel()).toBeInTheDocument(); + }); + + it('closes the feed on escape', () => { + const entry = renderEntryWithTwoThreads(); + + fireEvent.click(entry.getActivityButton()); + fireEvent.keyDown(document, {key: 'Escape'}); + + expect(entry.queryActivityPanel()).toBeNull(); + }); + + it('reveals the subject of a clicked entry without leaving the feed', () => { + const entry = renderEntryWithTwoThreads(); + + fireEvent.click(entry.getActivityButton()); + fireEvent.click(entry.getByText('First topic')); + + expect(entry.getActivityPanel()).toBeInTheDocument(); + expect(window.HTMLElement.prototype.scrollIntoView).toHaveBeenCalled(); + }); + + it('highlights the entry that was clicked', () => { + const entry = renderEntryWithTwoThreads(); + + fireEvent.click(entry.getActivityButton()); + fireEvent.click(entry.getByText('First topic')); + + expect(entry.getByText('First topic').closest('[aria-current="true"]')) + .not.toBeNull(); + }); + + // The row shows the thread already, so a popover would only say it + // twice and cover the content just revealed. + it('opens no popover for a clicked entry', () => { + const entry = renderEntryWithTwoThreads(); + + fireEvent.click(entry.getActivityButton()); + fireEvent.click(entry.getByText('First topic')); + + expect(entry.getAllByText('First topic')).toHaveLength(1); + }); + + it('opens the popover from the badge of a revealed subject', () => { + const entry = renderEntryWithTwoThreads(); + + fireEvent.click(entry.getActivityButton()); + fireEvent.click(entry.getByText('First topic')); + + fireEvent.click(entry.getAllCommentBadges()[0]); + + expect(entry.getAllByText('First topic')).toHaveLength(2); + }); + + it('reveals a resolved thread without turning all of them on', () => { + const entry = renderEntryWithThreads([ + {id: 1, permaId: 5, subjectType: 'ContentElement', subjectId: 1, + resolvedAt: '2026-08-17T12:00:00.000Z', resolvedById: 2, resolverName: 'Bob', + comments: [{id: 10, body: 'Resolved topic', creatorName: 'Bob', creatorId: 2, + createdAt: '2026-08-17T09:00:00.000Z'}]} + ]); + + fireEvent.click(entry.getActivityButton()); + fireEvent.click(entry.getByText('Resolved topic')); + + expect(entry.getCommentFilterButton('unresolved')) + .toHaveAttribute('aria-pressed', 'true'); + expect(entry.getByText('Resolved topic')).toBeInTheDocument(); + expect(entry.getAllCommentBadges()[0]).toHaveClass(badgeStyles.resolved); + }); + + it('marks the button while activity is unseen', () => { + const entry = renderEntryWithTwoThreads(); + + expect(entry.getActivityButton().querySelector(`.${activityStyles.unseenDot}`)) + .not.toBeNull(); + }); + + it('leaves the button unmarked once everything has been seen', () => { + const entry = renderEntryWithThreads([ + {id: 1, permaId: 5, subjectType: 'ContentElement', subjectId: 1, + comments: [{id: 10, body: 'My own topic', creatorName: 'Alice', + creatorId: currentUser.id, + createdAt: '2026-08-17T09:00:00.000Z'}]} + ]); + + expect(entry.getActivityButton().querySelector(`.${activityStyles.unseenDot}`)) + .toBeNull(); + }); +}); diff --git a/entry_types/scrolled/package/spec/frontend/commenting/features/unreadBadges-spec.js b/entry_types/scrolled/package/spec/frontend/commenting/features/unreadBadges-spec.js index 772afc1217..fd42691d4f 100644 --- a/entry_types/scrolled/package/spec/frontend/commenting/features/unreadBadges-spec.js +++ b/entry_types/scrolled/package/spec/frontend/commenting/features/unreadBadges-spec.js @@ -11,8 +11,8 @@ describe('unread badges', () => { useFakeTranslations({ 'pageflow_scrolled.review.reply_placeholder': 'Reply...', - 'pageflow_scrolled.review.unread_comment_count.one': '1 unread comment', - 'pageflow_scrolled.review.unread_comment_count.other': '%{count} unread comments' + 'pageflow_scrolled.review.unread_count.one': '1 unread', + 'pageflow_scrolled.review.unread_count.other': '%{count} unread' }); function renderEntryWithUnreadThread() { @@ -51,23 +51,13 @@ describe('unread badges', () => { expect(entry.queryAllUnreadCommentBadges()).toHaveLength(1); }); - it('keeps badge marked while the thread list is open', async () => { + it('clears badge once its threads have been read', async () => { const entry = renderEntryWithUnreadThread(); fireEvent.click(entry.getAllCommentBadges()[0]); await markThreadRead(); expect(entry.getByText('Nice work')).toBeInTheDocument(); - expect(entry.queryAllUnreadCommentBadges()).toHaveLength(1); - }); - - it('clears badge once the thread list is closed', async () => { - const entry = renderEntryWithUnreadThread(); - - fireEvent.click(entry.getAllCommentBadges()[0]); - await markThreadRead(); - fireEvent.click(entry.getAllCommentBadges()[0]); - expect(entry.queryAllUnreadCommentBadges()).toEqual([]); }); }); diff --git a/entry_types/scrolled/package/spec/frontend/commenting/features/unreadToolbar-spec.js b/entry_types/scrolled/package/spec/frontend/commenting/features/unreadToolbar-spec.js index 3d120d752d..1b7451d5bd 100644 --- a/entry_types/scrolled/package/spec/frontend/commenting/features/unreadToolbar-spec.js +++ b/entry_types/scrolled/package/spec/frontend/commenting/features/unreadToolbar-spec.js @@ -12,20 +12,21 @@ describe('unread comments on the collapsed toolbar', () => { useFakeTranslations({ 'pageflow_scrolled.review.show_comments': 'Show comments', 'pageflow_scrolled.review.show_comments_with_unread.one': - 'Show comments (1 unread comment)', + 'Show comments (1 topic with new activity)', 'pageflow_scrolled.review.show_comments_with_unread.other': - 'Show comments (%{count} unread comments)' + 'Show comments (%{count} topics with new activity)' }); const currentUser = {id: 42, name: 'Alice'}; - function renderCollapsedEntry({comments}) { + function renderCollapsedEntry({comments, ...threadAttributes}) { const entry = renderEntry({ seed: {contentElements: [{typeName: 'withTestId', configuration: {testId: 5}}]}, commenting: { currentUser, commentThreads: [ - {id: 1, permaId: 7, subjectType: 'ContentElement', subjectId: 1, comments} + {id: 1, permaId: 7, subjectType: 'ContentElement', subjectId: 1, comments, + ...threadAttributes} ], commentThreadReads: {} } @@ -54,13 +55,26 @@ describe('unread comments on the collapsed toolbar', () => { expect(unreadDot(entry)).not.toBeNull(); }); - it('names the unseen comments on the show button', () => { + // Counted the way the activity feed counts, so that the toolbar's two + // markers cannot disagree. + it('counts the topics rather than their comments on the show button', () => { const entry = renderCollapsedEntry({ comments: [comment(), comment({id: 11, creatorId: 44})] }); expect(entry.getShowCommentsButton()) - .toHaveAttribute('aria-label', 'Show comments (2 unread comments)'); + .toHaveAttribute('aria-label', 'Show comments (1 topic with new activity)'); + }); + + it('marks the show button for a resolution without new comments', () => { + const entry = renderCollapsedEntry({ + comments: [comment({creatorId: currentUser.id, creatorName: 'Alice'})], + resolvedAt: '2026-08-17T12:00:00.000Z', + resolvedById: 43, + resolverName: 'Bob' + }); + + expect(unreadDot(entry)).not.toBeNull(); }); it('leaves the show button unmarked without unseen comments', () => { diff --git a/entry_types/scrolled/package/spec/frontend/inlineEditing/EditableText/features/commentBadges-spec.js b/entry_types/scrolled/package/spec/frontend/inlineEditing/EditableText/features/commentBadges-spec.js index 8c004e8392..695db8c9db 100644 --- a/entry_types/scrolled/package/spec/frontend/inlineEditing/EditableText/features/commentBadges-spec.js +++ b/entry_types/scrolled/package/spec/frontend/inlineEditing/EditableText/features/commentBadges-spec.js @@ -45,6 +45,44 @@ describe('inline editing EditableText comment badges', () => { expect(badges[0].isInDotMode()).toBe(true); }); + function renderEntryWithUnreadThread() { + const value = [{type: 'paragraph', children: [{text: 'Some text to comment on'}]}]; + + return renderEntry({ + contentElement: { + ui: , + typeOptions: {inlineComments: true, customSelectionRect: true} + }, + commenting: { + currentUser: {id: 42, name: 'Alice'}, + commentThreads: [{ + id: 5, + permaId: 5, + subjectType: 'ContentElement', + subjectId: 10, + subjectRange: {anchor: {path: [0, 0], offset: 5}, focus: {path: [0, 0], offset: 9}}, + comments: [{ + id: 1, body: 'A comment', creatorName: 'Bob', creatorId: 43, + createdAt: '2026-08-17T11:00:00.000Z' + }] + }], + commentThreadReads: {} + } + }); + } + + it('marks a badge whose thread has unseen comments', () => { + const entry = renderEntryWithUnreadThread(); + + expect(entry.queryAllCommentBadges()[0].isUnread()).toBe(true); + }); + + it('keeps a badge with unseen comments out of dot mode', () => { + const entry = renderEntryWithUnreadThread(); + + expect(entry.queryAllCommentBadges()[0].isInDotMode()).toBe(false); + }); + it('renders only the highlighted thread badge in active mode', () => { const value = [ {type: 'paragraph', children: [{text: 'First paragraph thread here'}]}, @@ -116,6 +154,54 @@ describe('inline editing EditableText comment badges', () => { expect(badge.isActive()).toBe(true); }); + it('keeps the badge of an overlapped thread after a resolved thread is revealed', async () => { + const value = [{type: 'paragraph', children: [{text: 'Alpha beta gamma delta'}]}]; + + function range(start, end) { + return {anchor: {path: [0, 0], offset: start}, focus: {path: [0, 0], offset: end}}; + } + + function selectThread(threadId) { + act(() => { + window.dispatchEvent(new MessageEvent('message', { + data: {type: 'SELECT_COMMENT_THREAD', payload: {threadId}}, + origin: window.location.origin + })); + }); + } + + const entry = renderEntry({ + contentElement: { + ui: , + typeOptions: {inlineComments: true, customSelectionRect: true} + }, + commenting: { + currentUser: null, + commentThreads: [ + {id: 5, subjectType: 'ContentElement', subjectId: 10, subjectRange: range(0, 5), + comments: [{id: 1, body: 'a', creatorName: 'Alice', creatorId: 1}]}, + {id: 6, subjectType: 'ContentElement', subjectId: 10, subjectRange: range(6, 16), + comments: [{id: 2, body: 'b', creatorName: 'Bob', creatorId: 2}]}, + {id: 7, subjectType: 'ContentElement', subjectId: 10, subjectRange: range(17, 22), + comments: [{id: 3, body: 'c', creatorName: 'Carol', creatorId: 3}]}, + {id: 8, subjectType: 'ContentElement', subjectId: 10, subjectRange: range(8, 12), + resolvedAt: '2026-06-01T00:00:00Z', + comments: [{id: 4, body: 'r', creatorName: 'Dave', creatorId: 4}]} + ] + } + }); + + expect(entry.queryAllCommentBadges()).toHaveLength(3); + + selectThread(8); + await waitFor(() => expect(entry.queryAllCommentBadges()).toHaveLength(4)); + + selectThread(5); + await waitFor(() => expect(entry.queryAllCommentBadges()[0].isActive()).toBe(true)); + + expect(entry.queryAllCommentBadges()).toHaveLength(3); + }); + it('renders sibling badge in regular mode when in same block as highlighted thread', () => { const value = [ {type: 'paragraph', children: [{text: 'First paragraph with two threads'}]}, diff --git a/entry_types/scrolled/package/spec/frontend/inlineEditing/features/contentElementCommentBadges-spec.js b/entry_types/scrolled/package/spec/frontend/inlineEditing/features/contentElementCommentBadges-spec.js index 6f38ba2493..0fd3284e17 100644 --- a/entry_types/scrolled/package/spec/frontend/inlineEditing/features/contentElementCommentBadges-spec.js +++ b/entry_types/scrolled/package/spec/frontend/inlineEditing/features/contentElementCommentBadges-spec.js @@ -129,7 +129,7 @@ describe('inline editing content element comment badges', () => { expect(window.parent.postMessage).toHaveBeenCalledWith({ type: 'SELECTED', - payload: {type: 'contentElementComments', id: 1, highlightedThreadId: 7} + payload: {type: 'contentElementComments', id: 1, highlightedThreadId: 7, source: 'editor'} }, expect.anything()); expect(scrollIntoView).toHaveBeenCalled(); @@ -182,7 +182,7 @@ describe('inline editing content element comment badges', () => { expect(window.parent.postMessage).toHaveBeenCalledWith({ type: 'SELECTED', - payload: {type: 'contentElementComments', id: 1, highlightedThreadId: 7} + payload: {type: 'contentElementComments', id: 1, highlightedThreadId: 7, source: 'editor'} }, expect.anything()); expect(scrollIntoView).toHaveBeenCalled(); diff --git a/entry_types/scrolled/package/spec/frontend/inlineEditing/features/sectionCommentBadges-spec.js b/entry_types/scrolled/package/spec/frontend/inlineEditing/features/sectionCommentBadges-spec.js index 9efdfb7a02..4025a6d2d9 100644 --- a/entry_types/scrolled/package/spec/frontend/inlineEditing/features/sectionCommentBadges-spec.js +++ b/entry_types/scrolled/package/spec/frontend/inlineEditing/features/sectionCommentBadges-spec.js @@ -244,7 +244,7 @@ describe('inline editing section comment badges', () => { expect(section.scrollIntoView).toHaveBeenCalled(); expect(window.parent.postMessage).toHaveBeenCalledWith({ type: 'SELECTED', - payload: {type: 'sectionComments', id: 1, highlightedThreadId: 1} + payload: {type: 'sectionComments', id: 1, highlightedThreadId: 1, source: 'editor'} }, expect.anything()); }); @@ -289,7 +289,7 @@ describe('inline editing section comment badges', () => { expect(section.scrollIntoView).toHaveBeenCalled(); expect(window.parent.postMessage).toHaveBeenCalledWith({ type: 'SELECTED', - payload: {type: 'sectionComments', id: 1, highlightedThreadId: 3} + payload: {type: 'sectionComments', id: 1, highlightedThreadId: 3, source: 'editor'} }, expect.anything()); }); }); diff --git a/entry_types/scrolled/package/spec/review/ActivityList-spec.js b/entry_types/scrolled/package/spec/review/ActivityList-spec.js new file mode 100644 index 0000000000..7d87c9b4b1 --- /dev/null +++ b/entry_types/scrolled/package/spec/review/ActivityList-spec.js @@ -0,0 +1,611 @@ +import React from 'react'; +import '@testing-library/jest-dom/extend-expect'; +import userEvent from '@testing-library/user-event'; +import I18n from 'i18n-js'; +import {useFakeTranslations} from 'pageflow/testHelpers'; + +import {act} from '@testing-library/react'; + +import {ActivityList} from 'review/ActivityList'; +import styles from 'review/ActivityList.module.css'; +import {renderWithReviewState} from 'support/renderWithReviewState'; +import {simulateScrollingIntoView} from 'support/fakeIntersectionObserver'; + +// ActivityList resolves its entries from the located threads, so the +// subjects the threads hang off have to exist in the entry structure. +const seed = { + sections: [{id: 1, permaId: 100}], + contentElements: [{id: 1, permaId: 10, sectionId: 1, typeName: 'textBlock'}] +}; + +const currentUser = {id: 42, name: 'Alice'}; + +function renderActivityList(ui, options = {}) { + return renderWithReviewState(ui, {seed, currentUser, ...options}); +} + +function thread({id = 1, comments = [], ...rest}) { + return { + id, + permaId: id + 4, + subjectType: 'ContentElement', + subjectId: 10, + comments, + ...rest + }; +} + +function comment({id = 100, creatorId = 43, creatorName = 'Bob', body = 'A comment', createdAt, ...rest}) { + return {id, creatorId, creatorName, body, createdAt, ...rest}; +} + +describe('ActivityList', () => { + useFakeTranslations({ + 'pageflow_scrolled.review.activity.no_activity_yet': 'No activity yet', + 'pageflow_scrolled.review.activity.show_more': 'Show more', + 'pageflow_scrolled.review.activity.summary.topic': 'topic started', + 'pageflow_scrolled.review.activity.summary.reply_count.one': '1 reply', + 'pageflow_scrolled.review.activity.summary.reply_count.other': '%{count} replies', + 'pageflow_scrolled.review.activity.summary.resolution': 'marked as resolved', + 'pageflow_scrolled.review.activity.summary.and': ' and ', + 'pageflow_scrolled.review.activity.today': 'Today', + 'pageflow_scrolled.review.activity.yesterday': 'Yesterday', + 'pageflow_scrolled.review.refers_to_deleted_element': 'Refers to a deleted element', + 'pageflow_scrolled.review.earlier_reply_count.one': '1 more', + 'pageflow_scrolled.review.earlier_reply_count.other': '%{count} more', + 'pageflow_scrolled.review.reply_count.one': '1 reply', + 'pageflow_scrolled.review.reply_count.other': '%{count} replies', + 'pageflow_scrolled.review.reply_placeholder': 'Reply...', + 'pageflow_scrolled.review.send': 'Send', + 'pageflow_scrolled.review.toggle_replies': 'Toggle replies', + 'pageflow_scrolled.review.unread_count.one': '1 unread', + 'pageflow_scrolled.review.unread_count.other': '%{count} unread', + 'pageflow_scrolled.review.resolution_by': 'Marked as resolved by', + 'pageflow_scrolled.review.resolve': 'Mark as resolved', + 'pageflow_scrolled.review.unresolve': 'Mark as unresolved' + }); + + it('shows who resolved a thread', () => { + const {getByText} = renderActivityList(, { + commentThreads: [thread({ + comments: [comment({body: 'A topic', createdAt: '2026-08-17T09:00:00.000Z'})], + resolvedAt: '2026-08-17T10:00:00.000Z', + resolvedById: 44, + resolverName: 'Carol' + })] + }); + + expect(getByText('Marked as resolved by')).toBeInTheDocument(); + expect(getByText('Carol')).toBeInTheDocument(); + }); + + it('lists threads with the most recent activity first', () => { + const {getAllByText} = renderActivityList(, { + commentThreads: [ + thread({ + id: 1, + comments: [comment({ + id: 100, creatorName: 'Bob', body: 'Older topic', + createdAt: '2026-08-17T09:00:00.000Z' + })] + }), + thread({ + id: 2, + comments: [comment({ + id: 200, creatorName: 'Carol', body: 'Newer topic', + createdAt: '2026-08-17T11:00:00.000Z' + })] + }) + ] + }); + + expect(getAllByText(/^(Newer|Older) topic$/).map(node => node.textContent)) + .toEqual(['Newer topic', 'Older topic']); + }); + + it('renders a blank slate without any activity', () => { + const {getByText} = renderActivityList(); + + expect(getByText('No activity yet')).toBeInTheDocument(); + }); + + it('leaves pointing out a deleted element to the thread', () => { + const {getByText} = renderActivityList(, { + commentThreads: [thread({ + subjectId: 999, + sectionPermaId: 100, + comments: [comment({body: 'A topic', createdAt: '2026-08-17T09:00:00.000Z'})] + })] + }); + + expect(getByText('Refers to a deleted element')).toBeInTheDocument(); + }); + + describe('summary', () => { + function summaryOf(container) { + return container.querySelector(`.${styles.summary}`); + } + + // Everything counts as new without a read mark, so the tests about + // the plain wording have to say the thread was read. + const seen = {5: '2026-08-18T12:00:00.000Z'}; + + it('names a topic started on the day it is listed under', () => { + const {container} = renderActivityList(, { + commentThreads: [thread({ + comments: [comment({body: 'A topic', createdAt: '2026-08-17T09:00:00.000Z'})] + })], + commentThreadReads: seen + }); + + expect(summaryOf(container)).toHaveTextContent('topic started'); + }); + + it('counts the replies of that day', () => { + const {container} = renderActivityList(, { + commentThreads: [thread({ + comments: [ + comment({id: 100, body: 'A topic', createdAt: '2026-08-16T12:00:00.000Z'}), + comment({id: 101, body: 'First', createdAt: '2026-08-17T12:00:00.000Z'}), + comment({id: 102, body: 'Second', createdAt: '2026-08-17T13:00:00.000Z'}) + ] + })], + commentThreadReads: seen + }); + + expect(summaryOf(container)).toHaveTextContent('2 replies'); + }); + + it('joins what happened on that day', () => { + const {container} = renderActivityList(, { + commentThreads: [thread({ + comments: [ + comment({id: 100, body: 'A topic', createdAt: '2026-08-17T09:00:00.000Z'}), + comment({id: 101, body: 'A reply', createdAt: '2026-08-17T12:00:00.000Z'}) + ], + resolvedAt: '2026-08-17T13:00:00.000Z', + resolvedById: 44, + resolverName: 'Carol' + })], + commentThreadReads: seen + }); + + expect(summaryOf(container)) + .toHaveTextContent('topic started, 1 reply and marked as resolved'); + }); + + it('counts a day\'s replies whether or not they have been read', () => { + const {container} = renderActivityList(, { + commentThreads: [thread({ + permaId: 7, + comments: [ + comment({id: 100, body: 'A topic', createdAt: '2026-08-16T12:00:00.000Z'}), + comment({id: 101, body: 'First', createdAt: '2026-08-17T12:00:00.000Z'}), + comment({id: 102, body: 'Second', createdAt: '2026-08-17T13:00:00.000Z'}) + ] + })], + commentThreadReads: {7: '2026-08-17T12:30:00.000Z'} + }); + + expect(summaryOf(container).textContent).toEqual('2 replies'); + }); + + it('leaves out what happened on other days', () => { + const {container} = renderActivityList(, { + commentThreads: [thread({ + comments: [ + comment({id: 100, body: 'A topic', createdAt: '2026-08-16T12:00:00.000Z'}), + comment({id: 101, body: 'A reply', createdAt: '2026-08-17T12:00:00.000Z'}) + ] + })], + commentThreadReads: seen + }); + + expect(summaryOf(container).textContent).toEqual('1 reply'); + }); + }); + + describe('embedded thread', () => { + const threadWithReplies = thread({ + comments: [ + comment({id: 100, body: 'A topic', createdAt: '2026-08-17T09:00:00.000Z'}), + comment({id: 101, body: 'First reply', createdAt: '2026-08-17T10:00:00.000Z'}), + comment({id: 102, body: 'Second reply', createdAt: '2026-08-17T12:00:00.000Z'}) + ] + }); + + // Unseen comments show whatever day they are from, so the tests about + // folding have to say the thread was read. + const seenAll = {5: '2026-08-18T12:00:00.000Z'}; + + const threadAcrossDays = thread({ + comments: [ + comment({id: 100, body: 'A topic', createdAt: '2026-08-16T12:00:00.000Z'}), + comment({id: 101, body: 'First reply', createdAt: '2026-08-16T13:00:00.000Z'}), + comment({id: 102, body: 'Second reply', createdAt: '2026-08-17T12:00:00.000Z'}) + ] + }); + + it('shows every reply made on the day it is listed under', () => { + const {getByText, queryByText} = renderActivityList(, { + commentThreads: [threadWithReplies] + }); + + expect(getByText('A topic')).toBeInTheDocument(); + expect(getByText('First reply')).toBeInTheDocument(); + expect(getByText('Second reply')).toBeInTheDocument(); + expect(queryByText('1 more')).toBeNull(); + }); + + it('folds away replies from earlier days', () => { + const {getByText, queryByText} = renderActivityList(, { + commentThreads: [threadAcrossDays], + commentThreadReads: seenAll + }); + + expect(getByText('A topic')).toBeInTheDocument(); + expect(getByText('Second reply')).toBeInTheDocument(); + expect(getByText('1 more')).toBeInTheDocument(); + expect(queryByText('First reply')).toBeNull(); + }); + + it('shows the latest reply when the day contributed none', () => { + const {getByText} = renderActivityList(, { + commentThreads: [thread({ + comments: [ + comment({id: 100, body: 'A topic', createdAt: '2026-08-16T12:00:00.000Z'}), + comment({id: 101, body: 'Only reply', createdAt: '2026-08-16T13:00:00.000Z'}) + ], + resolvedAt: '2026-08-17T12:00:00.000Z', + resolvedById: 44, + resolverName: 'Carol' + })] + }); + + expect(getByText('Only reply')).toBeInTheDocument(); + }); + + it('expands the discussion in place', async () => { + const user = userEvent.setup(); + + const {getByRole, getByText} = renderActivityList(, { + commentThreads: [threadAcrossDays], + commentThreadReads: seenAll + }); + + await user.click(getByRole('button', {name: '1 more'})); + + expect(getByText('First reply')).toBeInTheDocument(); + expect(getByText('Second reply')).toBeInTheDocument(); + }); + + it('offers the reply form only once expanded', async () => { + const user = userEvent.setup(); + + const {getByRole, getByPlaceholderText, queryByPlaceholderText} = + renderActivityList(, { + commentThreads: [threadAcrossDays], + commentThreadReads: seenAll + }); + + expect(queryByPlaceholderText('Reply...')).toBeNull(); + + await user.click(getByRole('button', {name: '1 more'})); + + expect(getByPlaceholderText('Reply...')).toBeInTheDocument(); + }); + + it('offers no reply count toggle while replies are folded', () => { + const {queryByRole} = renderActivityList(, { + commentThreads: [threadAcrossDays], + commentThreadReads: seenAll + }); + + expect(queryByRole('button', {name: /2 replies/})).toBeNull(); + }); + + it('collapses the row through the reply count once nothing is folded', async () => { + const user = userEvent.setup(); + + const {getByRole, getByText, queryByText} = renderActivityList(, { + commentThreads: [threadAcrossDays], + commentThreadReads: seenAll + }); + + await user.click(getByRole('button', {name: '1 more'})); + await user.click(getByRole('button', {name: /2 replies/})); + + expect(getByText('A topic')).toBeInTheDocument(); + expect(queryByText('First reply')).toBeNull(); + expect(queryByText('Second reply')).toBeNull(); + }); + + it('offers resolving without expanding', () => { + const {getByRole} = renderActivityList(, { + commentThreads: [threadAcrossDays] + }); + + expect(getByRole('button', {name: 'Mark as resolved'})).toBeInTheDocument(); + }); + }); + + describe('unseen activity', () => { + // Read state is per thread, so a reply left unread days ago sits above + // the day the row is listed under. Folding it away would hide the very + // thing the feed exists to surface. + const threadWithUnseenReplyFromEarlierDay = thread({ + permaId: 7, + comments: [ + comment({id: 100, body: 'A topic', createdAt: '2026-08-15T12:00:00.000Z'}), + comment({id: 101, body: 'Unseen reply', createdAt: '2026-08-16T12:00:00.000Z'}), + comment({id: 102, body: 'Latest reply', createdAt: '2026-08-17T12:00:00.000Z'}) + ] + }); + + it('marks a thread with unseen comments', () => { + const {getByLabelText} = renderActivityList(, { + commentThreads: [threadWithUnseenReplyFromEarlierDay], + commentThreadReads: {} + }); + + expect(getByLabelText('3 unread')).toBeInTheDocument(); + }); + + it('does not mark a thread the reviewer has seen', () => { + const {queryByLabelText} = renderActivityList(, { + commentThreads: [threadWithUnseenReplyFromEarlierDay], + commentThreadReads: {7: '2026-08-18T12:00:00.000Z'} + }); + + expect(queryByLabelText('1 unread')).toBeNull(); + expect(queryByLabelText('2 unread')).toBeNull(); + }); + + it('shows unseen replies from earlier days', () => { + const {getByText} = renderActivityList(, { + commentThreads: [threadWithUnseenReplyFromEarlierDay], + commentThreadReads: {7: '2026-08-15T18:00:00.000Z'} + }); + + expect(getByText('Unseen reply')).toBeInTheDocument(); + expect(getByText('Latest reply')).toBeInTheDocument(); + }); + + it('folds away seen replies from earlier days', () => { + const {getByText, queryByText} = renderActivityList(, { + commentThreads: [threadWithUnseenReplyFromEarlierDay], + commentThreadReads: {7: '2026-08-18T12:00:00.000Z'} + }); + + expect(queryByText('Unseen reply')).toBeNull(); + expect(getByText('1 more')).toBeInTheDocument(); + }); + }); + + describe('interaction', () => { + function renderTwoEntries(ui) { + return renderActivityList(ui, { + commentThreads: [ + thread({ + id: 1, + comments: [comment({ + id: 100, creatorName: 'Bob', body: 'Older topic', + createdAt: '2026-08-17T09:00:00.000Z' + })] + }), + thread({ + id: 2, + comments: [comment({ + id: 200, creatorName: 'Carol', body: 'Newer topic', + createdAt: '2026-08-17T11:00:00.000Z' + })] + }) + ] + }); + } + + it('passes the entry of a clicked thread to onEntryClick', async () => { + const user = userEvent.setup(); + const onEntryClick = jest.fn(); + + const {getByText} = renderTwoEntries(); + + await user.click(getByText('Older topic')); + + expect(onEntryClick).toHaveBeenCalledWith( + expect.objectContaining({threadId: 1}) + ); + }); + + it('marks the thread of the highlighted entry as current', () => { + const {getByText} = renderTwoEntries(); + + expect(getByText('Older topic').closest('[aria-current]')).not.toBeNull(); + expect(getByText('Newer topic').closest('[aria-current]')).toBeNull(); + }); + }); + + describe('date groups', () => { + // Relative labels need a fixed today to be worth asserting. + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-08-24T12:00:00.000Z')); + I18n.locale = 'en'; + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + function dayThread({id, createdAt}) { + return thread({ + id, + comments: [comment({id: id * 100, body: `Topic ${id}`, createdAt})] + }); + } + + function renderDays(ui, days) { + return renderActivityList(ui, {commentThreads: days.map(dayThread)}); + } + + it('heads the rows of today with a relative label', () => { + const {getByRole} = renderDays(, [ + {id: 1, createdAt: '2026-08-24T09:00:00.000Z'} + ]); + + expect(getByRole('heading', {name: 'Today'})).toBeInTheDocument(); + }); + + it('heads the rows of the day before with a relative label', () => { + const {getByRole} = renderDays(, [ + {id: 1, createdAt: '2026-08-23T09:00:00.000Z'} + ]); + + expect(getByRole('heading', {name: 'Yesterday'})).toBeInTheDocument(); + }); + + it('heads earlier rows with their date', () => { + const {getByRole} = renderDays(, [ + {id: 1, createdAt: '2026-08-17T09:00:00.000Z'} + ]); + + expect(getByRole('heading', {name: 'Aug 17'})).toBeInTheDocument(); + }); + + it('includes the year for rows of previous years', () => { + const {getByRole} = renderDays(, [ + {id: 1, createdAt: '2025-08-17T09:00:00.000Z'} + ]); + + expect(getByRole('heading', {name: 'Aug 17, 2025'})).toBeInTheDocument(); + }); + + it('heads each day once, in the order of the rows', () => { + const {getAllByRole} = renderDays(, [ + {id: 1, createdAt: '2026-08-24T09:00:00.000Z'}, + {id: 2, createdAt: '2026-08-24T11:00:00.000Z'}, + {id: 3, createdAt: '2026-08-23T09:00:00.000Z'} + ]); + + expect(getAllByRole('heading').map(node => node.textContent)) + .toEqual(['Today', 'Yesterday']); + }); + + it('groups the rows under the heading of their day', () => { + const {getByRole, getByText} = renderDays(, [ + {id: 1, createdAt: '2026-08-24T09:00:00.000Z'}, + {id: 2, createdAt: '2026-08-23T09:00:00.000Z'} + ]); + + const yesterday = getByRole('heading', {name: 'Yesterday'}); + + expect(yesterday.compareDocumentPosition(getByText('Topic 1')) & + Node.DOCUMENT_POSITION_PRECEDING).toBeTruthy(); + expect(yesterday.compareDocumentPosition(getByText('Topic 2')) & + Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + + it('carries a machine readable date on the heading', () => { + const {getByRole} = renderDays(, [ + {id: 1, createdAt: '2026-08-17T09:00:00.000Z'} + ]); + + expect(getByRole('heading', {name: 'Aug 17'}).querySelector('time')) + .toHaveAttribute('dateTime', '2026-08-17'); + }); + + it('heads a day only once when it is split across pages', () => { + const {getAllByRole} = renderDays(, [ + {id: 1, createdAt: '2026-08-24T09:00:00.000Z'}, + {id: 2, createdAt: '2026-08-24T11:00:00.000Z'} + ]); + + expect(getAllByRole('heading').map(node => node.textContent)).toEqual(['Today']); + }); + }); + + describe('paging', () => { + function renderPagedEntries(ui) { + return renderActivityList(ui, { + commentThreads: [1, 2, 3].map(id => thread({ + id, + comments: [comment({ + id: id * 100, body: `Topic ${id}`, createdAt: `2026-08-1${id}T09:00:00.000Z` + })] + })) + }); + } + + it('shows only a first page of entries', () => { + const {getByText, queryByText} = renderPagedEntries(); + + expect(getByText('Topic 3')).toBeInTheDocument(); + expect(getByText('Topic 2')).toBeInTheDocument(); + expect(queryByText('Topic 1')).toBeNull(); + }); + + it('extends the list on show more', async () => { + const user = userEvent.setup(); + + const {getByRole, getByText} = renderPagedEntries(); + + await user.click(getByRole('button', {name: 'Show more'})); + + expect(getByText('Topic 1')).toBeInTheDocument(); + }); + + it('offers no show more button once everything is shown', () => { + const {queryByRole} = renderPagedEntries(); + + expect(queryByRole('button', {name: 'Show more'})).toBeNull(); + }); + }); + describe('marking read', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + const unreadThread = thread({ + id: 1, + comments: [comment({id: 100, body: 'A topic', createdAt: '2026-08-17T09:00:00.000Z'})] + }); + + function renderWithPostMessage(ui) { + const postMessage = jest.spyOn(window.top, 'postMessage').mockImplementation(() => {}); + + return { + ...renderActivityList(ui, {commentThreads: [unreadThread]}), + postMessage + }; + } + + function markReadMessages(postMessage) { + return postMessage.mock.calls.filter(([message]) => message.type === 'MARK_THREADS_READ'); + } + + it('leaves a row the reviewer only scrolled past unread', () => { + const {container, postMessage} = renderWithPostMessage(); + + simulateScrollingIntoView(container); + act(() => jest.advanceTimersByTime(1000)); + + expect(markReadMessages(postMessage)).toEqual([]); + }); + + it('marks a highlighted row read', () => { + const {container, postMessage} = renderWithPostMessage( + + ); + + simulateScrollingIntoView(container); + act(() => jest.advanceTimersByTime(1000)); + + expect(markReadMessages(postMessage)).toHaveLength(1); + }); + }); +}); diff --git a/entry_types/scrolled/package/spec/review/CommentMenu-spec.js b/entry_types/scrolled/package/spec/review/CommentMenu-spec.js index 701bec44f0..564252c0ea 100644 --- a/entry_types/scrolled/package/spec/review/CommentMenu-spec.js +++ b/entry_types/scrolled/package/spec/review/CommentMenu-spec.js @@ -1,21 +1,23 @@ import React from 'react'; import '@testing-library/jest-dom/extend-expect'; import userEvent from '@testing-library/user-event'; -import {useFakeTranslations} from 'pageflow/testHelpers'; - import {CommentMenu} from 'review/CommentMenu'; +import EditIcon from 'review/images/edit.svg'; import {renderWithReviewState} from 'support/renderWithReviewState'; describe('CommentMenu', () => { - useFakeTranslations({ - 'pageflow_scrolled.review.comment_actions': 'Comment actions', - 'pageflow_scrolled.review.edit_comment': 'Edit' - }); + function menu(props) { + return ( + {}}]} + {...props} /> + ); + } it('only renders the menu once the button has been clicked', async () => { const user = userEvent.setup(); - const {getByRole, queryByRole} = renderWithReviewState(); + const {getByRole, queryByRole} = renderWithReviewState(menu()); expect(queryByRole('menu')).toBeNull(); @@ -27,7 +29,7 @@ describe('CommentMenu', () => { // The form styles in pageflow/ui/forms.scss turn buttons announcing a // popup into full width select lookalikes, but spare this value. it('announces a menu rather than a generic popup', () => { - const {getByRole} = renderWithReviewState(); + const {getByRole} = renderWithReviewState(menu()); expect(getByRole('button', {name: 'Comment actions'})) .toHaveAttribute('aria-haspopup', 'menu'); @@ -36,7 +38,7 @@ describe('CommentMenu', () => { it('exposes the expanded state of the menu', async () => { const user = userEvent.setup(); - const {getByRole} = renderWithReviewState(); + const {getByRole} = renderWithReviewState(menu()); const button = getByRole('button', {name: 'Comment actions'}); expect(button).toHaveAttribute('aria-expanded', 'false'); @@ -46,23 +48,25 @@ describe('CommentMenu', () => { expect(button).toHaveAttribute('aria-expanded', 'true'); }); - it('invokes onEdit and closes the menu when the item is selected', async () => { + it('invokes onSelect and closes the menu when the item is selected', async () => { const user = userEvent.setup(); - const onEdit = jest.fn(); + const onSelect = jest.fn(); - const {getByRole, queryByRole} = renderWithReviewState(); + const {getByRole, queryByRole} = renderWithReviewState( + menu({items: [{icon: EditIcon, label: 'Edit', onSelect}]}) + ); await user.click(getByRole('button', {name: 'Comment actions'})); await user.click(getByRole('menuitem', {name: 'Edit'})); - expect(onEdit).toHaveBeenCalled(); + expect(onSelect).toHaveBeenCalled(); expect(queryByRole('menu')).toBeNull(); }); it('closes the menu on escape', async () => { const user = userEvent.setup(); - const {getByRole, queryByRole} = renderWithReviewState(); + const {getByRole, queryByRole} = renderWithReviewState(menu()); await user.click(getByRole('button', {name: 'Comment actions'})); await user.keyboard('{Escape}'); @@ -77,7 +81,7 @@ describe('CommentMenu', () => { const user = userEvent.setup(); const listener = jest.fn(); - const {getByRole, queryByRole} = renderWithReviewState(); + const {getByRole, queryByRole} = renderWithReviewState(menu()); await user.click(getByRole('button', {name: 'Comment actions'})); @@ -97,7 +101,7 @@ describe('CommentMenu', () => { it('moves focus to items via arrow keys', async () => { const user = userEvent.setup(); - const {getByRole} = renderWithReviewState(); + const {getByRole} = renderWithReviewState(menu()); await user.click(getByRole('button', {name: 'Comment actions'})); await user.keyboard('{ArrowDown}'); @@ -112,7 +116,7 @@ describe('CommentMenu', () => { const {getByRole} = renderWithReviewState(
- + {menu()}
); diff --git a/entry_types/scrolled/package/spec/review/Thread/features/foldedReplies-spec.js b/entry_types/scrolled/package/spec/review/Thread/features/foldedReplies-spec.js new file mode 100644 index 0000000000..b3ea84012d --- /dev/null +++ b/entry_types/scrolled/package/spec/review/Thread/features/foldedReplies-spec.js @@ -0,0 +1,226 @@ +import React from 'react'; +import '@testing-library/jest-dom/extend-expect'; +import userEvent from '@testing-library/user-event'; +import {act} from '@testing-library/react'; +import {useFakeTranslations} from 'pageflow/testHelpers'; + +import {Thread} from 'review/Thread'; +import {renderWithReviewState} from 'support/renderWithReviewState'; +import { + simulateScrollingIntoView +} from 'support/fakeIntersectionObserver'; + +describe('Thread folded replies', () => { + useFakeTranslations({ + 'pageflow_scrolled.review.reply_placeholder': 'Reply...', + 'pageflow_scrolled.review.reply_count.one': '1 reply', + 'pageflow_scrolled.review.reply_count.other': '%{count} replies', + 'pageflow_scrolled.review.earlier_reply_count.one': '1 more', + 'pageflow_scrolled.review.earlier_reply_count.other': '%{count} more', + 'pageflow_scrolled.review.unread_replies': 'Unread replies' + }); + + const currentUser = {id: 42, name: 'Alice'}; + + function comment(attributes) { + return {creatorId: 43, creatorName: 'Bob', ...attributes}; + } + + const thread = { + id: 1, + permaId: 5, + subjectType: 'ContentElement', + subjectId: 10, + comments: [ + comment({id: 100, body: 'On the pull quote', createdAt: '2026-08-17T09:00:00.000Z'}), + comment({id: 101, body: 'First reply', createdAt: '2026-08-17T09:30:00.000Z'}), + comment({id: 102, body: 'Second reply', createdAt: '2026-08-17T10:00:00.000Z'}), + comment({id: 103, body: 'Third reply', createdAt: '2026-08-17T10:30:00.000Z'}) + ] + }; + + function render(ui, options = {}) { + return renderWithReviewState(ui, {currentUser, commentThreads: [thread], ...options}); + } + + it('shows the opening comment and the last replies asked for', () => { + const {getByText, queryByText} = render( + + ); + + expect(getByText('On the pull quote')).toBeInTheDocument(); + expect(getByText('Third reply')).toBeInTheDocument(); + expect(queryByText('First reply')).toBeNull(); + expect(queryByText('Second reply')).toBeNull(); + }); + + it('counts the folded replies between them', () => { + const {getByText} = render(); + + expect(getByText('2 more')).toBeInTheDocument(); + }); + + it('folds every reply away when none are asked for', () => { + const {getByText, queryByText} = render( + + ); + + expect(getByText('On the pull quote')).toBeInTheDocument(); + expect(getByText('3 more')).toBeInTheDocument(); + expect(queryByText('Third reply')).toBeNull(); + }); + + it('offers no reply count toggle while replies are folded', () => { + const {queryByRole} = render(); + + expect(queryByRole('button', {name: /3 replies/})).toBeNull(); + }); + + it('offers the reply count toggle once nothing is folded', () => { + const {getByRole} = render(); + + expect(getByRole('button', {name: /3 replies/})).toBeInTheDocument(); + }); + + it('offers the reply count toggle after expanding the folded replies', async () => { + const user = userEvent.setup(); + + function ExpandingThread() { + const [expanded, setExpanded] = React.useState(false); + + return ( + setExpanded(true)} /> + ); + } + + const {getByRole, queryByRole} = render(); + + expect(queryByRole('button', {name: /3 replies/})).toBeNull(); + + await user.click(getByRole('button', {name: '2 more'})); + + expect(getByRole('button', {name: /3 replies/})).toBeInTheDocument(); + }); + + it('offers the reply count toggle while collapsed with folded replies', () => { + const {getByRole} = render( + + ); + + expect(getByRole('button', {name: /3 replies/})).toBeInTheDocument(); + }); + + it('shows every reply without a visible count', () => { + const {getByText, queryByText} = render(); + + expect(getByText('First reply')).toBeInTheDocument(); + expect(getByText('Third reply')).toBeInTheDocument(); + expect(queryByText('2 more')).toBeNull(); + }); + + it('folds nothing when the count covers every reply', () => { + const {getByText, queryByText} = render( + + ); + + expect(getByText('First reply')).toBeInTheDocument(); + expect(queryByText('1 more')).toBeNull(); + }); + + it('reports the fold marker being clicked', async () => { + const user = userEvent.setup(); + const onExpandReplies = jest.fn(); + + const {getByRole} = render( + + ); + + await user.click(getByRole('button', {name: '2 more'})); + + expect(onExpandReplies).toHaveBeenCalled(); + }); + + it('does not offer the marker as a button without a handler', () => { + const {queryByRole, getByText} = render( + + ); + + expect(getByText('2 more')).toBeInTheDocument(); + expect(queryByRole('button', {name: '2 more'})).toBeNull(); + }); + + // Replying to a discussion you have only partly seen invites talking + // past what was already said. + it('hides the reply form while replies are folded away', () => { + const {queryByPlaceholderText} = render( + + ); + + expect(queryByPlaceholderText('Reply...')).toBeNull(); + }); + + it('offers the reply form once nothing is folded away', () => { + const {getByPlaceholderText} = render( + + ); + + expect(getByPlaceholderText('Reply...')).toBeInTheDocument(); + }); + + describe('marking read', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + function renderWithPostMessage(ui, options) { + const postMessage = jest.spyOn(window.top, 'postMessage').mockImplementation(() => {}); + + return {...render(ui, options), postMessage}; + } + + function markReadMessages(postMessage) { + return postMessage.mock.calls.filter(([message]) => message.type === 'MARK_THREADS_READ'); + } + + it('does not mark the thread read while replies are folded away', () => { + const {container, postMessage} = renderWithPostMessage( + + ); + + simulateScrollingIntoView(container); + act(() => jest.advanceTimersByTime(1000)); + + expect(markReadMessages(postMessage)).toEqual([]); + }); + + it('marks the thread read when the replies folded away have been read', () => { + const {container, postMessage} = renderWithPostMessage( + , + {commentThreadReads: {5: '2026-08-17T10:15:00.000Z'}} + ); + + simulateScrollingIntoView(container); + act(() => jest.advanceTimersByTime(1000)); + + expect(markReadMessages(postMessage)).toHaveLength(1); + }); + + it('marks the thread read once nothing is folded away', () => { + const {container, postMessage} = renderWithPostMessage( + + ); + + simulateScrollingIntoView(container); + act(() => jest.advanceTimersByTime(1000)); + + expect(markReadMessages(postMessage)).toHaveLength(1); + }); + }); +}); diff --git a/entry_types/scrolled/package/spec/review/Thread/features/markingRead-spec.js b/entry_types/scrolled/package/spec/review/Thread/features/markingRead-spec.js index 6f9b3bff2c..760d527fe5 100644 --- a/entry_types/scrolled/package/spec/review/Thread/features/markingRead-spec.js +++ b/entry_types/scrolled/package/spec/review/Thread/features/markingRead-spec.js @@ -15,7 +15,6 @@ describe('Thread marking read', () => { 'pageflow_scrolled.review.reply_placeholder': 'Reply...', 'pageflow_scrolled.review.reply_count.one': '1 reply', 'pageflow_scrolled.review.reply_count.other': '%{count} replies', - 'pageflow_scrolled.review.toggle_replies': 'Toggle replies' }); const currentUser = {id: 42, name: 'Alice'}; @@ -133,6 +132,49 @@ describe('Thread marking read', () => { expect(markReadMessages(postMessage)).toHaveLength(1); }); + it('does not mark thread read while it is not the highlighted one', () => { + const {container, postMessage} = render( + + ); + + simulateScrollingIntoView(container); + passTime(1000); + + expect(markReadMessages(postMessage)).toEqual([]); + }); + + it('marks thread read once it is the highlighted one', () => { + const {container, postMessage} = render( + + ); + + simulateScrollingIntoView(container); + passTime(1000); + + expect(markReadMessages(postMessage)).toHaveLength(1); + }); + + it('marks thread read once a resolution by someone else has been seen', () => { + const resolved = { + ...thread, + resolvedAt: '2026-08-17T13:00:00.000Z', + resolvedById: 44 + }; + + const {container, postMessage} = render( + , + { + commentThreads: [resolved], + commentThreadReads: {5: '2026-08-17T12:00:00.000Z'} + } + ); + + simulateScrollingIntoView(container); + passTime(1000); + + expect(markReadMessages(postMessage)).toHaveLength(1); + }); + it('does not mark thread read again once all comments have been read', () => { const {container, postMessage} = render( , diff --git a/entry_types/scrolled/package/spec/review/Thread/features/replyForm-spec.js b/entry_types/scrolled/package/spec/review/Thread/features/replyForm-spec.js index abeccf5bf8..71cafb2af9 100644 --- a/entry_types/scrolled/package/spec/review/Thread/features/replyForm-spec.js +++ b/entry_types/scrolled/package/spec/review/Thread/features/replyForm-spec.js @@ -11,7 +11,6 @@ describe('Thread reply form', () => { 'pageflow_scrolled.review.reply_placeholder': 'Reply...', 'pageflow_scrolled.review.reply_count.one': '1 reply', 'pageflow_scrolled.review.reply_count.other': '%{count} replies', - 'pageflow_scrolled.review.toggle_replies': 'Toggle replies' }); const thread = { diff --git a/entry_types/scrolled/package/spec/review/Thread/features/replyToggle-spec.js b/entry_types/scrolled/package/spec/review/Thread/features/replyToggle-spec.js new file mode 100644 index 0000000000..6e902f912b --- /dev/null +++ b/entry_types/scrolled/package/spec/review/Thread/features/replyToggle-spec.js @@ -0,0 +1,101 @@ +import React from 'react'; +import '@testing-library/jest-dom/extend-expect'; +import userEvent from '@testing-library/user-event'; +import {useFakeTranslations} from 'pageflow/testHelpers'; + +import {Thread} from 'review/Thread'; +import avatarStyles from 'review/Avatar.module.css'; +import {renderWithReviewState} from 'support/renderWithReviewState'; + +describe('Thread reply toggle', () => { + useFakeTranslations({ + 'pageflow_scrolled.review.reply_count.one': '1 reply', + 'pageflow_scrolled.review.reply_count.other': '%{count} replies', + 'pageflow_scrolled.review.reply_placeholder': 'Reply...', + 'pageflow_scrolled.review.send': 'Send', + 'pageflow_scrolled.review.enter_for_new_line': 'Enter for new line' + }); + + const thread = { + id: 1, + comments: [ + {id: 10, body: 'A topic', creatorName: 'Bob', creatorId: 2}, + {id: 11, body: 'A reply', creatorName: 'Carol', creatorId: 3}, + {id: 12, body: 'Another reply', creatorName: 'Dave', creatorId: 4} + ] + }; + + const withoutReplies = {id: 1, comments: [thread.comments[0]]}; + + it('renders no toggle for a thread without replies', () => { + const {queryByRole} = renderWithReviewState( + + ); + + expect(queryByRole('button', {name: /repl/})).toBeNull(); + }); + + it('counts the replies', () => { + const {getByRole} = renderWithReviewState( + + ); + + expect(getByRole('button', {name: /2 replies/})).toBeInTheDocument(); + }); + + it('keeps the count once the replies are shown', () => { + const {getByRole} = renderWithReviewState( + + ); + + expect(getByRole('button', {name: /2 replies/})).toBeInTheDocument(); + }); + + it('toggles the thread', async () => { + const user = userEvent.setup(); + const onToggle = jest.fn(); + + const {getByRole} = renderWithReviewState( + + ); + + await user.click(getByRole('button', {name: /2 replies/})); + + expect(onToggle).toHaveBeenCalled(); + }); + + it('exposes that the replies are hidden', () => { + const {getByRole} = renderWithReviewState( + + ); + + expect(getByRole('button', {name: /2 replies/})) + .toHaveAttribute('aria-expanded', 'false'); + }); + + it('exposes that the replies are shown', () => { + const {getByRole} = renderWithReviewState( + + ); + + expect(getByRole('button', {name: /2 replies/})) + .toHaveAttribute('aria-expanded', 'true'); + }); + + it('shows who replied while the replies are hidden', () => { + const {container} = renderWithReviewState( + + ); + + expect(container.querySelector(`.${avatarStyles.avatarStack}`)) + .toBeInTheDocument(); + }); + + it('hides who replied once the replies are shown', () => { + const {container} = renderWithReviewState( + + ); + + expect(container.querySelector(`.${avatarStyles.avatarStack}`)).toBeNull(); + }); +}); diff --git a/entry_types/scrolled/package/spec/review/Thread/features/resolution-spec.js b/entry_types/scrolled/package/spec/review/Thread/features/resolution-spec.js new file mode 100644 index 0000000000..0cd1294953 --- /dev/null +++ b/entry_types/scrolled/package/spec/review/Thread/features/resolution-spec.js @@ -0,0 +1,188 @@ +import React from 'react'; +import '@testing-library/jest-dom/extend-expect'; +import userEvent from '@testing-library/user-event'; +import {useFakeTranslations} from 'pageflow/testHelpers'; + +import {Thread} from 'review/Thread'; +import styles from 'review/Thread.module.css'; +import {renderWithReviewState} from 'support/renderWithReviewState'; + +describe('Thread resolution', () => { + useFakeTranslations({ + 'pageflow_scrolled.review.resolution': 'Marked as resolved', + 'pageflow_scrolled.review.resolution_by': 'Marked as resolved by', + 'pageflow_scrolled.review.resolve': 'Mark as resolved', + 'pageflow_scrolled.review.unresolve': 'Mark as unresolved', + 'pageflow_scrolled.review.thread_actions': 'Thread actions', + 'pageflow_scrolled.review.reply_placeholder': 'Reply...', + 'pageflow_scrolled.review.send': 'Send', + 'pageflow_scrolled.review.enter_for_new_line': 'Enter for new line' + }); + + const thread = { + id: 1, + comments: [{id: 10, body: 'On the pull quote', creatorName: 'Bob', creatorId: 2}] + }; + + const withReply = { + ...thread, + comments: [...thread.comments, + {id: 11, body: 'A reply', creatorName: 'Carol', creatorId: 3}] + }; + + const resolvedWithReply = { + ...withReply, + resolvedAt: '2026-08-19T10:00:00.000Z', + resolvedById: 3, + resolverName: 'Ada' + }; + + const resolved = { + ...thread, + resolvedAt: '2026-08-19T10:00:00.000Z', + resolvedById: 3, + resolverName: 'Ada' + }; + + describe('unread', () => { + const currentUser = {id: 42, name: 'Alice'}; + + // Read state is looked up by perma id, so the thread needs one. + const tracked = {...resolved, permaId: 5}; + + function renderResolved(thread, options) { + return renderWithReviewState( + , + {currentUser, commentThreads: [thread], ...options} + ); + } + + function resolveRow(container) { + return container.querySelector(`.${styles.resolveRow}`); + } + + it('marks the line while the resolution has not been seen', () => { + const {container} = renderResolved(tracked, {commentThreadReads: {}}); + + expect(resolveRow(container)).toHaveClass(styles.unreadResolution); + }); + + it('leaves the line alone once the resolution has been seen', () => { + const {container} = renderResolved(tracked, { + commentThreadReads: {5: '2026-08-19T11:00:00.000Z'} + }); + + expect(resolveRow(container)).not.toHaveClass(styles.unreadResolution); + }); + + it('leaves the line alone for the reviewer resolving it themselves', () => { + const {container} = renderResolved( + {...tracked, resolvedById: currentUser.id}, + {commentThreadReads: {}} + ); + + expect(resolveRow(container)).not.toHaveClass(styles.unreadResolution); + }); + }); + + it('names the user who resolved the thread', () => { + const {getByText} = renderWithReviewState( + + ); + + expect(getByText('Marked as resolved by')).toBeInTheDocument(); + expect(getByText('Ada')).toBeInTheDocument(); + }); + + it('renders the time the thread was resolved', () => { + const {container} = renderWithReviewState( + + ); + + expect(container.querySelector('time[datetime="2026-08-19T10:00:00.000Z"]')) + .toBeInTheDocument(); + }); + + it('says a thread was resolved even when the resolver is not known', () => { + const {getByText, queryByText} = renderWithReviewState( + + ); + + expect(getByText('Marked as resolved')).toBeInTheDocument(); + expect(queryByText('Marked as resolved by')).toBeNull(); + }); + + it('does not render a resolution for an unresolved thread', () => { + const {queryByText} = renderWithReviewState( + {}} /> + ); + + expect(queryByText('Marked as resolved by')).toBeNull(); + expect(queryByText('Marked as resolved')).toBeNull(); + }); + + it('replaces the resolve button once the thread is resolved', () => { + const {queryByRole} = renderWithReviewState( + {}} /> + ); + + expect(queryByRole('button', {name: 'Mark as resolved'})).toBeNull(); + }); + + it('offers the resolve button while the thread is unresolved', () => { + const {getByRole} = renderWithReviewState( + {}} /> + ); + + expect(getByRole('button', {name: 'Mark as resolved'})).toBeInTheDocument(); + }); + + it('says a collapsed thread is resolved', () => { + const {getByText} = renderWithReviewState( + + ); + + expect(getByText('Marked as resolved by')).toBeInTheDocument(); + expect(getByText('Ada')).toBeInTheDocument(); + }); + + it('undoes the resolution of a collapsed thread as well', () => { + const {getByRole} = renderWithReviewState( + {}} /> + ); + + expect(getByRole('button', {name: 'Thread actions'})).toBeInTheDocument(); + }); + + it('offers no resolve button while collapsed', () => { + const {queryByRole} = renderWithReviewState( + {}} /> + ); + + expect(queryByRole('button', {name: 'Mark as resolved'})).toBeNull(); + }); + + it('undoes the resolution through a menu', async () => { + const user = userEvent.setup(); + const onResolve = jest.fn(); + + const {getByRole} = renderWithReviewState( + + ); + + await user.click(getByRole('button', {name: 'Thread actions'})); + await user.click(getByRole('menuitem', {name: 'Mark as unresolved'})); + + expect(onResolve).toHaveBeenCalled(); + }); + + it('renders no menu without a way to resolve', () => { + const {getByText, queryByRole} = renderWithReviewState( + + ); + + expect(getByText('Ada')).toBeInTheDocument(); + expect(queryByRole('button', {name: 'Thread actions'})).toBeNull(); + }); +}); diff --git a/entry_types/scrolled/package/spec/review/Thread/features/newMarkers-spec.js b/entry_types/scrolled/package/spec/review/Thread/features/unreadMarkers-spec.js similarity index 54% rename from entry_types/scrolled/package/spec/review/Thread/features/newMarkers-spec.js rename to entry_types/scrolled/package/spec/review/Thread/features/unreadMarkers-spec.js index ac944b9c97..19cef66641 100644 --- a/entry_types/scrolled/package/spec/review/Thread/features/newMarkers-spec.js +++ b/entry_types/scrolled/package/spec/review/Thread/features/unreadMarkers-spec.js @@ -6,16 +6,15 @@ import {Thread} from 'review/Thread'; import {renderWithReviewState} from 'support/renderWithReviewState'; import styles from 'review/Thread.module.css'; -describe('Thread new markers', () => { +describe('Thread unread markers', () => { useFakeTranslations({ 'pageflow_scrolled.review.reply_placeholder': 'Reply...', 'pageflow_scrolled.review.reply_count.one': '1 reply', 'pageflow_scrolled.review.reply_count.other': '%{count} replies', - 'pageflow_scrolled.review.new_reply_count.one': '1 new', - 'pageflow_scrolled.review.new_reply_count.other': '%{count} new', - 'pageflow_scrolled.review.toggle_replies': 'Toggle replies', - 'pageflow_scrolled.review.unread_comment_count.one': '1 unread comment', - 'pageflow_scrolled.review.unread_comment_count.other': '%{count} unread comments' + 'pageflow_scrolled.review.unread_reply_count.one': '1 unread', + 'pageflow_scrolled.review.unread_reply_count.other': '%{count} unread', + 'pageflow_scrolled.review.unread_count.one': '1 unread', + 'pageflow_scrolled.review.unread_count.other': '%{count} unread' }); const currentUser = {id: 42, name: 'Alice'}; @@ -51,53 +50,110 @@ describe('Thread new markers', () => { return renderWithReviewState(ui, {currentUser, commentThreads: [thread], ...options}); } - function newDot(container) { - return container.querySelector(`.${styles.newDot}`); + function unreadDot(container) { + return container.querySelector(`.${styles.unreadDot}`); } describe('dot on the thread', () => { it('marks a thread with unseen comments', () => { const {container, getByLabelText} = render( - + ); - expect(newDot(container)).not.toBeNull(); - expect(getByLabelText('1 unread comment')).toBeInTheDocument(); + expect(unreadDot(container)).not.toBeNull(); + expect(getByLabelText('1 unread')).toBeInTheDocument(); }); it('marks a thread whose replies are unseen', () => { const {container} = render( - , + , { commentThreads: [threadWithReplies], commentThreadReads: {5: '2026-08-17T10:00:00.000Z'} } ); - expect(newDot(container)).not.toBeNull(); + expect(unreadDot(container)).not.toBeNull(); + }); + + it('marks a thread somebody else has resolved', () => { + const resolved = { + ...thread, + resolvedAt: '2026-08-17T13:00:00.000Z', + resolvedById: 44 + }; + + const {container} = render( + , + { + commentThreads: [resolved], + commentThreadReads: {5: '2026-08-17T12:00:00.000Z'} + } + ); + + expect(unreadDot(container)).not.toBeNull(); }); it('does not mark a thread without unseen comments', () => { const {container} = render( - , + , {commentThreadReads: {5: '2026-08-17T12:00:00.000Z'}} ); - expect(newDot(container)).toBeNull(); + expect(unreadDot(container)).toBeNull(); }); // The badge that opened the list already carries the same information. it('does not mark a thread shown on its own', () => { const {container} = render(); - expect(newDot(container)).toBeNull(); + expect(unreadDot(container)).toBeNull(); + }); + }); + + describe('outline on the thread', () => { + function outlined(container) { + return container.querySelector(`.${styles.unreadTopic}`); + } + + it('outlines a thread whose topic is unseen', () => { + const {container} = render(); + + expect(outlined(container)).not.toBeNull(); + }); + + it('leaves a thread whose replies are unseen unoutlined', () => { + const {container} = render( + , + { + commentThreads: [threadWithReplies], + commentThreadReads: {5: '2026-08-17T10:00:00.000Z'} + } + ); + + expect(outlined(container)).toBeNull(); + }); + + it('leaves a thread without unseen comments unoutlined', () => { + const {container} = render( + , + {commentThreadReads: {5: '2026-08-17T12:00:00.000Z'}} + ); + + expect(outlined(container)).toBeNull(); + }); + + it('leaves a thread shown on its own unoutlined', () => { + const {container} = render(); + + expect(outlined(container)).toBeNull(); }); }); describe('new reply count', () => { it('counts unseen replies hidden by collapsing', () => { const {getByText} = render( - , + , { commentThreads: [threadWithReplies], commentThreadReads: {5: '2026-08-17T10:00:00.000Z'} @@ -105,7 +161,7 @@ describe('Thread new markers', () => { ); expect(getByText('1 reply')).toBeInTheDocument(); - expect(getByText('1 new')).toBeInTheDocument(); + expect(getByText('1 unread')).toBeInTheDocument(); }); // An unread first comment must not be counted among the replies it @@ -120,17 +176,17 @@ describe('Thread new markers', () => { }; const {getByText, queryByText} = render( - , + , {commentThreads: [threadWithOwnReply]} ); expect(getByText('1 reply')).toBeInTheDocument(); - expect(queryByText('1 new')).toBeNull(); + expect(queryByText('1 unread')).toBeNull(); }); it('does not show a count when all replies have been seen', () => { const {queryByText} = render( - , + , { commentThreads: [threadWithReplies], commentThreadReads: {5: '2026-08-17T13:00:00.000Z'} @@ -138,7 +194,7 @@ describe('Thread new markers', () => { ); expect(queryByText('1 reply')).toBeInTheDocument(); - expect(queryByText('1 new')).toBeNull(); + expect(queryByText('1 unread')).toBeNull(); }); }); }); diff --git a/entry_types/scrolled/package/spec/review/Thread/features/newRepliesDivider-spec.js b/entry_types/scrolled/package/spec/review/Thread/features/unreadRepliesDivider-spec.js similarity index 87% rename from entry_types/scrolled/package/spec/review/Thread/features/newRepliesDivider-spec.js rename to entry_types/scrolled/package/spec/review/Thread/features/unreadRepliesDivider-spec.js index c91f953f0c..8f324319d7 100644 --- a/entry_types/scrolled/package/spec/review/Thread/features/newRepliesDivider-spec.js +++ b/entry_types/scrolled/package/spec/review/Thread/features/unreadRepliesDivider-spec.js @@ -5,13 +5,12 @@ import {useFakeTranslations} from 'pageflow/testHelpers'; import {Thread} from 'review/Thread'; import {renderWithReviewState} from 'support/renderWithReviewState'; -describe('Thread new replies divider', () => { +describe('Thread unread replies divider', () => { useFakeTranslations({ 'pageflow_scrolled.review.reply_placeholder': 'Reply...', 'pageflow_scrolled.review.reply_count.one': '1 reply', 'pageflow_scrolled.review.reply_count.other': '%{count} replies', - 'pageflow_scrolled.review.toggle_replies': 'Toggle replies', - 'pageflow_scrolled.review.new_replies': 'New replies' + 'pageflow_scrolled.review.unread_replies': 'Unread replies' }); const currentUser = {id: 42, name: 'Alice'}; @@ -45,7 +44,7 @@ describe('Thread new replies divider', () => { it('separates unseen replies from the ones already seen', () => { const {getByText} = render({commentThreadReads: {5: '2026-08-17T10:00:00.000Z'}}); - const divider = getByText('New replies'); + const divider = getByText('Unread replies'); const seen = getByText('Older reply'); const unseen = getByText('Newer reply'); @@ -58,14 +57,14 @@ describe('Thread new replies divider', () => { it('renders no divider when every reply has been seen', () => { const {queryByText} = render({commentThreadReads: {5: '2026-08-17T12:00:00.000Z'}}); - expect(queryByText('New replies')).toBeNull(); + expect(queryByText('Unread replies')).toBeNull(); }); // The thread's own marker already says the whole thread is new. it('renders no divider when the thread is new all through', () => { const {queryByText} = render(); - expect(queryByText('New replies')).toBeNull(); + expect(queryByText('Unread replies')).toBeNull(); }); it('renders no divider while replies are collapsed', () => { @@ -78,7 +77,7 @@ describe('Thread new replies divider', () => { } ); - expect(queryByText('New replies')).toBeNull(); + expect(queryByText('Unread replies')).toBeNull(); }); it('renders no divider before the current user own replies', () => { @@ -103,6 +102,6 @@ describe('Thread new replies divider', () => { } ); - expect(queryByText('New replies')).toBeNull(); + expect(queryByText('Unread replies')).toBeNull(); }); }); diff --git a/entry_types/scrolled/package/spec/review/ThreadList-spec.js b/entry_types/scrolled/package/spec/review/ThreadList-spec.js index 377e310f2d..3ec63a28a2 100644 --- a/entry_types/scrolled/package/spec/review/ThreadList-spec.js +++ b/entry_types/scrolled/package/spec/review/ThreadList-spec.js @@ -7,6 +7,7 @@ import {useFakeTranslations} from 'pageflow/testHelpers'; import {ThreadList} from 'review/ThreadList'; import { postReviewStateDraftsChangeMessage, + postReviewStateReadsChangeMessage, postReviewStateThreadChangeMessage } from 'review/postMessage'; import {review} from 'review/api'; @@ -41,13 +42,14 @@ describe('ThreadList', () => { 'pageflow_scrolled.review.reply_placeholder': 'Reply...', 'pageflow_scrolled.review.send': 'Send', 'pageflow_scrolled.review.enter_for_new_line': 'Enter for new line', - 'pageflow_scrolled.review.toggle_replies': 'Toggle replies', 'pageflow_scrolled.review.resolve': 'Mark as resolved', 'pageflow_scrolled.review.unresolve': 'Mark as unresolved', 'pageflow_scrolled.review.resolved_count.one': '1 resolved', 'pageflow_scrolled.review.resolved_count.other': '%{count} resolved', 'pageflow_scrolled.review.no_threads_yet': 'No comments yet', - 'pageflow_scrolled.review.refers_to_deleted_element': 'Refers to a deleted element' + 'pageflow_scrolled.review.refers_to_deleted_element': 'Refers to a deleted element', + 'pageflow_scrolled.review.unread_count.one': '1 unread', + 'pageflow_scrolled.review.unread_count.other': '%{count} unread' }); afterEach(() => { @@ -130,6 +132,25 @@ describe('ThreadList', () => { expect(highlighted).not.toContainElement(getByText('first')); }); + // Its only thread is resolved, so the list would otherwise read as + // empty and offer to start a topic. + it('offers no new thread form while a resolved thread is highlighted', () => { + const {getByText, queryByPlaceholderText} = renderThreadList( + , + { + commentThreads: [ + {id: 2, subjectType: 'ContentElement', subjectId: 10, + resolvedAt: '2026-08-17T10:00:00.000Z', comments: [ + {id: 20, body: 'Resolved topic', creatorName: 'Bob', creatorId: 2} + ]} + ] + } + ); + + expect(getByText('Resolved topic')).toBeInTheDocument(); + expect(queryByPlaceholderText('Add a comment...')).toBeNull(); + }); + it('highlights every thread when highlightedThreadId is an array of ids', () => { const {container, getByText} = renderThreadList( , @@ -315,6 +336,58 @@ describe('ThreadList', () => { expect(getByText('B')).toBeInTheDocument(); }); + describe('unread markers', () => { + const currentUser = {id: 42, name: 'Alice'}; + + // The reply is the reviewer's own, so each thread has exactly one + // unseen comment and the toggle to expand it. + function thread(id, permaId, body) { + return { + id, permaId, subjectType: 'ContentElement', subjectId: 10, + comments: [ + {id: id * 10, body, creatorName: 'Bob', creatorId: 43, + createdAt: '2026-08-17T11:00:00.000Z'}, + {id: id * 10 + 1, body: 'A reply', creatorName: 'Alice', creatorId: 42, + createdAt: '2026-08-17T11:30:00.000Z'} + ] + }; + } + + const commentThreads = [thread(1, 5, 'First topic'), thread(2, 6, 'Second topic')]; + + async function postReadsChange(reads) { + await act(async () => { + postReviewStateReadsChangeMessage(window, reads); + await new Promise(resolve => setTimeout(resolve, 0)); + }); + } + + it('holds a thread marked while the list stays as it is', async () => { + const {queryAllByLabelText} = renderThreadList( + , + {currentUser, commentThreads, commentThreadReads: {}} + ); + + await postReadsChange({5: '2026-08-17T12:00:00.000Z'}); + + expect(queryAllByLabelText('1 unread')).toHaveLength(2); + }); + + it('drops the marker of a thread once another one is expanded', async () => { + const user = userEvent.setup(); + + const {getAllByRole, queryAllByLabelText} = renderThreadList( + , + {currentUser, commentThreads, commentThreadReads: {}} + ); + + await postReadsChange({5: '2026-08-17T12:00:00.000Z'}); + await user.click(getAllByRole('button', {name: /1 reply/})[1]); + + expect(queryAllByLabelText('1 unread')).toHaveLength(1); + }); + }); + it('collapses threads when more than one exists', () => { const {queryByText} = renderThreadList( , @@ -338,7 +411,7 @@ describe('ThreadList', () => { expect(queryByText('Second reply')).not.toBeInTheDocument(); }); - it('does not collapse single thread', () => { + it('expands the only thread', () => { const {getByText} = renderThreadList( , { @@ -355,6 +428,69 @@ describe('ThreadList', () => { expect(getByText('A reply')).toBeInTheDocument(); }); + it('keeps the only thread collapsed when startCollapsed is set', () => { + const {queryByText} = renderThreadList( + , + { + commentThreads: [ + {id: 1, subjectType: 'ContentElement', subjectId: 10, comments: [ + {id: 10, body: 'First comment', creatorName: 'Bob', creatorId: 2}, + {id: 11, body: 'A reply', creatorName: 'Alice', creatorId: 1} + ]} + ] + } + ); + + expect(queryByText('First comment')).toBeInTheDocument(); + expect(queryByText('A reply')).not.toBeInTheDocument(); + }); + + it('collapses the only thread from its reply count', async () => { + const user = userEvent.setup(); + + const {getByRole, queryByText} = renderThreadList( + , + { + commentThreads: [ + {id: 1, subjectType: 'ContentElement', subjectId: 10, comments: [ + {id: 10, body: 'First comment', creatorName: 'Bob', creatorId: 2}, + {id: 11, body: 'A reply', creatorName: 'Alice', creatorId: 1} + ]} + ] + } + ); + + await user.click(getByRole('button', {name: /1 reply/})); + + expect(queryByText('A reply')).not.toBeInTheDocument(); + }); + + it('collapses an expanded thread when another one is expanded', async () => { + const user = userEvent.setup(); + + const {getAllByRole, queryByText} = renderThreadList( + , + { + commentThreads: [ + {id: 1, subjectType: 'ContentElement', subjectId: 10, comments: [ + {id: 10, body: 'First comment', creatorName: 'Bob', creatorId: 2}, + {id: 11, body: 'First reply', creatorName: 'Alice', creatorId: 1} + ]}, + {id: 2, subjectType: 'ContentElement', subjectId: 10, comments: [ + {id: 20, body: 'Second comment', creatorName: 'Eve', creatorId: 3}, + {id: 21, body: 'Second reply', creatorName: 'Bob', creatorId: 2} + ]} + ] + } + ); + + await user.click(getAllByRole('button', {name: /1 reply/})[0]); + await user.click(getAllByRole('button', {name: /1 reply/})[1]); + + expect(queryByText('First reply')).not.toBeInTheDocument(); + expect(queryByText('Second reply')).toBeInTheDocument(); + }); + describe('orphaned threads', () => { it('shows a section\'s orphaned threads on top with a deleted-element hint', () => { const {getByText} = renderThreadList( @@ -763,6 +899,45 @@ describe('ThreadList', () => { postMessage.mockRestore(); }); + it('expands the thread that was replied to', async () => { + const user = userEvent.setup(); + + const {getByPlaceholderText, getByRole} = renderThreadList( + , + { + commentThreads: [ + {id: 1, subjectType: 'ContentElement', subjectId: 10, comments: [ + {id: 10, body: 'First topic', creatorName: 'Bob', creatorId: 2}, + {id: 11, body: 'A reply', creatorName: 'Alice', creatorId: 1}, + {id: 12, body: 'Another reply', creatorName: 'Bob', creatorId: 2} + ]}, + {id: 2, subjectType: 'ContentElement', subjectId: 10, comments: [ + {id: 20, body: 'Second topic', creatorName: 'Eve', creatorId: 3} + ]} + ] + } + ); + + await user.type(getByPlaceholderText('Reply...'), 'My reply'); + await user.click(getByRole('button', {name: 'Send'})); + + postDraftsChange({}); + postThreadChange({ + id: 2, + subjectType: 'ContentElement', + subjectId: 10, + comments: [ + {id: 20, body: 'Second topic', creatorName: 'Eve', creatorId: 3}, + {id: 21, body: 'My reply', creatorName: 'Alice', creatorId: 1} + ] + }); + + await waitFor(() => + expect(getByRole('button', {name: /1 reply/})) + .toHaveAttribute('aria-expanded', 'true') + ); + }); + it('includes the quote of the thread range in create comment message', async () => { const user = userEvent.setup(); const postMessage = jest.spyOn(window.top, 'postMessage').mockImplementation(() => {}); @@ -896,6 +1071,49 @@ describe('ThreadList', () => { expect(getByText('Resolved thread')).toBeInTheDocument(); }); + it('leaves resolved threads collapsed while an unresolved one is expanded', () => { + const {getByText, queryByText} = renderThreadList( + , + { + commentThreads: [ + {id: 1, subjectType: 'ContentElement', subjectId: 10, + resolvedAt: null, + comments: [ + {id: 10, body: 'Active thread', creatorName: 'Alice', creatorId: 1}, + {id: 11, body: 'Active reply', creatorName: 'Bob', creatorId: 2} + ]}, + {id: 2, subjectType: 'ContentElement', subjectId: 10, + resolvedAt: '2026-04-09T10:00:00Z', + comments: [ + {id: 20, body: 'Resolved thread', creatorName: 'Bob', creatorId: 2}, + {id: 21, body: 'Resolved reply', creatorName: 'Alice', creatorId: 1} + ]} + ] + } + ); + + expect(getByText('Active reply')).toBeInTheDocument(); + expect(queryByText('Resolved reply')).not.toBeInTheDocument(); + }); + + it('expands the only resolved thread when none is unresolved', () => { + const {getByText} = renderThreadList( + , + { + commentThreads: [ + {id: 1, subjectType: 'ContentElement', subjectId: 10, + resolvedAt: '2026-04-09T10:00:00Z', + comments: [ + {id: 10, body: 'Resolved thread', creatorName: 'Bob', creatorId: 2}, + {id: 11, body: 'Resolved reply', creatorName: 'Alice', creatorId: 1} + ]} + ] + } + ); + + expect(getByText('Resolved reply')).toBeInTheDocument(); + }); + it('shows resolved threads instead of the new form when all are resolved and expandResolved is set', () => { const {getByText, queryByPlaceholderText} = renderThreadList( , diff --git a/entry_types/scrolled/package/spec/review/ThreadsBadge-spec.js b/entry_types/scrolled/package/spec/review/ThreadsBadge-spec.js index 1a9575fa2d..ca70bdcc6a 100644 --- a/entry_types/scrolled/package/spec/review/ThreadsBadge-spec.js +++ b/entry_types/scrolled/package/spec/review/ThreadsBadge-spec.js @@ -20,8 +20,8 @@ function renderThreadsBadge(ui, {commentThreads = [], ...options} = {}) { describe('ThreadsBadge', () => { describe('unread comments', () => { useFakeTranslations({ - 'pageflow_scrolled.review.unread_comment_count.one': '1 unread comment', - 'pageflow_scrolled.review.unread_comment_count.other': '%{count} unread comments' + 'pageflow_scrolled.review.unread_count.one': '1 unread', + 'pageflow_scrolled.review.unread_count.other': '%{count} unread' }); const currentUser = {id: 42, name: 'Alice'}; @@ -48,7 +48,7 @@ describe('ThreadsBadge', () => { ); expect(getByRole('status')).toHaveClass(badgeStyles.unread); - expect(getByRole('status')).toHaveAttribute('aria-label', '1 unread comment'); + expect(getByRole('status')).toHaveAttribute('aria-label', '1 unread'); }); it('counts unread comments across threads of the subject', () => { @@ -63,7 +63,26 @@ describe('ThreadsBadge', () => { } ); - expect(getByRole('status')).toHaveAttribute('aria-label', '2 unread comments'); + expect(getByRole('status')).toHaveAttribute('aria-label', '2 unread'); + }); + + it('counts a resolution where resolved threads are shown', () => { + const resolved = { + ...threadWithComment(), + resolvedAt: '2026-08-17T13:00:00.000Z', + resolvedById: 44 + }; + + const {getByRole} = renderThreadsBadge( + , + { + currentUser, + commentThreads: [resolved], + commentThreadReads: {5: '2026-08-17T12:00:00.000Z'} + } + ); + + expect(getByRole('status')).toHaveAttribute('aria-label', '1 unread'); }); it('does not mark badge as unread once comments have been read', () => { @@ -141,6 +160,37 @@ describe('ThreadsBadge', () => { expect(getByRole('status')).toHaveTextContent('2'); }); + it('does not count a revealed resolved thread', () => { + const {getByRole} = renderThreadsBadge( + , + { + commentThreads: [ + {id: 1, subjectType: 'ContentElement', subjectId: 10, resolvedAt: null, comments: []}, + {id: 2, subjectType: 'ContentElement', subjectId: 10, resolvedAt: null, comments: []}, + {id: 3, subjectType: 'ContentElement', subjectId: 10, + resolvedAt: '2026-04-09T10:00:00Z', comments: []} + ] + } + ); + + expect(getByRole('status')).toHaveTextContent('2'); + }); + + it('still shows a badge for a revealed resolved thread on its own', () => { + const {getByRole} = renderThreadsBadge( + , + { + commentThreads: [ + {id: 3, subjectType: 'ContentElement', subjectId: 10, + resolvedAt: '2026-04-09T10:00:00Z', comments: []} + ] + } + ); + + expect(getByRole('status')).toBeInTheDocument(); + expect(getByRole('status')).toHaveClass(badgeStyles.resolved); + }); + it('counts a section\'s orphaned threads too', () => { const {getByRole} = renderThreadsBadge( , diff --git a/entry_types/scrolled/package/spec/review/activityEntries-spec.js b/entry_types/scrolled/package/spec/review/activityEntries-spec.js new file mode 100644 index 0000000000..0b0014df30 --- /dev/null +++ b/entry_types/scrolled/package/spec/review/activityEntries-spec.js @@ -0,0 +1,430 @@ +import {act} from '@testing-library/react'; + +import { + activityEntries, + useActivityEntries, + useUnreadThreadCount +} from 'review/activityEntries'; +import {postReviewStateThreadChangeMessage} from 'review/postMessage'; +import {renderHookWithReviewState} from 'support/renderWithReviewState'; + +const currentUser = {id: 42, name: 'Alice'}; + +function thread({id = 1, permaId = id + 4, comments = [], ...rest}) { + return {id, permaId, comments, ...rest}; +} + +function comment({id = 100, creatorId = 43, creatorName = 'Bob', body = 'A comment', createdAt}) { + return {id, creatorId, creatorName, body, createdAt}; +} + +describe('activityEntries', () => { + it('emits one entry per thread', () => { + const entries = activityEntries({ + threads: [thread({ + comments: [ + comment({id: 100, body: 'A topic', createdAt: '2026-08-17T09:00:00.000Z'}), + comment({id: 101, body: 'A reply', createdAt: '2026-08-17T10:00:00.000Z'}) + ] + })], + currentUser, + commentThreadReads: {} + }); + + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({key: 'thread-1', threadId: 1, threadPermaId: 5}); + }); + + it('carries the thread along for rendering', () => { + const commentThread = thread({ + comments: [comment({createdAt: '2026-08-17T09:00:00.000Z'})] + }); + + const entries = activityEntries({ + threads: [commentThread], + currentUser, + commentThreadReads: {} + }); + + expect(entries[0].thread).toBe(commentThread); + }); + + it('skips threads without any event', () => { + const entries = activityEntries({ + threads: [thread({comments: []})], + currentUser, + commentThreadReads: {} + }); + + expect(entries).toEqual([]); + }); + + describe('latest event', () => { + it('is the opening comment of a thread without replies', () => { + const entries = activityEntries({ + threads: [thread({ + comments: [comment({createdAt: '2026-08-17T09:00:00.000Z'})] + })], + currentUser, + commentThreadReads: {} + }); + + expect(entries[0]).toMatchObject({at: '2026-08-17T09:00:00.000Z'}); + }); + + it('is the most recent reply of a thread with replies', () => { + const entries = activityEntries({ + threads: [thread({ + comments: [ + comment({id: 100, createdAt: '2026-08-17T09:00:00.000Z'}), + comment({id: 101, createdAt: '2026-08-17T10:00:00.000Z'}) + ] + })], + currentUser, + commentThreadReads: {} + }); + + expect(entries[0]).toMatchObject({at: '2026-08-17T10:00:00.000Z'}); + }); + + it('is the resolution of a resolved thread', () => { + const entries = activityEntries({ + threads: [thread({ + comments: [comment({createdAt: '2026-08-17T09:00:00.000Z'})], + resolvedAt: '2026-08-17T10:00:00.000Z', + resolvedById: 44 + })], + currentUser, + commentThreadReads: {} + }); + + expect(entries[0]).toMatchObject({ + at: '2026-08-17T10:00:00.000Z', + resolved: true + }); + }); + }); + + describe('order', () => { + it('puts threads with the most recent activity first', () => { + const entries = activityEntries({ + threads: [ + thread({id: 1, comments: [comment({id: 100, createdAt: '2026-08-17T09:00:00.000Z'})]}), + thread({id: 2, comments: [comment({id: 200, createdAt: '2026-08-17T11:00:00.000Z'})]}) + ], + currentUser, + commentThreadReads: {} + }); + + expect(entries.map(entry => entry.threadId)).toEqual([2, 1]); + }); + + it('goes by the latest event rather than when the thread started', () => { + const entries = activityEntries({ + threads: [ + thread({ + id: 1, + comments: [ + comment({id: 100, createdAt: '2026-08-10T09:00:00.000Z'}), + comment({id: 101, createdAt: '2026-08-17T12:00:00.000Z'}) + ] + }), + thread({id: 2, comments: [comment({id: 200, createdAt: '2026-08-17T11:00:00.000Z'})]}) + ], + currentUser, + commentThreadReads: {} + }); + + expect(entries.map(entry => entry.threadId)).toEqual([1, 2]); + }); + + it('keeps threads sharing a timestamp in a stable order', () => { + const entries = activityEntries({ + threads: [ + thread({id: 2, comments: [comment({id: 200, createdAt: '2026-08-17T09:00:00.000Z'})]}), + thread({id: 1, comments: [comment({id: 100, createdAt: '2026-08-17T09:00:00.000Z'})]}) + ], + currentUser, + commentThreadReads: {} + }); + + expect(entries.map(entry => entry.threadId)).toEqual([1, 2]); + }); + }); + + describe('unseen', () => { + it('counts the events the reviewer has not seen', () => { + const entries = activityEntries({ + threads: [thread({ + permaId: 5, + comments: [ + comment({id: 100, createdAt: '2026-08-17T09:00:00.000Z'}), + comment({id: 101, createdAt: '2026-08-17T11:00:00.000Z'}), + comment({id: 102, createdAt: '2026-08-17T12:00:00.000Z'}) + ] + })], + currentUser, + commentThreadReads: {5: '2026-08-17T10:00:00.000Z'} + }); + + expect(entries[0]).toMatchObject({ + unreadCount: 2, + unreadCommentIds: [101, 102] + }); + }); + + it('does not count own comments', () => { + const entries = activityEntries({ + threads: [thread({ + comments: [comment({creatorId: 42, createdAt: '2026-08-17T11:00:00.000Z'})] + })], + currentUser, + commentThreadReads: {} + }); + + expect(entries[0]).toMatchObject({unreadCount: 0, unreadCommentIds: []}); + }); + + // A resolution has no read record of its own, so it goes by the + // thread's: opening the thread clears it. + it('counts a resolution by the read state of its thread', () => { + const threads = permaId => [thread({ + permaId, + comments: [comment({createdAt: '2026-08-17T09:00:00.000Z'})], + resolvedAt: '2026-08-17T11:00:00.000Z', + resolvedById: 44 + })]; + + const unread = activityEntries({ + threads: threads(5), + currentUser, + commentThreadReads: {5: '2026-08-17T10:00:00.000Z'} + }); + const read = activityEntries({ + threads: threads(6), + currentUser, + commentThreadReads: {6: '2026-08-17T12:00:00.000Z'} + }); + + expect(unread[0].unreadCount).toEqual(1); + expect(read[0].unreadCount).toEqual(0); + }); + + it('leaves the resolution out of the unseen comment ids', () => { + const entries = activityEntries({ + threads: [thread({ + permaId: 5, + comments: [ + comment({id: 100, createdAt: '2026-08-17T09:00:00.000Z'}), + comment({id: 101, createdAt: '2026-08-17T11:00:00.000Z'}) + ], + resolvedAt: '2026-08-17T12:00:00.000Z', + resolvedById: 44 + })], + currentUser, + commentThreadReads: {5: '2026-08-17T10:00:00.000Z'} + }); + + expect(entries[0]).toMatchObject({unreadCount: 2, unreadCommentIds: [101]}); + }); + + it('counts nothing while the current user is unknown', () => { + const entries = activityEntries({ + threads: [thread({ + comments: [comment({createdAt: '2026-08-17T11:00:00.000Z'})] + })], + currentUser: null, + commentThreadReads: {} + }); + + expect(entries[0].unreadCount).toEqual(0); + }); + }); + + describe('useActivityEntries', () => { + const seed = { + sections: [{id: 1, permaId: 100}], + contentElements: [{id: 1, permaId: 10, sectionId: 1, typeName: 'textBlock'}] + }; + + const commentThread = { + id: 1, permaId: 5, + subjectType: 'ContentElement', subjectId: 10, + comments: [ + {id: 100, creatorId: 43, body: 'A topic', createdAt: '2026-08-17T09:00:00.000Z'}, + {id: 101, creatorId: 43, body: 'A reply', createdAt: '2026-08-17T11:00:00.000Z'} + ] + }; + + it('derives entries from located threads and review state', () => { + const {result} = renderHookWithReviewState( + () => useActivityEntries(), + { + seed, + currentUser, + commentThreads: [commentThread], + commentThreadReads: {5: '2026-08-17T10:00:00.000Z'} + } + ); + + expect(result.current).toHaveLength(1); + expect(result.current[0]).toMatchObject({ + threadId: 1, + unreadCount: 1, + unreadCommentIds: [101] + }); + }); + + it('includes threads whose content element is gone', () => { + const orphan = { + id: 2, permaId: 6, + subjectType: 'ContentElement', subjectId: 999, + sectionPermaId: 100, + comments: [{id: 200, creatorId: 43, createdAt: '2026-08-17T09:00:00.000Z'}] + }; + + const {result} = renderHookWithReviewState( + () => useActivityEntries(), + {seed, currentUser, commentThreads: [orphan]} + ); + + expect(result.current).toHaveLength(1); + expect(result.current[0].thread.orphaned).toBe(true); + }); + + describe('while the feed is displayed', () => { + const older = { + id: 1, permaId: 5, + subjectType: 'ContentElement', subjectId: 10, + comments: [{id: 100, creatorId: 43, body: 'Older topic', + createdAt: '2026-08-17T09:00:00.000Z'}] + }; + + const newer = { + id: 2, permaId: 6, + subjectType: 'ContentElement', subjectId: 10, + comments: [{id: 200, creatorId: 43, body: 'Newer topic', + createdAt: '2026-08-17T11:00:00.000Z'}] + }; + + function renderEntries() { + return renderHookWithReviewState( + () => useActivityEntries(), + {seed, currentUser, commentThreads: [older, newer]} + ); + } + + // Posting crosses the window boundary, so it has to be flushed + // before the hook reflects it. + async function postThreadChange(thread) { + await act(async () => { + postReviewStateThreadChangeMessage(window, thread); + await new Promise(resolve => setTimeout(resolve, 0)); + }); + } + + function entryFor(entries, threadId) { + return entries.find(entry => entry.threadId === threadId); + } + + function withReply(thread, at) { + return { + ...thread, + comments: [...thread.comments, + {id: 300, creatorId: currentUser.id, body: 'A reply', createdAt: at}] + }; + } + + it('keeps threads in the place they had when it appeared', async () => { + const {result} = renderEntries(); + + await postThreadChange(withReply(older, '2026-08-17T12:00:00.000Z')); + + expect(result.current.map(entry => entry.threadId)).toEqual([2, 1]); + }); + + it('still reflects what was said', async () => { + const {result} = renderEntries(); + + await postThreadChange(withReply(older, '2026-08-17T12:00:00.000Z')); + + expect(entryFor(result.current, 1).thread.comments).toHaveLength(2); + }); + + it('keeps the time the place was taken', async () => { + const {result} = renderEntries(); + + await postThreadChange(withReply(older, '2026-08-17T12:00:00.000Z')); + + expect(entryFor(result.current, 1).at).toEqual('2026-08-17T09:00:00.000Z'); + }); + + it('orders threads that turn up later by their own time', async () => { + const {result} = renderEntries(); + + await postThreadChange({ + id: 3, permaId: 7, + subjectType: 'ContentElement', subjectId: 10, + comments: [{id: 400, creatorId: 43, body: 'Newest topic', + createdAt: '2026-08-17T13:00:00.000Z'}] + }); + + expect(result.current.map(entry => entry.threadId)).toEqual([3, 2, 1]); + }); + + it('reflects the new order on the next visit', async () => { + const {result: first} = renderEntries(); + + await postThreadChange(withReply(older, '2026-08-17T12:00:00.000Z')); + expect(first.current.map(entry => entry.threadId)).toEqual([2, 1]); + + const {result} = renderHookWithReviewState( + () => useActivityEntries(), + { + seed, + currentUser, + commentThreads: [ + {...older, + comments: [...older.comments, + {id: 300, creatorId: currentUser.id, body: 'A reply', + createdAt: '2026-08-17T12:00:00.000Z'}]}, + newer + ] + } + ); + + expect(result.current.map(entry => entry.threadId)).toEqual([1, 2]); + }); + }); + + describe('useUnreadThreadCount', () => { + it('counts the threads carrying something new', () => { + const {result} = renderHookWithReviewState( + () => useUnreadThreadCount(), + { + seed, + currentUser, + commentThreads: [commentThread], + commentThreadReads: {5: '2026-08-17T10:00:00.000Z'} + } + ); + + expect(result.current).toEqual(1); + }); + + it('counts nothing once the thread has been read', () => { + const {result} = renderHookWithReviewState( + () => useUnreadThreadCount(), + { + seed, + currentUser, + commentThreads: [commentThread], + commentThreadReads: {5: '2026-08-17T12:00:00.000Z'} + } + ); + + expect(result.current).toEqual(0); + }); + }); + }); +}); diff --git a/entry_types/scrolled/package/spec/review/commentThreadReadsSnapshot-spec.js b/entry_types/scrolled/package/spec/review/commentThreadReadsSnapshot-spec.js index 96773da29b..bf853ce25d 100644 --- a/entry_types/scrolled/package/spec/review/commentThreadReadsSnapshot-spec.js +++ b/entry_types/scrolled/package/spec/review/commentThreadReadsSnapshot-spec.js @@ -90,6 +90,45 @@ describe('CommentThreadReadsSnapshot', () => { expect(reads(getByTestId)).toEqual({5: '2026-08-17T10:00:00.000Z'}); }); + it('freezes anew when resetOn changes', async () => { + const {getByTestId, rerender} = renderWithReviewState( + + + , + {currentUser, commentThreads, commentThreadReads: {5: '2026-08-17T10:00:00.000Z'}} + ); + + await postReadsChange({5: '2026-08-17T12:00:00.000Z'}); + expect(reads(getByTestId)).toEqual({5: '2026-08-17T10:00:00.000Z'}); + + rerender( + + + + ); + + expect(reads(getByTestId)).toEqual({5: '2026-08-17T12:00:00.000Z'}); + }); + + it('holds still while resetOn stays the same', async () => { + const {getByTestId, rerender} = renderWithReviewState( + + + , + {currentUser, commentThreads, commentThreadReads: {5: '2026-08-17T10:00:00.000Z'}} + ); + + await postReadsChange({5: '2026-08-17T12:00:00.000Z'}); + + rerender( + + + + ); + + expect(reads(getByTestId)).toEqual({5: '2026-08-17T10:00:00.000Z'}); + }); + it('reuses the outer snapshot when nested', async () => { const {getByTestId} = renderWithReviewState( diff --git a/entry_types/scrolled/package/spec/review/rangeAnchors-spec.js b/entry_types/scrolled/package/spec/review/rangeAnchors-spec.js new file mode 100644 index 0000000000..d45a839c44 --- /dev/null +++ b/entry_types/scrolled/package/spec/review/rangeAnchors-spec.js @@ -0,0 +1,59 @@ +import React, {useEffect, useState} from 'react'; +import {render} from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; + +import {useRangeAnchors, RangeAnchor, useAnchoredFloating} from 'review/rangeAnchors'; + +describe('range anchors', () => { + function Anchored({rangeKey, anchors}) { + const {refs, hasAnchor} = useAnchoredFloating(rangeKey, anchors); + const [anchorText, setAnchorText] = useState(); + + useEffect(() => { + setAnchorText(refs.reference.current?.textContent); + }, [refs.reference, hasAnchor]); + + return ( + + {hasAnchor ? anchorText : 'none'} + + ); + } + + function Anchors({texts}) { + const {anchors, registerAnchor} = useRangeAnchors(); + + return ( +
+ {texts.map((text, index) => + + {text} + + )} + +
+ ); + } + + it('anchors a range to the first of its elements in the document', () => { + const {getByTestId} = render(); + + expect(getByTestId('anchored-a')).toHaveTextContent('first'); + }); + + it('keeps a range anchored while one of its elements goes away', () => { + const {getByTestId, rerender} = render(); + + rerender(); + + expect(getByTestId('anchored-a')).toHaveTextContent('first'); + }); + + it('drops the anchor once the last of its elements goes away', () => { + const {getByTestId, rerender} = render(); + + rerender(); + + expect(getByTestId('anchored-a')).toHaveTextContent('none'); + }); +}); diff --git a/entry_types/scrolled/package/spec/review/unreadComments-spec.js b/entry_types/scrolled/package/spec/review/unreadActivity-spec.js similarity index 52% rename from entry_types/scrolled/package/spec/review/unreadComments-spec.js rename to entry_types/scrolled/package/spec/review/unreadActivity-spec.js index b02437e3b6..aa18d29111 100644 --- a/entry_types/scrolled/package/spec/review/unreadComments-spec.js +++ b/entry_types/scrolled/package/spec/review/unreadActivity-spec.js @@ -1,7 +1,7 @@ -import {unreadComments, useUnreadComments} from 'review/unreadComments'; +import {isUnread, unreadActivity, useUnreadActivity} from 'review/unreadActivity'; import {renderHookWithReviewState} from 'support/renderWithReviewState'; -describe('unreadComments', () => { +describe('unreadActivity', () => { const currentUser = {id: 42, name: 'Alice'}; function thread(comments) { @@ -9,7 +9,7 @@ describe('unreadComments', () => { } it('returns comments created after read timestamp', () => { - const result = unreadComments( + const result = unreadActivity( thread([ {id: 100, creatorId: 43, createdAt: '2026-08-17T09:00:00.000Z'}, {id: 101, creatorId: 43, createdAt: '2026-08-17T11:00:00.000Z'} @@ -20,8 +20,48 @@ describe('unreadComments', () => { expect(result.map(comment => comment.id)).toEqual([101]); }); + it('counts a resolution by someone else', () => { + const result = unreadActivity( + { + ...thread([{id: 100, creatorId: 43, createdAt: '2026-08-17T09:00:00.000Z'}]), + resolvedAt: '2026-08-17T13:00:00.000Z', + resolvedById: 44 + }, + {currentUser, readAt: '2026-08-17T10:00:00.000Z'} + ); + + expect(result).toHaveLength(1); + expect(result[0].createdAt).toEqual('2026-08-17T13:00:00.000Z'); + }); + + it('does not count the reviewer resolving a thread themselves', () => { + const result = unreadActivity( + { + ...thread([{id: 100, creatorId: 43, createdAt: '2026-08-17T09:00:00.000Z'}]), + resolvedAt: '2026-08-17T13:00:00.000Z', + resolvedById: currentUser.id + }, + {currentUser, readAt: '2026-08-17T10:00:00.000Z'} + ); + + expect(result).toEqual([]); + }); + + it('does not count a resolution the reviewer has seen', () => { + const result = unreadActivity( + { + ...thread([{id: 100, creatorId: 43, createdAt: '2026-08-17T09:00:00.000Z'}]), + resolvedAt: '2026-08-17T13:00:00.000Z', + resolvedById: 44 + }, + {currentUser, readAt: '2026-08-17T14:00:00.000Z'} + ); + + expect(result).toEqual([]); + }); + it('returns all comments of never read thread', () => { - const result = unreadComments( + const result = unreadActivity( thread([ {id: 100, creatorId: 43, createdAt: '2026-08-17T09:00:00.000Z'}, {id: 101, creatorId: 43, createdAt: '2026-08-17T11:00:00.000Z'} @@ -33,7 +73,7 @@ describe('unreadComments', () => { }); it('excludes comments of current user', () => { - const result = unreadComments( + const result = unreadActivity( thread([ {id: 100, creatorId: 42, createdAt: '2026-08-17T11:00:00.000Z'}, {id: 101, creatorId: 43, createdAt: '2026-08-17T11:00:00.000Z'} @@ -45,7 +85,7 @@ describe('unreadComments', () => { }); it('returns nothing while current user is unknown', () => { - const result = unreadComments( + const result = unreadActivity( thread([{id: 100, creatorId: 43, createdAt: '2026-08-17T11:00:00.000Z'}]), {currentUser: null, readAt: undefined} ); @@ -57,7 +97,7 @@ describe('unreadComments', () => { const joinedUser = {...currentUser, unreadCommentsSinceAt: '2026-08-17T10:00:00.000Z'}; it('ignores comments from before the baseline', () => { - const result = unreadComments( + const result = unreadActivity( thread([{id: 100, creatorId: 43, createdAt: '2026-08-17T09:00:00.000Z'}]), {currentUser: joinedUser, readAt: undefined} ); @@ -66,7 +106,7 @@ describe('unreadComments', () => { }); it('returns comments from after the baseline', () => { - const result = unreadComments( + const result = unreadActivity( thread([{id: 100, creatorId: 43, createdAt: '2026-08-17T11:00:00.000Z'}]), {currentUser: joinedUser, readAt: undefined} ); @@ -76,7 +116,7 @@ describe('unreadComments', () => { // A thread read after the baseline has moved past it. it('prefers a later read timestamp over the baseline', () => { - const result = unreadComments( + const result = unreadActivity( thread([{id: 100, creatorId: 43, createdAt: '2026-08-17T11:00:00.000Z'}]), {currentUser: joinedUser, readAt: '2026-08-17T12:00:00.000Z'} ); @@ -86,7 +126,7 @@ describe('unreadComments', () => { // A thread last read before the baseline says nothing newer than it. it('prefers the baseline over an earlier read timestamp', () => { - const result = unreadComments( + const result = unreadActivity( thread([{id: 100, creatorId: 43, createdAt: '2026-08-17T09:30:00.000Z'}]), {currentUser: joinedUser, readAt: '2026-08-17T09:00:00.000Z'} ); @@ -96,7 +136,7 @@ describe('unreadComments', () => { }); it('compares timestamps of different time zone offsets', () => { - const result = unreadComments( + const result = unreadActivity( thread([{id: 100, creatorId: 43, createdAt: '2026-08-17T12:00:00.000+02:00'}]), {currentUser, readAt: '2026-08-17T11:00:00.000Z'} ); @@ -104,6 +144,75 @@ describe('unreadComments', () => { expect(result).toEqual([]); }); + describe('isUnread', () => { + it('is true for a comment created after the read timestamp', () => { + const result = isUnread( + {creatorId: 43, createdAt: '2026-08-17T11:00:00.000Z'}, + {currentUser, readAt: '2026-08-17T10:00:00.000Z'} + ); + + expect(result).toBe(true); + }); + + it('is false for a comment created before the read timestamp', () => { + const result = isUnread( + {creatorId: 43, createdAt: '2026-08-17T09:00:00.000Z'}, + {currentUser, readAt: '2026-08-17T10:00:00.000Z'} + ); + + expect(result).toBe(false); + }); + + it('is true for a comment in a never read thread', () => { + const result = isUnread( + {creatorId: 43, createdAt: '2026-08-17T09:00:00.000Z'}, + {currentUser, readAt: undefined} + ); + + expect(result).toBe(true); + }); + + it('is false for an event of the current user', () => { + const result = isUnread( + {creatorId: 42, createdAt: '2026-08-17T11:00:00.000Z'}, + {currentUser, readAt: undefined} + ); + + expect(result).toBe(false); + }); + + it('is false while the current user is unknown', () => { + const result = isUnread( + {creatorId: 43, createdAt: '2026-08-17T11:00:00.000Z'}, + {currentUser: null, readAt: undefined} + ); + + expect(result).toBe(false); + }); + + describe('with a baseline on the current user', () => { + const joinedUser = {...currentUser, unreadCommentsSinceAt: '2026-08-17T10:00:00.000Z'}; + + it('is false for an event from before the baseline', () => { + const result = isUnread( + {creatorId: 43, createdAt: '2026-08-17T09:00:00.000Z'}, + {currentUser: joinedUser, readAt: undefined} + ); + + expect(result).toBe(false); + }); + + it('is true for an event from after the baseline', () => { + const result = isUnread( + {creatorId: 43, createdAt: '2026-08-17T11:00:00.000Z'}, + {currentUser: joinedUser, readAt: undefined} + ); + + expect(result).toBe(true); + }); + }); + }); + describe('useUnreadComments', () => { it('reads current user and read timestamp from review state', () => { const commentThread = { @@ -118,7 +227,7 @@ describe('unreadComments', () => { }; const {result} = renderHookWithReviewState( - () => useUnreadComments(commentThread), + () => useUnreadActivity(commentThread), { currentUser, commentThreads: [commentThread], @@ -139,7 +248,7 @@ describe('unreadComments', () => { }; const {result} = renderHookWithReviewState( - () => useUnreadComments(commentThread), + () => useUnreadActivity(commentThread), {commentThreads: [commentThread]} ); diff --git a/entry_types/scrolled/package/spec/review/watchUnreadComments-spec.js b/entry_types/scrolled/package/spec/review/watchUnreadComments-spec.js index d77df4eb11..5813d69855 100644 --- a/entry_types/scrolled/package/spec/review/watchUnreadComments-spec.js +++ b/entry_types/scrolled/package/spec/review/watchUnreadComments-spec.js @@ -58,6 +58,19 @@ describe('watchUnreadComments', () => { expect(entry.get('hasUnreadComments')).toBe(false); }); + it('is true while a resolution by someone else is unseen', () => { + const {entry} = watch({ + currentUser, + commentThreads: [thread({ + resolvedAt: '2026-08-17T13:00:00.000Z', + resolvedById: 44 + })], + commentThreadReads: {5: '2026-08-17T12:00:00.000Z'} + }); + + expect(entry.get('hasUnreadComments')).toBe(true); + }); + it('is false for own comments', () => { const {entry} = watch({ currentUser, diff --git a/entry_types/scrolled/package/spec/support/pageObjects/commenting.js b/entry_types/scrolled/package/spec/support/pageObjects/commenting.js index bac9dd65f0..1b60fe07c7 100644 --- a/entry_types/scrolled/package/spec/support/pageObjects/commenting.js +++ b/entry_types/scrolled/package/spec/support/pageObjects/commenting.js @@ -42,7 +42,10 @@ export function renderEntry({ getCommentFilterButton: resolution => result.getByRole('button', {name: resolution === 'all' ? 'All' : 'Unresolved'}), getPreviousCommentButton: () => result.getByRole('button', {name: 'Previous comment'}), - getNextCommentButton: () => result.getByRole('button', {name: 'Next comment'}) + getNextCommentButton: () => result.getByRole('button', {name: 'Next comment'}), + getActivityButton: () => result.getByRole('button', {name: 'Latest activity'}), + getActivityPanel: () => result.getByRole('dialog', {name: 'Latest activity'}), + queryActivityPanel: () => result.queryByRole('dialog', {name: 'Latest activity'}) }; } @@ -74,7 +77,20 @@ export function useCommentingPageObjects() { 'pageflow_scrolled.review.filter.unresolved': 'Unresolved', 'pageflow_scrolled.review.filter.all': 'All', 'pageflow_scrolled.review.previous_comment': 'Previous comment', - 'pageflow_scrolled.review.next_comment': 'Next comment' + 'pageflow_scrolled.review.next_comment': 'Next comment', + 'pageflow_scrolled.review.activity.toggle': 'Latest activity', + 'pageflow_scrolled.review.activity.no_activity_yet': 'No activity yet', + 'pageflow_scrolled.review.activity.today': 'Today', + 'pageflow_scrolled.review.activity.yesterday': 'Yesterday', + 'pageflow_scrolled.review.reply_count.one': '1 reply', + 'pageflow_scrolled.review.reply_count.other': '%{count} replies', + 'pageflow_scrolled.review.earlier_reply_count.one': '1 more', + 'pageflow_scrolled.review.earlier_reply_count.other': '%{count} more', + 'pageflow_scrolled.review.resolve': 'Mark as resolved', + 'pageflow_scrolled.review.unresolve': 'Mark as unresolved', + 'pageflow_scrolled.review.thread_actions': 'Thread actions', + 'pageflow_scrolled.review.resolution_by': 'Marked as resolved by', + 'pageflow_scrolled.review.resolution': 'Marked as resolved' }); usePageObjects(); diff --git a/entry_types/scrolled/package/spec/support/pageObjects/inlineEditing.js b/entry_types/scrolled/package/spec/support/pageObjects/inlineEditing.js index 5829320f4b..4bfef69af8 100644 --- a/entry_types/scrolled/package/spec/support/pageObjects/inlineEditing.js +++ b/entry_types/scrolled/package/spec/support/pageObjects/inlineEditing.js @@ -148,6 +148,7 @@ function createCommentBadgePageObject(el) { isInDotMode: () => el.classList.contains(badgeStyles.dot), isActive: () => el.classList.contains(badgeStyles.active), isResolved: () => el.classList.contains(badgeStyles.resolved), + isUnread: () => el.classList.contains(badgeStyles.unread), select: () => fireEvent.click(el) }; } diff --git a/entry_types/scrolled/package/src/editor/controllers/PreviewMessageController.js b/entry_types/scrolled/package/src/editor/controllers/PreviewMessageController.js index 85b1fb990e..766b365897 100644 --- a/entry_types/scrolled/package/src/editor/controllers/PreviewMessageController.js +++ b/entry_types/scrolled/package/src/editor/controllers/PreviewMessageController.js @@ -200,10 +200,10 @@ export const PreviewMessageController = Object.extend({ }); if (type === 'contentElementComments' || type === 'sectionComments') { - // Stay on the current tab when the user is already on the - // comments route — only force the selection tab when arriving - // there from elsewhere. - if (!Backbone.history.fragment?.startsWith('scrolled/comments')) { + // Stay on the current tab when the user is already looking at + // the comments view — only force the selection tab when + // arriving there from elsewhere. + if (!onCommentsView() && message.data.payload.source !== 'editor') { this.editor.navigate('/scrolled/comments?tab=selection', {trigger: true}) } } @@ -344,3 +344,13 @@ function modelForSubject(entry, {subjectType, subjectId}) { const collection = subjectType === 'Section' ? entry.sections : entry.contentElements; return collection.findWhere({permaId: subjectId}); } + +// The comments view itself, but none of the routes below it: the +// activity feed lists every subject at once, so a subject picked in the +// preview has nowhere to appear there. +function onCommentsView() { + const fragment = Backbone.history.fragment || ''; + + return fragment === 'scrolled/comments' || + fragment.startsWith('scrolled/comments?'); +} diff --git a/entry_types/scrolled/package/src/editor/controllers/SideBarController.js b/entry_types/scrolled/package/src/editor/controllers/SideBarController.js index 66fedbed1f..de911442ce 100644 --- a/entry_types/scrolled/package/src/editor/controllers/SideBarController.js +++ b/entry_types/scrolled/package/src/editor/controllers/SideBarController.js @@ -8,6 +8,7 @@ import {EditSectionTransitionView} from '../views/EditSectionTransitionView'; import {EditSectionPaddingsView} from '../views/EditSectionPaddingsView'; import {EditContentElementView} from '../views/EditContentElementView'; import {CommentsView} from '../views/CommentsView'; +import {CommentActivityView} from '../views/CommentActivityView'; import {NewThreadView} from '../views/NewThreadView'; export const SideBarController = Marionette.Controller.extend({ @@ -57,6 +58,13 @@ export const SideBarController = Marionette.Controller.extend({ })); }, + commentActivity: function() { + this.region.show(new CommentActivityView({ + entry: this.entry, + editor + })); + }, + contentElement: function(id, tab) { this.region.show(new EditContentElementView({ entry: this.entry, diff --git a/entry_types/scrolled/package/src/editor/routers/SideBarRouter.js b/entry_types/scrolled/package/src/editor/routers/SideBarRouter.js index 375e8db858..d71d779e6d 100644 --- a/entry_types/scrolled/package/src/editor/routers/SideBarRouter.js +++ b/entry_types/scrolled/package/src/editor/routers/SideBarRouter.js @@ -4,6 +4,7 @@ export const SideBarRouter = Marionette.AppRouter.extend({ appRoutes: { 'scrolled/comments?tab=:tab': 'comments', 'scrolled/comments': 'comments', + 'scrolled/comments/activity': 'commentActivity', 'scrolled/chapters/:id': 'chapter', 'scrolled/sections/:id/transition': 'sectionTransition', 'scrolled/sections/:id/paddings?position=:position': 'sectionPaddings', diff --git a/entry_types/scrolled/package/src/editor/views/CommentActivityView.js b/entry_types/scrolled/package/src/editor/views/CommentActivityView.js new file mode 100644 index 0000000000..d7e26078ef --- /dev/null +++ b/entry_types/scrolled/package/src/editor/views/CommentActivityView.js @@ -0,0 +1,87 @@ +import React from 'react'; +import I18n from 'i18n-js'; +import Marionette from 'backbone.marionette'; + +import {editor} from 'pageflow/editor'; +import {TabsView} from 'pageflow/ui'; +import {ActivityList} from 'pageflow-scrolled/review'; + +import {ReviewView} from './ReviewView'; + +import styles from './CommentActivityView.module.css'; + +export const CommentActivityView = Marionette.ItemView.extend({ + className: `comment_activity_view ${styles.root}`, + + template: () => ` + ${I18n.t('pageflow_scrolled.editor.comment_activity_view.back')} +
+ `, + + ui: { + tabs: '.tabs' + }, + + events: { + 'click a.back': 'goBack' + }, + + onRender: function() { + const {entry} = this.options; + + const tabsView = new TabsView({ + i18n: 'pageflow_scrolled.editor.comment_activity_view.tabs' + }); + + tabsView.tab('activity', () => new ActivityListView({entry})); + + this.appendSubview(tabsView, {to: this.ui.tabs}); + }, + + goBack: function() { + editor.navigate('/scrolled/comments', {trigger: true}); + } +}); + +const ActivityListView = ReviewView.extend({ + className: styles.list, + + initialize() { + this._trackHighlight(); + + // Structure changes reach React through WatchEntryCollections and + // review state through the ReviewMessageHandler, so only the + // highlight needs a rerender here. + this.listenTo(this.options.entry, 'change:highlightedThreadId', () => { + this._trackHighlight(); + this.rerender(); + }); + }, + + props() { + return { + highlightedThreadId: this.selectedThreadId, + onEntryClick: entry => this._selectThread(entry.threadId) + }; + }, + + renderContent(props) { + return ; + }, + + _selectThread(threadId) { + this.selectedThreadId = threadId; + this.options.entry.trigger('selectCommentThread', threadId); + this.rerender(); + }, + + // The preview reports selected section threads without a highlighted id, + // so the id of the clicked row is kept here to survive the round trip. + _trackHighlight() { + const highlightedThreadId = this.options.entry.get('highlightedThreadId'); + + if (highlightedThreadId) { + this.selectedThreadId = highlightedThreadId; + } + } +}); diff --git a/entry_types/scrolled/package/src/editor/views/CommentActivityView.module.css b/entry_types/scrolled/package/src/editor/views/CommentActivityView.module.css new file mode 100644 index 0000000000..d3e9437c26 --- /dev/null +++ b/entry_types/scrolled/package/src/editor/views/CommentActivityView.module.css @@ -0,0 +1,7 @@ +.root > div { + margin: space(2.5) 0; +} + +.list { + padding-top: space(2); +} diff --git a/entry_types/scrolled/package/src/editor/views/CommentsView.js b/entry_types/scrolled/package/src/editor/views/CommentsView.js index 75ce799b48..9d175bd9ab 100644 --- a/entry_types/scrolled/package/src/editor/views/CommentsView.js +++ b/entry_types/scrolled/package/src/editor/views/CommentsView.js @@ -7,6 +7,8 @@ import {cssModulesUtils, TabsView} from 'pageflow/ui'; import {EntryCommentsView} from './EntryCommentsView'; import {SelectionCommentsView} from './SelectionCommentsView'; +import activityIcon from './images/activity.svg'; + import styles from './CommentsView.module.css'; export const CommentsView = Marionette.ItemView.extend({ @@ -25,7 +27,8 @@ export const CommentsView = Marionette.ItemView.extend({ events: { 'click a.back': 'goBack', ...cssModulesUtils.events(styles, { - 'click newThreadButton': 'startNewThread' + 'click newThreadButton': 'startNewThread', + 'click activityButton': 'showActivity' }) }, @@ -33,6 +36,9 @@ export const CommentsView = Marionette.ItemView.extend({ this.listenTo(this.options.entry, 'change:selectedCommentsSubject', this._updateNewThreadButton); + this.listenTo(this.options.entry, + 'change:hasUnreadComments', + this._updateActivityButton); }, onRender: function() { @@ -49,7 +55,13 @@ export const CommentsView = Marionette.ItemView.extend({ new SelectionCommentsView({entry, editor: editorApi})); this.appendSubview(tabsView, {to: this.ui.tabs}); + + // Beside the tab list rather than inside it, which is a tablist the + // link is not part of. + this.$('.tabs_view-scroller').append(activityButton()); + this._updateNewThreadButton(); + this._updateActivityButton(); }, startNewThread: function() { @@ -74,6 +86,10 @@ export const CommentsView = Marionette.ItemView.extend({ } }, + showActivity: function() { + editor.navigate('/scrolled/comments/activity', {trigger: true}); + }, + goBack: function() { editor.navigate('/', {trigger: true}); }, @@ -82,5 +98,26 @@ export const CommentsView = Marionette.ItemView.extend({ const enabled = !!this.options.entry.get('selectedCommentsSubject'); this.$(cssModulesUtils.selector(styles, 'newThreadButton')) .prop('disabled', !enabled); + }, + + _updateActivityButton: function() { + this.$(cssModulesUtils.selector(styles, 'activityButton')) + .toggleClass(styles.indicator, + !!this.options.entry.get('hasUnreadComments')); } }); + +function activityButton() { + const label = I18n.t('pageflow_scrolled.editor.comments_view.activity'); + + return ` + + `; +} + +function escapeCssUrl(url) { + return url.replace(/'/g, "\\'").replace(/\n/g, ''); +} diff --git a/entry_types/scrolled/package/src/editor/views/CommentsView.module.css b/entry_types/scrolled/package/src/editor/views/CommentsView.module.css index 68f370eb21..45a77f4563 100644 --- a/entry_types/scrolled/package/src/editor/views/CommentsView.module.css +++ b/entry_types/scrolled/package/src/editor/views/CommentsView.module.css @@ -23,3 +23,46 @@ composes: secondaryAddButton from './buttons.module.css'; float: right; } + +/* Rides the tab bar at its far end. The bar is the scroller's containing + block, and its tab list is absolutely positioned for the same reason. */ +.activityButton { + position: absolute; + top: 0; + right: 0; + display: flex; + align-items: center; + justify-content: center; + height: 100%; + background: none; + border: 0; + padding: 0 space(2); + cursor: pointer; +} + +.activityIcon { + width: 16px; + height: 16px; + background: var(--ui-on-surface-color-light); + mask-size: contain; + mask-repeat: no-repeat; + mask-position: center; +} + +.activityButton:hover .activityIcon { + background: var(--ui-primary-color); +} + +/* Matches a.indicator::after in app/assets/stylesheets/pageflow/editor/menu.scss, + which points at the same unseen comments from the sidebar root. */ +.indicator::after { + content: ""; + position: absolute; + top: space(1); + right: space(1); + width: space(1.5); + height: space(1.5); + border-radius: 50%; + background: var(--ui-warning-color); + box-shadow: 0 0 0 1px var(--ui-on-surface-color-lighter); +} diff --git a/entry_types/scrolled/package/src/editor/views/EntryCommentsView.js b/entry_types/scrolled/package/src/editor/views/EntryCommentsView.js index 69e5a783a2..f059da1b6c 100644 --- a/entry_types/scrolled/package/src/editor/views/EntryCommentsView.js +++ b/entry_types/scrolled/package/src/editor/views/EntryCommentsView.js @@ -152,6 +152,8 @@ function ContentElementGroup({ highlightedThreadId={groupHighlight} onThreadClick={onThreadClick} restrictInteractionsToHighlighted + startCollapsed + markReadWhenHighlighted showNewForm={false} hideNewTopicButton /> @@ -176,6 +178,8 @@ function SectionGroup({section, selectedSubject, highlightedThreadId, onThreadCl highlightedThreadId={groupHighlight} onThreadClick={onThreadClick} restrictInteractionsToHighlighted + startCollapsed + markReadWhenHighlighted showNewForm={false} hideNewTopicButton /> diff --git a/entry_types/scrolled/package/src/editor/views/ReviewView.module.css b/entry_types/scrolled/package/src/editor/views/ReviewView.module.css index 89824fa606..cce413cd26 100644 --- a/entry_types/scrolled/package/src/editor/views/ReviewView.module.css +++ b/entry_types/scrolled/package/src/editor/views/ReviewView.module.css @@ -1,10 +1,9 @@ .container { position: relative; --review-thread-box-shadow: none; - --review-thread-border: 1px solid var(--ui-on-surface-color-lightest); + --review-thread-border-color: var(--ui-on-surface-color-lightest); + --review-thread-border: 1px solid var(--review-thread-border-color); --review-resolved-threads-pill-align: flex-end; --review-resolved-threads-pill-color: var(--ui-on-surface-color); --review-resolved-threads-pill-background-color: transparent; - --review-resolved-thread-opacity: 0.6; - --review-resolved-thread-background: var(--ui-on-surface-color-lightest); } diff --git a/entry_types/scrolled/package/src/editor/views/images/activity.svg b/entry_types/scrolled/package/src/editor/views/images/activity.svg new file mode 100644 index 0000000000..2e2bdd30dc --- /dev/null +++ b/entry_types/scrolled/package/src/editor/views/images/activity.svg @@ -0,0 +1 @@ + diff --git a/entry_types/scrolled/package/src/frontend/commenting/ActivityButton.js b/entry_types/scrolled/package/src/frontend/commenting/ActivityButton.js new file mode 100644 index 0000000000..c58afaca4c --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/commenting/ActivityButton.js @@ -0,0 +1,128 @@ +import React, {useCallback, useEffect, useRef, useState} from 'react'; +import classNames from 'classnames'; +import { + useFloating, FloatingPortal, offset, shift, size, autoUpdate +} from '@floating-ui/react'; + +import {ActivityList, useUnreadThreadCount} from 'pageflow-scrolled/review'; +import {useI18n} from '../i18n'; +import {useFloatingPortalRoot} from '../FloatingPortalRootProvider'; +import {useCommentNavigation, useSelectedSubject} from './SelectedSubjectProvider'; + +import ActivityIcon from './images/activity.svg'; +import toolbarStyles from './FloatingToolbar.module.css'; +import styles from './ActivityButton.module.css'; + +// The default navigation sits across the top of the viewport - a 50px bar +// with an 8px progress bar under it - and the panel grows up into it. +const viewportPadding = {top: 74, right: 16, bottom: 16, left: 16}; + +export function ActivityButton() { + const {t} = useI18n({locale: 'ui'}); + const [open, setOpen] = useState(false); + + const unreadCount = useUnreadThreadCount(); + const label = t('pageflow_scrolled.review.activity.toggle'); + const {clearSelection} = useSelectedSubject(); + const portalRoot = useFloatingPortalRoot(); + + const {refs, floatingStyles} = useFloating({ + open, + strategy: 'fixed', + placement: 'top-end', + middleware: [ + offset(8), + shift({padding: viewportPadding}), + size({ + padding: viewportPadding, + apply({availableHeight, elements}) { + elements.floating.style.maxHeight = `${availableHeight}px`; + } + }) + ], + whileElementsMounted: autoUpdate + }); + + return ( + <> + + + {/* Out of the toolbar, whose view transition would snapshot the + panel along with it and whose clicks are exempt from dismissing + open popovers. */} + {open && + + setOpen(false)} /> + } + + ); +} + +const ActivityPanel = React.forwardRef(function ActivityPanel({style, onClose}, ref) { + const {t} = useI18n({locale: 'ui'}); + const {goToThread, highlightedThreadId} = useCommentNavigation(); + + const panelRef = useRef(); + + const setRefs = useCallback(node => { + panelRef.current = node; + ref(node); + }, [ref]); + + useEffect(() => { + function handleClick(event) { + if (panelRef.current?.contains(event.target)) return; + if (event.target.closest('[data-comment-toolbar]')) return; + + onClose(); + } + + // Claimed in the capture phase so that an open popover does not close + // itself on the same key. + function handleKeyDown(event) { + if (event.key === 'Escape') { + event.stopPropagation(); + onClose(); + } + } + + document.addEventListener('mousedown', handleClick); + document.addEventListener('keydown', handleKeyDown, true); + + return () => { + document.removeEventListener('mousedown', handleClick); + document.removeEventListener('keydown', handleKeyDown, true); + }; + }, [onClose]); + + return ( +
+
+ goToThread(entry.threadId, + {revealOnly: true})} /> +
+
+ ); +}); diff --git a/entry_types/scrolled/package/src/frontend/commenting/ActivityButton.module.css b/entry_types/scrolled/package/src/frontend/commenting/ActivityButton.module.css new file mode 100644 index 0000000000..17c554de0a --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/commenting/ActivityButton.module.css @@ -0,0 +1,48 @@ +.button { + position: relative; +} + +.unseenDot { + position: absolute; + top: space(0.5); + right: space(0.5); + width: space(2); + height: space(2); + border-radius: 50%; + background: var(--ui-warning-color); + box-shadow: 0 0 0 1px var(--ui-primary-color); +} + +/* Entry typography would otherwise reach in here, since this renders + inside the entry's own DOM. */ +.panel { + display: flex; + flex-direction: column; + width: space(96); + overflow: hidden; + font-family: var(--ui-font-family); + font-size: var(--ui-font-size); + line-height: normal; + text-align: left; + color: var(--ui-on-surface-color); + background: var(--ui-surface-color); + border-radius: rounded(lg); + box-shadow: var(--ui-box-shadow-md); + + --review-first-day-heading-margin-top: 0; + --review-thread-box-shadow: none; + --review-thread-border-color: var(--ui-on-surface-color-lightest); + --review-thread-border: 1px solid var(--review-thread-border-color); + --review-resolved-threads-pill-align: flex-end; + --review-resolved-threads-pill-color: var(--ui-on-surface-color); + --review-resolved-threads-pill-background-color: transparent; +} + +/* Scrolls inside the panel rather than the panel itself, so that the + scrollbar is clipped by the rounded corners instead of squaring them + off. */ +.scroller { + min-height: 0; + overflow-y: auto; + padding: space(3); +} diff --git a/entry_types/scrolled/package/src/frontend/commenting/EditableText.js b/entry_types/scrolled/package/src/frontend/commenting/EditableText.js index 0cb5451bce..ba93bea6a4 100644 --- a/entry_types/scrolled/package/src/frontend/commenting/EditableText.js +++ b/entry_types/scrolled/package/src/frontend/commenting/EditableText.js @@ -42,12 +42,14 @@ function CommentingEditableText({ const {anchors, registerAnchor} = useRangeAnchors(); const {contentElementPermaId} = useContentElementAttributes(); const {active, deactivate, preselect, clearPreselection} = useAddCommentMode(); - const {subjectRange, select} = useSelectedSubject('ContentElement', contentElementPermaId); + const {subjectRange, select, highlightedThreadId} = + useSelectedSubject('ContentElement', contentElementPermaId); const {resolution} = useCommentDisplayFilter(); const threads = useCommentThreads({ subjectType: 'ContentElement', subjectId: contentElementPermaId, - resolution + resolution, + revealedThreadId: highlightedThreadId }); const highlights = useCommentHighlights(threads, subjectRange); @@ -110,20 +112,21 @@ function CommentingEditableText({ function ClickableHighlight({subjectRange, children}) { const {contentElementPermaId} = useContentElementAttributes(); const {deactivate} = useAddCommentMode(); - const {isSelected, select} = useSelectedSubject('ContentElement', contentElementPermaId, subjectRange); + const {isSelected, revealOnly, select, highlightedThreadId} = + useSelectedSubject('ContentElement', contentElementPermaId, subjectRange); function handleClick(event) { if (event.target.closest('a')) return; - if (isSelected) return; + if (isSelected && !revealOnly) return; deactivate(); - select(); + select(revealOnly ? {highlightedThreadId} : undefined); } return ( {children} diff --git a/entry_types/scrolled/package/src/frontend/commenting/FloatingToolbar.js b/entry_types/scrolled/package/src/frontend/commenting/FloatingToolbar.js index e4483f85ab..875332b447 100644 --- a/entry_types/scrolled/package/src/frontend/commenting/FloatingToolbar.js +++ b/entry_types/scrolled/package/src/frontend/commenting/FloatingToolbar.js @@ -1,12 +1,13 @@ import React, {useEffect} from 'react'; import classNames from 'classnames'; -import {useLocatedCommentThreads, useUnreadCommentCount} from 'pageflow-scrolled/review'; +import {useLocatedCommentThreads, useUnreadThreadCount} from 'pageflow-scrolled/review'; import {useI18n} from '../i18n'; import {useAddCommentMode} from './AddCommentModeProvider'; import {useCommentDisplayFilter} from './CommentDisplayFilterProvider'; import {useCommentingVisibility} from './CommentingVisibilityProvider'; import {useCommentNavigation} from './SelectedSubjectProvider'; +import {ActivityButton} from './ActivityButton'; import AddCommentIcon from './images/addComment.svg'; import CancelCommentIcon from './images/cancelComment.svg'; @@ -38,6 +39,7 @@ export function FloatingToolbar() { + @@ -62,16 +64,15 @@ function HideCommentsButton() { function ShowCommentsButton() { const {t} = useI18n({locale: 'ui'}); const {toggle} = useCommentingVisibility(); - const {threads} = useLocatedCommentThreads(); - const unreadCommentCount = useUnreadCommentCount(threads); - const unread = unreadCommentCount > 0; + const unreadCount = useUnreadThreadCount(); + const unread = unreadCount > 0; // Named rather than only marked, since the dot is the sole cue that // comments are waiting behind the collapsed toolbar. const label = unread ? t('pageflow_scrolled.review.show_comments_with_unread', - {count: unreadCommentCount}) : + {count: unreadCount}) : t('pageflow_scrolled.review.show_comments'); return ( diff --git a/entry_types/scrolled/package/src/frontend/commenting/Popover.js b/entry_types/scrolled/package/src/frontend/commenting/Popover.js index 4857d111a2..f567e91ada 100644 --- a/entry_types/scrolled/package/src/frontend/commenting/Popover.js +++ b/entry_types/scrolled/package/src/frontend/commenting/Popover.js @@ -4,7 +4,7 @@ import { offset, flip, shift, autoUpdate } from '@floating-ui/react'; -import {ThreadsBadge, ThreadList, CommentThreadReadsSnapshot} from 'pageflow-scrolled/review'; +import {ThreadsBadge, ThreadList} from 'pageflow-scrolled/review'; import {useFloatingPortalRoot} from '../FloatingPortalRootProvider'; import {useCommentDisplayFilter} from './CommentDisplayFilterProvider'; import {useSelectedSubject} from './SelectedSubjectProvider'; @@ -15,7 +15,7 @@ export function Popover({ subjectType, subjectId, subjectRange, placement = 'bottom-start', strategy = 'absolute', hideNewTopicButton }) { - const {isSelected, showNewForm, select, clearSelection, highlightedThreadId} = + const {isSelected, revealOnly, showNewForm, select, clearSelection, highlightedThreadId} = useSelectedSubject(subjectType, subjectId, subjectRange); const {resolution} = useCommentDisplayFilter(); const [reference, setReference] = useState(null); @@ -33,36 +33,38 @@ export function Popover({ }, [isSelected, highlightedThreadId, reference]); function handleBadgeClick() { - if (isSelected) { + if (isSelected && !revealOnly) { clearSelection(); } else { - select(); + // A revealed subject opens its popover on the thread it was + // revealed for rather than starting over. + select(revealOnly ? {highlightedThreadId} : undefined); } } return ( - - - {isSelected && - } - + + + {isSelected && !revealOnly && + } ); } diff --git a/entry_types/scrolled/package/src/frontend/commenting/SectionDecorator.js b/entry_types/scrolled/package/src/frontend/commenting/SectionDecorator.js index 4e2769dce1..ea62dbf402 100644 --- a/entry_types/scrolled/package/src/frontend/commenting/SectionDecorator.js +++ b/entry_types/scrolled/package/src/frontend/commenting/SectionDecorator.js @@ -15,12 +15,13 @@ import styles from './SectionDecorator.module.css'; export function SectionDecorator({section, children}) { const {visible} = useCommentingVisibility(); const {active} = useAddCommentMode(); - const {isSelected} = useSelectedSubject('Section', section.permaId); + const {isSelected, highlightedThreadId} = useSelectedSubject('Section', section.permaId); const {resolution} = useCommentDisplayFilter(); const threads = useLocatedCommentThreadsForSubject({ subjectType: 'Section', subjectId: section.permaId, - resolution + resolution, + revealedThreadId: highlightedThreadId }); const hasThreads = threads.length > 0; diff --git a/entry_types/scrolled/package/src/frontend/commenting/SelectedSubjectProvider.js b/entry_types/scrolled/package/src/frontend/commenting/SelectedSubjectProvider.js index 5c6f340ab6..6b47793df5 100644 --- a/entry_types/scrolled/package/src/frontend/commenting/SelectedSubjectProvider.js +++ b/entry_types/scrolled/package/src/frontend/commenting/SelectedSubjectProvider.js @@ -14,7 +14,8 @@ const CommentNavigationContext = createContext({ count: 0, position: 0, goToNext: () => {}, - goToPrevious: () => {} + goToPrevious: () => {}, + goToThread: () => {} }); export function SelectedSubjectProvider({children}) { @@ -24,27 +25,18 @@ export function SelectedSubjectProvider({children}) { const [selectedSubject, setSelectedSubject] = useState(null); + const allTargets = useMemo(() => navigableTargets(chapters), [chapters]); + const targets = useMemo( - () => navigableTargets(chapters, resolution), - [chapters, resolution] + () => allTargets.filter(target => matchesResolution(target, resolution)), + [allTargets, resolution] ); const clearSelection = useCallback(() => { setSelectedSubject(null); }, []); - const goTo = useCallback(step => { - if (targets.length === 0) { - return; - } - - const current = currentTargetIndex(targets, selectedSubject); - const next = current < 0 - ? (step > 0 ? 0 : targets.length - 1) - : (current + step + targets.length) % targets.length; - - const target = targets[next]; - + const selectTarget = useCallback((target, options) => { // Activate the excursion the target lives in (or leave the current // one) before selecting it, so its popover can mount and open. Only // needed when moving to a different subject. @@ -61,9 +53,35 @@ export function SelectedSubjectProvider({children}) { subjectType: target.subjectType, subjectId: target.subjectId, subjectRange: target.subjectRange, - highlightedThreadId: target.threadId + highlightedThreadId: target.threadId, + ...options }); - }, [targets, selectedSubject, activateExcursionOfSection, returnFromExcursion]); + }, [selectedSubject, activateExcursionOfSection, returnFromExcursion]); + + const goTo = useCallback(step => { + if (targets.length === 0) { + return; + } + + const current = currentTargetIndex(targets, selectedSubject); + const next = current < 0 + ? (step > 0 ? 0 : targets.length - 1) + : (current + step + targets.length) % targets.length; + + selectTarget(targets[next]); + }, [targets, selectedSubject, selectTarget]); + + // Searched among all targets rather than the filtered ones, so that a + // resolved thread stays reachable from lists that show it whatever the + // toolbar filters. Showing it is left to the selection: turning all + // resolved threads on for the sake of one changes the whole preview. + const goToThread = useCallback((threadId, options) => { + const target = allTargets.find(target => target.threadId === threadId); + + if (target) { + selectTarget(target, options); + } + }, [allTargets, selectTarget]); const position = useMemo( () => currentTargetIndex(targets, selectedSubject) + 1, @@ -79,9 +97,11 @@ export function SelectedSubjectProvider({children}) { const navigation = useMemo(() => ({ count: targets.length, position, + highlightedThreadId: selectedSubject?.highlightedThreadId ?? null, goToNext: () => goTo(1), - goToPrevious: () => goTo(-1) - }), [targets.length, position, goTo]); + goToPrevious: () => goTo(-1), + goToThread + }), [targets.length, position, selectedSubject, goTo, goToThread]); return ( @@ -109,6 +129,9 @@ export function useSelectedSubject(subjectType, subjectId, subjectRange) { }, [setSelectedSubject, subjectType, subjectId, subjectRange]); return {isSelected, hasSelection: !!selectedSubject, select, clearSelection, + // Reveals the subject without opening its popover, for lists + // that show the thread themselves. + revealOnly: !!(isSelected && selectedSubject.revealOnly), showNewForm: isSelected && selectedSubject.showNewForm, subjectRange: isSelected ? selectedSubject.subjectRange : undefined, highlightedThreadId: isSelected ? selectedSubject.highlightedThreadId ?? null : null}; @@ -130,7 +153,7 @@ function currentTargetIndex(targets, selectedSubject) { return targets.findIndex(target => target.key === key); } -function navigableTargets(chapters, resolution) { +function navigableTargets(chapters) { const targets = []; chapters.forEach(chapter => { @@ -155,7 +178,7 @@ function navigableTargets(chapters, resolution) { }); }); - return targets.filter(target => matchesResolution(target, resolution)); + return targets; } function pushTargets(targets, threads, location) { diff --git a/entry_types/scrolled/package/src/frontend/commenting/images/activity.svg b/entry_types/scrolled/package/src/frontend/commenting/images/activity.svg new file mode 100644 index 0000000000..2e2bdd30dc --- /dev/null +++ b/entry_types/scrolled/package/src/frontend/commenting/images/activity.svg @@ -0,0 +1 @@ + diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/ContentElementDecorator.js b/entry_types/scrolled/package/src/frontend/inlineEditing/ContentElementDecorator.js index d1fcff2d53..4123facb12 100644 --- a/entry_types/scrolled/package/src/frontend/inlineEditing/ContentElementDecorator.js +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/ContentElementDecorator.js @@ -62,10 +62,11 @@ function DefaultSelectionRect(props) { subjectType: 'ContentElement', subjectId: props.permaId, getScrollTarget: useCallback(() => selectionRectRef.current, []), - selectThread: useCallback(threadId => selectComments({ + selectThread: useCallback((threadId, options) => selectComments({ type: 'contentElementComments', id: props.id, - highlightedThreadId: threadId + highlightedThreadId: threadId, + ...options }), [selectComments, props.id]) }); diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/BadgeColumn.js b/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/BadgeColumn.js index cf4d27edb8..b50d768311 100644 --- a/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/BadgeColumn.js +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/EditableText/BadgeColumn.js @@ -1,14 +1,16 @@ -import React, {useCallback} from 'react'; +import React, {useCallback, useMemo} from 'react'; import {Range, Transforms} from 'slate'; import {useSlate, ReactEditor} from 'slate-react'; -import {Badge, useAnchoredFloating} from 'pageflow-scrolled/review'; +import {Badge, useAnchoredFloating, useUnreadActivityCount} from 'pageflow-scrolled/review'; import {useContentElementCommentSelection} from '../useCommentSelection'; import {highlightOverlapsSelection} from './highlightOverlapsSelection'; import styles from './BadgeColumn.module.css'; +const noThreads = []; + export function BadgeColumn({highlights, anchors}) { const editor = useSlate(); const {highlightedThreadId} = useContentElementCommentSelection(); @@ -52,6 +54,12 @@ function PositionedBadge({editor, highlight, overlapSelection, anchors}) { const {refs, floatingStyles, hasAnchor} = useAnchoredFloating(highlight.key, anchors, {placement: 'left-start'}); + const threads = useMemo( + () => (highlight.thread ? [highlight.thread] : noThreads), + [highlight.thread] + ); + const unreadCount = useUnreadActivityCount(threads); + const handleClick = useCallback(() => { if (highlight.key === 'selection') { selectComments(); @@ -86,6 +94,7 @@ function PositionedBadge({editor, highlight, overlapSelection, anchors}) { ); diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/useCommentSelection.js b/entry_types/scrolled/package/src/frontend/inlineEditing/useCommentSelection.js index b85385a72a..bbda782350 100644 --- a/entry_types/scrolled/package/src/frontend/inlineEditing/useCommentSelection.js +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/useCommentSelection.js @@ -27,7 +27,8 @@ export function useCommentSelection({type, id, subjectType, subjectId}) { )); const selectThread = useCallback( - threadId => selectComments({type, id, highlightedThreadId: threadId}), + (threadId, options) => + selectComments({type, id, highlightedThreadId: threadId, ...options}), [selectComments, type, id] ); diff --git a/entry_types/scrolled/package/src/frontend/inlineEditing/useSelectCommentThreadHandler.js b/entry_types/scrolled/package/src/frontend/inlineEditing/useSelectCommentThreadHandler.js index 78758254c9..a54eedfd9d 100644 --- a/entry_types/scrolled/package/src/frontend/inlineEditing/useSelectCommentThreadHandler.js +++ b/entry_types/scrolled/package/src/frontend/inlineEditing/useSelectCommentThreadHandler.js @@ -12,7 +12,10 @@ import {usePostMessageListener} from '../../shared/usePostMessageListener'; // callers that need to prepare for it (e.g. move the editor cursor into // the thread's block). Shared by the content element and section // decorators and the EditableText editor so the handling lives in one -// place instead of inside each badge. +// place instead of inside each badge. The selection names the editor as +// its source, which tells the editor it is looking at its own request +// coming back rather than at the reviewer picking a subject in the +// preview. export function useSelectCommentThreadHandler({ subjectType, subjectId, getScrollTarget, beforeSelect, selectThread }) { @@ -33,6 +36,6 @@ export function useSelectCommentThreadHandler({ if (beforeSelect) beforeSelect(threadId); - selectThread(threadId); + selectThread(threadId, {source: 'editor'}); }, [threads, getScrollTarget, beforeSelect, selectThread])); } diff --git a/entry_types/scrolled/package/src/review/ActivityList.js b/entry_types/scrolled/package/src/review/ActivityList.js new file mode 100644 index 0000000000..113b67f83f --- /dev/null +++ b/entry_types/scrolled/package/src/review/ActivityList.js @@ -0,0 +1,200 @@ +import React, {useState} from 'react'; + +import {useI18n, useLocale} from 'pageflow-scrolled/frontend'; +import {Thread} from './Thread'; +import {CommentThreadReadsSnapshot} from './commentThreadReadsSnapshot'; +import {useActivityEntries} from './activityEntries'; +import {formatDate} from './formatDate'; +import {postUpdateThreadMessage} from './postMessage'; + +import styles from './ActivityList.module.css'; + +export function ActivityList({onEntryClick, highlightedThreadId, pageSize = 30}) { + return ( + + + + ); +} + +// Reading the entries has to happen inside the snapshot for the freeze to +// apply. +function Entries({onEntryClick, highlightedThreadId, pageSize}) { + const {t} = useI18n({locale: 'ui'}); + const entries = useActivityEntries(); + const [pages, setPages] = useState(1); + + if (!entries.length) { + return ( +

+ {t('pageflow_scrolled.review.activity.no_activity_yet')} +

+ ); + } + + const shown = entries.slice(0, pages * pageSize); + + return ( +
+ {dayGroups(shown).map(group => + + + {group.entries.map(entry => + onEntryClick(entry))} /> + )} + + )} + + {shown.length < entries.length && + } +
+ ); +} + +function Entry({entry, day, highlighted, onClick}) { + const {t} = useI18n({locale: 'ui'}); + const [expanded, setExpanded] = useState(false); + const [collapsed, setCollapsed] = useState(false); + + return ( +
+

{summary(t, entry, day)}

+ setExpanded(true)} + collapsed={collapsed} + onToggle={() => setCollapsed(!collapsed)} + showUnreadMarker + markReadWhenHighlighted + onClick={onClick} + highlighted={highlighted} + onResolve={() => postUpdateThreadMessage({ + threadId: entry.threadId, + resolved: !entry.resolved + })} /> +
+ ); +} + +// What happened to the thread on the day the row is listed under, which +// is what puts it there. +function summary(t, {thread}, day) { + const replies = thread.comments.slice(1); + + const parts = [ + onDay(thread.comments[0], day) && + t('pageflow_scrolled.review.activity.summary.topic'), + replyCountPart(t, replies.filter(reply => onDay(reply, day)).length), + thread.resolvedAt && dayOf(thread.resolvedAt) === day && + t('pageflow_scrolled.review.activity.summary.resolution') + ].filter(Boolean); + + return joinParts(t, parts); +} + +function replyCountPart(t, count) { + return count > 0 && + t('pageflow_scrolled.review.activity.summary.reply_count', {count}); +} + +function onDay(comment, day) { + return dayOf(comment.createdAt) === day; +} + +function joinParts(t, parts) { + if (parts.length < 2) { + return parts[0]; + } + + return [parts.slice(0, -1).join(', '), parts[parts.length - 1]] + .join(t('pageflow_scrolled.review.activity.summary.and')); +} + +// Everything said on the day the row is listed under, plus anything +// unseen from before it - folding that away would hide what the feed +// exists to surface. Falls back to the latest reply, so that a row never +// shows a thread without the comment it is listed for. +function visibleReplyCount({thread, unreadCommentIds}, day) { + const replies = thread.comments.slice(1); + + const starts = [ + replies.findIndex(reply => unreadCommentIds.includes(reply.id)), + replies.findIndex(reply => dayOf(reply.createdAt) === day) + ].filter(index => index >= 0); + + return starts.length ? replies.length - Math.min(...starts) : + Math.min(replies.length, 1); +} + +function DayHeading({day, at}) { + const {t} = useI18n({locale: 'ui'}); + const locale = useLocale({locale: 'ui'}); + + return ( +

+ +

+ ); +} + +// Grouping the shown slice rather than every entry keeps a day that spans +// the "show more" boundary from being headed twice. +function dayGroups(entries) { + const groups = []; + + entries.forEach(entry => { + const day = dayOf(entry.at); + const current = groups[groups.length - 1]; + + if (current && current.day === day) { + current.entries.push(entry); + } + else { + groups.push({day, at: entry.at, entries: [entry]}); + } + }); + + return groups; +} + +function dayOf(at) { + const date = new Date(at); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + + return `${date.getFullYear()}-${month}-${day}`; +} + +// Formatted from a timestamp within the day: a bare date parses as UTC +// midnight, which reads as the day before west of it. +function dayLabel(t, {day, at}, locale) { + const days = daysSince(day); + + if (days === 0) { + return t('pageflow_scrolled.review.activity.today'); + } + + if (days === 1) { + return t('pageflow_scrolled.review.activity.yesterday'); + } + + return formatDate(at, locale); +} + +function daysSince(day) { + const [year, month, date] = day.split('-').map(Number); + const then = new Date(year, month - 1, date); + const now = new Date(); + + return Math.round( + (new Date(now.getFullYear(), now.getMonth(), now.getDate()) - then) / 86400000 + ); +} diff --git a/entry_types/scrolled/package/src/review/ActivityList.module.css b/entry_types/scrolled/package/src/review/ActivityList.module.css new file mode 100644 index 0000000000..4fb35b0c0b --- /dev/null +++ b/entry_types/scrolled/package/src/review/ActivityList.module.css @@ -0,0 +1,66 @@ +.list { + display: flex; + flex-direction: column; + gap: space(3); +} + +.showMore { + align-self: center; + font: inherit; + font-weight: 500; + color: var(--ui-on-surface-color-light); + background: none; + border: none; + padding: space(2); + cursor: pointer; +} + +.showMore:hover { + color: var(--ui-primary-color); +} + +.blankSlate { + margin: 0; + padding: space(4) space(2); + text-align: center; + color: var(--ui-on-surface-color-light); +} + +.entry { + display: flex; + flex-direction: column; + gap: space(2); +} + +.summary { + margin: space(1) 0; + line-height: 1.4; + color: var(--ui-on-surface-color-light); +} + +/* The parts are fragments that get joined in whichever order the day + holds them, so the sentence is capitalized where it starts. */ +.summary::first-letter { + text-transform: uppercase; +} + +.dayHeading { + display: flex; + align-items: center; + gap: space(2); + margin: space(2) 0 0; + font-size: space(3); + font-weight: 500; + color: var(--ui-on-surface-color-light); +} + +.dayHeading:first-child { + margin-top: var(--review-first-day-heading-margin-top, space(2)); +} + +.dayHeading::before, +.dayHeading::after { + content: ''; + flex: 1; + border-bottom: solid 1px var(--ui-on-surface-color-lightest); +} diff --git a/entry_types/scrolled/package/src/review/Badge.js b/entry_types/scrolled/package/src/review/Badge.js index ac3d2bc04d..5e478bdfec 100644 --- a/entry_types/scrolled/package/src/review/Badge.js +++ b/entry_types/scrolled/package/src/review/Badge.js @@ -1,13 +1,17 @@ import React, {forwardRef} from 'react'; import classNames from 'classnames'; +import {useI18n} from 'pageflow-scrolled/frontend'; import CommentIcon from './images/comment.svg'; import styles from './Badge.module.css'; export const Badge = forwardRef(function Badge({ - counter, mode, resolved, unread, label, onClick + counter, hasThreads = counter > 0, mode, resolved, unreadCount = 0, onClick }, ref) { - const variant = resolveVariant(mode, counter > 0, unread); + const {t} = useI18n({locale: 'ui'}); + + const unread = unreadCount > 0; + const variant = resolveVariant(mode, hasThreads, unread); if (!variant) { return null; @@ -16,7 +20,10 @@ export const Badge = forwardRef(function Badge({ return ( diff --git a/entry_types/scrolled/package/src/review/ReplyForm.js b/entry_types/scrolled/package/src/review/ReplyForm.js index 66ed67d3be..53a6898af2 100644 --- a/entry_types/scrolled/package/src/review/ReplyForm.js +++ b/entry_types/scrolled/package/src/review/ReplyForm.js @@ -10,7 +10,7 @@ import SendIcon from './images/send.svg'; import SpinnerIcon from './images/spinner.svg'; import styles from './ReplyForm.module.css'; -export function ReplyForm({threadId, subjectType, subjectId, subjectRange}) { +export function ReplyForm({threadId, subjectType, subjectId, subjectRange, onSubmit}) { const {t} = useI18n({locale: 'ui'}); const {body, setBody, submitting} = useDraftedBody({threadId}); @@ -41,6 +41,8 @@ export function ReplyForm({threadId, subjectType, subjectId, subjectRange}) { if (!hasText || submitting) return; createComment(body); + + if (onSubmit) onSubmit(); } return ( diff --git a/entry_types/scrolled/package/src/review/ReviewStateProvider.js b/entry_types/scrolled/package/src/review/ReviewStateProvider.js index d20d7348ec..e89eee4dc7 100644 --- a/entry_types/scrolled/package/src/review/ReviewStateProvider.js +++ b/entry_types/scrolled/package/src/review/ReviewStateProvider.js @@ -182,7 +182,9 @@ export function useCommentThread(threadId) { return context?.commentThreads.find(t => t.id === threadId); } -export function useCommentThreads({subjectType, subjectId, subjectRange, resolution = 'all'} = {}) { +export function useCommentThreads({ + subjectType, subjectId, subjectRange, resolution = 'all', revealedThreadId +} = {}) { const context = useContext(ReviewStateContext); const commentThreads = context ? context.commentThreads : []; const hasSubject = subjectType !== undefined; @@ -196,9 +198,10 @@ export function useCommentThreads({subjectType, subjectId, subjectRange, resolut thread.subjectId === subjectId && (!rangeKey || JSON.stringify(thread.subjectRange) === rangeKey))) && - matchesResolution(thread, resolution) + (matchesResolution(thread, resolution) || thread.id === revealedThreadId) ); - }, [commentThreads, hasSubject, subjectType, subjectId, subjectRange, resolution]); + }, [commentThreads, hasSubject, subjectType, subjectId, subjectRange, resolution, + revealedThreadId]); } export function matchesResolution(thread, resolution) { diff --git a/entry_types/scrolled/package/src/review/Thread.js b/entry_types/scrolled/package/src/review/Thread.js index 1d21c33664..b7f4471739 100644 --- a/entry_types/scrolled/package/src/review/Thread.js +++ b/entry_types/scrolled/package/src/review/Thread.js @@ -1,15 +1,17 @@ import React, {useEffect, useMemo, useRef, useState} from 'react'; import classNames from 'classnames'; -import {useI18n} from 'pageflow-scrolled/frontend'; +import {useI18n, useLocale} from 'pageflow-scrolled/frontend'; import {AvatarStack} from './Avatar'; import {Comment} from './Comment'; +import {CommentMenu} from './CommentMenu'; +import {formatDate} from './formatDate'; import {ReplyForm} from './ReplyForm'; import {useCommentDraft} from './ReviewStateProvider'; import {useSubjectQuote} from './subjectQuote'; import {commentsWithOutdatedQuote} from './outdatedQuotes'; import {useMarkThreadReadWhenSeen} from './markThreadReadWhenSeen'; -import {useUnreadComments} from './unreadComments'; +import {useUnreadActivity} from './unreadActivity'; import {useScrollHighlightedThreadIntoView} from './scrollHighlightedThreadIntoView'; import ChevronIcon from './images/chevron.svg'; @@ -17,7 +19,7 @@ import ResolveIcon from './images/resolve.svg'; import UnresolveIcon from './images/unresolve.svg'; import styles from './Thread.module.css'; -export function Thread({thread, collapsed: collapsedProp, onToggle, onResolve, onClick, highlighted, showNewMarker, interactive = true}) { +export function Thread({thread, collapsed: collapsedProp, visibleReplyCount, onExpandReplies, onToggle, onReply, onResolve, onClick, highlighted, showUnreadMarker, markReadWhenHighlighted, interactive = true}) { const {t} = useI18n({locale: 'ui'}); const firstComment = thread.comments[0]; const replies = thread.comments.slice(1); @@ -29,25 +31,36 @@ export function Thread({thread, collapsed: collapsedProp, onToggle, onResolve, o const repliesCollapsed = collapsed && replies.length > 0; - const newComments = useUnreadComments(thread); - const newReplyCount = useMemo(() => { - const ids = new Set(newComments.map(comment => comment.id)); - return replies.filter(reply => ids.has(reply.id)).length; - }, [newComments, replies]); + // A collapsed thread has nothing folded - hiding both the fold and the + // count would leave no way back into it. + const foldedReplyCount = visibleReplyCount === undefined || repliesCollapsed ? + 0 : + Math.max(replies.length - visibleReplyCount, 0); + const shownReplies = foldedReplyCount > 0 ? + replies.slice(foldedReplyCount) : + replies; + + const unread = useUnreadActivity(thread); + const unreadIds = useMemo( + () => new Set(unread.map(event => event.id)), + [unread] + ); - const hidesNewReplies = repliesCollapsed && newReplyCount > 0; + const unreadResolution = unread.some(event => event.resolution); + const unreadReplyCount = replies.filter(reply => unreadIds.has(reply.id)).length; + const hidesUnreadReplies = repliesCollapsed && unreadReplyCount > 0; + const unreadTopic = !!firstComment && unreadIds.has(firstComment.id); // Where the unseen part of the thread starts. Only meaningful with // seen comments above it: a thread that is new all through says so // through its dot instead of repeating it at the very top. - const firstNewReplyId = useMemo(() => { - if (!newComments.length || newComments[0].id === firstComment?.id) { + const firstUnreadReplyId = useMemo(() => { + if (!unread.length || unread[0].id === firstComment?.id) { return null; } - const ids = new Set(newComments.map(comment => comment.id)); - return replies.find(reply => ids.has(reply.id))?.id; - }, [newComments, replies, firstComment]); + return replies.find(reply => unreadIds.has(reply.id))?.id; + }, [unread, unreadIds, replies, firstComment]); // Kept here rather than per comment so that a thread never shows two // textareas at once: neither two comments being edited, nor an edit next @@ -73,7 +86,17 @@ export function Thread({thread, collapsed: collapsedProp, onToggle, onResolve, o const ref = useRef(); const scrollHighlightedIntoView = useScrollHighlightedThreadIntoView(); - useMarkThreadReadWhenSeen({thread, ref, enabled: !repliesCollapsed}); + // Read state is one timestamp per thread, so a thread still keeping an + // unread comment out of sight would be marked read over comments never + // shown. Replies folded away for having been seen hide nothing. + const hiddenReplies = repliesCollapsed ? replies : replies.slice(0, foldedReplyCount); + const hidesUnread = hiddenReplies.some(reply => unreadIds.has(reply.id)); + + useMarkThreadReadWhenSeen({ + thread, + ref, + enabled: !hidesUnread && (highlighted || !markReadWhenHighlighted) + }); useEffect(() => { if (scrollHighlightedIntoView && highlighted && ref.current) { @@ -85,25 +108,18 @@ export function Thread({thread, collapsed: collapsedProp, onToggle, onResolve, o
{/* A lone thread needs no marker of its own: the badge that opened the list already says the same thing right next to it. */} - {showNewMarker && newComments.length > 0 && + {showUnreadMarker && unread.length > 0 && } - - {replies.length > 0 && - } + className={styles.unreadDot} + aria-label={t('pageflow_scrolled.review.unread_count', + {count: unread.length})} />} {thread.orphaned &&

@@ -115,23 +131,34 @@ export function Thread({thread, collapsed: collapsedProp, onToggle, onResolve, o showQuote={outdatedQuotes.has(firstComment.id)} {...editProps(firstComment)} />} - {repliesCollapsed && - } - {!collapsed && replies.map(comment => ( + {!collapsed && foldedReplyCount > 0 && + } + + {!collapsed && shownReplies.map(comment => ( - {comment.id === firstNewReplyId && -

- {t('pageflow_scrolled.review.new_replies')} + {comment.id === firstUnreadReplyId && +
+ {t('pageflow_scrolled.review.unread_replies')}
} ))} - {interactive && !thread.resolvedAt && !repliesCollapsed && !editing && + {interactive && !thread.resolvedAt && !repliesCollapsed && !foldedReplyCount && !editing && } - - {interactive && onResolve && !repliesCollapsed && -
- + subjectRange={thread.subjectRange} + onSubmit={onReply} />} + + {(thread.resolvedAt || (interactive && onResolve && !repliesCollapsed)) && +
+ {thread.resolvedAt ? + : + }
}
); } + + +function Resolution({thread, onUnresolve}) { + const {t} = useI18n({locale: 'ui'}); + const locale = useLocale({locale: 'ui'}); + + return ( + <> + +
+ + {t(thread.resolverName + ? 'pageflow_scrolled.review.resolution_by' + : 'pageflow_scrolled.review.resolution')} + + + {thread.resolverName && + {thread.resolverName}} + + +
+ + {onUnresolve && + } + + ); +} + +function FoldedReplies({count, onExpand}) { + const {t} = useI18n({locale: 'ui'}); + const label = t('pageflow_scrolled.review.earlier_reply_count', {count}); + + if (!onExpand) { + return
{label}
; + } + + return ( + + ); +} diff --git a/entry_types/scrolled/package/src/review/Thread.module.css b/entry_types/scrolled/package/src/review/Thread.module.css index b4d83849ae..2c719add46 100644 --- a/entry_types/scrolled/package/src/review/Thread.module.css +++ b/entry_types/scrolled/package/src/review/Thread.module.css @@ -7,7 +7,11 @@ background: var(--ui-surface-color); border-radius: rounded(lg); box-shadow: var(--review-thread-box-shadow, var(--ui-box-shadow)); - border: var(--review-thread-border, solid 1px var(--ui-surface-color)); + /* The folded seam is drawn in this too. Hosts restyling the border set + the color here as well, since a shorthand leaves nothing to read it + back out of. */ + --thread-border-color: var(--review-thread-border-color, var(--ui-surface-color)); + border: var(--review-thread-border, solid 1px var(--thread-border-color)); scroll-margin-top: space(11); } @@ -16,23 +20,28 @@ } .clickable:not(.highlighted):hover { - border-color: var(--review-thread-hover-border-color, var(--ui-accent-color-light)); + --thread-border-color: var(--review-thread-hover-border-color, var(--ui-accent-color-light)); + border-color: var(--thread-border-color); } -.highlighted:not(.resolved) { - --review-thread-border: solid 1px var(--ui-accent-color); +.highlighted { + --thread-border-color: var(--ui-accent-color); + --review-thread-border: solid 1px var(--thread-border-color); --review-thread-box-shadow: 0 0 0 space(1) var(--ui-accent-color-lighter); } -.resolved { - opacity: var(--review-resolved-thread-opacity, 1); - background: var(--review-resolved-thread-background, var(--ui-surface-color)); +/* Ties the specificity of the hover and highlight borders, so it has to + stay below them to win. */ +.thread.unreadTopic, +.thread.unreadTopic:hover { + --thread-border-color: var(--ui-warning-color-lighter); + border-color: var(--thread-border-color); } /* Locates the new thread among several. Matches the badge dot, which carries the same meaning one level up. Straddles the corner so it - stays clear of the chevron button inside it. */ -.newDot { + stays clear of the menu button inside it. */ +.unreadDot { position: absolute; top: space(-1); right: space(-1); @@ -49,18 +58,29 @@ gap: space(2); } -.newReplyCount::before { +/* Wraps between the counts but never inside one. Holding the chevron + outside keeps it off a line of its own. */ +.counts { + display: flex; + flex-wrap: wrap; + gap: space(1) space(2); + white-space: nowrap; +} + +/* Part of the reply count rather than of what it separates, so that it + stays behind on the first line when the two counts stack. */ +.count:has(+ .unreadReplyCount)::after { content: '·'; - margin-right: space(2); + margin-left: space(2); color: var(--ui-on-surface-color-light); } -.newReplyCount { +.unreadReplyCount { color: var(--ui-warning-color); font-weight: 600; } -.newRepliesDivider { +.unreadRepliesDivider { display: flex; align-items: center; gap: space(2); @@ -68,8 +88,8 @@ color: var(--ui-warning-color); } -.newRepliesDivider::before, -.newRepliesDivider::after { +.unreadRepliesDivider::before, +.unreadRepliesDivider::after { content: ''; flex: 1; height: 1px; @@ -84,23 +104,9 @@ color: var(--ui-on-surface-color-light); } -.chevronButton { - position: absolute; - top: space(3); - right: space(4); - display: flex; - align-items: center; - justify-content: center; - height: space(6); - color: var(--ui-on-surface-color-light); - background: none; - border: none; - cursor: pointer; - padding: 0; -} - -.chevronButton:hover { - color: var(--ui-on-surface-color); +.replyChevron { + width: space(3); + height: space(3); } .chevronExpanded { @@ -108,11 +114,14 @@ transition: transform 0.2s; } -.expandButton { +/* The height of the avatars it holds while collapsed, so that toggling + the replies does not shift everything below. */ +.repliesToggle { display: flex; align-items: center; justify-content: space-between; width: 100%; + min-height: space(6); gap: space(2); padding: 0 0 0 space(8); font: inherit; @@ -123,13 +132,14 @@ cursor: pointer; } -.expandButton:hover { +.repliesToggle:hover { color: var(--ui-primary-color); } .resolveRow { display: flex; - justify-content: center; + align-items: center; + gap: space(2); margin-top: space(2); padding-top: space(2); border-top: 1px solid var(--ui-on-surface-color-lightest); @@ -140,7 +150,7 @@ font-weight: 500; display: flex; align-items: center; - gap: space(1); + gap: space(2); color: var(--ui-on-surface-color-light); background: none; border: none; @@ -148,6 +158,94 @@ padding: 0; } -.resolveButton:hover { +.resolveButton:hover, +.resolveButton:focus-visible { + color: var(--ui-primary-color); +} + +/* Takes the width of an avatar, so that icon and text line up with the + comments above. */ +.resolveRowIcon { + flex-shrink: 0; + margin: 0 space(1); +} + +.resolveIcon { + composes: resolveRowIcon; + color: var(--ui-on-surface-color-lighter); +} + +.resolveButton:hover .resolveIcon, +.resolveButton:focus-visible .resolveIcon { + color: inherit; +} + +.unreadResolution .resolutionIcon, +.unreadResolution .resolution, +.unreadResolution .resolver { + color: var(--ui-warning-color); +} + +.resolution { + display: flex; + flex-direction: column; + gap: space(1); + margin-right: auto; + color: var(--ui-on-surface-color-light); +} + +.resolutionIcon { + composes: resolveRowIcon; + color: var(--ui-primary-color); +} + +.resolutionMeta { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0 space(2); +} + +.resolver { + font-weight: 600; + overflow-wrap: anywhere; + color: var(--ui-on-surface-color); +} + +/* Drawn as a mask so the zig-zag takes the surrounding color, which a + data URI could not read from a custom property. */ +.foldedReplies { + display: flex; + align-items: center; + gap: space(2); + margin: space(1) space(-3); + font-size: space(3); + color: var(--ui-on-surface-color-light); +} + +.foldedReplies::before, +.foldedReplies::after { + content: ''; + flex: 1; + height: 10px; + background: var(--thread-border-color); + mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='10' viewBox='0 0 16 10'%3E%3Cpath d='M0 8L4 2L8 8L12 2L16 8' fill='none' stroke='%23000' stroke-width='1'/%3E%3C/svg%3E"); + mask-size: 16px 10px; + mask-repeat: repeat-x; + mask-position: center; +} + +.foldedRepliesButton { + composes: foldedReplies; + font: inherit; + font-size: space(3); + background: none; + border: none; + padding: 0; + cursor: pointer; +} + +.foldedRepliesButton:hover, +.foldedRepliesButton:focus-visible { color: var(--ui-primary-color); } diff --git a/entry_types/scrolled/package/src/review/ThreadList.js b/entry_types/scrolled/package/src/review/ThreadList.js index e9b8dfff5c..70032088f9 100644 --- a/entry_types/scrolled/package/src/review/ThreadList.js +++ b/entry_types/scrolled/package/src/review/ThreadList.js @@ -13,7 +13,7 @@ import ChevronIcon from './images/chevron.svg'; import NewTopicIcon from './images/newTopic.svg'; import styles from './ThreadList.module.css'; -export function ThreadList({subjectType, subjectId, subjectRange, filter, highlightedThreadId, onThreadClick, restrictInteractionsToHighlighted, showNewForm: showNewFormProp, hideNewTopicButton, reversed, expandResolved}) { +export function ThreadList({subjectType, subjectId, subjectRange, filter, highlightedThreadId, onThreadClick, restrictInteractionsToHighlighted, showNewForm: showNewFormProp, hideNewTopicButton, reversed, expandResolved, startCollapsed, markReadWhenHighlighted}) { const {t} = useI18n({locale: 'ui'}); // Threads arrive already located: in display order, with orphans of @@ -40,14 +40,25 @@ export function ThreadList({subjectType, subjectId, subjectRange, filter, highli const noThreads = activeThreads.length === 0 && resolvedThreads.length === 0; const [draft] = useCommentDraft({subjectType, subjectId}); - const [expandedThreadId, setExpandedThreadId] = useState(null); + const [expandedThreadId, setExpandedThreadId] = useState( + () => startCollapsed ? undefined : + (soleThread(activeThreads) || soleThread(resolvedThreads))?.id + ); const [resolvedToggled, setResolvedToggled] = useState(null); + // A group highlight covers every thread of the subject; only one naming + // a single thread says the reviewer picked it out, which is what brings + // a resolved thread out of the fold. + const pickedThreadId = Array.isArray(highlightedThreadId) ? null : highlightedThreadId; + + const revealsResolved = + !!expandResolved || resolvedThreads.some(thread => thread.id === pickedThreadId); + const [formToggled, setFormToggled] = useState( showNewFormProp !== undefined ? showNewFormProp : - expandResolved ? noThreads : activeThreads.length === 0 + revealsResolved ? noThreads : activeThreads.length === 0 ); - const showResolved = resolvedToggled !== null ? resolvedToggled : !!expandResolved; + const showResolved = resolvedToggled !== null ? resolvedToggled : revealsResolved; // An unsent draft reopens the form and keeps it open while the thread is // being created. Callers passing showNewForm={false} suppress the form @@ -60,7 +71,7 @@ export function ThreadList({subjectType, subjectId, subjectRange, filter, highli } return ( - +
{!showNewForm && !hideNewTopicButton &&