Redesign plugin detail page in design's 320 + 1fr grid
Plugin detail now opens with the design's marketplace card: a 4/3 brand-gradient cover plus install/secondary buttons plus a permissions list with risk badges on the left, and a category overline plus serif Newsreader title plus description plus a 4-up real-data stat grid (permissions, high-risk, contributes, signature) plus a "What it adds to ELY" contributions list on the right. Every value comes from PluginManifest — no fabricated ratings, install counts, or feature copy. Internal: chrome::plugin_detail_view holds the layout, and chrome::plugin_labels owns the permission scope labels and contribution copy so plugin_detail_view stays under 500 lines. The internal_pages/plugin_details.rs shim only handles route parsing and the missing-plugin fallback.
This commit is contained in:
@@ -3,6 +3,8 @@ pub(crate) mod brand_glyph;
|
|||||||
pub(crate) mod command_footer;
|
pub(crate) mod command_footer;
|
||||||
pub(crate) mod command_overlay;
|
pub(crate) mod command_overlay;
|
||||||
pub(crate) mod home;
|
pub(crate) mod home;
|
||||||
|
pub(crate) mod plugin_detail_view;
|
||||||
|
pub(crate) mod plugin_labels;
|
||||||
pub(crate) mod settings_layout;
|
pub(crate) mod settings_layout;
|
||||||
pub(crate) mod sidebar;
|
pub(crate) mod sidebar;
|
||||||
pub(crate) mod sidebar_header;
|
pub(crate) mod sidebar_header;
|
||||||
@@ -15,6 +17,7 @@ pub(crate) use appearance_form::render_appearance_form;
|
|||||||
pub(crate) use brand_glyph::render_glyph_for;
|
pub(crate) use brand_glyph::render_glyph_for;
|
||||||
pub(crate) use command_overlay::render_command_overlay;
|
pub(crate) use command_overlay::render_command_overlay;
|
||||||
pub(crate) use home::render_home_page;
|
pub(crate) use home::render_home_page;
|
||||||
|
pub(crate) use plugin_detail_view::render_plugin_detail_view;
|
||||||
pub(crate) use settings_layout::render_settings_landing;
|
pub(crate) use settings_layout::render_settings_landing;
|
||||||
pub(crate) use sidebar::{PANEL_BG, panel_shadow};
|
pub(crate) use sidebar::{PANEL_BG, panel_shadow};
|
||||||
pub(crate) use sidebar_header::render_sidebar_header;
|
pub(crate) use sidebar_header::render_sidebar_header;
|
||||||
|
|||||||
@@ -0,0 +1,452 @@
|
|||||||
|
use ely_browser_core::InstalledPlugin;
|
||||||
|
use ely_design_system::colors;
|
||||||
|
use ely_domain::{
|
||||||
|
PluginContributionPoint, PluginManifest, PluginPermission, PluginPermissionRisk, ProfileKind,
|
||||||
|
};
|
||||||
|
use gpui::{
|
||||||
|
AnyElement, Context, FontWeight, InteractiveElement, IntoElement, ParentElement, SharedString,
|
||||||
|
StatefulInteractiveElement, Styled, div, hsla, linear_color_stop, linear_gradient, px, rgb,
|
||||||
|
rgba,
|
||||||
|
};
|
||||||
|
use gpui_component::IconName;
|
||||||
|
|
||||||
|
use crate::shell::ElyShell;
|
||||||
|
use crate::shell::chrome::SERIF_FAMILY;
|
||||||
|
use crate::shell::chrome::plugin_labels::{
|
||||||
|
contribution_detail, contribution_title, permission_scope_label,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub(crate) fn render_plugin_detail_view(
|
||||||
|
plugin: &InstalledPlugin,
|
||||||
|
profile_kind: &ProfileKind,
|
||||||
|
cx: &mut Context<ElyShell>,
|
||||||
|
) -> AnyElement {
|
||||||
|
div()
|
||||||
|
.size_full()
|
||||||
|
.pt(px(40.0))
|
||||||
|
.px(px(56.0))
|
||||||
|
.pb(px(40.0))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.max_w(px(960.0))
|
||||||
|
.mx_auto()
|
||||||
|
.p(px(24.0))
|
||||||
|
.rounded(px(16.0))
|
||||||
|
.bg(rgba(CARD_BG))
|
||||||
|
.grid()
|
||||||
|
.grid_cols(8)
|
||||||
|
.gap(px(24.0))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.col_span(3)
|
||||||
|
.child(render_left_column(plugin, profile_kind, cx)),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.col_span(5)
|
||||||
|
.child(render_right_column(plugin)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_left_column(
|
||||||
|
plugin: &InstalledPlugin,
|
||||||
|
profile_kind: &ProfileKind,
|
||||||
|
cx: &mut Context<ElyShell>,
|
||||||
|
) -> AnyElement {
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.gap(px(16.0))
|
||||||
|
.child(render_cover(plugin))
|
||||||
|
.child(render_install_row(plugin, profile_kind, cx))
|
||||||
|
.child(render_permissions_block(plugin.manifest()))
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_cover(plugin: &InstalledPlugin) -> AnyElement {
|
||||||
|
let initial = plugin
|
||||||
|
.manifest()
|
||||||
|
.name()
|
||||||
|
.chars()
|
||||||
|
.next()
|
||||||
|
.unwrap_or('◇')
|
||||||
|
.to_string()
|
||||||
|
.to_uppercase();
|
||||||
|
|
||||||
|
div()
|
||||||
|
.h(px(220.0))
|
||||||
|
.rounded(px(14.0))
|
||||||
|
.bg(linear_gradient(
|
||||||
|
135.0,
|
||||||
|
linear_color_stop(hsla(341.0 / 360.0, 0.78, 0.85, 1.0), 0.0),
|
||||||
|
linear_color_stop(hsla(228.0 / 360.0, 1.0, 0.86, 1.0), 1.0),
|
||||||
|
))
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.text_size(px(80.0))
|
||||||
|
.text_color(rgb(0xffffff))
|
||||||
|
.child(initial)
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_install_row(
|
||||||
|
plugin: &InstalledPlugin,
|
||||||
|
profile_kind: &ProfileKind,
|
||||||
|
cx: &mut Context<ElyShell>,
|
||||||
|
) -> AnyElement {
|
||||||
|
let plugin_id = plugin.id().clone();
|
||||||
|
let enabled = plugin.enabled_for_profile(profile_kind);
|
||||||
|
let primary_label = if enabled { "Disable" } else { "Enable" };
|
||||||
|
let target_enabled = !plugin.enabled();
|
||||||
|
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.gap(px(10.0))
|
||||||
|
.child(render_primary_button(primary_label, cx, move |shell, _, cx| {
|
||||||
|
shell.set_plugin_enabled(plugin_id.clone(), target_enabled, cx);
|
||||||
|
}))
|
||||||
|
.child(render_secondary_button(IconName::Info, cx, |shell, window, cx| {
|
||||||
|
shell.open_internal_tab("ely://settings/plugins", window, cx);
|
||||||
|
}))
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_primary_button<F>(
|
||||||
|
label: &'static str,
|
||||||
|
cx: &mut Context<ElyShell>,
|
||||||
|
handler: F,
|
||||||
|
) -> AnyElement
|
||||||
|
where
|
||||||
|
F: Fn(&mut ElyShell, &mut gpui::Window, &mut Context<ElyShell>) + 'static,
|
||||||
|
{
|
||||||
|
div()
|
||||||
|
.id(SharedString::from("plugin-detail-primary"))
|
||||||
|
.flex_1()
|
||||||
|
.h(px(40.0))
|
||||||
|
.rounded(px(10.0))
|
||||||
|
.bg(rgb(colors::INK))
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.gap(px(8.0))
|
||||||
|
.text_size(px(13.0))
|
||||||
|
.font_weight(FontWeight(500.0))
|
||||||
|
.text_color(rgb(0xffffff))
|
||||||
|
.cursor_pointer()
|
||||||
|
.hover(|style| style.opacity(0.92))
|
||||||
|
.active(|style| style.opacity(0.78))
|
||||||
|
.on_click(cx.listener(move |shell, _, window, cx| handler(shell, window, cx)))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_color(rgb(0xffffff))
|
||||||
|
.child(IconName::Check),
|
||||||
|
)
|
||||||
|
.child(label)
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_secondary_button<F>(
|
||||||
|
icon: IconName,
|
||||||
|
cx: &mut Context<ElyShell>,
|
||||||
|
handler: F,
|
||||||
|
) -> AnyElement
|
||||||
|
where
|
||||||
|
F: Fn(&mut ElyShell, &mut gpui::Window, &mut Context<ElyShell>) + 'static,
|
||||||
|
{
|
||||||
|
div()
|
||||||
|
.id(SharedString::from("plugin-detail-secondary"))
|
||||||
|
.size(px(40.0))
|
||||||
|
.rounded(px(10.0))
|
||||||
|
.bg(rgba(SECONDARY_BG))
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.text_color(rgb(colors::INK_3))
|
||||||
|
.cursor_pointer()
|
||||||
|
.hover(|style| style.bg(rgba(SECONDARY_BG_HOVER)).text_color(rgb(colors::INK)))
|
||||||
|
.active(|style| style.opacity(0.82))
|
||||||
|
.on_click(cx.listener(move |shell, _, window, cx| handler(shell, window, cx)))
|
||||||
|
.child(icon)
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_permissions_block(manifest: &PluginManifest) -> AnyElement {
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.gap(px(8.0))
|
||||||
|
.pt(px(4.0))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_size(px(11.0))
|
||||||
|
.text_color(rgb(colors::INK_3))
|
||||||
|
.child("PERMISSIONS"),
|
||||||
|
)
|
||||||
|
.child(if manifest.permissions().is_empty() {
|
||||||
|
div()
|
||||||
|
.text_size(px(12.0))
|
||||||
|
.text_color(rgb(colors::INK_4))
|
||||||
|
.child("This plugin declares no permissions.")
|
||||||
|
.into_any_element()
|
||||||
|
} else {
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.gap(px(6.0))
|
||||||
|
.children(manifest.permissions().iter().map(render_permission_row))
|
||||||
|
.into_any_element()
|
||||||
|
})
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_permission_row(permission: &PluginPermission) -> AnyElement {
|
||||||
|
let high_risk = matches!(permission.risk(), PluginPermissionRisk::High);
|
||||||
|
let badge_bg = if high_risk { 0xfde7e7 } else { 0xe8f3ee };
|
||||||
|
let badge_color = if high_risk { 0xb53737 } else { 0x2f7d68 };
|
||||||
|
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.gap(px(8.0))
|
||||||
|
.text_size(px(12.0))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.size(px(14.0))
|
||||||
|
.rounded(px(4.0))
|
||||||
|
.bg(rgb(badge_bg))
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.text_color(rgb(badge_color))
|
||||||
|
.text_size(px(10.0))
|
||||||
|
.child(if high_risk { "!" } else { "✓" }),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex_1()
|
||||||
|
.text_color(rgb(colors::INK_2))
|
||||||
|
.child(permission_scope_label(permission)),
|
||||||
|
)
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_right_column(plugin: &InstalledPlugin) -> AnyElement {
|
||||||
|
let manifest = plugin.manifest();
|
||||||
|
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.gap(px(16.0))
|
||||||
|
.child(render_right_header(plugin))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_size(px(13.5))
|
||||||
|
.text_color(rgb(colors::INK_2))
|
||||||
|
.child(manifest.description().to_string()),
|
||||||
|
)
|
||||||
|
.child(render_stats_grid(plugin))
|
||||||
|
.child(render_contributions(manifest))
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_right_header(plugin: &InstalledPlugin) -> AnyElement {
|
||||||
|
let manifest = plugin.manifest();
|
||||||
|
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.items_start()
|
||||||
|
.gap(px(14.0))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex_1()
|
||||||
|
.min_w_0()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.gap(px(4.0))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_size(px(11.0))
|
||||||
|
.text_color(rgb(colors::INK_4))
|
||||||
|
.child(category_label(plugin)),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.font_family(SERIF_FAMILY)
|
||||||
|
.text_size(px(32.0))
|
||||||
|
.font_weight(FontWeight(400.0))
|
||||||
|
.text_color(rgb(colors::INK))
|
||||||
|
.child(manifest.name().to_string()),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_size(px(12.5))
|
||||||
|
.text_color(rgb(colors::INK_3))
|
||||||
|
.child(format!(
|
||||||
|
"by {} · min ELY {}",
|
||||||
|
manifest.author(),
|
||||||
|
manifest.min_ely_build()
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(render_status_chip(plugin))
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_status_chip(plugin: &InstalledPlugin) -> AnyElement {
|
||||||
|
let enabled = plugin.enabled();
|
||||||
|
let label = if enabled { "Enabled" } else { "Disabled" };
|
||||||
|
let bg = if enabled { 0xe8f3ee } else { 0x281e140d };
|
||||||
|
let color = if enabled { 0x2f7d68 } else { colors::INK_3 };
|
||||||
|
|
||||||
|
div()
|
||||||
|
.px(px(10.0))
|
||||||
|
.py(px(4.0))
|
||||||
|
.rounded(px(999.0))
|
||||||
|
.bg(rgba(bg))
|
||||||
|
.text_size(px(11.0))
|
||||||
|
.font_weight(FontWeight(500.0))
|
||||||
|
.text_color(rgb(color))
|
||||||
|
.child(label)
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_stats_grid(plugin: &InstalledPlugin) -> AnyElement {
|
||||||
|
let manifest = plugin.manifest();
|
||||||
|
let permissions = manifest.permissions().len();
|
||||||
|
let high_risk = manifest.high_risk_permissions().count();
|
||||||
|
let contributes = manifest.contributes().len();
|
||||||
|
let signature = manifest.signature().algorithm().as_str();
|
||||||
|
|
||||||
|
div()
|
||||||
|
.grid()
|
||||||
|
.grid_cols(4)
|
||||||
|
.gap(px(8.0))
|
||||||
|
.child(stat_card("Permissions", permissions.to_string()))
|
||||||
|
.child(stat_card("High risk", high_risk.to_string()))
|
||||||
|
.child(stat_card("Contributes", contributes.to_string()))
|
||||||
|
.child(stat_card("Signature", signature.to_string()))
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stat_card(label: &'static str, value: String) -> AnyElement {
|
||||||
|
div()
|
||||||
|
.px(px(12.0))
|
||||||
|
.py(px(10.0))
|
||||||
|
.rounded(px(10.0))
|
||||||
|
.bg(rgba(STAT_BG))
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.gap(px(2.0))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_size(px(10.5))
|
||||||
|
.text_color(rgb(colors::INK_4))
|
||||||
|
.child(label),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_size(px(13.0))
|
||||||
|
.font_weight(FontWeight(500.0))
|
||||||
|
.text_color(rgb(colors::INK))
|
||||||
|
.child(value),
|
||||||
|
)
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_contributions(manifest: &PluginManifest) -> AnyElement {
|
||||||
|
let contributions = manifest.contributes();
|
||||||
|
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.gap(px(10.0))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_size(px(13.0))
|
||||||
|
.font_weight(FontWeight(600.0))
|
||||||
|
.text_color(rgb(colors::INK))
|
||||||
|
.child("What it adds to ELY"),
|
||||||
|
)
|
||||||
|
.child(if contributions.is_empty() {
|
||||||
|
div()
|
||||||
|
.text_size(px(12.0))
|
||||||
|
.text_color(rgb(colors::INK_4))
|
||||||
|
.child("This plugin does not register any contributions.")
|
||||||
|
.into_any_element()
|
||||||
|
} else {
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.gap(px(8.0))
|
||||||
|
.children(contributions.iter().map(render_contribution_row))
|
||||||
|
.into_any_element()
|
||||||
|
})
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_contribution_row(contribution: &PluginContributionPoint) -> AnyElement {
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.items_start()
|
||||||
|
.gap(px(10.0))
|
||||||
|
.px(px(12.0))
|
||||||
|
.py(px(10.0))
|
||||||
|
.rounded(px(10.0))
|
||||||
|
.bg(rgba(CONTRIBUTION_BG))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.size(px(18.0))
|
||||||
|
.rounded(px(5.0))
|
||||||
|
.bg(rgba(CONTRIBUTION_BADGE_BG))
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.mt(px(1.0))
|
||||||
|
.text_color(rgb(colors::ACCENT))
|
||||||
|
.text_size(px(11.0))
|
||||||
|
.child(IconName::Check),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex_1()
|
||||||
|
.min_w_0()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.gap(px(2.0))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_size(px(12.5))
|
||||||
|
.font_weight(FontWeight(500.0))
|
||||||
|
.text_color(rgb(colors::INK))
|
||||||
|
.child(contribution_title(contribution)),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_size(px(11.5))
|
||||||
|
.text_color(rgb(colors::INK_3))
|
||||||
|
.child(contribution_detail(contribution)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn category_label(plugin: &InstalledPlugin) -> &'static str {
|
||||||
|
let high_risk = plugin.manifest().high_risk_permissions().next().is_some();
|
||||||
|
if high_risk {
|
||||||
|
"ELY · AUDIT NEEDED"
|
||||||
|
} else {
|
||||||
|
"ELY · SANDBOXED"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const CARD_BG: u32 = 0xffffffd9;
|
||||||
|
const SECONDARY_BG: u32 = 0xffffffd9;
|
||||||
|
const SECONDARY_BG_HOVER: u32 = 0xffffffeb;
|
||||||
|
const STAT_BG: u32 = 0xffffffb3;
|
||||||
|
const CONTRIBUTION_BG: u32 = 0xffffff8c;
|
||||||
|
const CONTRIBUTION_BADGE_BG: u32 = 0xc964421f;
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
use ely_domain::{PluginContributionPoint, PluginPermission};
|
||||||
|
|
||||||
|
pub(crate) fn permission_scope_label(permission: &PluginPermission) -> &'static str {
|
||||||
|
match permission {
|
||||||
|
PluginPermission::TabsRead => "Reads tab metadata.",
|
||||||
|
PluginPermission::TabsWrite => "Creates, moves, or closes tabs.",
|
||||||
|
PluginPermission::SpacesRead => "Reads Space metadata.",
|
||||||
|
PluginPermission::SpacesWrite => "Creates or changes Spaces.",
|
||||||
|
PluginPermission::BookmarksRead => "Reads bookmarks.",
|
||||||
|
PluginPermission::BookmarksWrite => "Writes bookmarks.",
|
||||||
|
PluginPermission::HistoryRead => "Reads browsing history.",
|
||||||
|
PluginPermission::DownloadsRead => "Reads download entries.",
|
||||||
|
PluginPermission::DownloadsWrite => "Controls downloads.",
|
||||||
|
PluginPermission::PageMetadata => "Reads active page metadata.",
|
||||||
|
PluginPermission::PageScreenshot => "Captures page screenshots.",
|
||||||
|
PluginPermission::PageScript => "Runs scoped page scripts.",
|
||||||
|
PluginPermission::ClipboardRead => "Reads clipboard content.",
|
||||||
|
PluginPermission::ClipboardWrite => "Writes clipboard content.",
|
||||||
|
PluginPermission::FilesystemRead => "Reads user-selected files.",
|
||||||
|
PluginPermission::FilesystemWrite => "Writes user-selected files.",
|
||||||
|
PluginPermission::NetworkFetch => "Performs plugin network requests.",
|
||||||
|
PluginPermission::SettingsRead => "Reads plugin settings.",
|
||||||
|
PluginPermission::SettingsWrite => "Writes plugin settings.",
|
||||||
|
PluginPermission::SyncPlugin => "Syncs plugin configuration.",
|
||||||
|
PluginPermission::UiPanel => "Registers sidebar panels.",
|
||||||
|
PluginPermission::UiCommand => "Registers command bar actions.",
|
||||||
|
PluginPermission::UiContextMenu => "Registers context menu actions.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn contribution_title(contribution: &PluginContributionPoint) -> &'static str {
|
||||||
|
match contribution {
|
||||||
|
PluginContributionPoint::CommandBarCommand => "Command bar action",
|
||||||
|
PluginContributionPoint::TabContextMenu => "Tab context menu",
|
||||||
|
PluginContributionPoint::PageContextMenu => "Page context menu",
|
||||||
|
PluginContributionPoint::SidebarPanel => "Sidebar panel",
|
||||||
|
PluginContributionPoint::SettingsPage => "Settings page",
|
||||||
|
PluginContributionPoint::StatusBarIndicator => "Status bar indicator",
|
||||||
|
PluginContributionPoint::DownloadAction => "Download action",
|
||||||
|
PluginContributionPoint::BookmarkAction => "Bookmark action",
|
||||||
|
PluginContributionPoint::ReadingModeExporter => "Reading mode exporter",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn contribution_detail(contribution: &PluginContributionPoint) -> &'static str {
|
||||||
|
match contribution {
|
||||||
|
PluginContributionPoint::CommandBarCommand => "Adds entries to the ⌘K command switcher.",
|
||||||
|
PluginContributionPoint::TabContextMenu => "Adds tab right-click actions.",
|
||||||
|
PluginContributionPoint::PageContextMenu => "Adds page right-click actions.",
|
||||||
|
PluginContributionPoint::SidebarPanel => "Registers a custom sidebar panel.",
|
||||||
|
PluginContributionPoint::SettingsPage => "Registers a settings page route.",
|
||||||
|
PluginContributionPoint::StatusBarIndicator => "Renders an indicator in the status bar.",
|
||||||
|
PluginContributionPoint::DownloadAction => "Adds actions to download entries.",
|
||||||
|
PluginContributionPoint::BookmarkAction => "Adds actions to bookmarks.",
|
||||||
|
PluginContributionPoint::ReadingModeExporter => "Exports reading mode content elsewhere.",
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,15 @@
|
|||||||
use ely_browser_core::{BrowserSnapshot, InstalledPlugin};
|
use ely_browser_core::{BrowserSnapshot, InstalledPlugin};
|
||||||
use ely_design_system::colors;
|
use ely_design_system::colors;
|
||||||
use ely_domain::{PluginId, PluginManifest, PluginPermission, PluginPermissionRisk, ProfileKind};
|
use ely_domain::PluginId;
|
||||||
use gpui::prelude::FluentBuilder;
|
|
||||||
use gpui::{AnyElement, Context, IntoElement, ParentElement, Styled, div, px, rgb};
|
use gpui::{AnyElement, Context, IntoElement, ParentElement, Styled, div, px, rgb};
|
||||||
use gpui_component::{
|
use gpui_component::{
|
||||||
IconName, Sizable, StyledExt,
|
IconName, Sizable,
|
||||||
button::{Button, ButtonVariants},
|
button::{Button, ButtonVariants},
|
||||||
scroll::ScrollableElement,
|
scroll::ScrollableElement,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{ElyShell, render_canvas_surface};
|
use super::{ElyShell, render_canvas_surface};
|
||||||
use crate::shell::chrome::SERIF_FAMILY;
|
use crate::shell::chrome::{SERIF_FAMILY, render_plugin_detail_view};
|
||||||
|
|
||||||
impl ElyShell {
|
impl ElyShell {
|
||||||
pub(super) fn render_plugin_detail_page(
|
pub(super) fn render_plugin_detail_page(
|
||||||
@@ -23,330 +22,15 @@ impl ElyShell {
|
|||||||
let plugin =
|
let plugin =
|
||||||
plugin_id.as_ref().and_then(|plugin_id| find_installed_plugin(snapshot, plugin_id));
|
plugin_id.as_ref().and_then(|plugin_id| find_installed_plugin(snapshot, plugin_id));
|
||||||
|
|
||||||
render_canvas_surface(
|
match plugin {
|
||||||
div()
|
Some(plugin) => render_canvas_surface(
|
||||||
.size_full()
|
div().size_full().overflow_y_scrollbar().child(
|
||||||
.p_8()
|
render_plugin_detail_view(plugin, &snapshot.active_profile_kind, cx),
|
||||||
.flex()
|
|
||||||
.flex_col()
|
|
||||||
.gap_5()
|
|
||||||
.when_some(plugin, |this, plugin| {
|
|
||||||
this.child(self.render_plugin_detail_header(
|
|
||||||
plugin,
|
|
||||||
&snapshot.active_profile_kind,
|
|
||||||
cx,
|
|
||||||
))
|
|
||||||
.child(render_plugin_security_summary(plugin))
|
|
||||||
.child(render_plugin_manifest_rows(plugin.manifest()))
|
|
||||||
.child(render_plugin_permission_list(plugin.manifest()))
|
|
||||||
})
|
|
||||||
.when(plugin.is_none(), |this| {
|
|
||||||
this.child(render_missing_plugin_detail(plugin_id.as_ref(), cx))
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_plugin_detail_header(
|
|
||||||
&mut self,
|
|
||||||
plugin: &InstalledPlugin,
|
|
||||||
profile_kind: &ProfileKind,
|
|
||||||
cx: &mut Context<Self>,
|
|
||||||
) -> AnyElement {
|
|
||||||
let plugin_id = plugin.id().clone();
|
|
||||||
let target_enabled = !plugin.enabled();
|
|
||||||
let private_allowed = plugin.private_window_allowed();
|
|
||||||
let status_label = plugin_status_label(plugin, profile_kind);
|
|
||||||
let status_color =
|
|
||||||
if plugin.enabled_for_profile(profile_kind) { colors::SUCCESS } else { colors::MUTED };
|
|
||||||
let action_label = if plugin.enabled() { "Disable" } else { "Enable" };
|
|
||||||
let action_icon = if plugin.enabled() { IconName::CircleX } else { IconName::Check };
|
|
||||||
let private_action_label = if private_allowed { "Block Private" } else { "Allow Private" };
|
|
||||||
let private_action_icon = if private_allowed { IconName::CircleX } else { IconName::Check };
|
|
||||||
|
|
||||||
div()
|
|
||||||
.flex()
|
|
||||||
.items_end()
|
|
||||||
.justify_between()
|
|
||||||
.gap_4()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.min_w_0()
|
|
||||||
.flex()
|
|
||||||
.flex_col()
|
|
||||||
.gap_2()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.font_family(SERIF_FAMILY)
|
|
||||||
.text_size(px(28.0))
|
|
||||||
.truncate()
|
|
||||||
.text_color(rgb(colors::INK))
|
|
||||||
.child(plugin.manifest().name().to_string()),
|
|
||||||
)
|
|
||||||
.child(div().text_sm().truncate().text_color(rgb(colors::MUTED)).child(
|
|
||||||
format!("{} - {}", plugin.manifest().author(), plugin.id().as_str()),
|
|
||||||
)),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.flex()
|
|
||||||
.items_center()
|
|
||||||
.gap_3()
|
|
||||||
.text_xs()
|
|
||||||
.child(div().font_semibold().text_color(rgb(status_color)).child(status_label))
|
|
||||||
.child(
|
|
||||||
Button::new("toggle-plugin-detail-private")
|
|
||||||
.ghost()
|
|
||||||
.xsmall()
|
|
||||||
.icon(private_action_icon)
|
|
||||||
.label(private_action_label)
|
|
||||||
.tooltip("Set Private Window Permission")
|
|
||||||
.on_click(cx.listener({
|
|
||||||
let plugin_id = plugin_id.clone();
|
|
||||||
move |shell, _, _, cx| {
|
|
||||||
shell.set_plugin_private_window_allowed(
|
|
||||||
plugin_id.clone(),
|
|
||||||
!private_allowed,
|
|
||||||
cx,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
Button::new("toggle-plugin-detail-enabled")
|
|
||||||
.xsmall()
|
|
||||||
.icon(action_icon)
|
|
||||||
.label(action_label)
|
|
||||||
.tooltip("Set Plugin State")
|
|
||||||
.on_click(cx.listener(move |shell, _, _, cx| {
|
|
||||||
shell.set_plugin_enabled(plugin_id.clone(), target_enabled, cx);
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
Button::new("open-plugin-settings")
|
|
||||||
.ghost()
|
|
||||||
.xsmall()
|
|
||||||
.icon(IconName::Info)
|
|
||||||
.label("Settings")
|
|
||||||
.tooltip("Open Plugin Settings")
|
|
||||||
.on_click(cx.listener(|shell, _, window, cx| {
|
|
||||||
shell.open_internal_tab("ely://settings/plugins", window, cx);
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.into_any_element()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn plugin_status_label(plugin: &InstalledPlugin, profile_kind: &ProfileKind) -> &'static str {
|
|
||||||
if !plugin.enabled() {
|
|
||||||
return "Disabled";
|
|
||||||
}
|
|
||||||
match profile_kind {
|
|
||||||
ProfileKind::Standard => "Enabled",
|
|
||||||
ProfileKind::Private if plugin.private_window_allowed() => "Private Enabled",
|
|
||||||
ProfileKind::Private => "Private Off",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_plugin_security_summary(plugin: &InstalledPlugin) -> AnyElement {
|
|
||||||
let manifest = plugin.manifest();
|
|
||||||
div()
|
|
||||||
.border_t_1()
|
|
||||||
.border_b_1()
|
|
||||||
.border_color(rgb(colors::HAIRLINE))
|
|
||||||
.py_3()
|
|
||||||
.flex()
|
|
||||||
.items_center()
|
|
||||||
.justify_between()
|
|
||||||
.gap_4()
|
|
||||||
.children([
|
|
||||||
detail_metric("Signature", manifest.signature().algorithm().as_str()),
|
|
||||||
detail_metric("Key", manifest.signature().key_id()),
|
|
||||||
detail_metric("High Risk", manifest.high_risk_permissions().count().to_string()),
|
|
||||||
detail_metric("Sync", sync_participation_label(manifest)),
|
|
||||||
])
|
|
||||||
.into_any_element()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn detail_metric(label: &'static str, value: impl Into<String>) -> AnyElement {
|
|
||||||
div()
|
|
||||||
.min_w_0()
|
|
||||||
.flex()
|
|
||||||
.flex_col()
|
|
||||||
.gap_1()
|
|
||||||
.child(div().text_xs().text_color(rgb(colors::MUTED)).child(label))
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_sm()
|
|
||||||
.font_semibold()
|
|
||||||
.truncate()
|
|
||||||
.text_color(rgb(colors::INK))
|
|
||||||
.child(value.into()),
|
|
||||||
)
|
|
||||||
.into_any_element()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_plugin_manifest_rows(manifest: &PluginManifest) -> AnyElement {
|
|
||||||
div()
|
|
||||||
.flex()
|
|
||||||
.flex_col()
|
|
||||||
.border_b_1()
|
|
||||||
.border_color(rgb(colors::HAIRLINE))
|
|
||||||
.child(plugin_detail_row(
|
|
||||||
IconName::Info,
|
|
||||||
"Description",
|
|
||||||
manifest.description(),
|
|
||||||
"Manifest summary",
|
|
||||||
))
|
|
||||||
.child(plugin_detail_row(IconName::User, "Author", manifest.author(), "Publisher"))
|
|
||||||
.child(plugin_detail_row(IconName::Globe, "Homepage", manifest.homepage(), "Publisher URL"))
|
|
||||||
.child(plugin_detail_row(
|
|
||||||
IconName::CircleCheck,
|
|
||||||
"Minimum Build",
|
|
||||||
manifest.min_ely_build().to_string(),
|
|
||||||
"Required ELY version",
|
|
||||||
))
|
|
||||||
.child(plugin_detail_row(
|
|
||||||
IconName::BookOpen,
|
|
||||||
"Checksum",
|
|
||||||
manifest.checksum(),
|
|
||||||
"Wasm package checksum",
|
|
||||||
))
|
|
||||||
.into_any_element()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn plugin_detail_row(
|
|
||||||
icon: IconName,
|
|
||||||
label: &'static str,
|
|
||||||
value: impl Into<String>,
|
|
||||||
detail: &'static str,
|
|
||||||
) -> AnyElement {
|
|
||||||
let value = value.into();
|
|
||||||
|
|
||||||
div()
|
|
||||||
.py_3()
|
|
||||||
.border_t_1()
|
|
||||||
.border_color(rgb(colors::HAIRLINE))
|
|
||||||
.flex()
|
|
||||||
.items_center()
|
|
||||||
.justify_between()
|
|
||||||
.gap_4()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.min_w_0()
|
|
||||||
.flex()
|
|
||||||
.items_center()
|
|
||||||
.gap_3()
|
|
||||||
.child(div().text_color(rgb(colors::MUTED_SOFT)).child(icon))
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.min_w_0()
|
|
||||||
.flex()
|
|
||||||
.flex_col()
|
|
||||||
.gap_1()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_sm()
|
|
||||||
.font_semibold()
|
|
||||||
.truncate()
|
|
||||||
.text_color(rgb(colors::INK))
|
|
||||||
.child(label),
|
|
||||||
)
|
|
||||||
.child(div().text_xs().text_color(rgb(colors::MUTED)).child(detail)),
|
|
||||||
),
|
),
|
||||||
)
|
),
|
||||||
.child(
|
None => render_canvas_surface(render_missing_plugin_detail(plugin_id.as_ref(), cx)),
|
||||||
div()
|
}
|
||||||
.max_w(px(380.0))
|
|
||||||
.truncate()
|
|
||||||
.text_sm()
|
|
||||||
.font_semibold()
|
|
||||||
.text_color(rgb(colors::INK))
|
|
||||||
.child(value),
|
|
||||||
)
|
|
||||||
.into_any_element()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_plugin_permission_list(manifest: &PluginManifest) -> AnyElement {
|
|
||||||
if manifest.permissions().is_empty() {
|
|
||||||
return div()
|
|
||||||
.text_sm()
|
|
||||||
.text_color(rgb(colors::MUTED))
|
|
||||||
.child("This plugin declares no permissions.")
|
|
||||||
.into_any_element();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
div()
|
|
||||||
.flex_1()
|
|
||||||
.min_h_0()
|
|
||||||
.flex()
|
|
||||||
.flex_col()
|
|
||||||
.gap_3()
|
|
||||||
.child(div().text_xs().font_semibold().text_color(rgb(colors::MUTED)).child("Permissions"))
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.flex_1()
|
|
||||||
.min_h_0()
|
|
||||||
.flex()
|
|
||||||
.flex_col()
|
|
||||||
.overflow_y_scrollbar()
|
|
||||||
.children(manifest.permissions().iter().map(render_permission_row)),
|
|
||||||
)
|
|
||||||
.into_any_element()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_permission_row(permission: &PluginPermission) -> AnyElement {
|
|
||||||
let risk = permission.risk();
|
|
||||||
let risk_color = match risk {
|
|
||||||
PluginPermissionRisk::Standard => colors::MUTED,
|
|
||||||
PluginPermissionRisk::High => colors::ERROR,
|
|
||||||
};
|
|
||||||
|
|
||||||
div()
|
|
||||||
.py_3()
|
|
||||||
.border_b_1()
|
|
||||||
.border_color(rgb(colors::HAIRLINE))
|
|
||||||
.flex()
|
|
||||||
.items_center()
|
|
||||||
.justify_between()
|
|
||||||
.gap_4()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.min_w_0()
|
|
||||||
.flex()
|
|
||||||
.items_center()
|
|
||||||
.gap_3()
|
|
||||||
.child(div().text_color(rgb(risk_color)).child(permission_icon(permission)))
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.min_w_0()
|
|
||||||
.flex()
|
|
||||||
.flex_col()
|
|
||||||
.gap_1()
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_sm()
|
|
||||||
.font_semibold()
|
|
||||||
.truncate()
|
|
||||||
.text_color(rgb(colors::INK))
|
|
||||||
.child(permission.as_str()),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_xs()
|
|
||||||
.text_color(rgb(colors::MUTED))
|
|
||||||
.child(permission_scope_label(permission)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
div()
|
|
||||||
.text_xs()
|
|
||||||
.font_semibold()
|
|
||||||
.text_color(rgb(risk_color))
|
|
||||||
.child(permission_risk_label(risk)),
|
|
||||||
)
|
|
||||||
.into_any_element()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_missing_plugin_detail(
|
fn render_missing_plugin_detail(
|
||||||
@@ -359,16 +43,23 @@ fn render_missing_plugin_detail(
|
|||||||
|
|
||||||
div()
|
div()
|
||||||
.size_full()
|
.size_full()
|
||||||
|
.pt(px(40.0))
|
||||||
|
.px(px(56.0))
|
||||||
|
.pb(px(40.0))
|
||||||
.flex()
|
.flex()
|
||||||
.flex_col()
|
.flex_col()
|
||||||
.gap_5()
|
.gap(px(20.0))
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.flex()
|
.flex()
|
||||||
.flex_col()
|
.flex_col()
|
||||||
.gap_2()
|
.gap(px(6.0))
|
||||||
.child(
|
.child(
|
||||||
div().text_size(px(26.0)).text_color(rgb(colors::INK)).child("Plugin Details"),
|
div()
|
||||||
|
.font_family(SERIF_FAMILY)
|
||||||
|
.text_size(px(28.0))
|
||||||
|
.text_color(rgb(colors::INK))
|
||||||
|
.child("Plugin Details"),
|
||||||
)
|
)
|
||||||
.child(div().text_sm().text_color(rgb(colors::MUTED)).child(detail)),
|
.child(div().text_sm().text_color(rgb(colors::MUTED)).child(detail)),
|
||||||
)
|
)
|
||||||
@@ -376,11 +67,11 @@ fn render_missing_plugin_detail(
|
|||||||
div()
|
div()
|
||||||
.border_t_1()
|
.border_t_1()
|
||||||
.border_color(rgb(colors::HAIRLINE))
|
.border_color(rgb(colors::HAIRLINE))
|
||||||
.pt_5()
|
.pt(px(20.0))
|
||||||
.flex()
|
.flex()
|
||||||
.items_center()
|
.items_center()
|
||||||
.justify_between()
|
.justify_between()
|
||||||
.gap_4()
|
.gap(px(16.0))
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.text_sm()
|
.text_sm()
|
||||||
@@ -413,58 +104,3 @@ fn find_installed_plugin<'a>(
|
|||||||
) -> Option<&'a InstalledPlugin> {
|
) -> Option<&'a InstalledPlugin> {
|
||||||
snapshot.installed_plugins.iter().find(|plugin| plugin.id() == plugin_id)
|
snapshot.installed_plugins.iter().find(|plugin| plugin.id() == plugin_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sync_participation_label(manifest: &PluginManifest) -> &'static str {
|
|
||||||
if manifest
|
|
||||||
.permissions()
|
|
||||||
.iter()
|
|
||||||
.any(|permission| matches!(permission, PluginPermission::SyncPlugin))
|
|
||||||
{
|
|
||||||
"Participates"
|
|
||||||
} else {
|
|
||||||
"Local"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn permission_risk_label(risk: PluginPermissionRisk) -> &'static str {
|
|
||||||
match risk {
|
|
||||||
PluginPermissionRisk::Standard => "Standard",
|
|
||||||
PluginPermissionRisk::High => "High",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn permission_icon(permission: &PluginPermission) -> IconName {
|
|
||||||
if permission.requires_separate_confirmation() {
|
|
||||||
IconName::TriangleAlert
|
|
||||||
} else {
|
|
||||||
IconName::CircleCheck
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn permission_scope_label(permission: &PluginPermission) -> &'static str {
|
|
||||||
match permission {
|
|
||||||
PluginPermission::TabsRead => "Reads tab metadata.",
|
|
||||||
PluginPermission::TabsWrite => "Creates, moves, or closes tabs.",
|
|
||||||
PluginPermission::SpacesRead => "Reads Space metadata.",
|
|
||||||
PluginPermission::SpacesWrite => "Creates or changes Spaces.",
|
|
||||||
PluginPermission::BookmarksRead => "Reads bookmarks.",
|
|
||||||
PluginPermission::BookmarksWrite => "Writes bookmarks.",
|
|
||||||
PluginPermission::HistoryRead => "Reads browsing history.",
|
|
||||||
PluginPermission::DownloadsRead => "Reads download entries.",
|
|
||||||
PluginPermission::DownloadsWrite => "Controls downloads.",
|
|
||||||
PluginPermission::PageMetadata => "Reads active page metadata.",
|
|
||||||
PluginPermission::PageScreenshot => "Captures page screenshots.",
|
|
||||||
PluginPermission::PageScript => "Runs scoped page scripts.",
|
|
||||||
PluginPermission::ClipboardRead => "Reads clipboard content.",
|
|
||||||
PluginPermission::ClipboardWrite => "Writes clipboard content.",
|
|
||||||
PluginPermission::FilesystemRead => "Reads user-selected files.",
|
|
||||||
PluginPermission::FilesystemWrite => "Writes user-selected files.",
|
|
||||||
PluginPermission::NetworkFetch => "Performs plugin network requests.",
|
|
||||||
PluginPermission::SettingsRead => "Reads plugin settings.",
|
|
||||||
PluginPermission::SettingsWrite => "Writes plugin settings.",
|
|
||||||
PluginPermission::SyncPlugin => "Syncs plugin configuration.",
|
|
||||||
PluginPermission::UiPanel => "Registers sidebar panels.",
|
|
||||||
PluginPermission::UiCommand => "Registers command bar actions.",
|
|
||||||
PluginPermission::UiContextMenu => "Registers context menu actions.",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user