+ MeshCore Open clients send GIFs and emoji reactions as encoded text (e.g.{' '}
+ g:abc123 or{' '}
+ r:1a2b:05). When enabled, these render as
+ the GIF image or reaction emoji instead of the raw text. Reactions show generically
+ (the emoji is not tied to a specific message). GIFs load from media.giphy.com, which
+ reaches outside your local network and exposes your IP to Giphy — so this is off by
+ default.
+
+
+
+
(null);
@@ -229,6 +230,7 @@ export function SettingsRadioSection({
useEffect(() => {
setAdvertIntervalHours(String(Math.round(appSettings.advert_interval / 3600)));
setFloodScope(stripRegionScopePrefix(appSettings.flood_scope));
+ setKnownRegions((appSettings.known_regions ?? []).join('\n'));
setMaxRadioContacts(String(appSettings.max_radio_contacts));
}, [appSettings]);
@@ -414,6 +416,14 @@ export function SettingsRadioSection({
if (floodScope !== stripRegionScopePrefix(appSettings.flood_scope)) {
update.flood_scope = floodScope;
}
+ // Known regions: one per line (commas also accepted), trimmed, blanks dropped.
+ const parsedRegions = knownRegions
+ .split(/[\n,]/)
+ .map((r) => r.trim())
+ .filter((r) => r.length > 0);
+ if (JSON.stringify(parsedRegions) !== JSON.stringify(appSettings.known_regions ?? [])) {
+ update.known_regions = parsedRegions;
+ }
const newMaxRadioContacts = parseInt(maxRadioContacts, 10);
if (!isNaN(newMaxRadioContacts) && newMaxRadioContacts !== appSettings.max_radio_contacts) {
update.max_radio_contacts = newMaxRadioContacts;
@@ -1157,6 +1167,25 @@ export function SettingsRadioSection({
+
+
+
+
void;
+}
+
+const noop = () => {};
+
+const RichPayloadContext = createContext({
+ renderRichPayloads: false,
+ setRenderRichPayloads: noop,
+});
+
+export function RichPayloadProvider({
+ renderRichPayloads,
+ setRenderRichPayloads,
+ children,
+}: RichPayloadContextValue & { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function useRichPayloads() {
+ return useContext(RichPayloadContext);
+}
diff --git a/frontend/src/hooks/useAppShell.ts b/frontend/src/hooks/useAppShell.ts
index 5bb7342..2db16db 100644
--- a/frontend/src/hooks/useAppShell.ts
+++ b/frontend/src/hooks/useAppShell.ts
@@ -2,6 +2,7 @@ import { startTransition, useCallback, useEffect, useRef, useState } from 'react
import { getLocalLabel, type LocalLabel } from '../utils/localLabel';
import { getSavedDistanceUnit, type DistanceUnit } from '../utils/distanceUnits';
+import { getSavedRenderRichPayloads } from '../utils/richPayloadPreference';
import type { SettingsSection } from '../components/settings/settingsConstants';
import { parseHashSettingsSection, updateSettingsHash, pushSettingsHash } from '../utils/urlHash';
@@ -14,11 +15,13 @@ interface UseAppShellResult {
crackerRunning: boolean;
localLabel: LocalLabel;
distanceUnit: DistanceUnit;
+ renderRichPayloads: boolean;
setSettingsSection: (section: SettingsSection) => void;
setSidebarOpen: (open: boolean) => void;
setCrackerRunning: (running: boolean) => void;
setLocalLabel: (label: LocalLabel) => void;
setDistanceUnit: (unit: DistanceUnit) => void;
+ setRenderRichPayloads: (enabled: boolean) => void;
handleCloseSettingsView: () => void;
handleToggleSettingsView: () => void;
handleOpenNewMessage: () => void;
@@ -38,6 +41,7 @@ export function useAppShell(): UseAppShellResult {
const [crackerRunning, setCrackerRunning] = useState(false);
const [localLabel, setLocalLabel] = useState(getLocalLabel);
const [distanceUnit, setDistanceUnit] = useState(getSavedDistanceUnit);
+ const [renderRichPayloads, setRenderRichPayloads] = useState(getSavedRenderRichPayloads);
const previousHashRef = useRef('');
const isOpeningSettingsRef = useRef(false);
const pushedSettingsEntryRef = useRef(false);
@@ -127,11 +131,13 @@ export function useAppShell(): UseAppShellResult {
crackerRunning,
localLabel,
distanceUnit,
+ renderRichPayloads,
setSettingsSection,
setSidebarOpen,
setCrackerRunning,
setLocalLabel,
setDistanceUnit,
+ setRenderRichPayloads,
handleCloseSettingsView,
handleToggleSettingsView,
handleOpenNewMessage,
diff --git a/frontend/src/test/appFavorites.test.tsx b/frontend/src/test/appFavorites.test.tsx
index f6f1b2a..b8746b6 100644
--- a/frontend/src/test/appFavorites.test.tsx
+++ b/frontend/src/test/appFavorites.test.tsx
@@ -222,6 +222,7 @@ const baseSettings = {
advert_interval: 0,
last_advert_time: 0,
flood_scope: '',
+ known_regions: [],
blocked_keys: [],
blocked_names: [],
};
diff --git a/frontend/src/test/fanoutSection.test.tsx b/frontend/src/test/fanoutSection.test.tsx
index bf76c8e..f3db23e 100644
--- a/frontend/src/test/fanoutSection.test.tsx
+++ b/frontend/src/test/fanoutSection.test.tsx
@@ -105,6 +105,7 @@ beforeEach(() => {
advert_interval: 0,
last_advert_time: 0,
flood_scope: '',
+ known_regions: [],
blocked_keys: [],
blocked_names: [],
discovery_blocked_types: [],
@@ -1147,6 +1148,7 @@ describe('SettingsFanoutSection', () => {
advert_interval: 0,
last_advert_time: 0,
flood_scope: '',
+ known_regions: [],
blocked_keys: [],
blocked_names: [],
discovery_blocked_types: [],
diff --git a/frontend/src/test/meshcoreOpenPayloads.test.ts b/frontend/src/test/meshcoreOpenPayloads.test.ts
new file mode 100644
index 0000000..49be78d
--- /dev/null
+++ b/frontend/src/test/meshcoreOpenPayloads.test.ts
@@ -0,0 +1,81 @@
+/**
+ * Tests for MeshCore Open rich-chat payload parsing (GIFs and reactions).
+ *
+ * Formats are ported from meshcore-open; see meshcoreOpenPayloads.ts.
+ */
+
+import { describe, it, expect } from 'vitest';
+import {
+ REACTION_EMOJIS,
+ giphyUrlForId,
+ parseGif,
+ parseReaction,
+} from '../utils/meshcoreOpenPayloads';
+
+describe('parseGif', () => {
+ it('parses a g: payload', () => {
+ expect(parseGif('g:abc123')).toBe('abc123');
+ });
+
+ it('accepts ids with underscores and dashes', () => {
+ expect(parseGif('g:aB3_-xY')).toBe('aB3_-xY');
+ });
+
+ it('trims surrounding whitespace', () => {
+ expect(parseGif(' g:abc123 ')).toBe('abc123');
+ });
+
+ it('returns null for non-gif text', () => {
+ expect(parseGif('hello world')).toBeNull();
+ expect(parseGif('g:')).toBeNull();
+ expect(parseGif('g:abc 123')).toBeNull();
+ expect(parseGif('prefix g:abc')).toBeNull();
+ expect(parseGif('g:abc!')).toBeNull();
+ });
+
+ it('builds the Giphy media URL', () => {
+ expect(giphyUrlForId('abc123')).toBe('https://media.giphy.com/media/abc123/giphy.gif');
+ });
+});
+
+describe('parseReaction', () => {
+ it('decodes the first emoji (index 00)', () => {
+ const result = parseReaction('r:1a2b:00');
+ expect(result).toEqual({ emoji: REACTION_EMOJIS[0], targetHash: '1a2b' });
+ expect(result?.emoji).toBe('👍');
+ });
+
+ it('decodes a non-zero index', () => {
+ // index 0x06 -> first smiley (after the 6 quick emojis)
+ const result = parseReaction('r:ffff:06');
+ expect(result?.emoji).toBe(REACTION_EMOJIS[6]);
+ expect(result?.targetHash).toBe('ffff');
+ });
+
+ it('trims surrounding whitespace', () => {
+ expect(parseReaction(' r:1a2b:00 ')?.emoji).toBe('👍');
+ });
+
+ it('returns null for an out-of-range index', () => {
+ // 0xff (255) is beyond the emoji list length
+ expect(parseReaction('r:1a2b:ff')).toBeNull();
+ });
+
+ it('returns null for malformed reactions', () => {
+ expect(parseReaction('r:1a2b')).toBeNull();
+ expect(parseReaction('r:1a2:00')).toBeNull(); // hash too short
+ expect(parseReaction('r:1A2B:00')).toBeNull(); // uppercase hex not accepted
+ expect(parseReaction('r:1a2b:0')).toBeNull(); // index too short
+ expect(parseReaction('hello')).toBeNull();
+ });
+
+ it('exposes a stable, deduplication-free emoji index range', () => {
+ // 6 quick + 64 smileys + 33 gestures + 32 hearts + 49 objects
+ expect(REACTION_EMOJIS.length).toBe(184);
+ // every defined index decodes to a string
+ for (let i = 0; i < REACTION_EMOJIS.length; i++) {
+ const hex = i.toString(16).padStart(2, '0');
+ expect(parseReaction(`r:0000:${hex}`)?.emoji).toBe(REACTION_EMOJIS[i]);
+ }
+ });
+});
diff --git a/frontend/src/test/messageList.test.tsx b/frontend/src/test/messageList.test.tsx
index d469d27..faed098 100644
--- a/frontend/src/test/messageList.test.tsx
+++ b/frontend/src/test/messageList.test.tsx
@@ -62,6 +62,31 @@ describe('MessageList channel sender rendering', () => {
expect(screen.getByTestId('corrupt-avatar')).toBeInTheDocument();
});
+ it('renders a region badge for region-scoped channel messages', () => {
+ render(
+
+ );
+
+ expect(screen.getByText('nl-gr')).toBeInTheDocument();
+ expect(screen.getByTitle('Regional scope: nl-gr')).toBeInTheDocument();
+ });
+
+ it('does not render a region badge for unscoped messages', () => {
+ render(
+
+ );
+
+ expect(screen.queryByText('nl-gr')).not.toBeInTheDocument();
+ });
+
it('prefers stored sender_name for channel messages even when text is not sender-prefixed', () => {
render(
{
expect(pathRun.className).toBe(idleClassName);
});
- it('shows scope card with transport codes for scoped packets', () => {
+ it('shows scope card with transport codes for scoped packets without a resolved region', () => {
render();
expect(screen.getByText('Scope')).toBeInTheDocument();
expect(screen.getByText('Regional')).toBeInTheDocument();
+ expect(screen.getByText('0x1234, 0x5678 · unknown region')).toBeInTheDocument();
+ });
+
+ it('shows the resolved region name in the scope card when the backend matched one', () => {
+ render(
+
+ );
+
+ expect(screen.getByText('Scope')).toBeInTheDocument();
+ expect(screen.getByText('nl-gr')).toBeInTheDocument();
+ // Raw codes remain visible as the secondary detail.
expect(screen.getByText('0x1234, 0x5678')).toBeInTheDocument();
});
diff --git a/frontend/src/test/richPayloadPreference.test.ts b/frontend/src/test/richPayloadPreference.test.ts
new file mode 100644
index 0000000..ee75c98
--- /dev/null
+++ b/frontend/src/test/richPayloadPreference.test.ts
@@ -0,0 +1,35 @@
+import { beforeEach, describe, expect, it } from 'vitest';
+
+import {
+ RENDER_RICH_PAYLOADS_KEY,
+ getSavedRenderRichPayloads,
+ setSavedRenderRichPayloads,
+} from '../utils/richPayloadPreference';
+
+describe('richPayloadPreference utilities', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ it('defaults to off when unset', () => {
+ expect(getSavedRenderRichPayloads()).toBe(false);
+ });
+
+ it('returns true when enabled', () => {
+ localStorage.setItem(RENDER_RICH_PAYLOADS_KEY, 'true');
+ expect(getSavedRenderRichPayloads()).toBe(true);
+ });
+
+ it('treats any non-"true" value as off', () => {
+ localStorage.setItem(RENDER_RICH_PAYLOADS_KEY, 'yes');
+ expect(getSavedRenderRichPayloads()).toBe(false);
+ });
+
+ it('persists when enabled and clears the key when disabled', () => {
+ setSavedRenderRichPayloads(true);
+ expect(localStorage.getItem(RENDER_RICH_PAYLOADS_KEY)).toBe('true');
+
+ setSavedRenderRichPayloads(false);
+ expect(localStorage.getItem(RENDER_RICH_PAYLOADS_KEY)).toBeNull();
+ });
+});
diff --git a/frontend/src/test/settingsModal.test.tsx b/frontend/src/test/settingsModal.test.tsx
index ec881c3..ed019e0 100644
--- a/frontend/src/test/settingsModal.test.tsx
+++ b/frontend/src/test/settingsModal.test.tsx
@@ -66,6 +66,7 @@ const baseSettings: AppSettings = {
advert_interval: 0,
last_advert_time: 0,
flood_scope: '',
+ known_regions: [],
blocked_keys: [],
blocked_names: [],
discovery_blocked_types: [],
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index db8e9f4..ef6994e 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -309,6 +309,10 @@ export interface Message {
sender_name: string | null;
channel_name?: string | null;
packet_id?: number | null;
+ /** Region scope transport code (uint16) when this arrived via a transport-routed packet. */
+ transport_code?: number | null;
+ /** Resolved region name for the transport code, if it matched a known region. */
+ region?: string | null;
}
export interface MessagesAroundResponse {
@@ -352,6 +356,10 @@ export interface RawPacket {
sender_timestamp: number | null;
message: string | null;
} | null;
+ /** Region scope transport code (uint16) for TransportFlood/TransportDirect packets. */
+ transport_code?: number | null;
+ /** Resolved region name for the transport code, if it matched a known region. */
+ region?: string | null;
}
export interface AppSettings {
@@ -361,6 +369,7 @@ export interface AppSettings {
advert_interval: number;
last_advert_time: number;
flood_scope: string;
+ known_regions: string[];
blocked_keys: string[];
blocked_names: string[];
discovery_blocked_types: number[];
@@ -377,6 +386,7 @@ export interface AppSettingsUpdate {
advert_interval?: number;
auto_resend_channel?: boolean;
flood_scope?: string;
+ known_regions?: string[];
blocked_keys?: string[];
blocked_names?: string[];
discovery_blocked_types?: number[];
diff --git a/frontend/src/utils/meshcoreOpenPayloads.ts b/frontend/src/utils/meshcoreOpenPayloads.ts
new file mode 100644
index 0000000..5adacfc
--- /dev/null
+++ b/frontend/src/utils/meshcoreOpenPayloads.ts
@@ -0,0 +1,113 @@
+/**
+ * Parsing for rich-chat payloads sent by MeshCore Open clients as ordinary
+ * plaintext mesh messages.
+ *
+ * MeshCore Open encodes some rich features into the message body with a short
+ * prefix. RemoteTerm recognizes two of them for display:
+ *
+ * g: Giphy GIF -> https://media.giphy.com/media//giphy.gif
+ * r:: Emoji reaction -> picks an emoji from a fixed list
+ *
+ * Formats and the emoji table are ported verbatim from meshcore-open:
+ * lib/helpers/gif_helper.dart
+ * lib/helpers/reaction_helper.dart
+ * lib/widgets/emoji_picker.dart
+ * (github.com/zjs81/meshcore-open, dev branch).
+ *
+ * Reaction support here is intentionally "generic display only": we decode the
+ * emoji from and show it, but we do NOT resolve back to the
+ * target message (that requires porting Dart's String.hashCode). See issue #291.
+ */
+
+// --- Emoji table (order must match meshcore-open exactly for index compat) ---
+
+const QUICK_EMOJIS = ['👍', '❤️', '😂', '🎉', '👏', '🔥'];
+
+// prettier-ignore
+const SMILEYS = [
+ '😀', '😃', '😄', '😁', '😅', '😂', '🤣', '😊', '😇', '🙂',
+ '🙃', '😉', '😌', '😍', '🥰', '😘', '😗', '😙', '😚', '😋',
+ '😛', '😝', '😜', '🤪', '🤨', '🧐', '🤓', '😎', '🥸', '🤩',
+ '🥳', '😏', '😒', '😞', '😔', '😟', '😕', '🙁', '😣', '😖',
+ '😫', '😩', '🥺', '😢', '😭', '😤', '😠', '😡', '🤬', '🤯',
+ '😳', '🥵', '🥶', '😱', '😨', '😰', '😥', '😓', '🤗', '🤔',
+ '🤭', '🤫', '🤥', '😶',
+];
+
+// prettier-ignore
+const GESTURES = [
+ '👍', '👎', '👊', '✊', '🤛', '🤜', '🤞', '✌️', '🤟', '🤘',
+ '👌', '🤌', '🤏', '👈', '👉', '👆', '👇', '☝️', '👋', '🤚',
+ '🖐️', '✋', '🖖', '👏', '🙌', '👐', '🤲', '🤝', '🙏', '✍️',
+ '💅', '🤳', '💪',
+];
+
+// prettier-ignore
+const HEARTS = [
+ '❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', '🤎', '💔',
+ '❤️🔥', '❤️🩹', '💕', '💞', '💓', '💗', '💖', '💘', '💝', '💟',
+ '💌', '💢', '💥', '💫', '💦', '💨', '🕳️', '💬', '👁️🗨️', '🗨️',
+ '🗯️', '💭',
+];
+
+// prettier-ignore
+const OBJECTS = [
+ '🎉', '🎊', '🎈', '🎁', '🎀', '🪅', '🪆', '🏆', '🥇', '🥈',
+ '🥉', '⚽', '⚾', '🥎', '🏀', '🏐', '🏈', '🏉', '🎾', '🥏',
+ '🎳', '🏏', '🏑', '🏒', '🥍', '🏓', '🏸', '🥊', '🥋', '🥅',
+ '⛳', '🔥', '⭐', '🌟', '✨', '⚡', '💡', '🔦', '🏮', '🪔',
+ '📱', '💻', '⌚', '📷', '📺', '📻', '🎵', '🎶', '🚀',
+];
+
+/** Combined reaction emoji list, in the fixed index order used on the wire. */
+export const REACTION_EMOJIS: readonly string[] = [
+ ...QUICK_EMOJIS,
+ ...SMILEYS,
+ ...GESTURES,
+ ...HEARTS,
+ ...OBJECTS,
+];
+
+// --- GIF (g:) ---
+
+const GIF_PATTERN = /^g:([A-Za-z0-9_-]+)$/;
+
+/**
+ * Parse a MeshCore Open GIF payload. Returns the Giphy GIF id, or null if the
+ * (trimmed) text is not a `g:` payload.
+ */
+export function parseGif(text: string): string | null {
+ const match = GIF_PATTERN.exec(text.trim());
+ return match ? match[1] : null;
+}
+
+/** Build the Giphy media URL for a GIF id. */
+export function giphyUrlForId(gifId: string): string {
+ return `https://media.giphy.com/media/${gifId}/giphy.gif`;
+}
+
+// --- Reaction (r::) ---
+
+const REACTION_PATTERN = /^r:([0-9a-f]{4}):([0-9a-f]{2})$/;
+
+export interface ParsedReaction {
+ /** The decoded reaction emoji. */
+ emoji: string;
+ /** 4-hex hash identifying the target message (not resolved here). */
+ targetHash: string;
+}
+
+/**
+ * Parse a MeshCore Open reaction payload. Returns the decoded emoji and the
+ * (unresolved) target-message hash, or null if the (trimmed) text is not a
+ * valid `r::` payload or the index is out of range.
+ */
+export function parseReaction(text: string): ParsedReaction | null {
+ const match = REACTION_PATTERN.exec(text.trim());
+ if (!match) return null;
+ const index = parseInt(match[2], 16);
+ if (!Number.isInteger(index) || index < 0 || index >= REACTION_EMOJIS.length) {
+ return null;
+ }
+ return { emoji: REACTION_EMOJIS[index], targetHash: match[1] };
+}
diff --git a/frontend/src/utils/richPayloadPreference.ts b/frontend/src/utils/richPayloadPreference.ts
new file mode 100644
index 0000000..fc9aabe
--- /dev/null
+++ b/frontend/src/utils/richPayloadPreference.ts
@@ -0,0 +1,26 @@
+// Browser-local preference for rendering MeshCore Open rich-chat payloads
+// (Giphy GIFs and emoji reactions) instead of their raw encoded text. This is
+// a pure display tweak, stored per-browser in localStorage. GIF rendering
+// fetches images from media.giphy.com, so it is off by default.
+
+export const RENDER_RICH_PAYLOADS_KEY = 'remoteterm-render-rich-payloads';
+
+export function getSavedRenderRichPayloads(): boolean {
+ try {
+ return localStorage.getItem(RENDER_RICH_PAYLOADS_KEY) === 'true';
+ } catch {
+ return false;
+ }
+}
+
+export function setSavedRenderRichPayloads(enabled: boolean): void {
+ try {
+ if (enabled) {
+ localStorage.setItem(RENDER_RICH_PAYLOADS_KEY, 'true');
+ } else {
+ localStorage.removeItem(RENDER_RICH_PAYLOADS_KEY);
+ }
+ } catch {
+ // localStorage may be unavailable
+ }
+}
diff --git a/tests/test_event_handlers.py b/tests/test_event_handlers.py
index c2b7b4d..fd72c0b 100644
--- a/tests/test_event_handlers.py
+++ b/tests/test_event_handlers.py
@@ -384,6 +384,8 @@ class TestContactMessageCLIFiltering:
"sender_name",
"channel_name",
"packet_id",
+ "transport_code",
+ "region",
}
with patch("app.event_handlers.broadcast_event") as mock_broadcast:
diff --git a/tests/test_migrations/conftest.py b/tests/test_migrations/conftest.py
index c066e9a..d5a7d68 100644
--- a/tests/test_migrations/conftest.py
+++ b/tests/test_migrations/conftest.py
@@ -2,4 +2,4 @@
# run ``run_migrations`` to completion assert ``get_version == LATEST`` and
# ``applied == LATEST - starting_version`` so only this constant needs to
# change, not every individual assertion.
-LATEST_SCHEMA_VERSION = 62
+LATEST_SCHEMA_VERSION = 63
diff --git a/tests/test_region_resolver.py b/tests/test_region_resolver.py
new file mode 100644
index 0000000..1c7172a
--- /dev/null
+++ b/tests/test_region_resolver.py
@@ -0,0 +1,198 @@
+"""Tests for regional flood-scope (transport code) resolution."""
+
+import hashlib
+import hmac
+import json
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+
+from app.path_utils import parse_packet_envelope
+from app.region_resolver import compute_transport_code, resolve_region
+
+FIXTURES_PATH = Path(__file__).parent / "fixtures" / "websocket_events.json"
+with open(FIXTURES_PATH) as f:
+ FIXTURES = json.load(f)
+
+
+def _reference_code(region_name: str, payload_type: int, payload: bytes) -> int:
+ """Independent reimplementation of the firmware algorithm for cross-checking."""
+ key = hashlib.sha256(("#" + region_name).encode()).digest()[:16]
+ digest = hmac.new(key, bytes([payload_type]) + payload, hashlib.sha256).digest()
+ code = int.from_bytes(digest[:2], "little")
+ if code == 0:
+ return 1
+ if code == 0xFFFF:
+ return 0xFFFE
+ return code
+
+
+class TestComputeTransportCode:
+ def test_matches_reference_algorithm(self):
+ payload = bytes.fromhex("0badcafe1234")
+ assert compute_transport_code("nl-gr", 0x05, payload) == _reference_code(
+ "nl-gr", 0x05, payload
+ )
+
+ def test_hashtag_prefix_is_equivalent(self):
+ payload = b"hello"
+ assert compute_transport_code("nl-gr", 0x05, payload) == compute_transport_code(
+ "#nl-gr", 0x05, payload
+ )
+
+ def test_blank_region_returns_none(self):
+ assert compute_transport_code("", 0x05, b"x") is None
+
+ def test_code_depends_on_payload(self):
+ # The code is a keyed MAC over the payload, so different payloads under the
+ # same region produce different codes (this is why there is no static map).
+ assert compute_transport_code("nl-gr", 0x05, b"a") != compute_transport_code(
+ "nl-gr", 0x05, b"b"
+ )
+
+ def test_never_returns_reserved_values(self):
+ for i in range(3000):
+ code = compute_transport_code(f"region-{i}", 0x05, bytes([i & 0xFF, (i >> 8) & 0xFF]))
+ assert code not in (0x0000, 0xFFFF)
+
+
+class TestResolveRegion:
+ def test_resolves_first_matching_region(self):
+ payload = bytes.fromhex("c0ffee")
+ code = compute_transport_code("nl-gr", 0x05, payload)
+ assert resolve_region(0x05, payload, code, ["de-by", "nl-gr", "fr"]) == "nl-gr"
+
+ def test_no_match_returns_none(self):
+ payload = bytes.fromhex("c0ffee")
+ code = compute_transport_code("nl-gr", 0x05, payload)
+ assert resolve_region(0x05, payload, code, ["de-by", "fr"]) is None
+
+ def test_empty_candidates_returns_none(self):
+ assert resolve_region(0x05, b"x", 0x1234, []) is None
+
+ def test_blank_candidate_names_skipped(self):
+ payload = b"x"
+ code = compute_transport_code("nl-gr", 0x05, payload)
+ assert resolve_region(0x05, payload, code, ["", "nl-gr"]) == "nl-gr"
+
+
+class TestEnvelopeTransportCodes:
+ def test_flood_packet_has_no_transport_codes(self):
+ raw = bytes.fromhex(FIXTURES["channel_message"]["raw_packet_hex"])
+ env = parse_packet_envelope(raw)
+ assert env is not None
+ assert env.transport_codes is None
+
+ def test_transport_routed_packet_exposes_codes(self):
+ # Build a TRANSPORT_FLOOD packet: header | code_1 | code_2 | path_byte | payload
+ code_1, code_2 = 0x9164, 0x0000
+ header = bytes([0x05 << 2]) # payload_type=GROUP_TEXT, route_type=TRANSPORT_FLOOD(0)
+ raw = (
+ header
+ + code_1.to_bytes(2, "little")
+ + code_2.to_bytes(2, "little")
+ + bytes([0x00]) # path byte: 0 hops, 1-byte hash
+ + b"payloadbytes"
+ )
+ env = parse_packet_envelope(raw)
+ assert env is not None
+ assert env.transport_codes == (code_1, code_2)
+ assert env.payload == b"payloadbytes"
+
+
+def _build_transport_channel_packet(region_name: str | None, code_override: int | None = None):
+ """Rebuild the channel fixture as a TRANSPORT_FLOOD packet, scoped to a region."""
+ raw = bytes.fromhex(FIXTURES["channel_message"]["raw_packet_hex"])
+ env = parse_packet_envelope(raw)
+ assert env is not None and env.hop_count == 0
+ payload = env.payload
+ if code_override is not None:
+ code_1 = code_override
+ else:
+ code_1 = compute_transport_code(region_name, 0x05, payload)
+ header = bytes([0x05 << 2]) # GROUP_TEXT + TRANSPORT_FLOOD
+ return (
+ header + code_1.to_bytes(2, "little") + (0).to_bytes(2, "little") + bytes([0x00]) + payload
+ ), code_1
+
+
+class TestRegionPersistedOnChannelMessage:
+ @pytest.mark.asyncio
+ async def test_known_region_stored_on_message_and_broadcast(self, test_db, captured_broadcasts):
+ from app.packet_processor import process_raw_packet
+ from app.repository import AppSettingsRepository, ChannelRepository, MessageRepository
+
+ fixture = FIXTURES["channel_message"]
+ await ChannelRepository.upsert(
+ key=fixture["channel_key_hex"].upper(), name=fixture["channel_name"], is_hashtag=True
+ )
+ await AppSettingsRepository.update(known_regions=["nl-gr"])
+
+ packet_bytes, code = _build_transport_channel_packet("nl-gr")
+ broadcasts, mock_broadcast = captured_broadcasts
+ with patch("app.packet_processor.broadcast_event", mock_broadcast):
+ await process_raw_packet(packet_bytes, timestamp=1700000000)
+
+ messages = await MessageRepository.get_all(
+ msg_type="CHAN", conversation_key=fixture["channel_key_hex"].upper(), limit=10
+ )
+ assert len(messages) == 1
+ assert messages[0].region == "nl-gr"
+ assert messages[0].transport_code == code
+
+ # WS message + raw_packet broadcasts both carry the region
+ msg_b = [b for b in broadcasts if b["type"] == "message"][0]
+ assert msg_b["data"]["region"] == "nl-gr"
+ assert msg_b["data"]["transport_code"] == code
+ raw_b = [b for b in broadcasts if b["type"] == "raw_packet"][0]
+ assert raw_b["data"]["region"] == "nl-gr"
+ assert raw_b["data"]["transport_code"] == code
+
+ @pytest.mark.asyncio
+ async def test_unlisted_region_keeps_code_but_no_name(self, test_db, captured_broadcasts):
+ from app.packet_processor import process_raw_packet
+ from app.repository import AppSettingsRepository, ChannelRepository, MessageRepository
+
+ fixture = FIXTURES["channel_message"]
+ await ChannelRepository.upsert(
+ key=fixture["channel_key_hex"].upper(), name=fixture["channel_name"], is_hashtag=True
+ )
+ # Region list does NOT include the scope this packet is tagged with.
+ await AppSettingsRepository.update(known_regions=["somewhere-else"])
+
+ packet_bytes, code = _build_transport_channel_packet("nl-gr")
+ _, mock_broadcast = captured_broadcasts
+ with patch("app.packet_processor.broadcast_event", mock_broadcast):
+ await process_raw_packet(packet_bytes, timestamp=1700000000)
+
+ messages = await MessageRepository.get_all(
+ msg_type="CHAN", conversation_key=fixture["channel_key_hex"].upper(), limit=10
+ )
+ assert len(messages) == 1
+ # Scoped (transport_code set) but region unknown → distinguishable from unscoped.
+ assert messages[0].transport_code == code
+ assert messages[0].region is None
+
+ @pytest.mark.asyncio
+ async def test_plain_flood_message_is_unscoped(self, test_db, captured_broadcasts):
+ from app.packet_processor import process_raw_packet
+ from app.repository import AppSettingsRepository, ChannelRepository, MessageRepository
+
+ fixture = FIXTURES["channel_message"]
+ await ChannelRepository.upsert(
+ key=fixture["channel_key_hex"].upper(), name=fixture["channel_name"], is_hashtag=True
+ )
+ await AppSettingsRepository.update(known_regions=["nl-gr"])
+
+ packet_bytes = bytes.fromhex(fixture["raw_packet_hex"]) # original FLOOD packet
+ _, mock_broadcast = captured_broadcasts
+ with patch("app.packet_processor.broadcast_event", mock_broadcast):
+ await process_raw_packet(packet_bytes, timestamp=1700000000)
+
+ messages = await MessageRepository.get_all(
+ msg_type="CHAN", conversation_key=fixture["channel_key_hex"].upper(), limit=10
+ )
+ assert len(messages) == 1
+ assert messages[0].transport_code is None
+ assert messages[0].region is None
diff --git a/tests/test_settings_router.py b/tests/test_settings_router.py
index e351c9e..30e8468 100644
--- a/tests/test_settings_router.py
+++ b/tests/test_settings_router.py
@@ -81,6 +81,29 @@ class TestUpdateSettings:
result = await update_settings(AppSettingsUpdate(flood_scope="#MyRegion"))
assert result.flood_scope == "#MyRegion"
+ @pytest.mark.asyncio
+ async def test_known_regions_round_trip(self, test_db):
+ """Known regions should be saved and retrieved as a clean list."""
+ result = await update_settings(AppSettingsUpdate(known_regions=["nl-gr", "de-by"]))
+ assert result.known_regions == ["nl-gr", "de-by"]
+
+ fresh = await AppSettingsRepository.get()
+ assert fresh.known_regions == ["nl-gr", "de-by"]
+
+ @pytest.mark.asyncio
+ async def test_known_regions_default_empty(self, test_db):
+ """Fresh DB should have known_regions as an empty list."""
+ settings = await AppSettingsRepository.get()
+ assert settings.known_regions == []
+
+ @pytest.mark.asyncio
+ async def test_known_regions_cleaned_and_deduped(self, test_db):
+ """Leading hashes, whitespace, blanks, and case-insensitive dupes are normalized."""
+ result = await update_settings(
+ AppSettingsUpdate(known_regions=[" #nl-gr ", "", "nl-gr", "DE-BY", "de-by"])
+ )
+ assert result.known_regions == ["nl-gr", "DE-BY"]
+
@pytest.mark.asyncio
async def test_flood_scope_applies_to_radio(self, test_db):
"""When radio is connected, setting flood_scope calls set_flood_scope on radio."""