Compare commits
28 Commits
main
...
dev/chat-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6614f789fa | ||
|
|
1aa304850e | ||
|
|
ed30b241f9 | ||
|
|
006c47cbd1 | ||
|
|
c7a3fcd33a | ||
|
|
2454a251cd | ||
|
|
eb5a39a98c | ||
|
|
4d587da197 | ||
|
|
c666b8154c | ||
|
|
f5ca3a9b14 | ||
|
|
f43dd753ba | ||
|
|
53d605c238 | ||
|
|
bed2b26c18 | ||
|
|
cbe0886836 | ||
|
|
11ec0ea8a3 | ||
|
|
949d2f95fc | ||
|
|
99a21cd633 | ||
|
|
f0d8663202 | ||
|
|
252fef0e70 | ||
|
|
1f4393fd1b | ||
|
|
c6aa8c1da2 | ||
|
|
f6c35239dd | ||
|
|
291701034c | ||
|
|
8e91c9cc92 | ||
|
|
792fcb2e01 | ||
|
|
51c3166c67 | ||
|
|
aa9b945be1 | ||
|
|
e4fe322fa4 |
@ -10,7 +10,7 @@
|
||||
<DButton @id="chat-move-to-channel-btn" @class="btn-secondary" @icon="sign-out-alt" @label="chat.selection.move_selection_to_channel" @title="chat.selection.move_selection_to_channel" @disabled={{not this.anyMessagesSelected}} @action={{action "openMoveMessageModal"}} />
|
||||
{{/if}}
|
||||
|
||||
<DButton @icon="times" @class="btn-secondary cancel-btn" @label="chat.selection.cancel" @title="chat.selection.cancel" @action={{this.cancelSelecting}} />
|
||||
<DButton @id="chat-cancel-selection-btn" @icon="times" @class="btn-secondary cancel-btn" @label="chat.selection.cancel" @title="chat.selection.cancel" @action={{this.cancelSelecting}} />
|
||||
</div>
|
||||
|
||||
{{#if this.showChatQuoteSuccess}}
|
||||
|
||||
@ -85,6 +85,8 @@ export default {
|
||||
I18n.t("dates.long_no_year")
|
||||
);
|
||||
}
|
||||
|
||||
dateTimeEl.dataset.dateFormatted = true;
|
||||
});
|
||||
},
|
||||
{ id: "chat-transcript-datetime" }
|
||||
|
||||
@ -23,6 +23,10 @@ module PageObjects
|
||||
has_no_css?(".chat-skeleton")
|
||||
end
|
||||
|
||||
def has_selection_management?
|
||||
has_css?(".chat-selection-management")
|
||||
end
|
||||
|
||||
def has_message?(text: nil, id: nil)
|
||||
if text
|
||||
has_css?(".chat-message-text", text: text)
|
||||
|
||||
251
plugins/chat/spec/system/transcript_spec.rb
Normal file
251
plugins/chat/spec/system/transcript_spec.rb
Normal file
@ -0,0 +1,251 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
RSpec.describe "Quoting chat message transcripts", type: :system, js: true do
|
||||
fab!(:current_user) { Fabricate(:user) }
|
||||
fab!(:chat_channel_1) { Fabricate(:chat_channel) }
|
||||
|
||||
let(:chat_page) { PageObjects::Pages::Chat.new }
|
||||
let(:chat_channel_page) { PageObjects::Pages::ChatChannel.new }
|
||||
let(:topic_page) { PageObjects::Pages::Topic.new }
|
||||
|
||||
before do
|
||||
chat_system_bootstrap(Fabricate(:admin), [chat_channel_1])
|
||||
chat_channel_1.add(current_user)
|
||||
sign_in(current_user)
|
||||
end
|
||||
|
||||
def select_message_desktop(message)
|
||||
if page.has_css?(".chat-message-container.selecting-messages")
|
||||
chat_channel_page.message_by_id(message.id).find(".chat-message-selector").click
|
||||
else
|
||||
chat_channel_page.message_by_id(message.id).hover
|
||||
expect(page).to have_css(".chat-message-actions .more-buttons")
|
||||
find(".chat-message-actions .more-buttons").click
|
||||
find(".select-kit-row[data-value=\"selectMessage\"]").click
|
||||
end
|
||||
end
|
||||
|
||||
def select_message_mobile(message)
|
||||
if page.has_css?(".chat-message-container.selecting-messages")
|
||||
chat_channel_page.message_by_id(message.id).find(".chat-message-selector").click
|
||||
else
|
||||
chat_channel_page.message_by_id(message.id).click(delay: 0.5)
|
||||
find(".chat-message-action-item[data-id=\"selectMessage\"]").click
|
||||
end
|
||||
end
|
||||
|
||||
def cdp_allow_clipboard_access!
|
||||
cdp_params = {
|
||||
origin: page.server_url,
|
||||
permission: {
|
||||
name: "clipboard-read",
|
||||
},
|
||||
setting: "granted",
|
||||
}
|
||||
page.driver.browser.execute_cdp("Browser.setPermission", **cdp_params)
|
||||
|
||||
cdp_params = {
|
||||
origin: page.server_url,
|
||||
permission: {
|
||||
name: "clipboard-write",
|
||||
},
|
||||
setting: "granted",
|
||||
}
|
||||
page.driver.browser.execute_cdp("Browser.setPermission", **cdp_params)
|
||||
end
|
||||
|
||||
def read_clipboard
|
||||
page.evaluate_async_script("navigator.clipboard.readText().then(arguments[0])")
|
||||
end
|
||||
|
||||
def click_selection_button(button)
|
||||
selector =
|
||||
case button
|
||||
when "quote"
|
||||
"#chat-quote-btn"
|
||||
when "copy"
|
||||
"#chat-copy-btn"
|
||||
when "cancel"
|
||||
"#chat-cancel-selection-btn"
|
||||
when "move"
|
||||
"#chat-move-to-channel-btn"
|
||||
end
|
||||
within(".chat-selection-management-buttons") { find(selector).click }
|
||||
end
|
||||
|
||||
def copy_messages_to_clipboard(messages)
|
||||
messages = Array.wrap(messages)
|
||||
messages.each { |message| select_message_desktop(message) }
|
||||
expect(chat_channel_page).to have_selection_management
|
||||
click_selection_button("copy")
|
||||
expect(page).to have_content("Chat quote copied to clipboard")
|
||||
clip_text = read_clipboard
|
||||
expect(clip_text.chomp).to eq(generate_transcript(messages, current_user))
|
||||
clip_text
|
||||
end
|
||||
|
||||
def generate_transcript(messages, acting_user)
|
||||
messages = Array.wrap(messages)
|
||||
ChatTranscriptService
|
||||
.new(messages.first.chat_channel, acting_user, messages_or_ids: messages.map(&:id))
|
||||
.generate_markdown
|
||||
.chomp
|
||||
end
|
||||
|
||||
describe "copying quote transcripts with the clipboard" do
|
||||
before { cdp_allow_clipboard_access! }
|
||||
|
||||
context "when quoting a single message into a topic" do
|
||||
fab!(:post_1) { Fabricate(:post) }
|
||||
fab!(:message_1) { Fabricate(:chat_message, chat_channel: chat_channel_1) }
|
||||
|
||||
it "quotes the message" do
|
||||
chat_page.visit_channel(chat_channel_1)
|
||||
|
||||
expect(chat_channel_page).to have_no_loading_skeleton
|
||||
|
||||
clip_text = copy_messages_to_clipboard(message_1)
|
||||
topic_page.visit_topic_and_open_composer(post_1.topic)
|
||||
topic_page.fill_in_composer("This is a new post!\n\n" + clip_text)
|
||||
|
||||
within(".d-editor-preview") { expect(page).to have_css(".chat-transcript") }
|
||||
|
||||
topic_page.send_reply
|
||||
selector = topic_page.post_by_number_selector(2)
|
||||
|
||||
begin
|
||||
expect(page).to have_css(selector)
|
||||
rescue RSpec::Expectations::ExpectationNotMetError
|
||||
puts "SELECTOR FAILED #{selector}"
|
||||
puts page.html
|
||||
raise
|
||||
end
|
||||
within(selector) { expect(page).to have_css(".chat-transcript") }
|
||||
end
|
||||
end
|
||||
|
||||
context "when quoting multiple messages into a topic" do
|
||||
fab!(:post_1) { Fabricate(:post) }
|
||||
fab!(:message_1) { Fabricate(:chat_message, chat_channel: chat_channel_1) }
|
||||
fab!(:message_2) { Fabricate(:chat_message, chat_channel: chat_channel_1) }
|
||||
|
||||
it "quotes the messages" do
|
||||
chat_page.visit_channel(chat_channel_1)
|
||||
|
||||
expect(chat_channel_page).to have_no_loading_skeleton
|
||||
|
||||
clip_text = copy_messages_to_clipboard([message_1, message_2])
|
||||
topic_page.visit_topic_and_open_composer(post_1.topic)
|
||||
topic_page.fill_in_composer("This is a new post!\n\n" + clip_text)
|
||||
|
||||
within(".d-editor-preview") { expect(page).to have_css(".chat-transcript", count: 2) }
|
||||
expect(page).to have_content("Originally sent in #{chat_channel_1.name}")
|
||||
|
||||
topic_page.send_reply
|
||||
|
||||
selector = topic_page.post_by_number_selector(2)
|
||||
expect(page).to have_css(selector)
|
||||
within(selector) { expect(page).to have_css(".chat-transcript", count: 2) }
|
||||
end
|
||||
end
|
||||
|
||||
context "when quoting a message containing a onebox" do
|
||||
fab!(:post_1) { Fabricate(:post) }
|
||||
fab!(:message_1) { Fabricate(:chat_message, chat_channel: chat_channel_1) }
|
||||
|
||||
before do
|
||||
Oneboxer.stubs(:preview).returns(
|
||||
"<aside class=\"onebox\"><article class=\"onebox-body\"><h3><a href=\"http://www.example.com/article.html\" tabindex=\"-1\">An interesting article</a></h3></article></aside>",
|
||||
)
|
||||
message_1.update!(message: "http://www.example.com/has-title.html")
|
||||
message_1.rebake!
|
||||
end
|
||||
|
||||
it "works" do
|
||||
chat_page.visit_channel(chat_channel_1)
|
||||
|
||||
expect(chat_channel_page).to have_no_loading_skeleton
|
||||
|
||||
clip_text = copy_messages_to_clipboard(message_1)
|
||||
topic_page.visit_topic_and_open_composer(post_1.topic)
|
||||
topic_page.fill_in_composer(clip_text)
|
||||
|
||||
within(".chat-transcript-messages") do
|
||||
expect(page).to have_content("An interesting article")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "when quoting a message in another message" do
|
||||
fab!(:message_1) { Fabricate(:chat_message, chat_channel: chat_channel_1) }
|
||||
|
||||
it "quotes the message" do
|
||||
chat_page.visit_channel(chat_channel_1)
|
||||
|
||||
expect(chat_channel_page).to have_no_loading_skeleton
|
||||
|
||||
clip_text = copy_messages_to_clipboard(message_1)
|
||||
click_selection_button("cancel")
|
||||
chat_channel_page.fill_composer(clip_text)
|
||||
chat_channel_page.click_send_message
|
||||
|
||||
expect(page).to have_selector(".chat-message", count: 2)
|
||||
|
||||
message = ChatMessage.find_by(user: current_user, message: clip_text.chomp)
|
||||
|
||||
within(chat_channel_page.message_by_id(message.id)) do
|
||||
expect(page).to have_css(".chat-transcript")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "when quoting into a topic directly" do
|
||||
fab!(:message_1) { Fabricate(:chat_message, chat_channel: chat_channel_1) }
|
||||
let(:topic_title) { "Some topic title for testing" }
|
||||
|
||||
it "opens the topic composer with correct state" do
|
||||
chat_page.visit_channel(chat_channel_1)
|
||||
|
||||
expect(chat_channel_page).to have_no_loading_skeleton
|
||||
|
||||
select_message_desktop(message_1)
|
||||
click_selection_button("quote")
|
||||
|
||||
expect(topic_page).to have_expanded_composer
|
||||
expect(topic_page).to have_composer_content(generate_transcript(message_1, current_user))
|
||||
expect(page).to have_css(
|
||||
".category-input .select-kit-header[data-value='#{chat_channel_1.chatable.id}']",
|
||||
)
|
||||
expect(page).not_to have_current_path(chat_channel_1.chatable.url)
|
||||
|
||||
topic_page.fill_in_composer_title(topic_title)
|
||||
topic_page.send_reply
|
||||
|
||||
selector = topic_page.post_by_number_selector(1)
|
||||
expect(page).to have_css(selector)
|
||||
within(selector) { expect(page).to have_css(".chat-transcript") }
|
||||
|
||||
topic = Topic.find_by(user: current_user, title: topic_title)
|
||||
expect(page).to have_current_path(topic.url)
|
||||
end
|
||||
|
||||
context "when on mobile" do
|
||||
it "first navigates to the channel's category before opening the topic composer with the quote prefilled",
|
||||
mobile: true do
|
||||
chat_page.visit_channel(chat_channel_1)
|
||||
expect(chat_channel_page).to have_no_loading_skeleton
|
||||
|
||||
select_message_mobile(message_1)
|
||||
click_selection_button("quote")
|
||||
|
||||
expect(topic_page).to have_expanded_composer
|
||||
expect(topic_page).to have_composer_content(generate_transcript(message_1, current_user))
|
||||
expect(page).to have_current_path(chat_channel_1.chatable.url)
|
||||
expect(page).to have_css(
|
||||
".category-input .select-kit-header[data-value='#{chat_channel_1.chatable.id}']",
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -1,183 +0,0 @@
|
||||
import { skip, test } from "qunit";
|
||||
import {
|
||||
click,
|
||||
currentURL,
|
||||
tap,
|
||||
triggerEvent,
|
||||
visit,
|
||||
} from "@ember/test-helpers";
|
||||
import {
|
||||
acceptance,
|
||||
exists,
|
||||
loggedInUser,
|
||||
query,
|
||||
visible,
|
||||
} from "discourse/tests/helpers/qunit-helpers";
|
||||
import {
|
||||
chatChannels,
|
||||
generateChatView,
|
||||
} from "discourse/plugins/chat/chat-fixtures";
|
||||
import selectKit from "discourse/tests/helpers/select-kit-helper";
|
||||
|
||||
const quoteResponse = {
|
||||
markdown: `[chat quote="martin-chat;3875498;2022-02-04T01:12:15Z" channel="The Beam Discussions" channelId="1234"]
|
||||
an extremely insightful response :)
|
||||
[/chat]`,
|
||||
};
|
||||
|
||||
function setupPretenders(server, helper) {
|
||||
server.get("/chat/chat_channels.json", () => helper.response(chatChannels));
|
||||
server.post(`/chat/4/quote.json`, () => helper.response(quoteResponse));
|
||||
server.post(`/chat/7/quote.json`, () => helper.response(quoteResponse));
|
||||
server.get("/chat/:chat_channel_id/messages.json", () =>
|
||||
helper.response(generateChatView(loggedInUser()))
|
||||
);
|
||||
server.post("/uploads/lookup-urls", () => {
|
||||
return helper.response([]);
|
||||
});
|
||||
}
|
||||
|
||||
acceptance("Discourse Chat | Copying messages", function (needs) {
|
||||
needs.user({
|
||||
admin: false,
|
||||
moderator: false,
|
||||
username: "eviltrout",
|
||||
id: 1,
|
||||
can_chat: true,
|
||||
has_chat_enabled: true,
|
||||
});
|
||||
|
||||
needs.settings({
|
||||
chat_enabled: true,
|
||||
});
|
||||
|
||||
needs.pretender((server, helper) => {
|
||||
setupPretenders(server, helper);
|
||||
});
|
||||
|
||||
test("it copies the quote and shows a message", async function (assert) {
|
||||
await visit("/chat/channel/7/Bug");
|
||||
assert.ok(exists(".chat-message-container"));
|
||||
|
||||
const firstMessage = query(".chat-message-container");
|
||||
await triggerEvent(firstMessage, "mouseenter");
|
||||
const dropdown = selectKit(
|
||||
`.chat-message-actions-container[data-id="${firstMessage.dataset.id}"] .more-buttons`
|
||||
);
|
||||
await dropdown.expand();
|
||||
await dropdown.selectRowByValue("selectMessage");
|
||||
assert.ok(firstMessage.classList.contains("selecting-messages"));
|
||||
|
||||
const copyButton = query(".chat-live-pane #chat-copy-btn");
|
||||
assert.equal(
|
||||
copyButton.disabled,
|
||||
false,
|
||||
"button is enabled as a message is selected"
|
||||
);
|
||||
|
||||
await click(firstMessage.querySelector("input[type='checkbox']"));
|
||||
assert.equal(
|
||||
copyButton.disabled,
|
||||
true,
|
||||
"button is disabled when no messages are selected"
|
||||
);
|
||||
|
||||
await click(firstMessage.querySelector("input[type='checkbox']"));
|
||||
await click("#chat-copy-btn");
|
||||
assert.ok(exists(".chat-selection-message"), "shows the message");
|
||||
});
|
||||
});
|
||||
|
||||
acceptance("Discourse Chat | Quoting in composer", async function (needs) {
|
||||
needs.user({
|
||||
admin: false,
|
||||
moderator: false,
|
||||
username: "eviltrout",
|
||||
id: 1,
|
||||
can_chat: true,
|
||||
has_chat_enabled: true,
|
||||
});
|
||||
|
||||
needs.settings({
|
||||
chat_enabled: true,
|
||||
});
|
||||
|
||||
needs.pretender((server, helper) => {
|
||||
setupPretenders(server, helper);
|
||||
});
|
||||
|
||||
skip("it opens the composer for the topic and pastes in the quote", async function (assert) {
|
||||
await visit("/t/internationalization-localization/280");
|
||||
|
||||
await click(".header-dropdown-toggle.open-chat");
|
||||
assert.ok(visible(".chat-drawer-container"), "chat drawer is open");
|
||||
assert.ok(exists(".chat-message-container"));
|
||||
|
||||
const firstMessage = query(".chat-message-container");
|
||||
await triggerEvent(firstMessage, "mouseenter");
|
||||
const dropdown = selectKit(".chat-message-container .more-buttons");
|
||||
await dropdown.expand();
|
||||
await dropdown.selectRowByValue("selectMessage");
|
||||
assert.ok(firstMessage.classList.contains("selecting-messages"));
|
||||
|
||||
await click("#chat-quote-btn");
|
||||
assert.ok(exists("#reply-control.composer-action-reply"));
|
||||
assert.strictEqual(
|
||||
query(".composer-action-title .action-title").innerText,
|
||||
"Internationalization / localization"
|
||||
);
|
||||
assert.strictEqual(
|
||||
query("textarea.d-editor-input").value,
|
||||
quoteResponse.markdown
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
acceptance("Discourse Chat | Quoting on mobile", async function (needs) {
|
||||
needs.user({
|
||||
admin: false,
|
||||
moderator: false,
|
||||
username: "eviltrout",
|
||||
id: 1,
|
||||
can_chat: true,
|
||||
has_chat_enabled: true,
|
||||
});
|
||||
|
||||
needs.settings({
|
||||
chat_enabled: true,
|
||||
});
|
||||
|
||||
needs.pretender((server, helper) => {
|
||||
setupPretenders(server, helper);
|
||||
});
|
||||
|
||||
needs.mobileView();
|
||||
|
||||
skip("it opens the chatable, opens the composer, and pastes the markdown in", async function (assert) {
|
||||
await visit("/chat/channel/7/Bug");
|
||||
assert.ok(exists(".chat-message-container"));
|
||||
|
||||
const firstMessage = query(".chat-message-container");
|
||||
await tap(firstMessage);
|
||||
await click(".chat-message-action-item[data-id='selectMessage'] button");
|
||||
assert.ok(firstMessage.classList.contains("selecting-messages"));
|
||||
|
||||
await click("#chat-quote-btn");
|
||||
|
||||
assert.equal(currentURL(), "/c/bug/1", "navigates to the chatable url");
|
||||
assert.ok(
|
||||
exists("#reply-control.composer-action-createTopic"),
|
||||
"the composer opens"
|
||||
);
|
||||
assert.strictEqual(
|
||||
query("textarea.d-editor-input").value,
|
||||
quoteResponse.markdown,
|
||||
"the composer has the markdown"
|
||||
);
|
||||
assert.strictEqual(
|
||||
selectKit(".category-chooser").header().value(),
|
||||
"1",
|
||||
"it fills category selector with the right category"
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -1,597 +0,0 @@
|
||||
import PrettyText, { buildOptions } from "pretty-text/pretty-text";
|
||||
import { emojiUnescape } from "discourse/lib/text";
|
||||
import I18n from "I18n";
|
||||
import topicFixtures from "discourse/tests/fixtures/topic";
|
||||
import { cloneJSON, deepMerge } from "discourse-common/lib/object";
|
||||
import QUnit, { test } from "qunit";
|
||||
|
||||
import { click, fillIn, visit } from "@ember/test-helpers";
|
||||
import { acceptance, query } from "discourse/tests/helpers/qunit-helpers";
|
||||
|
||||
const rawOpts = {
|
||||
siteSettings: {
|
||||
enable_emoji: true,
|
||||
enable_emoji_shortcuts: true,
|
||||
enable_mentions: true,
|
||||
emoji_set: "twitter",
|
||||
external_emoji_url: "",
|
||||
highlighted_languages: "json|ruby|javascript",
|
||||
default_code_lang: "auto",
|
||||
enable_markdown_linkify: true,
|
||||
markdown_linkify_tlds: "com",
|
||||
chat_enabled: true,
|
||||
},
|
||||
getURL: (url) => url,
|
||||
};
|
||||
|
||||
function cookMarkdown(input, opts) {
|
||||
const merged = deepMerge({}, rawOpts, opts);
|
||||
return new PrettyText(buildOptions(merged)).cook(input);
|
||||
}
|
||||
|
||||
QUnit.assert.cookedChatTranscript = function (input, opts, expected, message) {
|
||||
const actual = cookMarkdown(input, opts);
|
||||
this.pushResult({
|
||||
result: actual === expected,
|
||||
actual,
|
||||
expected,
|
||||
message,
|
||||
});
|
||||
};
|
||||
|
||||
function generateTranscriptHTML(messageContent, opts) {
|
||||
const channelDataAttr = opts.channel
|
||||
? ` data-channel-name=\"${opts.channel}\"`
|
||||
: "";
|
||||
const channelIdDataAttr = opts.channelId
|
||||
? ` data-channel-id=\"${opts.channelId}\"`
|
||||
: "";
|
||||
const reactDataAttr = opts.reactions
|
||||
? ` data-reactions=\"${opts.reactionsAttr}\"`
|
||||
: "";
|
||||
|
||||
let tabIndexHTML = opts.linkTabIndex ? ' tabindex="-1"' : "";
|
||||
|
||||
let transcriptClasses = ["chat-transcript"];
|
||||
if (opts.chained) {
|
||||
transcriptClasses.push("chat-transcript-chained");
|
||||
}
|
||||
|
||||
const transcript = [];
|
||||
transcript.push(
|
||||
`<div class=\"${transcriptClasses.join(" ")}\" data-message-id=\"${
|
||||
opts.messageId
|
||||
}\" data-username=\"${opts.username}\" data-datetime=\"${
|
||||
opts.datetime
|
||||
}\"${reactDataAttr}${channelDataAttr}${channelIdDataAttr}>`
|
||||
);
|
||||
|
||||
if (opts.channel && opts.multiQuote) {
|
||||
let originallySent = I18n.t("chat.quote.original_channel", {
|
||||
channel: opts.channel,
|
||||
channelLink: `/chat/channel/${opts.channelId}/-`,
|
||||
});
|
||||
if (opts.linkTabIndex) {
|
||||
originallySent = originallySent.replace(">", tabIndexHTML + ">");
|
||||
}
|
||||
transcript.push(`<div class=\"chat-transcript-meta\">
|
||||
${originallySent}</div>`);
|
||||
}
|
||||
|
||||
const dateTimeText = opts.showDateTimeText
|
||||
? moment
|
||||
.tz(opts.datetime, opts.timezone)
|
||||
.format(I18n.t("dates.long_no_year"))
|
||||
: "";
|
||||
|
||||
const innerDatetimeEl =
|
||||
opts.noLink || !opts.channelId
|
||||
? `<span title=\"${opts.datetime}\">${dateTimeText}</span>`
|
||||
: `<a href=\"/chat/channel/${opts.channelId}/-?messageId=${opts.messageId}\" title=\"${opts.datetime}\"${tabIndexHTML}>${dateTimeText}</a>`;
|
||||
transcript.push(`<div class=\"chat-transcript-user\">
|
||||
<div class=\"chat-transcript-user-avatar\"></div>
|
||||
<div class=\"chat-transcript-username\">
|
||||
${opts.username}</div>
|
||||
<div class=\"chat-transcript-datetime\">
|
||||
${innerDatetimeEl}</div>`);
|
||||
|
||||
if (opts.channel && !opts.multiQuote) {
|
||||
transcript.push(
|
||||
`<a class=\"chat-transcript-channel\" href="/chat/channel/${opts.channelId}/-"${tabIndexHTML}>
|
||||
#${opts.channel}</a></div>`
|
||||
);
|
||||
} else {
|
||||
transcript.push("</div>");
|
||||
}
|
||||
|
||||
let messageHtml = `<div class=\"chat-transcript-messages\">\n${messageContent}`;
|
||||
|
||||
if (opts.reactions) {
|
||||
let reactionsHtml = [`<div class=\"chat-transcript-reactions\">\n`];
|
||||
opts.reactions.forEach((react) => {
|
||||
reactionsHtml.push(
|
||||
`<div class=\"chat-transcript-reaction\">\n${emojiUnescape(
|
||||
`:${react.emoji}:`,
|
||||
{ lazy: true }
|
||||
).replace(/'/g, '"')} ${react.usernames.length}</div>\n`
|
||||
);
|
||||
});
|
||||
reactionsHtml.push(`</div>\n`);
|
||||
messageHtml += reactionsHtml.join("");
|
||||
}
|
||||
transcript.push(`${messageHtml}</div>`);
|
||||
transcript.push("</div>");
|
||||
return transcript.join("\n");
|
||||
}
|
||||
|
||||
// these are both set by the plugin with Site.markdown_additional_options which we can't really
|
||||
// modify the response for here, source of truth are consts in ChatMessage::MARKDOWN_FEATURES
|
||||
// and ChatMessage::MARKDOWN_IT_RULES
|
||||
function buildAdditionalOptions() {
|
||||
return {
|
||||
chat: {
|
||||
limited_pretty_text_features: [
|
||||
"anchor",
|
||||
"bbcode-block",
|
||||
"bbcode-inline",
|
||||
"code",
|
||||
"category-hashtag",
|
||||
"censored",
|
||||
"discourse-local-dates",
|
||||
"emoji",
|
||||
"emojiShortcuts",
|
||||
"inlineEmoji",
|
||||
"html-img",
|
||||
"mentions",
|
||||
"onebox",
|
||||
"text-post-process",
|
||||
"upload-protocol",
|
||||
"watched-words",
|
||||
"table",
|
||||
"spoiler-alert",
|
||||
],
|
||||
limited_pretty_text_markdown_rules: [
|
||||
"autolink",
|
||||
"list",
|
||||
"backticks",
|
||||
"newline",
|
||||
"code",
|
||||
"fence",
|
||||
"table",
|
||||
"linkify",
|
||||
"link",
|
||||
"strikethrough",
|
||||
"blockquote",
|
||||
"emphasis",
|
||||
],
|
||||
hashtag_configurations: {
|
||||
"chat-composer": ["channel", "category", "tag"],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
acceptance("Discourse Chat | chat-transcript", function (needs) {
|
||||
let additionalOptions = buildAdditionalOptions();
|
||||
|
||||
needs.user({
|
||||
admin: false,
|
||||
moderator: false,
|
||||
username: "eviltrout",
|
||||
id: 1,
|
||||
can_chat: false,
|
||||
has_chat_enabled: false,
|
||||
timezone: "Australia/Brisbane",
|
||||
});
|
||||
|
||||
needs.settings({
|
||||
emoji_set: "twitter",
|
||||
});
|
||||
|
||||
test("works with a minimal quote bbcode block", function (assert) {
|
||||
assert.cookedChatTranscript(
|
||||
`[chat quote="martin;2321;2022-01-25T05:40:39Z"]\nThis is a chat message.\n[/chat]`,
|
||||
{ additionalOptions },
|
||||
generateTranscriptHTML("<p>This is a chat message.</p>", {
|
||||
messageId: "2321",
|
||||
username: "martin",
|
||||
datetime: "2022-01-25T05:40:39Z",
|
||||
timezone: "Australia/Brisbane",
|
||||
}),
|
||||
"renders the chat message with the required CSS classes and attributes"
|
||||
);
|
||||
});
|
||||
|
||||
test("renders the channel name if provided with multiQuote", function (assert) {
|
||||
assert.cookedChatTranscript(
|
||||
`[chat quote="martin;2321;2022-01-25T05:40:39Z" channel="Cool Cats Club" channelId="1234" multiQuote="true"]\nThis is a chat message.\n[/chat]`,
|
||||
{ additionalOptions },
|
||||
generateTranscriptHTML("<p>This is a chat message.</p>", {
|
||||
messageId: "2321",
|
||||
username: "martin",
|
||||
datetime: "2022-01-25T05:40:39Z",
|
||||
channel: "Cool Cats Club",
|
||||
channelId: "1234",
|
||||
multiQuote: true,
|
||||
timezone: "Australia/Brisbane",
|
||||
}),
|
||||
"renders the chat transcript with the channel name included above the user and datetime"
|
||||
);
|
||||
});
|
||||
|
||||
test("renders the channel name if provided without multiQuote", function (assert) {
|
||||
assert.cookedChatTranscript(
|
||||
`[chat quote="martin;2321;2022-01-25T05:40:39Z" channel="Cool Cats Club" channelId="1234"]\nThis is a chat message.\n[/chat]`,
|
||||
{ additionalOptions },
|
||||
generateTranscriptHTML("<p>This is a chat message.</p>", {
|
||||
messageId: "2321",
|
||||
username: "martin",
|
||||
datetime: "2022-01-25T05:40:39Z",
|
||||
channel: "Cool Cats Club",
|
||||
channelId: "1234",
|
||||
timezone: "Australia/Brisbane",
|
||||
}),
|
||||
"renders the chat transcript with the channel name included next to the datetime"
|
||||
);
|
||||
});
|
||||
|
||||
test("renders with the chained attribute for more compact quotes", function (assert) {
|
||||
assert.cookedChatTranscript(
|
||||
`[chat quote="martin;2321;2022-01-25T05:40:39Z" channel="Cool Cats Club" channelId="1234" multiQuote="true" chained="true"]\nThis is a chat message.\n[/chat]`,
|
||||
{ additionalOptions },
|
||||
generateTranscriptHTML("<p>This is a chat message.</p>", {
|
||||
messageId: "2321",
|
||||
username: "martin",
|
||||
datetime: "2022-01-25T05:40:39Z",
|
||||
channel: "Cool Cats Club",
|
||||
channelId: "1234",
|
||||
multiQuote: true,
|
||||
chained: true,
|
||||
timezone: "Australia/Brisbane",
|
||||
}),
|
||||
"renders with the chained attribute"
|
||||
);
|
||||
});
|
||||
|
||||
test("renders with the noLink attribute to remove the links to the individual messages from the datetimes", function (assert) {
|
||||
assert.cookedChatTranscript(
|
||||
`[chat quote="martin;2321;2022-01-25T05:40:39Z" channel="Cool Cats Club" channelId="1234" multiQuote="true" noLink="true"]\nThis is a chat message.\n[/chat]`,
|
||||
{ additionalOptions },
|
||||
generateTranscriptHTML("<p>This is a chat message.</p>", {
|
||||
messageId: "2321",
|
||||
username: "martin",
|
||||
datetime: "2022-01-25T05:40:39Z",
|
||||
channel: "Cool Cats Club",
|
||||
channelId: "1234",
|
||||
multiQuote: true,
|
||||
noLink: true,
|
||||
timezone: "Australia/Brisbane",
|
||||
}),
|
||||
"renders with the noLink attribute"
|
||||
);
|
||||
});
|
||||
|
||||
test("renders with the reactions attribute", function (assert) {
|
||||
const reactionsAttr = "+1:martin;heart:martin,eviltrout";
|
||||
assert.cookedChatTranscript(
|
||||
`[chat quote="martin;2321;2022-01-25T05:40:39Z" channel="Cool Cats Club" channelId="1234" reactions="${reactionsAttr}"]\nThis is a chat message.\n[/chat]`,
|
||||
{ additionalOptions },
|
||||
generateTranscriptHTML("<p>This is a chat message.</p>", {
|
||||
messageId: "2321",
|
||||
username: "martin",
|
||||
datetime: "2022-01-25T05:40:39Z",
|
||||
channel: "Cool Cats Club",
|
||||
channelId: "1234",
|
||||
timezone: "Australia/Brisbane",
|
||||
reactionsAttr,
|
||||
reactions: [
|
||||
{ emoji: "+1", usernames: ["martin"] },
|
||||
{ emoji: "heart", usernames: ["martin", "eviltrout"] },
|
||||
],
|
||||
}),
|
||||
"renders with the reaction data attribute and HTML"
|
||||
);
|
||||
});
|
||||
|
||||
test("renders with minimal markdown rules inside the quote bbcode block, same as server-side chat messages", function (assert) {
|
||||
assert.cookedChatTranscript(
|
||||
`[chat quote="johnsmith;450;2021-04-25T05:40:39Z"]
|
||||
[quote="martin, post:3, topic:6215"]
|
||||
another cool reply
|
||||
[/quote]
|
||||
[/chat]`,
|
||||
{ additionalOptions },
|
||||
generateTranscriptHTML(
|
||||
`<p>[quote="martin, post:3, topic:6215"]<br>
|
||||
another cool reply<br>
|
||||
[/quote]</p>`,
|
||||
{
|
||||
messageId: "450",
|
||||
username: "johnsmith",
|
||||
datetime: "2021-04-25T05:40:39Z",
|
||||
timezone: "Australia/Brisbane",
|
||||
}
|
||||
),
|
||||
"does not render the markdown feature that has been excluded"
|
||||
);
|
||||
|
||||
assert.cookedChatTranscript(
|
||||
`[chat quote="martin;2321;2022-01-25T05:40:39Z"]\nThis ~~does work~~ with removed _rules_.\n\n* list item 1\n[/chat]`,
|
||||
{ additionalOptions },
|
||||
generateTranscriptHTML(
|
||||
`<p>This <s>does work</s> with removed <em>rules</em>.</p>
|
||||
<ul>
|
||||
<li>list item 1</li>
|
||||
</ul>`,
|
||||
{
|
||||
messageId: "2321",
|
||||
username: "martin",
|
||||
datetime: "2022-01-25T05:40:39Z",
|
||||
timezone: "Australia/Brisbane",
|
||||
}
|
||||
),
|
||||
"renders correctly when the rule has not been excluded"
|
||||
);
|
||||
|
||||
additionalOptions.chat.limited_pretty_text_markdown_rules = [
|
||||
"autolink",
|
||||
// "list",
|
||||
"backticks",
|
||||
"newline",
|
||||
"code",
|
||||
"fence",
|
||||
"table",
|
||||
"linkify",
|
||||
"link",
|
||||
// "strikethrough",
|
||||
"blockquote",
|
||||
// "emphasis",
|
||||
];
|
||||
|
||||
assert.cookedChatTranscript(
|
||||
`[chat quote="martin;2321;2022-01-25T05:40:39Z"]\nThis ~~does work~~ with removed _rules_.\n\n* list item 1\n[/chat]`,
|
||||
{ additionalOptions },
|
||||
generateTranscriptHTML(
|
||||
`<p>This ~~does work~~ with removed _rules_.</p>
|
||||
<p>* list item 1</p>`,
|
||||
{
|
||||
messageId: "2321",
|
||||
username: "martin",
|
||||
datetime: "2022-01-25T05:40:39Z",
|
||||
timezone: "Australia/Brisbane",
|
||||
}
|
||||
),
|
||||
"renders correctly with some obvious rules excluded (list/strikethrough/emphasis)"
|
||||
);
|
||||
|
||||
assert.cookedChatTranscript(
|
||||
`[chat quote="martin;2321;2022-01-25T05:40:39Z"]\nhere is a message :P with category hashtag #test\n[/chat]`,
|
||||
{ additionalOptions },
|
||||
generateTranscriptHTML(
|
||||
`<p>here is a message <img src=\"/images/emoji/twitter/stuck_out_tongue.png?v=12\" title=\":stuck_out_tongue:\" class=\"emoji\" alt=\":stuck_out_tongue:\" loading=\"lazy\" width=\"20\" height=\"20\"> with category hashtag <span class=\"hashtag\">#test</span></p>`,
|
||||
{
|
||||
messageId: "2321",
|
||||
username: "martin",
|
||||
datetime: "2022-01-25T05:40:39Z",
|
||||
timezone: "Australia/Brisbane",
|
||||
}
|
||||
),
|
||||
"renders correctly when the feature has not been excluded"
|
||||
);
|
||||
|
||||
additionalOptions.chat.limited_pretty_text_features = [
|
||||
"anchor",
|
||||
"bbcode-block",
|
||||
"bbcode-inline",
|
||||
"code",
|
||||
// "category-hashtag",
|
||||
"censored",
|
||||
"discourse-local-dates",
|
||||
"emoji",
|
||||
// "emojiShortcuts",
|
||||
"inlineEmoji",
|
||||
"html-img",
|
||||
"mentions",
|
||||
"onebox",
|
||||
"text-post-process",
|
||||
"upload-protocolrouter.location.setURL",
|
||||
"watched-words",
|
||||
"table",
|
||||
"spoiler-alert",
|
||||
];
|
||||
|
||||
assert.cookedChatTranscript(
|
||||
`[chat quote="martin;2321;2022-01-25T05:40:39Z"]\nhere is a message :P with category hashtag #test\n[/chat]`,
|
||||
{ additionalOptions },
|
||||
generateTranscriptHTML(
|
||||
`<p>here is a message :P with category hashtag #test</p>`,
|
||||
{
|
||||
messageId: "2321",
|
||||
username: "martin",
|
||||
datetime: "2022-01-25T05:40:39Z",
|
||||
timezone: "Australia/Brisbane",
|
||||
}
|
||||
),
|
||||
"renders correctly with some obvious features excluded (category-hashtag, emojiShortcuts)"
|
||||
);
|
||||
|
||||
assert.cookedChatTranscript(
|
||||
`This ~~does work~~ with removed _rules_.
|
||||
|
||||
* list item 1
|
||||
|
||||
here is a message :P with category hashtag #test
|
||||
|
||||
[chat quote="martin;2321;2022-01-25T05:40:39Z"]
|
||||
This ~~does work~~ with removed _rules_.
|
||||
|
||||
* list item 1
|
||||
|
||||
here is a message :P with category hashtag #test
|
||||
[/chat]`,
|
||||
{ additionalOptions },
|
||||
`<p>This <s>does work</s> with removed <em>rules</em>.</p>
|
||||
<ul>
|
||||
<li>list item 1</li>
|
||||
</ul>
|
||||
<p>here is a message <img src=\"/images/emoji/twitter/stuck_out_tongue.png?v=12\" title=\":stuck_out_tongue:\" class=\"emoji\" alt=\":stuck_out_tongue:\" loading=\"lazy\" width=\"20\" height=\"20\"> with category hashtag <span class=\"hashtag\">#test</span></p>\n` +
|
||||
generateTranscriptHTML(
|
||||
`<p>This ~~does work~~ with removed _rules_.</p>
|
||||
<p>* list item 1</p>
|
||||
<p>here is a message :P with category hashtag #test</p>`,
|
||||
{
|
||||
messageId: "2321",
|
||||
username: "martin",
|
||||
datetime: "2022-01-25T05:40:39Z",
|
||||
timezone: "Australia/Brisbane",
|
||||
}
|
||||
),
|
||||
"the rule changes do not apply outside the BBCode [chat] block"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
acceptance(
|
||||
"Discourse Chat | chat-transcript date decoration",
|
||||
function (needs) {
|
||||
let additionalOptions = buildAdditionalOptions();
|
||||
|
||||
needs.user({
|
||||
admin: false,
|
||||
moderator: false,
|
||||
username: "eviltrout",
|
||||
id: 1,
|
||||
can_chat: true,
|
||||
has_chat_enabled: true,
|
||||
timezone: "Australia/Brisbane",
|
||||
});
|
||||
needs.settings({
|
||||
chat_enabled: true,
|
||||
});
|
||||
|
||||
needs.pretender((server, helper) => {
|
||||
server.get("/chat/chat_channels.json", () =>
|
||||
helper.response({
|
||||
public_channels: [],
|
||||
direct_message_channels: [],
|
||||
message_bus_last_ids: {
|
||||
channel_metadata: 0,
|
||||
channel_edits: 0,
|
||||
channel_status: 0,
|
||||
new_channel: 0,
|
||||
user_tracking_state: 0,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const topicResponse = cloneJSON(topicFixtures["/t/280/1.json"]);
|
||||
const firstPost = topicResponse.post_stream.posts[0];
|
||||
const postCooked = cookMarkdown(
|
||||
`[chat quote="martin;2321;2022-01-25T05:40:39Z"]\nThis is a chat message.\n[/chat]`,
|
||||
{ additionalOptions }
|
||||
);
|
||||
firstPost.cooked += postCooked;
|
||||
|
||||
server.get("/t/280.json", () => helper.response(topicResponse));
|
||||
});
|
||||
|
||||
test("chat transcript datetimes are formatted into the link with decorateCookedElement", async function (assert) {
|
||||
await visit("/t/-/280");
|
||||
|
||||
assert.strictEqual(
|
||||
query(".chat-transcript-datetime span").innerText.trim(),
|
||||
moment
|
||||
.tz("2022-01-25T05:40:39Z", "Australia/Brisbane")
|
||||
.format(I18n.t("dates.long_no_year")),
|
||||
"it decorates the chat transcript datetime link with a formatted date"
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
acceptance(
|
||||
"Discourse Chat - chat-transcript - Composer Oneboxes ",
|
||||
function (needs) {
|
||||
let additionalOptions = buildAdditionalOptions();
|
||||
needs.user({
|
||||
admin: false,
|
||||
moderator: false,
|
||||
username: "eviltrout",
|
||||
id: 1,
|
||||
can_chat: true,
|
||||
has_chat_enabled: true,
|
||||
timezone: "Australia/Brisbane",
|
||||
});
|
||||
needs.settings({
|
||||
chat_enabled: true,
|
||||
enable_markdown_linkify: true,
|
||||
max_oneboxes_per_post: 2,
|
||||
});
|
||||
needs.pretender((server, helper) => {
|
||||
server.get("/chat/chat_channels.json", () =>
|
||||
helper.response({
|
||||
public_channels: [],
|
||||
direct_message_channels: [],
|
||||
message_bus_last_ids: {
|
||||
channel_metadata: 0,
|
||||
channel_edits: 0,
|
||||
channel_status: 0,
|
||||
new_channel: 0,
|
||||
user_tracking_state: 0,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const topicResponse = cloneJSON(topicFixtures["/t/280/1.json"]);
|
||||
const firstPost = topicResponse.post_stream.posts[0];
|
||||
const postCooked = cookMarkdown(
|
||||
`[chat quote="martin;2321;2022-01-25T05:40:39Z"]\nThis is a chat message.\n[/chat]`,
|
||||
{ additionalOptions }
|
||||
);
|
||||
firstPost.cooked += postCooked;
|
||||
|
||||
server.get("/t/280.json", () => helper.response(topicResponse));
|
||||
});
|
||||
|
||||
test("Preview should not error for oneboxes within [chat] bbcode", async function (assert) {
|
||||
await visit("/t/internationalization-localization/280");
|
||||
await click("#topic-footer-buttons .btn.create");
|
||||
|
||||
await fillIn(
|
||||
".d-editor-input",
|
||||
`
|
||||
[chat quote="martin;2321;2022-01-25T05:40:39Z" channel="Cool Cats Club" channelId="1234" multiQuote="true"]
|
||||
http://www.example.com/has-title.html
|
||||
[/chat]`
|
||||
);
|
||||
|
||||
const rendered = generateTranscriptHTML(
|
||||
'<p><aside class="onebox"><article class="onebox-body"><h3><a href="http://www.example.com/article.html" tabindex="-1">An interesting article</a></h3></article></aside></p>',
|
||||
{
|
||||
messageId: "2321",
|
||||
username: "martin",
|
||||
datetime: "2022-01-25T05:40:39Z",
|
||||
channel: "Cool Cats Club",
|
||||
channelId: "1234",
|
||||
multiQuote: true,
|
||||
linkTabIndex: true,
|
||||
showDateTimeText: true,
|
||||
timezone: "Australia/Brisbane",
|
||||
}
|
||||
);
|
||||
|
||||
assert.strictEqual(
|
||||
query(".d-editor-preview").innerHTML.trim(),
|
||||
rendered.trim(),
|
||||
"it renders correctly with the onebox inside the [chat] bbcode"
|
||||
);
|
||||
|
||||
const textarea = query("#reply-control .d-editor-input");
|
||||
await fillIn(".d-editor-input", textarea.value + "\nA");
|
||||
assert.ok(
|
||||
query(".d-editor-preview").innerHTML.trim().includes("\n<p>A</p>"),
|
||||
"it does not error with a opts.discourse.hoisted error in the markdown pipeline when typing more text"
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
@ -249,6 +249,8 @@ RSpec.configure do |config|
|
||||
allow: [Webdrivers::Chromedriver.base_url]
|
||||
)
|
||||
|
||||
Capybara.disable_animation = true
|
||||
|
||||
Capybara.configure do |capybara_config|
|
||||
capybara_config.server_host = "localhost"
|
||||
capybara_config.server_port = 31337
|
||||
|
||||
@ -20,6 +20,7 @@ module SystemHelpers
|
||||
SiteSetting.content_security_policy = false
|
||||
SiteSetting.force_hostname = "#{Capybara.server_host}:#{Capybara.server_port}"
|
||||
SiteSetting.external_system_avatars_enabled = false
|
||||
SiteSetting.disable_avatar_education_message = true
|
||||
end
|
||||
|
||||
def try_until_success(timeout: 2, frequency: 0.01)
|
||||
|
||||
@ -34,7 +34,11 @@ module PageObjects
|
||||
|
||||
def post_by_number(post_or_number)
|
||||
post_or_number = post_or_number.is_a?(Post) ? post_or_number.post_number : post_or_number
|
||||
find("#post_#{post_or_number}")
|
||||
find(".topic-post:not(.staged) #post_#{post_or_number}")
|
||||
end
|
||||
|
||||
def post_by_number_selector(post_number)
|
||||
".topic-post:not(.staged) #post_#{post_number}"
|
||||
end
|
||||
|
||||
def has_post_more_actions?(post)
|
||||
@ -74,22 +78,44 @@ module PageObjects
|
||||
|
||||
def click_reply_button
|
||||
find(".topic-footer-main-buttons > .create").click
|
||||
has_expanded_composer?
|
||||
end
|
||||
|
||||
def has_expanded_composer?
|
||||
has_css?("#reply-control.open")
|
||||
end
|
||||
|
||||
def find_composer
|
||||
find("#reply-control .d-editor .d-editor-input")
|
||||
end
|
||||
|
||||
def type_in_composer(input)
|
||||
find("#reply-control .d-editor .d-editor-input").send_keys(input)
|
||||
find_composer.send_keys(input)
|
||||
end
|
||||
|
||||
def fill_in_composer(input)
|
||||
find_composer.fill_in(with: input)
|
||||
end
|
||||
|
||||
def clear_composer
|
||||
find("#reply-control .d-editor .d-editor-input").set("")
|
||||
fill_in_composer("")
|
||||
end
|
||||
|
||||
def has_composer_content?(content)
|
||||
find_composer.value == content
|
||||
end
|
||||
|
||||
def send_reply
|
||||
within("#reply-control") { find(".save-or-cancel .create").click }
|
||||
# find("#reply-control .save-or-cancel .create").click
|
||||
within("#reply-control") do
|
||||
puts "REPLY CONTROL HTML"
|
||||
puts current_scope["innerHTML"]
|
||||
find(".save-or-cancel .create").click
|
||||
end
|
||||
end
|
||||
|
||||
def fill_in_composer_title(title)
|
||||
find("#reply-title").fill_in(with: title)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
Reference in New Issue
Block a user