crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

commit 5931ee62a39b20f57163797d1c50f6da724fedf9
parent 464db7cbcda97d045a4a5f1485456b9a60aff2ee
Author: Michael Camilleri <[email protected]>
Date:   Fri, 19 Jun 2026 20:26:21 +0900

Fix nudge button cooldown

Diffstat:
MCrossmate/Services/SessionCoordinator.swift | 11++++++++---
MCrossmate/Views/Puzzle/PuzzleScoreboard.swift | 128++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
2 files changed, 79 insertions(+), 60 deletions(-)

diff --git a/Crossmate/Services/SessionCoordinator.swift b/Crossmate/Services/SessionCoordinator.swift @@ -199,6 +199,14 @@ final class SessionCoordinator { syncMonitor.note("push(nudge): skipped (no authorID)") return } + // Arm the cooldown the moment we accept the gesture, not after a publish + // that happens to find recipients. The button flashes "Nudge Sent" and + // dims on every tap regardless of how many devices we actually reach (a + // present-only or push-incapable peer reaches none), so the cooldown that + // drives the dimming has to track the gesture — otherwise the button + // snaps back to ready the instant the confirmation clears. Also closes + // the double-tap race before this publish returns. + lastNudge[gameID] = Date() guard let pushClient else { syncMonitor.note("push(nudge): skipped (no pushClient)") return @@ -233,9 +241,6 @@ final class SessionCoordinator { syncMonitor.note("push(nudge): skipped (no addressable recipients)") return } - // Stamp the cooldown the moment we commit to sending, so a rapid - // second tap is rejected even before this publish returns. - lastNudge[gameID] = Date() await pushClient.publish( kind: "nudge", gameID: gameID, diff --git a/Crossmate/Views/Puzzle/PuzzleScoreboard.swift b/Crossmate/Views/Puzzle/PuzzleScoreboard.swift @@ -13,6 +13,12 @@ struct PuzzleScoreboard: View { @ScaledMetric(relativeTo: .subheadline) private var horizontalHeaderHeight: CGFloat = 56 /// Briefly swaps the nudge capsule for a "Nudge Sent" confirmation. @State private var showNudgeSent = false + /// The send-cooldown deadline that dims the button, stamped synchronously on + /// tap (`tapTime + nudgeCooldown`) so it exists at render time — the + /// coordinator stamps its own copy asynchronously, too late for this view. + /// `cooldownWatch` clears it at the deadline to un-dim; seeded from the + /// coordinator on appear so the dimming survives a view rebuild mid-cooldown. + @State private var nudgeDeadline: Date? enum Layout { /// Side-panel style: stacked rows under a "Players" heading. @@ -182,14 +188,31 @@ struct PuzzleScoreboard: View { && roster.entries.contains(where: { !$0.isLocal }) } - @ViewBuilder var body: some View { - switch layout { - case .vertical: - verticalBody - case .horizontal: - horizontalBody + Group { + switch layout { + case .vertical: + verticalBody + case .horizontal: + horizontalBody + } + } + .onAppear { if nudgeDeadline == nil { nudgeDeadline = nudgeReadyAt() } } + .task(id: nudgeDeadline) { await cooldownWatch() } + } + + /// Sleeps until `nudgeDeadline`, then clears it so the button un-dims on its + /// own. Re-runs whenever `nudgeDeadline` changes — a fresh nudge or the view + /// re-appearing mid-cooldown — and no-ops when none is pending. Re-checks the + /// deadline is unchanged before clearing so a nudge fired during the sleep + /// isn't cut short. + private func cooldownWatch() async { + guard let deadline = nudgeDeadline else { return } + let delay = deadline.timeIntervalSinceNow + if delay > 0 { + try? await Task.sleep(for: .seconds(delay)) } + if nudgeDeadline == deadline { nudgeDeadline = nil } } private var verticalBody: some View { @@ -260,28 +283,26 @@ struct PuzzleScoreboard: View { /// with the "Nudge Sent" confirmation and cross-faded with opacity, so the /// heading reserves the capsule's footprint and nothing shifts on tap. private var nudgeButton: some View { - // TimelineView re-renders at `nudgeSchedule`'s deadline so the button - // re-evaluates `isNudgeDisabled` and un-dims the instant the cooldown - // lapses — `nudgeReadyAt()` is a pull-only closure SwiftUI can't observe. - TimelineView(.explicit(nudgeSchedule)) { _ in - ZStack(alignment: .trailing) { - Button { - sendNudge() - } label: { - nudgeCapsule - } - .buttonStyle(.plain) - .disabled(isNudgeDisabled) - .accessibilityLabel("Nudge Players") - .opacity(showNudgeSent ? 0 : 1) - .accessibilityHidden(showNudgeSent) - - Text("Nudge Sent") - .font(.footnote.weight(.semibold)) - .foregroundStyle(.secondary) - .opacity(showNudgeSent ? 1 : 0) - .accessibilityHidden(!showNudgeSent) + // The capsule and confirmation are cross-faded in a ZStack so the + // heading reserves the capsule's footprint and nothing shifts on tap. + // The cooldown un-dim is driven by `cooldownWatch` (see `body`). + ZStack(alignment: .trailing) { + Button { + sendNudge() + } label: { + nudgeCapsule } + .buttonStyle(.plain) + .disabled(isNudgeDisabled) + .accessibilityLabel("Nudge Players") + .opacity(showNudgeSent ? 0 : 1) + .accessibilityHidden(showNudgeSent) + + Text("Nudge Sent") + .font(.footnote.weight(.semibold)) + .foregroundStyle(.secondary) + .opacity(showNudgeSent ? 1 : 0) + .accessibilityHidden(!showNudgeSent) } } @@ -306,27 +327,24 @@ struct PuzzleScoreboard: View { // Keep both the capsule and the confirmation laid out in a ZStack // and cross-fade with opacity, so the header always reserves the // capsule's exact footprint — the score chips below never shift. The - // TimelineView re-renders at the cooldown deadline so the button - // un-dims on its own (see `nudgeButton`). - TimelineView(.explicit(nudgeSchedule)) { _ in - ZStack { - Button { - sendNudge() - } label: { - playersCapsule - } - .buttonStyle(.plain) - .disabled(isNudgeDisabled) - .accessibilityLabel("Nudge Players") - .opacity(showNudgeSent ? 0 : 1) - .accessibilityHidden(showNudgeSent) - - Text("Nudge Sent") - .font(.footnote.weight(.semibold)) - .foregroundStyle(.primary) - .opacity(showNudgeSent ? 1 : 0) - .accessibilityHidden(!showNudgeSent) + // cooldown un-dim is driven by `cooldownWatch` (see `body`). + ZStack { + Button { + sendNudge() + } label: { + playersCapsule } + .buttonStyle(.plain) + .disabled(isNudgeDisabled) + .accessibilityLabel("Nudge Players") + .opacity(showNudgeSent ? 0 : 1) + .accessibilityHidden(showNudgeSent) + + Text("Nudge Sent") + .font(.footnote.weight(.semibold)) + .foregroundStyle(.primary) + .opacity(showNudgeSent ? 1 : 0) + .accessibilityHidden(!showNudgeSent) } } else { // No one to nudge (a solo game, or no peers have joined yet): the @@ -340,24 +358,20 @@ struct PuzzleScoreboard: View { } /// The nudge button is disabled mid-confirmation and during the send - /// cooldown (a pending `nudgeReadyAt()`). + /// cooldown (a pending `nudgeDeadline`). private var isNudgeDisabled: Bool { - showNudgeSent || nudgeReadyAt() != nil - } - - /// Schedule that re-renders the button exactly when the cooldown lapses. A - /// pending deadline fires a single update; otherwise a distant-future - /// placeholder keeps the schedule non-empty without ever firing. - private var nudgeSchedule: [Date] { - [nudgeReadyAt() ?? .distantFuture] + showNudgeSent || nudgeDeadline != nil } /// Fires the nudge and flashes the "Nudge Sent" confirmation in the header - /// for a couple of seconds before restoring the button. + /// for a couple of seconds before restoring the button. Arms the cooldown + /// deadline synchronously (`cooldownWatch` clears it) so the button dims with + /// the tap regardless of how the actual send fans out. private func sendNudge() { if let onNudge { Task { await onNudge() } } + nudgeDeadline = Date().addingTimeInterval(SessionCoordinator.nudgeCooldown) withAnimation(.easeInOut(duration: 0.25)) { showNudgeSent = true } Task { try? await Task.sleep(for: .seconds(3))