Merge pull request #307 from Bjorkan/fixRegions. Closes #303.

Fix unscoped flood-scope handling
This commit is contained in:
Jack Kingsman
2026-07-08 15:51:50 -07:00
committed by GitHub
20 changed files with 432 additions and 55 deletions
@@ -1,6 +1,10 @@
import { useEffect, useState } from 'react';
import { stripRegionScopePrefix } from '../utils/regionScope';
import {
UNSCOPED_OVERRIDE_MARKER,
isUnscopedMarker,
stripRegionScopePrefix,
} from '../utils/regionScope';
import { Button } from './ui/button';
import {
Dialog,
@@ -34,11 +38,18 @@ export function ChannelFloodScopeOverrideModal({
if (!open) {
return;
}
setRegion(stripRegionScopePrefix(currentOverride));
// The unscoped marker isn't a region name, so start the input blank for it.
setRegion(isUnscopedMarker(currentOverride) ? '' : stripRegionScopePrefix(currentOverride));
}, [currentOverride, open]);
const trimmedRegion = region.trim();
const currentOverrideLabel = isUnscopedMarker(currentOverride)
? 'unscoped (plain flood)'
: currentOverride
? stripRegionScopePrefix(currentOverride)
: 'inherit global setting';
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="sm:max-w-[520px]">
@@ -46,7 +57,9 @@ export function ChannelFloodScopeOverrideModal({
<DialogTitle>Regional Override</DialogTitle>
<DialogDescription>
Channel-level regional routing temporarily changes the radio flood scope before send and
restores it after. This can noticeably slow channel sends.
restores it after. This can noticeably slow channel sends. Choose one of three modes
below: scope to a region, force unscoped (plain flood, ignoring your global region), or
inherit the global setting.
</DialogDescription>
</DialogHeader>
@@ -54,8 +67,7 @@ export function ChannelFloodScopeOverrideModal({
<div className="rounded-md border border-border bg-muted/20 p-3 text-sm">
<div className="font-medium">{roomName}</div>
<div className="mt-1 text-muted-foreground">
Current regional override:{' '}
{currentOverride ? stripRegionScopePrefix(currentOverride) : 'none'}
Current setting: {currentOverrideLabel}
</div>
</div>
@@ -83,8 +95,19 @@ export function ChannelFloodScopeOverrideModal({
}}
>
{trimmedRegion.length > 0
? `Use ${trimmedRegion} region for ${roomName}`
: `Use region for ${roomName}`}
? `Scope ${roomName} to ${trimmedRegion}`
: `Scope ${roomName} to a region`}
</Button>
<Button
type="button"
variant="outline"
className="w-full"
onClick={() => {
onSetOverride(UNSCOPED_OVERRIDE_MARKER);
onClose();
}}
>
Always send {roomName} unscoped (ignore global region)
</Button>
<Button
type="button"
@@ -95,7 +118,7 @@ export function ChannelFloodScopeOverrideModal({
onClose();
}}
>
Do not use region routing for {roomName}
Use global region setting for {roomName}
</Button>
</div>
</DialogFooter>
@@ -1163,7 +1163,9 @@ export function SettingsRadioSection({
<p className="text-[0.8125rem] text-muted-foreground">
Tag outgoing 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.
it. Leave empty to send unscoped (plain flood). Forcing unscoped when a default scope is
configured on the radio requires firmware v12 or newer. Individual channels can override
this with their own region or an "always unscoped" setting.
</p>
</div>
+6 -6
View File
@@ -154,10 +154,10 @@ const publicChannel = {
// contacts, unreads) that must all settle before conversation selection
// renders. This suite passes in isolation, but under the full parallel suite
// (~70 files) CPU contention can stretch that startup well past RTL's 1000ms
// default — and even past vitest's 5000ms test timeout — for any waitFor in
// this file. It's starvation, not a hang, so give this file generous headroom
// on both timeouts: a healthy render still settles in ~100ms (nothing is
// slowed), only a starved run waits longer.
// default — and occasionally past 10s on GitHub's shared runners — for any
// waitFor in this file. It's starvation, not a hang, so give this file generous
// headroom on both timeouts: a healthy render still settles in ~100ms (nothing
// is slowed), only a starved run waits longer.
//
// These MUST run at module scope, not in beforeAll: vitest bakes each test's
// timeout in when it() registers the test (during collection, before any
@@ -167,8 +167,8 @@ const publicChannel = {
// 10s but the test killed at 5s) that flaked under load. vi.setConfig is scoped
// to this file (each file runs in its own isolated worker), so other files are
// unaffected.
vi.setConfig({ testTimeout: 15000 });
configure({ asyncUtilTimeout: 10000 });
vi.setConfig({ testTimeout: 45000 });
configure({ asyncUtilTimeout: 30000 });
describe('App startup hash resolution', () => {
beforeEach(() => {
@@ -360,8 +360,34 @@ describe('ChatHeader key visibility', () => {
expect(await screen.findByRole('dialog')).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('Region'), { target: { value: 'Esperance' } });
fireEvent.click(screen.getByRole('button', { name: 'Use Esperance region for #flightless' }));
fireEvent.click(screen.getByRole('button', { name: 'Scope #flightless to Esperance' }));
expect(onSetChannelFloodScopeOverride).toHaveBeenCalledWith(key, 'Esperance');
});
it('forces the channel unscoped via the modal (issue #303)', async () => {
const key = 'CD'.repeat(16);
const channel = makeChannel(key, '#flightless', true);
const conversation: Conversation = { type: 'channel', id: key, name: '#flightless' };
const onSetChannelFloodScopeOverride = vi.fn();
render(
<ChatHeader
{...baseProps}
conversation={conversation}
channels={[channel]}
onSetChannelFloodScopeOverride={onSetChannelFloodScopeOverride}
/>
);
fireEvent.click(screen.getByTitle('Set regional override'));
expect(await screen.findByRole('dialog')).toBeInTheDocument();
fireEvent.click(
screen.getByRole('button', {
name: 'Always send #flightless unscoped (ignore global region)',
})
);
expect(onSetChannelFloodScopeOverride).toHaveBeenCalledWith(key, '*');
});
});
+9
View File
@@ -1,3 +1,12 @@
// Canonical persisted marker meaning "force this channel unscoped / plain flood"
// (mirrors the backend UNSCOPED_OVERRIDE_MARKER). Distinct from null, which means
// "inherit the global scope".
export const UNSCOPED_OVERRIDE_MARKER = '*';
export function isUnscopedMarker(scope: string | null | undefined): boolean {
return scope === UNSCOPED_OVERRIDE_MARKER;
}
export function stripRegionScopePrefix(scope: string | null | undefined): string {
if (!scope) return '';
return scope.startsWith('#') ? scope.slice(1) : scope;