mirror of
https://github.com/discourse/discourse.git
synced 2025-06-03 11:25:15 +08:00
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:
@ -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(
|
||||
|
@ -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
|
||||
|
Reference in New Issue
Block a user