refactor(clients): standardize product naming on community (#1858)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Wes
2026-07-14 12:11:02 -06:00
committed by GitHub
parent fa6f4819b3
commit 3e76481a14
185 changed files with 2613 additions and 2488 deletions

View File

@@ -66,7 +66,7 @@ export default defineConfig({
"**/home-collapsed-top-chrome.spec.ts",
"**/top-chrome-zoom-clearance.spec.ts",
"**/thread-unread.spec.ts",
"**/workspace-rail.spec.ts",
"**/community-rail.spec.ts",
"**/boot-splash.spec.ts",
"**/thread-reply-anchor-roleplay.spec.ts",
"**/threadpane-ultrawide.spec.ts",

View File

@@ -21,11 +21,11 @@ import { OnboardingSlideTransition } from "@/features/onboarding/ui/OnboardingSl
import { OnboardingFlow } from "@/features/onboarding/ui/OnboardingFlow";
import { KeyringLockedScreen } from "@/features/onboarding/ui/KeyringLockedScreen";
import { RelaunchRequiredScreen } from "@/features/onboarding/ui/RelaunchRequiredScreen";
import type { Workspace } from "@/features/workspaces/types";
import { useWorkspaceInit } from "@/features/workspaces/useWorkspaceInit";
import { useNestNotifications } from "@/features/workspaces/useNestNotifications";
import { useWorkspaces } from "@/features/workspaces/useWorkspaces";
import { WelcomeSetup } from "@/features/workspaces/ui/WelcomeSetup";
import type { Community } from "@/features/communities/types";
import { useCommunityInit } from "@/features/communities/useCommunityInit";
import { useNestNotifications } from "@/features/communities/useNestNotifications";
import { useCommunities } from "@/features/communities/useCommunities";
import { WelcomeSetup } from "@/features/communities/ui/WelcomeSetup";
import { createBuzzQueryClient } from "@/shared/api/queryClient";
import { isSharedIdentity as isSharedIdentityCmd } from "@/shared/api/tauri";
import { listenForDeepLinks } from "@/shared/deep-link";
@@ -38,10 +38,10 @@ import { FuzzyLogo } from "@/shared/ui/buzz-logo/FuzzyLogo";
import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
import { StepProgress } from "@/shared/ui/step-progress";
const LOADING_TEXT = "Setting up your workspace...";
const LOADING_TEXT = "Setting up your community...";
// Minimum time the cold-boot splash stays on screen. A real boot resolves the
// workspace in well under 100ms, and the native window setup plus first paint
// community in well under 100ms, and the native window setup plus first paint
// can take longer than that — without a hold, the bee is unmounted before it is
// ever visible. The hold runs as an overlay above the already-mounted app, so
// time-to-interactive is unchanged; only the reveal waits.
@@ -130,7 +130,7 @@ function BeeLoader({
// Cold boot gate: the theme-adaptive grainient background with a single
// centered Buzz bee flying over it — the same static mark as before, now with
// its wings flapping (ported from the Buzz website's wing-flap). Replaces the
// old "Setting up your workspace" text, which stays as an sr-only caption.
// old "Setting up your community" text, which stays as an sr-only caption.
function AppLoadingGate() {
return (
<div
@@ -146,9 +146,9 @@ function AppLoadingGate() {
);
}
// Quiet gate for switching between already-set-up workspaces: visually empty
// Quiet gate for switching between already-set-up communities: visually empty
// unless the switch takes long, so fast switches don't flash the boot splash.
function WorkspaceSwitchGate() {
function CommunitySwitchGate() {
const [showSpinner, setShowSpinner] = useState(false);
useEffect(() => {
@@ -159,14 +159,14 @@ function WorkspaceSwitchGate() {
return (
<div
className="flex min-h-dvh items-center justify-center bg-background"
data-testid="workspace-switch-gate"
data-testid="community-switch-gate"
role="status"
>
<StartupWindowDragRegion />
<span className="sr-only">Switching workspace…</span>
<span className="sr-only">Switching community…</span>
{showSpinner ? (
<BeeLoader
ariaLabel="Switching workspace…"
ariaLabel="Switching community…"
className="h-auto w-20"
tintClassName="text-muted-foreground"
/>
@@ -197,7 +197,7 @@ function OnboardingLoadingGate() {
className="flex w-full flex-col items-center text-center"
direction="forward"
effect="none"
transitionKey="workspace-connecting"
transitionKey="community-connecting"
>
<div className="flex h-14 w-14 items-center justify-center rounded-lg border border-border bg-background text-foreground shadow-xs">
<Hexagon className="h-7 w-7" aria-hidden="true" />
@@ -207,7 +207,7 @@ function OnboardingLoadingGate() {
Welcome to Buzz
</h1>
<p className="mt-3 max-w-[440px] text-sm leading-6 text-muted-foreground">
Choose your first workspace to get started.
Choose your first community to get started.
</p>
<div className="mt-8 flex w-full max-w-[500px] flex-col gap-3">
@@ -217,7 +217,7 @@ function OnboardingLoadingGate() {
tabIndex={-1}
type="button"
>
Continue with default workspace
Continue with default community
</Button>
<Button
@@ -227,7 +227,7 @@ function OnboardingLoadingGate() {
type="button"
variant="secondary"
>
Join a workspace
Join a community
</Button>
<Button
@@ -247,7 +247,7 @@ function OnboardingLoadingGate() {
);
}
function WorkspaceQueryProvider({ children }: { children: ReactNode }) {
function CommunityQueryProvider({ children }: { children: ReactNode }) {
const [queryClient] = useState(createBuzzQueryClient);
useEffect(() => {
@@ -273,30 +273,30 @@ function WorkspaceQueryProvider({ children }: { children: ReactNode }) {
}
function AppReady({
canBackToWorkspaceSetup,
isCompletingFirstRunWorkspace,
canBackToCommunitySetup,
isCompletingFirstRunCommunity,
isSharedIdentity,
isWorkspaceSwitch,
onFirstRunWorkspaceSettled,
onBackToWorkspaceSetup,
isCommunitySwitch,
onFirstRunCommunitySettled,
onBackToCommunitySetup,
}: {
canBackToWorkspaceSetup: boolean;
isCompletingFirstRunWorkspace: boolean;
canBackToCommunitySetup: boolean;
isCompletingFirstRunCommunity: boolean;
isSharedIdentity: boolean;
isWorkspaceSwitch: boolean;
onFirstRunWorkspaceSettled: () => void;
onBackToWorkspaceSetup: () => void;
isCommunitySwitch: boolean;
onFirstRunCommunitySettled: () => void;
onBackToCommunitySetup: () => void;
}) {
const onboarding = useAppOnboardingState(isSharedIdentity);
useEffect(() => {
if (isCompletingFirstRunWorkspace && onboarding.stage !== "blocking") {
onFirstRunWorkspaceSettled();
if (isCompletingFirstRunCommunity && onboarding.stage !== "blocking") {
onFirstRunCommunitySettled();
}
}, [
isCompletingFirstRunWorkspace,
isCompletingFirstRunCommunity,
onboarding.stage,
onFirstRunWorkspaceSettled,
onFirstRunCommunitySettled,
]);
if (onboarding.stage === "keyring-locked") {
@@ -311,21 +311,21 @@ function AppReady({
return (
<OnboardingFlow
actions={onboarding.flow.actions}
canBackToWorkspaceSetup={canBackToWorkspaceSetup}
canBackToCommunitySetup={canBackToCommunitySetup}
identityLost={onboarding.identityLost}
initialProfile={onboarding.flow.initialProfile}
key={onboarding.currentPubkey ?? "anonymous"}
onBackToWorkspaceSetup={onBackToWorkspaceSetup}
onBackToCommunitySetup={onBackToCommunitySetup}
/>
);
}
if (onboarding.stage === "blocking") {
if (isCompletingFirstRunWorkspace) {
if (isCompletingFirstRunCommunity) {
return <OnboardingLoadingGate />;
}
return isWorkspaceSwitch ? <WorkspaceSwitchGate /> : <AppLoadingGate />;
return isCommunitySwitch ? <CommunitySwitchGate /> : <AppLoadingGate />;
}
return (
@@ -352,75 +352,75 @@ export function App() {
}, []);
const {
activeWorkspace,
activeCommunity,
reinitKey,
addWorkspace,
clearWorkspaces,
switchWorkspace,
reconnectWorkspace,
} = useWorkspaces();
const [isCompletingFirstRunWorkspace, setIsCompletingFirstRunWorkspace] =
addCommunity,
clearCommunities,
switchCommunity,
reconnectCommunity,
} = useCommunities();
const [isCompletingFirstRunCommunity, setIsCompletingFirstRunCommunity] =
useState(false);
const [canBackToWorkspaceSetup, setCanBackToWorkspaceSetup] = useState(false);
const [canBackToCommunitySetup, setCanBackToCommunitySetup] = useState(false);
const [welcomeTransitionMode, setWelcomeTransitionMode] = useState<
"initial" | "backward"
>("initial");
useEffect(() => {
const unlisten = listenForDeepLinks({
addWorkspace,
switchWorkspace,
reconnectWorkspace,
addCommunity,
switchCommunity,
reconnectCommunity,
});
return () => {
void unlisten.then((fn) => fn());
};
}, [addWorkspace, switchWorkspace, reconnectWorkspace]);
}, [addCommunity, switchCommunity, reconnectCommunity]);
// Surface nest-related backend events (repos-dir errors, legacy migration)
// as toasts. Mounted before useWorkspaceInit so the listeners are registered
// as toasts. Mounted before useCommunityInit so the listeners are registered
// ahead of the first apply_workspace call.
useNestNotifications();
// Composite key: changes when workspace ID changes OR when
// the active workspace's config is updated (relayUrl/token).
const workspaceKey = `${activeWorkspace?.id ?? "none"}-${reinitKey}`;
// Composite key: changes when community ID changes OR when
// the active community's config is updated (relayUrl/token).
const communityKey = `${activeCommunity?.id ?? "none"}-${reinitKey}`;
// Latch once the workspace key deviates from its cold-boot value: from then
// Latch once the community key deviates from its cold-boot value: from then
// on, loading phases are in-app switches and get the quiet gate instead of
// the full "Setting up your workspace" splash.
const initialWorkspaceKeyRef = useRef(workspaceKey);
const hasSwitchedWorkspaceRef = useRef(false);
if (workspaceKey !== initialWorkspaceKeyRef.current) {
hasSwitchedWorkspaceRef.current = true;
// the full "Setting up your community" splash.
const initialCommunityKeyRef = useRef(communityKey);
const hasSwitchedCommunityRef = useRef(false);
if (communityKey !== initialCommunityKeyRef.current) {
hasSwitchedCommunityRef.current = true;
}
const isWorkspaceSwitch = hasSwitchedWorkspaceRef.current;
const isCommunitySwitch = hasSwitchedCommunityRef.current;
const workspace = useWorkspaceInit(
activeWorkspace,
workspaceKey,
const community = useCommunityInit(
activeCommunity,
communityKey,
sharedIdentity ?? false,
);
const handleSetupComplete = useCallback(
(workspace: Workspace) => {
(community: Community) => {
setWelcomeTransitionMode("initial");
setIsCompletingFirstRunWorkspace(true);
setCanBackToWorkspaceSetup(true);
const workspaceId = addWorkspace(workspace);
switchWorkspace(workspaceId);
setIsCompletingFirstRunCommunity(true);
setCanBackToCommunitySetup(true);
const communityId = addCommunity(community);
switchCommunity(communityId);
},
[addWorkspace, switchWorkspace],
[addCommunity, switchCommunity],
);
const handleBackToWorkspaceSetup = useCallback(() => {
const handleBackToCommunitySetup = useCallback(() => {
setWelcomeTransitionMode("backward");
setIsCompletingFirstRunWorkspace(false);
setCanBackToWorkspaceSetup(false);
clearWorkspaces();
}, [clearWorkspaces]);
setIsCompletingFirstRunCommunity(false);
setCanBackToCommunitySetup(false);
clearCommunities();
}, [clearCommunities]);
const handleFirstRunWorkspaceSettled = useCallback(() => {
setIsCompletingFirstRunWorkspace(false);
const handleFirstRunCommunitySettled = useCallback(() => {
setIsCompletingFirstRunCommunity(false);
}, []);
const bootSplashPhase = useBootSplashHold();
@@ -432,47 +432,47 @@ export function App() {
return <AppLoadingGate />;
}
// Show welcome setup for first-run users with no workspaces
if (workspace.needsSetup) {
// Show welcome setup for first-run users with no communities
if (community.needsSetup) {
return (
<WelcomeSetup
defaultRelayUrl={workspace.defaultRelayUrl}
defaultRelayUrl={community.defaultRelayUrl}
initialTransitionMode={welcomeTransitionMode}
onComplete={handleSetupComplete}
/>
);
}
// Wait for this exact workspace config to be applied to the backend before
// Wait for this exact community config to be applied to the backend before
// rendering anything that connects to the relay. The appliedKey check avoids
// a one-render race where React sees the new active workspace while the Tauri
// a one-render race where React sees the new active community while the Tauri
// backend is still configured for the previous one.
if (!workspace.isReady || workspace.appliedKey !== workspaceKey) {
if (isCompletingFirstRunWorkspace) {
if (!community.isReady || community.appliedKey !== communityKey) {
if (isCompletingFirstRunCommunity) {
return <OnboardingLoadingGate />;
}
return isWorkspaceSwitch ? <WorkspaceSwitchGate /> : <AppLoadingGate />;
return isCommunitySwitch ? <CommunitySwitchGate /> : <AppLoadingGate />;
}
// The app mounts (and starts loading data) beneath the splash overlay; the
// overlay just keeps the bee on screen long enough to be seen, then fades.
// Workspace switches and first-run completion keep their quiet gates.
// Community switches and first-run completion keep their quiet gates.
const showBootSplashOverlay =
bootSplashPhase !== "done" &&
!isWorkspaceSwitch &&
!isCompletingFirstRunWorkspace;
!isCommunitySwitch &&
!isCompletingFirstRunCommunity;
return (
<WorkspaceQueryProvider key={workspaceKey}>
<CommunityQueryProvider key={communityKey}>
<AppReady
canBackToWorkspaceSetup={canBackToWorkspaceSetup}
isCompletingFirstRunWorkspace={isCompletingFirstRunWorkspace}
isWorkspaceSwitch={isWorkspaceSwitch}
key={workspaceKey}
canBackToCommunitySetup={canBackToCommunitySetup}
isCompletingFirstRunCommunity={isCompletingFirstRunCommunity}
isCommunitySwitch={isCommunitySwitch}
key={communityKey}
isSharedIdentity={sharedIdentity}
onFirstRunWorkspaceSettled={handleFirstRunWorkspaceSettled}
onBackToWorkspaceSetup={handleBackToWorkspaceSetup}
onFirstRunCommunitySettled={handleFirstRunCommunitySettled}
onBackToCommunitySetup={handleBackToCommunitySetup}
/>
{showBootSplashOverlay ? (
<div
@@ -487,6 +487,6 @@ export function App() {
<AppLoadingGate />
</div>
) : null}
</WorkspaceQueryProvider>
</CommunityQueryProvider>
);
}

View File

@@ -50,7 +50,7 @@ import {
useUserStatusQuery,
useUserStatusSubscription,
} from "@/features/user-status/hooks";
import { useWorkspaceEmojiLiveUpdates } from "@/features/custom-emoji/hooks";
import { useCommunityEmojiLiveUpdates } from "@/features/custom-emoji/hooks";
import { useArchiveSync } from "@/features/local-archive/archiveSyncManager";
import { useObserverArchiveSeed } from "@/features/local-archive/useObserverArchiveSeed";
import { useAgentMetricArchiveSeed } from "@/features/local-archive/useAgentMetricArchiveSeed";
@@ -65,10 +65,10 @@ import { useDueReminderBadgeCount } from "@/features/reminders/hooks";
import { RemindMeLaterProvider } from "@/features/reminders/ui/RemindMeLaterProvider";
import { useReminderNotifications } from "@/features/reminders/useReminderNotifications";
import { AppSidebar } from "@/features/sidebar/ui/AppSidebar";
import { WorkspaceRail } from "@/features/sidebar/ui/WorkspaceRail";
import { CommunityRail } from "@/features/sidebar/ui/CommunityRail";
import { useChannelMutes } from "@/features/sidebar/lib/useChannelMutes";
import { useChannelStars } from "@/features/sidebar/lib/useChannelStars";
import { useWorkspaces } from "@/features/workspaces/useWorkspaces";
import { useCommunities } from "@/features/communities/useCommunities";
import { useApplyTemplate } from "@/features/channel-templates/useApplyTemplate";
import { relayClient } from "@/shared/api/relayClient";
import { useFeatureEnabled } from "@/shared/features";
@@ -98,9 +98,9 @@ export function AppShell() {
useTauriWindowDrag();
useWebviewScrollBoundaryLock();
const workspacesHook = useWorkspaces();
const workspaceRailEnabled = useFeatureEnabled("workspaceRail");
const [isAddWorkspaceOpen, setIsAddWorkspaceOpen] = React.useState(false);
const communitiesHook = useCommunities();
const communityRailEnabled = useFeatureEnabled("workspaceRail");
const [isAddCommunityOpen, setIsAddCommunityOpen] = React.useState(false);
const [isChannelManagementOpen, setIsChannelManagementOpen] =
React.useState(false);
const [managedChannelId, setManagedChannelId] = React.useState<string | null>(
@@ -128,24 +128,24 @@ export function AppShell() {
} = useAppNavigation();
const { canGoBack, canGoForward, goBack, goForward } =
useBackForwardControls();
// Navigate home before switching workspaces so the outgoing channel URL is
// Navigate home before switching communities so the outgoing channel URL is
// cleared. Without this, ChannelScreen's read effect continues firing
// markChannelRead({ topLevelOnly: true }) for the previous workspace's
// markChannelRead({ topLevelOnly: true }) for the previous community's
// channel, advancing its NIP-RS markers and causing the rail badge to vanish
// on the next 30s poll (A→B→A→B disappearance bug).
// Guard: skip goHome() when re-selecting the already-active workspace so
// Guard: skip goHome() when re-selecting the already-active community so
// the current channel is not unexpectedly cleared.
const handleSwitchWorkspace = React.useCallback(
const handleSwitchCommunity = React.useCallback(
(id: string) => {
if (id !== workspacesHook.activeWorkspace?.id) {
if (id !== communitiesHook.activeCommunity?.id) {
void goHome();
}
workspacesHook.switchWorkspace(id);
communitiesHook.switchCommunity(id);
},
[
goHome,
workspacesHook.activeWorkspace?.id,
workspacesHook.switchWorkspace,
communitiesHook.activeCommunity?.id,
communitiesHook.switchCommunity,
],
);
const { selectedChannelId, selectedView } = React.useMemo(
@@ -196,7 +196,7 @@ export function AppShell() {
useRelayAutoHeal();
usePresenceSubscription();
useUserStatusSubscription();
useWorkspaceEmojiLiveUpdates();
useCommunityEmojiLiveUpdates();
useMembershipNotifications(identityQuery.data?.pubkey);
const presenceSession = usePresenceSession(deferredPubkey);
const selfStatusQuery = useUserStatusQuery(
@@ -227,7 +227,7 @@ export function AppShell() {
: undefined;
const relayConnectionCard = useSidebarRelayConnectionCard(
channelsErrorMessage,
workspacesHook.activeWorkspace?.relayUrl,
communitiesHook.activeCommunity?.relayUrl,
);
const memberChannels = React.useMemo(
() => channels.filter((channel) => channel.isMember),
@@ -293,7 +293,7 @@ export function AppShell() {
} = useUnreadChannels(sidebarChannels, activeChannel, {
pubkey: identityQuery.data?.pubkey,
relayClient,
relayUrl: workspacesHook.activeWorkspace?.relayUrl,
relayUrl: communitiesHook.activeCommunity?.relayUrl,
currentPubkey: identityQuery.data?.pubkey,
mutedChannelIds,
notifyForActiveChannel: notificationSettings.settings.notifyWhileViewing,
@@ -676,16 +676,16 @@ export function AppShell() {
isHuddleDrawerOpen && "buzz-huddle-app-surface-open",
)}
>
{workspaceRailEnabled ? (
<WorkspaceRail
activeWorkspaceId={
workspacesHook.activeWorkspace?.id ?? null
{communityRailEnabled ? (
<CommunityRail
activeCommunityId={
communitiesHook.activeCommunity?.id ?? null
}
onAddWorkspace={() => setIsAddWorkspaceOpen(true)}
onRemoveWorkspace={workspacesHook.removeWorkspace}
onSwitchWorkspace={handleSwitchWorkspace}
onUpdateWorkspace={workspacesHook.updateWorkspace}
workspaces={workspacesHook.workspaces}
onAddCommunity={() => setIsAddCommunityOpen(true)}
onRemoveCommunity={communitiesHook.removeCommunity}
onSwitchCommunity={handleSwitchCommunity}
onUpdateCommunity={communitiesHook.updateCommunity}
communities={communitiesHook.communities}
/>
) : null}
<SidebarProvider className="min-h-0 flex-1 flex-col overflow-hidden">
@@ -693,9 +693,9 @@ export function AppShell() {
<AppTopChrome
canGoBack={canGoBack}
canGoForward={canGoForward}
hasWorkspaceRail={
workspaceRailEnabled &&
workspacesHook.workspaces.length > 1
hasCommunityRail={
communityRailEnabled &&
communitiesHook.communities.length > 1
}
onGoBack={goBack}
onGoForward={goForward}
@@ -746,35 +746,35 @@ export function AppShell() {
) : (
<div className="flex min-h-0 flex-1 overflow-hidden">
<AppSidebar
activeWorkspace={workspacesHook.activeWorkspace}
activeCommunity={communitiesHook.activeCommunity}
channels={sidebarChannels}
currentPubkey={identityQuery.data?.pubkey}
errorMessage={channelsErrorMessage}
fallbackDisplayName={identityQuery.data?.displayName}
homeBadgeCount={homeBadgeCount + dueReminderBadge}
isAddWorkspaceOpen={isAddWorkspaceOpen}
isAddCommunityOpen={isAddCommunityOpen}
relayConnectionCard={relayConnectionCard}
isCreatingChannel={createChannelMutation.isPending}
isCreatingForum={createForumMutation.isPending}
isLoading={channelsQuery.isLoading}
isCreateChannelOpen={isCreateChannelOpen}
isPresencePending={presenceSession.isPending}
onAddWorkspace={(workspace) => {
const id = workspacesHook.addWorkspace(workspace);
handleSwitchWorkspace(id);
onAddCommunity={(community) => {
const id = communitiesHook.addCommunity(community);
handleSwitchCommunity(id);
}}
onAddWorkspaceOpenChange={setIsAddWorkspaceOpen}
onAddCommunityOpenChange={setIsAddCommunityOpen}
onNewMessage={handleOpenNewDm}
onCreateChannelOpenChange={setIsCreateChannelOpen}
onOpenAddWorkspace={() => setIsAddWorkspaceOpen(true)}
onUpdateWorkspace={workspacesHook.updateWorkspace}
onRemoveWorkspace={workspacesHook.removeWorkspace}
onSwitchWorkspace={handleSwitchWorkspace}
onOpenAddCommunity={() => setIsAddCommunityOpen(true)}
onUpdateCommunity={communitiesHook.updateCommunity}
onRemoveCommunity={communitiesHook.removeCommunity}
onSwitchCommunity={handleSwitchCommunity}
onCreateAgent={() =>
void goAgents().then(requestOpenCreateAgent)
}
selfPresenceStatus={presenceSession.currentStatus}
workspaces={workspacesHook.workspaces}
communities={communitiesHook.communities}
onCreateChannel={async ({
description,
name,
@@ -893,9 +893,9 @@ export function AppShell() {
<RelayConnectionOverlay
card={relayConnectionCard}
errorMessage={channelsErrorMessage}
hasWorkspaceRail={
workspaceRailEnabled &&
workspacesHook.workspaces.length > 1
hasCommunityRail={
communityRailEnabled &&
communitiesHook.communities.length > 1
}
isHuddleDrawerOpen={isHuddleDrawerOpen}
/>

View File

@@ -18,7 +18,7 @@ type AppTopChromeProps = {
canGoForward: boolean;
onGoBack: () => void;
onGoForward: () => void;
hasWorkspaceRail?: boolean;
hasCommunityRail?: boolean;
};
// Fixed px on purpose (button box + glyph): these controls sit beside the
@@ -61,13 +61,13 @@ export function AppTopChrome({
canGoForward,
onGoBack,
onGoForward,
hasWorkspaceRail = false,
hasCommunityRail = false,
}: AppTopChromeProps) {
const topChromeRef = React.useRef<HTMLDivElement>(null);
const isFullscreen = useIsFullscreen();
// On macOS the traffic-light buttons overlay the chrome (see
// `trafficLightPosition` in `tauri.conf.json`), so the nav row clears their
// x-position. When the workspace rail is present it already occupies the far
// x-position. When the community rail is present it already occupies the far
// left, so the nav row only needs to clear the lights past the rail edge
// rather than the full offset. In fullscreen those buttons hide.
//
@@ -76,7 +76,7 @@ export function AppTopChrome({
// zoomed out. This is a deliberate exception to the rem-first rule.
const macChrome = isMacPlatform() && !isFullscreen;
const navRowPaddingClass = macChrome
? hasWorkspaceRail
? hasCommunityRail
? "pl-[32px]"
: "pl-[80px]"
: "pl-3";

View File

@@ -11,7 +11,7 @@ import { useSidebar } from "@/shared/ui/sidebar";
type RelayConnectionOverlayProps = {
card: ReturnType<typeof useSidebarRelayConnectionCard>;
errorMessage?: string;
hasWorkspaceRail?: boolean;
hasCommunityRail?: boolean;
isHuddleDrawerOpen?: boolean;
};
@@ -19,7 +19,7 @@ type RelayConnectionOverlayProps = {
* Fixed bottom-left overlay that shows the relay reconnect card when the
* sidebar is collapsed. When the sidebar is open, the card lives in the
* sidebar footer instead (and this overlay is hidden). Offsets itself for
* the workspace rail (48px) and huddle drawer when present.
* the community rail (48px) and huddle drawer when present.
*
* Also surfaces non-unreachable disconnect errors (e.g. auth rejections)
* when the sidebar is hidden, since those errors are only rendered inside
@@ -28,7 +28,7 @@ type RelayConnectionOverlayProps = {
export function RelayConnectionOverlay({
card,
errorMessage,
hasWorkspaceRail,
hasCommunityRail,
isHuddleDrawerOpen,
}: RelayConnectionOverlayProps) {
const { open: sidebarOpen, openMobile } = useSidebar();
@@ -60,7 +60,7 @@ export function RelayConnectionOverlay({
animate={{ opacity: 1, y: 0 }}
className={cn(
"pointer-events-none fixed z-50 w-[284px]",
hasWorkspaceRail ? "left-[60px]" : "left-3",
hasCommunityRail ? "left-[60px]" : "left-3",
isHuddleDrawerOpen
? "bottom-[calc(var(--buzz-huddle-drawer-height,0px)+12px)]"
: "bottom-3",
@@ -86,7 +86,7 @@ export function RelayConnectionOverlay({
animate={{ opacity: 1, y: 0 }}
className={cn(
"pointer-events-none fixed z-50 w-[284px]",
hasWorkspaceRail ? "left-[60px]" : "left-3",
hasCommunityRail ? "left-[60px]" : "left-3",
isHuddleDrawerOpen
? "bottom-[calc(var(--buzz-huddle-drawer-height,0px)+12px)]"
: "bottom-3",

View File

@@ -7,7 +7,7 @@ import { hasPrimaryShortcutModifier } from "@/shared/lib/platform";
* Ctrl+R elsewhere), matching browser behavior.
*
* `window.location.reload()` is the app's existing reload primitive (see
* App.tsx, useWorkspaceInit.ts): it triggers a full reinit that re-reads
* App.tsx, useCommunityInit.ts): it triggers a full reinit that re-reads
* localStorage and reconnects relays.
*/
export function useReloadShortcut() {

View File

@@ -42,11 +42,11 @@ test("thread activity feed projection filters muted roots", () => {
assert.equal(items[0]?.channelType, "stream");
});
test("channel-presence fence: items from channels absent in workspace are filtered out", () => {
// workspace A has channel-1; workspace B (relayed) has only channel-2.
test("channel-presence fence: items from channels absent in community are filtered out", () => {
// community A has channel-1; community B (relayed) has only channel-2.
// Simulate A's stored items landing in B's projection call — they should
// all be filtered out because channel-1 is not in B's channel set.
const workspaceBChannels = [
const communityBChannels = [
{ id: OTHER_CHANNEL_ID, name: "other-channel", channelType: "stream" },
];
@@ -59,10 +59,10 @@ test("channel-presence fence: items from channels absent in workspace are filter
}),
],
new Set(),
workspaceBChannels,
communityBChannels,
);
// Only the item whose channelId exists in the current workspace passes.
// Only the item whose channelId exists in the current community passes.
assert.deepEqual(
items.map((item) => item.id),
["reply-from-b"],

View File

@@ -14,9 +14,9 @@ export function buildThreadActivityFeedItems(
return threadActivityItems
.filter((item) => {
// Defense-in-depth relay-scope fence: only synthesize rows whose channel
// is present in the active workspace's channel set. Rows persisted under
// is present in the active community's channel set. Rows persisted under
// a different relay's scope key should never reach this function, but
// this filter is the last line of defense against cross-workspace leaks.
// this filter is the last line of defense against cross-community leaks.
if (channelById.get(item.channelId) === undefined) return false;
const rootId = getThreadReference(item.tags).rootId;
return !rootId || !mutedRootIds.has(rootId);

View File

@@ -8,9 +8,9 @@ import {
getActiveTurnsByChannel,
resetActiveAgentTurnsStore,
subscribeActiveAgentTurns,
saveActiveAgentTurnsForWorkspace,
restoreActiveAgentTurnsForWorkspace,
clearSavedWorkspaceSnapshot,
saveActiveAgentTurnsForCommunity,
restoreActiveAgentTurnsForCommunity,
clearSavedCommunitySnapshot,
} from "./activeAgentTurnsStore.ts";
import {
injectObserverEventsForE2E,
@@ -1332,13 +1332,13 @@ describe("formatElapsed", () => {
});
});
describe("workspace-switch save / restore", () => {
describe("community-switch save / restore", () => {
beforeEach(() => {
resetActiveAgentTurnsStore();
});
it("restores original startedAt timestamps across a round-trip", () => {
// Simulate a turn active in workspace A with a known start timestamp.
// Simulate a turn active in community A with a known start timestamp.
syncAgentTurnsFromEvents(AGENT, [
makeEvent({
seq: 1,
@@ -1350,7 +1350,7 @@ describe("workspace-switch save / restore", () => {
const anchorBefore = getActiveTurnsForAgent(AGENT)[0].anchorAt;
// Switch away (save A, reset, apply B).
saveActiveAgentTurnsForWorkspace("ws-a");
saveActiveAgentTurnsForCommunity("ws-a");
resetActiveAgentTurnsStore();
assert.equal(
getActiveTurnsForAgent(AGENT).length,
@@ -1359,7 +1359,7 @@ describe("workspace-switch save / restore", () => {
);
// Switch back (restore A).
restoreActiveAgentTurnsForWorkspace("ws-a");
restoreActiveAgentTurnsForCommunity("ws-a");
const turns = getActiveTurnsForAgent(AGENT);
assert.equal(turns.length, 1, "restored turn must reappear");
assert.equal(turns[0].channelId, "c1");
@@ -1370,7 +1370,7 @@ describe("workspace-switch save / restore", () => {
);
});
it("no-op restore when no snapshot exists for the workspace", () => {
it("no-op restore when no snapshot exists for the community", () => {
// Populate the store so we can verify nothing changes.
syncAgentTurnsFromEvents(AGENT, [
makeEvent({ seq: 1, turnId: "t1", channelId: "c1" }),
@@ -1379,7 +1379,7 @@ describe("workspace-switch save / restore", () => {
const unsub = subscribeActiveAgentTurns(() => {
notified++;
});
restoreActiveAgentTurnsForWorkspace("ws-unknown");
restoreActiveAgentTurnsForCommunity("ws-unknown");
unsub();
assert.equal(notified, 0, "no-op restore must not notify listeners");
// Store should be unchanged — still has the turn we set up above.
@@ -1391,15 +1391,15 @@ describe("workspace-switch save / restore", () => {
syncAgentTurnsFromEvents(AGENT, [
makeEvent({ seq: 1, turnId: "t1", channelId: "c1" }),
]);
saveActiveAgentTurnsForWorkspace("ws-a");
saveActiveAgentTurnsForCommunity("ws-a");
// Second pass: the turns ended while we were away, so the store is empty
// when we try to save again.
resetActiveAgentTurnsStore();
saveActiveAgentTurnsForWorkspace("ws-a"); // empty store → delete snapshot
saveActiveAgentTurnsForCommunity("ws-a"); // empty store → delete snapshot
// Restore must be a no-op now.
restoreActiveAgentTurnsForWorkspace("ws-a");
restoreActiveAgentTurnsForCommunity("ws-a");
assert.equal(
getActiveTurnsForAgent(AGENT).length,
0,
@@ -1411,15 +1411,15 @@ describe("workspace-switch save / restore", () => {
syncAgentTurnsFromEvents(AGENT, [
makeEvent({ seq: 1, turnId: "t1", channelId: "c1" }),
]);
saveActiveAgentTurnsForWorkspace("ws-a");
saveActiveAgentTurnsForCommunity("ws-a");
resetActiveAgentTurnsStore();
restoreActiveAgentTurnsForWorkspace("ws-a");
restoreActiveAgentTurnsForCommunity("ws-a");
assert.equal(getActiveTurnsForAgent(AGENT).length, 1, "first restore ok");
// Second restore — snapshot was consumed.
resetActiveAgentTurnsStore();
restoreActiveAgentTurnsForWorkspace("ws-a");
restoreActiveAgentTurnsForCommunity("ws-a");
assert.equal(
getActiveTurnsForAgent(AGENT).length,
0,
@@ -1436,9 +1436,9 @@ describe("workspace-switch save / restore", () => {
timestamp: "2024-01-01T00:00:00Z",
}),
]);
saveActiveAgentTurnsForWorkspace("ws-a");
saveActiveAgentTurnsForCommunity("ws-a");
resetActiveAgentTurnsStore();
restoreActiveAgentTurnsForWorkspace("ws-a");
restoreActiveAgentTurnsForCommunity("ws-a");
// Replaying an event at or below the watermark must be a no-op.
let notified = 0;
@@ -1479,9 +1479,9 @@ describe("workspace-switch save / restore", () => {
timestamp: "2024-01-01T00:00:10Z",
}),
]);
saveActiveAgentTurnsForWorkspace("ws-a");
saveActiveAgentTurnsForCommunity("ws-a");
resetActiveAgentTurnsStore();
restoreActiveAgentTurnsForWorkspace("ws-a");
restoreActiveAgentTurnsForCommunity("ws-a");
// A liveness stamped before the completion must not resurrect t1.
syncAgentTurnsFromEvents(AGENT, [
@@ -1504,36 +1504,36 @@ describe("workspace-switch save / restore", () => {
syncAgentTurnsFromEvents(AGENT, [
makeEvent({ seq: 1, turnId: "t1", channelId: "c1" }),
]);
saveActiveAgentTurnsForWorkspace("ws-a");
saveActiveAgentTurnsForCommunity("ws-a");
resetActiveAgentTurnsStore();
let notified = 0;
const unsub = subscribeActiveAgentTurns(() => {
notified++;
});
restoreActiveAgentTurnsForWorkspace("ws-a");
restoreActiveAgentTurnsForCommunity("ws-a");
unsub();
assert.ok(notified > 0, "restore must notify listeners");
});
it("multiple workspaces maintain independent snapshots", () => {
// Workspace A: agent in c1.
it("multiple communities maintain independent snapshots", () => {
// Community A: agent in c1.
syncAgentTurnsFromEvents(AGENT, [
makeEvent({ seq: 1, turnId: "t1", channelId: "c1" }),
]);
saveActiveAgentTurnsForWorkspace("ws-a");
saveActiveAgentTurnsForCommunity("ws-a");
// Workspace B: different agent/channel.
// Community B: different agent/channel.
resetActiveAgentTurnsStore();
syncAgentTurnsFromEvents(AGENT_2, [
makeEvent({ seq: 1, turnId: "t2", channelId: "c2" }),
]);
saveActiveAgentTurnsForWorkspace("ws-b");
saveActiveAgentTurnsForCommunity("ws-b");
// Switch to A — only A's turns must appear.
resetActiveAgentTurnsStore();
restoreActiveAgentTurnsForWorkspace("ws-a");
restoreActiveAgentTurnsForCommunity("ws-a");
const aChannels = new Set(
getActiveTurnsForAgent(AGENT).map((s) => s.channelId),
);
@@ -1546,28 +1546,28 @@ describe("workspace-switch save / restore", () => {
// Switch to B — only B's turns.
resetActiveAgentTurnsStore();
restoreActiveAgentTurnsForWorkspace("ws-b");
restoreActiveAgentTurnsForCommunity("ws-b");
const bChannels = new Set(
getActiveTurnsForAgent(AGENT_2).map((s) => s.channelId),
);
assert.ok(bChannels.has("c2"), "ws-b must restore c2");
});
it("clearSavedWorkspaceSnapshot discards the snapshot so restore is a no-op", () => {
it("clearSavedCommunitySnapshot discards the snapshot so restore is a no-op", () => {
syncAgentTurnsFromEvents(AGENT, [
makeEvent({ seq: 1, turnId: "t1", channelId: "c1" }),
]);
saveActiveAgentTurnsForWorkspace("ws-a");
saveActiveAgentTurnsForCommunity("ws-a");
// Simulate workspace deletion: GC the snapshot before switching back.
clearSavedWorkspaceSnapshot("ws-a");
// Simulate community deletion: GC the snapshot before switching back.
clearSavedCommunitySnapshot("ws-a");
resetActiveAgentTurnsStore();
restoreActiveAgentTurnsForWorkspace("ws-a");
restoreActiveAgentTurnsForCommunity("ws-a");
assert.equal(
getActiveTurnsForAgent(AGENT).length,
0,
"restore must be no-op after clearSavedWorkspaceSnapshot",
"restore must be no-op after clearSavedCommunitySnapshot",
);
});
});

View File

@@ -561,7 +561,7 @@ export function useActiveAgentTurnsBridge(
/**
* Clears all live turn state (active turns, offsets, watermarks, tombstones).
* Intentionally preserves `savedByWorkspace` — workspace-switch snapshots
* Intentionally preserves `savedByCommunity` — community-switch snapshots
* must survive the reset that runs between save and restore.
*/
export function resetActiveAgentTurnsStore() {
@@ -575,7 +575,7 @@ export function resetActiveAgentTurnsStore() {
}
// ---------------------------------------------------------------------------
// Workspace-switch save / restore
// Community-switch save / restore
// ---------------------------------------------------------------------------
type TurnsStoreSnapshot = {
@@ -585,11 +585,11 @@ type TurnsStoreSnapshot = {
terminals: Map<string, Map<string, number>>;
};
/** Per-workspace snapshots. Keyed by workspace ID. */
const savedByWorkspace = new Map<string, TurnsStoreSnapshot>();
/** Per-community snapshots. Keyed by community ID. */
const savedByCommunity = new Map<string, TurnsStoreSnapshot>();
/**
* Snapshot the current active-turns state under `workspaceId` so it can be
* Snapshot the current active-turns state under `communityId` so it can be
* restored when the user switches back. If both the turns map and the
* tombstone map are empty there is nothing worth restoring — discard any
* previously-saved snapshot instead.
@@ -597,9 +597,9 @@ const savedByWorkspace = new Map<string, TurnsStoreSnapshot>();
* Deep-clones all four maps so subsequent mutations on the live maps do not
* corrupt the snapshot.
*/
export function saveActiveAgentTurnsForWorkspace(workspaceId: string): void {
export function saveActiveAgentTurnsForCommunity(communityId: string): void {
if (activeTurnsByAgent.size === 0 && terminalAtByAgent.size === 0) {
savedByWorkspace.delete(workspaceId);
savedByCommunity.delete(communityId);
return;
}
@@ -624,17 +624,17 @@ export function saveActiveAgentTurnsForWorkspace(workspaceId: string): void {
terminals.set(agentKey, new Map(tombstones));
}
savedByWorkspace.set(workspaceId, { turns, offsets, watermarks, terminals });
savedByCommunity.set(communityId, { turns, offsets, watermarks, terminals });
}
/**
* Restore a previously saved active-turns snapshot for `workspaceId` into the
* Restore a previously saved active-turns snapshot for `communityId` into the
* module maps. No-op when no snapshot exists.
*
* Clears all four module maps before writing so the function is
* self-contained — it replaces rather than merging, regardless of whether the
* caller pre-cleared. At the primary call site (`useWorkspaceInit`) the maps
* are already empty after `resetWorkspaceState()`, but this guard makes the
* caller pre-cleared. At the primary call site (`useCommunityInit`) the maps
* are already empty after `resetCommunityState()`, but this guard makes the
* contract explicit.
*
* Refreshes `lastActivityAt` on every restored turn so the prune interval
@@ -642,13 +642,13 @@ export function saveActiveAgentTurnsForWorkspace(workspaceId: string): void {
* threshold). New observer events arriving after restore will update
* `lastActivityAt` normally via `recordActivity`.
*
* Consumes the snapshot (deletes it from `savedByWorkspace`) — a given
* workspace's snapshot is only usable once per round-trip.
* Consumes the snapshot (deletes it from `savedByCommunity`) — a given
* community's snapshot is only usable once per round-trip.
*/
export function restoreActiveAgentTurnsForWorkspace(workspaceId: string): void {
const snap = savedByWorkspace.get(workspaceId);
export function restoreActiveAgentTurnsForCommunity(communityId: string): void {
const snap = savedByCommunity.get(communityId);
if (!snap) return;
savedByWorkspace.delete(workspaceId);
savedByCommunity.delete(communityId);
// Clear before writing so this is a replace, not a merge.
activeTurnsByAgent.clear();
@@ -684,10 +684,10 @@ export function restoreActiveAgentTurnsForWorkspace(workspaceId: string): void {
}
/**
* Discard the saved turn-state snapshot for a workspace that has been
* Discard the saved turn-state snapshot for a community that has been
* permanently deleted so the entry doesn't sit in memory indefinitely.
* Call this alongside the other relay-specific GC in `removeWorkspace`.
* Call this alongside the other relay-specific GC in `removeCommunity`.
*/
export function clearSavedWorkspaceSnapshot(workspaceId: string): void {
savedByWorkspace.delete(workspaceId);
export function clearSavedCommunitySnapshot(communityId: string): void {
savedByCommunity.delete(communityId);
}

View File

@@ -338,7 +338,7 @@ export function useChannelWorkingAgentPubkeys(
);
}
/** Workspace-switch reset (see resetWorkspaceState in useWorkspaceInit). */
/** Community-switch reset (see resetCommunityState in useCommunityInit). */
export function resetAgentWorkingSignal() {
typingByChannel.clear();
invalidateCaches();

View File

@@ -37,7 +37,7 @@ export type FriendlyAgentLastError =
* test asserts the user-facing string verbatim rather than a fuzzy pattern.
*/
export const RELAY_MESH_DENIED_COPY =
"Relay mesh denied this agent — check your relay membership.";
"Community access denied this agent — check its community membership.";
export const MODEL_NOT_FOUND_COPY =
"The configured model is not available — open agent settings and select a different one from the dropdown.";

View File

@@ -241,7 +241,7 @@ export function EditAgentAdvancedFields({
disabled={disabled}
id="edit-agent-relay-url"
onChange={(event) => onRelayUrlChange(event.target.value)}
placeholder="Leave blank to use the workspace relay"
placeholder="Leave blank to use the community relay"
value={relayUrl}
/>
</div>

View File

@@ -85,7 +85,7 @@ export function ManagedAgentRow({
// When the harness recovered a meaningful error string from the agent's
// log tail (Max's seam in `managed_agents/storage.rs`), promote it to
// user-visible copy below the process detail. Specifically renders the
// friendly "Relay mesh denied this agent — check your relay membership."
// friendly "Community access denied this agent — check its community membership."
// for auth failures so the user knows it's a membership thing, not a
// crash. Generic exits stay verbatim so we don't lie about other failures.
const friendlyError = friendlyAgentLastError(

View File

@@ -459,7 +459,7 @@ function SectionHeader({
<div>
<h3 className="text-sm font-semibold tracking-tight">Agents</h3>
<p className="text-sm text-secondary-foreground/75">
Agents in this workspace.
Agents in this community.
</p>
</div>
<input

View File

@@ -4,8 +4,8 @@ import * as React from "react";
* User preference for animating transcript activity rows as they stream in.
* Persisted in localStorage and shared across every transcript surface
* (thread panel, profile live-activity preview). This is a device-level UI
* preference, not workspace-scoped data, so it is intentionally not reset on
* workspace switch.
* preference, not community-scoped data, so it is intentionally not reset on
* community switch.
*/
const STORAGE_KEY = "buzz:animate-transcript-activity";

View File

@@ -4,8 +4,8 @@ import * as React from "react";
* User preference for showing a timestamp under each activity row — polished
* transcript rows and raw JSON-RPC cards alike. Persisted in localStorage and
* shared across every transcript surface, same as the animation preference.
* This is a device-level UI preference, not workspace-scoped data, so it is
* intentionally not reset on workspace switch. Defaults off to keep the feed
* This is a device-level UI preference, not community-scoped data, so it is
* intentionally not reset on community switch. Defaults off to keep the feed
* compact.
*/
const STORAGE_KEY = "buzz:show-transcript-timestamps";

View File

@@ -30,9 +30,9 @@ const KnownAgentPubkeysContext = React.createContext<ReadonlySet<string>>(
* published set's identity changes, which `useStableSet` restricts to actual
* membership change.
*
* Mounted once per workspace inside `AppReady` (under the workspace-keyed
* `WorkspaceQueryProvider` remount boundary), so the observers tear down and
* re-create on workspace switch without a `resetWorkspaceState()` entry.
* Mounted once per community inside `AppReady` (under the community-keyed
* `CommunityQueryProvider` remount boundary), so the observers tear down and
* re-create on community switch without a `resetCommunityState()` entry.
*/
export function KnownAgentPubkeysProvider({
children,
@@ -56,7 +56,7 @@ export function KnownAgentPubkeysProvider({
}
/**
* The workspace-scoped "known agent pubkeys" baseline: locally managed agents
* The community-scoped "known agent pubkeys" baseline: locally managed agents
* ∪ relay-registered agents, normalised via `normalizePubkey`. Home-feed agent
* activity is intentionally excluded: it is a display category, not an
* authenticated agent-identity source.

View File

@@ -1,13 +1,13 @@
/**
* Per-relay cache of the last successfully fetched channel list.
*
* Each workspace mounts a fresh React-Query client, so switching workspaces
* Each community mounts a fresh React-Query client, so switching communities
* (or switching back to one just visited) starts cold and blocks the sidebar
* on a multi-round-trip `get_channels()`. This module persists the last-known
* channel list per relay so the sidebar can paint instantly from the snapshot
* while the live fetch revalidates in the background.
*
* Keyed per relay URL (not workspace id) so equivalent URL formatting maps to
* Keyed per relay URL (not community id) so equivalent URL formatting maps to
* one slot and one relay's list never bleeds into another.
*/
@@ -60,7 +60,7 @@ export function writeChannelSnapshot(
}
/**
* Removes the channel snapshot for a relay. Called when a workspace is removed.
* Removes the channel snapshot for a relay. Called when a community is removed.
*/
export function removeChannelSnapshotForRelay(relayUrl: string): void {
try {

View File

@@ -2,7 +2,7 @@
* Per-pubkey localStorage record store for channels manually marked unread via
* right-click → "mark unread". Keyed by channelId (not thread-root). Persisted
* so the sidebar badge survives reload and the rail observer can read it for
* inactive workspaces.
* inactive communities.
*
* Each entry stores the channel's own NIP-RS read marker (unix seconds) at the
* moment mark-unread was invoked, or null if no marker existed yet. The rail

View File

@@ -31,7 +31,7 @@ import type {
SetChannelTopicInput,
UpdateChannelInput,
} from "@/shared/api/types";
import { useWorkspaces } from "@/features/workspaces/useWorkspaces";
import { useCommunities } from "@/features/communities/useCommunities";
import {
readChannelSnapshot,
writeChannelSnapshot,
@@ -188,8 +188,8 @@ function setChannelArchivedState(
}
export function useChannelsQuery(options?: { enabled?: boolean }) {
const { activeWorkspace } = useWorkspaces();
const relayUrl = activeWorkspace?.relayUrl ?? null;
const { activeCommunity } = useCommunities();
const relayUrl = activeCommunity?.relayUrl ?? null;
return useQuery({
enabled: options?.enabled ?? true,

View File

@@ -385,7 +385,7 @@ export function AgentSessionThreadPanel({
canStopCurrentTurn
? "Interrupt the current ACP turn without stopping the agent process."
: isWorking
? "Only locally managed agents can be interrupted from this workspace."
? "Only locally managed agents can be interrupted from this community."
: "Available while the agent is working."
}
>

View File

@@ -126,7 +126,7 @@ export function ChannelManagementModerationActions({
<AlertDialogHeader>
<AlertDialogTitle>Delete channel?</AlertDialogTitle>
<AlertDialogDescription>
Delete {resolvedChannelName} from the workspace list. This
Delete {resolvedChannelName} from the community list. This
action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>

View File

@@ -353,20 +353,20 @@ export function ChannelScreen({
relayAgents,
});
// Agent set for ChannelPane's own consumers (DM huddle member resolution,
// the agents list): the workspace-scoped baseline shared by every surface,
// the agents list): the community-scoped baseline shared by every surface,
// widened with channel-member roles and this screen's profile lookup.
// Message rows no longer take this — MessageRow derives agent-ness itself
// from useKnownAgentPubkeys + per-pubkey profile checks.
const workspaceAgentPubkeys = useKnownAgentPubkeys();
const communityAgentPubkeys = useKnownAgentPubkeys();
const agentPubkeys = React.useMemo(() => {
const pubkeys = new Set([...workspaceAgentPubkeys, ...knownAgentPubkeys]);
const pubkeys = new Set([...communityAgentPubkeys, ...knownAgentPubkeys]);
for (const [pubkey, profile] of Object.entries(messageProfiles)) {
if (profile.isAgent) {
pubkeys.add(normalizePubkey(pubkey));
}
}
return pubkeys;
}, [knownAgentPubkeys, messageProfiles, workspaceAgentPubkeys]);
}, [knownAgentPubkeys, messageProfiles, communityAgentPubkeys]);
const personasQuery = usePersonasQuery();
const { personaLookup, respondToLookup } = React.useMemo(() => {
const agents = managedAgentsQuery.data ?? [];

View File

@@ -8,7 +8,7 @@ import {
useUnbanMemberMutation,
useUntimeoutMemberMutation,
} from "@/features/moderation/hooks";
import { useMyRelayMembershipQuery } from "@/features/relay-members/hooks";
import { useMyRelayMembershipQuery } from "@/features/community-members/hooks";
import { isTimedOut } from "@/features/moderation/lib/restrictionState";
import type { ChannelMember } from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey";

View File

@@ -131,18 +131,18 @@ test("round-trip: A→B→A — A rows absent in B, A rows return on switch back
const relayA = "wss://relay-a.example.com";
const relayB = "wss://relay-b.example.com";
// Workspace A accumulates two activity rows.
// Community A accumulates two activity rows.
const itemsA = [
makeItem("reply-a1", "channel-a1", 1),
makeItem("reply-a2", "channel-a2", 2),
];
writeActivityToStorage(pubkey, relayA, itemsA);
// Workspace B has its own rows.
// Community B has its own rows.
const itemsB = [makeItem("reply-b1", "channel-b1", 3)];
writeActivityToStorage(pubkey, relayB, itemsB);
// While in workspace B, reading relay A gives B's rows (not A's).
// While in community B, reading relay A gives B's rows (not A's).
const inB = readActivityFromStorage(pubkey, relayB);
assert.equal(inB.length, 1);
assert.equal(inB[0].id, "reply-b1");

View File

@@ -148,7 +148,7 @@ export function useUnreadChannels(
const activeChannelId = activeChannel?.id ?? null;
const normalizedPubkey = pubkey?.toLowerCase() ?? null;
// Scoped relay key for activity storage; empty string when relay not yet known
// so rows from an unknown relay never load into the wrong workspace.
// so rows from an unknown relay never load into the wrong community.
const normalizedRelayUrl = relayUrlOption
? normalizeRelayUrl(relayUrlOption)
: "";
@@ -192,7 +192,7 @@ export function useUnreadChannels(
// stores the channel's NIP-RS read marker (unix seconds) at force-time, or
// null if no marker existed yet. Persisted to localStorage
// (buzz-forced-unread.v1) so it survives reload and is visible to the rail
// observer for inactive workspaces.
// observer for inactive communities.
const forcedUnreadRef = React.useRef<ForcedUnreadMap>(
pubkey ? forcedUnreadStore.read(pubkey) : {},
);

View File

@@ -1,10 +1,10 @@
/**
* Local cache of workspace icons keyed by relay URL, so the rail renders
* Local cache of community icons keyed by relay URL, so the rail renders
* icons instantly on boot and for unreachable relays. Values are small
* data-URLs (or http URLs) from the relay's NIP-11 `icon` field.
*/
const ICON_CACHE_KEY = "buzz-workspace-icons";
const ICON_CACHE_KEY = "buzz-community-icons";
function loadCache(): Record<string, string> {
try {
@@ -19,11 +19,11 @@ function loadCache(): Record<string, string> {
return {};
}
export function loadCachedWorkspaceIcon(relayUrl: string): string | null {
export function loadCachedCommunityIcon(relayUrl: string): string | null {
return loadCache()[relayUrl] ?? null;
}
export function saveCachedWorkspaceIcon(
export function saveCachedCommunityIcon(
relayUrl: string,
icon: string | null,
): void {

View File

@@ -3,8 +3,8 @@ import test, { beforeEach } from "node:test";
import {
chunkChannelContexts,
publishWorkspaceReadState,
} from "./workspaceMarkRead.ts";
publishCommunityReadState,
} from "./communityMarkRead.ts";
const PUBKEY = "a".repeat(64);
const READ_AT = 1_700_000_000;
@@ -93,14 +93,14 @@ function makeClient({ channelIds, metadata }) {
};
}
test("publishWorkspaceReadState publishes one read-state event covering observed channels", async () => {
test("publishCommunityReadState publishes one read-state event covering observed channels", async () => {
const client = makeClient({
channelIds: ["chan-1", "chan-2"],
metadata: [metadataEvent("chan-1"), metadataEvent("chan-2")],
});
const encrypted = [];
await publishWorkspaceReadState({
await publishCommunityReadState({
client,
pubkey: PUBKEY,
relayUrl: "wss://relay.example",
@@ -138,13 +138,13 @@ test("publishWorkspaceReadState publishes one read-state event covering observed
assert.deepEqual(blob.contexts, { "chan-1": READ_AT, "chan-2": READ_AT });
});
test("publishWorkspaceReadState skips archived channels and publishes nothing when none remain", async () => {
test("publishCommunityReadState skips archived channels and publishes nothing when none remain", async () => {
const client = makeClient({
channelIds: ["chan-1"],
metadata: [metadataEvent("chan-1", [["archived", "true"]])],
});
await publishWorkspaceReadState({
await publishCommunityReadState({
client,
pubkey: PUBKEY,
relayUrl: "wss://relay.example",
@@ -158,7 +158,7 @@ test("publishWorkspaceReadState skips archived channels and publishes nothing wh
assert.equal(client.published.length, 0);
});
test("publishWorkspaceReadState reuses stable slot ids so blobs are replaceable", async () => {
test("publishCommunityReadState reuses stable slot ids so blobs are replaceable", async () => {
const makeArgs = (client) => ({
client,
pubkey: PUBKEY,
@@ -180,12 +180,12 @@ test("publishWorkspaceReadState reuses stable slot ids so blobs are replaceable"
channelIds: ["chan-1"],
metadata: [metadataEvent("chan-1")],
});
await publishWorkspaceReadState(makeArgs(clientA));
await publishCommunityReadState(makeArgs(clientA));
const clientB = makeClient({
channelIds: ["chan-1"],
metadata: [metadataEvent("chan-1")],
});
await publishWorkspaceReadState(makeArgs(clientB));
await publishCommunityReadState(makeArgs(clientB));
const dTag = (event) => event.tags.find((tag) => tag[0] === "d")[1];
assert.equal(dTag(clientA.published[0]), dTag(clientB.published[0]));

View File

@@ -1,6 +1,6 @@
import { READ_STATE_MAX_PLAINTEXT_BYTES } from "@/features/channels/readState/readStateFormat";
import type { Workspace } from "@/features/workspaces/types";
import { fetchObservedChannels } from "@/features/workspaces/workspaceUnreadObserver";
import type { Community } from "@/features/communities/types";
import { fetchObservedChannels } from "@/features/communities/communityUnreadObserver";
import { withReadOnlyRelayClient } from "@/shared/api/readOnlyRelayClient";
import type { RelaySubscriptionFilter } from "@/shared/api/relayClientShared";
import { nip44EncryptToSelf, signRelayEvent } from "@/shared/api/tauri";
@@ -69,11 +69,11 @@ export function chunkChannelContexts(
/**
* Publish read-state blobs marking every observed channel on an INACTIVE
* workspace's relay as read-now. Grow-only NIP-RS semantics: other devices
* community's relay as read-now. Grow-only NIP-RS semantics: other devices
* max-merge these markers, so publishing "everything read at `nowSeconds`"
* can never regress a marker that is already further ahead.
*/
export async function publishWorkspaceReadState(args: {
export async function publishCommunityReadState(args: {
client: MarkReadRelay;
pubkey: string;
relayUrl: string;
@@ -116,15 +116,15 @@ export async function publishWorkspaceReadState(args: {
}
}
export async function markWorkspaceRead(
workspace: Workspace,
export async function markCommunityRead(
community: Community,
pubkey: string,
): Promise<void> {
await withReadOnlyRelayClient(workspace.relayUrl, (client) =>
publishWorkspaceReadState({
await withReadOnlyRelayClient(community.relayUrl, (client) =>
publishCommunityReadState({
client,
pubkey,
relayUrl: workspace.relayUrl,
relayUrl: community.relayUrl,
}),
);
}

View File

@@ -0,0 +1,61 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
clearCommunityStorage,
migrateLegacyCommunityStorage,
} from "./communityStorage.ts";
function createMemoryStorage(initial = {}) {
const values = new Map(Object.entries(initial));
return {
getItem: (key) => values.get(key) ?? null,
setItem: (key, value) => values.set(key, String(value)),
removeItem: (key) => values.delete(key),
clear: () => values.clear(),
key: (index) => Array.from(values.keys())[index] ?? null,
get length() {
return values.size;
},
};
}
test("migrateLegacyCommunityStorage promotes current Buzz workspace state", () => {
const storage = createMemoryStorage({
"buzz-workspaces": '[{"id":"current"}]',
"buzz-active-workspace-id": "current",
});
migrateLegacyCommunityStorage(storage);
assert.equal(storage.getItem("buzz-communities"), '[{"id":"current"}]');
assert.equal(storage.getItem("buzz-active-community-id"), "current");
});
test("migrateLegacyCommunityStorage does not overwrite new community state", () => {
const storage = createMemoryStorage({
"buzz-communities": '[{"id":"new"}]',
"buzz-active-community-id": "new",
"buzz-workspaces": '[{"id":"old"}]',
"buzz-active-workspace-id": "old",
});
migrateLegacyCommunityStorage(storage);
assert.equal(storage.getItem("buzz-communities"), '[{"id":"new"}]');
assert.equal(storage.getItem("buzz-active-community-id"), "new");
});
test("clearCommunityStorage removes new and legacy state", () => {
const storage = createMemoryStorage({
"buzz-communities": "new",
"buzz-active-community-id": "new",
"buzz-workspaces": "old",
"buzz-active-workspace-id": "old",
});
clearCommunityStorage(storage);
migrateLegacyCommunityStorage(storage);
assert.equal(storage.length, 0);
});

View File

@@ -1,9 +1,11 @@
import type { Workspace } from "./types";
import type { Community } from "./types";
import { homeDir } from "@tauri-apps/api/path";
import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota";
const WORKSPACES_KEY = "buzz-workspaces";
const ACTIVE_WORKSPACE_KEY = "buzz-active-workspace-id";
const COMMUNITIES_KEY = "buzz-communities";
const ACTIVE_COMMUNITY_KEY = "buzz-active-community-id";
const LEGACY_WORKSPACES_KEY = "buzz-workspaces";
const LEGACY_ACTIVE_WORKSPACE_KEY = "buzz-active-workspace-id";
/**
* Expand a leading `~` to the user's home directory. The backend rejects
@@ -27,9 +29,27 @@ export async function expandTilde(input: string): Promise<string | undefined> {
return trimmed;
}
export function loadWorkspaces(): Workspace[] {
export function migrateLegacyCommunityStorage(
storage: Storage = localStorage,
): void {
if (storage.getItem(COMMUNITIES_KEY) === null) {
const legacyCommunities = storage.getItem(LEGACY_WORKSPACES_KEY);
if (legacyCommunities !== null) {
storage.setItem(COMMUNITIES_KEY, legacyCommunities);
}
}
if (storage.getItem(ACTIVE_COMMUNITY_KEY) === null) {
const legacyActiveCommunity = storage.getItem(LEGACY_ACTIVE_WORKSPACE_KEY);
if (legacyActiveCommunity !== null) {
storage.setItem(ACTIVE_COMMUNITY_KEY, legacyActiveCommunity);
}
}
}
export function loadCommunities(): Community[] {
try {
const raw = localStorage.getItem(WORKSPACES_KEY);
migrateLegacyCommunityStorage();
const raw = localStorage.getItem(COMMUNITIES_KEY);
if (!raw) {
return [];
}
@@ -51,9 +71,9 @@ export function loadWorkspaces(): Workspace[] {
return rest;
}
return entry;
}) as Workspace[];
}) as Community[];
if (didStrip) {
setLocalStorageItemWithRecovery(WORKSPACES_KEY, JSON.stringify(cleaned));
setLocalStorageItemWithRecovery(COMMUNITIES_KEY, JSON.stringify(cleaned));
}
return cleaned;
} catch {
@@ -61,21 +81,24 @@ export function loadWorkspaces(): Workspace[] {
}
}
export function saveWorkspaces(workspaces: Workspace[]): void {
setLocalStorageItemWithRecovery(WORKSPACES_KEY, JSON.stringify(workspaces));
export function saveCommunities(communities: Community[]): void {
setLocalStorageItemWithRecovery(COMMUNITIES_KEY, JSON.stringify(communities));
}
export function clearWorkspaceStorage(): void {
localStorage.removeItem(WORKSPACES_KEY);
localStorage.removeItem(ACTIVE_WORKSPACE_KEY);
export function clearCommunityStorage(storage: Storage = localStorage): void {
storage.removeItem(COMMUNITIES_KEY);
storage.removeItem(ACTIVE_COMMUNITY_KEY);
storage.removeItem(LEGACY_WORKSPACES_KEY);
storage.removeItem(LEGACY_ACTIVE_WORKSPACE_KEY);
}
export function loadActiveWorkspaceId(): string | null {
return localStorage.getItem(ACTIVE_WORKSPACE_KEY);
export function loadActiveCommunityId(): string | null {
migrateLegacyCommunityStorage();
return localStorage.getItem(ACTIVE_COMMUNITY_KEY);
}
export function saveActiveWorkspaceId(id: string): void {
setLocalStorageItemWithRecovery(ACTIVE_WORKSPACE_KEY, id);
export function saveActiveCommunityId(id: string): void {
setLocalStorageItemWithRecovery(ACTIVE_COMMUNITY_KEY, id);
}
export function normalizeRelayUrl(url: string): string {
@@ -85,7 +108,7 @@ export function normalizeRelayUrl(url: string): string {
return url;
}
export function deriveWorkspaceName(relayUrl: string): string {
export function deriveCommunityName(relayUrl: string): string {
try {
const url = new URL(
relayUrl.replace("ws://", "http://").replace("wss://", "https://"),
@@ -105,25 +128,25 @@ export function deriveWorkspaceName(relayUrl: string): string {
}
return host;
} catch {
return "Workspace";
return "Community";
}
}
export function initFirstWorkspace(
export function initFirstCommunity(
relayUrl: string,
pubkey: string,
name?: string,
): Workspace {
): Community {
const normalizedUrl = normalizeRelayUrl(relayUrl);
const trimmedName = name?.trim();
const workspace: Workspace = {
const community: Community = {
id: crypto.randomUUID(),
name: trimmedName || deriveWorkspaceName(normalizedUrl),
name: trimmedName || deriveCommunityName(normalizedUrl),
relayUrl: normalizedUrl,
pubkey,
addedAt: new Date().toISOString(),
};
saveWorkspaces([workspace]);
saveActiveWorkspaceId(workspace.id);
return workspace;
saveCommunities([community]);
saveActiveCommunityId(community.id);
return community;
}

View File

@@ -4,9 +4,9 @@ import test from "node:test";
import {
extractHiddenDmIds,
extractMemberChannelIds,
fetchWorkspaceUnread,
fetchCommunityUnread,
resolveObservedChannels,
} from "./workspaceUnreadObserver.ts";
} from "./communityUnreadObserver.ts";
const PUBKEY = "a".repeat(64);
const OTHER = "b".repeat(64);
@@ -122,7 +122,7 @@ test("extractHiddenDmIds reads h tags from latest visibility snapshot", () => {
);
});
test("fetchWorkspaceUnread returns dot and mention count without total unread count", async () => {
test("fetchCommunityUnread returns dot and mention count without total unread count", async () => {
const relay = relayFor([
// 1. member events
() => [
@@ -169,7 +169,7 @@ test("fetchWorkspaceUnread returns dot and mention count without total unread co
],
]);
const result = await fetchWorkspaceUnread({
const result = await fetchCommunityUnread({
client: relay,
pubkey: PUBKEY,
nowSeconds: 100,
@@ -182,7 +182,7 @@ test("fetchWorkspaceUnread returns dot and mention count without total unread co
assert.equal(relay.requests.at(-1)["#p"][0], PUBKEY);
});
test("fetchWorkspaceUnread ignores self-authored and read thread/message events", async () => {
test("fetchCommunityUnread ignores self-authored and read thread/message events", async () => {
const threadReply = event({
id: "reply".padEnd(64, "0"),
created_at: 50,
@@ -251,7 +251,7 @@ test("fetchWorkspaceUnread ignores self-authored and read thread/message events"
() => [threadReply, selfMention],
]);
const result = await fetchWorkspaceUnread({
const result = await fetchCommunityUnread({
client: relay,
pubkey: PUBKEY,
nowSeconds: 100,
@@ -263,7 +263,7 @@ test("fetchWorkspaceUnread ignores self-authored and read thread/message events"
assert.deepEqual(result, { hasUnread: false, mentionCount: 0 });
});
test("fetchWorkspaceUnread excludes muted-only channel — returns hasUnread:false mentionCount:0", async () => {
test("fetchCommunityUnread excludes muted-only channel — returns hasUnread:false mentionCount:0", async () => {
const MUTED_CHANNEL = "muted-channel-1";
const relay = relayFor([
@@ -299,7 +299,7 @@ test("fetchWorkspaceUnread excludes muted-only channel — returns hasUnread:fal
// No per-channel fetches should follow — muted channel is skipped
]);
const result = await fetchWorkspaceUnread({
const result = await fetchCommunityUnread({
client: relay,
pubkey: PUBKEY,
nowSeconds: 100,
@@ -311,7 +311,7 @@ test("fetchWorkspaceUnread excludes muted-only channel — returns hasUnread:fal
assert.deepEqual(result, { hasUnread: false, mentionCount: 0 });
});
test("fetchWorkspaceUnread counts unmuted channel but skips muted channel", async () => {
test("fetchCommunityUnread counts unmuted channel but skips muted channel", async () => {
const UNMUTED_CHANNEL = "channel-unmuted";
const MUTED_CHANNEL = "channel-muted";
@@ -373,7 +373,7 @@ test("fetchWorkspaceUnread counts unmuted channel but skips muted channel", asyn
],
]);
const result = await fetchWorkspaceUnread({
const result = await fetchCommunityUnread({
client: relay,
pubkey: PUBKEY,
nowSeconds: 100,
@@ -385,7 +385,7 @@ test("fetchWorkspaceUnread counts unmuted channel but skips muted channel", asyn
assert.deepEqual(result, { hasUnread: true, mentionCount: 1 });
});
test("fetchWorkspaceUnread treats decryption failure as empty mutes set", async () => {
test("fetchCommunityUnread treats decryption failure as empty mutes set", async () => {
const relay = relayFor([
// 1. member events
() => [
@@ -428,7 +428,7 @@ test("fetchWorkspaceUnread treats decryption failure as empty mutes set", async
() => [],
]);
const result = await fetchWorkspaceUnread({
const result = await fetchCommunityUnread({
client: relay,
pubkey: PUBKEY,
nowSeconds: 100,
@@ -443,7 +443,7 @@ test("fetchWorkspaceUnread treats decryption failure as empty mutes set", async
assert.deepEqual(result, { hasUnread: true, mentionCount: 0 });
});
test("fetchWorkspaceUnread treats absent mutes blob as empty mutes set", async () => {
test("fetchCommunityUnread treats absent mutes blob as empty mutes set", async () => {
const relay = relayFor([
// 1. member events
() => [
@@ -481,7 +481,7 @@ test("fetchWorkspaceUnread treats absent mutes blob as empty mutes set", async (
() => [],
]);
const result = await fetchWorkspaceUnread({
const result = await fetchCommunityUnread({
client: relay,
pubkey: PUBKEY,
nowSeconds: 100,
@@ -544,10 +544,10 @@ function baseRelay(unreadEvent, mutesPayload = null) {
]);
}
test("fetchWorkspaceUnread threaded reply in untracked root → hasUnread:false", async () => {
test("fetchCommunityUnread threaded reply in untracked root → hasUnread:false", async () => {
const relay = baseRelay(threadedReplyEvent());
const result = await fetchWorkspaceUnread({
const result = await fetchCommunityUnread({
client: relay,
pubkey: PUBKEY,
nowSeconds: 100,
@@ -560,10 +560,10 @@ test("fetchWorkspaceUnread threaded reply in untracked root → hasUnread:false"
assert.deepEqual(result, { hasUnread: false, mentionCount: 0 });
});
test("fetchWorkspaceUnread threaded reply in participatedRootIds → hasUnread:true", async () => {
test("fetchCommunityUnread threaded reply in participatedRootIds → hasUnread:true", async () => {
const relay = baseRelay(threadedReplyEvent());
const result = await fetchWorkspaceUnread({
const result = await fetchCommunityUnread({
client: relay,
pubkey: PUBKEY,
nowSeconds: 100,
@@ -577,7 +577,7 @@ test("fetchWorkspaceUnread threaded reply in participatedRootIds → hasUnread:t
assert.deepEqual(result, { hasUnread: true, mentionCount: 0 });
});
test("fetchWorkspaceUnread #p-mention reply in untracked root → hasUnread:true (mention overrides)", async () => {
test("fetchCommunityUnread #p-mention reply in untracked root → hasUnread:true (mention overrides)", async () => {
// A @mention of the user bypasses the follow/participation gate
const relay = baseRelay(
threadedReplyEvent({
@@ -586,7 +586,7 @@ test("fetchWorkspaceUnread #p-mention reply in untracked root → hasUnread:true
}),
);
const result = await fetchWorkspaceUnread({
const result = await fetchCommunityUnread({
client: relay,
pubkey: PUBKEY,
nowSeconds: 100,
@@ -598,7 +598,7 @@ test("fetchWorkspaceUnread #p-mention reply in untracked root → hasUnread:true
assert.deepEqual(result, { hasUnread: true, mentionCount: 0 });
});
test("fetchWorkspaceUnread top-level post → hasUnread:true (no thread gate)", async () => {
test("fetchCommunityUnread top-level post → hasUnread:true (no thread gate)", async () => {
// Top-level posts have no parentId — shouldNotifyForEvent returns true
const relay = baseRelay(
event({
@@ -608,7 +608,7 @@ test("fetchWorkspaceUnread top-level post → hasUnread:true (no thread gate)",
}),
);
const result = await fetchWorkspaceUnread({
const result = await fetchCommunityUnread({
client: relay,
pubkey: PUBKEY,
nowSeconds: 100,
@@ -620,12 +620,12 @@ test("fetchWorkspaceUnread top-level post → hasUnread:true (no thread gate)",
assert.deepEqual(result, { hasUnread: true, mentionCount: 0 });
});
test("fetchWorkspaceUnread threaded reply whose root is in mutedRootIds → hasUnread:false", async () => {
test("fetchCommunityUnread threaded reply whose root is in mutedRootIds → hasUnread:false", async () => {
const relay = baseRelay(
threadedReplyEvent({ id: "muted-reply".padEnd(64, "0") }),
);
const result = await fetchWorkspaceUnread({
const result = await fetchCommunityUnread({
client: relay,
pubkey: PUBKEY,
nowSeconds: 100,
@@ -727,10 +727,10 @@ function quietRelayWithReadState(readAtSeconds) {
]);
}
test("fetchWorkspaceUnread forced-unread channel lights rail dot (hasUnread:true)", async () => {
test("fetchCommunityUnread forced-unread channel lights rail dot (hasUnread:true)", async () => {
const relay = quietRelay();
const result = await fetchWorkspaceUnread({
const result = await fetchCommunityUnread({
client: relay,
pubkey: PUBKEY,
nowSeconds: 100,
@@ -744,7 +744,7 @@ test("fetchWorkspaceUnread forced-unread channel lights rail dot (hasUnread:true
assert.deepEqual(result, { hasUnread: true, mentionCount: 0 });
});
test("fetchWorkspaceUnread forced-unread channel not in member list → hasUnread:false", async () => {
test("fetchCommunityUnread forced-unread channel not in member list → hasUnread:false", async () => {
// The channel is marked forced-unread but the user is not a member of ANY
// channel — forced-unread is not consulted when the channel set is empty.
const relay = relayFor([
@@ -752,7 +752,7 @@ test("fetchWorkspaceUnread forced-unread channel not in member list → hasUnrea
() => [],
]);
const result = await fetchWorkspaceUnread({
const result = await fetchCommunityUnread({
client: relay,
pubkey: PUBKEY,
nowSeconds: 100,
@@ -765,7 +765,7 @@ test("fetchWorkspaceUnread forced-unread channel not in member list → hasUnrea
assert.deepEqual(result, { hasUnread: false, mentionCount: 0 });
});
test("fetchWorkspaceUnread forced-unread channel that is also muted → hasUnread:false", async () => {
test("fetchCommunityUnread forced-unread channel that is also muted → hasUnread:false", async () => {
const relay = relayFor([
// 1. member events
() => [
@@ -799,7 +799,7 @@ test("fetchWorkspaceUnread forced-unread channel that is also muted → hasUnrea
// No per-channel fetches expected — muted channel is skipped
]);
const result = await fetchWorkspaceUnread({
const result = await fetchCommunityUnread({
client: relay,
pubkey: PUBKEY,
nowSeconds: 100,
@@ -813,7 +813,7 @@ test("fetchWorkspaceUnread forced-unread channel that is also muted → hasUnrea
assert.deepEqual(result, { hasUnread: false, mentionCount: 0 });
});
test("fetchWorkspaceUnread readForcedUnread returns empty map → falls through to relay gate", async () => {
test("fetchCommunityUnread readForcedUnread returns empty map → falls through to relay gate", async () => {
// No forced-unread, but there IS a real unread event → hasUnread:true via relay
const relay = relayFor([
// 1. member events
@@ -852,7 +852,7 @@ test("fetchWorkspaceUnread readForcedUnread returns empty map → falls through
() => [],
]);
const result = await fetchWorkspaceUnread({
const result = await fetchCommunityUnread({
client: relay,
pubkey: PUBKEY,
nowSeconds: 100,
@@ -865,11 +865,11 @@ test("fetchWorkspaceUnread readForcedUnread returns empty map → falls through
assert.deepEqual(result, { hasUnread: true, mentionCount: 0 });
});
test("fetchWorkspaceUnread forced-unread + synced marker advanced PAST baseline → hasUnread:false", async () => {
test("fetchCommunityUnread forced-unread + synced marker advanced PAST baseline → hasUnread:false", async () => {
// markerAtWhenForced = 50, observed readAt = 100 (100 > 50 → cross-device read wins)
const relay = quietRelayWithReadState(100);
const result = await fetchWorkspaceUnread({
const result = await fetchCommunityUnread({
client: relay,
pubkey: PUBKEY,
nowSeconds: 200,
@@ -883,11 +883,11 @@ test("fetchWorkspaceUnread forced-unread + synced marker advanced PAST baseline
assert.deepEqual(result, { hasUnread: false, mentionCount: 0 });
});
test("fetchWorkspaceUnread forced-unread + synced marker NOT advanced past baseline → hasUnread:true", async () => {
test("fetchCommunityUnread forced-unread + synced marker NOT advanced past baseline → hasUnread:true", async () => {
// markerAtWhenForced = 100, observed readAt = 100 (equal → force still stands)
const relay = quietRelayWithReadState(100);
const result = await fetchWorkspaceUnread({
const result = await fetchCommunityUnread({
client: relay,
pubkey: PUBKEY,
nowSeconds: 200,
@@ -901,12 +901,12 @@ test("fetchWorkspaceUnread forced-unread + synced marker NOT advanced past basel
assert.deepEqual(result, { hasUnread: true, mentionCount: 0 });
});
test("fetchWorkspaceUnread forced-unread with null baseline + synced marker present → hasUnread:false", async () => {
test("fetchCommunityUnread forced-unread with null baseline + synced marker present → hasUnread:false", async () => {
// markerAtWhenForced = null (no marker at force-time), but readAt = 50 now
// A cross-device read appeared after the force → do NOT light the dot
const relay = quietRelayWithReadState(50);
const result = await fetchWorkspaceUnread({
const result = await fetchCommunityUnread({
client: relay,
pubkey: PUBKEY,
nowSeconds: 200,

View File

@@ -18,7 +18,7 @@ import {
mutedChannelIdsFromStore,
parseMutePayload,
} from "@/features/sidebar/lib/channelMutesStorage";
import type { Workspace } from "@/features/workspaces/types";
import type { Community } from "@/features/communities/types";
import { withReadOnlyRelayClient } from "@/shared/api/readOnlyRelayClient";
import type { RelaySubscriptionFilter } from "@/shared/api/relayClientShared";
import { nip44DecryptFromSelf } from "@/shared/api/tauri";
@@ -34,8 +34,8 @@ import {
const KIND_NIP29_GROUP_METADATA = 39000;
const KIND_NIP29_GROUP_MEMBERS = 39002;
// Stores for thread-relationship sets. Keyed by pubkey only (no relay/workspace),
// so they read correctly from the same origin regardless of which workspace is active.
// Stores for thread-relationship sets. Keyed by pubkey only (no relay/community),
// so they read correctly from the same origin regardless of which community is active.
const participationStore = makeRootIdStore("buzz-thread-participation.v1");
const authoredStore = makeRootIdStore("buzz-thread-authored.v1");
const mutedRootsStore = makeRootIdStore("buzz-thread-muted.v1");
@@ -88,12 +88,12 @@ const MENTION_COUNT_LIMIT = 100;
const READ_STATE_FETCH_LIMIT = 500;
const READ_STATE_HORIZON_SECONDS = 7 * 24 * 60 * 60;
export type WorkspaceUnreadObserverResult = {
export type CommunityUnreadObserverResult = {
hasUnread: boolean;
mentionCount: number;
};
type WorkspaceUnreadRelay = {
type CommunityUnreadRelay = {
fetchEvents(filter: RelaySubscriptionFilter): Promise<RelayEvent[]>;
};
@@ -109,7 +109,7 @@ type ObservedChannel = {
* unread poll and "mark all as read" must agree on.
*/
export async function fetchObservedChannels(
client: WorkspaceUnreadRelay,
client: CommunityUnreadRelay,
pubkey: string,
): Promise<ObservedChannel[]> {
const memberEvents = await client.fetchEvents({
@@ -143,24 +143,24 @@ export async function fetchObservedChannels(
);
}
export async function pollWorkspaceUnread(
workspace: Workspace,
export async function pollCommunityUnread(
community: Community,
pubkey: string,
): Promise<WorkspaceUnreadObserverResult> {
return withReadOnlyRelayClient(workspace.relayUrl, (client) =>
fetchWorkspaceUnread({ client, pubkey }),
): Promise<CommunityUnreadObserverResult> {
return withReadOnlyRelayClient(community.relayUrl, (client) =>
fetchCommunityUnread({ client, pubkey }),
);
}
export async function fetchWorkspaceUnread(args: {
client: WorkspaceUnreadRelay;
export async function fetchCommunityUnread(args: {
client: CommunityUnreadRelay;
pubkey: string;
nowSeconds?: number;
decryptReadState?: (ciphertext: string) => Promise<string>;
decryptMutes?: (ciphertext: string) => Promise<string>;
readThreadRelationships?: (pubkey: string) => ThreadRelationships;
readForcedUnread?: (pubkey: string) => ForcedUnreadMap;
}): Promise<WorkspaceUnreadObserverResult> {
}): Promise<CommunityUnreadObserverResult> {
const { client, pubkey } = args;
const normalizedPubkey = pubkey.toLowerCase();
const nowSeconds = args.nowSeconds ?? Math.floor(Date.now() / 1_000);
@@ -235,8 +235,8 @@ export async function fetchWorkspaceUnread(args: {
// synced read marker has NOT advanced past the stored baseline. This
// prevents stale forced-unread from lighting the rail after a cross-device
// read has covered the channel (the drain path in useUnreadChannels only
// runs while the workspace is active, so the store may not be pruned for
// inactive workspaces).
// runs while the community is active, so the store may not be pruned for
// inactive communities).
if (!hasUnread && Object.hasOwn(forcedUnreadMap, channel.id)) {
const markerAtWhenForced = forcedUnreadMap[channel.id];
if (

View File

@@ -0,0 +1,153 @@
import assert from "node:assert/strict";
import test from "node:test";
import { applyLegacyCommunityStorage } from "./legacyCommunityStorage.ts";
function createMemoryStorage(initial = {}) {
const values = new Map(Object.entries(initial));
return {
getItem(key) {
return values.has(key) ? values.get(key) : null;
},
setItem(key, value) {
values.set(key, String(value));
},
removeItem(key) {
values.delete(key);
},
clear() {
values.clear();
},
key(index) {
return Array.from(values.keys())[index] ?? null;
},
get length() {
return values.size;
},
};
}
const legacyCommunities = JSON.stringify([
{
id: "legacy-community",
name: "Existing relay",
relayUrl: "wss://relay.example.com",
addedAt: "2026-06-12T00:00:00.000Z",
},
]);
const currentCommunities = JSON.stringify([
{
id: "current-community",
name: "Current relay",
relayUrl: "wss://current.example.com",
addedAt: "2026-06-12T00:00:00.000Z",
},
]);
const localhostCommunities = JSON.stringify([
{
id: "local-community",
name: "Local Dev",
relayUrl: "ws://localhost:3000",
addedAt: "2026-06-12T00:00:00.000Z",
},
]);
test("applyLegacyCommunityStorage seeds missing communities and active community", () => {
const storage = createMemoryStorage();
applyLegacyCommunityStorage(
{
workspaces: legacyCommunities,
activeWorkspaceId: "legacy-community",
onboardingCompletions: [],
},
storage,
);
assert.equal(storage.getItem("buzz-communities"), legacyCommunities);
assert.equal(storage.getItem("buzz-active-community-id"), "legacy-community");
});
test("applyLegacyCommunityStorage preserves existing non-local Buzz communities", () => {
const storage = createMemoryStorage({
"buzz-communities": currentCommunities,
"buzz-active-community-id": "current-community",
});
applyLegacyCommunityStorage(
{
workspaces: legacyCommunities,
activeWorkspaceId: "legacy-community",
onboardingCompletions: [],
},
storage,
);
assert.equal(storage.getItem("buzz-communities"), currentCommunities);
assert.equal(
storage.getItem("buzz-active-community-id"),
"current-community",
);
});
test("applyLegacyCommunityStorage replaces broken localhost first-run community", () => {
const storage = createMemoryStorage({
"buzz-communities": localhostCommunities,
"buzz-active-community-id": "local-community",
});
applyLegacyCommunityStorage(
{
workspaces: legacyCommunities,
activeWorkspaceId: "legacy-community",
onboardingCompletions: [],
},
storage,
);
assert.equal(storage.getItem("buzz-communities"), legacyCommunities);
assert.equal(storage.getItem("buzz-active-community-id"), "legacy-community");
});
test("applyLegacyCommunityStorage treats trailing-slash localhost as broken", () => {
const storage = createMemoryStorage({
"buzz-communities": JSON.stringify([
{
id: "local-community",
name: "Local Dev",
relayUrl: "ws://localhost:3000/",
addedAt: "2026-06-12T00:00:00.000Z",
},
]),
"buzz-active-community-id": "local-community",
});
applyLegacyCommunityStorage(
{
workspaces: legacyCommunities,
activeWorkspaceId: "legacy-community",
onboardingCompletions: [],
},
storage,
);
assert.equal(storage.getItem("buzz-communities"), legacyCommunities);
assert.equal(storage.getItem("buzz-active-community-id"), "legacy-community");
});
test("applyLegacyCommunityStorage migrates onboarding completion keys", () => {
const storage = createMemoryStorage();
applyLegacyCommunityStorage(
{
workspaces: null,
activeWorkspaceId: null,
onboardingCompletions: [{ pubkey: "abc123", value: "true" }],
},
storage,
);
assert.equal(storage.getItem("buzz-onboarding-complete.v1:abc123"), "true");
});

View File

@@ -0,0 +1,141 @@
import { invokeTauri } from "@/shared/api/tauri";
import { migrateLegacyCommunityStorage } from "./communityStorage";
const BUZZ_COMMUNITIES_KEY = "buzz-communities";
const BUZZ_ACTIVE_COMMUNITY_KEY = "buzz-active-community-id";
const BUZZ_ONBOARDING_COMPLETION_STORAGE_KEY_PREFIX =
"buzz-onboarding-complete.v1:";
const LOCAL_DEV_RELAY_URLS = new Set([
"ws://localhost:3000",
"ws://127.0.0.1:3000",
]);
type LegacyCommunityStorageSnapshot = {
workspaces: string | null;
activeWorkspaceId: string | null;
onboardingCompletions: Array<{
pubkey: string;
value: string;
}>;
};
type StoredCommunity = {
relayUrl?: unknown;
};
function parseCommunityList(raw: string | null): StoredCommunity[] | null {
if (!raw) {
return null;
}
try {
const parsed: unknown = JSON.parse(raw);
return Array.isArray(parsed) ? (parsed as StoredCommunity[]) : null;
} catch {
return null;
}
}
function normalizeRelayUrl(relayUrl: string) {
return relayUrl.trim().replace(/\/$/, "");
}
function hasOnlyLocalDevCommunity(raw: string | null): boolean {
const communities = parseCommunityList(raw);
return (
communities?.length === 1 &&
typeof communities[0]?.relayUrl === "string" &&
LOCAL_DEV_RELAY_URLS.has(normalizeRelayUrl(communities[0].relayUrl))
);
}
function hasNonLocalCurrentCommunities(raw: string | null): boolean {
const communities = parseCommunityList(raw);
return (
communities !== null &&
communities.length > 0 &&
!hasOnlyLocalDevCommunity(raw)
);
}
function shouldWriteLegacyCommunities({
currentCommunitiesRaw,
legacyCommunitiesRaw,
}: {
currentCommunitiesRaw: string | null;
legacyCommunitiesRaw: string | null;
}) {
const legacyCommunities = parseCommunityList(legacyCommunitiesRaw);
if (!legacyCommunities || legacyCommunities.length === 0) {
return false;
}
return !hasNonLocalCurrentCommunities(currentCommunitiesRaw);
}
export function applyLegacyCommunityStorage(
legacyStorage: LegacyCommunityStorageSnapshot,
storage: Storage = window.localStorage,
): void {
const currentCommunitiesRaw = storage.getItem(BUZZ_COMMUNITIES_KEY);
const shouldWriteCommunities = shouldWriteLegacyCommunities({
currentCommunitiesRaw,
legacyCommunitiesRaw: legacyStorage.workspaces,
});
if (shouldWriteCommunities && legacyStorage.workspaces) {
storage.setItem(BUZZ_COMMUNITIES_KEY, legacyStorage.workspaces);
}
const currentActiveCommunityId = storage.getItem(BUZZ_ACTIVE_COMMUNITY_KEY);
if (
legacyStorage.activeWorkspaceId &&
(!currentActiveCommunityId || shouldWriteCommunities)
) {
storage.setItem(BUZZ_ACTIVE_COMMUNITY_KEY, legacyStorage.activeWorkspaceId);
}
for (const completion of legacyStorage.onboardingCompletions) {
const key = `${BUZZ_ONBOARDING_COMPLETION_STORAGE_KEY_PREFIX}${completion.pubkey}`;
if (storage.getItem(key) === null) {
storage.setItem(key, completion.value);
}
}
}
/**
* Seed Buzz localStorage from legacy Sprout WebKit localStorage before the app
* renders providers that read community state. The native command reads the old
* app identifier's WebKit SQLite database; this frontend step writes only when
* Buzz does not already have community state, except for the known broken
* Sprout→Buzz first-run handoff that created a single localhost community.
*/
export async function migrateLegacyCommunityStorageBeforeRender(): Promise<void> {
if (typeof window === "undefined") {
return;
}
migrateLegacyCommunityStorage(window.localStorage);
const currentCommunitiesRaw =
window.localStorage.getItem(BUZZ_COMMUNITIES_KEY);
const hasCurrentActiveCommunity = window.localStorage.getItem(
BUZZ_ACTIVE_COMMUNITY_KEY,
);
if (
currentCommunitiesRaw &&
hasCurrentActiveCommunity &&
!hasOnlyLocalDevCommunity(currentCommunitiesRaw)
) {
return;
}
try {
applyLegacyCommunityStorage(
await invokeTauri<LegacyCommunityStorageSnapshot>(
"get_legacy_workspace_storage",
),
);
} catch (error) {
console.warn("Failed to read legacy Sprout community storage.", error);
}
}

View File

@@ -1,8 +1,8 @@
/**
* Downscale an image file to a small square data-URL for use as a workspace
* Downscale an image file to a small square data-URL for use as a community
* icon. The result is inlined into the kind:9033 command (and the NIP-11
* document the relay serves) so it renders
* across workspaces without cross-relay media fetches; the relay caps icon
* across communities without cross-relay media fetches; the relay caps icon
* data-URLs at 96 KB, and 128px WebP/PNG output stays far under that.
*/

View File

@@ -1,10 +1,10 @@
export type Workspace = {
export type Community = {
id: string;
name: string;
relayUrl: string;
token?: string;
/**
* The pubkey associated with the active identity at the time the workspace
* The pubkey associated with the active identity at the time the community
* was created. Display-only — auth always uses the persisted `identity.key`
* file resolved at startup, never this field.
*/
@@ -20,7 +20,7 @@ export type Workspace = {
/**
* @deprecated Never read. Kept on the type so old localStorage entries
* deserialise without errors. New entries never set this field, and
* `loadWorkspaces()` strips it on read so it cannot leak forward. The
* `loadCommunities()` strips it on read so it cannot leak forward. The
* authoritative private key is the on-disk `identity.key` file.
*/
nsec?: never;

View File

@@ -1,11 +1,11 @@
import * as React from "react";
import type { Workspace } from "@/features/workspaces/types";
import type { Community } from "@/features/communities/types";
import {
deriveWorkspaceName,
deriveCommunityName,
expandTilde,
normalizeRelayUrl,
} from "@/features/workspaces/workspaceStorage";
} from "@/features/communities/communityStorage";
import {
inviteErrorMessage,
isInviteExpiredError,
@@ -22,17 +22,17 @@ import {
} from "@/shared/ui/dialog";
import { Input } from "@/shared/ui/input";
type AddWorkspaceDialogProps = {
type AddCommunityDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (workspace: Workspace) => void;
onSubmit: (community: Community) => void;
};
export function AddWorkspaceDialog({
export function AddCommunityDialog({
open,
onOpenChange,
onSubmit,
}: AddWorkspaceDialogProps) {
}: AddCommunityDialogProps) {
const [name, setName] = React.useState("");
const [relayUrl, setRelayUrl] = React.useState("");
const [token, setToken] = React.useState("");
@@ -72,7 +72,7 @@ export function AddWorkspaceDialog({
}
// If the relay handed out an invite code, claim it before saving the
// workspace — a closed relay would otherwise reject the connection.
// community — a closed relay would otherwise reject the connection.
const normalizedRelayUrl = normalizeRelayUrl(relayUrl.trim());
if (inviteCode.trim()) {
try {
@@ -88,16 +88,16 @@ export function AddWorkspaceDialog({
}
}
const workspace: Workspace = {
const community: Community = {
id: crypto.randomUUID(),
name: name.trim() || deriveWorkspaceName(relayUrl.trim()),
name: name.trim() || deriveCommunityName(relayUrl.trim()),
relayUrl: normalizedRelayUrl,
token: token.trim() || undefined,
reposDir: expandedReposDir,
addedAt: new Date().toISOString(),
};
onSubmit(workspace);
onSubmit(community);
handleClose();
},
[name, relayUrl, token, inviteCode, reposDir, onSubmit, handleClose],
@@ -107,9 +107,9 @@ export function AddWorkspaceDialog({
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Add Workspace</DialogTitle>
<DialogTitle>Add Community</DialogTitle>
<DialogDescription>
Connect to another Buzz relay. Each workspace has its own channels,
Connect to another Buzz relay. Each community has its own channels,
messages, and identity.
</DialogDescription>
</DialogHeader>
@@ -146,7 +146,7 @@ export function AddWorkspaceDialog({
<Input
id="ws-name"
onChange={(e) => setName(e.target.value)}
placeholder="My Workspace"
placeholder="My Community"
type="text"
value={name}
/>
@@ -223,7 +223,7 @@ export function AddWorkspaceDialog({
</p>
</div>
<p className="text-xs text-muted-foreground">
Workspaces share your active identity. To use a different key,
Communities share your active identity. To use a different key,
import it on the profile step (or in settings).
</p>
<div className="flex justify-end gap-2 pt-2">
@@ -231,7 +231,7 @@ export function AddWorkspaceDialog({
Cancel
</Button>
<Button disabled={!relayUrl.trim()} type="submit">
Add Workspace
Add Community
</Button>
</div>
</form>

View File

@@ -3,38 +3,38 @@ import * as React from "react";
import { toast } from "sonner";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { downscaleIconToDataUrl } from "@/features/workspaces/lib/downscaleIcon";
import { downscaleIconToDataUrl } from "@/features/communities/lib/downscaleIcon";
import {
useActiveWorkspaceIcon,
workspaceIconQueryKey,
} from "@/features/workspaces/useWorkspaceIcons";
import { useWorkspaces } from "@/features/workspaces/useWorkspaces";
import { workspaceInitials } from "@/features/sidebar/ui/WorkspaceRail";
import { setWorkspaceIcon } from "@/shared/api/workspaceProfile";
useActiveCommunityIcon,
communityIconQueryKey,
} from "@/features/communities/useCommunityIcons";
import { useCommunities } from "@/features/communities/useCommunities";
import { communityInitials } from "@/features/sidebar/ui/CommunityRail";
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 workspace icon editor, rendered inside the Relay Access
* 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.
*/
export function WorkspaceIconSettingsCard() {
const { activeWorkspace } = useWorkspaces();
const relayUrl = activeWorkspace?.relayUrl;
const iconQuery = useActiveWorkspaceIcon(relayUrl);
export function CommunityIconSettingsCard() {
const { activeCommunity } = useCommunities();
const relayUrl = activeCommunity?.relayUrl;
const iconQuery = useActiveCommunityIcon(relayUrl);
const queryClient = useQueryClient();
const inputRef = React.useRef<HTMLInputElement | null>(null);
const mutation = useMutation({
mutationFn: (icon: string) => setWorkspaceIcon(icon),
mutationFn: (icon: string) => setCommunityIcon(icon),
onSuccess: async (_data, icon) => {
if (relayUrl) {
queryClient.setQueryData(workspaceIconQueryKey(relayUrl), icon || null);
queryClient.setQueryData(communityIconQueryKey(relayUrl), icon || null);
await queryClient.invalidateQueries({
queryKey: workspaceIconQueryKey(relayUrl),
queryKey: communityIconQueryKey(relayUrl),
});
}
},
@@ -48,12 +48,12 @@ export function WorkspaceIconSettingsCard() {
try {
const dataUrl = await downscaleIconToDataUrl(file);
await mutation.mutateAsync(dataUrl);
toast.success("Workspace icon updated");
toast.success("Community icon updated");
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Failed to update the workspace icon.",
: "Failed to update the community icon.",
);
}
}
@@ -61,31 +61,31 @@ export function WorkspaceIconSettingsCard() {
async function handleClear() {
try {
await mutation.mutateAsync("");
toast.success("Workspace icon removed");
toast.success("Community icon removed");
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Failed to remove the workspace icon.",
: "Failed to remove the community icon.",
);
}
}
const icon = iconQuery.data ?? null;
const initials = activeWorkspace
? workspaceInitials(activeWorkspace.name)
const initials = activeCommunity
? communityInitials(activeCommunity.name)
: "";
return (
<div className="space-y-1.5" data-testid="workspace-icon-settings">
<span className="text-sm font-medium">Workspace icon</span>
<div className="space-y-1.5" data-testid="community-icon-settings">
<span className="text-sm font-medium">Community icon</span>
<div className="flex items-center gap-3">
<span className="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-sidebar-accent/60 text-sm font-semibold text-sidebar-foreground/80">
{icon ? (
<img
alt="Workspace icon"
alt="Community icon"
className="h-full w-full object-cover"
data-testid="workspace-icon-preview"
data-testid="community-icon-preview"
src={icon}
/>
) : (
@@ -94,7 +94,7 @@ export function WorkspaceIconSettingsCard() {
</span>
<div className="flex items-center gap-2">
<Button
data-testid="workspace-icon-upload"
data-testid="community-icon-upload"
disabled={mutation.isPending}
onClick={() => inputRef.current?.click()}
type="button"
@@ -105,7 +105,7 @@ export function WorkspaceIconSettingsCard() {
</Button>
{icon ? (
<Button
data-testid="workspace-icon-remove"
data-testid="community-icon-remove"
disabled={mutation.isPending}
onClick={() => void handleClear()}
type="button"
@@ -129,7 +129,7 @@ export function WorkspaceIconSettingsCard() {
/>
</div>
<p className="text-xs text-muted-foreground">
Shown to every member in the workspace rail and switcher. Square images
Shown to every member in the community rail and switcher. Square images
work best.
</p>
</div>

View File

@@ -8,7 +8,7 @@ import {
} from "lucide-react";
import * as React from "react";
import type { Workspace } from "@/features/workspaces/types";
import type { Community } from "@/features/communities/types";
import {
DropdownMenu,
DropdownMenuContent,
@@ -29,8 +29,8 @@ import {
isRelayConnectionDegraded,
useRelayConnection,
} from "@/shared/api/useRelayConnection";
import { useActiveWorkspaceIcon } from "@/features/workspaces/useWorkspaceIcons";
import { EditWorkspaceDialog } from "./EditWorkspaceDialog";
import { useActiveCommunityIcon } from "@/features/communities/useCommunityIcons";
import { EditCommunityDialog } from "./EditCommunityDialog";
const CONNECTION_STATE_LABEL: Record<ConnectionState, string> = {
idle: "Not connected",
@@ -41,20 +41,20 @@ const CONNECTION_STATE_LABEL: Record<ConnectionState, string> = {
disconnected: "Disconnected from relay",
};
type WorkspaceSwitcherProps = {
activeWorkspace: Workspace | null;
workspaces: Workspace[];
type CommunitySwitcherProps = {
activeCommunity: Community | null;
communities: Community[];
variant?: "sidebar" | "profile" | "profile-menu";
onSwitchWorkspace: (id: string) => void;
onAddWorkspace: () => void;
onUpdateWorkspace: (
onSwitchCommunity: (id: string) => void;
onAddCommunity: () => void;
onUpdateCommunity: (
id: string,
updates: Partial<Pick<Workspace, "name" | "relayUrl" | "token">>,
updates: Partial<Pick<Community, "name" | "relayUrl" | "token">>,
) => void;
onRemoveWorkspace: (id: string) => void;
onRemoveCommunity: (id: string) => void;
};
export function WorkspaceEmojiIcon({
export function CommunityEmojiIcon({
className,
iconUrl,
}: {
@@ -83,23 +83,23 @@ export function WorkspaceEmojiIcon({
);
}
export function WorkspaceSwitcher({
activeWorkspace,
workspaces,
export function CommunitySwitcher({
activeCommunity,
communities,
variant = "sidebar",
onSwitchWorkspace,
onAddWorkspace,
onUpdateWorkspace,
onRemoveWorkspace,
}: WorkspaceSwitcherProps) {
const [editingWorkspace, setEditingWorkspace] =
React.useState<Workspace | null>(null);
onSwitchCommunity,
onAddCommunity,
onUpdateCommunity,
onRemoveCommunity,
}: CommunitySwitcherProps) {
const [editingCommunity, setEditingCommunity] =
React.useState<Community | null>(null);
const [dropdownOpen, setDropdownOpen] = React.useState(false);
const profileMenuHoverTimer = React.useRef<number | null>(null);
const connectionState = useRelayConnection();
const degraded = isRelayConnectionDegraded(connectionState);
const connectionLabel = CONNECTION_STATE_LABEL[connectionState];
const activeIconQuery = useActiveWorkspaceIcon(activeWorkspace?.relayUrl);
const activeIconQuery = useActiveCommunityIcon(activeCommunity?.relayUrl);
const activeIcon = activeIconQuery.data ?? null;
const isProfileVariant = variant === "profile";
@@ -162,7 +162,7 @@ export function WorkspaceSwitcher({
</TooltipContent>
</Tooltip>
) : (
<WorkspaceEmojiIcon
<CommunityEmojiIcon
className={
isProfileVariant
? "flex w-5 shrink-0 items-center justify-center rounded-md border border-sidebar-border/70 bg-sidebar-accent/40 text-2xs"
@@ -178,7 +178,7 @@ export function WorkspaceSwitcher({
: "min-w-0 flex-1 truncate font-medium"
}
>
{activeWorkspace?.name ?? "No workspace"}
{activeCommunity?.name ?? "No community"}
</span>
{variant === "profile-menu" ? (
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
@@ -203,11 +203,11 @@ export function WorkspaceSwitcher({
aria-haspopup="menu"
aria-label={
degraded
? `${activeWorkspace?.name ?? "Workspace"} — ${connectionLabel}`
: "Switch workspace"
? `${activeCommunity?.name ?? "Community"} — ${connectionLabel}`
: "Switch community"
}
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="workspace-switcher"
data-testid="community-switcher"
onMouseEnter={() => scheduleProfileMenu(true)}
onMouseLeave={() => scheduleProfileMenu(false)}
role="menuitem"
@@ -224,37 +224,37 @@ export function WorkspaceSwitcher({
side="right"
sideOffset={0}
>
<div aria-label="Workspaces" role="menu">
{workspaces.map((workspace) => (
<div aria-label="Communities" role="menu">
{communities.map((community) => (
<div
className="group flex min-h-9 items-center rounded-lg transition-colors hover:bg-muted/50 focus-within:bg-muted/50"
key={workspace.id}
key={community.id}
>
<button
className="flex min-h-9 min-w-0 flex-1 items-center gap-2 py-2 pl-2 pr-1 text-left text-sm outline-hidden focus:outline-none"
onClick={() => {
onSwitchWorkspace(workspace.id);
onSwitchCommunity(community.id);
setDropdownOpen(false);
}}
role="menuitem"
type="button"
>
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
{activeWorkspace?.id === workspace.id ? (
{activeCommunity?.id === community.id ? (
<Check className="h-4 w-4 text-primary" />
) : null}
</span>
<span className="min-w-0 flex-1 truncate">
{workspace.name}
{community.name}
</span>
</button>
<button
aria-label={`Edit ${workspace.name}`}
aria-label={`Edit ${community.name}`}
className="mr-1 flex h-5 w-5 shrink-0 items-center justify-center rounded opacity-0 hover:bg-muted/70 group-hover:opacity-100 group-focus-within:opacity-100"
onClick={(e) => {
e.stopPropagation();
setDropdownOpen(false);
setEditingWorkspace(workspace);
setEditingCommunity(community);
}}
type="button"
>
@@ -267,13 +267,13 @@ export function WorkspaceSwitcher({
className="flex min-h-9 w-full items-center gap-2 rounded-lg py-2 pl-2 pr-4 text-left text-sm outline-hidden transition-colors hover:bg-muted/50 focus:bg-muted/50 focus:outline-none focus-visible:bg-muted/50 focus-visible:outline-none"
onClick={() => {
setDropdownOpen(false);
onAddWorkspace();
onAddCommunity();
}}
role="menuitem"
type="button"
>
<Plus className="h-4 w-4" />
<span>Add Workspace</span>
<span>Add Community</span>
</button>
</div>
</PopoverContent>
@@ -291,11 +291,11 @@ export function WorkspaceSwitcher({
<button
aria-label={
degraded
? `${activeWorkspace?.name ?? "Workspace"} — ${connectionLabel}`
: "Switch workspace"
? `${activeCommunity?.name ?? "Community"} — ${connectionLabel}`
: "Switch community"
}
className="flex min-w-0 max-w-full items-center gap-1.5 rounded-md py-0.5 text-left text-xs text-sidebar-foreground/50 outline-hidden transition-colors hover:text-sidebar-foreground focus:outline-none focus-visible:outline-none data-[state=open]:text-sidebar-foreground"
data-testid="workspace-switcher"
data-testid="community-switcher"
type="button"
>
{triggerContent}
@@ -304,11 +304,11 @@ export function WorkspaceSwitcher({
<SidebarMenuButton
aria-label={
degraded
? `${activeWorkspace?.name ?? "Workspace"} — ${connectionLabel}`
? `${activeCommunity?.name ?? "Community"} — ${connectionLabel}`
: undefined
}
className="h-auto gap-2 rounded-xl px-2.5 py-2 data-[state=open]:bg-sidebar-accent"
data-testid="workspace-switcher"
data-testid="community-switcher"
type="button"
>
{triggerContent}
@@ -322,28 +322,28 @@ export function WorkspaceSwitcher({
side={variant === "profile" ? "top" : "bottom"}
sideOffset={4}
>
{workspaces.map((workspace) => (
{communities.map((community) => (
<DropdownMenuItem
key={workspace.id}
key={community.id}
className="group flex items-center gap-2 pr-1"
onSelect={() => {
onSwitchWorkspace(workspace.id);
onSwitchCommunity(community.id);
}}
>
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
{activeWorkspace?.id === workspace.id ? (
{activeCommunity?.id === community.id ? (
<Check className="h-4 w-4 text-primary" />
) : null}
</span>
<span className="min-w-0 flex-1 truncate">{workspace.name}</span>
<span className="min-w-0 flex-1 truncate">{community.name}</span>
<button
aria-label={`Edit ${workspace.name}`}
aria-label={`Edit ${community.name}`}
className="flex h-5 w-5 shrink-0 items-center justify-center rounded opacity-0 hover:bg-accent group-hover:opacity-100 group-focus:opacity-100"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setDropdownOpen(false);
setEditingWorkspace(workspace);
setEditingCommunity(community);
}}
type="button"
>
@@ -352,9 +352,9 @@ export function WorkspaceSwitcher({
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onAddWorkspace}>
<DropdownMenuItem onSelect={onAddCommunity}>
<Plus className="h-4 w-4" />
<span>Add Workspace</span>
<span>Add Community</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -372,15 +372,15 @@ export function WorkspaceSwitcher({
</SidebarMenu>
)}
<EditWorkspaceDialog
canRemove={workspaces.length > 1}
<EditCommunityDialog
canRemove={communities.length > 1}
onOpenChange={(open) => {
if (!open) setEditingWorkspace(null);
if (!open) setEditingCommunity(null);
}}
onRemove={onRemoveWorkspace}
onSave={onUpdateWorkspace}
open={editingWorkspace !== null}
workspace={editingWorkspace}
onRemove={onRemoveCommunity}
onSave={onUpdateCommunity}
open={editingCommunity !== null}
community={editingCommunity}
/>
</>
);

View File

@@ -1,10 +1,10 @@
import * as React from "react";
import type { Workspace } from "@/features/workspaces/types";
import type { Community } from "@/features/communities/types";
import {
expandTilde,
normalizeRelayUrl,
} from "@/features/workspaces/workspaceStorage";
} from "@/features/communities/communityStorage";
import { validateReposDir } from "@/shared/api/tauri";
import { Button } from "@/shared/ui/button";
import {
@@ -16,44 +16,44 @@ import {
} from "@/shared/ui/dialog";
import { Input } from "@/shared/ui/input";
type EditWorkspaceDialogProps = {
workspace: Workspace | null;
type EditCommunityDialogProps = {
community: Community | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onSave: (
id: string,
updates: Partial<
Pick<Workspace, "name" | "relayUrl" | "token" | "reposDir">
Pick<Community, "name" | "relayUrl" | "token" | "reposDir">
>,
) => void;
onRemove?: (id: string) => void;
canRemove?: boolean;
};
export function EditWorkspaceDialog({
workspace,
export function EditCommunityDialog({
community,
open,
onOpenChange,
onSave,
onRemove,
canRemove,
}: EditWorkspaceDialogProps) {
}: 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<string | null>(null);
// Sync form state when the dialog opens with a workspace
// Sync form state when the dialog opens with a community
React.useEffect(() => {
if (workspace && open) {
setName(workspace.name);
setRelayUrl(workspace.relayUrl);
setToken(workspace.token ?? "");
setReposDir(workspace.reposDir ?? "");
if (community && open) {
setName(community.name);
setRelayUrl(community.relayUrl);
setToken(community.token ?? "");
setReposDir(community.reposDir ?? "");
setReposDirError(null);
}
}, [workspace, open]);
}, [community, open]);
const handleClose = React.useCallback(() => {
onOpenChange(false);
@@ -62,26 +62,26 @@ export function EditWorkspaceDialog({
const handleSubmit = React.useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
if (!workspace || !relayUrl.trim()) {
if (!community || !relayUrl.trim()) {
return;
}
const updates: Partial<
Pick<Workspace, "name" | "relayUrl" | "token" | "reposDir">
Pick<Community, "name" | "relayUrl" | "token" | "reposDir">
> = {};
const trimmedName = name.trim();
if (trimmedName && trimmedName !== workspace.name) {
if (trimmedName && trimmedName !== community.name) {
updates.name = trimmedName;
}
const normalizedUrl = normalizeRelayUrl(relayUrl.trim());
if (normalizedUrl !== workspace.relayUrl) {
if (normalizedUrl !== community.relayUrl) {
updates.relayUrl = normalizedUrl;
}
const trimmedToken = token.trim() || undefined;
if (trimmedToken !== workspace.token) {
if (trimmedToken !== community.token) {
updates.token = trimmedToken;
}
@@ -92,7 +92,7 @@ export function EditWorkspaceDialog({
// bricking a later boot. Only emit when the resolved value actually
// changed so a no-op edit doesn't trigger a backend re-apply.
const expandedReposDir = await expandTilde(reposDir);
if (expandedReposDir !== workspace.reposDir) {
if (expandedReposDir !== community.reposDir) {
try {
await validateReposDir(expandedReposDir ?? "");
} catch (error) {
@@ -103,22 +103,22 @@ export function EditWorkspaceDialog({
}
if (Object.keys(updates).length > 0) {
onSave(workspace.id, updates);
onSave(community.id, updates);
}
handleClose();
},
[workspace, name, relayUrl, token, reposDir, onSave, handleClose],
[community, name, relayUrl, token, reposDir, onSave, handleClose],
);
const handleRemove = React.useCallback(() => {
if (workspace && onRemove) {
onRemove(workspace.id);
if (community && onRemove) {
onRemove(community.id);
handleClose();
}
}, [workspace, onRemove, handleClose]);
}, [community, onRemove, handleClose]);
if (!workspace) {
if (!community) {
return null;
}
@@ -126,9 +126,9 @@ export function EditWorkspaceDialog({
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Edit Workspace</DialogTitle>
<DialogTitle>Edit Community</DialogTitle>
<DialogDescription>
Update this workspace's name or relay URL.
Update this community's name or relay URL.
</DialogDescription>
</DialogHeader>
<form
@@ -146,7 +146,7 @@ export function EditWorkspaceDialog({
autoFocus
id="edit-ws-name"
onChange={(e) => setName(e.target.value)}
placeholder="My Workspace"
placeholder="My Community"
type="text"
value={name}
/>
@@ -223,7 +223,7 @@ export function EditWorkspaceDialog({
type="button"
variant="ghost"
>
Remove Workspace
Remove Community
</Button>
) : null}
</div>

View File

@@ -17,19 +17,19 @@ import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion";
import { StepProgress } from "@/shared/ui/step-progress";
import { useSystemColorScheme } from "@/shared/theme/useSystemColorScheme";
import type { Workspace } from "../types";
import { initFirstWorkspace } from "../workspaceStorage";
import type { Community } from "../types";
import { initFirstCommunity } from "../communityStorage";
type WelcomeSetupPage = "welcome" | "create-workspace" | "nostr-key";
type WelcomeSetupPage = "welcome" | "create-community" | "nostr-key";
type WelcomeTransitionMode = "initial" | OnboardingTransitionDirection;
type WelcomeSetupProps = {
defaultRelayUrl: string;
initialTransitionMode?: WelcomeTransitionMode;
onComplete: (workspace: Workspace) => void;
onComplete: (community: Community) => void;
};
const DEFAULT_WORKSPACE_HANDOFF_MIN_MS = 200;
const DEFAULT_COMMUNITY_HANDOFF_MIN_MS = 200;
const LOCAL_DEV_RELAY_URLS = new Set([
"ws://localhost:3000",
"ws://127.0.0.1:3000",
@@ -91,23 +91,23 @@ export function WelcomeSetup({
const [page, setPage] = React.useState<WelcomeSetupPage>("welcome");
const [transitionMode, setTransitionMode] =
React.useState<WelcomeTransitionMode>(initialTransitionMode);
const [customWorkspaceName, setCustomWorkspaceName] = React.useState("");
const [customCommunityName, setCustomCommunityName] = React.useState("");
const [customRelayUrl, setCustomRelayUrl] = React.useState("");
const [isConnecting, setIsConnecting] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const systemColorScheme = useSystemColorScheme();
const handleConnect = React.useCallback(
async (relayUrl: string, workspaceName?: string, pubkey?: string) => {
async (relayUrl: string, communityName?: string, pubkey?: string) => {
const trimmedUrl = relayUrl.trim();
if (!trimmedUrl) {
setError("Please enter a workspace URL.");
setError("Please enter a community URL.");
return;
}
if (!workspaceName && isLocalDevRelayUrl(trimmedUrl)) {
setError("Enter your relay URL to join a workspace.");
if (!communityName && isLocalDevRelayUrl(trimmedUrl)) {
setError("Enter your relay URL to join a community.");
setTransitionMode("forward");
setPage("create-workspace");
setPage("create-community");
return;
}
@@ -118,26 +118,26 @@ export function WelcomeSetup({
});
try {
// We snapshot only the pubkey for display purposes (workspace switcher
// We snapshot only the pubkey for display purposes (community switcher
// labels, etc.). The private key lives on disk in `identity.key` and
// is the single source of truth — never copied into localStorage.
const identityPubkey = pubkey ?? (await getIdentity()).pubkey;
const workspace = initFirstWorkspace(
const community = initFirstCommunity(
trimmedUrl,
identityPubkey,
workspaceName,
communityName,
);
if (!workspaceName) {
if (!communityName) {
const elapsedMs = performance.now() - handoffStartedAt;
if (elapsedMs < DEFAULT_WORKSPACE_HANDOFF_MIN_MS) {
await wait(DEFAULT_WORKSPACE_HANDOFF_MIN_MS - elapsedMs);
if (elapsedMs < DEFAULT_COMMUNITY_HANDOFF_MIN_MS) {
await wait(DEFAULT_COMMUNITY_HANDOFF_MIN_MS - elapsedMs);
}
}
// The parent moves this workspace into React state so first-run setup
// The parent moves this community into React state so first-run setup
// can continue without a full page reload.
onComplete(workspace);
onComplete(community);
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to connect. Try again.",
@@ -156,28 +156,28 @@ export function WelcomeSetup({
[defaultRelayUrl, handleConnect],
);
const handleCustomWorkspaceSubmit = React.useCallback(
const handleCustomCommunitySubmit = React.useCallback(
(event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const trimmedName = customWorkspaceName.trim();
const trimmedName = customCommunityName.trim();
const trimmedUrl = customRelayUrl.trim();
if (!trimmedName) {
setError("Please enter a workspace name.");
setError("Please enter a community name.");
return;
}
if (!trimmedUrl) {
setError("Please enter a workspace URL.");
setError("Please enter a community URL.");
return;
}
void handleConnect(trimmedUrl, trimmedName);
},
[customRelayUrl, customWorkspaceName, handleConnect],
[customRelayUrl, customCommunityName, handleConnect],
);
const showCreateWorkspacePage = React.useCallback(() => {
const showCreateCommunityPage = React.useCallback(() => {
setError(null);
setTransitionMode("forward");
setPage("create-workspace");
setPage("create-community");
}, []);
const showNostrKeyPage = React.useCallback(() => {
@@ -232,7 +232,7 @@ export function WelcomeSetup({
Welcome to Buzz
</h1>
<p className="mt-3 max-w-[440px] text-sm leading-6 text-muted-foreground">
Choose your first workspace to get started.
Choose your first community to get started.
</p>
<div className="mt-8 flex w-full flex-col gap-3">
@@ -249,7 +249,7 @@ export function WelcomeSetup({
}}
type="button"
>
Continue with default workspace
Continue with default community
</Button>
)}
@@ -260,12 +260,12 @@ export function WelcomeSetup({
if (isConnecting) {
return;
}
showCreateWorkspacePage();
showCreateCommunityPage();
}}
type="button"
variant="secondary"
>
Join a workspace
Join a community
</Button>
<Button
@@ -291,57 +291,57 @@ export function WelcomeSetup({
</div>
) : null}
</OnboardingSlideTransition>
) : page === "create-workspace" ? (
) : page === "create-community" ? (
<OnboardingSlideTransition
className="flex w-full flex-col items-center text-center"
direction={transitionDirection}
transitionKey={`create-workspace-${transitionDirection}`}
transitionKey={`create-community-${transitionDirection}`}
>
<div className="w-full max-w-[440px]">
<h1 className="text-3xl font-semibold tracking-tight">
Join a workspace
Join a community
</h1>
<p className="mt-3 text-sm leading-6 text-muted-foreground">
Workspaces are where teammates and agents collaborate across
Communities are where teammates and agents collaborate across
channels, DMs, and shared projects.
</p>
</div>
<form
className="mt-8 flex w-full flex-col gap-4"
onSubmit={handleCustomWorkspaceSubmit}
onSubmit={handleCustomCommunitySubmit}
>
<div className="space-y-1.5 text-left">
<label
className="text-sm font-medium text-foreground"
htmlFor="workspace-name"
htmlFor="community-name"
>
Workspace name
Community name
</label>
<Input
autoFocus
className="h-10 bg-background"
id="workspace-name"
id="community-name"
onChange={(event) => {
setCustomWorkspaceName(event.target.value);
setCustomCommunityName(event.target.value);
setError(null);
}}
placeholder="Design team"
type="text"
value={customWorkspaceName}
value={customCommunityName}
/>
</div>
<div className="space-y-1.5 text-left">
<label
className="text-sm font-medium text-foreground"
htmlFor="workspace-url"
htmlFor="community-url"
>
Workspace URL
Community URL
</label>
<Input
className="h-10 bg-background"
id="workspace-url"
id="community-url"
onChange={(event) => {
setCustomRelayUrl(event.target.value);
setError(null);
@@ -357,18 +357,18 @@ export function WelcomeSetup({
className="h-10 w-full"
disabled={
isConnecting ||
!customWorkspaceName.trim() ||
!customCommunityName.trim() ||
!customRelayUrl.trim()
}
type="submit"
>
{isConnecting ? (
<Spinner
aria-label="Joining workspace"
aria-label="Joining community"
className="h-4 w-4 border-2"
/>
) : (
"Join a workspace"
"Join a community"
)}
</Button>

View File

@@ -0,0 +1,203 @@
import {
createContext,
useCallback,
useContext,
useMemo,
useRef,
useState,
} from "react";
import type { ReactNode } from "react";
import type { Community } from "./types";
import {
clearCommunityStorage,
loadActiveCommunityId,
loadCommunities,
saveActiveCommunityId,
saveCommunities,
} from "./communityStorage";
import { removeSelfProfileCachesForRelay } from "@/features/profile/lib/selfProfileStorage";
import { removeChannelSnapshotForRelay } from "@/features/channels/channelSnapshot";
import { removeMessageSnapshotsForRelay } from "@/features/messages/lib/messageSnapshot";
import { clearSavedCommunitySnapshot } from "@/features/agents/activeAgentTurnsStore";
export type UseCommunitiesReturn = {
communities: Community[];
activeCommunity: Community | null;
/** Counter bumped when the active community's config changes (relayUrl/token). */
reinitKey: number;
/** Add a community, deduplicating by relayUrl. Returns the final ID in the list. */
addCommunity: (community: Community) => string;
clearCommunities: () => void;
removeCommunity: (id: string) => void;
switchCommunity: (id: string) => void;
/** Force the active community to re-init (e.g. after a deep-link reconnect). */
reconnectCommunity: () => void;
updateCommunity: (
id: string,
updates: Partial<
Pick<Community, "name" | "relayUrl" | "token" | "pubkey" | "reposDir">
>,
) => void;
};
const CommunitiesContext = createContext<UseCommunitiesReturn | null>(null);
export function CommunitiesProvider({ children }: { children: ReactNode }) {
const value = useCommunitiesInternal();
return (
<CommunitiesContext.Provider value={value}>
{children}
</CommunitiesContext.Provider>
);
}
export function useCommunities(): UseCommunitiesReturn {
const ctx = useContext(CommunitiesContext);
if (!ctx) {
throw new Error("useCommunities must be used within a CommunitiesProvider");
}
return ctx;
}
function useCommunitiesInternal(): UseCommunitiesReturn {
const [communities, setCommunitiesState] =
useState<Community[]>(loadCommunities);
const [activeId, setActiveId] = useState<string | null>(
loadActiveCommunityId,
);
const [reinitKey, setReinitKey] = useState(0);
const communitiesRef = useRef(communities);
communitiesRef.current = communities;
const activeCommunity = useMemo(
() => communities.find((w) => w.id === activeId) ?? communities[0] ?? null,
[communities, activeId],
);
const addCommunity = useCallback((community: Community): string => {
const existing = communitiesRef.current.find(
(w) => w.relayUrl === community.relayUrl,
);
const resolvedId = existing?.id ?? community.id;
setCommunitiesState((prev) => {
const dup = prev.find((w) => w.relayUrl === community.relayUrl);
let next: Community[];
if (dup) {
next = prev.map((w) =>
w.id === dup.id
? {
...w,
name: community.name || w.name,
token: community.token ?? w.token,
pubkey: community.pubkey ?? w.pubkey,
}
: w,
);
} else {
next = [...prev, community];
}
saveCommunities(next);
return next;
});
return resolvedId;
}, []);
const clearCommunities = useCallback(() => {
clearCommunityStorage();
setCommunitiesState([]);
setActiveId(null);
}, []);
const removeCommunity = useCallback(
(id: string) => {
// GC self-profile caches for the removed community's relay. Mirror the
// updater guard (length > 1) so we only GC when removal will actually
// proceed. Runs outside the updater — updaters can execute twice under
// React StrictMode.
if (communities.length > 1) {
const removed = communities.find((w) => w.id === id);
if (removed) {
removeSelfProfileCachesForRelay(removed.relayUrl);
removeChannelSnapshotForRelay(removed.relayUrl);
removeMessageSnapshotsForRelay(removed.relayUrl);
clearSavedCommunitySnapshot(id);
}
}
setCommunitiesState((prev) => {
// Never allow removing the last community
if (prev.length <= 1) {
return prev;
}
const next = prev.filter((w) => w.id !== id);
saveCommunities(next);
// If removing the active community, switch to first remaining
if (activeId === id && next.length > 0) {
saveActiveCommunityId(next[0].id);
setActiveId(next[0].id);
}
return next;
});
},
[activeId, communities],
);
const switchCommunity = useCallback(
(id: string) => {
if (id === activeId) return;
saveActiveCommunityId(id);
setActiveId(id);
},
[activeId],
);
const reconnectCommunity = useCallback(() => {
setReinitKey((k) => k + 1);
}, []);
const updateCommunity = useCallback(
(
id: string,
updates: Partial<
Pick<Community, "name" | "relayUrl" | "token" | "pubkey" | "reposDir">
>,
) => {
setCommunitiesState((prev) => {
// Prevent duplicate relay URLs across communities
if (
updates.relayUrl &&
prev.some((w) => w.id !== id && w.relayUrl === updates.relayUrl)
) {
return prev;
}
const next = prev.map((w) => (w.id === id ? { ...w, ...updates } : w));
saveCommunities(next);
return next;
});
// If the active community's relay URL or token changed, bump reinitKey
// so the React tree remounts with the new config.
if (
id === activeId &&
(updates.relayUrl || updates.token !== undefined)
) {
setReinitKey((k) => k + 1);
}
},
[activeId],
);
return {
communities,
activeCommunity,
reinitKey,
addCommunity,
clearCommunities,
removeCommunity,
switchCommunity,
reconnectCommunity,
updateCommunity,
};
}

View File

@@ -0,0 +1,68 @@
import { useQueries, useQuery } from "@tanstack/react-query";
import { fetchCommunityIcon } from "@/shared/api/communityProfile";
import type { Community } from "./types";
import {
loadCachedCommunityIcon,
saveCachedCommunityIcon,
} from "./communityIconCache";
export const communityIconQueryKey = (relayUrl: string) =>
["communityIcon", relayUrl] as const;
const ICON_STALE_MS = 5 * 60_000;
async function fetchIconForCommunity(
community: Community,
): Promise<string | null> {
const icon = await fetchCommunityIcon(community.relayUrl);
saveCachedCommunityIcon(community.relayUrl, icon);
return icon;
}
function iconQueryOptions(community: Community) {
return {
queryKey: communityIconQueryKey(community.relayUrl),
queryFn: () => fetchIconForCommunity(community),
// Cached icon renders immediately; the fetch still runs and replaces it.
placeholderData: loadCachedCommunityIcon(community.relayUrl),
staleTime: ICON_STALE_MS,
retry: 1,
};
}
/**
* Community icons for the rail, keyed by community id. Each icon is read
* from its relay's NIP-11 document over plain HTTP — active and inactive
* communities alike. Falls back to the localStorage cache (then null →
* initials) when a relay is unreachable.
*/
export function useCommunityIcons(
communities: Community[],
): Record<string, string | null> {
const results = useQueries({
queries: communities.map((community) => iconQueryOptions(community)),
});
const icons: Record<string, string | null> = {};
communities.forEach((community, index) => {
icons[community.id] =
results[index]?.data ?? loadCachedCommunityIcon(community.relayUrl);
});
return icons;
}
/** Icon of the ACTIVE community, for settings preview. */
export function useActiveCommunityIcon(relayUrl: string | undefined) {
return useQuery({
queryKey: communityIconQueryKey(relayUrl ?? ""),
queryFn: async () => {
const icon = await fetchCommunityIcon(relayUrl ?? "");
if (relayUrl) saveCachedCommunityIcon(relayUrl, icon);
return icon;
},
enabled: relayUrl !== undefined,
staleTime: ICON_STALE_MS,
});
}

View File

@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { relayClient } from "@/shared/api/relayClient";
import { applyWorkspace, getDefaultRelayUrl } from "@/shared/api/tauri";
import { applyCommunity, getDefaultRelayUrl } from "@/shared/api/tauri";
import { getIdentity } from "@/shared/api/tauriIdentity";
import { resetMediaCaches } from "@/shared/lib/mediaUrl";
import { clearSearchHitEventCache } from "@/app/navigation/searchHitEventCache";
@@ -9,8 +9,8 @@ import { initDraftStore } from "@/features/messages/lib/useDrafts";
import { resetRenderScopedReactionHydration } from "@/features/messages/lib/renderScopedReactions";
import {
resetActiveAgentTurnsStore,
saveActiveAgentTurnsForWorkspace,
restoreActiveAgentTurnsForWorkspace,
saveActiveAgentTurnsForCommunity,
restoreActiveAgentTurnsForCommunity,
} from "@/features/agents/activeAgentTurnsStore";
import { resetAgentWorkingSignal } from "@/features/agents/agentWorkingSignal";
import { resetAgentObserverStore } from "@/features/agents/observerRelayStore";
@@ -18,17 +18,17 @@ import { resetSidebarRelayConnectionCardState } from "@/features/sidebar/ui/useS
import { clearMarkdownNodeCache } from "@/shared/ui/markdown/nodeCache";
import { resetVideoPlayerState } from "@/shared/ui/videoPlayerState";
import { initFirstWorkspace } from "./workspaceStorage";
import type { Workspace } from "./types";
import { initFirstCommunity } from "./communityStorage";
import type { Community } from "./types";
/**
* Tear down all workspace-scoped module singletons so the new
* workspace starts with a clean slate. Hook-managed singletons
* Tear down all community-scoped module singletons so the new
* community starts with a clean slate. Hook-managed singletons
* (e.g. ChannelMuteSyncManager, ChannelSectionSyncManager) are
* destroyed via effect cleanup and do not need entries here.
* See AGENTS.md "Workspace Switching" for the full contract.
* See AGENTS.md "Community Switching" for the full contract.
*/
function resetWorkspaceState(): void {
function resetCommunityState(): void {
relayClient.disconnect();
resetAgentObserverStore();
resetActiveAgentTurnsStore();
@@ -41,7 +41,7 @@ function resetWorkspaceState(): void {
clearMarkdownNodeCache();
}
type WorkspaceInitResult =
type CommunityInitResult =
| { isReady: true; needsSetup: false; appliedKey: string }
| {
isReady: false;
@@ -51,45 +51,45 @@ type WorkspaceInitResult =
| { isReady: false; needsSetup: false; appliedKey: string | null };
/**
* Applies the active workspace config to the Tauri backend and resets
* all workspace-scoped module singletons when the workspace changes.
* Applies the active community config to the Tauri backend and resets
* all community-scoped module singletons when the community changes.
*
* Returns a discriminated union — only render the app after the
* workspace is applied. When `needsSetup` is true, the caller
* community is applied. When `needsSetup` is true, the caller
* should show a first-run welcome screen.
*/
export function useWorkspaceInit(
activeWorkspace: Workspace | null,
workspaceKey: string,
export function useCommunityInit(
activeCommunity: Community | null,
communityKey: string,
isSharedIdentity: boolean,
): WorkspaceInitResult {
const [result, setResult] = useState<WorkspaceInitResult>({
): CommunityInitResult {
const [result, setResult] = useState<CommunityInitResult>({
isReady: false,
needsSetup: false,
appliedKey: null,
});
// Track whether this is the initial mount or a workspace switch.
// Track whether this is the initial mount or a community switch.
// On the initial mount we skip resetting singletons (they're fresh).
const hasInitializedRef = useRef(false);
// Track the previously-applied workspace ID so we can save its turn state
// before resetting when the user switches to a different workspace.
const prevWorkspaceIdRef = useRef<string | null>(null);
// Track the previously-applied community ID so we can save its turn state
// before resetting when the user switches to a different community.
const prevCommunityIdRef = useRef<string | null>(null);
// biome-ignore lint/correctness/useExhaustiveDependencies: we intentionally depend on specific properties (id/relayUrl/token/reposDir) — depending on the whole object would trigger resets on name-only changes
useEffect(() => {
let cancelled = false;
async function init() {
if (!activeWorkspace) {
if (!activeCommunity) {
try {
const defaultRelayUrl = await getDefaultRelayUrl();
if (isSharedIdentity) {
const identity = await getIdentity();
if (cancelled) return;
initFirstWorkspace(defaultRelayUrl, identity.pubkey);
initFirstCommunity(defaultRelayUrl, identity.pubkey);
if (!cancelled) {
window.location.reload();
}
@@ -115,59 +115,59 @@ export function useWorkspaceInit(
return;
}
// Mark this workspace config as pending while it is applied to the
// backend. App.tsx also checks appliedKey against the active workspaceKey,
// which prevents rendering workspace-scoped UI for a new workspace until
// Mark this community config as pending while it is applied to the
// backend. App.tsx also checks appliedKey against the active communityKey,
// which prevents rendering community-scoped UI for a new community until
// that exact config has finished applying.
setResult({
isReady: false,
needsSetup: false,
appliedKey: workspaceKey,
appliedKey: communityKey,
});
// On workspace switch (not initial mount), reset module singletons
// On community switch (not initial mount), reset module singletons
// so the new tree starts with a clean slate.
if (hasInitializedRef.current) {
// Save the outgoing workspace's turn state before wiping the store so
// Save the outgoing community's turn state before wiping the store so
// timers survive a round-trip (A → B → A keeps A's elapsed time).
if (prevWorkspaceIdRef.current) {
saveActiveAgentTurnsForWorkspace(prevWorkspaceIdRef.current);
// Null out immediately so a rapid workspace switch (A→B→C before
// B's applyWorkspace resolves) doesn't re-save the now-empty
// store under the outgoing workspace ID and delete its snapshot.
prevWorkspaceIdRef.current = null;
if (prevCommunityIdRef.current) {
saveActiveAgentTurnsForCommunity(prevCommunityIdRef.current);
// Null out immediately so a rapid community switch (A→B→C before
// B's applyCommunity resolves) doesn't re-save the now-empty
// store under the outgoing community ID and delete its snapshot.
prevCommunityIdRef.current = null;
}
resetWorkspaceState();
resetCommunityState();
}
hasInitializedRef.current = true;
// Apply workspace config to the Tauri backend.
// Apply community config to the Tauri backend.
//
// Note: we deliberately do NOT pass an nsec here. The persisted
// `identity.key` file (resolved at startup by `resolve_persisted_identity`,
// and updated atomically by `import_identity`) is the single source of
// truth for the active key. Older builds stored the nsec in localStorage
// and re-applied it on every reload, which silently overwrote any
// imported key. `loadWorkspaces()` strips lingering `nsec` fields from
// imported key. `loadCommunities()` strips lingering `nsec` fields from
// legacy entries; this site refuses to apply one even if present.
try {
await applyWorkspace(
activeWorkspace.relayUrl,
await applyCommunity(
activeCommunity.relayUrl,
undefined,
activeWorkspace.token,
activeWorkspace.reposDir,
activeCommunity.token,
activeCommunity.reposDir,
);
} catch (error) {
// A bad `repos_dir` no longer reaches here — `apply_workspace` treats
// it as non-fatal (relay/keys apply, bad value not persisted, REPOS
// falls back to a real dir, a `repos-dir-error` toast surfaces it) and
// returns Ok, so the app boots into a working state where the user can
// fix the value in workspace settings. This catch now only fires on a
// fix the value in community settings. This catch now only fires on a
// genuine relay/key apply failure (e.g. an invalid nsec or a poisoned
// lock). For those, marking the workspace ready would render
// workspace-scoped UI against a backend that never applied — park on
// lock). For those, marking the community ready would render
// community-scoped UI against a backend that never applied — park on
// the loading gate (isReady:false, no appliedKey) instead.
console.error("Failed to apply workspace to backend:", error);
console.error("Failed to apply community to backend:", error);
if (!cancelled) {
setResult({ isReady: false, needsSetup: false, appliedKey: null });
}
@@ -177,19 +177,19 @@ export function useWorkspaceInit(
if (!cancelled) {
// Initialise the draft store for this identity so localStorage drafts
// are scoped to the correct pubkey before the app renders.
if (activeWorkspace.pubkey) {
initDraftStore(activeWorkspace.pubkey);
if (activeCommunity.pubkey) {
initDraftStore(activeCommunity.pubkey);
}
// Restore any turn state saved for this workspace (a prior A→B round-
// trip). This runs after applyWorkspace succeeds and before the app
// Restore any turn state saved for this community (a prior A→B round-
// trip). This runs after applyCommunity succeeds and before the app
// renders so components see the restored timers on first render.
restoreActiveAgentTurnsForWorkspace(activeWorkspace.id);
// Prime the ref so the NEXT switch saves this workspace's state.
prevWorkspaceIdRef.current = activeWorkspace.id;
restoreActiveAgentTurnsForCommunity(activeCommunity.id);
// Prime the ref so the NEXT switch saves this community's state.
prevCommunityIdRef.current = activeCommunity.id;
setResult({
isReady: true,
needsSetup: false,
appliedKey: workspaceKey,
appliedKey: communityKey,
});
}
}
@@ -200,12 +200,12 @@ export function useWorkspaceInit(
cancelled = true;
};
}, [
activeWorkspace?.id,
activeWorkspace?.relayUrl,
activeWorkspace?.token,
activeWorkspace?.reposDir,
activeCommunity?.id,
activeCommunity?.relayUrl,
activeCommunity?.token,
activeCommunity?.reposDir,
isSharedIdentity,
workspaceKey,
communityKey,
]);
return result;

View File

@@ -0,0 +1,182 @@
import * as React from "react";
import { getIdentity } from "@/shared/api/tauriIdentity";
import { markCommunityRead } from "@/features/communities/communityMarkRead";
import { pollCommunityUnread } from "@/features/communities/communityUnreadObserver";
import type { Community } from "./types";
const COMMUNITY_UNREAD_POLL_MS = 30_000;
/**
* Per-community unread summary for the community rail.
*
* `state` distinguishes "observed, no unread" from "not observed yet" so the
* rail never renders a false "no unread" for a relay it could not reach:
* - `unknown` — not yet observed (render dim, no unread affordance)
* - `loading` — observation in flight (render dim/skeleton)
* - `ready` — observed; trust `hasUnread` / `count`
* - `error` — observation failed (render neutral, never "no unread")
*
* `count` carries the MENTION count (not total unread) — the rail shows a dot
* for any unread and a numeric badge only when mentions are present.
*/
export type CommunityUnreadState = {
hasUnread: boolean;
count?: number;
state: "unknown" | "loading" | "ready" | "error";
};
const unknownUnreadState: CommunityUnreadState = {
hasUnread: false,
state: "unknown",
};
function seedCommunityStates(
communities: Community[],
previous: Record<string, CommunityUnreadState>,
): Record<string, CommunityUnreadState> {
const next: Record<string, CommunityUnreadState> = {};
for (const community of communities) {
next[community.id] = previous[community.id] ?? unknownUnreadState;
}
return next;
}
/**
* Observe unread activity for INACTIVE communities without touching the active
* relay singleton.
*/
export function useCommunityUnread(
communities: Community[],
activeCommunityId: string | null,
): {
unreadByCommunity: Record<string, CommunityUnreadState>;
markCommunityRead: (communityId: string) => Promise<void>;
} {
const [unreadByCommunity, setUnreadByCommunity] = React.useState<
Record<string, CommunityUnreadState>
>(() => seedCommunityStates(communities, {}));
React.useEffect(() => {
let cancelled = false;
let pollTimer: number | null = null;
const inactiveCommunities = communities.filter(
(community) => community.id !== activeCommunityId,
);
setUnreadByCommunity((previous) =>
seedCommunityStates(communities, previous),
);
const markLoading = (communityId: string) => {
setUnreadByCommunity((previous) => {
const current = previous[communityId] ?? unknownUnreadState;
if (current.state === "ready") {
return previous;
}
return {
...previous,
[communityId]: { hasUnread: false, state: "loading" },
};
});
};
const markReady = (
communityId: string,
result: { hasUnread: boolean; mentionCount: number },
) => {
setUnreadByCommunity((previous) => ({
...previous,
[communityId]: {
hasUnread: result.hasUnread,
count: result.mentionCount > 0 ? result.mentionCount : undefined,
state: "ready",
},
}));
};
const markError = (communityId: string) => {
setUnreadByCommunity((previous) => ({
...previous,
[communityId]: { hasUnread: false, state: "error" },
}));
};
const scheduleNextPoll = () => {
if (cancelled) return;
pollTimer = window.setTimeout(() => {
void pollInactiveCommunities();
}, COMMUNITY_UNREAD_POLL_MS);
};
const pollInactiveCommunities = async () => {
if (inactiveCommunities.length === 0) {
return;
}
let pubkey: string;
try {
pubkey = (await getIdentity()).pubkey;
} catch {
for (const community of inactiveCommunities) {
if (cancelled) return;
markError(community.id);
}
scheduleNextPoll();
return;
}
for (const community of inactiveCommunities) {
if (cancelled) return;
markLoading(community.id);
try {
const result = await pollCommunityUnread(community, pubkey);
if (cancelled) return;
markReady(community.id, result);
} catch (error) {
console.debug(
`[CommunityUnread] poll failed community=${community.id}:`,
error,
);
if (cancelled) return;
markError(community.id);
}
}
scheduleNextPoll();
};
void pollInactiveCommunities();
return () => {
cancelled = true;
if (pollTimer !== null) {
window.clearTimeout(pollTimer);
}
};
}, [activeCommunityId, communities]);
const communitiesRef = React.useRef(communities);
communitiesRef.current = communities;
const markRead = React.useCallback(
async (communityId: string) => {
const community = communitiesRef.current.find(
(candidate) => candidate.id === communityId,
);
if (!community || communityId === activeCommunityId) return;
const { pubkey } = await getIdentity();
await markCommunityRead(community, pubkey);
// Optimistic clear — the next poll re-verifies against the relay.
setUnreadByCommunity((previous) => ({
...previous,
[communityId]: { hasUnread: false, state: "ready" },
}));
},
[activeCommunityId],
);
return { unreadByCommunity, markCommunityRead: markRead };
}

View File

@@ -17,7 +17,7 @@ const MIGRATION_TOAST_KEY = "buzz-legacy-nest-migrated-notified";
* localStorage); the backend re-emits each launch while `~/.sprout` exists,
* which also covers the event being emitted before this listener mounts.
*
* Mounted at the app root ahead of the workspace-init effect so the listener
* Mounted at the app root ahead of the community-init effect so the listener
* is registered before the first `apply_workspace` call.
*/
export function useNestNotifications(): void {

View File

@@ -4,7 +4,7 @@ import { toast } from "sonner";
import {
useAddRelayMemberMutation,
useRelayMembersQuery,
} from "@/features/relay-members/hooks";
} from "@/features/community-members/hooks";
import type { RelayMemberRole } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";

View File

@@ -9,7 +9,7 @@ import {
useChangeRelayMemberRoleMutation,
useMyRelayMembershipQuery,
useRelayMembersQuery,
} from "@/features/relay-members/hooks";
} from "@/features/community-members/hooks";
import type { RelayMember, RelayMemberRole } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
@@ -164,7 +164,7 @@ function MemberRow({
const ROLE_ORDER: Record<string, number> = { owner: 0, admin: 1, member: 2 };
export function RelayMembersCard({
export function CommunityMembersCard({
currentPubkey,
}: {
currentPubkey?: string;
@@ -213,13 +213,13 @@ export function RelayMembersCard({
}
return (
<section className="min-w-0" data-testid="settings-relay-members">
<section className="min-w-0" data-testid="settings-community-members">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<Shield className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold tracking-tight">
Relay Members
Community Members
</h2>
</div>
<p className="mt-1 text-sm text-muted-foreground">

View File

@@ -20,7 +20,7 @@ import {
useMyRelayMembershipQuery,
useRelayMembersQuery,
useRemoveRelayMemberMutation,
} from "@/features/relay-members/hooks";
} 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";
@@ -37,7 +37,7 @@ import {
} from "@/shared/ui/dropdown-menu";
import { Input } from "@/shared/ui/input";
import { SettingsSectionHeader } from "@/features/settings/ui/SettingsSectionHeader";
import { WorkspaceIconSettingsCard } from "@/features/workspaces/ui/WorkspaceIconSettingsCard";
import { CommunityIconSettingsCard } from "@/features/communities/ui/CommunityIconSettingsCard";
import { VirtualizedList } from "@/shared/ui/VirtualizedList";
import { InviteLinkSection } from "./InviteLinkSection";
@@ -148,7 +148,7 @@ function RelayMemberRow({
toast.error(
error instanceof Error
? error.message
: "Relay membership update failed",
: "Community membership update failed",
);
}
}
@@ -241,11 +241,11 @@ function RelayMemberRow({
)
}
size="icon"
title="Remove from relay"
title="Remove from community"
variant="ghost"
>
<Trash2 className="h-4 w-4" />
<span className="sr-only">Remove from relay</span>
<span className="sr-only">Remove from community</span>
</Button>
) : null}
</div>
@@ -270,7 +270,7 @@ const ROLE_OPTIONS: {
},
];
export function RelayMembersSettingsCard({
export function CommunityMembersSettingsCard({
currentPubkey,
}: {
currentPubkey?: string;
@@ -315,7 +315,7 @@ export function RelayMembersSettingsCard({
if (myMembershipQuery.isLoading) {
return (
<section className="min-w-0" data-testid="settings-relay-members">
<section className="min-w-0" data-testid="settings-community-members">
<p className="text-sm text-muted-foreground">
Checking relay permissions…
</p>
@@ -352,9 +352,9 @@ export function RelayMembersSettingsCard({
}
return (
<section className="min-w-0" data-testid="settings-relay-members">
<section className="min-w-0" data-testid="settings-community-members">
<SettingsSectionHeader
title="Relay Access"
title="Community Access"
description={
<>
Manage who can connect to this relay. Owners can invite admins or
@@ -364,7 +364,7 @@ export function RelayMembersSettingsCard({
/>
<div className="space-y-6">
<WorkspaceIconSettingsCard />
<CommunityIconSettingsCard />
<form className="space-y-1.5" onSubmit={handleAddMember}>
<label className="text-sm font-medium" htmlFor="relay-member-pubkey">
Invite a person
@@ -470,7 +470,7 @@ export function RelayMembersSettingsCard({
autoCapitalize="none"
autoCorrect="off"
className="w-full rounded-lg border border-border/70 bg-background/70 py-2 pl-9 pr-3 text-sm placeholder:text-muted-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
data-testid="relay-members-search"
data-testid="community-members-search"
onChange={(event) => setSearch(event.target.value)}
placeholder="Search members by name, npub, or role…"
spellCheck={false}
@@ -481,11 +481,11 @@ export function RelayMembersSettingsCard({
{membersQuery.isLoading ? (
<p className="text-sm text-muted-foreground">
Loading relay members…
Loading community members…
</p>
) : members.length === 0 ? (
<p className="rounded-lg border border-dashed border-border/70 bg-background/40 px-3 py-6 text-center text-sm text-muted-foreground">
No relay members yet. Invite someone above to get started.
No community members yet. Invite someone above to get started.
</p>
) : filteredMembers.length === 0 ? (
<p className="rounded-lg border border-dashed border-border/70 bg-background/40 px-3 py-6 text-center text-sm text-muted-foreground">

View File

@@ -2,7 +2,7 @@ import { toast } from "sonner";
import { truncatePubkey } from "@/shared/lib/pubkey";
import { PubKey } from "@/shared/ui/PubKey";
import { useRemoveRelayMemberMutation } from "@/features/relay-members/hooks";
import { useRemoveRelayMemberMutation } from "@/features/community-members/hooks";
import type { RelayMember } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import {

View File

@@ -2,7 +2,7 @@ import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
/**
* Build the emoji-mart `custom` prop from the workspace custom emoji palette.
* Build the emoji-mart `custom` prop from the community custom emoji palette.
* Returns `undefined` when there are none (so the Picker shows only standard
* categories). A selected custom emoji has no `native` field — only `id`/`src`
* — so select handlers must insert `:id:` for these.

View File

@@ -13,7 +13,7 @@ import { relayClient } from "@/shared/api/relayClient";
import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji";
/**
* React-query hooks for the workspace custom emoji palette (NIP-30, kind:30030).
* React-query hooks for the community custom emoji palette (NIP-30, kind:30030).
*
* The palette is the client-side UNION of every member's own kind:30030 set, so
* the query key is stable — not keyed by channel or pubkey. Freshness comes from
@@ -40,7 +40,7 @@ export function useCustomEmojiQuery() {
/**
* The caller's OWN custom emoji set — the only thing the settings card may add
* to or remove from. Distinct from the workspace union (`useCustomEmojiQuery`),
* to or remove from. Distinct from the community union (`useCustomEmojiQuery`),
* which is read-only across members.
*/
export function useOwnCustomEmojiQuery() {
@@ -56,7 +56,7 @@ export function useOwnCustomEmojiQuery() {
* query whenever one arrives. Call once near the app root (alongside other
* global live subscriptions). Safe to mount once; disposes on unmount.
*/
export function useWorkspaceEmojiLiveUpdates(): void {
export function useCommunityEmojiLiveUpdates(): void {
const queryClient = useQueryClient();
React.useEffect(() => {
@@ -80,7 +80,7 @@ export function useWorkspaceEmojiLiveUpdates(): void {
}
})
.catch((error) => {
console.error("Failed to subscribe to workspace custom emoji", error);
console.error("Failed to subscribe to community custom emoji", error);
});
// Re-sync on reconnect: a member's 30030 published while we were

View File

@@ -23,14 +23,14 @@ import { SettingsSectionHeader } from "@/features/settings/ui/SettingsSectionHea
* Custom emoji management (NIP-30, kind:30030). Each member owns their own set:
* adding uploads an image and republishes the caller's own 30030; removing only
* touches the caller's own set. So this card edits "My emoji" — the only set the
* caller can publish — and shows the workspace palette (the read-only union of
* caller can publish — and shows the community palette (the read-only union of
* every member's set) separately, since a member cannot remove someone else's
* emoji. When shortcodes collide across members, the palette shows one
* deterministic winner (see `unionCustomEmoji`).
*/
export function CustomEmojiSettingsCard() {
const { data: own = [], isLoading: ownLoading } = useOwnCustomEmojiQuery();
const { data: workspace = [], isLoading: workspaceLoading } =
const { data: community = [], isLoading: communityLoading } =
useCustomEmojiQuery();
const setEmoji = useSetCustomEmojiMutation();
const removeEmoji = useRemoveCustomEmojiMutation();
@@ -119,9 +119,9 @@ export function CustomEmojiSettingsCard() {
[removeEmoji],
);
// Workspace emoji owned by someone else (so the caller can't remove them).
// Community emoji owned by someone else (so the caller can't remove them).
const ownShortcodes = new Set(own.map((e) => e.shortcode));
const othersEmoji = workspace.filter((e) => !ownShortcodes.has(e.shortcode));
const othersEmoji = community.filter((e) => !ownShortcodes.has(e.shortcode));
return (
<section className="min-w-0" data-testid="settings-custom-emoji">
@@ -303,10 +303,10 @@ export function CustomEmojiSettingsCard() {
)}
</div>
{!workspaceLoading && othersEmoji.length > 0 ? (
<div className="space-y-3" data-testid="custom-emoji-workspace">
{!communityLoading && othersEmoji.length > 0 ? (
<div className="space-y-3" data-testid="custom-emoji-community">
<h3 className="text-sm font-medium">
Workspace emoji ({othersEmoji.length})
Community emoji ({othersEmoji.length})
</h3>
<p className="text-sm font-normal text-muted-foreground">
Added by other members. You can use these, but only their owner

View File

@@ -87,7 +87,7 @@ function disableSearchInputCorrections(
* drift across call sites (they used to, and that's why custom emoji were
* missing from some pickers).
*
* It always wires the workspace custom-emoji palette in via `useCustomEmoji()`,
* It always wires the community custom-emoji palette in via `useCustomEmoji()`,
* so custom emoji show up everywhere for free. Selection is normalized to a
* single string: a standard emoji emits its `native` glyph; a custom emoji has
* no `native`, so it emits its `:shortcode:` (the emoji-mart `id` is the

View File

@@ -331,11 +331,11 @@ export function HomeView({
enabled: feedProfilePubkeys.length > 0,
});
const feedProfiles = feedProfilesQuery.data?.profiles;
// Agent set for the inbox list/detail bot badges: the workspace-scoped
// Agent set for the inbox list/detail bot badges: the community-scoped
// baseline widened with this surface's profile lookup.
const workspaceAgentPubkeys = useKnownAgentPubkeys();
const communityAgentPubkeys = useKnownAgentPubkeys();
const inboxAgentPubkeys = React.useMemo(() => {
const pubkeys = new Set(workspaceAgentPubkeys);
const pubkeys = new Set(communityAgentPubkeys);
for (const [pubkey, profile] of Object.entries(feedProfiles ?? {})) {
if (profile.isAgent) {
@@ -344,7 +344,7 @@ export function HomeView({
}
return pubkeys;
}, [feedProfiles, workspaceAgentPubkeys]);
}, [feedProfiles, communityAgentPubkeys]);
const inboxItems = React.useMemo(
() =>
buildInboxItems({

View File

@@ -64,7 +64,7 @@ export function InboxMessageRow({
errorMessage: reactionErrorMessage,
select: handleReactionSelect,
} = useReactionHandler(timelineMessage, onToggleReaction);
// "Is this pubkey an agent" = the workspace-scoped baseline every surface
// "Is this pubkey an agent" = the community-scoped baseline every surface
// shares plus this surface's extras passed via `agentPubkeys` (HomeView
// folds feed-profile `isAgent` flags in). Mirrors MessageRow's predicate.
const knownAgentPubkeys = useKnownAgentPubkeys();

View File

@@ -18,7 +18,7 @@ type SelectionAnchorResult = {
* 1. Direct match in feedItems for the current anchor ID.
* 2. Committed same-anchor latch (survives anchor disappearing from feed).
* 3. Cold-recovered synthetic FeedItem from getEventById (anchor absent from
* feed; validated against the active workspace channel set).
* feed; validated against the active community channel set).
*/
activeLatchedItem: FeedItem | null;
/**
@@ -59,7 +59,7 @@ type ColdAttemptStatus =
*
* Layer 3 — cold recovery: when the anchor is absent from feedItems (e.g. a
* cold ?item= navigation or back/forward to a stale URL), fetches the event
* by ID, validates its `h` channel against the active workspace, and returns
* by ID, validates its `h` channel against the active community, and returns
* a synthetic FeedItem for context seeding. The recovered event is never
* injected into the live feed snapshot.
*/
@@ -284,7 +284,7 @@ export function useInboxSelectionAnchor({
}
const hTag = event.tags.find((t) => t[0] === "h")?.[1] ?? null;
if (!hTag || !latestAvailableChannelIdsRef.current.has(hTag)) {
// Event found but not in the current workspace — record membership
// Event found but not in the current community — record membership
// snapshot so a channel-set change triggers exactly one retry.
const failedKey = [...latestAvailableChannelIdsRef.current]
.sort()
@@ -346,7 +346,7 @@ export function useInboxSelectionAnchor({
// ── Active latch derivation ───────────────────────────────────────────────
// Synchronous direct match wins; then committed latch (survives eviction from
// feed); then cold recovery (absent-from-feed path). Non-direct cached layers
// are gated by workspace membership so a shrinking availableChannelIds does
// are gated by community membership so a shrinking availableChannelIds does
// not serve a stale context seed.
const directSelectedFeedItem = React.useMemo(
() =>

View File

@@ -2,7 +2,7 @@ import * as React from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { useMyRelayMembershipQuery } from "@/features/relay-members/hooks";
import { useMyRelayMembershipQuery } from "@/features/community-members/hooks";
import { useIdentityQuery } from "@/shared/api/hooks";
import {
archiveIdentity,

View File

@@ -8,7 +8,7 @@
* "0" → user explicitly disabled
* null → no explicit choice yet (default-on seeding may still fire)
*
* Device-level localStorage — intentionally not reset on workspace switch
* Device-level localStorage — intentionally not reset on community switch
* (the archive subscription itself is identity-scoped in SQLite; this flag
* is just the UI gate that prevents re-seeding after an explicit opt-out).
*/

View File

@@ -72,7 +72,7 @@ function scopeKey(scopeType: ScopeType, scopeValue: string): string {
* config and forwards matched events to `archive_events` in debounced batches.
*
* Lifecycle: created once at app-shell mount (see `useArchiveSync`), destroyed
* on workspace switch. Resubscribes automatically when subscriptions change
* on community switch. Resubscribes automatically when subscriptions change
* via the module-level notifier in `tauriArchive.ts`.
*
* Accepts optional `deps` for testing — production callers pass nothing.
@@ -304,7 +304,7 @@ import * as React from "react";
/**
* Starts the ArchiveSyncManager at app-shell mount and tears it down when the
* component unmounts (workspace switch). No return value needed — the manager
* component unmounts (community switch). No return value needed — the manager
* runs entirely in the background.
*/
export function useArchiveSync(): void {

View File

@@ -8,7 +8,7 @@
* "0" → user explicitly disabled
* null → no explicit choice yet (default-on seeding may still fire)
*
* Device-level localStorage — intentionally not reset on workspace switch
* Device-level localStorage — intentionally not reset on community switch
* (the archive subscription itself is identity-scoped in SQLite; this flag
* is just the UI gate that prevents re-seeding after an explicit opt-out).
*/

View File

@@ -636,7 +636,7 @@ export function useToggleReactionMutation() {
}
// Custom-emoji reaction: emoji is `:shortcode:`. Resolve its image URL
// from the cached workspace palette so the kind:7 carries the NIP-30
// from the cached community palette so the kind:7 carries the NIP-30
// `["emoji", shortcode, url]` tag. Unicode reactions resolve to no URL.
const emojiUrl = reactionEmojiUrl(
emoji,

View File

@@ -170,7 +170,7 @@ export const CustomEmojiNode = Node.create<CustomEmojiNodeOptions>({
src: {
default: "",
parseHTML: (el) => (el as HTMLElement).getAttribute("src") ?? "",
// `src` is derived from the workspace palette at render time, not
// `src` is derived from the community palette at render time, not
// persisted in serialized output — see renderText/markdown below.
renderHTML: () => ({}),
},

View File

@@ -2,7 +2,7 @@
* Per-channel persisted message snapshots.
*
* A channel revisited after its React-Query cache entry is gone (app restart,
* gcTime expiry, workspace remount) goes fully cold and holds a skeleton for a
* gcTime expiry, community remount) goes fully cold and holds a skeleton for a
* relay round trip. This module persists the newest slice of each channel's
* timeline so a revisit can paint instantly from the snapshot while the
* history fetch revalidates behind it — the same stale-then-revalidate pattern
@@ -187,7 +187,7 @@ export function mergeHistoryOverSnapshot(input: {
}
/**
* Removes every channel message snapshot for a relay. Called when a workspace
* Removes every channel message snapshot for a relay. Called when a community
* is removed.
*/
export function removeMessageSnapshotsForRelay(relayUrl: string): void {

View File

@@ -83,7 +83,7 @@ function storageKey(): string {
/**
* Initialise (or re-initialise) the draft store for a given identity.
* Called from `useWorkspaceInit` alongside the other singleton resets.
* Called from `useCommunityInit` alongside the other singleton resets.
* Resets the in-memory cache whenever the pubkey changes so a direct
* identity switch (without a prior `clearAllDrafts`) never serves the
* wrong identity's drafts.
@@ -99,7 +99,7 @@ export function initDraftStore(pubkey: string): void {
}
/**
* Reset the in-memory draft store on workspace switch.
* Reset the in-memory draft store on community switch.
* Replaces the old `clearAllDrafts()`.
*/
export function clearAllDrafts(): void {

View File

@@ -104,7 +104,7 @@ export function useEmojiAutocomplete(customEmoji: CustomEmoji[] = []) {
native: emoji.skins[0]?.native ?? "",
}))
.filter((e) => e.native !== "");
// Custom emoji first (workspace-specific), then standard, capped.
// Custom emoji first (community-specific), then standard, capped.
const merged = [...customMatches, ...standard].slice(0, MAX_RESULTS);
setSuggestions(merged);
setEmojiSelectedIndex(0);

View File

@@ -166,7 +166,7 @@ export const MessageRow = React.memo(
() => resolveMentionProps(message.tags, profiles),
[profiles, message.tags],
);
// "Is this pubkey an agent" = the workspace-scoped baseline every surface
// "Is this pubkey an agent" = the community-scoped baseline every surface
// shares (managed ∪ relay) plus the pubkey's own profile `isAgent` flag from this surface's lookup. Both are per-pubkey
// O(1) checks — no per-row rescan of `profiles` (that duplicated parent
// work in every mounted row and re-ran on each profile-lookup change).

View File

@@ -27,7 +27,7 @@ const HUMAN_SIGNER =
const AGENT_PUBKEY =
"2222222222222222222222222222222222222222222222222222222222222222";
// MessageRow passes a predicate combining the workspace known-agent set with
// MessageRow passes a predicate combining the community known-agent set with
// per-pubkey profile `isAgent` checks; the set-membership form is the minimal
// equivalent for exercising the signer-selection seam.
const AGENT_PUBKEYS = new Set([AGENT_PUBKEY]);

View File

@@ -11,7 +11,7 @@ import type { TimelineMessage } from "@/features/messages/types";
* 2. `message.signerPubkey` is set and passes `isKnownAgentPubkey` —
* authenticates against the raw event signer (NOT `message.pubkey`,
* which is the tag-attributed display author and can be spoofed via
* `actor`/`p` tags). The caller's predicate combines the workspace-wide
* `actor`/`p` tags). The caller's predicate combines the community-wide
* known-agent baseline (`useKnownAgentPubkeys`) with any surface-local
* signals such as the signer profile's `isAgent` flag.
*

View File

@@ -1,9 +1,9 @@
import * as React from "react";
import {
loadActiveWorkspaceId,
loadWorkspaces,
} from "@/features/workspaces/workspaceStorage";
loadActiveCommunityId,
loadCommunities,
} from "@/features/communities/communityStorage";
import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji";
const QUICK_REACTION_STORAGE_KEY = "buzz.quick-reaction-emojis.v1";
@@ -27,28 +27,28 @@ function canUseLocalStorage() {
}
}
function getActiveWorkspaceScope() {
function getActiveCommunityScope() {
if (!canUseLocalStorage()) return null;
try {
return loadActiveWorkspaceId() ?? loadWorkspaces()[0]?.id ?? null;
return loadActiveCommunityId() ?? loadCommunities()[0]?.id ?? null;
} catch {
return null;
}
}
function quickReactionStorageKey(workspaceScope: string | null) {
return workspaceScope
? `${QUICK_REACTION_STORAGE_KEY}:${workspaceScope}`
function quickReactionStorageKey(communityScope: string | null) {
return communityScope
? `${QUICK_REACTION_STORAGE_KEY}:${communityScope}`
: QUICK_REACTION_STORAGE_KEY;
}
function quickReactionSessionKey(
limit: number,
workspaceScope: string | null,
communityScope: string | null,
customEmojiSignature: string,
) {
return `${workspaceScope ?? "global"}:${customEmojiSignature}:${limit}`;
return `${communityScope ?? "global"}:${customEmojiSignature}:${limit}`;
}
function normalizeEntry(entry: unknown): QuickReactionEntry | null {
@@ -175,11 +175,11 @@ export function resolveQuickReactionEmojis(
function getQuickReactionEmojis(
limit: number,
workspaceScope: string | null,
communityScope: string | null,
customEmojiSignature: string,
) {
return resolveQuickReactionEmojisWithShortcodes(
readQuickReactionEntries(quickReactionStorageKey(workspaceScope)),
readQuickReactionEntries(quickReactionStorageKey(communityScope)),
limit,
customEmojiShortcodesFromSignature(customEmojiSignature),
);
@@ -187,12 +187,12 @@ function getQuickReactionEmojis(
function getSessionQuickReactionEmojis(
limit: number,
workspaceScope: string | null,
communityScope: string | null,
customEmojiSignature: string,
) {
const sessionKey = quickReactionSessionKey(
limit,
workspaceScope,
communityScope,
customEmojiSignature,
);
const cached = sessionQuickReactionEmojis.get(sessionKey);
@@ -200,7 +200,7 @@ function getSessionQuickReactionEmojis(
const emojis = getQuickReactionEmojis(
limit,
workspaceScope,
communityScope,
customEmojiSignature,
);
sessionQuickReactionEmojis.set(sessionKey, emojis);
@@ -211,8 +211,8 @@ export function recordQuickReactionEmoji(emoji: string) {
const trimmed = emoji.trim();
if (!trimmed) return;
const workspaceScope = getActiveWorkspaceScope();
const storageKey = quickReactionStorageKey(workspaceScope);
const communityScope = getActiveCommunityScope();
const storageKey = quickReactionStorageKey(communityScope);
const entries = readQuickReactionEntries(storageKey);
const existing = entries.find((entry) => entry.emoji === trimmed);
if (existing) {
@@ -227,7 +227,7 @@ export function recordQuickReactionEmoji(emoji: string) {
}
// Keep the current hover tray stable; the stored recents apply on reload or
// when another tab updates this workspace's quick reactions.
// when another tab updates this community's quick reactions.
writeQuickReactionEntries(entries, storageKey);
}
@@ -235,19 +235,19 @@ export function useQuickReactionEmojis(
limit = 4,
customEmoji: ReadonlyArray<CustomEmoji> = [],
) {
const workspaceScope = getActiveWorkspaceScope();
const communityScope = getActiveCommunityScope();
const customEmojiCacheKey = customEmojiSignature(customEmoji);
const [emojis, setEmojis] = React.useState(() =>
getSessionQuickReactionEmojis(limit, workspaceScope, customEmojiCacheKey),
getSessionQuickReactionEmojis(limit, communityScope, customEmojiCacheKey),
);
React.useEffect(() => {
if (typeof window === "undefined") return;
const storageKey = quickReactionStorageKey(workspaceScope);
const storageKey = quickReactionStorageKey(communityScope);
const sessionKey = quickReactionSessionKey(
limit,
workspaceScope,
communityScope,
customEmojiCacheKey,
);
const handleStorage = (event: StorageEvent) => {
@@ -256,7 +256,7 @@ export function useQuickReactionEmojis(
setEmojis(
getSessionQuickReactionEmojis(
limit,
workspaceScope,
communityScope,
customEmojiCacheKey,
),
);
@@ -265,13 +265,13 @@ export function useQuickReactionEmojis(
window.addEventListener("storage", handleStorage);
setEmojis(
getSessionQuickReactionEmojis(limit, workspaceScope, customEmojiCacheKey),
getSessionQuickReactionEmojis(limit, communityScope, customEmojiCacheKey),
);
return () => {
window.removeEventListener("storage", handleStorage);
};
}, [customEmojiCacheKey, limit, workspaceScope]);
}, [customEmojiCacheKey, limit, communityScope]);
return emojis;
}

View File

@@ -28,7 +28,7 @@ export const relaySelfQueryKey = ["relaySelf"] as const;
/**
* The active relay's NIP-11 `self` pubkey (hex), or `null` when it advertises
* none / is unreachable. Used to recognize a moderation DM. Workspace-scoped
* none / is unreachable. Used to recognize a moderation DM. Community-scoped
* and effectively static for a session, so it is cached indefinitely; a `null`
* result is a valid answer (fail open), not an error to retry into.
*/

View File

@@ -10,7 +10,7 @@ import {
useUnbanMemberMutation,
useUntimeoutMemberMutation,
} from "@/features/moderation/hooks";
import { useMyRelayMembershipQuery } from "@/features/relay-members/hooks";
import { useMyRelayMembershipQuery } from "@/features/community-members/hooks";
import type { TimelineMessage } from "@/features/messages/types";
import { isTimedOut } from "@/features/moderation/lib/restrictionState";
import { useIdentityQuery } from "@/shared/api/hooks";

View File

@@ -18,7 +18,7 @@ import {
getWelcomeGuideAgentPubkeys,
} from "@/features/onboarding/welcomeGuide";
import { useProfileQuery } from "@/features/profile/hooks";
import { useWorkspaces } from "@/features/workspaces/useWorkspaces";
import { useCommunities } from "@/features/communities/useCommunities";
import { useIdentityQuery } from "@/shared/api/hooks";
import {
createChannel,
@@ -55,16 +55,16 @@ async function initializeWelcomeChannel(
{
focus,
pubkey,
workspaceScope,
communityScope,
}: {
focus: boolean;
pubkey: string | null;
workspaceScope: string | null;
communityScope: string | null;
},
) {
try {
const allowedMemberPubkeys = await getWelcomeGuideAgentPubkeys(
workspaceScope,
communityScope,
).catch(() => []);
const welcomeChannel = await ensureWelcomeChannel(
{
@@ -79,7 +79,7 @@ async function initializeWelcomeChannel(
);
let didInitializeWelcomeGuide = false;
try {
await ensureWelcomeGuideIntro(welcomeChannel.id, workspaceScope);
await ensureWelcomeGuideIntro(welcomeChannel.id, communityScope);
didInitializeWelcomeGuide = true;
await Promise.all([
queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }),
@@ -89,7 +89,7 @@ async function initializeWelcomeChannel(
console.warn("Failed to initialize Welcome guide.", error);
}
if (didInitializeWelcomeGuide) {
markWelcomeChannelEnsured(pubkey, workspaceScope);
markWelcomeChannelEnsured(pubkey, communityScope);
}
if (focus) {
rememberPendingWelcomeChannel(welcomeChannel.id);
@@ -398,11 +398,11 @@ export function useFirstRunOnboardingGate({
export function useAppOnboardingState(isSharedIdentity: boolean) {
const queryClient = useQueryClient();
const { activeWorkspace } = useWorkspaces();
const { activeCommunity } = useCommunities();
const identityQuery = useIdentityQuery();
const identity = identityQuery.data;
const currentPubkey = identity?.pubkey ?? null;
const welcomeChannelWorkspaceScope = activeWorkspace?.relayUrl ?? null;
const welcomeChannelCommunityScope = activeCommunity?.relayUrl ?? null;
const welcomeChannelInitPromisesRef = React.useRef(
new Map<string, Promise<void>>(),
);
@@ -438,11 +438,11 @@ export function useAppOnboardingState(isSharedIdentity: boolean) {
const gateComplete = onboardingGate.complete;
const requestWelcomeChannel = React.useCallback(
(focus: boolean) => {
if (!currentPubkey || !welcomeChannelWorkspaceScope) {
if (!currentPubkey || !welcomeChannelCommunityScope) {
return Promise.resolve();
}
const welcomeChannelInitKey = `${welcomeChannelWorkspaceScope}:${currentPubkey}`;
const welcomeChannelInitKey = `${welcomeChannelCommunityScope}:${currentPubkey}`;
const currentPromise = welcomeChannelInitPromisesRef.current.get(
welcomeChannelInitKey,
);
@@ -453,7 +453,7 @@ export function useAppOnboardingState(isSharedIdentity: boolean) {
const promise = initializeWelcomeChannel(queryClient, {
focus,
pubkey: currentPubkey,
workspaceScope: welcomeChannelWorkspaceScope,
communityScope: welcomeChannelCommunityScope,
});
welcomeChannelInitPromisesRef.current.set(welcomeChannelInitKey, promise);
void promise.finally(() => {
@@ -461,16 +461,16 @@ export function useAppOnboardingState(isSharedIdentity: boolean) {
});
return promise;
},
[currentPubkey, queryClient, welcomeChannelWorkspaceScope],
[currentPubkey, queryClient, welcomeChannelCommunityScope],
);
React.useEffect(() => {
if (
onboardingGate.stage !== "ready" ||
!currentPubkey ||
!welcomeChannelWorkspaceScope ||
!welcomeChannelCommunityScope ||
!readOnboardingCompletion(currentPubkey) ||
hasEnsuredWelcomeChannel(currentPubkey, welcomeChannelWorkspaceScope)
hasEnsuredWelcomeChannel(currentPubkey, welcomeChannelCommunityScope)
) {
return;
}
@@ -480,7 +480,7 @@ export function useAppOnboardingState(isSharedIdentity: boolean) {
currentPubkey,
onboardingGate.stage,
requestWelcomeChannel,
welcomeChannelWorkspaceScope,
welcomeChannelCommunityScope,
]);
const completeAndShowWelcome = React.useCallback(() => {

View File

@@ -20,8 +20,8 @@ type NostrKeyImportFormProps = {
/**
* Paste-or-drop nsec import form with a live npub preview.
*
* Shared between the first-run welcome flow (no workspace yet) and the
* onboarding profile flow (workspace exists, user wants to reuse an
* Shared between the first-run welcome flow (no community yet) and the
* onboarding profile flow (community exists, user wants to reuse an
* existing key). The caller owns what happens after `onImport` resolves.
*/
export function NostrKeyImportForm({

View File

@@ -77,10 +77,10 @@ async function checkMembershipDenied(): Promise<boolean> {
type OnboardingFlowProps = {
actions: OnboardingActions;
canBackToWorkspaceSetup: boolean;
canBackToCommunitySetup: boolean;
identityLost?: boolean;
initialProfile: OnboardingProfileSeed;
onBackToWorkspaceSetup: () => void;
onBackToCommunitySetup: () => void;
};
function isFallbackDisplayName(value?: string | null) {
@@ -147,10 +147,10 @@ function resolveProfileSaveRecovery(
export function OnboardingFlow({
actions,
canBackToWorkspaceSetup,
canBackToCommunitySetup,
identityLost = false,
initialProfile,
onBackToWorkspaceSetup,
onBackToCommunitySetup,
}: OnboardingFlowProps) {
const { complete, skipForNow } = actions;
const queryClient = useQueryClient();
@@ -394,7 +394,7 @@ export function OnboardingFlow({
}
: profileStepState.saveRecovery,
};
// Page sequences by path — step 1 is the workspace-picker that precedes
// Page sequences by path — step 1 is the community-picker that precedes
// onboarding, so these pages start at step 2 (STEP_OFFSET below).
// Fresh-key path: profile(2) → backup(3) → avatar(4) → theme(5) → setup(6)
// Imported-key path: profile(2) → avatar(3) → theme(4) → setup(5)
@@ -418,7 +418,7 @@ export function OnboardingFlow({
currentPage === "key-import" ? "profile" : currentPage;
const pageIndex = activeSteps.indexOf(normalizedPage);
const currentStep = pageIndex >= 0 ? pageIndex + STEP_OFFSET : STEP_OFFSET;
const totalOnboardingSteps = activeSteps.length + 1; // +1 for step 1 (workspace picker)
const totalOnboardingSteps = activeSteps.length + 1; // +1 for step 1 (community picker)
const hideFixedProgressOnCompact =
currentPage === "avatar" || currentPage === "theme";
@@ -468,14 +468,14 @@ export function OnboardingFlow({
return (
<MembershipDenied
onChangeKey={
canBackToWorkspaceSetup
canBackToCommunitySetup
? () => {
setTransitionDirection("backward");
onBackToWorkspaceSetup();
onBackToCommunitySetup();
}
: undefined
}
onImportKey={canBackToWorkspaceSetup ? undefined : importExistingKey}
onImportKey={canBackToCommunitySetup ? undefined : importExistingKey}
onRetry={() => {
void saveProfileAndContinue(membershipRetryPage);
}}
@@ -532,10 +532,10 @@ export function OnboardingFlow({
<ProfileStep
actions={{
advanceWithoutSaving: advanceFromProfileWithoutSaving,
back: canBackToWorkspaceSetup
back: canBackToCommunitySetup
? () => {
setTransitionDirection("backward");
onBackToWorkspaceSetup();
onBackToCommunitySetup();
}
: undefined,
clearAvatarDraft: resetAvatarDraft,

View File

@@ -363,7 +363,7 @@ function RuntimeDetails({ runtime }: { runtime: AcpRuntimeCatalogEntry }) {
This updates the machine-global{" "}
<code className="rounded bg-muted px-0.5 text-2xs">codex-acp</code>{" "}
adapter. Older Buzz releases using the legacy adapter contract may
lose relay access until{" "}
lose community access until{" "}
<code className="rounded bg-muted px-0.5 text-2xs">
@zed-industries/codex-acp@0.16.0
</code>{" "}

View File

@@ -97,7 +97,7 @@ test("ensureWelcomeChannel creates a private Welcome channel when one is missing
test("ensureWelcomeChannel clears ttl on an existing ephemeral Welcome channel", async () => {
const existingChannel = makeChannel({
description:
"A private ephemeral channel for getting oriented in this workspace.",
"A private ephemeral channel for getting oriented in this community.",
id: "existing-welcome",
ttlDeadline: "2026-06-11T00:00:00.000Z",
ttlSeconds: 86400,
@@ -227,26 +227,26 @@ test("pending Welcome channel is consumed only after it appears in the channel l
}
});
test("Welcome ensured marker is scoped to the current identity and workspace", () => {
test("Welcome ensured marker is scoped to the current identity and community", () => {
const { restore } = installWindowSessionStorage();
try {
markWelcomeChannelEnsured("pubkey-a", "wss://workspace-a.example");
markWelcomeChannelEnsured("pubkey-a", "wss://community-a.example");
assert.equal(
hasEnsuredWelcomeChannel("pubkey-a", "wss://workspace-a.example"),
hasEnsuredWelcomeChannel("pubkey-a", "wss://community-a.example"),
true,
);
assert.equal(
hasEnsuredWelcomeChannel("pubkey-a", "wss://workspace-b.example"),
hasEnsuredWelcomeChannel("pubkey-a", "wss://community-b.example"),
false,
);
assert.equal(
hasEnsuredWelcomeChannel("pubkey-b", "wss://workspace-a.example"),
hasEnsuredWelcomeChannel("pubkey-b", "wss://community-a.example"),
false,
);
assert.equal(hasEnsuredWelcomeChannel("pubkey-a", null), false);
assert.equal(
hasEnsuredWelcomeChannel(null, "wss://workspace-a.example"),
hasEnsuredWelcomeChannel(null, "wss://community-a.example"),
false,
);
} finally {

View File

@@ -7,7 +7,7 @@ import type {
export const WELCOME_CHANNEL_NAME = "Welcome";
export const WELCOME_CHANNEL_DESCRIPTION =
"A private channel for getting oriented in this workspace.";
"A private channel for getting oriented in this community.";
export const WELCOME_CHANNEL_READY_EVENT =
"buzz:onboarding-welcome-channel-ready";
@@ -162,25 +162,25 @@ export async function ensureWelcomeChannel(
export function welcomeChannelEnsuredStorageKey(
pubkey: string,
workspaceScope: string,
communityScope: string,
) {
return `${WELCOME_CHANNEL_ENSURED_STORAGE_KEY}:${encodeURIComponent(
workspaceScope,
communityScope,
)}:${pubkey}`;
}
export function hasEnsuredWelcomeChannel(
pubkey: string | null | undefined,
workspaceScope: string | null | undefined,
communityScope: string | null | undefined,
) {
if (typeof window === "undefined" || !pubkey || !workspaceScope) {
if (typeof window === "undefined" || !pubkey || !communityScope) {
return false;
}
try {
return (
window.localStorage.getItem(
welcomeChannelEnsuredStorageKey(pubkey, workspaceScope),
welcomeChannelEnsuredStorageKey(pubkey, communityScope),
) === "true"
);
} catch {
@@ -190,15 +190,15 @@ export function hasEnsuredWelcomeChannel(
export function markWelcomeChannelEnsured(
pubkey: string | null | undefined,
workspaceScope: string | null | undefined,
communityScope: string | null | undefined,
) {
if (typeof window === "undefined" || !pubkey || !workspaceScope) {
if (typeof window === "undefined" || !pubkey || !communityScope) {
return;
}
try {
window.localStorage.setItem(
welcomeChannelEnsuredStorageKey(pubkey, workspaceScope),
welcomeChannelEnsuredStorageKey(pubkey, communityScope),
"true",
);
} catch {

View File

@@ -93,14 +93,14 @@ test("pickWelcomeGuideAgent ignores non-Kit agents with the legacy prompt", () =
assert.equal(pickWelcomeGuideAgent([nonKit, fizz]), fizz);
});
test("pickWelcomeGuideAgentForRelay ignores Fizz agents from other workspaces", () => {
const otherWorkspaceFizz = makeAgent({
test("pickWelcomeGuideAgentForRelay ignores Fizz agents from other communities", () => {
const otherCommunityFizz = makeAgent({
pubkey: PUB_A,
personaId: WELCOME_GUIDE_PERSONA_ID,
relayUrl: RELAY_A,
status: "running",
});
const currentWorkspaceFizz = makeAgent({
const currentCommunityFizz = makeAgent({
pubkey: PUB_B,
personaId: WELCOME_GUIDE_PERSONA_ID,
relayUrl: RELAY_B,
@@ -109,22 +109,22 @@ test("pickWelcomeGuideAgentForRelay ignores Fizz agents from other workspaces",
assert.equal(
pickWelcomeGuideAgentForRelay(
[otherWorkspaceFizz, currentWorkspaceFizz],
[otherCommunityFizz, currentCommunityFizz],
RELAY_B,
),
currentWorkspaceFizz,
currentCommunityFizz,
);
});
test("pickWelcomeGuideAgentForRelay returns null when Fizz only exists in another workspace", () => {
const otherWorkspaceFizz = makeAgent({
test("pickWelcomeGuideAgentForRelay returns null when Fizz only exists in another community", () => {
const otherCommunityFizz = makeAgent({
pubkey: PUB_A,
personaId: WELCOME_GUIDE_PERSONA_ID,
relayUrl: RELAY_A,
});
assert.equal(
pickWelcomeGuideAgentForRelay([otherWorkspaceFizz], RELAY_B),
pickWelcomeGuideAgentForRelay([otherCommunityFizz], RELAY_B),
null,
);
});

View File

@@ -14,7 +14,7 @@ export const WELCOME_GUIDE_PERSONA_ID = "builtin:fizz";
export const WELCOME_GUIDE_INTRO_MARKER = "buzz-welcome-intro.v1";
const LEGACY_WELCOME_GUIDE_AGENT_NAME = "Kit";
export const LEGACY_WELCOME_GUIDE_SYSTEM_PROMPT =
"You are Kit, Sprout's friendly welcome guide. Help new users understand the workspace, channels, messages, and agents. Keep introductions concise, practical, and warm.";
"You are Kit, Sprout's friendly welcome guide. Help new users understand the community, channels, messages, and agents. Keep introductions concise, practical, and warm.";
export const WELCOME_GUIDE_INTRO_MESSAGE =
"Hi, I'm Fizz. Welcome to Buzz.\n\nI can help you get oriented, answer questions, and make the first few steps feel less mysterious.\n\nFeel free to ask me what else you can do in Buzz, or just talk through what you want to build.";

View File

@@ -41,7 +41,7 @@ import {
shouldFetchAvatar,
resolveAvatarDataUrl,
} from "@/features/profile/lib/selfProfileStorage";
import { useWorkspaces } from "@/features/workspaces/useWorkspaces";
import { useCommunities } from "@/features/communities/useCommunities";
export const profileQueryKey = ["profile"] as const;
export const contactListQueryKey = (pubkey: string) =>
@@ -83,10 +83,10 @@ async function persistSelfProfile(
}
export function useProfileQuery(enabled = true) {
const { activeWorkspace } = useWorkspaces();
const { activeCommunity } = useCommunities();
const identityQuery = useIdentityQuery();
const queryClient = useQueryClient();
const relayUrl = activeWorkspace?.relayUrl ?? "";
const relayUrl = activeCommunity?.relayUrl ?? "";
const pubkey = identityQuery.data?.pubkey ?? "";
// Parse localStorage once per relayUrl/pubkey pair — not on every render.
@@ -119,7 +119,7 @@ export function useProfileQuery(enabled = true) {
);
// `initialData` is only honored at query construction, which happens before
// identity/workspace resolve on a fresh QueryClient — seed the cache
// identity/community resolve on a fresh QueryClient — seed the cache
// imperatively once they arrive, without ever stomping a real fetch result.
React.useEffect(() => {
if (!initialData || !cached) return;
@@ -157,9 +157,9 @@ export function useProfileQuery(enabled = true) {
* SELF_PROFILE_CACHE_EVENT after writes so this hook re-reads without polling.
*/
export function useSelfProfileCache(): SelfProfileCache | null {
const { activeWorkspace } = useWorkspaces();
const { activeCommunity } = useCommunities();
const identityQuery = useIdentityQuery();
const relayUrl = activeWorkspace?.relayUrl ?? "";
const relayUrl = activeCommunity?.relayUrl ?? "";
const pubkey = identityQuery.data?.pubkey ?? "";
const [cache, setCache] = React.useState<SelfProfileCache | null>(() =>
@@ -485,7 +485,7 @@ export function useUserSearchFetchMoreOnScroll(
export function useUpdateProfileMutation() {
const queryClient = useQueryClient();
const { activeWorkspace } = useWorkspaces();
const { activeCommunity } = useCommunities();
const identityQuery = useIdentityQuery();
return useMutation({
@@ -504,7 +504,7 @@ export function useUpdateProfileMutation() {
// Cancel again: a refetch may have started while mutationFn awaited.
await queryClient.cancelQueries({ queryKey: profileQueryKey });
queryClient.setQueryData(profileQueryKey, profile);
const relayUrl = activeWorkspace?.relayUrl ?? "";
const relayUrl = activeCommunity?.relayUrl ?? "";
const pubkey = identityQuery.data?.pubkey ?? profile.pubkey;
if (relayUrl && pubkey) {
void persistSelfProfile(relayUrl, pubkey, profile);

View File

@@ -8,7 +8,7 @@
*
* Profiles are keyed per relay + pubkey rather than pubkey alone because the
* same key can have a different kind-0 on different relays. Scoping by relay
* prevents one workspace's cached identity from bleeding into another.
* prevents one community's cached identity from bleeding into another.
*/
const STORAGE_KEY_PREFIX = "buzz-self-profile.v1";
@@ -160,7 +160,7 @@ export function writeSelfProfileCache(
/**
* Removes all self-profile cache entries for every pubkey on the given relay.
* Called when a workspace is removed to GC storage for that relay.
* Called when a community is removed to GC storage for that relay.
*/
export function removeSelfProfileCachesForRelay(relayUrl: string): void {
try {
@@ -223,7 +223,7 @@ export function resolveAvatarDataUrl(
* is known to be reachable, so the fetch has the best chance of succeeding.
*
* The data URL is capped at 256 KB to keep localStorage usage bounded across
* workspaces and accounts. Returns null on ANY failure: network error, non-OK
* communities and accounts. Returns null on ANY failure: network error, non-OK
* response, wrong content-type, blob too large, or FileReader error.
*/
export async function fetchAvatarDataUrl(

View File

@@ -30,9 +30,9 @@ interface ProfilePopoverProps {
// primary PopoverTrigger and toggle the popover via controlled `open`.
triggerContainerRef?: React.RefObject<HTMLElement | null>;
// Optional slot rendered between the identity block and the menu items.
// Used by the sidebar to surface the workspace/relay selector inside the
// Used by the sidebar to surface the community/relay selector inside the
// profile menu instead of on the sidebar card.
workspaceSwitcherSlot?: React.ReactNode;
communitySwitcherSlot?: React.ReactNode;
}
const MENU_ITEM_CLASS =
@@ -56,7 +56,7 @@ export function ProfilePopover({
onOpenSettings,
children,
triggerContainerRef,
workspaceSwitcherSlot,
communitySwitcherSlot,
}: ProfilePopoverProps) {
const [statusDialogOpen, setStatusDialogOpen] = React.useState(false);
const [presenceMenuOpen, setPresenceMenuOpen] = React.useState(false);
@@ -259,12 +259,12 @@ export function ProfilePopover({
</kbd>
</button>
{workspaceSwitcherSlot ? (
{communitySwitcherSlot ? (
<>
<hr className="my-1 h-px border-0 bg-border" />
{/* ── Workspace / relay selector ─────────────────── */}
<div data-testid="profile-popover-workspace">
{workspaceSwitcherSlot}
{/* ── Community / relay selector ─────────────────── */}
<div data-testid="profile-popover-community">
{communitySwitcherSlot}
</div>
</>
) : null}

View File

@@ -312,7 +312,7 @@ function AgentDeleteConfirmDialog({
<AlertDialogHeader>
<AlertDialogTitle>Delete this agent?</AlertDialogTitle>
<AlertDialogDescription>
Deleting this agent stops and removes the agent from this workspace.
Deleting this agent stops and removes the agent from this community.
</AlertDialogDescription>
</AlertDialogHeader>
<ul className="list-disc space-y-1.5 pl-5 text-sm text-muted-foreground">

View File

@@ -53,7 +53,7 @@ import { ProfilePanelProvider } from "@/shared/context/ProfilePanelContext";
import { useHistorySearchState } from "@/shared/hooks/useHistorySearchState";
import { useThreadPanelWidth } from "@/shared/hooks/useThreadPanelWidth";
import { Button } from "@/shared/ui/button";
import { useWorkspaces } from "@/features/workspaces/useWorkspaces";
import { useCommunities } from "@/features/communities/useCommunities";
import { useProjectCommitDiffQuery } from "@/features/projects/useProjectCommitDiff";
import { useGitIdentityQuery } from "@/features/projects/useGitIdentity";
import type { ViewerGitIdentity } from "@/features/projects/lib/projectContributorMatching";
@@ -100,7 +100,7 @@ const PROJECT_DETAIL_PANEL_SEARCH_KEYS = [
export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
const { projectId, pullRequestId, issueId } = props;
const { goChannel, goProjects } = useAppNavigation();
const { activeWorkspace } = useWorkspaces();
const { activeCommunity } = useCommunities();
const mainInsetRef = useMainInsetRef();
const projectDetailHeaderChromeRef = useMeasuredCssVariable({
targetRef: mainInsetRef,
@@ -209,7 +209,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
);
const localRepoDiffQuery = useProjectLocalRepoDiffQuery(
project,
activeWorkspace?.reposDir,
activeCommunity?.reposDir,
activeBranch,
activeRepoPullRequest,
repoSource === "local" && Boolean(activeRepoPullRequest),
@@ -218,21 +218,21 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
project,
selectedCommitHash,
repoSource,
activeWorkspace?.reposDir,
activeCommunity?.reposDir,
);
const localRepoSnapshotQuery = useProjectLocalRepoSnapshotQuery(
project,
activeWorkspace?.reposDir,
activeCommunity?.reposDir,
activeBranch,
);
const repoSyncStatusQuery = useProjectRepoSyncStatusQuery(
project,
activeWorkspace?.reposDir,
activeCommunity?.reposDir,
activeBranch,
);
const pushLocalRepoMutation = usePushProjectLocalRepositoryMutation(
project,
activeWorkspace?.reposDir,
activeCommunity?.reposDir,
activeBranch,
);
const hasLocalCheckout = Boolean(
@@ -400,7 +400,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
repoSyncStatusQuery,
]);
const openTerminal = useOpenProjectTerminal(activeWorkspace?.reposDir);
const openTerminal = useOpenProjectTerminal(activeCommunity?.reposDir);
const handleOpenTerminal = React.useCallback(() => {
if (!project) return Promise.resolve();
return openTerminal(project, {

View File

@@ -9,7 +9,7 @@ import {
} from "lucide-react";
import type * as React from "react";
import { WorkspaceEmojiIcon } from "@/features/workspaces/ui/WorkspaceSwitcher";
import { CommunityEmojiIcon } from "@/features/communities/ui/CommunitySwitcher";
import type {
Project,
ProjectActivitySummary,
@@ -41,7 +41,7 @@ type ProjectsOverviewPanelProps = {
profiles?: UserProfileLookup;
projects: Project[];
relayName: string;
/** Repo snapshots keyed by project ID, for workspace-wide aggregates. */
/** Repo snapshots keyed by project ID, for community-wide aggregates. */
snapshots?: Record<string, ProjectRepoSnapshot>;
snapshotsLoading?: boolean;
summaries?: Record<string, ProjectActivitySummary>;
@@ -224,14 +224,14 @@ export function ProjectsOverviewPanel({
return (
<section className="-mx-4 mb-4 bg-card">
<div className="flex min-w-0 items-start gap-3 p-4">
<WorkspaceEmojiIcon className="flex h-10 w-10 shrink-0 items-center justify-center border border-border/60 bg-muted/40 text-2xl" />
<CommunityEmojiIcon className="flex h-10 w-10 shrink-0 items-center justify-center border border-border/60 bg-muted/40 text-2xl" />
<div className="-mt-1 min-w-0 flex-1">
<h2 className="text-xl font-semibold leading-6 tracking-tight text-foreground">
{relayName} Projects
</h2>
<p className="max-w-2xl text-sm font-normal text-muted-foreground">
Browse shared repositories, pull requests, and local project
checkouts in this workspace.
checkouts in this community.
</p>
</div>
</div>

View File

@@ -61,7 +61,7 @@ import {
projectTerminalLabel,
useOpenProjectTerminal,
} from "@/features/projects/ui/useOpenProjectTerminal";
import { useWorkspaces } from "@/features/workspaces/useWorkspaces";
import { useCommunities } from "@/features/communities/useCommunities";
import { useIdentityQuery } from "@/shared/api/hooks";
import { useMainInsetRef } from "@/shared/layout/MainInsetContext";
import {
@@ -617,7 +617,7 @@ function ProjectListRow({
export function ProjectsView() {
const { goProject } = useAppNavigation();
const { activeWorkspace } = useWorkspaces();
const { activeCommunity } = useCommunities();
const mainInsetRef = useMainInsetRef();
const projectsHeaderChromeRef = useMeasuredCssVariable({
targetRef: mainInsetRef,
@@ -628,7 +628,7 @@ export function ProjectsView() {
const projects = projectsQuery.data ?? [];
const activitySummariesQuery = useProjectActivitySummariesQuery(projects);
const localRepositoriesQuery = useProjectLocalRepositoriesQuery(
activeWorkspace?.reposDir,
activeCommunity?.reposDir,
);
const projectPullRequestsQuery = useProjectsPullRequestsQuery(projects);
const [filter, setFilter] = React.useState<ProjectsFilter>(() =>
@@ -645,7 +645,7 @@ export function ProjectsView() {
);
const repoSnapshotsQuery = useProjectsRepoSnapshotsQuery(
snapshotProjects,
activeWorkspace?.reposDir,
activeCommunity?.reposDir,
);
const [storedViewMode, setStoredViewMode] =
React.useState<ProjectsViewMode | null>(() => readStoredViewMode());
@@ -815,7 +815,7 @@ export function ProjectsView() {
[goProject],
);
const openTerminal = useOpenProjectTerminal(activeWorkspace?.reposDir);
const openTerminal = useOpenProjectTerminal(activeCommunity?.reposDir);
const handleOpenTerminal = React.useCallback(
(project: Project) =>
openTerminal(project, {
@@ -882,7 +882,7 @@ export function ProjectsView() {
onSelectSection={handleFilterChange}
profiles={profiles}
projects={projects}
relayName={activeWorkspace?.name || "Relay"}
relayName={activeCommunity?.name || "Relay"}
snapshots={repoSnapshotsQuery.data}
snapshotsLoading={repoSnapshotsQuery.isLoading}
summaries={activitySummariesQuery.data}

View File

@@ -78,7 +78,7 @@ async function fetchProjectsRepoSnapshots(
/**
* Fetches repo snapshots for a set of projects (throttled, failure-tolerant)
* for workspace-wide aggregates like the overview language breakdown.
* for community-wide aggregates like the overview language breakdown.
* Prefers local checkouts under `reposDir`; falls back to remote clones.
* Callers should pre-filter and cap `projects` — up to one git clone per entry.
*/

View File

@@ -294,7 +294,7 @@ function RuntimeRow({
codex-acp
</code>{" "}
adapter. Older Buzz releases using the legacy adapter contract may
lose relay access until{" "}
lose community access until{" "}
<code className="rounded bg-muted px-1 py-0.5 text-2xs">
@zed-industries/codex-acp@0.16.0
</code>{" "}

View File

@@ -10,7 +10,7 @@ import {
type ModerationReport as HookModerationReport,
type ResolutionAction,
} from "@/features/moderation/hooks";
import { useMyRelayMembershipQuery } from "@/features/relay-members/hooks";
import { useMyRelayMembershipQuery } from "@/features/community-members/hooks";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import {
deleteMessage,

View File

@@ -27,7 +27,7 @@ import type {
NotificationSettings,
} from "@/features/notifications/hooks";
import type { SoundName, SoundSlot } from "@/features/notifications/lib/sound";
import { RelayMembersSettingsCard } from "@/features/relay-members/ui/RelayMembersSettingsCard";
import { CommunityMembersSettingsCard } from "@/features/community-members/ui/CommunityMembersSettingsCard";
import { CustomEmojiSettingsCard } from "@/features/custom-emoji/ui/CustomEmojiSettingsCard";
import { LocalArchiveSettingsCard } from "@/features/local-archive/ui/LocalArchiveSettingsCard";
import { cn } from "@/shared/lib/cn";
@@ -76,7 +76,7 @@ export type SettingsSection =
| "compute"
| "appearance"
| "shortcuts"
| "relay-members"
| "community-members"
| "moderation"
| "custom-emoji"
| "local-archive"
@@ -95,7 +95,7 @@ const SETTINGS_SECTION_VALUES: readonly SettingsSection[] = [
"compute",
"appearance",
"shortcuts",
"relay-members",
"community-members",
"moderation",
"custom-emoji",
"local-archive",
@@ -178,8 +178,8 @@ export const settingsSections: SettingsSectionDescriptor[] = [
icon: Keyboard,
},
{
value: "relay-members",
label: "Relay Access",
value: "community-members",
label: "Community Access",
icon: LockKeyhole,
},
{
@@ -721,8 +721,10 @@ export function renderSettingsSection(
return <ThemeSettingsCard />;
case "shortcuts":
return <KeyboardShortcutsCard />;
case "relay-members":
return <RelayMembersSettingsCard currentPubkey={props.currentPubkey} />;
case "community-members":
return (
<CommunityMembersSettingsCard currentPubkey={props.currentPubkey} />
);
case "moderation":
return <ModerationQueueCard />;
case "custom-emoji":

View File

@@ -2,7 +2,7 @@ import * as React from "react";
import { getVersion } from "@tauri-apps/api/app";
import { ArrowLeft } from "lucide-react";
import { useMyRelayMembershipQuery } from "@/features/relay-members/hooks";
import { useMyRelayMembershipQuery } from "@/features/community-members/hooks";
import { getFeature } from "@/shared/features/manifest";
import {
resolveEnabled,
@@ -58,8 +58,8 @@ const settingsNavGroups: Array<{
],
},
{
label: "Workspaces",
sections: ["channel-templates", "relay-members"],
label: "Communities",
sections: ["channel-templates", "community-members"],
},
{
label: "App",
@@ -142,8 +142,8 @@ export function SettingsView({
return false;
}
}
// Relay members requires admin/owner role
if (s.value === "relay-members") {
// Community members requires admin/owner role
if (s.value === "community-members") {
return (
membership != null &&
(membership.role === "owner" || membership.role === "admin")

View File

@@ -26,7 +26,7 @@ export const DEFAULT_STORE: ChannelSectionStore = Object.freeze({
*
* When `relayUrl` is provided the key is scoped to that relay (normalized via
* the same `normalizeRelayUrl` used by all relay-scoped local stores) so
* sections from different workspaces/relays don't bleed across each other.
* sections from different communities/relays don't bleed across each other.
* When omitted the legacy pubkey-only key is returned (used only during
* one-time migration in `readChannelSectionsStore`).
*/

View File

@@ -15,7 +15,7 @@ function makeStore(overrides = {}) {
// ─── destroy() must cancel pending publish, not flush ─────────────────────────
// Regression guard for the workspace-switch cross-relay publish vector:
// Regression guard for the community-switch cross-relay publish vector:
// edit sections in relay A → destroy() is called (relayUrl dep change) →
// no publish should fire. The scoped localStorage write is durable; when the
// user returns to relay A the seed-publish path handles it.
@@ -62,7 +62,7 @@ test("destroy: cancels pending publish without flushing to the relay", () => {
manager.publishSections(store);
assert.ok(timerCallback !== null, "debounce timer should be set");
// Destroy before the debounce fires — simulates workspace switch.
// Destroy before the debounce fires — simulates community switch.
manager.destroy();
// Timer must be cleared and no publish should fire now.

View File

@@ -146,7 +146,7 @@ export class ChannelSectionSyncManager {
try {
const merged = await this.fetchOwnBlobBeforePublish(store);
// Guard: manager may have been destroyed while fetchOwnBlobBeforePublish
// was awaited (workspace switch during in-flight fetch). If so, abort
// was awaited (community switch during in-flight fetch). If so, abort
// before touching the relay.
if (this.destroyed) return;
if (this.isIdenticalToLastPublished(merged)) {
@@ -174,7 +174,7 @@ export class ChannelSectionSyncManager {
});
// Final guard immediately before the network call — sign/encrypt are
// synchronous-ish but cheap; the relay socket may have moved to a
// different workspace by the time we reach this point.
// different community by the time we reach this point.
if (this.destroyed) return;
await relayClient.publishEvent(
event,
@@ -222,7 +222,7 @@ export class ChannelSectionSyncManager {
// in-flight doPublish() calls abort before reaching relayClient. The
// scoped localStorage write is already durable; when the user returns to
// this relay the existing seed-publish guard will re-publish from local
// state. Flushing here would race against workspace switching and could
// state. Flushing here would race against community switching and could
// publish relay A's sections to relay B via the shared relayClient
// singleton.
this.destroyed = true;

View File

@@ -37,7 +37,7 @@ export function sectionSortGroupKey(sectionId: string): ChannelSortGroupKey {
*
* When `relayUrl` is provided the key is scoped to that relay (normalized via
* the same `normalizeRelayUrl` used by all relay-scoped local stores) so
* preferences don't bleed across workspaces/relays.
* preferences don't bleed across communities/relays.
*/
export function storageKey(pubkey: string, relayUrl?: string): string {
if (!relayUrl) return `${STORAGE_KEY_PREFIX}:${pubkey}`;

Some files were not shown because too many files have changed in this diff Show More