Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 49 additions & 3 deletions desktop/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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() {
Expand Down Expand Up @@ -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.
Expand Down
160 changes: 115 additions & 45 deletions desktop/src/features/communities/ui/WelcomeSetup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -56,6 +58,8 @@ export function WelcomeSetup({
React.useState<WelcomeTransitionMode>(initialTransitionMode);
const [npub, setNpub] = React.useState("");
const [identityError, setIdentityError] = React.useState<string | null>(null);
const [relayUrl, setRelayUrl] = React.useState("");
const [relayUrlError, setRelayUrlError] = React.useState<string | null>(null);
const [copied, setCopied] = React.useState(false);
const communityOnboarding = useCommunityOnboarding();
const systemColorScheme = useSystemColorScheme();
Expand All @@ -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({
Expand Down Expand Up @@ -172,58 +194,106 @@ export function WelcomeSetup({
</p>
</div>
<div className="flex w-full flex-1 items-center justify-center pb-4 pt-12">
<div className="w-full max-w-4xl">
<div
className={ONBOARDING_KEY_FRAME_CLASS}
data-testid="welcome-join-npub-frame"
<div className="w-full max-w-4xl space-y-7">
<div>
<div
className={ONBOARDING_KEY_FRAME_CLASS}
data-testid="welcome-join-npub-frame"
>
<div className={ONBOARDING_KEY_ROW_CLASS}>
<div className="min-w-0 flex-1">
<code
className={`${ONBOARDING_KEY_TEXT_CLASS} block`}
data-testid="welcome-join-npub"
>
{npub || "Loading…"}
</code>
</div>
<Button
aria-label="Copy npub"
className="h-10 w-10 shrink-0 text-muted-foreground hover:text-foreground"
disabled={!npub}
onClick={() => {
void writeTextToClipboard(npub).then(() => {
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
});
}}
size="icon"
type="button"
variant="ghost"
>
{copied ? (
<Check
className="h-6 w-6 text-primary"
aria-hidden="true"
/>
) : (
<Copy className="h-6 w-6" aria-hidden="true" />
)}
</Button>
</div>
</div>
{identityError ? (
<p className="mt-4 text-sm text-destructive">
{identityError}
</p>
) : (
<p className="mx-auto mt-4 flex max-w-[440px] items-start justify-center gap-1.5 text-center text-xs leading-5 text-[var(--buzz-onboarding-backup-ink)]">
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>
This is safe to share. It does not reveal your private
key.
</span>
</p>
)}
</div>
<form
className="mx-auto w-full max-w-[680px] text-left"
onSubmit={handleJoin}
>
<div className={ONBOARDING_KEY_ROW_CLASS}>
<label
className="text-sm font-medium"
htmlFor="welcome-join-community-url"
>
Community URL
</label>
<div className="mt-2 flex items-start gap-3">
<div className="min-w-0 flex-1">
<code
className={`${ONBOARDING_KEY_TEXT_CLASS} block`}
data-testid="welcome-join-npub"
>
{npub || "Loading…"}
</code>
<Input
autoCapitalize="none"
autoCorrect="off"
data-testid="welcome-join-community-url"
id="welcome-join-community-url"
onChange={(event) => {
setRelayUrl(event.target.value);
setRelayUrlError(null);
}}
placeholder="https://community.example.com"
spellCheck={false}
type="url"
value={relayUrl}
/>
{relayUrlError ? (
<p className="mt-2 text-sm text-destructive">
{relayUrlError}
</p>
) : (
<p className="mt-2 text-xs leading-5 text-foreground/70">
Once the host adds your public key, enter the URL to
join. You can retry if access is not ready yet.
</p>
)}
</div>
<Button
aria-label="Copy npub"
className="h-10 w-10 shrink-0 text-muted-foreground hover:text-foreground"
disabled={!npub}
onClick={() => {
void writeTextToClipboard(npub).then(() => {
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
});
}}
size="icon"
type="button"
variant="ghost"
className="h-10 shrink-0 rounded-full px-6"
disabled={!relayUrl.trim()}
type="submit"
>
{copied ? (
<Check
className="h-6 w-6 text-primary"
aria-hidden="true"
/>
) : (
<Copy className="h-6 w-6" aria-hidden="true" />
)}
Join community
</Button>
</div>
</div>
{identityError ? (
<p className="mt-4 text-sm text-destructive">
{identityError}
</p>
) : (
<p className="mx-auto mt-6 flex max-w-[440px] items-start justify-center gap-1.5 text-center text-xs leading-5 text-[var(--buzz-onboarding-backup-ink)]">
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>
This is safe to share. It does not reveal your private
key.
</span>
</p>
)}
</form>
</div>
</div>
<OnboardingFooter>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -295,7 +299,7 @@ export function CommunityOnboardingFlow({
</p>
<div className="mt-6 flex justify-center gap-3">
{transaction.error ? (
<Button className="rounded-full px-6" onClick={retryClaim}>
<Button className="rounded-full px-6" onClick={retry}>
Retry
</Button>
) : null}
Expand Down
Loading