mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-09 09:29:44 -06:00
Merge pull request #5134 from dotCooCoo/fix/ai-os23-3797
Fix #3797: documents open in Preview, and Outline is a setting of each document
This commit is contained in:
@@ -72,6 +72,11 @@ impl VectorcraftApp {
|
||||
self.session.active_index().and_then(|i| self.views.get(i))
|
||||
}
|
||||
|
||||
/// Whether the active document is in Outline view (View › Outline); false without one.
|
||||
pub fn outline_view(&self) -> bool {
|
||||
self.view().is_some_and(|v| v.outline)
|
||||
}
|
||||
|
||||
/// The zero point shown by rulers and position fields: the document's with global rulers, the
|
||||
/// active artboard's with artboard rulers. Command coordinates stay absolute.
|
||||
pub(crate) fn ruler_origin(&self) -> vectorcraft_geom::Point {
|
||||
@@ -88,7 +93,7 @@ impl VectorcraftApp {
|
||||
pub fn view_info(&self) -> ViewInfo {
|
||||
ViewInfo {
|
||||
zoom: self.view().map(|v| v.zoom).unwrap_or(1.0),
|
||||
outline: self.ui.view.outline,
|
||||
outline: self.outline_view(),
|
||||
smart_guides: self.ui.view.smart_guides,
|
||||
guides: self.ui.view.guides,
|
||||
snap_to_grid: self.ui.view.snap_to_grid,
|
||||
|
||||
@@ -492,7 +492,7 @@ fn cache_key(
|
||||
cy: v.center.y,
|
||||
w,
|
||||
h,
|
||||
outline: app.ui.view.outline,
|
||||
outline: app.outline_view(),
|
||||
trim: app.ui.view.trim_view,
|
||||
templates: app.ui.view.templates,
|
||||
guides: app.ui.view.guides,
|
||||
@@ -560,7 +560,7 @@ fn frame_options(
|
||||
mask_view: Option<vectorcraft_doc::NodeId>,
|
||||
smooth_images: bool,
|
||||
) -> vectorcraft_render::RenderOptions {
|
||||
let opts = vectorcraft_render::RenderOptions { outline: app.ui.view.outline, background: None, artboards: false, ..Default::default() };
|
||||
let opts = vectorcraft_render::RenderOptions { outline: app.outline_view(), background: None, artboards: false, ..Default::default() };
|
||||
vectorcraft_render::RenderOptions {
|
||||
proof: vectorcraft_render::proof::active_proof(),
|
||||
overprint_preview: vectorcraft_render::proof::overprint_preview_on(),
|
||||
@@ -669,7 +669,7 @@ fn paint_paper(app: &VectorcraftApp, ui: &Ui, painter: &egui::Painter, xf: &Xf,
|
||||
// View › Hide Artboards: no edges and no pasteboard, the whole canvas paper (#4038).
|
||||
let setup = &doc.setup;
|
||||
painter.rect_filled(rect, 0.0, if setup.simulate_paper { crate::panels::c32(&setup.paper()) } else { Color32::WHITE });
|
||||
} else if !app.ui.view.outline {
|
||||
} else if !app.outline_view() {
|
||||
let setup = &doc.setup;
|
||||
let grid = (st.transparency_grid && setup.background == vectorcraft_doc::Background::Transparent).then(|| checker_texture(ui.ctx(), setup));
|
||||
let paper = if setup.simulate_paper { crate::panels::c32(&setup.paper()) } else { Color32::WHITE };
|
||||
@@ -687,7 +687,7 @@ fn paint_paper(app: &VectorcraftApp, ui: &Ui, painter: &egui::Painter, xf: &Xf,
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if app.ui.view.outline {
|
||||
} else if app.outline_view() {
|
||||
for ab in &doc.artboards {
|
||||
painter.add(Shape::convex_polygon(xf.quad(ab.rect), Color32::WHITE, Stroke::NONE));
|
||||
}
|
||||
|
||||
@@ -184,7 +184,7 @@ pub(super) fn hit_with<T>(
|
||||
let prefs = &app.session.prefs;
|
||||
let opt = vectorcraft_doc::hit::HitOptions {
|
||||
tol: prefs.selection_tolerance / zoom,
|
||||
outline: app.ui.view.outline,
|
||||
outline: app.outline_view(),
|
||||
path_only: prefs.object_selection_by_path_only,
|
||||
type_path_only: prefs.type_selection_by_path_only,
|
||||
scope: app.session.active().and_then(|st| st.isolation),
|
||||
|
||||
@@ -2588,7 +2588,7 @@ fn pan_fills_the_area_the_last_frame_leaves() {
|
||||
assert!(app.canvas.fill_worker.as_ref().unwrap().pending.is_none(), "the late fill was taken");
|
||||
// A pan with Outline turned on: the frame shown's options are not the ones asked for, and
|
||||
// no fill is asked for.
|
||||
app.ui.view.outline = true;
|
||||
app.view_mut().unwrap().outline = true;
|
||||
app.view_mut().unwrap().center = Point::new(400.0, 150.0);
|
||||
frame(&mut app);
|
||||
assert!(jobs.try_iter().last().is_some_and(|j| j.key.outline), "the outline frame is on the worker");
|
||||
@@ -3521,7 +3521,7 @@ fn scrolls_and_pans_move_the_view_by_whole_device_pixels() {
|
||||
/// another document, or when the device pixels a point changed.
|
||||
#[test]
|
||||
fn a_scrolls_leftover_pixel_part_carries_only_to_the_same_view() {
|
||||
let view = View { zoom: 1.0, center: Point::new(100.0, 100.0), fitted: true, rotation: 0.0, artboard: 0 };
|
||||
let view = View { zoom: 1.0, center: Point::new(100.0, 100.0), fitted: true, rotation: 0.0, artboard: 0, outline: false };
|
||||
let mut v = view;
|
||||
let rest = scroll_by(&mut v, 1, 1.0, vec2(0.4, -0.3), ScrollRest::default());
|
||||
assert_eq!(v.center, view.center, "less than half a pixel does not move the view");
|
||||
@@ -3765,6 +3765,64 @@ fn show_live_paint_gaps_draws_the_selected_groups_gaps_in_their_preview_color()
|
||||
assert!(!serde_json::from_value::<crate::UiState>(saved).unwrap().view.live_paint_gaps);
|
||||
}
|
||||
|
||||
/// #3797: View › Outline is a setting of each document's view. A document opened or made after
|
||||
/// another was put in Outline opens in Preview, where a click inside its fill picks it, and each
|
||||
/// document keeps its own setting.
|
||||
#[test]
|
||||
fn outline_is_per_document_and_new_documents_start_in_preview() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
app.session.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 100, "height": 100})).unwrap();
|
||||
app.sync_views();
|
||||
let inside = Point::new(70.0, 70.0);
|
||||
let picks_fill = |app: &VectorcraftApp| super::outline::hit_at(app, inside, 1.0).is_some();
|
||||
assert!(picks_fill(&app), "Preview picks the fill");
|
||||
app.run("view.outline", json!({})).unwrap();
|
||||
assert!(app.outline_view() && app.view_info().outline);
|
||||
assert!(!picks_fill(&app), "Outline picks only the path");
|
||||
// An SVG opened now opens in Preview, its fill drawn and picked.
|
||||
let svg =
|
||||
br##"<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200"><rect x="20" y="20" width="100" height="100" fill="#ff0000"/></svg>"##;
|
||||
crate::io::open_bytes(&mut app, "b.svg", svg, None).unwrap();
|
||||
assert_eq!(app.session.active_index(), Some(1));
|
||||
assert!(!app.outline_view() && !app.view_info().outline, "the opened document is in Preview");
|
||||
assert!(picks_fill(&app));
|
||||
assert_eq!(crate::menus::dynamic_label(&app, "view.outline", "Outline"), "Outline");
|
||||
assert_eq!(crate::menus::checked(&app, "view.outline", &json!({})), Some(false));
|
||||
// A new document opens in Preview too.
|
||||
app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
assert!(!app.outline_view());
|
||||
// The first document is still in Outline.
|
||||
app.session.execute("document.activate", &json!({"index": 0})).unwrap();
|
||||
assert!(app.outline_view() && !picks_fill(&app));
|
||||
assert_eq!(crate::menus::dynamic_label(&app, "view.outline", "Outline"), "Preview");
|
||||
assert_eq!(crate::menus::checked(&app, "view.outline", &json!({})), Some(true));
|
||||
// Without a document there is nothing to put in Outline.
|
||||
while !app.session.documents().is_empty() {
|
||||
app.session.execute("file.close", &json!({})).unwrap();
|
||||
}
|
||||
app.sync_views();
|
||||
assert!(!app.outline_view() && !crate::menus::enabled(&app, "view.outline"));
|
||||
assert!(app.run("view.outline", json!({})).is_err());
|
||||
}
|
||||
|
||||
/// #3797: a preferences file saved while View › Outline was an interface setting still loads, with
|
||||
/// its other view settings, and its Outline value does not put a document in Outline.
|
||||
#[test]
|
||||
fn old_outline_preference_loads_but_does_not_restore_outline() {
|
||||
let mut saved = serde_json::to_value(crate::UiState::default()).unwrap();
|
||||
assert!(saved["view"].get("outline").is_none(), "Outline is not saved with the interface");
|
||||
saved["view"]["outline"] = json!(true);
|
||||
saved["view"]["rulers"] = json!(true);
|
||||
let ui = serde_json::from_value::<crate::UiState>(saved).unwrap().sanitized();
|
||||
assert!(ui.view.rulers, "the other view settings load");
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.ui = ui;
|
||||
app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
app.sync_views();
|
||||
assert!(!app.outline_view(), "a new document is in Preview");
|
||||
}
|
||||
|
||||
/// #511: a star shows a widget in each of its ten corners with Direct Selection (none with
|
||||
/// the Selection tool: it isn't a live shape); View → Hide Corner Widget hides them.
|
||||
#[test]
|
||||
|
||||
@@ -12,6 +12,13 @@ pub(super) fn tab_title(d: &vectorcraft_engine::DocState, zoom: f64, outline: bo
|
||||
format!("{}{} @ {} ({mode})", d.title(), if d.is_dirty() { "*" } else { "" }, zoom_label(zoom, display_print_size).replace('%', " %"))
|
||||
}
|
||||
|
||||
/// The title of open document `i`'s tab, with its own view's zoom and Outline or Preview.
|
||||
pub(super) fn doc_tab_title(app: &VectorcraftApp, i: usize, d: &vectorcraft_engine::DocState) -> String {
|
||||
let view = app.views.get(i);
|
||||
let zoom = view.map_or(1.0, |v| v.zoom);
|
||||
tab_title(d, zoom, view.is_some_and(|v| v.outline), app.session.prefs.display_print_size)
|
||||
}
|
||||
|
||||
/// Whether a document tab's × comes before its title (macOS) rather than after it (Windows,
|
||||
/// Linux and the web).
|
||||
pub(super) const CLOSE_BEFORE_TITLE: bool = cfg!(target_os = "macos");
|
||||
@@ -46,8 +53,7 @@ pub fn doc_tabs(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, d)| {
|
||||
let zoom = app.views.get(i).map(|v| v.zoom).unwrap_or(1.0);
|
||||
let title = tab_title(d, zoom, app.ui.view.outline, app.session.prefs.display_print_size);
|
||||
let title = doc_tab_title(app, i, d);
|
||||
let color = if Some(i) == active { t.text_strong } else { t.text_dim };
|
||||
(title.clone(), ui.painter().layout_no_wrap(title, theme::semibold(title_size), color))
|
||||
})
|
||||
|
||||
@@ -866,6 +866,18 @@ fn the_tab_title_names_the_opacity_mask_while_it_is_edited() {
|
||||
assert!(title(&s).ends_with("(RGB/Preview)"));
|
||||
}
|
||||
|
||||
/// #3797: each document's tab names its own Outline or Preview.
|
||||
#[test]
|
||||
fn only_the_outlined_documents_tab_reads_outline() {
|
||||
let mut app = crate::VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
app.run("view.outline", json!({})).unwrap();
|
||||
let titles: Vec<String> = app.session.documents().iter().enumerate().map(|(i, d)| super::tabs::doc_tab_title(&app, i, d)).collect();
|
||||
assert!(titles[0].ends_with("(RGB/Preview)"), "{titles:?}");
|
||||
assert!(titles[1].ends_with("(RGB/Outline)"), "{titles:?}");
|
||||
}
|
||||
|
||||
/// One headless frame of the Control bar; returns the isolation button's rectangle, if it shows.
|
||||
fn isolation_button(app: &mut crate::VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>) -> Option<egui::Rect> {
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 300.0));
|
||||
|
||||
@@ -593,6 +593,10 @@ fn synthetic_click(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlR
|
||||
fn set_ui(app: &mut VectorcraftApp, req: &ControlRequest) -> Outcome {
|
||||
let p = &req.params;
|
||||
let s = |k: &str| p.get(k).and_then(Value::as_str);
|
||||
// Outline is a setting of the active document's view: without one, nothing is set.
|
||||
if p.get("outline").is_some_and(Value::is_boolean) && app.session.active().is_none() {
|
||||
return err("no document");
|
||||
}
|
||||
let mut r = Ok(Value::Null);
|
||||
if let Some(b) = s("brightness") {
|
||||
r = app.run("window.brightness", json!({"brightness": b}));
|
||||
@@ -613,7 +617,7 @@ fn set_ui(app: &mut VectorcraftApp, req: &ControlRequest) -> Outcome {
|
||||
if let Some(want) = p.get(k).and_then(Value::as_bool) {
|
||||
let cur = match k {
|
||||
"rulers" => app.ui.view.rulers,
|
||||
"outline" => app.ui.view.outline,
|
||||
"outline" => app.outline_view(),
|
||||
"grid" => app.ui.view.grid,
|
||||
"smartGuides" => app.ui.view.smart_guides,
|
||||
_ => app.ui.view.bounding_box,
|
||||
|
||||
@@ -22,6 +22,24 @@ fn ui_set_turns_the_image_trace_preview_off_and_on() {
|
||||
}
|
||||
}
|
||||
|
||||
/// #3797: `ui.set` `outline` sets the active document's view; without a document it is an error
|
||||
/// and sets nothing else either.
|
||||
#[test]
|
||||
fn ui_set_outline_sets_the_active_documents_view() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Services::default());
|
||||
for params in [json!({"outline": false}), json!({"outline": true, "grid": true})] {
|
||||
let r = input_request(&mut app, "ui.set", params);
|
||||
assert_eq!(r["ok"], false, "{r}");
|
||||
}
|
||||
assert!(!app.ui.view.grid, "nothing is set");
|
||||
app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
let r = input_request(&mut app, "ui.set", json!({"outline": true}));
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert!(app.outline_view());
|
||||
assert_eq!(app.views.iter().map(|v| v.outline).collect::<Vec<_>>(), [false, true], "the other document stays in Preview");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_image_trace_preview_is_a_command() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Services::default());
|
||||
|
||||
@@ -17,7 +17,7 @@ pub fn dynamic_label(app: &VectorcraftApp, id: &str, label: &str) -> String {
|
||||
fn view_toggle_label(app: &VectorcraftApp, id: &str) -> Option<String> {
|
||||
let v = &app.ui.view;
|
||||
let text: String = match id {
|
||||
"view.outline" => if v.outline { "Preview" } else { "Outline" }.into(),
|
||||
"view.outline" => if app.outline_view() { "Preview" } else { "Outline" }.into(),
|
||||
"window.workspace.reset" => format!("Reset {}", app.ui.workspace),
|
||||
"view.edges" => if v.edges { "Hide Edges" } else { "Show Edges" }.into(),
|
||||
"view.template" => if v.templates { "Hide Template" } else { "Show Template" }.into(),
|
||||
|
||||
@@ -184,7 +184,11 @@ fn run_ui_part2(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<
|
||||
"edit.colorSettings" | "edit.assignProfile" if p.as_object().is_none_or(|o| o.is_empty()) => {
|
||||
crate::panel_docking::reveal_panel(app, "separations").and_then(|_| app.session.execute(id, p).map_err(|e| e.to_string()))
|
||||
}
|
||||
"view.outline" => flag(&mut app.ui.view.outline),
|
||||
// Outline or Preview is a setting of the active document's view.
|
||||
"view.outline" => match app.view_mut() {
|
||||
Some(v) => flag(&mut v.outline),
|
||||
None => Err("no document".into()),
|
||||
},
|
||||
_ => return None,
|
||||
};
|
||||
Some(r)
|
||||
|
||||
@@ -41,7 +41,7 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option<bool> {
|
||||
selected_text(false).is_some_and(|t| t.vertical == (id == "type.orientation.vertical"))
|
||||
}
|
||||
"type.opticalMarginAlignment" => crate::panels::character::shared_text_flag(app, |t| t.optical_margin) == Some(true),
|
||||
"view.outline" => v.outline,
|
||||
"view.outline" => app.outline_view(),
|
||||
"view.pixelPreview" => v.pixel_preview,
|
||||
"view.trimView" => v.trim_view,
|
||||
"view.snapToPixel" => v.snap_to_pixel,
|
||||
@@ -205,6 +205,7 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool {
|
||||
| "view.fitArtboard"
|
||||
| "view.fitAll"
|
||||
| "view.actualSize"
|
||||
| "view.outline"
|
||||
| "view.rotate" => app.session.active().is_some(),
|
||||
"view.rotateToSelection" | crate::addons::arrange::ZOOM => app.session.active().is_some_and(|d| !d.selection.objects.is_empty()),
|
||||
id if id.starts_with("file.openRecent") => recent_slot(app, id).is_some(),
|
||||
|
||||
@@ -21,11 +21,15 @@ pub struct View {
|
||||
/// Size show it.
|
||||
#[serde(default)]
|
||||
pub artboard: usize,
|
||||
/// View › Outline: the document's art is drawn as outlines. Each document has its own, and a
|
||||
/// document opens in Preview.
|
||||
#[serde(default)]
|
||||
pub outline: bool,
|
||||
}
|
||||
|
||||
impl Default for View {
|
||||
fn default() -> Self {
|
||||
Self { zoom: 1.0, center: Point::new(306.0, 396.0), fitted: false, rotation: 0.0, artboard: 0 }
|
||||
Self { zoom: 1.0, center: Point::new(306.0, 396.0), fitted: false, rotation: 0.0, artboard: 0, outline: false }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +49,7 @@ impl View {
|
||||
fitted: true,
|
||||
rotation: if v.rotation.is_finite() { v.rotation } else { 0.0 },
|
||||
artboard: 0,
|
||||
outline: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -232,7 +237,6 @@ pub struct ViewFlags {
|
||||
pub guides: bool,
|
||||
pub smart_guides: bool,
|
||||
pub bounding_box: bool,
|
||||
pub outline: bool,
|
||||
pub pixel_preview: bool,
|
||||
pub snap_to_grid: bool,
|
||||
pub snap_to_point: bool,
|
||||
@@ -271,7 +275,6 @@ impl Default for ViewFlags {
|
||||
guides: true,
|
||||
smart_guides: true,
|
||||
bounding_box: true,
|
||||
outline: false,
|
||||
pixel_preview: false,
|
||||
snap_to_grid: false,
|
||||
snap_to_point: true,
|
||||
|
||||
@@ -101,7 +101,7 @@ the two can still win that race.
|
||||
| `ui.move` | `{x, y}` | screen-point pointer move |
|
||||
| `ui.click` / `ui.drag` | `{x, y, button?, count?, shift?, alt?, ctrl?, cmd?}` / `{x, y, toX, toY, steps?, count?, button?, shift?, alt?, ctrl?, cmd?}` | egui pointer input; see synthetic input budgets above |
|
||||
| `ui.wheel` | `{x, y, dy?, dx?, unit?: "line"\|"point", shift?, alt?, cmd?}` | a mouse wheel turn over screen point (x, y): `dy` notches up (+) or down, `dx` sideways. Over the canvas the wheel scrolls and Cmd- or Alt-wheel (Option on the Mac) zooms about the pointer; with the `zoomWithMouseWheel` preference the wheel and Alt-wheel zoom about the pointer, Shift-wheel scrolls up and down and Cmd-wheel (Ctrl on Windows and Linux) sideways. Over a focused numeric field (click it first) each notch steps its value as Up/Down do (Shift: ten, Cmd/Ctrl: a tenth) and the panel stays put; over anything else in a panel the wheel scrolls it |
|
||||
| `ui.set` | `{brightness?, panel?, rulers?, outline?, grid?, smartGuides?, boundingBox?, controlBar?, imageTracePreview?}` | `imageTracePreview`: the Image Trace panel's Preview (`ui.inspect` shows it as `ui.image_trace_preview`) |
|
||||
| `ui.set` | `{brightness?, panel?, rulers?, outline?, grid?, smartGuides?, boundingBox?, controlBar?, imageTracePreview?}` | `outline`: View › Outline for the active document only (each document has its own, a document opens in Preview, and `ui.inspect` shows it as `view.outline`; an error without a document). `imageTracePreview`: the Image Trace panel's Preview (`ui.inspect` shows it as `ui.image_trace_preview`) |
|
||||
| `ui.dialog.set` / `.confirm` / `.cancel` | `{field, value}` or `{fields: [{field, value}…]}` | fill and submit the open dialog. `ui.dialog.set` takes the fields the dialog has (`ui.inspect`), `__` fields, `discard` on a dialog with a discard button, `preview` on one with a live preview, the fields that the dialog reads but adds only when a control changes (such as `searchFolder`, or `copy` in the Transform dialogs), and the fields it passes to the command it runs: every parameter of the `command` dialog's command (`ui.paramDialog`), and the `textureImage` that Load Texture… sets in the Glass and Texturizer effects; any other field is an error that lists the dialog's fields, and the dialog stays as it was `fields` sets the list (at most 256 items) in order (a missing `value` is `null`) and checks every item first: when one is refused, the call is an error and no field changes |
|
||||
| `ui.screenshot` | `{path?, data?}` | capture the window (PNG), written to `path`; `data: true` sends it back as `pngBase64` too. The inline PNG is capped at 64 MiB: with a `path` the file is still written and `pngBase64` is omitted when the image is larger, while a path-less `data: true` request fails if either the raw RGBA image or the encoded PNG exceeds the cap. Needs a presented frame: with the screen locked or the window minimized/covered it fails after ~8 s with an explanatory error |
|
||||
| `ui.render` | `{path?, scale?, artboard?, data?, traceViews?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first; `traceViews: true`: Image Trace objects draw in their View, as on the canvas, instead of their tracing result): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` |
|
||||
|
||||
Reference in New Issue
Block a user