mirror of
https://github.com/block/buzz.git
synced 2026-11-02 23:41:08 +00:00
## Context Adding an existing community by relay URL could fail with `Community rejected: Load failed` even when its WebSocket endpoint was reachable. The Add Community flow fetched `/api/join-policy` from the WebView, so a relay without a matching CORS allowance blocked the policy request before the app could join it. ## Summary This bug fix fetches join policies through Tauri's native networking layer for direct URL joins. Invite-code discovery, policy acceptance, and signed invite claims remain on the WebView path so those operations can migrate together later. ## Changes - Uses native networking for Add Community and first-community direct URL join-policy requests. - Validates relay schemes, rejects URLs containing credentials, and refuses redirects. - Bounds declared and chunked native responses before JSON parsing. - Preserves existing `404`, non-success status, malformed JSON, and absent-policy behavior. - Requires every join-policy caller to choose its transport explicitly. Public relays using Buzz's default permissive CORS configuration are not known to be affected. ### Related issue Related to #2872. ### Testing #### Reviewer-reproducible examples End-to-end red/green requires a relay with restrictive CORS and a Buzz identity authorized to join it. ##### Red: `main` From a clean checkout of `main`: ```bash . ./bin/activate-hermit just staging ``` In Buzz Desktop: 1. Add another community so the restrictive-CORS relay can be removed. 2. Remove that relay. 3. Open Add Community and enter the relay's WebSocket URL. 4. Select Add Community. Observed result: ```text Community rejected: Load failed ``` ##### Green: this PR From a clean checkout of this branch: ```bash . ./bin/activate-hermit just staging ``` Repeat the same steps above. Observed result: ```text The community rejoins successfully. ``` Supporting checks: - Six native join-policy tests, including oversized declared and chunked responses. - Four TypeScript API tests, including the native command contract. - E2E build and four focused onboarding and sidebar Playwright tests. - Full `just ci` and pre-push suites. - Builderbot, Kalvin, and minimize-diff review fanout found no actionable issues after the final rebase.
363 lines
10 KiB
TypeScript
363 lines
10 KiB
TypeScript
import * as React from "react";
|
|
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
|
import { inviteErrorMessage } from "@/shared/api/inviteHelpers";
|
|
import {
|
|
getJoinPolicy,
|
|
isJoinPolicyDiscoveryCandidate,
|
|
type JoinPolicy,
|
|
} from "@/shared/api/invites";
|
|
import { Button } from "@/shared/ui/button";
|
|
import { Input } from "@/shared/ui/input";
|
|
import { Spinner } from "@/shared/ui/spinner";
|
|
import { JoinPolicyNotice } from "@/features/onboarding/ui/JoinPolicyNotice";
|
|
import { normalizeRelayUrl, probeRelayReachable } from "../relayProbe";
|
|
|
|
const POLICY_DISCOVERY_DELAY_MS = 250;
|
|
const POLICY_REVEAL_EASE = [0.23, 1, 0.32, 1] as const;
|
|
|
|
export type CommunityEditFormProps = {
|
|
cancelLabel?: string;
|
|
initialName: string;
|
|
initialRelayUrl: string;
|
|
isSubmitting?: boolean;
|
|
joinPolicyRequired?: boolean;
|
|
onCancel: () => void;
|
|
onSubmit: (name: string, relayUrl: string) => void;
|
|
submitLabel: string;
|
|
};
|
|
|
|
export function CommunityEditForm({
|
|
cancelLabel = "Cancel",
|
|
initialName,
|
|
initialRelayUrl,
|
|
isSubmitting = false,
|
|
joinPolicyRequired = false,
|
|
onCancel,
|
|
onSubmit,
|
|
submitLabel,
|
|
}: CommunityEditFormProps) {
|
|
const [name, setName] = React.useState(initialName);
|
|
const [relayUrl, setRelayUrl] = React.useState(initialRelayUrl);
|
|
const [error, setError] = React.useState<string | null>(null);
|
|
const [probeWarning, setProbeWarning] = React.useState<string | null>(null);
|
|
const [isProbing, setIsProbing] = React.useState(false);
|
|
const [useAnywayOverride, setUseAnywayOverride] = React.useState(false);
|
|
const [joinPolicy, setJoinPolicy] = React.useState<JoinPolicy | null>(null);
|
|
const [policyRelayUrl, setPolicyRelayUrl] = React.useState<string | null>(
|
|
null,
|
|
);
|
|
const [ageConfirmed, setAgeConfirmed] = React.useState(false);
|
|
const [agreementConfirmed, setAgreementConfirmed] = React.useState(false);
|
|
const shouldReduceMotion = useReducedMotion();
|
|
|
|
const cancelRef = React.useRef<(() => void) | null>(null);
|
|
|
|
React.useEffect(() => {
|
|
return () => {
|
|
cancelRef.current?.();
|
|
};
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
if (!joinPolicyRequired) return;
|
|
|
|
const normalizedUrl = normalizeRelayUrl(relayUrl);
|
|
if (!normalizedUrl || !isJoinPolicyDiscoveryCandidate(normalizedUrl)) {
|
|
return;
|
|
}
|
|
|
|
let cancelled = false;
|
|
const timeoutId = window.setTimeout(() => {
|
|
void getJoinPolicy(normalizedUrl, "native")
|
|
.then((policy) => {
|
|
if (cancelled || !policy) return;
|
|
setJoinPolicy(policy);
|
|
setPolicyRelayUrl(normalizedUrl);
|
|
setAgeConfirmed(false);
|
|
setAgreementConfirmed(false);
|
|
setError(null);
|
|
})
|
|
.catch(() => {
|
|
// Background discovery is best-effort. A deliberate submit retries
|
|
// the request and surfaces any relay error to the user.
|
|
});
|
|
}, POLICY_DISCOVERY_DELAY_MS);
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
window.clearTimeout(timeoutId);
|
|
};
|
|
}, [joinPolicyRequired, relayUrl]);
|
|
|
|
const handleSubmit = React.useCallback(
|
|
async (event: React.FormEvent<HTMLFormElement>) => {
|
|
event.preventDefault();
|
|
const trimmedName = name.trim();
|
|
if (!trimmedName) {
|
|
setError("Please enter a community name.");
|
|
return;
|
|
}
|
|
const normalizedUrl = normalizeRelayUrl(relayUrl);
|
|
if (!normalizedUrl) {
|
|
setError("Enter a valid ws:// or wss:// relay URL.");
|
|
return;
|
|
}
|
|
|
|
if (joinPolicyRequired) {
|
|
try {
|
|
const policy = await getJoinPolicy(normalizedUrl, "native");
|
|
if (!policy) {
|
|
onSubmit(trimmedName, normalizedUrl);
|
|
return;
|
|
}
|
|
if (
|
|
!joinPolicy ||
|
|
joinPolicy.version !== policy.version ||
|
|
policyRelayUrl !== normalizedUrl
|
|
) {
|
|
setJoinPolicy(policy);
|
|
setPolicyRelayUrl(normalizedUrl);
|
|
setAgeConfirmed(false);
|
|
setAgreementConfirmed(false);
|
|
return;
|
|
}
|
|
if (policy.ageAttestationRequired && !ageConfirmed) {
|
|
setError("Confirm that you are at least 18 years old.");
|
|
return;
|
|
}
|
|
if (
|
|
(policy.termsMarkdown || policy.privacyMarkdown) &&
|
|
!agreementConfirmed
|
|
) {
|
|
setError("Agree to the Terms of Service and Privacy Policy.");
|
|
return;
|
|
}
|
|
} catch (policyError) {
|
|
setError(inviteErrorMessage(policyError));
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (useAnywayOverride) {
|
|
onSubmit(trimmedName, normalizedUrl);
|
|
return;
|
|
}
|
|
|
|
// Cancel any in-flight probe before starting a new one.
|
|
cancelRef.current?.();
|
|
setIsProbing(true);
|
|
setProbeWarning(null);
|
|
|
|
const probe = probeRelayReachable(normalizedUrl);
|
|
cancelRef.current = probe.cancel;
|
|
|
|
let reachable: boolean;
|
|
try {
|
|
reachable = await probe.promise;
|
|
} finally {
|
|
setIsProbing(false);
|
|
}
|
|
|
|
if (!reachable) {
|
|
setProbeWarning("Can't reach this relay — check the URL");
|
|
return;
|
|
}
|
|
|
|
onSubmit(trimmedName, normalizedUrl);
|
|
},
|
|
[
|
|
ageConfirmed,
|
|
agreementConfirmed,
|
|
joinPolicy,
|
|
joinPolicyRequired,
|
|
name,
|
|
onSubmit,
|
|
policyRelayUrl,
|
|
relayUrl,
|
|
useAnywayOverride,
|
|
],
|
|
);
|
|
|
|
const handleUseAnyway = React.useCallback(() => {
|
|
setUseAnywayOverride(true);
|
|
setProbeWarning(null);
|
|
const trimmedName = name.trim();
|
|
const normalizedUrl = normalizeRelayUrl(relayUrl);
|
|
if (trimmedName && normalizedUrl) {
|
|
onSubmit(trimmedName, normalizedUrl);
|
|
}
|
|
}, [name, onSubmit, relayUrl]);
|
|
|
|
const isBusy = isSubmitting || isProbing;
|
|
|
|
return (
|
|
<form
|
|
className="flex w-full flex-col gap-4"
|
|
onSubmit={(e) => {
|
|
void handleSubmit(e);
|
|
}}
|
|
>
|
|
<div className="space-y-1.5 text-left">
|
|
<label
|
|
className="text-sm font-medium text-foreground"
|
|
htmlFor="community-edit-name"
|
|
>
|
|
Community name
|
|
</label>
|
|
<Input
|
|
autoFocus
|
|
className="h-10 bg-background"
|
|
disabled={isProbing}
|
|
id="community-edit-name"
|
|
onChange={(event) => {
|
|
setName(event.target.value);
|
|
setError(null);
|
|
}}
|
|
placeholder="Design team"
|
|
type="text"
|
|
value={name}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-1.5 text-left">
|
|
<label
|
|
className="text-sm font-medium text-foreground"
|
|
htmlFor="community-edit-url"
|
|
>
|
|
Community URL
|
|
</label>
|
|
<Input
|
|
className="h-10 bg-background"
|
|
disabled={isProbing}
|
|
id="community-edit-url"
|
|
onChange={(event) => {
|
|
setRelayUrl(event.target.value);
|
|
setError(null);
|
|
setProbeWarning(null);
|
|
setUseAnywayOverride(false);
|
|
setJoinPolicy(null);
|
|
setPolicyRelayUrl(null);
|
|
setAgeConfirmed(false);
|
|
setAgreementConfirmed(false);
|
|
}}
|
|
placeholder="wss://relay.example.com"
|
|
type="text"
|
|
value={relayUrl}
|
|
/>
|
|
</div>
|
|
|
|
<AnimatePresence initial={false}>
|
|
{joinPolicy && policyRelayUrl ? (
|
|
<motion.div
|
|
animate={{
|
|
height: "auto",
|
|
marginTop: 0,
|
|
opacity: 1,
|
|
transform: "translateY(0rem)",
|
|
}}
|
|
className="overflow-hidden"
|
|
exit={
|
|
shouldReduceMotion
|
|
? { height: 0, marginTop: "-1rem", opacity: 0 }
|
|
: {
|
|
height: 0,
|
|
marginTop: "-1rem",
|
|
opacity: 0,
|
|
transform: "translateY(-0.25rem)",
|
|
}
|
|
}
|
|
initial={
|
|
shouldReduceMotion
|
|
? false
|
|
: {
|
|
height: 0,
|
|
marginTop: "-1rem",
|
|
opacity: 0,
|
|
transform: "translateY(-0.25rem)",
|
|
}
|
|
}
|
|
key={`${policyRelayUrl}:${joinPolicy.version}`}
|
|
transition={
|
|
shouldReduceMotion
|
|
? { duration: 0 }
|
|
: { duration: 0.22, ease: POLICY_REVEAL_EASE }
|
|
}
|
|
>
|
|
<JoinPolicyNotice
|
|
ageConfirmed={ageConfirmed}
|
|
agreementConfirmed={agreementConfirmed}
|
|
onAgeConfirmedChange={(confirmed) => {
|
|
setAgeConfirmed(confirmed);
|
|
setError(null);
|
|
}}
|
|
onAgreementConfirmedChange={(confirmed) => {
|
|
setAgreementConfirmed(confirmed);
|
|
setError(null);
|
|
}}
|
|
policy={joinPolicy}
|
|
relayWsUrl={policyRelayUrl}
|
|
/>
|
|
</motion.div>
|
|
) : null}
|
|
</AnimatePresence>
|
|
|
|
<div className="flex w-full flex-col gap-3 pt-1">
|
|
<Button
|
|
className="h-10 w-full"
|
|
disabled={
|
|
isBusy ||
|
|
!name.trim() ||
|
|
!relayUrl.trim() ||
|
|
Boolean(joinPolicy?.ageAttestationRequired && !ageConfirmed) ||
|
|
Boolean(
|
|
joinPolicy &&
|
|
(joinPolicy.termsMarkdown || joinPolicy.privacyMarkdown) &&
|
|
!agreementConfirmed,
|
|
)
|
|
}
|
|
type="submit"
|
|
>
|
|
{isProbing ? (
|
|
<Spinner aria-label="Checking relay" className="h-4 w-4 border-2" />
|
|
) : isSubmitting ? (
|
|
<Spinner aria-label="Saving" className="h-4 w-4 border-2" />
|
|
) : (
|
|
submitLabel
|
|
)}
|
|
</Button>
|
|
|
|
<Button
|
|
className="h-10 w-full text-muted-foreground hover:text-accent-foreground"
|
|
disabled={isBusy}
|
|
onClick={onCancel}
|
|
type="button"
|
|
variant="ghost"
|
|
>
|
|
{cancelLabel}
|
|
</Button>
|
|
|
|
{error ? (
|
|
<p className="text-center text-sm text-destructive">{error}</p>
|
|
) : null}
|
|
|
|
{probeWarning ? (
|
|
<div className="flex flex-col items-center gap-2">
|
|
<p className="text-center text-sm text-destructive">
|
|
{probeWarning}
|
|
</p>
|
|
<Button
|
|
className="h-8 text-xs"
|
|
onClick={handleUseAnyway}
|
|
size="sm"
|
|
type="button"
|
|
variant="outline"
|
|
>
|
|
Use anyway
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|