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:
Joffrey JAFFEUX
2023-05-10 11:42:32 +02:00
committed by GitHub
parent 7a84fc3d9d
commit c6b43ce68b
74 changed files with 1512 additions and 64 deletions

View File

@ -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

View File

@ -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

View File

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

View 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

View File

@ -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

View 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

View File

@ -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

View File

@ -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

View 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

View File

@ -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

View 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