From e6c90bb7c430d1b2af16508b634f9a5283b7fa3b Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sun, 26 Jul 2026 21:20:26 +0100 Subject: [PATCH] Polish community rail and mobile pairing (#2972) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - align the multi-community rail with the content surface and balance its visible 10px side gutters - center Mobile pairing, start sessions on demand, and keep retry states inside the QR area - reveal the QR code and copy action with 250ms motion and use the standard loading spinner The rail was centered within its own box, but the adjacent sidebar added another 11px to the visible right gap. Mobile pairing also started before user intent, which could leave an idle session waiting for EOSE. ## Validation - `pnpm -C desktop build:e2e` - `pnpm -C desktop test` — 3,516 passed - `pnpm -C desktop exec playwright test tests/e2e/community-rail.spec.ts --project=smoke` — 19 passed - `pnpm -C desktop exec playwright test tests/e2e/mobile-pairing-qr.spec.ts --project=smoke` — 1 passed `pnpm -C desktop check` is currently blocked by the existing `src-tauri/src/managed_agents/runtime.rs` file-size baseline (2,220 lines; limit 2,216). --- desktop/src-tauri/src/commands/pairing.rs | 128 ++++- desktop/src/app/RelayConnectionOverlay.tsx | 4 +- .../settings/ui/MobilePairingCard.tsx | 509 +++++++++++------- .../src/features/sidebar/ui/AppSidebar.tsx | 4 +- .../src/features/sidebar/ui/CommunityRail.tsx | 14 +- desktop/src/shared/api/relayClientSession.ts | 69 ++- desktop/src/shared/api/relayClientShared.ts | 13 +- .../shared/api/relayClosedRecovery.test.mjs | 37 +- desktop/src/shared/api/relayClosedRecovery.ts | 11 +- desktop/src/shared/api/relayGateBoundary.ts | 76 +++ desktop/src/shared/api/relayMembers.ts | 4 +- .../src/shared/styles/globals/animations.css | 63 +++ desktop/src/shared/ui/styled-qr-code.test.mjs | 15 + desktop/src/shared/ui/styled-qr-code.tsx | 25 +- desktop/src/testing/e2eBridge.ts | 21 +- desktop/tests/e2e/community-rail.spec.ts | 32 +- desktop/tests/e2e/mobile-pairing-qr.spec.ts | 175 +++++- desktop/tests/helpers/bridge.ts | 4 + 18 files changed, 891 insertions(+), 313 deletions(-) diff --git a/desktop/src-tauri/src/commands/pairing.rs b/desktop/src-tauri/src/commands/pairing.rs index 639ae16da..fc874a015 100644 --- a/desktop/src-tauri/src/commands/pairing.rs +++ b/desktop/src-tauri/src/commands/pairing.rs @@ -1,3 +1,4 @@ +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use std::time::Duration; @@ -35,6 +36,7 @@ struct PairingErrorPayload { /// Managed Tauri state for an active pairing session. pub struct PairingHandle { session: Arc>>, + generation: Arc, cancel: std::sync::Mutex>, /// Send JSON-serialized events to the background WS task for relay publication. outbound_tx: std::sync::Mutex>>, @@ -47,6 +49,7 @@ impl PairingHandle { pub fn new() -> Self { Self { session: Arc::new(tokio::sync::Mutex::new(None)), + generation: Arc::new(AtomicU64::new(0)), cancel: std::sync::Mutex::new(None), outbound_tx: std::sync::Mutex::new(None), payload: std::sync::Mutex::new(None), @@ -71,10 +74,18 @@ pub async fn start_pairing( state: State<'_, AppState>, pairing: State<'_, PairingHandle>, ) -> Result { + let task_generation = pairing + .generation + .fetch_add(1, Ordering::SeqCst) + .wrapping_add(1); if let Some(token) = pairing.cancel.lock().map_err(|e| e.to_string())?.take() { token.cancel(); } pairing.clear(); + { + let mut session = pairing.session.lock().await; + *session = None; + } let keys = state.signing_keys()?; let nsec = keys @@ -117,9 +128,12 @@ pub async fn start_pairing( *pairing.cancel.lock().map_err(|e| e.to_string())? = Some(cancel.clone()); let session_arc = Arc::clone(&pairing.session); + let generation = Arc::clone(&pairing.generation); tauri::async_runtime::spawn(pairing_ws_task( pairing_relay_url, session_arc, + generation, + task_generation, cancel, outbound_rx, app, @@ -199,6 +213,8 @@ pub async fn cancel_pairing(pairing: State<'_, PairingHandle>) -> Result<(), Str } } + pairing.generation.fetch_add(1, Ordering::SeqCst); + if let Some(token) = pairing.cancel.lock().map_err(|e| e.to_string())?.take() { token.cancel(); } @@ -215,22 +231,35 @@ pub async fn cancel_pairing(pairing: State<'_, PairingHandle>) -> Result<(), Str async fn pairing_ws_task( relay_url: String, session: Arc>>, + generation: Arc, + task_generation: u64, cancel: CancellationToken, mut outbound_rx: mpsc::Receiver, app: AppHandle, ) { - if let Err(e) = - pairing_ws_task_inner(&relay_url, &session, &cancel, &mut outbound_rx, &app).await + if let Err(e) = pairing_ws_task_inner( + &relay_url, + &session, + &generation, + task_generation, + &cancel, + &mut outbound_rx, + &app, + ) + .await { - let _ = app.emit("pairing-error", PairingErrorPayload { message: e }); + if pairing_task_is_current(&generation, task_generation) { + let _ = app.emit("pairing-error", PairingErrorPayload { message: e }); + } } - let mut s = session.lock().await; - *s = None; + clear_pairing_session_if_current(&session, &generation, task_generation).await; } async fn pairing_ws_task_inner( relay_url: &str, session: &Arc>>, + generation: &AtomicU64, + task_generation: u64, cancel: &CancellationToken, outbound_rx: &mut mpsc::Receiver, app: &AppHandle, @@ -261,12 +290,18 @@ async fn pairing_ws_task_inner( tokio::pin!(hard_timeout); loop { + if !pairing_task_is_current(generation, task_generation) { + break; + } + tokio::select! { _ = cancel.cancelled() => break, _ = &mut hard_timeout => { - let _ = app.emit("pairing-error", PairingErrorPayload { - message: "Session timed out".into(), - }); + if pairing_task_is_current(generation, task_generation) { + let _ = app.emit("pairing-error", PairingErrorPayload { + message: "Session timed out".into(), + }); + } break; } Some(json_msg) = outbound_rx.recv() => { @@ -282,30 +317,42 @@ async fn pairing_ws_task_inner( let Message::Text(text) = msg else { continue }; if let Some(event) = parse_relay_event(text.as_str(), "pair") { + if !pairing_task_is_current(generation, task_generation) { + break; + } + let mut guard = session.lock().await; let Some(s) = guard.as_mut() else { break }; if let Ok(reason) = s.handle_abort(&event) { - let _ = app.emit("pairing-aborted", PairingAbortedPayload { - reason: format!("{reason:?}"), - }); + if pairing_task_is_current(generation, task_generation) { + let _ = app.emit("pairing-aborted", PairingAbortedPayload { + reason: format!("{reason:?}"), + }); + } break; } if let Ok(sas) = s.handle_offer(&event) { - let _ = app.emit("pairing-sas-received", PairingSasPayload { sas }); + if pairing_task_is_current(generation, task_generation) { + let _ = app.emit("pairing-sas-received", PairingSasPayload { sas }); + } continue; } match s.handle_complete(&event) { Ok(()) => { - let _ = app.emit("pairing-complete", serde_json::json!({})); + if pairing_task_is_current(generation, task_generation) { + let _ = app.emit("pairing-complete", serde_json::json!({})); + } break; } Err(ref e) if format!("{e}").contains("success=false") => { - let _ = app.emit("pairing-error", PairingErrorPayload { - message: "Mobile device reported failure importing credentials".into(), - }); + if pairing_task_is_current(generation, task_generation) { + let _ = app.emit("pairing-error", PairingErrorPayload { + message: "Mobile device reported failure importing credentials".into(), + }); + } break; } Err(_) => {} @@ -318,6 +365,21 @@ async fn pairing_ws_task_inner( Ok(()) } +fn pairing_task_is_current(generation: &AtomicU64, task_generation: u64) -> bool { + generation.load(Ordering::SeqCst) == task_generation +} + +async fn clear_pairing_session_if_current( + session: &Arc>>, + generation: &AtomicU64, + task_generation: u64, +) { + let mut session = session.lock().await; + if pairing_task_is_current(generation, task_generation) { + *session = None; + } +} + async fn handle_nip42_auth( read: &mut R, write: &mut W, @@ -527,6 +589,40 @@ where .map_err(|_| "timeout waiting for EOSE".to_string())? } +#[cfg(test)] +mod pairing_generation_tests { + use std::sync::atomic::{AtomicU64, Ordering}; + use std::sync::Arc; + + use super::{clear_pairing_session_if_current, PairingSession}; + + #[tokio::test] + async fn stale_task_does_not_clear_replacement_session() { + let (initial, _) = PairingSession::new_source("ws://initial.example".to_string()); + let session = Arc::new(tokio::sync::Mutex::new(Some(initial))); + let generation = AtomicU64::new(1); + + generation.store(2, Ordering::SeqCst); + let (replacement, _) = PairingSession::new_source("ws://replacement.example".to_string()); + *session.lock().await = Some(replacement); + + clear_pairing_session_if_current(&session, &generation, 1).await; + + assert!(session.lock().await.is_some()); + } + + #[tokio::test] + async fn current_task_clears_its_session() { + let (active, _) = PairingSession::new_source("ws://active.example".to_string()); + let session = Arc::new(tokio::sync::Mutex::new(Some(active))); + let generation = AtomicU64::new(3); + + clear_pairing_session_if_current(&session, &generation, 3).await; + + assert!(session.lock().await.is_none()); + } +} + #[cfg(test)] mod pairing_relay_tests { use super::{ diff --git a/desktop/src/app/RelayConnectionOverlay.tsx b/desktop/src/app/RelayConnectionOverlay.tsx index faf28ee51..07c6933d0 100644 --- a/desktop/src/app/RelayConnectionOverlay.tsx +++ b/desktop/src/app/RelayConnectionOverlay.tsx @@ -60,7 +60,7 @@ export function RelayConnectionOverlay({ animate={{ opacity: 1, y: 0 }} className={cn( "pointer-events-none fixed z-50 w-[284px]", - hasCommunityRail ? "left-[60px]" : "left-3", + hasCommunityRail ? "left-[68px]" : "left-3", isHuddleDrawerOpen ? "bottom-[calc(var(--buzz-huddle-drawer-height,0px)+12px)]" : "bottom-3", @@ -86,7 +86,7 @@ export function RelayConnectionOverlay({ animate={{ opacity: 1, y: 0 }} className={cn( "pointer-events-none fixed z-50 w-[284px]", - hasCommunityRail ? "left-[60px]" : "left-3", + hasCommunityRail ? "left-[68px]" : "left-3", isHuddleDrawerOpen ? "bottom-[calc(var(--buzz-huddle-drawer-height,0px)+12px)]" : "bottom-3", diff --git a/desktop/src/features/settings/ui/MobilePairingCard.tsx b/desktop/src/features/settings/ui/MobilePairingCard.tsx index 2013d17de..e15f54316 100644 --- a/desktop/src/features/settings/ui/MobilePairingCard.tsx +++ b/desktop/src/features/settings/ui/MobilePairingCard.tsx @@ -2,16 +2,15 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { Check, Copy, + LoaderCircle, + RefreshCw, ShieldCheck, - Smartphone, TriangleAlert, X, } from "lucide-react"; import { listen } from "@tauri-apps/api/event"; import { toast } from "sonner"; -import { Spinner } from "@/shared/ui/spinner"; - import { cancelPairing, confirmPairingSas, @@ -31,206 +30,74 @@ import { SettingsSectionHeader } from "./SettingsSectionHeader"; import { writeTextToClipboard } from "@/shared/lib/clipboard"; type PairingStep = + | "idle" | "generating" | "qr" + | "expired" | "sas" | "transferring" | "done" | "error"; -function PairingDialog({ - open, - onOpenChange, +function pairingErrorMessage(error: unknown) { + const message = + error instanceof Error + ? error.message + : typeof error === "string" + ? error + : ""; + + if (message.toLowerCase().includes("timeout waiting for eose")) { + return "Pairing took too long. Try again."; + } + + return message || "We couldn't start pairing. Try again."; +} + +function isPairingSessionTimeout(message: string) { + return message.toLowerCase().includes("session timed out"); +} + +function PairingStatusDialog({ + onClose, + onConfirm, + onDeny, + sasCode, + step, }: { - open: boolean; - onOpenChange: (open: boolean) => void; + onClose: () => void; + onConfirm: () => void; + onDeny: () => void; + sasCode: string | null; + step: PairingStep; }) { - const [step, setStep] = useState("generating"); - const [qrUri, setQrUri] = useState(null); - const [sasCode, setSasCode] = useState(null); - const [error, setError] = useState(null); - const stepRef = useRef(step); - stepRef.current = step; - - // Start pairing when dialog opens. - useEffect(() => { - if (!open) return; - - setStep("generating"); - setQrUri(null); - setSasCode(null); - setError(null); - let cancelled = false; - - startPairing().then( - (uri) => { - if (!cancelled) { - setQrUri(uri); - setStep("qr"); - } - }, - (err) => { - if (!cancelled) { - setError( - err instanceof Error - ? err.message - : "Failed to start pairing session", - ); - setStep("error"); - } - }, - ); - - return () => { - cancelled = true; - }; - }, [open]); - - // Listen for Tauri events from the pairing backend. - useEffect(() => { - if (!open) return; - - let cancelled = false; - const unlisteners: (() => void)[] = []; - - listen<{ sas: string }>("pairing-sas-received", (event) => { - if (!cancelled) { - setSasCode(event.payload.sas); - setStep("sas"); - } - }).then((fn) => { - if (cancelled) fn(); - else unlisteners.push(fn); - }); - - listen("pairing-complete", () => { - if (!cancelled) { - setStep("done"); - } - }).then((fn) => { - if (cancelled) fn(); - else unlisteners.push(fn); - }); - - listen<{ reason: string }>("pairing-aborted", (event) => { - if (!cancelled) { - setError(`Pairing aborted: ${event.payload.reason}`); - setStep("error"); - } - }).then((fn) => { - if (cancelled) fn(); - else unlisteners.push(fn); - }); - - listen<{ message: string }>("pairing-error", (event) => { - if (!cancelled) { - setError(event.payload.message); - setStep("error"); - } - }).then((fn) => { - if (cancelled) fn(); - else unlisteners.push(fn); - }); - - return () => { - cancelled = true; - for (const fn of unlisteners) fn(); - }; - }, [open]); - - // Cancel pairing when dialog closes before completion. - const handleOpenChange = useCallback( - (nextOpen: boolean) => { - if (!nextOpen && stepRef.current !== "done") { - cancelPairing().catch(() => {}); - } - onOpenChange(nextOpen); - }, - [onOpenChange], - ); - - async function handleConfirmSas() { - setStep("transferring"); - try { - await confirmPairingSas(); - } catch (err) { - setError( - err instanceof Error ? err.message : "Failed to send credentials", - ); - setStep("error"); - } - } - - function handleDenySas() { - cancelPairing().catch(() => {}); - setError("SAS code mismatch — pairing cancelled for security."); - setStep("error"); - } - - async function handleCopy() { - if (!qrUri) return; - await writeTextToClipboard(qrUri); - toast.success("Copied to clipboard"); - } + const open = step === "sas" || step === "transferring" || step === "done"; return ( - + { + if (!nextOpen) onClose(); + }} + open={open} + >
- Pair Mobile Device + Pair mobile device {step === "sas" ? "Verify the security code matches your mobile device." : step === "done" ? "Your mobile device is now paired." - : "Scan this QR code with the Buzz mobile app to securely pair."} + : "Securely sending your identity to the mobile app."}
- {step === "error" && error ? ( -
- - {error} -
- ) : step === "generating" ? ( -
- -

- Preparing secure pairing session... -

-
- ) : step === "qr" && qrUri ? ( -
-
- -
- - -
- ) : step === "sas" && sasCode ? ( + {step === "sas" && sasCode ? (
@@ -255,7 +122,7 @@ function PairingDialog({
) : step === "transferring" ? (
- +
) : step === "done" ? ( -
+
-

- Mobile device paired successfully -

+

Mobile device paired

Your mobile app is now connected to this relay.

@@ -303,7 +174,158 @@ export function MobilePairingCard({ }: { currentPubkey?: string; }) { - const [dialogOpen, setDialogOpen] = useState(false); + const [step, setStep] = useState("idle"); + const [qrUri, setQrUri] = useState(null); + const [sasCode, setSasCode] = useState(null); + const [error, setError] = useState(null); + const requestIdRef = useRef(0); + const pairingActiveRef = useRef(false); + const stepRef = useRef(step); + stepRef.current = step; + + const beginPairing = useCallback(() => { + const requestId = ++requestIdRef.current; + pairingActiveRef.current = true; + setStep("generating"); + setQrUri(null); + setSasCode(null); + setError(null); + + startPairing().then( + (uri) => { + if (requestId === requestIdRef.current) { + setQrUri(uri); + setStep("qr"); + } + }, + (err) => { + if (requestId === requestIdRef.current) { + pairingActiveRef.current = false; + setError(pairingErrorMessage(err)); + setStep("error"); + } + }, + ); + }, []); + + useEffect(() => { + ++requestIdRef.current; + pairingActiveRef.current = false; + setStep("idle"); + setQrUri(null); + setSasCode(null); + setError(null); + + if (!currentPubkey) { + return; + } + + let cancelled = false; + const unlisteners: (() => void)[] = []; + + listen<{ sas: string }>("pairing-sas-received", (event) => { + if (!cancelled && pairingActiveRef.current) { + setSasCode(event.payload.sas); + setStep("sas"); + } + }).then((fn) => { + if (cancelled) fn(); + else unlisteners.push(fn); + }); + + listen("pairing-complete", () => { + if (!cancelled && pairingActiveRef.current) { + pairingActiveRef.current = false; + setStep("done"); + } + }).then((fn) => { + if (cancelled) fn(); + else unlisteners.push(fn); + }); + + listen<{ reason: string }>("pairing-aborted", (event) => { + if (!cancelled && pairingActiveRef.current) { + pairingActiveRef.current = false; + setError(`Pairing stopped: ${event.payload.reason}`); + setStep("error"); + } + }).then((fn) => { + if (cancelled) fn(); + else unlisteners.push(fn); + }); + + listen<{ message: string }>("pairing-error", (event) => { + if (!cancelled && pairingActiveRef.current) { + pairingActiveRef.current = false; + if (isPairingSessionTimeout(event.payload.message)) { + setQrUri(null); + setSasCode(null); + setError(null); + setStep("expired"); + return; + } + + setError(pairingErrorMessage(event.payload.message)); + setStep("error"); + } + }).then((fn) => { + if (cancelled) fn(); + else unlisteners.push(fn); + }); + + return () => { + cancelled = true; + ++requestIdRef.current; + pairingActiveRef.current = false; + for (const fn of unlisteners) fn(); + if (stepRef.current !== "idle" && stepRef.current !== "done") { + cancelPairing().catch(() => {}); + } + }; + }, [currentPubkey]); + + async function handleCopy() { + if (!qrUri) return; + await writeTextToClipboard(qrUri); + toast.success("Copied to clipboard"); + } + + async function handleConfirmSas() { + setStep("transferring"); + try { + await confirmPairingSas(); + } catch (err) { + setError( + err instanceof Error + ? err.message + : "We couldn't send your identity. Try again.", + ); + pairingActiveRef.current = false; + setStep("error"); + } + } + + function handleDenySas() { + pairingActiveRef.current = false; + cancelPairing().catch(() => {}); + setError("The codes didn't match. Pairing was canceled."); + setStep("error"); + } + + function handleStatusDialogClose() { + pairingActiveRef.current = false; + if (stepRef.current === "done") { + setStep("idle"); + setQrUri(null); + setSasCode(null); + setError(null); + return; + } + + cancelPairing().catch(() => {}); + setError("Pairing was canceled."); + setStep("error"); + } return (
@@ -318,29 +340,106 @@ export function MobilePairingCard({ } /> - - - -
-

Pair Mobile Device

-

- Securely transfer your identity via NIP-AB protocol -

-
- + {step === "qr" && qrUri ? ( + + ) : step === "expired" ? ( +
+

+ Pairing code expired. +

+ +
+ ) : step === "error" ? ( +
+ +

+ {error ?? "Pairing session ended."} +

+ +
+ ) : step === "idle" ? ( + currentPubkey ? ( + + ) : ( +

+ Sign in to generate a mobile pairing code. +

+ ) + ) : ( +
+
+ )} +
+ + {step === "qr" && qrUri ? ( + + ) : null} - {currentPubkey && ( - - )} + void handleConfirmSas()} + onDeny={handleDenySas} + sasCode={sasCode} + step={step} + /> ); } diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index b40f56b04..55f467f21 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -562,7 +562,9 @@ export function AppSidebar({ variant="sidebar" >
1 ? "md:-ml-[11px] md:w-[calc(100%+11px)]" : "" + }`} data-sidebar-background data-testid="app-sidebar-scroll-anchor" > diff --git a/desktop/src/features/sidebar/ui/CommunityRail.tsx b/desktop/src/features/sidebar/ui/CommunityRail.tsx index 86d632398..b15e0bab7 100644 --- a/desktop/src/features/sidebar/ui/CommunityRail.tsx +++ b/desktop/src/features/sidebar/ui/CommunityRail.tsx @@ -37,8 +37,6 @@ import { import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { cn } from "@/shared/lib/cn"; import { getInitials } from "@/shared/lib/initials"; -import { isMacPlatform } from "@/shared/lib/platform"; -import { useIsFullscreen } from "@/shared/lib/useIsFullscreen"; import { writeTextToClipboard } from "@/shared/lib/clipboard"; type CommunityRailProps = { @@ -315,7 +313,6 @@ export function CommunityRail({ activeCommunityId, ); const iconsByCommunity = useCommunityIcons(communities); - const isFullscreen = useIsFullscreen(); const { markAllChannelsRead, onOpenSettings } = useAppShell(); const myMembershipQuery = useMyRelayMembershipLookupQuery(); const activeRole = myMembershipQuery.data?.membership?.role; @@ -370,19 +367,10 @@ export function CommunityRail({ }); }; - // macOS traffic lights overlay the top-left, so start buttons below them (they hide in fullscreen). - const topPaddingClass = - isMacPlatform() && !isFullscreen - ? "pt-(--buzz-top-chrome-height,40px)" - : "pt-3"; - return (