From ac9e804dbeec6c03a99932f7dcbc84124302ceb9 Mon Sep 17 00:00:00 2001 From: Jan Cernik <66427541+jancernik@users.noreply.github.com> Date: Mon, 27 Nov 2023 11:47:35 -0300 Subject: [PATCH] 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)`. --- .../lib/discourse-markdown/chat-transcript.js | 132 ++++++++++-- .../stylesheets/common/chat-transcript.scss | 34 ++- plugins/chat/config/locales/client.en.yml | 1 + plugins/chat/config/locales/server.en.yml | 4 + .../chat/lib/chat/channel_archive_service.rb | 153 +++++++++++-- plugins/chat/lib/chat/transcript_service.rb | 127 ++++++++++- .../lib/chat/channel_archive_service_spec.rb | 62 ++++++ .../spec/lib/chat/transcript_service_spec.rb | 201 +++++++++++++++++- 8 files changed, 673 insertions(+), 41 deletions(-) diff --git a/plugins/chat/assets/javascripts/lib/discourse-markdown/chat-transcript.js b/plugins/chat/assets/javascripts/lib/discourse-markdown/chat-transcript.js index 327f20790bd..7a4c90aeed7 100644 --- a/plugins/chat/assets/javascripts/lib/discourse-markdown/chat-transcript.js +++ b/plugins/chat/assets/javascripts/lib/discourse-markdown/chat-transcript.js @@ -20,6 +20,8 @@ const chatTranscriptRule = { const noLink = !!tagInfo.attrs.noLink; const channelName = tagInfo.attrs.channel; const channelId = tagInfo.attrs.channelId; + const threadId = tagInfo.attrs.threadId; + const threadTitle = tagInfo.attrs.threadTitle; const channelLink = channelId ? options.getURL(`/chat/c/-/${channelId}`) : null; @@ -29,6 +31,78 @@ const chatTranscriptRule = { } let wrapperDivToken = state.push("div_chat_transcript_wrap", "div", 1); + + if (channelName && multiQuote) { + let metaDivToken = state.push("div_chat_transcript_meta", "div", 1); + metaDivToken.attrs = [["class", "chat-transcript-meta"]]; + const channelToken = state.push("html_inline", "", 0); + + const unescapedChannelName = performEmojiUnescape(channelName, { + getURL: options.getURL, + emojiSet: options.emojiSet, + emojiCDNUrl: options.emojiCDNUrl, + enableEmojiShortcuts: options.enableEmojiShortcuts, + inlineEmoji: options.inlineEmoji, + lazy: true, + }); + + channelToken.content = I18n.t("chat.quote.original_channel", { + channel: unescapedChannelName, + channelLink, + }); + state.push("div_chat_transcript_meta", "div", -1); + } + + if (threadId) { + state.push("details_chat_transcript_wrap_open", "details", 1); + state.push("summary_chat_transcript_open", "summary", 1); + + const threadToken = state.push("div_thread_open", "div", 1); + threadToken.attrs = [["class", "chat-transcript-thread"]]; + + const threadHeaderToken = state.push("div_thread_header_open", "div", 1); + threadHeaderToken.attrs = [["class", "chat-transcript-thread-header"]]; + + const thread_svg = state.push("svg_thread_header_open", "svg", 1); + thread_svg.block = false; + thread_svg.attrs = [ + ["class", "fa d-icon d-icon-discourse-threads svg-icon svg-node"], + ]; + state.push(thread_svg); + let thread_use = state.push("use_svg_thread_open", "use", 1); + thread_use.block = false; + thread_use.attrs = [["href", "#discourse-threads"]]; + state.push(thread_use); + state.push(state.push("use_svg_thread_close", "use", -1)); + state.push(state.push("svg_thread_header_close", "svg", -1)); + + const threadTitleContainerToken = state.push( + "span_thread_title_open", + "span", + 1 + ); + threadTitleContainerToken.attrs = [ + ["class", "chat-transcript-thread-header__title"], + ]; + + const threadTitleToken = state.push("html_inline", "", 0); + const unescapedThreadTitle = performEmojiUnescape(threadTitle, { + getURL: options.getURL, + emojiSet: options.emojiSet, + emojiCDNUrl: options.emojiCDNUrl, + enableEmojiShortcuts: options.enableEmojiShortcuts, + inlineEmoji: options.inlineEmoji, + lazy: true, + }); + threadTitleToken.content = unescapedThreadTitle + ? unescapedThreadTitle + : I18n.t("chat.quote.default_thread_title"); + + state.push("span_thread_title_close", "span", -1); + + state.push("div_thread_header_close", "div", -1); + } + let wrapperClasses = ["chat-transcript"]; if (!!tagInfo.attrs.chained) { @@ -46,17 +120,6 @@ const chatTranscriptRule = { if (channelName) { wrapperDivToken.attrs.push(["data-channel-name", channelName]); - - if (multiQuote) { - let metaDivToken = state.push("div_chat_transcript_meta", "div", 1); - metaDivToken.attrs = [["class", "chat-transcript-meta"]]; - const channelToken = state.push("html_inline", "", 0); - channelToken.content = I18n.t("chat.quote.original_channel", { - channel: channelName, - channelLink, - }); - state.push("div_chat_transcript_meta", "div", -1); - } } if (channelId) { @@ -117,6 +180,17 @@ const chatTranscriptRule = { spanToken.attrs = [["title", messageTimeStart]]; spanToken.block = false; + if (channelName && !multiQuote) { + let channelLinkToken = state.push("link_open", "a", 1); + channelLinkToken.attrs = [ + ["class", "chat-transcript-channel"], + ["href", channelLink], + ]; + let inlineTextToken = state.push("html_inline", "", 0); + inlineTextToken.content = `#${channelName}`; + channelLinkToken = state.push("link_close", "a", -1); + channelLinkToken.block = false; + } spanToken = state.push("span_close", "span", -1); spanToken.block = false; } else { @@ -153,11 +227,32 @@ const chatTranscriptRule = { let messagesToken = state.push("div_chat_transcript_messages", "div", 1); messagesToken.attrs = [["class", "chat-transcript-messages"]]; - // rendering chat message content with limited markdown rule subset - const token = state.push("html_raw", "", 1); + if (threadId) { + const regex = /\[chat/i; + const match = regex.exec(content); - token.content = customMarkdownCookFn(content); - state.push("html_raw", "", -1); + if (match) { + const threadToken = state.push("html_raw", "", 1); + + threadToken.content = customMarkdownCookFn( + content.substring(0, match.index) + ); + state.push("html_raw", "", -1); + state.push("div_thread_close", "div", -1); + state.push("summary_chat_transcript_close", "summary", -1); + const token = state.push("html_raw", "", 1); + + token.content = customMarkdownCookFn(content.substring(match.index)); + state.push("html_raw", "", -1); + state.push("details_chat_transcript_wrap_close", "details", -1); + } + } else { + // rendering chat message content with limited markdown rule subset + const token = state.push("html_raw", "", 1); + + token.content = customMarkdownCookFn(content); + state.push("html_raw", "", -1); + } if (reactions) { let emojiHtmlCache = {}; @@ -202,8 +297,12 @@ const chatTranscriptRule = { export function setup(helper) { helper.allowList([ + "svg[class=fa d-icon d-icon-discourse-threads svg-icon svg-node]", + "use[href=#discourse-threads]", "div[class=chat-transcript]", + "details[class=chat-transcript]", "div[class=chat-transcript chat-transcript-chained]", + "details[class=chat-transcript chat-transcript-chained]", "div.chat-transcript-meta", "div.chat-transcript-user", "div.chat-transcript-username", @@ -219,6 +318,9 @@ export function setup(helper) { "div[data-username]", "div[data-datetime]", "a.chat-transcript-channel", + "div.chat-transcript-thread", + "div.chat-transcript-thread-header", + "span.chat-transcript-thread-header__title", ]); helper.registerOptions((opts, siteSettings) => { diff --git a/plugins/chat/assets/stylesheets/common/chat-transcript.scss b/plugins/chat/assets/stylesheets/common/chat-transcript.scss index 1a70987b426..8584b4ef0f0 100644 --- a/plugins/chat/assets/stylesheets/common/chat-transcript.scss +++ b/plugins/chat/assets/stylesheets/common/chat-transcript.scss @@ -17,17 +17,21 @@ border-bottom: 0; } + details > .chat-transcript-chained:first-of-type { + margin-top: 0.5rem; + } + .chat-transcript-meta { color: var(--primary-high); font-size: var(--font-down-2-rem); border-bottom: 1px solid var(--primary-low); margin-bottom: 1rem; padding-bottom: 0.5rem; - } - .chat-transcript-channel, - .chat-transcript-thread { - font-size: var(--font-down-1-rem); + img.emoji { + height: 1.1em; + width: 1.1em; + } } .chat-transcript-separator { @@ -61,6 +65,26 @@ } } + .topic-body .cooked & { + > details { + padding: 0.5rem; + + > summary { + display: flex; + + &::before { + line-height: 1; + } + + .chat-transcript-thread { + &-header { + margin-bottom: 0.5rem; + } + } + } + } + } + .chat-transcript-user-avatar .avatar { aspect-ratio: 20 / 20; } @@ -81,6 +105,8 @@ .chat-transcript-reaction { @include chat-reaction; + + pointer-events: none; } } diff --git a/plugins/chat/config/locales/client.en.yml b/plugins/chat/config/locales/client.en.yml index 21e71a05f34..7a414b9423e 100644 --- a/plugins/chat/config/locales/client.en.yml +++ b/plugins/chat/config/locales/client.en.yml @@ -445,6 +445,7 @@ en: quote: original_channel: 'Originally sent in %{channel}' copy_success: "Chat quote copied to clipboard" + default_thread_title: "Thread" notification_levels: never: "Never" diff --git a/plugins/chat/config/locales/server.en.yml b/plugins/chat/config/locales/server.en.yml index b8d3147f40b..e88ac71fa5a 100644 --- a/plugins/chat/config/locales/server.en.yml +++ b/plugins/chat/config/locales/server.en.yml @@ -197,6 +197,10 @@ en: summaries: no_targets: "There were no messages during the selected period." + transcript: + default_thread_title: "Thread" + split_thread_range: "messages %{start} to %{end} of %{total}" + discourse_push_notifications: popup: chat_mention: diff --git a/plugins/chat/lib/chat/channel_archive_service.rb b/plugins/chat/lib/chat/channel_archive_service.rb index 3dffdb76923..a49a03fab40 100644 --- a/plugins/chat/lib/chat/channel_archive_service.rb +++ b/plugins/chat/lib/chat/channel_archive_service.rb @@ -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( diff --git a/plugins/chat/lib/chat/transcript_service.rb b/plugins/chat/lib/chat/transcript_service.rb index 82f27dca428..84afda41361 100644 --- a/plugins/chat/lib/chat/transcript_service.rb +++ b/plugins/chat/lib/chat/transcript_service.rb @@ -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 diff --git a/plugins/chat/spec/lib/chat/channel_archive_service_spec.rb b/plugins/chat/spec/lib/chat/channel_archive_service_spec.rb index 41ffaf420f9..75cefaecb9a 100644 --- a/plugins/chat/spec/lib/chat/channel_archive_service_spec.rb +++ b/plugins/chat/spec/lib/chat/channel_archive_service_spec.rb @@ -90,6 +90,13 @@ describe Chat::ChannelArchiveService do num.times { Fabricate(:chat_message, chat_channel: channel) } end + def create_threaded_messages(num, title: nil) + original_message = Fabricate(:chat_message, chat_channel: channel) + thread = + Fabricate(:chat_thread, channel: channel, title: title, original_message: original_message) + (num - 1).times { Fabricate(:chat_message, chat_channel: channel, thread: thread) } + end + def start_archive @channel_archive = described_class.create_archive_process( @@ -143,6 +150,61 @@ describe Chat::ChannelArchiveService do expect(@channel_archive.chat_channel.chat_messages.count).to eq(0) end + it "creates the correct posts for a channel with messages and threads" do + create_messages(2) + create_threaded_messages(6, title: "a new thread") + create_messages(7) + create_threaded_messages(3) + create_threaded_messages(27, title: "another long thread") + create_messages(10) + + start_archive + + stub_const(Chat::ChannelArchiveService, "ARCHIVED_MESSAGES_PER_POST", 5) do + described_class.new(@channel_archive).execute + end + + @channel_archive.reload + topic = @channel_archive.destination_topic + expect(topic.posts.count).to eq(14) + + topic + .posts + .where.not(post_number: 1) + .each do |post| + case post.post_number + when 2 + expect(post.raw).to include("a new thread") + expect(post.raw).to include( + I18n.t("chat.transcript.split_thread_range", start: 1, end: 2, total: 5), + ) + when 3 + expect(post.raw).to include("a new thread") + expect(post.raw).to include( + I18n.t("chat.transcript.split_thread_range", start: 3, end: 5, total: 5), + ) + when 5 + expect(post.raw).to include( + "threadTitle=\"#{I18n.t("chat.transcript.default_thread_title")}\"", + ) + when 10 + expect(post.raw).to include("another long thread") + expect(post.raw).to include( + I18n.t("chat.transcript.split_thread_range", start: 17, end: 20, total: 26), + ) + end + + expect(post.raw).to include("[chat") + expect(post.raw).to include("noLink=\"true\"") + expect(post.user).to eq(Discourse.system_user) + end + expect(topic.archived).to eq(true) + + expect(@channel_archive.archived_messages).to eq(55) + expect(@channel_archive.chat_channel.status).to eq("archived") + expect(@channel_archive.chat_channel.chat_messages.count).to eq(0) + end + it "does not stop the process if the post length is too high (validations disabled)" do create_messages(50) && start_archive SiteSetting.max_post_length = 1 diff --git a/plugins/chat/spec/lib/chat/transcript_service_spec.rb b/plugins/chat/spec/lib/chat/transcript_service_spec.rb index 96d086ce17e..a87a2f58f90 100644 --- a/plugins/chat/spec/lib/chat/transcript_service_spec.rb +++ b/plugins/chat/spec/lib/chat/transcript_service_spec.rb @@ -6,7 +6,9 @@ describe Chat::TranscriptService do let(:acting_user) { Fabricate(:user) } let(:user1) { Fabricate(:user, username: "martinchat") } let(:user2) { Fabricate(:user, username: "brucechat") } - let(:channel) { Fabricate(:category_channel, name: "The Beam Discussions") } + let(:channel) do + Fabricate(:category_channel, name: "The Beam Discussions", threading_enabled: true) + end def service(message_ids, opts: {}) described_class.new(channel, acting_user, messages_or_ids: Array.wrap(message_ids), opts: opts) @@ -254,4 +256,201 @@ describe Chat::TranscriptService do [/chat] MARKDOWN end + + it "generates reaction data for threaded messages" do + thread = Fabricate(:chat_thread, channel: channel) + thread_om = + Fabricate( + :chat_message, + user: user1, + chat_channel: channel, + thread: thread, + message: "an extremely insightful response :)", + ) + thread_reply_1 = + Fabricate( + :chat_message, + chat_channel: channel, + user: user2, + thread: thread, + message: "wow so tru", + ) + thread_reply_2 = + Fabricate( + :chat_message, + chat_channel: channel, + user: user1, + thread: thread, + message: "a new perspective", + ) + + Chat::MessageReaction.create!( + chat_message: thread_om, + user: Fabricate(:user, username: "bjorn"), + emoji: "heart", + ) + Chat::MessageReaction.create!( + chat_message: thread_reply_1, + user: Fabricate(:user, username: "sigurd"), + emoji: "heart", + ) + Chat::MessageReaction.create!( + chat_message: thread_reply_1, + user: Fabricate(:user, username: "hvitserk"), + emoji: "+1", + ) + Chat::MessageReaction.create!( + chat_message: thread_reply_2, + user: Fabricate(:user, username: "ubbe"), + emoji: "money_mouth_face", + ) + + rendered = + service( + [thread_om.id, thread_reply_1.id, thread_reply_2.id], + opts: { + include_reactions: true, + }, + ).generate_markdown + expect(rendered).to eq(<<~MARKDOWN) + [chat quote="martinchat;#{thread_om.id};#{thread_om.created_at.iso8601}" channel="The Beam Discussions" channelId="#{channel.id}" multiQuote="true" chained="true" reactions="heart:bjorn" threadId="#{thread.id}" threadTitle="#{I18n.t("chat.transcript.default_thread_title")}"] + an extremely insightful response :) + + [chat quote="brucechat;#{thread_reply_1.id};#{thread_reply_1.created_at.iso8601}" chained="true" reactions="+1:hvitserk;heart:sigurd"] + wow so tru + [/chat] + + [chat quote="martinchat;#{thread_reply_2.id};#{thread_reply_2.created_at.iso8601}" chained="true" reactions="money_mouth_face:ubbe"] + a new perspective + [/chat] + + [/chat] + MARKDOWN + end + + it "generates a chat transcript for threaded messages" do + thread = Fabricate(:chat_thread, channel: channel) + thread_om = + Fabricate( + :chat_message, + chat_channel: channel, + user: user1, + thread: thread, + message: "reply to me!", + ) + thread_reply_1 = + Fabricate(:chat_message, chat_channel: channel, user: user2, thread: thread, message: "done") + thread_reply_2 = + Fabricate( + :chat_message, + chat_channel: channel, + user: user1, + thread: thread, + message: "thanks", + ) + + rendered = service([thread_om.id, thread_reply_1.id, thread_reply_2.id]).generate_markdown + expect(rendered).to eq(<<~MARKDOWN) + [chat quote="martinchat;#{thread_om.id};#{thread_om.created_at.iso8601}" channel="The Beam Discussions" channelId="#{channel.id}" multiQuote="true" chained="true" threadId="#{thread.id}" threadTitle="#{I18n.t("chat.transcript.default_thread_title")}"] + reply to me! + + [chat quote="brucechat;#{thread_reply_1.id};#{thread_reply_1.created_at.iso8601}" chained="true"] + done + [/chat] + + [chat quote="martinchat;#{thread_reply_2.id};#{thread_reply_2.created_at.iso8601}" chained="true"] + thanks + [/chat] + + [/chat] + MARKDOWN + end + + it "generates the correct markdown for multiple threads" do + channel_message_1 = + Fabricate(:chat_message, user: user1, chat_channel: channel, message: "I need ideas") + thread_1 = Fabricate(:chat_thread, channel: channel) + thread_1_om = + Fabricate( + :chat_message, + chat_channel: channel, + user: user2, + thread: thread_1, + message: "this is my idea", + ) + thread_1_message = + Fabricate( + :chat_message, + chat_channel: channel, + user: user1, + thread: thread_1, + message: "cool", + ) + + channel_message_2 = + Fabricate(:chat_message, user: user2, chat_channel: channel, message: "more?") + thread_2 = Fabricate(:chat_thread, channel: channel, title: "the second idea") + thread_2_om = + Fabricate( + :chat_message, + chat_channel: channel, + user: user2, + thread: thread_2, + message: "another one", + ) + thread_2_message_1 = + Fabricate( + :chat_message, + chat_channel: channel, + user: user1, + thread: thread_2, + message: "thanks", + ) + thread_2_message_2 = + Fabricate(:chat_message, chat_channel: channel, user: user2, thread: thread_2, message: "np") + + rendered = + service( + [ + channel_message_1.id, + thread_1_om.id, + thread_1_message.id, + channel_message_2.id, + thread_2_om.id, + thread_2_message_1.id, + thread_2_message_2.id, + ], + ).generate_markdown + expect(rendered).to eq(<<~MARKDOWN) + [chat quote="martinchat;#{channel_message_1.id};#{channel_message_1.created_at.iso8601}" channel="The Beam Discussions" channelId="#{channel.id}" multiQuote="true" chained="true"] + I need ideas + [/chat] + + [chat quote="brucechat;#{thread_1_om.id};#{thread_1_om.created_at.iso8601}" chained="true" threadId="#{thread_1.id}" threadTitle="#{I18n.t("chat.transcript.default_thread_title")}"] + this is my idea + + [chat quote="martinchat;#{thread_1_message.id};#{thread_1_message.created_at.iso8601}" chained="true"] + cool + [/chat] + + [/chat] + + [chat quote="brucechat;#{channel_message_2.id};#{channel_message_2.created_at.iso8601}" chained="true"] + more? + [/chat] + + [chat quote="brucechat;#{thread_2_om.id};#{thread_2_om.created_at.iso8601}" chained="true" threadId="#{thread_2.id}" threadTitle="the second idea"] + another one + + [chat quote="martinchat;#{thread_2_message_1.id};#{thread_2_message_1.created_at.iso8601}" chained="true"] + thanks + [/chat] + + [chat quote="brucechat;#{thread_2_message_2.id};#{thread_2_message_2.created_at.iso8601}" chained="true"] + np + [/chat] + + [/chat] + MARKDOWN + end end