diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts
index 7d529722036..224da878b4c 100644
--- a/desktop/playwright.config.ts
+++ b/desktop/playwright.config.ts
@@ -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",
diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx
index 45707ce66d2..0ff3ffaa218 100644
--- a/desktop/src/app/App.tsx
+++ b/desktop/src/app/App.tsx
@@ -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 (
{
@@ -159,14 +159,14 @@ function WorkspaceSwitchGate() {
return (
@@ -207,7 +207,7 @@ function OnboardingLoadingGate() {
Welcome to Buzz
- Choose your first workspace to get started.
+ Choose your first community to get started.
@@ -217,7 +217,7 @@ function OnboardingLoadingGate() {
tabIndex={-1}
type="button"
>
- Continue with default workspace
+ Continue with default community
) : null}
-
+
);
}
diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx
index f7c6c496f7a..c5f802a6668 100644
--- a/desktop/src/app/AppShell.tsx
+++ b/desktop/src/app/AppShell.tsx
@@ -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(
@@ -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 ? (
- 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}
@@ -693,9 +693,9 @@ export function AppShell() {
1
+ hasCommunityRail={
+ communityRailEnabled &&
+ communitiesHook.communities.length > 1
}
onGoBack={goBack}
onGoForward={goForward}
@@ -746,35 +746,35 @@ export function AppShell() {
) : (
{
- 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() {
1
+ hasCommunityRail={
+ communityRailEnabled &&
+ communitiesHook.communities.length > 1
}
isHuddleDrawerOpen={isHuddleDrawerOpen}
/>
diff --git a/desktop/src/app/AppTopChrome.tsx b/desktop/src/app/AppTopChrome.tsx
index 0f00205340a..845c403b8c2 100644
--- a/desktop/src/app/AppTopChrome.tsx
+++ b/desktop/src/app/AppTopChrome.tsx
@@ -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(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";
diff --git a/desktop/src/app/RelayConnectionOverlay.tsx b/desktop/src/app/RelayConnectionOverlay.tsx
index 683afb459d7..faf28ee51e0 100644
--- a/desktop/src/app/RelayConnectionOverlay.tsx
+++ b/desktop/src/app/RelayConnectionOverlay.tsx
@@ -11,7 +11,7 @@ import { useSidebar } from "@/shared/ui/sidebar";
type RelayConnectionOverlayProps = {
card: ReturnType;
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",
diff --git a/desktop/src/app/useReloadShortcut.ts b/desktop/src/app/useReloadShortcut.ts
index 42f9854e72a..74bfd4f2fe9 100644
--- a/desktop/src/app/useReloadShortcut.ts
+++ b/desktop/src/app/useReloadShortcut.ts
@@ -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() {
diff --git a/desktop/src/app/useThreadActivityFeedItems.test.mjs b/desktop/src/app/useThreadActivityFeedItems.test.mjs
index a343eb2d4ee..6c017028ca4 100644
--- a/desktop/src/app/useThreadActivityFeedItems.test.mjs
+++ b/desktop/src/app/useThreadActivityFeedItems.test.mjs
@@ -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"],
diff --git a/desktop/src/app/useThreadActivityFeedItems.ts b/desktop/src/app/useThreadActivityFeedItems.ts
index 211cd1c1c67..af0c50ce48a 100644
--- a/desktop/src/app/useThreadActivityFeedItems.ts
+++ b/desktop/src/app/useThreadActivityFeedItems.ts
@@ -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);
diff --git a/desktop/src/features/agents/activeAgentTurnsStore.test.mjs b/desktop/src/features/agents/activeAgentTurnsStore.test.mjs
index 4897d73a456..2346e00d621 100644
--- a/desktop/src/features/agents/activeAgentTurnsStore.test.mjs
+++ b/desktop/src/features/agents/activeAgentTurnsStore.test.mjs
@@ -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",
);
});
});
diff --git a/desktop/src/features/agents/activeAgentTurnsStore.ts b/desktop/src/features/agents/activeAgentTurnsStore.ts
index 07a6e7e01d6..355e7b3e814 100644
--- a/desktop/src/features/agents/activeAgentTurnsStore.ts
+++ b/desktop/src/features/agents/activeAgentTurnsStore.ts
@@ -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>;
};
-/** Per-workspace snapshots. Keyed by workspace ID. */
-const savedByWorkspace = new Map();
+/** Per-community snapshots. Keyed by community ID. */
+const savedByCommunity = new Map();
/**
- * 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();
* 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);
}
diff --git a/desktop/src/features/agents/agentWorkingSignal.ts b/desktop/src/features/agents/agentWorkingSignal.ts
index 1c3a74fc430..eeae58b0703 100644
--- a/desktop/src/features/agents/agentWorkingSignal.ts
+++ b/desktop/src/features/agents/agentWorkingSignal.ts
@@ -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();
diff --git a/desktop/src/features/agents/lib/friendlyAgentLastError.ts b/desktop/src/features/agents/lib/friendlyAgentLastError.ts
index 7c9fd68972f..60c77bb04cc 100644
--- a/desktop/src/features/agents/lib/friendlyAgentLastError.ts
+++ b/desktop/src/features/agents/lib/friendlyAgentLastError.ts
@@ -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.";
diff --git a/desktop/src/features/agents/ui/EditAgentAdvancedFields.tsx b/desktop/src/features/agents/ui/EditAgentAdvancedFields.tsx
index 74aa6142578..614c444347c 100644
--- a/desktop/src/features/agents/ui/EditAgentAdvancedFields.tsx
+++ b/desktop/src/features/agents/ui/EditAgentAdvancedFields.tsx
@@ -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}
/>
diff --git a/desktop/src/features/agents/ui/ManagedAgentRow.tsx b/desktop/src/features/agents/ui/ManagedAgentRow.tsx
index eeb300c33dd..e93e089b3b5 100644
--- a/desktop/src/features/agents/ui/ManagedAgentRow.tsx
+++ b/desktop/src/features/agents/ui/ManagedAgentRow.tsx
@@ -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(
diff --git a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx
index a67758bdd99..5bcded76a8f 100644
--- a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx
+++ b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx
@@ -459,7 +459,7 @@ function SectionHeader({
Agents
- Agents in this workspace.
+ Agents in this community.
>(
* 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.
diff --git a/desktop/src/features/channels/channelSnapshot.ts b/desktop/src/features/channels/channelSnapshot.ts
index 3f062b579bb..404377eee3c 100644
--- a/desktop/src/features/channels/channelSnapshot.ts
+++ b/desktop/src/features/channels/channelSnapshot.ts
@@ -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 {
diff --git a/desktop/src/features/channels/forcedUnreadStore.ts b/desktop/src/features/channels/forcedUnreadStore.ts
index 23db48cec9f..c6a6fd07585 100644
--- a/desktop/src/features/channels/forcedUnreadStore.ts
+++ b/desktop/src/features/channels/forcedUnreadStore.ts
@@ -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
diff --git a/desktop/src/features/channels/hooks.ts b/desktop/src/features/channels/hooks.ts
index 58f432beac2..c3298cf289d 100644
--- a/desktop/src/features/channels/hooks.ts
+++ b/desktop/src/features/channels/hooks.ts
@@ -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,
diff --git a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx
index 380db8816ed..a907f872458 100644
--- a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx
+++ b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx
@@ -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."
}
>
diff --git a/desktop/src/features/channels/ui/ChannelManagementModerationActions.tsx b/desktop/src/features/channels/ui/ChannelManagementModerationActions.tsx
index c5e192fd2a7..4e11996dc9b 100644
--- a/desktop/src/features/channels/ui/ChannelManagementModerationActions.tsx
+++ b/desktop/src/features/channels/ui/ChannelManagementModerationActions.tsx
@@ -126,7 +126,7 @@ export function ChannelManagementModerationActions({
Delete channel?
- Delete {resolvedChannelName} from the workspace list. This
+ Delete {resolvedChannelName} from the community list. This
action cannot be undone.
diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx
index 4d5b0240db1..ec46e8fb377 100644
--- a/desktop/src/features/channels/ui/ChannelScreen.tsx
+++ b/desktop/src/features/channels/ui/ChannelScreen.tsx
@@ -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 ?? [];
diff --git a/desktop/src/features/channels/ui/useMembersSidebarModeration.ts b/desktop/src/features/channels/ui/useMembersSidebarModeration.ts
index 9a546d48366..04c4f1a70b2 100644
--- a/desktop/src/features/channels/ui/useMembersSidebarModeration.ts
+++ b/desktop/src/features/channels/ui/useMembersSidebarModeration.ts
@@ -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";
diff --git a/desktop/src/features/channels/useUnreadChannels.storage.test.mjs b/desktop/src/features/channels/useUnreadChannels.storage.test.mjs
index 6f0423ca8bd..fb5ebfd7bf0 100644
--- a/desktop/src/features/channels/useUnreadChannels.storage.test.mjs
+++ b/desktop/src/features/channels/useUnreadChannels.storage.test.mjs
@@ -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");
diff --git a/desktop/src/features/channels/useUnreadChannels.ts b/desktop/src/features/channels/useUnreadChannels.ts
index f560cdb57e0..a2376f9b7bb 100644
--- a/desktop/src/features/channels/useUnreadChannels.ts
+++ b/desktop/src/features/channels/useUnreadChannels.ts
@@ -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(
pubkey ? forcedUnreadStore.read(pubkey) : {},
);
diff --git a/desktop/src/features/workspaces/workspaceIconCache.ts b/desktop/src/features/communities/communityIconCache.ts
similarity index 80%
rename from desktop/src/features/workspaces/workspaceIconCache.ts
rename to desktop/src/features/communities/communityIconCache.ts
index df61a32263b..704b89ed27e 100644
--- a/desktop/src/features/workspaces/workspaceIconCache.ts
+++ b/desktop/src/features/communities/communityIconCache.ts
@@ -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 {
try {
@@ -19,11 +19,11 @@ function loadCache(): Record {
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 {
diff --git a/desktop/src/features/workspaces/workspaceMarkRead.test.mjs b/desktop/src/features/communities/communityMarkRead.test.mjs
similarity index 92%
rename from desktop/src/features/workspaces/workspaceMarkRead.test.mjs
rename to desktop/src/features/communities/communityMarkRead.test.mjs
index 1f4bcc3c628..8cb3680a246 100644
--- a/desktop/src/features/workspaces/workspaceMarkRead.test.mjs
+++ b/desktop/src/features/communities/communityMarkRead.test.mjs
@@ -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]));
diff --git a/desktop/src/features/workspaces/workspaceMarkRead.ts b/desktop/src/features/communities/communityMarkRead.ts
similarity index 90%
rename from desktop/src/features/workspaces/workspaceMarkRead.ts
rename to desktop/src/features/communities/communityMarkRead.ts
index 16b2e6b6cb0..ff5af6541ff 100644
--- a/desktop/src/features/workspaces/workspaceMarkRead.ts
+++ b/desktop/src/features/communities/communityMarkRead.ts
@@ -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 {
- await withReadOnlyRelayClient(workspace.relayUrl, (client) =>
- publishWorkspaceReadState({
+ await withReadOnlyRelayClient(community.relayUrl, (client) =>
+ publishCommunityReadState({
client,
pubkey,
- relayUrl: workspace.relayUrl,
+ relayUrl: community.relayUrl,
}),
);
}
diff --git a/desktop/src/features/communities/communityStorage.test.mjs b/desktop/src/features/communities/communityStorage.test.mjs
new file mode 100644
index 00000000000..4187d55d540
--- /dev/null
+++ b/desktop/src/features/communities/communityStorage.test.mjs
@@ -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);
+});
diff --git a/desktop/src/features/workspaces/workspaceStorage.ts b/desktop/src/features/communities/communityStorage.ts
similarity index 58%
rename from desktop/src/features/workspaces/workspaceStorage.ts
rename to desktop/src/features/communities/communityStorage.ts
index 5c0c5724fee..e406d9087f0 100644
--- a/desktop/src/features/workspaces/workspaceStorage.ts
+++ b/desktop/src/features/communities/communityStorage.ts
@@ -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 {
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;
}
diff --git a/desktop/src/features/workspaces/workspaceUnreadObserver.test.mjs b/desktop/src/features/communities/communityUnreadObserver.test.mjs
similarity index 91%
rename from desktop/src/features/workspaces/workspaceUnreadObserver.test.mjs
rename to desktop/src/features/communities/communityUnreadObserver.test.mjs
index ddc3d1ce059..45c19813d18 100644
--- a/desktop/src/features/workspaces/workspaceUnreadObserver.test.mjs
+++ b/desktop/src/features/communities/communityUnreadObserver.test.mjs
@@ -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,
diff --git a/desktop/src/features/workspaces/workspaceUnreadObserver.ts b/desktop/src/features/communities/communityUnreadObserver.ts
similarity index 94%
rename from desktop/src/features/workspaces/workspaceUnreadObserver.ts
rename to desktop/src/features/communities/communityUnreadObserver.ts
index 280fc04d9eb..5bc32984132 100644
--- a/desktop/src/features/workspaces/workspaceUnreadObserver.ts
+++ b/desktop/src/features/communities/communityUnreadObserver.ts
@@ -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;
};
@@ -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 {
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 {
- return withReadOnlyRelayClient(workspace.relayUrl, (client) =>
- fetchWorkspaceUnread({ client, pubkey }),
+): Promise {
+ 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;
decryptMutes?: (ciphertext: string) => Promise;
readThreadRelationships?: (pubkey: string) => ThreadRelationships;
readForcedUnread?: (pubkey: string) => ForcedUnreadMap;
-}): Promise {
+}): Promise {
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 (
diff --git a/desktop/src/features/communities/legacyCommunityStorage.test.mjs b/desktop/src/features/communities/legacyCommunityStorage.test.mjs
new file mode 100644
index 00000000000..d6a574cbcc8
--- /dev/null
+++ b/desktop/src/features/communities/legacyCommunityStorage.test.mjs
@@ -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");
+});
diff --git a/desktop/src/features/communities/legacyCommunityStorage.ts b/desktop/src/features/communities/legacyCommunityStorage.ts
new file mode 100644
index 00000000000..d140fa4c628
--- /dev/null
+++ b/desktop/src/features/communities/legacyCommunityStorage.ts
@@ -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 {
+ 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(
+ "get_legacy_workspace_storage",
+ ),
+ );
+ } catch (error) {
+ console.warn("Failed to read legacy Sprout community storage.", error);
+ }
+}
diff --git a/desktop/src/features/workspaces/lib/downscaleIcon.ts b/desktop/src/features/communities/lib/downscaleIcon.ts
similarity index 93%
rename from desktop/src/features/workspaces/lib/downscaleIcon.ts
rename to desktop/src/features/communities/lib/downscaleIcon.ts
index 391911a457b..b79507988a3 100644
--- a/desktop/src/features/workspaces/lib/downscaleIcon.ts
+++ b/desktop/src/features/communities/lib/downscaleIcon.ts
@@ -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.
*/
diff --git a/desktop/src/features/workspaces/types.ts b/desktop/src/features/communities/types.ts
similarity index 88%
rename from desktop/src/features/workspaces/types.ts
rename to desktop/src/features/communities/types.ts
index ef3976796ce..7087ca7ce3b 100644
--- a/desktop/src/features/workspaces/types.ts
+++ b/desktop/src/features/communities/types.ts
@@ -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;
diff --git a/desktop/src/features/workspaces/ui/AddWorkspaceDialog.tsx b/desktop/src/features/communities/ui/AddCommunityDialog.tsx
similarity index 90%
rename from desktop/src/features/workspaces/ui/AddWorkspaceDialog.tsx
rename to desktop/src/features/communities/ui/AddCommunityDialog.tsx
index 07258fddd32..b3ac2116ec3 100644
--- a/desktop/src/features/workspaces/ui/AddWorkspaceDialog.tsx
+++ b/desktop/src/features/communities/ui/AddCommunityDialog.tsx
@@ -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({
- 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).
@@ -231,7 +231,7 @@ export function AddWorkspaceDialog({
Cancel
diff --git a/desktop/src/features/workspaces/ui/WorkspaceIconSettingsCard.tsx b/desktop/src/features/communities/ui/CommunityIconSettingsCard.tsx
similarity index 66%
rename from desktop/src/features/workspaces/ui/WorkspaceIconSettingsCard.tsx
rename to desktop/src/features/communities/ui/CommunityIconSettingsCard.tsx
index f2ca3053c53..bf62c253e4d 100644
--- a/desktop/src/features/workspaces/ui/WorkspaceIconSettingsCard.tsx
+++ b/desktop/src/features/communities/ui/CommunityIconSettingsCard.tsx
@@ -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(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 (
-
- 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.
@@ -291,11 +291,11 @@ export function WorkspaceSwitcher({
) : null}
diff --git a/desktop/src/features/workspaces/ui/WelcomeSetup.tsx b/desktop/src/features/communities/ui/WelcomeSetup.tsx
similarity index 82%
rename from desktop/src/features/workspaces/ui/WelcomeSetup.tsx
rename to desktop/src/features/communities/ui/WelcomeSetup.tsx
index c91d253c4aa..d827df88923 100644
--- a/desktop/src/features/workspaces/ui/WelcomeSetup.tsx
+++ b/desktop/src/features/communities/ui/WelcomeSetup.tsx
@@ -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("welcome");
const [transitionMode, setTransitionMode] =
React.useState(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(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) => {
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
- Choose your first workspace to get started.
+ Choose your first community to get started.
@@ -249,7 +249,7 @@ export function WelcomeSetup({
}}
type="button"
>
- Continue with default workspace
+ Continue with default community
)}
@@ -260,12 +260,12 @@ export function WelcomeSetup({
if (isConnecting) {
return;
}
- showCreateWorkspacePage();
+ showCreateCommunityPage();
}}
type="button"
variant="secondary"
>
- Join a workspace
+ Join a community
) : null}
- ) : page === "create-workspace" ? (
+ ) : page === "create-community" ? (
- Join a workspace
+ Join a community
- Workspaces are where teammates and agents collaborate across
+ Communities are where teammates and agents collaborate across
channels, DMs, and shared projects.