refactor(engine): split hud_plugin runtime code into submodules
Continues #118 after settings_plugin (PR #127): the 2,725-line mod.rs becomes five focused submodules along existing system boundaries — mod.rs 553 markers, resources, popover enums, plugin build spawn.rs 552 band / columns / avatar / action-bar construction interaction.rs 616 button handlers, Modes/Menu popovers, tap gesture fx.rs 430 action fades, score pulses/floaters, streak flourish updates.rs 612 HUD text / typography / visibility updaters Moved items are pub(super) (fx re-exported pub for HudActionFade and format_time_limit consumers). Code moved verbatim; no behaviour change; all 44 hud tests pass unchanged. Refs #118 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,552 @@
|
||||
//! HUD construction: band, columns, avatar, and action-bar spawning.
|
||||
|
||||
use super::*;
|
||||
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use crate::avatar_plugin::AvatarResource;
|
||||
|
||||
/// Spawns the invisible HUD band that reserves vertical space at the top of
|
||||
/// the screen so the card layout (computed by `layout::compute_layout` using
|
||||
/// `HUD_BAND_HEIGHT`) aligns correctly below the score readouts.
|
||||
///
|
||||
/// The entity carries no `BackgroundColor` — the green felt shows through.
|
||||
/// A slim grey background is handled by each content section individually
|
||||
/// (the bottom action bar has its own `BG_HUD_BAND` background).
|
||||
pub(super) fn spawn_hud_band(mut commands: Commands) {
|
||||
const BASE_TOP: f32 = 0.0;
|
||||
commands.spawn((
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
top: Val::Px(BASE_TOP),
|
||||
left: Val::Px(0.0),
|
||||
width: Val::Percent(100.0),
|
||||
height: Val::Px(HUD_BAND_HEIGHT),
|
||||
..default()
|
||||
},
|
||||
ZIndex(Z_HUD - 1),
|
||||
SafeAreaAnchoredTop { base_top: BASE_TOP },
|
||||
HudBand,
|
||||
));
|
||||
}
|
||||
|
||||
/// Spawns the in-game HUD as a 4-tier vertical column anchored to the
|
||||
/// top-left of the play area.
|
||||
///
|
||||
/// Tiers (top to bottom):
|
||||
/// 1. **Primary** — Score (display weight) · Moves · Timer.
|
||||
/// Always visible during gameplay.
|
||||
/// 2. **Mode context** — Mode badge · Daily-challenge constraint ·
|
||||
/// Draw-cycle indicator. Each cell is empty when not relevant; the
|
||||
/// row collapses visually when all cells are empty.
|
||||
/// 3. **Penalty / bonus** — Undos · Recycles · Auto-complete badge.
|
||||
/// Both penalty counters share `STATE_WARNING` (the audit found
|
||||
/// they were inconsistent: Undos amber, Recycles white).
|
||||
/// 4. **Selection** — keyboard-driven pile selector chip.
|
||||
///
|
||||
/// The audit identified the original single-row layout (10 readouts in
|
||||
/// one horizontal flex row, 5+ colour families competing) as the
|
||||
/// player's #1 complaint. This restructure groups by purpose, lets
|
||||
/// transient items disappear cleanly, and uses the typography scale to
|
||||
/// make Score the visual protagonist.
|
||||
pub(super) fn spawn_hud(font_res: Option<Res<FontResource>>, mut commands: Commands) {
|
||||
let font_handle = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default();
|
||||
let font_score = TextFont {
|
||||
font: font_handle.clone(),
|
||||
font_size: TYPE_HEADLINE,
|
||||
..default()
|
||||
};
|
||||
let font_lg = TextFont {
|
||||
font: font_handle.clone(),
|
||||
font_size: TYPE_BODY_LG,
|
||||
..default()
|
||||
};
|
||||
let font_body = TextFont {
|
||||
font: font_handle,
|
||||
font_size: TYPE_BODY,
|
||||
..default()
|
||||
};
|
||||
|
||||
let row_node = || Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
column_gap: VAL_SPACE_3,
|
||||
// On a narrow viewport the four tier rows (Score/Moves/Timer,
|
||||
// Mode/Challenge/Draw-cycle/Won-previously, Undos/Recycles/
|
||||
// Auto-complete, selection chip) can collectively be wider than
|
||||
// the available space and overflow into the action-button column
|
||||
// on the right. `flex_wrap: Wrap` lets each tier soft-wrap onto
|
||||
// a second line; on a desktop window the rows stay single-line
|
||||
// because the parent column has no width cap and the row never
|
||||
// exceeds the natural line width.
|
||||
flex_wrap: FlexWrap::Wrap,
|
||||
row_gap: VAL_SPACE_1,
|
||||
align_items: AlignItems::Baseline,
|
||||
..default()
|
||||
};
|
||||
|
||||
commands
|
||||
.spawn((
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left: VAL_SPACE_3,
|
||||
top: Val::Px(SPACE_2),
|
||||
flex_direction: FlexDirection::Column,
|
||||
// Cap the column at 50% of viewport so on narrow
|
||||
// (mobile) widths the inner tier rows have a bounded
|
||||
// width to wrap against, and the column can't bleed
|
||||
// into the right-anchored action button row (also
|
||||
// capped at 50%). On desktop 50% of 1920 = 960 px,
|
||||
// wider than any tier row's natural width, so the
|
||||
// visible layout is unaffected.
|
||||
max_width: Val::Percent(50.0),
|
||||
row_gap: VAL_SPACE_1,
|
||||
..default()
|
||||
},
|
||||
ZIndex(Z_HUD),
|
||||
SafeAreaAnchoredTop { base_top: SPACE_2 },
|
||||
HudColumn,
|
||||
))
|
||||
.with_children(|hud| {
|
||||
// Tier 1 — primary readouts. Score is the protagonist (HEADLINE);
|
||||
// Moves and Timer are supporting context (BODY_LG, secondary tone).
|
||||
hud.spawn(row_node()).with_children(|t1| {
|
||||
t1.spawn((
|
||||
HudScore,
|
||||
Tooltip::new("Points earned this game. Hidden in Zen mode."),
|
||||
Text::new("Score: 0"),
|
||||
font_score.clone(),
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
t1.spawn((
|
||||
HudMoves,
|
||||
Tooltip::new("Moves you've made this game. Counts placements and stock draws."),
|
||||
Text::new("Moves: 0"),
|
||||
font_lg.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
t1.spawn((
|
||||
HudTime,
|
||||
Tooltip::new("Time on this game. Counts down in Time Attack."),
|
||||
Text::new("0:00"),
|
||||
font_lg.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
});
|
||||
|
||||
// Tier 2 — mode context. Each cell is empty until update_hud
|
||||
// populates it (and clears it when no longer relevant), so the
|
||||
// row collapses when nothing in this tier applies.
|
||||
hud.spawn(row_node()).with_children(|t2| {
|
||||
t2.spawn((
|
||||
HudMode,
|
||||
Tooltip::new("Active game mode. Click Modes to switch."),
|
||||
Text::new(""),
|
||||
font_body.clone(),
|
||||
TextColor(ACCENT_PRIMARY),
|
||||
));
|
||||
t2.spawn((
|
||||
HudChallenge,
|
||||
Tooltip::new("Today's daily challenge target. Beat it for bonus XP."),
|
||||
Text::new(""),
|
||||
font_body.clone(),
|
||||
TextColor(STATE_INFO),
|
||||
));
|
||||
t2.spawn((
|
||||
HudDrawCycle,
|
||||
Tooltip::new("Cards drawn on the next stock click in Draw-Three."),
|
||||
Text::new(""),
|
||||
font_body.clone(),
|
||||
TextColor(STATE_INFO),
|
||||
));
|
||||
t2.spawn((
|
||||
HudWonPreviously,
|
||||
Tooltip::new("You've won this deal before. Same seed in your replay history."),
|
||||
Text::new(""),
|
||||
font_body.clone(),
|
||||
TextColor(STATE_SUCCESS),
|
||||
));
|
||||
});
|
||||
|
||||
// Tier 3 — penalty / bonus. Undos and Recycles share the
|
||||
// warning hue so they read as the same category ("you took a
|
||||
// penalty"); the auto-complete badge stays success-green.
|
||||
hud.spawn(row_node()).with_children(|t3| {
|
||||
t3.spawn((
|
||||
HudUndos,
|
||||
Tooltip::new("Undos used this game. Any undo blocks the No Undo achievement."),
|
||||
Text::new(""),
|
||||
font_body.clone(),
|
||||
TextColor(STATE_WARNING),
|
||||
));
|
||||
t3.spawn((
|
||||
HudRecycles,
|
||||
Tooltip::new(
|
||||
"Times you've recycled the stock. Three or more unlocks Comeback.",
|
||||
),
|
||||
Text::new(""),
|
||||
font_body.clone(),
|
||||
TextColor(STATE_WARNING),
|
||||
));
|
||||
t3.spawn((
|
||||
HudAutoComplete,
|
||||
Tooltip::new("Board is solvable from here. Press Enter to auto-finish."),
|
||||
Text::new(""),
|
||||
font_body.clone(),
|
||||
TextColor(STATE_SUCCESS),
|
||||
));
|
||||
});
|
||||
|
||||
// Tier 4 — selection chip. Stays in HUD for now; a future
|
||||
// pass can reposition it next to the selected pile.
|
||||
hud.spawn(row_node()).with_children(|t4| {
|
||||
t4.spawn((
|
||||
HudSelection,
|
||||
Tooltip::new("Pile selected with Tab. Use arrows or Enter to act."),
|
||||
Text::new(""),
|
||||
font_body,
|
||||
TextColor(ACCENT_SECONDARY),
|
||||
));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Spawns the circular avatar / initials button anchored to the top-right
|
||||
/// of the HUD band. Initial content is seeded from whatever resources are
|
||||
/// available at startup; `update_hud_avatar` replaces the children whenever
|
||||
/// `AvatarResource` or `SettingsResource` later changes.
|
||||
pub(super) fn spawn_hud_avatar(
|
||||
font_res: Option<Res<FontResource>>,
|
||||
avatar: Option<Res<AvatarResource>>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
const SIZE: f32 = 32.0;
|
||||
let id = commands
|
||||
.spawn((
|
||||
HudAvatar,
|
||||
Button,
|
||||
Tooltip::new("Your profile — tap to open."),
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
top: Val::Px(SPACE_2),
|
||||
right: VAL_SPACE_3,
|
||||
width: Val::Px(SIZE),
|
||||
height: Val::Px(SIZE),
|
||||
border_radius: BorderRadius::all(Val::Px(SIZE / 2.0)),
|
||||
align_items: AlignItems::Center,
|
||||
justify_content: JustifyContent::Center,
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(ACCENT_PRIMARY),
|
||||
ZIndex(Z_HUD),
|
||||
SafeAreaAnchoredTop { base_top: SPACE_2 },
|
||||
))
|
||||
.id();
|
||||
spawn_avatar_child(
|
||||
&mut commands,
|
||||
id,
|
||||
avatar.as_deref(),
|
||||
settings.as_deref(),
|
||||
font_res.as_deref(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Re-spawns the avatar circle content (image or initials) whenever either
|
||||
/// [`AvatarResource`] or [`SettingsResource`] changes — covers both the
|
||||
/// image arriving after download and the username changing after login.
|
||||
pub(super) fn update_hud_avatar(
|
||||
avatar: Option<Res<AvatarResource>>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
q: Query<Entity, With<HudAvatar>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
let avatar_changed = avatar.as_ref().is_some_and(|r| r.is_changed());
|
||||
let settings_changed = settings.as_ref().is_some_and(|r| r.is_changed());
|
||||
if !avatar_changed && !settings_changed {
|
||||
return;
|
||||
}
|
||||
let Ok(entity) = q.single() else {
|
||||
return;
|
||||
};
|
||||
commands.entity(entity).despawn_related::<Children>();
|
||||
spawn_avatar_child(
|
||||
&mut commands,
|
||||
entity,
|
||||
avatar.as_deref(),
|
||||
settings.as_deref(),
|
||||
font_res.as_deref(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Populates the avatar container with either the downloaded image or an
|
||||
/// initials fallback disc. Called from both the startup spawn and the
|
||||
/// reactive update system so the rendering logic lives in one place.
|
||||
pub(super) fn spawn_avatar_child(
|
||||
commands: &mut Commands,
|
||||
parent: Entity,
|
||||
avatar: Option<&AvatarResource>,
|
||||
settings: Option<&SettingsResource>,
|
||||
font_res: Option<&FontResource>,
|
||||
) {
|
||||
const SIZE: f32 = 32.0;
|
||||
if let Some(handle) = avatar.and_then(|a| a.0.clone()) {
|
||||
// Logged-in with a downloaded avatar: keep the accent disc behind it.
|
||||
commands.entity(parent).insert(BackgroundColor(ACCENT_PRIMARY));
|
||||
// Image fills the circle container; border_radius clips it to a disc.
|
||||
commands.entity(parent).with_children(|b| {
|
||||
b.spawn((
|
||||
ImageNode::new(handle),
|
||||
Node {
|
||||
width: Val::Px(SIZE),
|
||||
height: Val::Px(SIZE),
|
||||
border_radius: BorderRadius::all(Val::Px(SIZE / 2.0)),
|
||||
..default()
|
||||
},
|
||||
));
|
||||
});
|
||||
} else {
|
||||
let initial = settings
|
||||
.and_then(|s| match &s.0.sync_backend {
|
||||
SyncBackend::SolitaireServer { username, .. } => username.chars().next(),
|
||||
SyncBackend::Local => None,
|
||||
})
|
||||
.and_then(|c| c.to_uppercase().next())
|
||||
.unwrap_or('?');
|
||||
// Real initial (logged in) keeps the red accent disc; the '?'
|
||||
// unauthenticated fallback uses a neutral grey so it reads as a
|
||||
// "tap to log in" affordance rather than an error.
|
||||
let disc_bg = if initial == '?' {
|
||||
BG_ELEVATED_HI
|
||||
} else {
|
||||
ACCENT_PRIMARY
|
||||
};
|
||||
commands.entity(parent).insert(BackgroundColor(disc_bg));
|
||||
commands.entity(parent).with_children(|b| {
|
||||
b.spawn((
|
||||
Text::new(initial.to_string()),
|
||||
TextFont {
|
||||
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||||
font_size: 14.0,
|
||||
..default()
|
||||
},
|
||||
TextColor(TEXT_PRIMARY),
|
||||
));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the Profile overlay when the avatar button is pressed.
|
||||
pub(super) fn handle_avatar_button(
|
||||
interaction_query: Query<&Interaction, (With<HudAvatar>, Changed<Interaction>)>,
|
||||
mut toggle_profile: MessageWriter<ToggleProfileRequestEvent>,
|
||||
) {
|
||||
for interaction in &interaction_query {
|
||||
if *interaction == Interaction::Pressed {
|
||||
toggle_profile.write(ToggleProfileRequestEvent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawns the action button bar anchored to the top-right of the window.
|
||||
/// Each child is a clickable button mirroring a keyboard accelerator —
|
||||
/// per the UI-first principle (CLAUDE.md / ARCHITECTURE.md §1) the buttons
|
||||
/// are the primary entry point and the hotkeys are optional.
|
||||
///
|
||||
/// Order (left → right): Undo, Pause, Help, New Game. New Game is rightmost
|
||||
/// because it's the most consequential action; the destructive button sits
|
||||
/// on its own visual edge.
|
||||
pub(super) fn spawn_action_buttons(
|
||||
font_res: Option<Res<FontResource>>,
|
||||
windows: Query<&Window>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
let action_font_size =
|
||||
action_bar_font_size(windows.iter().next().map_or(900.0, |win| win.width()));
|
||||
let font = TextFont {
|
||||
font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(),
|
||||
font_size: action_font_size,
|
||||
..default()
|
||||
};
|
||||
|
||||
// Bottom bar: full-width, centered, sits above the gesture-navigation zone.
|
||||
// `SafeAreaAnchoredBottom` applies the correct logical-pixel inset once
|
||||
// Android reports it (frames 1-3); initial value is 0.0.
|
||||
commands
|
||||
.spawn((
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
bottom: Val::Px(0.0),
|
||||
left: Val::Px(0.0),
|
||||
width: Val::Percent(100.0),
|
||||
flex_direction: FlexDirection::Row,
|
||||
flex_wrap: FlexWrap::Wrap,
|
||||
justify_content: JustifyContent::Center,
|
||||
column_gap: ACTION_BAR_COLUMN_GAP,
|
||||
row_gap: VAL_SPACE_2,
|
||||
align_items: AlignItems::Center,
|
||||
padding: UiRect {
|
||||
left: VAL_SPACE_3,
|
||||
right: VAL_SPACE_3,
|
||||
top: VAL_SPACE_2,
|
||||
bottom: VAL_SPACE_2,
|
||||
},
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(BG_HUD_BAND),
|
||||
ZIndex(Z_HUD),
|
||||
SafeAreaAnchoredBottom { base_bottom: 0.0 },
|
||||
HudActionBar,
|
||||
))
|
||||
.with_children(|row| {
|
||||
// The trailing `order` argument feeds `Focusable { group: Hud, order }`
|
||||
// so Tab cycles the action bar in visual reading order.
|
||||
// Undo and Pause are the primary gameplay actions — full brightness.
|
||||
// Menu, Help, Hint, Modes, New are navigation/utility — dimmed.
|
||||
spawn_action_button(
|
||||
row,
|
||||
MenuButton,
|
||||
ACTION_BAR_LABELS[0],
|
||||
None,
|
||||
"Open Stats, Achievements, Profile, Settings, or Leaderboard.",
|
||||
&font,
|
||||
0,
|
||||
TEXT_SECONDARY,
|
||||
);
|
||||
spawn_action_button(
|
||||
row,
|
||||
UndoButton,
|
||||
ACTION_BAR_LABELS[1],
|
||||
Some("U"),
|
||||
"Take back your last move. Costs points and blocks No Undo.",
|
||||
&font,
|
||||
1,
|
||||
TEXT_PRIMARY,
|
||||
);
|
||||
spawn_action_button(
|
||||
row,
|
||||
PauseButton,
|
||||
ACTION_BAR_LABELS[2],
|
||||
Some("Esc"),
|
||||
"Pause the game and freeze the timer.",
|
||||
&font,
|
||||
2,
|
||||
TEXT_PRIMARY,
|
||||
);
|
||||
spawn_action_button(
|
||||
row,
|
||||
HelpButton,
|
||||
ACTION_BAR_LABELS[3],
|
||||
Some("F1"),
|
||||
"Show controls, rules, and keyboard shortcuts.",
|
||||
&font,
|
||||
3,
|
||||
TEXT_SECONDARY,
|
||||
);
|
||||
spawn_action_button(
|
||||
row,
|
||||
HintButton,
|
||||
ACTION_BAR_LABELS[4],
|
||||
Some("H"),
|
||||
"Highlight a suggested move. Cycles through alternatives on repeat taps.",
|
||||
&font,
|
||||
4,
|
||||
TEXT_SECONDARY,
|
||||
);
|
||||
spawn_action_button(
|
||||
row,
|
||||
ModesButton,
|
||||
ACTION_BAR_LABELS[5],
|
||||
None,
|
||||
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack.",
|
||||
&font,
|
||||
5,
|
||||
TEXT_SECONDARY,
|
||||
);
|
||||
spawn_action_button(
|
||||
row,
|
||||
NewGameButton,
|
||||
ACTION_BAR_LABELS[6],
|
||||
Some("N"),
|
||||
"Start a fresh deal. Confirms first if a game is in progress.",
|
||||
&font,
|
||||
6,
|
||||
TEXT_SECONDARY,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Spawns a single action button as a child of `row`. Each button shares
|
||||
/// the same node geometry, idle colour, and `ActionButton` marker so
|
||||
/// `paint_action_buttons` can recolour all of them with one query.
|
||||
///
|
||||
/// `order` is the button's index inside the action bar (0 for the
|
||||
/// leftmost). It propagates into the [`Focusable`] this function inserts
|
||||
/// so Phase 2's keyboard focus ring cycles the HUD in visual order.
|
||||
///
|
||||
/// `tooltip` is the hover-reveal caption attached via [`Tooltip`]. Every
|
||||
/// action button ships with one — there is no opt-out — because each button
|
||||
/// represents a player-triggered action and benefits from a one-line
|
||||
/// reminder of what it does.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn spawn_action_button<M: Component>(
|
||||
row: &mut ChildSpawnerCommands,
|
||||
marker: M,
|
||||
label: &str,
|
||||
hotkey: Option<&'static str>,
|
||||
tooltip: &'static str,
|
||||
font: &TextFont,
|
||||
order: i32,
|
||||
text_color: Color,
|
||||
) {
|
||||
// Hotkey hint chips ("U", "Esc", "F1", "N") are meaningless on a
|
||||
// touch device — the button itself is the affordance — and they
|
||||
// visibly clutter the narrow-viewport action row. The chevrons on
|
||||
// Menu/Modes remain because they indicate dropdown behaviour.
|
||||
let hotkey = if SHOW_KEYBOARD_ACCELERATORS {
|
||||
hotkey
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let hotkey_font = TextFont {
|
||||
font: font.font.clone(),
|
||||
font_size: TYPE_CAPTION,
|
||||
..default()
|
||||
};
|
||||
let (pad, min_w, min_h) = action_button_metrics();
|
||||
|
||||
row.spawn((
|
||||
marker,
|
||||
ActionButton,
|
||||
Button,
|
||||
Tooltip::new(tooltip),
|
||||
Focusable {
|
||||
group: FocusGroup::Hud,
|
||||
order,
|
||||
},
|
||||
Node {
|
||||
padding: pad,
|
||||
min_width: min_w,
|
||||
min_height: min_h,
|
||||
justify_content: JustifyContent::Center,
|
||||
align_items: AlignItems::Center,
|
||||
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
||||
column_gap: VAL_SPACE_2,
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(ACTION_BTN_IDLE),
|
||||
BorderColor::all(BORDER_SUBTLE),
|
||||
HighContrastBorder::with_default(BORDER_SUBTLE),
|
||||
))
|
||||
.with_children(|b| {
|
||||
spawn_action_button_label(b, label, font, text_color);
|
||||
if let Some(key) = hotkey {
|
||||
// Hotkey hint rendered as a dim caption next to the label —
|
||||
// keeps the keyboard accelerator discoverable without
|
||||
// hijacking the button's primary affordance.
|
||||
b.spawn((Text::new(key), hotkey_font, TextColor(TEXT_SECONDARY)));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user