Skip to content

Commit af53295

Browse files
authored
FEATURE: Pin chat messages (discourse#37985)
Allow staff to pin important messages in chat channels. Pinned messages appear in a dedicated panel accessible from the channel navbar. Members of allowed groups (admins/moderators by default) can pin and unpin messages via the message actions menu. Pinned messages show "pinned by" attribution, track unread state per user, and auto-unpin when the original message is trashed. Real-time updates via MessageBus keep the pinned list in sync. Gated behind the chat_pinned_messages upcoming change (experimental).
1 parent 2a6fd61 commit af53295

71 files changed

Lines changed: 2036 additions & 20 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# frozen_string_literal: true
2+
3+
class Chat::Api::ChannelPinsController < Chat::ApiController
4+
def index
5+
raise Discourse::NotFound unless SiteSetting.chat_pinned_messages
6+
7+
Chat::ListChannelPins.call(service_params) do
8+
on_success do |pins:, membership:|
9+
render_serialized({ pins:, membership: }, Chat::ChannelPinsSerializer, root: false)
10+
end
11+
on_model_not_found(:channel) { raise Discourse::NotFound }
12+
on_model_not_found(:membership) do |pins:|
13+
render_serialized({ pins:, membership: nil }, Chat::ChannelPinsSerializer, root: false)
14+
end
15+
on_failed_policy(:can_view_channel) { raise Discourse::InvalidAccess }
16+
on_failed_contract do |contract|
17+
render(json: failed_json.merge(errors: contract.errors.full_messages), status: :bad_request)
18+
end
19+
on_failure { render(json: failed_json, status: :unprocessable_entity) }
20+
end
21+
end
22+
23+
def mark_read
24+
raise Discourse::NotFound unless SiteSetting.chat_pinned_messages
25+
26+
Chat::MarkPinsAsRead.call(params: { channel_id: params[:channel_id] }, guardian: guardian) do
27+
on_success { render json: success_json }
28+
on_model_not_found(:channel) { raise Discourse::NotFound }
29+
on_model_not_found(:membership) { raise Discourse::NotFound }
30+
on_failed_policy(:can_access_channel) { raise Discourse::InvalidAccess }
31+
on_failed_contract do |contract|
32+
render(json: failed_json.merge(errors: contract.errors.full_messages), status: :bad_request)
33+
end
34+
on_failure { render(json: failed_json, status: :unprocessable_entity) }
35+
end
36+
end
37+
38+
def create
39+
raise Discourse::NotFound unless SiteSetting.chat_pinned_messages
40+
41+
Chat::PinMessage.call(service_params) do
42+
on_success { render(json: success_json) }
43+
on_failure { render(json: failed_json, status: :unprocessable_entity) }
44+
on_model_not_found(:message) { raise Discourse::NotFound }
45+
on_failed_policy(:can_pin) { raise Discourse::InvalidAccess }
46+
on_failed_policy(:within_pin_limit) do
47+
render(
48+
json:
49+
failed_json.merge(
50+
error:
51+
I18n.t(
52+
"chat.errors.pin_limit_reached",
53+
limit: Chat::PinnedMessage::MAX_PINS_PER_CHANNEL,
54+
),
55+
),
56+
status: :unprocessable_entity,
57+
)
58+
end
59+
on_failed_policy(:not_already_pinned) do
60+
render(
61+
json: failed_json.merge(error: I18n.t("chat.errors.message_already_pinned")),
62+
status: :unprocessable_entity,
63+
)
64+
end
65+
on_failed_contract do |contract|
66+
render(json: failed_json.merge(errors: contract.errors.full_messages), status: :bad_request)
67+
end
68+
end
69+
end
70+
71+
def destroy
72+
raise Discourse::NotFound unless SiteSetting.chat_pinned_messages
73+
74+
Chat::UnpinMessage.call(service_params) do
75+
on_success { render(json: success_json) }
76+
on_failure { render(json: failed_json, status: :unprocessable_entity) }
77+
on_model_not_found(:message) { raise Discourse::NotFound }
78+
on_model_not_found(:pin) do
79+
render(
80+
json: failed_json.merge(error: I18n.t("chat.errors.message_not_pinned")),
81+
status: :unprocessable_entity,
82+
)
83+
end
84+
on_failed_policy(:can_unpin) { raise Discourse::InvalidAccess }
85+
on_failed_contract do |contract|
86+
render(json: failed_json.merge(errors: contract.errors.full_messages), status: :bad_request)
87+
end
88+
end
89+
end
90+
end

plugins/chat/app/jobs/scheduled/chat/delete_old_messages.rb

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,19 +18,23 @@ def delete_public_channel_messages
1818
return unless valid_day_value?(:chat_channel_retention_days)
1919

2020
::Chat::MessageDestroyer.new.destroy_in_batches(
21-
::Chat::Message.in_public_channel.with_deleted.created_before(
22-
::SiteSetting.chat_channel_retention_days.days.ago,
23-
),
21+
::Chat::Message
22+
.in_public_channel
23+
.with_deleted
24+
.created_before(::SiteSetting.chat_channel_retention_days.days.ago)
25+
.where.not(id: ::Chat::PinnedMessage.select(:chat_message_id)),
2426
)
2527
end
2628

2729
def delete_dm_channel_messages
2830
return unless valid_day_value?(:chat_dm_retention_days)
2931

3032
::Chat::MessageDestroyer.new.destroy_in_batches(
31-
::Chat::Message.in_dm_channel.with_deleted.created_before(
32-
::SiteSetting.chat_dm_retention_days.days.ago,
33-
),
33+
::Chat::Message
34+
.in_dm_channel
35+
.with_deleted
36+
.created_before(::SiteSetting.chat_dm_retention_days.days.ago)
37+
.where.not(id: ::Chat::PinnedMessage.select(:chat_message_id)),
3438
)
3539
end
3640

plugins/chat/app/models/chat/channel.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ class Channel < ActiveRecord::Base
2525
class_name: "Chat::Message",
2626
foreign_key: :last_message_id,
2727
optional: true
28+
has_many :pinned_messages, class_name: "Chat::PinnedMessage", foreign_key: :chat_channel_id
2829

2930
def last_message
3031
super || NullMessage.new
@@ -145,6 +146,10 @@ def leave(user)
145146
self.remove(user)
146147
end
147148

149+
def pinned_messages_count
150+
pinned_messages.size
151+
end
152+
148153
def url
149154
"#{Discourse.base_url}/chat/c/#{self.slug || "-"}/#{self.id}"
150155
end

plugins/chat/app/models/chat/message.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ class Message < ActiveRecord::Base
5757
dependent: :destroy,
5858
class_name: "Chat::Mention",
5959
foreign_key: :chat_message_id
60+
has_one :pinned_message,
61+
class_name: "Chat::PinnedMessage",
62+
foreign_key: :chat_message_id,
63+
dependent: :destroy
6064
has_many :user_mentions,
6165
dependent: :destroy,
6266
class_name: "Chat::UserMention",
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# frozen_string_literal: true
2+
3+
module Chat
4+
class PinnedMessage < ActiveRecord::Base
5+
self.table_name = "chat_pinned_messages"
6+
7+
MAX_PINS_PER_CHANNEL = 20
8+
9+
belongs_to :chat_message, class_name: "Chat::Message"
10+
belongs_to :chat_channel, class_name: "Chat::Channel"
11+
belongs_to :user, foreign_key: :pinned_by_id
12+
13+
validates :chat_message_id, uniqueness: true
14+
15+
scope :for_channel, ->(channel) { where(chat_channel: channel).order(created_at: :desc) }
16+
end
17+
end
18+
19+
# == Schema Information
20+
#
21+
# Table name: chat_pinned_messages
22+
#
23+
# id :bigint not null, primary key
24+
# created_at :datetime not null
25+
# updated_at :datetime not null
26+
# chat_channel_id :bigint not null
27+
# chat_message_id :bigint not null
28+
# pinned_by_id :bigint not null
29+
#
30+
# Indexes
31+
#
32+
# idx_chat_pinned_messages_channel_created (chat_channel_id,created_at DESC)
33+
# index_chat_pinned_messages_on_chat_message_id (chat_message_id) UNIQUE
34+
#

plugins/chat/app/models/chat/user_chat_channel_membership.rb

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,24 @@ class UserChatChannelMembership < ActiveRecord::Base
1717
def mark_read!(new_last_read_id = nil)
1818
update!(last_read_message_id: new_last_read_id || chat_channel.last_message_id)
1919
end
20+
21+
def has_unseen_pins?
22+
pins = chat_channel.pinned_messages
23+
24+
if pins.loaded?
25+
others = pins.reject { |pin| pin.pinned_by_id == user_id }
26+
return false if others.empty?
27+
return true if last_viewed_pins_at.nil?
28+
others.any? { |pin| pin.created_at > last_viewed_pins_at }
29+
else
30+
others = pins.where.not(pinned_by_id: user_id)
31+
if last_viewed_pins_at.nil?
32+
others.exists?
33+
else
34+
others.where("created_at > ?", last_viewed_pins_at).exists?
35+
end
36+
end
37+
end
2038
end
2139
end
2240

@@ -28,6 +46,7 @@ def mark_read!(new_last_read_id = nil)
2846
# following :boolean default(FALSE), not null
2947
# join_mode :integer default("manual"), not null
3048
# last_viewed_at :datetime not null
49+
# last_viewed_pins_at :datetime
3150
# muted :boolean default(FALSE), not null
3251
# notification_level :integer default("mention"), not null
3352
# starred :boolean default(FALSE), not null

plugins/chat/app/queries/chat/messages_query.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ def self.base_query(channel:)
107107
.includes(uploads: { optimized_videos: :optimized_upload })
108108
.includes(chat_channel: :chatable)
109109
.includes(thread: %i[original_message last_message])
110+
.includes(:pinned_message)
110111
.where(chat_channel_id: channel.id)
111112

112113
user_includes = SiteSetting.enable_user_status ? %i[user_status user_option] : %i[user_option]

plugins/chat/app/serializers/chat/base_channel_membership_serializer.rb

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ class BaseChannelMembershipSerializer < ApplicationSerializer
88
:chat_channel_id,
99
:last_read_message_id,
1010
:last_viewed_at,
11-
:starred
11+
:last_viewed_pins_at,
12+
:starred,
13+
:has_unseen_pins
1214

1315
def starred
1416
object.starred
@@ -17,5 +19,17 @@ def starred
1719
def include_starred?
1820
scope&.authenticated?
1921
end
22+
23+
def has_unseen_pins
24+
object.has_unseen_pins?
25+
end
26+
27+
def include_has_unseen_pins?
28+
SiteSetting.chat_pinned_messages && scope&.authenticated?
29+
end
30+
31+
def include_last_viewed_pins_at?
32+
SiteSetting.chat_pinned_messages && scope&.authenticated?
33+
end
2034
end
2135
end
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# frozen_string_literal: true
2+
3+
module Chat
4+
class ChannelPinsSerializer < ApplicationSerializer
5+
attributes :pinned_messages, :membership
6+
7+
def pinned_messages
8+
ActiveModel::ArraySerializer.new(
9+
object[:pins],
10+
each_serializer: Chat::PinnedMessageSerializer,
11+
scope: scope,
12+
)
13+
end
14+
15+
def membership
16+
return if !object[:membership]
17+
18+
Chat::UserChannelMembershipSerializer.new(object[:membership], scope: scope, root: false)
19+
end
20+
end
21+
end

plugins/chat/app/serializers/chat/channel_serializer.rb

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ class ChannelSerializer < ApplicationSerializer
2323
:memberships_count,
2424
:current_user_membership,
2525
:meta,
26-
:threading_enabled
26+
:threading_enabled,
27+
:pinned_messages_count
2728

2829
has_one :last_message, serializer: Chat::LastMessageSerializer, embed: :objects
2930

@@ -46,6 +47,14 @@ def memberships_count
4647
object.user_count
4748
end
4849

50+
def pinned_messages_count
51+
object.pinned_messages.size
52+
end
53+
54+
def include_pinned_messages_count?
55+
SiteSetting.chat_pinned_messages
56+
end
57+
4958
def chatable_url
5059
object.chatable_url
5160
end
@@ -140,6 +149,7 @@ def meta
140149
data[:can_delete_self] = scope.can_delete_own_chats?(object.chatable)
141150
data[:can_delete_others] = scope.can_delete_other_chats?(object.chatable)
142151
data[:can_remove_members] = scope.can_remove_members?(object)
152+
data[:can_manage_pins] = scope.can_manage_chat_channel_pins?(object)
143153

144154
data
145155
end

0 commit comments

Comments
 (0)