Notify when another player plays a card
Add an opt-in bell to the game HUD: the first click asks for the browser's notification permission, and the choice is remembered per device. A play by any other seat then raises a desktop notification with the player's name, the card and the next player, while the page is in the background and the viewer is actually seated in the game. Detect the newest play across the game families: scopone and scopa report it in last_move, while briscola and briscola chiamata only expose the trick, which mid-trick or once completed ends with the card just played. The first snapshot after every (re)connection is a resync baseline, so reconnects and the initial load never replay old moves; so are the viewer's own plays and the server's auto-plays. Deliver through the service worker registration (installed PWAs on Android reject page-context notifications), falling back to a page notification when no worker is active, under a per-game tag that replaces the previous move. sw.js now focuses the game window when a notification is clicked.
This commit is contained in:
+60
-8
@@ -9,12 +9,14 @@ use crate::api;
|
||||
use crate::components::briscola::{deal_rows, deal_summary_modal};
|
||||
use crate::components::card::{card_back, card_img};
|
||||
use crate::components::copy::CopyButton;
|
||||
use crate::components::notify::{notify_toggle, NotifyCtx};
|
||||
use crate::components::summary::{hand_summary_modal, summary_rows};
|
||||
use crate::components::toast::toast;
|
||||
use crate::model::{
|
||||
card_label, hand_sort_key, is_briscola, is_chiamata, is_individual_game, is_scopa, GameView,
|
||||
MoveView, PlayerView, Scores, ServerMessage,
|
||||
};
|
||||
use crate::notify::{self, Permission};
|
||||
use crate::pages::briscola::briscola_table;
|
||||
use crate::pages::chiamata::{call_picker, chiamata_table};
|
||||
use crate::pages::scopa::scopa_table;
|
||||
@@ -96,7 +98,7 @@ pub(crate) fn move_banner(mv: MoveView) -> View {
|
||||
}
|
||||
|
||||
/// Signals shared by the websocket connection and its reconnect attempts.
|
||||
#[derive(Clone, Copy)]
|
||||
#[derive(Clone)]
|
||||
struct ConnCtx {
|
||||
socket: Signal<Option<GameSocket>>,
|
||||
game: Signal<Option<GameView>>,
|
||||
@@ -111,6 +113,12 @@ struct ConnCtx {
|
||||
attempts: Signal<u32>,
|
||||
capture_choice: Signal<Option<(String, Vec<Vec<String>>)>>,
|
||||
selected: Signal<Option<String>>,
|
||||
/// Baseline for the move notifications; reset on every (re)connect.
|
||||
baseline: notify::Baseline,
|
||||
/// Whether the player opted into move notifications.
|
||||
notify_enabled: Signal<bool>,
|
||||
/// The browser's notification permission, as last read.
|
||||
notify_permission: Signal<Permission>,
|
||||
}
|
||||
|
||||
/// Reconnect attempts: 1s, 2s, 4s, … capped at 30s, at most this many.
|
||||
@@ -125,7 +133,11 @@ fn backoff_ms(attempt: u32) -> u32 {
|
||||
/// The server pushes a full state snapshot on connect, so a reconnect is
|
||||
/// also a resync: no client-side state merging is needed.
|
||||
fn start_connect(id: Rc<String>, ctx: ConnCtx, alive: Rc<Cell<bool>>) {
|
||||
// A fresh connection resyncs from scratch: the first snapshot it
|
||||
// delivers is the notification baseline, not a play to announce.
|
||||
ctx.baseline.reset();
|
||||
let on_message = {
|
||||
let ctx = ctx.clone();
|
||||
let alive = alive.clone();
|
||||
move |msg: ServerMessage| {
|
||||
if !alive.get() {
|
||||
@@ -134,6 +146,19 @@ fn start_connect(id: Rc<String>, ctx: ConnCtx, alive: Rc<Cell<bool>>) {
|
||||
}
|
||||
match msg {
|
||||
ServerMessage::State { game: g } => {
|
||||
// Observe every snapshot, even with the preference
|
||||
// off: enabling it mid-game must not replay old moves.
|
||||
let play = ctx.baseline.observe(&g);
|
||||
if let Some(play) = play {
|
||||
// Move notifications are for when the player
|
||||
// cannot watch the table themselves.
|
||||
if ctx.notify_enabled.get()
|
||||
&& ctx.notify_permission.get() == Permission::Granted
|
||||
&& notify::page_in_background()
|
||||
{
|
||||
notify::notify_move(&g, &play);
|
||||
}
|
||||
}
|
||||
ctx.capture_choice.set(None);
|
||||
ctx.selected.set(None);
|
||||
// A received state proves the (re)connection works.
|
||||
@@ -151,6 +176,7 @@ fn start_connect(id: Rc<String>, ctx: ConnCtx, alive: Rc<Cell<bool>>) {
|
||||
};
|
||||
let on_close = {
|
||||
let id = id.clone();
|
||||
let ctx = ctx.clone();
|
||||
let alive = alive.clone();
|
||||
move |_code: Option<u16>| {
|
||||
if !alive.get() {
|
||||
@@ -166,6 +192,7 @@ fn start_connect(id: Rc<String>, ctx: ConnCtx, alive: Rc<Cell<bool>>) {
|
||||
// transient. Only on the first failure: later attempts
|
||||
// are governed by the retry backoff.
|
||||
let id = id.clone();
|
||||
let ctx = ctx.clone();
|
||||
let alive = alive.clone();
|
||||
spawn_local(async move {
|
||||
match api::probe_game(&id).await {
|
||||
@@ -178,7 +205,7 @@ fn start_connect(id: Rc<String>, ctx: ConnCtx, alive: Rc<Cell<bool>>) {
|
||||
}
|
||||
});
|
||||
} else {
|
||||
schedule_retry(id.clone(), ctx, alive.clone());
|
||||
schedule_retry(id.clone(), ctx.clone(), alive.clone());
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -281,6 +308,17 @@ pub fn GamePage(id: String) -> View {
|
||||
}
|
||||
});
|
||||
|
||||
// Move notifications: opt-in and persisted per device. The signals
|
||||
// are page-scoped (not per table render) so a permission prompt that
|
||||
// resolves after the table rebuilt still lands somewhere safe.
|
||||
let notify_enabled = create_signal(notify::enabled());
|
||||
let notify_permission = create_signal(notify::permission());
|
||||
let notify_ctx = NotifyCtx {
|
||||
enabled: notify_enabled,
|
||||
permission: notify_permission,
|
||||
alive: alive.clone(),
|
||||
};
|
||||
|
||||
let id = Rc::new(id);
|
||||
let ctx = ConnCtx {
|
||||
socket,
|
||||
@@ -293,13 +331,16 @@ pub fn GamePage(id: String) -> View {
|
||||
attempts,
|
||||
capture_choice,
|
||||
selected,
|
||||
baseline: notify::Baseline::new(),
|
||||
notify_enabled,
|
||||
notify_permission,
|
||||
};
|
||||
start_connect(id.clone(), ctx, alive.clone());
|
||||
start_connect(id.clone(), ctx.clone(), alive.clone());
|
||||
let reconnect: Rc<dyn Fn()> = Rc::new(move || {
|
||||
ctx.attempts.set(0);
|
||||
ctx.gave_up.set(false);
|
||||
ctx.closed.set(false);
|
||||
start_connect(id.clone(), ctx, alive.clone());
|
||||
start_connect(id.clone(), ctx.clone(), alive.clone());
|
||||
});
|
||||
|
||||
// Clicking a card in the player's own hand.
|
||||
@@ -423,20 +464,28 @@ pub fn GamePage(id: String) -> View {
|
||||
// Briscola chiamata has its own five-seat table with the
|
||||
// calling auction and the secret-partner reveal.
|
||||
Some(g) if is_chiamata(&g.game_type) => {
|
||||
chiamata_table(g, on_hand_card, on_bid_rank, on_bid_points, on_pass, now)
|
||||
chiamata_table(
|
||||
g,
|
||||
on_hand_card,
|
||||
on_bid_rank,
|
||||
on_bid_points,
|
||||
on_pass,
|
||||
now,
|
||||
notify_ctx.clone(),
|
||||
)
|
||||
}
|
||||
// Briscola has its own table view for both variants.
|
||||
Some(g) if is_briscola(&g.game_type) => {
|
||||
briscola_table(g, on_hand_card, now)
|
||||
briscola_table(g, on_hand_card, now, notify_ctx.clone())
|
||||
}
|
||||
// Scopa has its own table view for both variants.
|
||||
Some(g) if is_scopa(&g.game_type) => {
|
||||
scopa_table(g, on_hand_card, now)
|
||||
scopa_table(g, on_hand_card, now, notify_ctx.clone())
|
||||
}
|
||||
// The live table is scopone-specific; other games get a
|
||||
// fallback until they ship their own play view.
|
||||
Some(g) if g.game_type == "scopone_scientifico" => {
|
||||
table_view(g, on_hand_card, selected, now)
|
||||
table_view(g, on_hand_card, selected, now, notify_ctx.clone())
|
||||
}
|
||||
Some(g) => unsupported_game_view(g),
|
||||
})
|
||||
@@ -606,6 +655,7 @@ fn table_view(
|
||||
on_hand_card: impl Fn(String) + Copy + 'static,
|
||||
selected: Signal<Option<String>>,
|
||||
now: Signal<f64>,
|
||||
notify: NotifyCtx,
|
||||
) -> View {
|
||||
// Own seat: the only player entry carrying a hand. Spectators have no
|
||||
// hand, so they watch from seat 0's perspective.
|
||||
@@ -713,6 +763,7 @@ fn table_view(
|
||||
let spectator_badge = game.spectator.then(|| view! {
|
||||
span(class="spectator-badge") { "Spectating" }
|
||||
});
|
||||
let bell = (!game.spectator).then(|| notify_toggle(notify));
|
||||
let watch_link = watch_link(&game.id);
|
||||
|
||||
view! {
|
||||
@@ -729,6 +780,7 @@ fn table_view(
|
||||
span(class=turn_cls) { (turn_note) }
|
||||
(countdown)
|
||||
CopyButton(text=watch_link, label="Copy watch link".to_string())
|
||||
(bell)
|
||||
}
|
||||
div(class="table-grid") {
|
||||
(top)
|
||||
|
||||
Reference in New Issue
Block a user