//! 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::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 { 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 { 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 { 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>>, primed: Rc>, } 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 { 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::() { 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, 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); } }