Files
buzz/desktop/src/features/communities/ui/CommunityEditForm.tsx
John Matthew Tennant 0019f80765 fix(desktop): fetch join policies through native networking (#2862)
## 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.
2026-07-27 17:52:36 -04:00

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>
);
}