commit 5931ee62a39b20f57163797d1c50f6da724fedf9
parent 464db7cbcda97d045a4a5f1485456b9a60aff2ee
Author: Michael Camilleri <[email protected]>
Date: Fri, 19 Jun 2026 20:26:21 +0900
Fix nudge button cooldown
Diffstat:
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))