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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 673 additions and 41 deletions

View File

@ -20,6 +20,8 @@ const chatTranscriptRule = {
const noLink = !!tagInfo.attrs.noLink; const noLink = !!tagInfo.attrs.noLink;
const channelName = tagInfo.attrs.channel; const channelName = tagInfo.attrs.channel;
const channelId = tagInfo.attrs.channelId; const channelId = tagInfo.attrs.channelId;
const threadId = tagInfo.attrs.threadId;
const threadTitle = tagInfo.attrs.threadTitle;
const channelLink = channelId const channelLink = channelId
? options.getURL(`/chat/c/-/${channelId}`) ? options.getURL(`/chat/c/-/${channelId}`)
: null; : null;
@ -29,6 +31,78 @@ const chatTranscriptRule = {
} }
let wrapperDivToken = state.push("div_chat_transcript_wrap", "div", 1); 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"]; let wrapperClasses = ["chat-transcript"];
if (!!tagInfo.attrs.chained) { if (!!tagInfo.attrs.chained) {
@ -46,17 +120,6 @@ const chatTranscriptRule = {
if (channelName) { if (channelName) {
wrapperDivToken.attrs.push(["data-channel-name", 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) { if (channelId) {
@ -117,6 +180,17 @@ const chatTranscriptRule = {
spanToken.attrs = [["title", messageTimeStart]]; spanToken.attrs = [["title", messageTimeStart]];
spanToken.block = false; 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 = state.push("span_close", "span", -1);
spanToken.block = false; spanToken.block = false;
} else { } else {
@ -153,11 +227,32 @@ const chatTranscriptRule = {
let messagesToken = state.push("div_chat_transcript_messages", "div", 1); let messagesToken = state.push("div_chat_transcript_messages", "div", 1);
messagesToken.attrs = [["class", "chat-transcript-messages"]]; messagesToken.attrs = [["class", "chat-transcript-messages"]];
// rendering chat message content with limited markdown rule subset if (threadId) {
const token = state.push("html_raw", "", 1); const regex = /\[chat/i;
const match = regex.exec(content);
token.content = customMarkdownCookFn(content); if (match) {
state.push("html_raw", "", -1); 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) { if (reactions) {
let emojiHtmlCache = {}; let emojiHtmlCache = {};
@ -202,8 +297,12 @@ const chatTranscriptRule = {
export function setup(helper) { export function setup(helper) {
helper.allowList([ helper.allowList([
"svg[class=fa d-icon d-icon-discourse-threads svg-icon svg-node]",
"use[href=#discourse-threads]",
"div[class=chat-transcript]", "div[class=chat-transcript]",
"details[class=chat-transcript]",
"div[class=chat-transcript chat-transcript-chained]", "div[class=chat-transcript chat-transcript-chained]",
"details[class=chat-transcript chat-transcript-chained]",
"div.chat-transcript-meta", "div.chat-transcript-meta",
"div.chat-transcript-user", "div.chat-transcript-user",
"div.chat-transcript-username", "div.chat-transcript-username",
@ -219,6 +318,9 @@ export function setup(helper) {
"div[data-username]", "div[data-username]",
"div[data-datetime]", "div[data-datetime]",
"a.chat-transcript-channel", "a.chat-transcript-channel",
"div.chat-transcript-thread",
"div.chat-transcript-thread-header",
"span.chat-transcript-thread-header__title",
]); ]);
helper.registerOptions((opts, siteSettings) => { helper.registerOptions((opts, siteSettings) => {

View File

@ -17,17 +17,21 @@
border-bottom: 0; border-bottom: 0;
} }
details > .chat-transcript-chained:first-of-type {
margin-top: 0.5rem;
}
.chat-transcript-meta { .chat-transcript-meta {
color: var(--primary-high); color: var(--primary-high);
font-size: var(--font-down-2-rem); font-size: var(--font-down-2-rem);
border-bottom: 1px solid var(--primary-low); border-bottom: 1px solid var(--primary-low);
margin-bottom: 1rem; margin-bottom: 1rem;
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
}
.chat-transcript-channel, img.emoji {
.chat-transcript-thread { height: 1.1em;
font-size: var(--font-down-1-rem); width: 1.1em;
}
} }
.chat-transcript-separator { .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 { .chat-transcript-user-avatar .avatar {
aspect-ratio: 20 / 20; aspect-ratio: 20 / 20;
} }
@ -81,6 +105,8 @@
.chat-transcript-reaction { .chat-transcript-reaction {
@include chat-reaction; @include chat-reaction;
pointer-events: none;
} }
} }

View File

@ -445,6 +445,7 @@ en:
quote: quote:
original_channel: 'Originally sent in <a href="%{channelLink}">%{channel}</a>' original_channel: 'Originally sent in <a href="%{channelLink}">%{channel}</a>'
copy_success: "Chat quote copied to clipboard" copy_success: "Chat quote copied to clipboard"
default_thread_title: "Thread"
notification_levels: notification_levels:
never: "Never" never: "Never"

View File

@ -197,6 +197,10 @@ en:
summaries: summaries:
no_targets: "There were no messages during the selected period." 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: discourse_push_notifications:
popup: popup:
chat_mention: chat_mention:

View File

@ -85,6 +85,7 @@ module Chat
@chat_channel_archive = chat_channel_archive @chat_channel_archive = chat_channel_archive
@chat_channel = chat_channel_archive.chat_channel @chat_channel = chat_channel_archive.chat_channel
@chat_channel_title = chat_channel.title(chat_channel_archive.archived_by) @chat_channel_title = chat_channel.title(chat_channel_archive.archived_by)
@archived_messages_ids = []
end end
def execute def execute
@ -107,22 +108,88 @@ module Chat
# Another future improvement is to send a MessageBus message for each # Another future improvement is to send a MessageBus message for each
# completed batch, so the UI can receive updates and show a progress # completed batch, so the UI can receive updates and show a progress
# bar or something similar. # bar or something similar.
buffer = []
batch_thread_ranges = {}
chat_channel chat_channel
.chat_messages .chat_messages
.find_in_batches(batch_size: ARCHIVED_MESSAGES_PER_POST) do |chat_messages| .order("created_at ASC")
create_post( .find_in_batches(batch_size: ARCHIVED_MESSAGES_PER_POST) do |message_batch|
Chat::TranscriptService.new( thread_ids = message_batch.map(&:thread_id).compact.uniq
chat_channel, threads =
chat_channel_archive.archived_by, chat_channel
messages_or_ids: chat_messages, .chat_messages
opts: { .where(
no_link: true, thread_id:
include_reactions: true, Chat::Message
}, .select(:thread_id)
).generate_markdown, .where(thread_id: thread_ids)
) { delete_message_batch(chat_messages.map(&:id)) } .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 end
create_post_from_batch(buffer, batch_thread_ranges) unless buffer.empty?
kick_all_users kick_all_users
complete_archive complete_archive
rescue => err rescue => err
@ -133,6 +200,63 @@ module Chat
private 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) def create_post(raw)
pc = nil pc = nil
Post.transaction do Post.transaction do
@ -228,9 +352,8 @@ module Chat
deleted_by_id: chat_channel_archive.archived_by.id, deleted_by_id: chat_channel_archive.archived_by.id,
) )
chat_channel_archive.update!( @archived_messages_ids = (@archived_messages_ids + message_ids).uniq
archived_messages: chat_channel_archive.archived_messages + message_ids.length, chat_channel_archive.update!(archived_messages: @archived_messages_ids.length)
)
end end
Rails.logger.info( Rails.logger.info(

View File

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

View File

@ -90,6 +90,13 @@ describe Chat::ChannelArchiveService do
num.times { Fabricate(:chat_message, chat_channel: channel) } num.times { Fabricate(:chat_message, chat_channel: channel) }
end 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 def start_archive
@channel_archive = @channel_archive =
described_class.create_archive_process( described_class.create_archive_process(
@ -143,6 +150,61 @@ describe Chat::ChannelArchiveService do
expect(@channel_archive.chat_channel.chat_messages.count).to eq(0) expect(@channel_archive.chat_channel.chat_messages.count).to eq(0)
end 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 it "does not stop the process if the post length is too high (validations disabled)" do
create_messages(50) && start_archive create_messages(50) && start_archive
SiteSetting.max_post_length = 1 SiteSetting.max_post_length = 1

View File

@ -6,7 +6,9 @@ describe Chat::TranscriptService do
let(:acting_user) { Fabricate(:user) } let(:acting_user) { Fabricate(:user) }
let(:user1) { Fabricate(:user, username: "martinchat") } let(:user1) { Fabricate(:user, username: "martinchat") }
let(:user2) { Fabricate(:user, username: "brucechat") } 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: {}) def service(message_ids, opts: {})
described_class.new(channel, acting_user, messages_or_ids: Array.wrap(message_ids), opts: 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] [/chat]
MARKDOWN MARKDOWN
end 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 end