forked from alemi/upub
fix(web): fixes for apb node changes
This commit is contained in:
parent
7f091291af
commit
456ca2d8b1
8 changed files with 38 additions and 38 deletions
|
@ -1,16 +1,16 @@
|
||||||
use leptos::*;
|
use leptos::*;
|
||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
|
|
||||||
use apb::{target::Addressed, Base, Activity, Object};
|
use apb::{field::OptionalString, target::Addressed, Activity, Base, Object};
|
||||||
|
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn ActivityLine(activity: crate::Object) -> impl IntoView {
|
pub fn ActivityLine(activity: crate::Object) -> impl IntoView {
|
||||||
let object_id = activity.object().id().unwrap_or_default();
|
let object_id = activity.object().id().str().unwrap_or_default();
|
||||||
let activity_url = activity.id().map(|x| view! {
|
let activity_url = activity.id().map(|x| view! {
|
||||||
<sup><small><a class="clean ml-s" href={x.to_string()} target="_blank">"↗"</a></small></sup>
|
<sup><small><a class="clean ml-s" href={x.to_string()} target="_blank">"↗"</a></small></sup>
|
||||||
});
|
});
|
||||||
let actor_id = activity.actor().id().unwrap_or_default();
|
let actor_id = activity.actor().id().str().unwrap_or_default();
|
||||||
let actor = CACHE.get_or(&actor_id, serde_json::Value::String(actor_id.clone()).into());
|
let actor = CACHE.get_or(&actor_id, serde_json::Value::String(actor_id.clone()).into());
|
||||||
let kind = activity.activity_type().unwrap_or(apb::ActivityType::Activity);
|
let kind = activity.activity_type().unwrap_or(apb::ActivityType::Activity);
|
||||||
let href = match kind {
|
let href = match kind {
|
||||||
|
@ -54,7 +54,7 @@ pub fn Item(
|
||||||
Some(view! { <Object object=item.clone() />{sep.clone()} }.into_view()),
|
Some(view! { <Object object=item.clone() />{sep.clone()} }.into_view()),
|
||||||
// everything else
|
// everything else
|
||||||
apb::ObjectType::Activity(t) => {
|
apb::ObjectType::Activity(t) => {
|
||||||
let object_id = item.object().id().unwrap_or_default();
|
let object_id = item.object().id().str().unwrap_or_default();
|
||||||
let object = match t {
|
let object = match t {
|
||||||
apb::ActivityType::Create | apb::ActivityType::Announce =>
|
apb::ActivityType::Create | apb::ActivityType::Announce =>
|
||||||
CACHE.get(&object_id).map(|obj| {
|
CACHE.get(&object_id).map(|obj| {
|
||||||
|
|
|
@ -3,7 +3,7 @@ use std::sync::Arc;
|
||||||
use leptos::*;
|
use leptos::*;
|
||||||
use crate::{prelude::*, URL_SENSITIVE};
|
use crate::{prelude::*, URL_SENSITIVE};
|
||||||
|
|
||||||
use apb::{target::Addressed, ActivityMut, Base, Collection, CollectionMut, Document, Object, ObjectMut};
|
use apb::{field::OptionalString, target::Addressed, ActivityMut, Base, Collection, CollectionMut, Document, Object, ObjectMut};
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Attachment(
|
pub fn Attachment(
|
||||||
|
@ -13,7 +13,7 @@ pub fn Attachment(
|
||||||
) -> impl IntoView {
|
) -> impl IntoView {
|
||||||
let config = use_context::<Signal<crate::Config>>().expect("missing config context");
|
let config = use_context::<Signal<crate::Config>>().expect("missing config context");
|
||||||
let (expand, set_expand) = create_signal(false);
|
let (expand, set_expand) = create_signal(false);
|
||||||
let href = object.url().id().unwrap_or_default();
|
let href = object.url().id().str().unwrap_or_default();
|
||||||
let media_type = object.media_type()
|
let media_type = object.media_type()
|
||||||
.unwrap_or("link") // TODO make it an Option rather than defaulting to link everywhere
|
.unwrap_or("link") // TODO make it an Option rather than defaulting to link everywhere
|
||||||
.to_string();
|
.to_string();
|
||||||
|
@ -109,12 +109,12 @@ pub fn Attachment(
|
||||||
pub fn Object(object: crate::Object) -> impl IntoView {
|
pub fn Object(object: crate::Object) -> impl IntoView {
|
||||||
let oid = object.id().unwrap_or_default().to_string();
|
let oid = object.id().unwrap_or_default().to_string();
|
||||||
let content = mdhtml::safe_html(object.content().unwrap_or_default());
|
let content = mdhtml::safe_html(object.content().unwrap_or_default());
|
||||||
let author_id = object.attributed_to().id().unwrap_or_default();
|
let author_id = object.attributed_to().id().str().unwrap_or_default();
|
||||||
let author = CACHE.get_or(&author_id, serde_json::Value::String(author_id.clone()).into());
|
let author = CACHE.get_or(&author_id, serde_json::Value::String(author_id.clone()).into());
|
||||||
let sensitive = object.sensitive().unwrap_or_default();
|
let sensitive = object.sensitive().unwrap_or_default();
|
||||||
let addressed = object.addressed();
|
let addressed = object.addressed();
|
||||||
let public = addressed.iter().any(|x| x.as_str() == apb::target::PUBLIC);
|
let public = addressed.iter().any(|x| x.as_str() == apb::target::PUBLIC);
|
||||||
let external_url = object.url().id().unwrap_or_else(|| oid.clone());
|
let external_url = object.url().id().str().unwrap_or_else(|| oid.clone());
|
||||||
let attachments = object.attachment()
|
let attachments = object.attachment()
|
||||||
.map(|x| view! { <Attachment object=x sensitive=sensitive /> })
|
.map(|x| view! { <Attachment object=x sensitive=sensitive /> })
|
||||||
.collect_view();
|
.collect_view();
|
||||||
|
@ -175,7 +175,7 @@ pub fn Object(object: crate::Object) -> impl IntoView {
|
||||||
<tr>
|
<tr>
|
||||||
<td><ActorBanner object=author /></td>
|
<td><ActorBanner object=author /></td>
|
||||||
<td class="rev" >
|
<td class="rev" >
|
||||||
{object.in_reply_to().id().map(|reply| view! {
|
{object.in_reply_to().id().str().map(|reply| view! {
|
||||||
<small><i><a class="clean" href={Uri::web(U::Object, &reply)} title={reply}>reply</a></i></small>
|
<small><i><a class="clean" href={Uri::web(U::Object, &reply)} title={reply}>reply</a></i></small>
|
||||||
})}
|
})}
|
||||||
<PrivacyMarker addressed=addressed />
|
<PrivacyMarker addressed=addressed />
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
use apb::{ActivityMut, Base, BaseMut, Object, ObjectMut};
|
use apb::{field::OptionalString, ActivityMut, Base, BaseMut, Object, ObjectMut};
|
||||||
|
|
||||||
use leptos::*;
|
use leptos::*;
|
||||||
use crate::{prelude::*, WEBFINGER};
|
use crate::{prelude::*, WEBFINGER};
|
||||||
|
@ -12,7 +12,7 @@ pub struct ReplyControls {
|
||||||
impl ReplyControls {
|
impl ReplyControls {
|
||||||
pub fn reply(&self, oid: &str) {
|
pub fn reply(&self, oid: &str) {
|
||||||
if let Some(obj) = CACHE.get(oid) {
|
if let Some(obj) = CACHE.get(oid) {
|
||||||
self.context.set(obj.context().id());
|
self.context.set(obj.context().id().str());
|
||||||
self.reply_to.set(obj.id().ok().map(|x| x.to_string()));
|
self.reply_to.set(obj.id().ok().map(|x| x.to_string()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -24,7 +24,7 @@ impl ReplyControls {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn post_author(post_id: &str) -> Option<crate::Object> {
|
fn post_author(post_id: &str) -> Option<crate::Object> {
|
||||||
let usr = CACHE.get(post_id)?.attributed_to().id()?;
|
let usr = CACHE.get(post_id)?.attributed_to().id().str()?;
|
||||||
CACHE.get(&usr)
|
CACHE.get(&usr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
use std::{collections::BTreeSet, pin::Pin, sync::Arc};
|
use std::{collections::BTreeSet, pin::Pin, sync::Arc};
|
||||||
|
|
||||||
use apb::{Activity, ActivityMut, Base, Object};
|
use apb::{field::OptionalString, Activity, ActivityMut, Base, Object};
|
||||||
use leptos::*;
|
use leptos::*;
|
||||||
use leptos_use::{signal_throttled, use_element_size, use_window_scroll, UseElementSizeReturn};
|
use leptos_use::{signal_throttled, use_element_size, use_window_scroll, UseElementSizeReturn};
|
||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
|
@ -68,8 +68,8 @@ impl Timeline {
|
||||||
feed.append(&mut older);
|
feed.append(&mut older);
|
||||||
self.feed.set(feed);
|
self.feed.set(feed);
|
||||||
|
|
||||||
if let Some(next) = collection.next().id() {
|
if let Ok(next) = collection.next().id() {
|
||||||
self.next.set(next);
|
self.next.set(next.to_string());
|
||||||
} else {
|
} else {
|
||||||
self.over.set(true);
|
self.over.set(true);
|
||||||
}
|
}
|
||||||
|
@ -86,9 +86,9 @@ pub fn TimelineRepliesRecursive(tl: Timeline, root: String) -> impl IntoView {
|
||||||
.filter_map(|x| CACHE.get(&x))
|
.filter_map(|x| CACHE.get(&x))
|
||||||
.filter(|x| match x.object_type() {
|
.filter(|x| match x.object_type() {
|
||||||
Ok(apb::ObjectType::Activity(apb::ActivityType::Create)) => {
|
Ok(apb::ObjectType::Activity(apb::ActivityType::Create)) => {
|
||||||
let Some(oid) = x.object().id() else { return false; };
|
let Some(oid) = x.object().id().str() else { return false; };
|
||||||
let Some(object) = CACHE.get(&oid) else { return false; };
|
let Some(object) = CACHE.get(&oid) else { return false; };
|
||||||
let Some(reply) = object.in_reply_to().id() else { return false; };
|
let Some(reply) = object.in_reply_to().id().str() else { return false; };
|
||||||
reply == root
|
reply == root
|
||||||
},
|
},
|
||||||
Ok(apb::ObjectType::Activity(_)) => x.object().id().map(|o| o == root).unwrap_or(false),
|
Ok(apb::ObjectType::Activity(_)) => x.object().id().map(|o| o == root).unwrap_or(false),
|
||||||
|
@ -199,7 +199,7 @@ async fn process_activities(activities: Vec<serde_json::Value>, auth: Auth) -> V
|
||||||
// save embedded object if present
|
// save embedded object if present
|
||||||
if let Some(object) = activity.object().get() {
|
if let Some(object) = activity.object().get() {
|
||||||
// also fetch actor attributed to
|
// also fetch actor attributed to
|
||||||
if let Some(attributed_to) = object.attributed_to().id() {
|
if let Some(attributed_to) = object.attributed_to().id().str() {
|
||||||
actors_seen.insert(attributed_to);
|
actors_seen.insert(attributed_to);
|
||||||
}
|
}
|
||||||
if let Ok(object_uri) = object.id() {
|
if let Ok(object_uri) = object.id() {
|
||||||
|
@ -208,7 +208,7 @@ async fn process_activities(activities: Vec<serde_json::Value>, auth: Auth) -> V
|
||||||
tracing::warn!("embedded object without id: {object:?}");
|
tracing::warn!("embedded object without id: {object:?}");
|
||||||
}
|
}
|
||||||
} else { // try fetching it
|
} else { // try fetching it
|
||||||
if let Some(object_id) = activity.object().id() {
|
if let Some(object_id) = activity.object().id().str() {
|
||||||
if !gonna_fetch.contains(&object_id) {
|
if !gonna_fetch.contains(&object_id) {
|
||||||
let fetch_kind = match activity_type {
|
let fetch_kind = match activity_type {
|
||||||
apb::ActivityType::Follow => U::Actor,
|
apb::ActivityType::Follow => U::Actor,
|
||||||
|
@ -221,25 +221,25 @@ async fn process_activities(activities: Vec<serde_json::Value>, auth: Auth) -> V
|
||||||
}
|
}
|
||||||
|
|
||||||
// save activity, removing embedded object
|
// save activity, removing embedded object
|
||||||
let object_id = activity.object().id();
|
let object_id = activity.object().id().str();
|
||||||
if let Ok(activity_id) = activity.id() {
|
if let Some(activity_id) = activity.id().str() {
|
||||||
out.push(activity_id.to_string());
|
out.push(activity_id.to_string());
|
||||||
CACHE.put(
|
CACHE.put(
|
||||||
activity_id.to_string(),
|
activity_id.to_string(),
|
||||||
Arc::new(activity.clone().set_object(apb::Node::maybe_link(object_id)))
|
Arc::new(activity.clone().set_object(apb::Node::maybe_link(object_id)))
|
||||||
);
|
);
|
||||||
} else if let Some(object_id) = activity.object().id() {
|
} else if let Some(object_id) = activity.object().id().str() {
|
||||||
out.push(object_id);
|
out.push(object_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(uid) = activity.attributed_to().id() {
|
if let Some(uid) = activity.attributed_to().id().str() {
|
||||||
if CACHE.get(&uid).is_none() && !gonna_fetch.contains(&uid) {
|
if CACHE.get(&uid).is_none() && !gonna_fetch.contains(&uid) {
|
||||||
gonna_fetch.insert(uid.clone());
|
gonna_fetch.insert(uid.clone());
|
||||||
sub_tasks.push(Box::pin(fetch_and_update(U::Actor, uid, auth)));
|
sub_tasks.push(Box::pin(fetch_and_update(U::Actor, uid, auth)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(uid) = activity.actor().id() {
|
if let Some(uid) = activity.actor().id().str() {
|
||||||
if CACHE.get(&uid).is_none() && !gonna_fetch.contains(&uid) {
|
if CACHE.get(&uid).is_none() && !gonna_fetch.contains(&uid) {
|
||||||
gonna_fetch.insert(uid.clone());
|
gonna_fetch.insert(uid.clone());
|
||||||
sub_tasks.push(Box::pin(fetch_and_update(U::Actor, uid, auth)));
|
sub_tasks.push(Box::pin(fetch_and_update(U::Actor, uid, auth)));
|
||||||
|
@ -267,8 +267,8 @@ async fn fetch_and_update_with_user(kind: U, id: String, auth: Auth) {
|
||||||
fetch_and_update(kind, id.clone(), auth).await;
|
fetch_and_update(kind, id.clone(), auth).await;
|
||||||
if let Some(obj) = CACHE.get(&id) {
|
if let Some(obj) = CACHE.get(&id) {
|
||||||
if let Some(actor_id) = match kind {
|
if let Some(actor_id) = match kind {
|
||||||
U::Object => obj.attributed_to().id(),
|
U::Object => obj.attributed_to().id().str(),
|
||||||
U::Activity => obj.actor().id(),
|
U::Activity => obj.actor().id().str(),
|
||||||
U::Actor | U::Context => None,
|
U::Actor | U::Context => None,
|
||||||
} {
|
} {
|
||||||
fetch_and_update(U::Actor, actor_id, auth).await;
|
fetch_and_update(U::Actor, actor_id, auth).await;
|
||||||
|
|
|
@ -1,14 +1,14 @@
|
||||||
use leptos::*;
|
use leptos::*;
|
||||||
use crate::{prelude::*, DEFAULT_AVATAR_URL};
|
use crate::{prelude::*, DEFAULT_AVATAR_URL};
|
||||||
|
|
||||||
use apb::{Activity, ActivityMut, Actor, Base, Object, ObjectMut};
|
use apb::{field::OptionalString, Activity, ActivityMut, Actor, Base, Object, ObjectMut};
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn ActorStrip(object: crate::Object) -> impl IntoView {
|
pub fn ActorStrip(object: crate::Object) -> impl IntoView {
|
||||||
let actor_id = object.id().unwrap_or_default().to_string();
|
let actor_id = object.id().unwrap_or_default().to_string();
|
||||||
let username = object.preferred_username().unwrap_or_default().to_string();
|
let username = object.preferred_username().unwrap_or_default().to_string();
|
||||||
let domain = object.id().unwrap_or_default().replace("https://", "").split('/').next().unwrap_or_default().to_string();
|
let domain = object.id().unwrap_or_default().replace("https://", "").split('/').next().unwrap_or_default().to_string();
|
||||||
let avatar = object.icon().get().map(|x| x.url().id().unwrap_or(DEFAULT_AVATAR_URL.into())).unwrap_or(DEFAULT_AVATAR_URL.into());
|
let avatar = object.icon().get().map(|x| x.url().id().str().unwrap_or(DEFAULT_AVATAR_URL.into())).unwrap_or(DEFAULT_AVATAR_URL.into());
|
||||||
view! {
|
view! {
|
||||||
<a href={Uri::web(U::Actor, &actor_id)} class="clean hover">
|
<a href={Uri::web(U::Actor, &actor_id)} class="clean hover">
|
||||||
<img src={avatar} class="avatar inline mr-s" /><b>{username}</b><small>@{domain}</small>
|
<img src={avatar} class="avatar inline mr-s" /><b>{username}</b><small>@{domain}</small>
|
||||||
|
@ -25,7 +25,7 @@ pub fn ActorBanner(object: crate::Object) -> impl IntoView {
|
||||||
serde_json::Value::Object(_) => {
|
serde_json::Value::Object(_) => {
|
||||||
let uid = object.id().unwrap_or_default().to_string();
|
let uid = object.id().unwrap_or_default().to_string();
|
||||||
let uri = Uri::web(U::Actor, &uid);
|
let uri = Uri::web(U::Actor, &uid);
|
||||||
let avatar_url = object.icon().get().map(|x| x.url().id().unwrap_or(DEFAULT_AVATAR_URL.into())).unwrap_or(DEFAULT_AVATAR_URL.into());
|
let avatar_url = object.icon().get().map(|x| x.url().id().str().unwrap_or(DEFAULT_AVATAR_URL.into())).unwrap_or(DEFAULT_AVATAR_URL.into());
|
||||||
let display_name = object.name().unwrap_or_default().to_string();
|
let display_name = object.name().unwrap_or_default().to_string();
|
||||||
let username = object.preferred_username().unwrap_or_default().to_string();
|
let username = object.preferred_username().unwrap_or_default().to_string();
|
||||||
let domain = object.id().unwrap_or_default().replace("https://", "").split('/').next().unwrap_or_default().to_string();
|
let domain = object.id().unwrap_or_default().replace("https://", "").split('/').next().unwrap_or_default().to_string();
|
||||||
|
@ -55,7 +55,7 @@ pub fn FollowRequestButtons(activity_id: String, actor_id: String) -> impl IntoV
|
||||||
// TODO lmao what is going on with this double move / triple clone ???????????
|
// TODO lmao what is going on with this double move / triple clone ???????????
|
||||||
let _activity_id = activity_id.clone();
|
let _activity_id = activity_id.clone();
|
||||||
let _actor_id = actor_id.clone();
|
let _actor_id = actor_id.clone();
|
||||||
let from_actor = CACHE.get(&activity_id).map(|x| x.actor().id().unwrap_or_default()).unwrap_or_default();
|
let from_actor = CACHE.get(&activity_id).map(|x| x.actor().id().str().unwrap_or_default()).unwrap_or_default();
|
||||||
let _from_actor = from_actor.clone();
|
let _from_actor = from_actor.clone();
|
||||||
if actor_id == auth.user_id() {
|
if actor_id == auth.user_id() {
|
||||||
Some(view! {
|
Some(view! {
|
||||||
|
|
|
@ -61,9 +61,9 @@ impl FiltersConfig {
|
||||||
let mut reply_filter = true;
|
let mut reply_filter = true;
|
||||||
|
|
||||||
if
|
if
|
||||||
item.in_reply_to().id().is_some() ||
|
item.in_reply_to().id().is_ok() ||
|
||||||
item.object().get().map(|x|
|
item.object().get().map(|x|
|
||||||
x.in_reply_to().id().is_some()
|
x.in_reply_to().id().is_ok()
|
||||||
).unwrap_or(false)
|
).unwrap_or(false)
|
||||||
{
|
{
|
||||||
reply_filter = self.replies;
|
reply_filter = self.replies;
|
||||||
|
|
|
@ -24,11 +24,11 @@ pub fn ObjectPage(tl: Timeline) -> impl IntoView {
|
||||||
None => {
|
None => {
|
||||||
let obj = Http::fetch::<serde_json::Value>(&Uri::api(U::Object, &oid, true), auth).await.ok()?;
|
let obj = Http::fetch::<serde_json::Value>(&Uri::api(U::Object, &oid, true), auth).await.ok()?;
|
||||||
let obj = Arc::new(obj);
|
let obj = Arc::new(obj);
|
||||||
if let Some(author) = obj.attributed_to().id() {
|
if let Ok(author) = obj.attributed_to().id() {
|
||||||
if let Ok(user) = Http::fetch::<serde_json::Value>(
|
if let Ok(user) = Http::fetch::<serde_json::Value>(
|
||||||
&Uri::api(U::Actor, &author, true), auth
|
&Uri::api(U::Actor, author, true), auth
|
||||||
).await {
|
).await {
|
||||||
CACHE.put(Uri::full(U::Actor, &author), Arc::new(user));
|
CACHE.put(Uri::full(U::Actor, author), Arc::new(user));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
CACHE.put(Uri::full(U::Object, &oid), obj.clone());
|
CACHE.put(Uri::full(U::Object, &oid), obj.clone());
|
||||||
|
|
|
@ -4,7 +4,7 @@ use leptos::*;
|
||||||
use leptos_router::*;
|
use leptos_router::*;
|
||||||
use crate::{prelude::*, DEFAULT_AVATAR_URL};
|
use crate::{prelude::*, DEFAULT_AVATAR_URL};
|
||||||
|
|
||||||
use apb::{Base, Actor, ActivityMut, Object, ObjectMut};
|
use apb::{field::OptionalString, ActivityMut, Actor, Base, Object, ObjectMut};
|
||||||
|
|
||||||
fn send_follow_request(target: String) {
|
fn send_follow_request(target: String) {
|
||||||
let auth = use_context::<Auth>().expect("missing auth context");
|
let auth = use_context::<Auth>().expect("missing auth context");
|
||||||
|
@ -72,8 +72,8 @@ pub fn UserPage(tl: Timeline) -> impl IntoView {
|
||||||
},
|
},
|
||||||
Some(Some(object)) => {
|
Some(Some(object)) => {
|
||||||
let uid = object.id().unwrap_or_default().to_string();
|
let uid = object.id().unwrap_or_default().to_string();
|
||||||
let avatar_url = object.icon().get().map(|x| x.url().id().unwrap_or(DEFAULT_AVATAR_URL.into())).unwrap_or(DEFAULT_AVATAR_URL.into());
|
let avatar_url = object.icon().get().map(|x| x.url().id().str().unwrap_or(DEFAULT_AVATAR_URL.into())).unwrap_or(DEFAULT_AVATAR_URL.into());
|
||||||
let background_url = object.image().get().map(|x| x.url().id().unwrap_or(DEFAULT_AVATAR_URL.into())).unwrap_or(DEFAULT_AVATAR_URL.into());
|
let background_url = object.image().get().map(|x| x.url().id().str().unwrap_or(DEFAULT_AVATAR_URL.into())).unwrap_or(DEFAULT_AVATAR_URL.into());
|
||||||
let display_name = object.name().unwrap_or_default().to_string();
|
let display_name = object.name().unwrap_or_default().to_string();
|
||||||
let username = object.preferred_username().unwrap_or_default().to_string();
|
let username = object.preferred_username().unwrap_or_default().to_string();
|
||||||
let summary = object.summary().unwrap_or_default().to_string();
|
let summary = object.summary().unwrap_or_default().to_string();
|
||||||
|
|
Loading…
Reference in a new issue