mirror of
https://github.com/flibusta-apps/book_bot.git
synced 2026-03-02 22:55:24 +01:00
Add per-user default search setting
This commit is contained in:
@@ -9,7 +9,9 @@ use teloxide::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
bots::approved_bot::services::user_settings::create_or_update_user_settings,
|
||||
bots::approved_bot::services::user_settings::{
|
||||
create_or_update_user_settings, get_user_settings,
|
||||
},
|
||||
bots_manager::USER_ACTIVITY_CACHE,
|
||||
};
|
||||
|
||||
@@ -38,6 +40,8 @@ async fn _update_activity(me: teloxide::types::Me, user: teloxide::types::User)
|
||||
|
||||
if update_result.is_err() {
|
||||
let allowed_langs = get_user_or_default_lang_codes(user.id).await;
|
||||
let current = get_user_settings(user.id).await.ok().flatten();
|
||||
let default_search = current.as_ref().and_then(|s| s.default_search);
|
||||
|
||||
if create_or_update_user_settings(
|
||||
user.id,
|
||||
@@ -46,6 +50,7 @@ async fn _update_activity(me: teloxide::types::Me, user: teloxide::types::User)
|
||||
&user.username.unwrap_or("".to_string()),
|
||||
&me.username.clone().unwrap_or("".to_string()),
|
||||
allowed_langs,
|
||||
default_search,
|
||||
)
|
||||
.await
|
||||
.is_ok()
|
||||
|
||||
@@ -3,7 +3,10 @@ use std::{fmt::Display, str::FromStr};
|
||||
use regex::Regex;
|
||||
use strum_macros::EnumIter;
|
||||
|
||||
use crate::bots::approved_bot::modules::utils::pagination::GetPaginationCallbackData;
|
||||
use crate::bots::approved_bot::{
|
||||
modules::utils::pagination::GetPaginationCallbackData,
|
||||
services::user_settings::DefaultSearchType,
|
||||
};
|
||||
|
||||
#[derive(Clone, EnumIter)]
|
||||
pub enum SearchCallbackData {
|
||||
@@ -52,6 +55,16 @@ impl FromStr for SearchCallbackData {
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts default search type to SearchCallbackData with page 1.
|
||||
pub fn default_search_to_callback_data(t: DefaultSearchType) -> SearchCallbackData {
|
||||
match t {
|
||||
DefaultSearchType::Book => SearchCallbackData::Book { page: 1 },
|
||||
DefaultSearchType::Author => SearchCallbackData::Authors { page: 1 },
|
||||
DefaultSearchType::Series => SearchCallbackData::Sequences { page: 1 },
|
||||
DefaultSearchType::Translator => SearchCallbackData::Translators { page: 1 },
|
||||
}
|
||||
}
|
||||
|
||||
impl GetPaginationCallbackData for SearchCallbackData {
|
||||
fn get_pagination_callback_data(&self, target_page: u32) -> String {
|
||||
match self {
|
||||
|
||||
@@ -21,14 +21,17 @@ use crate::bots::{
|
||||
search_author, search_book, search_sequence, search_translator,
|
||||
types::Page,
|
||||
},
|
||||
user_settings::get_user_or_default_lang_codes,
|
||||
user_settings::{get_user_default_search, get_user_or_default_lang_codes},
|
||||
},
|
||||
tools::filter_callback_query,
|
||||
},
|
||||
BotHandlerInternal,
|
||||
};
|
||||
|
||||
use self::{callback_data::SearchCallbackData, utils::get_query};
|
||||
use self::{
|
||||
callback_data::{default_search_to_callback_data, SearchCallbackData},
|
||||
utils::get_query,
|
||||
};
|
||||
|
||||
use super::utils::pagination::generic_get_pagination_keyboard;
|
||||
|
||||
@@ -124,8 +127,103 @@ where
|
||||
}
|
||||
|
||||
pub async fn message_handler(message: Message, bot: CacheMe<Throttle<Bot>>) -> BotHandlerInternal {
|
||||
let message_text = "Что ищем?";
|
||||
let query = message.text().map(|t| t.trim()).filter(|t| !t.is_empty());
|
||||
let user_id = message.from.as_ref().map(|u| u.id);
|
||||
|
||||
if let (Some(user_id), Some(query)) = (user_id, query) {
|
||||
if let Some(default_type) = get_user_default_search(user_id).await {
|
||||
let search_data = default_search_to_callback_data(default_type);
|
||||
let allowed_langs = get_user_or_default_lang_codes(user_id).await;
|
||||
let query_owned = query.to_string();
|
||||
let chat_id = message.chat.id;
|
||||
let reply_params = ReplyParameters::new(message.id);
|
||||
|
||||
let (formatted, pages) = match &search_data {
|
||||
SearchCallbackData::Book { .. } => {
|
||||
match search_book(query_owned, 1, allowed_langs).await {
|
||||
Ok(p) if p.pages == 0 => {
|
||||
bot.send_message(chat_id, "Книги не найдены!")
|
||||
.reply_parameters(reply_params)
|
||||
.send()
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
Ok(p) => (p.format(1, 4096), p.pages),
|
||||
Err(_) => {
|
||||
bot.send_message(chat_id, "Ошибка! Попробуйте позже :(")
|
||||
.send()
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
SearchCallbackData::Authors { .. } => {
|
||||
match search_author(query_owned, 1, allowed_langs).await {
|
||||
Ok(p) if p.pages == 0 => {
|
||||
bot.send_message(chat_id, "Авторы не найдены!")
|
||||
.reply_parameters(reply_params)
|
||||
.send()
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
Ok(p) => (p.format(1, 4096), p.pages),
|
||||
Err(_) => {
|
||||
bot.send_message(chat_id, "Ошибка! Попробуйте позже :(")
|
||||
.send()
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
SearchCallbackData::Sequences { .. } => {
|
||||
match search_sequence(query_owned, 1, allowed_langs).await {
|
||||
Ok(p) if p.pages == 0 => {
|
||||
bot.send_message(chat_id, "Серии не найдены!")
|
||||
.reply_parameters(reply_params)
|
||||
.send()
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
Ok(p) => (p.format(1, 4096), p.pages),
|
||||
Err(_) => {
|
||||
bot.send_message(chat_id, "Ошибка! Попробуйте позже :(")
|
||||
.send()
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
SearchCallbackData::Translators { .. } => {
|
||||
match search_translator(query_owned, 1, allowed_langs).await {
|
||||
Ok(p) if p.pages == 0 => {
|
||||
bot.send_message(chat_id, "Переводчики не найдены!")
|
||||
.reply_parameters(reply_params)
|
||||
.send()
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
Ok(p) => (p.format(1, 4096), p.pages),
|
||||
Err(_) => {
|
||||
bot.send_message(chat_id, "Ошибка! Попробуйте позже :(")
|
||||
.send()
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let keyboard = generic_get_pagination_keyboard(1, pages, search_data, true);
|
||||
bot.send_message(chat_id, formatted)
|
||||
.reply_parameters(reply_params)
|
||||
.reply_markup(keyboard)
|
||||
.send()
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let message_text = "Что ищем?";
|
||||
let keyboard = InlineKeyboardMarkup {
|
||||
inline_keyboard: vec![
|
||||
vec![InlineKeyboardButton {
|
||||
|
||||
@@ -6,8 +6,20 @@ use smartstring::alias::String as SmartString;
|
||||
#[derive(Clone)]
|
||||
pub enum SettingsCallbackData {
|
||||
Settings,
|
||||
On { code: SmartString },
|
||||
Off { code: SmartString },
|
||||
On {
|
||||
code: SmartString,
|
||||
},
|
||||
Off {
|
||||
code: SmartString,
|
||||
},
|
||||
/// Open "default search type" submenu
|
||||
DefaultSearchMenu,
|
||||
/// Set default search: value is "book"|"author"|"series"|"translator"|"none"
|
||||
DefaultSearch {
|
||||
value: SmartString,
|
||||
},
|
||||
/// Return from default search submenu to main settings
|
||||
DefaultSearchBack,
|
||||
}
|
||||
|
||||
impl FromStr for SettingsCallbackData {
|
||||
@@ -17,6 +29,17 @@ impl FromStr for SettingsCallbackData {
|
||||
if s == SettingsCallbackData::Settings.to_string().as_str() {
|
||||
return Ok(SettingsCallbackData::Settings);
|
||||
}
|
||||
if s == "defsearch" {
|
||||
return Ok(SettingsCallbackData::DefaultSearchMenu);
|
||||
}
|
||||
if s == "defsearch_back" {
|
||||
return Ok(SettingsCallbackData::DefaultSearchBack);
|
||||
}
|
||||
if let Some(value) = s.strip_prefix("defsearch_") {
|
||||
return Ok(SettingsCallbackData::DefaultSearch {
|
||||
value: value.to_string().into(),
|
||||
});
|
||||
}
|
||||
|
||||
let re = Regex::new(r"^lang_(?P<action>(off)|(on))_(?P<code>[a-zA-z]+)$").unwrap();
|
||||
|
||||
@@ -43,6 +66,9 @@ impl Display for SettingsCallbackData {
|
||||
SettingsCallbackData::Settings => write!(f, "lang_settings"),
|
||||
SettingsCallbackData::On { code } => write!(f, "lang_on_{code}"),
|
||||
SettingsCallbackData::Off { code } => write!(f, "lang_off_{code}"),
|
||||
SettingsCallbackData::DefaultSearchMenu => write!(f, "defsearch"),
|
||||
SettingsCallbackData::DefaultSearch { value } => write!(f, "defsearch_{value}"),
|
||||
SettingsCallbackData::DefaultSearchBack => write!(f, "defsearch_back"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,14 @@ pub mod commands;
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use smallvec::SmallVec;
|
||||
use smartstring::alias::String as SmartString;
|
||||
|
||||
use crate::bots::{
|
||||
approved_bot::{
|
||||
services::user_settings::{
|
||||
create_or_update_user_settings, get_langs, get_user_or_default_lang_codes, Lang,
|
||||
create_or_update_user_settings, get_langs, get_user_or_default_lang_codes,
|
||||
get_user_settings, DefaultSearchType, Lang,
|
||||
},
|
||||
tools::filter_callback_query,
|
||||
},
|
||||
@@ -23,18 +25,28 @@ use teloxide::{
|
||||
|
||||
use self::{callback_data::SettingsCallbackData, commands::SettingsCommand};
|
||||
|
||||
async fn settings_handler(message: Message, bot: CacheMe<Throttle<Bot>>) -> BotHandlerInternal {
|
||||
let keyboard = InlineKeyboardMarkup {
|
||||
inline_keyboard: vec![vec![InlineKeyboardButton {
|
||||
text: "Языки".to_string(),
|
||||
kind: teloxide::types::InlineKeyboardButtonKind::CallbackData(
|
||||
SettingsCallbackData::Settings.to_string(),
|
||||
),
|
||||
}]],
|
||||
};
|
||||
fn get_main_settings_keyboard() -> InlineKeyboardMarkup {
|
||||
InlineKeyboardMarkup {
|
||||
inline_keyboard: vec![
|
||||
vec![InlineKeyboardButton {
|
||||
text: "Языки".to_string(),
|
||||
kind: teloxide::types::InlineKeyboardButtonKind::CallbackData(
|
||||
SettingsCallbackData::Settings.to_string(),
|
||||
),
|
||||
}],
|
||||
vec![InlineKeyboardButton {
|
||||
text: "Поиск по умолчанию".to_string(),
|
||||
kind: teloxide::types::InlineKeyboardButtonKind::CallbackData(
|
||||
SettingsCallbackData::DefaultSearchMenu.to_string(),
|
||||
),
|
||||
}],
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
async fn settings_handler(message: Message, bot: CacheMe<Throttle<Bot>>) -> BotHandlerInternal {
|
||||
bot.send_message(message.chat.id, "Настройки")
|
||||
.reply_markup(keyboard)
|
||||
.reply_markup(get_main_settings_keyboard())
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
@@ -71,6 +83,65 @@ fn get_lang_keyboard(
|
||||
}
|
||||
}
|
||||
|
||||
fn get_default_search_keyboard(current: Option<DefaultSearchType>) -> InlineKeyboardMarkup {
|
||||
let check = |v: DefaultSearchType| if current == Some(v) { " ✓" } else { "" };
|
||||
InlineKeyboardMarkup {
|
||||
inline_keyboard: vec![
|
||||
vec![InlineKeyboardButton {
|
||||
text: format!("Книга{}", check(DefaultSearchType::Book)),
|
||||
kind: teloxide::types::InlineKeyboardButtonKind::CallbackData(
|
||||
SettingsCallbackData::DefaultSearch {
|
||||
value: "book".into(),
|
||||
}
|
||||
.to_string(),
|
||||
),
|
||||
}],
|
||||
vec![InlineKeyboardButton {
|
||||
text: format!("Автор{}", check(DefaultSearchType::Author)),
|
||||
kind: teloxide::types::InlineKeyboardButtonKind::CallbackData(
|
||||
SettingsCallbackData::DefaultSearch {
|
||||
value: "author".into(),
|
||||
}
|
||||
.to_string(),
|
||||
),
|
||||
}],
|
||||
vec![InlineKeyboardButton {
|
||||
text: format!("Серия{}", check(DefaultSearchType::Series)),
|
||||
kind: teloxide::types::InlineKeyboardButtonKind::CallbackData(
|
||||
SettingsCallbackData::DefaultSearch {
|
||||
value: "series".into(),
|
||||
}
|
||||
.to_string(),
|
||||
),
|
||||
}],
|
||||
vec![InlineKeyboardButton {
|
||||
text: format!("Переводчик{}", check(DefaultSearchType::Translator)),
|
||||
kind: teloxide::types::InlineKeyboardButtonKind::CallbackData(
|
||||
SettingsCallbackData::DefaultSearch {
|
||||
value: "translator".into(),
|
||||
}
|
||||
.to_string(),
|
||||
),
|
||||
}],
|
||||
vec![InlineKeyboardButton {
|
||||
text: format!("Не выбрано{}", if current.is_none() { " ✓" } else { "" }),
|
||||
kind: teloxide::types::InlineKeyboardButtonKind::CallbackData(
|
||||
SettingsCallbackData::DefaultSearch {
|
||||
value: "none".into(),
|
||||
}
|
||||
.to_string(),
|
||||
),
|
||||
}],
|
||||
vec![InlineKeyboardButton {
|
||||
text: "← Назад".to_string(),
|
||||
kind: teloxide::types::InlineKeyboardButtonKind::CallbackData(
|
||||
SettingsCallbackData::DefaultSearchBack.to_string(),
|
||||
),
|
||||
}],
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
async fn settings_callback_handler(
|
||||
cq: CallbackQuery,
|
||||
bot: CacheMe<Throttle<Bot>>,
|
||||
@@ -89,6 +160,72 @@ async fn settings_callback_handler(
|
||||
|
||||
let user = cq.from;
|
||||
|
||||
match &callback_data {
|
||||
SettingsCallbackData::DefaultSearchMenu => {
|
||||
let current = get_user_settings(user.id).await.ok().flatten();
|
||||
let current_default = current.as_ref().and_then(|s| s.default_search);
|
||||
let keyboard = get_default_search_keyboard(current_default);
|
||||
bot.edit_message_text(message.chat().id, message.id(), "Поиск по умолчанию")
|
||||
.reply_markup(keyboard)
|
||||
.send()
|
||||
.await?;
|
||||
bot.answer_callback_query(cq.id).send().await?;
|
||||
return Ok(());
|
||||
}
|
||||
SettingsCallbackData::DefaultSearchBack => {
|
||||
bot.edit_message_text(message.chat().id, message.id(), "Настройки")
|
||||
.reply_markup(get_main_settings_keyboard())
|
||||
.send()
|
||||
.await?;
|
||||
bot.answer_callback_query(cq.id).send().await?;
|
||||
return Ok(());
|
||||
}
|
||||
SettingsCallbackData::DefaultSearch { value } => {
|
||||
let current = get_user_settings(user.id).await.ok().flatten();
|
||||
let allowed_langs: SmallVec<[SmartString; 3]> = match current {
|
||||
Some(s) => s.allowed_langs.into_iter().map(|l| l.code).collect(),
|
||||
None => get_user_or_default_lang_codes(user.id).await,
|
||||
};
|
||||
let default_search = if value.as_str() == "none" {
|
||||
None
|
||||
} else if let Some(t) = DefaultSearchType::from_api_str(value.as_str()) {
|
||||
Some(t)
|
||||
} else {
|
||||
bot.answer_callback_query(cq.id).send().await?;
|
||||
return Ok(());
|
||||
};
|
||||
if create_or_update_user_settings(
|
||||
user.id,
|
||||
&user.last_name.unwrap_or("".to_string()),
|
||||
&user.first_name,
|
||||
user.username.as_deref().unwrap_or(""),
|
||||
&me.username.clone().unwrap(),
|
||||
allowed_langs,
|
||||
default_search,
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
bot.answer_callback_query(cq.id)
|
||||
.text("Ошибка! Попробуйте заново(")
|
||||
.show_alert(true)
|
||||
.send()
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
bot.edit_message_text(message.chat().id, message.id(), "Настройки")
|
||||
.reply_markup(get_main_settings_keyboard())
|
||||
.send()
|
||||
.await?;
|
||||
bot.answer_callback_query(cq.id)
|
||||
.text("Готово")
|
||||
.send()
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let allowed_langs = get_user_or_default_lang_codes(user.id).await;
|
||||
|
||||
let mut allowed_langs_set: HashSet<SmartString> = HashSet::new();
|
||||
@@ -104,6 +241,7 @@ async fn settings_callback_handler(
|
||||
SettingsCallbackData::Off { code } => {
|
||||
allowed_langs_set.remove(&code);
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
|
||||
if allowed_langs_set.is_empty() {
|
||||
@@ -116,6 +254,9 @@ async fn settings_callback_handler(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let current_settings = get_user_settings(user.id).await.ok().flatten();
|
||||
let default_search = current_settings.as_ref().and_then(|s| s.default_search);
|
||||
|
||||
if let Err(err) = create_or_update_user_settings(
|
||||
user.id,
|
||||
&user.last_name.unwrap_or("".to_string()),
|
||||
@@ -123,6 +264,7 @@ async fn settings_callback_handler(
|
||||
&user.username.unwrap_or("".to_string()),
|
||||
&me.username.clone().unwrap(),
|
||||
allowed_langs_set.clone().into_iter().collect(),
|
||||
default_search,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use once_cell::sync::Lazy;
|
||||
use reqwest::StatusCode;
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use smallvec::{smallvec, SmallVec};
|
||||
use smartstring::alias::String as SmartString;
|
||||
@@ -11,6 +11,45 @@ use crate::{bots_manager::USER_LANGS_CACHE, config};
|
||||
|
||||
pub static CLIENT: Lazy<reqwest::Client> = Lazy::new(reqwest::Client::new);
|
||||
|
||||
/// API values: "book" | "author" | "series" | "translator"
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum DefaultSearchType {
|
||||
Book,
|
||||
Author,
|
||||
Series,
|
||||
Translator,
|
||||
}
|
||||
|
||||
impl DefaultSearchType {
|
||||
pub fn as_api_str(self) -> &'static str {
|
||||
match self {
|
||||
DefaultSearchType::Book => "book",
|
||||
DefaultSearchType::Author => "author",
|
||||
DefaultSearchType::Series => "series",
|
||||
DefaultSearchType::Translator => "translator",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_api_str(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"book" => Some(DefaultSearchType::Book),
|
||||
"author" => Some(DefaultSearchType::Author),
|
||||
"series" => Some(DefaultSearchType::Series),
|
||||
"translator" => Some(DefaultSearchType::Translator),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn deserialize_optional_default_search<'de, D>(d: D) -> Result<Option<DefaultSearchType>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let opt = Option::<String>::deserialize(d)?;
|
||||
Ok(opt.and_then(|s| DefaultSearchType::from_api_str(&s)))
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub struct Lang {
|
||||
// pub id: u32,
|
||||
@@ -26,6 +65,8 @@ pub struct UserSettings {
|
||||
// pub username: SmartString,
|
||||
// pub source: SmartString,
|
||||
pub allowed_langs: SmallVec<[Lang; 3]>,
|
||||
#[serde(default, deserialize_with = "deserialize_optional_default_search")]
|
||||
pub default_search: Option<DefaultSearchType>,
|
||||
}
|
||||
|
||||
pub async fn get_user_settings(
|
||||
@@ -79,16 +120,22 @@ pub async fn create_or_update_user_settings(
|
||||
username: &str,
|
||||
source: &str,
|
||||
allowed_langs: SmallVec<[SmartString; 3]>,
|
||||
default_search: Option<DefaultSearchType>,
|
||||
) -> anyhow::Result<UserSettings> {
|
||||
USER_LANGS_CACHE.invalidate(&user_id).await;
|
||||
|
||||
let default_search_json = match &default_search {
|
||||
Some(t) => serde_json::Value::String(t.as_api_str().to_string()),
|
||||
None => serde_json::Value::Null,
|
||||
};
|
||||
let body = json!({
|
||||
"user_id": user_id,
|
||||
"last_name": last_name,
|
||||
"first_name": first_name,
|
||||
"username": username,
|
||||
"source": source,
|
||||
"allowed_langs": allowed_langs.into_vec()
|
||||
"allowed_langs": allowed_langs.into_vec(),
|
||||
"default_search": default_search_json
|
||||
});
|
||||
|
||||
let response = CLIENT
|
||||
@@ -103,6 +150,14 @@ pub async fn create_or_update_user_settings(
|
||||
Ok(response.json::<UserSettings>().await?)
|
||||
}
|
||||
|
||||
/// Returns user's default search type from API. None if not set or on error.
|
||||
pub async fn get_user_default_search(user_id: UserId) -> Option<DefaultSearchType> {
|
||||
match get_user_settings(user_id).await {
|
||||
Ok(Some(s)) => s.default_search,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_langs() -> anyhow::Result<Vec<Lang>> {
|
||||
let response = CLIENT
|
||||
.get(format!("{}/languages/", &config::CONFIG.user_settings_url))
|
||||
|
||||
Reference in New Issue
Block a user