graceful shutdown
This commit is contained in:
parent
93da746bdc
commit
878ea11502
@ -2,14 +2,31 @@ mod sender;
|
|||||||
|
|
||||||
use std::env;
|
use std::env;
|
||||||
|
|
||||||
use log::info;
|
use log::{info, warn};
|
||||||
use serenity::client::Context;
|
use serenity::client::Context;
|
||||||
use sqlx::{Executor, MySql};
|
use sqlx::{Executor, MySql};
|
||||||
use tokio::time::{sleep_until, Duration, Instant};
|
use tokio::{
|
||||||
|
sync::broadcast::Receiver,
|
||||||
|
time::{sleep_until, Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
type Database = MySql;
|
type Database = MySql;
|
||||||
|
|
||||||
pub async fn initialize(ctx: Context, pool: impl Executor<'_, Database = Database> + Copy) {
|
pub async fn initialize(
|
||||||
|
mut kill: Receiver<()>,
|
||||||
|
ctx: Context,
|
||||||
|
pool: impl Executor<'_, Database = Database> + Copy,
|
||||||
|
) -> Result<(), &'static str> {
|
||||||
|
tokio::select! {
|
||||||
|
output = _initialize(ctx, pool) => Ok(output),
|
||||||
|
_ = kill.recv() => {
|
||||||
|
warn!("Received terminate signal. Goodbye");
|
||||||
|
Err("Received terminate signal. Goodbye")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn _initialize(ctx: Context, pool: impl Executor<'_, Database = Database> + Copy) {
|
||||||
let remind_interval = env::var("REMIND_INTERVAL")
|
let remind_interval = env::var("REMIND_INTERVAL")
|
||||||
.map(|inner| inner.parse::<u64>().ok())
|
.map(|inner| inner.parse::<u64>().ok())
|
||||||
.ok()
|
.ok()
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
use std::{collections::HashMap, env, sync::atomic::Ordering};
|
use std::{collections::HashMap, env, sync::atomic::Ordering};
|
||||||
|
|
||||||
use log::{info, warn};
|
use log::{error, info, warn};
|
||||||
use poise::{
|
use poise::{
|
||||||
serenity::{model::interactions::Interaction, utils::shard_id},
|
serenity::{model::interactions::Interaction, utils::shard_id},
|
||||||
serenity_prelude as serenity,
|
serenity_prelude as serenity,
|
||||||
@ -15,10 +15,12 @@ pub async fn listener(
|
|||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
match event {
|
match event {
|
||||||
poise::Event::CacheReady { .. } => {
|
poise::Event::CacheReady { .. } => {
|
||||||
info!("Cache Ready!");
|
info!("Cache Ready! Preparing extra processes");
|
||||||
info!("Preparing to send reminders");
|
|
||||||
|
|
||||||
if !data.is_loop_running.load(Ordering::Relaxed) {
|
if !data.is_loop_running.load(Ordering::Relaxed) {
|
||||||
|
let kill_tx = data.broadcast.clone();
|
||||||
|
let kill_recv = data.broadcast.subscribe();
|
||||||
|
|
||||||
let ctx1 = ctx.clone();
|
let ctx1 = ctx.clone();
|
||||||
let ctx2 = ctx.clone();
|
let ctx2 = ctx.clone();
|
||||||
|
|
||||||
@ -29,7 +31,12 @@ pub async fn listener(
|
|||||||
|
|
||||||
if !run_settings.contains("postman") {
|
if !run_settings.contains("postman") {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
postman::initialize(ctx1, &pool1).await;
|
match postman::initialize(kill_recv, ctx1, &pool1).await {
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => {
|
||||||
|
error!("postman exiting: {}", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
warn!("Not running postman")
|
warn!("Not running postman")
|
||||||
@ -37,7 +44,7 @@ pub async fn listener(
|
|||||||
|
|
||||||
if !run_settings.contains("web") {
|
if !run_settings.contains("web") {
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
reminder_web::initialize(ctx2, pool2).await.unwrap();
|
reminder_web::initialize(kill_tx, ctx2, pool2).await.unwrap();
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
warn!("Not running web")
|
warn!("Not running web")
|
||||||
|
39
src/main.rs
39
src/main.rs
@ -12,7 +12,13 @@ mod models;
|
|||||||
mod time_parser;
|
mod time_parser;
|
||||||
mod utils;
|
mod utils;
|
||||||
|
|
||||||
use std::{collections::HashMap, env, fmt::Formatter, sync::atomic::AtomicBool};
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
env,
|
||||||
|
error::Error as StdError,
|
||||||
|
fmt::{Debug, Display, Formatter},
|
||||||
|
sync::atomic::AtomicBool,
|
||||||
|
};
|
||||||
|
|
||||||
use chrono_tz::Tz;
|
use chrono_tz::Tz;
|
||||||
use dotenv::dotenv;
|
use dotenv::dotenv;
|
||||||
@ -21,7 +27,7 @@ use poise::serenity::model::{
|
|||||||
id::{GuildId, UserId},
|
id::{GuildId, UserId},
|
||||||
};
|
};
|
||||||
use sqlx::{MySql, Pool};
|
use sqlx::{MySql, Pool};
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::{broadcast, broadcast::Sender, RwLock};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
commands::{info_cmds, moderation_cmds, reminder_cmds, todo_cmds},
|
commands::{info_cmds, moderation_cmds, reminder_cmds, todo_cmds},
|
||||||
@ -43,6 +49,7 @@ pub struct Data {
|
|||||||
recording_macros: RwLock<HashMap<(GuildId, UserId), CommandMacro<Data, Error>>>,
|
recording_macros: RwLock<HashMap<(GuildId, UserId), CommandMacro<Data, Error>>>,
|
||||||
popular_timezones: Vec<Tz>,
|
popular_timezones: Vec<Tz>,
|
||||||
is_loop_running: AtomicBool,
|
is_loop_running: AtomicBool,
|
||||||
|
broadcast: Sender<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Debug for Data {
|
impl std::fmt::Debug for Data {
|
||||||
@ -51,8 +58,33 @@ impl std::fmt::Debug for Data {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct Ended;
|
||||||
|
|
||||||
|
impl Debug for Ended {
|
||||||
|
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str("Process ended.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for Ended {
|
||||||
|
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str("Process ended.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StdError for Ended {}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
async fn main() -> Result<(), Box<dyn StdError + Send + Sync>> {
|
||||||
|
let (tx, mut rx) = broadcast::channel(16);
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
output = _main(tx) => output,
|
||||||
|
_ = rx.recv() => Err(Box::new(Ended) as Box<dyn StdError + Send + Sync>)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
|
||||||
env_logger::init();
|
env_logger::init();
|
||||||
|
|
||||||
dotenv()?;
|
dotenv()?;
|
||||||
@ -157,6 +189,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|||||||
popular_timezones,
|
popular_timezones,
|
||||||
recording_macros: Default::default(),
|
recording_macros: Default::default(),
|
||||||
is_loop_running: AtomicBool::new(false),
|
is_loop_running: AtomicBool::new(false),
|
||||||
|
broadcast: tx,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -9,7 +9,7 @@ mod routes;
|
|||||||
use std::{collections::HashMap, env};
|
use std::{collections::HashMap, env};
|
||||||
|
|
||||||
use oauth2::{basic::BasicClient, AuthUrl, ClientId, ClientSecret, RedirectUrl, TokenUrl};
|
use oauth2::{basic::BasicClient, AuthUrl, ClientId, ClientSecret, RedirectUrl, TokenUrl};
|
||||||
use rocket::fs::FileServer;
|
use rocket::{fs::FileServer, tokio::sync::broadcast::Sender};
|
||||||
use rocket_dyn_templates::Template;
|
use rocket_dyn_templates::Template;
|
||||||
use serenity::{
|
use serenity::{
|
||||||
client::Context,
|
client::Context,
|
||||||
@ -53,6 +53,7 @@ async fn internal_server_error() -> Template {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn initialize(
|
pub async fn initialize(
|
||||||
|
kill_channel: Sender<()>,
|
||||||
serenity_context: Context,
|
serenity_context: Context,
|
||||||
db_pool: Pool<Database>,
|
db_pool: Pool<Database>,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
@ -119,6 +120,10 @@ pub async fn initialize(
|
|||||||
.launch()
|
.launch()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
warn!("Exiting rocket runtime");
|
||||||
|
// distribute kill signal
|
||||||
|
kill_channel.send(());
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -57,6 +57,14 @@ button.change-color {
|
|||||||
left: calc(-1rem - 40px);
|
left: calc(-1rem - 40px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button.disable-enable[data-action="enable"]:after {
|
||||||
|
content: "Enable";
|
||||||
|
}
|
||||||
|
|
||||||
|
button.disable-enable[data-action="disable"]:after {
|
||||||
|
content: "Disable";
|
||||||
|
}
|
||||||
|
|
||||||
.media-content {
|
.media-content {
|
||||||
overflow-x: visible;
|
overflow-x: visible;
|
||||||
}
|
}
|
||||||
|
@ -3,10 +3,7 @@ let $discordFrame;
|
|||||||
const $loader = document.querySelector("#loader");
|
const $loader = document.querySelector("#loader");
|
||||||
const $colorPickerModal = document.querySelector("div#pickColorModal");
|
const $colorPickerModal = document.querySelector("div#pickColorModal");
|
||||||
const $colorPickerInput = $colorPickerModal.querySelector("input");
|
const $colorPickerInput = $colorPickerModal.querySelector("input");
|
||||||
|
const $deleteReminderBtn = document.querySelector("#delete-reminder-confirm");
|
||||||
let timezone = luxon.DateTime.now().zone.name;
|
|
||||||
const browserTimezone = luxon.DateTime.now().zone.name;
|
|
||||||
let botTimezone = "UTC";
|
|
||||||
|
|
||||||
let channels;
|
let channels;
|
||||||
let roles;
|
let roles;
|
||||||
@ -75,6 +72,8 @@ function fetch_roles(guild_id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetch_reminders(guild_id) {
|
async function fetch_reminders(guild_id) {
|
||||||
|
document.dispatchEvent(new Event("remindersLoading"));
|
||||||
|
|
||||||
const $reminderBox = document.querySelector("div#guildReminders");
|
const $reminderBox = document.querySelector("div#guildReminders");
|
||||||
|
|
||||||
// reset div contents
|
// reset div contents
|
||||||
@ -113,7 +112,6 @@ async function fetch_reminders(guild_id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let $enableBtn = newFrame.querySelector(".disable-enable");
|
let $enableBtn = newFrame.querySelector(".disable-enable");
|
||||||
$enableBtn.textContent = reminder["enabled"] ? "Disable" : "Enable";
|
|
||||||
$enableBtn.dataset.action = reminder["enabled"]
|
$enableBtn.dataset.action = reminder["enabled"]
|
||||||
? "disable"
|
? "disable"
|
||||||
: "enable";
|
: "enable";
|
||||||
@ -164,14 +162,36 @@ document.addEventListener("remindersLoaded", (event) => {
|
|||||||
if (data.error) {
|
if (data.error) {
|
||||||
show_error(data.error);
|
show_error(data.error);
|
||||||
} else {
|
} else {
|
||||||
enableBtn.textContent = data["enabled"] ? "Disable" : "Enable";
|
|
||||||
enableBtn.dataset.action = data["enabled"] ? "enable" : "disable";
|
enableBtn.dataset.action = data["enabled"] ? "enable" : "disable";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
reminder.node
|
||||||
|
.querySelector("button.delete-reminder")
|
||||||
|
.addEventListener("click", () => {
|
||||||
|
let uid = reminder.node.closest(".reminderContent").dataset.uid;
|
||||||
|
|
||||||
|
$deleteReminderBtn.dataset["uid"] = uid;
|
||||||
|
$deleteReminderBtn.closest(".modal").classList.toggle("is-active");
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$deleteReminderBtn.addEventListener("click", () => {
|
||||||
|
let guild = document.querySelector(".guildList a.is-active").dataset["guild"];
|
||||||
|
|
||||||
|
fetch(`/dashboard/api/guild/${guild}/reminders`, {
|
||||||
|
method: "DELETE",
|
||||||
|
body: JSON.stringify({
|
||||||
|
uid: $deleteReminderBtn.dataset["uid"],
|
||||||
|
}),
|
||||||
|
}).then(() => {
|
||||||
|
document.querySelector("#deleteReminderModal").classList.remove("is-active");
|
||||||
|
fetch_reminders(guild);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
function show_error(error) {
|
function show_error(error) {
|
||||||
document.getElementById("errors").querySelector("span.error-message").textContent =
|
document.getElementById("errors").querySelector("span.error-message").textContent =
|
||||||
error;
|
error;
|
||||||
@ -182,60 +202,6 @@ function show_error(error) {
|
|||||||
}, 5000);
|
}, 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function update_times() {
|
|
||||||
document.querySelectorAll("span.set-timezone").forEach((element) => {
|
|
||||||
element.textContent = timezone;
|
|
||||||
});
|
|
||||||
document.querySelectorAll("span.set-time").forEach((element) => {
|
|
||||||
element.textContent = luxon.DateTime.now().setZone(timezone).toFormat("HH:mm");
|
|
||||||
});
|
|
||||||
document.querySelectorAll("span.browser-timezone").forEach((element) => {
|
|
||||||
element.textContent = browserTimezone;
|
|
||||||
});
|
|
||||||
document.querySelectorAll("span.browser-time").forEach((element) => {
|
|
||||||
element.textContent = luxon.DateTime.now().toFormat("HH:mm");
|
|
||||||
});
|
|
||||||
document.querySelectorAll("span.bot-timezone").forEach((element) => {
|
|
||||||
element.textContent = botTimezone;
|
|
||||||
});
|
|
||||||
document.querySelectorAll("span.bot-time").forEach((element) => {
|
|
||||||
element.textContent = luxon.DateTime.now().setZone(botTimezone).toFormat("HH:mm");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
window.setInterval(() => {
|
|
||||||
update_times();
|
|
||||||
}, 30000);
|
|
||||||
|
|
||||||
document.getElementById("set-bot-timezone").addEventListener("click", () => {
|
|
||||||
timezone = botTimezone;
|
|
||||||
update_times();
|
|
||||||
});
|
|
||||||
document.getElementById("set-browser-timezone").addEventListener("click", () => {
|
|
||||||
timezone = browserTimezone;
|
|
||||||
update_times();
|
|
||||||
});
|
|
||||||
document.getElementById("update-bot-timezone").addEventListener("click", () => {
|
|
||||||
timezone = browserTimezone;
|
|
||||||
fetch("/dashboard/api/user", {
|
|
||||||
method: "PATCH",
|
|
||||||
headers: {
|
|
||||||
Accept: "application/json",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ timezone: timezone }),
|
|
||||||
})
|
|
||||||
.then((response) => response.json())
|
|
||||||
.then((data) => {
|
|
||||||
if (data.error) {
|
|
||||||
show_error(data.error);
|
|
||||||
} else {
|
|
||||||
botTimezone = browserTimezone;
|
|
||||||
update_times();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
$colorPickerInput.value = colorPicker.color.hexString;
|
$colorPickerInput.value = colorPicker.color.hexString;
|
||||||
|
|
||||||
$colorPickerInput.addEventListener("input", () => {
|
$colorPickerInput.addEventListener("input", () => {
|
||||||
@ -482,6 +448,7 @@ $createReminder.querySelector("button#createReminder").addEventListener("click",
|
|||||||
.then((data) => console.log(data));
|
.then((data) => console.log(data));
|
||||||
|
|
||||||
// process response
|
// process response
|
||||||
|
fetch_reminders(guild);
|
||||||
|
|
||||||
// reset inputs
|
// reset inputs
|
||||||
});
|
});
|
||||||
|
57
web/static/js/timezone.js
Normal file
57
web/static/js/timezone.js
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
let timezone = luxon.DateTime.now().zone.name;
|
||||||
|
const browserTimezone = luxon.DateTime.now().zone.name;
|
||||||
|
let botTimezone = "UTC";
|
||||||
|
|
||||||
|
function update_times() {
|
||||||
|
document.querySelectorAll("span.set-timezone").forEach((element) => {
|
||||||
|
element.textContent = timezone;
|
||||||
|
});
|
||||||
|
document.querySelectorAll("span.set-time").forEach((element) => {
|
||||||
|
element.textContent = luxon.DateTime.now().setZone(timezone).toFormat("HH:mm");
|
||||||
|
});
|
||||||
|
document.querySelectorAll("span.browser-timezone").forEach((element) => {
|
||||||
|
element.textContent = browserTimezone;
|
||||||
|
});
|
||||||
|
document.querySelectorAll("span.browser-time").forEach((element) => {
|
||||||
|
element.textContent = luxon.DateTime.now().toFormat("HH:mm");
|
||||||
|
});
|
||||||
|
document.querySelectorAll("span.bot-timezone").forEach((element) => {
|
||||||
|
element.textContent = botTimezone;
|
||||||
|
});
|
||||||
|
document.querySelectorAll("span.bot-time").forEach((element) => {
|
||||||
|
element.textContent = luxon.DateTime.now().setZone(botTimezone).toFormat("HH:mm");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.setInterval(() => {
|
||||||
|
update_times();
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
document.getElementById("set-bot-timezone").addEventListener("click", () => {
|
||||||
|
timezone = botTimezone;
|
||||||
|
update_times();
|
||||||
|
});
|
||||||
|
document.getElementById("set-browser-timezone").addEventListener("click", () => {
|
||||||
|
timezone = browserTimezone;
|
||||||
|
update_times();
|
||||||
|
});
|
||||||
|
document.getElementById("update-bot-timezone").addEventListener("click", () => {
|
||||||
|
timezone = browserTimezone;
|
||||||
|
fetch("/dashboard/api/user", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ timezone: timezone }),
|
||||||
|
})
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (data.error) {
|
||||||
|
show_error(data.error);
|
||||||
|
} else {
|
||||||
|
botTimezone = browserTimezone;
|
||||||
|
update_times();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
@ -140,6 +140,27 @@
|
|||||||
<button class="modal-close is-large close-modal" aria-label="close"></button>
|
<button class="modal-close is-large close-modal" aria-label="close"></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="modal" id="deleteReminderModal">
|
||||||
|
<div class="modal-background"></div>
|
||||||
|
<div class="modal-card">
|
||||||
|
<header class="modal-card-head">
|
||||||
|
<label class="modal-card-title" for="urlInput">Delete Reminder</label>
|
||||||
|
<button class="delete close-modal" aria-label="close"></button>
|
||||||
|
</header>
|
||||||
|
<section class="modal-card-body">
|
||||||
|
<p>
|
||||||
|
This reminder will be permenantly deleted. Are you sure?
|
||||||
|
</p>
|
||||||
|
<br>
|
||||||
|
<div class="has-text-centered">
|
||||||
|
<button class="button is-danger" id="delete-reminder-confirm">Delete</button>
|
||||||
|
<button class="button is-light close-modal">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<button class="modal-close is-large close-modal" aria-label="close"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="columns is-gapless dashboard-frame">
|
<div class="columns is-gapless dashboard-frame">
|
||||||
<div class="column is-2 is-sidebar-menu dashboard-sidebar is-hidden-touch" style="display: flex; flex-direction: column;">
|
<div class="column is-2 is-sidebar-menu dashboard-sidebar is-hidden-touch" style="display: flex; flex-direction: column;">
|
||||||
<a href="/">
|
<a href="/">
|
||||||
@ -300,6 +321,7 @@
|
|||||||
{% include "reminder_dashboard/personal_reminder" %}
|
{% include "reminder_dashboard/personal_reminder" %}
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script src="/static/js/timezone.js"></script>
|
||||||
<script src="/static/js/main.js"></script>
|
<script src="/static/js/main.js"></script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
@ -215,9 +215,8 @@
|
|||||||
Saved!
|
Saved!
|
||||||
</button>
|
</button>
|
||||||
<button class="button is-warning disable-enable">
|
<button class="button is-warning disable-enable">
|
||||||
Disable
|
|
||||||
</button>
|
</button>
|
||||||
<button class="button is-danger">
|
<button class="button is-danger delete-reminder">
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
Loading…
Reference in New Issue
Block a user