Notify when another player plays a card
CI / Build and push docker image (push) Successful in 1m42s

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:
2026-10-15 21:46:00 +08:00
parent 517ac6fb46
commit 03a571a6a5
12 changed files with 679 additions and 16 deletions
+17 -3
View File
@@ -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/`):
+1 -1
View File
@@ -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"
+1
View File
@@ -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;
+80
View File
@@ -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<bool>,
pub permission: Signal<Permission>,
pub alive: Rc<Cell<bool>>,
}
/// 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)
}
}
}
+1
View File
@@ -3,6 +3,7 @@ mod api;
mod auth;
mod components;
mod model;
mod notify;
mod pages;
mod pwa;
mod ws;
+464
View File
@@ -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::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);
}
}
+4
View File
@@ -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<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.
@@ -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)
+4
View File
@@ -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<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.
@@ -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)
+60 -8
View File
@@ -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)
+4
View File
@@ -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<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.
@@ -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)
+3 -3
View File
@@ -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.
+40 -1
View File
@@ -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