FEATURE: Add threads support to chat archives (#24325)

This PR introduces thread support for channel archives. Now, threaded messages are rendered inside a `details` HTML tag in posts.

The transcript markdown rules now support two new attributes: `threadId` and `threadTitle`.

- If `threadId` is present, all nested `chat` tags are rendered inside the first one.
- `threadTitle` (optional) defines the summary content.

```
[chat threadId=19 ... ]
thread OM

  [chat ... ]
  thread reply
  [/chat]

[/chat]
```

If threads are split across multiple posts when archiving, the range of messages in each part will be displayed alongside the thread title. For example: `(message 1 to 16 of 20)` and `(message 17 to 20 of 20)`.
This commit is contained in:
Jan Cernik
2023-11-27 11:47:35 -03:00
committed by GitHub
parent d506721eee
commit ac9e804dbe
8 changed files with 673 additions and 41 deletions

View File

@ -85,6 +85,7 @@ module Chat
@chat_channel_archive = chat_channel_archive
@chat_channel = chat_channel_archive.chat_channel
@chat_channel_title = chat_channel.title(chat_channel_archive.archived_by)
@archived_messages_ids = []
end
def execute
@ -107,22 +108,88 @@ module Chat
# Another future improvement is to send a MessageBus message for each
# completed batch, so the UI can receive updates and show a progress
# bar or something similar.
buffer = []
batch_thread_ranges = {}
chat_channel
.chat_messages
.find_in_batches(batch_size: ARCHIVED_MESSAGES_PER_POST) do |chat_messages|
create_post(
Chat::TranscriptService.new(
chat_channel,
chat_channel_archive.archived_by,
messages_or_ids: chat_messages,
opts: {
no_link: true,
include_reactions: true,
},
).generate_markdown,
) { delete_message_batch(chat_messages.map(&:id)) }
.order("created_at ASC")
.find_in_batches(batch_size: ARCHIVED_MESSAGES_PER_POST) do |message_batch|
thread_ids = message_batch.map(&:thread_id).compact.uniq
threads =
chat_channel
.chat_messages
.where(
thread_id:
Chat::Message
.select(:thread_id)
.where(thread_id: thread_ids)
.group(:thread_id)
.having("count(*) > 1"),
)
.order("created_at ASC")
.to_a
full_batch = (buffer + message_batch + threads).uniq { |msg| msg.id }
message_chunk = full_batch.group_by { |msg| msg.thread_id || msg.id }.values.flatten
buffer.clear
if message_chunk.size > ARCHIVED_MESSAGES_PER_POST
post_last_message = message_chunk[ARCHIVED_MESSAGES_PER_POST - 1]
thread = threads.select { |msg| msg.thread_id == post_last_message.thread_id }
thread_om = thread.first
if !thread_om.nil?
thread_ranges =
calculate_thread_ranges(message_chunk, thread, thread_om, post_last_message)
end
end
batch = []
batch_thread_added = false
message_chunk.each do |message|
# When a thread spans across multiple posts and the first message is part of a thread in
# a previous post, we need to duplicate the original message to give context to the user.
if thread_om.present?
if batch.empty? && message_chunk.size > ARCHIVED_MESSAGES_PER_POST &&
message&.thread_id == thread_om&.thread_id && message != thread_om
batch << thread_om
# We determine the correct range for the current part of the thread.
batch_thread_ranges[thread_om.id] = thread_ranges[message.thread_id].first
thread_ranges[message.thread_id].slice!(0)
elsif thread_ranges.has_key?(message.thread_id) &&
thread_ranges[message.thread_id].present? && batch_thread_added == false
# We determine the correct range for the current part of the thread.
batch_thread_ranges[thread_om.id] = thread_ranges[message.thread_id].first
thread_ranges[message.thread_id].slice!(0)
batch_thread_added = true
end
end
if message == thread_om && batch.size + 1 >= ARCHIVED_MESSAGES_PER_POST
batch_size = batch.size + 1
else
batch << message
batch_size = batch.size
end
if batch_size >= ARCHIVED_MESSAGES_PER_POST
create_post_from_batch(batch, batch_thread_ranges)
batch.clear
end
end
buffer += batch
end
create_post_from_batch(buffer, batch_thread_ranges) unless buffer.empty?
kick_all_users
complete_archive
rescue => err
@ -133,6 +200,63 @@ module Chat
private
# It's used to call the TranscriptService, which will
# generate the markdown for a given set of messages.
def create_post_from_batch(chat_messages, batch_thread_ranges)
create_post(
Chat::TranscriptService.new(
chat_channel,
chat_channel_archive.archived_by,
messages_or_ids: chat_messages,
thread_ranges: batch_thread_ranges,
opts: {
no_link: true,
include_reactions: true,
},
).generate_markdown,
) { delete_message_batch(chat_messages.map(&:id)) }
end
# Message batches can be greater than the maximum number of messages
# per post if we also include threads. This is used to calculate all
# the ranges when we split the threads that are included in the batch.
def calculate_thread_ranges(message_chunk, thread, thread_om, post_last_message)
ranges = {}
thread_size = thread.size - 1
last_thread_index = 0
iterations = (message_chunk.size.to_f / (ARCHIVED_MESSAGES_PER_POST - 1)).ceil
iterations.times do |index|
if last_thread_index != thread_size
if index == 0
thread_index = thread.index(post_last_message)
else
next_post_last_message =
message_chunk[(ARCHIVED_MESSAGES_PER_POST * (index + 1)) - index]
if next_post_last_message&.thread_id == post_last_message&.thread_id
thread_index = last_thread_index + ARCHIVED_MESSAGES_PER_POST - 1
else
thread_index = thread_size
end
end
range =
I18n.t(
"chat.transcript.split_thread_range",
start: last_thread_index + 1,
end: thread_index,
total: thread_size,
)
ranges[thread_om.thread_id] ||= []
ranges[thread_om.thread_id] << range
last_thread_index = thread_index
end
end
ranges
end
def create_post(raw)
pc = nil
Post.transaction do
@ -228,9 +352,8 @@ module Chat
deleted_by_id: chat_channel_archive.archived_by.id,
)
chat_channel_archive.update!(
archived_messages: chat_channel_archive.archived_messages + message_ids.length,
)
@archived_messages_ids = (@archived_messages_ids + message_ids).uniq
chat_channel_archive.update!(archived_messages: @archived_messages_ids.length)
end
Rails.logger.info(

View File

@ -21,7 +21,13 @@ module Chat
NO_LINK_ATTR = "noLink=\"true\""
class TranscriptBBCode
attr_reader :channel, :multiquote, :chained, :no_link, :include_reactions
attr_reader :channel,
:multiquote,
:chained,
:no_link,
:include_reactions,
:thread_id,
:thread_ranges
def initialize(
channel: nil,
@ -29,7 +35,9 @@ module Chat
multiquote: false,
chained: false,
no_link: false,
include_reactions: false
include_reactions: false,
thread_id: nil,
thread_ranges: {}
)
@channel = channel
@acting_user = acting_user
@ -37,13 +45,20 @@ module Chat
@chained = chained
@no_link = no_link
@include_reactions = include_reactions
@thread_ranges = thread_ranges
@message_data = []
@threads_markdown = {}
@thread_id = thread_id
end
def add(message:, reactions: nil)
@message_data << { message: message, reactions: reactions }
end
def add_thread_markdown(thread_id:, markdown:)
@threads_markdown[thread_id] = markdown
end
def render
attrs = [quote_attr(@message_data.first[:message])]
@ -57,15 +72,40 @@ module Chat
attrs << NO_LINK_ATTR if no_link
attrs << reactions_attr if include_reactions
if thread_id
attrs << thread_id_attr
attrs << thread_title_attr(@message_data.first[:message])
end
<<~MARKDOWN
[chat #{attrs.compact.join(" ")}]
#{@message_data.map { |msg| msg[:message].to_markdown }.join("\n\n")}
#{render_messages}
[/chat]
MARKDOWN
end
private
def render_messages
@message_data
.map do |msg_data|
rendered_message = msg_data[:message].to_markdown
if msg_data[:message].thread_id.present?
thread_data = @threads_markdown[msg_data[:message].thread_id]
if thread_data.present?
rendered_message + "\n\n" + thread_data
else
rendered_message
end
else
rendered_message
end
end
.join("\n\n")
end
def reactions_attr
reaction_data =
@message_data.reduce([]) do |array, msg_data|
@ -89,9 +129,23 @@ module Chat
def channel_id_attr
"channelId=\"#{channel.id}\""
end
def thread_id_attr
"threadId=\"#{thread_id}\""
end
def thread_title_attr(message)
thread = Chat::Thread.find(thread_id)
range = thread_ranges[message.id] if thread_ranges.has_key?(message.id)
thread_title =
thread.title.present? ? thread.title : I18n.t("chat.transcript.default_thread_title")
thread_title += " (#{range})" if range.present?
"threadTitle=\"#{thread_title}\""
end
end
def initialize(channel, acting_user, messages_or_ids: [], opts: {})
def initialize(channel, acting_user, messages_or_ids: [], thread_ranges: {}, opts: {})
@channel = channel
@acting_user = acting_user
@ -101,12 +155,15 @@ module Chat
@messages = messages_or_ids
end
@opts = opts
@thread_ranges = thread_ranges
end
def generate_markdown
previous_message = nil
rendered_markdown = []
rendered_thread_markdown = []
all_messages_same_user = messages.count(:user_id) == 1
open_bbcode_tag =
TranscriptBBCode.new(
channel: @channel,
@ -114,11 +171,19 @@ module Chat
multiquote: messages.length > 1,
chained: !all_messages_same_user,
no_link: @opts[:no_link],
thread_id: messages.first.thread_id,
thread_ranges: @thread_ranges,
include_reactions: @opts[:include_reactions],
)
messages.each.with_index do |message, idx|
if previous_message.present? && previous_message.user_id != message.user_id
group_messages(messages).each do |id, message_group|
message = message_group.first
if previous_message.present? &&
(
previous_message.user_id != message.user_id ||
previous_message.thread_id != message.thread_id
)
rendered_markdown << open_bbcode_tag.render
open_bbcode_tag =
@ -126,6 +191,8 @@ module Chat
acting_user: @acting_user,
chained: !all_messages_same_user,
no_link: @opts[:no_link],
thread_id: message.thread_id,
thread_ranges: @thread_ranges,
include_reactions: @opts[:include_reactions],
)
end
@ -135,7 +202,51 @@ module Chat
else
open_bbcode_tag.add(message: message)
end
previous_message = message
if message_group.length > 1
previous_thread_message = nil
rendered_thread_markdown.clear
thread_bbcode_tag =
TranscriptBBCode.new(
acting_user: @acting_user,
chained: !all_messages_same_user,
no_link: @opts[:no_link],
include_reactions: @opts[:include_reactions],
)
message_group[1..].each do |thread_message|
if previous_thread_message.present? &&
previous_thread_message.user_id != thread_message.user_id
rendered_thread_markdown << thread_bbcode_tag.render
thread_bbcode_tag =
TranscriptBBCode.new(
acting_user: @acting_user,
chained: !all_messages_same_user,
no_link: @opts[:no_link],
include_reactions: @opts[:include_reactions],
)
end
if @opts[:include_reactions]
thread_bbcode_tag.add(
message: thread_message,
reactions: reactions_for_message(thread_message),
)
else
thread_bbcode_tag.add(message: thread_message)
end
previous_thread_message = thread_message
end
rendered_thread_markdown << thread_bbcode_tag.render
end
open_bbcode_tag.add_thread_markdown(
thread_id: message_group.first.thread_id,
markdown: rendered_thread_markdown.join("\n"),
)
end
# tie off the last open bbcode + render
@ -145,6 +256,10 @@ module Chat
private
def group_messages(messages)
messages.group_by { |msg| msg.thread_id || msg.id }
end
def messages
@messages ||=
Chat::Message