Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
d5d6c49
Keep a badge anchored while any of its leaves survives
tf Aug 27, 2026
6a494b0
Speak of unread comments rather than new ones
tf Aug 26, 2026
fd9e8d0
Toggle a thread's replies from their count
tf Aug 25, 2026
94ed7a7
Order comments of a thread by id
tf Aug 24, 2026
115f2dd
Name the user that resolved a comment thread
tf Aug 19, 2026
e5bf7e0
Extract comment date formatting
tf Aug 19, 2026
86cc2be
Show who resolved a comment thread
tf Aug 24, 2026
d139385
Derive a chronological feed of comment activity
tf Aug 19, 2026
6cbd5ba
Hold the activity feed's order while it is displayed
tf Aug 24, 2026
2831344
Fold away earlier replies of a thread
tf Aug 24, 2026
60c03e5
Render a list of comment activity
tf Aug 19, 2026
94c6ff2
Group comment activity by date
tf Aug 24, 2026
f4497b5
Mark activity the reviewer has not seen
tf Aug 19, 2026
0c1d136
Show latest comment activity on its own sidebar route
tf Aug 19, 2026
1dcfc0d
Link to latest activity from the comments view
tf Aug 19, 2026
eb49677
Leave the activity feed when a comment is picked in the preview
tf Aug 26, 2026
1179352
Say a thread is resolved even while it is collapsed
tf Aug 26, 2026
514d237
Extract selecting a comment navigation target
tf Aug 26, 2026
3f3e93c
Open the latest activity from the comment toolbar
tf Aug 26, 2026
1d158d5
Count the collapsed toolbar's unseen marker like the feed
tf Aug 26, 2026
f28cab9
Reveal a thread from the activity feed without its popover
tf Aug 26, 2026
a6bcd05
Expand the thread a reply was sent to
tf Aug 27, 2026
6bb9e5d
Read a thread only where the reviewer opened it
tf Aug 27, 2026
8ef37b5
Outline a thread nobody has read yet
tf Aug 27, 2026
a996dd0
Show unseen comments on badges beside commented text
tf Aug 27, 2026
198e8e7
Refresh unread markers as the reviewer moves through a list
tf Aug 27, 2026
5ead6c4
Count a resolution as unread activity
tf Aug 27, 2026
593b779
Count a resolution in the entry list's comment indicator
tf Aug 27, 2026
a135408
Mark a resolution the reviewer has not seen
tf Aug 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/assets/stylesheets/pageflow/ui/properties.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 9 additions & 5 deletions app/helpers/pageflow/admin/entries_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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(', '))
Expand Down
4 changes: 3 additions & 1 deletion app/models/pageflow/comment_thread.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
80 changes: 53 additions & 27 deletions app/models/pageflow/entry_comment_summary.rb
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
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
# query per row.
#
# @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|
Expand All @@ -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
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
15 changes: 9 additions & 6 deletions config/locales/de.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 9 additions & 6 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
45 changes: 34 additions & 11 deletions entry_types/scrolled/config/locales/de.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Loading
Loading