From 50191b72a6d4405773f83026ce7c3efa99d9d90c Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Fri, 24 Jul 2026 10:52:06 -0700 Subject: [PATCH 1/5] Refine community management flows --- desktop/playwright.config.ts | 5 +- .../agents/ui/AgentCreationPreview.tsx | 292 ++++++++-- .../agents/ui/AgentCreationPreview.utils.ts | 16 + .../communities/ui/AddCommunityDialog.tsx | 491 +++++------------ .../ui/CommunityIconSettingsCard.tsx | 187 +++---- .../communities/ui/CommunitySwitcher.tsx | 76 ++- .../ui/HostedCommunityCreateFlow.tsx | 457 ++++++++++++++++ .../ui/CommunityInviteDialog.tsx | 44 ++ .../ui/CommunityMembersSettingsCard.tsx | 503 ++++++------------ .../ui/InviteLinkSection.tsx | 284 ++++------ .../onboarding/ui/InviteRedeemForm.tsx | 43 +- .../profile/ui/MaskedAvatarBadgeFrame.tsx | 129 ++++- .../profile/ui/ProfileAvatarEditor.utils.ts | 9 +- .../features/profile/ui/ProfilePopover.tsx | 61 ++- .../src/features/profile/useAvatarUpload.ts | 40 +- .../ui/HostedCommunitiesSettingsCard.tsx | 34 +- .../features/settings/ui/SettingsPanels.tsx | 6 +- .../src/features/settings/ui/SettingsView.tsx | 8 +- .../src/features/sidebar/ui/AppSidebar.tsx | 3 +- .../src/features/sidebar/ui/CommunityRail.tsx | 32 +- .../sidebar/ui/SidebarProfileCard.tsx | 17 +- .../e2e/add-community-screenshots.spec.ts | 69 +++ desktop/tests/e2e/community-rail.spec.ts | 178 +++++++ desktop/tests/e2e/deep-link-invite.spec.ts | 33 +- ...d-communities-settings-screenshots.spec.ts | 96 ++++ ...nload.spec.ts => invite-link-copy.spec.ts} | 29 +- .../e2e/invites-settings-screenshots.spec.ts | 100 ++++ desktop/tests/e2e/sidebar.spec.ts | 65 ++- desktop/tests/helpers/settings.ts | 1 + 29 files changed, 2124 insertions(+), 1184 deletions(-) create mode 100644 desktop/src/features/agents/ui/AgentCreationPreview.utils.ts create mode 100644 desktop/src/features/communities/ui/HostedCommunityCreateFlow.tsx create mode 100644 desktop/src/features/community-members/ui/CommunityInviteDialog.tsx create mode 100644 desktop/tests/e2e/add-community-screenshots.spec.ts create mode 100644 desktop/tests/e2e/hosted-communities-settings-screenshots.spec.ts rename desktop/tests/e2e/{invite-qr-download.spec.ts => invite-link-copy.spec.ts} (57%) create mode 100644 desktop/tests/e2e/invites-settings-screenshots.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 5046b29688..aaa1486953 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -30,6 +30,9 @@ export default defineConfig({ "**/badge.spec.ts", "**/channel-browser.spec.ts", "**/channel-add-screenshots.spec.ts", + "**/add-community-screenshots.spec.ts", + "**/hosted-communities-settings-screenshots.spec.ts", + "**/invites-settings-screenshots.spec.ts", "**/messaging.spec.ts", "**/custom-emoji.spec.ts", "**/profile-custom-emoji-status.spec.ts", @@ -102,7 +105,7 @@ export default defineConfig({ "**/channel-sort.spec.ts", "**/identity-lost.spec.ts", "**/deep-link-invite.spec.ts", - "**/invite-qr-download.spec.ts", + "**/invite-link-copy.spec.ts", "**/global-agent-config-screenshots.spec.ts", "**/doctor-states.spec.ts", "**/onboarding-avatar-skip.spec.ts", diff --git a/desktop/src/features/agents/ui/AgentCreationPreview.tsx b/desktop/src/features/agents/ui/AgentCreationPreview.tsx index 6c73d14adc..1bdd93673b 100644 --- a/desktop/src/features/agents/ui/AgentCreationPreview.tsx +++ b/desktop/src/features/agents/ui/AgentCreationPreview.tsx @@ -38,31 +38,29 @@ import { } from "@/shared/ui/popover"; import { Spinner } from "@/shared/ui/spinner"; import { Tabs, TabsList, TabsTrigger } from "@/shared/ui/tabs"; - -function isAvatarFileDrag(event: React.DragEvent) { - return Array.from(event.dataTransfer.types).includes("Files"); -} - -const AVATAR_APPLY_MOTION_TRANSITION = { - duration: 0.14, - ease: [0.23, 1, 0.32, 1], -} as const; - -type AvatarTab = "image" | "emoji"; - -type EmojiMartEmoji = { - native?: string; -}; +import { + AVATAR_APPLY_MOTION_TRANSITION, + type AvatarTab, + type EmojiMartEmoji, + isAvatarFileDrag, +} from "./AgentCreationPreview.utils"; export function AgentCreationPreview({ + assetLabel = "avatar", avatarUrl, disabled = false, hideEditControl = false, label, onClearAvatar, + onCommitAvatar, onUploadPendingChange, onSelectAvatar, + processImage, + shape = "circle", + testIdPrefix = "agent-avatar", + variant = "default", }: { + assetLabel?: string; avatarUrl: string | null; disabled?: boolean; /** When true, omit all upload/edit controls and render the avatar as a @@ -71,8 +69,13 @@ export function AgentCreationPreview({ hideEditControl?: boolean; label: string; onClearAvatar?: () => void; + onCommitAvatar?: (avatarUrl: string) => void; onUploadPendingChange?: (isPending: boolean) => void; onSelectAvatar: (avatarUrl: string) => void; + processImage?: (file: File) => Promise; + shape?: "circle" | "rounded-square"; + testIdPrefix?: string; + variant?: "compact" | "default"; }) { const [isDragOverAvatar, setIsDragOverAvatar] = React.useState(false); const [isAvatarMenuOpen, setIsAvatarMenuOpen] = React.useState(false); @@ -102,6 +105,11 @@ export function AgentCreationPreview({ const emojiPickerContainerRef = React.useRef(null); const emojiMartThemeVars = useEmojiMartThemeVars(); const { burstEmoji } = useEmojiBurst(); + const assetLabelTitle = + assetLabel.charAt(0).toUpperCase() + assetLabel.slice(1); + const isRoundedSquare = shape === "rounded-square"; + const isCompact = variant === "compact"; + const emojiShape = isRoundedSquare ? "rounded-square" : "circle"; const { inputRef: avatarUploadInputRef, isUploading, @@ -111,10 +119,13 @@ export function AgentCreationPreview({ uploadFile: uploadAvatarFile, handleFileChange: handleAvatarUploadFileChange, } = useAvatarUpload({ + fallbackErrorMessage: `Could not use that ${assetLabel}.`, onUploadSuccess: (url) => { onSelectAvatar(url); + onCommitAvatar?.(url); setIsAvatarMenuOpen(false); }, + processImage, }); useEmojiMartStyles( @@ -164,7 +175,11 @@ export function AgentCreationPreview({ if (!isCustomColorPickerOpen || !selectedEmoji) { return; } - const nextAvatarUrl = emojiAvatarDataUrl(selectedEmoji, customColorDraft); + const nextAvatarUrl = emojiAvatarDataUrl( + selectedEmoji, + customColorDraft, + emojiShape, + ); if (avatarUrl === nextAvatarUrl) { return; } @@ -175,6 +190,7 @@ export function AgentCreationPreview({ isCustomColorPickerOpen, onSelectAvatar, selectedEmoji, + emojiShape, ]); function applyAvatarUrl() { @@ -184,14 +200,22 @@ export function AgentCreationPreview({ } clearUploadError(); onSelectAvatar(nextUrl); + onCommitAvatar?.(nextUrl); setIsAvatarMenuOpen(false); } function applyEmojiAvatar(emoji: string, color = selectedColor) { - onSelectAvatar(emojiAvatarDataUrl(emoji, color)); + const nextAvatarUrl = emojiAvatarDataUrl(emoji, color, emojiShape); + onSelectAvatar(nextAvatarUrl); + onCommitAvatar?.(nextAvatarUrl); setSquishKey((key) => key + 1); } + function clearAvatar() { + onClearAvatar?.(); + onCommitAvatar?.(""); + } + function handleColorSelect(swatch: AvatarColorSwatch) { if (disabled) { return; @@ -385,7 +409,7 @@ export function AgentCreationPreview({ > {/* Single drop zone covering the entire popover */}
{ - onClearAvatar(); + clearAvatar(); setIsAvatarMenuOpen(false); }} type="button" > - Remove avatar + Remove {assetLabel} ) : null} @@ -624,7 +648,7 @@ export function AgentCreationPreview({ setCustomValue(nextValue); }} saturation={customSaturation} - testIdPrefix="agent-avatar" + testIdPrefix={testIdPrefix} value={customValue} visible={isCustomColorPickerVisible} /> @@ -634,13 +658,13 @@ export function AgentCreationPreview({ className="flex min-h-8 w-full items-center justify-center rounded-lg text-xs text-destructive outline-hidden transition-colors duration-150 ease-out hover:bg-destructive/10 focus-visible:bg-destructive/10 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50" disabled={disabled} onClick={() => { - onClearAvatar(); + clearAvatar(); setSelectedEmoji(null); setIsAvatarMenuOpen(false); }} type="button" > - Remove avatar + Remove {assetLabel} ) : null} @@ -653,24 +677,60 @@ export function AgentCreationPreview({ // Used when the caller provides its own edit affordance. if (hideEditControl) { return ( -
-
-
+
+
+
{emojiAvatarPreview ? (
- + {emojiAvatarPreview.emoji}
+ ) : isRoundedSquare && avatarUrl ? ( + {`${label} ) : ( )} @@ -681,11 +741,17 @@ export function AgentCreationPreview({ } return ( -
+
-
- {hasAvatar ? ( +
+ {hasAvatar && isRoundedSquare ? ( + + + + } + badgeBox={ + isCompact + ? { bottom: 0, height: 28, right: 0, width: 28 } + : { bottom: 0, height: 42, right: 0, width: 42 } + } + className={isCompact ? "h-16 w-16" : "h-36 w-36"} + clipTestId={`${testIdPrefix}-mask`} + cornerRadius={isCompact ? 16 : 32} + cutout={ + isCompact + ? { cx: 58, cy: 58, r: 16.5 } + : { cx: 123, cy: 123, r: 24 } + } + maskMode={isCompact ? "radial" : "clip-path"} + size={isCompact ? 64 : 144} + > + {emojiAvatarPreview ? ( +
+ 0 && "buzz-avatar-squish", + )} + key={squishKey} + style={ + { + "--buzz-avatar-emoji-offset-x": "0px", + "--buzz-avatar-emoji-offset-y": "0px", + } as React.CSSProperties + } + > + {emojiAvatarPreview.emoji} + +
+ ) : ( + {`${label} + )} +
+ ) : hasAvatar ? ( } - badgeBox={{ bottom: 0, height: 42, right: 0, width: 42 }} - className="h-36 w-36" - cutout={{ cx: 123, cy: 123, r: 24 }} - size={144} + badgeBox={ + isCompact + ? { bottom: 0, height: 28, right: 0, width: 28 } + : { bottom: 0, height: 42, right: 0, width: 42 } + } + className={isCompact ? "h-16 w-16" : "h-36 w-36"} + clipTestId={`${testIdPrefix}-mask`} + cutout={ + isCompact + ? { cx: 58, cy: 58, r: 16.5 } + : { cx: 123, cy: 123, r: 24 } + } + maskMode={isCompact ? "radial" : "clip-path"} + size={isCompact ? 64 : 144} > {emojiAvatarPreview ? (
0 && "buzz-avatar-squish", )} key={squishKey} @@ -762,7 +938,8 @@ export function AgentCreationPreview({ diff --git a/desktop/src/features/agents/ui/AgentCreationPreview.utils.ts b/desktop/src/features/agents/ui/AgentCreationPreview.utils.ts new file mode 100644 index 0000000000..a6ba650557 --- /dev/null +++ b/desktop/src/features/agents/ui/AgentCreationPreview.utils.ts @@ -0,0 +1,16 @@ +import type * as React from "react"; + +export function isAvatarFileDrag(event: React.DragEvent) { + return Array.from(event.dataTransfer.types).includes("Files"); +} + +export const AVATAR_APPLY_MOTION_TRANSITION = { + duration: 0.14, + ease: [0.23, 1, 0.32, 1], +} as const; + +export type AvatarTab = "image" | "emoji"; + +export type EmojiMartEmoji = { + native?: string; +}; diff --git a/desktop/src/features/communities/ui/AddCommunityDialog.tsx b/desktop/src/features/communities/ui/AddCommunityDialog.tsx index 227804e7b4..a83456652c 100644 --- a/desktop/src/features/communities/ui/AddCommunityDialog.tsx +++ b/desktop/src/features/communities/ui/AddCommunityDialog.tsx @@ -1,22 +1,10 @@ import * as React from "react"; -import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { ArrowLeft, ChevronRight, Link2, Plus } from "lucide-react"; import type { AddCommunityPrefillRequest } from "@/features/communities/addCommunityPrefill"; -import { - deriveCommunityName, - expandTilde, - normalizeRelayUrl, -} from "@/features/communities/communityStorage"; +import { HostedCommunityCreateFlow } from "@/features/communities/ui/HostedCommunityCreateFlow"; import { useCommunityOnboarding } from "@/features/onboarding/communityOnboarding"; -import { inviteErrorMessage } from "@/shared/api/inviteHelpers"; -import { - acceptJoinPolicy, - getJoinPolicy, - isJoinPolicyDiscoveryCandidate, - type JoinPolicy, -} from "@/shared/api/invites"; -import { validateReposDir } from "@/shared/api/tauri"; -import { Button } from "@/shared/ui/button"; +import { InviteRedeemForm } from "@/features/onboarding/ui/InviteRedeemForm"; import { Dialog, DialogContent, @@ -24,11 +12,6 @@ import { DialogHeader, DialogTitle, } from "@/shared/ui/dialog"; -import { Input } from "@/shared/ui/input"; -import { JoinPolicyNotice } from "@/features/onboarding/ui/JoinPolicyNotice"; - -const POLICY_DISCOVERY_DELAY_MS = 250; -const POLICY_REVEAL_EASE = [0.23, 1, 0.32, 1] as const; type AddCommunityDialogProps = { prefill?: AddCommunityPrefillRequest | null; @@ -39,162 +22,76 @@ type AddCommunityDialogProps = { onOpenChange: (open: boolean) => void; }; +type AddCommunityMode = "choose" | "create" | "join"; + +const OPTION_CLASS = + "flex w-full items-center gap-3 rounded-xl border border-border/70 bg-muted/30 px-4 py-4 text-left transition-colors duration-150 ease-out hover:bg-muted/60 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"; + export function AddCommunityDialog({ prefill, open, onOpenChange, }: AddCommunityDialogProps) { - const [name, setName] = React.useState(""); - const [relayUrl, setRelayUrl] = React.useState(""); - const [token, setToken] = React.useState(""); - const [inviteCode, setInviteCode] = React.useState(""); - const [inviteError, setInviteError] = React.useState(null); - const [joinPolicy, setJoinPolicy] = React.useState(null); - const [ageConfirmed, setAgeConfirmed] = React.useState(false); - const [agreementConfirmed, setAgreementConfirmed] = React.useState(false); - const [reposDir, setReposDir] = React.useState(""); const communityOnboarding = useCommunityOnboarding(); - const [reposDirError, setReposDirError] = React.useState(null); - const shouldReduceMotion = useReducedMotion(); + const [mode, setMode] = React.useState("choose"); + const [joinError, setJoinError] = React.useState(null); const appliedPrefillId = React.useRef(null); React.useEffect(() => { if (!prefill || appliedPrefillId.current === prefill.requestId) return; appliedPrefillId.current = prefill.requestId; - setName(prefill.name ?? deriveCommunityName(prefill.relayUrl)); - setRelayUrl(prefill.relayUrl); - setToken(""); - setInviteCode(""); - setReposDir(""); - setReposDirError(null); + setJoinError(null); + setMode("join"); }, [prefill]); - React.useEffect(() => { - if (!open || !relayUrl.trim()) return; - - const normalizedUrl = normalizeRelayUrl(relayUrl.trim()); - if (!isJoinPolicyDiscoveryCandidate(normalizedUrl)) return; - - let cancelled = false; - const timeoutId = window.setTimeout(() => { - void getJoinPolicy(normalizedUrl) - .then((policy) => { - if (cancelled || !policy) return; - setJoinPolicy(policy); - setAgeConfirmed(false); - setAgreementConfirmed(false); - setInviteError(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); - }; - }, [open, relayUrl]); - const handleClose = React.useCallback(() => { onOpenChange(false); - setName(""); - setRelayUrl(""); - setToken(""); - setInviteCode(""); - setInviteError(null); - setJoinPolicy(null); - setAgeConfirmed(false); - setAgreementConfirmed(false); - setReposDir(""); - setReposDirError(null); + setMode("choose"); + setJoinError(null); }, [onOpenChange]); - const handleSubmit = React.useCallback( - async (e: React.FormEvent) => { - e.preventDefault(); - if (!relayUrl.trim()) { - return; - } - - // Expand `~` before save — the backend rejects tilde paths. Empty input - // resolves to `undefined` so REPOS keeps its default location. Validate - // the expanded value (the bytes the backend canonicalizes) before save - // so a bad path is caught here instead of bricking a later boot. - const expandedReposDir = await expandTilde(reposDir); - try { - await validateReposDir(expandedReposDir ?? ""); - } catch (error) { - setReposDirError(String(error)); - return; - } - - const normalizedRelayUrl = normalizeRelayUrl(relayUrl.trim()); - let policyReceipt: string | undefined; - try { - const policy = await getJoinPolicy(normalizedRelayUrl); - if (policy && (!joinPolicy || joinPolicy.version !== policy.version)) { - setJoinPolicy(policy); - setAgeConfirmed(false); - setAgreementConfirmed(false); - setInviteError(null); - return; - } - if (policy?.ageAttestationRequired && !ageConfirmed) { - setInviteError("Confirm that you are at least 18 years old."); - return; - } - if ( - policy && - (policy.termsMarkdown || policy.privacyMarkdown) && - !agreementConfirmed - ) { - setInviteError("Agree to the Terms of Service and Privacy Policy."); - return; - } - - // Receipts are bound to an invite code, so one is only minted when a - // code is present. The claim itself runs on the onboarding - // transaction (useClaimInvite), which forwards this receipt. - if (policy && inviteCode.trim()) { - policyReceipt = await acceptJoinPolicy( - normalizedRelayUrl, - inviteCode.trim(), - policy.version, - ageConfirmed, - ); - } - } catch (error) { - setInviteError(`Community rejected: ${inviteErrorMessage(error)}`); - return; - } - - communityOnboarding.start({ + const startConnection = React.useCallback( + ({ + relayUrl, + inviteCode, + policyReceipt, + }: { + relayUrl: string; + inviteCode?: string; + policyReceipt?: string; + }) => { + const started = communityOnboarding.start({ source: "add-community", - relayUrl: normalizedRelayUrl, - inviteCode: inviteCode.trim() || undefined, - communityName: name.trim() || deriveCommunityName(normalizedRelayUrl), - token: token.trim() || undefined, - reposDir: expandedReposDir, + relayUrl, + inviteCode, + communityName: prefill?.name, policyReceipt, }); + if (!started) { + setJoinError( + "Finish connecting the community already in progress, then try again.", + ); + return; + } handleClose(); }, - [ - name, - relayUrl, - token, - inviteCode, - reposDir, - joinPolicy, - ageConfirmed, - agreementConfirmed, - communityOnboarding, - handleClose, - ], + [communityOnboarding, handleClose, prefill?.name], ); + const title = + mode === "create" + ? "Create a new community" + : mode === "join" + ? "Join an existing community" + : "Add community"; + + const description = + mode === "create" + ? "Opens Builderlab in your browser." + : mode === "join" + ? "Use the community URL or invite link you received." + : "Create a new community or join one you already have."; + return ( { @@ -203,212 +100,98 @@ export function AddCommunityDialog({ }} open={open} > - - - Add Community - - Connect to another Buzz relay. Each community has its own channels, - messages, and identity. - - -
void handleSubmit(e)} - > -
- - { - setRelayUrl(e.target.value); - setInviteError(null); - setJoinPolicy(null); - setAgeConfirmed(false); - setAgreementConfirmed(false); - }} - placeholder="wss://relay.example.com" - type="text" - value={relayUrl} - /> -
-
- - setName(e.target.value)} - placeholder="My Community" - type="text" - value={name} - /> -
-
- - setToken(e.target.value)} - placeholder="buzz_..." - type="password" - value={token} - /> -
-
- - { - setInviteCode(e.target.value); - setInviteError(null); - setJoinPolicy(null); - setAgeConfirmed(false); - setAgreementConfirmed(false); - }} - placeholder="Paste an invite code for a members-only relay" - type="text" - value={inviteCode} - /> -
-
- - { - setReposDir(e.target.value); - setReposDirError(null); - }} - placeholder="~/Development" - type="text" - value={reposDir} - /> - {reposDirError ? ( -

{reposDirError}

- ) : null} -

- Point the agent's REPOS directory at an existing - folder so agents work in your local checkouts. Leave blank to use - the default location. -

-
-

- Communities share your active identity. To use a different key, - import it on the profile step (or in settings). -

- {inviteError ? ( -

{inviteError}

- ) : null} - - {joinPolicy && relayUrl.trim() ? ( - + +
+ {mode !== "choose" ? ( + ) : null} - -
- - + {title}
- + + {description} + + + +
+ {mode === "choose" ? ( +
+ + + +
+ ) : mode === "join" ? ( + { + setJoinError(null); + setMode("choose"); + }} + onConnect={(relayUrl) => startConnection({ relayUrl })} + onRedeem={(relayUrl, inviteCode, policyReceipt) => + startConnection({ relayUrl, inviteCode, policyReceipt }) + } + variant="add-community" + /> + ) : ( + + )} +
); diff --git a/desktop/src/features/communities/ui/CommunityIconSettingsCard.tsx b/desktop/src/features/communities/ui/CommunityIconSettingsCard.tsx index 4f557fd8a3..339347e85a 100644 --- a/desktop/src/features/communities/ui/CommunityIconSettingsCard.tsx +++ b/desktop/src/features/communities/ui/CommunityIconSettingsCard.tsx @@ -1,135 +1,114 @@ -import { ImagePlus, Trash2 } from "lucide-react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import * as React from "react"; import { toast } from "sonner"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { AgentCreationPreview } from "@/features/agents/ui/AgentCreationPreview"; import { downscaleIconToDataUrl } from "@/features/communities/lib/downscaleIcon"; import { - useActiveCommunityIcon, communityIconQueryKey, + useActiveCommunityIcon, } from "@/features/communities/useCommunityIcons"; import { useCommunities } from "@/features/communities/useCommunities"; -import { getInitials } from "@/shared/lib/initials"; import { setCommunityIcon } from "@/shared/api/communityProfile"; -import { Button } from "@/shared/ui/button"; - -const ICON_IMAGE_TYPES = ["image/gif", "image/jpeg", "image/png", "image/webp"]; /** - * Admin-only community icon editor, rendered inside the Community Access - * settings section. Publishes a kind:9033 command; the relay stores - * the icon per community and serves it in NIP-11, which every member's - * rail reads. + * Community icon editor using the same image/emoji picker as agent creation. + * Images stay as small inline data URLs so inactive-community rails can render + * them without crossing another relay's authenticated media boundary. */ -export function CommunityIconSettingsCard() { +export function CommunityIconSettingsCard({ + compact = false, +}: { + compact?: boolean; +}) { const { activeCommunity } = useCommunities(); const relayUrl = activeCommunity?.relayUrl; const iconQuery = useActiveCommunityIcon(relayUrl); const queryClient = useQueryClient(); - const inputRef = React.useRef(null); + const persistedIcon = iconQuery.data ?? ""; + const [draftIcon, setDraftIcon] = React.useState(persistedIcon); + const queuedIconRef = React.useRef(undefined); + const isSavingRef = React.useRef(false); + const hasLocalEditRef = React.useRef(false); + + React.useEffect(() => { + if (hasLocalEditRef.current) return; + setDraftIcon(persistedIcon); + }, [persistedIcon]); const mutation = useMutation({ mutationFn: (icon: string) => setCommunityIcon(icon), onSuccess: async (_data, icon) => { - if (relayUrl) { - queryClient.setQueryData(communityIconQueryKey(relayUrl), icon || null); - await queryClient.invalidateQueries({ - queryKey: communityIconQueryKey(relayUrl), - }); - } + if (!relayUrl) return; + queryClient.setQueryData(communityIconQueryKey(relayUrl), icon || null); + await queryClient.invalidateQueries({ + queryKey: communityIconQueryKey(relayUrl), + }); }, }); - async function handleFile(file: File) { - if (!ICON_IMAGE_TYPES.includes(file.type)) { - toast.error("Choose a PNG, JPG, GIF, or WebP image."); - return; - } - try { - const dataUrl = await downscaleIconToDataUrl(file); - await mutation.mutateAsync(dataUrl); - toast.success("Community icon updated"); - } catch (error) { - toast.error( - error instanceof Error - ? error.message - : "Failed to update the community icon.", - ); - } - } + const mutateIcon = mutation.mutateAsync; + + const persistIcon = React.useCallback( + (icon: string) => { + hasLocalEditRef.current = true; + queuedIconRef.current = icon; + setDraftIcon(icon); + if (isSavingRef.current) return; - async function handleClear() { - try { - await mutation.mutateAsync(""); - toast.success("Community icon removed"); - } catch (error) { - toast.error( - error instanceof Error - ? error.message - : "Failed to remove the community icon.", - ); - } + isSavingRef.current = true; + const drainQueue = async () => { + while (queuedIconRef.current !== undefined) { + const nextIcon = queuedIconRef.current; + queuedIconRef.current = undefined; + try { + await mutateIcon(nextIcon); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Couldn’t update the community icon.", + ); + } + } + isSavingRef.current = false; + }; + void drainQueue(); + }, + [mutateIcon], + ); + + function previewIcon(icon: string) { + hasLocalEditRef.current = true; + setDraftIcon(icon); } - const icon = iconQuery.data ?? null; - const initials = activeCommunity ? getInitials(activeCommunity.name) : ""; + function clearIconPreview() { + hasLocalEditRef.current = true; + setDraftIcon(""); + } return ( -
- Community icon -
- - {icon ? ( - Community icon - ) : ( - initials || "🐝" - )} - -
- - {icon ? ( - - ) : null} -
- { - const file = event.target.files?.[0]; - event.target.value = ""; - if (file) void handleFile(file); - }} - ref={inputRef} - type="file" - /> -
-

- Shown to every member in the community rail and switcher. Square images - work best. -

+
+
); } diff --git a/desktop/src/features/communities/ui/CommunitySwitcher.tsx b/desktop/src/features/communities/ui/CommunitySwitcher.tsx index 020a2e8838..b6ad193fcb 100644 --- a/desktop/src/features/communities/ui/CommunitySwitcher.tsx +++ b/desktop/src/features/communities/ui/CommunitySwitcher.tsx @@ -2,8 +2,11 @@ import { Check, ChevronDown, ChevronRight, + Link2, MoreHorizontal, Plus, + Settings2, + Ticket, WifiOff, } from "lucide-react"; import * as React from "react"; @@ -29,6 +32,7 @@ import { isRelayConnectionDegraded, useRelayConnection, } from "@/shared/api/useRelayConnection"; +import { writeTextToClipboard } from "@/shared/lib/clipboard"; import { useActiveCommunityIcon } from "@/features/communities/useCommunityIcons"; import { EditCommunityDialog } from "./EditCommunityDialog"; @@ -45,6 +49,8 @@ type CommunitySwitcherProps = { activeCommunity: Community | null; communities: Community[]; variant?: "sidebar" | "profile" | "profile-menu"; + canInvite?: boolean; + onInvite?: () => void; onSwitchCommunity: (id: string) => void; onAddCommunity: () => void; onUpdateCommunity: ( @@ -87,6 +93,8 @@ export function CommunitySwitcher({ activeCommunity, communities, variant = "sidebar", + canInvite = false, + onInvite, onSwitchCommunity, onAddCommunity, onUpdateCommunity, @@ -204,7 +212,7 @@ export function CommunitySwitcher({ aria-label={ degraded ? `${activeCommunity?.name ?? "Community"} — ${connectionLabel}` - : "Switch community" + : "Community actions" } className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm text-popover-foreground outline-hidden transition-colors hover:bg-muted/50 focus:bg-muted/50 focus:outline-none focus-visible:bg-muted/50 focus-visible:outline-none data-[state=open]:bg-muted/50 data-[state=open]:text-popover-foreground" data-testid="community-switcher" @@ -221,50 +229,60 @@ export function CommunitySwitcher({ className="w-60 p-1" onMouseEnter={() => scheduleProfileMenu(true)} onMouseLeave={() => scheduleProfileMenu(false)} + onOpenAutoFocus={(event) => event.preventDefault()} side="right" sideOffset={0} > -
- {communities.map((community) => ( -
+
+ {activeCommunity ? ( + <> + {canInvite && onInvite ? ( + + ) : null} -
- ))} -
+
+ + ) : null}
@@ -354,7 +372,7 @@ export function CommunitySwitcher({ - Add Community + Add a community diff --git a/desktop/src/features/communities/ui/HostedCommunityCreateFlow.tsx b/desktop/src/features/communities/ui/HostedCommunityCreateFlow.tsx new file mode 100644 index 0000000000..2401f5dee8 --- /dev/null +++ b/desktop/src/features/communities/ui/HostedCommunityCreateFlow.tsx @@ -0,0 +1,457 @@ +import * as React from "react"; +import { AlertCircle, ExternalLink, LoaderCircle } from "lucide-react"; + +import { + bindBuilderlabIdentity, + cancelBuilderlabLogin, + checkHostedCommunityName, + clearBuilderlabAuth, + createHostedCommunity, + deleteBuilderlabIdentity, + getBuilderlabAuth, + HOSTED_COMMUNITY_LIMIT, + HOSTED_COMMUNITY_SUFFIX, + hostedCommunityErrorMessage, + hostedCommunityRelayUrl, + type BuilderlabAuth, + type HostedCommunity, + type HostedNostrIdentity, + loadHostedCommunityAccount, + startBuilderlabLogin, + VALID_HOSTED_COMMUNITY_NAME, +} from "@/features/communities/hostedCommunityApi"; +import { useCommunityOnboarding } from "@/features/onboarding/communityOnboarding"; +import { + CHANNEL_FORM_FIELD_CONTROL_CLASS, + CHANNEL_FORM_FIELD_SHELL_CLASS, +} from "@/features/channels/ui/channelFormStyles"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import { cn } from "@/shared/lib/cn"; +import { safeNpub } from "@/shared/lib/nostrUtils"; +import { Button } from "@/shared/ui/button"; +import { Input } from "@/shared/ui/input"; + +type HostedCommunityCreateFlowProps = { + onComplete: () => void; +}; + +export function HostedCommunityCreateFlow({ + onComplete, +}: HostedCommunityCreateFlowProps) { + const onboarding = useCommunityOnboarding(); + const localPubkey = useIdentityQuery().data?.pubkey ?? null; + const [auth, setAuth] = React.useState(null); + const [identity, setIdentity] = React.useState( + null, + ); + const [communities, setCommunities] = React.useState([]); + const [name, setName] = React.useState(""); + const [availability, setAvailability] = React.useState(null); + const [checkingName, setCheckingName] = React.useState(false); + const [loading, setLoading] = React.useState(true); + const [action, setAction] = React.useState(null); + const [error, setError] = React.useState(null); + const loginAttempt = React.useRef(0); + const signingIn = React.useRef(false); + + const loadAccount = React.useCallback(async () => { + const account = await loadHostedCommunityAccount(); + setIdentity(account.identity); + setCommunities(account.communities); + }, []); + + React.useEffect(() => { + let active = true; + void getBuilderlabAuth() + .then(async (nextAuth) => { + if (!active) return; + setAuth(nextAuth); + if (nextAuth) await loadAccount(); + }) + .catch((cause) => { + if (active) + setError(cause instanceof Error ? cause.message : String(cause)); + }) + .finally(() => { + if (active) setLoading(false); + }); + return () => { + active = false; + loginAttempt.current += 1; + if (signingIn.current) { + signingIn.current = false; + void cancelBuilderlabLogin().catch(() => { + // The browser sign-in is already detached from this flow. + // Cancellation is best-effort cleanup for the native callback. + }); + } + }; + }, [loadAccount]); + + const run = async (label: string, operation: () => Promise) => { + setAction(label); + setError(null); + try { + await operation(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setAction(null); + } + }; + + const signIn = () => { + const attempt = ++loginAttempt.current; + signingIn.current = true; + setAction("Signing in…"); + setError(null); + void startBuilderlabLogin() + .then(async (nextAuth) => { + if (loginAttempt.current !== attempt) return; + setAuth(nextAuth); + await loadAccount(); + }) + .catch((cause) => { + if (loginAttempt.current !== attempt) return; + setError(cause instanceof Error ? cause.message : String(cause)); + }) + .finally(() => { + if (loginAttempt.current === attempt) { + signingIn.current = false; + setAction(null); + } + }); + }; + + const connectIdentity = () => + run("Connecting identity…", async () => { + const response = await bindBuilderlabIdentity(); + if (response.error) { + throw new Error( + hostedCommunityErrorMessage( + response.error, + response.correlation_id, + "Could not connect the Buzz identity.", + ), + ); + } + setIdentity(response.identity ?? null); + await loadAccount(); + }); + + const signOut = () => + run("Signing out…", async () => { + await clearBuilderlabAuth(); + setAuth(null); + setIdentity(null); + setCommunities([]); + }); + + const boundPubkey = identity?.pubkey_hex ?? null; + const identityMismatch = Boolean( + identity && + boundPubkey && + localPubkey && + boundPubkey.toLowerCase() !== localPubkey.toLowerCase(), + ); + const localNpub = localPubkey ? safeNpub(localPubkey) : null; + + const switchToDeviceIdentity = () => + run("Switching identity…", async () => { + const released = await deleteBuilderlabIdentity(); + if (released.error) { + throw new Error( + hostedCommunityErrorMessage( + released.error, + released.correlation_id, + "Could not disconnect the account's previous Buzz identity.", + ), + ); + } + const bound = await bindBuilderlabIdentity(); + if (bound.error) { + await loadAccount(); + throw new Error( + bound.error.code === "pubkey_already_bound" + ? "This device's Buzz identity belongs to a different Builderlab account. Sign in with the account that already owns this identity." + : hostedCommunityErrorMessage( + bound.error, + bound.correlation_id, + "Could not connect this device's Buzz identity.", + ), + ); + } + setIdentity(bound.identity ?? null); + await loadAccount(); + }); + + const normalizedName = name.trim().toLowerCase(); + const validName = + normalizedName.length <= 63 && + VALID_HOSTED_COMMUNITY_NAME.test(normalizedName); + const atCommunityLimit = communities.length >= HOSTED_COMMUNITY_LIMIT; + const ready = Boolean(auth && identity && !identityMismatch); + + React.useEffect(() => { + if (!ready || !normalizedName || !validName) { + setCheckingName(false); + return; + } + let cancelled = false; + setCheckingName(true); + const handle = window.setTimeout(() => { + void checkHostedCommunityName(normalizedName) + .then((response) => { + if (!cancelled) + setAvailability( + response.error ? null : (response.available ?? false), + ); + }) + .catch(() => { + if (!cancelled) setAvailability(null); + }) + .finally(() => { + if (!cancelled) setCheckingName(false); + }); + }, 500); + return () => { + cancelled = true; + window.clearTimeout(handle); + }; + }, [normalizedName, ready, validName]); + + const create = (event: React.FormEvent) => { + event.preventDefault(); + if (!validName || !identity || identityMismatch || atCommunityLimit) return; + void run("Creating community…", async () => { + const available = await checkHostedCommunityName(normalizedName); + if (available.error || !available.available) { + setAvailability(false); + throw new Error( + hostedCommunityErrorMessage( + available.error, + available.correlation_id, + "That Buzz address is already taken.", + ), + ); + } + const response = await createHostedCommunity(normalizedName); + if (response.error || !response.community) { + throw new Error( + hostedCommunityErrorMessage( + response.error, + response.correlation_id, + "Could not create the community.", + ), + ); + } + const relayUrl = hostedCommunityRelayUrl(response.community); + if (!relayUrl) { + throw new Error( + "The community was created, but Builderlab did not return its community URL. Try connecting it again from settings.", + ); + } + const started = onboarding.start({ + source: "add-community", + relayUrl, + communityName: response.community.name ?? response.community.slug, + }); + if (!started) { + throw new Error( + "Finish connecting the community already in progress, then try again.", + ); + } + onComplete(); + }); + }; + + const errorBox = error ? ( +
+ +

{error}

+
+ ) : null; + + if (loading) { + return ( +
+ + Checking sign-in +
+ ); + } + + if (!auth) { + return ( +
+

+ Sign in with Builderlab to create and host a community. Buzz will open + your browser, then bring you back here. +

+ {errorBox} +
+ +
+
+ ); + } + + if (!identity) { + return ( +
+

+ Connect this device’s Buzz identity to your Builderlab account. Your + private key stays on this device. +

+ {errorBox} +
+ + +
+
+ ); + } + + if (identityMismatch) { + return ( +
+

+ This Builderlab account uses a different Buzz identity. Switch it to + this device, or sign in with another account. +

+
+

Account: {identity.npub ?? boundPubkey}

+

+ This device: {localNpub ?? localPubkey} +

+
+ {errorBox} +
+ + +
+
+ ); + } + + const feedback = atCommunityLimit + ? `You’ve reached the limit of ${HOSTED_COMMUNITY_LIMIT} hosted communities.` + : name && !validName + ? "Use lowercase letters, numbers, and single hyphens." + : checkingName + ? "Checking availability…" + : availability === false + ? "That address is already taken." + : availability === true + ? "That address is available." + : "You can’t change this address after creating the community."; + + return ( +
+
+ +
+ { + setName(event.target.value.toLowerCase()); + setAvailability(null); + setError(null); + }} + placeholder="north-star" + spellCheck={false} + value={name} + /> + + .{HOSTED_COMMUNITY_SUFFIX} + +
+

+ {feedback} +

+
+ {errorBox} +
+ +
+
+ ); +} diff --git a/desktop/src/features/community-members/ui/CommunityInviteDialog.tsx b/desktop/src/features/community-members/ui/CommunityInviteDialog.tsx new file mode 100644 index 0000000000..bd23e2bbec --- /dev/null +++ b/desktop/src/features/community-members/ui/CommunityInviteDialog.tsx @@ -0,0 +1,44 @@ +import * as React from "react"; + +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; +import { + DEFAULT_INVITE_TTL_SECS, + InviteLinkSection, +} from "./InviteLinkSection"; + +export function CommunityInviteDialog({ + onOpenChange, + open, +}: { + onOpenChange: (open: boolean) => void; + open: boolean; +}) { + // Email delivery is not available yet, so the modal only mints shareable + // invite links through the relay's existing invite flow. + const [ttlSecs, setTtlSecs] = React.useState(DEFAULT_INVITE_TTL_SECS); + + React.useEffect(() => { + if (open) setTtlSecs(DEFAULT_INVITE_TTL_SECS); + }, [open]); + + return ( + + + + Invite to community + + + + + + ); +} diff --git a/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx b/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx index a170c85891..9097f35387 100644 --- a/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx +++ b/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx @@ -1,74 +1,43 @@ -import { - ArrowDown, - ArrowUp, - ChevronDown, - Crown, - Search, - Shield, - Trash2, - UserPlus, -} from "lucide-react"; +import { Crown, MoreHorizontal, Search, Shield } from "lucide-react"; import { nip19 } from "nostr-tools"; import * as React from "react"; import { toast } from "sonner"; -import { canEditCommunityProfile } from "@/shared/api/relayMembers"; -import { useUsersBatchQuery } from "@/features/profile/hooks"; -import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import { - useAddRelayMemberMutation, useChangeRelayMemberRoleMutation, useMyRelayMembershipLookupQuery, useRelayMembersQuery, useRemoveRelayMemberMutation, } from "@/features/community-members/hooks"; -import type { RelayMember, RelayMemberRole } from "@/shared/api/types"; -import type { UserProfileSummary } from "@/shared/api/types"; -import { cn } from "@/shared/lib/cn"; +import { useUsersBatchQuery } from "@/features/profile/hooks"; +import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; +import { SettingsSectionHeader } from "@/features/settings/ui/SettingsSectionHeader"; +import type { + RelayMember, + RelayMemberRole, + UserProfileSummary, +} from "@/shared/api/types"; import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; import { Button } from "@/shared/ui/button"; import { DropdownMenu, DropdownMenuContent, - DropdownMenuLabel, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, + DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; -import { Input } from "@/shared/ui/input"; -import { SettingsSectionHeader } from "@/features/settings/ui/SettingsSectionHeader"; -import { CommunityIconSettingsCard } from "@/features/communities/ui/CommunityIconSettingsCard"; import { VirtualizedList } from "@/shared/ui/VirtualizedList"; -import { InviteLinkSection } from "./InviteLinkSection"; - -type AssignableRelayRole = Exclude; - -function normalizeRelayPubkeyInput(value: string): string { - const trimmed = value.trim(); - const withoutPrefix = trimmed.toLowerCase().startsWith("nostr:") - ? trimmed.slice("nostr:".length) - : trimmed; - const normalized = normalizePubkey(withoutPrefix); - if (normalized.startsWith("npub1")) { - try { - const decoded = nip19.decode(normalized); - if (decoded.type === "npub" && typeof decoded.data === "string") { - return decoded.data.toLowerCase(); - } - } catch { - // fall through to return the normalized npub so validation can flag it - } - } - return normalized; -} - -function isValidHexPubkey(value: string): boolean { - return /^[0-9a-f]{64}$/.test(value); -} +import { CommunityInviteDialog } from "./CommunityInviteDialog"; function formatDisplayName(member: RelayMember, displayName?: string | null) { - return displayName?.trim() || truncatePubkey(member.pubkey); + const trimmedDisplayName = displayName?.trim(); + if ( + trimmedDisplayName && + !trimmedDisplayName.toLowerCase().startsWith("npub1") + ) { + return trimmedDisplayName; + } + return member.role === "owner" ? "Community owner" : "Unnamed member"; } function npubFromPubkey(pubkey: string): string | null { @@ -87,26 +56,28 @@ function formatDate(dateString: string): string { }); } -function roleBadgeClass(role: RelayMemberRole): string { - switch (role) { - case "owner": - return "bg-amber-500/10 text-amber-500"; - case "admin": - return "bg-blue-500/10 text-blue-500"; - case "member": - return "bg-muted text-muted-foreground"; - } -} - -function RoleBadge({ role }: { role: RelayMemberRole }) { +function HoverMemberIdentity({ + displayName, + pubkey, +}: { + displayName: string; + pubkey: string; +}) { + const npub = npubFromPubkey(pubkey) ?? pubkey; return ( - - {role} + + + {displayName} + + + {truncatePubkey(npub)} + ); } @@ -134,9 +105,8 @@ function RelayMemberRow({ (currentRole === "owner" || member.role === "member"); const canPromote = currentRole === "owner" && member.role === "member"; const canDemote = currentRole === "owner" && member.role === "admin"; - + const hasActions = canRemove || canPromote || canDemote; const displayName = formatDisplayName(member, profile?.displayName); - const npub = npubFromPubkey(member.pubkey); async function mutateWithToast( action: () => Promise, @@ -149,128 +119,120 @@ function RelayMemberRow({ toast.error( error instanceof Error ? error.message - : "Community membership update failed", + : "Couldn’t update this community member.", ); } } return (
-
-
+
+
+ {member.role === "owner" ? ( ) : null} {member.role === "admin" ? ( ) : null} - {displayName} +
+
+ {member.role} + + Added {formatDate(member.createdAt)} {isSelf ? ( - you + <> + + You + ) : null} -
-

- {npub ?? member.pubkey} -

-

- Added {formatDate(member.createdAt)} -

-
-
- {canPromote ? ( - - ) : null} - {canDemote ? ( - - ) : null} - {canRemove ? ( - - ) : null}
+ + {hasActions ? ( + + + + + + {canPromote ? ( + + void mutateWithToast( + () => + changeRoleMutation.mutateAsync({ + pubkey: member.pubkey, + role: "admin", + }), + "Made community admin", + ) + } + > + Make admin + + ) : null} + {canDemote ? ( + + void mutateWithToast( + () => + changeRoleMutation.mutateAsync({ + pubkey: member.pubkey, + role: "member", + }), + "Made community member", + ) + } + > + Make member + + ) : null} + {canRemove && (canPromote || canDemote) ? ( + + ) : null} + {canRemove ? ( + + void mutateWithToast( + () => removeMutation.mutateAsync(member.pubkey), + "Removed community member", + ) + } + > + Remove from community + + ) : null} + + + ) : null}
); } -const ROLE_OPTIONS: { - description: string; - label: string; - value: AssignableRelayRole; -}[] = [ - { - value: "member", - label: "Member", - description: "Can connect and participate", - }, - { - value: "admin", - label: "Admin", - description: "Can also invite members", - }, -]; - export function CommunityMembersSettingsCard({ currentPubkey, }: { @@ -279,10 +241,6 @@ export function CommunityMembersSettingsCard({ const myMembershipQuery = useMyRelayMembershipLookupQuery(); const currentRole = myMembershipQuery.data?.membership?.role ?? null; const canManageRelay = currentRole === "owner" || currentRole === "admin"; - // Open relays do not advertise NIP-43, so they cannot expose an admin role - // snapshot. Keep the icon editor available there and let the relay enforce - // the authoritative owner/admin check on kind 9033. - const canEditIcon = canEditCommunityProfile(myMembershipQuery.data); const membersQuery = useRelayMembersQuery(canManageRelay); const members = React.useMemo( () => membersQuery.data ?? [], @@ -295,16 +253,15 @@ export function CommunityMembersSettingsCard({ }, ); const profiles = profilesQuery.data?.profiles; - const addMutation = useAddRelayMemberMutation(); - const [pubkeyInput, setPubkeyInput] = React.useState(""); - const [role, setRole] = React.useState("member"); + const [inviteDialogOpen, setInviteDialogOpen] = React.useState(false); const [search, setSearch] = React.useState(""); const filteredMembers = React.useMemo(() => { const q = search.trim().toLowerCase(); if (!q) return members; return members.filter((member) => { - const profile = profiles?.[normalizePubkey(member.pubkey)]; + const normalizedPubkey = normalizePubkey(member.pubkey); + const profile = profiles?.[normalizedPubkey]; const displayName = profile?.displayName?.toLowerCase() ?? ""; const nip05 = profile?.nip05Handle?.toLowerCase() ?? ""; const npub = npubFromPubkey(member.pubkey)?.toLowerCase() ?? ""; @@ -322,163 +279,42 @@ export function CommunityMembersSettingsCard({ return (

- Checking relay permissions… + Checking invite permissions…

); } - if (!canEditIcon) { - return null; - } - if (!canManageRelay || !currentRole) { - return ( -
- - -
- ); - } - - const normalizedInput = normalizeRelayPubkeyInput(pubkeyInput); - const canGrantAdmin = currentRole === "owner"; - const canAdd = - isValidHexPubkey(normalizedInput) && - !addMutation.isPending && - (role === "member" || canGrantAdmin); - - async function handleAddMember(event: React.FormEvent) { - event.preventDefault(); - if (!canAdd) return; - try { - await addMutation.mutateAsync({ pubkey: normalizedInput, role }); - toast.success( - role === "admin" ? "Added relay admin" : "Added relay member", - ); - setPubkeyInput(""); - setRole("member"); - } catch (error) { - toast.error( - error instanceof Error ? error.message : "Failed to add relay member", - ); - } + return null; } return (
- Manage who can connect to this relay. Owners can invite admins or - members; admins can invite members. - + action={ + } + title="Invites" + description="Manage members and community access." /> -
- -
- -
- setPubkeyInput(event.target.value)} - placeholder="npub1… or 64-char hex pubkey" - value={pubkeyInput} - /> -
- - {canGrantAdmin ? ( - - - - - - Invite as - - - setRole(value as AssignableRelayRole) - } - value={role} - > - {ROLE_OPTIONS.map((option) => ( - -
- - {option.label} - - - {option.description} - -
-
- ))} -
-
-
- ) : null} -
-
- {pubkeyInput.trim().length > 0 && - !isValidHexPubkey(normalizedInput) ? ( -

- Enter a valid npub or 64-character hex pubkey. -

- ) : null} -
- - - - {membersQuery.error instanceof Error ? ( -

- {membersQuery.error.message} -

- ) : null} - -
-
-

+
+
+
+

Members {members.length > 0 ? ( - ({members.length}) + {members.length} ) : null} -

+

@@ -486,48 +322,57 @@ export function CommunityMembersSettingsCard({ setSearch(event.target.value)} - placeholder="Search members by name, npub, or role…" + placeholder="Search members" spellCheck={false} type="text" value={search} />
+ {membersQuery.error instanceof Error ? ( +

+ {membersQuery.error.message} +

+ ) : null} + {membersQuery.isLoading ? ( -

+

Loading community members…

) : members.length === 0 ? ( -

- No community members yet. Invite someone above to get started. +

+ No community members yet.

) : filteredMembers.length === 0 ? ( -

+

No members match your search.

) : ( member.pubkey} items={filteredMembers} renderItem={(member) => ( -
- -
+ )} /> )}
+ +
); } diff --git a/desktop/src/features/community-members/ui/InviteLinkSection.tsx b/desktop/src/features/community-members/ui/InviteLinkSection.tsx index 552fa34082..76c0262ea5 100644 --- a/desktop/src/features/community-members/ui/InviteLinkSection.tsx +++ b/desktop/src/features/community-members/ui/InviteLinkSection.tsx @@ -1,10 +1,9 @@ -import { Check, Copy, Link2 } from "lucide-react"; -import { QRCodeCanvas } from "qrcode.react"; +import { Check, ChevronDown, Link2 } from "lucide-react"; import * as React from "react"; import { toast } from "sonner"; import { mintInvite } from "@/shared/api/invites"; -import { invokeTauri } from "@/shared/api/tauri"; +import { writeTextToClipboard } from "@/shared/lib/clipboard"; import { Button } from "@/shared/ui/button"; import { DropdownMenu, @@ -15,12 +14,8 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/shared/ui/dropdown-menu"; -import { writeTextToClipboard } from "@/shared/lib/clipboard"; -import { - MediaContextMenu, - type MediaContextMenuPosition, - useDismissMediaContextMenu, -} from "@/shared/ui/markdown/MediaContextMenu"; +import { Separator } from "@/shared/ui/separator"; +import { Spinner } from "@/shared/ui/spinner"; const TTL_OPTIONS: { label: string; value: number }[] = [ { label: "1 day", value: 24 * 60 * 60 }, @@ -29,207 +24,122 @@ const TTL_OPTIONS: { label: string; value: number }[] = [ { label: "30 days", value: 30 * 24 * 60 * 60 }, ]; -function formatExpiry(expiresAtUnix: number): string { - return new Date(expiresAtUnix * 1000).toLocaleString(undefined, { - month: "short", - day: "numeric", - hour: "numeric", - minute: "2-digit", - }); -} +export const DEFAULT_INVITE_TTL_SECS = TTL_OPTIONS[1].value; + +type CopyStatus = "idle" | "copying" | "copied"; /** - * "Create invite link" section of the relay-access settings card. + * Share-with-link footer for the community invite dialog. * - * Mints a stateless invite code via `POST /api/invites` (owner/admin only — - * the parent card already gates on that) and surfaces the shareable - * `/invite/` landing-page URL. Codes are multi-use until expiry and - * are not individually revocable; the relay key is the revocation lever. + * Each copy action mints a fresh stateless invite code and places its + * shareable landing-page URL on the clipboard. */ -export function InviteLinkSection() { - const [ttlSecs, setTtlSecs] = React.useState(TTL_OPTIONS[1].value); - const [minting, setMinting] = React.useState(false); - const [invite, setInvite] = React.useState<{ - url: string; - expiresAt: number; - } | null>(null); - const [copied, setCopied] = React.useState(false); - const [qrMenu, setQrMenu] = React.useState( - null, - ); - const qrRef = React.useRef(null); - const closeQrMenu = React.useCallback(() => setQrMenu(null), []); - useDismissMediaContextMenu(Boolean(qrMenu), closeQrMenu); - +export function InviteLinkSection({ + onTtlSecsChange, + ttlSecs, +}: { + onTtlSecsChange: (ttlSecs: number) => void; + ttlSecs: number; +}) { + const [copyStatus, setCopyStatus] = React.useState("idle"); const ttlLabel = TTL_OPTIONS.find((option) => option.value === ttlSecs)?.label ?? "3 days"; + const copyLabel = + copyStatus === "copying" + ? "Copying…" + : copyStatus === "copied" + ? "Copied" + : "Copy link"; - async function handleCreate() { - setInvite(null); - setCopied(false); - setMinting(true); - try { - const minted = await mintInvite(ttlSecs); - setInvite({ url: minted.url, expiresAt: minted.expiresAt }); - } catch (error) { - toast.error( - error instanceof Error - ? `Couldn't create invite link: ${error.message}` - : "Couldn't create invite link", - ); - } finally { - setMinting(false); - } - } + React.useEffect(() => { + if (copyStatus !== "copied") return; + const resetTimer = window.setTimeout(() => setCopyStatus("idle"), 2000); + return () => window.clearTimeout(resetTimer); + }, [copyStatus]); async function handleCopy() { - if (!invite) return; + if (copyStatus === "copying") return; + setCopyStatus("copying"); try { + const invite = await mintInvite(ttlSecs); await writeTextToClipboard(invite.url); - setCopied(true); + setCopyStatus("copied"); toast.success("Invite link copied"); - setTimeout(() => setCopied(false), 2000); } catch { - toast.error("Couldn't copy to clipboard"); - } - } - - async function handleDownloadQr() { - closeQrMenu(); - const dataUrl = qrRef.current?.toDataURL("image/png"); - if (!dataUrl) return; - try { - await invokeTauri("save_png_data_url", { - dataUrl, - filename: "buzz-community-invite.png", - }); - } catch (error) { - toast.error(error instanceof Error ? error.message : "Download failed"); + setCopyStatus("idle"); + toast.error("Couldn’t copy the invite link. Try again."); } } return ( -
- Invite link -
-
- - - - - - - Expires after - - setTtlSecs(Number(value))} - value={String(ttlSecs)} - > - {TTL_OPTIONS.map((option) => ( - - {option.label} - - ))} - - - +
+
+ + +
+

Share with a link

+

+ Anyone with the link can join this community. +

- {invite ? ( -
- - {invite.url} - + + -
- ) : null} + + + Expires after + + onTtlSecsChange(Number(value))} + value={String(ttlSecs)} + > + {TTL_OPTIONS.map((option) => ( + + {option.label} + + ))} + + +
- {invite ? ( -
-
- { - event.preventDefault(); - event.stopPropagation(); - event.nativeEvent.stopImmediatePropagation(); - setQrMenu({ x: event.clientX, y: event.clientY }); - }} - size={128} - value={invite.url} - /> -
-
-

- Scan this QR code or share the link above to invite someone to - this relay. -

-

- Anyone with this link or QR code can join as a member until{" "} - {formatExpiry(invite.expiresAt)}. -

-
-
- ) : ( -

- Create a shareable link that lets anyone join this relay as a member - until it expires. -

- )} - {qrMenu ? ( - void handleDownloadQr(), - }, - ]} - position={qrMenu} - /> - ) : null} -
+ +
+ +
+ ); } diff --git a/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx b/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx index de685fe52c..e6a88d4a09 100644 --- a/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx +++ b/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx @@ -44,17 +44,19 @@ type InviteRedeemFormProps = { */ defaultRelayUrl?: string; error: string | null; + initialValue?: string; isRedeeming: boolean; onCancel: () => void; onConnect?: (relayWsUrl: string) => void; onRedeem: (relayWsUrl: string, code: string, policyReceipt?: string) => void; placeholder?: string; - variant?: "default" | "onboarding-spotlight"; + variant?: "add-community" | "default" | "onboarding-spotlight"; }; export function InviteRedeemForm({ defaultRelayUrl, error, + initialValue = "", isRedeeming, onCancel, onConnect, @@ -63,7 +65,7 @@ export function InviteRedeemForm({ variant = "default", }: InviteRedeemFormProps) { const formId = React.useId(); - const [inviteInput, setInviteInput] = React.useState(""); + const [inviteInput, setInviteInput] = React.useState(initialValue); const [bareCodeRelayUrl, setBareCodeRelayUrl] = React.useState( defaultRelayUrl ?? "", ); @@ -129,6 +131,7 @@ export function InviteRedeemForm({ (isBareCode && bareCodeRelayUrl.trim().length > 0))) || normalizedRelayUrl !== null; const isOnboardingSpotlight = variant === "onboarding-spotlight"; + const isAddCommunity = variant === "add-community"; const showInvalidInviteTip = isOnboardingSpotlight && inviteInput.trim().length > 0 && !canSubmit; @@ -232,7 +235,11 @@ export function InviteRedeemForm({ const submitButton = (
-
+
- {/* ── Settings ───────────────────────────────────────── */} - + {communitySwitcherSlot ? ( + <> + {/* ── Community actions ──────────────────────────── */} +
+ {communitySwitcherSlot} +
+
+ + ) : null} {onSendFeedback ? (
diff --git a/desktop/src/features/profile/useAvatarUpload.ts b/desktop/src/features/profile/useAvatarUpload.ts index f3477c494e..f73050d017 100644 --- a/desktop/src/features/profile/useAvatarUpload.ts +++ b/desktop/src/features/profile/useAvatarUpload.ts @@ -11,9 +11,11 @@ const AVATAR_IMAGE_TYPES = [ ]; type UseAvatarUploadOptions = { + fallbackErrorMessage?: string; onUploadStart?: (file: File) => void; onUploadSettled?: () => void; onUploadSuccess: (url: string) => void; + processImage?: (file: File) => Promise; }; type UseAvatarUploadReturn = { @@ -27,9 +29,11 @@ type UseAvatarUploadReturn = { }; export function useAvatarUpload({ + fallbackErrorMessage = "Could not upload that avatar.", onUploadStart, onUploadSettled, onUploadSuccess, + processImage, }: UseAvatarUploadOptions): UseAvatarUploadReturn { const inputRef = React.useRef(null); const [isUploading, setIsUploading] = React.useState(false); @@ -57,29 +61,37 @@ export function useAvatarUpload({ }); try { - const buffer = await file.arrayBuffer(); - const uploaded = await uploadMediaBytes([...new Uint8Array(buffer)]); - // The shared upload path is now generic (accepts any non-denied file), - // so the browser-provided `file.type` check above is no longer a - // backstop. Verify the server-detected MIME is actually an image before - // accepting it as an avatar — defends against spoofed/blank picker MIME. - if (!uploaded.type.startsWith("image/")) { - setErrorMessage("Choose a PNG, JPG, GIF, or WebP image."); - return; + if (processImage) { + onUploadSuccess(await processImage(file)); + } else { + const buffer = await file.arrayBuffer(); + const uploaded = await uploadMediaBytes([...new Uint8Array(buffer)]); + // The shared upload path is now generic (accepts any non-denied file), + // so the browser-provided `file.type` check above is no longer a + // backstop. Verify the server-detected MIME is actually an image before + // accepting it as an avatar — defends against spoofed/blank picker MIME. + if (!uploaded.type.startsWith("image/")) { + setErrorMessage("Choose a PNG, JPG, GIF, or WebP image."); + return; + } + onUploadSuccess(uploaded.url); } - onUploadSuccess(uploaded.url); } catch (error) { setErrorMessage( - error instanceof Error - ? error.message - : "Could not upload that avatar.", + error instanceof Error ? error.message : fallbackErrorMessage, ); } finally { setIsUploading(false); onUploadSettled?.(); } }, - [onUploadSettled, onUploadStart, onUploadSuccess], + [ + fallbackErrorMessage, + onUploadSettled, + onUploadStart, + onUploadSuccess, + processImage, + ], ); const handleFileChange = React.useCallback( diff --git a/desktop/src/features/settings/ui/HostedCommunitiesSettingsCard.tsx b/desktop/src/features/settings/ui/HostedCommunitiesSettingsCard.tsx index 9c16359b45..4eebddaea9 100644 --- a/desktop/src/features/settings/ui/HostedCommunitiesSettingsCard.tsx +++ b/desktop/src/features/settings/ui/HostedCommunitiesSettingsCard.tsx @@ -28,6 +28,8 @@ import { type HostedNostrIdentity as NostrIdentity, VALID_HOSTED_COMMUNITY_NAME as VALID_NAME, } from "@/features/communities/hostedCommunityApi"; +import { CommunityIconSettingsCard } from "@/features/communities/ui/CommunityIconSettingsCard"; +import { useCommunities } from "@/features/communities/useCommunities"; import { safeNpub } from "@/shared/lib/nostrUtils"; import { useCommunityOnboarding } from "@/features/onboarding/communityOnboarding"; import { @@ -52,8 +54,18 @@ import { import { Input } from "@/shared/ui/input"; import { SettingsSectionHeader } from "./SettingsSectionHeader"; +function relayHost(url: string | null | undefined) { + if (!url) return null; + try { + return new URL(url).host.toLowerCase(); + } catch { + return null; + } +} + export function HostedCommunitiesSettingsCard() { const onboarding = useCommunityOnboarding(); + const { activeCommunity } = useCommunities(); const localPubkey = useIdentityQuery().data?.pubkey ?? null; const [auth, setAuth] = React.useState(null); const [communities, setCommunities] = React.useState([]); @@ -573,6 +585,10 @@ export function HostedCommunitiesSettingsCard() { community={community} busy={busy} canConnect={!identityMismatch} + showIconPicker={ + relayHost(relayUrl(community)) === + relayHost(activeCommunity?.relayUrl) + } onConnect={() => { const url = relayUrl(community); if (url) @@ -719,6 +735,7 @@ function CommunityRow({ onArchive, onUnarchive, onTransfer, + showIconPicker, }: { community: HostedCommunity; busy: boolean; @@ -727,6 +744,7 @@ function CommunityRow({ onArchive: () => void; onUnarchive: () => void; onTransfer: (npub: string) => Promise; + showIconPicker: boolean; }) { const [confirmArchive, setConfirmArchive] = React.useState(false); const [confirmUnarchive, setConfirmUnarchive] = React.useState(false); @@ -740,13 +758,17 @@ function CommunityRow({ className={`flex flex-wrap items-center justify-between gap-3 rounded-xl border border-border/70 p-4 ${ archived ? "opacity-70" : "" }`} + data-testid="hosted-community-row" > -
-

{displayName}

-

- {community.normalized_host} - {archived ? " · Archived" : ""} -

+
+ {showIconPicker ? : null} +
+

{displayName}

+

+ {community.normalized_host} + {archived ? " · Archived" : ""} +

+
{archived ? ( diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx index a203269239..e2b95f249a 100644 --- a/desktop/src/features/settings/ui/SettingsPanels.tsx +++ b/desktop/src/features/settings/ui/SettingsPanels.tsx @@ -11,7 +11,6 @@ import { FlaskConical, Keyboard, LayoutTemplate, - LockKeyhole, MessagesSquare, MonitorCog, Moon, @@ -20,6 +19,7 @@ import { Smile, Sun, SunMoon, + Ticket, UserRound, type LucideIcon, } from "lucide-react"; @@ -202,8 +202,8 @@ export const settingsSections: SettingsSectionDescriptor[] = [ }, { value: "community-members", - label: "Community access", - icon: LockKeyhole, + label: "Invites", + icon: Ticket, }, { value: "moderation", diff --git a/desktop/src/features/settings/ui/SettingsView.tsx b/desktop/src/features/settings/ui/SettingsView.tsx index 90634e01f4..8cba34f23a 100644 --- a/desktop/src/features/settings/ui/SettingsView.tsx +++ b/desktop/src/features/settings/ui/SettingsView.tsx @@ -242,7 +242,7 @@ export function SettingsView({ data-testid="community-access-loading" > - Checking community access… + Checking invite permissions…
) : null} {myMembershipQuery.isError ? ( @@ -252,7 +252,7 @@ export function SettingsView({ >
- Community access could not be checked. + Invite settings could not be checked.
) : null} {visibleNavGroups.map((group) => ( diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index c4552bb54d..b40f56b042 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -55,6 +55,7 @@ import { useDeferredModalOpen } from "@/shared/ui/deferredModalOpen"; import { SidebarUpdateCard } from "@/features/settings/SidebarUpdateCard"; import { useUpdaterContext } from "@/features/settings/hooks/UpdaterProvider"; import { shouldShowSidebarUpdateCard } from "@/features/settings/sidebarUpdateCardVisibility"; +import type { SettingsSection } from "@/features/settings/ui/SettingsPanels"; import type { Channel, ChannelVisibility, @@ -155,7 +156,7 @@ type AppSidebarProps = { */ searchChannels: Channel[]; searchFocusRequest: number; - onSelectSettings: (section?: "profile" | "appearance") => void; + onSelectSettings: (section?: SettingsSection) => void; onSetPresenceStatus?: (status: "online" | "away" | "offline") => void; onSetUserStatus: (text: string, emoji: string) => void; onClearUserStatus: () => void; diff --git a/desktop/src/features/sidebar/ui/CommunityRail.tsx b/desktop/src/features/sidebar/ui/CommunityRail.tsx index 2737a41f4f..e71f1ae23d 100644 --- a/desktop/src/features/sidebar/ui/CommunityRail.tsx +++ b/desktop/src/features/sidebar/ui/CommunityRail.tsx @@ -15,12 +15,13 @@ import { useSortable, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { CheckCheck, Link2, Plus, Settings2 } from "lucide-react"; +import { CheckCheck, Link2, Plus, Settings2, Ticket } from "lucide-react"; import * as React from "react"; import type { Community } from "@/features/communities/types"; import { EditCommunityDialog } from "@/features/communities/ui/EditCommunityDialog"; import { useCommunityIcons } from "@/features/communities/useCommunityIcons"; +import { useMyRelayMembershipLookupQuery } from "@/features/community-members/hooks"; import { useCommunityUnread, type CommunityUnreadState, @@ -141,7 +142,7 @@ function CommunityButton({ isActive ? "rounded-xl bg-primary text-primary-foreground" : "bg-sidebar-accent/60 text-sidebar-foreground/80 hover:rounded-xl hover:bg-primary/80 hover:text-primary-foreground", - pending && "opacity-60", + pending && !isActive && "opacity-60", )} > {iconUrl ? ( @@ -214,6 +215,8 @@ function SortableCommunityButton({ activeCommunityId, iconsByCommunity, unreadByCommunity, + canInvite, + onInvite, onSwitchCommunity, onMarkAllRead, onSetEditingCommunity, @@ -222,6 +225,8 @@ function SortableCommunityButton({ activeCommunityId: string | null; iconsByCommunity: Record; unreadByCommunity: Record; + canInvite: boolean; + onInvite: () => void; onSwitchCommunity: (id: string) => void; onMarkAllRead: (community: Community) => void; onSetEditingCommunity: (community: Community) => void; @@ -255,15 +260,21 @@ function SortableCommunityButton({ Mark all as read + { void writeTextToClipboard(community.relayUrl); }} > - Copy relay URL + Copy community URL - + {canInvite ? ( + + + Invite to community + + ) : null} onSetEditingCommunity(community)}> Community settings @@ -286,7 +297,7 @@ function SortableCommunityButton({ * Discord/Slack-style vertical rail of communities on the far left of the app. * Shows a mention-count badge for inactive communities (observed via * `useCommunityUnread`) and switches relays on click. Right-click opens a - * per-community menu: mark all as read, copy relay URL, community settings. + * per-community menu for read state, community URL, invites, and settings. * * Hidden entirely with a single community — a rail of one adds no value. */ @@ -305,7 +316,12 @@ export function CommunityRail({ ); const iconsByCommunity = useCommunityIcons(communities); const isFullscreen = useIsFullscreen(); - const { markAllChannelsRead } = useAppShell(); + const { markAllChannelsRead, onOpenSettings } = useAppShell(); + const myMembershipQuery = useMyRelayMembershipLookupQuery(); + const activeRole = myMembershipQuery.data?.membership?.role; + const canInviteToActiveCommunity = + onOpenSettings !== null && + (activeRole === "owner" || activeRole === "admin"); const [editingCommunity, setEditingCommunity] = React.useState(null); const [draggingId, setDraggingId] = React.useState(null); @@ -382,9 +398,13 @@ export function CommunityRail({ onOpenSettings?.("community-members")} onMarkAllRead={handleMarkAllRead} onSetEditingCommunity={setEditingCommunity} onSwitchCommunity={onSwitchCommunity} diff --git a/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx b/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx index a714d5e58a..5db10509bf 100644 --- a/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx +++ b/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx @@ -12,6 +12,8 @@ import { ProfilePopover } from "@/features/profile/ui/ProfilePopover"; import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji"; import type { Community } from "@/features/communities/types"; import { CommunitySwitcher } from "@/features/communities/ui/CommunitySwitcher"; +import { useMyRelayMembershipLookupQuery } from "@/features/community-members/hooks"; +import type { SettingsSection } from "@/features/settings/ui/SettingsPanels"; import type { PresenceStatus, Profile, UserStatus } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; @@ -19,7 +21,7 @@ type SidebarProfileCardProps = { activeCommunity: Community | null; isPresencePending?: boolean; onOpenAddCommunity: () => void; - onOpenSettings: (section?: "profile" | "appearance") => void; + onOpenSettings: (section?: SettingsSection) => void; onRemoveCommunity: (id: string) => void; onSendFeedback?: () => void; onSetPresenceStatus?: (status: PresenceStatus) => void; @@ -56,6 +58,9 @@ export function SidebarProfileCard({ communities, }: SidebarProfileCardProps) { const selfProfileCache = useSelfProfileCache(); + const myMembershipQuery = useMyRelayMembershipLookupQuery(); + const activeRole = myMembershipQuery.data?.membership?.role; + const canInvite = activeRole === "owner" || activeRole === "admin"; const [profilePopoverOpen, setProfilePopoverOpen] = React.useState(false); const profileCardRef = React.useRef(null); const toggleProfilePopover = React.useCallback( @@ -159,7 +164,15 @@ export function SidebarProfileCard({ communitySwitcherSlot={ { + setProfilePopoverOpen(false); + onOpenAddCommunity(); + }} + onInvite={() => { + setProfilePopoverOpen(false); + onOpenSettings("community-members"); + }} onRemoveCommunity={onRemoveCommunity} onSwitchCommunity={onSwitchCommunity} onUpdateCommunity={onUpdateCommunity} diff --git a/desktop/tests/e2e/add-community-screenshots.spec.ts b/desktop/tests/e2e/add-community-screenshots.spec.ts new file mode 100644 index 0000000000..f2aa49a20f --- /dev/null +++ b/desktop/tests/e2e/add-community-screenshots.spec.ts @@ -0,0 +1,69 @@ +import { test } from "@playwright/test"; + +import { waitForAnimations } from "../helpers/animations"; +import { installMockBridge } from "../helpers/bridge"; + +const OUTDIR = "test-results/add-community"; +const DEFAULT_MOCK_PUBKEY = "deadbeef".repeat(8); +const COMMUNITIES = [ + { + id: "ws-a", + name: "Alpha", + relayUrl: "ws://localhost:3000", + addedAt: "2026-01-01T00:00:00.000Z", + }, + { + id: "ws-b", + name: "Bravo", + relayUrl: "ws://localhost:3001", + addedAt: "2026-01-02T00:00:00.000Z", + }, +]; + +test.beforeEach(async ({ page }) => { + await page.addInitScript((communities) => { + window.localStorage.setItem( + "buzz-communities", + JSON.stringify(communities), + ); + window.localStorage.setItem("buzz-active-community-id", communities[0].id); + }, COMMUNITIES); + await installMockBridge( + page, + { + builderlabAuth: { + email: "owner@example.com", + expiresAt: "2099-01-01T00:00:00Z", + }, + builderlabIdentity: { pubkey_hex: DEFAULT_MOCK_PUBKEY }, + }, + { + skipCommunitySeed: true, + }, + ); + await page.goto("/"); + await page.getByTestId("community-rail-add").click(); +}); + +test("capture: add-community choices", async ({ page }) => { + const dialog = page.getByTestId("add-community-dialog"); + await dialog.waitFor(); + await waitForAnimations(page); + await dialog.screenshot({ path: `${OUTDIR}/01-choices.png` }); +}); + +test("capture: join an existing community", async ({ page }) => { + await page.getByTestId("add-community-join").click(); + const dialog = page.getByTestId("add-community-dialog"); + await page.getByLabel("Community URL or invite link").waitFor(); + await waitForAnimations(page); + await dialog.screenshot({ path: `${OUTDIR}/02-join.png` }); +}); + +test("capture: create a new community", async ({ page }) => { + await page.getByTestId("add-community-create").click(); + const dialog = page.getByTestId("add-community-dialog"); + await page.getByLabel("Community address").waitFor(); + await waitForAnimations(page); + await dialog.screenshot({ path: `${OUTDIR}/03-create.png` }); +}); diff --git a/desktop/tests/e2e/community-rail.spec.ts b/desktop/tests/e2e/community-rail.spec.ts index 2af7bb8783..b56270be46 100644 --- a/desktop/tests/e2e/community-rail.spec.ts +++ b/desktop/tests/e2e/community-rail.spec.ts @@ -60,6 +60,10 @@ test.describe("community rail", () => { // The active community is marked via aria-current. await expect(buttonA).toHaveAttribute("aria-current", "true"); await expect(buttonB).not.toHaveAttribute("aria-current", "true"); + await expect(buttonA.locator(":scope > span").first()).toHaveCSS( + "opacity", + "1", + ); // The add-community affordance lives at the bottom of the rail. await expect(page.getByTestId("community-rail-add")).toBeVisible(); @@ -97,6 +101,180 @@ test.describe("community rail", () => { .toBe(COMMUNITY_B.id); }); + test("lets community admins open invite controls from the rail", async ({ + page, + }) => { + await installMockBridge( + page, + { + relayRequiresMembership: true, + relayRole: "admin", + }, + { skipCommunitySeed: true }, + ); + await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id); + await page.goto("/"); + + await page + .getByTestId(`community-rail-button-${COMMUNITY_A.id}`) + .click({ button: "right" }); + const railMenu = page.getByTestId(`community-rail-menu-${COMMUNITY_A.id}`); + await expect(railMenu.getByRole("separator")).toHaveCount(1); + await expect(railMenu.getByRole("menuitem")).toHaveText([ + "Mark all as read", + "Copy community URL", + "Invite to community", + "Community settings", + ]); + await page.getByRole("menuitem", { name: "Invite to community" }).click(); + + await expect(page).toHaveURL(/#\/settings\?section=community-members$/); + await expect(page.getByTestId("settings-community-members")).toBeVisible(); + await expect( + page.getByRole("heading", { name: "Invites", exact: true }), + ).toBeVisible(); + await expect(page.getByTestId("community-icon-settings")).toHaveCount(0); + await expect( + page.getByTestId("community-invite-dialog-trigger"), + ).toBeVisible(); + await expect(page.getByTestId("community-invite-email-field")).toHaveCount( + 0, + ); + await page.getByTestId("community-invite-dialog-trigger").click(); + await expect(page.getByTestId("community-invite-email-field")).toHaveCount( + 0, + ); + await expect(page.getByTestId("copy-invite-link")).toBeVisible(); + }); + + test("hides rail invite controls from community members", async ({ + page, + }) => { + await installMockBridge( + page, + { + relayRequiresMembership: true, + relayRole: "member", + }, + { skipCommunitySeed: true }, + ); + await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id); + await page.goto("/"); + + await page + .getByTestId(`community-rail-button-${COMMUNITY_A.id}`) + .click({ button: "right" }); + + await expect( + page.getByRole("menuitem", { name: "Invite to community" }), + ).toHaveCount(0); + }); + + test("shows active community actions instead of another switcher in the profile menu", async ({ + page, + }) => { + await installMockBridge( + page, + { + relayRequiresMembership: true, + relayRole: "admin", + }, + { skipCommunitySeed: true }, + ); + await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id); + await page.goto("/"); + + await page.getByTestId("sidebar-profile-avatar-button").click(); + const communityTrigger = page.getByTestId("community-switcher"); + const feedback = page.getByTestId("profile-popover-send-feedback"); + const settings = page.getByTestId("profile-popover-settings"); + const communityBox = await communityTrigger.boundingBox(); + const feedbackBox = await feedback.boundingBox(); + const settingsBox = await settings.boundingBox(); + expect(communityBox).not.toBeNull(); + expect(feedbackBox).not.toBeNull(); + expect(settingsBox).not.toBeNull(); + expect(communityBox?.y).toBeLessThan(feedbackBox?.y ?? 0); + expect(feedbackBox?.y).toBeLessThan(settingsBox?.y ?? 0); + + await page.getByTestId("community-switcher").click(); + + const menu = page.getByRole("menu", { name: "Community actions" }); + await expect(menu).toBeVisible(); + await expect( + menu.getByRole("menuitem", { name: "Copy community URL" }), + ).toBeVisible(); + await expect( + menu.getByRole("menuitem", { name: "Copy community URL" }), + ).not.toBeFocused(); + await expect( + menu.getByRole("menuitem", { name: "Invite to community" }), + ).toBeVisible(); + await expect( + menu.getByRole("menuitem", { name: "Community settings" }), + ).toBeVisible(); + await expect( + menu.getByRole("menuitem", { name: "Add a community" }), + ).toBeVisible(); + await expect(menu.getByRole("separator")).toHaveCount(1); + await expect(menu.getByRole("menuitem", { name: "Alpha" })).toHaveCount(0); + await expect(menu.getByRole("menuitem", { name: "Bravo" })).toHaveCount(0); + + await menu.getByRole("menuitem", { name: "Invite to community" }).click(); + await expect(page).toHaveURL(/#\/settings\?section=community-members$/); + }); + + test("keeps profile community actions available to members without invite access", async ({ + page, + }) => { + await page + .context() + .grantPermissions(["clipboard-read", "clipboard-write"], { + origin: "http://127.0.0.1:4173", + }); + await installMockBridge( + page, + { + relayRequiresMembership: true, + relayRole: "member", + }, + { skipCommunitySeed: true }, + ); + await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id); + await page.goto("/"); + + await page.getByTestId("sidebar-profile-avatar-button").click(); + await page.getByTestId("community-switcher").click(); + + const menu = page.getByRole("menu", { name: "Community actions" }); + await expect( + menu.getByRole("menuitem", { name: "Invite to community" }), + ).toHaveCount(0); + await expect( + menu.getByRole("menuitem", { name: "Copy community URL" }), + ).toBeVisible(); + await expect( + menu.getByRole("menuitem", { name: "Add a community" }), + ).toBeVisible(); + + await menu.getByRole("menuitem", { name: "Copy community URL" }).click(); + await expect + .poll(() => + page.evaluate(() => { + return (window.__BUZZ_E2E_COMMAND_LOG__ ?? []).findLast( + (entry) => entry.command === "copy_text_to_clipboard", + )?.payload; + }), + ) + .toEqual({ text: COMMUNITY_A.relayUrl }); + + await page.getByTestId("community-switcher").click(); + await menu.getByRole("menuitem", { name: "Community settings" }).click(); + await expect( + page.getByRole("dialog", { name: "Edit Community" }), + ).toBeVisible(); + }); + test("switches the active community on click", async ({ page }) => { await installMockBridge(page, undefined, { skipCommunitySeed: true }); await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id); diff --git a/desktop/tests/e2e/deep-link-invite.spec.ts b/desktop/tests/e2e/deep-link-invite.spec.ts index 34f310d9ee..0b3ccb7b5a 100644 --- a/desktop/tests/e2e/deep-link-invite.spec.ts +++ b/desktop/tests/e2e/deep-link-invite.spec.ts @@ -179,26 +179,22 @@ test("add-community deep link opens one editable prefill and acknowledges the qu await page.goto("/"); await expect( - page.getByRole("heading", { name: "Add Community" }), + page.getByRole("heading", { name: "Join an existing community" }), ).toBeVisible(); - const relayInput = page.locator("#ws-relay-url"); - const nameInput = page.locator("#ws-name"); - await expect(relayInput).toHaveValue(PENDING_ADD_COMMUNITY_LINK.relayUrl); - await expect(nameInput).toHaveValue(PENDING_ADD_COMMUNITY_LINK.name); + const communityInput = page.getByLabel("Community URL or invite link"); + await expect(communityInput).toHaveValue(PENDING_ADD_COMMUNITY_LINK.relayUrl); + await expect(page.getByLabel("Name")).toHaveCount(0); - await nameInput.fill("Edited Team"); - await expect(nameInput).toHaveValue("Edited Team"); - - await page.getByRole("button", { name: "Cancel" }).click(); + await page.getByRole("button", { name: "Close" }).click(); await expect( - page.getByRole("heading", { name: "Add Community" }), + page.getByRole("heading", { name: "Join an existing community" }), ).toHaveCount(0); await page.getByTestId("sidebar-profile-avatar-button").click(); await page.getByTestId("community-switcher").click(); - await page.getByRole("menuitem", { name: "Add Community" }).click(); - await expect(relayInput).toHaveValue(""); - await expect(nameInput).toHaveValue(""); + await page.getByRole("menuitem", { name: "Add a community" }).click(); + await page.getByTestId("add-community-join").click(); + await expect(communityInput).toHaveValue(""); const acknowledgements = await page.evaluate(() => (window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter( @@ -228,10 +224,8 @@ test("queued add-community links open and acknowledge one at a time", async ({ ); await page.goto("/"); - const relayInput = page.locator("#ws-relay-url"); - const nameInput = page.locator("#ws-name"); - await expect(relayInput).toHaveValue(PENDING_ADD_COMMUNITY_LINK.relayUrl); - await expect(nameInput).toHaveValue(PENDING_ADD_COMMUNITY_LINK.name); + const communityInput = page.getByLabel("Community URL or invite link"); + await expect(communityInput).toHaveValue(PENDING_ADD_COMMUNITY_LINK.relayUrl); await expect .poll(() => @@ -246,12 +240,11 @@ test("queued add-community links open and acknowledge one at a time", async ({ ) .toEqual([{ id: PENDING_ADD_COMMUNITY_LINK.id }]); - await page.getByRole("button", { name: "Cancel" }).click(); + await page.getByRole("button", { name: "Close" }).click(); - await expect(relayInput).toHaveValue( + await expect(communityInput).toHaveValue( SECOND_PENDING_ADD_COMMUNITY_LINK.relayUrl, ); - await expect(nameInput).toHaveValue(SECOND_PENDING_ADD_COMMUNITY_LINK.name); await expect .poll(() => page.evaluate(() => diff --git a/desktop/tests/e2e/hosted-communities-settings-screenshots.spec.ts b/desktop/tests/e2e/hosted-communities-settings-screenshots.spec.ts new file mode 100644 index 0000000000..3ec6dc01c3 --- /dev/null +++ b/desktop/tests/e2e/hosted-communities-settings-screenshots.spec.ts @@ -0,0 +1,96 @@ +import { expect, test } from "@playwright/test"; + +import { waitForAnimations } from "../helpers/animations"; +import { installMockBridge } from "../helpers/bridge"; +import { openSettings } from "../helpers/settings"; + +const OUTDIR = "test-results/hosted-communities"; +const DEFAULT_MOCK_PUBKEY = "deadbeef".repeat(8); + +test.beforeEach(async ({ page }) => { + await installMockBridge(page, { + builderlabAuth: { + email: "owner@example.com", + expiresAt: "2099-01-01T00:00:00Z", + }, + builderlabIdentity: { pubkey_hex: DEFAULT_MOCK_PUBKEY }, + builderlabCommunities: [ + { + id: "active-community", + name: "E2E Test", + normalized_host: "localhost:3000", + }, + { + id: "other-community", + name: "Design studio", + normalized_host: "design-studio.communities.buzz.xyz", + }, + ], + }); + await page.goto("/"); + await openSettings(page, "hosted-communities"); +}); + +test("capture: community icon picker sits beside its hosted community", async ({ + page, +}) => { + const activeRow = page + .getByTestId("hosted-community-row") + .filter({ hasText: "E2E Test" }); + const otherRow = page + .getByTestId("hosted-community-row") + .filter({ hasText: "Design studio" }); + + await expect(activeRow.getByTestId("community-icon-settings")).toBeVisible(); + await expect(otherRow.getByTestId("community-icon-settings")).toHaveCount(0); + + const iconDataUrl = `data:image/svg+xml,${encodeURIComponent( + '😅', + )}`; + await activeRow.getByLabel("Add community icon").click(); + + const picker = page.getByRole("group", { name: "Community icon picker" }); + await expect(picker).toBeVisible(); + await expect(page.getByRole("tab", { name: "Image" })).toBeVisible(); + await expect(page.getByRole("tab", { name: "Emoji" })).toBeVisible(); + await page.getByPlaceholder("Paste a URL").fill(iconDataUrl); + await page.getByRole("button", { name: "Apply" }).click(); + + const icon = activeRow.getByRole("img", { name: /community icon$/i }); + await expect(icon).toBeVisible(); + const maskImage = await activeRow + .getByTestId("community-icon-mask") + .evaluate((element) => getComputedStyle(element).webkitMaskImage); + expect(maskImage).toContain("radial-gradient"); + await expect(page.getByTestId("community-icon-save")).toHaveCount(0); + await expect + .poll(() => + page.evaluate(() => + window.__BUZZ_E2E_SIGNED_EVENTS__?.some( + (event) => + event.kind === 9033 && + event.tags.some( + (tag) => tag[0] === "icon" && tag[1]?.startsWith("data:image/"), + ), + ), + ), + ) + .toBe(true); + + const iconBox = await activeRow + .getByTestId("community-icon-settings") + .boundingBox(); + const nameBox = await activeRow + .getByText("E2E Test", { exact: true }) + .boundingBox(); + expect(iconBox).not.toBeNull(); + expect(nameBox).not.toBeNull(); + expect(iconBox?.x ?? Number.POSITIVE_INFINITY).toBeLessThan( + nameBox?.x ?? Number.NEGATIVE_INFINITY, + ); + + await waitForAnimations(page); + await activeRow.screenshot({ + path: `${OUTDIR}/01-community-icon-row.png`, + }); +}); diff --git a/desktop/tests/e2e/invite-qr-download.spec.ts b/desktop/tests/e2e/invite-link-copy.spec.ts similarity index 57% rename from desktop/tests/e2e/invite-qr-download.spec.ts rename to desktop/tests/e2e/invite-link-copy.spec.ts index 357d72f064..94bc8e26b0 100644 --- a/desktop/tests/e2e/invite-qr-download.spec.ts +++ b/desktop/tests/e2e/invite-link-copy.spec.ts @@ -4,6 +4,9 @@ import { installMockBridge } from "../helpers/bridge"; import { openSettings } from "../helpers/settings"; test.beforeEach(async ({ page }) => { + await page.context().grantPermissions(["clipboard-read", "clipboard-write"], { + origin: "http://127.0.0.1:4173", + }); await installMockBridge(page, { relayRequiresMembership: true, }); @@ -20,20 +23,18 @@ test.beforeEach(async ({ page }) => { }); }); -test("invite QR reuses the media menu and saves its PNG", async ({ page }) => { +test("copies a freshly minted invite link without showing a URL or QR code", async ({ + page, +}) => { await page.goto("/"); await openSettings(page, "community-members"); await expect(page.getByTestId("settings-community-members")).toBeVisible(); - await page.getByTestId("create-invite-link").click(); - const qrCode = page.getByTestId("invite-link-qr-code"); - await expect(qrCode).toBeVisible(); - - await qrCode.click({ button: "right", position: { x: 32, y: 32 } }); - const menu = page.locator("[data-invite-qr-context-menu]"); - await expect(menu).toBeVisible(); - await menu.getByRole("button", { name: "Download image" }).click(); - await expect(menu).not.toBeVisible(); + await page.getByTestId("community-invite-dialog-trigger").click(); + await expect(page.getByTestId("invite-link-url")).toHaveCount(0); + await expect(page.getByTestId("invite-link-qr-code")).toHaveCount(0); + await page.getByTestId("copy-invite-link").click(); + await expect(page.getByTestId("copy-invite-link")).toContainText("Copied"); const payload = await page.evaluate(() => { const log = ( @@ -44,9 +45,11 @@ test("invite QR reuses the media menu and saves its PNG", async ({ page }) => { }>; } ).__BUZZ_E2E_COMMAND_LOG__; - return log?.find(({ command }) => command === "save_png_data_url")?.payload; + return log?.findLast(({ command }) => command === "copy_text_to_clipboard") + ?.payload; }); - expect(payload?.filename).toBe("buzz-community-invite.png"); - expect(payload?.dataUrl).toMatch(/^data:image\/png;base64,/); + expect(payload).toEqual({ + text: "buzz://join?relay=wss%3A%2F%2Frelay.example.com&code=qr-download-test", + }); }); diff --git a/desktop/tests/e2e/invites-settings-screenshots.spec.ts b/desktop/tests/e2e/invites-settings-screenshots.spec.ts new file mode 100644 index 0000000000..654aec56fe --- /dev/null +++ b/desktop/tests/e2e/invites-settings-screenshots.spec.ts @@ -0,0 +1,100 @@ +import { expect, test } from "@playwright/test"; + +import { waitForAnimations } from "../helpers/animations"; +import { installMockBridge } from "../helpers/bridge"; +import { openSettings } from "../helpers/settings"; + +const OUTDIR = "test-results/invites-settings"; + +test.beforeEach(async ({ page }) => { + await installMockBridge(page, { + relayRequiresMembership: true, + relayRole: "owner", + }); + await page.route("**/api/invites", async (route) => { + await route.fulfill({ + contentType: "application/json", + json: { + code: "community-email-test", + expires_at: Math.floor(Date.now() / 1000) + 3 * 86_400, + url: "https://alpha.example.com/invite/community-email-test", + }, + status: 200, + }); + }); + await page.goto("/"); + await openSettings(page, "community-members"); +}); + +test("capture: consolidated invites settings", async ({ page }) => { + const panel = page.getByTestId("settings-panel-community-members"); + + await expect( + page.getByTestId("settings-nav-community-members"), + ).toContainText("Invites"); + await expect( + page.getByRole("heading", { name: "Invites", exact: true }), + ).toBeVisible(); + await expect(page.getByTestId("community-icon-settings")).toHaveCount(0); + await expect( + page.getByTestId("community-invite-dialog-trigger"), + ).toBeVisible(); + await expect(page.getByTestId("community-invite-email-field")).toHaveCount(0); + await expect(page.getByTestId("copy-invite-link")).toHaveCount(0); + await expect(page.getByText("alice", { exact: true })).toBeVisible(); + await expect(page.getByText("bob", { exact: true })).toBeVisible(); + await expect( + page.getByText("Manage roles or remove access.", { exact: true }), + ).toHaveCount(0); + await expect( + page.getByText("People who use the link join as members."), + ).toHaveCount(0); + await expect(page.getByTestId("community-icon-save")).toHaveCount(0); + + const aliceName = page.getByText("alice", { exact: true }); + const aliceRow = page + .locator('[data-testid^="relay-member-row-"]') + .filter({ has: aliceName }); + const aliceNpub = aliceRow.locator('[data-testid^="relay-member-npub-"]'); + await expect(aliceName).toHaveCSS("opacity", "1"); + await expect(aliceNpub).toHaveCSS("opacity", "0"); + await aliceRow.hover(); + await expect(aliceName).toHaveCSS("opacity", "0"); + await expect(aliceNpub).toHaveCSS("opacity", "1"); + await page.mouse.move(0, 0); + + await waitForAnimations(page); + await panel.screenshot({ path: `${OUTDIR}/01-invites-settings.png` }); +}); + +test("capture: share-style community invite dialog", async ({ page }) => { + await page.getByTestId("community-invite-dialog-trigger").click(); + + const dialog = page.getByTestId("community-invite-dialog"); + await expect(dialog).toBeVisible(); + await expect( + dialog.getByRole("heading", { name: "Invite to community" }), + ).toBeVisible(); + await expect(page.getByTestId("community-invite-email-field")).toHaveCount(0); + await expect(page.getByPlaceholder("Type an email address")).toHaveCount(0); + await expect( + dialog.getByRole("heading", { name: "Share with a link" }), + ).toBeVisible(); + await expect(page.getByTestId("copy-invite-link")).toHaveText("Copy link"); + await expect(page.getByTestId("invite-link-qr-code")).toHaveCount(0); + await expect(page.getByTestId("invite-link-url")).toHaveCount(0); + + const expiryTrigger = page.getByTestId("invite-link-ttl-trigger"); + await expect(expiryTrigger).toHaveText("3 days"); + await expiryTrigger.click(); + await expect( + page.getByRole("menuitemradio", { name: "1 day" }), + ).toBeVisible(); + await expect( + page.getByRole("menuitemradio", { name: "30 days" }), + ).toBeVisible(); + await page.keyboard.press("Escape"); + + await waitForAnimations(page); + await dialog.screenshot({ path: `${OUTDIR}/02-invite-dialog.png` }); +}); diff --git a/desktop/tests/e2e/sidebar.spec.ts b/desktop/tests/e2e/sidebar.spec.ts index 7c484672c5..b139f23142 100644 --- a/desktop/tests/e2e/sidebar.spec.ts +++ b/desktop/tests/e2e/sidebar.spec.ts @@ -65,7 +65,42 @@ async function dragSidebarRail(page: Page, deltaX: number) { await page.mouse.up(); } -test("automatically shows relay join requirements near the relay URL", async ({ +test("add community starts with create and join choices", async ({ page }) => { + await installMockBridge(page, {}); + await page.goto("/"); + + await page.getByTestId("sidebar-profile-card").click(); + await page.getByText("Add a community", { exact: true }).click(); + + await expect( + page.getByRole("heading", { name: "Add community" }), + ).toBeVisible(); + await expect(page.getByTestId("add-community-create")).toContainText( + "Create a new community", + ); + await expect(page.getByTestId("add-community-join")).toContainText( + "Join an existing community", + ); + await expect(page.getByLabel("API Token")).toHaveCount(0); + await expect(page.getByLabel("Repos Directory")).toHaveCount(0); + + await page.getByTestId("add-community-join").click(); + await expect( + page.getByRole("heading", { name: "Join an existing community" }), + ).toBeVisible(); + await expect(page.getByLabel("Community URL or invite link")).toBeVisible(); + await page.getByTestId("add-community-back").click(); + + await page.getByTestId("add-community-create").click(); + await expect( + page.getByRole("heading", { name: "Create a new community" }), + ).toBeVisible(); + await page.getByRole("button", { name: "Continue to Builderlab" }).click(); + await page.getByRole("button", { name: "Connect and continue" }).click(); + await expect(page.getByLabel("Community address")).toBeVisible(); +}); + +test("automatically shows community join requirements near the community URL", async ({ page, }) => { await page.route( @@ -88,8 +123,11 @@ test("automatically shows relay join requirements near the relay URL", async ({ await page.goto("/"); await page.getByTestId("sidebar-profile-card").click(); - await page.getByText("Add Community", { exact: true }).click(); - await page.getByLabel("Relay URL").fill("wss://policy.example.com"); + await page.getByText("Add a community", { exact: true }).click(); + await page.getByTestId("add-community-join").click(); + await page + .getByLabel("Community URL or invite link") + .fill("https://policy.example.com/invite/community-code"); const ageConfirmation = page.getByLabel("I am 18 years of age or older."); const agreementConfirmation = page.getByLabel( @@ -102,21 +140,22 @@ test("automatically shows relay join requirements near the relay URL", async ({ ).toHaveCount(0); await expect(page.getByText(/By continuing, you agree/)).toHaveCount(0); - const addCommunityButton = page.getByRole("button", { - name: "Add Community", - }); - await expect(addCommunityButton).toBeDisabled(); + const joinCommunityButton = page.getByTestId("invite-redeem-submit"); + await expect(joinCommunityButton).toBeDisabled(); await ageConfirmation.check(); await expect(ageConfirmation.locator("svg path")).toBeVisible(); - await expect(addCommunityButton).toBeDisabled(); + await expect(joinCommunityButton).toBeDisabled(); await agreementConfirmation.check(); - await expect(addCommunityButton).toBeEnabled(); + await expect(joinCommunityButton).toBeEnabled(); + await expect(joinCommunityButton).toHaveText("Accept and join"); const consentBox = await agreementConfirmation.boundingBox(); - const reposInput = await page.locator("#ws-repos-dir").boundingBox(); - const addButtonBox = await addCommunityButton.boundingBox(); - expect(consentBox?.y).toBeGreaterThan(reposInput?.y ?? Number.MAX_VALUE); - expect(consentBox?.y).toBeLessThan(addButtonBox?.y ?? 0); + const communityInput = await page + .getByLabel("Community URL or invite link") + .boundingBox(); + const joinButtonBox = await joinCommunityButton.boundingBox(); + expect(consentBox?.y).toBeGreaterThan(communityInput?.y ?? Number.MAX_VALUE); + expect(consentBox?.y).toBeLessThan(joinButtonBox?.y ?? 0); }); test("leaving a channel from the context menu never freezes the app", async ({ diff --git a/desktop/tests/helpers/settings.ts b/desktop/tests/helpers/settings.ts index 301e622719..a63b52453e 100644 --- a/desktop/tests/helpers/settings.ts +++ b/desktop/tests/helpers/settings.ts @@ -8,6 +8,7 @@ type SettingsSection = | "compute" | "appearance" | "shortcuts" + | "hosted-communities" | "tokens" | "community-members" | "mobile" From b572a0b1f3ad706b6df21ced4b3057afeeda5cec Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Fri, 24 Jul 2026 14:56:29 -0700 Subject: [PATCH 2/5] Address community join review feedback --- .../communities/ui/AddCommunityDialog.tsx | 7 +- .../onboarding/ui/InviteRedeemForm.tsx | 136 +++++++++++++++--- .../src/features/settings/ui/SettingsView.tsx | 8 +- desktop/src/shared/api/relayMembers.test.mjs | 20 +-- desktop/src/shared/api/relayMembers.ts | 6 +- desktop/tests/e2e/sidebar.spec.ts | 60 +++++++- 6 files changed, 195 insertions(+), 42 deletions(-) diff --git a/desktop/src/features/communities/ui/AddCommunityDialog.tsx b/desktop/src/features/communities/ui/AddCommunityDialog.tsx index a83456652c..e4f105471a 100644 --- a/desktop/src/features/communities/ui/AddCommunityDialog.tsx +++ b/desktop/src/features/communities/ui/AddCommunityDialog.tsx @@ -55,10 +55,12 @@ export function AddCommunityDialog({ relayUrl, inviteCode, policyReceipt, + token, }: { relayUrl: string; inviteCode?: string; policyReceipt?: string; + token?: string; }) => { const started = communityOnboarding.start({ source: "add-community", @@ -66,6 +68,7 @@ export function AddCommunityDialog({ inviteCode, communityName: prefill?.name, policyReceipt, + token, }); if (!started) { setJoinError( @@ -182,7 +185,9 @@ export function AddCommunityDialog({ setJoinError(null); setMode("choose"); }} - onConnect={(relayUrl) => startConnection({ relayUrl })} + onConnect={(relayUrl, token) => + startConnection({ relayUrl, token }) + } onRedeem={(relayUrl, inviteCode, policyReceipt) => startConnection({ relayUrl, inviteCode, policyReceipt }) } diff --git a/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx b/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx index e6a88d4a09..973c857fe7 100644 --- a/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx +++ b/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx @@ -47,7 +47,7 @@ type InviteRedeemFormProps = { initialValue?: string; isRedeeming: boolean; onCancel: () => void; - onConnect?: (relayWsUrl: string) => void; + onConnect?: (relayWsUrl: string, token?: string) => void; onRedeem: (relayWsUrl: string, code: string, policyReceipt?: string) => void; placeholder?: string; variant?: "add-community" | "default" | "onboarding-spotlight"; @@ -69,10 +69,12 @@ export function InviteRedeemForm({ const [bareCodeRelayUrl, setBareCodeRelayUrl] = React.useState( defaultRelayUrl ?? "", ); + const [apiToken, setApiToken] = React.useState(""); + const [showApiToken, setShowApiToken] = React.useState(false); const [joinPolicy, setJoinPolicy] = React.useState(null); - const [policyInvite, setPolicyInvite] = React.useState<{ + const [policyTarget, setPolicyTarget] = React.useState<{ relayWsUrl: string; - code: string; + code?: string; } | null>(null); const [ageConfirmed, setAgeConfirmed] = React.useState(false); const [agreementConfirmed, setAgreementConfirmed] = React.useState(false); @@ -93,22 +95,23 @@ export function InviteRedeemForm({ const needsRelayField = isBareCode && defaultRelayUrl !== undefined; React.useEffect(() => { - if (!parsedInvite) return; - - const relayWsUrl = - "relayWsUrl" in parsedInvite && - typeof parsedInvite.relayWsUrl === "string" + const relayWsUrl = normalizedRelayUrl + ? normalizedRelayUrl + : parsedInvite && "relayWsUrl" in parsedInvite ? parsedInvite.relayWsUrl - : bareCodeRelayUrl.trim(); + : parsedInvite + ? bareCodeRelayUrl.trim() + : ""; if (!relayWsUrl || !isJoinPolicyDiscoveryCandidate(relayWsUrl)) return; + const code = parsedInvite?.code; let cancelled = false; const timeoutId = window.setTimeout(() => { void getJoinPolicy(relayWsUrl) .then((policy) => { if (cancelled || !policy) return; setJoinPolicy(policy); - setPolicyInvite({ relayWsUrl, code: parsedInvite.code }); + setPolicyTarget({ relayWsUrl, code }); setAgeConfirmed(false); setAgreementConfirmed(false); setPolicyError(null); @@ -123,7 +126,7 @@ export function InviteRedeemForm({ cancelled = true; window.clearTimeout(timeoutId); }; - }, [bareCodeRelayUrl, parsedInvite]); + }, [bareCodeRelayUrl, normalizedRelayUrl, parsedInvite]); const canSubmit = (parsedInvite !== null && @@ -139,7 +142,46 @@ export function InviteRedeemForm({ async (event: React.FormEvent) => { event.preventDefault(); if (normalizedRelayUrl) { - onConnect?.(normalizedRelayUrl); + setPolicyError(null); + setIsLoadingPolicy(true); + try { + const policy = await getJoinPolicy(normalizedRelayUrl); + if (!policy) { + onConnect?.(normalizedRelayUrl, apiToken.trim() || undefined); + return; + } + + if ( + !joinPolicy || + joinPolicy.version !== policy.version || + policyTarget?.relayWsUrl !== normalizedRelayUrl || + policyTarget.code !== undefined + ) { + setJoinPolicy(policy); + setPolicyTarget({ relayWsUrl: normalizedRelayUrl }); + setAgeConfirmed(false); + setAgreementConfirmed(false); + return; + } + + if (policy.ageAttestationRequired && !ageConfirmed) { + setPolicyError("Confirm that you are at least 18 years old."); + return; + } + if ( + (policy.termsMarkdown || policy.privacyMarkdown) && + !agreementConfirmed + ) { + setPolicyError("Agree to the Terms of Service and Privacy Policy."); + return; + } + + onConnect?.(normalizedRelayUrl, apiToken.trim() || undefined); + } catch (policyFetchError) { + setPolicyError(inviteErrorMessage(policyFetchError)); + } finally { + setIsLoadingPolicy(false); + } return; } if (!parsedInvite) return; @@ -162,11 +204,11 @@ export function InviteRedeemForm({ if ( !joinPolicy || joinPolicy.version !== policy.version || - policyInvite?.relayWsUrl !== relayWsUrl || - policyInvite.code !== parsedInvite.code + policyTarget?.relayWsUrl !== relayWsUrl || + policyTarget.code !== parsedInvite.code ) { setJoinPolicy(policy); - setPolicyInvite({ relayWsUrl, code: parsedInvite.code }); + setPolicyTarget({ relayWsUrl, code: parsedInvite.code }); setAgeConfirmed(false); setAgreementConfirmed(false); return; @@ -200,13 +242,14 @@ export function InviteRedeemForm({ [ ageConfirmed, agreementConfirmed, + apiToken, bareCodeRelayUrl, joinPolicy, - onRedeem, normalizedRelayUrl, onConnect, + onRedeem, parsedInvite, - policyInvite, + policyTarget, ], ); @@ -215,7 +258,7 @@ export function InviteRedeemForm({ ) => { setInviteInput(event.target.value); setJoinPolicy(null); - setPolicyInvite(null); + setPolicyTarget(null); setAgeConfirmed(false); setAgreementConfirmed(false); setPolicyError(null); @@ -226,7 +269,7 @@ export function InviteRedeemForm({ ) => { setBareCodeRelayUrl(event.target.value); setJoinPolicy(null); - setPolicyInvite(null); + setPolicyTarget(null); setAgeConfirmed(false); setAgreementConfirmed(false); setPolicyError(null); @@ -416,6 +459,55 @@ export function InviteRedeemForm({
) : null} + {isAddCommunity && normalizedRelayUrl ? ( + showApiToken ? ( +
+
+ + +
+ setApiToken(event.target.value)} + placeholder="buzz_…" + type="password" + value={apiToken} + /> +
+ ) : ( + + ) + ) : null} + {policyError ? (

{policyError}

) : null} @@ -425,7 +517,7 @@ export function InviteRedeemForm({ ) : null} - {joinPolicy && policyInvite ? ( + {joinPolicy && policyTarget ? ( ) : null} diff --git a/desktop/src/features/settings/ui/SettingsView.tsx b/desktop/src/features/settings/ui/SettingsView.tsx index 8cba34f23a..20389b420a 100644 --- a/desktop/src/features/settings/ui/SettingsView.tsx +++ b/desktop/src/features/settings/ui/SettingsView.tsx @@ -4,7 +4,7 @@ import { AlertCircle, ArrowLeft, LoaderCircle, RefreshCw } from "lucide-react"; import { useMyRelayMembershipLookupQuery } from "@/features/community-members/hooks"; import { - canEditCommunityProfile, + canManageCommunityMembers, shouldWarnMissingMembershipSnapshot, } from "@/shared/api/relayMembers"; import { getFeature } from "@/shared/features/manifest"; @@ -139,10 +139,10 @@ export function SettingsView({ return false; } } - // Closed relays require a discovered admin/owner role. Open relays have - // no NIP-43 snapshot, so expose only the relay-authorized profile editor. + // Invites and member management require a discovered owner/admin role. + // Open relays have no membership snapshot or invite controls. if (s.value === "community-members") { - return canEditCommunityProfile(myMembershipQuery.data); + return canManageCommunityMembers(myMembershipQuery.data); } return true; }); diff --git a/desktop/src/shared/api/relayMembers.test.mjs b/desktop/src/shared/api/relayMembers.test.mjs index 0f54f9b098..50e4d715fe 100644 --- a/desktop/src/shared/api/relayMembers.test.mjs +++ b/desktop/src/shared/api/relayMembers.test.mjs @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { - canEditCommunityProfile, + canManageCommunityMembers, loadRelayMembershipLookup, shouldWarnMissingMembershipSnapshot, } from "./relayMembers.ts"; @@ -36,21 +36,21 @@ test("membership relays request their membership snapshot", async () => { assert.equal(lookup.membership?.role, "admin"); }); -test("community profile editing is visible on open relays", () => { +test("community member management is hidden on open relays", () => { assert.equal( - canEditCommunityProfile({ + canManageCommunityMembers({ snapshotFound: false, membershipRequired: false, membership: null, }), - true, + false, ); }); -test("community profile editing is visible to closed-relay admins and owners", () => { +test("community member management is visible to closed-relay admins and owners", () => { for (const role of ["admin", "owner"]) { assert.equal( - canEditCommunityProfile({ + canManageCommunityMembers({ snapshotFound: true, membershipRequired: true, membership: { pubkey: "a".repeat(64), role }, @@ -60,10 +60,10 @@ test("community profile editing is visible to closed-relay admins and owners", ( } }); -test("community profile editing stays hidden while loading and from closed-relay non-admins", () => { - assert.equal(canEditCommunityProfile(undefined), false); +test("community member management stays hidden while loading and from closed-relay non-admins", () => { + assert.equal(canManageCommunityMembers(undefined), false); assert.equal( - canEditCommunityProfile({ + canManageCommunityMembers({ snapshotFound: true, membershipRequired: true, membership: { pubkey: "a".repeat(64), role: "member" }, @@ -71,7 +71,7 @@ test("community profile editing stays hidden while loading and from closed-relay false, ); assert.equal( - canEditCommunityProfile({ + canManageCommunityMembers({ snapshotFound: true, membershipRequired: true, membership: null, diff --git a/desktop/src/shared/api/relayMembers.ts b/desktop/src/shared/api/relayMembers.ts index d449d4fc7e..e88d84488c 100644 --- a/desktop/src/shared/api/relayMembers.ts +++ b/desktop/src/shared/api/relayMembers.ts @@ -38,13 +38,11 @@ export type RelayMembershipLookup = { membership: RelayMember | null; }; -export function canEditCommunityProfile( +export function canManageCommunityMembers( lookup: RelayMembershipLookup | undefined, ): boolean { const role = lookup?.membership?.role; - return ( - lookup?.membershipRequired === false || role === "owner" || role === "admin" - ); + return role === "owner" || role === "admin"; } export function shouldWarnMissingMembershipSnapshot( diff --git a/desktop/tests/e2e/sidebar.spec.ts b/desktop/tests/e2e/sidebar.spec.ts index b139f23142..802c11a8fa 100644 --- a/desktop/tests/e2e/sidebar.spec.ts +++ b/desktop/tests/e2e/sidebar.spec.ts @@ -1,8 +1,11 @@ import { expect, test, type Page } from "@playwright/test"; import { installMockBridge } from "../helpers/bridge"; +import { openSettings } from "../helpers/settings"; const SIDEBAR_WIDTH_STORAGE_KEY = "buzz-sidebar-width"; +const COMMUNITY_ONBOARDING_STORAGE_KEY = + "buzz-community-onboarding-transaction.v1"; const DEFAULT_SIDEBAR_WIDTH = 300; test.beforeEach(async ({ page }) => { @@ -127,7 +130,7 @@ test("automatically shows community join requirements near the community URL", a await page.getByTestId("add-community-join").click(); await page .getByLabel("Community URL or invite link") - .fill("https://policy.example.com/invite/community-code"); + .fill("https://policy.example.com"); const ageConfirmation = page.getByLabel("I am 18 years of age or older."); const agreementConfirmation = page.getByLabel( @@ -156,6 +159,61 @@ test("automatically shows community join requirements near the community URL", a const joinButtonBox = await joinCommunityButton.boundingBox(); expect(consentBox?.y).toBeGreaterThan(communityInput?.y ?? Number.MAX_VALUE); expect(consentBox?.y).toBeLessThan(joinButtonBox?.y ?? 0); + + await joinCommunityButton.click(); + await expect + .poll(() => + page.evaluate( + (key) => window.localStorage.getItem(key), + COMMUNITY_ONBOARDING_STORAGE_KEY, + ), + ) + .toContain('"relayUrl":"wss://policy.example.com"'); +}); + +test("supports API tokens without cluttering the default join form", async ({ + page, +}) => { + await installMockBridge(page, { applyCommunityDelayMs: 1_000 }); + await page.route( + "https://token.example.com/api/join-policy", + async (route) => { + await route.fulfill({ status: 404 }); + }, + ); + await page.goto("/"); + + await page.getByTestId("sidebar-profile-card").click(); + await page.getByText("Add a community", { exact: true }).click(); + await page.getByTestId("add-community-join").click(); + + await expect(page.getByLabel("API token")).toHaveCount(0); + await expect(page.getByTestId("community-api-token-reveal")).toHaveCount(0); + + await page + .getByLabel("Community URL or invite link") + .fill("https://token.example.com"); + await page.getByTestId("community-api-token-reveal").click(); + await page.getByLabel("API token").fill("buzz_secret"); + await page.getByTestId("invite-redeem-submit").click(); + + await expect + .poll(() => + page.evaluate( + (key) => window.localStorage.getItem(key), + COMMUNITY_ONBOARDING_STORAGE_KEY, + ), + ) + .toContain('"token":"buzz_secret"'); +}); + +test("hides Invites settings on open relays", async ({ page }) => { + await page.goto("/"); + await openSettings(page); + + await expect(page.getByTestId("settings-nav-community-members")).toHaveCount( + 0, + ); }); test("leaving a channel from the context menu never freezes the app", async ({ From 21bf06f34b9a44464ddb8e99d89dfc9a6892e342 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Fri, 24 Jul 2026 15:20:15 -0700 Subject: [PATCH 3/5] Fix community join follow-up --- .../features/communities/relayProbe.test.mjs | 7 +++- .../src/features/communities/relayProbe.ts | 13 +++++++ .../onboarding/ui/InviteRedeemForm.tsx | 28 ++++++++----- desktop/tests/e2e/onboarding.spec.ts | 12 ++++++ desktop/tests/e2e/sidebar.spec.ts | 39 +++++++++++++------ 5 files changed, 76 insertions(+), 23 deletions(-) diff --git a/desktop/src/features/communities/relayProbe.test.mjs b/desktop/src/features/communities/relayProbe.test.mjs index 0184278f58..6eb0b3b135 100644 --- a/desktop/src/features/communities/relayProbe.test.mjs +++ b/desktop/src/features/communities/relayProbe.test.mjs @@ -76,8 +76,11 @@ test("normalizeRelayUrl_empty_returns_null", () => { assert.equal(normalizeRelayUrl(" "), null); }); -test("normalizeRelayUrl_bare_hostname_returns_null", () => { - assert.equal(normalizeRelayUrl("relay.example.com"), null); +test("normalizeRelayUrl_bare_hostname_adds_secure_scheme", () => { + assert.equal( + normalizeRelayUrl("relay.example.com"), + "wss://relay.example.com", + ); }); test("normalizeRelayUrl_ftp_scheme_returns_null", () => { diff --git a/desktop/src/features/communities/relayProbe.ts b/desktop/src/features/communities/relayProbe.ts index 7e9c2fe69a..efa5bdf281 100644 --- a/desktop/src/features/communities/relayProbe.ts +++ b/desktop/src/features/communities/relayProbe.ts @@ -37,6 +37,19 @@ export function normalizeRelayUrl(input: string): string | null { } } + // Match the legacy add/edit community forms: a scheme-less host is assumed + // to be a secure relay. Validation still rejects whitespace and malformed + // values instead of blindly persisting the prefixed string. + if (!trimmed.includes("://")) { + const wsUrl = `wss://${trimmed}`; + try { + new URL(wsUrl); + return wsUrl; + } catch { + return null; + } + } + return null; } diff --git a/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx b/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx index 973c857fe7..78929cde16 100644 --- a/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx +++ b/desktop/src/features/onboarding/ui/InviteRedeemForm.tsx @@ -4,6 +4,7 @@ import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { inviteErrorMessage, parseInviteInput, + type ParsedInvite, } from "@/shared/api/inviteHelpers"; import { acceptJoinPolicy, @@ -35,6 +36,12 @@ const SPOTLIGHT_OVERFLOW_FADE = { "linear-gradient(to right, transparent, black 2rem, black calc(100% - 2rem), transparent)", }; +function hasInviteRelay( + invite: ParsedInvite, +): invite is Extract { + return "relayWsUrl" in invite; +} + type InviteRedeemFormProps = { /** * Pre-fill and expose a relay URL field for bare-code inputs. @@ -87,17 +94,21 @@ export function InviteRedeemForm({ [inviteInput], ); const normalizedRelayUrl = React.useMemo( - () => (onConnect && !parsed ? normalizeRelayUrl(inviteInput) : null), - [inviteInput, onConnect, parsed], + () => + onConnect && + (!parsed || (variant === "add-community" && !hasInviteRelay(parsed))) + ? normalizeRelayUrl(inviteInput) + : null, + [inviteInput, onConnect, parsed, variant], ); - const parsedInvite = parsed; - const isBareCode = parsedInvite !== null && !("relayWsUrl" in parsedInvite); + const parsedInvite: ParsedInvite | null = normalizedRelayUrl ? null : parsed; + const isBareCode = parsedInvite !== null && !hasInviteRelay(parsedInvite); const needsRelayField = isBareCode && defaultRelayUrl !== undefined; React.useEffect(() => { const relayWsUrl = normalizedRelayUrl ? normalizedRelayUrl - : parsedInvite && "relayWsUrl" in parsedInvite + : parsedInvite && hasInviteRelay(parsedInvite) ? parsedInvite.relayWsUrl : parsedInvite ? bareCodeRelayUrl.trim() @@ -186,10 +197,9 @@ export function InviteRedeemForm({ } if (!parsedInvite) return; - const relayWsUrl = - "relayWsUrl" in parsedInvite - ? parsedInvite.relayWsUrl - : bareCodeRelayUrl.trim(); + const relayWsUrl = hasInviteRelay(parsedInvite) + ? parsedInvite.relayWsUrl + : bareCodeRelayUrl.trim(); if (!relayWsUrl) return; setPolicyError(null); diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index 0c4d630994..bfbfc6f063 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -1231,6 +1231,12 @@ test("first-community shows the scenario cards for localhost", async ({ }); test("first-community direct join reaches profile", async ({ page }) => { + await page.route( + "https://onboarding.communities.buzz.xyz/api/join-policy", + async (route) => { + await route.fulfill({ status: 404 }); + }, + ); await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await page.addInitScript((pubkey) => { window.localStorage.setItem( @@ -1283,6 +1289,12 @@ test("first-community direct join reaches profile", async ({ page }) => { test("first-community direct join cancel returns to request access", async ({ page, }) => { + await page.route( + "https://onboarding.communities.buzz.xyz/api/join-policy", + async (route) => { + await route.fulfill({ status: 404 }); + }, + ); await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); await page.addInitScript((pubkey) => { window.localStorage.setItem( diff --git a/desktop/tests/e2e/sidebar.spec.ts b/desktop/tests/e2e/sidebar.spec.ts index 802c11a8fa..6ff62a214a 100644 --- a/desktop/tests/e2e/sidebar.spec.ts +++ b/desktop/tests/e2e/sidebar.spec.ts @@ -33,6 +33,12 @@ async function loadTheme(page: Page, theme: string) { await page.goto("/"); } +async function openAddCommunityDialog(page: Page) { + await page.getByTestId("sidebar-profile-avatar-button").click(); + await page.getByTestId("community-switcher").click(); + await page.getByRole("menuitem", { name: "Add a community" }).click(); +} + // Regression guard for the "Leave channel" lockup: with two bundled copies of // @radix-ui/react-dismissable-layer, opening a modal AlertDialog from a modal // Radix ContextMenu left `pointer-events: none` stuck on after the @@ -72,8 +78,7 @@ test("add community starts with create and join choices", async ({ page }) => { await installMockBridge(page, {}); await page.goto("/"); - await page.getByTestId("sidebar-profile-card").click(); - await page.getByText("Add a community", { exact: true }).click(); + await openAddCommunityDialog(page); await expect( page.getByRole("heading", { name: "Add community" }), @@ -106,6 +111,7 @@ test("add community starts with create and join choices", async ({ page }) => { test("automatically shows community join requirements near the community URL", async ({ page, }) => { + await installMockBridge(page, { applyCommunityDelayMs: 1_000 }); await page.route( "https://policy.example.com/api/join-policy", async (route) => { @@ -125,8 +131,7 @@ test("automatically shows community join requirements near the community URL", a ); await page.goto("/"); - await page.getByTestId("sidebar-profile-card").click(); - await page.getByText("Add a community", { exact: true }).click(); + await openAddCommunityDialog(page); await page.getByTestId("add-community-join").click(); await page .getByLabel("Community URL or invite link") @@ -183,8 +188,7 @@ test("supports API tokens without cluttering the default join form", async ({ ); await page.goto("/"); - await page.getByTestId("sidebar-profile-card").click(); - await page.getByText("Add a community", { exact: true }).click(); + await openAddCommunityDialog(page); await page.getByTestId("add-community-join").click(); await expect(page.getByLabel("API token")).toHaveCount(0); @@ -192,19 +196,30 @@ test("supports API tokens without cluttering the default join form", async ({ await page .getByLabel("Community URL or invite link") - .fill("https://token.example.com"); + .fill("token.example.com"); await page.getByTestId("community-api-token-reveal").click(); await page.getByLabel("API token").fill("buzz_secret"); await page.getByTestId("invite-redeem-submit").click(); await expect .poll(() => - page.evaluate( - (key) => window.localStorage.getItem(key), - COMMUNITY_ONBOARDING_STORAGE_KEY, - ), + page.evaluate((key) => { + const raw = window.localStorage.getItem(key); + if (!raw) return null; + const transaction = JSON.parse(raw) as { + relayUrl?: string; + token?: string; + }; + return { + relayUrl: transaction.relayUrl, + token: transaction.token, + }; + }, COMMUNITY_ONBOARDING_STORAGE_KEY), ) - .toContain('"token":"buzz_secret"'); + .toEqual({ + relayUrl: "wss://token.example.com", + token: "buzz_secret", + }); }); test("hides Invites settings on open relays", async ({ page }) => { From ac96ea61803c59319d0ddcab4c64bdc884368603 Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 24 Jul 2026 19:45:42 -0600 Subject: [PATCH 4/5] Restore general community icon editing Co-authored-by: Carl Signed-off-by: Wes --- .../ui/CommunityIconSettingsCard.tsx | 9 +++++++- .../communities/ui/CommunitySwitcher.tsx | 1 + .../communities/ui/EditCommunityDialog.tsx | 22 +++++++++++++++++++ .../src/features/sidebar/ui/CommunityRail.tsx | 1 + desktop/tests/e2e/community-rail.spec.ts | 6 +++++ 5 files changed, 38 insertions(+), 1 deletion(-) diff --git a/desktop/src/features/communities/ui/CommunityIconSettingsCard.tsx b/desktop/src/features/communities/ui/CommunityIconSettingsCard.tsx index 339347e85a..a9fed1648e 100644 --- a/desktop/src/features/communities/ui/CommunityIconSettingsCard.tsx +++ b/desktop/src/features/communities/ui/CommunityIconSettingsCard.tsx @@ -58,12 +58,17 @@ export function CommunityIconSettingsCard({ isSavingRef.current = true; const drainQueue = async () => { + let confirmedIcon = persistedIcon; + let lastAttemptFailed = false; while (queuedIconRef.current !== undefined) { const nextIcon = queuedIconRef.current; queuedIconRef.current = undefined; try { await mutateIcon(nextIcon); + confirmedIcon = nextIcon; + lastAttemptFailed = false; } catch (error) { + lastAttemptFailed = true; toast.error( error instanceof Error ? error.message @@ -71,11 +76,13 @@ export function CommunityIconSettingsCard({ ); } } + if (lastAttemptFailed) setDraftIcon(confirmedIcon); + hasLocalEditRef.current = false; isSavingRef.current = false; }; void drainQueue(); }, - [mutateIcon], + [mutateIcon, persistedIcon], ); function previewIcon(icon: string) { diff --git a/desktop/src/features/communities/ui/CommunitySwitcher.tsx b/desktop/src/features/communities/ui/CommunitySwitcher.tsx index b6ad193fcb..985d28fa8e 100644 --- a/desktop/src/features/communities/ui/CommunitySwitcher.tsx +++ b/desktop/src/features/communities/ui/CommunitySwitcher.tsx @@ -399,6 +399,7 @@ export function CommunitySwitcher({ onSave={onUpdateCommunity} open={editingCommunity !== null} community={editingCommunity} + showIconEditor={editingCommunity?.id === activeCommunity?.id} /> ); diff --git a/desktop/src/features/communities/ui/EditCommunityDialog.tsx b/desktop/src/features/communities/ui/EditCommunityDialog.tsx index 7176019143..3b963979bc 100644 --- a/desktop/src/features/communities/ui/EditCommunityDialog.tsx +++ b/desktop/src/features/communities/ui/EditCommunityDialog.tsx @@ -1,5 +1,7 @@ import * as React from "react"; +import { CommunityIconSettingsCard } from "@/features/communities/ui/CommunityIconSettingsCard"; +import { useMyRelayMembershipLookupQuery } from "@/features/community-members/hooks"; import type { Community } from "@/features/communities/types"; import { expandTilde, @@ -28,6 +30,7 @@ type EditCommunityDialogProps = { ) => void; onRemove?: (id: string) => void; canRemove?: boolean; + showIconEditor?: boolean; }; export function EditCommunityDialog({ @@ -37,12 +40,20 @@ export function EditCommunityDialog({ onSave, onRemove, canRemove, + showIconEditor = false, }: EditCommunityDialogProps) { const [name, setName] = React.useState(""); const [relayUrl, setRelayUrl] = React.useState(""); const [token, setToken] = React.useState(""); const [reposDir, setReposDir] = React.useState(""); const [reposDirError, setReposDirError] = React.useState(null); + const membershipQuery = useMyRelayMembershipLookupQuery(); + const activeRole = membershipQuery.data?.membership?.role; + const canEditIcon = + showIconEditor && + (membershipQuery.data?.membershipRequired === false || + activeRole === "owner" || + activeRole === "admin"); // Sync form state when the dialog opens with a community React.useEffect(() => { @@ -135,6 +146,17 @@ export function EditCommunityDialog({ className="flex flex-col gap-4" onSubmit={(e) => void handleSubmit(e)} > + {canEditIcon ? ( +
+
+

Community icon

+

+ Shown in the community rail and switcher. +

+
+ +
+ ) : null}