mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Polish community rail and mobile pairing (#2972)
## 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).
This commit is contained in:
@@ -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<tokio::sync::Mutex<Option<PairingSession>>>,
|
||||
generation: Arc<AtomicU64>,
|
||||
cancel: std::sync::Mutex<Option<CancellationToken>>,
|
||||
/// Send JSON-serialized events to the background WS task for relay publication.
|
||||
outbound_tx: std::sync::Mutex<Option<mpsc::Sender<String>>>,
|
||||
@@ -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<String, String> {
|
||||
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<tokio::sync::Mutex<Option<PairingSession>>>,
|
||||
generation: Arc<AtomicU64>,
|
||||
task_generation: u64,
|
||||
cancel: CancellationToken,
|
||||
mut outbound_rx: mpsc::Receiver<String>,
|
||||
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<tokio::sync::Mutex<Option<PairingSession>>>,
|
||||
generation: &AtomicU64,
|
||||
task_generation: u64,
|
||||
cancel: &CancellationToken,
|
||||
outbound_rx: &mut mpsc::Receiver<String>,
|
||||
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<tokio::sync::Mutex<Option<PairingSession>>>,
|
||||
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<R, W>(
|
||||
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::{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<PairingStep>("generating");
|
||||
const [qrUri, setQrUri] = useState<string | null>(null);
|
||||
const [sasCode, setSasCode] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<Dialog onOpenChange={handleOpenChange} open={open}>
|
||||
<Dialog
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen) onClose();
|
||||
}}
|
||||
open={open}
|
||||
>
|
||||
<DialogContent
|
||||
className="max-w-md gap-0 overflow-hidden border-0 px-6 pb-6 pt-6"
|
||||
data-testid="mobile-pairing-dialog"
|
||||
>
|
||||
<div className="flex max-h-[85vh] flex-col">
|
||||
<DialogHeader className="shrink-0 pb-5 pr-8">
|
||||
<DialogTitle>Pair Mobile Device</DialogTitle>
|
||||
<DialogTitle>Pair mobile device</DialogTitle>
|
||||
<DialogDescription>
|
||||
{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."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto pt-4">
|
||||
{step === "error" && error ? (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
<TriangleAlert className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
) : step === "generating" ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-8">
|
||||
<Spinner className="h-6 w-6 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Preparing secure pairing session...
|
||||
</p>
|
||||
</div>
|
||||
) : step === "qr" && qrUri ? (
|
||||
<div className="mx-auto grid w-fit gap-4">
|
||||
<div
|
||||
className="rounded-lg border border-border/70 bg-white p-3"
|
||||
data-testid="mobile-pairing-qr-container"
|
||||
>
|
||||
<StyledQrCode
|
||||
centerImageSrc="/app-icon@2x.png"
|
||||
data-testid="mobile-pairing-qr"
|
||||
size={240}
|
||||
title="Mobile pairing QR code"
|
||||
value={qrUri}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
data-testid="copy-pairing-code"
|
||||
onClick={handleCopy}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Copy className="mr-1.5 h-4 w-4" />
|
||||
Copy pairing code
|
||||
</Button>
|
||||
</div>
|
||||
) : step === "sas" && sasCode ? (
|
||||
{step === "sas" && sasCode ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col items-center gap-3 py-4">
|
||||
<ShieldCheck className="h-10 w-10 text-primary" />
|
||||
@@ -255,7 +122,7 @@ function PairingDialog({
|
||||
<Button
|
||||
className="flex-1"
|
||||
data-testid="deny-sas"
|
||||
onClick={handleDenySas}
|
||||
onClick={onDeny}
|
||||
variant="outline"
|
||||
>
|
||||
<X className="mr-1.5 h-4 w-4" />
|
||||
@@ -264,28 +131,32 @@ function PairingDialog({
|
||||
<Button
|
||||
className="flex-1"
|
||||
data-testid="confirm-sas"
|
||||
onClick={handleConfirmSas}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
<Check className="mr-1.5 h-4 w-4" />
|
||||
Codes Match
|
||||
Codes match
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : step === "transferring" ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-8">
|
||||
<Spinner className="h-6 w-6 text-muted-foreground" />
|
||||
<LoaderCircle
|
||||
aria-hidden="true"
|
||||
className="h-6 w-6 animate-spin text-muted-foreground"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Sending identity to mobile device...
|
||||
</p>
|
||||
</div>
|
||||
) : step === "done" ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-8">
|
||||
<div
|
||||
className="flex flex-col items-center justify-center gap-3 py-8"
|
||||
data-testid="mobile-pairing-done"
|
||||
>
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30">
|
||||
<Check className="h-6 w-6 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<p className="text-sm font-medium">
|
||||
Mobile device paired successfully
|
||||
</p>
|
||||
<p className="text-sm font-medium">Mobile device paired</p>
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
Your mobile app is now connected to this relay.
|
||||
</p>
|
||||
@@ -303,7 +174,158 @@ export function MobilePairingCard({
|
||||
}: {
|
||||
currentPubkey?: string;
|
||||
}) {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [step, setStep] = useState<PairingStep>("idle");
|
||||
const [qrUri, setQrUri] = useState<string | null>(null);
|
||||
const [sasCode, setSasCode] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<section className="min-w-0" data-testid="settings-mobile">
|
||||
@@ -318,29 +340,106 @@ export function MobilePairingCard({
|
||||
}
|
||||
/>
|
||||
|
||||
<SettingsOptionGroup>
|
||||
<SettingsOptionRow className="gap-3">
|
||||
<Smartphone className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">Pair Mobile Device</p>
|
||||
<p className="text-sm font-normal text-muted-foreground">
|
||||
Securely transfer your identity via NIP-AB protocol
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
data-testid="pair-mobile-button"
|
||||
disabled={!currentPubkey}
|
||||
onClick={() => setDialogOpen(true)}
|
||||
size="sm"
|
||||
<SettingsOptionGroup
|
||||
className="mx-auto w-fit max-w-full"
|
||||
data-testid="mobile-pairing-card"
|
||||
>
|
||||
<SettingsOptionRow className="flex-col items-stretch justify-start gap-3 p-4">
|
||||
<div
|
||||
className="flex min-h-[266px] w-[266px] shrink-0 items-center justify-center rounded-lg border border-border/70 bg-white p-3"
|
||||
data-testid="mobile-pairing-qr-container"
|
||||
>
|
||||
Pair
|
||||
</Button>
|
||||
{step === "qr" && qrUri ? (
|
||||
<StyledQrCode
|
||||
animate
|
||||
centerImageSrc="/app-icon@2x.png"
|
||||
data-testid="mobile-pairing-qr"
|
||||
size={240}
|
||||
title="Mobile pairing QR code"
|
||||
value={qrUri}
|
||||
/>
|
||||
) : step === "expired" ? (
|
||||
<div className="flex max-w-52 origin-center animate-in flex-col items-center gap-3 text-center fade-in-0 zoom-in-95 duration-[250ms] ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:animate-none">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Pairing code expired.
|
||||
</p>
|
||||
<Button
|
||||
data-testid="regenerate-pairing-button"
|
||||
onClick={beginPairing}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<RefreshCw className="mr-1.5 h-4 w-4" />
|
||||
Generate new pairing code
|
||||
</Button>
|
||||
</div>
|
||||
) : step === "error" ? (
|
||||
<div className="flex max-w-52 flex-col items-center gap-3 text-center">
|
||||
<TriangleAlert className="h-6 w-6 text-destructive" />
|
||||
<p className="text-sm text-destructive">
|
||||
{error ?? "Pairing session ended."}
|
||||
</p>
|
||||
<Button
|
||||
data-testid="retry-pairing-button"
|
||||
onClick={beginPairing}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
) : step === "idle" ? (
|
||||
currentPubkey ? (
|
||||
<Button
|
||||
data-testid="start-pairing-button"
|
||||
onClick={beginPairing}
|
||||
type="button"
|
||||
>
|
||||
Start pairing
|
||||
</Button>
|
||||
) : (
|
||||
<p className="max-w-44 text-center text-sm text-muted-foreground">
|
||||
Sign in to generate a mobile pairing code.
|
||||
</p>
|
||||
)
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center gap-3">
|
||||
<LoaderCircle
|
||||
aria-hidden="true"
|
||||
className="h-6 w-6 animate-spin text-muted-foreground"
|
||||
data-testid="pairing-loading-spinner"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Starting pairing...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{step === "qr" && qrUri ? (
|
||||
<Button
|
||||
className="w-full origin-top animate-in fade-in-0 zoom-in-95 duration-[250ms] ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:animate-none"
|
||||
data-testid="copy-pairing-code"
|
||||
onClick={handleCopy}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Copy className="mr-1.5 h-4 w-4" />
|
||||
Copy pairing code
|
||||
</Button>
|
||||
) : null}
|
||||
</SettingsOptionRow>
|
||||
</SettingsOptionGroup>
|
||||
|
||||
{currentPubkey && (
|
||||
<PairingDialog onOpenChange={setDialogOpen} open={dialogOpen} />
|
||||
)}
|
||||
<PairingStatusDialog
|
||||
onClose={handleStatusDialogClose}
|
||||
onConfirm={() => void handleConfirmSas()}
|
||||
onDeny={handleDenySas}
|
||||
sasCode={sasCode}
|
||||
step={step}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -562,7 +562,9 @@ export function AppSidebar({
|
||||
variant="sidebar"
|
||||
>
|
||||
<div
|
||||
className="relative flex min-h-0 flex-1 flex-col overflow-hidden"
|
||||
className={`relative flex min-h-0 flex-1 flex-col overflow-hidden ${
|
||||
communities.length > 1 ? "md:-ml-[11px] md:w-[calc(100%+11px)]" : ""
|
||||
}`}
|
||||
data-sidebar-background
|
||||
data-testid="app-sidebar-scroll-anchor"
|
||||
>
|
||||
|
||||
@@ -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 (
|
||||
<nav
|
||||
aria-label="Communities"
|
||||
className={cn(
|
||||
"flex w-12 shrink-0 flex-col items-center gap-2 overflow-y-auto bg-sidebar pb-3",
|
||||
topPaddingClass,
|
||||
)}
|
||||
className="relative z-20 mb-2 mt-[calc(var(--buzz-top-chrome-height,40px)+1px)] flex w-14 shrink-0 flex-col items-center gap-2 overflow-y-auto bg-sidebar px-2.5 pb-3 pt-1.5"
|
||||
data-testid="community-rail"
|
||||
>
|
||||
<DndContext
|
||||
|
||||
@@ -20,14 +20,12 @@ import {
|
||||
type RelaySubscriptionFilter,
|
||||
} from "@/shared/api/relayClientShared";
|
||||
import {
|
||||
AUX_BACKFILL_CHUNK_SIZE,
|
||||
buildChannelAuxDeletionFilter,
|
||||
buildChannelFilter,
|
||||
buildChannelHistoryFilter,
|
||||
buildChannelMentionFilter,
|
||||
buildGlobalStreamFilter,
|
||||
} from "@/shared/api/relayChannelFilters";
|
||||
import { collectWithConcurrency } from "@/shared/api/concurrency";
|
||||
import {
|
||||
clearClosedRetry,
|
||||
handleRelayClosed,
|
||||
@@ -40,7 +38,11 @@ import {
|
||||
parseRateLimitHint,
|
||||
waitForRateLimit,
|
||||
} from "@/shared/api/relayRateLimitGate";
|
||||
import { requestHistoryGated } from "@/shared/api/relayGateBoundary";
|
||||
import {
|
||||
fetchChunkedHistory,
|
||||
requestFirstEventGated,
|
||||
requestHistoryGated,
|
||||
} from "@/shared/api/relayGateBoundary";
|
||||
import { RelayConnectionStateEmitter } from "@/shared/api/relayConnectionStateEmitter";
|
||||
import {
|
||||
isServiceRestartClose,
|
||||
@@ -53,8 +55,7 @@ import { closeWebSocket } from "@/shared/api/relayWebSocketClose";
|
||||
import { buildThreadReferenceTags } from "@/features/messages/lib/threading";
|
||||
const RECONNECT_BASE_DELAY_MS = 1_000,
|
||||
RECONNECT_MAX_DELAY_MS = 30_000,
|
||||
EVENT_BATCH_MS = 16,
|
||||
AUX_BACKFILL_CONCURRENCY = 4;
|
||||
EVENT_BATCH_MS = 16;
|
||||
|
||||
/**
|
||||
* Op-level timeout constants. Raised from 8 s to 25 s to survive degraded
|
||||
@@ -176,7 +177,7 @@ export class RelayClient {
|
||||
}
|
||||
|
||||
for (const [subId, sub] of this.subscriptions) {
|
||||
if (sub.mode === "history") {
|
||||
if (sub.mode !== "live") {
|
||||
window.clearTimeout(sub.timeout);
|
||||
sub.reject(error);
|
||||
} else {
|
||||
@@ -224,10 +225,10 @@ export class RelayClient {
|
||||
eventIds: string[],
|
||||
) => RelaySubscriptionFilter,
|
||||
) {
|
||||
return this.fetchChunkedAuxEvents(
|
||||
channelId,
|
||||
return fetchChunkedHistory(
|
||||
referencedEventIds,
|
||||
buildFilter,
|
||||
(eventIds) => buildFilter(channelId, eventIds),
|
||||
(filter) => this.fetchHistory(filter),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -235,10 +236,10 @@ export class RelayClient {
|
||||
channelId: string,
|
||||
auxEventIds: string[],
|
||||
): Promise<RelayEvent[]> {
|
||||
return this.fetchChunkedAuxEvents(
|
||||
channelId,
|
||||
return fetchChunkedHistory(
|
||||
auxEventIds,
|
||||
buildChannelAuxDeletionFilter,
|
||||
(eventIds) => buildChannelAuxDeletionFilter(channelId, eventIds),
|
||||
(filter) => this.fetchHistory(filter),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -246,32 +247,21 @@ export class RelayClient {
|
||||
return this.fetchHistory(filter);
|
||||
}
|
||||
|
||||
private async fetchChunkedAuxEvents(
|
||||
channelId: string,
|
||||
eventIds: string[],
|
||||
buildFilter: (
|
||||
channelId: string,
|
||||
eventIds: string[],
|
||||
) => RelaySubscriptionFilter,
|
||||
): Promise<RelayEvent[]> {
|
||||
if (eventIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the first event matching `filter` as soon as it arrives, without
|
||||
* waiting for EOSE. Resolves to `null` when EOSE arrives before any event.
|
||||
*/
|
||||
async fetchFirstEvent(
|
||||
filter: RelaySubscriptionFilter,
|
||||
): Promise<RelayEvent | null> {
|
||||
await this.ensureConnected();
|
||||
|
||||
const chunks: string[][] = [];
|
||||
for (let i = 0; i < eventIds.length; i += AUX_BACKFILL_CHUNK_SIZE) {
|
||||
chunks.push(eventIds.slice(i, i + AUX_BACKFILL_CHUNK_SIZE));
|
||||
}
|
||||
|
||||
const batches = await collectWithConcurrency(
|
||||
chunks,
|
||||
AUX_BACKFILL_CONCURRENCY,
|
||||
(ids) => this.requestHistory(buildFilter(channelId, ids)),
|
||||
return requestFirstEventGated(
|
||||
this.subscriptions,
|
||||
(payload) => this.sendRaw(payload),
|
||||
(subId) => this.closeSubscription(subId),
|
||||
filter,
|
||||
HISTORY_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
return batches.flat();
|
||||
}
|
||||
|
||||
private async fetchHistory(filter: RelaySubscriptionFilter) {
|
||||
@@ -877,6 +867,11 @@ export class RelayClient {
|
||||
return;
|
||||
}
|
||||
|
||||
if (subscription.mode === "first") {
|
||||
subscription.onEvent(event);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!prepareSubscriptionEvent(subscription, event)) return;
|
||||
this.eventBuffer.push({ subId, event });
|
||||
this.flushTimeout ??= window.setTimeout(
|
||||
@@ -1068,7 +1063,7 @@ export class RelayClient {
|
||||
}
|
||||
|
||||
for (const [subId, subscription] of this.subscriptions) {
|
||||
if (subscription.mode === "history") {
|
||||
if (subscription.mode !== "live") {
|
||||
window.clearTimeout(subscription.timeout);
|
||||
subscription.reject(error);
|
||||
this.subscriptions.delete(subId);
|
||||
|
||||
@@ -46,6 +46,14 @@ type HistorySubscription = {
|
||||
timeout: number;
|
||||
};
|
||||
|
||||
type FirstEventSubscription = {
|
||||
mode: "first";
|
||||
onEvent: (event: RelayEvent) => void;
|
||||
resolve: (event: RelayEvent | null) => void;
|
||||
reject: (error: Error) => void;
|
||||
timeout: number;
|
||||
};
|
||||
|
||||
type LiveSubscription = {
|
||||
mode: "live";
|
||||
filter: RelaySubscriptionFilter;
|
||||
@@ -63,7 +71,10 @@ export type PendingEvent = {
|
||||
timeout: number;
|
||||
};
|
||||
|
||||
export type RelaySubscription = HistorySubscription | LiveSubscription;
|
||||
export type RelaySubscription =
|
||||
| HistorySubscription
|
||||
| FirstEventSubscription
|
||||
| LiveSubscription;
|
||||
|
||||
export function sortEvents(events: RelayEvent[]) {
|
||||
return [...events].sort((left, right) => {
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { handleRelayClosed } from "./relayClosedRecovery.ts";
|
||||
import { requestHistoryGated } from "./relayGateBoundary.ts";
|
||||
import {
|
||||
handleRelayClosed,
|
||||
handleSubscriptionEose,
|
||||
} from "./relayClosedRecovery.ts";
|
||||
import {
|
||||
requestFirstEventGated,
|
||||
requestHistoryGated,
|
||||
} from "./relayGateBoundary.ts";
|
||||
|
||||
// ── Fake-timer setup ──────────────────────────────────────────────────────────
|
||||
// The rate-limit gate and closed-retry logic use window.setTimeout/clearTimeout.
|
||||
@@ -227,6 +233,33 @@ test("gate armed by rate-limited history CLOSED defers the next REQ until expiry
|
||||
assert.ok(sentAt[0] >= 5_001, "REQ must fire only after gate expiry");
|
||||
});
|
||||
|
||||
test("first-event request resolves null when EOSE arrives without an event", async () => {
|
||||
resetAll(0);
|
||||
const subscriptions = new Map();
|
||||
let requestedSubId = "";
|
||||
const firstEventPromise = requestFirstEventGated(
|
||||
subscriptions,
|
||||
async (payload) => {
|
||||
requestedSubId = payload[1];
|
||||
},
|
||||
async () => {},
|
||||
{ kinds: [13_534], limit: 1 },
|
||||
25_000,
|
||||
);
|
||||
|
||||
await Promise.resolve();
|
||||
assert.match(requestedSubId, /^first-/);
|
||||
|
||||
handleSubscriptionEose({
|
||||
subscriptions,
|
||||
subId: requestedSubId,
|
||||
closeSubscription: async () => {},
|
||||
});
|
||||
|
||||
assert.equal(await firstEventPromise, null);
|
||||
assert.equal(subscriptions.has(requestedSubId), false);
|
||||
});
|
||||
|
||||
test("production CLOSED handler removes terminal live subscriptions", () => {
|
||||
let readyCalls = 0;
|
||||
const subscriptions = new Map([
|
||||
|
||||
@@ -35,7 +35,7 @@ export function handleRelayClosed({
|
||||
}) {
|
||||
const subscription = subscriptions.get(subId);
|
||||
if (!subscription) return;
|
||||
if (subscription.mode === "history") {
|
||||
if (subscription.mode !== "live") {
|
||||
// Classify before rejecting so a `rate-limited:` history CLOSED arms the
|
||||
// gate for concurrent ops. A history sub can't be retried (the caller holds
|
||||
// the promise), so we still reject immediately after arming.
|
||||
@@ -133,6 +133,9 @@ export function prepareSubscriptionEvent(
|
||||
subscription.events.push(event);
|
||||
return false;
|
||||
}
|
||||
if (subscription.mode === "first") {
|
||||
return false;
|
||||
}
|
||||
subscription.closedRetryAttempt = 0;
|
||||
clearClosedRetry(subscription);
|
||||
subscription.lastSeenCreatedAt = Math.max(
|
||||
@@ -163,5 +166,9 @@ export function handleSubscriptionEose({
|
||||
window.clearTimeout(subscription.timeout);
|
||||
subscriptions.delete(subId);
|
||||
void closeSubscription(subId);
|
||||
subscription.resolve(sortEvents(subscription.events));
|
||||
if (subscription.mode === "first") {
|
||||
subscription.resolve(null);
|
||||
} else {
|
||||
subscription.resolve(sortEvents(subscription.events));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
* the gate-await + op-timeout pattern lives in one place and the op timeout
|
||||
* budget starts only after the rate-limit window has cleared.
|
||||
*/
|
||||
import { collectWithConcurrency } from "@/shared/api/concurrency";
|
||||
import { AUX_BACKFILL_CHUNK_SIZE } from "@/shared/api/relayChannelFilters";
|
||||
import { waitForRateLimit } from "@/shared/api/relayRateLimitGate";
|
||||
import type {
|
||||
RelaySubscription,
|
||||
@@ -12,6 +14,8 @@ import type {
|
||||
} from "@/shared/api/relayClientShared";
|
||||
import type { RelayEvent } from "@/shared/api/types";
|
||||
|
||||
const AUX_BACKFILL_CONCURRENCY = 4;
|
||||
|
||||
/**
|
||||
* Issue a history REQ on `filter`, waiting for any active rate-limit gate
|
||||
* before starting the subscription so the op timeout begins only after
|
||||
@@ -54,3 +58,75 @@ export async function requestHistoryGated(
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue a REQ that resolves as soon as its first matching event arrives.
|
||||
*
|
||||
* This keeps single-event lookups from waiting for EOSE while still resolving
|
||||
* `null` when the relay completes an empty result set.
|
||||
*/
|
||||
export async function requestFirstEventGated(
|
||||
subscriptions: Map<string, RelaySubscription>,
|
||||
sendRaw: (payload: unknown[]) => Promise<void>,
|
||||
closeSubscription: (subId: string) => Promise<void>,
|
||||
filter: RelaySubscriptionFilter,
|
||||
historyTimeoutMs: number,
|
||||
): Promise<RelayEvent | null> {
|
||||
await waitForRateLimit();
|
||||
|
||||
return new Promise<RelayEvent | null>((resolve, reject) => {
|
||||
const subId = `first-${crypto.randomUUID()}`;
|
||||
const timeout = window.setTimeout(() => {
|
||||
subscriptions.delete(subId);
|
||||
void closeSubscription(subId);
|
||||
reject(new Error("Timed out while loading relay event."));
|
||||
}, historyTimeoutMs);
|
||||
|
||||
subscriptions.set(subId, {
|
||||
mode: "first",
|
||||
onEvent: (event) => {
|
||||
window.clearTimeout(timeout);
|
||||
subscriptions.delete(subId);
|
||||
void closeSubscription(subId);
|
||||
resolve(event);
|
||||
},
|
||||
resolve,
|
||||
reject,
|
||||
timeout,
|
||||
});
|
||||
|
||||
void sendRaw(["REQ", subId, filter]).catch((error) => {
|
||||
window.clearTimeout(timeout);
|
||||
subscriptions.delete(subId);
|
||||
reject(
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error("Failed to request relay event."),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch relay history for event IDs in bounded, concurrent chunks and flatten
|
||||
* the responses into one list. Returns an empty list without issuing a request
|
||||
* when `eventIds` is empty.
|
||||
*/
|
||||
export async function fetchChunkedHistory(
|
||||
eventIds: string[],
|
||||
buildFilter: (eventIds: string[]) => RelaySubscriptionFilter,
|
||||
fetchHistory: (filter: RelaySubscriptionFilter) => Promise<RelayEvent[]>,
|
||||
): Promise<RelayEvent[]> {
|
||||
if (eventIds.length === 0) return [];
|
||||
|
||||
const chunks: string[][] = [];
|
||||
for (let i = 0; i < eventIds.length; i += AUX_BACKFILL_CHUNK_SIZE) {
|
||||
chunks.push(eventIds.slice(i, i + AUX_BACKFILL_CHUNK_SIZE));
|
||||
}
|
||||
const batches = await collectWithConcurrency(
|
||||
chunks,
|
||||
AUX_BACKFILL_CONCURRENCY,
|
||||
(ids) => fetchHistory(buildFilter(ids)),
|
||||
);
|
||||
return batches.flat();
|
||||
}
|
||||
|
||||
@@ -104,12 +104,10 @@ export function relayMembershipLookupFromEvent(
|
||||
}
|
||||
|
||||
async function fetchMembershipListEvent(): Promise<RelayEvent | null> {
|
||||
const events = await relayClient.fetchEvents({
|
||||
return relayClient.fetchFirstEvent({
|
||||
kinds: [KIND_NIP43_MEMBERSHIP_LIST],
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
return events[events.length - 1] ?? null;
|
||||
}
|
||||
|
||||
/** Loads the NIP-43 snapshot only when the relay advertises membership support. */
|
||||
|
||||
@@ -2,6 +2,69 @@
|
||||
animation: sprout-arc-spinner-spin 500ms linear infinite;
|
||||
}
|
||||
|
||||
@keyframes buzz-qr-cell-reveal {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0);
|
||||
}
|
||||
|
||||
27% {
|
||||
opacity: 0.27;
|
||||
transform: scale(0);
|
||||
}
|
||||
|
||||
30% {
|
||||
opacity: 0.3;
|
||||
transform: scale(0.043);
|
||||
}
|
||||
|
||||
35% {
|
||||
opacity: 0.35;
|
||||
transform: scale(0.167);
|
||||
}
|
||||
|
||||
40% {
|
||||
opacity: 0.4;
|
||||
transform: scale(0.358);
|
||||
}
|
||||
|
||||
45% {
|
||||
opacity: 0.45;
|
||||
transform: scale(0.632);
|
||||
}
|
||||
|
||||
49% {
|
||||
opacity: 0.49;
|
||||
transform: scale(0.918);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.buzz-qr-cell-reveal {
|
||||
opacity: 0;
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
animation: buzz-qr-cell-reveal 58ms linear var(--buzz-qr-reveal-delay, 0ms)
|
||||
forwards;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.buzz-qr-cell-reveal {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sprout-arc-spinner-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
|
||||
@@ -37,3 +37,18 @@ test("uses a deterministic lower-density matrix for the same payload", () => {
|
||||
|
||||
assert.equal(first, second);
|
||||
});
|
||||
|
||||
test("adds the row-based reveal motion when requested", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
React.createElement(StyledQrCode, {
|
||||
animate: true,
|
||||
value: TEST_PAIRING_URI,
|
||||
}),
|
||||
);
|
||||
|
||||
assert.match(html, /class="buzz-qr-cell-reveal"/);
|
||||
assert.match(html, /data-qr-cell-row="0"/);
|
||||
assert.match(html, /--buzz-qr-reveal-delay:0ms/);
|
||||
assert.match(html, /data-qr-cell-row="56"/);
|
||||
assert.match(html, /--buzz-qr-reveal-delay:189ms/);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { useId, useMemo, type ReactNode, type SVGProps } from "react";
|
||||
import {
|
||||
useId,
|
||||
useMemo,
|
||||
type CSSProperties,
|
||||
type ReactNode,
|
||||
type SVGProps,
|
||||
} from "react";
|
||||
import { create } from "qrcode";
|
||||
|
||||
const CELL_SPACING_RATIO = 0.2;
|
||||
@@ -6,11 +12,16 @@ const FINDER_PATTERN_SIZE = 7;
|
||||
const QUIET_ZONE_SIZE = 4;
|
||||
const MAX_CENTER_OBSCURED_RATIO = 0.1;
|
||||
const CENTER_ICON_SIZE_RATIO = 0.8;
|
||||
const QR_REVEAL_DURATION_MS = 250;
|
||||
const QR_REVEAL_WAVE_SPREAD = 0.3;
|
||||
const QR_REVEAL_ROW_TRAVEL_MS =
|
||||
QR_REVEAL_DURATION_MS / (1 + QR_REVEAL_WAVE_SPREAD);
|
||||
|
||||
type StyledQrCodeProps = Omit<
|
||||
SVGProps<SVGSVGElement>,
|
||||
"children" | "height" | "title" | "width"
|
||||
> & {
|
||||
animate?: boolean;
|
||||
backgroundColor?: string;
|
||||
centerImageSrc?: string;
|
||||
foregroundColor?: string;
|
||||
@@ -76,6 +87,7 @@ function FinderPattern({
|
||||
}
|
||||
|
||||
export function StyledQrCode({
|
||||
animate = false,
|
||||
backgroundColor = "#ffffff",
|
||||
centerImageSrc,
|
||||
foregroundColor = "#000000",
|
||||
@@ -118,11 +130,22 @@ export function StyledQrCode({
|
||||
|
||||
cells.push(
|
||||
<circle
|
||||
className={animate ? "buzz-qr-cell-reveal" : undefined}
|
||||
cx={column + 0.5}
|
||||
cy={row + 0.5}
|
||||
data-qr-cell-row={row}
|
||||
fill={foregroundColor}
|
||||
key={`${row}-${column}`}
|
||||
r={dataCellRadius}
|
||||
style={
|
||||
animate
|
||||
? ({
|
||||
"--buzz-qr-reveal-delay": `${Math.round(
|
||||
(row / Math.max(matrix.size, 1)) * QR_REVEAL_ROW_TRAVEL_MS,
|
||||
)}ms`,
|
||||
} as CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -284,6 +284,8 @@ type E2eConfig = {
|
||||
oaOwnerIsMe?: boolean;
|
||||
/** Whether the mock relay advertises NIP-43 membership support. Defaults to false. */
|
||||
relayRequiresMembership?: boolean;
|
||||
/** Delay EOSE for membership snapshots after delivering the event. */
|
||||
relayMembershipEoseDelayMs?: number;
|
||||
relayRole?: "owner" | "admin" | "member" | null;
|
||||
// Descriptors returned by the mocked `pick_and_upload_media` /
|
||||
// `upload_media_bytes` commands. Lets a spec drive the attachment flow
|
||||
@@ -296,6 +298,8 @@ type E2eConfig = {
|
||||
/** Delay (ms) applied to `get_relay_self` so E2E tests can prove the
|
||||
* fail-closed race: DMs are withheld while classification is unresolved. */
|
||||
relaySelfDelayMs?: number;
|
||||
/** Delay (ms) applied to `start_pairing` so pairing loading UI is observable. */
|
||||
pairingStartDelayMs?: number;
|
||||
/**
|
||||
* Sequenced results for `confirm_team_snapshot_import`. String = throw
|
||||
* with that message; null = succeed. Call N uses results[N]; last entry
|
||||
@@ -8746,7 +8750,15 @@ function sendToMockSocket(args: {
|
||||
subId,
|
||||
createMockRelayMembershipEvent(),
|
||||
]);
|
||||
sendWsText(socket.handler, ["EOSE", subId]);
|
||||
const eoseDelayMs = getConfig()?.mock?.relayMembershipEoseDelayMs ?? 0;
|
||||
if (eoseDelayMs > 0) {
|
||||
window.setTimeout(
|
||||
() => sendWsText(socket.handler, ["EOSE", subId]),
|
||||
eoseDelayMs,
|
||||
);
|
||||
} else {
|
||||
sendWsText(socket.handler, ["EOSE", subId]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -10902,8 +10914,13 @@ export function maybeInstallE2eTauriMocks() {
|
||||
case "plugin:event|listen":
|
||||
// Tauri event system (pairing, huddle) — no-op in e2e, return unlisten fn ID
|
||||
return Math.floor(Math.random() * 1_000_000);
|
||||
case "start_pairing":
|
||||
case "start_pairing": {
|
||||
const delayMs = activeConfig?.mock?.pairingStartDelayMs ?? 0;
|
||||
if (delayMs > 0) {
|
||||
await new Promise((resolve) => window.setTimeout(resolve, delayMs));
|
||||
}
|
||||
return "nostrpair://8f4b8db31967ce14fef970a1ff1e8eecf19a430aa1c83875e2f5be68dcac0f1a?relay=wss%3A%2F%2Frelay.example.com&secret=87d5a8cfd5807a0cb44f728b67d88d6dcb8daf99be137c158f21a50c1e913c0a&v=1";
|
||||
}
|
||||
case "cancel_pairing":
|
||||
case "confirm_pairing_sas":
|
||||
return null;
|
||||
|
||||
@@ -182,6 +182,7 @@ test.describe("community rail", () => {
|
||||
await installMockBridge(
|
||||
page,
|
||||
{
|
||||
relayMembershipEoseDelayMs: 30_000,
|
||||
relayRequiresMembership: true,
|
||||
relayRole: "admin",
|
||||
},
|
||||
@@ -215,7 +216,7 @@ test.describe("community rail", () => {
|
||||
).not.toBeFocused();
|
||||
await expect(
|
||||
menu.getByRole("menuitem", { name: "Invite to community" }),
|
||||
).toBeVisible();
|
||||
).toBeVisible({ timeout: 1_000 });
|
||||
await expect(
|
||||
menu.getByRole("menuitem", { name: "Community settings" }),
|
||||
).toBeVisible();
|
||||
@@ -658,9 +659,32 @@ test.describe("community rail", () => {
|
||||
`community-rail-button-${COMMUNITY_A.id}`,
|
||||
);
|
||||
await expect(firstButton).toBeVisible();
|
||||
const box = await firstButton.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
expect(box?.y ?? 0).toBeGreaterThanOrEqual(32);
|
||||
const buttonBox = await firstButton.boundingBox();
|
||||
const railBox = await page.getByTestId("community-rail").boundingBox();
|
||||
const searchBox = await page.getByTestId("open-search").boundingBox();
|
||||
const contentBox = await page
|
||||
.locator("[data-buzz-content-surface]")
|
||||
.first()
|
||||
.boundingBox();
|
||||
expect(buttonBox).not.toBeNull();
|
||||
expect(railBox).not.toBeNull();
|
||||
expect(searchBox).not.toBeNull();
|
||||
expect(contentBox).not.toBeNull();
|
||||
expect(buttonBox?.y ?? 0).toBeGreaterThanOrEqual(32);
|
||||
expect(Math.abs((railBox?.y ?? 0) - (contentBox?.y ?? 0))).toBeLessThan(
|
||||
0.5,
|
||||
);
|
||||
|
||||
const leftInset = (buttonBox?.x ?? 0) - (railBox?.x ?? 0);
|
||||
const rightInset =
|
||||
(railBox?.x ?? 0) +
|
||||
(railBox?.width ?? 0) -
|
||||
((buttonBox?.x ?? 0) + (buttonBox?.width ?? 0));
|
||||
expect(Math.abs(leftInset - 10)).toBeLessThan(0.5);
|
||||
expect(Math.abs(leftInset - rightInset)).toBeLessThan(0.5);
|
||||
const visibleRightGap =
|
||||
(searchBox?.x ?? 0) - ((buttonBox?.x ?? 0) + (buttonBox?.width ?? 0));
|
||||
expect(Math.abs(leftInset - visibleRightGap)).toBeLessThan(0.5);
|
||||
|
||||
// With the rail visible, the top-chrome controls (sidebar toggle, back/
|
||||
// forward) sit just past the traffic lights near the rail edge — not
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { mkdirSync } from "node:fs";
|
||||
|
||||
import { installMockBridge } from "../helpers/bridge";
|
||||
@@ -7,52 +7,179 @@ import { waitForAnimations } from "../helpers/animations";
|
||||
const SCREENSHOT_DIR = "test-results/mobile-pairing-qr";
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await installMockBridge(page);
|
||||
await installMockBridge(page, { pairingStartDelayMs: 300 });
|
||||
});
|
||||
|
||||
test("mobile pairing uses the local Wallet-style QR renderer", async ({
|
||||
async function emitPairingEvent(page: Page, event: string, payload?: unknown) {
|
||||
await page.evaluate(
|
||||
async ({ eventName, eventPayload }) => {
|
||||
const internals = (
|
||||
window as Window & {
|
||||
__TAURI_INTERNALS__?: {
|
||||
invoke?: (
|
||||
command: string,
|
||||
args: Record<string, unknown>,
|
||||
) => Promise<unknown>;
|
||||
};
|
||||
}
|
||||
).__TAURI_INTERNALS__;
|
||||
if (!internals?.invoke) {
|
||||
throw new Error("Tauri E2E event bridge is unavailable");
|
||||
}
|
||||
await internals.invoke("plugin:event|emit", {
|
||||
event: eventName,
|
||||
payload: eventPayload,
|
||||
});
|
||||
},
|
||||
{ eventName: event, eventPayload: payload },
|
||||
);
|
||||
}
|
||||
|
||||
test("mobile pairing starts on demand and reveals the QR code", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("open-settings").click();
|
||||
await page.getByTestId("profile-popover-settings").click();
|
||||
await page.getByTestId("settings-nav-mobile").click();
|
||||
await page.getByTestId("pair-mobile-button").click();
|
||||
|
||||
const dialog = page.getByTestId("mobile-pairing-dialog");
|
||||
const qrCode = page.getByTestId("mobile-pairing-qr");
|
||||
mkdirSync(SCREENSHOT_DIR, { recursive: true });
|
||||
|
||||
const section = page.getByTestId("settings-mobile");
|
||||
const card = page.getByTestId("mobile-pairing-card");
|
||||
const qrContainer = page.getByTestId("mobile-pairing-qr-container");
|
||||
const copyButton = dialog.getByTestId("copy-pairing-code");
|
||||
await expect(dialog).toBeVisible();
|
||||
const startButton = card.getByTestId("start-pairing-button");
|
||||
await expect(card).toBeVisible();
|
||||
await expect(startButton).toHaveText("Start pairing");
|
||||
await expect(page.getByTestId("mobile-pairing-qr")).toHaveCount(0);
|
||||
await expect(page.getByTestId("copy-pairing-code")).toHaveCount(0);
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
(window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter(
|
||||
(entry) => entry.command === "start_pairing",
|
||||
).length,
|
||||
),
|
||||
).toBe(0);
|
||||
|
||||
const sectionBox = await section.boundingBox();
|
||||
const cardBox = await card.boundingBox();
|
||||
expect(sectionBox).not.toBeNull();
|
||||
expect(cardBox).not.toBeNull();
|
||||
const sectionCenter = (sectionBox?.x ?? 0) + (sectionBox?.width ?? 0) / 2;
|
||||
const cardCenter = (cardBox?.x ?? 0) + (cardBox?.width ?? 0) / 2;
|
||||
expect(Math.abs(sectionCenter - cardCenter)).toBeLessThan(0.5);
|
||||
await waitForAnimations(page);
|
||||
await card.screenshot({ path: `${SCREENSHOT_DIR}/pairing-start.png` });
|
||||
|
||||
await startButton.click();
|
||||
|
||||
const loadingSpinner = card.getByTestId("pairing-loading-spinner");
|
||||
await expect(loadingSpinner).toBeVisible();
|
||||
await expect(loadingSpinner).toHaveCSS("animation-name", "spin");
|
||||
|
||||
const qrCode = page.getByTestId("mobile-pairing-qr");
|
||||
const copyButton = card.getByTestId("copy-pairing-code");
|
||||
await expect(qrCode).toBeVisible();
|
||||
await expect(copyButton).toHaveText("Copy pairing code");
|
||||
await expect(dialog).toHaveCSS("border-radius", "16px");
|
||||
await expect(dialog).toHaveCSS("border-left-width", "0px");
|
||||
await expect(dialog).toHaveCSS("padding-left", "24px");
|
||||
await expect(dialog).toHaveCSS("padding-right", "24px");
|
||||
await expect(dialog).toHaveCSS("padding-top", "24px");
|
||||
await expect(dialog.getByTestId("mobile-pairing-done")).toHaveCount(0);
|
||||
await expect(startButton).toHaveCount(0);
|
||||
await expect(card.getByText("Pair mobile device")).toHaveCount(0);
|
||||
await expect(page.getByTestId("mobile-pairing-dialog")).toHaveCount(0);
|
||||
await expect(
|
||||
dialog.getByRole("button", { name: "Close", exact: true }),
|
||||
).toHaveCount(1);
|
||||
await expect(
|
||||
dialog.getByText("Waiting for mobile device to scan..."),
|
||||
page.getByText("Securely transfer your identity via NIP-AB protocol"),
|
||||
).toHaveCount(0);
|
||||
await expect(dialog.locator("code")).toHaveCount(0);
|
||||
await expect(qrCode).toHaveAttribute("data-qr-matrix-size", "57");
|
||||
await expect(qrCode.locator("[data-qr-finder-pattern]")).toHaveCount(3);
|
||||
expect(await qrCode.locator("circle").count()).toBeGreaterThan(100);
|
||||
expect(await qrCode.locator(".buzz-qr-cell-reveal").count()).toBeGreaterThan(
|
||||
100,
|
||||
);
|
||||
await expect(qrCode.locator(".buzz-qr-cell-reveal").first()).toHaveCSS(
|
||||
"animation-name",
|
||||
"buzz-qr-cell-reveal",
|
||||
);
|
||||
await expect(qrCode.locator(".buzz-qr-cell-reveal").first()).toHaveCSS(
|
||||
"animation-duration",
|
||||
"0.058s",
|
||||
);
|
||||
await expect(qrCode.locator(".buzz-qr-cell-reveal").first()).toHaveCSS(
|
||||
"animation-timing-function",
|
||||
"linear",
|
||||
);
|
||||
await expect(
|
||||
qrCode.locator('[data-qr-cell-row="56"].buzz-qr-cell-reveal').first(),
|
||||
).toHaveCSS("animation-delay", "0.189s");
|
||||
await expect(copyButton).toHaveCSS("animation-name", "enter");
|
||||
await expect(copyButton).toHaveCSS("animation-duration", "0.25s");
|
||||
await expect(qrCode.locator("image")).toHaveAttribute(
|
||||
"href",
|
||||
"/app-icon@2x.png",
|
||||
);
|
||||
await waitForAnimations(page);
|
||||
const qrContainerWidth = await qrContainer.evaluate(
|
||||
(element) => getComputedStyle(element).width,
|
||||
const qrBox = await qrContainer.boundingBox();
|
||||
const copyBox = await copyButton.boundingBox();
|
||||
expect(qrBox).not.toBeNull();
|
||||
expect(copyBox).not.toBeNull();
|
||||
expect(copyBox?.y ?? 0).toBeGreaterThan(
|
||||
(qrBox?.y ?? 0) + (qrBox?.height ?? 0),
|
||||
);
|
||||
expect(Math.abs((copyBox?.x ?? 0) - (qrBox?.x ?? 0))).toBeLessThan(0.5);
|
||||
expect(Math.abs((copyBox?.width ?? 0) - (qrBox?.width ?? 0))).toBeLessThan(
|
||||
0.5,
|
||||
);
|
||||
await expect(copyButton).toHaveCSS("width", qrContainerWidth);
|
||||
|
||||
mkdirSync(SCREENSHOT_DIR, { recursive: true });
|
||||
await dialog.screenshot({ path: `${SCREENSHOT_DIR}/pairing-dialog.png` });
|
||||
await emitPairingEvent(page, "pairing-error", {
|
||||
message: "Session timed out",
|
||||
});
|
||||
|
||||
await expect(qrCode).toHaveCount(0);
|
||||
await expect(copyButton).toHaveCount(0);
|
||||
await expect(card.getByText("Pairing code expired.")).toBeVisible();
|
||||
|
||||
const regenerateButton = card.getByTestId("regenerate-pairing-button");
|
||||
await expect(regenerateButton).toHaveText("Generate new pairing code");
|
||||
await waitForAnimations(page);
|
||||
await card.screenshot({ path: `${SCREENSHOT_DIR}/pairing-expired.png` });
|
||||
await regenerateButton.click();
|
||||
await expect(loadingSpinner).toBeVisible();
|
||||
await expect(qrCode).toBeVisible();
|
||||
await expect(copyButton).toBeVisible();
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
(window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter(
|
||||
(entry) => entry.command === "start_pairing",
|
||||
).length,
|
||||
),
|
||||
).toBe(2);
|
||||
|
||||
await waitForAnimations(page);
|
||||
await card.screenshot({ path: `${SCREENSHOT_DIR}/pairing-card.png` });
|
||||
await qrCode.screenshot({ path: `${SCREENSHOT_DIR}/pairing-qr.png` });
|
||||
});
|
||||
|
||||
test("late pairing events are ignored after canceling", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("open-settings").click();
|
||||
await page.getByTestId("profile-popover-settings").click();
|
||||
await page.getByTestId("settings-nav-mobile").click();
|
||||
|
||||
const card = page.getByTestId("mobile-pairing-card");
|
||||
await card.getByTestId("start-pairing-button").click();
|
||||
await expect(page.getByTestId("mobile-pairing-qr")).toBeVisible();
|
||||
|
||||
await emitPairingEvent(page, "pairing-sas-received", { sas: "123456" });
|
||||
const dialog = page.getByTestId("mobile-pairing-dialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole("button", { name: "Close" }).click();
|
||||
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(card.getByText("Pairing was canceled.")).toBeVisible();
|
||||
|
||||
await emitPairingEvent(page, "pairing-complete");
|
||||
await emitPairingEvent(page, "pairing-sas-received", { sas: "654321" });
|
||||
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(page.getByTestId("mobile-pairing-done")).toHaveCount(0);
|
||||
await expect(card.getByText("Pairing was canceled.")).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -301,6 +301,8 @@ type MockBridgeOptions = {
|
||||
oaOwnerIsMe?: boolean;
|
||||
/** Whether the mock relay advertises NIP-43 membership support. Defaults to false. */
|
||||
relayRequiresMembership?: boolean;
|
||||
/** Delay EOSE for membership snapshots after delivering the event. */
|
||||
relayMembershipEoseDelayMs?: number;
|
||||
/**
|
||||
* Active identity's role in the seeded `mockRelayMembers`. `null` removes
|
||||
* the active identity from the membership list entirely (admin-path branch
|
||||
@@ -320,6 +322,8 @@ type MockBridgeOptions = {
|
||||
/** Delay (ms) applied to `get_relay_self` so E2E tests can prove the
|
||||
* fail-closed race: DMs are withheld while classification is unresolved. */
|
||||
relaySelfDelayMs?: number;
|
||||
/** Delay (ms) applied to `start_pairing` so pairing loading UI is observable. */
|
||||
pairingStartDelayMs?: number;
|
||||
/**
|
||||
* Sequenced results for `confirm_team_snapshot_import`. String = throw
|
||||
* with that message; null = succeed. Call N uses results[N]; last entry
|
||||
|
||||
Reference in New Issue
Block a user