add upstream and fork in ui
This commit is contained in:
@@ -3,6 +3,8 @@ use std::collections::HashSet;
|
||||
use gpui::SharedString;
|
||||
use nostr::prelude::*;
|
||||
|
||||
use crate::RepoAddr;
|
||||
|
||||
/// Parsed NIP-34 repository announcement (plain data, ready for the UI).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Announcement {
|
||||
@@ -28,11 +30,53 @@ pub struct Announcement {
|
||||
pub maintainers: Vec<PublicKey>,
|
||||
/// Value of a `u` tag, if any: this repository is a subordinate fork of
|
||||
/// the referenced upstream (NIP-34).
|
||||
pub upstream: Option<String>,
|
||||
pub upstream: Option<Upstream>,
|
||||
/// Hashtags labelling the repository (`t` tags).
|
||||
pub hashtags: Vec<String>,
|
||||
}
|
||||
|
||||
/// The `u` tag of a fork announcement (NIP-34): the repository this one is a
|
||||
/// subordinate fork of. The first value is the upstream coordinate
|
||||
/// (`30617:<pubkey>:<id>`) or a git URL; the second is an optional relay hint
|
||||
/// for the upstream.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Upstream {
|
||||
/// Raw first value of the `u` tag (coordinate or git URL).
|
||||
pub raw: String,
|
||||
/// The upstream `30617:<pubkey>:<id>` coordinate, when the `u` tag
|
||||
/// references a NIP-34 repository; `None` for the git-URL form.
|
||||
pub addr: Option<RepoAddr>,
|
||||
/// Relay hint for the upstream, if the `u` tag carries one.
|
||||
pub relay_hint: Option<RelayUrl>,
|
||||
}
|
||||
|
||||
impl Upstream {
|
||||
/// Parse the `u` tag values. The first is the upstream coordinate or a
|
||||
/// git URL (the coordinate form may append `|git-url`; the coordinate is
|
||||
/// the part before the first `|`), the second an optional relay hint.
|
||||
fn parse(raw: &str, relay_hint: Option<&str>) -> Self {
|
||||
let coordinate = raw.split('|').next().unwrap_or(raw);
|
||||
let addr = coordinate
|
||||
.parse::<Coordinate>()
|
||||
.ok()
|
||||
.filter(|c| c.kind == Kind::GitRepoAnnouncement);
|
||||
Self {
|
||||
raw: raw.to_owned(),
|
||||
addr,
|
||||
relay_hint: relay_hint.and_then(|hint| RelayUrl::parse(hint).ok()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Text for display: the upstream coordinate when it is a NIP-34
|
||||
/// repository, otherwise the raw `u` value (git-URL form).
|
||||
pub fn display(&self) -> SharedString {
|
||||
match &self.addr {
|
||||
Some(addr) => SharedString::from(addr.to_string()),
|
||||
None => SharedString::from(self.raw.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Subject of a NIP-34 issue or pull request event: the `subject` tag,
|
||||
/// falling back to the first non-empty line of the content.
|
||||
pub fn activity_subject(event: &Event) -> SharedString {
|
||||
@@ -193,7 +237,7 @@ impl Announcement {
|
||||
let mut relays: Vec<RelayUrl> = Vec::new();
|
||||
let mut euc: Option<String> = None;
|
||||
let mut maintainers: Vec<PublicKey> = Vec::new();
|
||||
let mut upstream: Option<String> = None;
|
||||
let mut upstream: Option<Upstream> = None;
|
||||
|
||||
for tag in event.tags.iter() {
|
||||
match Nip34Tag::parse(tag.as_slice()) {
|
||||
@@ -208,9 +252,13 @@ impl Announcement {
|
||||
}
|
||||
|
||||
// The `u` tag is not modelled by the SDK's `Nip34Tag`; parse it
|
||||
// manually (first value wins).
|
||||
// manually (first wins).
|
||||
if upstream.is_none() && tag.kind() == "u" {
|
||||
upstream = tag.content().map(str::to_owned);
|
||||
let values = tag.as_slice();
|
||||
let raw = values.get(1).map(String::as_str).unwrap_or_default();
|
||||
if !raw.is_empty() {
|
||||
upstream = Some(Upstream::parse(raw, values.get(2).map(String::as_str)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -391,14 +439,55 @@ mod tests {
|
||||
fn parses_upstream_tag() {
|
||||
let event = announcement_event(&[
|
||||
&["d", "my-fork"],
|
||||
&["u", "30617:abc:upstream|https://example.com/upstream.git"],
|
||||
&[
|
||||
"u",
|
||||
"30617:68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272:upstream|https://example.com/upstream.git",
|
||||
"wss://relay.example.com",
|
||||
],
|
||||
]);
|
||||
|
||||
let announcement = Announcement::from_event(&event).expect("parses");
|
||||
let upstream = announcement.upstream.expect("parses the u tag");
|
||||
|
||||
// The coordinate part resolves to a repository address; the raw
|
||||
// value keeps the `|git-url` suffix.
|
||||
assert_eq!(
|
||||
announcement.upstream.as_deref(),
|
||||
Some("30617:abc:upstream|https://example.com/upstream.git")
|
||||
upstream.addr,
|
||||
Some(crate::repo_addr(
|
||||
PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"),
|
||||
"upstream"
|
||||
))
|
||||
);
|
||||
assert_eq!(
|
||||
upstream.raw,
|
||||
"30617:68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272:upstream|https://example.com/upstream.git"
|
||||
);
|
||||
assert_eq!(
|
||||
upstream.relay_hint,
|
||||
Some(RelayUrl::parse("wss://relay.example.com").expect("valid relay"))
|
||||
);
|
||||
assert_eq!(
|
||||
upstream.display().to_string(),
|
||||
"30617:68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272:upstream"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_git_url_upstream() {
|
||||
// The `u` tag may reference a non-nostr upstream by git URL only;
|
||||
// there is no repository address to navigate to.
|
||||
let event = announcement_event(&[
|
||||
&["d", "my-fork"],
|
||||
&["u", "https://example.com/upstream.git"],
|
||||
]);
|
||||
|
||||
let announcement = Announcement::from_event(&event).expect("parses");
|
||||
let upstream = announcement.upstream.expect("parses the u tag");
|
||||
|
||||
assert_eq!(upstream.addr, None);
|
||||
assert_eq!(
|
||||
upstream.display().to_string(),
|
||||
"https://example.com/upstream.git"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,5 +3,6 @@ mod repo_list;
|
||||
pub(crate) mod sidebar;
|
||||
|
||||
pub use repo_detail::RepoDetailView;
|
||||
pub(crate) use repo_detail::open_repo_panel;
|
||||
pub use repo_list::RepoListView;
|
||||
pub use sidebar::SidebarPanel;
|
||||
|
||||
@@ -60,6 +60,7 @@ fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement {
|
||||
cx,
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(euc) = &announcement.euc {
|
||||
rows.push(row(
|
||||
"Earliest Commit",
|
||||
@@ -67,13 +68,11 @@ fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement {
|
||||
cx,
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(upstream) = &announcement.upstream {
|
||||
rows.push(row(
|
||||
"Upstream",
|
||||
text(SharedString::from(upstream.clone())),
|
||||
cx,
|
||||
));
|
||||
rows.push(row("Upstream", text(upstream.display()), cx));
|
||||
}
|
||||
|
||||
if !announcement.hashtags.is_empty() {
|
||||
rows.push(row(
|
||||
"Hashtags",
|
||||
@@ -81,6 +80,7 @@ fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement {
|
||||
cx,
|
||||
));
|
||||
}
|
||||
|
||||
if !announcement.clone.is_empty() {
|
||||
rows.push(row(
|
||||
"Clone URLs",
|
||||
@@ -92,6 +92,7 @@ fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement {
|
||||
cx,
|
||||
));
|
||||
}
|
||||
|
||||
if !announcement.relays.is_empty() {
|
||||
rows.push(row(
|
||||
"Grasp Relays",
|
||||
@@ -103,6 +104,7 @@ fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement {
|
||||
cx,
|
||||
));
|
||||
}
|
||||
|
||||
if !announcement.maintainers.is_empty() {
|
||||
rows.push(row(
|
||||
"Maintainers",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::rc::Rc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Error;
|
||||
use assets::CustomIconName;
|
||||
@@ -26,9 +27,9 @@ use gpui_component::{
|
||||
VirtualListScrollHandle, h_flex, v_flex,
|
||||
};
|
||||
use nostr::prelude::{EventId, RelayUrl, ToBech32};
|
||||
use signed_core::Announcement;
|
||||
use signed_core::{Announcement, RepoAddr, filters};
|
||||
use signed_git::{CommitList, FileCommit};
|
||||
use signed_state::{Backend, GitStore, LocalReposStore, ProfileStore, RepoStore};
|
||||
use signed_state::{Backend, GitStore, LocalReposStore, ProfileStore, RepoListStore, RepoStore};
|
||||
use signed_ui::image_cache::{MAX_IMAGES, image_cache};
|
||||
use signed_ui::{DropdownButton, PixelAvatar, UserAvatar, copy_row};
|
||||
|
||||
@@ -188,6 +189,9 @@ pub struct RepoDetailView {
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
/// Subscriptions keeping the selectors' confirm events alive.
|
||||
_subscriptions: Vec<Subscription>,
|
||||
/// Upstream repository (from this fork's `u` tag) the user asked to
|
||||
/// open, while its announcement is still being fetched.
|
||||
pending_upstream: Option<RepoAddr>,
|
||||
}
|
||||
|
||||
impl RepoDetailView {
|
||||
@@ -315,6 +319,7 @@ impl RepoDetailView {
|
||||
focus_handle: cx.focus_handle(),
|
||||
tasks: Vec::new(),
|
||||
_subscriptions: subscriptions,
|
||||
pending_upstream: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -959,6 +964,73 @@ impl RepoDetailView {
|
||||
});
|
||||
}
|
||||
|
||||
/// Open the upstream repository (the `u` tag of this fork's announcement).
|
||||
/// When the upstream announcement is not in the local database yet,
|
||||
/// subscribe for it and open the panel as soon as it lands.
|
||||
fn open_upstream(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self.pending_upstream.is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(announcement) = self.announcement(cx).cloned() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(addr) = announcement.upstream.and_then(|upstream| upstream.addr) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Some(found) = RepoListStore::global(cx)
|
||||
.read(cx)
|
||||
.announcements
|
||||
.iter()
|
||||
.find(|a| a.addr() == addr)
|
||||
.cloned()
|
||||
{
|
||||
open_repo_panel(&self.dock_area, &found, window, &mut *cx);
|
||||
return;
|
||||
}
|
||||
|
||||
Backend::global(cx).update(cx, |backend, cx| {
|
||||
backend.subscribe_bootstrap(vec![filters::announcement(&addr)], cx);
|
||||
});
|
||||
self.pending_upstream = Some(addr);
|
||||
|
||||
let task = cx.spawn_in(window, async move |this, cx| {
|
||||
for _ in 0..60 {
|
||||
cx.background_executor()
|
||||
.timer(Duration::from_millis(250))
|
||||
.await;
|
||||
let opened = this.update_in(cx, |this, window, cx| {
|
||||
let Some(addr) = this.pending_upstream.clone() else {
|
||||
return true;
|
||||
};
|
||||
let found = RepoListStore::global(cx)
|
||||
.read(cx)
|
||||
.announcements
|
||||
.iter()
|
||||
.find(|a| a.addr() == addr)
|
||||
.cloned();
|
||||
match found {
|
||||
Some(found) => {
|
||||
this.pending_upstream = None;
|
||||
open_repo_panel(&this.dock_area, &found, window, &mut *cx);
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
})?;
|
||||
if opened {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
this.update(cx, |this, _cx| this.pending_upstream = None)?;
|
||||
Ok(())
|
||||
});
|
||||
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
/// Check out `name` (a branch or tag picked in the header) and refresh
|
||||
/// the explorer once the switch completes.
|
||||
fn switch_ref(
|
||||
@@ -1332,6 +1404,7 @@ impl RepoDetailView {
|
||||
.text_ellipsis()
|
||||
.child(description),
|
||||
)
|
||||
.when_some(fork_row(&announcement, cx), |this, row| this.child(row))
|
||||
.child(
|
||||
h_flex()
|
||||
.mt_2()
|
||||
@@ -2015,3 +2088,76 @@ fn nostr_clone_url(announcement: &Announcement, nip05: Option<&str>) -> SharedSt
|
||||
|
||||
SharedString::from(url)
|
||||
}
|
||||
|
||||
/// The "Forked from …" row of the detail header: a clickable link to the
|
||||
/// upstream repository when the `u` tag references a NIP-34 repo,
|
||||
/// plain text when it only carries a git URL.
|
||||
fn fork_row(announcement: &Announcement, cx: &mut Context<RepoDetailView>) -> Option<AnyElement> {
|
||||
let upstream = announcement.upstream.as_ref()?;
|
||||
|
||||
let (label, clickable) = match &upstream.addr {
|
||||
Some(addr) => {
|
||||
// Prefer the upstream's display name when its announcement
|
||||
// is already known locally fall back to its repository id.
|
||||
let name = RepoListStore::global(cx)
|
||||
.read(cx)
|
||||
.announcements
|
||||
.iter()
|
||||
.find(|a| a.addr() == *addr)
|
||||
.map(|a| {
|
||||
a.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| SharedString::from(a.id.clone()))
|
||||
})
|
||||
.unwrap_or_else(|| SharedString::from(addr.identifier.clone()));
|
||||
(SharedString::from(format!("Forked from {name}")), true)
|
||||
}
|
||||
None => (upstream.display(), false),
|
||||
};
|
||||
|
||||
let row = h_flex()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.min_w_0()
|
||||
.text_sm()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(Icon::new(CustomIconName::GitBranch).small())
|
||||
.child(div().whitespace_nowrap().text_ellipsis().child(label));
|
||||
|
||||
Some(if clickable {
|
||||
row.id("fork-upstream")
|
||||
.cursor_pointer()
|
||||
.hover(|this| this.text_color(cx.theme().foreground))
|
||||
.on_click(cx.listener(|this, _ev, window, cx| this.open_upstream(window, cx)))
|
||||
.into_any_element()
|
||||
} else {
|
||||
row.into_any_element()
|
||||
})
|
||||
}
|
||||
|
||||
/// Open `announcement` as a repository panel in the dock's center, returning
|
||||
/// the new detail view. Shared by the explore list, the sidebar and fork
|
||||
/// links so every entry point opens repositories identically.
|
||||
pub(crate) fn open_repo_panel(
|
||||
dock_area: &WeakEntity<DockArea>,
|
||||
announcement: &Announcement,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Entity<RepoDetailView> {
|
||||
let detail =
|
||||
cx.new(|cx| RepoDetailView::new(dock_area.clone(), announcement.clone(), window, cx));
|
||||
|
||||
if let Some(dock_area) = dock_area.upgrade() {
|
||||
dock_area.update(cx, |dock_area, cx| {
|
||||
dock_area.add_panel_view(
|
||||
panel_handle(detail.clone()),
|
||||
DockPlacement::Center,
|
||||
None,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
detail
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use assets::CustomIconName;
|
||||
use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle};
|
||||
use dock::{BasePanel, DockArea, Panel, PanelEvent};
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render,
|
||||
@@ -19,7 +19,7 @@ use signed_ui::image_cache::{MAX_IMAGES, image_cache};
|
||||
use signed_ui::{SegmentButton, UserAvatar};
|
||||
use utils::relative_time;
|
||||
|
||||
use super::RepoDetailView;
|
||||
use super::open_repo_panel;
|
||||
|
||||
const COLUMNS: usize = 2;
|
||||
const CARD_HEIGHT: f32 = 40. + 64. + 48. + 2. + 6.;
|
||||
@@ -173,21 +173,7 @@ impl RepoListView {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let dock_area = self.dock_area.clone();
|
||||
let detail =
|
||||
cx.new(|cx| RepoDetailView::new(dock_area.clone(), announcement.clone(), window, cx));
|
||||
|
||||
if let Some(dock_area) = dock_area.upgrade() {
|
||||
dock_area.update(cx, |dock_area, cx| {
|
||||
dock_area.add_panel_view(
|
||||
panel_handle(detail),
|
||||
DockPlacement::Center,
|
||||
None,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
}
|
||||
open_repo_panel(&self.dock_area, announcement, window, &mut *cx);
|
||||
}
|
||||
|
||||
fn render_card(
|
||||
@@ -215,6 +201,26 @@ impl RepoListView {
|
||||
.map(|label| SharedString::from(format!("Updated {label}")))
|
||||
.unwrap_or_default();
|
||||
|
||||
// Fork badge: the upstream's display name when its announcement is
|
||||
// known locally, otherwise its repository id from the `u` tag.
|
||||
let fork_label: Option<SharedString> =
|
||||
announcement.upstream.as_ref().and_then(|upstream| {
|
||||
let addr = upstream.addr.as_ref()?;
|
||||
let name = self
|
||||
.store
|
||||
.read(cx)
|
||||
.announcements
|
||||
.iter()
|
||||
.find(|a| a.addr() == *addr)
|
||||
.map(|a| {
|
||||
a.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| SharedString::from(a.id.clone()))
|
||||
})
|
||||
.unwrap_or_else(|| SharedString::from(addr.identifier.clone()));
|
||||
Some(SharedString::from(format!("forked from {name}")))
|
||||
});
|
||||
|
||||
v_flex()
|
||||
.id(ix)
|
||||
.flex_1()
|
||||
@@ -228,11 +234,29 @@ impl RepoListView {
|
||||
.child(
|
||||
h_flex()
|
||||
.h_10()
|
||||
.text_sm()
|
||||
.font_semibold()
|
||||
.whitespace_nowrap()
|
||||
.text_ellipsis()
|
||||
.child(name),
|
||||
.gap_1p5()
|
||||
.items_center()
|
||||
.child(
|
||||
div()
|
||||
.min_w_0()
|
||||
.text_sm()
|
||||
.font_semibold()
|
||||
.whitespace_nowrap()
|
||||
.text_ellipsis()
|
||||
.child(name),
|
||||
)
|
||||
.when_some(fork_label, |this, label| {
|
||||
this.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.items_center()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.whitespace_nowrap()
|
||||
.child(Icon::new(CustomIconName::GitBranch).small())
|
||||
.child(label),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use dock::{DockArea, DockPlacement, panel_handle};
|
||||
use dock::DockArea;
|
||||
use gpui::prelude::*;
|
||||
use gpui::{App, Entity, PathPromptOptions, SharedString, WeakEntity, Window, div, px};
|
||||
use gpui_base::input::TextareaState;
|
||||
@@ -11,7 +11,7 @@ use settings::SettingsStore;
|
||||
use signed_core::Announcement;
|
||||
use signed_state::Backend;
|
||||
|
||||
use super::super::RepoDetailView;
|
||||
use super::super::open_repo_panel;
|
||||
use super::grasp_servers::{GraspServersState, grasp_servers_field, load_user_grasp_servers};
|
||||
|
||||
/// Shared state for the Create Repository dialog, so async results can be rendered.
|
||||
@@ -262,13 +262,5 @@ fn open_repo(
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let Some(dock_area) = dock_area.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let panel = cx.new(|cx| RepoDetailView::new(dock_area.downgrade(), announcement, window, cx));
|
||||
|
||||
dock_area.update(cx, |dock_area, cx| {
|
||||
dock_area.add_panel_view(panel_handle(panel), DockPlacement::Center, None, window, cx);
|
||||
});
|
||||
open_repo_panel(&dock_area, &announcement, window, cx);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ use signed_state::{Backend, BackendEvent, LocalReposStore, Profile, ProfileStore
|
||||
use signed_ui::image_cache::{MAX_IMAGES, image_cache};
|
||||
use signed_ui::{NavItem, PixelAvatar, UserAvatar, title_bar_drag_handlers};
|
||||
|
||||
use super::{RepoDetailView, RepoListView};
|
||||
use super::{RepoDetailView, RepoListView, open_repo_panel};
|
||||
|
||||
mod create_repo_dialog;
|
||||
pub(crate) mod grasp_servers;
|
||||
@@ -158,19 +158,7 @@ impl SidebarPanel {
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let detail = cx.new(|cx| {
|
||||
RepoDetailView::new(self.dock_area.clone(), announcement.clone(), window, cx)
|
||||
});
|
||||
|
||||
let _ = self.dock_area.update(cx, |dock_area, cx| {
|
||||
dock_area.add_panel_view(
|
||||
panel_handle(detail),
|
||||
DockPlacement::Center,
|
||||
None,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
});
|
||||
open_repo_panel(&self.dock_area, announcement, window, &mut *cx);
|
||||
}
|
||||
|
||||
/// Open a local repository's detail view in the dock's center; the
|
||||
|
||||
Reference in New Issue
Block a user