diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index a4b44d2974..0fe740ebc3 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -39,6 +39,7 @@ import { WelcomeSetup } from "@/features/communities/ui/WelcomeSetup"; import { CommunityApplyErrorScreen } from "@/features/communities/ui/CommunityApplyErrorScreen"; import { CommunityChangeOverlay } from "@/features/communities/ui/CommunityChangeOverlay"; import { createBuzzQueryClient } from "@/shared/api/queryClient"; +import { getMyRelayMembershipLookup } from "@/shared/api/relayMembers"; import { isSharedIdentity as isSharedIdentityCmd } from "@/shared/api/tauri"; import { type AddCommunityDeepLinkPayload, @@ -61,6 +62,16 @@ const BOOT_SPLASH_MIN_VISIBLE_MS = 1_200; const BOOT_SPLASH_FADE_MS = 200; const INITIAL_RENDER_READY_EVENT = "initial-render-ready"; +function isRelayMembershipDeniedError(error: unknown): boolean { + if (!(error instanceof Error)) return false; + return [ + "You must be a relay member", + "relay_membership_required", + "restricted: not a relay member", + "invalid: you are not a relay member", + ].some((message) => error.message.includes(message)); +} + type BootSplashPhase = "holding" | "fading" | "done"; function useInitialRenderReady() { @@ -325,10 +336,45 @@ function CommunityApp({ community.isReady && community.appliedKey === communityKey; useEffect(() => { - if (transaction?.stage === "connecting" && targetIsReady) { - communityOnboarding.update({ stage: "profile", error: undefined }); + if ( + transaction?.stage !== "connecting" || + transaction.error || + !targetIsReady + ) { + return; } - }, [communityOnboarding.update, targetIsReady, transaction?.stage]); + + let cancelled = false; + void getMyRelayMembershipLookup() + .then(({ membership, snapshotFound }) => { + if (cancelled) return; + if (snapshotFound && membership === null) { + communityOnboarding.update({ + error: + "You have not been added to this community yet. Ask the host to add your public key, then try again.", + }); + return; + } + communityOnboarding.update({ stage: "profile", error: undefined }); + }) + .catch((error: unknown) => { + if (cancelled) return; + communityOnboarding.update({ + error: isRelayMembershipDeniedError(error) + ? "You have not been added to this community yet. Ask the host to add your public key, then try again." + : "Could not check community access. Check the URL and try again.", + }); + }); + + return () => { + cancelled = true; + }; + }, [ + communityOnboarding.update, + targetIsReady, + transaction?.error, + transaction?.stage, + ]); // During "entering" the transaction stays alive as a curtain: the app mounts // underneath (already pointed at the Welcome channel route) while the // onboarding screen covers it, then fades once Welcome reports ready. diff --git a/desktop/src/features/communities/ui/WelcomeSetup.tsx b/desktop/src/features/communities/ui/WelcomeSetup.tsx index c4412bd2d9..7c4dbdaaec 100644 --- a/desktop/src/features/communities/ui/WelcomeSetup.tsx +++ b/desktop/src/features/communities/ui/WelcomeSetup.tsx @@ -3,6 +3,7 @@ import { openUrl } from "@tauri-apps/plugin-opener"; import { Check, Copy, Info } from "lucide-react"; import { useCommunityOnboarding } from "@/features/onboarding/communityOnboarding"; +import { normalizeRelayUrl } from "@/features/communities/relayProbe"; import { InviteRedeemForm } from "@/features/onboarding/ui/InviteRedeemForm"; import { ONBOARDING_KEY_FRAME_CLASS, @@ -20,6 +21,7 @@ import { import { getIdentity } from "@/shared/api/tauriIdentity"; import { pubkeyToNpub } from "@/shared/lib/nostrUtils"; import { Button } from "@/shared/ui/button"; +import { Input } from "@/shared/ui/input"; import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion"; import { useSystemColorScheme } from "@/shared/theme/useSystemColorScheme"; import { OnboardingChrome } from "@/features/onboarding/ui/OnboardingChrome"; @@ -56,6 +58,8 @@ export function WelcomeSetup({ React.useState(initialTransitionMode); const [npub, setNpub] = React.useState(""); const [identityError, setIdentityError] = React.useState(null); + const [relayUrl, setRelayUrl] = React.useState(""); + const [relayUrlError, setRelayUrlError] = React.useState(null); const [copied, setCopied] = React.useState(false); const communityOnboarding = useCommunityOnboarding(); const systemColorScheme = useSystemColorScheme(); @@ -79,6 +83,24 @@ export function WelcomeSetup({ setPage(nextPage); }, []); + const handleJoin = React.useCallback( + (event: React.FormEvent) => { + event.preventDefault(); + const normalizedRelayUrl = normalizeRelayUrl(relayUrl); + if (!normalizedRelayUrl) { + setRelayUrlError("Enter a valid community URL."); + return; + } + + setRelayUrlError(null); + communityOnboarding.start({ + source: "first-community", + relayUrl: normalizedRelayUrl, + }); + }, + [communityOnboarding, relayUrl], + ); + const handleInviteRedeem = React.useCallback( (relayWsUrl: string, code: string, policyReceipt?: string) => { communityOnboarding.start({ @@ -172,58 +194,106 @@ export function WelcomeSetup({

-
-
+
+
+
+
+ + {npub || "Loading…"} + +
+ +
+
+ {identityError ? ( +

+ {identityError} +

+ ) : ( +

+ + + This is safe to share. It does not reveal your private + key. + +

+ )} +
+
-
+ +
- - {npub || "Loading…"} - + { + setRelayUrl(event.target.value); + setRelayUrlError(null); + }} + placeholder="https://community.example.com" + spellCheck={false} + type="url" + value={relayUrl} + /> + {relayUrlError ? ( +

+ {relayUrlError} +

+ ) : ( +

+ Once the host adds your public key, enter the URL to + join. You can retry if access is not ready yet. +

+ )}
-
- {identityError ? ( -

- {identityError} -

- ) : ( -

- - - This is safe to share. It does not reveal your private - key. - -

- )} +
diff --git a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx index e4f8459728..fee7cd6788 100644 --- a/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/CommunityOnboardingFlow.tsx @@ -172,7 +172,11 @@ export function CommunityOnboardingFlow({ }; }, [clear, isEnteringStage]); - const retryClaim = () => update({ stage: "claiming", error: undefined }); + const retry = () => + update({ + stage: transaction?.inviteCode ? "claiming" : "connecting", + error: undefined, + }); const relayUrl = transaction?.relayUrl; const finish = React.useCallback(async () => { if (!relayUrl) return; @@ -295,7 +299,7 @@ export function CommunityOnboardingFlow({

{transaction.error ? ( - ) : null}