Settings: a settings window, its sections, a search, and what fills them
SettingsLayout lists sections down the side and shows the open one, the list going above the page in a narrow box; SettingsSection and SettingsRow title and hold settings, a row's control wrapping under its name. SettingsSearch finds a setting by its name or words and opens its section. ThemeSelector, AccentColorPicker, FontSizeControl and DensitySelector set the look; KeyboardShortcutsList finds shortcuts by every word; ProxySettings, PrivacySettings, NotificationSettings, StartupSettings, StorageSettings with its clear, ImportExportSettings, ResetToDefault, FeatureFlags and DeveloperModeToggle fill the rest. Every handler a control needs is required and fails loud when missing. ColorPalette wraps its swatches in a row capped at its columns, so it keeps their size in a box sized by its content, and its swatches carry the palette's id. ThemeSwitcher points here: src/theme sits under the chapters that draw with buttons. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4ab478c3c3
commit
93de2a6bd6
18 files changed
+2873
-27
No files matched your search
@@ -28,6 +28,7 @@ mod navigation;
|
||||
mod overlays;
|
||||
mod primitives;
|
||||
mod project;
|
||||
mod settings;
|
||||
mod shell;
|
||||
mod tables;
|
||||
mod terminal;
|
||||
@@ -83,6 +84,7 @@ pub const ALL: &[Page] = &[
|
||||
canvas::PAGE,
|
||||
devtools::PAGE,
|
||||
dashboard::PAGE,
|
||||
settings::PAGE,
|
||||
theme::PAGE,
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
use gpui::{AnyElement, App, Window, div, prelude::*};
|
||||
|
||||
mod pages;
|
||||
mod window;
|
||||
|
||||
use super::Page;
|
||||
use crate::script::Step;
|
||||
|
||||
pub const PAGE: Page = Page {
|
||||
number: 35,
|
||||
slug: "settings",
|
||||
title: "Settings",
|
||||
summary: "A settings window: its sections, a search over them, and the controls each one holds.",
|
||||
render,
|
||||
script: SCRIPT,
|
||||
};
|
||||
|
||||
const SCRIPT: &[Step] = &[
|
||||
Step::DownAt("settings-layout", 60.0, 16.0),
|
||||
Step::UpAt("settings-layout", 60.0, 16.0),
|
||||
Step::Key("down"),
|
||||
Step::Wait(200),
|
||||
Step::Shot("keyboard"),
|
||||
Step::Key("down"),
|
||||
Step::Wait(200),
|
||||
Step::Shot("network"),
|
||||
Step::Key("down"),
|
||||
Step::Wait(200),
|
||||
Step::Shot("privacy"),
|
||||
Step::Key("down"),
|
||||
Step::Wait(200),
|
||||
Step::Shot("notifications"),
|
||||
Step::Key("down"),
|
||||
Step::Wait(200),
|
||||
Step::Shot("startup"),
|
||||
Step::Key("down"),
|
||||
Step::Wait(200),
|
||||
Step::Shot("storage"),
|
||||
Step::Key("down"),
|
||||
Step::Wait(200),
|
||||
Step::Shot("advanced"),
|
||||
];
|
||||
|
||||
fn render(window: &mut Window, cx: &mut App) -> AnyElement {
|
||||
div().child(window::settings(window, cx)).into_any_element()
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
use ely_gpui_component::{
|
||||
forms::{Choice, Combobox, FontPicker},
|
||||
settings::{
|
||||
AccentColorPicker, Changed, DensitySelector, DeveloperModeToggle, FeatureFlags,
|
||||
FontSizeControl, ImportExportSettings, KeyboardShortcutsList, NotificationSettings,
|
||||
PrivacySettings, ProxySettings, ResetToDefault, SettingsRow, SettingsSection, Shortcut,
|
||||
StartupSettings, StorageSettings, ThemeSelector,
|
||||
},
|
||||
theme::{ActiveTheme, HUE_NAMES},
|
||||
};
|
||||
use gpui::{AnyElement, App, Entity, IntoElement, ParentElement, Styled, div, px};
|
||||
|
||||
use super::window::{Fields, Prefs, SIZE};
|
||||
use crate::ui::change;
|
||||
|
||||
/// The open section's page.
|
||||
pub(super) fn page(prefs: &Entity<Prefs>, fields: &Fields, section: &str, cx: &App) -> AnyElement {
|
||||
match section {
|
||||
"appearance" => appearance(prefs, fields, cx),
|
||||
"keyboard" => keyboard(prefs, fields, cx),
|
||||
"network" => network(prefs, cx),
|
||||
"privacy" => privacy(prefs, cx),
|
||||
"notifications" => notifications(prefs, cx),
|
||||
"startup" => startup(prefs, cx),
|
||||
"storage" => storage(prefs, cx),
|
||||
"advanced" => advanced(prefs, fields, cx),
|
||||
other => panic!("settings demo: no page {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn appearance(prefs: &Entity<Prefs>, fields: &Fields, cx: &App) -> AnyElement {
|
||||
let now = prefs.read(cx);
|
||||
let presets = HUE_NAMES
|
||||
.iter()
|
||||
.take(6)
|
||||
.enumerate()
|
||||
.map(|(ix, name)| (*name, cx.theme().colors.hue(ix, "accent presets")));
|
||||
let [theme, accent, size, density, font] = [(); 5].map(|_| prefs.clone());
|
||||
SettingsSection::new("Appearance")
|
||||
.description("How the app looks.")
|
||||
.row(
|
||||
SettingsRow::new("Theme").control(
|
||||
ThemeSelector::new("settings-theme", now.appearance).on_change(
|
||||
move |next, _, cx| change(&theme, cx, |prefs| prefs.appearance = next),
|
||||
),
|
||||
),
|
||||
)
|
||||
.row(
|
||||
SettingsRow::new("Accent color")
|
||||
.description("Buttons, links and what is chosen.")
|
||||
.control(
|
||||
AccentColorPicker::new("settings-accent", now.accent, presets).on_change(
|
||||
move |next, _, cx| change(&accent, cx, |prefs| prefs.accent = next),
|
||||
),
|
||||
),
|
||||
)
|
||||
.row(
|
||||
SettingsRow::new("Font size").control(
|
||||
FontSizeControl::new("settings-size", now.size, SIZE, (11.0, 20.0))
|
||||
.on_change(move |next, _, cx| change(&size, cx, |prefs| prefs.size = next)),
|
||||
),
|
||||
)
|
||||
.row(
|
||||
SettingsRow::new("Density")
|
||||
.description("How close things sit.")
|
||||
.control(
|
||||
DensitySelector::new("settings-density", now.density).on_change(
|
||||
move |next, _, cx| change(&density, cx, |prefs| prefs.density = next),
|
||||
),
|
||||
),
|
||||
)
|
||||
.row(
|
||||
SettingsRow::new("Font")
|
||||
.description("FontFamilySelect → forms::FontPicker")
|
||||
.control(
|
||||
div().w(px(220.)).child(
|
||||
FontPicker::new("settings-font", &fields.font)
|
||||
.selected(now.family.clone())
|
||||
.on_change(move |next, _, cx| {
|
||||
change(&font, cx, |prefs| prefs.family = next.clone())
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn shortcut(group: &str, name: &str, keys: &str) -> Shortcut {
|
||||
Shortcut {
|
||||
group: group.to_string().into(),
|
||||
name: name.to_string().into(),
|
||||
keys: keys.to_string().into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn keyboard(prefs: &Entity<Prefs>, fields: &Fields, cx: &App) -> AnyElement {
|
||||
let language = prefs.clone();
|
||||
SettingsSection::new("Keyboard and language")
|
||||
.description("ShortcutEditor → editor::KeybindingsEditor")
|
||||
.row(
|
||||
SettingsRow::new("Language")
|
||||
.description("LanguageSelector → forms::Combobox::languages")
|
||||
.control(
|
||||
div().w(px(220.)).child(
|
||||
Combobox::languages("settings-language", &fields.language)
|
||||
.selected(prefs.read(cx).language.clone())
|
||||
.on_change(move |next, _, cx| {
|
||||
change(&language, cx, |prefs| prefs.language = next.clone())
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
.row(KeyboardShortcutsList::new(
|
||||
[
|
||||
shortcut("General", "Command palette", "cmd-shift-p"),
|
||||
shortcut("General", "Settings", "cmd-,"),
|
||||
shortcut("Editing", "Undo", "cmd-z"),
|
||||
shortcut("Editing", "Find", "cmd-f"),
|
||||
shortcut("Window", "New window", "cmd-shift-n"),
|
||||
],
|
||||
&fields.shortcuts,
|
||||
))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn network(prefs: &Entity<Prefs>, cx: &App) -> AnyElement {
|
||||
let proxy = prefs.clone();
|
||||
SettingsSection::new("Network")
|
||||
.row(
|
||||
ProxySettings::new("settings-proxy", prefs.read(cx).proxy.clone())
|
||||
.on_apply(move |next, _, cx| change(&proxy, cx, |prefs| prefs.proxy = next)),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn privacy(prefs: &Entity<Prefs>, cx: &App) -> AnyElement {
|
||||
let privacy = prefs.clone();
|
||||
PrivacySettings::new("settings-privacy", prefs.read(cx).privacy)
|
||||
.on_change(move |next, _, cx| change(&privacy, cx, |prefs| prefs.privacy = next))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn notifications(prefs: &Entity<Prefs>, cx: &App) -> AnyElement {
|
||||
let notices = prefs.clone();
|
||||
NotificationSettings::new("settings-notices", prefs.read(cx).notices.clone())
|
||||
.on_change(move |next, _, cx| change(¬ices, cx, |prefs| prefs.notices = next))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn startup(prefs: &Entity<Prefs>, cx: &App) -> AnyElement {
|
||||
let startup = prefs.clone();
|
||||
let pages = [
|
||||
Choice::new("last", "Where I left off"),
|
||||
Choice::new("home", "The home page"),
|
||||
Choice::new("blank", "A blank window"),
|
||||
];
|
||||
StartupSettings::new("settings-startup", prefs.read(cx).startup.clone(), pages)
|
||||
.on_change(move |next, _, cx| change(&startup, cx, |prefs| prefs.startup = next))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn storage(prefs: &Entity<Prefs>, cx: &App) -> AnyElement {
|
||||
let now = prefs.read(cx);
|
||||
let [cleared, reset, all] = [(); 3].map(|_| prefs.clone());
|
||||
let changed = (now.size != SIZE).then(|| Changed {
|
||||
key: "size".into(),
|
||||
name: "Font size".into(),
|
||||
now: format!("{} pt", now.size).into(),
|
||||
default: format!("{SIZE} pt").into(),
|
||||
});
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_8()
|
||||
.child(
|
||||
SettingsSection::new("Storage")
|
||||
.description("What the app keeps on this device.")
|
||||
.row(
|
||||
StorageSettings::new("settings-storage", now.stores.clone()).on_clear(
|
||||
move |key, _, cx| {
|
||||
change(&cleared, cx, |prefs| {
|
||||
prefs
|
||||
.stores
|
||||
.iter_mut()
|
||||
.filter(|store| store.key == *key)
|
||||
.for_each(|store| store.bytes = 0)
|
||||
})
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
SettingsSection::new("Import and export").row(
|
||||
ImportExportSettings::new("settings-files")
|
||||
.on_export(|_, _| log::info!("gallery: settings exported"))
|
||||
.on_import(|path, _, _| {
|
||||
log::info!("gallery: settings imported from {}", path.display())
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
SettingsSection::new("Defaults")
|
||||
.description("A larger font size shows here.")
|
||||
.row(
|
||||
ResetToDefault::new("settings-reset", changed)
|
||||
.on_reset(move |_, _, cx| change(&reset, cx, |prefs| prefs.size = SIZE))
|
||||
.on_reset_all(move |_, cx| change(&all, cx, |prefs| prefs.size = SIZE)),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn advanced(prefs: &Entity<Prefs>, fields: &Fields, cx: &App) -> AnyElement {
|
||||
let now = prefs.read(cx);
|
||||
let [toggled, developer] = [(); 2].map(|_| prefs.clone());
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_8()
|
||||
.child(
|
||||
SettingsSection::new("Features")
|
||||
.description("AdvancedSettings / FeatureFlags")
|
||||
.row(
|
||||
FeatureFlags::new("settings-flags", now.flags.clone(), &fields.flags)
|
||||
.on_toggle(move |key, on, _, cx| {
|
||||
change(&toggled, cx, |prefs| {
|
||||
prefs
|
||||
.flags
|
||||
.iter_mut()
|
||||
.filter(|flag| flag.key == *key)
|
||||
.for_each(|flag| flag.on = on)
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
DeveloperModeToggle::new("settings-developer", now.developer)
|
||||
.on_change(move |on, _, cx| change(&developer, cx, |prefs| prefs.developer = on)),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
use ely_gpui_component::{
|
||||
forms::TextInput,
|
||||
primitives::IconName,
|
||||
settings::{
|
||||
Appearance, Flag, Notices, Privacy, Proxy, SettingEntry, SettingsLayout, SettingsSearch,
|
||||
Stage, Startup, Store,
|
||||
},
|
||||
theme::{ActiveTheme, Density},
|
||||
};
|
||||
use gpui::{App, Entity, Hsla, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
|
||||
|
||||
use super::pages;
|
||||
use crate::{
|
||||
probe::probe,
|
||||
ui::{change, keep, section},
|
||||
};
|
||||
|
||||
pub(super) const SECTIONS: [(&str, &str, IconName); 8] = [
|
||||
("appearance", "Appearance", IconName::Palette),
|
||||
("keyboard", "Keyboard", IconName::Keyboard),
|
||||
("network", "Network", IconName::Globe),
|
||||
("privacy", "Privacy", IconName::Shield),
|
||||
("notifications", "Notifications", IconName::Bell),
|
||||
("startup", "Startup", IconName::Power),
|
||||
("storage", "Storage", IconName::HardDrive),
|
||||
("advanced", "Advanced", IconName::Wrench),
|
||||
];
|
||||
|
||||
/// Everything the settings window holds.
|
||||
pub(super) struct Prefs {
|
||||
pub section: SharedString,
|
||||
pub appearance: Appearance,
|
||||
pub accent: Hsla,
|
||||
pub size: f32,
|
||||
pub density: Density,
|
||||
pub family: SharedString,
|
||||
pub language: SharedString,
|
||||
pub proxy: Proxy,
|
||||
pub privacy: Privacy,
|
||||
pub notices: Notices,
|
||||
pub startup: Startup,
|
||||
pub stores: Vec<Store>,
|
||||
pub flags: Vec<Flag>,
|
||||
pub developer: bool,
|
||||
}
|
||||
|
||||
pub(super) const SIZE: f32 = 14.0;
|
||||
|
||||
fn flag(key: &str, name: &str, description: &str, stage: Stage, on: bool) -> Flag {
|
||||
Flag {
|
||||
key: key.to_string().into(),
|
||||
name: name.to_string().into(),
|
||||
description: description.to_string().into(),
|
||||
stage,
|
||||
on,
|
||||
}
|
||||
}
|
||||
|
||||
fn pair(kind: &str, channel: &str) -> (SharedString, SharedString) {
|
||||
(kind.to_string().into(), channel.to_string().into())
|
||||
}
|
||||
|
||||
impl Prefs {
|
||||
fn new(accent: Hsla) -> Self {
|
||||
Self {
|
||||
section: "appearance".into(),
|
||||
appearance: Appearance::System,
|
||||
accent,
|
||||
size: SIZE,
|
||||
density: Density::Standard,
|
||||
family: "Inter".into(),
|
||||
language: "en".into(),
|
||||
proxy: Proxy::System,
|
||||
privacy: Privacy {
|
||||
telemetry: true,
|
||||
crash_reports: true,
|
||||
personalization: false,
|
||||
history: true,
|
||||
},
|
||||
notices: Notices {
|
||||
channels: vec!["App".into(), "Email".into(), "Push".into()],
|
||||
kinds: vec![
|
||||
pair("mentions", "Mentions"),
|
||||
pair("replies", "Replies"),
|
||||
pair("digest", "Weekly digest"),
|
||||
],
|
||||
on: vec![
|
||||
pair("mentions", "App"),
|
||||
pair("mentions", "Push"),
|
||||
pair("replies", "App"),
|
||||
pair("digest", "Email"),
|
||||
],
|
||||
quiet: false,
|
||||
},
|
||||
startup: Startup {
|
||||
at_login: false,
|
||||
restore: true,
|
||||
opens: "last".into(),
|
||||
updates: true,
|
||||
},
|
||||
stores: vec![
|
||||
Store {
|
||||
key: "cache".into(),
|
||||
name: "Cache".into(),
|
||||
bytes: 312 << 20,
|
||||
clearable: true,
|
||||
},
|
||||
Store {
|
||||
key: "logs".into(),
|
||||
name: "Logs".into(),
|
||||
bytes: 48 << 20,
|
||||
clearable: true,
|
||||
},
|
||||
Store {
|
||||
key: "documents".into(),
|
||||
name: "Documents".into(),
|
||||
bytes: 1_240 << 20,
|
||||
clearable: false,
|
||||
},
|
||||
],
|
||||
flags: vec![
|
||||
flag(
|
||||
"tabs",
|
||||
"Vertical tabs",
|
||||
"Tabs down the side of the window.",
|
||||
Stage::Beta,
|
||||
false,
|
||||
),
|
||||
flag(
|
||||
"sync",
|
||||
"Settings sync",
|
||||
"The same settings on every device.",
|
||||
Stage::Stable,
|
||||
true,
|
||||
),
|
||||
flag(
|
||||
"suggest",
|
||||
"Inline suggestions",
|
||||
"The rest of a line, offered as you type.",
|
||||
Stage::Experimental,
|
||||
false,
|
||||
),
|
||||
],
|
||||
developer: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The window's text fields, each kept across frames.
|
||||
pub(super) struct Fields {
|
||||
pub font: Entity<TextInput>,
|
||||
pub language: Entity<TextInput>,
|
||||
pub shortcuts: Entity<TextInput>,
|
||||
pub flags: Entity<TextInput>,
|
||||
}
|
||||
|
||||
fn field(
|
||||
key: &'static str,
|
||||
hint: &'static str,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Entity<TextInput> {
|
||||
window.use_keyed_state(key, cx, move |window, cx| {
|
||||
TextInput::new(window, cx).placeholder(hint)
|
||||
})
|
||||
}
|
||||
|
||||
fn entry(section: &str, key: &str, name: &str, words: &[&str]) -> SettingEntry {
|
||||
let (_, title, _) = SECTIONS
|
||||
.iter()
|
||||
.find(|(each, _, _)| *each == section)
|
||||
.expect("a settings section");
|
||||
SettingEntry {
|
||||
key: key.to_string().into(),
|
||||
section: (*title).into(),
|
||||
name: name.to_string().into(),
|
||||
words: words
|
||||
.iter()
|
||||
.map(|word| SharedString::from(word.to_string()))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The section each searchable setting lives in, by the setting's key.
|
||||
fn home(key: &str) -> &'static str {
|
||||
match key {
|
||||
"theme" | "accent" | "size" | "density" | "font" => "appearance",
|
||||
"language" | "shortcuts" => "keyboard",
|
||||
"proxy" => "network",
|
||||
"telemetry" => "privacy",
|
||||
"quiet" => "notifications",
|
||||
"login" => "startup",
|
||||
"cache" => "storage",
|
||||
"developer" => "advanced",
|
||||
other => panic!("settings demo: no home for {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn settings(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let accent = cx.theme().colors.accent;
|
||||
let prefs = keep("settings-prefs", move || Prefs::new(accent), window, cx);
|
||||
let search = field("settings-search", "Search settings", window, cx);
|
||||
let fields = Fields {
|
||||
font: field("settings-font", "Font", window, cx),
|
||||
language: field("settings-language", "Language", window, cx),
|
||||
shortcuts: field("settings-shortcuts", "Find a shortcut", window, cx),
|
||||
flags: field("settings-flags", "Find a feature", window, cx),
|
||||
};
|
||||
let entries = [
|
||||
entry("appearance", "theme", "Theme", &["dark", "light", "mode"]),
|
||||
entry("appearance", "accent", "Accent color", &["color", "hue"]),
|
||||
entry("appearance", "size", "Font size", &["text", "zoom"]),
|
||||
entry("appearance", "density", "Density", &["compact", "spacing"]),
|
||||
entry("appearance", "font", "Font", &["typeface", "family"]),
|
||||
entry(
|
||||
"keyboard",
|
||||
"language",
|
||||
"Language",
|
||||
&["locale", "translation"],
|
||||
),
|
||||
entry(
|
||||
"keyboard",
|
||||
"shortcuts",
|
||||
"Keyboard shortcuts",
|
||||
&["keys", "bindings"],
|
||||
),
|
||||
entry("network", "proxy", "Proxy", &["http", "socks"]),
|
||||
entry(
|
||||
"privacy",
|
||||
"telemetry",
|
||||
"Usage data",
|
||||
&["telemetry", "analytics"],
|
||||
),
|
||||
entry(
|
||||
"notifications",
|
||||
"quiet",
|
||||
"Hold notices",
|
||||
&["do not disturb", "quiet"],
|
||||
),
|
||||
entry("startup", "login", "Open at login", &["launch", "boot"]),
|
||||
entry("storage", "cache", "Clear cache", &["disk", "space"]),
|
||||
entry(
|
||||
"advanced",
|
||||
"developer",
|
||||
"Developer mode",
|
||||
&["debug", "inspector"],
|
||||
),
|
||||
];
|
||||
let selected = prefs.read(cx).section.clone();
|
||||
let page = pages::page(&prefs, &fields, &selected, cx);
|
||||
let [found, picked] = [(); 2].map(|_| prefs.clone());
|
||||
section(
|
||||
"SettingsLayout · SettingsSection · SettingsRow · SettingsSearch",
|
||||
"A settings window: sections down the side and the open one beside them, the list going above in a narrow window. The search finds a setting by its name or the words it answers to, and Enter opens its section.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_6()
|
||||
.child(div().w(px(360.)).child(SettingsSearch::new("settings-search", entries, &search).on_pick(
|
||||
move |key, _, cx| change(&found, cx, |prefs| prefs.section = home(key).into()),
|
||||
)))
|
||||
.child(probe(
|
||||
"settings-layout",
|
||||
div().w(px(880.)).child(
|
||||
SettingsLayout::new("settings-layout", SECTIONS, selected)
|
||||
.on_select(move |key, _, cx| change(&picked, cx, |prefs| prefs.section = key.clone()))
|
||||
.page(page),
|
||||
),
|
||||
)),
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user