diff --git a/README.md b/README.md index 21c2d7c..4bdc1fc 100644 --- a/README.md +++ b/README.md @@ -81,10 +81,10 @@ sees a "game not started yet" waiting room. The frontend is an installable PWA: `web/manifest.webmanifest` describes the app (standalone display, felt theme colors, PNG icons), and `web/sw.js` -is an install-only service worker — it deliberately caches nothing, since +is a caching-free service worker — it deliberately caches nothing, since live state always comes from the server and hashed asset names change every -build. Browsers offer to install Tavolo over HTTPS (or on localhost in -development). +build — that also handles clicks on move notifications. Browsers offer to +install Tavolo over HTTPS (or on localhost in development). Both files must be served from the site root, or the worker's scope and the manifest's `start_url` could not cover the app: Granian only serves @@ -94,6 +94,20 @@ same build directory as the shell. Icons are committed under `web/assets/icons/`; `web/make-icons.sh` regenerates them with `rsvg-convert` after editing the vector sources. +## Move notifications + +Players can opt into desktop notifications for the other players' card +plays with the bell in the game HUD (the first click asks for the browser +permission; the preference is stored per device and applies to every +game). Notifications only appear while the game tab is in the background +or its window is unfocused — never while the player is watching the table +— and only for games the viewer is seated in, never for their own plays. +They are delivered through the service worker (so installed PWAs get them +too) under a per-game tag, which replaces the previous move instead of +stacking one notification per play; clicking one focuses the game tab. +The game page must stay open to receive them, since that is where the +live-play websocket lives. + ## Development Backend (from `server/`): diff --git a/web/Cargo.toml b/web/Cargo.toml index 38c4c55..ea76c61 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -13,7 +13,7 @@ serde_json = "1" wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" futures = "0.3" -web-sys = { version = "0.3", features = ["Window", "Location", "Clipboard", "Navigator", "console", "Storage", "ServiceWorkerContainer"] } +web-sys = { version = "0.3", features = ["Window", "Location", "Clipboard", "Navigator", "console", "Storage", "ServiceWorkerContainer", "ServiceWorkerRegistration", "Notification", "NotificationPermission", "NotificationOptions", "Document"] } js-sys = "0.3" gloo-timers = "0.3" console_error_panic_hook = "0.1" diff --git a/web/src/components/mod.rs b/web/src/components/mod.rs index 757f6ce..fd22742 100644 --- a/web/src/components/mod.rs +++ b/web/src/components/mod.rs @@ -4,5 +4,6 @@ pub mod card; pub mod copy; pub mod login; pub mod nav; +pub mod notify; pub mod summary; pub mod toast; diff --git a/web/src/components/notify.rs b/web/src/components/notify.rs new file mode 100644 index 0000000..2c2fc76 --- /dev/null +++ b/web/src/components/notify.rs @@ -0,0 +1,80 @@ +//! HUD bell for desktop notifications about the other players' moves. +use std::cell::Cell; +use std::rc::Rc; + +use sycamore::prelude::*; +use wasm_bindgen_futures::spawn_local; + +use crate::notify::{self, Permission}; + +/// Notification state shared by the game page and its table views: the +/// opt-in flag and the browser permission, both page-scoped so a +/// permission prompt that resolves after a table re-render still lands +/// somewhere safe. `alive` mirrors the game page's unmount flag. +#[derive(Clone)] +pub struct NotifyCtx { + pub enabled: Signal, + pub permission: Signal, + pub alive: Rc>, +} + +/// The caption for the current (enabled, permission) state, and whether +/// the button is a dead end (the user must change browser settings). +fn face(enabled: bool, permission: Permission) -> (&'static str, bool) { + match (enabled, permission) { + (true, _) => ("🔔 Notifications on", false), + (false, Permission::Granted | Permission::Default) => ("🔔 Notify me", false), + (false, Permission::Denied | Permission::Unsupported) => { + ("🔔 Notifications blocked", true) + } + } +} + +/// The move-notification bell, rendered in the HUD of seated players. +/// +/// The preference is global (all games) and persisted per device. The +/// permission prompt fires on the first click: browsers require a user +/// gesture, and asking on page load would be ignored. +pub fn notify_toggle(ctx: NotifyCtx) -> View { + let NotifyCtx { enabled, permission, alive } = ctx; + let on_click = move |_| { + if enabled.get() { + enabled.set(false); + notify::set_enabled(false); + return; + } + match permission.get() { + Permission::Default => { + let alive = alive.clone(); + spawn_local(async move { + let granted = notify::request_permission().await; + if !alive.get() { + return; + } + permission.set(granted); + if granted == Permission::Granted { + enabled.set(true); + notify::set_enabled(true); + } + }) + } + Permission::Granted => { + enabled.set(true); + notify::set_enabled(true); + } + Permission::Denied | Permission::Unsupported => {} + } + }; + + view! { + button( + class="btn btn-outline-light", + disabled=move || face(enabled.get(), permission.get()).1, + title="Desktop notification when another player plays a card, \ + while this tab is in the background", + on:click=on_click, + ) { + (move || face(enabled.get(), permission.get()).0) + } + } +} diff --git a/web/src/main.rs b/web/src/main.rs index 2a9774c..8bad193 100644 --- a/web/src/main.rs +++ b/web/src/main.rs @@ -3,6 +3,7 @@ mod api; mod auth; mod components; mod model; +mod notify; mod pages; mod pwa; mod ws; diff --git a/web/src/notify.rs b/web/src/notify.rs new file mode 100644 index 0000000..1f0cec4 --- /dev/null +++ b/web/src/notify.rs @@ -0,0 +1,464 @@ +//! 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); + } +} diff --git a/web/src/pages/briscola.rs b/web/src/pages/briscola.rs index cbb8cd9..424c4db 100644 --- a/web/src/pages/briscola.rs +++ b/web/src/pages/briscola.rs @@ -7,6 +7,7 @@ use sycamore::prelude::*; use crate::components::card::{card_back, card_img}; use crate::components::copy::CopyButton; +use crate::components::notify::{notify_toggle, NotifyCtx}; use crate::model::{ hand_sort_key, is_individual_game, suit_name, GameView, PlayerView, Scores, }; @@ -121,6 +122,7 @@ pub fn briscola_table( game: GameView, on_hand_card: impl Fn(String) + Copy + 'static, now: Signal, + notify: NotifyCtx, ) -> View { // Own seat: the only player entry carrying a hand. Spectators have no // hand, so they watch from seat 0's perspective. @@ -236,6 +238,7 @@ pub fn briscola_table( 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); let trump_badge = (!game.briscola_suit.is_empty()).then(|| { let suit = suit_name(&game.briscola_suit); @@ -257,6 +260,7 @@ pub fn briscola_table( span(class=turn_cls) { (turn_note) } (countdown) CopyButton(text=watch_link, label="Copy watch link".to_string()) + (bell) } div(class=grid_cls) { (top) diff --git a/web/src/pages/chiamata.rs b/web/src/pages/chiamata.rs index c6c1563..f937436 100644 --- a/web/src/pages/chiamata.rs +++ b/web/src/pages/chiamata.rs @@ -12,6 +12,7 @@ use sycamore::prelude::*; use crate::components::card::{card_back, card_img}; use crate::components::copy::CopyButton; +use crate::components::notify::{notify_toggle, NotifyCtx}; use crate::model::{ card_label, hand_sort_key, rank_name, suit_name, AuctionBid, GameView, PlayerView, }; @@ -255,6 +256,7 @@ pub fn chiamata_table( on_bid_points: impl Fn(i32) + Copy + 'static, on_pass: impl Fn() + Copy + 'static, now: Signal, + notify: NotifyCtx, ) -> View { // Own seat: the only player entry carrying a hand. Spectators have no // hand, so they watch from seat 0's perspective. @@ -423,6 +425,7 @@ pub fn chiamata_table( 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); let trump_badge = (!game.briscola_suit.is_empty()).then(|| { let suit = suit_name(&game.briscola_suit); @@ -446,6 +449,7 @@ pub fn chiamata_table( span(class=turn_cls) { (turn_note) } (countdown) CopyButton(text=watch_link, label="Copy watch link".to_string()) + (bell) } div(class="table-grid cinque") { (top_left) diff --git a/web/src/pages/game.rs b/web/src/pages/game.rs index 6839101..2ea724d 100644 --- a/web/src/pages/game.rs +++ b/web/src/pages/game.rs @@ -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>, game: Signal>, @@ -111,6 +113,12 @@ struct ConnCtx { attempts: Signal, capture_choice: Signal>)>>, selected: Signal>, + /// Baseline for the move notifications; reset on every (re)connect. + baseline: notify::Baseline, + /// Whether the player opted into move notifications. + notify_enabled: Signal, + /// The browser's notification permission, as last read. + notify_permission: Signal, } /// 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, ctx: ConnCtx, alive: Rc>) { + // 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, ctx: ConnCtx, alive: Rc>) { } 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, ctx: ConnCtx, alive: Rc>) { }; let on_close = { let id = id.clone(); + let ctx = ctx.clone(); let alive = alive.clone(); move |_code: Option| { if !alive.get() { @@ -166,6 +192,7 @@ fn start_connect(id: Rc, ctx: ConnCtx, alive: Rc>) { // 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, ctx: ConnCtx, alive: Rc>) { } }); } 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 = 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>, now: Signal, + 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) diff --git a/web/src/pages/scopa.rs b/web/src/pages/scopa.rs index 298abe4..97bc764 100644 --- a/web/src/pages/scopa.rs +++ b/web/src/pages/scopa.rs @@ -8,6 +8,7 @@ use sycamore::prelude::*; use crate::components::card::{card_back, card_img}; use crate::components::copy::CopyButton; +use crate::components::notify::{notify_toggle, NotifyCtx}; use crate::model::{ hand_sort_key, is_individual_game, GameView, PlayerView, Scores, }; @@ -71,6 +72,7 @@ pub fn scopa_table( game: GameView, on_hand_card: impl Fn(String) + Copy + 'static, now: Signal, + notify: NotifyCtx, ) -> View { // Own seat: the only player entry carrying a hand. Spectators have no // hand, so they watch from seat 0's perspective. @@ -190,6 +192,7 @@ pub fn scopa_table( let spectator_badge = game.spectator.then(|| view! { span(class="spectator-badge") { "Spectating" } }); + let bell = (!game.spectator).then(|| notify_toggle(notify)); let rule_badge = game.asso_pigliatutto.then(|| view! { span(class="pigliatutto-badge") { "Asso pigliatutto" } }); @@ -217,6 +220,7 @@ pub fn scopa_table( span(class=turn_cls) { (turn_note) } (countdown) CopyButton(text=watch_link, label="Copy watch link".to_string()) + (bell) } div(class=grid_cls) { (top) diff --git a/web/src/pwa.rs b/web/src/pwa.rs index fa76d21..d035595 100644 --- a/web/src/pwa.rs +++ b/web/src/pwa.rs @@ -1,9 +1,9 @@ //! Service worker registration. //! //! Registering `/sw.js` is what makes the app installable as a PWA; the -//! worker itself is install-only (no offline caching — see `web/sw.js`). -//! Registration is best-effort: browsers without service worker support, -//! and insecure origins other than localhost, simply skip it. +//! worker itself is caching-free (see `web/sw.js`). Registration is +//! best-effort: browsers without service worker support, and insecure +//! origins other than localhost, simply skip it. use wasm_bindgen_futures::{spawn_local, JsFuture}; /// Register the service worker, if the browser supports it. diff --git a/web/sw.js b/web/sw.js index d576ed0..c32a3b4 100644 --- a/web/sw.js +++ b/web/sw.js @@ -1,4 +1,5 @@ -// Tavolo service worker: installability only, no offline caching. +// Tavolo service worker: installability and notification clicks, no +// offline caching. // // The app is a thin client for a live game server: state arrives over // /api and /ws and is never safe to replay from a cache, and a new build @@ -6,6 +7,8 @@ // app until the cache expired. The worker therefore only performs the // passthrough fetch below — enough for the browser to treat the app as // installable — and leaves every request's caching to the network stack. +// It also handles clicks on the move notifications raised by the game +// page (see src/notify.rs). self.addEventListener("install", () => { // A new worker takes over on the next reload instead of waiting for @@ -17,6 +20,42 @@ self.addEventListener("activate", (event) => { event.waitUntil(self.clients.claim()); }); +// Clicking a move notification focuses the game: an existing window on +// the game is focused as is, any other window of the app is navigated +// there, and when the app is closed a new window is opened. +self.addEventListener("notificationclick", (event) => { + event.notification.close(); + const url = (event.notification.data && event.notification.data.url) || "/"; + event.waitUntil( + (async () => { + const target = new URL(url, self.location.origin); + const windows = await self.clients.matchAll({ + type: "window", + includeUncontrolled: true, + }); + const onGame = windows.find((client) => { + const current = new URL(client.url); + return current.origin === target.origin && current.pathname === target.pathname; + }); + if (onGame) { + return onGame.focus(); + } + const anyWindow = windows.find( + (client) => new URL(client.url).origin === target.origin, + ); + if (anyWindow) { + await anyWindow.focus(); + try { + return await anyWindow.navigate(url); + } catch { + return anyWindow; + } + } + return self.clients.openWindow(url); + })(), + ); +}); + self.addEventListener("fetch", (event) => { const url = new URL(event.request.url); // Let the browser handle other origins and the live endpoints directly