diff --git a/app/radio.py b/app/radio.py index 9dc9aa6..ed86958 100644 --- a/app/radio.py +++ b/app/radio.py @@ -262,10 +262,11 @@ class RadioManager: # Apply flood scope from settings (best-effort; older firmware # may not support set_flood_scope) + from app.region_scope import normalize_region_scope from app.repository import AppSettingsRepository app_settings = await AppSettingsRepository.get() - scope = app_settings.flood_scope + scope = normalize_region_scope(app_settings.flood_scope) try: await mc.commands.set_flood_scope(scope if scope else "") logger.info("Applied flood_scope=%r", scope or "(disabled)") diff --git a/app/region_scope.py b/app/region_scope.py new file mode 100644 index 0000000..681d4cf --- /dev/null +++ b/app/region_scope.py @@ -0,0 +1,20 @@ +"""Helpers for normalizing MeshCore flood-scope / region names.""" + + +def normalize_region_scope(scope: str | None) -> str: + """Normalize a user-facing region scope into MeshCore's internal form. + + Region names are now user-facing plain strings like ``Esperance``. + Internally, MeshCore still expects hashtag-style names like ``#Esperance``. + + Backward compatibility: + - blank/None stays disabled (`""`) + - existing leading ``#`` is preserved + """ + + stripped = (scope or "").strip() + if not stripped: + return "" + if stripped.startswith("#"): + return stripped + return f"#{stripped}" diff --git a/app/routers/channels.py b/app/routers/channels.py index 8f3831a..f9ea589 100644 --- a/app/routers/channels.py +++ b/app/routers/channels.py @@ -9,6 +9,7 @@ from app.dependencies import require_connected from app.models import Channel, ChannelDetail, ChannelMessageCounts, ChannelTopSender from app.radio import radio_manager from app.radio_sync import upsert_channel_from_radio_slot +from app.region_scope import normalize_region_scope from app.repository import ChannelRepository, MessageRepository from app.websocket import broadcast_event @@ -153,7 +154,7 @@ async def set_channel_flood_scope_override( if not channel: raise HTTPException(status_code=404, detail="Channel not found") - override = request.flood_scope_override.strip() or None + override = normalize_region_scope(request.flood_scope_override) or None updated = await ChannelRepository.update_flood_scope_override(channel.key, override) if not updated: raise HTTPException(status_code=500, detail="Failed to update flood-scope override") diff --git a/app/routers/messages.py b/app/routers/messages.py index 26f8ee2..b15c960 100644 --- a/app/routers/messages.py +++ b/app/routers/messages.py @@ -14,6 +14,7 @@ from app.models import ( SendDirectMessageRequest, ) from app.radio import radio_manager +from app.region_scope import normalize_region_scope from app.repository import AmbiguousPublicKeyPrefixError, AppSettingsRepository, MessageRepository from app.websocket import broadcast_error, broadcast_event @@ -31,12 +32,12 @@ async def _send_channel_message_with_effective_scope( action_label: str, ) -> Any: """Send a channel message, temporarily overriding flood scope when configured.""" - override_scope = (channel.flood_scope_override or "").strip() + override_scope = normalize_region_scope(channel.flood_scope_override) baseline_scope = "" if override_scope: settings = await AppSettingsRepository.get() - baseline_scope = settings.flood_scope + baseline_scope = normalize_region_scope(settings.flood_scope) if override_scope and override_scope != baseline_scope: logger.info( diff --git a/app/routers/settings.py b/app/routers/settings.py index 7f220a3..a5e053c 100644 --- a/app/routers/settings.py +++ b/app/routers/settings.py @@ -6,6 +6,7 @@ from fastapi import APIRouter from pydantic import BaseModel, Field from app.models import AppSettings +from app.region_scope import normalize_region_scope from app.repository import AppSettingsRepository logger = logging.getLogger(__name__) @@ -123,8 +124,7 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings: # Flood scope flood_scope_changed = False if update.flood_scope is not None: - stripped = update.flood_scope.strip() - kwargs["flood_scope"] = stripped + kwargs["flood_scope"] = normalize_region_scope(update.flood_scope) flood_scope_changed = True if kwargs: diff --git a/frontend/src/components/ChatHeader.tsx b/frontend/src/components/ChatHeader.tsx index e57fad9..5d7497d 100644 --- a/frontend/src/components/ChatHeader.tsx +++ b/frontend/src/components/ChatHeader.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { toast } from './ui/sonner'; import { isFavorite } from '../utils/favorites'; import { handleKeyboardActivate } from '../utils/a11y'; +import { stripRegionScopePrefix } from '../utils/regionScope'; import { ContactAvatar } from './ContactAvatar'; import { ContactStatusInfo } from './ContactStatusInfo'; import type { Channel, Contact, Conversation, Favorite, RadioConfig } from '../types'; @@ -54,8 +55,8 @@ export function ChatHeader({ const handleEditFloodScopeOverride = () => { if (conversation.type !== 'channel' || !onSetChannelFloodScopeOverride) return; const nextValue = window.prompt( - 'Enter regional override flood scope for this room. This temporarily changes the radio flood scope before send and restores it after, which significantly slows room sends. Leave blank to clear.\n\nNote: some radio clients (including the official ones) implicitly add a "#" character to the beginning of a region specifier. That needs to be manually added here, so if you use the official app with region "Anytown", you should enter it here as "#Anytown".', - activeChannel?.flood_scope_override ?? '' + 'Enter regional override flood scope for this room. This temporarily changes the radio flood scope before send and restores it after, which significantly slows room sends. Leave blank to clear.', + stripRegionScopePrefix(activeChannel?.flood_scope_override) ); if (nextValue === null) return; onSetChannelFloodScopeOverride(conversation.id, nextValue); @@ -140,7 +141,7 @@ export function ChatHeader({ )} {conversation.type === 'channel' && activeChannel?.flood_scope_override && ( - Regional override active: {activeChannel.flood_scope_override} + Regional override active: {stripRegionScopePrefix(activeChannel.flood_scope_override)} )} {conversation.type === 'contact' && diff --git a/frontend/src/components/settings/SettingsRadioSection.tsx b/frontend/src/components/settings/SettingsRadioSection.tsx index 14f3ac7..35b748b 100644 --- a/frontend/src/components/settings/SettingsRadioSection.tsx +++ b/frontend/src/components/settings/SettingsRadioSection.tsx @@ -5,6 +5,7 @@ import { Button } from '../ui/button'; import { Separator } from '../ui/separator'; import { toast } from '../ui/sonner'; import { RADIO_PRESETS } from '../../utils/radioPresets'; +import { stripRegionScopePrefix } from '../../utils/regionScope'; import type { AppSettings, AppSettingsUpdate, @@ -83,7 +84,7 @@ export function SettingsRadioSection({ useEffect(() => { setAdvertIntervalHours(String(Math.round(appSettings.advert_interval / 3600))); - setFloodScope(appSettings.flood_scope); + setFloodScope(stripRegionScopePrefix(appSettings.flood_scope)); setMaxRadioContacts(String(appSettings.max_radio_contacts)); }, [appSettings]); @@ -256,7 +257,7 @@ export function SettingsRadioSection({ if (newAdvertInterval !== appSettings.advert_interval) { update.advert_interval = newAdvertInterval; } - if (floodScope !== appSettings.flood_scope) { + if (floodScope !== stripRegionScopePrefix(appSettings.flood_scope)) { update.flood_scope = floodScope; } const newMaxRadioContacts = parseInt(maxRadioContacts, 10); @@ -554,10 +555,10 @@ export function SettingsRadioSection({ id="flood-scope" value={floodScope} onChange={(e) => setFloodScope(e.target.value)} - placeholder="#MyRegion" + placeholder="MyRegion" />

- Tag outgoing flood messages with a region name (e.g. #MyRegion). Repeaters configured for + Tag outgoing flood messages with a region name (e.g. MyRegion). Repeaters configured for that region can forward the traffic, while repeaters configured to deny other regions may drop it. Leave empty to disable.

diff --git a/frontend/src/test/chatHeaderKeyVisibility.test.tsx b/frontend/src/test/chatHeaderKeyVisibility.test.tsx index b1e4231..04952dc 100644 --- a/frontend/src/test/chatHeaderKeyVisibility.test.tsx +++ b/frontend/src/test/chatHeaderKeyVisibility.test.tsx @@ -117,7 +117,7 @@ describe('ChatHeader key visibility', () => { render(); - expect(screen.getByText('Regional override active: #Esperance')).toBeInTheDocument(); + expect(screen.getByText('Regional override active: Esperance')).toBeInTheDocument(); }); it('prompts for regional override when globe button is clicked', () => { @@ -125,7 +125,7 @@ describe('ChatHeader key visibility', () => { const channel = makeChannel(key, '#flightless', true); const conversation: Conversation = { type: 'channel', id: key, name: '#flightless' }; const onSetChannelFloodScopeOverride = vi.fn(); - const promptSpy = vi.spyOn(window, 'prompt').mockReturnValue('#Esperance'); + const promptSpy = vi.spyOn(window, 'prompt').mockReturnValue('Esperance'); render( { fireEvent.click(screen.getByTitle('Set regional override')); expect(promptSpy).toHaveBeenCalled(); - expect(onSetChannelFloodScopeOverride).toHaveBeenCalledWith(key, '#Esperance'); + expect(onSetChannelFloodScopeOverride).toHaveBeenCalledWith(key, 'Esperance'); promptSpy.mockRestore(); }); }); diff --git a/frontend/src/utils/regionScope.ts b/frontend/src/utils/regionScope.ts new file mode 100644 index 0000000..a6e3a41 --- /dev/null +++ b/frontend/src/utils/regionScope.ts @@ -0,0 +1,4 @@ +export function stripRegionScopePrefix(scope: string | null | undefined): string { + if (!scope) return ''; + return scope.startsWith('#') ? scope.slice(1) : scope; +} diff --git a/tests/test_channels_router.py b/tests/test_channels_router.py index d247e14..02aca10 100644 --- a/tests/test_channels_router.py +++ b/tests/test_channels_router.py @@ -270,7 +270,7 @@ class TestChannelFloodScopeOverride: with patch("app.routers.channels.broadcast_event") as mock_broadcast: response = await client.post( f"/api/channels/{key}/flood-scope-override", - json={"flood_scope_override": "#Esperance"}, + json={"flood_scope_override": "Esperance"}, ) assert response.status_code == 200 @@ -283,6 +283,20 @@ class TestChannelFloodScopeOverride: mock_broadcast.assert_called_once() assert mock_broadcast.call_args.args[0] == "channel" + @pytest.mark.asyncio + async def test_existing_hash_is_not_doubled(self, test_db, client): + key = "CC" * 16 + await ChannelRepository.upsert(key=key, name="#flightless", is_hashtag=True) + + response = await client.post( + f"/api/channels/{key}/flood-scope-override", + json={"flood_scope_override": "#Esperance"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["flood_scope_override"] == "#Esperance" + @pytest.mark.asyncio async def test_blank_override_clears_channel_flood_scope_override(self, test_db, client): key = "BB" * 16 diff --git a/tests/test_radio.py b/tests/test_radio.py index 2ff8cce..71fcf29 100644 --- a/tests/test_radio.py +++ b/tests/test_radio.py @@ -655,6 +655,40 @@ class TestPostConnectSetupOrdering: mock_mc.commands.set_flood_scope.assert_awaited_once_with("#TestRegion") + @pytest.mark.asyncio + async def test_plain_flood_scope_is_normalized_during_setup(self): + """Legacy/plain stored flood_scope is normalized before applying to radio.""" + from app.models import AppSettings + from app.radio import RadioManager + + rm = RadioManager() + mock_mc = MagicMock() + mock_mc.start_auto_message_fetching = AsyncMock() + mock_mc.commands.set_flood_scope = AsyncMock() + rm._meshcore = mock_mc + + mock_settings = AppSettings(flood_scope="TestRegion") + + with ( + patch("app.event_handlers.register_event_handlers"), + patch("app.keystore.export_and_store_private_key", new_callable=AsyncMock), + patch("app.radio_sync.sync_radio_time", new_callable=AsyncMock), + patch( + "app.repository.AppSettingsRepository.get", + new_callable=AsyncMock, + return_value=mock_settings, + ), + patch("app.radio_sync.sync_and_offload_all", new_callable=AsyncMock, return_value={}), + patch("app.radio_sync.start_periodic_sync"), + patch("app.radio_sync.send_advertisement", new_callable=AsyncMock, return_value=False), + patch("app.radio_sync.start_periodic_advert"), + patch("app.radio_sync.drain_pending_messages", new_callable=AsyncMock, return_value=0), + patch("app.radio_sync.start_message_polling"), + ): + await rm.post_connect_setup() + + mock_mc.commands.set_flood_scope.assert_awaited_once_with("#TestRegion") + @pytest.mark.asyncio async def test_flood_scope_empty_resets_during_setup(self): """Empty flood_scope calls set_flood_scope("") during post_connect_setup.""" diff --git a/tests/test_send_messages.py b/tests/test_send_messages.py index 43a6046..111bf25 100644 --- a/tests/test_send_messages.py +++ b/tests/test_send_messages.py @@ -279,8 +279,8 @@ class TestOutgoingChannelBroadcast: mc = _make_mc(name="MyNode") chan_key = "de" * 16 await ChannelRepository.upsert(key=chan_key, name="#flightless") - await ChannelRepository.update_flood_scope_override(chan_key, "#Esperance") - await AppSettingsRepository.update(flood_scope="#Baseline") + await ChannelRepository.update_flood_scope_override(chan_key, "Esperance") + await AppSettingsRepository.update(flood_scope="Baseline") with ( patch("app.routers.messages.require_connected", return_value=mc), @@ -303,8 +303,8 @@ class TestOutgoingChannelBroadcast: mc = _make_mc(name="MyNode") chan_key = "df" * 16 await ChannelRepository.upsert(key=chan_key, name="#matching") - await ChannelRepository.update_flood_scope_override(chan_key, "#Esperance") - await AppSettingsRepository.update(flood_scope="#Esperance") + await ChannelRepository.update_flood_scope_override(chan_key, "Esperance") + await AppSettingsRepository.update(flood_scope="Esperance") with ( patch("app.routers.messages.require_connected", return_value=mc), diff --git a/tests/test_settings_router.py b/tests/test_settings_router.py index e2b39cf..28e2f0b 100644 --- a/tests/test_settings_router.py +++ b/tests/test_settings_router.py @@ -55,7 +55,7 @@ class TestUpdateSettings: @pytest.mark.asyncio async def test_flood_scope_round_trip(self, test_db): """Flood scope should be saved and retrieved correctly.""" - result = await update_settings(AppSettingsUpdate(flood_scope="#MyRegion")) + result = await update_settings(AppSettingsUpdate(flood_scope="MyRegion")) assert result.flood_scope == "#MyRegion" fresh = await AppSettingsRepository.get() @@ -70,7 +70,13 @@ class TestUpdateSettings: @pytest.mark.asyncio async def test_flood_scope_whitespace_stripped(self, test_db): """Flood scope should be stripped of whitespace.""" - result = await update_settings(AppSettingsUpdate(flood_scope=" #MyRegion ")) + result = await update_settings(AppSettingsUpdate(flood_scope=" MyRegion ")) + assert result.flood_scope == "#MyRegion" + + @pytest.mark.asyncio + async def test_flood_scope_existing_hash_is_not_doubled(self, test_db): + """Existing leading hash should be preserved for backward compatibility.""" + result = await update_settings(AppSettingsUpdate(flood_scope="#MyRegion")) assert result.flood_scope == "#MyRegion" @pytest.mark.asyncio @@ -92,7 +98,7 @@ class TestUpdateSettings: mock_rm.radio_operation = mock_radio_op with patch("app.radio.radio_manager", mock_rm): - await update_settings(AppSettingsUpdate(flood_scope="#TestRegion")) + await update_settings(AppSettingsUpdate(flood_scope="TestRegion")) mock_mc.commands.set_flood_scope.assert_awaited_once_with("#TestRegion")