mirror of
https://github.com/minetest/minetest.git
synced 2024-08-18 20:44:16 +02:00
Make chat web links clickable (#11092)
If enabled in minetest.conf, provides colored, clickable (middle-mouse or ctrl-left-mouse) weblinks in chat output, to open the OS' default web browser.
This commit is contained in:
parent
e1b297a14b
commit
1805775f3d
@ -973,6 +973,12 @@ mute_sound (Mute sound) bool false
|
|||||||
|
|
||||||
[Client]
|
[Client]
|
||||||
|
|
||||||
|
# Clickable weblinks (middle-click or ctrl-left-click) enabled in chat console output.
|
||||||
|
clickable_chat_weblinks (Chat weblinks) bool false
|
||||||
|
|
||||||
|
# Optional override for chat weblink color.
|
||||||
|
chat_weblink_color (Weblink color) string
|
||||||
|
|
||||||
[*Network]
|
[*Network]
|
||||||
|
|
||||||
# Address to connect to.
|
# Address to connect to.
|
||||||
|
@ -1155,6 +1155,14 @@
|
|||||||
# Client
|
# Client
|
||||||
#
|
#
|
||||||
|
|
||||||
|
# If enabled, http links in chat can be middle-clicked or ctrl-left-clicked to open the link in the OS's default web browser.
|
||||||
|
# type: bool
|
||||||
|
# clickable_chat_weblinks = false
|
||||||
|
|
||||||
|
# If clickable_chat_weblinks is enabled, specify the color (as 24-bit hexadecimal) of weblinks in chat.
|
||||||
|
# type: string
|
||||||
|
# chat_weblink_color = #8888FF
|
||||||
|
|
||||||
## Network
|
## Network
|
||||||
|
|
||||||
# Address to connect to.
|
# Address to connect to.
|
||||||
|
@ -6551,3 +6551,12 @@ msgid ""
|
|||||||
"be queued.\n"
|
"be queued.\n"
|
||||||
"This should be lower than curl_parallel_limit."
|
"This should be lower than curl_parallel_limit."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/gui/guiChatConsole.cpp
|
||||||
|
msgid "Opening webpage"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/gui/guiChatConsole.cpp
|
||||||
|
msgid "Failed to open webpage"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
133
src/chat.cpp
133
src/chat.cpp
@ -35,6 +35,17 @@ ChatBuffer::ChatBuffer(u32 scrollback):
|
|||||||
if (m_scrollback == 0)
|
if (m_scrollback == 0)
|
||||||
m_scrollback = 1;
|
m_scrollback = 1;
|
||||||
m_empty_formatted_line.first = true;
|
m_empty_formatted_line.first = true;
|
||||||
|
|
||||||
|
m_cache_clickable_chat_weblinks = false;
|
||||||
|
// Curses mode cannot access g_settings here
|
||||||
|
if (g_settings != nullptr) {
|
||||||
|
m_cache_clickable_chat_weblinks = g_settings->getBool("clickable_chat_weblinks");
|
||||||
|
if (m_cache_clickable_chat_weblinks) {
|
||||||
|
std::string colorval = g_settings->get("chat_weblink_color");
|
||||||
|
parseColorString(colorval, m_cache_chat_weblink_color, false, 255);
|
||||||
|
m_cache_chat_weblink_color.setAlpha(255);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void ChatBuffer::addLine(const std::wstring &name, const std::wstring &text)
|
void ChatBuffer::addLine(const std::wstring &name, const std::wstring &text)
|
||||||
@ -263,78 +274,144 @@ u32 ChatBuffer::formatChatLine(const ChatLine& line, u32 cols,
|
|||||||
//EnrichedString line_text(line.text);
|
//EnrichedString line_text(line.text);
|
||||||
|
|
||||||
next_line.first = true;
|
next_line.first = true;
|
||||||
bool text_processing = false;
|
// Set/use forced newline after the last frag in each line
|
||||||
|
bool mark_newline = false;
|
||||||
|
|
||||||
// Produce fragments and layout them into lines
|
// Produce fragments and layout them into lines
|
||||||
while (!next_frags.empty() || in_pos < line.text.size())
|
while (!next_frags.empty() || in_pos < line.text.size()) {
|
||||||
{
|
mark_newline = false; // now using this to USE line-end frag
|
||||||
|
|
||||||
// Layout fragments into lines
|
// Layout fragments into lines
|
||||||
while (!next_frags.empty())
|
while (!next_frags.empty()) {
|
||||||
{
|
|
||||||
ChatFormattedFragment& frag = next_frags[0];
|
ChatFormattedFragment& frag = next_frags[0];
|
||||||
if (frag.text.size() <= cols - out_column)
|
|
||||||
{
|
// Force newline after this frag, if marked
|
||||||
|
if (frag.column == INT_MAX)
|
||||||
|
mark_newline = true;
|
||||||
|
|
||||||
|
if (frag.text.size() <= cols - out_column) {
|
||||||
// Fragment fits into current line
|
// Fragment fits into current line
|
||||||
frag.column = out_column;
|
frag.column = out_column;
|
||||||
next_line.fragments.push_back(frag);
|
next_line.fragments.push_back(frag);
|
||||||
out_column += frag.text.size();
|
out_column += frag.text.size();
|
||||||
next_frags.erase(next_frags.begin());
|
next_frags.erase(next_frags.begin());
|
||||||
}
|
} else {
|
||||||
else
|
|
||||||
{
|
|
||||||
// Fragment does not fit into current line
|
// Fragment does not fit into current line
|
||||||
// So split it up
|
// So split it up
|
||||||
temp_frag.text = frag.text.substr(0, cols - out_column);
|
temp_frag.text = frag.text.substr(0, cols - out_column);
|
||||||
temp_frag.column = out_column;
|
temp_frag.column = out_column;
|
||||||
//temp_frag.bold = frag.bold;
|
temp_frag.weblink = frag.weblink;
|
||||||
|
|
||||||
next_line.fragments.push_back(temp_frag);
|
next_line.fragments.push_back(temp_frag);
|
||||||
frag.text = frag.text.substr(cols - out_column);
|
frag.text = frag.text.substr(cols - out_column);
|
||||||
|
frag.column = 0;
|
||||||
out_column = cols;
|
out_column = cols;
|
||||||
}
|
}
|
||||||
if (out_column == cols || text_processing)
|
|
||||||
{
|
if (out_column == cols || mark_newline) {
|
||||||
// End the current line
|
// End the current line
|
||||||
destination.push_back(next_line);
|
destination.push_back(next_line);
|
||||||
num_added++;
|
num_added++;
|
||||||
next_line.fragments.clear();
|
next_line.fragments.clear();
|
||||||
next_line.first = false;
|
next_line.first = false;
|
||||||
|
|
||||||
out_column = text_processing ? hanging_indentation : 0;
|
out_column = hanging_indentation;
|
||||||
|
mark_newline = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Produce fragment
|
// Produce fragment(s) for next formatted line
|
||||||
if (in_pos < line.text.size())
|
if (!(in_pos < line.text.size()))
|
||||||
{
|
continue;
|
||||||
u32 remaining_in_input = line.text.size() - in_pos;
|
|
||||||
u32 remaining_in_output = cols - out_column;
|
|
||||||
|
|
||||||
|
const std::wstring &linestring = line.text.getString();
|
||||||
|
u32 remaining_in_output = cols - out_column;
|
||||||
|
size_t http_pos = std::wstring::npos;
|
||||||
|
mark_newline = false; // now using this to SET line-end frag
|
||||||
|
|
||||||
|
// Construct all frags for next output line
|
||||||
|
while (!mark_newline) {
|
||||||
// Determine a fragment length <= the minimum of
|
// Determine a fragment length <= the minimum of
|
||||||
// remaining_in_{in,out}put. Try to end the fragment
|
// remaining_in_{in,out}put. Try to end the fragment
|
||||||
// on a word boundary.
|
// on a word boundary.
|
||||||
u32 frag_length = 1, space_pos = 0;
|
u32 frag_length = 0, space_pos = 0;
|
||||||
|
u32 remaining_in_input = line.text.size() - in_pos;
|
||||||
|
|
||||||
|
if (m_cache_clickable_chat_weblinks) {
|
||||||
|
// Note: unsigned(-1) on fail
|
||||||
|
http_pos = linestring.find(L"https://", in_pos);
|
||||||
|
if (http_pos == std::wstring::npos)
|
||||||
|
http_pos = linestring.find(L"http://", in_pos);
|
||||||
|
if (http_pos != std::wstring::npos)
|
||||||
|
http_pos -= in_pos;
|
||||||
|
}
|
||||||
|
|
||||||
while (frag_length < remaining_in_input &&
|
while (frag_length < remaining_in_input &&
|
||||||
frag_length < remaining_in_output)
|
frag_length < remaining_in_output) {
|
||||||
{
|
if (iswspace(linestring[in_pos + frag_length]))
|
||||||
if (iswspace(line.text.getString()[in_pos + frag_length]))
|
|
||||||
space_pos = frag_length;
|
space_pos = frag_length;
|
||||||
++frag_length;
|
++frag_length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (http_pos >= remaining_in_output) {
|
||||||
|
// Http not in range, grab until space or EOL, halt as normal.
|
||||||
|
// Note this works because (http_pos = npos) is unsigned(-1)
|
||||||
|
|
||||||
|
mark_newline = true;
|
||||||
|
} else if (http_pos == 0) {
|
||||||
|
// At http, grab ALL until FIRST whitespace or end marker. loop.
|
||||||
|
// If at end of string, next loop will be empty string to mark end of weblink.
|
||||||
|
|
||||||
|
frag_length = 6; // Frag is at least "http://"
|
||||||
|
|
||||||
|
// Chars to mark end of weblink
|
||||||
|
// TODO? replace this with a safer (slower) regex whitelist?
|
||||||
|
static const std::wstring delim_chars = L"\'\");,";
|
||||||
|
wchar_t tempchar = linestring[in_pos+frag_length];
|
||||||
|
while (frag_length < remaining_in_input &&
|
||||||
|
!iswspace(tempchar) &&
|
||||||
|
delim_chars.find(tempchar) == std::wstring::npos) {
|
||||||
|
++frag_length;
|
||||||
|
tempchar = linestring[in_pos+frag_length];
|
||||||
|
}
|
||||||
|
|
||||||
|
space_pos = frag_length - 1;
|
||||||
|
// This frag may need to be force-split. That's ok, urls aren't "words"
|
||||||
|
if (frag_length >= remaining_in_output) {
|
||||||
|
mark_newline = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Http in range, grab until http, loop
|
||||||
|
|
||||||
|
space_pos = http_pos - 1;
|
||||||
|
frag_length = http_pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include trailing space in current frag
|
||||||
if (space_pos != 0 && frag_length < remaining_in_input)
|
if (space_pos != 0 && frag_length < remaining_in_input)
|
||||||
frag_length = space_pos + 1;
|
frag_length = space_pos + 1;
|
||||||
|
|
||||||
temp_frag.text = line.text.substr(in_pos, frag_length);
|
temp_frag.text = line.text.substr(in_pos, frag_length);
|
||||||
temp_frag.column = 0;
|
// A hack so this frag remembers mark_newline for the layout phase
|
||||||
//temp_frag.bold = 0;
|
temp_frag.column = mark_newline ? INT_MAX : 0;
|
||||||
|
|
||||||
|
if (http_pos == 0) {
|
||||||
|
// Discard color stuff from the source frag
|
||||||
|
temp_frag.text = EnrichedString(temp_frag.text.getString());
|
||||||
|
temp_frag.text.setDefaultColor(m_cache_chat_weblink_color);
|
||||||
|
// Set weblink in the frag meta
|
||||||
|
temp_frag.weblink = wide_to_utf8(temp_frag.text.getString());
|
||||||
|
} else {
|
||||||
|
temp_frag.weblink.clear();
|
||||||
|
}
|
||||||
next_frags.push_back(temp_frag);
|
next_frags.push_back(temp_frag);
|
||||||
in_pos += frag_length;
|
in_pos += frag_length;
|
||||||
text_processing = true;
|
remaining_in_output -= std::min(frag_length, remaining_in_output);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// End the last line
|
// End the last line
|
||||||
if (num_added == 0 || !next_line.fragments.empty())
|
if (num_added == 0 || !next_line.fragments.empty()) {
|
||||||
{
|
|
||||||
destination.push_back(next_line);
|
destination.push_back(next_line);
|
||||||
num_added++;
|
num_added++;
|
||||||
}
|
}
|
||||||
|
@ -57,6 +57,8 @@ struct ChatFormattedFragment
|
|||||||
EnrichedString text;
|
EnrichedString text;
|
||||||
// starting column
|
// starting column
|
||||||
u32 column;
|
u32 column;
|
||||||
|
// web link is empty for most frags
|
||||||
|
std::string weblink;
|
||||||
// formatting
|
// formatting
|
||||||
//u8 bold:1;
|
//u8 bold:1;
|
||||||
};
|
};
|
||||||
@ -118,6 +120,7 @@ public:
|
|||||||
std::vector<ChatFormattedLine>& destination) const;
|
std::vector<ChatFormattedLine>& destination) const;
|
||||||
|
|
||||||
void resize(u32 scrollback);
|
void resize(u32 scrollback);
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
s32 getTopScrollPos() const;
|
s32 getTopScrollPos() const;
|
||||||
s32 getBottomScrollPos() const;
|
s32 getBottomScrollPos() const;
|
||||||
@ -138,6 +141,11 @@ private:
|
|||||||
std::vector<ChatFormattedLine> m_formatted;
|
std::vector<ChatFormattedLine> m_formatted;
|
||||||
// Empty formatted line, for error returns
|
// Empty formatted line, for error returns
|
||||||
ChatFormattedLine m_empty_formatted_line;
|
ChatFormattedLine m_empty_formatted_line;
|
||||||
|
|
||||||
|
// Enable clickable chat weblinks
|
||||||
|
bool m_cache_clickable_chat_weblinks;
|
||||||
|
// Color of clickable chat weblinks
|
||||||
|
irr::video::SColor m_cache_chat_weblink_color;
|
||||||
};
|
};
|
||||||
|
|
||||||
class ChatPrompt
|
class ChatPrompt
|
||||||
|
@ -65,6 +65,8 @@ void set_default_settings()
|
|||||||
settings->setDefault("max_out_chat_queue_size", "20");
|
settings->setDefault("max_out_chat_queue_size", "20");
|
||||||
settings->setDefault("pause_on_lost_focus", "false");
|
settings->setDefault("pause_on_lost_focus", "false");
|
||||||
settings->setDefault("enable_register_confirmation", "true");
|
settings->setDefault("enable_register_confirmation", "true");
|
||||||
|
settings->setDefault("clickable_chat_weblinks", "false");
|
||||||
|
settings->setDefault("chat_weblink_color", "#8888FF");
|
||||||
|
|
||||||
// Keymap
|
// Keymap
|
||||||
settings->setDefault("remote_port", "30000");
|
settings->setDefault("remote_port", "30000");
|
||||||
|
@ -41,6 +41,10 @@ inline u32 clamp_u8(s32 value)
|
|||||||
return (u32) MYMIN(MYMAX(value, 0), 255);
|
return (u32) MYMIN(MYMAX(value, 0), 255);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inline bool isInCtrlKeys(const irr::EKEY_CODE& kc)
|
||||||
|
{
|
||||||
|
return kc == KEY_LCONTROL || kc == KEY_RCONTROL || kc == KEY_CONTROL;
|
||||||
|
}
|
||||||
|
|
||||||
GUIChatConsole::GUIChatConsole(
|
GUIChatConsole::GUIChatConsole(
|
||||||
gui::IGUIEnvironment* env,
|
gui::IGUIEnvironment* env,
|
||||||
@ -91,6 +95,10 @@ GUIChatConsole::GUIChatConsole(
|
|||||||
|
|
||||||
// set default cursor options
|
// set default cursor options
|
||||||
setCursor(true, true, 2.0, 0.1);
|
setCursor(true, true, 2.0, 0.1);
|
||||||
|
|
||||||
|
// track ctrl keys for mouse event
|
||||||
|
m_is_ctrl_down = false;
|
||||||
|
m_cache_clickable_chat_weblinks = g_settings->getBool("clickable_chat_weblinks");
|
||||||
}
|
}
|
||||||
|
|
||||||
GUIChatConsole::~GUIChatConsole()
|
GUIChatConsole::~GUIChatConsole()
|
||||||
@ -405,8 +413,21 @@ bool GUIChatConsole::OnEvent(const SEvent& event)
|
|||||||
|
|
||||||
ChatPrompt &prompt = m_chat_backend->getPrompt();
|
ChatPrompt &prompt = m_chat_backend->getPrompt();
|
||||||
|
|
||||||
if(event.EventType == EET_KEY_INPUT_EVENT && event.KeyInput.PressedDown)
|
if (event.EventType == EET_KEY_INPUT_EVENT && !event.KeyInput.PressedDown)
|
||||||
{
|
{
|
||||||
|
// CTRL up
|
||||||
|
if (isInCtrlKeys(event.KeyInput.Key))
|
||||||
|
{
|
||||||
|
m_is_ctrl_down = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if(event.EventType == EET_KEY_INPUT_EVENT && event.KeyInput.PressedDown)
|
||||||
|
{
|
||||||
|
// CTRL down
|
||||||
|
if (isInCtrlKeys(event.KeyInput.Key)) {
|
||||||
|
m_is_ctrl_down = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Key input
|
// Key input
|
||||||
if (KeyPress(event.KeyInput) == getKeySetting("keymap_console")) {
|
if (KeyPress(event.KeyInput) == getKeySetting("keymap_console")) {
|
||||||
closeConsole();
|
closeConsole();
|
||||||
@ -613,11 +634,24 @@ bool GUIChatConsole::OnEvent(const SEvent& event)
|
|||||||
}
|
}
|
||||||
else if(event.EventType == EET_MOUSE_INPUT_EVENT)
|
else if(event.EventType == EET_MOUSE_INPUT_EVENT)
|
||||||
{
|
{
|
||||||
if(event.MouseInput.Event == EMIE_MOUSE_WHEEL)
|
if (event.MouseInput.Event == EMIE_MOUSE_WHEEL)
|
||||||
{
|
{
|
||||||
s32 rows = myround(-3.0 * event.MouseInput.Wheel);
|
s32 rows = myround(-3.0 * event.MouseInput.Wheel);
|
||||||
m_chat_backend->scroll(rows);
|
m_chat_backend->scroll(rows);
|
||||||
}
|
}
|
||||||
|
// Middle click or ctrl-click opens weblink, if enabled in config
|
||||||
|
else if(m_cache_clickable_chat_weblinks && (
|
||||||
|
event.MouseInput.Event == EMIE_MMOUSE_PRESSED_DOWN ||
|
||||||
|
(event.MouseInput.Event == EMIE_LMOUSE_PRESSED_DOWN && m_is_ctrl_down)
|
||||||
|
))
|
||||||
|
{
|
||||||
|
// If clicked within console output region
|
||||||
|
if (event.MouseInput.Y / m_fontsize.Y < (m_height / m_fontsize.Y) - 1 )
|
||||||
|
{
|
||||||
|
// Translate pixel position to font position
|
||||||
|
middleClick(event.MouseInput.X / m_fontsize.X, event.MouseInput.Y / m_fontsize.Y);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#if (IRRLICHT_VERSION_MT_REVISION >= 2)
|
#if (IRRLICHT_VERSION_MT_REVISION >= 2)
|
||||||
else if(event.EventType == EET_STRING_INPUT_EVENT)
|
else if(event.EventType == EET_STRING_INPUT_EVENT)
|
||||||
@ -640,3 +674,63 @@ void GUIChatConsole::setVisible(bool visible)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void GUIChatConsole::middleClick(s32 col, s32 row)
|
||||||
|
{
|
||||||
|
// Prevent accidental rapid clicking
|
||||||
|
static u64 s_oldtime = 0;
|
||||||
|
u64 newtime = porting::getTimeMs();
|
||||||
|
|
||||||
|
// 0.6 seconds should suffice
|
||||||
|
if (newtime - s_oldtime < 600)
|
||||||
|
return;
|
||||||
|
s_oldtime = newtime;
|
||||||
|
|
||||||
|
const std::vector<ChatFormattedFragment> &
|
||||||
|
frags = m_chat_backend->getConsoleBuffer().getFormattedLine(row).fragments;
|
||||||
|
std::string weblink = ""; // from frag meta
|
||||||
|
|
||||||
|
// Identify targetted fragment, if exists
|
||||||
|
int indx = frags.size() - 1;
|
||||||
|
if (indx < 0) {
|
||||||
|
// Invalid row, frags is empty
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Scan from right to left, offset by 1 font space because left margin
|
||||||
|
while (indx > -1 && (u32)col < frags[indx].column + 1) {
|
||||||
|
--indx;
|
||||||
|
}
|
||||||
|
if (indx > -1) {
|
||||||
|
weblink = frags[indx].weblink;
|
||||||
|
// Note if(indx < 0) then a frag somehow had a corrupt column field
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
// Debug help. Please keep this in case adjustments are made later.
|
||||||
|
std::string ws;
|
||||||
|
ws = "Middleclick: (" + std::to_string(col) + ',' + std::to_string(row) + ')' + " frags:";
|
||||||
|
// show all frags <position>(<length>) for the clicked row
|
||||||
|
for (u32 i=0;i<frags.size();++i) {
|
||||||
|
if (indx == int(i))
|
||||||
|
// tag the actual clicked frag
|
||||||
|
ws += '*';
|
||||||
|
ws += std::to_string(frags.at(i).column) + '('
|
||||||
|
+ std::to_string(frags.at(i).text.size()) + "),";
|
||||||
|
}
|
||||||
|
actionstream << ws << std::endl;
|
||||||
|
*/
|
||||||
|
|
||||||
|
// User notification
|
||||||
|
if (weblink.size() != 0) {
|
||||||
|
std::ostringstream msg;
|
||||||
|
msg << " * ";
|
||||||
|
if (porting::open_url(weblink)) {
|
||||||
|
msg << gettext("Opening webpage");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
msg << gettext("Failed to open webpage");
|
||||||
|
}
|
||||||
|
msg << " '" << weblink << "'";
|
||||||
|
msg.flush();
|
||||||
|
m_chat_backend->addUnparsedMessage(utf8_to_wide(msg.str()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -84,6 +84,9 @@ private:
|
|||||||
void drawText();
|
void drawText();
|
||||||
void drawPrompt();
|
void drawPrompt();
|
||||||
|
|
||||||
|
// If clicked fragment has a web url, send it to the system default web browser
|
||||||
|
void middleClick(s32 col, s32 row);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
ChatBackend* m_chat_backend;
|
ChatBackend* m_chat_backend;
|
||||||
Client* m_client;
|
Client* m_client;
|
||||||
@ -126,4 +129,9 @@ private:
|
|||||||
// font
|
// font
|
||||||
gui::IGUIFont *m_font = nullptr;
|
gui::IGUIFont *m_font = nullptr;
|
||||||
v2u32 m_fontsize;
|
v2u32 m_fontsize;
|
||||||
|
|
||||||
|
// Enable clickable chat weblinks
|
||||||
|
bool m_cache_clickable_chat_weblinks;
|
||||||
|
// Track if a ctrl key is currently held down
|
||||||
|
bool m_is_ctrl_down;
|
||||||
};
|
};
|
||||||
|
Loading…
Reference in New Issue
Block a user