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.
465 lines
16 KiB
Rust
465 lines
16 KiB
Rust
//! Desktop notifications for the other players' card plays.
|
|
//!
|
|
//! Move notifications are opt-in (the bell in the game HUD), fire only
|
|
//! while the game page is in the background, and only for games the viewer
|
|
//! is seated in. They are delivered through the service worker —
|
|
//! page-context notifications are rejected on some platforms (Android in
|
|
//! particular) — with a page-context fallback when no worker is active.
|
|
//!
|
|
//! The interesting logic is split into pure functions ([`last_play`],
|
|
//! [`opponent_play`], [`message`]) so it is unit-testable on the host;
|
|
//! everything else talks to the browser.
|
|
use std::cell::{Cell, RefCell};
|
|
use std::rc::Rc;
|
|
|
|
use wasm_bindgen::JsCast;
|
|
use wasm_bindgen_futures::{spawn_local, JsFuture};
|
|
|
|
use crate::model::{card_label, is_briscola, is_chiamata, GameView};
|
|
|
|
/// localStorage key holding whether the user wants move notifications.
|
|
const ENABLED_KEY: &str = "tavolo.notify.moves";
|
|
|
|
/// The most recent card played, in a form comparable across snapshots.
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct LastPlay {
|
|
pub seat: usize,
|
|
pub card: String,
|
|
}
|
|
|
|
/// The browser's notification permission.
|
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
pub enum Permission {
|
|
Default,
|
|
Granted,
|
|
Denied,
|
|
/// The browser has no Notification API at all.
|
|
Unsupported,
|
|
}
|
|
|
|
/// Whether the user has opted into move notifications. The preference is
|
|
/// global (all games) and per device; browsers without `localStorage`
|
|
/// (rare) just never remember it.
|
|
pub fn enabled() -> bool {
|
|
storage()
|
|
.and_then(|s| s.get_item(ENABLED_KEY).ok().flatten())
|
|
.as_deref()
|
|
== Some("1")
|
|
}
|
|
|
|
/// Persist the opt-in state.
|
|
pub fn set_enabled(on: bool) {
|
|
if let Some(storage) = storage() {
|
|
let _ = if on {
|
|
storage.set_item(ENABLED_KEY, "1")
|
|
} else {
|
|
storage.remove_item(ENABLED_KEY)
|
|
};
|
|
}
|
|
}
|
|
|
|
fn storage() -> Option<web_sys::Storage> {
|
|
web_sys::window()?.local_storage().ok().flatten()
|
|
}
|
|
|
|
fn has_notification_api() -> bool {
|
|
js_sys::Reflect::get(&js_sys::global(), &wasm_bindgen::JsValue::from_str("Notification"))
|
|
.map(|value| !value.is_undefined())
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
/// The browser's current notification permission.
|
|
pub fn permission() -> Permission {
|
|
if !has_notification_api() {
|
|
return Permission::Unsupported;
|
|
}
|
|
match web_sys::Notification::permission() {
|
|
web_sys::NotificationPermission::Granted => Permission::Granted,
|
|
web_sys::NotificationPermission::Denied => Permission::Denied,
|
|
_ => Permission::Default,
|
|
}
|
|
}
|
|
|
|
/// Ask the browser for the notification permission.
|
|
///
|
|
/// Must be called from a user gesture (a click): Chrome rejects
|
|
/// permission prompts without one. Returns the resulting permission.
|
|
pub async fn request_permission() -> Permission {
|
|
if !has_notification_api() {
|
|
return Permission::Unsupported;
|
|
}
|
|
match web_sys::Notification::request_permission() {
|
|
Ok(promise) => {
|
|
let _ = JsFuture::from(promise).await;
|
|
// Re-read the permission: the promise resolves to the same
|
|
// enum, but re-reading also covers browsers that settled it
|
|
// through their own UI.
|
|
permission()
|
|
}
|
|
Err(_) => permission(),
|
|
}
|
|
}
|
|
|
|
/// Whether the page is not in the foreground: the tab is hidden, or its
|
|
/// window lost focus. Notifications are only useful — and only shown —
|
|
/// when the viewer cannot see the table.
|
|
pub fn page_in_background() -> bool {
|
|
let Some(window) = web_sys::window() else {
|
|
// No window object: headless contexts cannot show notifications
|
|
// anyway; treat the page as visible and stay quiet.
|
|
return false;
|
|
};
|
|
let Some(document) = window.document() else {
|
|
return false;
|
|
};
|
|
document.hidden() || !document.has_focus().unwrap_or(true)
|
|
}
|
|
|
|
/// The viewer's seat, or `None` for spectators. The viewer is the only
|
|
/// player whose hand is present in the snapshot.
|
|
pub fn viewer_seat(game: &GameView) -> Option<usize> {
|
|
if game.spectator {
|
|
return None;
|
|
}
|
|
game.players
|
|
.iter()
|
|
.find(|p| p.hand.is_some())
|
|
.map(|p| p.seat)
|
|
}
|
|
|
|
/// The most recent card played in the snapshot, across all game families:
|
|
///
|
|
/// - scopone and scopa report every play in `last_move`;
|
|
/// - briscola and briscola chiamata don't: a play either extends `trick`
|
|
/// or, when it completes the trick, moves `trick` into `last_trick` —
|
|
/// in both cases the latest play is the last card of the trick.
|
|
pub fn last_play(game: &GameView) -> Option<LastPlay> {
|
|
if let Some(mv) = &game.last_move {
|
|
return Some(LastPlay { seat: mv.seat, card: mv.card.clone() });
|
|
}
|
|
if let Some(card) = game.trick.last() {
|
|
return Some(LastPlay { seat: card.seat, card: card.card.clone() });
|
|
}
|
|
game.last_trick
|
|
.as_ref()
|
|
.and_then(|trick| trick.cards.last())
|
|
.map(|card| LastPlay { seat: card.seat, card: card.card.clone() })
|
|
}
|
|
|
|
/// The play to announce between two snapshots.
|
|
///
|
|
/// `primed` is false for the first snapshot after a (re)connection: the
|
|
/// snapshot is a resync, not a play, and only establishes the baseline.
|
|
/// Only plays by *other* seats are reported; the viewer's own plays (and
|
|
/// the server's auto-plays for them) are already visible.
|
|
pub fn opponent_play(primed: bool, prev: Option<&LastPlay>, next: &GameView) -> Option<LastPlay> {
|
|
let cur = last_play(next)?;
|
|
if !primed || Some(&cur) == prev {
|
|
return None;
|
|
}
|
|
// Spectators (and snapshots without a visible hand) have no seat to
|
|
// compare against: stay quiet rather than announce every play.
|
|
let seat = viewer_seat(next)?;
|
|
if cur.seat == seat {
|
|
return None;
|
|
}
|
|
Some(cur)
|
|
}
|
|
|
|
/// Track the notification baseline across game snapshots. The baseline
|
|
/// must be reset for every (re)connection; see [`opponent_play`].
|
|
#[derive(Clone)]
|
|
pub struct Baseline {
|
|
prev: Rc<RefCell<Option<LastPlay>>>,
|
|
primed: Rc<Cell<bool>>,
|
|
}
|
|
|
|
impl Baseline {
|
|
pub fn new() -> Self {
|
|
Baseline {
|
|
prev: Rc::new(RefCell::new(None)),
|
|
primed: Rc::new(Cell::new(false)),
|
|
}
|
|
}
|
|
|
|
/// Forget the previous snapshot: the next one is a resync baseline.
|
|
pub fn reset(&self) {
|
|
*self.prev.borrow_mut() = None;
|
|
self.primed.set(false);
|
|
}
|
|
|
|
/// Record `game` and return the opponent's play to notify about, if
|
|
/// any. Must be called for every snapshot, whether or not the
|
|
/// preference is on, so that enabling it mid-game never replays an
|
|
/// old move.
|
|
pub fn observe(&self, game: &GameView) -> Option<LastPlay> {
|
|
let play = opponent_play(self.primed.get(), self.prev.borrow().as_ref(), game);
|
|
*self.prev.borrow_mut() = last_play(game);
|
|
self.primed.set(true);
|
|
play
|
|
}
|
|
}
|
|
|
|
/// The notification title and body for `play` in `game`.
|
|
pub fn message(game: &GameView, play: &LastPlay) -> (String, String) {
|
|
let title = format!(
|
|
"{} played {}",
|
|
game.player_name_for_seat(play.seat),
|
|
card_label(&play.card)
|
|
);
|
|
let mut parts = vec![if is_briscola(&game.game_type) || is_chiamata(&game.game_type) {
|
|
format!("Deal {}", game.hand_number)
|
|
} else {
|
|
format!("Hand {}", game.hand_number)
|
|
}];
|
|
if game.your_turn == Some(true) {
|
|
parts.push("Your turn".to_string());
|
|
} else {
|
|
parts.push(format!("{} to play", game.player_name_for_seat(game.turn)));
|
|
}
|
|
if let Some(mv) = &game.last_move {
|
|
if mv.scopa {
|
|
parts.push("Scopa!".to_string());
|
|
}
|
|
if mv.pigliatutto {
|
|
parts.push("Asso pigliatutto!".to_string());
|
|
}
|
|
}
|
|
(title, parts.join(" · "))
|
|
}
|
|
|
|
/// Show a desktop notification for `play`.
|
|
///
|
|
/// Best-effort: failures (no permission, no worker, platform rejection)
|
|
/// are silently ignored — the table itself is the source of truth.
|
|
pub fn notify_move(game: &GameView, play: &LastPlay) {
|
|
let (title, body) = message(game, play);
|
|
let url = format!("/game/{}", game.id);
|
|
let tag = format!("tavolo-{}", game.id);
|
|
spawn_local(async move {
|
|
show(&title, &body, &tag, &url).await;
|
|
});
|
|
}
|
|
|
|
/// Build the notification options shared by both delivery paths.
|
|
fn options(body: &str, tag: Option<&str>, url: &str) -> web_sys::NotificationOptions {
|
|
let options = web_sys::NotificationOptions::new();
|
|
options.set_body(body);
|
|
options.set_icon("/assets/icons/icon-192.png");
|
|
if let Some(tag) = tag {
|
|
// renotify needs a tag; the tag replaces the previous move of the
|
|
// same game instead of stacking one notification per play.
|
|
options.set_tag(tag);
|
|
options.set_renotify(true);
|
|
}
|
|
let data = js_sys::Object::new();
|
|
let _ = js_sys::Reflect::set(
|
|
&data,
|
|
&wasm_bindgen::JsValue::from_str("url"),
|
|
&wasm_bindgen::JsValue::from_str(url),
|
|
);
|
|
options.set_data(&data);
|
|
options
|
|
}
|
|
|
|
async fn show(title: &str, body: &str, tag: &str, url: &str) {
|
|
// Prefer the service worker registration: it is the only path that
|
|
// works for installed PWAs on Android. `getRegistration` resolves
|
|
// promptly (to `undefined` when nothing is registered), unlike
|
|
// `ready`, which never settles without a worker.
|
|
if let Some(window) = web_sys::window() {
|
|
let container = window.navigator().service_worker();
|
|
if let Ok(value) = JsFuture::from(container.get_registration()).await {
|
|
if let Some(registration) = value.dyn_ref::<web_sys::ServiceWorkerRegistration>() {
|
|
if let Ok(promise) =
|
|
registration.show_notification_with_options(title, &options(body, Some(tag), url))
|
|
{
|
|
if JsFuture::from(promise).await.is_ok() {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let _ = web_sys::Notification::new_with_options(title, &options(body, None, url));
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::model::GameView;
|
|
use serde_json::json;
|
|
|
|
fn scopone_view(last_move: serde_json::Value, my_turn: bool) -> GameView {
|
|
serde_json::from_value(json!({
|
|
"id": "g1",
|
|
"game_type": "scopone_scientifico",
|
|
"phase": "playing",
|
|
"hand_number": 2,
|
|
"turn": 2,
|
|
"your_turn": my_turn,
|
|
"players": [
|
|
{"sub": "alice", "name": "Alice", "seat": 0},
|
|
{"sub": "bob", "name": "Bob", "seat": 1, "hand": ["01D", "02C"]},
|
|
{"sub": "carol", "name": "Carol", "seat": 2},
|
|
{"sub": "dave", "name": "Dave", "seat": 3},
|
|
],
|
|
"last_move": last_move,
|
|
}))
|
|
.unwrap()
|
|
}
|
|
|
|
fn briscola_view(trick: Vec<serde_json::Value>, last_trick: serde_json::Value) -> GameView {
|
|
serde_json::from_value(json!({
|
|
"id": "g2",
|
|
"game_type": "briscola",
|
|
"phase": "playing",
|
|
"hand_number": 1,
|
|
"turn": 1,
|
|
"your_turn": false,
|
|
"players": [
|
|
{"sub": "alice", "name": "Alice", "seat": 0, "hand": ["01D"]},
|
|
{"sub": "bob", "name": "Bob", "seat": 1},
|
|
],
|
|
"trick": trick,
|
|
"last_trick": last_trick,
|
|
}))
|
|
.unwrap()
|
|
}
|
|
|
|
fn play(seat: usize, card: &str) -> LastPlay {
|
|
LastPlay { seat, card: card.to_string() }
|
|
}
|
|
|
|
#[test]
|
|
fn last_play_reads_last_move() {
|
|
let g = scopone_view(
|
|
json!({"seat": 3, "name": "Dave", "card": "07D", "captured": []}),
|
|
false,
|
|
);
|
|
assert_eq!(Some(play(3, "07D")), last_play(&g));
|
|
}
|
|
|
|
#[test]
|
|
fn last_play_reads_the_trick_in_progress() {
|
|
let g = briscola_view(
|
|
vec![json!({"seat": 0, "card": "04C"}), json!({"seat": 1, "card": "02D"})],
|
|
serde_json::Value::Null,
|
|
);
|
|
assert_eq!(Some(play(1, "02D")), last_play(&g));
|
|
}
|
|
|
|
#[test]
|
|
fn last_play_reads_the_completed_trick() {
|
|
// The trick-completing play empties `trick`; the newest card is
|
|
// the last one of `last_trick.cards`.
|
|
let g = briscola_view(
|
|
vec![],
|
|
json!({"winner": 1, "cards": [
|
|
{"seat": 0, "card": "04C"},
|
|
{"seat": 1, "card": "02D"},
|
|
]}),
|
|
);
|
|
assert_eq!(Some(play(1, "02D")), last_play(&g));
|
|
}
|
|
|
|
#[test]
|
|
fn last_play_is_none_before_any_card() {
|
|
let g = briscola_view(vec![], serde_json::Value::Null);
|
|
assert_eq!(None, last_play(&g));
|
|
}
|
|
|
|
#[test]
|
|
fn opponent_play_skips_the_first_snapshot() {
|
|
let g = scopone_view(
|
|
json!({"seat": 3, "name": "Dave", "card": "07D"}),
|
|
false,
|
|
);
|
|
assert_eq!(None, opponent_play(false, None, &g));
|
|
}
|
|
|
|
#[test]
|
|
fn opponent_play_skips_the_viewer_seat() {
|
|
// Bob (seat 1) carries the hand: his own play is not announced.
|
|
let g = scopone_view(
|
|
json!({"seat": 1, "name": "Bob", "card": "07D"}),
|
|
false,
|
|
);
|
|
assert_eq!(None, opponent_play(true, Some(&play(2, "03B")), &g));
|
|
}
|
|
|
|
#[test]
|
|
fn opponent_play_reports_a_new_move_and_not_a_repeat() {
|
|
let g = scopone_view(
|
|
json!({"seat": 3, "name": "Dave", "card": "07D"}),
|
|
false,
|
|
);
|
|
let prev = play(2, "03B");
|
|
assert_eq!(Some(play(3, "07D")), opponent_play(true, Some(&prev), &g));
|
|
assert_eq!(None, opponent_play(true, Some(&play(3, "07D")), &g));
|
|
}
|
|
|
|
#[test]
|
|
fn opponent_play_stays_quiet_for_spectators() {
|
|
let mut g = scopone_view(
|
|
json!({"seat": 3, "name": "Dave", "card": "07D"}),
|
|
false,
|
|
);
|
|
g.spectator = true;
|
|
for player in &mut g.players {
|
|
player.hand = None;
|
|
}
|
|
assert_eq!(None, opponent_play(true, Some(&play(2, "03B")), &g));
|
|
}
|
|
|
|
#[test]
|
|
fn opponent_play_reports_after_a_hand_boundary() {
|
|
// The new hand cleared `last_move`; the first play is still news.
|
|
let g = scopone_view(
|
|
json!({"seat": 0, "name": "Alice", "card": "01D"}),
|
|
false,
|
|
);
|
|
assert_eq!(Some(play(0, "01D")), opponent_play(true, None, &g));
|
|
}
|
|
|
|
#[test]
|
|
fn baseline_resets_per_connection() {
|
|
let baseline = Baseline::new();
|
|
let g = scopone_view(
|
|
json!({"seat": 3, "name": "Dave", "card": "07D"}),
|
|
false,
|
|
);
|
|
// First snapshot of the connection: baseline only.
|
|
assert_eq!(None, baseline.observe(&g));
|
|
// A new play by another seat is reported.
|
|
let next = scopone_view(
|
|
json!({"seat": 0, "name": "Alice", "card": "06C"}),
|
|
false,
|
|
);
|
|
assert_eq!(Some(play(0, "06C")), baseline.observe(&next));
|
|
// A reconnect resyncs to the same snapshot: no notification.
|
|
baseline.reset();
|
|
assert_eq!(None, baseline.observe(&next));
|
|
}
|
|
|
|
#[test]
|
|
fn message_names_the_player_and_card() {
|
|
let g = scopone_view(
|
|
json!({"seat": 3, "name": "Dave", "card": "07D", "scopa": true}),
|
|
true,
|
|
);
|
|
let (title, body) = message(&g, &play(3, "07D"));
|
|
assert_eq!("Dave played 7 di denari", title);
|
|
assert_eq!("Hand 2 · Your turn · Scopa!", body);
|
|
}
|
|
|
|
#[test]
|
|
fn message_names_the_next_player_and_the_deal() {
|
|
let mut g = briscola_view(vec![json!({"seat": 1, "card": "02D"})], serde_json::Value::Null);
|
|
g.turn = 0;
|
|
let (title, body) = message(&g, &play(1, "02D"));
|
|
assert_eq!("Bob played 2 di denari", title);
|
|
assert_eq!("Deal 1 · Alice to play", body);
|
|
}
|
|
}
|