Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 2 additions & 1 deletion admins/pageflow/entry.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ module Pageflow
entry_publication_state_indicator(entry)
end
column :title, sortable: 'title' do |entry|
link_to(entry.title, admin_entry_path(entry))
safe_join([link_to(entry.title, admin_entry_path(entry)),
entry_comments_indicator(entry)].compact)
end
column I18n.t('pageflow.admin.entries.members'), class: 'members' do |entry|
entry_user_badge_list(entry)
Expand Down
1 change: 1 addition & 0 deletions app/assets/images/pageflow/admin/icons/comment.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions app/assets/stylesheets/pageflow/admin.scss
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ $pageflow-hint-color: #666 !default;
@import "pageflow/admin/embed_code";
@import "pageflow/admin/embedded_index_table";
@import "pageflow/admin/entries";
@import "pageflow/admin/entry_comments_indicator";
@import "pageflow/admin/features";
@import "pageflow/admin/filters";
@import "pageflow/admin/forms";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
$pageflow-entry-comments-indicator-color: #8a5a00 !default;

$pageflow-entry-comments-indicator-background-color: #fdf1dc !default;

$pageflow-entry-comments-indicator-dot-color: #ff7400 !default;

$pageflow-entry-comments-indicator-icon-directory: "pageflow/admin/icons" !default;

.entry_comments_indicator {
$dir: $pageflow-entry-comments-indicator-icon-directory;

position: relative;
display: inline-block;
margin-left: 8px;
padding: 1px 6px 1px 22px;
vertical-align: middle;
border-radius: 10px;
background: $pageflow-entry-comments-indicator-background-color
image-url("#{$dir}/comment.svg") no-repeat 5px center / 13px 13px;
color: $pageflow-entry-comments-indicator-color;
font-size: 0.7rem;
line-height: 1.6;

// The tooltip claims both pseudo elements of the indicator, so the
// dot has to be an element of its own.
//
// Straddles the corner like the unread dot inside the editor, so both
// read as the same signal.
.unread_dot {
position: absolute;
top: -2px;
right: -2px;
width: 6px;
height: 6px;
border-radius: 50%;
background-color: $pageflow-entry-comments-indicator-dot-color;
}

// The bubble is placed by its static position, which on an inline
// element sits at the far end of the content and thus away from the
// arrow anchored to the left.
&::after {
left: 0;
}
}
14 changes: 14 additions & 0 deletions app/assets/stylesheets/pageflow/editor/menu.scss
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,18 @@ ul.menu {
display: block;
padding: space(2.5);
}

// Set right after the label rather than at the edge of the item, so
// it reads as belonging to the label it marks.
a.indicator::after {
content: "";
display: inline-block;
position: relative;
top: space(-2);
margin-left: space(0.5);
width: space(1);
height: space(1);
border-radius: 50%;
background: var(--ui-warning-color);
}
}
30 changes: 30 additions & 0 deletions app/controllers/pageflow/review/comment_thread_reads_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
module Pageflow
module Review
# @api private
class CommentThreadReadsController < Pageflow::ApplicationController
respond_to :json
before_action :authenticate_user!

def create
entry = DraftEntry.find(params[:entry_id])
authorize!(:read, entry.to_model)

CommentThreadRead.mark(entry: entry.to_model,
user: current_user,
comment_thread_perma_ids: known_perma_ids(entry))

head :no_content
end

private

# Guards against read records piling up for comment threads that
# do not exist in the entry.
def known_perma_ids(entry)
entry.comment_threads
.where(perma_id: params.fetch(:comment_thread_perma_ids, []))
.pluck(:perma_id)
end
end
end
end
2 changes: 2 additions & 0 deletions app/controllers/pageflow/review/comment_threads_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ def index
authorize!(:read, entry.to_model)

@comment_threads = entry.comment_threads.includes(comments: :creator)
@read_at_by_perma_id =
CommentThreadRead.read_at_by_perma_id(entry: entry.to_model, user: current_user)
end

def create
Expand Down
36 changes: 36 additions & 0 deletions app/helpers/pageflow/admin/entries_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,42 @@ def collection_for_entry_publication_states
end
end

def entry_comments_indicator(entry, summaries: entry_comment_summaries)
summary = summaries[entry.id]
return unless summary&.any?

content_tag(:span,
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)
end
end

# Built for the whole page at once, so that rendering a row does
# not query. Views without an index table collection pass their own
# summaries instead.
def entry_comment_summaries
@entry_comment_summaries ||=
EntryCommentSummary.for_entries(collection, user: current_user)
end

def entry_comments_tooltip(summary)
scope = 'pageflow.admin.entries.comments'

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)
end

if summary.new_reply_count.positive?
parts << t("#{scope}.new_reply_count", count: summary.new_reply_count)
end

t("#{scope}.tooltip", summary: parts.join(', '))
end

def entry_type_collection(entry_types = Pageflow.config.entry_types)
entry_types.map(&:name).index_by do |type|
I18n.t(type, scope: 'activerecord.values.pageflow/entry.type_names')
Expand Down
22 changes: 22 additions & 0 deletions app/models/pageflow/comment_thread_read.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
module Pageflow
# Records when a user last read a comment thread. Keyed by perma id
# so read state survives comment threads being copied to a new
# revision.
#
# @api private
class CommentThreadRead < ApplicationRecord
belongs_to :entry
belongs_to :user

def self.read_at_by_perma_id(entry:, user:)
where(entry:, user:).pluck(:comment_thread_perma_id, :read_at).to_h
end

def self.mark(entry:, user:, comment_thread_perma_ids:, read_at: Time.current)
comment_thread_perma_ids.each do |perma_id|
find_or_initialize_by(entry:, user:, comment_thread_perma_id: perma_id)
.update!(read_at:)
end
end
end
end
2 changes: 2 additions & 0 deletions app/models/pageflow/entry.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ class PasswordMissingError < StandardError

has_many :imports, class_name: 'Pageflow::FileImport', dependent: :destroy

has_many :comment_thread_reads, dependent: :destroy

has_one :draft, -> { editable }, class_name: 'Revision', inverse_of: :entry
has_one :published_revision, -> { published }, class_name: 'Revision', inverse_of: :entry

Expand Down
88 changes: 88 additions & 0 deletions app/models/pageflow/entry_comment_summary.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
module Pageflow
# Counts of comment topics and comments 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

def self.for_entries(entries, user:)
entries = entries.to_a
return {} if entries.empty?

threads = unresolved_threads_by_entry_id(entries)
read_at = read_at_by_entry_id(entries, user)

entries.to_h do |entry|
[entry.id, build(threads.fetch(entry.id, []),
read_at: read_at.fetch(entry.id, {}),
user:)]
end
end

def initialize(topic_count:, new_topic_count:, new_reply_count:)
@topic_count = topic_count
@new_topic_count = new_topic_count
@new_reply_count = new_reply_count
end

def any?
topic_count.positive?
end

def new?
new_topic_count.positive? || new_reply_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)
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)
.includes(:comments)
.group_by { |thread| entry_id_by_revision_id[thread.revision_id] }
end
private_class_method :unresolved_threads_by_entry_id

def self.read_at_by_entry_id(entries, user)
CommentThreadRead
.where(user:, entry_id: entries.map(&:id))
.pluck(:entry_id, :comment_thread_perma_id, :read_at)
.group_by(&:first)
.transform_values do |rows|
rows.to_h { |(_entry_id, perma_id, read_at)| [perma_id, read_at] }
end
end
private_class_method :read_at_by_entry_id

def self.build(threads, read_at:, user:)
new_topics = 0
new_replies = 0

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_topics += 1 if first && unread?(first, seen_up_to, user)
new_replies += replies.count { |reply| unread?(reply, seen_up_to, user) }
end

new(topic_count: threads.size, new_topic_count: new_topics, new_reply_count: new_replies)
end
private_class_method :build

# 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)
end
private_class_method :unread?
end
end
3 changes: 3 additions & 0 deletions app/views/pageflow/review/comment_threads/index.json.jbuilder
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@ json.key_format!(camelize: :lower)
json.current_user do
json.id current_user.id
json.name current_user.full_name
json.unread_comments_since_at current_user.unread_comments_since_at
end

json.comment_threads(@comment_threads) do |comment_thread|
json.partial!('pageflow/review/comment_threads/comment_thread', comment_thread:)
end

json.comment_thread_reads(@read_at_by_perma_id.transform_keys(&:to_s))
11 changes: 11 additions & 0 deletions config/locales/de.yml
Original file line number Diff line number Diff line change
Expand Up @@ -828,6 +828,17 @@ de:
site_defaults_inline_help: Die folgenden Einstellungen werden als Standard für neue Beiträge des Kontos verwendet. Änderungen wirken sich nicht auf existierende Beiträge aus.
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"
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
11 changes: 11 additions & 0 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -827,6 +827,17 @@ en:
site_defaults_inline_help: The following settings will be used as defaults for new stories in this account. Changes do not affect existing stories.
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"
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
2 changes: 2 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@
resources :comment_threads, only: [:index, :create, :update] do
resources :comments, only: [:create, :update]
end

resources :comment_thread_reads, only: [:create]
end
end

Expand Down
16 changes: 16 additions & 0 deletions db/migrate/20260817000000_create_comment_thread_reads.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
class CreateCommentThreadReads < ActiveRecord::Migration[6.0]
def change
create_table :pageflow_comment_thread_reads do |t|
t.integer :entry_id, null: false
t.integer :user_id, null: false
t.integer :comment_thread_perma_id, null: false
t.datetime :read_at, null: false
end

add_index :pageflow_comment_thread_reads,
[:user_id, :entry_id, :comment_thread_perma_id],
unique: true,
name: 'index_comment_thread_reads_on_user_and_entry_and_thread'
add_index :pageflow_comment_thread_reads, :entry_id
end
end
18 changes: 18 additions & 0 deletions db/migrate/20260819000000_add_unread_comments_since_at_to_users.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
class AddUnreadCommentsSinceAtToUsers < ActiveRecord::Migration[7.1]
class MigratedUser < ActiveRecord::Base
self.table_name = 'users'
end

def up
add_column :users, :unread_comments_since_at, :datetime

# Comments written before the feature existed have not gone unread:
# without a baseline, every one of them would turn up as unread for
# every user at once.
MigratedUser.update_all(unread_comments_since_at: Time.current)
end

def down
remove_column :users, :unread_comments_since_at
end
end
Loading
Loading