feat(engine): win-summary action hierarchy — Play Again, Watch, Share #164
@@ -5,6 +5,13 @@
|
|||||||
//! started), a full-screen modal is spawned showing score, time, XP, and a
|
//! started), a full-screen modal is spawned showing score, time, XP, and a
|
||||||
//! "Play Again" button that fires `NewGameRequestEvent` and closes the modal.
|
//! "Play Again" button that fires `NewGameRequestEvent` and closes the modal.
|
||||||
//!
|
//!
|
||||||
|
//! # Phase G (docs/ui-redesign-2026-07.md) — action hierarchy
|
||||||
|
//! The modal leads with actions: **Play Again** (primary, Enter
|
||||||
|
//! accelerator), then **Watch Replay** and **Share Replay** (shared
|
||||||
|
//! `stats_plugin` markers, so the global handlers there act on the
|
||||||
|
//! just-won replay), with the score/time/XP recap reading quietly
|
||||||
|
//! below.
|
||||||
|
//!
|
||||||
//! # Task #47 — Win fanfare screen-shake
|
//! # Task #47 — Win fanfare screen-shake
|
||||||
//! When `GameWonEvent` fires, `ScreenShakeResource` is set. A system offsets
|
//! When `GameWonEvent` fires, `ScreenShakeResource` is set. A system offsets
|
||||||
//! the `Camera2d` `Transform` each frame with a decaying oscillation until the
|
//! the `Camera2d` `Transform` each frame with a decaying oscillation until the
|
||||||
@@ -23,7 +30,7 @@ use crate::game_plugin::GameMutation;
|
|||||||
use crate::progress_plugin::ProgressResource;
|
use crate::progress_plugin::ProgressResource;
|
||||||
use crate::resources::GameStateResource;
|
use crate::resources::GameStateResource;
|
||||||
use crate::settings_plugin::SettingsResource;
|
use crate::settings_plugin::SettingsResource;
|
||||||
use crate::stats_plugin::{StatsResource, StatsUpdate};
|
use crate::stats_plugin::{CopyShareLinkButton, StatsResource, StatsUpdate, WatchReplayButton};
|
||||||
use crate::ui_modal::ModalScrim;
|
use crate::ui_modal::ModalScrim;
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
ACCENT_PRIMARY, BG_BASE, BG_ELEVATED, MOTION_SCORE_BREAKDOWN_FADE_SECS,
|
ACCENT_PRIMARY, BG_BASE, BG_ELEVATED, MOTION_SCORE_BREAKDOWN_FADE_SECS,
|
||||||
@@ -163,12 +170,15 @@ pub struct SessionAchievements {
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct WinSummaryOverlay;
|
pub struct WinSummaryOverlay;
|
||||||
|
|
||||||
/// Marker on the "Play Again" / "Watch Replay" buttons inside the win-summary modal.
|
/// Marker on the "Play Again" button inside the win-summary modal.
|
||||||
|
///
|
||||||
|
/// Watch Replay and Share Replay carry the shared
|
||||||
|
/// [`WatchReplayButton`] / [`CopyShareLinkButton`] markers from
|
||||||
|
/// `stats_plugin`, so the global handlers there drive them (both act
|
||||||
|
/// on [`crate::stats_plugin::SelectedReplayIndex`], which snaps to the
|
||||||
|
/// just-won replay on every `GameWonEvent`).
|
||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
enum WinSummaryButton {
|
struct WinSummaryPlayAgainButton;
|
||||||
PlayAgain,
|
|
||||||
WatchReplay,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Marker for one row of the win-modal score-breakdown reveal.
|
/// Marker for one row of the win-modal score-breakdown reveal.
|
||||||
///
|
///
|
||||||
@@ -230,6 +240,7 @@ impl Plugin for WinSummaryPlugin {
|
|||||||
collect_session_achievements,
|
collect_session_achievements,
|
||||||
spawn_win_summary_after_delay,
|
spawn_win_summary_after_delay,
|
||||||
handle_win_summary_buttons,
|
handle_win_summary_buttons,
|
||||||
|
close_overlay_on_watch_replay,
|
||||||
handle_win_summary_keyboard,
|
handle_win_summary_keyboard,
|
||||||
apply_screen_shake,
|
apply_screen_shake,
|
||||||
reveal_score_breakdown,
|
reveal_score_breakdown,
|
||||||
@@ -604,50 +615,48 @@ fn spawn_win_summary_after_delay(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handles "Play Again" and "Watch Replay" in the win-summary modal.
|
/// Handles "Play Again" in the win-summary modal: collapses the
|
||||||
/// Handles "Play Again" and "Watch Replay" in the win-summary modal.
|
/// overlay and requests a fresh deal. `NewGameRequestEvent::default()`
|
||||||
|
/// reuses the current game's `GameMode`, and `handle_new_game` reads
|
||||||
|
/// the deal options (draw mode, difficulty) from `Settings` — so the
|
||||||
|
/// rematch is "same mode + same options" in one tap.
|
||||||
fn handle_win_summary_buttons(
|
fn handle_win_summary_buttons(
|
||||||
interaction_query: Query<(&Interaction, &WinSummaryButton), Changed<Interaction>>,
|
interaction_query: Query<&Interaction, (Changed<Interaction>, With<WinSummaryPlayAgainButton>)>,
|
||||||
overlays: Query<Entity, With<WinSummaryOverlay>>,
|
overlays: Query<Entity, With<WinSummaryOverlay>>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
mut new_game: MessageWriter<NewGameRequestEvent>,
|
mut new_game: MessageWriter<NewGameRequestEvent>,
|
||||||
mut toast: MessageWriter<InfoToastEvent>,
|
|
||||||
history: Option<Res<crate::stats_plugin::ReplayHistoryResource>>,
|
|
||||||
mut playback: Option<ResMut<crate::replay_playback::ReplayPlaybackState>>,
|
|
||||||
) {
|
) {
|
||||||
// Collect all pressed buttons first to avoid moving `playback` inside the loop.
|
if !interaction_query.iter().any(|i| *i == Interaction::Pressed) {
|
||||||
let pressed: Vec<&WinSummaryButton> = interaction_query
|
return;
|
||||||
.iter()
|
}
|
||||||
.filter(|(i, _)| **i == Interaction::Pressed)
|
for entity in &overlays {
|
||||||
.map(|(_, b)| b)
|
commands.entity(entity).despawn();
|
||||||
.collect();
|
}
|
||||||
|
new_game.write(NewGameRequestEvent::default());
|
||||||
|
}
|
||||||
|
|
||||||
for button in pressed {
|
/// Collapses the win-summary overlay when its "Watch Replay" button is
|
||||||
match button {
|
/// pressed, so playback (started by `stats_plugin`'s global
|
||||||
WinSummaryButton::PlayAgain => {
|
/// [`WatchReplayButton`] handler reacting to the same press) is not
|
||||||
for entity in &overlays {
|
/// hidden behind the celebration scrim. No-op while the overlay is
|
||||||
commands.entity(entity).despawn();
|
/// closed — the Replays-tab copy of the button manages its own modal.
|
||||||
}
|
///
|
||||||
new_game.write(NewGameRequestEvent::default());
|
/// "Share Replay" ([`CopyShareLinkButton`]) deliberately does NOT
|
||||||
}
|
/// close the overlay: the player stays on the celebration while the
|
||||||
WinSummaryButton::WatchReplay => {
|
/// copy-feedback toast confirms the link.
|
||||||
let latest = history.as_ref().and_then(|h| h.0.replays.last()).cloned();
|
fn close_overlay_on_watch_replay(
|
||||||
match (latest, playback.as_mut()) {
|
buttons: Query<&Interaction, (Changed<Interaction>, With<WatchReplayButton>)>,
|
||||||
(Some(replay), Some(pb)) => {
|
overlays: Query<Entity, With<WinSummaryOverlay>>,
|
||||||
for entity in &overlays {
|
mut commands: Commands,
|
||||||
commands.entity(entity).despawn();
|
) {
|
||||||
}
|
if overlays.is_empty() {
|
||||||
crate::replay_playback::start_replay_playback(&mut commands, pb, replay);
|
return;
|
||||||
}
|
}
|
||||||
(Some(_), None) => {
|
if !buttons.iter().any(|i| *i == Interaction::Pressed) {
|
||||||
toast.write(InfoToastEvent("Replay playback not available".to_string()));
|
return;
|
||||||
}
|
}
|
||||||
(None, _) => {
|
for entity in &overlays {
|
||||||
toast.write(InfoToastEvent("No replay saved yet".to_string()));
|
commands.entity(entity).despawn();
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -818,18 +827,68 @@ fn spawn_overlay(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Score breakdown reveal — replaces the previous single
|
// --- Action hierarchy (Phase G) ---
|
||||||
// "Score:" line with a per-component multi-row layout.
|
// Play Again is the hero action; the two replay
|
||||||
|
// actions sit under it; the stats recap reads quietly
|
||||||
|
// below all three. Rematch is one tap.
|
||||||
|
|
||||||
|
// Play Again (primary, full row)
|
||||||
|
card.spawn((
|
||||||
|
WinSummaryPlayAgainButton,
|
||||||
|
Button,
|
||||||
|
Node {
|
||||||
|
padding: UiRect::axes(Val::Px(20.0), VAL_SPACE_3),
|
||||||
|
justify_content: JustifyContent::Center,
|
||||||
|
align_self: AlignSelf::Stretch,
|
||||||
|
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
||||||
|
margin: UiRect::top(VAL_SPACE_2),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(ACCENT_PRIMARY),
|
||||||
|
))
|
||||||
|
.with_children(|b| {
|
||||||
|
b.spawn((
|
||||||
|
Text::new("Play Again \u{21B5}"),
|
||||||
|
TextFont {
|
||||||
|
font_size: TYPE_BODY_LG,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
TextColor(BG_BASE),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Watch Replay + Share Replay (secondary, side by side).
|
||||||
|
// Both reuse the global stats_plugin handlers, which
|
||||||
|
// target the just-won replay (`SelectedReplayIndex`
|
||||||
|
// snaps to 0 on every win). Each is always rendered —
|
||||||
|
// with no replay / no share URL the handler explains
|
||||||
|
// itself in a toast instead of silently doing nothing.
|
||||||
|
card.spawn(Node {
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
justify_content: JustifyContent::Center,
|
||||||
|
column_gap: VAL_SPACE_3,
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
.with_children(|row| {
|
||||||
|
spawn_replay_action(row, WatchReplayButton, "Watch Replay");
|
||||||
|
spawn_replay_action(row, CopyShareLinkButton, "Share Replay");
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Quiet stats recap, below the actions ---
|
||||||
|
|
||||||
|
// Score breakdown reveal — per-component multi-row
|
||||||
|
// layout with the staggered fade-in.
|
||||||
spawn_score_breakdown(card, &breakdown, anim_speed);
|
spawn_score_breakdown(card, &breakdown, anim_speed);
|
||||||
|
|
||||||
// Time
|
// Time (demoted to body/secondary — part of the quiet
|
||||||
|
// recap, not the celebration headline)
|
||||||
card.spawn((
|
card.spawn((
|
||||||
Text::new(format!("Time: {}", format_win_time(pending.time_seconds))),
|
Text::new(format!("Time: {}", format_win_time(pending.time_seconds))),
|
||||||
TextFont {
|
TextFont {
|
||||||
font_size: TYPE_HEADLINE,
|
font_size: TYPE_BODY_LG,
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
TextColor(TEXT_PRIMARY),
|
TextColor(TEXT_SECONDARY),
|
||||||
));
|
));
|
||||||
|
|
||||||
// XP total
|
// XP total
|
||||||
@@ -859,68 +918,40 @@ fn spawn_overlay(
|
|||||||
if !session.names.is_empty() {
|
if !session.names.is_empty() {
|
||||||
spawn_achievements_section(card, &session.names);
|
spawn_achievements_section(card, &session.names);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Button row: Watch Replay + Play Again side by side.
|
|
||||||
card.spawn(Node {
|
|
||||||
flex_direction: FlexDirection::Row,
|
|
||||||
justify_content: JustifyContent::Center,
|
|
||||||
column_gap: VAL_SPACE_3,
|
|
||||||
margin: UiRect::top(VAL_SPACE_2),
|
|
||||||
..default()
|
|
||||||
})
|
|
||||||
.with_children(|row| {
|
|
||||||
// Watch Replay (secondary style)
|
|
||||||
row.spawn((
|
|
||||||
WinSummaryButton::WatchReplay,
|
|
||||||
Button,
|
|
||||||
Node {
|
|
||||||
padding: UiRect::axes(Val::Px(20.0), VAL_SPACE_3),
|
|
||||||
justify_content: JustifyContent::Center,
|
|
||||||
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
|
||||||
border: UiRect::all(Val::Px(1.0)),
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
BackgroundColor(Color::NONE),
|
|
||||||
BorderColor::all(ACCENT_PRIMARY),
|
|
||||||
))
|
|
||||||
.with_children(|b| {
|
|
||||||
b.spawn((
|
|
||||||
Text::new("Watch Replay"),
|
|
||||||
TextFont {
|
|
||||||
font_size: TYPE_BODY_LG,
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
TextColor(ACCENT_PRIMARY),
|
|
||||||
));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Play Again (primary style)
|
|
||||||
row.spawn((
|
|
||||||
WinSummaryButton::PlayAgain,
|
|
||||||
Button,
|
|
||||||
Node {
|
|
||||||
padding: UiRect::axes(Val::Px(20.0), VAL_SPACE_3),
|
|
||||||
justify_content: JustifyContent::Center,
|
|
||||||
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
BackgroundColor(ACCENT_PRIMARY),
|
|
||||||
))
|
|
||||||
.with_children(|b| {
|
|
||||||
b.spawn((
|
|
||||||
Text::new("Play Again \u{21B5}"),
|
|
||||||
TextFont {
|
|
||||||
font_size: TYPE_BODY_LG,
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
TextColor(BG_BASE),
|
|
||||||
));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Spawns one secondary (outline-style) replay action button in the
|
||||||
|
/// win modal's replay row. `marker` is the shared `stats_plugin`
|
||||||
|
/// click-target component (`WatchReplayButton` / `CopyShareLinkButton`)
|
||||||
|
/// whose global handler reacts to the press.
|
||||||
|
fn spawn_replay_action<M: Component>(row: &mut ChildSpawnerCommands, marker: M, label: &str) {
|
||||||
|
row.spawn((
|
||||||
|
marker,
|
||||||
|
Button,
|
||||||
|
Node {
|
||||||
|
padding: UiRect::axes(Val::Px(20.0), VAL_SPACE_3),
|
||||||
|
justify_content: JustifyContent::Center,
|
||||||
|
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
||||||
|
border: UiRect::all(Val::Px(1.0)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(Color::NONE),
|
||||||
|
BorderColor::all(ACCENT_PRIMARY),
|
||||||
|
))
|
||||||
|
.with_children(|b| {
|
||||||
|
b.spawn((
|
||||||
|
Text::new(label.to_string()),
|
||||||
|
TextFont {
|
||||||
|
font_size: TYPE_BODY_LG,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
TextColor(ACCENT_PRIMARY),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Maximum number of achievement names shown explicitly in the win modal before
|
/// Maximum number of achievement names shown explicitly in the win modal before
|
||||||
/// the overflow "...and N more" line is shown instead.
|
/// the overflow "...and N more" line is shown instead.
|
||||||
const MAX_ACHIEVEMENTS_SHOWN: usize = 3;
|
const MAX_ACHIEVEMENTS_SHOWN: usize = 3;
|
||||||
@@ -1863,4 +1894,134 @@ mod tests {
|
|||||||
assert_eq!(stagger, 0.0);
|
assert_eq!(stagger, 0.0);
|
||||||
assert_eq!(fade, 0.0);
|
assert_eq!(fade, 0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Phase G — action hierarchy
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Like [`make_app`] but with `TimePlugin` disabled and a manual
|
||||||
|
/// `Time` resource, so tests can step the win-summary delay timer
|
||||||
|
/// deterministically via `Time::advance_by` (the real clock's
|
||||||
|
/// microsecond deltas would never reach the 0.5 s threshold).
|
||||||
|
fn make_app_manual_clock() -> App {
|
||||||
|
use bevy::time::TimePlugin;
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins.build().disable::<TimePlugin>())
|
||||||
|
.add_plugins(WinSummaryPlugin)
|
||||||
|
.insert_resource(StatsResource(StatsSnapshot::default()))
|
||||||
|
.insert_resource(GameStateResource(GameState::new(
|
||||||
|
0,
|
||||||
|
solitaire_core::DrawStockConfig::DrawOne,
|
||||||
|
)))
|
||||||
|
.insert_resource(ProgressResource(PlayerProgress::default()));
|
||||||
|
app.init_resource::<Time>();
|
||||||
|
app.update();
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drives the real spawn path: fire `GameWonEvent`, then advance
|
||||||
|
/// `Time` past the 0.5 s celebration delay so
|
||||||
|
/// `spawn_win_summary_after_delay` spawns the overlay.
|
||||||
|
fn open_win_overlay(app: &mut App) {
|
||||||
|
app.world_mut().write_message(GameWonEvent {
|
||||||
|
score: 1200,
|
||||||
|
time_seconds: 90,
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
{
|
||||||
|
let mut time = app.world_mut().resource_mut::<Time>();
|
||||||
|
time.advance_by(std::time::Duration::from_secs_f32(
|
||||||
|
WIN_SUMMARY_DELAY_SECS + 0.1,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
app.update();
|
||||||
|
// One more frame so the deferred spawn commands flush.
|
||||||
|
app.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn overlay_count(app: &mut App) -> usize {
|
||||||
|
app.world_mut()
|
||||||
|
.query::<&WinSummaryOverlay>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn button_entity<M: Component>(app: &mut App) -> Entity {
|
||||||
|
let entities: Vec<Entity> = app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<Entity, With<M>>()
|
||||||
|
.iter(app.world())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(entities.len(), 1, "expected exactly one button");
|
||||||
|
entities[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The win modal must render all three Phase G actions: the
|
||||||
|
/// primary Play Again plus the shared Watch/Share replay buttons.
|
||||||
|
#[test]
|
||||||
|
fn win_modal_renders_play_again_watch_and_share_actions() {
|
||||||
|
let mut app = make_app_manual_clock();
|
||||||
|
open_win_overlay(&mut app);
|
||||||
|
assert_eq!(overlay_count(&mut app), 1, "overlay must spawn after delay");
|
||||||
|
button_entity::<WinSummaryPlayAgainButton>(&mut app);
|
||||||
|
button_entity::<WatchReplayButton>(&mut app);
|
||||||
|
button_entity::<CopyShareLinkButton>(&mut app);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Play Again collapses the overlay and requests a fresh deal.
|
||||||
|
#[test]
|
||||||
|
fn play_again_press_closes_overlay_and_requests_new_game() {
|
||||||
|
use bevy::ecs::message::Messages;
|
||||||
|
|
||||||
|
let mut app = make_app_manual_clock();
|
||||||
|
open_win_overlay(&mut app);
|
||||||
|
let button = button_entity::<WinSummaryPlayAgainButton>(&mut app);
|
||||||
|
app.world_mut()
|
||||||
|
.entity_mut(button)
|
||||||
|
.insert(Interaction::Pressed);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert_eq!(overlay_count(&mut app), 0, "Play Again must close overlay");
|
||||||
|
let events = app.world().resource::<Messages<NewGameRequestEvent>>();
|
||||||
|
assert!(
|
||||||
|
!events.is_empty(),
|
||||||
|
"Play Again must write NewGameRequestEvent"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Watch Replay collapses the overlay so playback (driven by the
|
||||||
|
/// global `stats_plugin` handler on the same press) is visible.
|
||||||
|
#[test]
|
||||||
|
fn watch_replay_press_closes_the_overlay() {
|
||||||
|
let mut app = make_app_manual_clock();
|
||||||
|
open_win_overlay(&mut app);
|
||||||
|
let button = button_entity::<WatchReplayButton>(&mut app);
|
||||||
|
app.world_mut()
|
||||||
|
.entity_mut(button)
|
||||||
|
.insert(Interaction::Pressed);
|
||||||
|
app.update();
|
||||||
|
assert_eq!(
|
||||||
|
overlay_count(&mut app),
|
||||||
|
0,
|
||||||
|
"Watch Replay must close the celebration overlay"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Share Replay leaves the overlay open — the player stays on the
|
||||||
|
/// celebration while the copy-feedback toast confirms the link.
|
||||||
|
#[test]
|
||||||
|
fn share_replay_press_keeps_the_overlay_open() {
|
||||||
|
let mut app = make_app_manual_clock();
|
||||||
|
open_win_overlay(&mut app);
|
||||||
|
let button = button_entity::<CopyShareLinkButton>(&mut app);
|
||||||
|
app.world_mut()
|
||||||
|
.entity_mut(button)
|
||||||
|
.insert(Interaction::Pressed);
|
||||||
|
app.update();
|
||||||
|
assert_eq!(
|
||||||
|
overlay_count(&mut app),
|
||||||
|
1,
|
||||||
|
"Share Replay must not close the overlay"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user