mirror of
https://github.com/chatmail/core.git
synced 2026-04-02 05:22:14 +03:00
api: dc_accounts_set_push_device_token and dc_get_push_state APIs
This commit is contained in:
@@ -686,6 +686,24 @@ int dc_get_connectivity (dc_context_t* context);
|
||||
char* dc_get_connectivity_html (dc_context_t* context);
|
||||
|
||||
|
||||
#define DC_PUSH_NOT_CONNECTED 0
|
||||
#define DC_PUSH_HEARTBEAT 1
|
||||
#define DC_PUSH_CONNECTED 2
|
||||
|
||||
/**
|
||||
* Get the current push notification state.
|
||||
* One of:
|
||||
* - DC_PUSH_NOT_CONNECTED
|
||||
* - DC_PUSH_HEARTBEAT
|
||||
* - DC_PUSH_CONNECTED
|
||||
*
|
||||
* @memberof dc_context_t
|
||||
* @param context The context object.
|
||||
* @return Push notification state.
|
||||
*/
|
||||
int dc_get_push_state (dc_context_t* context);
|
||||
|
||||
|
||||
/**
|
||||
* Standalone version of dc_accounts_all_work_done().
|
||||
* Only used by the python tests.
|
||||
@@ -3165,6 +3183,16 @@ void dc_accounts_maybe_network_lost (dc_accounts_t* accounts);
|
||||
*/
|
||||
int dc_accounts_background_fetch (dc_accounts_t* accounts, uint64_t timeout);
|
||||
|
||||
|
||||
/**
|
||||
* Sets device token for Apple Push Notification service.
|
||||
* Returns immediately.
|
||||
*
|
||||
* @memberof dc_accounts_t
|
||||
* @param token Hexadecimal device token
|
||||
*/
|
||||
void dc_accounts_set_push_device_token (dc_accounts_t* accounts, const char *token);
|
||||
|
||||
/**
|
||||
* Create the event emitter that is used to receive events.
|
||||
*
|
||||
|
||||
@@ -384,7 +384,7 @@ pub unsafe extern "C" fn dc_get_connectivity(context: *const dc_context_t) -> li
|
||||
return 0;
|
||||
}
|
||||
let ctx = &*context;
|
||||
block_on(async move { ctx.get_connectivity().await as u32 as libc::c_int })
|
||||
block_on(ctx.get_connectivity()) as u32 as libc::c_int
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
@@ -407,6 +407,16 @@ pub unsafe extern "C" fn dc_get_connectivity_html(
|
||||
})
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_get_push_state(context: *const dc_context_t) -> libc::c_int {
|
||||
if context.is_null() {
|
||||
eprintln!("ignoring careless call to dc_get_push_state()");
|
||||
return 0;
|
||||
}
|
||||
let ctx = &*context;
|
||||
block_on(ctx.push_state()) as libc::c_int
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_all_work_done(context: *mut dc_context_t) -> libc::c_int {
|
||||
if context.is_null() {
|
||||
@@ -4919,6 +4929,29 @@ pub unsafe extern "C" fn dc_accounts_background_fetch(
|
||||
1
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_accounts_set_push_device_token(
|
||||
accounts: *mut dc_accounts_t,
|
||||
token: *const libc::c_char,
|
||||
) {
|
||||
if accounts.is_null() {
|
||||
eprintln!("ignoring careless call to dc_accounts_set_push_device_token()");
|
||||
return;
|
||||
}
|
||||
|
||||
let accounts = &*accounts;
|
||||
let token = to_string_lossy(token);
|
||||
|
||||
block_on(async move {
|
||||
let mut accounts = accounts.write().await;
|
||||
if let Err(err) = accounts.set_push_device_token(&token).await {
|
||||
accounts.emit_event(EventType::Error(format!(
|
||||
"Failed to set notify token: {err:#}."
|
||||
)));
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn dc_accounts_get_event_emitter(
|
||||
accounts: *mut dc_accounts_t,
|
||||
|
||||
@@ -19,6 +19,7 @@ use tokio::time::{sleep, Duration};
|
||||
|
||||
use crate::context::{Context, ContextBuilder};
|
||||
use crate::events::{Event, EventEmitter, EventType, Events};
|
||||
use crate::push::PushSubscriber;
|
||||
use crate::stock_str::StockStrings;
|
||||
|
||||
/// Account manager, that can handle multiple accounts in a single place.
|
||||
@@ -37,6 +38,9 @@ pub struct Accounts {
|
||||
/// This way changing a translation for one context automatically
|
||||
/// changes it for all other contexts.
|
||||
pub(crate) stockstrings: StockStrings,
|
||||
|
||||
/// Push notification subscriber shared between accounts.
|
||||
push_subscriber: PushSubscriber,
|
||||
}
|
||||
|
||||
impl Accounts {
|
||||
@@ -73,8 +77,9 @@ impl Accounts {
|
||||
.context("failed to load accounts config")?;
|
||||
let events = Events::new();
|
||||
let stockstrings = StockStrings::new();
|
||||
let push_subscriber = PushSubscriber::new();
|
||||
let accounts = config
|
||||
.load_accounts(&events, &stockstrings, &dir)
|
||||
.load_accounts(&events, &stockstrings, push_subscriber.clone(), &dir)
|
||||
.await
|
||||
.context("failed to load accounts")?;
|
||||
|
||||
@@ -84,6 +89,7 @@ impl Accounts {
|
||||
accounts,
|
||||
events,
|
||||
stockstrings,
|
||||
push_subscriber,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -124,6 +130,7 @@ impl Accounts {
|
||||
.with_id(account_config.id)
|
||||
.with_events(self.events.clone())
|
||||
.with_stock_strings(self.stockstrings.clone())
|
||||
.with_push_subscriber(self.push_subscriber.clone())
|
||||
.build()
|
||||
.await?;
|
||||
// Try to open without a passphrase,
|
||||
@@ -144,6 +151,7 @@ impl Accounts {
|
||||
.with_id(account_config.id)
|
||||
.with_events(self.events.clone())
|
||||
.with_stock_strings(self.stockstrings.clone())
|
||||
.with_push_subscriber(self.push_subscriber.clone())
|
||||
.build()
|
||||
.await?;
|
||||
self.accounts.insert(account_config.id, ctx);
|
||||
@@ -340,6 +348,12 @@ impl Accounts {
|
||||
pub fn get_event_emitter(&self) -> EventEmitter {
|
||||
self.events.get_emitter()
|
||||
}
|
||||
|
||||
/// Sets notification token for Apple Push Notification service.
|
||||
pub async fn set_push_device_token(&mut self, token: &str) -> Result<()> {
|
||||
self.push_subscriber.set_device_token(token).await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration file name.
|
||||
@@ -525,6 +539,7 @@ impl Config {
|
||||
&self,
|
||||
events: &Events,
|
||||
stockstrings: &StockStrings,
|
||||
push_subscriber: PushSubscriber,
|
||||
dir: &Path,
|
||||
) -> Result<BTreeMap<u32, Context>> {
|
||||
let mut accounts = BTreeMap::new();
|
||||
@@ -535,6 +550,7 @@ impl Config {
|
||||
.with_id(account_config.id)
|
||||
.with_events(events.clone())
|
||||
.with_stock_strings(stockstrings.clone())
|
||||
.with_push_subscriber(push_subscriber.clone())
|
||||
.build()
|
||||
.await
|
||||
.with_context(|| format!("failed to create context from file {:?}", &dbfile))?;
|
||||
|
||||
@@ -30,6 +30,7 @@ use crate::login_param::LoginParam;
|
||||
use crate::message::{self, Message, MessageState, MsgId, Viewtype};
|
||||
use crate::param::{Param, Params};
|
||||
use crate::peerstate::Peerstate;
|
||||
use crate::push::PushSubscriber;
|
||||
use crate::quota::QuotaInfo;
|
||||
use crate::scheduler::{convert_folder_meaning, SchedulerState};
|
||||
use crate::sql::Sql;
|
||||
@@ -86,6 +87,8 @@ pub struct ContextBuilder {
|
||||
events: Events,
|
||||
stock_strings: StockStrings,
|
||||
password: Option<String>,
|
||||
|
||||
push_subscriber: Option<PushSubscriber>,
|
||||
}
|
||||
|
||||
impl ContextBuilder {
|
||||
@@ -101,6 +104,7 @@ impl ContextBuilder {
|
||||
events: Events::new(),
|
||||
stock_strings: StockStrings::new(),
|
||||
password: None,
|
||||
push_subscriber: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,10 +159,23 @@ impl ContextBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets push subscriber.
|
||||
pub(crate) fn with_push_subscriber(mut self, push_subscriber: PushSubscriber) -> Self {
|
||||
self.push_subscriber = Some(push_subscriber);
|
||||
self
|
||||
}
|
||||
|
||||
/// Builds the [`Context`] without opening it.
|
||||
pub async fn build(self) -> Result<Context> {
|
||||
let context =
|
||||
Context::new_closed(&self.dbfile, self.id, self.events, self.stock_strings).await?;
|
||||
let push_subscriber = self.push_subscriber.unwrap_or_default();
|
||||
let context = Context::new_closed(
|
||||
&self.dbfile,
|
||||
self.id,
|
||||
self.events,
|
||||
self.stock_strings,
|
||||
push_subscriber,
|
||||
)
|
||||
.await?;
|
||||
Ok(context)
|
||||
}
|
||||
|
||||
@@ -263,6 +280,13 @@ pub struct InnerContext {
|
||||
/// Standard RwLock instead of [`tokio::sync::RwLock`] is used
|
||||
/// because the lock is used from synchronous [`Context::emit_event`].
|
||||
pub(crate) debug_logging: std::sync::RwLock<Option<DebugLogging>>,
|
||||
|
||||
/// Push subscriber to store device token
|
||||
/// and register for heartbeat notifications.
|
||||
pub(crate) push_subscriber: PushSubscriber,
|
||||
|
||||
/// True if account has subscribed to push notifications via IMAP.
|
||||
pub(crate) push_subscribed: AtomicBool,
|
||||
}
|
||||
|
||||
/// The state of ongoing process.
|
||||
@@ -308,7 +332,8 @@ impl Context {
|
||||
events: Events,
|
||||
stock_strings: StockStrings,
|
||||
) -> Result<Context> {
|
||||
let context = Self::new_closed(dbfile, id, events, stock_strings).await?;
|
||||
let context =
|
||||
Self::new_closed(dbfile, id, events, stock_strings, Default::default()).await?;
|
||||
|
||||
// Open the database if is not encrypted.
|
||||
if context.check_passphrase("".to_string()).await? {
|
||||
@@ -323,6 +348,7 @@ impl Context {
|
||||
id: u32,
|
||||
events: Events,
|
||||
stockstrings: StockStrings,
|
||||
push_subscriber: PushSubscriber,
|
||||
) -> Result<Context> {
|
||||
let mut blob_fname = OsString::new();
|
||||
blob_fname.push(dbfile.file_name().unwrap_or_default());
|
||||
@@ -331,7 +357,14 @@ impl Context {
|
||||
if !blobdir.exists() {
|
||||
tokio::fs::create_dir_all(&blobdir).await?;
|
||||
}
|
||||
let context = Context::with_blobdir(dbfile.into(), blobdir, id, events, stockstrings)?;
|
||||
let context = Context::with_blobdir(
|
||||
dbfile.into(),
|
||||
blobdir,
|
||||
id,
|
||||
events,
|
||||
stockstrings,
|
||||
push_subscriber,
|
||||
)?;
|
||||
Ok(context)
|
||||
}
|
||||
|
||||
@@ -374,6 +407,7 @@ impl Context {
|
||||
id: u32,
|
||||
events: Events,
|
||||
stockstrings: StockStrings,
|
||||
push_subscriber: PushSubscriber,
|
||||
) -> Result<Context> {
|
||||
ensure!(
|
||||
blobdir.is_dir(),
|
||||
@@ -408,6 +442,8 @@ impl Context {
|
||||
last_full_folder_scan: Mutex::new(None),
|
||||
last_error: std::sync::RwLock::new("".to_string()),
|
||||
debug_logging: std::sync::RwLock::new(None),
|
||||
push_subscriber,
|
||||
push_subscribed: AtomicBool::new(false),
|
||||
};
|
||||
|
||||
let ctx = Context {
|
||||
@@ -1509,7 +1545,14 @@ mod tests {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dbfile = tmp.path().join("db.sqlite");
|
||||
let blobdir = PathBuf::new();
|
||||
let res = Context::with_blobdir(dbfile, blobdir, 1, Events::new(), StockStrings::new());
|
||||
let res = Context::with_blobdir(
|
||||
dbfile,
|
||||
blobdir,
|
||||
1,
|
||||
Events::new(),
|
||||
StockStrings::new(),
|
||||
Default::default(),
|
||||
);
|
||||
assert!(res.is_err());
|
||||
}
|
||||
|
||||
@@ -1518,7 +1561,14 @@ mod tests {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dbfile = tmp.path().join("db.sqlite");
|
||||
let blobdir = tmp.path().join("blobs");
|
||||
let res = Context::with_blobdir(dbfile, blobdir, 1, Events::new(), StockStrings::new());
|
||||
let res = Context::with_blobdir(
|
||||
dbfile,
|
||||
blobdir,
|
||||
1,
|
||||
Events::new(),
|
||||
StockStrings::new(),
|
||||
Default::default(),
|
||||
);
|
||||
assert!(res.is_err());
|
||||
}
|
||||
|
||||
@@ -1741,16 +1791,18 @@ mod tests {
|
||||
let dir = tempdir()?;
|
||||
let dbfile = dir.path().join("db.sqlite");
|
||||
|
||||
let id = 1;
|
||||
let context = Context::new_closed(&dbfile, id, Events::new(), StockStrings::new())
|
||||
let context = ContextBuilder::new(dbfile.clone())
|
||||
.with_id(1)
|
||||
.build()
|
||||
.await
|
||||
.context("failed to create context")?;
|
||||
assert_eq!(context.open("foo".to_string()).await?, true);
|
||||
assert_eq!(context.is_open().await, true);
|
||||
drop(context);
|
||||
|
||||
let id = 2;
|
||||
let context = Context::new(&dbfile, id, Events::new(), StockStrings::new())
|
||||
let context = ContextBuilder::new(dbfile)
|
||||
.with_id(2)
|
||||
.build()
|
||||
.await
|
||||
.context("failed to create context")?;
|
||||
assert_eq!(context.is_open().await, false);
|
||||
@@ -1766,8 +1818,9 @@ mod tests {
|
||||
let dir = tempdir()?;
|
||||
let dbfile = dir.path().join("db.sqlite");
|
||||
|
||||
let id = 1;
|
||||
let context = Context::new_closed(&dbfile, id, Events::new(), StockStrings::new())
|
||||
let context = ContextBuilder::new(dbfile)
|
||||
.with_id(1)
|
||||
.build()
|
||||
.await
|
||||
.context("failed to create context")?;
|
||||
assert_eq!(context.open("foo".to_string()).await?, true);
|
||||
|
||||
32
src/imap.rs
32
src/imap.rs
@@ -8,6 +8,7 @@ use std::{
|
||||
collections::{BTreeMap, BTreeSet, HashMap},
|
||||
iter::Peekable,
|
||||
mem::take,
|
||||
sync::atomic::Ordering,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
@@ -1445,6 +1446,37 @@ impl Session {
|
||||
*lock = Some(ServerMetadata { comment, admin });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stores device token into /private/devicetoken IMAP METADATA of the Inbox.
|
||||
pub(crate) async fn register_token(&mut self, context: &Context) -> Result<()> {
|
||||
if context.push_subscribed.load(Ordering::Relaxed) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(device_token) = context.push_subscriber.device_token().await else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if self.can_metadata() && self.can_push() {
|
||||
let folder = context
|
||||
.get_config(Config::ConfiguredInboxFolder)
|
||||
.await?
|
||||
.context("INBOX is not configured")?;
|
||||
|
||||
self.run_command_and_check_ok(format!(
|
||||
"SETMETADATA \"{folder}\" (/private/devicetoken \"{device_token}\")"
|
||||
))
|
||||
.await
|
||||
.context("SETMETADATA command failed")?;
|
||||
context.push_subscribed.store(true, Ordering::Relaxed);
|
||||
} else if !context.push_subscriber.heartbeat_subscribed().await {
|
||||
let context = context.clone();
|
||||
// Subscribe for heartbeat notifications.
|
||||
tokio::spawn(async move { context.push_subscriber.subscribe().await });
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
|
||||
@@ -25,6 +25,13 @@ pub(crate) struct Capabilities {
|
||||
/// <https://tools.ietf.org/html/rfc5464>
|
||||
pub can_metadata: bool,
|
||||
|
||||
/// True if the server supports XDELTAPUSH capability.
|
||||
/// This capability means setting /private/devicetoken IMAP METADATA
|
||||
/// on the INBOX results in new mail notifications
|
||||
/// via notifications.delta.chat service.
|
||||
/// This is supported by <https://github.com/deltachat/chatmail>
|
||||
pub can_push: bool,
|
||||
|
||||
/// Server ID if the server supports ID capability.
|
||||
pub server_id: Option<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ async fn determine_capabilities(
|
||||
can_check_quota: caps.has_str("QUOTA"),
|
||||
can_condstore: caps.has_str("CONDSTORE"),
|
||||
can_metadata: caps.has_str("METADATA"),
|
||||
can_push: caps.has_str("XDELTAPUSH"),
|
||||
server_id,
|
||||
};
|
||||
Ok(capabilities)
|
||||
|
||||
@@ -90,6 +90,10 @@ impl Session {
|
||||
self.capabilities.can_metadata
|
||||
}
|
||||
|
||||
pub fn can_push(&self) -> bool {
|
||||
self.capabilities.can_push
|
||||
}
|
||||
|
||||
/// Returns the names of all folders on the IMAP server.
|
||||
pub async fn list_folders(&mut self) -> Result<Vec<async_imap::types::Name>> {
|
||||
let list = self.list(Some(""), Some("*")).await?.try_collect().await?;
|
||||
|
||||
@@ -98,6 +98,7 @@ mod color;
|
||||
pub mod html;
|
||||
pub mod net;
|
||||
pub mod plaintext;
|
||||
mod push;
|
||||
pub mod summary;
|
||||
|
||||
mod debug_logging;
|
||||
|
||||
115
src/push.rs
Normal file
115
src/push.rs
Normal file
@@ -0,0 +1,115 @@
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::context::Context;
|
||||
use crate::net::http;
|
||||
|
||||
/// Manages subscription to Apple Push Notification services.
|
||||
///
|
||||
/// This structure is created by account manager and is shared between accounts.
|
||||
/// To enable notifications, application should request the device token as described in
|
||||
/// <https://developer.apple.com/documentation/usernotifications/registering-your-app-with-apns>
|
||||
/// and give it to the account manager, which will forward the token in this structure.
|
||||
///
|
||||
/// Each account (context) can then retrieve device token
|
||||
/// from this structure and give it to the email server.
|
||||
/// If email server does not support push notifications,
|
||||
/// account can call `subscribe` method
|
||||
/// to register device token with the heartbeat
|
||||
/// notification provider server as a fallback.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PushSubscriber {
|
||||
inner: Arc<RwLock<PushSubscriberState>>,
|
||||
}
|
||||
|
||||
impl PushSubscriber {
|
||||
/// Creates new push notification subscriber.
|
||||
pub(crate) fn new() -> Self {
|
||||
Default::default()
|
||||
}
|
||||
|
||||
/// Sets device token for Apple Push Notification service.
|
||||
pub(crate) async fn set_device_token(&mut self, token: &str) {
|
||||
self.inner.write().await.device_token = Some(token.to_string());
|
||||
}
|
||||
|
||||
/// Retrieves device token.
|
||||
///
|
||||
/// Token may be not available if application is not running on Apple platform,
|
||||
/// failed to register for remote notifications or is in the process of registering.
|
||||
///
|
||||
/// IMAP loop should periodically check if device token is available
|
||||
/// and send the token to the email server if it supports push notifications.
|
||||
pub(crate) async fn device_token(&self) -> Option<String> {
|
||||
self.inner.read().await.device_token.clone()
|
||||
}
|
||||
|
||||
/// Subscribes for heartbeat notifications with previously set device token.
|
||||
pub(crate) async fn subscribe(&self) -> Result<()> {
|
||||
let mut state = self.inner.write().await;
|
||||
|
||||
if state.heartbeat_subscribed {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(ref token) = state.device_token else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let socks5_config = None;
|
||||
let response = http::get_client(socks5_config)?
|
||||
.post("https://notifications.delta.chat/register")
|
||||
.body(format!("{{\"token\":\"{token}\"}}"))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let response_status = response.status();
|
||||
if response_status.is_success() {
|
||||
state.heartbeat_subscribed = true;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn heartbeat_subscribed(&self) -> bool {
|
||||
self.inner.read().await.heartbeat_subscribed
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct PushSubscriberState {
|
||||
/// Device token.
|
||||
device_token: Option<String>,
|
||||
|
||||
/// If subscribed to heartbeat push notifications.
|
||||
heartbeat_subscribed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, FromPrimitive, ToPrimitive)]
|
||||
#[repr(i8)]
|
||||
pub enum NotifyState {
|
||||
/// Not subscribed to push notifications.
|
||||
#[default]
|
||||
NotConnected = 0,
|
||||
|
||||
/// Subscribed to heartbeat push notifications.
|
||||
Heartbeat = 1,
|
||||
|
||||
/// Subscribed to push notifications for new messages.
|
||||
Connected = 2,
|
||||
}
|
||||
|
||||
impl Context {
|
||||
/// Returns push notification subscriber state.
|
||||
pub async fn push_state(&self) -> NotifyState {
|
||||
if self.push_subscribed.load(Ordering::Relaxed) {
|
||||
NotifyState::Connected
|
||||
} else if self.push_subscriber.heartbeat_subscribed().await {
|
||||
NotifyState::Heartbeat
|
||||
} else {
|
||||
NotifyState::NotConnected
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -525,6 +525,10 @@ async fn inbox_fetch_idle(ctx: &Context, imap: &mut Imap, mut session: Session)
|
||||
.fetch_metadata(ctx)
|
||||
.await
|
||||
.context("Failed to fetch metadata")?;
|
||||
session
|
||||
.register_token(ctx)
|
||||
.await
|
||||
.context("Failed to register push token")?;
|
||||
|
||||
let session = fetch_idle(ctx, imap, session, FolderMeaning::Inbox).await?;
|
||||
Ok(session)
|
||||
|
||||
Reference in New Issue
Block a user