From cda5b36c11ed2f0acf3750b3b344966338797425 Mon Sep 17 00:00:00 2001 From: iamnbutler Date: Thu, 22 Oct 2026 16:36:11 -0400 Subject: [PATCH] fix: tell both people where an ask to join stands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The asker's Waiting… compared peers by object, so the list's 5 s refresh dropped it to a disabled Ask to join; it now says when the request lapses and toasts when they're let in. The owner gets a desktop notification for an ask that arrives while Ace isn't focused, the dialog says when it lapses, and Approve or Deny confirms. --- apps/app/src/team-asks.tsx | 27 +++++++++++++++++++++++++-- apps/app/src/team-setup.tsx | 25 +++++++++++++++++++------ apps/host/src/asks.ts | 5 +---- apps/host/src/protocol.ts | 6 ++++++ 4 files changed, 51 insertions(+), 12 deletions(-) diff --git a/apps/app/src/team-asks.tsx b/apps/app/src/team-asks.tsx index 0ee98ca8..ba7fd3d0 100644 --- a/apps/app/src/team-asks.tsx +++ b/apps/app/src/team-asks.tsx @@ -1,4 +1,4 @@ -import { useState, useSyncExternalStore } from "react"; +import { useEffect, useRef, useState, useSyncExternalStore } from "react"; import { Button, @@ -8,10 +8,15 @@ import { DialogFooter, DialogHeader, DialogTitle, + toast, } from "@ace/ui"; +import { desktop } from "./desktop"; +import { isFocused } from "./focus"; import { host } from "./host"; +const CLOCK = new Intl.DateTimeFormat(undefined, { timeStyle: "short" }); + /** * Someone on the tailnet asked to join this host's team. Approving sends them the directory URL * and team secret; closing leaves the ask to expire. @@ -21,6 +26,19 @@ export function TeamAsks() { const [dismissed, setDismissed] = useState([]); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); + // The dialog waits in a window the owner may not be looking at; the ask lapses unanswered. + const told = useRef(new Set()); + useEffect(() => { + for (const ask of asks) { + if (told.current.has(ask.id)) continue; + told.current.add(ask.id); + if (isFocused()) continue; + desktop?.notify( + `Let ${ask.github || ask.login} join your team?`, + `${ask.login} on ${ask.host} asked. Open Ace to approve or deny.`, + ).catch(() => {}); + } + }, [asks]); const ask = asks.find((value) => !dismissed.includes(value.id)); if (!ask) return null; const who = ask.github || ask.login; @@ -30,6 +48,11 @@ export function TeamAsks() { setError(""); try { await host.request({ op: "team-answer", ask: ask!.id, approve }); + if (approve) { + toast.success(`Let ${who} in`, { + description: `${ask!.host} has your team's directory and joins it now.`, + }); + } else toast(`Declined ${who}'s request`); } catch (error) { setError((error as Error).message); } finally { @@ -46,7 +69,7 @@ export function TeamAsks() { {ask.login} on {ask.host}{" "} asked from Ace. Approving sends this host's directory URL and team secret to {ask.host} {" "} - over your tailnet. + over your tailnet. The request lapses at {CLOCK.format(ask.expires)}. {error &&

{error}

} diff --git a/apps/app/src/team-setup.tsx b/apps/app/src/team-setup.tsx index 8a238526..508e75fe 100644 --- a/apps/app/src/team-setup.tsx +++ b/apps/app/src/team-setup.tsx @@ -10,9 +10,10 @@ import { DialogHeader, DialogTitle, Input, + toast, } from "@ace/ui"; import { IconArrowUpRight, IconCheck, IconCopy, IconLoader, IconUsers } from "@ace/ui/icons"; -import type { TeamPeer } from "@ace/host/protocol"; +import { TEAM_ASK_EXPIRES, type TeamPeer } from "@ace/host/protocol"; import { host } from "./host"; import { useDiagnostics } from "./host-settings"; @@ -429,18 +430,26 @@ function useTeamPeers(): TeamPeer[] { return peers; } +const CLOCK = new Intl.DateTimeFormat(undefined, { timeStyle: "short" }); + /** Ask a teammate's host to let this one in; their owner approves in their Ace. */ function Nearby({ onJoined }: { onJoined: () => void }) { const peers = useTeamPeers(); - const [asking, setAsking] = useState(); + // By host: the peer list is read again every few seconds, which replaces its objects. + const [asking, setAsking] = useState<{ host: string; who: string; lapses: number }>(); const [error, setError] = useState(""); if (!peers.length) return null; async function ask(peer: TeamPeer) { - setAsking(peer); + const who = peer.github || peer.login; + setAsking({ host: peer.host, who, lapses: Date.now() + TEAM_ASK_EXPIRES }); setError(""); try { await host.request({ op: "team-ask", host: peer.host }); + // Joining closes Team setup, so the asker may have looked away; say it outside it. + toast.success(`${who} let you in`, { + description: "You're in your team's directory now.", + }); onJoined(); } catch (error) { setError((error as Error).message); @@ -468,14 +477,18 @@ function Nearby({ onJoined }: { onJoined: () => void }) { disabled={!!asking} onClick={() => void ask(peer)} > - {asking === peer && } - {asking === peer ? "Waiting…" : "Ask to join"} + {asking?.host === peer.host && ( + + )} + {asking?.host === peer.host ? "Waiting…" : "Ask to join"} ))} {asking && (

- {asking.github || asking.login} approves it in their Ace. You join as soon as they do. + Asked {asking.who}{" "} + to approve it in their Ace. You join as soon as they do; the request lapses at{" "} + {CLOCK.format(asking.lapses)} if nobody answers.

)} {error &&

{error}

} diff --git a/apps/host/src/asks.ts b/apps/host/src/asks.ts index 8f20d1c8..d71965c4 100644 --- a/apps/host/src/asks.ts +++ b/apps/host/src/asks.ts @@ -4,12 +4,9 @@ * WireGuard, so nobody sends them by hand. Tailscale names who is asking, and only this host's * owner, from its local app, can approve. */ -import type { Invite, TeamAsk } from "./protocol"; +import { type Invite, TEAM_ASK_EXPIRES as EXPIRES, type TeamAsk } from "./protocol"; import { invite } from "./settings"; -/** Long enough to notice the prompt; an unanswered ask then fails instead of waiting forever. */ -const EXPIRES = 5 * 60_000; - type Waiting = { ask: TeamAsk; /** The asking peer's socket, so an ask ends with its connection. */ diff --git a/apps/host/src/protocol.ts b/apps/host/src/protocol.ts index 825d4fbb..4e8c78a4 100644 --- a/apps/host/src/protocol.ts +++ b/apps/host/src/protocol.ts @@ -197,6 +197,12 @@ export type Member = { export type TeamPeer = { host: string; login: string; github?: string; team: string }; +/** + * Long enough to notice the prompt; an unanswered ask then fails instead of waiting forever. The + * asker's app names when it lapses from this. + */ +export const TEAM_ASK_EXPIRES = 5 * 60_000; + /** Someone on the tailnet asking this host's owner to let them into the team. */ export type TeamAsk = { id: string; login: string; host: string; github?: string; expires: number };