mirror of
https://github.com/discourse/discourse.git
synced 2025-06-03 12:14:36 +08:00
FEATURE: Thread list initial UI (#21412)
This commit adds an initial thread list UI. There are several limitations with this that will be addressed in future PRs: * There is no MessageBus reactivity, so e.g. if someone edits the original message of the thread it will not be reflected in the list. However if the thread title is updated the original message indicator will be updated. * There is no unread functionality for threads in the list, if new messages come into the thread there is no indicator in the UI. * There is no unread indicator on the actual button to open the thread list. * No pagination. In saying that, this is the functionality so far: * We show a list of the 50 threads that the user has most recently participated in (i.e. sent a message) for the channel in descending order. * Each thread we show a rich excerpt, the title, and the user who is the OM creator. * The title is editable by staff and by the OM creator. * Thread indicators show a title. We also replace emojis in the titles. * Thread list works in the drawer/mobile.
This commit is contained in:
@ -1,6 +1,27 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Chat::Api::ChannelThreadsController < Chat::ApiController
|
||||
def index
|
||||
with_service(::Chat::LookupChannelThreads) do
|
||||
on_success do
|
||||
render_serialized(
|
||||
::Chat::ThreadsView.new(
|
||||
user: guardian.user,
|
||||
threads: result.threads,
|
||||
channel: result.channel,
|
||||
),
|
||||
::Chat::ThreadListSerializer,
|
||||
root: false,
|
||||
)
|
||||
end
|
||||
on_failed_policy(:threaded_discussions_enabled) { raise Discourse::NotFound }
|
||||
on_failed_policy(:threading_enabled_for_channel) { raise Discourse::NotFound }
|
||||
on_failed_policy(:can_view_channel) { raise Discourse::InvalidAccess }
|
||||
on_model_not_found(:channel) { raise Discourse::NotFound }
|
||||
on_model_not_found(:threads) { render json: success_json.merge(threads: []) }
|
||||
end
|
||||
end
|
||||
|
||||
def show
|
||||
with_service(::Chat::LookupThread) do
|
||||
on_success { render_serialized(result.thread, ::Chat::ThreadSerializer, root: "thread") }
|
||||
@ -9,4 +30,17 @@ class Chat::Api::ChannelThreadsController < Chat::ApiController
|
||||
on_model_not_found(:thread) { raise Discourse::NotFound }
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
with_service(::Chat::UpdateThread) do
|
||||
on_failed_policy(:threaded_discussions_enabled) { raise Discourse::NotFound }
|
||||
on_failed_policy(:threading_enabled_for_channel) { raise Discourse::NotFound }
|
||||
on_failed_policy(:can_view_channel) { raise Discourse::InvalidAccess }
|
||||
on_failed_policy(:can_edit_thread) { raise Discourse::InvalidAccess }
|
||||
on_model_not_found(:thread) { raise Discourse::NotFound }
|
||||
on_failed_step(:update) do
|
||||
render json: failed_json.merge(errors: [result["result.step.update"].error]), status: 422
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -128,6 +128,24 @@ module Chat
|
||||
PrettyText.excerpt(message, max_length, { text_entities: true })
|
||||
end
|
||||
|
||||
# TODO (martin) Replace the above #excerpt method usage with this one. The
|
||||
# issue with the above one is that we cannot actually render nice HTML
|
||||
# fore replies/excerpts in the UI because text_entitites: true will
|
||||
# allow through even denied HTML because of 07ab20131a15ab907c1974fee405d9bdce0c0723.
|
||||
#
|
||||
# For now only the thread index uses this new version since it is not interactive,
|
||||
# we can go back to the interactive reply/edit cases in another PR.
|
||||
def rich_excerpt(max_length: 50)
|
||||
# just show the URL if the whole message is a URL, because we cannot excerpt oneboxes
|
||||
return message if UrlHelper.relaxed_parse(message).is_a?(URI)
|
||||
|
||||
# upload-only messages are better represented as the filename
|
||||
return uploads.first.original_filename if cooked.blank? && uploads.present?
|
||||
|
||||
# this may return blank for some complex things like quotes, that is acceptable
|
||||
PrettyText.excerpt(cooked, max_length)
|
||||
end
|
||||
|
||||
def cooked_for_excerpt
|
||||
(cooked.blank? && uploads.present?) ? "<p>#{uploads.first.original_filename}</p>" : cooked
|
||||
end
|
||||
|
@ -3,6 +3,7 @@
|
||||
module Chat
|
||||
class Thread < ActiveRecord::Base
|
||||
EXCERPT_LENGTH = 150
|
||||
MAX_TITLE_LENGTH = 100
|
||||
|
||||
include Chat::ThreadCache
|
||||
|
||||
@ -24,6 +25,8 @@ module Chat
|
||||
|
||||
enum :status, { open: 0, read_only: 1, closed: 2, archived: 3 }, scopes: false
|
||||
|
||||
validates :title, length: { maximum: Chat::Thread::MAX_TITLE_LENGTH }
|
||||
|
||||
def replies
|
||||
self.chat_messages.where.not(id: self.original_message_id)
|
||||
end
|
||||
@ -37,7 +40,7 @@ module Chat
|
||||
end
|
||||
|
||||
def excerpt
|
||||
original_message.excerpt(max_length: EXCERPT_LENGTH)
|
||||
original_message.rich_excerpt(max_length: EXCERPT_LENGTH)
|
||||
end
|
||||
|
||||
def self.grouped_messages(thread_ids: nil, message_ids: nil, include_original_message: true)
|
||||
|
13
plugins/chat/app/models/chat/threads_view.rb
Normal file
13
plugins/chat/app/models/chat/threads_view.rb
Normal file
@ -0,0 +1,13 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Chat
|
||||
class ThreadsView
|
||||
attr_reader :user, :channel, :threads
|
||||
|
||||
def initialize(channel:, threads:, user:)
|
||||
@channel = channel
|
||||
@threads = threads
|
||||
@user = user
|
||||
end
|
||||
end
|
||||
end
|
@ -17,6 +17,7 @@ module Chat
|
||||
:available_flags,
|
||||
:thread_id,
|
||||
:thread_reply_count,
|
||||
:thread_title,
|
||||
:chat_channel_id
|
||||
|
||||
has_one :user, serializer: Chat::MessageUserSerializer, embed: :objects
|
||||
@ -168,5 +169,9 @@ module Chat
|
||||
def thread_reply_count
|
||||
object.thread&.replies_count_cache || 0
|
||||
end
|
||||
|
||||
def thread_title
|
||||
object.thread&.title
|
||||
end
|
||||
end
|
||||
end
|
||||
|
19
plugins/chat/app/serializers/chat/thread_list_serializer.rb
Normal file
19
plugins/chat/app/serializers/chat/thread_list_serializer.rb
Normal file
@ -0,0 +1,19 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Chat
|
||||
class ThreadListSerializer < ApplicationSerializer
|
||||
attributes :meta, :threads
|
||||
|
||||
def threads
|
||||
ActiveModel::ArraySerializer.new(
|
||||
object.threads,
|
||||
each_serializer: Chat::ThreadSerializer,
|
||||
scope: scope,
|
||||
)
|
||||
end
|
||||
|
||||
def meta
|
||||
{ channel_id: object.channel.id }
|
||||
end
|
||||
end
|
||||
end
|
@ -1,13 +1,37 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Chat
|
||||
class ThreadOriginalMessageSerializer < ApplicationSerializer
|
||||
attributes :id, :created_at, :excerpt, :thread_id
|
||||
|
||||
has_one :chat_webhook_event, serializer: Chat::WebhookEventSerializer, embed: :objects
|
||||
|
||||
class ThreadOriginalMessageSerializer < Chat::MessageSerializer
|
||||
def excerpt
|
||||
WordWatcher.censor(object.excerpt(max_length: Chat::Thread::EXCERPT_LENGTH))
|
||||
WordWatcher.censor(object.rich_excerpt(max_length: Chat::Thread::EXCERPT_LENGTH))
|
||||
end
|
||||
|
||||
def include_reactions?
|
||||
false
|
||||
end
|
||||
|
||||
def include_edited?
|
||||
false
|
||||
end
|
||||
|
||||
def include_in_reply_to?
|
||||
false
|
||||
end
|
||||
|
||||
def include_user_flag_status?
|
||||
false
|
||||
end
|
||||
|
||||
def include_uploads?
|
||||
false
|
||||
end
|
||||
|
||||
def include_bookmark?
|
||||
false
|
||||
end
|
||||
|
||||
def include_chat_webhook_event?
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -5,17 +5,22 @@ module Chat
|
||||
has_one :original_message_user, serializer: BasicUserWithStatusSerializer, embed: :objects
|
||||
has_one :original_message, serializer: Chat::ThreadOriginalMessageSerializer, embed: :objects
|
||||
|
||||
attributes :id, :title, :status, :channel_id, :meta
|
||||
attributes :id, :title, :status, :channel_id, :meta, :reply_count
|
||||
|
||||
def initialize(object, opts)
|
||||
super(object, opts)
|
||||
@opts = opts
|
||||
original_message.thread = object
|
||||
end
|
||||
|
||||
def meta
|
||||
{ message_bus_last_ids: { thread_message_bus_last_id: thread_message_bus_last_id } }
|
||||
end
|
||||
|
||||
def reply_count
|
||||
object.replies_count_cache || 0
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def thread_message_bus_last_id
|
||||
|
73
plugins/chat/app/services/chat/lookup_channel_threads.rb
Normal file
73
plugins/chat/app/services/chat/lookup_channel_threads.rb
Normal file
@ -0,0 +1,73 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Chat
|
||||
# Gets a list of threads for a channel to be shown in an index.
|
||||
# In future pagination and filtering will be added -- for now
|
||||
# we just want to return N threads ordered by the latest
|
||||
# message that the user has sent in a thread.
|
||||
#
|
||||
# @example
|
||||
# Chat::LookupChannelThreads.call(channel_id: 2, guardian: guardian)
|
||||
#
|
||||
class LookupChannelThreads
|
||||
include Service::Base
|
||||
|
||||
# @!method call(channel_id:, guardian:)
|
||||
# @param [Integer] channel_id
|
||||
# @param [Guardian] guardian
|
||||
# @return [Service::Base::Context]
|
||||
|
||||
policy :threaded_discussions_enabled
|
||||
contract
|
||||
model :channel
|
||||
policy :threading_enabled_for_channel
|
||||
policy :can_view_channel
|
||||
model :threads
|
||||
|
||||
# @!visibility private
|
||||
class Contract
|
||||
attribute :channel_id, :integer
|
||||
validates :channel_id, presence: true
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def threaded_discussions_enabled
|
||||
SiteSetting.enable_experimental_chat_threaded_discussions
|
||||
end
|
||||
|
||||
def fetch_channel(contract:, **)
|
||||
Chat::Channel.find_by(id: contract.channel_id)
|
||||
end
|
||||
|
||||
def threading_enabled_for_channel(channel:, **)
|
||||
channel.threading_enabled
|
||||
end
|
||||
|
||||
def can_view_channel(guardian:, channel:, **)
|
||||
guardian.can_preview_chat_channel?(channel)
|
||||
end
|
||||
|
||||
def fetch_threads(guardian:, channel:, **)
|
||||
Chat::Thread
|
||||
.includes(
|
||||
:channel,
|
||||
original_message_user: :user_status,
|
||||
original_message: :chat_webhook_event,
|
||||
)
|
||||
.select("chat_threads.*, MAX(chat_messages.created_at) AS last_posted_at")
|
||||
.joins(
|
||||
"LEFT JOIN chat_messages ON chat_threads.id = chat_messages.thread_id AND chat_messages.chat_channel_id = #{channel.id}",
|
||||
)
|
||||
.joins(
|
||||
"LEFT JOIN chat_messages original_messages ON chat_threads.original_message_id = original_messages.id",
|
||||
)
|
||||
.where("chat_messages.user_id = ? OR chat_messages.user_id IS NULL", guardian.user.id)
|
||||
.where(channel_id: channel.id)
|
||||
.where("original_messages.deleted_at IS NULL AND chat_messages.deleted_at IS NULL")
|
||||
.group("chat_threads.id")
|
||||
.order("last_posted_at DESC NULLS LAST")
|
||||
.limit(50)
|
||||
end
|
||||
end
|
||||
end
|
@ -72,6 +72,7 @@ module Chat
|
||||
type: :update_thread_original_message,
|
||||
original_message_id: thread.original_message_id,
|
||||
replies_count: thread.replies_count_cache,
|
||||
title: thread.title,
|
||||
},
|
||||
)
|
||||
end
|
||||
|
75
plugins/chat/app/services/chat/update_thread.rb
Normal file
75
plugins/chat/app/services/chat/update_thread.rb
Normal file
@ -0,0 +1,75 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Chat
|
||||
# Updates a thread. The thread_id and channel_id must
|
||||
# match. For now we do not want to allow updating threads if the
|
||||
# enable_experimental_chat_threaded_discussions hidden site setting
|
||||
# is not turned on, and the channel must specifically have threading
|
||||
# enabled.
|
||||
#
|
||||
# Only the thread title can be updated.
|
||||
#
|
||||
# @example
|
||||
# Chat::UpdateThread.call(thread_id: 88, channel_id: 2, guardian: guardian, title: "Restaurant for Saturday")
|
||||
#
|
||||
class UpdateThread
|
||||
include Service::Base
|
||||
|
||||
# @!method call(thread_id:, channel_id:, guardian:, **params_to_edit)
|
||||
# @param [Integer] thread_id
|
||||
# @param [Integer] channel_id
|
||||
# @param [Guardian] guardian
|
||||
# @option params_to_edit [String,nil] title
|
||||
# @return [Service::Base::Context]
|
||||
|
||||
policy :threaded_discussions_enabled
|
||||
contract
|
||||
model :thread, :fetch_thread
|
||||
policy :can_view_channel
|
||||
policy :can_edit_thread
|
||||
policy :threading_enabled_for_channel
|
||||
step :update
|
||||
step :publish_metadata
|
||||
|
||||
# @!visibility private
|
||||
class Contract
|
||||
attribute :thread_id, :integer
|
||||
attribute :channel_id, :integer
|
||||
attribute :title, :string
|
||||
|
||||
validates :thread_id, :channel_id, presence: true
|
||||
validates :title, length: { maximum: Chat::Thread::MAX_TITLE_LENGTH }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def threaded_discussions_enabled
|
||||
SiteSetting.enable_experimental_chat_threaded_discussions
|
||||
end
|
||||
|
||||
def fetch_thread(contract:, **)
|
||||
Chat::Thread.find_by(id: contract.thread_id, channel_id: contract.channel_id)
|
||||
end
|
||||
|
||||
def can_view_channel(guardian:, thread:, **)
|
||||
guardian.can_preview_chat_channel?(thread.channel)
|
||||
end
|
||||
|
||||
def can_edit_thread(guardian:, thread:, **)
|
||||
guardian.can_edit_thread?(thread)
|
||||
end
|
||||
|
||||
def threading_enabled_for_channel(thread:, **)
|
||||
thread.channel.threading_enabled
|
||||
end
|
||||
|
||||
def update(thread:, contract:, **)
|
||||
thread.update(title: contract.title)
|
||||
fail!(thread.errors.full_messages.join(", ")) if thread.invalid?
|
||||
end
|
||||
|
||||
def publish_metadata(thread:, **)
|
||||
Chat::Publisher.publish_thread_original_message_metadata!(thread)
|
||||
end
|
||||
end
|
||||
end
|
Reference in New Issue
Block a user