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/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/communities/ui/AddCommunityDialog.tsx b/desktop/src/features/communities/ui/AddCommunityDialog.tsx index 227804e7b4..e4f105471a 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,79 @@ 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, + token, + }: { + relayUrl: string; + inviteCode?: string; + policyReceipt?: string; + token?: 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, + token, }); + 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 +103,100 @@ 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, token) => + startConnection({ relayUrl, token }) + } + 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..a9fed1648e 100644 --- a/desktop/src/features/communities/ui/CommunityIconSettingsCard.tsx +++ b/desktop/src/features/communities/ui/CommunityIconSettingsCard.tsx @@ -1,135 +1,121 @@ -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 () => { + 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 + : "Couldn’t update the community icon.", + ); + } + } + if (lastAttemptFailed) setDraftIcon(confirmedIcon); + hasLocalEditRef.current = false; + isSavingRef.current = false; + }; + void drainQueue(); + }, + [mutateIcon, persistedIcon], + ); + + 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..985d28fa8e 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 @@ -381,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}