Add background-hash-mark addition for region routing

Per https://buymeacoffee.com/ripplebiz/region-filtering:

> After some discussions, and that there is some confusion
around #channels and #regions, it's been decided to drop
the requirement to have the '#' prefix. So, region names
will just be plain alphanumeric (and '-'), with no # prefix.

> For backwards compatibility, the names will internally have
a '#' prepended, but for all client GUI's and command lines,
you generally won't see mention of '#' prefixes. The next
firmware release (v1.12.0) and subsequent Ripple firmware
and Liam's app will have modified UI to remove the '#' requirement.

So, silently add, but don't duplicate, for users who have already
added hashmarks.
This commit is contained in:
Jack Kingsman
2026-03-09 15:24:23 -07:00
parent e03ddcaaa7
commit b157ee14e4
13 changed files with 107 additions and 24 deletions

View File

@@ -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)")

20
app/region_scope.py Normal file
View File

@@ -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}"

View File

@@ -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")

View File

@@ -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(

View File

@@ -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:

View File

@@ -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 && (
<span className="basis-full sm:basis-auto text-[11px] text-amber-700 dark:text-amber-300 truncate">
Regional override active: {activeChannel.flood_scope_override}
Regional override active: {stripRegionScopePrefix(activeChannel.flood_scope_override)}
</span>
)}
{conversation.type === 'contact' &&

View File

@@ -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"
/>
<p className="text-xs text-muted-foreground">
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.
</p>

View File

@@ -117,7 +117,7 @@ describe('ChatHeader key visibility', () => {
render(<ChatHeader {...baseProps} conversation={conversation} channels={[channel]} />);
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(
<ChatHeader
@@ -139,7 +139,7 @@ describe('ChatHeader key visibility', () => {
fireEvent.click(screen.getByTitle('Set regional override'));
expect(promptSpy).toHaveBeenCalled();
expect(onSetChannelFloodScopeOverride).toHaveBeenCalledWith(key, '#Esperance');
expect(onSetChannelFloodScopeOverride).toHaveBeenCalledWith(key, 'Esperance');
promptSpy.mockRestore();
});
});

View File

@@ -0,0 +1,4 @@
export function stripRegionScopePrefix(scope: string | null | undefined): string {
if (!scope) return '';
return scope.startsWith('#') ? scope.slice(1) : scope;
}

View File

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

View File

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

View File

@@ -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),

View File

@@ -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")