Merge pull request 'Add shortcut links to edit page' (#883) from shortcut-links into main
Reviewed-on: https://git.joinplu.me/Plume/Plume/pulls/883
This commit is contained in:
commit
76f7b5e7ac
@ -21,6 +21,7 @@ executors:
|
|||||||
RUST_TEST_THREADS: 1
|
RUST_TEST_THREADS: 1
|
||||||
FEATURES: <<#parameters.postgres>>postgres<</ parameters.postgres>><<^parameters.postgres>>sqlite<</parameters.postgres>>
|
FEATURES: <<#parameters.postgres>>postgres<</ parameters.postgres>><<^parameters.postgres>>sqlite<</parameters.postgres>>
|
||||||
DATABASE_URL: <<#parameters.postgres>>postgres://postgres@localhost/plume<</parameters.postgres>><<^parameters.postgres>>plume.sqlite<</parameters.postgres>>
|
DATABASE_URL: <<#parameters.postgres>>postgres://postgres@localhost/plume<</parameters.postgres>><<^parameters.postgres>>plume.sqlite<</parameters.postgres>>
|
||||||
|
ROCKET_SECRET_KEY: VN5xV1DN7XdpATadOCYcuGeR/dV0hHfgx9mx9TarLdM=
|
||||||
|
|
||||||
|
|
||||||
commands:
|
commands:
|
||||||
@ -143,12 +144,14 @@ jobs:
|
|||||||
cache: <<#parameters.postgres>>postgres<</ parameters.postgres>><<^parameters.postgres>>sqlite<</parameters.postgres>>
|
cache: <<#parameters.postgres>>postgres<</ parameters.postgres>><<^parameters.postgres>>sqlite<</parameters.postgres>>
|
||||||
- run_with_coverage:
|
- run_with_coverage:
|
||||||
cmd: |
|
cmd: |
|
||||||
cargo run -p plume-cli --no-default-features --features=${FEATURES} -- migration run
|
cargo build -p plume-cli --no-default-features --features=${FEATURES} -j1
|
||||||
|
./target/debug/plm migration run
|
||||||
|
./target/debug/plm search init
|
||||||
cmd="cargo test --all --exclude plume-front --exclude plume-macro --no-run --no-default-features --features=${FEATURES} -j"
|
cmd="cargo test --all --exclude plume-front --exclude plume-macro --no-run --no-default-features --features=${FEATURES} -j"
|
||||||
for i in 36 4 2 1 1; do
|
for i in 36 4 2 1 1; do
|
||||||
$cmd $i && break
|
$cmd $i && break
|
||||||
done
|
done
|
||||||
cargo test --all --exclude plume-front --exclude plume-macro --no-default-features --features="${FEATURES}" -j1 -- --test-threads=1
|
cargo test --all --exclude plume-front --exclude plume-macro --no-default-features --features="${FEATURES}" -j1
|
||||||
- upload_coverage:
|
- upload_coverage:
|
||||||
type: unit
|
type: unit
|
||||||
- cache:
|
- cache:
|
||||||
|
@ -183,6 +183,8 @@ p.error {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
|
position: relative;
|
||||||
|
|
||||||
min-width: 20em;
|
min-width: 20em;
|
||||||
min-height: 20em;
|
min-height: 20em;
|
||||||
margin: 1em;
|
margin: 1em;
|
||||||
@ -225,11 +227,14 @@ p.error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
margin: 0.75em 20px;
|
flex-grow: 1;
|
||||||
|
margin: 0;
|
||||||
font-family: $playfair;
|
font-family: $playfair;
|
||||||
font-size: 1.75em;
|
font-size: 1.75em;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
|
line-height: 1.75;
|
||||||
a {
|
a {
|
||||||
|
display: block;
|
||||||
transition: color 0.1s ease-in;
|
transition: color 0.1s ease-in;
|
||||||
color: $text-color;
|
color: $text-color;
|
||||||
|
|
||||||
@ -237,6 +242,15 @@ p.error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
float: right;
|
||||||
|
|
||||||
|
.button {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
main {
|
main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
||||||
|
@ -247,6 +247,7 @@ pub(crate) mod tests {
|
|||||||
use diesel::Connection;
|
use diesel::Connection;
|
||||||
|
|
||||||
pub(crate) fn fill_database(conn: &Conn) -> Vec<(NewInstance, Instance)> {
|
pub(crate) fn fill_database(conn: &Conn) -> Vec<(NewInstance, Instance)> {
|
||||||
|
diesel::delete(instances::table).execute(conn).unwrap();
|
||||||
let res = vec![
|
let res = vec![
|
||||||
NewInstance {
|
NewInstance {
|
||||||
default_license: "WTFPL".to_string(),
|
default_license: "WTFPL".to_string(),
|
||||||
|
@ -12,6 +12,7 @@ use activitypub::{
|
|||||||
use chrono::{NaiveDateTime, TimeZone, Utc};
|
use chrono::{NaiveDateTime, TimeZone, Utc};
|
||||||
use diesel::{self, BelongingToDsl, ExpressionMethods, QueryDsl, RunQueryDsl, SaveChangesDsl};
|
use diesel::{self, BelongingToDsl, ExpressionMethods, QueryDsl, RunQueryDsl, SaveChangesDsl};
|
||||||
use heck::KebabCase;
|
use heck::KebabCase;
|
||||||
|
use once_cell::sync::Lazy;
|
||||||
use plume_common::{
|
use plume_common::{
|
||||||
activity_pub::{
|
activity_pub::{
|
||||||
inbox::{AsObject, FromId},
|
inbox::{AsObject, FromId},
|
||||||
@ -20,11 +21,13 @@ use plume_common::{
|
|||||||
utils::md_to_html,
|
utils::md_to_html,
|
||||||
};
|
};
|
||||||
use riker::actors::{Publish, Tell};
|
use riker::actors::{Publish, Tell};
|
||||||
use std::collections::HashSet;
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::sync::Arc;
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
pub type LicensedArticle = CustomObject<Licensed, Article>;
|
pub type LicensedArticle = CustomObject<Licensed, Article>;
|
||||||
|
|
||||||
|
static BLOG_FQN_CACHE: Lazy<Mutex<HashMap<i32, String>>> = Lazy::new(|| Mutex::new(HashMap::new()));
|
||||||
|
|
||||||
#[derive(Queryable, Identifiable, Clone, AsChangeset, Debug)]
|
#[derive(Queryable, Identifiable, Clone, AsChangeset, Debug)]
|
||||||
#[changeset_options(treat_none_as_null = "true")]
|
#[changeset_options(treat_none_as_null = "true")]
|
||||||
pub struct Post {
|
pub struct Post {
|
||||||
@ -275,6 +278,24 @@ impl Post {
|
|||||||
.map_err(Error::from)
|
.map_err(Error::from)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// This method exists for use in templates to reduce database access.
|
||||||
|
/// This should not be used for other purpose.
|
||||||
|
///
|
||||||
|
/// This caches query result. The best way to cache query result is holding it in `Post`s field
|
||||||
|
/// but Diesel doesn't allow it currently.
|
||||||
|
/// If sometime Diesel allow it, this method should be removed.
|
||||||
|
pub fn get_blog_fqn(&self, conn: &Connection) -> String {
|
||||||
|
if let Some(blog_fqn) = BLOG_FQN_CACHE.lock().unwrap().get(&self.blog_id) {
|
||||||
|
return blog_fqn.to_string();
|
||||||
|
}
|
||||||
|
let blog_fqn = self.get_blog(conn).unwrap().fqn;
|
||||||
|
BLOG_FQN_CACHE
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.insert(self.blog_id, blog_fqn.clone());
|
||||||
|
blog_fqn
|
||||||
|
}
|
||||||
|
|
||||||
pub fn count_likes(&self, conn: &Connection) -> Result<i64> {
|
pub fn count_likes(&self, conn: &Connection) -> Result<i64> {
|
||||||
use crate::schema::likes;
|
use crate::schema::likes;
|
||||||
likes::table
|
likes::table
|
||||||
|
@ -79,7 +79,6 @@ impl ActorFactoryArgs<(Arc<Searcher>, DbPool)> for SearchActor {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::diesel::Connection;
|
use crate::diesel::Connection;
|
||||||
use crate::diesel::RunQueryDsl;
|
|
||||||
use crate::{
|
use crate::{
|
||||||
blog_authors::{BlogAuthor, NewBlogAuthor},
|
blog_authors::{BlogAuthor, NewBlogAuthor},
|
||||||
blogs::{Blog, NewBlog},
|
blogs::{Blog, NewBlog},
|
||||||
@ -114,7 +113,7 @@ mod tests {
|
|||||||
let conn = db_pool.clone().get().unwrap();
|
let conn = db_pool.clone().get().unwrap();
|
||||||
|
|
||||||
let title = random_hex()[..8].to_owned();
|
let title = random_hex()[..8].to_owned();
|
||||||
let (instance, user, blog) = fill_database(&conn);
|
let (_instance, _user, blog) = fill_database(&conn);
|
||||||
let author = &blog.list_authors(&conn).unwrap()[0];
|
let author = &blog.list_authors(&conn).unwrap()[0];
|
||||||
|
|
||||||
let post = Post::insert(
|
let post = Post::insert(
|
||||||
@ -151,11 +150,6 @@ mod tests {
|
|||||||
searcher.search_document(&conn, Query::from_str(&title).unwrap(), (0, 1))[0].id,
|
searcher.search_document(&conn, Query::from_str(&title).unwrap(), (0, 1))[0].id,
|
||||||
post_id
|
post_id
|
||||||
);
|
);
|
||||||
// TODO: Make sure records are deleted even when assertion failed
|
|
||||||
post.delete(&conn).unwrap();
|
|
||||||
blog.delete(&conn).unwrap();
|
|
||||||
user.delete(&conn).unwrap();
|
|
||||||
diesel::delete(&instance).execute(&conn).unwrap();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fill_database(conn: &Conn) -> (Instance, User, Blog) {
|
fn fill_database(conn: &Conn) -> (Instance, User, Blog) {
|
||||||
@ -164,7 +158,7 @@ mod tests {
|
|||||||
conn,
|
conn,
|
||||||
NewInstance {
|
NewInstance {
|
||||||
default_license: "CC-0-BY-SA".to_string(),
|
default_license: "CC-0-BY-SA".to_string(),
|
||||||
local: true,
|
local: false,
|
||||||
long_description: SafeString::new("Good morning"),
|
long_description: SafeString::new("Good morning"),
|
||||||
long_description_html: "<p>Good morning</p>".to_string(),
|
long_description_html: "<p>Good morning</p>".to_string(),
|
||||||
short_description: SafeString::new("Hello"),
|
short_description: SafeString::new("Hello"),
|
||||||
@ -175,14 +169,29 @@ mod tests {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let mut user = NewUser::default();
|
let user = User::insert(
|
||||||
user.instance_id = instance.id;
|
conn,
|
||||||
user.username = random_hex().to_string();
|
NewUser {
|
||||||
user.ap_url = random_hex().to_string();
|
username: random_hex().to_string(),
|
||||||
user.inbox_url = random_hex().to_string();
|
display_name: random_hex().to_string(),
|
||||||
user.outbox_url = random_hex().to_string();
|
outbox_url: random_hex().to_string(),
|
||||||
user.followers_endpoint = random_hex().to_string();
|
inbox_url: random_hex().to_string(),
|
||||||
let user = User::insert(conn, user).unwrap();
|
summary: "".to_string(),
|
||||||
|
email: None,
|
||||||
|
hashed_password: None,
|
||||||
|
instance_id: instance.id,
|
||||||
|
ap_url: random_hex().to_string(),
|
||||||
|
private_key: None,
|
||||||
|
public_key: "".to_string(),
|
||||||
|
shared_inbox_url: None,
|
||||||
|
followers_endpoint: random_hex().to_string(),
|
||||||
|
avatar_id: None,
|
||||||
|
summary_html: SafeString::new(""),
|
||||||
|
role: 0,
|
||||||
|
fqn: random_hex().to_string(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
let mut blog = NewBlog::default();
|
let mut blog = NewBlog::default();
|
||||||
blog.instance_id = instance.id;
|
blog.instance_id = instance.id;
|
||||||
blog.actor_id = random_hex().to_string();
|
blog.actor_id = random_hex().to_string();
|
||||||
|
11
src/main.rs
11
src/main.rs
@ -60,7 +60,7 @@ fn init_pool() -> Option<DbPool> {
|
|||||||
Some(pool)
|
Some(pool)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() {
|
pub(crate) fn init_rocket() -> rocket::Rocket {
|
||||||
match dotenv::dotenv() {
|
match dotenv::dotenv() {
|
||||||
Ok(path) => eprintln!("Configuration read from {}", path.display()),
|
Ok(path) => eprintln!("Configuration read from {}", path.display()),
|
||||||
Err(ref e) if e.not_found() => eprintln!("no .env was found"),
|
Err(ref e) if e.not_found() => eprintln!("no .env was found"),
|
||||||
@ -126,7 +126,7 @@ Then try to restart Plume.
|
|||||||
warn!("Please refer to the documentation to see how to configure it.");
|
warn!("Please refer to the documentation to see how to configure it.");
|
||||||
}
|
}
|
||||||
|
|
||||||
let rocket = rocket::custom(CONFIG.rocket.clone().unwrap())
|
rocket::custom(CONFIG.rocket.clone().unwrap())
|
||||||
.mount(
|
.mount(
|
||||||
"/",
|
"/",
|
||||||
routes![
|
routes![
|
||||||
@ -268,9 +268,14 @@ Then try to restart Plume.
|
|||||||
])
|
])
|
||||||
.finalize()
|
.finalize()
|
||||||
.expect("main: csrf fairing creation error"),
|
.expect("main: csrf fairing creation error"),
|
||||||
);
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let rocket = init_rocket();
|
||||||
|
|
||||||
#[cfg(feature = "test")]
|
#[cfg(feature = "test")]
|
||||||
let rocket = rocket.mount("/test", routes![test_routes::health,]);
|
let rocket = rocket.mount("/test", routes![test_routes::health,]);
|
||||||
|
|
||||||
rocket.launch();
|
rocket.launch();
|
||||||
}
|
}
|
||||||
|
@ -371,3 +371,135 @@ pub fn atom_feed(name: String, rockets: PlumeRocket) -> Option<Content<String>>
|
|||||||
feed.to_string(),
|
feed.to_string(),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use crate::init_rocket;
|
||||||
|
use diesel::Connection;
|
||||||
|
use plume_common::utils::random_hex;
|
||||||
|
use plume_models::{
|
||||||
|
blog_authors::{BlogAuthor, NewBlogAuthor},
|
||||||
|
blogs::{Blog, NewBlog},
|
||||||
|
db_conn::{DbConn, DbPool},
|
||||||
|
instance::{Instance, NewInstance},
|
||||||
|
post_authors::{NewPostAuthor, PostAuthor},
|
||||||
|
posts::{NewPost, Post},
|
||||||
|
safe_string::SafeString,
|
||||||
|
users::{NewUser, User, AUTH_COOKIE},
|
||||||
|
};
|
||||||
|
use rocket::{
|
||||||
|
http::{Cookie, Cookies, SameSite},
|
||||||
|
local::{Client, LocalRequest},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn edit_link_within_post_card() {
|
||||||
|
let rocket = init_rocket();
|
||||||
|
let client = Client::new(rocket).expect("valid rocket instance");
|
||||||
|
let dbpool = client.rocket().state::<DbPool>().unwrap();
|
||||||
|
let conn = &DbConn(dbpool.get().unwrap());
|
||||||
|
|
||||||
|
let (_instance, user, blog, post) = create_models(conn);
|
||||||
|
|
||||||
|
let blog_path = uri!(super::activity_details: name = &blog.fqn).to_string();
|
||||||
|
let edit_link = uri!(
|
||||||
|
super::super::posts::edit: blog = &blog.fqn,
|
||||||
|
slug = &post.slug
|
||||||
|
)
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let mut response = client.get(&blog_path).dispatch();
|
||||||
|
let body = response.body_string().unwrap();
|
||||||
|
assert!(!body.contains(&edit_link));
|
||||||
|
|
||||||
|
let request = client.get(&blog_path);
|
||||||
|
login(&request, &user);
|
||||||
|
let mut response = request.dispatch();
|
||||||
|
let body = response.body_string().unwrap();
|
||||||
|
assert!(body.contains(&edit_link));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_models(conn: &DbConn) -> (Instance, User, Blog, Post) {
|
||||||
|
conn.transaction::<(Instance, User, Blog, Post), diesel::result::Error, _>(|| {
|
||||||
|
let instance = Instance::get_local().unwrap_or_else(|_| {
|
||||||
|
let instance = Instance::insert(
|
||||||
|
conn,
|
||||||
|
NewInstance {
|
||||||
|
default_license: "CC-0-BY-SA".to_string(),
|
||||||
|
local: true,
|
||||||
|
long_description: SafeString::new("Good morning"),
|
||||||
|
long_description_html: "<p>Good morning</p>".to_string(),
|
||||||
|
short_description: SafeString::new("Hello"),
|
||||||
|
short_description_html: "<p>Hello</p>".to_string(),
|
||||||
|
name: random_hex().to_string(),
|
||||||
|
open_registrations: true,
|
||||||
|
public_domain: random_hex().to_string(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
Instance::cache_local(conn);
|
||||||
|
instance
|
||||||
|
});
|
||||||
|
let mut user = NewUser::default();
|
||||||
|
user.instance_id = instance.id;
|
||||||
|
user.username = random_hex().to_string();
|
||||||
|
user.ap_url = random_hex().to_string();
|
||||||
|
user.inbox_url = random_hex().to_string();
|
||||||
|
user.outbox_url = random_hex().to_string();
|
||||||
|
user.followers_endpoint = random_hex().to_string();
|
||||||
|
let user = User::insert(conn, user).unwrap();
|
||||||
|
let mut blog = NewBlog::default();
|
||||||
|
blog.instance_id = instance.id;
|
||||||
|
blog.actor_id = random_hex().to_string();
|
||||||
|
blog.ap_url = random_hex().to_string();
|
||||||
|
blog.inbox_url = random_hex().to_string();
|
||||||
|
blog.outbox_url = random_hex().to_string();
|
||||||
|
let blog = Blog::insert(conn, blog).unwrap();
|
||||||
|
BlogAuthor::insert(
|
||||||
|
conn,
|
||||||
|
NewBlogAuthor {
|
||||||
|
blog_id: blog.id,
|
||||||
|
author_id: user.id,
|
||||||
|
is_owner: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let post = Post::insert(
|
||||||
|
conn,
|
||||||
|
NewPost {
|
||||||
|
blog_id: blog.id,
|
||||||
|
slug: random_hex()[..8].to_owned(),
|
||||||
|
title: random_hex()[..8].to_owned(),
|
||||||
|
content: SafeString::new(""),
|
||||||
|
published: true,
|
||||||
|
license: "CC-By-SA".to_owned(),
|
||||||
|
ap_url: "".to_owned(),
|
||||||
|
creation_date: None,
|
||||||
|
subtitle: "".to_owned(),
|
||||||
|
source: "".to_owned(),
|
||||||
|
cover_id: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
PostAuthor::insert(
|
||||||
|
conn,
|
||||||
|
NewPostAuthor {
|
||||||
|
post_id: post.id,
|
||||||
|
author_id: user.id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
Ok((instance, user, blog, post))
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn login(request: &LocalRequest, user: &User) {
|
||||||
|
request.inner().guard::<Cookies>().unwrap().add_private(
|
||||||
|
Cookie::build(AUTH_COOKIE, user.id.to_string())
|
||||||
|
.same_site(SameSite::Lax)
|
||||||
|
.finish(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -8,11 +8,18 @@
|
|||||||
@if article.cover_id.is_some() {
|
@if article.cover_id.is_some() {
|
||||||
<div class="cover" style="background-image: url('@Html(article.cover_url(ctx.0).unwrap_or_default())')"></div>
|
<div class="cover" style="background-image: url('@Html(article.cover_url(ctx.0).unwrap_or_default())')"></div>
|
||||||
}
|
}
|
||||||
|
<header>
|
||||||
|
@if ctx.2.clone().and_then(|u| article.is_author(ctx.0, u.id).ok()).unwrap_or(false) {
|
||||||
|
<div class="controls">
|
||||||
|
<a class="button" href="@uri!(posts::edit: blog = &article.get_blog_fqn(ctx.0), slug = &article.slug)">@i18n!(ctx.1, "Edit")</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<h3 class="p-name" dir="auto">
|
<h3 class="p-name" dir="auto">
|
||||||
<a class="u-url" href="@uri!(posts::details: blog = article.get_blog(ctx.0).unwrap().fqn, slug = &article.slug, responding_to = _)">
|
<a class="u-url" href="@uri!(posts::details: blog = article.get_blog_fqn(ctx.0), slug = &article.slug, responding_to = _)">
|
||||||
@article.title
|
@article.title
|
||||||
</a>
|
</a>
|
||||||
</h3>
|
</h3>
|
||||||
|
</header>
|
||||||
<main>
|
<main>
|
||||||
<p class="p-summary" dir="auto">@article.subtitle</p>
|
<p class="p-summary" dir="auto">@article.subtitle</p>
|
||||||
</main>
|
</main>
|
||||||
@ -26,7 +33,7 @@
|
|||||||
@if article.published {
|
@if article.published {
|
||||||
⋅ <span class="dt-published" datetime="@article.creation_date.format("%F %T")">@article.creation_date.format("%B %e, %Y")</span>
|
⋅ <span class="dt-published" datetime="@article.creation_date.format("%F %T")">@article.creation_date.format("%B %e, %Y")</span>
|
||||||
}
|
}
|
||||||
⋅ <a href="@uri!(blogs::details: name = &article.get_blog(ctx.0).unwrap().fqn, page = _)">@article.get_blog(ctx.0).unwrap().title</a>
|
⋅ <a href="@uri!(blogs::details: name = &article.get_blog_fqn(ctx.0), page = _)">@article.get_blog(ctx.0).unwrap().title</a>
|
||||||
⋅
|
⋅
|
||||||
</div>
|
</div>
|
||||||
@if !article.published {
|
@if !article.published {
|
||||||
|
Loading…
Reference in New Issue
Block a user