From 08c951854f38931685c4e0400ac0957919a75443 Mon Sep 17 00:00:00 2001 From: Wes Date: Mon, 13 Jul 2026 17:51:59 -0600 Subject: [PATCH 1/2] refactor(clients): standardize mobile and web on community Rename the mobile product model, providers, storage, source paths, UI copy, and tests from workspace to community. Migrate existing workspace storage keys while preserving relay transport and wire identifiers. Update web product-facing repository and invite language to community while leaving relay URL helpers and deep-link contracts unchanged. Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Wes --- .../channels/channel_management_provider.dart | 2 +- .../channel_mutes/channel_mutes_provider.dart | 6 +- .../channel_sections_provider.dart | 6 +- .../channel_stars/channel_stars_provider.dart | 6 +- .../lib/features/channels/channels_page.dart | 24 +-- .../{workspace.dart => community.dart} | 84 +++++----- .../features/channels/channels_provider.dart | 2 +- .../read_state/read_state_provider.dart | 6 +- .../features/custom_emoji/custom_emoji.dart | 8 +- .../custom_emoji/custom_emoji_provider.dart | 4 +- mobile/lib/features/pairing/pairing_page.dart | 16 +- .../features/pairing/pairing_provider.dart | 22 +-- .../lib/features/search/search_provider.dart | 2 +- .../lib/features/settings/settings_page.dart | 6 +- mobile/lib/shared/auth/auth.dart | 6 +- mobile/lib/shared/auth/auth_provider.dart | 71 ++++---- .../community.dart} | 18 +-- .../shared/community/community_provider.dart | 127 +++++++++++++++ .../shared/community/community_storage.dart | 111 +++++++++++++ mobile/lib/shared/relay/relay_provider.dart | 8 +- .../shared/workspace/workspace_provider.dart | 127 --------------- .../shared/workspace/workspace_storage.dart | 96 ----------- .../features/channels/channels_page_test.dart | 2 +- .../channels/channels_provider_test.dart | 4 +- .../pairing/pairing_provider_test.dart | 12 +- .../test/shared/auth/auth_provider_test.dart | 28 ++-- .../community/community_provider_test.dart | 152 ++++++++++++++++++ .../community_storage_test.dart} | 47 ++++-- .../test/shared/relay/relay_session_test.dart | 4 +- .../workspace/workspace_provider_test.dart | 152 ------------------ web/src/features/invite/ui/InvitePage.tsx | 2 +- web/src/features/repos/mock-repos.ts | 4 +- web/src/features/repos/ui/ReposPage.tsx | 10 +- 33 files changed, 605 insertions(+), 570 deletions(-) rename mobile/lib/features/channels/channels_page/{workspace.dart => community.dart} (78%) rename mobile/lib/shared/{workspace/workspace.dart => community/community.dart} (85%) create mode 100644 mobile/lib/shared/community/community_provider.dart create mode 100644 mobile/lib/shared/community/community_storage.dart delete mode 100644 mobile/lib/shared/workspace/workspace_provider.dart delete mode 100644 mobile/lib/shared/workspace/workspace_storage.dart create mode 100644 mobile/test/shared/community/community_provider_test.dart rename mobile/test/shared/{workspace/workspace_storage_test.dart => community/community_storage_test.dart} (80%) delete mode 100644 mobile/test/shared/workspace/workspace_provider_test.dart diff --git a/mobile/lib/features/channels/channel_management_provider.dart b/mobile/lib/features/channels/channel_management_provider.dart index 0ac73157941..97953653c7c 100644 --- a/mobile/lib/features/channels/channel_management_provider.dart +++ b/mobile/lib/features/channels/channel_management_provider.dart @@ -102,7 +102,7 @@ final currentPubkeyProvider = Provider((ref) { } final authState = ref.watch(authProvider).whenData((value) => value).value; - final credentialPubkey = authState?.workspace?.pubkey?.trim(); + final credentialPubkey = authState?.community?.pubkey?.trim(); if (credentialPubkey != null && credentialPubkey.isNotEmpty) { return credentialPubkey.toLowerCase(); } diff --git a/mobile/lib/features/channels/channel_mutes/channel_mutes_provider.dart b/mobile/lib/features/channels/channel_mutes/channel_mutes_provider.dart index 3aa64fa8028..cd5e41da1af 100644 --- a/mobile/lib/features/channels/channel_mutes/channel_mutes_provider.dart +++ b/mobile/lib/features/channels/channel_mutes/channel_mutes_provider.dart @@ -3,7 +3,7 @@ import 'package:nostr/nostr.dart' as nostr; import '../../../shared/relay/relay.dart'; import '../../../shared/theme/theme_provider.dart'; -import '../../../shared/workspace/workspace_provider.dart'; +import '../../../shared/community/community_provider.dart'; import 'channel_mutes_manager.dart'; import 'channel_mutes_storage.dart'; @@ -31,8 +31,8 @@ class ChannelMutesNotifier extends Notifier { final relayConfig = ref.watch(relayConfigProvider); final sessionState = ref.watch(relaySessionProvider); - // Rebuild when the active workspace changes (pubkey may differ). - ref.watch(activeWorkspaceProvider); + // Rebuild when the active community changes (pubkey may differ). + ref.watch(activeCommunityProvider); final nsec = relayConfig.nsec?.trim(); if (nsec == null || nsec.isEmpty) { diff --git a/mobile/lib/features/channels/channel_sections/channel_sections_provider.dart b/mobile/lib/features/channels/channel_sections/channel_sections_provider.dart index 65c840404b5..2caa35c25f4 100644 --- a/mobile/lib/features/channels/channel_sections/channel_sections_provider.dart +++ b/mobile/lib/features/channels/channel_sections/channel_sections_provider.dart @@ -3,7 +3,7 @@ import 'package:nostr/nostr.dart' as nostr; import '../../../shared/relay/relay.dart'; import '../../../shared/theme/theme_provider.dart'; -import '../../../shared/workspace/workspace_provider.dart'; +import '../../../shared/community/community_provider.dart'; import 'channel_sections_manager.dart'; import 'channel_sections_storage.dart'; @@ -31,8 +31,8 @@ class ChannelSectionsNotifier extends Notifier { final relayConfig = ref.watch(relayConfigProvider); final sessionState = ref.watch(relaySessionProvider); - // Rebuild when the active workspace changes (pubkey may differ). - ref.watch(activeWorkspaceProvider); + // Rebuild when the active community changes (pubkey may differ). + ref.watch(activeCommunityProvider); final nsec = relayConfig.nsec?.trim(); if (nsec == null || nsec.isEmpty) { diff --git a/mobile/lib/features/channels/channel_stars/channel_stars_provider.dart b/mobile/lib/features/channels/channel_stars/channel_stars_provider.dart index a8223dbb050..4f6fb305b41 100644 --- a/mobile/lib/features/channels/channel_stars/channel_stars_provider.dart +++ b/mobile/lib/features/channels/channel_stars/channel_stars_provider.dart @@ -3,7 +3,7 @@ import 'package:nostr/nostr.dart' as nostr; import '../../../shared/relay/relay.dart'; import '../../../shared/theme/theme_provider.dart'; -import '../../../shared/workspace/workspace_provider.dart'; +import '../../../shared/community/community_provider.dart'; import 'channel_stars_manager.dart'; import 'channel_stars_storage.dart'; @@ -31,8 +31,8 @@ class ChannelStarsNotifier extends Notifier { final relayConfig = ref.watch(relayConfigProvider); final sessionState = ref.watch(relaySessionProvider); - // Rebuild when the active workspace changes (pubkey may differ). - ref.watch(activeWorkspaceProvider); + // Rebuild when the active community changes (pubkey may differ). + ref.watch(activeCommunityProvider); final nsec = relayConfig.nsec?.trim(); if (nsec == null || nsec.isEmpty) { diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index 2ffb046b6c6..952e3a4ff49 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -39,7 +39,7 @@ part 'channels_page/sections.dart'; part 'channels_page/channel_tile.dart'; part 'channels_page/sheets.dart'; part 'channels_page/badges.dart'; -part 'channels_page/workspace.dart'; +part 'channels_page/community.dart'; enum _QuickAction { createChannel, newDm } @@ -53,7 +53,7 @@ const double _kChannelLabelGap = Grid.xxs; const double _kChannelRowVerticalPadding = Grid.xxs + Grid.quarter; const double _kChannelLabelInset = _kChannelSectionInset + _kChannelLeadingWidth + _kChannelLabelGap; -const double _kWorkspaceAvatarInset = +const double _kCommunityAvatarInset = _kChannelSectionInset - Grid.quarter; // FrostedAppBar adds Grid.quarter. const Duration _kSectionExpandDuration = Duration(milliseconds: 220); const Duration _kSectionCollapseDuration = Duration(milliseconds: 170); @@ -135,17 +135,17 @@ class ChannelsPage extends HookConsumerWidget { // Cache the last successfully loaded channels so the UI never flashes // back to a loading state when the provider rebuilds (e.g. reconnect). - // Clear the cache on workspace switch so we show a full loader instead of - // stale channels from the previous workspace. unwrapPrevious() ensures the - // selector sees null during loading (not the previous workspace's ID). - final activeWorkspaceId = ref.watch( - activeWorkspaceProvider.select((v) => v.unwrapPrevious().value?.id), + // Clear the cache on community switch so we show a full loader instead of + // stale channels from the previous community. unwrapPrevious() ensures the + // selector sees null during loading (not the previous community's ID). + final activeCommunityId = ref.watch( + activeCommunityProvider.select((v) => v.unwrapPrevious().value?.id), ); final cachedChannels = useRef?>(null); - final lastWorkspaceId = useRef(null); - if (lastWorkspaceId.value != activeWorkspaceId) { + final lastCommunityId = useRef(null); + if (lastCommunityId.value != activeCommunityId) { cachedChannels.value = null; - lastWorkspaceId.value = activeWorkspaceId; + lastCommunityId.value = activeCommunityId; } if (channelsAsync.asData?.value case final data?) { cachedChannels.value = data; @@ -238,11 +238,11 @@ class ChannelsPage extends HookConsumerWidget { return FrostedScaffold( appBar: FrostedAppBar( - leading: _WorkspaceIndicator( + leading: _CommunityIndicator( onTap: () => showModalBottomSheet( context: context, showDragHandle: true, - builder: (_) => const _WorkspaceSwitcherSheet(), + builder: (_) => const _CommunitySwitcherSheet(), ), ), title: const SizedBox.shrink(), diff --git a/mobile/lib/features/channels/channels_page/workspace.dart b/mobile/lib/features/channels/channels_page/community.dart similarity index 78% rename from mobile/lib/features/channels/channels_page/workspace.dart rename to mobile/lib/features/channels/channels_page/community.dart index 4ad6143b7df..61d28240997 100644 --- a/mobile/lib/features/channels/channels_page/workspace.dart +++ b/mobile/lib/features/channels/channels_page/community.dart @@ -1,65 +1,65 @@ part of '../channels_page.dart'; -class _WorkspaceSwitcherSheet extends ConsumerWidget { - const _WorkspaceSwitcherSheet(); +class _CommunitySwitcherSheet extends ConsumerWidget { + const _CommunitySwitcherSheet(); @override Widget build(BuildContext context, WidgetRef ref) { - final workspacesAsync = ref.watch(workspaceListProvider); - final activeAsync = ref.watch(activeWorkspaceProvider); + final communitiesAsync = ref.watch(communityListProvider); + final activeAsync = ref.watch(activeCommunityProvider); final sessionState = ref.watch(relaySessionProvider); return SafeArea( - child: workspacesAsync.when( + child: communitiesAsync.when( loading: () => const SizedBox( height: 120, child: Center(child: CircularProgressIndicator()), ), error: (e, _) => Padding( padding: const EdgeInsets.all(Grid.xs), - child: Text('Error loading workspaces: $e'), + child: Text('Error loading communities: $e'), ), - data: (workspaces) { + data: (communities) { final activeId = activeAsync.value?.id; return Column( mainAxisSize: MainAxisSize.min, children: [ - for (final workspace in workspaces) - _WorkspaceSwitcherTile( - workspace: workspace, - isActive: workspace.id == activeId, - sessionStatus: workspace.id == activeId + for (final community in communities) + _CommunitySwitcherTile( + community: community, + isActive: community.id == activeId, + sessionStatus: community.id == activeId ? sessionState.status : null, onTap: () async { - if (workspace.id != activeId) { + if (community.id != activeId) { await ref - .read(workspaceListProvider.notifier) - .switchWorkspace(workspace.id); + .read(communityListProvider.notifier) + .switchCommunity(community.id); } if (context.mounted) Navigator.of(context).pop(); }, onRename: () async { final nav = Navigator.of(context, rootNavigator: true); - final notifier = ref.read(workspaceListProvider.notifier); + final notifier = ref.read(communityListProvider.notifier); Navigator.of(context).pop(); final name = await showDialog( context: nav.context, useRootNavigator: true, builder: (_) => - _RenameWorkspaceDialog(currentName: workspace.name), + _RenameCommunityDialog(currentName: community.name), ); if (name != null && name.isNotEmpty) { - await notifier.renameWorkspace(workspace.id, name); + await notifier.renameCommunity(community.id, name); } }, onRemove: () async { final confirmed = await showDialog( context: context, builder: (dialogContext) => AlertDialog( - title: const Text('Remove Workspace'), + title: const Text('Remove Community'), content: Text( - 'Remove "${workspace.name}"? You can re-pair later.', + 'Remove "${community.name}"? You can re-pair later.', ), actions: [ TextButton( @@ -79,13 +79,13 @@ class _WorkspaceSwitcherSheet extends ConsumerWidget { final messenger = ScaffoldMessenger.of(context); try { await ref - .read(workspaceListProvider.notifier) - .removeWorkspace(workspace.id); + .read(communityListProvider.notifier) + .removeCommunity(community.id); if (context.mounted) Navigator.of(context).pop(); } catch (e) { messenger.showSnackBar( SnackBar( - content: Text('Failed to remove workspace: $e'), + content: Text('Failed to remove community: $e'), ), ); } @@ -95,14 +95,14 @@ class _WorkspaceSwitcherSheet extends ConsumerWidget { const Divider(height: 1), ListTile( leading: const Icon(LucideIcons.plus), - title: const Text('Add Workspace'), + title: const Text('Add Community'), onTap: () { final nav = Navigator.of(context, rootNavigator: true); ref.read(pairingProvider.notifier).reset(); Navigator.of(context).pop(); nav.push( MaterialPageRoute( - builder: (_) => const PairingPage(addingWorkspace: true), + builder: (_) => const PairingPage(addingCommunity: true), ), ); }, @@ -115,16 +115,16 @@ class _WorkspaceSwitcherSheet extends ConsumerWidget { } } -class _WorkspaceSwitcherTile extends StatelessWidget { - final Workspace workspace; +class _CommunitySwitcherTile extends StatelessWidget { + final Community community; final bool isActive; final SessionStatus? sessionStatus; final VoidCallback onTap; final VoidCallback onRename; final VoidCallback onRemove; - const _WorkspaceSwitcherTile({ - required this.workspace, + const _CommunitySwitcherTile({ + required this.community, required this.isActive, required this.sessionStatus, required this.onTap, @@ -134,12 +134,12 @@ class _WorkspaceSwitcherTile extends StatelessWidget { @override Widget build(BuildContext context) { - final host = Uri.tryParse(workspace.relayUrl)?.host ?? workspace.relayUrl; + final host = Uri.tryParse(community.relayUrl)?.host ?? community.relayUrl; return ListTile( leading: _StatusDot(isActive: isActive, sessionStatus: sessionStatus), title: Text( - workspace.name, + community.name, style: context.textTheme.bodyLarge?.copyWith( fontWeight: isActive ? FontWeight.w600 : FontWeight.normal, ), @@ -208,17 +208,17 @@ class _StatusDot extends StatelessWidget { } } -class _RenameWorkspaceDialog extends HookWidget { +class _RenameCommunityDialog extends HookWidget { final String currentName; - const _RenameWorkspaceDialog({required this.currentName}); + const _RenameCommunityDialog({required this.currentName}); @override Widget build(BuildContext context) { final controller = useTextEditingController(text: currentName); return AlertDialog( - title: const Text('Rename Workspace'), + title: const Text('Rename Community'), content: TextField( controller: controller, autofocus: true, @@ -245,14 +245,14 @@ class _RenameWorkspaceDialog extends HookWidget { } } -class _WorkspaceIndicator extends ConsumerWidget { +class _CommunityIndicator extends ConsumerWidget { final VoidCallback onTap; - const _WorkspaceIndicator({required this.onTap}); + const _CommunityIndicator({required this.onTap}); @override Widget build(BuildContext context, WidgetRef ref) { - final activeAsync = ref.watch(activeWorkspaceProvider); + final activeAsync = ref.watch(activeCommunityProvider); final name = activeAsync.value?.name; @@ -260,11 +260,11 @@ class _WorkspaceIndicator extends ConsumerWidget { onTap: onTap, behavior: HitTestBehavior.opaque, child: Padding( - padding: const EdgeInsets.only(left: _kWorkspaceAvatarInset), + padding: const EdgeInsets.only(left: _kCommunityAvatarInset), child: Row( mainAxisSize: MainAxisSize.min, children: [ - _WorkspaceAvatar(name: name), + _CommunityAvatar(name: name), const SizedBox(width: Grid.xxs), if (name != null) Flexible( @@ -279,7 +279,7 @@ class _WorkspaceIndicator extends ConsumerWidget { ) else Text( - 'Workspace', + 'Community', maxLines: 1, overflow: TextOverflow.ellipsis, style: context.textTheme.labelLarge?.copyWith( @@ -293,10 +293,10 @@ class _WorkspaceIndicator extends ConsumerWidget { } } -class _WorkspaceAvatar extends StatelessWidget { +class _CommunityAvatar extends StatelessWidget { final String? name; - const _WorkspaceAvatar({required this.name}); + const _CommunityAvatar({required this.name}); @override Widget build(BuildContext context) { diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index 4405aee38c8..74bbe640bd6 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -90,7 +90,7 @@ class ChannelsNotifier extends AsyncNotifier> { }); if (sessionState.status != SessionStatus.connected) { - // Keep the prior workspace's cache visible until the new relay connects. + // Keep the prior community's cache visible until the new relay connects. if (_hasLoaded) return state.value ?? const []; await connected.future; } diff --git a/mobile/lib/features/channels/read_state/read_state_provider.dart b/mobile/lib/features/channels/read_state/read_state_provider.dart index 6e7b814f975..07ffc9044a0 100644 --- a/mobile/lib/features/channels/read_state/read_state_provider.dart +++ b/mobile/lib/features/channels/read_state/read_state_provider.dart @@ -5,7 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../../shared/relay/relay.dart'; import '../../../shared/theme/theme_provider.dart'; -import '../../../shared/workspace/workspace_provider.dart'; +import '../../../shared/community/community_provider.dart'; import 'read_state_manager.dart'; class ReadStateState { @@ -62,7 +62,7 @@ class ReadStateNotifier extends Notifier { final relayConfig = ref.watch(relayConfigProvider); ref.watch(relaySessionProvider); - final activeWorkspace = ref.watch(activeWorkspaceProvider).value; + final activeCommunity = ref.watch(activeCommunityProvider).value; final nsec = relayConfig.nsec?.trim(); if (nsec == null || nsec.isEmpty) { @@ -74,7 +74,7 @@ class ReadStateNotifier extends Notifier { nsec: nsec, ); final pubkey = - _normalizePubkey(activeWorkspace?.pubkey) ?? + _normalizePubkey(activeCommunity?.pubkey) ?? _safeDerivedPubkey(signedRelay); if (pubkey == null) { return const ReadStateState.inert(); diff --git a/mobile/lib/features/custom_emoji/custom_emoji.dart b/mobile/lib/features/custom_emoji/custom_emoji.dart index 4793e177634..5fe51151af9 100644 --- a/mobile/lib/features/custom_emoji/custom_emoji.dart +++ b/mobile/lib/features/custom_emoji/custom_emoji.dart @@ -2,10 +2,10 @@ import 'package:flutter/foundation.dart'; import '../../shared/relay/nostr_models.dart'; -/// A workspace custom emoji: a `:shortcode:` mapped to an image URL. +/// A community custom emoji: a `:shortcode:` mapped to an image URL. /// /// Mirrors desktop's NIP-30 model (kind:30030, per-user sets unioned into one -/// workspace palette). Identity downstream is shortcode-only — the palette must +/// community palette). Identity downstream is shortcode-only — the palette must /// never carry two URLs under one shortcode. @immutable class CustomEmoji { @@ -67,7 +67,7 @@ List customEmojiFromTags(List> tags) { return emoji; } -/// Union every member's kind:30030 set into the workspace palette, collapsed to +/// Union every member's kind:30030 set into the community palette, collapsed to /// one entry per shortcode. When members disagree on a shortcode's URL, the /// most recently published set wins (`created_at` is signed event data, so the /// result stays deterministic and fetch-order-independent); equal timestamps @@ -126,7 +126,7 @@ List> buildCustomEmojiTags( } /// Resolve the image URL for a reaction whose content is a custom-emoji -/// `:shortcode:`, from the workspace palette. Returns null for unicode +/// `:shortcode:`, from the community palette. Returns null for unicode /// reactions or unknown shortcodes. String? reactionEmojiUrl(String emoji, List? palette) { if (palette == null) return null; diff --git a/mobile/lib/features/custom_emoji/custom_emoji_provider.dart b/mobile/lib/features/custom_emoji/custom_emoji_provider.dart index 615172d316b..be866e17c44 100644 --- a/mobile/lib/features/custom_emoji/custom_emoji_provider.dart +++ b/mobile/lib/features/custom_emoji/custom_emoji_provider.dart @@ -3,7 +3,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/relay/relay.dart'; import 'custom_emoji.dart'; -/// Workspace custom-emoji palette (NIP-30, per-user kind:30030 sets unioned). +/// Community custom-emoji palette (NIP-30, per-user kind:30030 sets unioned). /// /// On build, fetches every member's set and collapses to one entry per /// shortcode (see [unionCustomEmoji]). Re-fetches when the relay session @@ -40,7 +40,7 @@ class CustomEmojiPaletteNotifier extends AsyncNotifier> { } } - /// Force a re-fetch of the workspace palette. + /// Force a re-fetch of the community palette. Future refresh() async { state = const AsyncValue.loading(); state = await AsyncValue.guard(_fetch); diff --git a/mobile/lib/features/pairing/pairing_page.dart b/mobile/lib/features/pairing/pairing_page.dart index 9502335c2b8..d7ecd68b632 100644 --- a/mobile/lib/features/pairing/pairing_page.dart +++ b/mobile/lib/features/pairing/pairing_page.dart @@ -8,11 +8,11 @@ import '../../shared/theme/theme.dart'; import 'pairing_provider.dart'; class PairingPage extends HookConsumerWidget { - /// When true, the pairing page is being used to add a new workspace - /// (user is already authenticated with at least one workspace). - final bool addingWorkspace; + /// When true, the pairing page is being used to add a new community + /// (user is already authenticated with at least one community). + final bool addingCommunity; - const PairingPage({super.key, this.addingWorkspace = false}); + const PairingPage({super.key, this.addingCommunity = false}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -23,8 +23,8 @@ class PairingPage extends HookConsumerWidget { pairingState.status == PairingStatus.transferring || pairingState.status == PairingStatus.storing; - // When adding a workspace and pairing succeeds, pop back. - if (addingWorkspace && pairingState.status == PairingStatus.success) { + // When adding a community and pairing succeeds, pop back. + if (addingCommunity && pairingState.status == PairingStatus.success) { WidgetsBinding.instance.addPostFrameCallback((_) { if (context.mounted) { ref.read(pairingProvider.notifier).reset(); @@ -40,13 +40,13 @@ class PairingPage extends HookConsumerWidget { } }, child: Scaffold( - appBar: addingWorkspace + appBar: addingCommunity ? AppBar( leading: IconButton( icon: const Icon(LucideIcons.arrowLeft), onPressed: () => Navigator.of(context).pop(), ), - title: const Text('Add Workspace'), + title: const Text('Add Community'), ) : null, body: SafeArea( diff --git a/mobile/lib/features/pairing/pairing_provider.dart b/mobile/lib/features/pairing/pairing_provider.dart index c854723090e..2aec19710dc 100644 --- a/mobile/lib/features/pairing/pairing_provider.dart +++ b/mobile/lib/features/pairing/pairing_provider.dart @@ -434,16 +434,16 @@ class PairingNotifier extends Notifier { // Send complete only after credentials are validated. _sendComplete(true); - // Store as workspace and switch to it. - final workspace = Workspace.create( - name: Workspace.nameFromUrl(relayUrl), + // Store as community and switch to it. + final community = Community.create( + name: Community.nameFromUrl(relayUrl), relayUrl: relayUrl, pubkey: pubkey, nsec: nsec, ); await ref .read(authProvider.notifier) - .authenticateWithWorkspace(workspace); + .authenticateWithCommunity(community); _cleanup(); state = const PairingState(status: PairingStatus.success); @@ -532,16 +532,16 @@ class PairingNotifier extends Notifier { state = const PairingState(status: PairingStatus.connecting); try { - final workspace = _parseLegacyInput(rawInput); + final community = _parseLegacyInput(rawInput); await _validateCredentials( - relayUrl: workspace.relayUrl, - nsec: workspace.nsec, + relayUrl: community.relayUrl, + nsec: community.nsec, ); await ref .read(authProvider.notifier) - .authenticateWithWorkspace(workspace); + .authenticateWithCommunity(community); state = const PairingState(status: PairingStatus.success); } on FormatException catch (e) { state = PairingState( @@ -590,7 +590,7 @@ class PairingNotifier extends Notifier { } } - Workspace _parseLegacyInput(String raw) { + Community _parseLegacyInput(String raw) { var payload = raw.trim(); if (payload.startsWith('buzz://')) { @@ -611,8 +611,8 @@ class PairingNotifier extends Notifier { _validateRelayUrl(relayUrl); - return Workspace.create( - name: Workspace.nameFromUrl(relayUrl), + return Community.create( + name: Community.nameFromUrl(relayUrl), relayUrl: relayUrl, pubkey: decoded['pubkey'] as String?, nsec: decoded['nsec'] as String?, diff --git a/mobile/lib/features/search/search_provider.dart b/mobile/lib/features/search/search_provider.dart index 190ee7ea0a4..bc9bfddc331 100644 --- a/mobile/lib/features/search/search_provider.dart +++ b/mobile/lib/features/search/search_provider.dart @@ -97,7 +97,7 @@ class SearchNotifier extends Notifier { @override SearchState build() { - // Watch relayConfig so search state resets on workspace switch. + // Watch relayConfig so search state resets on community switch. ref.watch(relayConfigProvider); ref.onDispose(() { _debounce?.cancel(); diff --git a/mobile/lib/features/settings/settings_page.dart b/mobile/lib/features/settings/settings_page.dart index 1074aba76aa..214360f9eb3 100644 --- a/mobile/lib/features/settings/settings_page.dart +++ b/mobile/lib/features/settings/settings_page.dart @@ -154,7 +154,7 @@ class SettingsPage extends HookConsumerWidget { child: TextButton.icon( onPressed: () => _confirmSignOut(context, ref), icon: const Icon(LucideIcons.logOut, size: 18), - label: const Text('Remove Workspace'), + label: const Text('Remove Community'), style: TextButton.styleFrom( foregroundColor: context.colors.error, ), @@ -192,9 +192,9 @@ class SettingsPage extends HookConsumerWidget { showDialog( context: context, builder: (ctx) => AlertDialog( - title: const Text('Remove Workspace'), + title: const Text('Remove Community'), content: const Text( - 'This will disconnect this workspace. You will need ' + 'This will disconnect this community. You will need ' 'to scan a new pairing code to reconnect.', ), actions: [ diff --git a/mobile/lib/shared/auth/auth.dart b/mobile/lib/shared/auth/auth.dart index f093cf7c828..e1066d35251 100644 --- a/mobile/lib/shared/auth/auth.dart +++ b/mobile/lib/shared/auth/auth.dart @@ -1,4 +1,4 @@ export 'auth_provider.dart'; -export '../workspace/workspace.dart'; -export '../workspace/workspace_provider.dart'; -export '../workspace/workspace_storage.dart'; +export '../community/community.dart'; +export '../community/community_provider.dart'; +export '../community/community_storage.dart'; diff --git a/mobile/lib/shared/auth/auth_provider.dart b/mobile/lib/shared/auth/auth_provider.dart index d32e3d4a652..ade2220264a 100644 --- a/mobile/lib/shared/auth/auth_provider.dart +++ b/mobile/lib/shared/auth/auth_provider.dart @@ -1,91 +1,92 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nostr/nostr.dart' as nostr; -import '../workspace/workspace.dart'; -import '../workspace/workspace_provider.dart'; +import '../community/community.dart'; +import '../community/community_provider.dart'; enum AuthStatus { unknown, unauthenticated, authenticated } class AuthState { final AuthStatus status; - final Workspace? workspace; + final Community? community; - const AuthState({required this.status, this.workspace}); + const AuthState({required this.status, this.community}); } -/// Restores the active workspace without making connectivity load-bearing. +/// Restores the active community without making connectivity load-bearing. /// The relay session owns connection recovery after startup. class AuthNotifier extends AsyncNotifier { @override Future build() async { - // Read from storage directly — NOT from workspace providers. - // Watching workspace providers here would create a circular dependency - // because authenticateWithWorkspace() writes to those providers. - final storage = ref.read(workspaceStorageProvider); - final workspaces = await storage.loadAll(); - if (workspaces.isEmpty) { + // Read from storage directly — NOT from community providers. + // Watching community providers here would create a circular dependency + // because authenticateWithCommunity() writes to those providers. + final storage = ref.read(communityStorageProvider); + final communities = await storage.loadAll(); + if (communities.isEmpty) { return const AuthState(status: AuthStatus.unauthenticated); } var activeId = await storage.loadActiveId(); - while (workspaces.isNotEmpty) { - final active = activeId != null && workspaces.any((w) => w.id == activeId) - ? workspaces.firstWhere((w) => w.id == activeId) - : workspaces.first; + while (communities.isNotEmpty) { + final active = + activeId != null && communities.any((w) => w.id == activeId) + ? communities.firstWhere((w) => w.id == activeId) + : communities.first; await storage.saveActiveId(active.id); if (_hasValidNsec(active.nsec)) { - return AuthState(status: AuthStatus.authenticated, workspace: active); + return AuthState(status: AuthStatus.authenticated, community: active); } await storage.remove(active.id); - workspaces.removeWhere((workspace) => workspace.id == active.id); + communities.removeWhere((community) => community.id == active.id); activeId = null; - ref.invalidate(workspaceListProvider); - ref.invalidate(activeWorkspaceProvider); + ref.invalidate(communityListProvider); + ref.invalidate(activeCommunityProvider); } await storage.clearActiveId(); return const AuthState(status: AuthStatus.unauthenticated); } - /// Authenticate with a workspace. Saves it and switches to it. - /// Writes to storage directly to avoid circular dependency with workspace + /// Authenticate with a community. Saves it and switches to it. + /// Writes to storage directly to avoid circular dependency with community /// providers. - Future authenticateWithWorkspace(Workspace workspace) async { - final storage = ref.read(workspaceStorageProvider); - await storage.save(workspace); - await storage.saveActiveId(workspace.id); + Future authenticateWithCommunity(Community community) async { + final storage = ref.read(communityStorageProvider); + await storage.save(community); + await storage.saveActiveId(community.id); - // Invalidate workspace providers so other consumers pick up the new data. - ref.invalidate(workspaceListProvider); - ref.invalidate(activeWorkspaceProvider); + // Invalidate community providers so other consumers pick up the new data. + ref.invalidate(communityListProvider); + ref.invalidate(activeCommunityProvider); state = AsyncData( - AuthState(status: AuthStatus.authenticated, workspace: workspace), + AuthState(status: AuthStatus.authenticated, community: community), ); } Future signOut() async { - final storage = ref.read(workspaceStorageProvider); + final storage = ref.read(communityStorageProvider); final activeId = await storage.loadActiveId(); if (activeId != null) { await storage.remove(activeId); await storage.clearActiveId(); } - // Check if other workspaces remain — switch to the next one instead of + // Check if other communities remain — switch to the next one instead of // forcing the user back to the pairing screen. final remaining = await storage.loadAll(); - // Invalidate workspace providers so other consumers pick up the change. - ref.invalidate(workspaceListProvider); - ref.invalidate(activeWorkspaceProvider); + // Invalidate community providers so other consumers pick up the change. + ref.invalidate(communityListProvider); + ref.invalidate(activeCommunityProvider); if (remaining.isNotEmpty) { final next = remaining.first; await storage.saveActiveId(next.id); - // Re-run build() to validate the next workspace's credentials. + // Re-run build() to validate the next community's credentials. ref.invalidateSelf(); await future; } else { diff --git a/mobile/lib/shared/workspace/workspace.dart b/mobile/lib/shared/community/community.dart similarity index 85% rename from mobile/lib/shared/workspace/workspace.dart rename to mobile/lib/shared/community/community.dart index 1fe1113ecca..1858609e057 100644 --- a/mobile/lib/shared/workspace/workspace.dart +++ b/mobile/lib/shared/community/community.dart @@ -3,7 +3,7 @@ import 'package:uuid/uuid.dart'; const _uuid = Uuid(); const _sentinel = Object(); -class Workspace { +class Community { final String id; final String name; final String relayUrl; @@ -11,7 +11,7 @@ class Workspace { final String? nsec; final DateTime addedAt; - const Workspace({ + const Community({ required this.id, required this.name, required this.relayUrl, @@ -20,13 +20,13 @@ class Workspace { required this.addedAt, }); - factory Workspace.create({ + factory Community.create({ required String name, required String relayUrl, String? pubkey, String? nsec, }) { - return Workspace( + return Community( id: _uuid.v4(), name: name, relayUrl: relayUrl, @@ -36,13 +36,13 @@ class Workspace { ); } - Workspace copyWith({ + Community copyWith({ String? name, String? relayUrl, Object? pubkey = _sentinel, Object? nsec = _sentinel, }) { - return Workspace( + return Community( id: id, name: name ?? this.name, relayUrl: relayUrl ?? this.relayUrl, @@ -61,7 +61,7 @@ class Workspace { 'addedAt': addedAt.toIso8601String(), }; - factory Workspace.fromJson(Map json) => Workspace( + factory Community.fromJson(Map json) => Community( id: json['id'] as String, name: json['name'] as String, relayUrl: json['relayUrl'] as String, @@ -70,7 +70,7 @@ class Workspace { addedAt: DateTime.parse(json['addedAt'] as String), ); - /// Derive a human-friendly workspace name from a relay URL. + /// Derive a human-friendly community name from a relay URL. static String nameFromUrl(String url) { try { final host = Uri.parse(url).host; @@ -79,7 +79,7 @@ class Workspace { if (parts.length > 2) return parts.first; return host; } catch (_) { - return 'Workspace'; + return 'Community'; } } } diff --git a/mobile/lib/shared/community/community_provider.dart b/mobile/lib/shared/community/community_provider.dart new file mode 100644 index 00000000000..d014c23b505 --- /dev/null +++ b/mobile/lib/shared/community/community_provider.dart @@ -0,0 +1,127 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../auth/auth_provider.dart'; +import 'community.dart'; +import 'community_storage.dart'; + +final communityStorageProvider = Provider((ref) { + return CommunityStorage(); +}); + +class CommunityListNotifier extends AsyncNotifier> { + @override + Future> build() async { + final storage = ref.read(communityStorageProvider); + return storage.loadAll(); + } + + /// Add a community. If one with the same relay URL already exists, update + /// its credentials instead. Returns the effective community ID. + Future addCommunity(Community community) async { + final storage = ref.read(communityStorageProvider); + final current = state.value ?? []; + + // If a community with the same relay URL exists, update its credentials + // instead of creating a duplicate entry. + final existingIndex = current.indexWhere( + (w) => w.relayUrl == community.relayUrl, + ); + if (existingIndex >= 0) { + final existing = current[existingIndex]; + final updated = existing.copyWith( + pubkey: community.pubkey, + nsec: community.nsec, + ); + await storage.save(updated); + final updatedList = [...current]; + updatedList[existingIndex] = updated; + state = AsyncData(updatedList); + return existing.id; + } + + await storage.save(community); + state = AsyncData([...current, community]); + return community.id; + } + + Future removeCommunity(String id) async { + final storage = ref.read(communityStorageProvider); + await storage.remove(id); + + final current = state.value ?? []; + state = AsyncData(current.where((w) => w.id != id).toList()); + + // If we removed the active community, switch to another or sign out. + final activeId = await storage.loadActiveId(); + if (activeId == id) { + final remaining = state.value ?? []; + if (remaining.isNotEmpty) { + await switchCommunity(remaining.first.id); + } else { + await storage.clearActiveId(); + // Invalidate auth so it re-evaluates against the now-empty storage + // and transitions to unauthenticated. + ref.invalidate(authProvider); + } + } + } + + Future switchCommunity(String id) async { + final storage = ref.read(communityStorageProvider); + await storage.saveActiveId(id); + // Reassign list state to trigger activeCommunityProvider (which watches + // communityListProvider.future) to rebuild and pick up the new active ID. + // We can't use ref.invalidate(activeCommunityProvider) here because that + // creates a circular dependency — activeCommunityProvider watches us. + state = AsyncData([...state.value ?? []]); + // Invalidate auth so AuthState.community reflects the new active community. + ref.invalidate(authProvider); + } + + Future renameCommunity(String id, String name) async { + final storage = ref.read(communityStorageProvider); + final current = state.value ?? []; + final index = current.indexWhere((w) => w.id == id); + if (index < 0) return; + + final updated = current[index].copyWith(name: name); + await storage.save(updated); + + final updatedList = [...current]; + updatedList[index] = updated; + state = AsyncData(updatedList); + } +} + +final communityListProvider = + AsyncNotifierProvider>( + CommunityListNotifier.new, + ); + +/// The currently active community, derived from the stored active ID and +/// the community list. +final activeCommunityProvider = FutureProvider((ref) async { + final communities = await ref.watch(communityListProvider.future); + final storage = ref.read(communityStorageProvider); + final activeId = await storage.loadActiveId(); + + if (communities.isEmpty) return null; + + if (activeId == null) { + // No active ID stored but communities exist — fall back to first. + await storage.saveActiveId(communities.first.id); + return communities.first; + } + + try { + return communities.firstWhere((w) => w.id == activeId); + } on StateError { + // Active ID points to a community that no longer exists. + // Fall back to first community. + if (communities.isNotEmpty) { + await storage.saveActiveId(communities.first.id); + return communities.first; + } + return null; + } +}); diff --git a/mobile/lib/shared/community/community_storage.dart b/mobile/lib/shared/community/community_storage.dart new file mode 100644 index 00000000000..20f65b01cb3 --- /dev/null +++ b/mobile/lib/shared/community/community_storage.dart @@ -0,0 +1,111 @@ +import 'dart:convert'; + +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +import 'community.dart'; + +class CommunityStorage { + static const _keyCommunities = 'buzz_communities'; + static const _keyActiveId = 'buzz_active_community_id'; + + // Legacy keys for migration. + static const _legacyCommunities = 'buzz_workspaces'; + static const _legacyActiveId = 'buzz_active_workspace_id'; + static const _legacyRelayUrl = 'buzz_relay_url'; + static const _legacyToken = 'buzz_token'; + static const _legacyPubkey = 'buzz_pubkey'; + static const _legacyNsec = 'buzz_nsec'; + + final FlutterSecureStorage _secure; + + CommunityStorage({FlutterSecureStorage? secure}) + : _secure = secure ?? const FlutterSecureStorage(); + + /// Load all communities. On first call, migrates legacy single-community + /// credentials if present. + Future> loadAll() async { + final raw = await _secure.read(key: _keyCommunities); + if (raw != null) return _decodeList(raw); + + final legacyCommunities = await _secure.read(key: _legacyCommunities); + if (legacyCommunities != null) { + final communities = _decodeList(legacyCommunities); + await _saveList(communities); + final legacyActiveId = await _secure.read(key: _legacyActiveId); + if (legacyActiveId != null) await saveActiveId(legacyActiveId); + await _secure.delete(key: _legacyCommunities); + await _secure.delete(key: _legacyActiveId); + return communities; + } + + // Migration: check for legacy single-community keys. + final legacyUrl = await _secure.read(key: _legacyRelayUrl); + final legacyToken = await _secure.read(key: _legacyToken); + if (legacyUrl != null && legacyToken != null) { + final legacyPubkey = await _secure.read(key: _legacyPubkey); + final legacyNsec = await _secure.read(key: _legacyNsec); + + final name = Community.nameFromUrl(legacyUrl); + final community = Community.create( + name: name, + relayUrl: legacyUrl, + pubkey: legacyPubkey, + nsec: legacyNsec, + ); + + await _saveList([community]); + await saveActiveId(community.id); + + // Delete legacy keys. + await _secure.delete(key: _legacyRelayUrl); + await _secure.delete(key: _legacyToken); + await _secure.delete(key: _legacyPubkey); + await _secure.delete(key: _legacyNsec); + + return [community]; + } + + return []; + } + + Future save(Community community) async { + final all = await loadAll(); + final index = all.indexWhere((w) => w.id == community.id); + if (index >= 0) { + all[index] = community; + } else { + all.add(community); + } + await _saveList(all); + } + + Future remove(String id) async { + final all = await loadAll(); + all.removeWhere((w) => w.id == id); + await _saveList(all); + } + + Future loadActiveId() async { + return _secure.read(key: _keyActiveId); + } + + Future saveActiveId(String id) async { + await _secure.write(key: _keyActiveId, value: id); + } + + Future clearActiveId() async { + await _secure.delete(key: _keyActiveId); + } + + List _decodeList(String raw) { + final list = jsonDecode(raw) as List; + return list + .map((entry) => Community.fromJson(entry as Map)) + .toList(); + } + + Future _saveList(List communities) async { + final json = jsonEncode(communities.map((item) => item.toJson()).toList()); + await _secure.write(key: _keyCommunities, value: json); + } +} diff --git a/mobile/lib/shared/relay/relay_provider.dart b/mobile/lib/shared/relay/relay_provider.dart index 030bcee5080..97b88dd3dfa 100644 --- a/mobile/lib/shared/relay/relay_provider.dart +++ b/mobile/lib/shared/relay/relay_provider.dart @@ -1,7 +1,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nostr/nostr.dart' as nostr; -import '../workspace/workspace_provider.dart'; +import '../community/community_provider.dart'; import 'relay_client.dart'; /// Relay connection configuration. @@ -41,9 +41,9 @@ class Env { class RelayConfigNotifier extends Notifier { @override RelayConfig build() { - // Watch the active workspace so that when it changes (workspace switch), + // Watch the active community so that when it changes (community switch), // the config rebuilds, triggering the full provider cascade. - final activeAsync = ref.watch(activeWorkspaceProvider); + final activeAsync = ref.watch(activeCommunityProvider); final active = activeAsync.value; if (active != null) { return RelayConfig(baseUrl: active.relayUrl, nsec: active.nsec); @@ -74,7 +74,7 @@ String? pubkeyFromNsec(String? nsec) { } } -/// The current user's hex pubkey, derived from the active workspace nsec. +/// The current user's hex pubkey, derived from the active community nsec. final myPubkeyProvider = Provider((ref) { final config = ref.watch(relayConfigProvider); return pubkeyFromNsec(config.nsec); diff --git a/mobile/lib/shared/workspace/workspace_provider.dart b/mobile/lib/shared/workspace/workspace_provider.dart deleted file mode 100644 index 51716e5e7e9..00000000000 --- a/mobile/lib/shared/workspace/workspace_provider.dart +++ /dev/null @@ -1,127 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; - -import '../auth/auth_provider.dart'; -import 'workspace.dart'; -import 'workspace_storage.dart'; - -final workspaceStorageProvider = Provider((ref) { - return WorkspaceStorage(); -}); - -class WorkspaceListNotifier extends AsyncNotifier> { - @override - Future> build() async { - final storage = ref.read(workspaceStorageProvider); - return storage.loadAll(); - } - - /// Add a workspace. If one with the same relay URL already exists, update - /// its credentials instead. Returns the effective workspace ID. - Future addWorkspace(Workspace workspace) async { - final storage = ref.read(workspaceStorageProvider); - final current = state.value ?? []; - - // If a workspace with the same relay URL exists, update its credentials - // instead of creating a duplicate entry. - final existingIndex = current.indexWhere( - (w) => w.relayUrl == workspace.relayUrl, - ); - if (existingIndex >= 0) { - final existing = current[existingIndex]; - final updated = existing.copyWith( - pubkey: workspace.pubkey, - nsec: workspace.nsec, - ); - await storage.save(updated); - final updatedList = [...current]; - updatedList[existingIndex] = updated; - state = AsyncData(updatedList); - return existing.id; - } - - await storage.save(workspace); - state = AsyncData([...current, workspace]); - return workspace.id; - } - - Future removeWorkspace(String id) async { - final storage = ref.read(workspaceStorageProvider); - await storage.remove(id); - - final current = state.value ?? []; - state = AsyncData(current.where((w) => w.id != id).toList()); - - // If we removed the active workspace, switch to another or sign out. - final activeId = await storage.loadActiveId(); - if (activeId == id) { - final remaining = state.value ?? []; - if (remaining.isNotEmpty) { - await switchWorkspace(remaining.first.id); - } else { - await storage.clearActiveId(); - // Invalidate auth so it re-evaluates against the now-empty storage - // and transitions to unauthenticated. - ref.invalidate(authProvider); - } - } - } - - Future switchWorkspace(String id) async { - final storage = ref.read(workspaceStorageProvider); - await storage.saveActiveId(id); - // Reassign list state to trigger activeWorkspaceProvider (which watches - // workspaceListProvider.future) to rebuild and pick up the new active ID. - // We can't use ref.invalidate(activeWorkspaceProvider) here because that - // creates a circular dependency — activeWorkspaceProvider watches us. - state = AsyncData([...state.value ?? []]); - // Invalidate auth so AuthState.workspace reflects the new active workspace. - ref.invalidate(authProvider); - } - - Future renameWorkspace(String id, String name) async { - final storage = ref.read(workspaceStorageProvider); - final current = state.value ?? []; - final index = current.indexWhere((w) => w.id == id); - if (index < 0) return; - - final updated = current[index].copyWith(name: name); - await storage.save(updated); - - final updatedList = [...current]; - updatedList[index] = updated; - state = AsyncData(updatedList); - } -} - -final workspaceListProvider = - AsyncNotifierProvider>( - WorkspaceListNotifier.new, - ); - -/// The currently active workspace, derived from the stored active ID and -/// the workspace list. -final activeWorkspaceProvider = FutureProvider((ref) async { - final workspaces = await ref.watch(workspaceListProvider.future); - final storage = ref.read(workspaceStorageProvider); - final activeId = await storage.loadActiveId(); - - if (workspaces.isEmpty) return null; - - if (activeId == null) { - // No active ID stored but workspaces exist — fall back to first. - await storage.saveActiveId(workspaces.first.id); - return workspaces.first; - } - - try { - return workspaces.firstWhere((w) => w.id == activeId); - } on StateError { - // Active ID points to a workspace that no longer exists. - // Fall back to first workspace. - if (workspaces.isNotEmpty) { - await storage.saveActiveId(workspaces.first.id); - return workspaces.first; - } - return null; - } -}); diff --git a/mobile/lib/shared/workspace/workspace_storage.dart b/mobile/lib/shared/workspace/workspace_storage.dart deleted file mode 100644 index d5982bf0f7d..00000000000 --- a/mobile/lib/shared/workspace/workspace_storage.dart +++ /dev/null @@ -1,96 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; - -import 'workspace.dart'; - -class WorkspaceStorage { - static const _keyWorkspaces = 'buzz_workspaces'; - static const _keyActiveId = 'buzz_active_workspace_id'; - - // Legacy keys for migration. - static const _legacyRelayUrl = 'buzz_relay_url'; - static const _legacyToken = 'buzz_token'; - static const _legacyPubkey = 'buzz_pubkey'; - static const _legacyNsec = 'buzz_nsec'; - - final FlutterSecureStorage _secure; - - WorkspaceStorage({FlutterSecureStorage? secure}) - : _secure = secure ?? const FlutterSecureStorage(); - - /// Load all workspaces. On first call, migrates legacy single-workspace - /// credentials if present. - Future> loadAll() async { - final raw = await _secure.read(key: _keyWorkspaces); - if (raw != null) { - final list = jsonDecode(raw) as List; - return list - .map((e) => Workspace.fromJson(e as Map)) - .toList(); - } - - // Migration: check for legacy keys. - final legacyUrl = await _secure.read(key: _legacyRelayUrl); - final legacyToken = await _secure.read(key: _legacyToken); - if (legacyUrl != null && legacyToken != null) { - final legacyPubkey = await _secure.read(key: _legacyPubkey); - final legacyNsec = await _secure.read(key: _legacyNsec); - - final name = Workspace.nameFromUrl(legacyUrl); - final workspace = Workspace.create( - name: name, - relayUrl: legacyUrl, - pubkey: legacyPubkey, - nsec: legacyNsec, - ); - - await _saveList([workspace]); - await saveActiveId(workspace.id); - - // Delete legacy keys. - await _secure.delete(key: _legacyRelayUrl); - await _secure.delete(key: _legacyToken); - await _secure.delete(key: _legacyPubkey); - await _secure.delete(key: _legacyNsec); - - return [workspace]; - } - - return []; - } - - Future save(Workspace workspace) async { - final all = await loadAll(); - final index = all.indexWhere((w) => w.id == workspace.id); - if (index >= 0) { - all[index] = workspace; - } else { - all.add(workspace); - } - await _saveList(all); - } - - Future remove(String id) async { - final all = await loadAll(); - all.removeWhere((w) => w.id == id); - await _saveList(all); - } - - Future loadActiveId() async { - return _secure.read(key: _keyActiveId); - } - - Future saveActiveId(String id) async { - await _secure.write(key: _keyActiveId, value: id); - } - - Future clearActiveId() async { - await _secure.delete(key: _keyActiveId); - } - - Future _saveList(List workspaces) async { - final json = jsonEncode(workspaces.map((w) => w.toJson()).toList()); - await _secure.write(key: _keyWorkspaces, value: json); - } -} diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index 4d3f5b83dd2..232cdeb912f 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -78,7 +78,7 @@ void main() { expect(find.text('Channels'), findsOneWidget); expect(find.text('FORUMS'), findsNothing); expect(find.text('DMs'), findsOneWidget); - expect(find.text('Workspace'), findsOneWidget); + expect(find.text('Community'), findsOneWidget); expect(find.byTooltip('Create or start conversation'), findsOneWidget); }); diff --git a/mobile/test/features/channels/channels_provider_test.dart b/mobile/test/features/channels/channels_provider_test.dart index 63aecf0fb6b..79be7a66f72 100644 --- a/mobile/test/features/channels/channels_provider_test.dart +++ b/mobile/test/features/channels/channels_provider_test.dart @@ -266,7 +266,7 @@ void main() { ); test( - 'refreshes cached channels after a disconnected workspace switch', + 'refreshes cached channels after a disconnected community switch', () async { final session = _FakeRelaySession( memberships: [_membership(_channelA, myPk)], @@ -285,7 +285,7 @@ void main() { session.metadata = [_meta(id: _channelB, name: 'random')]; container .read(relayConfigProvider.notifier) - .update(baseUrl: 'https://new-workspace.example'); + .update(baseUrl: 'https://new-community.example'); await Future.delayed(Duration.zero); expect(container.read(channelsProvider).value?.single.name, 'general'); diff --git a/mobile/test/features/pairing/pairing_provider_test.dart b/mobile/test/features/pairing/pairing_provider_test.dart index c29001e34bd..c5c5bbd3c1d 100644 --- a/mobile/test/features/pairing/pairing_provider_test.dart +++ b/mobile/test/features/pairing/pairing_provider_test.dart @@ -54,7 +54,7 @@ void main() { final state = container.read(pairingProvider); expect(state.status, PairingStatus.error); expect(state.errorMessage, contains('missing nsec')); - expect(fakeAuth.lastWorkspace, isNull); + expect(fakeAuth.lastCommunity, isNull); }); test('accepts buzz scheme prefix', () async { @@ -66,7 +66,7 @@ void main() { final state = container.read(pairingProvider); expect(state.status, PairingStatus.error); expect(state.errorMessage, contains('missing nsec')); - expect(fakeAuth.lastWorkspace, isNull); + expect(fakeAuth.lastCommunity, isNull); }); test('invalid base64 sets format error', () async { @@ -171,7 +171,7 @@ String _encodePairingCode({ /// A fake [AuthNotifier] that records calls instead of touching secure storage. class FakeAuthNotifier extends AsyncNotifier implements AuthNotifier { - Workspace? lastWorkspace; + Community? lastCommunity; bool signedOut = false; @override @@ -185,10 +185,10 @@ class FakeAuthNotifier extends AsyncNotifier } @override - Future authenticateWithWorkspace(Workspace workspace) async { - lastWorkspace = workspace; + Future authenticateWithCommunity(Community community) async { + lastCommunity = community; state = AsyncData( - AuthState(status: AuthStatus.authenticated, workspace: workspace), + AuthState(status: AuthStatus.authenticated, community: community), ); } } diff --git a/mobile/test/shared/auth/auth_provider_test.dart b/mobile/test/shared/auth/auth_provider_test.dart index fde5f1add6c..7a1e6a66083 100644 --- a/mobile/test/shared/auth/auth_provider_test.dart +++ b/mobile/test/shared/auth/auth_provider_test.dart @@ -2,18 +2,18 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:nostr/nostr.dart' as nostr; import 'package:buzz/shared/auth/auth_provider.dart'; -import 'package:buzz/shared/workspace/workspace.dart'; -import 'package:buzz/shared/workspace/workspace_provider.dart'; -import 'package:buzz/shared/workspace/workspace_storage.dart'; +import 'package:buzz/shared/community/community.dart'; +import 'package:buzz/shared/community/community_provider.dart'; +import 'package:buzz/shared/community/community_storage.dart'; -import '../workspace/workspace_storage_test.dart'; +import '../community/community_storage_test.dart'; void main() { test( - 'removes an invalid saved workspace instead of authenticating', + 'removes an invalid saved community instead of authenticating', () async { - final storage = WorkspaceStorage(secure: FakeSecureStorage()); - final invalid = Workspace.create( + final storage = CommunityStorage(secure: FakeSecureStorage()); + final invalid = Community.create( name: 'Invalid', relayUrl: 'https://relay.example', nsec: 'not-an-nsec', @@ -21,7 +21,7 @@ void main() { await storage.save(invalid); await storage.saveActiveId(invalid.id); final container = ProviderContainer( - overrides: [workspaceStorageProvider.overrideWithValue(storage)], + overrides: [communityStorageProvider.overrideWithValue(storage)], ); addTearDown(container.dispose); @@ -33,13 +33,13 @@ void main() { }, ); - test('falls through to the next valid saved workspace', () async { - final storage = WorkspaceStorage(secure: FakeSecureStorage()); - final invalid = Workspace.create( + test('falls through to the next valid saved community', () async { + final storage = CommunityStorage(secure: FakeSecureStorage()); + final invalid = Community.create( name: 'Invalid', relayUrl: 'https://invalid.example', ); - final valid = Workspace.create( + final valid = Community.create( name: 'Valid', relayUrl: 'https://valid.example', nsec: nostr.Keys.generate().nsec, @@ -48,14 +48,14 @@ void main() { await storage.save(valid); await storage.saveActiveId(invalid.id); final container = ProviderContainer( - overrides: [workspaceStorageProvider.overrideWithValue(storage)], + overrides: [communityStorageProvider.overrideWithValue(storage)], ); addTearDown(container.dispose); final auth = await container.read(authProvider.future); expect(auth.status, AuthStatus.authenticated); - expect(auth.workspace?.id, valid.id); + expect(auth.community?.id, valid.id); expect(await storage.loadActiveId(), valid.id); }); } diff --git a/mobile/test/shared/community/community_provider_test.dart b/mobile/test/shared/community/community_provider_test.dart new file mode 100644 index 00000000000..c58e03c56ae --- /dev/null +++ b/mobile/test/shared/community/community_provider_test.dart @@ -0,0 +1,152 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:buzz/shared/community/community.dart'; +import 'package:buzz/shared/community/community_provider.dart'; +import 'package:buzz/shared/community/community_storage.dart'; + +import 'community_storage_test.dart'; + +void main() { + late FakeSecureStorage fakeSecure; + late CommunityStorage communityStorage; + late ProviderContainer container; + + setUp(() { + fakeSecure = FakeSecureStorage(); + communityStorage = CommunityStorage(secure: fakeSecure); + }); + + tearDown(() => container.dispose()); + + ProviderContainer createContainer() { + return ProviderContainer( + overrides: [communityStorageProvider.overrideWithValue(communityStorage)], + ); + } + + group('CommunityListNotifier', () { + test('loads empty list initially', () async { + container = createContainer(); + final communities = await container.read(communityListProvider.future); + expect(communities, isEmpty); + }); + + test('addCommunity adds to list', () async { + container = createContainer(); + await container.read(communityListProvider.future); + + final ws = Community.create( + name: 'Test', + relayUrl: 'https://test.example.com', + ); + await container.read(communityListProvider.notifier).addCommunity(ws); + + final communities = await container.read(communityListProvider.future); + expect(communities, hasLength(1)); + expect(communities.first.name, 'Test'); + }); + + test('removeCommunity removes from list', () async { + container = createContainer(); + await container.read(communityListProvider.future); + + final ws = Community.create( + name: 'Test', + relayUrl: 'https://test.example.com', + ); + await container.read(communityListProvider.notifier).addCommunity(ws); + await container + .read(communityListProvider.notifier) + .removeCommunity(ws.id); + + final communities = await container.read(communityListProvider.future); + expect(communities, isEmpty); + }); + + test('renameCommunity updates name', () async { + container = createContainer(); + await container.read(communityListProvider.future); + + final ws = Community.create( + name: 'Original', + relayUrl: 'https://test.example.com', + ); + await container.read(communityListProvider.notifier).addCommunity(ws); + await container + .read(communityListProvider.notifier) + .renameCommunity(ws.id, 'Renamed'); + + final communities = await container.read(communityListProvider.future); + expect(communities.first.name, 'Renamed'); + }); + + test('switchCommunity updates active ID', () async { + container = createContainer(); + await container.read(communityListProvider.future); + + final ws1 = Community.create( + name: 'One', + relayUrl: 'https://one.example.com', + ); + final ws2 = Community.create( + name: 'Two', + relayUrl: 'https://two.example.com', + ); + + final notifier = container.read(communityListProvider.notifier); + await notifier.addCommunity(ws1); + await notifier.addCommunity(ws2); + await notifier.switchCommunity(ws2.id); + + final activeId = await communityStorage.loadActiveId(); + expect(activeId, ws2.id); + }); + }); + + group('activeCommunityProvider', () { + test('returns null when no communities', () async { + container = createContainer(); + final active = await container.read(activeCommunityProvider.future); + expect(active, isNull); + }); + + test('returns community matching active ID', () async { + container = createContainer(); + await container.read(communityListProvider.future); + + final ws = Community.create( + name: 'Test', + relayUrl: 'https://test.example.com', + ); + final notifier = container.read(communityListProvider.notifier); + await notifier.addCommunity(ws); + await notifier.switchCommunity(ws.id); + + final active = await container.read(activeCommunityProvider.future); + expect(active, isNotNull); + expect(active!.id, ws.id); + expect(active.name, 'Test'); + }); + + test('falls back to first community if active ID is invalid', () async { + container = createContainer(); + await container.read(communityListProvider.future); + + final ws = Community.create( + name: 'Fallback', + relayUrl: 'https://test.example.com', + ); + final notifier = container.read(communityListProvider.notifier); + await notifier.addCommunity(ws); + + // Set an invalid active ID. + await communityStorage.saveActiveId('nonexistent-id'); + + // Re-read — should fall back. + container.invalidate(activeCommunityProvider); + final active = await container.read(activeCommunityProvider.future); + expect(active, isNotNull); + expect(active!.id, ws.id); + }); + }); +} diff --git a/mobile/test/shared/workspace/workspace_storage_test.dart b/mobile/test/shared/community/community_storage_test.dart similarity index 80% rename from mobile/test/shared/workspace/workspace_storage_test.dart rename to mobile/test/shared/community/community_storage_test.dart index 7a6fc0ceea4..dc0835e6222 100644 --- a/mobile/test/shared/workspace/workspace_storage_test.dart +++ b/mobile/test/shared/community/community_storage_test.dart @@ -1,7 +1,9 @@ +import 'dart:convert'; + import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:buzz/shared/workspace/workspace.dart'; -import 'package:buzz/shared/workspace/workspace_storage.dart'; +import 'package:buzz/shared/community/community.dart'; +import 'package:buzz/shared/community/community_storage.dart'; /// In-memory fake that extends Fake to satisfy all FlutterSecureStorage /// interface methods, but implements the core read/write/delete with real @@ -87,21 +89,21 @@ class FakeSecureStorage extends Fake implements FlutterSecureStorage { void main() { late FakeSecureStorage fakeSecure; - late WorkspaceStorage storage; + late CommunityStorage storage; setUp(() { fakeSecure = FakeSecureStorage(); - storage = WorkspaceStorage(secure: fakeSecure); + storage = CommunityStorage(secure: fakeSecure); }); - group('WorkspaceStorage', () { + group('CommunityStorage', () { test('loadAll returns empty list when no data', () async { final result = await storage.loadAll(); expect(result, isEmpty); }); - test('save and loadAll round-trips a workspace', () async { - final ws = Workspace.create( + test('save and loadAll round-trips a community', () async { + final ws = Community.create( name: 'Test', relayUrl: 'https://relay.example.com', pubkey: 'abc123', @@ -117,8 +119,8 @@ void main() { expect(loaded.first.pubkey, 'abc123'); }); - test('save updates existing workspace with same id', () async { - final ws = Workspace.create( + test('save updates existing community with same id', () async { + final ws = Community.create( name: 'Original', relayUrl: 'https://relay.example.com', ); @@ -131,12 +133,12 @@ void main() { expect(loaded.first.name, 'Updated'); }); - test('remove deletes a workspace', () async { - final ws1 = Workspace.create( + test('remove deletes a community', () async { + final ws1 = Community.create( name: 'One', relayUrl: 'https://one.example.com', ); - final ws2 = Workspace.create( + final ws2 = Community.create( name: 'Two', relayUrl: 'https://two.example.com', ); @@ -150,7 +152,7 @@ void main() { expect(loaded.first.id, ws2.id); }); - test('active workspace ID persists', () async { + test('active community ID persists', () async { await storage.saveActiveId('ws-123'); final id = await storage.loadActiveId(); expect(id, 'ws-123'); @@ -164,7 +166,24 @@ void main() { }); group('migration', () { - test('migrates legacy keys to workspace on first load', () async { + test('migrates saved workspaces to communities', () async { + final legacy = Community.create( + name: 'Legacy', + relayUrl: 'https://legacy.example.com', + ); + fakeSecure['buzz_workspaces'] = jsonEncode([legacy.toJson()]); + fakeSecure['buzz_active_workspace_id'] = legacy.id; + + final loaded = await storage.loadAll(); + + expect(loaded.single.id, legacy.id); + expect(await storage.loadActiveId(), legacy.id); + expect(fakeSecure['buzz_communities'], isNotNull); + expect(fakeSecure['buzz_workspaces'], isNull); + expect(fakeSecure['buzz_active_workspace_id'], isNull); + }); + + test('migrates legacy keys to community on first load', () async { fakeSecure['buzz_relay_url'] = 'https://legacy.example.com'; fakeSecure['buzz_token'] = 'legacy_token'; fakeSecure['buzz_pubkey'] = 'legacy_pub'; diff --git a/mobile/test/shared/relay/relay_session_test.dart b/mobile/test/shared/relay/relay_session_test.dart index 68114f8df62..6cd601f56b0 100644 --- a/mobile/test/shared/relay/relay_session_test.dart +++ b/mobile/test/shared/relay/relay_session_test.dart @@ -168,7 +168,7 @@ void main() { }); test( - 'stops reconnecting without deleting workspace after auth rejection', + 'stops reconnecting without deleting community after auth rejection', () async { final session = RelaySessionNotifier(); final auth = _FakeAuthNotifier(); @@ -236,7 +236,7 @@ void main() { expect(sockets, hasLength(2)); sockets.first.disconnectWith( - const RelayAuthRejectedException('blocked: stale workspace'), + const RelayAuthRejectedException('blocked: stale community'), ); sockets.first.connectSuccessfully(); expect(session.state.status, SessionStatus.connecting); diff --git a/mobile/test/shared/workspace/workspace_provider_test.dart b/mobile/test/shared/workspace/workspace_provider_test.dart deleted file mode 100644 index 6979c4a6a28..00000000000 --- a/mobile/test/shared/workspace/workspace_provider_test.dart +++ /dev/null @@ -1,152 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:buzz/shared/workspace/workspace.dart'; -import 'package:buzz/shared/workspace/workspace_provider.dart'; -import 'package:buzz/shared/workspace/workspace_storage.dart'; - -import 'workspace_storage_test.dart'; - -void main() { - late FakeSecureStorage fakeSecure; - late WorkspaceStorage workspaceStorage; - late ProviderContainer container; - - setUp(() { - fakeSecure = FakeSecureStorage(); - workspaceStorage = WorkspaceStorage(secure: fakeSecure); - }); - - tearDown(() => container.dispose()); - - ProviderContainer createContainer() { - return ProviderContainer( - overrides: [workspaceStorageProvider.overrideWithValue(workspaceStorage)], - ); - } - - group('WorkspaceListNotifier', () { - test('loads empty list initially', () async { - container = createContainer(); - final workspaces = await container.read(workspaceListProvider.future); - expect(workspaces, isEmpty); - }); - - test('addWorkspace adds to list', () async { - container = createContainer(); - await container.read(workspaceListProvider.future); - - final ws = Workspace.create( - name: 'Test', - relayUrl: 'https://test.example.com', - ); - await container.read(workspaceListProvider.notifier).addWorkspace(ws); - - final workspaces = await container.read(workspaceListProvider.future); - expect(workspaces, hasLength(1)); - expect(workspaces.first.name, 'Test'); - }); - - test('removeWorkspace removes from list', () async { - container = createContainer(); - await container.read(workspaceListProvider.future); - - final ws = Workspace.create( - name: 'Test', - relayUrl: 'https://test.example.com', - ); - await container.read(workspaceListProvider.notifier).addWorkspace(ws); - await container - .read(workspaceListProvider.notifier) - .removeWorkspace(ws.id); - - final workspaces = await container.read(workspaceListProvider.future); - expect(workspaces, isEmpty); - }); - - test('renameWorkspace updates name', () async { - container = createContainer(); - await container.read(workspaceListProvider.future); - - final ws = Workspace.create( - name: 'Original', - relayUrl: 'https://test.example.com', - ); - await container.read(workspaceListProvider.notifier).addWorkspace(ws); - await container - .read(workspaceListProvider.notifier) - .renameWorkspace(ws.id, 'Renamed'); - - final workspaces = await container.read(workspaceListProvider.future); - expect(workspaces.first.name, 'Renamed'); - }); - - test('switchWorkspace updates active ID', () async { - container = createContainer(); - await container.read(workspaceListProvider.future); - - final ws1 = Workspace.create( - name: 'One', - relayUrl: 'https://one.example.com', - ); - final ws2 = Workspace.create( - name: 'Two', - relayUrl: 'https://two.example.com', - ); - - final notifier = container.read(workspaceListProvider.notifier); - await notifier.addWorkspace(ws1); - await notifier.addWorkspace(ws2); - await notifier.switchWorkspace(ws2.id); - - final activeId = await workspaceStorage.loadActiveId(); - expect(activeId, ws2.id); - }); - }); - - group('activeWorkspaceProvider', () { - test('returns null when no workspaces', () async { - container = createContainer(); - final active = await container.read(activeWorkspaceProvider.future); - expect(active, isNull); - }); - - test('returns workspace matching active ID', () async { - container = createContainer(); - await container.read(workspaceListProvider.future); - - final ws = Workspace.create( - name: 'Test', - relayUrl: 'https://test.example.com', - ); - final notifier = container.read(workspaceListProvider.notifier); - await notifier.addWorkspace(ws); - await notifier.switchWorkspace(ws.id); - - final active = await container.read(activeWorkspaceProvider.future); - expect(active, isNotNull); - expect(active!.id, ws.id); - expect(active.name, 'Test'); - }); - - test('falls back to first workspace if active ID is invalid', () async { - container = createContainer(); - await container.read(workspaceListProvider.future); - - final ws = Workspace.create( - name: 'Fallback', - relayUrl: 'https://test.example.com', - ); - final notifier = container.read(workspaceListProvider.notifier); - await notifier.addWorkspace(ws); - - // Set an invalid active ID. - await workspaceStorage.saveActiveId('nonexistent-id'); - - // Re-read — should fall back. - container.invalidate(activeWorkspaceProvider); - final active = await container.read(activeWorkspaceProvider.future); - expect(active, isNotNull); - expect(active!.id, ws.id); - }); - }); -} diff --git a/web/src/features/invite/ui/InvitePage.tsx b/web/src/features/invite/ui/InvitePage.tsx index e8ef471cb97..3a460a1ba89 100644 --- a/web/src/features/invite/ui/InvitePage.tsx +++ b/web/src/features/invite/ui/InvitePage.tsx @@ -5,7 +5,7 @@ import { Button } from "@/shared/ui/button"; const DOWNLOAD_URL = "https://github.com/block/buzz/releases/latest"; /** - * Landing page for a relay invite link (`/invite/`). + * Landing page for a community invite link (`/invite/`). * * The code is not validated here — validation happens in the desktop app when * the invite is claimed against `POST /api/invites/claim`, signed by the diff --git a/web/src/features/repos/mock-repos.ts b/web/src/features/repos/mock-repos.ts index c2e2394d9dd..e05bd3440fd 100644 --- a/web/src/features/repos/mock-repos.ts +++ b/web/src/features/repos/mock-repos.ts @@ -20,7 +20,7 @@ export const mockRepos: Repo[] = [ id: "buzz-desktop", name: "buzz-desktop", description: - "The desktop client for collaborating with people and agents across Buzz workspaces.", + "The desktop client for collaborating with people and agents across Buzz communities.", cloneUrls: ["https://example.com/buzz-desktop.git"], webUrl: null, channelId: null, @@ -115,7 +115,7 @@ export const mockRepoCommits: CommitInfo[] = [ export const mockRepoReadme: ReadmeResult = { filename: "README.md", content: - "# Buzz Desktop\n\nA focused workspace for people and agents to collaborate.\n\n## Getting started\n\nInstall dependencies, then start the development app.", + "# Buzz Desktop\n\nA focused community for people and agents to collaborate.\n\n## Getting started\n\nInstall dependencies, then start the development app.", }; export function getMockBlob( diff --git a/web/src/features/repos/ui/ReposPage.tsx b/web/src/features/repos/ui/ReposPage.tsx index 450cf80cf27..783f0a2aec7 100644 --- a/web/src/features/repos/ui/ReposPage.tsx +++ b/web/src/features/repos/ui/ReposPage.tsx @@ -45,7 +45,7 @@ function SearchEmptyState() { ); } -function RelayEmptyState() { +function CommunityEmptyState() { return (
@@ -56,11 +56,11 @@ function RelayEmptyState() { Buzz

- This relay is empty + This community is empty

- Repositories pushed to this relay will show up here. Open this relay - in the Buzz desktop app to start pushing code. + Repositories pushed to this community will show up here. Open this + community in the Buzz desktop app to start pushing code.

@@ -142,7 +142,7 @@ export function ReposPage() { } if (!repos || repos.length === 0) { - return ; + return ; } return ( From 1ceb5aeff2d692d8d4efa477d48a0683d84f4abc Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 14 Jul 2026 10:33:41 -0600 Subject: [PATCH 2/2] refactor(desktop): standardize product naming on community Rename desktop product-domain workspace and relay-member concepts to community while preserving relay transport and backend wire contracts. Migrate persisted workspace keys safely and retain compatibility reads. Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Wes --- desktop/playwright.config.ts | 2 +- desktop/src/app/App.tsx | 188 ++++++++-------- desktop/src/app/AppShell.tsx | 86 ++++---- desktop/src/app/AppTopChrome.tsx | 8 +- desktop/src/app/RelayConnectionOverlay.tsx | 10 +- desktop/src/app/useReloadShortcut.ts | 2 +- .../app/useThreadActivityFeedItems.test.mjs | 10 +- desktop/src/app/useThreadActivityFeedItems.ts | 4 +- .../agents/activeAgentTurnsStore.test.mjs | 68 +++--- .../features/agents/activeAgentTurnsStore.ts | 40 ++-- .../src/features/agents/agentWorkingSignal.ts | 2 +- .../agents/lib/friendlyAgentLastError.ts | 2 +- .../agents/ui/EditAgentAdvancedFields.tsx | 2 +- .../features/agents/ui/ManagedAgentRow.tsx | 2 +- .../agents/ui/UnifiedAgentsSection.tsx | 2 +- .../ui/transcriptAnimationPreference.ts | 4 +- .../ui/transcriptTimestampPreference.ts | 4 +- .../features/agents/useKnownAgentPubkeys.tsx | 8 +- .../src/features/channels/channelSnapshot.ts | 6 +- .../features/channels/forcedUnreadStore.ts | 2 +- desktop/src/features/channels/hooks.ts | 6 +- .../channels/ui/AgentSessionThreadPanel.tsx | 2 +- .../ui/ChannelManagementModerationActions.tsx | 2 +- .../features/channels/ui/ChannelScreen.tsx | 8 +- .../ui/useMembersSidebarModeration.ts | 2 +- .../useUnreadChannels.storage.test.mjs | 6 +- .../features/channels/useUnreadChannels.ts | 4 +- .../communityIconCache.ts} | 8 +- .../communityMarkRead.test.mjs} | 18 +- .../communityMarkRead.ts} | 18 +- .../communities/communityStorage.test.mjs | 61 ++++++ .../communityStorage.ts} | 73 ++++--- .../communityUnreadObserver.test.mjs} | 76 +++---- .../communityUnreadObserver.ts} | 32 +-- .../legacyCommunityStorage.test.mjs | 153 +++++++++++++ .../communities/legacyCommunityStorage.ts | 141 ++++++++++++ .../lib/downscaleIcon.ts | 4 +- .../{workspaces => communities}/types.ts | 6 +- .../ui/AddCommunityDialog.tsx} | 32 +-- .../ui/CommunityIconSettingsCard.tsx} | 56 ++--- .../ui/CommunitySwitcher.tsx} | 118 +++++----- .../ui/EditCommunityDialog.tsx} | 62 +++--- .../ui/WelcomeSetup.tsx | 92 ++++---- .../features/communities/useCommunities.tsx | 203 ++++++++++++++++++ .../features/communities/useCommunityIcons.ts | 68 ++++++ .../useCommunityInit.ts} | 120 +++++------ .../communities/useCommunityUnread.ts | 182 ++++++++++++++++ .../useNestNotifications.ts | 2 +- .../hooks.ts | 0 .../ui/AddMemberDialog.tsx | 2 +- .../ui/CommunityMembersCard.tsx} | 8 +- .../ui/CommunityMembersSettingsCard.tsx} | 26 +-- .../ui/ConfirmRemoveDialog.tsx | 2 +- .../ui/InviteLinkSection.tsx | 0 .../custom-emoji/emojiMartCategory.ts | 2 +- desktop/src/features/custom-emoji/hooks.ts | 8 +- .../ui/CustomEmojiSettingsCard.tsx | 14 +- .../features/custom-emoji/ui/EmojiPicker.tsx | 2 +- desktop/src/features/home/ui/HomeView.tsx | 8 +- .../src/features/home/ui/InboxMessageRow.tsx | 2 +- .../features/home/useInboxSelectionAnchor.ts | 8 +- .../src/features/identity-archive/hooks.ts | 2 +- .../agentMetricArchivePreference.ts | 2 +- .../local-archive/archiveSyncManager.ts | 4 +- .../observerArchivePreference.ts | 2 +- desktop/src/features/messages/hooks.ts | 2 +- .../features/messages/lib/customEmojiNode.ts | 2 +- .../features/messages/lib/messageSnapshot.ts | 4 +- .../src/features/messages/lib/useDrafts.ts | 4 +- .../messages/lib/useEmojiAutocomplete.ts | 2 +- .../src/features/messages/ui/MessageRow.tsx | 2 +- .../ui/configNudgeAuthPubkey.test.mjs | 2 +- .../messages/ui/configNudgeAuthPubkey.ts | 2 +- .../messages/ui/useQuickReactionEmojis.ts | 50 ++--- desktop/src/features/moderation/hooks.ts | 2 +- .../ui/MessageModerationMenuItems.tsx | 2 +- desktop/src/features/onboarding/hooks.ts | 30 +-- .../onboarding/ui/NostrKeyImportForm.tsx | 4 +- .../features/onboarding/ui/OnboardingFlow.tsx | 22 +- .../src/features/onboarding/ui/SetupStep.tsx | 2 +- .../src/features/onboarding/welcome.test.mjs | 14 +- desktop/src/features/onboarding/welcome.ts | 18 +- .../features/onboarding/welcomeGuide.test.mjs | 16 +- .../src/features/onboarding/welcomeGuide.ts | 2 +- desktop/src/features/profile/hooks.ts | 16 +- .../profile/lib/selfProfileStorage.ts | 6 +- .../features/profile/ui/ProfilePopover.tsx | 14 +- .../profile/ui/UserProfileAgentActions.tsx | 2 +- .../projects/ui/ProjectDetailScreen.tsx | 16 +- .../projects/ui/ProjectsOverviewPanel.tsx | 8 +- .../src/features/projects/ui/ProjectsView.tsx | 12 +- .../projects/useProjectsRepoSnapshots.ts | 2 +- .../settings/ui/DoctorSettingsPanel.tsx | 2 +- .../settings/ui/ModerationQueueCard.tsx | 2 +- .../features/settings/ui/SettingsPanels.tsx | 16 +- .../src/features/settings/ui/SettingsView.tsx | 10 +- .../sidebar/lib/channelSectionsStorage.ts | 2 +- .../sidebar/lib/channelSectionsSync.test.mjs | 4 +- .../sidebar/lib/channelSectionsSync.ts | 6 +- .../sidebar/lib/channelSortPreference.ts | 2 +- .../sidebar/lib/channelSortSync.test.mjs | 2 +- .../features/sidebar/lib/channelSortSync.ts | 6 +- .../sidebar/lib/useChannelSortPreference.ts | 2 +- .../src/features/sidebar/ui/AppSidebar.tsx | 68 +++--- ...ceRail.test.mjs => CommunityRail.test.mjs} | 38 ++-- .../{WorkspaceRail.tsx => CommunityRail.tsx} | 160 +++++++------- .../sidebar/ui/SidebarProfileCard.tsx | 56 ++--- .../sidebar/ui/sidebarLoadingSkeleton.tsx | 14 +- .../ui/useSidebarRelayConnectionCard.ts | 2 +- .../features/user-status/ui/StatusEmoji.tsx | 2 +- .../legacyWorkspaceStorage.test.mjs | 153 ------------- .../workspaces/legacyWorkspaceStorage.ts | 138 ------------ .../features/workspaces/useWorkspaceIcons.ts | 68 ------ .../features/workspaces/useWorkspaceUnread.ts | 182 ---------------- .../src/features/workspaces/useWorkspaces.tsx | 203 ------------------ desktop/src/main.tsx | 20 +- ...orkspaceProfile.ts => communityProfile.ts} | 26 +-- desktop/src/shared/api/customEmoji.ts | 16 +- desktop/src/shared/api/invites.ts | 6 +- desktop/src/shared/api/readOnlyRelayClient.ts | 4 +- desktop/src/shared/api/relayClientSession.ts | 12 +- desktop/src/shared/api/relayClientShared.ts | 4 +- .../shared/api/relayConnectionStateEmitter.ts | 2 +- .../api/relayQueryInvalidation.test.mjs | 2 +- .../src/shared/api/relayReconnectPolicy.ts | 2 +- .../shared/api/relayWebSocketClose.test.mjs | 6 +- desktop/src/shared/api/tauri.ts | 2 +- desktop/src/shared/api/useReconnectRelay.ts | 2 +- desktop/src/shared/deep-link.ts | 28 +-- desktop/src/shared/lib/customEmojiTags.ts | 2 +- desktop/src/shared/lib/linkPreview.ts | 4 +- desktop/src/shared/lib/localStorageQuota.ts | 2 +- desktop/src/shared/lib/mediaUrl.ts | 2 +- .../src/shared/styles/globals/animations.css | 2 +- desktop/src/shared/styles/globals/theme.css | 6 +- desktop/src/shared/ui/markdown/nodeCache.ts | 4 +- desktop/src/shared/ui/videoPlayerState.ts | 2 +- desktop/src/testing/e2eBridge.ts | 22 +- desktop/tests/e2e/boot-splash.spec.ts | 2 +- ...ce-rail.spec.ts => community-rail.spec.ts} | 115 +++++----- desktop/tests/e2e/custom-emoji-ui.spec.ts | 8 +- desktop/tests/e2e/custom-emoji.spec.ts | 2 +- desktop/tests/e2e/drafts-screenshots.spec.ts | 28 +-- desktop/tests/e2e/onboarding.spec.ts | 38 ++-- desktop/tests/e2e/profile.spec.ts | 2 +- .../tests/e2e/project-commit-detail.spec.ts | 4 +- desktop/tests/e2e/relay-reconnect.spec.ts | 2 +- desktop/tests/helpers/bridge.ts | 28 +-- desktop/tests/helpers/screenshot.mjs | 12 +- desktop/tests/helpers/settings.ts | 2 +- desktop/tests/helpers/zoom-shot.mjs | 6 +- preview-features.json | 4 +- 152 files changed, 2008 insertions(+), 1918 deletions(-) rename desktop/src/features/{workspaces/workspaceIconCache.ts => communities/communityIconCache.ts} (80%) rename desktop/src/features/{workspaces/workspaceMarkRead.test.mjs => communities/communityMarkRead.test.mjs} (92%) rename desktop/src/features/{workspaces/workspaceMarkRead.ts => communities/communityMarkRead.ts} (90%) create mode 100644 desktop/src/features/communities/communityStorage.test.mjs rename desktop/src/features/{workspaces/workspaceStorage.ts => communities/communityStorage.ts} (58%) rename desktop/src/features/{workspaces/workspaceUnreadObserver.test.mjs => communities/communityUnreadObserver.test.mjs} (91%) rename desktop/src/features/{workspaces/workspaceUnreadObserver.ts => communities/communityUnreadObserver.ts} (94%) create mode 100644 desktop/src/features/communities/legacyCommunityStorage.test.mjs create mode 100644 desktop/src/features/communities/legacyCommunityStorage.ts rename desktop/src/features/{workspaces => communities}/lib/downscaleIcon.ts (93%) rename desktop/src/features/{workspaces => communities}/types.ts (88%) rename desktop/src/features/{workspaces/ui/AddWorkspaceDialog.tsx => communities/ui/AddCommunityDialog.tsx} (90%) rename desktop/src/features/{workspaces/ui/WorkspaceIconSettingsCard.tsx => communities/ui/CommunityIconSettingsCard.tsx} (66%) rename desktop/src/features/{workspaces/ui/WorkspaceSwitcher.tsx => communities/ui/CommunitySwitcher.tsx} (80%) rename desktop/src/features/{workspaces/ui/EditWorkspaceDialog.tsx => communities/ui/EditCommunityDialog.tsx} (82%) rename desktop/src/features/{workspaces => communities}/ui/WelcomeSetup.tsx (82%) create mode 100644 desktop/src/features/communities/useCommunities.tsx create mode 100644 desktop/src/features/communities/useCommunityIcons.ts rename desktop/src/features/{workspaces/useWorkspaceInit.ts => communities/useCommunityInit.ts} (63%) create mode 100644 desktop/src/features/communities/useCommunityUnread.ts rename desktop/src/features/{workspaces => communities}/useNestNotifications.ts (96%) rename desktop/src/features/{relay-members => community-members}/hooks.ts (100%) rename desktop/src/features/{relay-members => community-members}/ui/AddMemberDialog.tsx (99%) rename desktop/src/features/{relay-members/ui/RelayMembersCard.tsx => community-members/ui/CommunityMembersCard.tsx} (97%) rename desktop/src/features/{relay-members/ui/RelayMembersSettingsCard.tsx => community-members/ui/CommunityMembersSettingsCard.tsx} (95%) rename desktop/src/features/{relay-members => community-members}/ui/ConfirmRemoveDialog.tsx (96%) rename desktop/src/features/{relay-members => community-members}/ui/InviteLinkSection.tsx (100%) rename desktop/src/features/sidebar/ui/{WorkspaceRail.test.mjs => CommunityRail.test.mjs} (68%) rename desktop/src/features/sidebar/ui/{WorkspaceRail.tsx => CommunityRail.tsx} (63%) delete mode 100644 desktop/src/features/workspaces/legacyWorkspaceStorage.test.mjs delete mode 100644 desktop/src/features/workspaces/legacyWorkspaceStorage.ts delete mode 100644 desktop/src/features/workspaces/useWorkspaceIcons.ts delete mode 100644 desktop/src/features/workspaces/useWorkspaceUnread.ts delete mode 100644 desktop/src/features/workspaces/useWorkspaces.tsx rename desktop/src/shared/api/{workspaceProfile.ts => communityProfile.ts} (63%) rename desktop/tests/e2e/{workspace-rail.spec.ts => community-rail.spec.ts} (54%) 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 (
- Switching workspace… + Switching community… {showSpinner ? ( @@ -197,7 +197,7 @@ function OnboardingLoadingGate() { className="flex w-full flex-col items-center text-center" direction="forward" effect="none" - transitionKey="workspace-connecting" + transitionKey="community-connecting" >
- Workspace icon +
+ Community icon
{icon ? ( Workspace icon ) : ( @@ -94,7 +94,7 @@ export function WorkspaceIconSettingsCard() {
{icon ? (

- 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.

diff --git a/desktop/src/features/workspaces/ui/WorkspaceSwitcher.tsx b/desktop/src/features/communities/ui/CommunitySwitcher.tsx similarity index 80% rename from desktop/src/features/workspaces/ui/WorkspaceSwitcher.tsx rename to desktop/src/features/communities/ui/CommunitySwitcher.tsx index 0349448a507..020a2e88381 100644 --- a/desktop/src/features/workspaces/ui/WorkspaceSwitcher.tsx +++ b/desktop/src/features/communities/ui/CommunitySwitcher.tsx @@ -8,7 +8,7 @@ import { } from "lucide-react"; import * as React from "react"; -import type { Workspace } from "@/features/workspaces/types"; +import type { Community } from "@/features/communities/types"; import { DropdownMenu, DropdownMenuContent, @@ -29,8 +29,8 @@ import { isRelayConnectionDegraded, useRelayConnection, } from "@/shared/api/useRelayConnection"; -import { useActiveWorkspaceIcon } from "@/features/workspaces/useWorkspaceIcons"; -import { EditWorkspaceDialog } from "./EditWorkspaceDialog"; +import { useActiveCommunityIcon } from "@/features/communities/useCommunityIcons"; +import { EditCommunityDialog } from "./EditCommunityDialog"; const CONNECTION_STATE_LABEL: Record = { idle: "Not connected", @@ -41,20 +41,20 @@ const CONNECTION_STATE_LABEL: Record = { disconnected: "Disconnected from relay", }; -type WorkspaceSwitcherProps = { - activeWorkspace: Workspace | null; - workspaces: Workspace[]; +type CommunitySwitcherProps = { + activeCommunity: Community | null; + communities: Community[]; variant?: "sidebar" | "profile" | "profile-menu"; - onSwitchWorkspace: (id: string) => void; - onAddWorkspace: () => void; - onUpdateWorkspace: ( + onSwitchCommunity: (id: string) => void; + onAddCommunity: () => void; + onUpdateCommunity: ( id: string, - updates: Partial>, + updates: Partial>, ) => void; - onRemoveWorkspace: (id: string) => void; + onRemoveCommunity: (id: string) => void; }; -export function WorkspaceEmojiIcon({ +export function CommunityEmojiIcon({ className, iconUrl, }: { @@ -83,23 +83,23 @@ export function WorkspaceEmojiIcon({ ); } -export function WorkspaceSwitcher({ - activeWorkspace, - workspaces, +export function CommunitySwitcher({ + activeCommunity, + communities, variant = "sidebar", - onSwitchWorkspace, - onAddWorkspace, - onUpdateWorkspace, - onRemoveWorkspace, -}: WorkspaceSwitcherProps) { - const [editingWorkspace, setEditingWorkspace] = - React.useState(null); + onSwitchCommunity, + onAddCommunity, + onUpdateCommunity, + onRemoveCommunity, +}: CommunitySwitcherProps) { + const [editingCommunity, setEditingCommunity] = + React.useState(null); const [dropdownOpen, setDropdownOpen] = React.useState(false); const profileMenuHoverTimer = React.useRef(null); const connectionState = useRelayConnection(); const degraded = isRelayConnectionDegraded(connectionState); const connectionLabel = CONNECTION_STATE_LABEL[connectionState]; - const activeIconQuery = useActiveWorkspaceIcon(activeWorkspace?.relayUrl); + const activeIconQuery = useActiveCommunityIcon(activeCommunity?.relayUrl); const activeIcon = activeIconQuery.data ?? null; const isProfileVariant = variant === "profile"; @@ -162,7 +162,7 @@ export function WorkspaceSwitcher({ ) : ( - - {activeWorkspace?.name ?? "No workspace"} + {activeCommunity?.name ?? "No community"} {variant === "profile-menu" ? ( @@ -203,11 +203,11 @@ export function WorkspaceSwitcher({ aria-haspopup="menu" aria-label={ degraded - ? `${activeWorkspace?.name ?? "Workspace"} — ${connectionLabel}` - : "Switch workspace" + ? `${activeCommunity?.name ?? "Community"} — ${connectionLabel}` + : "Switch community" } className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm text-popover-foreground outline-hidden transition-colors hover:bg-muted/50 focus:bg-muted/50 focus:outline-none focus-visible:bg-muted/50 focus-visible:outline-none data-[state=open]:bg-muted/50 data-[state=open]:text-popover-foreground" - data-testid="workspace-switcher" + data-testid="community-switcher" onMouseEnter={() => scheduleProfileMenu(true)} onMouseLeave={() => scheduleProfileMenu(false)} role="menuitem" @@ -224,37 +224,37 @@ export function WorkspaceSwitcher({ side="right" sideOffset={0} > -
- {workspaces.map((workspace) => ( +
+ {communities.map((community) => (
@@ -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 diff --git a/desktop/src/features/communities/useCommunities.tsx b/desktop/src/features/communities/useCommunities.tsx new file mode 100644 index 00000000000..83f92f3c133 --- /dev/null +++ b/desktop/src/features/communities/useCommunities.tsx @@ -0,0 +1,203 @@ +import { + createContext, + useCallback, + useContext, + useMemo, + useRef, + useState, +} from "react"; +import type { ReactNode } from "react"; + +import type { Community } from "./types"; +import { + clearCommunityStorage, + loadActiveCommunityId, + loadCommunities, + saveActiveCommunityId, + saveCommunities, +} from "./communityStorage"; +import { removeSelfProfileCachesForRelay } from "@/features/profile/lib/selfProfileStorage"; +import { removeChannelSnapshotForRelay } from "@/features/channels/channelSnapshot"; +import { removeMessageSnapshotsForRelay } from "@/features/messages/lib/messageSnapshot"; +import { clearSavedCommunitySnapshot } from "@/features/agents/activeAgentTurnsStore"; + +export type UseCommunitiesReturn = { + communities: Community[]; + activeCommunity: Community | null; + /** Counter bumped when the active community's config changes (relayUrl/token). */ + reinitKey: number; + /** Add a community, deduplicating by relayUrl. Returns the final ID in the list. */ + addCommunity: (community: Community) => string; + clearCommunities: () => void; + removeCommunity: (id: string) => void; + switchCommunity: (id: string) => void; + /** Force the active community to re-init (e.g. after a deep-link reconnect). */ + reconnectCommunity: () => void; + updateCommunity: ( + id: string, + updates: Partial< + Pick + >, + ) => void; +}; + +const CommunitiesContext = createContext(null); + +export function CommunitiesProvider({ children }: { children: ReactNode }) { + const value = useCommunitiesInternal(); + return ( + + {children} + + ); +} + +export function useCommunities(): UseCommunitiesReturn { + const ctx = useContext(CommunitiesContext); + if (!ctx) { + throw new Error("useCommunities must be used within a CommunitiesProvider"); + } + return ctx; +} + +function useCommunitiesInternal(): UseCommunitiesReturn { + const [communities, setCommunitiesState] = + useState(loadCommunities); + const [activeId, setActiveId] = useState( + loadActiveCommunityId, + ); + const [reinitKey, setReinitKey] = useState(0); + const communitiesRef = useRef(communities); + communitiesRef.current = communities; + + const activeCommunity = useMemo( + () => communities.find((w) => w.id === activeId) ?? communities[0] ?? null, + [communities, activeId], + ); + + const addCommunity = useCallback((community: Community): string => { + const existing = communitiesRef.current.find( + (w) => w.relayUrl === community.relayUrl, + ); + const resolvedId = existing?.id ?? community.id; + setCommunitiesState((prev) => { + const dup = prev.find((w) => w.relayUrl === community.relayUrl); + let next: Community[]; + if (dup) { + next = prev.map((w) => + w.id === dup.id + ? { + ...w, + name: community.name || w.name, + token: community.token ?? w.token, + pubkey: community.pubkey ?? w.pubkey, + } + : w, + ); + } else { + next = [...prev, community]; + } + saveCommunities(next); + return next; + }); + return resolvedId; + }, []); + + const clearCommunities = useCallback(() => { + clearCommunityStorage(); + setCommunitiesState([]); + setActiveId(null); + }, []); + + const removeCommunity = useCallback( + (id: string) => { + // GC self-profile caches for the removed community's relay. Mirror the + // updater guard (length > 1) so we only GC when removal will actually + // proceed. Runs outside the updater — updaters can execute twice under + // React StrictMode. + if (communities.length > 1) { + const removed = communities.find((w) => w.id === id); + if (removed) { + removeSelfProfileCachesForRelay(removed.relayUrl); + removeChannelSnapshotForRelay(removed.relayUrl); + removeMessageSnapshotsForRelay(removed.relayUrl); + clearSavedCommunitySnapshot(id); + } + } + + setCommunitiesState((prev) => { + // Never allow removing the last community + if (prev.length <= 1) { + return prev; + } + const next = prev.filter((w) => w.id !== id); + saveCommunities(next); + + // If removing the active community, switch to first remaining + if (activeId === id && next.length > 0) { + saveActiveCommunityId(next[0].id); + setActiveId(next[0].id); + } + + return next; + }); + }, + [activeId, communities], + ); + + const switchCommunity = useCallback( + (id: string) => { + if (id === activeId) return; + saveActiveCommunityId(id); + setActiveId(id); + }, + [activeId], + ); + + const reconnectCommunity = useCallback(() => { + setReinitKey((k) => k + 1); + }, []); + + const updateCommunity = useCallback( + ( + id: string, + updates: Partial< + Pick + >, + ) => { + setCommunitiesState((prev) => { + // Prevent duplicate relay URLs across communities + if ( + updates.relayUrl && + prev.some((w) => w.id !== id && w.relayUrl === updates.relayUrl) + ) { + return prev; + } + const next = prev.map((w) => (w.id === id ? { ...w, ...updates } : w)); + saveCommunities(next); + return next; + }); + // If the active community's relay URL or token changed, bump reinitKey + // so the React tree remounts with the new config. + if ( + id === activeId && + (updates.relayUrl || updates.token !== undefined) + ) { + setReinitKey((k) => k + 1); + } + }, + [activeId], + ); + + return { + communities, + activeCommunity, + reinitKey, + addCommunity, + clearCommunities, + removeCommunity, + switchCommunity, + reconnectCommunity, + updateCommunity, + }; +} diff --git a/desktop/src/features/communities/useCommunityIcons.ts b/desktop/src/features/communities/useCommunityIcons.ts new file mode 100644 index 00000000000..2abf208e319 --- /dev/null +++ b/desktop/src/features/communities/useCommunityIcons.ts @@ -0,0 +1,68 @@ +import { useQueries, useQuery } from "@tanstack/react-query"; + +import { fetchCommunityIcon } from "@/shared/api/communityProfile"; + +import type { Community } from "./types"; +import { + loadCachedCommunityIcon, + saveCachedCommunityIcon, +} from "./communityIconCache"; + +export const communityIconQueryKey = (relayUrl: string) => + ["communityIcon", relayUrl] as const; + +const ICON_STALE_MS = 5 * 60_000; + +async function fetchIconForCommunity( + community: Community, +): Promise { + const icon = await fetchCommunityIcon(community.relayUrl); + saveCachedCommunityIcon(community.relayUrl, icon); + return icon; +} + +function iconQueryOptions(community: Community) { + return { + queryKey: communityIconQueryKey(community.relayUrl), + queryFn: () => fetchIconForCommunity(community), + // Cached icon renders immediately; the fetch still runs and replaces it. + placeholderData: loadCachedCommunityIcon(community.relayUrl), + staleTime: ICON_STALE_MS, + retry: 1, + }; +} + +/** + * Community icons for the rail, keyed by community id. Each icon is read + * from its relay's NIP-11 document over plain HTTP — active and inactive + * communities alike. Falls back to the localStorage cache (then null → + * initials) when a relay is unreachable. + */ +export function useCommunityIcons( + communities: Community[], +): Record { + const results = useQueries({ + queries: communities.map((community) => iconQueryOptions(community)), + }); + + const icons: Record = {}; + communities.forEach((community, index) => { + icons[community.id] = + results[index]?.data ?? loadCachedCommunityIcon(community.relayUrl); + }); + return icons; +} + +/** Icon of the ACTIVE community, for settings preview. */ +export function useActiveCommunityIcon(relayUrl: string | undefined) { + return useQuery({ + queryKey: communityIconQueryKey(relayUrl ?? ""), + queryFn: async () => { + const icon = await fetchCommunityIcon(relayUrl ?? ""); + if (relayUrl) saveCachedCommunityIcon(relayUrl, icon); + return icon; + }, + enabled: relayUrl !== undefined, + staleTime: ICON_STALE_MS, + }); +} diff --git a/desktop/src/features/workspaces/useWorkspaceInit.ts b/desktop/src/features/communities/useCommunityInit.ts similarity index 63% rename from desktop/src/features/workspaces/useWorkspaceInit.ts rename to desktop/src/features/communities/useCommunityInit.ts index de82407bb88..c094d951f9b 100644 --- a/desktop/src/features/workspaces/useWorkspaceInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from "react"; import { relayClient } from "@/shared/api/relayClient"; -import { applyWorkspace, getDefaultRelayUrl } from "@/shared/api/tauri"; +import { applyCommunity, getDefaultRelayUrl } from "@/shared/api/tauri"; import { getIdentity } from "@/shared/api/tauriIdentity"; import { resetMediaCaches } from "@/shared/lib/mediaUrl"; import { clearSearchHitEventCache } from "@/app/navigation/searchHitEventCache"; @@ -9,8 +9,8 @@ import { initDraftStore } from "@/features/messages/lib/useDrafts"; import { resetRenderScopedReactionHydration } from "@/features/messages/lib/renderScopedReactions"; import { resetActiveAgentTurnsStore, - saveActiveAgentTurnsForWorkspace, - restoreActiveAgentTurnsForWorkspace, + saveActiveAgentTurnsForCommunity, + restoreActiveAgentTurnsForCommunity, } from "@/features/agents/activeAgentTurnsStore"; import { resetAgentWorkingSignal } from "@/features/agents/agentWorkingSignal"; import { resetAgentObserverStore } from "@/features/agents/observerRelayStore"; @@ -18,17 +18,17 @@ import { resetSidebarRelayConnectionCardState } from "@/features/sidebar/ui/useS import { clearMarkdownNodeCache } from "@/shared/ui/markdown/nodeCache"; import { resetVideoPlayerState } from "@/shared/ui/videoPlayerState"; -import { initFirstWorkspace } from "./workspaceStorage"; -import type { Workspace } from "./types"; +import { initFirstCommunity } from "./communityStorage"; +import type { Community } from "./types"; /** - * Tear down all workspace-scoped module singletons so the new - * workspace starts with a clean slate. Hook-managed singletons + * Tear down all community-scoped module singletons so the new + * community starts with a clean slate. Hook-managed singletons * (e.g. ChannelMuteSyncManager, ChannelSectionSyncManager) are * destroyed via effect cleanup and do not need entries here. - * See AGENTS.md "Workspace Switching" for the full contract. + * See AGENTS.md "Community Switching" for the full contract. */ -function resetWorkspaceState(): void { +function resetCommunityState(): void { relayClient.disconnect(); resetAgentObserverStore(); resetActiveAgentTurnsStore(); @@ -41,7 +41,7 @@ function resetWorkspaceState(): void { clearMarkdownNodeCache(); } -type WorkspaceInitResult = +type CommunityInitResult = | { isReady: true; needsSetup: false; appliedKey: string } | { isReady: false; @@ -51,45 +51,45 @@ type WorkspaceInitResult = | { isReady: false; needsSetup: false; appliedKey: string | null }; /** - * Applies the active workspace config to the Tauri backend and resets - * all workspace-scoped module singletons when the workspace changes. + * Applies the active community config to the Tauri backend and resets + * all community-scoped module singletons when the community changes. * * Returns a discriminated union — only render the app after the - * workspace is applied. When `needsSetup` is true, the caller + * community is applied. When `needsSetup` is true, the caller * should show a first-run welcome screen. */ -export function useWorkspaceInit( - activeWorkspace: Workspace | null, - workspaceKey: string, +export function useCommunityInit( + activeCommunity: Community | null, + communityKey: string, isSharedIdentity: boolean, -): WorkspaceInitResult { - const [result, setResult] = useState({ +): CommunityInitResult { + const [result, setResult] = useState({ isReady: false, needsSetup: false, appliedKey: null, }); - // Track whether this is the initial mount or a workspace switch. + // Track whether this is the initial mount or a community switch. // On the initial mount we skip resetting singletons (they're fresh). const hasInitializedRef = useRef(false); - // Track the previously-applied workspace ID so we can save its turn state - // before resetting when the user switches to a different workspace. - const prevWorkspaceIdRef = useRef(null); + // Track the previously-applied community ID so we can save its turn state + // before resetting when the user switches to a different community. + const prevCommunityIdRef = useRef(null); // biome-ignore lint/correctness/useExhaustiveDependencies: we intentionally depend on specific properties (id/relayUrl/token/reposDir) — depending on the whole object would trigger resets on name-only changes useEffect(() => { let cancelled = false; async function init() { - if (!activeWorkspace) { + if (!activeCommunity) { try { const defaultRelayUrl = await getDefaultRelayUrl(); if (isSharedIdentity) { const identity = await getIdentity(); if (cancelled) return; - initFirstWorkspace(defaultRelayUrl, identity.pubkey); + initFirstCommunity(defaultRelayUrl, identity.pubkey); if (!cancelled) { window.location.reload(); } @@ -115,59 +115,59 @@ export function useWorkspaceInit( return; } - // Mark this workspace config as pending while it is applied to the - // backend. App.tsx also checks appliedKey against the active workspaceKey, - // which prevents rendering workspace-scoped UI for a new workspace until + // Mark this community config as pending while it is applied to the + // backend. App.tsx also checks appliedKey against the active communityKey, + // which prevents rendering community-scoped UI for a new community until // that exact config has finished applying. setResult({ isReady: false, needsSetup: false, - appliedKey: workspaceKey, + appliedKey: communityKey, }); - // On workspace switch (not initial mount), reset module singletons + // On community switch (not initial mount), reset module singletons // so the new tree starts with a clean slate. if (hasInitializedRef.current) { - // Save the outgoing workspace's turn state before wiping the store so + // Save the outgoing community's turn state before wiping the store so // timers survive a round-trip (A → B → A keeps A's elapsed time). - if (prevWorkspaceIdRef.current) { - saveActiveAgentTurnsForWorkspace(prevWorkspaceIdRef.current); - // Null out immediately so a rapid workspace switch (A→B→C before - // B's applyWorkspace resolves) doesn't re-save the now-empty - // store under the outgoing workspace ID and delete its snapshot. - prevWorkspaceIdRef.current = null; + if (prevCommunityIdRef.current) { + saveActiveAgentTurnsForCommunity(prevCommunityIdRef.current); + // Null out immediately so a rapid community switch (A→B→C before + // B's applyCommunity resolves) doesn't re-save the now-empty + // store under the outgoing community ID and delete its snapshot. + prevCommunityIdRef.current = null; } - resetWorkspaceState(); + resetCommunityState(); } hasInitializedRef.current = true; - // Apply workspace config to the Tauri backend. + // Apply community config to the Tauri backend. // // Note: we deliberately do NOT pass an nsec here. The persisted // `identity.key` file (resolved at startup by `resolve_persisted_identity`, // and updated atomically by `import_identity`) is the single source of // truth for the active key. Older builds stored the nsec in localStorage // and re-applied it on every reload, which silently overwrote any - // imported key. `loadWorkspaces()` strips lingering `nsec` fields from + // imported key. `loadCommunities()` strips lingering `nsec` fields from // legacy entries; this site refuses to apply one even if present. try { - await applyWorkspace( - activeWorkspace.relayUrl, + await applyCommunity( + activeCommunity.relayUrl, undefined, - activeWorkspace.token, - activeWorkspace.reposDir, + activeCommunity.token, + activeCommunity.reposDir, ); } catch (error) { // A bad `repos_dir` no longer reaches here — `apply_workspace` treats // it as non-fatal (relay/keys apply, bad value not persisted, REPOS // falls back to a real dir, a `repos-dir-error` toast surfaces it) and // returns Ok, so the app boots into a working state where the user can - // fix the value in workspace settings. This catch now only fires on a + // fix the value in community settings. This catch now only fires on a // genuine relay/key apply failure (e.g. an invalid nsec or a poisoned - // lock). For those, marking the workspace ready would render - // workspace-scoped UI against a backend that never applied — park on + // lock). For those, marking the community ready would render + // community-scoped UI against a backend that never applied — park on // the loading gate (isReady:false, no appliedKey) instead. - console.error("Failed to apply workspace to backend:", error); + console.error("Failed to apply community to backend:", error); if (!cancelled) { setResult({ isReady: false, needsSetup: false, appliedKey: null }); } @@ -177,19 +177,19 @@ export function useWorkspaceInit( if (!cancelled) { // Initialise the draft store for this identity so localStorage drafts // are scoped to the correct pubkey before the app renders. - if (activeWorkspace.pubkey) { - initDraftStore(activeWorkspace.pubkey); + if (activeCommunity.pubkey) { + initDraftStore(activeCommunity.pubkey); } - // Restore any turn state saved for this workspace (a prior A→B round- - // trip). This runs after applyWorkspace succeeds and before the app + // Restore any turn state saved for this community (a prior A→B round- + // trip). This runs after applyCommunity succeeds and before the app // renders so components see the restored timers on first render. - restoreActiveAgentTurnsForWorkspace(activeWorkspace.id); - // Prime the ref so the NEXT switch saves this workspace's state. - prevWorkspaceIdRef.current = activeWorkspace.id; + restoreActiveAgentTurnsForCommunity(activeCommunity.id); + // Prime the ref so the NEXT switch saves this community's state. + prevCommunityIdRef.current = activeCommunity.id; setResult({ isReady: true, needsSetup: false, - appliedKey: workspaceKey, + appliedKey: communityKey, }); } } @@ -200,12 +200,12 @@ export function useWorkspaceInit( cancelled = true; }; }, [ - activeWorkspace?.id, - activeWorkspace?.relayUrl, - activeWorkspace?.token, - activeWorkspace?.reposDir, + activeCommunity?.id, + activeCommunity?.relayUrl, + activeCommunity?.token, + activeCommunity?.reposDir, isSharedIdentity, - workspaceKey, + communityKey, ]); return result; diff --git a/desktop/src/features/communities/useCommunityUnread.ts b/desktop/src/features/communities/useCommunityUnread.ts new file mode 100644 index 00000000000..851c77c9553 --- /dev/null +++ b/desktop/src/features/communities/useCommunityUnread.ts @@ -0,0 +1,182 @@ +import * as React from "react"; + +import { getIdentity } from "@/shared/api/tauriIdentity"; +import { markCommunityRead } from "@/features/communities/communityMarkRead"; +import { pollCommunityUnread } from "@/features/communities/communityUnreadObserver"; + +import type { Community } from "./types"; + +const COMMUNITY_UNREAD_POLL_MS = 30_000; + +/** + * Per-community unread summary for the community rail. + * + * `state` distinguishes "observed, no unread" from "not observed yet" so the + * rail never renders a false "no unread" for a relay it could not reach: + * - `unknown` — not yet observed (render dim, no unread affordance) + * - `loading` — observation in flight (render dim/skeleton) + * - `ready` — observed; trust `hasUnread` / `count` + * - `error` — observation failed (render neutral, never "no unread") + * + * `count` carries the MENTION count (not total unread) — the rail shows a dot + * for any unread and a numeric badge only when mentions are present. + */ +export type CommunityUnreadState = { + hasUnread: boolean; + count?: number; + state: "unknown" | "loading" | "ready" | "error"; +}; + +const unknownUnreadState: CommunityUnreadState = { + hasUnread: false, + state: "unknown", +}; + +function seedCommunityStates( + communities: Community[], + previous: Record, +): Record { + const next: Record = {}; + for (const community of communities) { + next[community.id] = previous[community.id] ?? unknownUnreadState; + } + return next; +} + +/** + * Observe unread activity for INACTIVE communities without touching the active + * relay singleton. + */ +export function useCommunityUnread( + communities: Community[], + activeCommunityId: string | null, +): { + unreadByCommunity: Record; + markCommunityRead: (communityId: string) => Promise; +} { + const [unreadByCommunity, setUnreadByCommunity] = React.useState< + Record + >(() => seedCommunityStates(communities, {})); + + React.useEffect(() => { + let cancelled = false; + let pollTimer: number | null = null; + const inactiveCommunities = communities.filter( + (community) => community.id !== activeCommunityId, + ); + + setUnreadByCommunity((previous) => + seedCommunityStates(communities, previous), + ); + + const markLoading = (communityId: string) => { + setUnreadByCommunity((previous) => { + const current = previous[communityId] ?? unknownUnreadState; + if (current.state === "ready") { + return previous; + } + return { + ...previous, + [communityId]: { hasUnread: false, state: "loading" }, + }; + }); + }; + + const markReady = ( + communityId: string, + result: { hasUnread: boolean; mentionCount: number }, + ) => { + setUnreadByCommunity((previous) => ({ + ...previous, + [communityId]: { + hasUnread: result.hasUnread, + count: result.mentionCount > 0 ? result.mentionCount : undefined, + state: "ready", + }, + })); + }; + + const markError = (communityId: string) => { + setUnreadByCommunity((previous) => ({ + ...previous, + [communityId]: { hasUnread: false, state: "error" }, + })); + }; + + const scheduleNextPoll = () => { + if (cancelled) return; + pollTimer = window.setTimeout(() => { + void pollInactiveCommunities(); + }, COMMUNITY_UNREAD_POLL_MS); + }; + + const pollInactiveCommunities = async () => { + if (inactiveCommunities.length === 0) { + return; + } + + let pubkey: string; + try { + pubkey = (await getIdentity()).pubkey; + } catch { + for (const community of inactiveCommunities) { + if (cancelled) return; + markError(community.id); + } + scheduleNextPoll(); + return; + } + + for (const community of inactiveCommunities) { + if (cancelled) return; + markLoading(community.id); + try { + const result = await pollCommunityUnread(community, pubkey); + if (cancelled) return; + markReady(community.id, result); + } catch (error) { + console.debug( + `[CommunityUnread] poll failed community=${community.id}:`, + error, + ); + if (cancelled) return; + markError(community.id); + } + } + + scheduleNextPoll(); + }; + + void pollInactiveCommunities(); + + return () => { + cancelled = true; + if (pollTimer !== null) { + window.clearTimeout(pollTimer); + } + }; + }, [activeCommunityId, communities]); + + const communitiesRef = React.useRef(communities); + communitiesRef.current = communities; + + const markRead = React.useCallback( + async (communityId: string) => { + const community = communitiesRef.current.find( + (candidate) => candidate.id === communityId, + ); + if (!community || communityId === activeCommunityId) return; + + const { pubkey } = await getIdentity(); + await markCommunityRead(community, pubkey); + // Optimistic clear — the next poll re-verifies against the relay. + setUnreadByCommunity((previous) => ({ + ...previous, + [communityId]: { hasUnread: false, state: "ready" }, + })); + }, + [activeCommunityId], + ); + + return { unreadByCommunity, markCommunityRead: markRead }; +} diff --git a/desktop/src/features/workspaces/useNestNotifications.ts b/desktop/src/features/communities/useNestNotifications.ts similarity index 96% rename from desktop/src/features/workspaces/useNestNotifications.ts rename to desktop/src/features/communities/useNestNotifications.ts index 665788a559c..d93bb89ad2b 100644 --- a/desktop/src/features/workspaces/useNestNotifications.ts +++ b/desktop/src/features/communities/useNestNotifications.ts @@ -17,7 +17,7 @@ const MIGRATION_TOAST_KEY = "buzz-legacy-nest-migrated-notified"; * localStorage); the backend re-emits each launch while `~/.sprout` exists, * which also covers the event being emitted before this listener mounts. * - * Mounted at the app root ahead of the workspace-init effect so the listener + * Mounted at the app root ahead of the community-init effect so the listener * is registered before the first `apply_workspace` call. */ export function useNestNotifications(): void { diff --git a/desktop/src/features/relay-members/hooks.ts b/desktop/src/features/community-members/hooks.ts similarity index 100% rename from desktop/src/features/relay-members/hooks.ts rename to desktop/src/features/community-members/hooks.ts diff --git a/desktop/src/features/relay-members/ui/AddMemberDialog.tsx b/desktop/src/features/community-members/ui/AddMemberDialog.tsx similarity index 99% rename from desktop/src/features/relay-members/ui/AddMemberDialog.tsx rename to desktop/src/features/community-members/ui/AddMemberDialog.tsx index 57833f69dfa..73e5c31b712 100644 --- a/desktop/src/features/relay-members/ui/AddMemberDialog.tsx +++ b/desktop/src/features/community-members/ui/AddMemberDialog.tsx @@ -4,7 +4,7 @@ import { toast } from "sonner"; import { useAddRelayMemberMutation, useRelayMembersQuery, -} from "@/features/relay-members/hooks"; +} from "@/features/community-members/hooks"; import type { RelayMemberRole } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; diff --git a/desktop/src/features/relay-members/ui/RelayMembersCard.tsx b/desktop/src/features/community-members/ui/CommunityMembersCard.tsx similarity index 97% rename from desktop/src/features/relay-members/ui/RelayMembersCard.tsx rename to desktop/src/features/community-members/ui/CommunityMembersCard.tsx index bb7a0d64041..fdc75f3d183 100644 --- a/desktop/src/features/relay-members/ui/RelayMembersCard.tsx +++ b/desktop/src/features/community-members/ui/CommunityMembersCard.tsx @@ -9,7 +9,7 @@ import { useChangeRelayMemberRoleMutation, useMyRelayMembershipQuery, useRelayMembersQuery, -} from "@/features/relay-members/hooks"; +} from "@/features/community-members/hooks"; import type { RelayMember, RelayMemberRole } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; @@ -164,7 +164,7 @@ function MemberRow({ const ROLE_ORDER: Record = { owner: 0, admin: 1, member: 2 }; -export function RelayMembersCard({ +export function CommunityMembersCard({ currentPubkey, }: { currentPubkey?: string; @@ -213,13 +213,13 @@ export function RelayMembersCard({ } return ( -
+

- Relay Members + Community Members

diff --git a/desktop/src/features/relay-members/ui/RelayMembersSettingsCard.tsx b/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx similarity index 95% rename from desktop/src/features/relay-members/ui/RelayMembersSettingsCard.tsx rename to desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx index 0af4321ff3f..00b53392992 100644 --- a/desktop/src/features/relay-members/ui/RelayMembersSettingsCard.tsx +++ b/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx @@ -20,7 +20,7 @@ import { useMyRelayMembershipQuery, useRelayMembersQuery, useRemoveRelayMemberMutation, -} from "@/features/relay-members/hooks"; +} from "@/features/community-members/hooks"; import type { RelayMember, RelayMemberRole } from "@/shared/api/types"; import type { UserProfileSummary } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; @@ -37,7 +37,7 @@ import { } from "@/shared/ui/dropdown-menu"; import { Input } from "@/shared/ui/input"; import { SettingsSectionHeader } from "@/features/settings/ui/SettingsSectionHeader"; -import { WorkspaceIconSettingsCard } from "@/features/workspaces/ui/WorkspaceIconSettingsCard"; +import { CommunityIconSettingsCard } from "@/features/communities/ui/CommunityIconSettingsCard"; import { VirtualizedList } from "@/shared/ui/VirtualizedList"; import { InviteLinkSection } from "./InviteLinkSection"; @@ -148,7 +148,7 @@ function RelayMemberRow({ toast.error( error instanceof Error ? error.message - : "Relay membership update failed", + : "Community membership update failed", ); } } @@ -241,11 +241,11 @@ function RelayMemberRow({ ) } size="icon" - title="Remove from relay" + title="Remove from community" variant="ghost" > - Remove from relay + Remove from community ) : null}

@@ -270,7 +270,7 @@ const ROLE_OPTIONS: { }, ]; -export function RelayMembersSettingsCard({ +export function CommunityMembersSettingsCard({ currentPubkey, }: { currentPubkey?: string; @@ -315,7 +315,7 @@ export function RelayMembersSettingsCard({ if (myMembershipQuery.isLoading) { return ( -
+

Checking relay permissions…

@@ -352,9 +352,9 @@ export function RelayMembersSettingsCard({ } return ( -
+
Manage who can connect to this relay. Owners can invite admins or @@ -364,7 +364,7 @@ export function RelayMembersSettingsCard({ />
- +
- {!workspaceLoading && othersEmoji.length > 0 ? ( -
+ {!communityLoading && othersEmoji.length > 0 ? ( +

- Workspace emoji ({othersEmoji.length}) + Community emoji ({othersEmoji.length})

Added by other members. You can use these, but only their owner diff --git a/desktop/src/features/custom-emoji/ui/EmojiPicker.tsx b/desktop/src/features/custom-emoji/ui/EmojiPicker.tsx index 520d5a61960..b3f1d0de656 100644 --- a/desktop/src/features/custom-emoji/ui/EmojiPicker.tsx +++ b/desktop/src/features/custom-emoji/ui/EmojiPicker.tsx @@ -87,7 +87,7 @@ function disableSearchInputCorrections( * drift across call sites (they used to, and that's why custom emoji were * missing from some pickers). * - * It always wires the workspace custom-emoji palette in via `useCustomEmoji()`, + * It always wires the community custom-emoji palette in via `useCustomEmoji()`, * so custom emoji show up everywhere for free. Selection is normalized to a * single string: a standard emoji emits its `native` glyph; a custom emoji has * no `native`, so it emits its `:shortcode:` (the emoji-mart `id` is the diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index 397c4984d55..41c66f1b3de 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -331,11 +331,11 @@ export function HomeView({ enabled: feedProfilePubkeys.length > 0, }); const feedProfiles = feedProfilesQuery.data?.profiles; - // Agent set for the inbox list/detail bot badges: the workspace-scoped + // Agent set for the inbox list/detail bot badges: the community-scoped // baseline widened with this surface's profile lookup. - const workspaceAgentPubkeys = useKnownAgentPubkeys(); + const communityAgentPubkeys = useKnownAgentPubkeys(); const inboxAgentPubkeys = React.useMemo(() => { - const pubkeys = new Set(workspaceAgentPubkeys); + const pubkeys = new Set(communityAgentPubkeys); for (const [pubkey, profile] of Object.entries(feedProfiles ?? {})) { if (profile.isAgent) { @@ -344,7 +344,7 @@ export function HomeView({ } return pubkeys; - }, [feedProfiles, workspaceAgentPubkeys]); + }, [feedProfiles, communityAgentPubkeys]); const inboxItems = React.useMemo( () => buildInboxItems({ diff --git a/desktop/src/features/home/ui/InboxMessageRow.tsx b/desktop/src/features/home/ui/InboxMessageRow.tsx index fa8d7e14ccb..a67ae281182 100644 --- a/desktop/src/features/home/ui/InboxMessageRow.tsx +++ b/desktop/src/features/home/ui/InboxMessageRow.tsx @@ -64,7 +64,7 @@ export function InboxMessageRow({ errorMessage: reactionErrorMessage, select: handleReactionSelect, } = useReactionHandler(timelineMessage, onToggleReaction); - // "Is this pubkey an agent" = the workspace-scoped baseline every surface + // "Is this pubkey an agent" = the community-scoped baseline every surface // shares plus this surface's extras passed via `agentPubkeys` (HomeView // folds feed-profile `isAgent` flags in). Mirrors MessageRow's predicate. const knownAgentPubkeys = useKnownAgentPubkeys(); diff --git a/desktop/src/features/home/useInboxSelectionAnchor.ts b/desktop/src/features/home/useInboxSelectionAnchor.ts index 0d741e01c9a..63a9faa4a51 100644 --- a/desktop/src/features/home/useInboxSelectionAnchor.ts +++ b/desktop/src/features/home/useInboxSelectionAnchor.ts @@ -18,7 +18,7 @@ type SelectionAnchorResult = { * 1. Direct match in feedItems for the current anchor ID. * 2. Committed same-anchor latch (survives anchor disappearing from feed). * 3. Cold-recovered synthetic FeedItem from getEventById (anchor absent from - * feed; validated against the active workspace channel set). + * feed; validated against the active community channel set). */ activeLatchedItem: FeedItem | null; /** @@ -59,7 +59,7 @@ type ColdAttemptStatus = * * Layer 3 — cold recovery: when the anchor is absent from feedItems (e.g. a * cold ?item= navigation or back/forward to a stale URL), fetches the event - * by ID, validates its `h` channel against the active workspace, and returns + * by ID, validates its `h` channel against the active community, and returns * a synthetic FeedItem for context seeding. The recovered event is never * injected into the live feed snapshot. */ @@ -284,7 +284,7 @@ export function useInboxSelectionAnchor({ } const hTag = event.tags.find((t) => t[0] === "h")?.[1] ?? null; if (!hTag || !latestAvailableChannelIdsRef.current.has(hTag)) { - // Event found but not in the current workspace — record membership + // Event found but not in the current community — record membership // snapshot so a channel-set change triggers exactly one retry. const failedKey = [...latestAvailableChannelIdsRef.current] .sort() @@ -346,7 +346,7 @@ export function useInboxSelectionAnchor({ // ── Active latch derivation ─────────────────────────────────────────────── // Synchronous direct match wins; then committed latch (survives eviction from // feed); then cold recovery (absent-from-feed path). Non-direct cached layers - // are gated by workspace membership so a shrinking availableChannelIds does + // are gated by community membership so a shrinking availableChannelIds does // not serve a stale context seed. const directSelectedFeedItem = React.useMemo( () => diff --git a/desktop/src/features/identity-archive/hooks.ts b/desktop/src/features/identity-archive/hooks.ts index 5f93c9f5223..8149eff7b74 100644 --- a/desktop/src/features/identity-archive/hooks.ts +++ b/desktop/src/features/identity-archive/hooks.ts @@ -2,7 +2,7 @@ import * as React from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; -import { useMyRelayMembershipQuery } from "@/features/relay-members/hooks"; +import { useMyRelayMembershipQuery } from "@/features/community-members/hooks"; import { useIdentityQuery } from "@/shared/api/hooks"; import { archiveIdentity, diff --git a/desktop/src/features/local-archive/agentMetricArchivePreference.ts b/desktop/src/features/local-archive/agentMetricArchivePreference.ts index 092d53ef01e..fc8ec8181ef 100644 --- a/desktop/src/features/local-archive/agentMetricArchivePreference.ts +++ b/desktop/src/features/local-archive/agentMetricArchivePreference.ts @@ -8,7 +8,7 @@ * "0" → user explicitly disabled * null → no explicit choice yet (default-on seeding may still fire) * - * Device-level localStorage — intentionally not reset on workspace switch + * Device-level localStorage — intentionally not reset on community switch * (the archive subscription itself is identity-scoped in SQLite; this flag * is just the UI gate that prevents re-seeding after an explicit opt-out). */ diff --git a/desktop/src/features/local-archive/archiveSyncManager.ts b/desktop/src/features/local-archive/archiveSyncManager.ts index aadfcb0c7b5..1361f548b5c 100644 --- a/desktop/src/features/local-archive/archiveSyncManager.ts +++ b/desktop/src/features/local-archive/archiveSyncManager.ts @@ -72,7 +72,7 @@ function scopeKey(scopeType: ScopeType, scopeValue: string): string { * config and forwards matched events to `archive_events` in debounced batches. * * Lifecycle: created once at app-shell mount (see `useArchiveSync`), destroyed - * on workspace switch. Resubscribes automatically when subscriptions change + * on community switch. Resubscribes automatically when subscriptions change * via the module-level notifier in `tauriArchive.ts`. * * Accepts optional `deps` for testing — production callers pass nothing. @@ -304,7 +304,7 @@ import * as React from "react"; /** * Starts the ArchiveSyncManager at app-shell mount and tears it down when the - * component unmounts (workspace switch). No return value needed — the manager + * component unmounts (community switch). No return value needed — the manager * runs entirely in the background. */ export function useArchiveSync(): void { diff --git a/desktop/src/features/local-archive/observerArchivePreference.ts b/desktop/src/features/local-archive/observerArchivePreference.ts index 8377f01c4c6..ddb9f41410f 100644 --- a/desktop/src/features/local-archive/observerArchivePreference.ts +++ b/desktop/src/features/local-archive/observerArchivePreference.ts @@ -8,7 +8,7 @@ * "0" → user explicitly disabled * null → no explicit choice yet (default-on seeding may still fire) * - * Device-level localStorage — intentionally not reset on workspace switch + * Device-level localStorage — intentionally not reset on community switch * (the archive subscription itself is identity-scoped in SQLite; this flag * is just the UI gate that prevents re-seeding after an explicit opt-out). */ diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index 2c2ef7e0518..41a1c915c28 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -636,7 +636,7 @@ export function useToggleReactionMutation() { } // Custom-emoji reaction: emoji is `:shortcode:`. Resolve its image URL - // from the cached workspace palette so the kind:7 carries the NIP-30 + // from the cached community palette so the kind:7 carries the NIP-30 // `["emoji", shortcode, url]` tag. Unicode reactions resolve to no URL. const emojiUrl = reactionEmojiUrl( emoji, diff --git a/desktop/src/features/messages/lib/customEmojiNode.ts b/desktop/src/features/messages/lib/customEmojiNode.ts index 3696aab0c81..e4ccc3a14df 100644 --- a/desktop/src/features/messages/lib/customEmojiNode.ts +++ b/desktop/src/features/messages/lib/customEmojiNode.ts @@ -170,7 +170,7 @@ export const CustomEmojiNode = Node.create({ src: { default: "", parseHTML: (el) => (el as HTMLElement).getAttribute("src") ?? "", - // `src` is derived from the workspace palette at render time, not + // `src` is derived from the community palette at render time, not // persisted in serialized output — see renderText/markdown below. renderHTML: () => ({}), }, diff --git a/desktop/src/features/messages/lib/messageSnapshot.ts b/desktop/src/features/messages/lib/messageSnapshot.ts index 4b83742e14c..a62d877b047 100644 --- a/desktop/src/features/messages/lib/messageSnapshot.ts +++ b/desktop/src/features/messages/lib/messageSnapshot.ts @@ -2,7 +2,7 @@ * Per-channel persisted message snapshots. * * A channel revisited after its React-Query cache entry is gone (app restart, - * gcTime expiry, workspace remount) goes fully cold and holds a skeleton for a + * gcTime expiry, community remount) goes fully cold and holds a skeleton for a * relay round trip. This module persists the newest slice of each channel's * timeline so a revisit can paint instantly from the snapshot while the * history fetch revalidates behind it — the same stale-then-revalidate pattern @@ -187,7 +187,7 @@ export function mergeHistoryOverSnapshot(input: { } /** - * Removes every channel message snapshot for a relay. Called when a workspace + * Removes every channel message snapshot for a relay. Called when a community * is removed. */ export function removeMessageSnapshotsForRelay(relayUrl: string): void { diff --git a/desktop/src/features/messages/lib/useDrafts.ts b/desktop/src/features/messages/lib/useDrafts.ts index 5ff6b3b959d..78c9e69d860 100644 --- a/desktop/src/features/messages/lib/useDrafts.ts +++ b/desktop/src/features/messages/lib/useDrafts.ts @@ -83,7 +83,7 @@ function storageKey(): string { /** * Initialise (or re-initialise) the draft store for a given identity. - * Called from `useWorkspaceInit` alongside the other singleton resets. + * Called from `useCommunityInit` alongside the other singleton resets. * Resets the in-memory cache whenever the pubkey changes so a direct * identity switch (without a prior `clearAllDrafts`) never serves the * wrong identity's drafts. @@ -99,7 +99,7 @@ export function initDraftStore(pubkey: string): void { } /** - * Reset the in-memory draft store on workspace switch. + * Reset the in-memory draft store on community switch. * Replaces the old `clearAllDrafts()`. */ export function clearAllDrafts(): void { diff --git a/desktop/src/features/messages/lib/useEmojiAutocomplete.ts b/desktop/src/features/messages/lib/useEmojiAutocomplete.ts index 2485b8b635e..2aedd69db4e 100644 --- a/desktop/src/features/messages/lib/useEmojiAutocomplete.ts +++ b/desktop/src/features/messages/lib/useEmojiAutocomplete.ts @@ -104,7 +104,7 @@ export function useEmojiAutocomplete(customEmoji: CustomEmoji[] = []) { native: emoji.skins[0]?.native ?? "", })) .filter((e) => e.native !== ""); - // Custom emoji first (workspace-specific), then standard, capped. + // Custom emoji first (community-specific), then standard, capped. const merged = [...customMatches, ...standard].slice(0, MAX_RESULTS); setSuggestions(merged); setEmojiSelectedIndex(0); diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 90499da2a42..a8cc91c88e1 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -166,7 +166,7 @@ export const MessageRow = React.memo( () => resolveMentionProps(message.tags, profiles), [profiles, message.tags], ); - // "Is this pubkey an agent" = the workspace-scoped baseline every surface + // "Is this pubkey an agent" = the community-scoped baseline every surface // shares (managed ∪ relay) plus the pubkey's own profile `isAgent` flag from this surface's lookup. Both are per-pubkey // O(1) checks — no per-row rescan of `profiles` (that duplicated parent // work in every mounted row and re-ran on each profile-lookup change). diff --git a/desktop/src/features/messages/ui/configNudgeAuthPubkey.test.mjs b/desktop/src/features/messages/ui/configNudgeAuthPubkey.test.mjs index 3c6d8f6b8be..39dccc69d37 100644 --- a/desktop/src/features/messages/ui/configNudgeAuthPubkey.test.mjs +++ b/desktop/src/features/messages/ui/configNudgeAuthPubkey.test.mjs @@ -27,7 +27,7 @@ const HUMAN_SIGNER = const AGENT_PUBKEY = "2222222222222222222222222222222222222222222222222222222222222222"; -// MessageRow passes a predicate combining the workspace known-agent set with +// MessageRow passes a predicate combining the community known-agent set with // per-pubkey profile `isAgent` checks; the set-membership form is the minimal // equivalent for exercising the signer-selection seam. const AGENT_PUBKEYS = new Set([AGENT_PUBKEY]); diff --git a/desktop/src/features/messages/ui/configNudgeAuthPubkey.ts b/desktop/src/features/messages/ui/configNudgeAuthPubkey.ts index 433ff0ffa71..54de42d7e41 100644 --- a/desktop/src/features/messages/ui/configNudgeAuthPubkey.ts +++ b/desktop/src/features/messages/ui/configNudgeAuthPubkey.ts @@ -11,7 +11,7 @@ import type { TimelineMessage } from "@/features/messages/types"; * 2. `message.signerPubkey` is set and passes `isKnownAgentPubkey` — * authenticates against the raw event signer (NOT `message.pubkey`, * which is the tag-attributed display author and can be spoofed via - * `actor`/`p` tags). The caller's predicate combines the workspace-wide + * `actor`/`p` tags). The caller's predicate combines the community-wide * known-agent baseline (`useKnownAgentPubkeys`) with any surface-local * signals such as the signer profile's `isAgent` flag. * diff --git a/desktop/src/features/messages/ui/useQuickReactionEmojis.ts b/desktop/src/features/messages/ui/useQuickReactionEmojis.ts index fbafa2405de..457c659141d 100644 --- a/desktop/src/features/messages/ui/useQuickReactionEmojis.ts +++ b/desktop/src/features/messages/ui/useQuickReactionEmojis.ts @@ -1,9 +1,9 @@ import * as React from "react"; import { - loadActiveWorkspaceId, - loadWorkspaces, -} from "@/features/workspaces/workspaceStorage"; + loadActiveCommunityId, + loadCommunities, +} from "@/features/communities/communityStorage"; import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji"; const QUICK_REACTION_STORAGE_KEY = "buzz.quick-reaction-emojis.v1"; @@ -27,28 +27,28 @@ function canUseLocalStorage() { } } -function getActiveWorkspaceScope() { +function getActiveCommunityScope() { if (!canUseLocalStorage()) return null; try { - return loadActiveWorkspaceId() ?? loadWorkspaces()[0]?.id ?? null; + return loadActiveCommunityId() ?? loadCommunities()[0]?.id ?? null; } catch { return null; } } -function quickReactionStorageKey(workspaceScope: string | null) { - return workspaceScope - ? `${QUICK_REACTION_STORAGE_KEY}:${workspaceScope}` +function quickReactionStorageKey(communityScope: string | null) { + return communityScope + ? `${QUICK_REACTION_STORAGE_KEY}:${communityScope}` : QUICK_REACTION_STORAGE_KEY; } function quickReactionSessionKey( limit: number, - workspaceScope: string | null, + communityScope: string | null, customEmojiSignature: string, ) { - return `${workspaceScope ?? "global"}:${customEmojiSignature}:${limit}`; + return `${communityScope ?? "global"}:${customEmojiSignature}:${limit}`; } function normalizeEntry(entry: unknown): QuickReactionEntry | null { @@ -175,11 +175,11 @@ export function resolveQuickReactionEmojis( function getQuickReactionEmojis( limit: number, - workspaceScope: string | null, + communityScope: string | null, customEmojiSignature: string, ) { return resolveQuickReactionEmojisWithShortcodes( - readQuickReactionEntries(quickReactionStorageKey(workspaceScope)), + readQuickReactionEntries(quickReactionStorageKey(communityScope)), limit, customEmojiShortcodesFromSignature(customEmojiSignature), ); @@ -187,12 +187,12 @@ function getQuickReactionEmojis( function getSessionQuickReactionEmojis( limit: number, - workspaceScope: string | null, + communityScope: string | null, customEmojiSignature: string, ) { const sessionKey = quickReactionSessionKey( limit, - workspaceScope, + communityScope, customEmojiSignature, ); const cached = sessionQuickReactionEmojis.get(sessionKey); @@ -200,7 +200,7 @@ function getSessionQuickReactionEmojis( const emojis = getQuickReactionEmojis( limit, - workspaceScope, + communityScope, customEmojiSignature, ); sessionQuickReactionEmojis.set(sessionKey, emojis); @@ -211,8 +211,8 @@ export function recordQuickReactionEmoji(emoji: string) { const trimmed = emoji.trim(); if (!trimmed) return; - const workspaceScope = getActiveWorkspaceScope(); - const storageKey = quickReactionStorageKey(workspaceScope); + const communityScope = getActiveCommunityScope(); + const storageKey = quickReactionStorageKey(communityScope); const entries = readQuickReactionEntries(storageKey); const existing = entries.find((entry) => entry.emoji === trimmed); if (existing) { @@ -227,7 +227,7 @@ export function recordQuickReactionEmoji(emoji: string) { } // Keep the current hover tray stable; the stored recents apply on reload or - // when another tab updates this workspace's quick reactions. + // when another tab updates this community's quick reactions. writeQuickReactionEntries(entries, storageKey); } @@ -235,19 +235,19 @@ export function useQuickReactionEmojis( limit = 4, customEmoji: ReadonlyArray = [], ) { - const workspaceScope = getActiveWorkspaceScope(); + const communityScope = getActiveCommunityScope(); const customEmojiCacheKey = customEmojiSignature(customEmoji); const [emojis, setEmojis] = React.useState(() => - getSessionQuickReactionEmojis(limit, workspaceScope, customEmojiCacheKey), + getSessionQuickReactionEmojis(limit, communityScope, customEmojiCacheKey), ); React.useEffect(() => { if (typeof window === "undefined") return; - const storageKey = quickReactionStorageKey(workspaceScope); + const storageKey = quickReactionStorageKey(communityScope); const sessionKey = quickReactionSessionKey( limit, - workspaceScope, + communityScope, customEmojiCacheKey, ); const handleStorage = (event: StorageEvent) => { @@ -256,7 +256,7 @@ export function useQuickReactionEmojis( setEmojis( getSessionQuickReactionEmojis( limit, - workspaceScope, + communityScope, customEmojiCacheKey, ), ); @@ -265,13 +265,13 @@ export function useQuickReactionEmojis( window.addEventListener("storage", handleStorage); setEmojis( - getSessionQuickReactionEmojis(limit, workspaceScope, customEmojiCacheKey), + getSessionQuickReactionEmojis(limit, communityScope, customEmojiCacheKey), ); return () => { window.removeEventListener("storage", handleStorage); }; - }, [customEmojiCacheKey, limit, workspaceScope]); + }, [customEmojiCacheKey, limit, communityScope]); return emojis; } diff --git a/desktop/src/features/moderation/hooks.ts b/desktop/src/features/moderation/hooks.ts index d5c8ed24efc..10fcff9ebd9 100644 --- a/desktop/src/features/moderation/hooks.ts +++ b/desktop/src/features/moderation/hooks.ts @@ -28,7 +28,7 @@ export const relaySelfQueryKey = ["relaySelf"] as const; /** * The active relay's NIP-11 `self` pubkey (hex), or `null` when it advertises - * none / is unreachable. Used to recognize a moderation DM. Workspace-scoped + * none / is unreachable. Used to recognize a moderation DM. Community-scoped * and effectively static for a session, so it is cached indefinitely; a `null` * result is a valid answer (fail open), not an error to retry into. */ diff --git a/desktop/src/features/moderation/ui/MessageModerationMenuItems.tsx b/desktop/src/features/moderation/ui/MessageModerationMenuItems.tsx index 6a60b8f8e7f..697eba8dafa 100644 --- a/desktop/src/features/moderation/ui/MessageModerationMenuItems.tsx +++ b/desktop/src/features/moderation/ui/MessageModerationMenuItems.tsx @@ -10,7 +10,7 @@ import { useUnbanMemberMutation, useUntimeoutMemberMutation, } from "@/features/moderation/hooks"; -import { useMyRelayMembershipQuery } from "@/features/relay-members/hooks"; +import { useMyRelayMembershipQuery } from "@/features/community-members/hooks"; import type { TimelineMessage } from "@/features/messages/types"; import { isTimedOut } from "@/features/moderation/lib/restrictionState"; import { useIdentityQuery } from "@/shared/api/hooks"; diff --git a/desktop/src/features/onboarding/hooks.ts b/desktop/src/features/onboarding/hooks.ts index bc0c441ac0e..34c4c231790 100644 --- a/desktop/src/features/onboarding/hooks.ts +++ b/desktop/src/features/onboarding/hooks.ts @@ -18,7 +18,7 @@ import { getWelcomeGuideAgentPubkeys, } from "@/features/onboarding/welcomeGuide"; import { useProfileQuery } from "@/features/profile/hooks"; -import { useWorkspaces } from "@/features/workspaces/useWorkspaces"; +import { useCommunities } from "@/features/communities/useCommunities"; import { useIdentityQuery } from "@/shared/api/hooks"; import { createChannel, @@ -55,16 +55,16 @@ async function initializeWelcomeChannel( { focus, pubkey, - workspaceScope, + communityScope, }: { focus: boolean; pubkey: string | null; - workspaceScope: string | null; + communityScope: string | null; }, ) { try { const allowedMemberPubkeys = await getWelcomeGuideAgentPubkeys( - workspaceScope, + communityScope, ).catch(() => []); const welcomeChannel = await ensureWelcomeChannel( { @@ -79,7 +79,7 @@ async function initializeWelcomeChannel( ); let didInitializeWelcomeGuide = false; try { - await ensureWelcomeGuideIntro(welcomeChannel.id, workspaceScope); + await ensureWelcomeGuideIntro(welcomeChannel.id, communityScope); didInitializeWelcomeGuide = true; await Promise.all([ queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }), @@ -89,7 +89,7 @@ async function initializeWelcomeChannel( console.warn("Failed to initialize Welcome guide.", error); } if (didInitializeWelcomeGuide) { - markWelcomeChannelEnsured(pubkey, workspaceScope); + markWelcomeChannelEnsured(pubkey, communityScope); } if (focus) { rememberPendingWelcomeChannel(welcomeChannel.id); @@ -398,11 +398,11 @@ export function useFirstRunOnboardingGate({ export function useAppOnboardingState(isSharedIdentity: boolean) { const queryClient = useQueryClient(); - const { activeWorkspace } = useWorkspaces(); + const { activeCommunity } = useCommunities(); const identityQuery = useIdentityQuery(); const identity = identityQuery.data; const currentPubkey = identity?.pubkey ?? null; - const welcomeChannelWorkspaceScope = activeWorkspace?.relayUrl ?? null; + const welcomeChannelCommunityScope = activeCommunity?.relayUrl ?? null; const welcomeChannelInitPromisesRef = React.useRef( new Map>(), ); @@ -438,11 +438,11 @@ export function useAppOnboardingState(isSharedIdentity: boolean) { const gateComplete = onboardingGate.complete; const requestWelcomeChannel = React.useCallback( (focus: boolean) => { - if (!currentPubkey || !welcomeChannelWorkspaceScope) { + if (!currentPubkey || !welcomeChannelCommunityScope) { return Promise.resolve(); } - const welcomeChannelInitKey = `${welcomeChannelWorkspaceScope}:${currentPubkey}`; + const welcomeChannelInitKey = `${welcomeChannelCommunityScope}:${currentPubkey}`; const currentPromise = welcomeChannelInitPromisesRef.current.get( welcomeChannelInitKey, ); @@ -453,7 +453,7 @@ export function useAppOnboardingState(isSharedIdentity: boolean) { const promise = initializeWelcomeChannel(queryClient, { focus, pubkey: currentPubkey, - workspaceScope: welcomeChannelWorkspaceScope, + communityScope: welcomeChannelCommunityScope, }); welcomeChannelInitPromisesRef.current.set(welcomeChannelInitKey, promise); void promise.finally(() => { @@ -461,16 +461,16 @@ export function useAppOnboardingState(isSharedIdentity: boolean) { }); return promise; }, - [currentPubkey, queryClient, welcomeChannelWorkspaceScope], + [currentPubkey, queryClient, welcomeChannelCommunityScope], ); React.useEffect(() => { if ( onboardingGate.stage !== "ready" || !currentPubkey || - !welcomeChannelWorkspaceScope || + !welcomeChannelCommunityScope || !readOnboardingCompletion(currentPubkey) || - hasEnsuredWelcomeChannel(currentPubkey, welcomeChannelWorkspaceScope) + hasEnsuredWelcomeChannel(currentPubkey, welcomeChannelCommunityScope) ) { return; } @@ -480,7 +480,7 @@ export function useAppOnboardingState(isSharedIdentity: boolean) { currentPubkey, onboardingGate.stage, requestWelcomeChannel, - welcomeChannelWorkspaceScope, + welcomeChannelCommunityScope, ]); const completeAndShowWelcome = React.useCallback(() => { diff --git a/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx b/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx index b7c230875b0..afb99a5ff5a 100644 --- a/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx +++ b/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx @@ -20,8 +20,8 @@ type NostrKeyImportFormProps = { /** * Paste-or-drop nsec import form with a live npub preview. * - * Shared between the first-run welcome flow (no workspace yet) and the - * onboarding profile flow (workspace exists, user wants to reuse an + * Shared between the first-run welcome flow (no community yet) and the + * onboarding profile flow (community exists, user wants to reuse an * existing key). The caller owns what happens after `onImport` resolves. */ export function NostrKeyImportForm({ diff --git a/desktop/src/features/onboarding/ui/OnboardingFlow.tsx b/desktop/src/features/onboarding/ui/OnboardingFlow.tsx index 1fa7fb51570..a827c3a81fb 100644 --- a/desktop/src/features/onboarding/ui/OnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/OnboardingFlow.tsx @@ -77,10 +77,10 @@ async function checkMembershipDenied(): Promise { type OnboardingFlowProps = { actions: OnboardingActions; - canBackToWorkspaceSetup: boolean; + canBackToCommunitySetup: boolean; identityLost?: boolean; initialProfile: OnboardingProfileSeed; - onBackToWorkspaceSetup: () => void; + onBackToCommunitySetup: () => void; }; function isFallbackDisplayName(value?: string | null) { @@ -147,10 +147,10 @@ function resolveProfileSaveRecovery( export function OnboardingFlow({ actions, - canBackToWorkspaceSetup, + canBackToCommunitySetup, identityLost = false, initialProfile, - onBackToWorkspaceSetup, + onBackToCommunitySetup, }: OnboardingFlowProps) { const { complete, skipForNow } = actions; const queryClient = useQueryClient(); @@ -394,7 +394,7 @@ export function OnboardingFlow({ } : profileStepState.saveRecovery, }; - // Page sequences by path — step 1 is the workspace-picker that precedes + // Page sequences by path — step 1 is the community-picker that precedes // onboarding, so these pages start at step 2 (STEP_OFFSET below). // Fresh-key path: profile(2) → backup(3) → avatar(4) → theme(5) → setup(6) // Imported-key path: profile(2) → avatar(3) → theme(4) → setup(5) @@ -418,7 +418,7 @@ export function OnboardingFlow({ currentPage === "key-import" ? "profile" : currentPage; const pageIndex = activeSteps.indexOf(normalizedPage); const currentStep = pageIndex >= 0 ? pageIndex + STEP_OFFSET : STEP_OFFSET; - const totalOnboardingSteps = activeSteps.length + 1; // +1 for step 1 (workspace picker) + const totalOnboardingSteps = activeSteps.length + 1; // +1 for step 1 (community picker) const hideFixedProgressOnCompact = currentPage === "avatar" || currentPage === "theme"; @@ -468,14 +468,14 @@ export function OnboardingFlow({ return ( { setTransitionDirection("backward"); - onBackToWorkspaceSetup(); + onBackToCommunitySetup(); } : undefined } - onImportKey={canBackToWorkspaceSetup ? undefined : importExistingKey} + onImportKey={canBackToCommunitySetup ? undefined : importExistingKey} onRetry={() => { void saveProfileAndContinue(membershipRetryPage); }} @@ -532,10 +532,10 @@ export function OnboardingFlow({ { setTransitionDirection("backward"); - onBackToWorkspaceSetup(); + onBackToCommunitySetup(); } : undefined, clearAvatarDraft: resetAvatarDraft, diff --git a/desktop/src/features/onboarding/ui/SetupStep.tsx b/desktop/src/features/onboarding/ui/SetupStep.tsx index 603643a6bff..bd59f01243a 100644 --- a/desktop/src/features/onboarding/ui/SetupStep.tsx +++ b/desktop/src/features/onboarding/ui/SetupStep.tsx @@ -363,7 +363,7 @@ function RuntimeDetails({ runtime }: { runtime: AcpRuntimeCatalogEntry }) { This updates the machine-global{" "} codex-acp{" "} adapter. Older Buzz releases using the legacy adapter contract may - lose relay access until{" "} + lose community access until{" "} @zed-industries/codex-acp@0.16.0 {" "} diff --git a/desktop/src/features/onboarding/welcome.test.mjs b/desktop/src/features/onboarding/welcome.test.mjs index 6449d05eaa8..c3e46615bfb 100644 --- a/desktop/src/features/onboarding/welcome.test.mjs +++ b/desktop/src/features/onboarding/welcome.test.mjs @@ -97,7 +97,7 @@ test("ensureWelcomeChannel creates a private Welcome channel when one is missing test("ensureWelcomeChannel clears ttl on an existing ephemeral Welcome channel", async () => { const existingChannel = makeChannel({ description: - "A private ephemeral channel for getting oriented in this workspace.", + "A private ephemeral channel for getting oriented in this community.", id: "existing-welcome", ttlDeadline: "2026-06-11T00:00:00.000Z", ttlSeconds: 86400, @@ -227,26 +227,26 @@ test("pending Welcome channel is consumed only after it appears in the channel l } }); -test("Welcome ensured marker is scoped to the current identity and workspace", () => { +test("Welcome ensured marker is scoped to the current identity and community", () => { const { restore } = installWindowSessionStorage(); try { - markWelcomeChannelEnsured("pubkey-a", "wss://workspace-a.example"); + markWelcomeChannelEnsured("pubkey-a", "wss://community-a.example"); assert.equal( - hasEnsuredWelcomeChannel("pubkey-a", "wss://workspace-a.example"), + hasEnsuredWelcomeChannel("pubkey-a", "wss://community-a.example"), true, ); assert.equal( - hasEnsuredWelcomeChannel("pubkey-a", "wss://workspace-b.example"), + hasEnsuredWelcomeChannel("pubkey-a", "wss://community-b.example"), false, ); assert.equal( - hasEnsuredWelcomeChannel("pubkey-b", "wss://workspace-a.example"), + hasEnsuredWelcomeChannel("pubkey-b", "wss://community-a.example"), false, ); assert.equal(hasEnsuredWelcomeChannel("pubkey-a", null), false); assert.equal( - hasEnsuredWelcomeChannel(null, "wss://workspace-a.example"), + hasEnsuredWelcomeChannel(null, "wss://community-a.example"), false, ); } finally { diff --git a/desktop/src/features/onboarding/welcome.ts b/desktop/src/features/onboarding/welcome.ts index 2a8fbfe7307..48d22a1454e 100644 --- a/desktop/src/features/onboarding/welcome.ts +++ b/desktop/src/features/onboarding/welcome.ts @@ -7,7 +7,7 @@ import type { export const WELCOME_CHANNEL_NAME = "Welcome"; export const WELCOME_CHANNEL_DESCRIPTION = - "A private channel for getting oriented in this workspace."; + "A private channel for getting oriented in this community."; export const WELCOME_CHANNEL_READY_EVENT = "buzz:onboarding-welcome-channel-ready"; @@ -162,25 +162,25 @@ export async function ensureWelcomeChannel( export function welcomeChannelEnsuredStorageKey( pubkey: string, - workspaceScope: string, + communityScope: string, ) { return `${WELCOME_CHANNEL_ENSURED_STORAGE_KEY}:${encodeURIComponent( - workspaceScope, + communityScope, )}:${pubkey}`; } export function hasEnsuredWelcomeChannel( pubkey: string | null | undefined, - workspaceScope: string | null | undefined, + communityScope: string | null | undefined, ) { - if (typeof window === "undefined" || !pubkey || !workspaceScope) { + if (typeof window === "undefined" || !pubkey || !communityScope) { return false; } try { return ( window.localStorage.getItem( - welcomeChannelEnsuredStorageKey(pubkey, workspaceScope), + welcomeChannelEnsuredStorageKey(pubkey, communityScope), ) === "true" ); } catch { @@ -190,15 +190,15 @@ export function hasEnsuredWelcomeChannel( export function markWelcomeChannelEnsured( pubkey: string | null | undefined, - workspaceScope: string | null | undefined, + communityScope: string | null | undefined, ) { - if (typeof window === "undefined" || !pubkey || !workspaceScope) { + if (typeof window === "undefined" || !pubkey || !communityScope) { return; } try { window.localStorage.setItem( - welcomeChannelEnsuredStorageKey(pubkey, workspaceScope), + welcomeChannelEnsuredStorageKey(pubkey, communityScope), "true", ); } catch { diff --git a/desktop/src/features/onboarding/welcomeGuide.test.mjs b/desktop/src/features/onboarding/welcomeGuide.test.mjs index d0849432bd3..49a717d447d 100644 --- a/desktop/src/features/onboarding/welcomeGuide.test.mjs +++ b/desktop/src/features/onboarding/welcomeGuide.test.mjs @@ -93,14 +93,14 @@ test("pickWelcomeGuideAgent ignores non-Kit agents with the legacy prompt", () = assert.equal(pickWelcomeGuideAgent([nonKit, fizz]), fizz); }); -test("pickWelcomeGuideAgentForRelay ignores Fizz agents from other workspaces", () => { - const otherWorkspaceFizz = makeAgent({ +test("pickWelcomeGuideAgentForRelay ignores Fizz agents from other communities", () => { + const otherCommunityFizz = makeAgent({ pubkey: PUB_A, personaId: WELCOME_GUIDE_PERSONA_ID, relayUrl: RELAY_A, status: "running", }); - const currentWorkspaceFizz = makeAgent({ + const currentCommunityFizz = makeAgent({ pubkey: PUB_B, personaId: WELCOME_GUIDE_PERSONA_ID, relayUrl: RELAY_B, @@ -109,22 +109,22 @@ test("pickWelcomeGuideAgentForRelay ignores Fizz agents from other workspaces", assert.equal( pickWelcomeGuideAgentForRelay( - [otherWorkspaceFizz, currentWorkspaceFizz], + [otherCommunityFizz, currentCommunityFizz], RELAY_B, ), - currentWorkspaceFizz, + currentCommunityFizz, ); }); -test("pickWelcomeGuideAgentForRelay returns null when Fizz only exists in another workspace", () => { - const otherWorkspaceFizz = makeAgent({ +test("pickWelcomeGuideAgentForRelay returns null when Fizz only exists in another community", () => { + const otherCommunityFizz = makeAgent({ pubkey: PUB_A, personaId: WELCOME_GUIDE_PERSONA_ID, relayUrl: RELAY_A, }); assert.equal( - pickWelcomeGuideAgentForRelay([otherWorkspaceFizz], RELAY_B), + pickWelcomeGuideAgentForRelay([otherCommunityFizz], RELAY_B), null, ); }); diff --git a/desktop/src/features/onboarding/welcomeGuide.ts b/desktop/src/features/onboarding/welcomeGuide.ts index 6cc2394d26e..051cfb8c7a7 100644 --- a/desktop/src/features/onboarding/welcomeGuide.ts +++ b/desktop/src/features/onboarding/welcomeGuide.ts @@ -14,7 +14,7 @@ export const WELCOME_GUIDE_PERSONA_ID = "builtin:fizz"; export const WELCOME_GUIDE_INTRO_MARKER = "buzz-welcome-intro.v1"; const LEGACY_WELCOME_GUIDE_AGENT_NAME = "Kit"; export const LEGACY_WELCOME_GUIDE_SYSTEM_PROMPT = - "You are Kit, Sprout's friendly welcome guide. Help new users understand the workspace, channels, messages, and agents. Keep introductions concise, practical, and warm."; + "You are Kit, Sprout's friendly welcome guide. Help new users understand the community, channels, messages, and agents. Keep introductions concise, practical, and warm."; export const WELCOME_GUIDE_INTRO_MESSAGE = "Hi, I'm Fizz. Welcome to Buzz.\n\nI can help you get oriented, answer questions, and make the first few steps feel less mysterious.\n\nFeel free to ask me what else you can do in Buzz, or just talk through what you want to build."; diff --git a/desktop/src/features/profile/hooks.ts b/desktop/src/features/profile/hooks.ts index b9f35542df2..85d123692b7 100644 --- a/desktop/src/features/profile/hooks.ts +++ b/desktop/src/features/profile/hooks.ts @@ -41,7 +41,7 @@ import { shouldFetchAvatar, resolveAvatarDataUrl, } from "@/features/profile/lib/selfProfileStorage"; -import { useWorkspaces } from "@/features/workspaces/useWorkspaces"; +import { useCommunities } from "@/features/communities/useCommunities"; export const profileQueryKey = ["profile"] as const; export const contactListQueryKey = (pubkey: string) => @@ -83,10 +83,10 @@ async function persistSelfProfile( } export function useProfileQuery(enabled = true) { - const { activeWorkspace } = useWorkspaces(); + const { activeCommunity } = useCommunities(); const identityQuery = useIdentityQuery(); const queryClient = useQueryClient(); - const relayUrl = activeWorkspace?.relayUrl ?? ""; + const relayUrl = activeCommunity?.relayUrl ?? ""; const pubkey = identityQuery.data?.pubkey ?? ""; // Parse localStorage once per relayUrl/pubkey pair — not on every render. @@ -119,7 +119,7 @@ export function useProfileQuery(enabled = true) { ); // `initialData` is only honored at query construction, which happens before - // identity/workspace resolve on a fresh QueryClient — seed the cache + // identity/community resolve on a fresh QueryClient — seed the cache // imperatively once they arrive, without ever stomping a real fetch result. React.useEffect(() => { if (!initialData || !cached) return; @@ -157,9 +157,9 @@ export function useProfileQuery(enabled = true) { * SELF_PROFILE_CACHE_EVENT after writes so this hook re-reads without polling. */ export function useSelfProfileCache(): SelfProfileCache | null { - const { activeWorkspace } = useWorkspaces(); + const { activeCommunity } = useCommunities(); const identityQuery = useIdentityQuery(); - const relayUrl = activeWorkspace?.relayUrl ?? ""; + const relayUrl = activeCommunity?.relayUrl ?? ""; const pubkey = identityQuery.data?.pubkey ?? ""; const [cache, setCache] = React.useState(() => @@ -485,7 +485,7 @@ export function useUserSearchFetchMoreOnScroll( export function useUpdateProfileMutation() { const queryClient = useQueryClient(); - const { activeWorkspace } = useWorkspaces(); + const { activeCommunity } = useCommunities(); const identityQuery = useIdentityQuery(); return useMutation({ @@ -504,7 +504,7 @@ export function useUpdateProfileMutation() { // Cancel again: a refetch may have started while mutationFn awaited. await queryClient.cancelQueries({ queryKey: profileQueryKey }); queryClient.setQueryData(profileQueryKey, profile); - const relayUrl = activeWorkspace?.relayUrl ?? ""; + const relayUrl = activeCommunity?.relayUrl ?? ""; const pubkey = identityQuery.data?.pubkey ?? profile.pubkey; if (relayUrl && pubkey) { void persistSelfProfile(relayUrl, pubkey, profile); diff --git a/desktop/src/features/profile/lib/selfProfileStorage.ts b/desktop/src/features/profile/lib/selfProfileStorage.ts index 5eeec69b187..a0b222ef2fa 100644 --- a/desktop/src/features/profile/lib/selfProfileStorage.ts +++ b/desktop/src/features/profile/lib/selfProfileStorage.ts @@ -8,7 +8,7 @@ * * Profiles are keyed per relay + pubkey rather than pubkey alone because the * same key can have a different kind-0 on different relays. Scoping by relay - * prevents one workspace's cached identity from bleeding into another. + * prevents one community's cached identity from bleeding into another. */ const STORAGE_KEY_PREFIX = "buzz-self-profile.v1"; @@ -160,7 +160,7 @@ export function writeSelfProfileCache( /** * Removes all self-profile cache entries for every pubkey on the given relay. - * Called when a workspace is removed to GC storage for that relay. + * Called when a community is removed to GC storage for that relay. */ export function removeSelfProfileCachesForRelay(relayUrl: string): void { try { @@ -223,7 +223,7 @@ export function resolveAvatarDataUrl( * is known to be reachable, so the fetch has the best chance of succeeding. * * The data URL is capped at 256 KB to keep localStorage usage bounded across - * workspaces and accounts. Returns null on ANY failure: network error, non-OK + * communities and accounts. Returns null on ANY failure: network error, non-OK * response, wrong content-type, blob too large, or FileReader error. */ export async function fetchAvatarDataUrl( diff --git a/desktop/src/features/profile/ui/ProfilePopover.tsx b/desktop/src/features/profile/ui/ProfilePopover.tsx index 7b647876774..51f4236d01d 100644 --- a/desktop/src/features/profile/ui/ProfilePopover.tsx +++ b/desktop/src/features/profile/ui/ProfilePopover.tsx @@ -30,9 +30,9 @@ interface ProfilePopoverProps { // primary PopoverTrigger and toggle the popover via controlled `open`. triggerContainerRef?: React.RefObject; // Optional slot rendered between the identity block and the menu items. - // Used by the sidebar to surface the workspace/relay selector inside the + // Used by the sidebar to surface the community/relay selector inside the // profile menu instead of on the sidebar card. - workspaceSwitcherSlot?: React.ReactNode; + communitySwitcherSlot?: React.ReactNode; } const MENU_ITEM_CLASS = @@ -56,7 +56,7 @@ export function ProfilePopover({ onOpenSettings, children, triggerContainerRef, - workspaceSwitcherSlot, + communitySwitcherSlot, }: ProfilePopoverProps) { const [statusDialogOpen, setStatusDialogOpen] = React.useState(false); const [presenceMenuOpen, setPresenceMenuOpen] = React.useState(false); @@ -259,12 +259,12 @@ export function ProfilePopover({ - {workspaceSwitcherSlot ? ( + {communitySwitcherSlot ? ( <>


- {/* ── Workspace / relay selector ─────────────────── */} -
- {workspaceSwitcherSlot} + {/* ── Community / relay selector ─────────────────── */} +
+ {communitySwitcherSlot}
) : null} diff --git a/desktop/src/features/profile/ui/UserProfileAgentActions.tsx b/desktop/src/features/profile/ui/UserProfileAgentActions.tsx index 510814dd153..1f0335294cf 100644 --- a/desktop/src/features/profile/ui/UserProfileAgentActions.tsx +++ b/desktop/src/features/profile/ui/UserProfileAgentActions.tsx @@ -312,7 +312,7 @@ function AgentDeleteConfirmDialog({ Delete this agent? - Deleting this agent stops and removes the agent from this workspace. + Deleting this agent stops and removes the agent from this community.
    diff --git a/desktop/src/features/projects/ui/ProjectDetailScreen.tsx b/desktop/src/features/projects/ui/ProjectDetailScreen.tsx index fbff91fe91c..d1765e05803 100644 --- a/desktop/src/features/projects/ui/ProjectDetailScreen.tsx +++ b/desktop/src/features/projects/ui/ProjectDetailScreen.tsx @@ -53,7 +53,7 @@ import { ProfilePanelProvider } from "@/shared/context/ProfilePanelContext"; import { useHistorySearchState } from "@/shared/hooks/useHistorySearchState"; import { useThreadPanelWidth } from "@/shared/hooks/useThreadPanelWidth"; import { Button } from "@/shared/ui/button"; -import { useWorkspaces } from "@/features/workspaces/useWorkspaces"; +import { useCommunities } from "@/features/communities/useCommunities"; import { useProjectCommitDiffQuery } from "@/features/projects/useProjectCommitDiff"; import { useGitIdentityQuery } from "@/features/projects/useGitIdentity"; import type { ViewerGitIdentity } from "@/features/projects/lib/projectContributorMatching"; @@ -100,7 +100,7 @@ const PROJECT_DETAIL_PANEL_SEARCH_KEYS = [ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { const { projectId, pullRequestId, issueId } = props; const { goChannel, goProjects } = useAppNavigation(); - const { activeWorkspace } = useWorkspaces(); + const { activeCommunity } = useCommunities(); const mainInsetRef = useMainInsetRef(); const projectDetailHeaderChromeRef = useMeasuredCssVariable({ targetRef: mainInsetRef, @@ -209,7 +209,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { ); const localRepoDiffQuery = useProjectLocalRepoDiffQuery( project, - activeWorkspace?.reposDir, + activeCommunity?.reposDir, activeBranch, activeRepoPullRequest, repoSource === "local" && Boolean(activeRepoPullRequest), @@ -218,21 +218,21 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { project, selectedCommitHash, repoSource, - activeWorkspace?.reposDir, + activeCommunity?.reposDir, ); const localRepoSnapshotQuery = useProjectLocalRepoSnapshotQuery( project, - activeWorkspace?.reposDir, + activeCommunity?.reposDir, activeBranch, ); const repoSyncStatusQuery = useProjectRepoSyncStatusQuery( project, - activeWorkspace?.reposDir, + activeCommunity?.reposDir, activeBranch, ); const pushLocalRepoMutation = usePushProjectLocalRepositoryMutation( project, - activeWorkspace?.reposDir, + activeCommunity?.reposDir, activeBranch, ); const hasLocalCheckout = Boolean( @@ -400,7 +400,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { repoSyncStatusQuery, ]); - const openTerminal = useOpenProjectTerminal(activeWorkspace?.reposDir); + const openTerminal = useOpenProjectTerminal(activeCommunity?.reposDir); const handleOpenTerminal = React.useCallback(() => { if (!project) return Promise.resolve(); return openTerminal(project, { diff --git a/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx b/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx index 8feef267f8d..3e91b97ffd5 100644 --- a/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx @@ -9,7 +9,7 @@ import { } from "lucide-react"; import type * as React from "react"; -import { WorkspaceEmojiIcon } from "@/features/workspaces/ui/WorkspaceSwitcher"; +import { CommunityEmojiIcon } from "@/features/communities/ui/CommunitySwitcher"; import type { Project, ProjectActivitySummary, @@ -41,7 +41,7 @@ type ProjectsOverviewPanelProps = { profiles?: UserProfileLookup; projects: Project[]; relayName: string; - /** Repo snapshots keyed by project ID, for workspace-wide aggregates. */ + /** Repo snapshots keyed by project ID, for community-wide aggregates. */ snapshots?: Record; snapshotsLoading?: boolean; summaries?: Record; @@ -224,14 +224,14 @@ export function ProjectsOverviewPanel({ return (
    - +

    {relayName} Projects

    Browse shared repositories, pull requests, and local project - checkouts in this workspace. + checkouts in this community.

    diff --git a/desktop/src/features/projects/ui/ProjectsView.tsx b/desktop/src/features/projects/ui/ProjectsView.tsx index 6e337f1482d..8d7b82aa775 100644 --- a/desktop/src/features/projects/ui/ProjectsView.tsx +++ b/desktop/src/features/projects/ui/ProjectsView.tsx @@ -61,7 +61,7 @@ import { projectTerminalLabel, useOpenProjectTerminal, } from "@/features/projects/ui/useOpenProjectTerminal"; -import { useWorkspaces } from "@/features/workspaces/useWorkspaces"; +import { useCommunities } from "@/features/communities/useCommunities"; import { useIdentityQuery } from "@/shared/api/hooks"; import { useMainInsetRef } from "@/shared/layout/MainInsetContext"; import { @@ -617,7 +617,7 @@ function ProjectListRow({ export function ProjectsView() { const { goProject } = useAppNavigation(); - const { activeWorkspace } = useWorkspaces(); + const { activeCommunity } = useCommunities(); const mainInsetRef = useMainInsetRef(); const projectsHeaderChromeRef = useMeasuredCssVariable({ targetRef: mainInsetRef, @@ -628,7 +628,7 @@ export function ProjectsView() { const projects = projectsQuery.data ?? []; const activitySummariesQuery = useProjectActivitySummariesQuery(projects); const localRepositoriesQuery = useProjectLocalRepositoriesQuery( - activeWorkspace?.reposDir, + activeCommunity?.reposDir, ); const projectPullRequestsQuery = useProjectsPullRequestsQuery(projects); const [filter, setFilter] = React.useState(() => @@ -645,7 +645,7 @@ export function ProjectsView() { ); const repoSnapshotsQuery = useProjectsRepoSnapshotsQuery( snapshotProjects, - activeWorkspace?.reposDir, + activeCommunity?.reposDir, ); const [storedViewMode, setStoredViewMode] = React.useState(() => readStoredViewMode()); @@ -815,7 +815,7 @@ export function ProjectsView() { [goProject], ); - const openTerminal = useOpenProjectTerminal(activeWorkspace?.reposDir); + const openTerminal = useOpenProjectTerminal(activeCommunity?.reposDir); const handleOpenTerminal = React.useCallback( (project: Project) => openTerminal(project, { @@ -882,7 +882,7 @@ export function ProjectsView() { onSelectSection={handleFilterChange} profiles={profiles} projects={projects} - relayName={activeWorkspace?.name || "Relay"} + relayName={activeCommunity?.name || "Relay"} snapshots={repoSnapshotsQuery.data} snapshotsLoading={repoSnapshotsQuery.isLoading} summaries={activitySummariesQuery.data} diff --git a/desktop/src/features/projects/useProjectsRepoSnapshots.ts b/desktop/src/features/projects/useProjectsRepoSnapshots.ts index 3ad711a807b..256c90a1e33 100644 --- a/desktop/src/features/projects/useProjectsRepoSnapshots.ts +++ b/desktop/src/features/projects/useProjectsRepoSnapshots.ts @@ -78,7 +78,7 @@ async function fetchProjectsRepoSnapshots( /** * Fetches repo snapshots for a set of projects (throttled, failure-tolerant) - * for workspace-wide aggregates like the overview language breakdown. + * for community-wide aggregates like the overview language breakdown. * Prefers local checkouts under `reposDir`; falls back to remote clones. * Callers should pre-filter and cap `projects` — up to one git clone per entry. */ diff --git a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx index b588fdc5aca..75c10e18344 100644 --- a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx +++ b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx @@ -294,7 +294,7 @@ function RuntimeRow({ codex-acp {" "} adapter. Older Buzz releases using the legacy adapter contract may - lose relay access until{" "} + lose community access until{" "} @zed-industries/codex-acp@0.16.0 {" "} diff --git a/desktop/src/features/settings/ui/ModerationQueueCard.tsx b/desktop/src/features/settings/ui/ModerationQueueCard.tsx index 95cb5e0905a..ca9adf980d2 100644 --- a/desktop/src/features/settings/ui/ModerationQueueCard.tsx +++ b/desktop/src/features/settings/ui/ModerationQueueCard.tsx @@ -10,7 +10,7 @@ import { type ModerationReport as HookModerationReport, type ResolutionAction, } from "@/features/moderation/hooks"; -import { useMyRelayMembershipQuery } from "@/features/relay-members/hooks"; +import { useMyRelayMembershipQuery } from "@/features/community-members/hooks"; import { useUsersBatchQuery } from "@/features/profile/hooks"; import { deleteMessage, diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx index 07092372cc8..8b27eef27e8 100644 --- a/desktop/src/features/settings/ui/SettingsPanels.tsx +++ b/desktop/src/features/settings/ui/SettingsPanels.tsx @@ -27,7 +27,7 @@ import type { NotificationSettings, } from "@/features/notifications/hooks"; import type { SoundName, SoundSlot } from "@/features/notifications/lib/sound"; -import { RelayMembersSettingsCard } from "@/features/relay-members/ui/RelayMembersSettingsCard"; +import { CommunityMembersSettingsCard } from "@/features/community-members/ui/CommunityMembersSettingsCard"; import { CustomEmojiSettingsCard } from "@/features/custom-emoji/ui/CustomEmojiSettingsCard"; import { LocalArchiveSettingsCard } from "@/features/local-archive/ui/LocalArchiveSettingsCard"; import { cn } from "@/shared/lib/cn"; @@ -76,7 +76,7 @@ export type SettingsSection = | "compute" | "appearance" | "shortcuts" - | "relay-members" + | "community-members" | "moderation" | "custom-emoji" | "local-archive" @@ -95,7 +95,7 @@ const SETTINGS_SECTION_VALUES: readonly SettingsSection[] = [ "compute", "appearance", "shortcuts", - "relay-members", + "community-members", "moderation", "custom-emoji", "local-archive", @@ -178,8 +178,8 @@ export const settingsSections: SettingsSectionDescriptor[] = [ icon: Keyboard, }, { - value: "relay-members", - label: "Relay Access", + value: "community-members", + label: "Community Access", icon: LockKeyhole, }, { @@ -721,8 +721,10 @@ export function renderSettingsSection( return ; case "shortcuts": return ; - case "relay-members": - return ; + case "community-members": + return ( + + ); case "moderation": return ; case "custom-emoji": diff --git a/desktop/src/features/settings/ui/SettingsView.tsx b/desktop/src/features/settings/ui/SettingsView.tsx index f70b72aa9d1..1a2dbae2ecc 100644 --- a/desktop/src/features/settings/ui/SettingsView.tsx +++ b/desktop/src/features/settings/ui/SettingsView.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import { getVersion } from "@tauri-apps/api/app"; import { ArrowLeft } from "lucide-react"; -import { useMyRelayMembershipQuery } from "@/features/relay-members/hooks"; +import { useMyRelayMembershipQuery } from "@/features/community-members/hooks"; import { getFeature } from "@/shared/features/manifest"; import { resolveEnabled, @@ -58,8 +58,8 @@ const settingsNavGroups: Array<{ ], }, { - label: "Workspaces", - sections: ["channel-templates", "relay-members"], + label: "Communities", + sections: ["channel-templates", "community-members"], }, { label: "App", @@ -142,8 +142,8 @@ export function SettingsView({ return false; } } - // Relay members requires admin/owner role - if (s.value === "relay-members") { + // Community members requires admin/owner role + if (s.value === "community-members") { return ( membership != null && (membership.role === "owner" || membership.role === "admin") diff --git a/desktop/src/features/sidebar/lib/channelSectionsStorage.ts b/desktop/src/features/sidebar/lib/channelSectionsStorage.ts index 68eea4f7c36..0d6b5768b6d 100644 --- a/desktop/src/features/sidebar/lib/channelSectionsStorage.ts +++ b/desktop/src/features/sidebar/lib/channelSectionsStorage.ts @@ -26,7 +26,7 @@ export const DEFAULT_STORE: ChannelSectionStore = Object.freeze({ * * When `relayUrl` is provided the key is scoped to that relay (normalized via * the same `normalizeRelayUrl` used by all relay-scoped local stores) so - * sections from different workspaces/relays don't bleed across each other. + * sections from different communities/relays don't bleed across each other. * When omitted the legacy pubkey-only key is returned (used only during * one-time migration in `readChannelSectionsStore`). */ diff --git a/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs b/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs index ab4b587cb72..5dad6c86735 100644 --- a/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs +++ b/desktop/src/features/sidebar/lib/channelSectionsSync.test.mjs @@ -15,7 +15,7 @@ function makeStore(overrides = {}) { // ─── destroy() must cancel pending publish, not flush ───────────────────────── -// Regression guard for the workspace-switch cross-relay publish vector: +// Regression guard for the community-switch cross-relay publish vector: // edit sections in relay A → destroy() is called (relayUrl dep change) → // no publish should fire. The scoped localStorage write is durable; when the // user returns to relay A the seed-publish path handles it. @@ -62,7 +62,7 @@ test("destroy: cancels pending publish without flushing to the relay", () => { manager.publishSections(store); assert.ok(timerCallback !== null, "debounce timer should be set"); - // Destroy before the debounce fires — simulates workspace switch. + // Destroy before the debounce fires — simulates community switch. manager.destroy(); // Timer must be cleared and no publish should fire now. diff --git a/desktop/src/features/sidebar/lib/channelSectionsSync.ts b/desktop/src/features/sidebar/lib/channelSectionsSync.ts index f882d8ef322..70930c26f6c 100644 --- a/desktop/src/features/sidebar/lib/channelSectionsSync.ts +++ b/desktop/src/features/sidebar/lib/channelSectionsSync.ts @@ -146,7 +146,7 @@ export class ChannelSectionSyncManager { try { const merged = await this.fetchOwnBlobBeforePublish(store); // Guard: manager may have been destroyed while fetchOwnBlobBeforePublish - // was awaited (workspace switch during in-flight fetch). If so, abort + // was awaited (community switch during in-flight fetch). If so, abort // before touching the relay. if (this.destroyed) return; if (this.isIdenticalToLastPublished(merged)) { @@ -174,7 +174,7 @@ export class ChannelSectionSyncManager { }); // Final guard immediately before the network call — sign/encrypt are // synchronous-ish but cheap; the relay socket may have moved to a - // different workspace by the time we reach this point. + // different community by the time we reach this point. if (this.destroyed) return; await relayClient.publishEvent( event, @@ -222,7 +222,7 @@ export class ChannelSectionSyncManager { // in-flight doPublish() calls abort before reaching relayClient. The // scoped localStorage write is already durable; when the user returns to // this relay the existing seed-publish guard will re-publish from local - // state. Flushing here would race against workspace switching and could + // state. Flushing here would race against community switching and could // publish relay A's sections to relay B via the shared relayClient // singleton. this.destroyed = true; diff --git a/desktop/src/features/sidebar/lib/channelSortPreference.ts b/desktop/src/features/sidebar/lib/channelSortPreference.ts index ff8b9677ee9..6a09e1e8f1b 100644 --- a/desktop/src/features/sidebar/lib/channelSortPreference.ts +++ b/desktop/src/features/sidebar/lib/channelSortPreference.ts @@ -37,7 +37,7 @@ export function sectionSortGroupKey(sectionId: string): ChannelSortGroupKey { * * When `relayUrl` is provided the key is scoped to that relay (normalized via * the same `normalizeRelayUrl` used by all relay-scoped local stores) so - * preferences don't bleed across workspaces/relays. + * preferences don't bleed across communities/relays. */ export function storageKey(pubkey: string, relayUrl?: string): string { if (!relayUrl) return `${STORAGE_KEY_PREFIX}:${pubkey}`; diff --git a/desktop/src/features/sidebar/lib/channelSortSync.test.mjs b/desktop/src/features/sidebar/lib/channelSortSync.test.mjs index 903245a9f47..76bf57b6c5e 100644 --- a/desktop/src/features/sidebar/lib/channelSortSync.test.mjs +++ b/desktop/src/features/sidebar/lib/channelSortSync.test.mjs @@ -10,7 +10,7 @@ function makeStore(groups = {}) { // ─── destroy() must cancel pending publish, not flush ───────────────────────── -// Regression guard for the workspace-switch cross-relay publish vector: +// Regression guard for the community-switch cross-relay publish vector: // change a sort mode in relay A → destroy() is called (relayUrl dep change) → // no publish should fire. The scoped localStorage write is durable; when the // user returns to relay A the seed-publish path handles it. diff --git a/desktop/src/features/sidebar/lib/channelSortSync.ts b/desktop/src/features/sidebar/lib/channelSortSync.ts index 55e326d66d2..e23387368d1 100644 --- a/desktop/src/features/sidebar/lib/channelSortSync.ts +++ b/desktop/src/features/sidebar/lib/channelSortSync.ts @@ -140,7 +140,7 @@ export class ChannelSortSyncManager { try { const merged = await this.fetchOwnBlobBeforePublish(store); // Guard: manager may have been destroyed while fetchOwnBlobBeforePublish - // was awaited (workspace switch during in-flight fetch). If so, abort + // was awaited (community switch during in-flight fetch). If so, abort // before touching the relay. if (this.destroyed) return; if (this.isIdenticalToLastPublished(merged)) { @@ -167,7 +167,7 @@ export class ChannelSortSyncManager { }); // Final guard immediately before the network call — sign/encrypt are // synchronous-ish but cheap; the relay socket may have moved to a - // different workspace by the time we reach this point. + // different community by the time we reach this point. if (this.destroyed) return; await relayClient.publishEvent( event, @@ -215,7 +215,7 @@ export class ChannelSortSyncManager { // in-flight doPublish() calls abort before reaching relayClient. The // scoped localStorage write is already durable; when the user returns to // this relay the existing seed-publish guard will re-publish from local - // state. Flushing here would race against workspace switching and could + // state. Flushing here would race against community switching and could // publish relay A's sort prefs to relay B via the shared relayClient // singleton. this.destroyed = true; diff --git a/desktop/src/features/sidebar/lib/useChannelSortPreference.ts b/desktop/src/features/sidebar/lib/useChannelSortPreference.ts index 3e9f26041a9..e347d41a9d8 100644 --- a/desktop/src/features/sidebar/lib/useChannelSortPreference.ts +++ b/desktop/src/features/sidebar/lib/useChannelSortPreference.ts @@ -17,7 +17,7 @@ import type { RemoteSortPrefs } from "./channelSortSync"; /** * Persistent per-group sidebar sort preferences, scoped by pubkey + relay so - * they don't bleed across identities or workspaces (same scoping as channel + * they don't bleed across identities or communities (same scoping as channel * sections). Each sidebar grouping (starred, channels, forums, dms, and each * custom section) carries its own saved Recent/A–Z mode; unset groups default * to A–Z. Mirrors changes made in other windows via the storage event. diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 02081b639cc..3a9f54bca62 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -3,8 +3,8 @@ import * as React from "react"; import { FeatureGate } from "@/shared/features"; import { SidebarDndContext } from "@/features/sidebar/ui/SidebarDnd"; -import type { Workspace } from "@/features/workspaces/types"; -import { AddWorkspaceDialog } from "@/features/workspaces/ui/AddWorkspaceDialog"; +import type { Community } from "@/features/communities/types"; +import { AddCommunityDialog } from "@/features/communities/ui/AddCommunityDialog"; import { useIsMobile } from "@/shared/hooks/use-mobile"; import { useDeferredLoad } from "@/shared/hooks/useDeferredStartup"; import { @@ -76,12 +76,12 @@ type CollapsibleSidebarGroup = type CreateChannelKind = "stream" | "forum"; type AppSidebarProps = { - activeWorkspace: Workspace | null; + activeCommunity: Community | null; channels: Channel[]; currentPubkey?: string; fallbackDisplayName?: string; homeBadgeCount: number; - isAddWorkspaceOpen?: boolean; + isAddCommunityOpen?: boolean; isLoading: boolean; isCreatingChannel: boolean; isCreatingForum: boolean; @@ -100,9 +100,9 @@ type AppSidebarProps = { | "projects"; unreadChannelCounts: ReadonlyMap; unreadChannelIds: ReadonlySet; - workspaces: Workspace[]; - onAddWorkspace: (workspace: Workspace) => void; - onAddWorkspaceOpenChange?: (open: boolean) => void; + communities: Community[]; + onAddCommunity: (community: Community) => void; + onAddCommunityOpenChange?: (open: boolean) => void; onCreateChannel: (input: { name: string; description?: string; @@ -117,7 +117,7 @@ type AppSidebarProps = { ttlSeconds?: number; templateId?: string; }) => Promise; - onOpenAddWorkspace: () => void; + onOpenAddCommunity: () => void; onHideDm: (channelId: string) => void; onMarkChannelUnread: (channelId: string) => void; onMarkChannelRead: ( @@ -127,11 +127,11 @@ type AppSidebarProps = { onMarkAllChannelsRead: () => void; onBrowseChannels?: () => void; onOpenDm: (input: { pubkeys: string[] }) => Promise; - onUpdateWorkspace: ( + onUpdateCommunity: ( id: string, - updates: Partial>, + updates: Partial>, ) => void; - onRemoveWorkspace: (id: string) => void; + onRemoveCommunity: (id: string) => void; onCreateAgent: () => void; onSelectAgents: () => void; onSelectProjects: () => void; @@ -151,7 +151,7 @@ type AppSidebarProps = { onSetPresenceStatus?: (status: "online" | "away" | "offline") => void; onSetUserStatus: (text: string, emoji: string) => void; onClearUserStatus: () => void; - onSwitchWorkspace: (id: string) => void; + onSwitchCommunity: (id: string) => void; selfUserStatus?: UserStatus; isPresencePending?: boolean; onNewMessage: () => void; @@ -166,12 +166,12 @@ type AppSidebarProps = { }; export function AppSidebar({ - activeWorkspace, + activeCommunity, channels, currentPubkey, fallbackDisplayName, homeBadgeCount, - isAddWorkspaceOpen, + isAddCommunityOpen, isLoading, isCreatingChannel, isCreatingForum, @@ -183,20 +183,20 @@ export function AppSidebar({ selectedView, unreadChannelCounts, unreadChannelIds, - workspaces, - onAddWorkspace, - onAddWorkspaceOpenChange, + communities, + onAddCommunity, + onAddCommunityOpenChange, onCreateChannel, onCreateForum, - onOpenAddWorkspace, + onOpenAddCommunity, onHideDm, onMarkChannelUnread, onMarkChannelRead, onMarkAllChannelsRead, onBrowseChannels, onOpenDm, - onUpdateWorkspace, - onRemoveWorkspace, + onUpdateCommunity, + onRemoveCommunity, onCreateAgent, onSelectAgents, onSelectProjects, @@ -211,7 +211,7 @@ export function AppSidebar({ onSetPresenceStatus, onSetUserStatus, onClearUserStatus, - onSwitchWorkspace, + onSwitchCommunity, selfUserStatus, isPresencePending, onNewMessage, @@ -342,7 +342,7 @@ export function AppSidebar({ reorderSections, assignChannel, unassignChannel, - } = useChannelSections(currentPubkey, activeWorkspace?.relayUrl); + } = useChannelSections(currentPubkey, activeCommunity?.relayUrl); const sectionIds = React.useMemo( () => channelSections.map((s) => s.id), @@ -351,7 +351,7 @@ export function AppSidebar({ const { sortModeFor, setSortModeFor } = useChannelSortPreference( currentPubkey, - activeWorkspace?.relayUrl, + activeCommunity?.relayUrl, sectionIds, ); @@ -474,7 +474,7 @@ export function AppSidebar({ [directMessages, dmChannelLabels, sortModeFor], ); const sidebarLoadingShape = useSidebarLoadingShape({ - activeWorkspaceId: activeWorkspace?.id, + activeCommunityId: activeCommunity?.id, currentPubkey, directMessages, dmChannelLabels, @@ -831,21 +831,21 @@ export function AppSidebar({ @@ -869,10 +869,10 @@ export function AppSidebar({ onCreate={handleCreateFromDialog} /> - {})} - onSubmit={onAddWorkspace} - open={isAddWorkspaceOpen ?? false} + {})} + onSubmit={onAddCommunity} + open={isAddCommunityOpen ?? false} /> { +describe("communityInitials", () => { it("filters punctuation before deriving initials", () => { - assert.equal(workspaceInitials("B (relay)"), "BR"); + assert.equal(communityInitials("B (relay)"), "BR"); }); it("handles a leading symbol on a single word", () => { - assert.equal(workspaceInitials("(staging)"), "S"); + assert.equal(communityInitials("(staging)"), "S"); }); it("still returns plain initials for normal names", () => { - assert.equal(workspaceInitials("Bravo Beta"), "BB"); + assert.equal(communityInitials("Bravo Beta"), "BB"); }); it("returns empty for a symbol-only name (caller falls back)", () => { - assert.equal(workspaceInitials("()"), ""); + assert.equal(communityInitials("()"), ""); }); }); -describe("workspaceRailIndicators", () => { - it("shows no badge for an observed workspace with unread but no mentions", () => { - const r = workspaceRailIndicators({ hasUnread: true, state: "ready" }); +describe("communityRailIndicators", () => { + it("shows no badge for an observed community with unread but no mentions", () => { + const r = communityRailIndicators({ hasUnread: true, state: "ready" }); assert.equal(r.showBadge, false); assert.equal(r.showDot, true); assert.equal(r.pending, false); }); - it("shows no badge and no dot for an observed workspace with no unread", () => { - const r = workspaceRailIndicators({ hasUnread: false, state: "ready" }); + it("shows no badge and no dot for an observed community with no unread", () => { + const r = communityRailIndicators({ hasUnread: false, state: "ready" }); assert.equal(r.showBadge, false); assert.equal(r.showDot, false); assert.equal(r.pending, false); }); it("shows a mention badge with the count when mentions are present — no dot", () => { - const r = workspaceRailIndicators({ + const r = communityRailIndicators({ hasUnread: true, count: 3, state: "ready", @@ -49,7 +49,7 @@ describe("workspaceRailIndicators", () => { }); it("caps the badge label at 99+", () => { - const r = workspaceRailIndicators({ + const r = communityRailIndicators({ hasUnread: true, count: 250, state: "ready", @@ -57,8 +57,8 @@ describe("workspaceRailIndicators", () => { assert.equal(r.badgeLabel, "99+"); }); - it("never reports mentions or dot for an unobserved (unknown) workspace", () => { - const r = workspaceRailIndicators({ + it("never reports mentions or dot for an unobserved (unknown) community", () => { + const r = communityRailIndicators({ hasUnread: true, count: 5, state: "unknown", @@ -70,14 +70,14 @@ describe("workspaceRailIndicators", () => { }); it("treats loading as pending — no badge, no dot", () => { - const r = workspaceRailIndicators({ hasUnread: false, state: "loading" }); + const r = communityRailIndicators({ hasUnread: false, state: "loading" }); assert.equal(r.pending, true); assert.equal(r.showBadge, false); assert.equal(r.showDot, false); }); it("never reports mentions or dot on an errored observation", () => { - const r = workspaceRailIndicators({ + const r = communityRailIndicators({ hasUnread: true, count: 2, state: "error", diff --git a/desktop/src/features/sidebar/ui/WorkspaceRail.tsx b/desktop/src/features/sidebar/ui/CommunityRail.tsx similarity index 63% rename from desktop/src/features/sidebar/ui/WorkspaceRail.tsx rename to desktop/src/features/sidebar/ui/CommunityRail.tsx index 0709602a912..76560d2dc8e 100644 --- a/desktop/src/features/sidebar/ui/WorkspaceRail.tsx +++ b/desktop/src/features/sidebar/ui/CommunityRail.tsx @@ -1,13 +1,13 @@ import { CheckCheck, Link2, Plus, Settings2 } from "lucide-react"; import * as React from "react"; -import type { Workspace } from "@/features/workspaces/types"; -import { EditWorkspaceDialog } from "@/features/workspaces/ui/EditWorkspaceDialog"; -import { useWorkspaceIcons } from "@/features/workspaces/useWorkspaceIcons"; +import type { Community } from "@/features/communities/types"; +import { EditCommunityDialog } from "@/features/communities/ui/EditCommunityDialog"; +import { useCommunityIcons } from "@/features/communities/useCommunityIcons"; import { - useWorkspaceUnread, - type WorkspaceUnreadState, -} from "@/features/workspaces/useWorkspaceUnread"; + useCommunityUnread, + type CommunityUnreadState, +} from "@/features/communities/useCommunityUnread"; import { useAppShell } from "@/app/AppShellContext"; import { ContextMenu, @@ -22,28 +22,28 @@ import { getInitials } from "@/shared/lib/initials"; import { isMacPlatform } from "@/shared/lib/platform"; import { useIsFullscreen } from "@/shared/lib/useIsFullscreen"; -type WorkspaceRailProps = { - workspaces: Workspace[]; - activeWorkspaceId: string | null; - onSwitchWorkspace: (id: string) => void; - onAddWorkspace: () => void; - onUpdateWorkspace: ( +type CommunityRailProps = { + communities: Community[]; + activeCommunityId: string | null; + onSwitchCommunity: (id: string) => void; + onAddCommunity: () => void; + onUpdateCommunity: ( id: string, - updates: Partial>, + updates: Partial>, ) => void; - onRemoveWorkspace: (id: string) => void; + onRemoveCommunity: (id: string) => void; }; const MAX_BADGE = 99; // Strip punctuation before initials so "B (relay)" yields "BR", not "B(". -export function workspaceInitials(name: string): string { +export function communityInitials(name: string): string { const cleaned = name.replace(/[^\p{L}\p{N}\s]/gu, " "); return getInitials(cleaned); } /** - * Presentation decisions for one workspace button, derived from its observed + * Presentation decisions for one community button, derived from its observed * mention state. Pure so it can be unit-tested without a DOM. The `state` guard * ensures we NEVER render any indicator for a relay we could not observe * (`unknown`/`loading`/`error`) — only a `ready` observation is trusted. @@ -53,7 +53,7 @@ export function workspaceInitials(name: string): string { * - `showDot`: plain unread dot when there are regular channel unreads but no * mentions. Mutually exclusive with `showBadge` by construction. */ -export function workspaceRailIndicators(unread: WorkspaceUnreadState): { +export function communityRailIndicators(unread: CommunityUnreadState): { mentionCount: number; showBadge: boolean; showDot: boolean; @@ -74,29 +74,29 @@ export function workspaceRailIndicators(unread: WorkspaceUnreadState): { }; } -function WorkspaceButton({ - workspace, +function CommunityButton({ + community, isActive, unread, iconUrl, onSwitch, menu, }: { - workspace: Workspace; + community: Community; isActive: boolean; - unread: WorkspaceUnreadState; + unread: CommunityUnreadState; iconUrl: string | null; onSwitch: () => void; menu: React.ReactNode; }) { const { mentionCount, showBadge, showDot, pending, badgeLabel } = - workspaceRailIndicators(unread); + communityRailIndicators(unread); const tooltipLabel = showBadge - ? `${workspace.name} — ${mentionCount} mention${mentionCount === 1 ? "" : "s"}` + ? `${community.name} — ${mentionCount} mention${mentionCount === 1 ? "" : "s"}` : showDot - ? `${workspace.name} — unread` - : workspace.name; + ? `${community.name} — unread` + : community.name; return ( @@ -107,7 +107,7 @@ function WorkspaceButton({ aria-current={isActive ? "true" : undefined} aria-label={tooltipLabel} className="relative flex h-9 w-9 items-center justify-center outline-hidden focus:outline-none focus-visible:outline-none" - data-testid={`workspace-rail-button-${workspace.id}`} + data-testid={`community-rail-button-${community.id}`} onClick={onSwitch} type="button" > @@ -124,25 +124,25 @@ function WorkspaceButton({ ) : ( - workspaceInitials(workspace.name) || "🐝" + communityInitials(community.name) || "🐝" )} {showBadge ? ( {badgeLabel} ) : showDot ? ( unread @@ -152,7 +152,7 @@ function WorkspaceButton({ {tooltipLabel} - + {menu} @@ -160,42 +160,42 @@ function WorkspaceButton({ } /** - * Discord/Slack-style vertical rail of workspaces on the far left of the app. - * Shows a mention-count badge for inactive workspaces (observed via - * `useWorkspaceUnread`) and switches relays on click. Right-click opens a - * per-workspace menu: mark all as read, copy relay URL, workspace settings. + * Discord/Slack-style vertical rail of communities on the far left of the app. + * Shows a mention-count badge for inactive communities (observed via + * `useCommunityUnread`) and switches relays on click. Right-click opens a + * per-community menu: mark all as read, copy relay URL, community settings. * - * Hidden entirely with a single workspace — a rail of one adds no value. + * Hidden entirely with a single community — a rail of one adds no value. */ -export function WorkspaceRail({ - workspaces, - activeWorkspaceId, - onSwitchWorkspace, - onAddWorkspace, - onUpdateWorkspace, - onRemoveWorkspace, -}: WorkspaceRailProps) { - const { unreadByWorkspace, markWorkspaceRead } = useWorkspaceUnread( - workspaces, - activeWorkspaceId, +export function CommunityRail({ + communities, + activeCommunityId, + onSwitchCommunity, + onAddCommunity, + onUpdateCommunity, + onRemoveCommunity, +}: CommunityRailProps) { + const { unreadByCommunity, markCommunityRead } = useCommunityUnread( + communities, + activeCommunityId, ); - const iconsByWorkspace = useWorkspaceIcons(workspaces); + const iconsByCommunity = useCommunityIcons(communities); const isFullscreen = useIsFullscreen(); const { markAllChannelsRead } = useAppShell(); - const [editingWorkspace, setEditingWorkspace] = - React.useState(null); - if (workspaces.length <= 1) { + const [editingCommunity, setEditingCommunity] = + React.useState(null); + if (communities.length <= 1) { return null; } - const handleMarkAllRead = (workspace: Workspace) => { - if (workspace.id === activeWorkspaceId) { + const handleMarkAllRead = (community: Community) => { + if (community.id === activeCommunityId) { markAllChannelsRead(); return; } - markWorkspaceRead(workspace.id).catch((error) => { + markCommunityRead(community.id).catch((error) => { console.warn( - `[WorkspaceRail] mark all read failed workspace=${workspace.id}:`, + `[CommunityRail] mark all read failed community=${community.id}:`, error, ); }); @@ -209,72 +209,72 @@ export function WorkspaceRail({ return ( ); diff --git a/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx b/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx index 885dd7e9a27..ef0037d0d83 100644 --- a/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx +++ b/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx @@ -10,48 +10,48 @@ import { } from "@/features/profile/ui/MaskedAvatarBadgeFrame"; import { ProfilePopover } from "@/features/profile/ui/ProfilePopover"; import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji"; -import type { Workspace } from "@/features/workspaces/types"; -import { WorkspaceSwitcher } from "@/features/workspaces/ui/WorkspaceSwitcher"; +import type { Community } from "@/features/communities/types"; +import { CommunitySwitcher } from "@/features/communities/ui/CommunitySwitcher"; import type { PresenceStatus, Profile, UserStatus } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; type SidebarProfileCardProps = { - activeWorkspace: Workspace | null; + activeCommunity: Community | null; isPresencePending?: boolean; - onOpenAddWorkspace: () => void; + onOpenAddCommunity: () => void; onOpenSettings: (section?: "profile" | "appearance") => void; - onRemoveWorkspace: (id: string) => void; + onRemoveCommunity: (id: string) => void; onSetPresenceStatus?: (status: PresenceStatus) => void; onSetUserStatus: (text: string, emoji: string) => void; onClearUserStatus: () => void; - onSwitchWorkspace: (id: string) => void; - onUpdateWorkspace: ( + onSwitchCommunity: (id: string) => void; + onUpdateCommunity: ( id: string, - updates: Partial>, + updates: Partial>, ) => void; profile?: Profile; resolvedDisplayName: string; selfPresenceStatus: PresenceStatus; selfUserStatus?: UserStatus; - workspaces: Workspace[]; + communities: Community[]; }; export function SidebarProfileCard({ - activeWorkspace, + activeCommunity, isPresencePending, - onOpenAddWorkspace, + onOpenAddCommunity, onOpenSettings, - onRemoveWorkspace, + onRemoveCommunity, onSetPresenceStatus, onSetUserStatus, onClearUserStatus, - onSwitchWorkspace, - onUpdateWorkspace, + onSwitchCommunity, + onUpdateCommunity, profile, resolvedDisplayName, selfPresenceStatus, selfUserStatus, - workspaces, + communities, }: SidebarProfileCardProps) { const selfProfileCache = useSelfProfileCache(); const [profilePopoverOpen, setProfilePopoverOpen] = React.useState(false); @@ -74,8 +74,8 @@ export function SidebarProfileCard({ [toggleProfilePopover], ); const hasStatus = Boolean(selfUserStatus?.text || selfUserStatus?.emoji); - const workspaceLabel = activeWorkspace?.name ?? "No workspace"; - const readonlyWorkspaceLabel = ( + const communityLabel = activeCommunity?.name ?? "No community"; + const readonlyCommunityLabel = ( - {workspaceLabel} + {communityLabel} ); @@ -150,15 +150,15 @@ export function SidebarProfileCard({ triggerContainerRef={profileCardRef} userStatusEmoji={selfUserStatus?.emoji} userStatusText={selfUserStatus?.text} - workspaceSwitcherSlot={ - } > @@ -209,11 +209,11 @@ export function SidebarProfileCard({ profilePopoverOpen && "opacity-0", )} > - {readonlyWorkspaceLabel} + {readonlyCommunityLabel}
) : ( -
{readonlyWorkspaceLabel}
+
{readonlyCommunityLabel}
)}
diff --git a/desktop/src/features/sidebar/ui/sidebarLoadingSkeleton.tsx b/desktop/src/features/sidebar/ui/sidebarLoadingSkeleton.tsx index 2748605cef5..8db31bf044e 100644 --- a/desktop/src/features/sidebar/ui/sidebarLoadingSkeleton.tsx +++ b/desktop/src/features/sidebar/ui/sidebarLoadingSkeleton.tsx @@ -95,11 +95,11 @@ function hasSidebarLoadingRows(shape: SidebarLoadingShape) { } function sidebarSkeletonCacheKey( - workspaceId: string | null | undefined, + communityId: string | null | undefined, pubkey: string | undefined, ) { - if (!workspaceId) return null; - return `${SIDEBAR_SKELETON_CACHE_PREFIX}:${workspaceId}:${pubkey ?? "anonymous"}`; + if (!communityId) return null; + return `${SIDEBAR_SKELETON_CACHE_PREFIX}:${communityId}:${pubkey ?? "anonymous"}`; } function readSidebarLoadingShape( @@ -174,14 +174,14 @@ function createSidebarLoadingShape({ } export function useSidebarLoadingShape({ - activeWorkspaceId, + activeCommunityId, directMessages, dmChannelLabels, isLoading, currentPubkey, streamChannels, }: { - activeWorkspaceId: string | null | undefined; + activeCommunityId: string | null | undefined; directMessages: Channel[]; dmChannelLabels: Record; isLoading: boolean; @@ -189,8 +189,8 @@ export function useSidebarLoadingShape({ streamChannels: Channel[]; }) { const cacheKey = React.useMemo( - () => sidebarSkeletonCacheKey(activeWorkspaceId, currentPubkey), - [activeWorkspaceId, currentPubkey], + () => sidebarSkeletonCacheKey(activeCommunityId, currentPubkey), + [activeCommunityId, currentPubkey], ); const liveShape = React.useMemo( () => diff --git a/desktop/src/features/sidebar/ui/useSidebarRelayConnectionCard.ts b/desktop/src/features/sidebar/ui/useSidebarRelayConnectionCard.ts index af11903ae48..f92e1a02e8e 100644 --- a/desktop/src/features/sidebar/ui/useSidebarRelayConnectionCard.ts +++ b/desktop/src/features/sidebar/ui/useSidebarRelayConnectionCard.ts @@ -34,7 +34,7 @@ function setRelayConnectivitySuccess( return; } - // Don't let one workspace clear another workspace's success state. + // Don't let one community clear another community's success state. if (!next && relayConnectivitySuccessKey !== relaySuccessKey(relayUrl)) { return; } diff --git a/desktop/src/features/user-status/ui/StatusEmoji.tsx b/desktop/src/features/user-status/ui/StatusEmoji.tsx index 0324c15aa22..7632cfb2678 100644 --- a/desktop/src/features/user-status/ui/StatusEmoji.tsx +++ b/desktop/src/features/user-status/ui/StatusEmoji.tsx @@ -7,7 +7,7 @@ import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; * Render a user-status emoji from its stored string. A status emoji is a bare * string (unlike reactions, which carry a companion `emojiUrl`): a native glyph * like `💬`, or a custom-emoji `:shortcode:`. This resolves a known shortcode to - * its workspace image and renders it as an ``; anything else (native glyph, + * its community image and renders it as an ``; anything else (native glyph, * or an unknown `:foo:`) renders as text. * * Every place that shows a status emoji renders this, so the shortcode→image diff --git a/desktop/src/features/workspaces/legacyWorkspaceStorage.test.mjs b/desktop/src/features/workspaces/legacyWorkspaceStorage.test.mjs deleted file mode 100644 index 83c1de781a0..00000000000 --- a/desktop/src/features/workspaces/legacyWorkspaceStorage.test.mjs +++ /dev/null @@ -1,153 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { applyLegacyWorkspaceStorage } from "./legacyWorkspaceStorage.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 legacyWorkspaces = JSON.stringify([ - { - id: "legacy-workspace", - name: "Existing relay", - relayUrl: "wss://relay.example.com", - addedAt: "2026-06-12T00:00:00.000Z", - }, -]); - -const currentWorkspaces = JSON.stringify([ - { - id: "current-workspace", - name: "Current relay", - relayUrl: "wss://current.example.com", - addedAt: "2026-06-12T00:00:00.000Z", - }, -]); - -const localhostWorkspaces = JSON.stringify([ - { - id: "local-workspace", - name: "Local Dev", - relayUrl: "ws://localhost:3000", - addedAt: "2026-06-12T00:00:00.000Z", - }, -]); - -test("applyLegacyWorkspaceStorage seeds missing workspaces and active workspace", () => { - const storage = createMemoryStorage(); - - applyLegacyWorkspaceStorage( - { - workspaces: legacyWorkspaces, - activeWorkspaceId: "legacy-workspace", - onboardingCompletions: [], - }, - storage, - ); - - assert.equal(storage.getItem("buzz-workspaces"), legacyWorkspaces); - assert.equal(storage.getItem("buzz-active-workspace-id"), "legacy-workspace"); -}); - -test("applyLegacyWorkspaceStorage preserves existing non-local Buzz workspaces", () => { - const storage = createMemoryStorage({ - "buzz-workspaces": currentWorkspaces, - "buzz-active-workspace-id": "current-workspace", - }); - - applyLegacyWorkspaceStorage( - { - workspaces: legacyWorkspaces, - activeWorkspaceId: "legacy-workspace", - onboardingCompletions: [], - }, - storage, - ); - - assert.equal(storage.getItem("buzz-workspaces"), currentWorkspaces); - assert.equal( - storage.getItem("buzz-active-workspace-id"), - "current-workspace", - ); -}); - -test("applyLegacyWorkspaceStorage replaces broken localhost first-run workspace", () => { - const storage = createMemoryStorage({ - "buzz-workspaces": localhostWorkspaces, - "buzz-active-workspace-id": "local-workspace", - }); - - applyLegacyWorkspaceStorage( - { - workspaces: legacyWorkspaces, - activeWorkspaceId: "legacy-workspace", - onboardingCompletions: [], - }, - storage, - ); - - assert.equal(storage.getItem("buzz-workspaces"), legacyWorkspaces); - assert.equal(storage.getItem("buzz-active-workspace-id"), "legacy-workspace"); -}); - -test("applyLegacyWorkspaceStorage treats trailing-slash localhost as broken", () => { - const storage = createMemoryStorage({ - "buzz-workspaces": JSON.stringify([ - { - id: "local-workspace", - name: "Local Dev", - relayUrl: "ws://localhost:3000/", - addedAt: "2026-06-12T00:00:00.000Z", - }, - ]), - "buzz-active-workspace-id": "local-workspace", - }); - - applyLegacyWorkspaceStorage( - { - workspaces: legacyWorkspaces, - activeWorkspaceId: "legacy-workspace", - onboardingCompletions: [], - }, - storage, - ); - - assert.equal(storage.getItem("buzz-workspaces"), legacyWorkspaces); - assert.equal(storage.getItem("buzz-active-workspace-id"), "legacy-workspace"); -}); - -test("applyLegacyWorkspaceStorage migrates onboarding completion keys", () => { - const storage = createMemoryStorage(); - - applyLegacyWorkspaceStorage( - { - 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/workspaces/legacyWorkspaceStorage.ts b/desktop/src/features/workspaces/legacyWorkspaceStorage.ts deleted file mode 100644 index 492e05d755f..00000000000 --- a/desktop/src/features/workspaces/legacyWorkspaceStorage.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { invokeTauri } from "@/shared/api/tauri"; - -const BUZZ_WORKSPACES_KEY = "buzz-workspaces"; -const BUZZ_ACTIVE_WORKSPACE_KEY = "buzz-active-workspace-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 LegacyWorkspaceStorageSnapshot = { - workspaces: string | null; - activeWorkspaceId: string | null; - onboardingCompletions: Array<{ - pubkey: string; - value: string; - }>; -}; - -type StoredWorkspace = { - relayUrl?: unknown; -}; - -function parseWorkspaceList(raw: string | null): StoredWorkspace[] | null { - if (!raw) { - return null; - } - - try { - const parsed: unknown = JSON.parse(raw); - return Array.isArray(parsed) ? (parsed as StoredWorkspace[]) : null; - } catch { - return null; - } -} - -function normalizeRelayUrl(relayUrl: string) { - return relayUrl.trim().replace(/\/$/, ""); -} - -function hasOnlyLocalDevWorkspace(raw: string | null): boolean { - const workspaces = parseWorkspaceList(raw); - return ( - workspaces?.length === 1 && - typeof workspaces[0]?.relayUrl === "string" && - LOCAL_DEV_RELAY_URLS.has(normalizeRelayUrl(workspaces[0].relayUrl)) - ); -} - -function hasNonLocalCurrentWorkspaces(raw: string | null): boolean { - const workspaces = parseWorkspaceList(raw); - return ( - workspaces !== null && - workspaces.length > 0 && - !hasOnlyLocalDevWorkspace(raw) - ); -} - -function shouldWriteLegacyWorkspaces({ - currentWorkspacesRaw, - legacyWorkspacesRaw, -}: { - currentWorkspacesRaw: string | null; - legacyWorkspacesRaw: string | null; -}) { - const legacyWorkspaces = parseWorkspaceList(legacyWorkspacesRaw); - if (!legacyWorkspaces || legacyWorkspaces.length === 0) { - return false; - } - - return !hasNonLocalCurrentWorkspaces(currentWorkspacesRaw); -} - -export function applyLegacyWorkspaceStorage( - legacyStorage: LegacyWorkspaceStorageSnapshot, - storage: Storage = window.localStorage, -): void { - const currentWorkspacesRaw = storage.getItem(BUZZ_WORKSPACES_KEY); - const shouldWriteWorkspaces = shouldWriteLegacyWorkspaces({ - currentWorkspacesRaw, - legacyWorkspacesRaw: legacyStorage.workspaces, - }); - - if (shouldWriteWorkspaces && legacyStorage.workspaces) { - storage.setItem(BUZZ_WORKSPACES_KEY, legacyStorage.workspaces); - } - - const currentActiveWorkspaceId = storage.getItem(BUZZ_ACTIVE_WORKSPACE_KEY); - if ( - legacyStorage.activeWorkspaceId && - (!currentActiveWorkspaceId || shouldWriteWorkspaces) - ) { - storage.setItem(BUZZ_ACTIVE_WORKSPACE_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 workspace state. The native command reads the old - * app identifier's WebKit SQLite database; this frontend step writes only when - * Buzz does not already have workspace state, except for the known broken - * Sprout→Buzz first-run handoff that created a single localhost workspace. - */ -export async function migrateLegacyWorkspaceStorageBeforeRender(): Promise { - if (typeof window === "undefined") { - return; - } - - const currentWorkspacesRaw = window.localStorage.getItem(BUZZ_WORKSPACES_KEY); - const hasCurrentActiveWorkspace = window.localStorage.getItem( - BUZZ_ACTIVE_WORKSPACE_KEY, - ); - if ( - currentWorkspacesRaw && - hasCurrentActiveWorkspace && - !hasOnlyLocalDevWorkspace(currentWorkspacesRaw) - ) { - return; - } - - try { - applyLegacyWorkspaceStorage( - await invokeTauri( - "get_legacy_workspace_storage", - ), - ); - } catch (error) { - console.warn("Failed to read legacy Sprout workspace storage.", error); - } -} diff --git a/desktop/src/features/workspaces/useWorkspaceIcons.ts b/desktop/src/features/workspaces/useWorkspaceIcons.ts deleted file mode 100644 index a96f1b14625..00000000000 --- a/desktop/src/features/workspaces/useWorkspaceIcons.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { useQueries, useQuery } from "@tanstack/react-query"; - -import { fetchWorkspaceIcon } from "@/shared/api/workspaceProfile"; - -import type { Workspace } from "./types"; -import { - loadCachedWorkspaceIcon, - saveCachedWorkspaceIcon, -} from "./workspaceIconCache"; - -export const workspaceIconQueryKey = (relayUrl: string) => - ["workspaceIcon", relayUrl] as const; - -const ICON_STALE_MS = 5 * 60_000; - -async function fetchIconForWorkspace( - workspace: Workspace, -): Promise { - const icon = await fetchWorkspaceIcon(workspace.relayUrl); - saveCachedWorkspaceIcon(workspace.relayUrl, icon); - return icon; -} - -function iconQueryOptions(workspace: Workspace) { - return { - queryKey: workspaceIconQueryKey(workspace.relayUrl), - queryFn: () => fetchIconForWorkspace(workspace), - // Cached icon renders immediately; the fetch still runs and replaces it. - placeholderData: loadCachedWorkspaceIcon(workspace.relayUrl), - staleTime: ICON_STALE_MS, - retry: 1, - }; -} - -/** - * Workspace icons for the rail, keyed by workspace id. Each icon is read - * from its relay's NIP-11 document over plain HTTP — active and inactive - * workspaces alike. Falls back to the localStorage cache (then null → - * initials) when a relay is unreachable. - */ -export function useWorkspaceIcons( - workspaces: Workspace[], -): Record { - const results = useQueries({ - queries: workspaces.map((workspace) => iconQueryOptions(workspace)), - }); - - const icons: Record = {}; - workspaces.forEach((workspace, index) => { - icons[workspace.id] = - results[index]?.data ?? loadCachedWorkspaceIcon(workspace.relayUrl); - }); - return icons; -} - -/** Icon of the ACTIVE workspace, for settings preview. */ -export function useActiveWorkspaceIcon(relayUrl: string | undefined) { - return useQuery({ - queryKey: workspaceIconQueryKey(relayUrl ?? ""), - queryFn: async () => { - const icon = await fetchWorkspaceIcon(relayUrl ?? ""); - if (relayUrl) saveCachedWorkspaceIcon(relayUrl, icon); - return icon; - }, - enabled: relayUrl !== undefined, - staleTime: ICON_STALE_MS, - }); -} diff --git a/desktop/src/features/workspaces/useWorkspaceUnread.ts b/desktop/src/features/workspaces/useWorkspaceUnread.ts deleted file mode 100644 index cbbfee518c2..00000000000 --- a/desktop/src/features/workspaces/useWorkspaceUnread.ts +++ /dev/null @@ -1,182 +0,0 @@ -import * as React from "react"; - -import { getIdentity } from "@/shared/api/tauriIdentity"; -import { markWorkspaceRead } from "@/features/workspaces/workspaceMarkRead"; -import { pollWorkspaceUnread } from "@/features/workspaces/workspaceUnreadObserver"; - -import type { Workspace } from "./types"; - -const WORKSPACE_UNREAD_POLL_MS = 30_000; - -/** - * Per-workspace unread summary for the workspace rail. - * - * `state` distinguishes "observed, no unread" from "not observed yet" so the - * rail never renders a false "no unread" for a relay it could not reach: - * - `unknown` — not yet observed (render dim, no unread affordance) - * - `loading` — observation in flight (render dim/skeleton) - * - `ready` — observed; trust `hasUnread` / `count` - * - `error` — observation failed (render neutral, never "no unread") - * - * `count` carries the MENTION count (not total unread) — the rail shows a dot - * for any unread and a numeric badge only when mentions are present. - */ -export type WorkspaceUnreadState = { - hasUnread: boolean; - count?: number; - state: "unknown" | "loading" | "ready" | "error"; -}; - -const unknownUnreadState: WorkspaceUnreadState = { - hasUnread: false, - state: "unknown", -}; - -function seedWorkspaceStates( - workspaces: Workspace[], - previous: Record, -): Record { - const next: Record = {}; - for (const workspace of workspaces) { - next[workspace.id] = previous[workspace.id] ?? unknownUnreadState; - } - return next; -} - -/** - * Observe unread activity for INACTIVE workspaces without touching the active - * relay singleton. - */ -export function useWorkspaceUnread( - workspaces: Workspace[], - activeWorkspaceId: string | null, -): { - unreadByWorkspace: Record; - markWorkspaceRead: (workspaceId: string) => Promise; -} { - const [unreadByWorkspace, setUnreadByWorkspace] = React.useState< - Record - >(() => seedWorkspaceStates(workspaces, {})); - - React.useEffect(() => { - let cancelled = false; - let pollTimer: number | null = null; - const inactiveWorkspaces = workspaces.filter( - (workspace) => workspace.id !== activeWorkspaceId, - ); - - setUnreadByWorkspace((previous) => - seedWorkspaceStates(workspaces, previous), - ); - - const markLoading = (workspaceId: string) => { - setUnreadByWorkspace((previous) => { - const current = previous[workspaceId] ?? unknownUnreadState; - if (current.state === "ready") { - return previous; - } - return { - ...previous, - [workspaceId]: { hasUnread: false, state: "loading" }, - }; - }); - }; - - const markReady = ( - workspaceId: string, - result: { hasUnread: boolean; mentionCount: number }, - ) => { - setUnreadByWorkspace((previous) => ({ - ...previous, - [workspaceId]: { - hasUnread: result.hasUnread, - count: result.mentionCount > 0 ? result.mentionCount : undefined, - state: "ready", - }, - })); - }; - - const markError = (workspaceId: string) => { - setUnreadByWorkspace((previous) => ({ - ...previous, - [workspaceId]: { hasUnread: false, state: "error" }, - })); - }; - - const scheduleNextPoll = () => { - if (cancelled) return; - pollTimer = window.setTimeout(() => { - void pollInactiveWorkspaces(); - }, WORKSPACE_UNREAD_POLL_MS); - }; - - const pollInactiveWorkspaces = async () => { - if (inactiveWorkspaces.length === 0) { - return; - } - - let pubkey: string; - try { - pubkey = (await getIdentity()).pubkey; - } catch { - for (const workspace of inactiveWorkspaces) { - if (cancelled) return; - markError(workspace.id); - } - scheduleNextPoll(); - return; - } - - for (const workspace of inactiveWorkspaces) { - if (cancelled) return; - markLoading(workspace.id); - try { - const result = await pollWorkspaceUnread(workspace, pubkey); - if (cancelled) return; - markReady(workspace.id, result); - } catch (error) { - console.debug( - `[WorkspaceUnread] poll failed workspace=${workspace.id}:`, - error, - ); - if (cancelled) return; - markError(workspace.id); - } - } - - scheduleNextPoll(); - }; - - void pollInactiveWorkspaces(); - - return () => { - cancelled = true; - if (pollTimer !== null) { - window.clearTimeout(pollTimer); - } - }; - }, [activeWorkspaceId, workspaces]); - - const workspacesRef = React.useRef(workspaces); - workspacesRef.current = workspaces; - - const markRead = React.useCallback( - async (workspaceId: string) => { - const workspace = workspacesRef.current.find( - (candidate) => candidate.id === workspaceId, - ); - if (!workspace || workspaceId === activeWorkspaceId) return; - - const { pubkey } = await getIdentity(); - await markWorkspaceRead(workspace, pubkey); - // Optimistic clear — the next poll re-verifies against the relay. - setUnreadByWorkspace((previous) => ({ - ...previous, - [workspaceId]: { hasUnread: false, state: "ready" }, - })); - }, - [activeWorkspaceId], - ); - - return { unreadByWorkspace, markWorkspaceRead: markRead }; -} diff --git a/desktop/src/features/workspaces/useWorkspaces.tsx b/desktop/src/features/workspaces/useWorkspaces.tsx deleted file mode 100644 index e289baa1798..00000000000 --- a/desktop/src/features/workspaces/useWorkspaces.tsx +++ /dev/null @@ -1,203 +0,0 @@ -import { - createContext, - useCallback, - useContext, - useMemo, - useRef, - useState, -} from "react"; -import type { ReactNode } from "react"; - -import type { Workspace } from "./types"; -import { - clearWorkspaceStorage, - loadActiveWorkspaceId, - loadWorkspaces, - saveActiveWorkspaceId, - saveWorkspaces, -} from "./workspaceStorage"; -import { removeSelfProfileCachesForRelay } from "@/features/profile/lib/selfProfileStorage"; -import { removeChannelSnapshotForRelay } from "@/features/channels/channelSnapshot"; -import { removeMessageSnapshotsForRelay } from "@/features/messages/lib/messageSnapshot"; -import { clearSavedWorkspaceSnapshot } from "@/features/agents/activeAgentTurnsStore"; - -export type UseWorkspacesReturn = { - workspaces: Workspace[]; - activeWorkspace: Workspace | null; - /** Counter bumped when the active workspace's config changes (relayUrl/token). */ - reinitKey: number; - /** Add a workspace, deduplicating by relayUrl. Returns the final ID in the list. */ - addWorkspace: (workspace: Workspace) => string; - clearWorkspaces: () => void; - removeWorkspace: (id: string) => void; - switchWorkspace: (id: string) => void; - /** Force the active workspace to re-init (e.g. after a deep-link reconnect). */ - reconnectWorkspace: () => void; - updateWorkspace: ( - id: string, - updates: Partial< - Pick - >, - ) => void; -}; - -const WorkspacesContext = createContext(null); - -export function WorkspacesProvider({ children }: { children: ReactNode }) { - const value = useWorkspacesInternal(); - return ( - - {children} - - ); -} - -export function useWorkspaces(): UseWorkspacesReturn { - const ctx = useContext(WorkspacesContext); - if (!ctx) { - throw new Error("useWorkspaces must be used within a WorkspacesProvider"); - } - return ctx; -} - -function useWorkspacesInternal(): UseWorkspacesReturn { - const [workspaces, setWorkspacesState] = - useState(loadWorkspaces); - const [activeId, setActiveId] = useState( - loadActiveWorkspaceId, - ); - const [reinitKey, setReinitKey] = useState(0); - const workspacesRef = useRef(workspaces); - workspacesRef.current = workspaces; - - const activeWorkspace = useMemo( - () => workspaces.find((w) => w.id === activeId) ?? workspaces[0] ?? null, - [workspaces, activeId], - ); - - const addWorkspace = useCallback((workspace: Workspace): string => { - const existing = workspacesRef.current.find( - (w) => w.relayUrl === workspace.relayUrl, - ); - const resolvedId = existing?.id ?? workspace.id; - setWorkspacesState((prev) => { - const dup = prev.find((w) => w.relayUrl === workspace.relayUrl); - let next: Workspace[]; - if (dup) { - next = prev.map((w) => - w.id === dup.id - ? { - ...w, - name: workspace.name || w.name, - token: workspace.token ?? w.token, - pubkey: workspace.pubkey ?? w.pubkey, - } - : w, - ); - } else { - next = [...prev, workspace]; - } - saveWorkspaces(next); - return next; - }); - return resolvedId; - }, []); - - const clearWorkspaces = useCallback(() => { - clearWorkspaceStorage(); - setWorkspacesState([]); - setActiveId(null); - }, []); - - const removeWorkspace = useCallback( - (id: string) => { - // GC self-profile caches for the removed workspace's relay. Mirror the - // updater guard (length > 1) so we only GC when removal will actually - // proceed. Runs outside the updater — updaters can execute twice under - // React StrictMode. - if (workspaces.length > 1) { - const removed = workspaces.find((w) => w.id === id); - if (removed) { - removeSelfProfileCachesForRelay(removed.relayUrl); - removeChannelSnapshotForRelay(removed.relayUrl); - removeMessageSnapshotsForRelay(removed.relayUrl); - clearSavedWorkspaceSnapshot(id); - } - } - - setWorkspacesState((prev) => { - // Never allow removing the last workspace - if (prev.length <= 1) { - return prev; - } - const next = prev.filter((w) => w.id !== id); - saveWorkspaces(next); - - // If removing the active workspace, switch to first remaining - if (activeId === id && next.length > 0) { - saveActiveWorkspaceId(next[0].id); - setActiveId(next[0].id); - } - - return next; - }); - }, - [activeId, workspaces], - ); - - const switchWorkspace = useCallback( - (id: string) => { - if (id === activeId) return; - saveActiveWorkspaceId(id); - setActiveId(id); - }, - [activeId], - ); - - const reconnectWorkspace = useCallback(() => { - setReinitKey((k) => k + 1); - }, []); - - const updateWorkspace = useCallback( - ( - id: string, - updates: Partial< - Pick - >, - ) => { - setWorkspacesState((prev) => { - // Prevent duplicate relay URLs across workspaces - if ( - updates.relayUrl && - prev.some((w) => w.id !== id && w.relayUrl === updates.relayUrl) - ) { - return prev; - } - const next = prev.map((w) => (w.id === id ? { ...w, ...updates } : w)); - saveWorkspaces(next); - return next; - }); - // If the active workspace's relay URL or token changed, bump reinitKey - // so the React tree remounts with the new config. - if ( - id === activeId && - (updates.relayUrl || updates.token !== undefined) - ) { - setReinitKey((k) => k + 1); - } - }, - [activeId], - ); - - return { - workspaces, - activeWorkspace, - reinitKey, - addWorkspace, - clearWorkspaces, - removeWorkspace, - switchWorkspace, - reconnectWorkspace, - updateWorkspace, - }; -} diff --git a/desktop/src/main.tsx b/desktop/src/main.tsx index 43c812b8b40..7966d426372 100644 --- a/desktop/src/main.tsx +++ b/desktop/src/main.tsx @@ -5,8 +5,8 @@ import { NostrBindConsentDialog } from "@/features/profile/ui/NostrBindConsentDi import "@fontsource-variable/inter/wght.css"; import "@/shared/styles/globals.css"; import { UpdaterProvider } from "@/features/settings/hooks/UpdaterProvider"; -import { migrateLegacyWorkspaceStorageBeforeRender } from "@/features/workspaces/legacyWorkspaceStorage"; -import { WorkspacesProvider } from "@/features/workspaces/useWorkspaces"; +import { migrateLegacyCommunityStorageBeforeRender } from "@/features/communities/legacyCommunityStorage"; +import { CommunitiesProvider } from "@/features/communities/useCommunities"; import { ThemeProvider } from "@/shared/theme/ThemeProvider"; import { EmojiBurstProvider } from "@/shared/ui/EmojiBurstProvider"; import { PoofBurstProvider } from "@/shared/ui/PoofBurstProvider"; @@ -18,7 +18,7 @@ type E2eWindow = Window & { }; const E2E_DEFAULT_PUBKEY = "deadbeef".repeat(8); -const E2E_WORKSPACE_ID = "e2e-default-workspace"; +const E2E_COMMUNITY_ID = "e2e-default-community"; const ONBOARDING_COMPLETION_STORAGE_KEY_PREFIX = "buzz-onboarding-complete.v1:"; function configureDevE2eBridgeFromUrl() { @@ -34,14 +34,14 @@ function configureDevE2eBridgeFromUrl() { const e2eWindow = window as E2eWindow; e2eWindow.__BUZZ_E2E__ ??= { mode: "mock" }; - const workspace = { + const community = { addedAt: new Date().toISOString(), - id: E2E_WORKSPACE_ID, + id: E2E_COMMUNITY_ID, name: "E2E Test", relayUrl: "ws://localhost:3000", }; - window.localStorage.setItem("buzz-workspaces", JSON.stringify([workspace])); - window.localStorage.setItem("buzz-active-workspace-id", E2E_WORKSPACE_ID); + window.localStorage.setItem("buzz-communities", JSON.stringify([community])); + window.localStorage.setItem("buzz-active-community-id", E2E_COMMUNITY_ID); window.localStorage.setItem( `${ONBOARDING_COMPLETION_STORAGE_KEY_PREFIX}${E2E_DEFAULT_PUBKEY}`, "true", @@ -51,7 +51,7 @@ function configureDevE2eBridgeFromUrl() { function renderApp() { ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - + @@ -65,7 +65,7 @@ function renderApp() { - + , ); } @@ -84,7 +84,7 @@ async function installE2eBridgeIfConfigured() { async function bootstrap() { configureDevE2eBridgeFromUrl(); await installE2eBridgeIfConfigured(); - await migrateLegacyWorkspaceStorageBeforeRender(); + await migrateLegacyCommunityStorageBeforeRender(); renderApp(); } diff --git a/desktop/src/shared/api/workspaceProfile.ts b/desktop/src/shared/api/communityProfile.ts similarity index 63% rename from desktop/src/shared/api/workspaceProfile.ts rename to desktop/src/shared/api/communityProfile.ts index f317ceda532..da7cb06e60d 100644 --- a/desktop/src/shared/api/workspaceProfile.ts +++ b/desktop/src/shared/api/communityProfile.ts @@ -1,29 +1,29 @@ /** - * Workspace icon, Buzz extension to NIP-43 + standard NIP-11 `icon`. + * Community icon, Buzz extension to NIP-43 + standard NIP-11 `icon`. * * An admin/owner publishes a kind:9033 command carrying the icon in an * `["icon", value]` tag; the relay validates the sender's relay role and * stores the icon per community, serving it in the standard `icon` field of * its NIP-11 relay information document. Every member's client reads NIP-11, - * so the whole workspace sees the same icon. + * so the whole community sees the same icon. * * The icon value is a small `data:image/*` URL (downscaled client-side - * before publish) so it renders for INACTIVE workspaces straight from the + * before publish) so it renders for INACTIVE communities straight from the * document — no cross-relay media fetch behind another relay's auth wall. */ import { relayClient } from "@/shared/api/relayClient"; import { invokeTauri, signRelayEvent } from "@/shared/api/tauri"; -/** Buzz: admin command to set the workspace profile (icon). */ -export const KIND_SET_WORKSPACE_PROFILE = 9033; +/** Buzz: admin command to set the community profile (icon). */ +export const KIND_SET_COMMUNITY_PROFILE = 9033; /** - * Fetch a workspace's icon from its relay's NIP-11 document (plain + * Fetch a community's icon from its relay's NIP-11 document (plain * unauthenticated HTTP via the Tauri backend — works for inactive - * workspaces too). Unreachable relay or no icon → null. + * communities too). Unreachable relay or no icon → null. */ -export async function fetchWorkspaceIcon( +export async function fetchCommunityIcon( relayUrl: string, ): Promise { const icon = await invokeTauri("fetch_workspace_icon", { @@ -33,19 +33,19 @@ export async function fetchWorkspaceIcon( } /** - * Publish a kind:9033 command setting (or clearing, with "") the workspace + * Publish a kind:9033 command setting (or clearing, with "") the community * icon on the active relay. Requires relay admin/owner role — the relay * rejects the command otherwise. */ -export async function setWorkspaceIcon(icon: string): Promise { +export async function setCommunityIcon(icon: string): Promise { const event = await signRelayEvent({ - kind: KIND_SET_WORKSPACE_PROFILE, + kind: KIND_SET_COMMUNITY_PROFILE, content: "", tags: [["icon", icon]], }); await relayClient.publishEvent( event, - "Timed out while updating the workspace icon.", - "Failed to update the workspace icon.", + "Timed out while updating the community icon.", + "Failed to update the community icon.", ); } diff --git a/desktop/src/shared/api/customEmoji.ts b/desktop/src/shared/api/customEmoji.ts index 2bc85aab159..6020ec5928f 100644 --- a/desktop/src/shared/api/customEmoji.ts +++ b/desktop/src/shared/api/customEmoji.ts @@ -1,9 +1,9 @@ /** - * Workspace custom emoji (NIP-30, per-user sets). + * Community custom emoji (NIP-30, per-user sets). * * Each member publishes their OWN kind:30030 parameterized-replaceable event, * signed as themselves, keyed by `(pubkey, 30030, "buzz:custom-emoji")`. The - * "workspace palette" shown in the picker/renderer is the client-side UNION of + * "community palette" shown in the picker/renderer is the client-side UNION of * every member's set, collapsed to one entry per shortcode (deterministic * winner) — a view computed on read, not stored state. Downstream identity is * shortcode-only (emoji-mart id, autocomplete key, reaction lookup, send tag), @@ -30,7 +30,7 @@ export const CUSTOM_EMOJI_SET_D_TAG = "buzz:custom-emoji"; /** * Resolve the image URL for a reaction whose content is a custom-emoji - * `:shortcode:`, from the workspace set. Returns undefined for unicode + * `:shortcode:`, from the community set. Returns undefined for unicode * reactions or unknown shortcodes (the kind:7 then carries no emoji tag). */ export function reactionEmojiUrl( @@ -104,7 +104,7 @@ export function customEmojiFromEvent(event: RelayEvent | null): CustomEmoji[] { } /** - * Union every member's kind:30030 set into the workspace palette, collapsed to + * Union every member's kind:30030 set into the community palette, collapsed to * one entry per shortcode. When members disagree on a shortcode's URL, the * most recently published set wins (`created_at` is signed event data, so this * is as deterministic and fetch-order-independent as any pure function of the @@ -134,20 +134,20 @@ export function unionCustomEmoji( } /** Fetch every member's 30030 set (catch-up). */ -export async function fetchWorkspaceEmojiEvents(): Promise { +export async function fetchCommunityEmojiEvents(): Promise { return relayClient.fetchEvents({ kinds: [KIND_EMOJI_SET], "#d": [CUSTOM_EMOJI_SET_D_TAG], - // One 30030 per member; a workspace has far fewer than this. The relay + // One 30030 per member; a community has far fewer than this. The relay // already keeps only the latest per (pubkey, d_tag), so this is the member // count, not history depth. limit: 500, }); } -/** Fetch the workspace custom emoji palette (union). Empty when none. */ +/** Fetch the community custom emoji palette (union). Empty when none. */ export async function listCustomEmoji(): Promise { - const events = await fetchWorkspaceEmojiEvents(); + const events = await fetchCommunityEmojiEvents(); return unionCustomEmoji(events); } diff --git a/desktop/src/shared/api/invites.ts b/desktop/src/shared/api/invites.ts index 3dc50b71612..f0161d62050 100644 --- a/desktop/src/shared/api/invites.ts +++ b/desktop/src/shared/api/invites.ts @@ -8,7 +8,7 @@ import { getRelayHttpUrl, signRelayEvent } from "@/shared/api/tauri"; // - POST /api/invites — mint a code (relay checks owner/admin role) // - POST /api/invites/claim — claim a code, signed by the *joining* key. // This one targets an arbitrary relay (the invite's relay, not necessarily -// the active workspace), so the claim helper takes an explicit ws URL. +// the active community), so the claim helper takes an explicit ws URL. const NIP98_KIND = 27235; @@ -84,7 +84,7 @@ async function invitePost( return json as T; } -/** Mint an invite code on the active workspace's relay (owner/admin only). */ +/** Mint an invite code on the active community's relay (owner/admin only). */ export async function mintInvite(ttlSecs?: number): Promise { const base = await getRelayHttpUrl(); const body = JSON.stringify(ttlSecs ? { ttl_secs: ttlSecs } : {}); @@ -98,7 +98,7 @@ export async function mintInvite(ttlSecs?: number): Promise { /** * Claim an invite code against `relayWsUrl` (the invite's relay — not - * necessarily the active workspace), signed by this app's identity key. + * necessarily the active community), signed by this app's identity key. */ export async function claimInvite( relayWsUrl: string, diff --git a/desktop/src/shared/api/readOnlyRelayClient.ts b/desktop/src/shared/api/readOnlyRelayClient.ts index 745ebaa90e1..1c24acc8fce 100644 --- a/desktop/src/shared/api/readOnlyRelayClient.ts +++ b/desktop/src/shared/api/readOnlyRelayClient.ts @@ -27,9 +27,9 @@ type PendingPublish = { }; /** - * Minimal relay session for inactive-workspace observation (and the rail's + * Minimal relay session for inactive-community observation (and the rail's * cross-relay "mark all as read" publish). It never reads or mutates the - * active workspace backend relay URL; callers pass an explicit URL and should + * active community backend relay URL; callers pass an explicit URL and should * disconnect as soon as their polling batch finishes. */ export class ReadOnlyRelayClient { diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index 63b71497de3..410551f9e5a 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -81,8 +81,8 @@ export class RelayClient { * the reconnect-timer / retry-wrapper paths racing back to "reconnecting" * after we've already declared the session dead. * - * Cleared only on explicit user re-engagement: `disconnect()` (workspace - * switch — the singleton is being reused for a different workspace) and + * Cleared only on explicit user re-engagement: `disconnect()` (community + * switch — the singleton is being reused for a different community) and * `preconnect()` (caller is asking us to come back up). */ private terminal = false; @@ -99,11 +99,11 @@ export class RelayClient { /** * Cleanly tear down the connection without scheduling a reconnect. - * Used during workspace switches to reset the singleton before the - * new workspace applies. + * Used during community switches to reset the singleton before the + * new community applies. */ disconnect() { - const error = new Error("Relay disconnected for workspace switch."); + const error = new Error("Relay disconnected for community switch."); if (this.reconnectTimeout) { window.clearTimeout(this.reconnectTimeout); @@ -119,7 +119,7 @@ export class RelayClient { this.connectionStateEmitter.set("idle"); if (this.wsId !== null) { - void closeWebSocket(this.wsId, "workspace switch"); + void closeWebSocket(this.wsId, "community switch"); this.wsId = null; } diff --git a/desktop/src/shared/api/relayClientShared.ts b/desktop/src/shared/api/relayClientShared.ts index 06b8a8b2e9a..1eb11cc8ef3 100644 --- a/desktop/src/shared/api/relayClientShared.ts +++ b/desktop/src/shared/api/relayClientShared.ts @@ -3,7 +3,7 @@ import type { RelayEvent } from "@/shared/api/types"; /** * Observable connection state for the relay singleton. * - * - `idle` — never tried to connect yet (post-init, pre-workspace). + * - `idle` — never tried to connect yet (post-init, pre-community). * - `connecting` — initial socket + AUTH handshake in flight. * - `connected` — socket open and AUTH'd. * - `reconnecting` — socket dropped, waiting for the backoff timer. @@ -11,7 +11,7 @@ import type { RelayEvent } from "@/shared/api/types"; * for a long time (half-open / Warp split-brain). We * surface this so the UI can warn even though tungstenite * hasn't reported anything wrong yet. - * - `disconnected` — final/terminal disconnect (auth rejected, workspace + * - `disconnected` — final/terminal disconnect (auth rejected, community * switch, etc.) — no auto-reconnect scheduled. */ export type ConnectionState = diff --git a/desktop/src/shared/api/relayConnectionStateEmitter.ts b/desktop/src/shared/api/relayConnectionStateEmitter.ts index e3773546de1..5b6b4ea6b72 100644 --- a/desktop/src/shared/api/relayConnectionStateEmitter.ts +++ b/desktop/src/shared/api/relayConnectionStateEmitter.ts @@ -50,7 +50,7 @@ export class RelayConnectionStateEmitter { }; } - /** Drop all listeners. Called on workspace teardown to match the legacy + /** Drop all listeners. Called on community teardown to match the legacy * pattern used for the reconnect listener set. */ clear(): void { this.listeners.clear(); diff --git a/desktop/src/shared/api/relayQueryInvalidation.test.mjs b/desktop/src/shared/api/relayQueryInvalidation.test.mjs index b163721c67c..6130992a30d 100644 --- a/desktop/src/shared/api/relayQueryInvalidation.test.mjs +++ b/desktop/src/shared/api/relayQueryInvalidation.test.mjs @@ -48,7 +48,7 @@ test("relay invalidation excludes local Tauri and disk-only query roots", () => ["acp-runtimes"], ["backend-providers"], ["managed-agent-log", "agent-1", 200], - ["workspace-icon", "wss://relay.example"], + ["community-icon", "wss://relay.example"], ["agent-memory", "agent-1"], ]) { assert.equal(isRelayDependentQueryKey(queryKey), false, queryKey.join("/")); diff --git a/desktop/src/shared/api/relayReconnectPolicy.ts b/desktop/src/shared/api/relayReconnectPolicy.ts index bcdb53fc19f..c124c082455 100644 --- a/desktop/src/shared/api/relayReconnectPolicy.ts +++ b/desktop/src/shared/api/relayReconnectPolicy.ts @@ -9,7 +9,7 @@ * * - **Terminal sessions never reconnect.** When the relay has explicitly * rejected us (today: kind:22242 AUTH OK=false) the session is dead - * until the user re-engages (workspace switch or explicit preconnect). + * until the user re-engages (community switch or explicit preconnect). * This guards the reconnect-timer catch handler — and the retry wrappers * in `publishEvent` / `sendRawWithReconnectRetry` — from racing the * `disconnected` state back to `reconnecting`. diff --git a/desktop/src/shared/api/relayWebSocketClose.test.mjs b/desktop/src/shared/api/relayWebSocketClose.test.mjs index 5fc5844d883..59b646385fd 100644 --- a/desktop/src/shared/api/relayWebSocketClose.test.mjs +++ b/desktop/src/shared/api/relayWebSocketClose.test.mjs @@ -8,7 +8,7 @@ import { closeWebSocket } from "./relayWebSocketClose.ts"; test("closeWebSocket sends a Close frame through plugin:websocket|send", async () => { const calls = []; - await closeWebSocket(42, "workspace switch", async (cmd, args) => { + await closeWebSocket(42, "community switch", async (cmd, args) => { calls.push({ cmd, args }); }); @@ -18,7 +18,7 @@ test("closeWebSocket sends a Close frame through plugin:websocket|send", async ( id: 42, message: { type: "Close", - data: { code: 1000, reason: "workspace switch" }, + data: { code: 1000, reason: "community switch" }, }, }); }); @@ -31,7 +31,7 @@ test("closeWebSocket swallows send failures (socket already gone)", async () => // Regression guard: tauri-plugin-websocket registers only `connect` and // `send` — there is no `disconnect` command. Invoking one rejects silently -// and leaks the socket (relay zombie pile, workspace-switch disconnects). +// and leaks the socket (relay zombie pile, community-switch disconnects). // Any socket teardown must go through closeWebSocket. test("no source file invokes the nonexistent plugin:websocket|disconnect command", () => { const srcRoot = path.resolve( diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 1779e974fcf..2236d742ca7 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -1272,7 +1272,7 @@ export async function cancelPairing(): Promise { await invokeTauri("cancel_pairing"); } -export async function applyWorkspace( +export async function applyCommunity( relayUrl: string, nsec?: string, token?: string, diff --git a/desktop/src/shared/api/useReconnectRelay.ts b/desktop/src/shared/api/useReconnectRelay.ts index e892ca5c623..0dbdb832fed 100644 --- a/desktop/src/shared/api/useReconnectRelay.ts +++ b/desktop/src/shared/api/useReconnectRelay.ts @@ -4,7 +4,7 @@ * Delegates all reconnect logic to the module-level `relayReconnectController` * singleton so that all mounted hook instances (banner + sidebar card) share * a single in-flight state. Deliberately uses `preconnect()` rather than the - * full `reconnectWorkspace()` path to avoid unmounting the React tree and + * full `reconnectCommunity()` path to avoid unmounting the React tree and * clearing draft state. * * See `relayReconnectController.ts` for the three-phase strategy details. diff --git a/desktop/src/shared/deep-link.ts b/desktop/src/shared/deep-link.ts index 5466ad73b54..47a5554e05f 100644 --- a/desktop/src/shared/deep-link.ts +++ b/desktop/src/shared/deep-link.ts @@ -6,16 +6,16 @@ import { isInviteExpiredError, } from "@/shared/api/inviteHelpers"; import { claimInvite } from "@/shared/api/invites"; -import type { Workspace } from "@/features/workspaces/types"; +import type { Community } from "@/features/communities/types"; import { - deriveWorkspaceName, + deriveCommunityName, normalizeRelayUrl, -} from "@/features/workspaces/workspaceStorage"; +} from "@/features/communities/communityStorage"; export interface DeepLinkDeps { - addWorkspace: (workspace: Workspace) => string; - switchWorkspace: (id: string) => void; - reconnectWorkspace: () => void; + addCommunity: (community: Community) => string; + switchCommunity: (id: string) => void; + reconnectCommunity: () => void; } /** @@ -54,13 +54,13 @@ export type JoinDeepLinkPayload = { * Register listeners for deep-link events emitted by the Rust backend. * * When a `buzz://connect?relay=` link is opened, the handler - * adds a workspace for the relay (deduplicating by URL) and switches + * adds a community for the relay (deduplicating by URL) and switches * to it. Returns an unlisten function to tear down all listeners. * * When a `buzz://join?relay=&code=` link is opened (relay * invite landing page), the handler first claims the invite against the * relay's HTTP API — signed by this app's identity key — and only adds and - * switches to the workspace once the relay has admitted the key. + * switches to the community once the relay has admitted the key. * * `buzz://message?…` is handled separately by `listenForMessageDeepLinks`, * because it needs to dispatch into the router which only exists below the @@ -69,17 +69,17 @@ export type JoinDeepLinkPayload = { export function listenForDeepLinks(deps: DeepLinkDeps): Promise { const addAndSwitch = (rawRelayUrl: string) => { const relayUrl = normalizeRelayUrl(rawRelayUrl); - const name = deriveWorkspaceName(relayUrl); - const id = deps.addWorkspace({ + const name = deriveCommunityName(relayUrl); + const id = deps.addCommunity({ id: crypto.randomUUID(), name, relayUrl, addedAt: new Date().toISOString(), }); - deps.switchWorkspace(id); - // If addWorkspace returned the already-active workspace (same relay URL), - // switchWorkspace is a no-op — force re-init so the connection refreshes. - deps.reconnectWorkspace(); + deps.switchCommunity(id); + // If addCommunity returned the already-active community (same relay URL), + // switchCommunity is a no-op — force re-init so the connection refreshes. + deps.reconnectCommunity(); return name; }; diff --git a/desktop/src/shared/lib/customEmojiTags.ts b/desktop/src/shared/lib/customEmojiTags.ts index 67eccdba5f0..1444d9c3b06 100644 --- a/desktop/src/shared/lib/customEmojiTags.ts +++ b/desktop/src/shared/lib/customEmojiTags.ts @@ -1,7 +1,7 @@ /** * Build NIP-30 `["emoji", shortcode, url]` tags for the custom shortcodes that * actually appear in an outgoing message body. Pure + testable; the resolved - * emoji list comes from the workspace palette (see `customEmoji.ts`). + * emoji list comes from the community palette (see `customEmoji.ts`). * * Mirrors how @mentions become `p` tags: the tag set is derived from the final * content at send time so the event is self-contained — any NIP-30 client can diff --git a/desktop/src/shared/lib/linkPreview.ts b/desktop/src/shared/lib/linkPreview.ts index d806fb38f2b..5cba96da877 100644 --- a/desktop/src/shared/lib/linkPreview.ts +++ b/desktop/src/shared/lib/linkPreview.ts @@ -320,11 +320,11 @@ function parseLinearIssue(parsed: URL): SupportedLinkPreview | null { const issueSegmentIndex = segments.findIndex( (segment) => segment.toLowerCase() === "issue", ); - const workspace = segments[0]; + const community = segments[0]; const issueId = segments[issueSegmentIndex + 1]?.toUpperCase(); if ( - !workspace || + !community || issueSegmentIndex < 1 || !issueId || !/^[A-Z][A-Z0-9]*-\d+$/.test(issueId) diff --git a/desktop/src/shared/lib/localStorageQuota.ts b/desktop/src/shared/lib/localStorageQuota.ts index 1a121a781ae..bf194a6cc8e 100644 --- a/desktop/src/shared/lib/localStorageQuota.ts +++ b/desktop/src/shared/lib/localStorageQuota.ts @@ -2,7 +2,7 @@ * Quota-aware localStorage writes with pure-cache eviction recovery. * * The desktop webview caps localStorage at ~5 MB per origin. Writers of - * load-bearing state (read-state, workspaces) must not leak QuotaExceededError + * load-bearing state (read-state, communities) must not leak QuotaExceededError * into React click/render paths. On a failed write this evicts snapshot caches * — safe to drop, they repaint from the relay — and retries the write once. */ diff --git a/desktop/src/shared/lib/mediaUrl.ts b/desktop/src/shared/lib/mediaUrl.ts index 622c1ace9c6..94e4cc41e55 100644 --- a/desktop/src/shared/lib/mediaUrl.ts +++ b/desktop/src/shared/lib/mediaUrl.ts @@ -72,7 +72,7 @@ if (typeof window !== "undefined") { /** * Reset module-level caches so the next render re-fetches the proxy port - * and relay origin for the new workspace. + * and relay origin for the new community. */ export function resetMediaCaches(): void { cachedPort = null; diff --git a/desktop/src/shared/styles/globals/animations.css b/desktop/src/shared/styles/globals/animations.css index 7ee44cde14d..42a9038c677 100644 --- a/desktop/src/shared/styles/globals/animations.css +++ b/desktop/src/shared/styles/globals/animations.css @@ -569,7 +569,7 @@ /* ── Tiptap rich-text composer ──────────────────────────────────────── */ /* ── Setup / cold-boot loading screen ─────────────────────────────────── - Theme-adaptive grainient background for the "setting up your workspace" + Theme-adaptive grainient background for the "setting up your community" gate. Restored from pre-#1570; the animated Buzz mark is the hero (see AppLoadingGate in app/App.tsx). */ diff --git a/desktop/src/shared/styles/globals/theme.css b/desktop/src/shared/styles/globals/theme.css index 5704e14a535..978e33b5dcf 100644 --- a/desktop/src/shared/styles/globals/theme.css +++ b/desktop/src/shared/styles/globals/theme.css @@ -183,7 +183,7 @@ * `data-buzz-glass-footer-wrap`) so they join the same visual layer. Two * chrome surfaces rendered outside the wrapper also get precise selectors: the * portaled mobile sidebar (`[data-sidebar="sidebar"][data-mobile="true"]`) and - * the workspace rail (`[data-testid="workspace-rail"]`). + * the community rail (`[data-testid="community-rail"]`). * Scoping this way keeps the branding on the app chrome and off unrelated * `bg-sidebar` consumers rendered in portals outside the shell (e.g. the * persona catalog dialog). `background-attachment: fixed` anchors the gradient @@ -239,7 +239,7 @@ .bg-sidebar:not([data-buzz-flat]), :root[data-buzz-sidebar] [data-sidebar="sidebar"][data-mobile="true"].bg-sidebar, -:root[data-buzz-sidebar] [data-testid="workspace-rail"].bg-sidebar { +:root[data-buzz-sidebar] [data-testid="community-rail"].bg-sidebar { background-color: transparent; background-image: linear-gradient( to bottom, @@ -475,7 +475,7 @@ :root[data-buzz-translucent] .group\/sidebar-wrapper, :root[data-buzz-translucent] [data-sidebar="sidebar"][data-mobile="true"].bg-sidebar, -:root[data-buzz-translucent] [data-testid="workspace-rail"].bg-sidebar { +:root[data-buzz-translucent] [data-testid="community-rail"].bg-sidebar { background-color: transparent; /* Buzz color wash above a neutral frost wash. */ background-image: diff --git a/desktop/src/shared/ui/markdown/nodeCache.ts b/desktop/src/shared/ui/markdown/nodeCache.ts index a90c2fcfe62..97cbe212d32 100644 --- a/desktop/src/shared/ui/markdown/nodeCache.ts +++ b/desktop/src/shared/ui/markdown/nodeCache.ts @@ -42,8 +42,8 @@ const MARKDOWN_NODE_CACHE_LIMIT = 1000; const MARKDOWN_NODE_CACHE_MAX_CONTENT_LENGTH = 32_000; const markdownNodeCache = new Map(); -/** Workspace switches swap relays; drop parses keyed against the old - * workspace's mention/channel-name space (see `resetWorkspaceState`). */ +/** Community switches swap relays; drop parses keyed against the old + * community's mention/channel-name space (see `resetCommunityState`). */ export function clearMarkdownNodeCache() { markdownNodeCache.clear(); } diff --git a/desktop/src/shared/ui/videoPlayerState.ts b/desktop/src/shared/ui/videoPlayerState.ts index f430f4cc2ea..b2134822ed2 100644 --- a/desktop/src/shared/ui/videoPlayerState.ts +++ b/desktop/src/shared/ui/videoPlayerState.ts @@ -1,6 +1,6 @@ // Inline/review playback state survives player remounts (for example route // swaps, optimistic-to-acked row replacement, or markdown context refreshes) -// without leaking across workspace switches. +// without leaking across community switches. const inlinePlaybackPositions = new Map(); const openReviewKeys = new Set(); const reviewPlaybackPositions = new Map(); diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 9dd66b016af..0b437008cc3 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -136,8 +136,8 @@ type E2eConfig = { feedReadError?: string; canvasReadError?: string; /** Delay (ms) for `apply_workspace` so e2e tests can observe the - * workspace-switch gate. 0/undefined = instant. */ - applyWorkspaceDelayMs?: number; + * community-switch gate. 0/undefined = instant. */ + applyCommunityDelayMs?: number; openDmDelayMs?: number; sendMessageDelayMs?: number; /** Reject successive kind-9 sends with these messages, then resume. */ @@ -673,7 +673,7 @@ function createMockRelayMembershipEvent(): RelayEvent { /** * Per-user custom emoji sets (kind:30030) the mock WS serves for - * `listCustomEmoji` REQs. The workspace palette is the client-side UNION of + * `listCustomEmoji` REQs. The community palette is the client-side UNION of * every member's own set (d=`buzz:custom-emoji`). We serve TWO member-authored * sets from distinct pubkeys so the e2e exercises the union/collapse path, not * a single relay-owned set. `:buzz:` is the stable shortcode exercised by @@ -2283,7 +2283,7 @@ const mockChannels: MockChannel[] = [ visibility: "private", description: "Company announcements", topic: "Leadership updates", - purpose: "Read-only announcements for the workspace.", + purpose: "Read-only announcements for the community.", last_message_at: null, archived_at: null, created_by: ALICE_PUBKEY, @@ -4453,7 +4453,7 @@ const MOCK_PROJECT_SEEDS = [ dtag: "buzz", name: "buzz", description: - "Relay, desktop, and mobile clients for the Buzz workspace platform.", + "Relay, desktop, and mobile clients for the Buzz community platform.", owner: MOCK_IDENTITY_PUBKEY, contributors: [ALICE_PUBKEY, BOB_PUBKEY, CHARLIE_PUBKEY], activityLevel: 4, @@ -7978,7 +7978,7 @@ function sendToMockSocket(args: { if (filter.kinds?.includes(KIND_EMOJI_SET)) { // Honor `authors` so `fetchOwnEmoji` (authors:[me]) sees only the // caller's set, while the union fetch (no authors) sees every member's — - // matching the real relay and the own-vs-workspace split in the UI. + // matching the real relay and the own-vs-community split in the UI. const authors = filter.authors?.map((a) => a.toLowerCase()); for (const emojiEvent of createMockCustomEmojiSetEvents()) { if (authors && !authors.includes(emojiEvent.pubkey.toLowerCase())) { @@ -8632,7 +8632,7 @@ export function maybeInstallE2eTauriMocks() { (payload as { nsec?: string } | null)?.nsec ?? "", ); case "apply_workspace": { - const applyDelayMs = activeConfig?.mock?.applyWorkspaceDelayMs ?? 0; + const applyDelayMs = activeConfig?.mock?.applyCommunityDelayMs ?? 0; if (applyDelayMs > 0) { return new Promise((resolve) => window.setTimeout(resolve, applyDelayMs), @@ -8734,7 +8734,7 @@ export function maybeInstallE2eTauriMocks() { author_name: "Git Importer", author_email: "git-importer@example.com", timestamp: Math.floor(Date.now() / 1000) - 7_200, - subject: "Merge remote project history into local workspace", + subject: "Merge remote project history into local community", }, ], contributors: [ @@ -8763,7 +8763,7 @@ export function maybeInstallE2eTauriMocks() { kind: "blob", size: 18420, preview_content: - 'export function ProjectDetailScreen() {\n return ;\n}\n', + 'export function ProjectDetailScreen() {\n return ;\n}\n', }, { path: "desktop/src/features/projects/ui/ProjectsView.tsx", @@ -8803,8 +8803,8 @@ export function maybeInstallE2eTauriMocks() { "@@ -1,6 +1,8 @@", ' import { Tabs } from "@/shared/ui/tabs";', "", - "-function WorkspaceTabs() {", - "+function WorkspaceTabs({ selectedCommitHash }) {", + "-function CommunityTabs() {", + "+function CommunityTabs({ selectedCommitHash }) {", '+ const [selectedTab, setSelectedTab] = useState("overview");', "+", " return (", diff --git a/desktop/tests/e2e/boot-splash.spec.ts b/desktop/tests/e2e/boot-splash.spec.ts index 231475b26bf..053ee84c724 100644 --- a/desktop/tests/e2e/boot-splash.spec.ts +++ b/desktop/tests/e2e/boot-splash.spec.ts @@ -1,7 +1,7 @@ import { expect, test } from "@playwright/test"; import { installMockBridge } from "../helpers/bridge"; -// Cold-boot splash hold: on a real boot the workspace resolves in well under +// Cold-boot splash hold: on a real boot the community resolves in well under // 100ms — before the hidden Tauri window ever puts a frame on screen — so the // loading gate keeps the flapping bee up as an overlay above the already // mounted app for a minimum visible duration, then fades out. E2E runs skip diff --git a/desktop/tests/e2e/workspace-rail.spec.ts b/desktop/tests/e2e/community-rail.spec.ts similarity index 54% rename from desktop/tests/e2e/workspace-rail.spec.ts rename to desktop/tests/e2e/community-rail.spec.ts index b4c0f64cc1f..d9b3d7ddfdd 100644 --- a/desktop/tests/e2e/workspace-rail.spec.ts +++ b/desktop/tests/e2e/community-rail.spec.ts @@ -4,105 +4,106 @@ import { installMockBridge } from "../helpers/bridge"; const RELAY_URL = "ws://localhost:3000"; -const WORKSPACE_A = { +const COMMUNITY_A = { id: "ws-a", name: "Alpha", relayUrl: RELAY_URL, addedAt: "2026-01-01T00:00:00.000Z", }; -const WORKSPACE_B = { +const COMMUNITY_B = { id: "ws-b", name: "Bravo", relayUrl: "ws://localhost:3001", addedAt: "2026-01-02T00:00:00.000Z", }; -async function seedWorkspaces( +async function seedCommunities( page: import("@playwright/test").Page, - workspaces: Array>, + communities: Array>, activeId: string, ) { await page.addInitScript( ({ list, active }) => { - window.localStorage.setItem("buzz-workspaces", JSON.stringify(list)); - window.localStorage.setItem("buzz-active-workspace-id", active); + window.localStorage.setItem("buzz-communities", JSON.stringify(list)); + window.localStorage.setItem("buzz-active-community-id", active); }, - { list: workspaces, active: activeId }, + { list: communities, active: activeId }, ); } -test.describe("workspace rail", () => { - test("shows a button per workspace and highlights the active one", async ({ +test.describe("community rail", () => { + test("shows a button per community and highlights the active one", async ({ page, }) => { - await installMockBridge(page, undefined, { skipWorkspaceSeed: true }); - await seedWorkspaces(page, [WORKSPACE_A, WORKSPACE_B], WORKSPACE_A.id); + await installMockBridge(page, undefined, { skipCommunitySeed: true }); + await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id); await page.goto("/"); - const rail = page.getByTestId("workspace-rail"); + const rail = page.getByTestId("community-rail"); await expect(rail).toBeVisible(); - const buttonA = page.getByTestId(`workspace-rail-button-${WORKSPACE_A.id}`); - const buttonB = page.getByTestId(`workspace-rail-button-${WORKSPACE_B.id}`); + const buttonA = page.getByTestId(`community-rail-button-${COMMUNITY_A.id}`); + const buttonB = page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`); await expect(buttonA).toBeVisible(); await expect(buttonB).toBeVisible(); - // The active workspace is marked via aria-current. + // The active community is marked via aria-current. await expect(buttonA).toHaveAttribute("aria-current", "true"); await expect(buttonB).not.toHaveAttribute("aria-current", "true"); - // The add-workspace affordance lives at the bottom of the rail. - await expect(page.getByTestId("workspace-rail-add")).toBeVisible(); + // The add-community affordance lives at the bottom of the rail. + await expect(page.getByTestId("community-rail-add")).toBeVisible(); }); - test("restores pointer events after dismissing workspace settings", async ({ + test("restores pointer events after dismissing community settings", async ({ page, }) => { - await installMockBridge(page, undefined, { skipWorkspaceSeed: true }); - await seedWorkspaces(page, [WORKSPACE_A, WORKSPACE_B], WORKSPACE_A.id); + await installMockBridge(page, undefined, { skipCommunitySeed: true }); + await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id); await page.goto("/"); - const workspaceButton = page.getByTestId( - `workspace-rail-button-${WORKSPACE_A.id}`, + const communityButton = page.getByTestId( + `community-rail-button-${COMMUNITY_A.id}`, ); - await workspaceButton.click({ button: "right" }); - await page.getByRole("menuitem", { name: "Workspace settings" }).click(); + await communityButton.click({ button: "right" }); + await page.getByRole("menuitem", { name: "Community settings" }).click(); await expect( - page.getByRole("dialog", { name: "Edit Workspace" }), + page.getByRole("dialog", { name: "Edit Community" }), ).toBeVisible(); await page.mouse.click(0, 0); await expect( - page.getByRole("dialog", { name: "Edit Workspace" }), + page.getByRole("dialog", { name: "Edit Community" }), ).toHaveCount(0); await expect(page.locator("body")).not.toHaveCSS("pointer-events", "none"); - await page.getByTestId(`workspace-rail-button-${WORKSPACE_B.id}`).click(); + await page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`).click(); await expect .poll(() => page.evaluate(() => - window.localStorage.getItem("buzz-active-workspace-id"), + window.localStorage.getItem("buzz-active-community-id"), ), ) - .toBe(WORKSPACE_B.id); + .toBe(COMMUNITY_B.id); }); - test("switches the active workspace on click", async ({ page }) => { - await installMockBridge(page, undefined, { skipWorkspaceSeed: true }); - await seedWorkspaces(page, [WORKSPACE_A, WORKSPACE_B], WORKSPACE_A.id); + test("switches the active community on click", async ({ page }) => { + await installMockBridge(page, undefined, { skipCommunitySeed: true }); + await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id); + await page.goto("/"); - await page.getByTestId(`workspace-rail-button-${WORKSPACE_B.id}`).click(); + await page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`).click(); - // Switching persists the newly active workspace id (the app then remounts - // against that relay via the existing workspace-init path). + // Switching persists the newly active community id (the app then remounts + // against that relay via the existing community-init path). await expect .poll(() => page.evaluate(() => - window.localStorage.getItem("buzz-active-workspace-id"), + window.localStorage.getItem("buzz-active-community-id"), ), ) - .toBe(WORKSPACE_B.id); + .toBe(COMMUNITY_B.id); }); test("shows the quiet switch gate, not the boot splash, while switching", async ({ @@ -111,47 +112,47 @@ test.describe("workspace rail", () => { // Slow down apply_workspace so the loading phase is observable. await installMockBridge( page, - { applyWorkspaceDelayMs: 800 }, - { skipWorkspaceSeed: true }, + { applyCommunityDelayMs: 800 }, + { skipCommunitySeed: true }, ); - await seedWorkspaces(page, [WORKSPACE_A, WORKSPACE_B], WORKSPACE_A.id); + await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id); await page.goto("/"); // Cold boot still uses the full splash. await expect(page.getByTestId("app-loading-gate")).toBeVisible(); - const buttonB = page.getByTestId(`workspace-rail-button-${WORKSPACE_B.id}`); + const buttonB = page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`); await expect(buttonB).toBeVisible(); await buttonB.click(); - // The switch renders the quiet gate; the "Setting up your workspace" + // The switch renders the quiet gate; the "Setting up your community" // splash must not reappear. - await expect(page.getByTestId("workspace-switch-gate")).toBeVisible(); + await expect(page.getByTestId("community-switch-gate")).toBeVisible(); await expect(page.getByTestId("app-loading-gate")).toHaveCount(0); - // The app settles into the new workspace once apply completes. + // The app settles into the new community once apply completes. await expect(buttonB).toHaveAttribute("aria-current", "true"); }); - test("hides the rail with a single workspace", async ({ page }) => { - await installMockBridge(page, undefined, { skipWorkspaceSeed: true }); - await seedWorkspaces(page, [WORKSPACE_A], WORKSPACE_A.id); + test("hides the rail with a single community", async ({ page }) => { + await installMockBridge(page, undefined, { skipCommunitySeed: true }); + await seedCommunities(page, [COMMUNITY_A], COMMUNITY_A.id); await page.goto("/"); // The channel sidebar still renders; the rail is omitted (a rail of one // adds nothing). await expect(page.getByTestId("app-sidebar")).toBeVisible(); - await expect(page.getByTestId("workspace-rail")).toHaveCount(0); + await expect(page.getByTestId("community-rail")).toHaveCount(0); }); test("keeps the rail visible when the sidebar is collapsed", async ({ page, }) => { - await installMockBridge(page, undefined, { skipWorkspaceSeed: true }); - await seedWorkspaces(page, [WORKSPACE_A, WORKSPACE_B], WORKSPACE_A.id); + await installMockBridge(page, undefined, { skipCommunitySeed: true }); + await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id); await page.goto("/"); - const rail = page.getByTestId("workspace-rail"); + const rail = page.getByTestId("community-rail"); await expect(rail).toBeVisible(); // Collapse the sidebar via its keyboard shortcut. The rail is a sibling of @@ -171,9 +172,9 @@ test.describe("workspace rail", () => { await expect(rail).toBeVisible(); await expect( - page.getByTestId(`workspace-rail-button-${WORKSPACE_B.id}`), + page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`), ).toBeVisible(); - await expect(page.getByTestId("workspace-rail-add")).toBeVisible(); + await expect(page.getByTestId("community-rail-add")).toBeVisible(); }); test("clears the macOS traffic lights", async ({ page }) => { @@ -181,14 +182,14 @@ test.describe("workspace rail", () => { await page.addInitScript(() => { Object.defineProperty(navigator, "platform", { get: () => "MacIntel" }); }); - await installMockBridge(page, undefined, { skipWorkspaceSeed: true }); - await seedWorkspaces(page, [WORKSPACE_A, WORKSPACE_B], WORKSPACE_A.id); + await installMockBridge(page, undefined, { skipCommunitySeed: true }); + await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id); await page.goto("/"); - // The first workspace button must start below the traffic-light band + // The first community button must start below the traffic-light band // (native controls sit around y<=31 with trafficLightPosition y:24). const firstButton = page.getByTestId( - `workspace-rail-button-${WORKSPACE_A.id}`, + `community-rail-button-${COMMUNITY_A.id}`, ); await expect(firstButton).toBeVisible(); const box = await firstButton.boundingBox(); diff --git a/desktop/tests/e2e/custom-emoji-ui.spec.ts b/desktop/tests/e2e/custom-emoji-ui.spec.ts index 4efd6fe62dd..d3c89fce5bb 100644 --- a/desktop/tests/e2e/custom-emoji-ui.spec.ts +++ b/desktop/tests/e2e/custom-emoji-ui.spec.ts @@ -31,7 +31,7 @@ test("composer renders a custom emoji inline", async ({ page }) => { await expect(input.locator("img[data-custom-emoji]")).toHaveCount(1); }); -test("settings card splits My emoji from read-only Workspace emoji", async ({ +test("settings card splits My emoji from read-only Community emoji", async ({ page, }) => { await page.goto("/"); @@ -49,11 +49,11 @@ test("settings card splits My emoji from read-only Workspace emoji", async ({ mine.getByRole("button", { name: "Remove :buzz:" }), ).toBeVisible(); - const workspace = card.getByTestId("custom-emoji-workspace"); - await expect(workspace).toContainText(":narf:"); + const community = card.getByTestId("custom-emoji-community"); + await expect(community).toContainText(":narf:"); // No remove button for someone else's emoji. await expect( - workspace.getByRole("button", { name: /^Remove :/ }), + community.getByRole("button", { name: /^Remove :/ }), ).toHaveCount(0); }); diff --git a/desktop/tests/e2e/custom-emoji.spec.ts b/desktop/tests/e2e/custom-emoji.spec.ts index ce60085f2e0..e78ac5737a9 100644 --- a/desktop/tests/e2e/custom-emoji.spec.ts +++ b/desktop/tests/e2e/custom-emoji.spec.ts @@ -12,7 +12,7 @@ import { installMockBridge } from "../helpers/bridge"; // The `:buzz:` shortcode lives in a member-authored kind:30030 set // (d=`buzz:custom-emoji`) served by the mock bridge from two distinct // pubkeys. `listCustomEmoji` reads every member's set over the relay WS and -// unions them (deduped by shortcode+url) into the workspace palette — which is +// unions them (deduped by shortcode+url) into the community palette — which is // live even in mock-bridge mode (the mock only intercepts Tauri commands), so // this spec uses the simpler mock-bridge setup like messaging.spec.ts. const SHORTCODE = "buzz"; diff --git a/desktop/tests/e2e/drafts-screenshots.spec.ts b/desktop/tests/e2e/drafts-screenshots.spec.ts index cd6ab2313f6..9e2ed5cae59 100644 --- a/desktop/tests/e2e/drafts-screenshots.spec.ts +++ b/desktop/tests/e2e/drafts-screenshots.spec.ts @@ -67,23 +67,23 @@ const ACTIVE_DRAFTS: StoredDrafts = { }; /** - * Patch the mock workspace to include the pubkey so initDraftStore gets the - * correct pubkey on app startup. The workspace is seeded by installMockBridge + * Patch the mock community to include the pubkey so initDraftStore gets the + * correct pubkey on app startup. The community is seeded by installMockBridge * without a pubkey field; this addInitScript runs after that seed (init * scripts execute in registration order) and adds it. */ -async function patchWorkspacePubkey(page: import("@playwright/test").Page) { +async function patchCommunityPubkey(page: import("@playwright/test").Page) { await page.addInitScript( ({ pubkey }) => { - const raw = window.localStorage.getItem("buzz-workspaces"); - const workspaces = raw + const raw = window.localStorage.getItem("buzz-communities"); + const communities = raw ? (JSON.parse(raw) as Array>) : []; - if (workspaces[0]) { - workspaces[0].pubkey = pubkey; + if (communities[0]) { + communities[0].pubkey = pubkey; window.localStorage.setItem( - "buzz-workspaces", - JSON.stringify(workspaces), + "buzz-communities", + JSON.stringify(communities), ); } }, @@ -140,7 +140,7 @@ test.describe("drafts screenshots", () => { test("01 — drafts section populated", async ({ page }) => { await installMockBridge(page); - await patchWorkspacePubkey(page); + await patchCommunityPubkey(page); await seedDraftStore(page, ACTIVE_DRAFTS); const panel = await openDraftsPanel(page); @@ -174,7 +174,7 @@ test.describe("drafts screenshots", () => { test("03 — hover actions visible", async ({ page }) => { await installMockBridge(page); - await patchWorkspacePubkey(page); + await patchCommunityPubkey(page); await seedDraftStore(page, ACTIVE_DRAFTS); const panel = await openDraftsPanel(page); @@ -228,7 +228,7 @@ test.describe("drafts screenshots", () => { // the correct channel and passes the autoSend key so the thread composer // (not the main composer) arms the auto-submit. await installMockBridge(page); - await patchWorkspacePubkey(page); + await patchCommunityPubkey(page); // A fixed fake root event ID — in the mock bridge get_event is unhandled // so useDraftRootStatus will map it to `error` (not `deleted`), keeping @@ -324,7 +324,7 @@ test.describe("drafts screenshots", () => { // 2. The badge next to "Drafts" in the filter dropdown. // Two active drafts are seeded so the count is 2. await installMockBridge(page); - await patchWorkspacePubkey(page); + await patchCommunityPubkey(page); await seedDraftStore(page, ACTIVE_DRAFTS); await page.goto("/", { waitUntil: "domcontentloaded" }); @@ -365,7 +365,7 @@ test.describe("drafts screenshots", () => { const THREAD_DRAFT_KEY = `thread:${DELETED_ROOT_ID}`; await installMockBridge(page, { deletedEventIds: [DELETED_ROOT_ID] }); - await patchWorkspacePubkey(page); + await patchCommunityPubkey(page); await seedDraftStore(page, { [THREAD_DRAFT_KEY]: { content: "Planning to follow up on the discussion from last week.", diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index 710f2f9ae21..ef93d83728e 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -550,7 +550,7 @@ test("completed users skip the loading gate while profile is still settling", as await expectHomeView(page); }); -test("first-run default workspace handoff gives immediate stepper feedback", async ({ +test("first-run default community handoff gives immediate stepper feedback", async ({ page, }) => { // Use a blank-username identity so the profile has no display name and @@ -565,14 +565,14 @@ test("first-run default workspace handoff gives immediate stepper feedback", asy { relayWsUrl: "wss://default.example.com", skipOnboardingSeed: true, - skipWorkspaceSeed: true, + skipCommunitySeed: true, }, ); await page.goto("/"); await expect(page.getByText("Welcome to Buzz")).toBeVisible(); await page - .getByRole("button", { name: "Continue with default workspace" }) + .getByRole("button", { name: "Continue with default community" }) .click(); await page.waitForTimeout(80); @@ -580,7 +580,7 @@ test("first-run default workspace handoff gives immediate stepper feedback", asy 0, ); await expect( - page.getByRole("button", { name: "Continue with default workspace" }), + page.getByRole("button", { name: "Continue with default community" }), ).toBeVisible(); await expect(page.getByRole("progressbar")).toHaveAttribute( "aria-valuenow", @@ -607,7 +607,7 @@ test("welcome can continue using an existing Nostr key", async ({ page }) => { await installMockBridge(page, undefined, { relayWsUrl: "wss://default.example.com", skipOnboardingSeed: true, - skipWorkspaceSeed: true, + skipCommunitySeed: true, }); await page.goto("/"); @@ -626,11 +626,11 @@ test("welcome can continue using an existing Nostr key", async ({ page }) => { await expect .poll(() => page.evaluate(() => { - const rawWorkspaces = window.localStorage.getItem("buzz-workspaces"); - const workspaces = rawWorkspaces - ? (JSON.parse(rawWorkspaces) as Array<{ pubkey?: string }>) + const rawCommunities = window.localStorage.getItem("buzz-communities"); + const communities = rawCommunities + ? (JSON.parse(rawCommunities) as Array<{ pubkey?: string }>) : []; - return workspaces[0]?.pubkey ?? null; + return communities[0]?.pubkey ?? null; }), ) .toBe(TEST_IDENTITIES.alice.pubkey); @@ -638,25 +638,25 @@ test("welcome can continue using an existing Nostr key", async ({ page }) => { await expectHomeView(page); }); -test("welcome presents custom workspace setup as joining a workspace", async ({ +test("welcome presents custom community setup as joining a community", async ({ page, }) => { await installMockBridge(page, undefined, { skipOnboardingSeed: true, - skipWorkspaceSeed: true, + skipCommunitySeed: true, }); await page.goto("/"); await expect( - page.getByRole("button", { name: "Continue with default workspace" }), + page.getByRole("button", { name: "Continue with default community" }), ).toHaveCount(0); - await page.getByRole("button", { name: "Join a workspace" }).click(); + await page.getByRole("button", { name: "Join a community" }).click(); await expect( - page.getByRole("heading", { name: "Join a workspace" }), + page.getByRole("heading", { name: "Join a community" }), ).toBeVisible(); await expect( - page.getByRole("button", { name: "Join a workspace" }), + page.getByRole("button", { name: "Join a community" }), ).toBeVisible(); }); @@ -1050,7 +1050,7 @@ test("first-run onboarding shows setup loading until Welcome bootstrap completes const loadingGate = page.getByTestId("app-loading-gate"); await expect(page.getByTestId("onboarding-gate")).toHaveCount(0); await expect(loadingGate).toBeVisible(); - await expect(loadingGate).toContainText("Setting up your workspace..."); + await expect(loadingGate).toContainText("Setting up your community..."); // The boot gate is the theme-adaptive grainient with the flapping Buzz bee // as its hero. The mark must paint complete on the FIRST frame — a blank @@ -1136,10 +1136,10 @@ test("existing relay profile with display name auto-skips onboarding without loc await expectHomeView(page); }); -test("onboarding can import an existing key when the workspace is already set up", async ({ +test("onboarding can import an existing key when the community is already set up", async ({ page, }) => { - // Workspace exists (default seed), but this identity has no profile yet, + // Community exists (default seed), but this identity has no profile yet, // so the app lands on the onboarding name step — Tyler's moved-laptop / // fresh-dev-instance case. await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); @@ -1311,7 +1311,7 @@ test("custom relay proxy sign-in failures use the generic reconnect card", async ).toContainText("Can't reach the relay"); }); -test("relay access failures use the generic reconnect card", async ({ +test("community access failures use the generic reconnect card", async ({ page, }) => { await seedActiveIdentity(page, BLANK_TYLER_IDENTITY); diff --git a/desktop/tests/e2e/profile.spec.ts b/desktop/tests/e2e/profile.spec.ts index af34a068e24..783663ebe9d 100644 --- a/desktop/tests/e2e/profile.spec.ts +++ b/desktop/tests/e2e/profile.spec.ts @@ -1007,7 +1007,7 @@ test("renders settings in the app shell with a back button", async ({ "aria-pressed", "true", ); - await expect(page.getByText("Workspaces", { exact: true })).toBeVisible(); + await expect(page.getByText("Communities", { exact: true })).toBeVisible(); await expect( page.getByTestId("settings-nav-channel-templates"), ).toBeVisible(); diff --git a/desktop/tests/e2e/project-commit-detail.spec.ts b/desktop/tests/e2e/project-commit-detail.spec.ts index 398bc102cca..b0a2fe9133a 100644 --- a/desktop/tests/e2e/project-commit-detail.spec.ts +++ b/desktop/tests/e2e/project-commit-detail.spec.ts @@ -67,7 +67,7 @@ test("commit detail opens from the commits feed with a diff", async ({ timeout: 10_000, }); await expect( - page.getByText("WorkspaceTabs({ selectedCommitHash })"), + page.getByText("CommunityTabs({ selectedCommitHash })"), ).toBeVisible(); await waitForAnimations(page); @@ -143,7 +143,7 @@ test("pull request and issue feeds share the commit row structure", async ({ page.getByRole("navigation", { name: "Project breadcrumb" }), ).toContainText("PRs"); - // Step back to the feed so the workspace tabs are available again. + // Step back to the feed so the community tabs are available again. await page .getByRole("navigation", { name: "Project breadcrumb" }) .getByRole("button", { name: "PRs", exact: true }) diff --git a/desktop/tests/e2e/relay-reconnect.spec.ts b/desktop/tests/e2e/relay-reconnect.spec.ts index a77ec04ad84..2df9075d648 100644 --- a/desktop/tests/e2e/relay-reconnect.spec.ts +++ b/desktop/tests/e2e/relay-reconnect.spec.ts @@ -143,7 +143,7 @@ test("profile popover does not show relay reconnect controls", async ({ await expect(page.getByTestId("profile-popover-reconnect")).toHaveCount(0); // The sidebar owns the relay reconnect affordance; the profile popover stays - // focused on profile/settings/workspace actions even while the relay is down. + // focused on profile/settings/community actions even while the relay is down. await driveConnectionDegraded(page, "stalled"); await expect(page.getByTestId("sidebar-relay-unreachable")).toBeVisible({ timeout: 10_000, diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 89058e70464..dc60c8f5267 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -161,7 +161,7 @@ type MockBridgeOptions = { feedReadError?: string; canvasReadError?: string; /** Delay (ms) for `apply_workspace`; see e2eBridge mock config. */ - applyWorkspaceDelayMs?: number; + applyCommunityDelayMs?: number; openDmDelayMs?: number; sendMessageDelayMs?: number; /** Reject successive kind-9 sends with these messages, then resume. */ @@ -318,7 +318,7 @@ type BridgeOptions = { relayHttpUrl?: string; relayWsUrl?: string; skipOnboardingSeed?: boolean; - skipWorkspaceSeed?: boolean; + skipCommunitySeed?: boolean; /** * When true (default), seed every preview feature in preview-features.json as * enabled in localStorage so E2E tests can interact with gated UI without @@ -477,21 +477,21 @@ async function seedOnboardingCompletionForKnownIdentities( ); } -async function seedDefaultWorkspace(page: Page, relayWsUrl?: string) { +async function seedDefaultCommunity(page: Page, relayWsUrl?: string) { await page.addInitScript( ({ relayUrl }) => { - const workspaceId = "e2e-default-workspace"; - const workspace = { - id: workspaceId, + const communityId = "e2e-default-community"; + const community = { + id: communityId, name: "E2E Test", relayUrl, addedAt: new Date().toISOString(), }; window.localStorage.setItem( - "buzz-workspaces", - JSON.stringify([workspace]), + "buzz-communities", + JSON.stringify([community]), ); - window.localStorage.setItem("buzz-active-workspace-id", workspaceId); + window.localStorage.setItem("buzz-active-community-id", communityId); }, { relayUrl: relayWsUrl ?? DEFAULT_RELAY_WS_URL }, ); @@ -514,10 +514,10 @@ export async function installBridge(page: Page, options: BridgeOptions) { ? TEST_IDENTITIES[options.user ?? "tyler"] : undefined; - // Most specs seed a workspace so useWorkspaceInit doesn't show WelcomeSetup. + // Most specs seed a community so useCommunityInit doesn't show WelcomeSetup. // skipOnboardingSeed only controls the onboarding-completion flag. - if (!options.skipWorkspaceSeed) { - await seedDefaultWorkspace(page, options.relayWsUrl); + if (!options.skipCommunitySeed) { + await seedDefaultCommunity(page, options.relayWsUrl); } if (!options.skipOnboardingSeed) { await seedOnboardingCompletionForKnownIdentities(page, options.relayWsUrl); @@ -618,7 +618,7 @@ export async function installMockBridge( options?: { relayWsUrl?: string; skipOnboardingSeed?: boolean; - skipWorkspaceSeed?: boolean; + skipCommunitySeed?: boolean; seedPreviewFeatures?: boolean; }, ) { @@ -627,7 +627,7 @@ export async function installMockBridge( mock, relayWsUrl: options?.relayWsUrl, skipOnboardingSeed: options?.skipOnboardingSeed, - skipWorkspaceSeed: options?.skipWorkspaceSeed, + skipCommunitySeed: options?.skipCommunitySeed, seedPreviewFeatures: options?.seedPreviewFeatures, }); } diff --git a/desktop/tests/helpers/screenshot.mjs b/desktop/tests/helpers/screenshot.mjs index 2d1d0b3928b..d60cfa41d1c 100644 --- a/desktop/tests/helpers/screenshot.mjs +++ b/desktop/tests/helpers/screenshot.mjs @@ -85,17 +85,17 @@ const page = await browser.newPage({ viewport: { width: vpWidth, height: vpHeight }, }); -// Seed default workspace (mirrors seedDefaultWorkspace in bridge.ts) +// Seed default community (mirrors seedDefaultCommunity in bridge.ts) await page.addInitScript(() => { - const workspaceId = "e2e-default-workspace"; - const workspace = { - id: workspaceId, + const communityId = "e2e-default-community"; + const community = { + id: communityId, name: "E2E Test", relayUrl: "ws://localhost:3000", addedAt: new Date().toISOString(), }; - window.localStorage.setItem("buzz-workspaces", JSON.stringify([workspace])); - window.localStorage.setItem("buzz-active-workspace-id", workspaceId); + window.localStorage.setItem("buzz-communities", JSON.stringify([community])); + window.localStorage.setItem("buzz-active-community-id", communityId); }); // Seed onboarding completion for all known identities diff --git a/desktop/tests/helpers/settings.ts b/desktop/tests/helpers/settings.ts index 0c2659b1977..7763905a39d 100644 --- a/desktop/tests/helpers/settings.ts +++ b/desktop/tests/helpers/settings.ts @@ -9,7 +9,7 @@ type SettingsSection = | "appearance" | "shortcuts" | "tokens" - | "relay-members" + | "community-members" | "mobile" | "updates" | "doctor"; diff --git a/desktop/tests/helpers/zoom-shot.mjs b/desktop/tests/helpers/zoom-shot.mjs index 3a9de2834cd..cbcc049ac74 100644 --- a/desktop/tests/helpers/zoom-shot.mjs +++ b/desktop/tests/helpers/zoom-shot.mjs @@ -41,15 +41,15 @@ const browser = await chromium.launch({ headless: true }); const page = await browser.newPage({ viewport: { width: vw, height: vh } }); await page.addInitScript(() => { - const id = "e2e-default-workspace"; + const id = "e2e-default-community"; const ws = { id, name: "E2E Test", relayUrl: "ws://localhost:3000", addedAt: new Date().toISOString(), }; - window.localStorage.setItem("buzz-workspaces", JSON.stringify([ws])); - window.localStorage.setItem("buzz-active-workspace-id", id); + window.localStorage.setItem("buzz-communities", JSON.stringify([ws])); + window.localStorage.setItem("buzz-active-community-id", id); }); await page.addInitScript( ({ prefix, pubkeys }) => { diff --git a/preview-features.json b/preview-features.json index bc5f6d16c0f..d7362197e67 100644 --- a/preview-features.json +++ b/preview-features.json @@ -27,8 +27,8 @@ }, { "id": "workspaceRail", - "name": "Workspace Rail", - "description": "Far-left rail for switching relays with cross-relay unread indicators", + "name": "Community Rail", + "description": "Far-left rail for switching communities with cross-community unread indicators", "platforms": [ "desktop" ]