Skip to content

Commit 85b9581

Browse files
committed
Add ability to star/bookmark topics
This is a quicker way to watch something than adding proper/notes tags, and gmail similarly supports both. Basically allows users to follow something simply with 1 click.
1 parent 7fc48a1 commit 85b9581

20 files changed

Lines changed: 265 additions & 10 deletions

app/assets/stylesheets/components/topics.css

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,20 @@
193193
}
194194
}
195195

196+
.activity-star {
197+
background-color: var(--color-bg-activity-team);
198+
.topic-icon-badge {
199+
background-color: var(--color-bg-activity-team);
200+
}
201+
}
202+
203+
.activity-star.is-starred {
204+
background-color: var(--color-bg-activity-user);
205+
.topic-icon-badge {
206+
background-color: var(--color-bg-activity-user);
207+
}
208+
}
209+
196210
.is-hidden {
197211
display: none !important;
198212
}

app/assets/stylesheets/layouts/topic-view.css

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,3 +282,9 @@
282282
color: var(--color-text-muted);
283283
font-size: var(--font-size-sm);
284284
}
285+
286+
.topic-title-with-star {
287+
display: flex;
288+
align-items: center;
289+
gap: var(--spacing-3);
290+
}

app/controllers/topics_controller.rb

Lines changed: 89 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
class TopicsController < ApplicationController
2-
before_action :set_topic, only: [:show, :aware, :read_all]
3-
before_action :require_authentication, only: [:aware, :aware_bulk, :aware_all, :read_all]
2+
before_action :set_topic, only: [:show, :aware, :read_all, :star, :unstar]
3+
before_action :require_authentication, only: [:aware, :aware_bulk, :aware_all, :read_all, :star, :unstar]
44
before_action :require_team_membership, only: [:index, :new_topics_count]
55

66
def index
@@ -45,7 +45,10 @@ def show
4545
build_participants_sidebar_data(messages_scope)
4646
build_thread_outline(messages_scope)
4747
load_commitfest_sidebar
48-
load_notes if user_signed_in?
48+
if user_signed_in?
49+
load_notes
50+
load_star_state
51+
end
4952
end
5053

5154
def aware
@@ -93,6 +96,30 @@ def read_all
9396
end
9497
end
9598

99+
def star
100+
TopicStar.create!(user: current_user, topic: @topic)
101+
respond_to do |format|
102+
format.turbo_stream { render :update_star_state }
103+
format.json { render json: { starred: true } }
104+
format.html { redirect_to topic_path(@topic) }
105+
end
106+
rescue ActiveRecord::RecordNotUnique
107+
respond_to do |format|
108+
format.turbo_stream { render :update_star_state }
109+
format.json { render json: { starred: true } }
110+
format.html { redirect_to topic_path(@topic) }
111+
end
112+
end
113+
114+
def unstar
115+
TopicStar.where(user: current_user, topic: @topic).destroy_all
116+
respond_to do |format|
117+
format.turbo_stream { render :update_star_state }
118+
format.json { render json: { starred: false } }
119+
format.html { redirect_to topic_path(@topic) }
120+
end
121+
end
122+
96123
def search
97124
@search_query = params[:q].to_s.strip
98125

@@ -123,6 +150,7 @@ def user_state
123150
preload_topic_states
124151
preload_note_counts
125152
preload_participation_flags
153+
preload_star_counts
126154

127155
payload = topic_ids.index_with do |tid|
128156
state = @topic_states[tid] || {}
@@ -139,6 +167,7 @@ def user_state
139167
team: participation[:team],
140168
aliases_count: Array(participation[:aliases]).size
141169
}
170+
star_data = @topic_star_data&.dig(tid) || { starred_by_me: false, team_starrers: [] }
142171
{
143172
status: state[:status],
144173
progress: state[:progress],
@@ -147,7 +176,8 @@ def user_state
147176
aware_until: state[:aware_until],
148177
team_readers: readers,
149178
note_count: @topic_note_counts&.dig(tid).to_i,
150-
participation: participation_payload
179+
participation: participation_payload,
180+
star: star_data
151181
}
152182
end
153183

@@ -164,6 +194,7 @@ def user_state_frame
164194
preload_note_counts
165195
preload_participation_flags
166196
preload_commitfest_summaries
197+
preload_star_counts
167198

168199
respond_to do |format|
169200
format.turbo_stream
@@ -360,6 +391,44 @@ def preload_note_counts
360391
.count
361392
end
362393

394+
def preload_star_counts
395+
topic_ids = @topics.map(&:id)
396+
return if topic_ids.empty?
397+
return unless user_signed_in?
398+
399+
my_stars = TopicStar.where(user: current_user, topic_id: topic_ids)
400+
.pluck(:topic_id)
401+
.to_set
402+
403+
team_ids = TeamMember.where(user_id: current_user.id).pluck(:team_id)
404+
team_stars = {}
405+
406+
if team_ids.any?
407+
teammate_ids = TeamMember.where(team_id: team_ids)
408+
.where.not(user_id: current_user.id)
409+
.pluck(:user_id)
410+
411+
if teammate_ids.any?
412+
stars = TopicStar.where(user_id: teammate_ids, topic_id: topic_ids)
413+
.includes(user: { person: :default_alias })
414+
415+
stars.each do |star|
416+
team_stars[star.topic_id] ||= []
417+
alias_record = star.user.person&.default_alias || star.user.aliases&.first
418+
team_stars[star.topic_id] << alias_record if alias_record
419+
end
420+
end
421+
end
422+
423+
@topic_star_data = {}
424+
@topics.each do |topic|
425+
@topic_star_data[topic.id] = {
426+
starred_by_me: my_stars.include?(topic.id),
427+
team_starrers: team_stars[topic.id] || []
428+
}
429+
end
430+
end
431+
363432
def load_visible_tags
364433
@available_note_tags = NoteTag.joins(:note)
365434
.merge(Note.active.visible_to(current_user))
@@ -553,6 +622,18 @@ def apply_filters(base_query)
553622
member_alias_ids = Alias.where(person_id: member_person_ids).select(:id)
554623
base_query = base_query.where(id: Message.where(sender_id: member_alias_ids).select(:topic_id))
555624
end
625+
when "starred_by_me"
626+
if current_user_id
627+
base_query = base_query.joins(:topic_stars)
628+
.where(topic_stars: { user_id: current_user_id })
629+
end
630+
when "starred_by_team"
631+
if team_id && current_user_id
632+
member_ids = TeamMember.where(team_id: team_id).select(:user_id)
633+
base_query = base_query.joins(:topic_stars)
634+
.where(topic_stars: { user_id: member_ids })
635+
.distinct
636+
end
556637
end
557638
base_query
558639
end
@@ -663,6 +744,10 @@ def load_notes
663744
end
664745
end
665746

747+
def load_star_state
748+
@is_starred = TopicStar.exists?(user: current_user, topic: @topic)
749+
end
750+
666751
def load_cached_search_results
667752
@viewing_since = viewing_since_param
668753
longpage = params[:longpage].to_i

app/models/topic.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ class Topic < ApplicationRecord
55
has_many :notes, dependent: :destroy
66
has_many :commitfest_patch_topics, dependent: :destroy
77
has_many :commitfest_patches, through: :commitfest_patch_topics
8-
8+
has_many :topic_stars, dependent: :destroy
9+
has_many :starring_users, through: :topic_stars, source: :user
10+
911
validates :title, presence: true
1012

1113
def participant_count

app/models/topic_star.rb

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
class TopicStar < ApplicationRecord
2+
belongs_to :user
3+
belongs_to :topic
4+
5+
validates :user_id, uniqueness: { scope: :topic_id }
6+
7+
def self.toggle_star(user:, topic:)
8+
existing = find_by(user: user, topic: topic)
9+
if existing
10+
existing.destroy
11+
false
12+
else
13+
create!(user: user, topic: topic)
14+
true
15+
end
16+
end
17+
18+
def self.starred_by_user?(user:, topic:)
19+
exists?(user: user, topic: topic)
20+
end
21+
end

app/models/user.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ class User < ApplicationRecord
1111
has_many :notes, foreign_key: :author_id
1212
has_many :note_edits, foreign_key: :editor_id
1313
has_many :activities
14+
has_many :topic_stars, dependent: :destroy
15+
has_many :starred_topics, through: :topic_stars, source: :topic
1416

1517
scope :active, -> { where(deleted_at: nil) }
1618

app/views/topics/_sidebar.html.slim

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@
2626
li = link_to "New for me", topics_path(filter: "new_for_me")
2727
li = link_to "Started by me", topics_path(filter: "started_by_me")
2828
li = link_to "I posted here", topics_path(filter: "messaged_by_me")
29+
li = link_to "Starred by me", topics_path(filter: "starred_by_me")
2930
- current_user.teams.each do |team|
31+
li = link_to "#{team.name}: starred by team", topics_path(filter: "starred_by_team", team_id: team.id)
3032
li = link_to "#{team.name}: not yet read by team", topics_path(filter: "team_unread", team_id: team.id)
3133
li = link_to "#{team.name}: my teammates are reading", topics_path(filter: "team_reading_others", team_id: team.id)
3234
li = link_to "#{team.name}: my team is reading", topics_path(filter: "team_reading_any", team_id: team.id)
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
- starred = local_assigns[:starred] || false
2+
- star_path = starred ? unstar_topic_path(topic) : star_topic_path(topic)
3+
- method = starred ? :delete : :post
4+
- icon_class = starred ? "fa-solid fa-star" : "fa-regular fa-star"
5+
- tooltip = starred ? "Unstar" : "Star"
6+
= link_to star_path, method: method, data: { turbo_method: method, turbo_stream: true }, class: "topic-icon activity-star #{starred ? 'is-starred' : ''}", title: tooltip, id: dom_id(topic, "star_button") do
7+
i class=icon_class
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
- star_data = star_data || {}
2+
- starred_by_me = star_data[:starred_by_me] || false
3+
- team_starrers = star_data[:team_starrers] || []
4+
- total_count = (starred_by_me ? 1 : 0) + team_starrers.size
5+
- classes = ["topic-icon", "activity-star"]
6+
- classes << "is-hidden" if total_count.zero?
7+
- classes << "is-starred" if starred_by_me
8+
- icon_class = starred_by_me ? "fa-solid fa-star" : "fa-regular fa-star"
9+
div class=classes.join(" ") id=dom_id(topic, "stars") data-controller="hover-popover" data-hover-popover-delay-value="200" data-action="mouseenter->hover-popover#show mouseleave->hover-popover#scheduleHide"
10+
i class=icon_class
11+
- if total_count > 2
12+
span.topic-icon-badge = total_count
13+
- if starred_by_me || team_starrers.any?
14+
.topic-icon-hover data-hover-popover-target="popover" data-action="mouseenter->hover-popover#show mouseleave->hover-popover#scheduleHide"
15+
- if starred_by_me
16+
- my_alias = current_user.person&.default_alias || current_user.aliases&.first
17+
- if my_alias
18+
- participant_stub = { alias: my_alias }
19+
- role_label = my_alias.contributor_badge || "User"
20+
= render partial: "participant_row", locals: { participant: participant_stub, avatar_size: 32, tooltip: "#{my_alias.name} (#{role_label})" }
21+
- team_starrers.each do |alias_record|
22+
- participant_stub = { alias: alias_record }
23+
- role_label = alias_record.contributor_badge || "User"
24+
= render partial: "participant_row", locals: { participant: participant_stub, avatar_size: 32, tooltip: "#{alias_record.name} (#{role_label})" }

app/views/topics/_status_cell.html.slim

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
- status = state[:status] || "new"
22
- status_class = "status-#{status}"
3+
- star_data = star_data || {}
34
td.topic-title.status-border class=status_class id=dom_id(topic, "status_cell") data-label="Topic"
45
- if status.to_s == "reading"
56
- read_count = state[:read_count].to_i
@@ -9,6 +10,7 @@ td.topic-title.status-border class=status_class id=dom_id(topic, "status_cell")
910
i.fa-solid.fa-envelope
1011
- if unread_count.positive?
1112
span.topic-icon-badge.topic-icon-badge-sup = unread_count
13+
= render partial: "topics/star_icon", locals: { topic: topic, star_data: star_data }
1214
= render partial: "topics/note_icon", locals: { topic: topic, count: note_count.to_i }
1315
= render partial: "topics/team_readers_icon", locals: { topic: topic, readers: team_readers }
1416
- commitfest_summary = @commitfest_summaries&.dig(topic.id)

0 commit comments

Comments
 (0)