diff --git a/src/components/nodes/MeshCoreChannelEditor.tsx b/src/components/nodes/MeshCoreChannelEditor.tsx index 23a005c..f57aece 100644 --- a/src/components/nodes/MeshCoreChannelEditor.tsx +++ b/src/components/nodes/MeshCoreChannelEditor.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { ArrowDown, ArrowUp, ChevronDown, Plus, X } from 'lucide-react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { Alert, AlertDescription } from '@/components/ui/alert'; @@ -17,9 +17,11 @@ import { useMeshtasticApi } from '@/hooks/api/useApi'; import { filterChannelsForProtocol } from '@/lib/message-channels'; import { assignedFromFeeder, + assignedIdentityKeys, assignedMcChannelRowDisplay, assignedOrderKey, assignedToApplyEntries, + messageChannelIdentityKey, messageChannelRowDisplay, newDraftChannel, reorderAssigned, @@ -70,24 +72,44 @@ export function MeshCoreChannelEditor({ node, open, onOpenChange }: MeshCoreChan const [showNewForm, setShowNewForm] = useState(false); const [newType, setNewType] = useState<'PUBLIC' | 'HASHTAG'>('PUBLIC'); const [newNameInput, setNewNameInput] = useState(''); + const [newRegionScope, setNewRegionScope] = useState(''); + const [newChannelError, setNewChannelError] = useState(null); const [confirmApplyOpen, setConfirmApplyOpen] = useState(false); + const feederMirrorKey = useMemo( + () => (node.mc_channels ?? []).map((c) => `${c.mc_channel_idx}:${c.id}:${c.region_scope ?? ''}`).join('|'), + [node.mc_channels] + ); + + const editorResetKey = `${node.internal_id ?? ''}:${feederMirrorKey}`; + const lastEditorResetKey = useRef(null); + useEffect(() => { + if (!open) { + lastEditorResetKey.current = null; + return; + } + if (lastEditorResetKey.current === editorResetKey) { + return; + } + lastEditorResetKey.current = editorResetKey; const next = assignedFromFeeder(node); setAssigned(next); setInitialOrderKey(assignedOrderKey(next)); setShowNewForm(false); setNewNameInput(''); - }, [node]); + setNewRegionScope(''); + setNewChannelError(null); + }, [open, editorResetKey, node]); - const assignedCatalogIds = useMemo( - () => new Set(assigned.map((a) => a.catalogId).filter((id): id is number => id != null)), - [assigned] + const assignedKeys = useMemo( + () => assignedIdentityKeys(assigned, catalog, feederSnapshots), + [assigned, catalog, feederSnapshots] ); const available = useMemo( - () => catalog.filter((ch) => !assignedCatalogIds.has(ch.id)), - [catalog, assignedCatalogIds] + () => catalog.filter((ch) => !assignedKeys.has(messageChannelIdentityKey(ch))), + [catalog, assignedKeys] ); const orderChanged = assigned.length > 0 && assignedOrderKey(assigned) !== initialOrderKey; @@ -107,11 +129,21 @@ export function MeshCoreChannelEditor({ node, open, onOpenChange }: MeshCoreChan }); const assignFromCatalog = (channelId: number) => { - setAssigned((prev) => [...prev, { clientId: newClientId('catalog'), catalogId: channelId }]); + const ch = catalog.find((c) => c.id === channelId); + if (!ch) { + return; + } + const identity = messageChannelIdentityKey(ch); + setAssigned((prev) => { + if (assignedIdentityKeys(prev, catalog, feederSnapshots).has(identity)) { + return prev; + } + return [...prev, { clientId: newClientId('catalog'), catalogId: channelId }]; + }); }; - const removeAssigned = (index: number) => { - setAssigned((prev) => prev.filter((_, i) => i !== index)); + const removeAssigned = (clientId: string) => { + setAssigned((prev) => prev.filter((row) => row.clientId !== clientId)); }; const moveAssigned = (index: number, direction: -1 | 1) => { @@ -119,14 +151,21 @@ export function MeshCoreChannelEditor({ node, open, onOpenChange }: MeshCoreChan }; const addDraftToAssigned = () => { - const draft = newDraftChannel(newType, newNameInput); - if (draft.mc_channel_type === 'HASHTAG' && !draft.mc_hashtag) { - return; + setNewChannelError(null); + try { + const draft = newDraftChannel(newType, newNameInput, newRegionScope); + if (draft.mc_channel_type === 'HASHTAG' && !draft.name) { + setNewChannelError('Hashtag channels require a non-empty tag.'); + return; + } + setAssigned((prev) => [...prev, { clientId: newClientId('draft'), draft }]); + setShowNewForm(false); + setNewNameInput(''); + setNewRegionScope(''); + setNewType('PUBLIC'); + } catch (err) { + setNewChannelError(err instanceof Error ? err.message : 'Invalid region scope.'); } - setAssigned((prev) => [...prev, { clientId: newClientId('draft'), draft }]); - setShowNewForm(false); - setNewNameInput(''); - setNewType('PUBLIC'); }; const syncedLabel = node.mc_channels_synced_at @@ -228,6 +267,18 @@ export function MeshCoreChannelEditor({ node, open, onOpenChange }: MeshCoreChan /> )} + setNewRegionScope(e.target.value)} + placeholder="Region scope (optional)" + aria-label="Region scope" + /> + {newChannelError ? ( +

+ {newChannelError} +

+ ) : null}
diff --git a/src/lib/mc-channel-editor.test.ts b/src/lib/mc-channel-editor.test.ts index a4e0670..55deb18 100644 --- a/src/lib/mc-channel-editor.test.ts +++ b/src/lib/mc-channel-editor.test.ts @@ -1,11 +1,13 @@ import { describe, expect, it } from 'vitest'; import { assignedFromFeeder, + assignedIdentityKeys, assignedMcChannelRowDisplay, assignedOrderKey, assignedToApplyEntries, formatAssignedMcChannelLabel, formatMcChannelDraftLabel, + messageChannelIdentityKey, messageChannelRowDisplay, newDraftChannel, reorderAssigned, @@ -27,8 +29,8 @@ describe('mc-channel-editor', () => { constellation: 1, protocol: 'meshcore', mc_channel_type: 'HASHTAG', - mc_hashtag: 'test', - display_label: '#test', + region_scope: 'sample-west', + display_label: '#test · sample-west', }, ]; @@ -38,7 +40,7 @@ describe('mc-channel-editor', () => { mc_channel_idx: 0, name: 'test', mc_channel_type: 'HASHTAG', - mc_hashtag: 'test', + region_scope: 'sample-west', }, ]; @@ -46,19 +48,56 @@ describe('mc-channel-editor', () => { const node = { mc_channels: feederSnapshots, } as OwnedManagedNode; - expect(assignedFromFeeder(node)).toEqual([{ clientId: 'catalog-11', catalogId: 11 }]); + expect(assignedFromFeeder(node)).toEqual([{ clientId: 'slot-0-11', catalogId: 11 }]); + }); + + it('treats scoped and unscoped catalog rows as distinct identities', () => { + const scoped = { + id: 11, + name: 'test', + constellation: 1, + protocol: 'meshcore' as const, + mc_channel_type: 'HASHTAG' as const, + region_scope: 'sample-west', + }; + const unscoped = { + id: 12, + name: 'test', + constellation: 1, + protocol: 'meshcore' as const, + mc_channel_type: 'HASHTAG' as const, + region_scope: null, + }; + expect(messageChannelIdentityKey(scoped)).not.toBe(messageChannelIdentityKey(unscoped)); + const keys = assignedIdentityKeys([{ clientId: 'a', catalogId: 11 }], [scoped, unscoped], []); + expect(keys.has(messageChannelIdentityKey(scoped))).toBe(true); + expect(keys.has(messageChannelIdentityKey(unscoped))).toBe(false); + }); + + it('uses unique client ids per feeder slot', () => { + const node = { + mc_channels: [ + { id: 11, mc_channel_idx: 0, name: 'a', mc_channel_type: 'PUBLIC' as const, region_scope: null }, + { id: 12, mc_channel_idx: 1, name: 'b', mc_channel_type: 'PUBLIC' as const, region_scope: null }, + ], + } as OwnedManagedNode; + const rows = assignedFromFeeder(node); + expect(rows[0].clientId).not.toBe(rows[1].clientId); }); it('formats hashtag labels for catalog and draft rows', () => { expect( formatAssignedMcChannelLabel({ clientId: 'a', catalogId: 11 }, catalog, feederSnapshots) - ).toBe('#test'); - expect(formatMcChannelDraftLabel(newDraftChannel('HASHTAG', 'mesh'))).toBe('#mesh'); + ).toBe('#test · sample-west'); + expect(formatMcChannelDraftLabel(newDraftChannel('HASHTAG', 'mesh', 'uk-wide'))).toBe('#mesh · uk-wide'); expect(formatMcChannelDraftLabel(newDraftChannel('PUBLIC', 'Scotland'))).toBe('Scotland'); }); it('includes type label in row display for catalog and draft', () => { - expect(messageChannelRowDisplay(catalog[1])).toEqual({ label: '#test', typeLabel: 'HASHTAG' }); + expect(messageChannelRowDisplay(catalog[1])).toEqual({ + label: '#test · sample-west', + typeLabel: 'HASHTAG', + }); expect( assignedMcChannelRowDisplay({ clientId: 'd', draft: newDraftChannel('PUBLIC', 'Scotland') }, catalog, []) ).toEqual({ label: 'Scotland', typeLabel: 'PUBLIC' }); @@ -72,17 +111,17 @@ describe('mc-channel-editor', () => { constellation: 1, protocol: 'meshcore', mc_channel_type: 2, - mc_hashtag: 'test', + region_scope: 'uk-wide', }, ]; expect( formatAssignedMcChannelLabel({ clientId: 'a', catalogId: 11 }, intCatalog, feederSnapshots) - ).toBe('#test'); + ).toBe('#test · uk-wide'); }); it('maps assigned order to apply entries with slot indices', () => { const assigned = [ - { clientId: 'd1', draft: newDraftChannel('HASHTAG', 'newtag') }, + { clientId: 'd1', draft: newDraftChannel('HASHTAG', 'newtag', 'west') }, { clientId: 'c1', catalogId: 10 }, ]; expect(assignedToApplyEntries(assigned, catalog, feederSnapshots)).toEqual([ @@ -90,13 +129,13 @@ describe('mc-channel-editor', () => { mc_channel_idx: 0, mc_channel_type: 'HASHTAG', name: 'newtag', - mc_hashtag: 'newtag', + region_scope: 'west', }, { mc_channel_idx: 1, mc_channel_type: 'PUBLIC', name: 'Scotland', - mc_hashtag: null, + region_scope: null, }, ]); }); diff --git a/src/lib/mc-channel-editor.ts b/src/lib/mc-channel-editor.ts index f3ed236..71eed14 100644 --- a/src/lib/mc-channel-editor.ts +++ b/src/lib/mc-channel-editor.ts @@ -6,11 +6,12 @@ import { normalizeMcChannelTypeLabel, type McChannelTypeLabel, } from '@/lib/message-channels'; +import { formatRegionScopeSuffix, normalizeRegionScope } from '@/lib/mc-region-scope'; export type McChannelDraft = { mc_channel_type: 'PUBLIC' | 'HASHTAG'; name: string; - mc_hashtag: string | null; + region_scope: string | null; }; /** Channel assigned to a feeder slot (order = device index). */ @@ -29,11 +30,12 @@ export function isHashtagType(type: string | number | null | undefined): boolean } export function formatMcChannelDraftLabel(draft: McChannelDraft): string { + const scope = formatRegionScopeSuffix(draft.region_scope); if (isHashtagType(draft.mc_channel_type)) { - const tag = stripHashtagPrefix(draft.mc_hashtag ?? draft.name); - return tag ? formatMcHashtagLabel(tag) : 'Hashtag channel'; + const tag = stripHashtagPrefix(draft.name); + return tag ? `${formatMcHashtagLabel(tag)}${scope}` : `Hashtag channel${scope}`; } - return (draft.name || 'Public channel').trim(); + return `${(draft.name || 'Public channel').trim()}${scope}`; } export type McChannelRowDisplay = { @@ -97,33 +99,91 @@ export function formatAssignedMcChannelLabel( } function snapshotToLabel(ch: McChannelSnapshot): string { + const scope = formatRegionScopeSuffix(ch.region_scope); if (isHashtagType(ch.mc_channel_type)) { - const tag = stripHashtagPrefix(ch.mc_hashtag ?? ch.name); - return tag ? formatMcHashtagLabel(tag) : ch.name; + const tag = stripHashtagPrefix(ch.name); + return tag ? `${formatMcHashtagLabel(tag)}${scope}` : ch.name; } - return ch.name; + return `${ch.name}${scope}`; } export function messageChannelToDraft(ch: MessageChannel): McChannelDraft { const isHashtag = isHashtagType(ch.mc_channel_type ?? undefined); if (isHashtag) { - const tag = stripHashtagPrefix(ch.mc_hashtag ?? ch.name); + const tag = stripHashtagPrefix(ch.name); return { mc_channel_type: 'HASHTAG', name: tag, - mc_hashtag: tag || null, + region_scope: ch.region_scope ?? null, }; } return { mc_channel_type: 'PUBLIC', name: (ch.name || 'Public').trim(), - mc_hashtag: null, + region_scope: ch.region_scope ?? null, }; } +/** Stable identity for scoped vs unscoped rows that share the same tag/name. */ +export function mcChannelIdentityKey(parts: { + mc_channel_type: string | number | null | undefined; + name: string; + region_scope?: string | null; +}): string { + const type = isHashtagType(parts.mc_channel_type) ? 'HASHTAG' : 'PUBLIC'; + const scope = (parts.region_scope ?? '').trim().toLowerCase(); + const name = + type === 'HASHTAG' ? stripHashtagPrefix(parts.name).toLowerCase() : (parts.name || '').trim().toLowerCase(); + return `${type}:${name}:${scope}`; +} + +export function messageChannelIdentityKey(ch: MessageChannel): string { + return mcChannelIdentityKey({ + mc_channel_type: ch.mc_channel_type, + name: ch.name, + region_scope: ch.region_scope, + }); +} + +export function assignedRowIdentityKey( + row: AssignedMcChannel, + catalog: MessageChannel[], + feederSnapshots: McChannelSnapshot[] +): string | null { + if (row.catalogId != null) { + const fromCatalog = catalog.find((c) => c.id === row.catalogId); + if (fromCatalog) { + return messageChannelIdentityKey(fromCatalog); + } + const fromFeeder = feederSnapshots.find((c) => c.id === row.catalogId); + if (fromFeeder) { + return mcChannelIdentityKey(fromFeeder); + } + } + if (row.draft) { + return mcChannelIdentityKey(row.draft); + } + return null; +} + +export function assignedIdentityKeys( + assigned: AssignedMcChannel[], + catalog: MessageChannel[], + feederSnapshots: McChannelSnapshot[] +): Set { + const keys = new Set(); + for (const row of assigned) { + const key = assignedRowIdentityKey(row, catalog, feederSnapshots); + if (key) { + keys.add(key); + } + } + return keys; +} + export function assignedFromFeeder(node: OwnedManagedNode): AssignedMcChannel[] { return (node.mc_channels ?? []).map((ch) => ({ - clientId: `catalog-${ch.id}`, + clientId: `slot-${ch.mc_channel_idx}-${ch.id}`, catalogId: ch.id, })); } @@ -138,25 +198,25 @@ function applyEntryFromCatalog(ch: MessageChannel, index: number): McChannelAppl mc_channel_idx: index, mc_channel_type: draft.mc_channel_type, name: draft.name, - mc_hashtag: draft.mc_hashtag, + region_scope: draft.region_scope, }; } function applyEntryFromSnapshot(ch: McChannelSnapshot, index: number): McChannelApplyEntry { if (isHashtagType(ch.mc_channel_type)) { - const tag = stripHashtagPrefix(ch.mc_hashtag ?? ch.name); + const tag = stripHashtagPrefix(ch.name); return { mc_channel_idx: index, mc_channel_type: 'HASHTAG', name: tag, - mc_hashtag: tag || null, + region_scope: ch.region_scope ?? null, }; } return { mc_channel_idx: index, mc_channel_type: 'PUBLIC', name: (ch.name || 'Public').trim(), - mc_hashtag: null, + region_scope: ch.region_scope ?? null, }; } @@ -178,34 +238,39 @@ export function assignedToApplyEntries( } if (row.draft) { if (row.draft.mc_channel_type === 'HASHTAG') { - const tag = stripHashtagPrefix(row.draft.mc_hashtag ?? row.draft.name); + const tag = stripHashtagPrefix(row.draft.name); return { mc_channel_idx: index, mc_channel_type: 'HASHTAG', name: tag, - mc_hashtag: tag || null, + region_scope: row.draft.region_scope, }; } return { mc_channel_idx: index, mc_channel_type: 'PUBLIC', name: row.draft.name.trim() || 'Public', - mc_hashtag: null, + region_scope: row.draft.region_scope, }; } throw new Error('Invalid assigned channel row'); }); } -export function newDraftChannel(type: 'PUBLIC' | 'HASHTAG', nameInput: string): McChannelDraft { +export function newDraftChannel( + type: 'PUBLIC' | 'HASHTAG', + nameInput: string, + regionScopeInput?: string +): McChannelDraft { + const region_scope = normalizeRegionScope(regionScopeInput ?? null); if (type === 'HASHTAG') { const tag = stripHashtagPrefix(nameInput); - return { mc_channel_type: 'HASHTAG', name: tag, mc_hashtag: tag || null }; + return { mc_channel_type: 'HASHTAG', name: tag, region_scope }; } return { mc_channel_type: 'PUBLIC', name: nameInput.trim() || 'Public', - mc_hashtag: null, + region_scope, }; } diff --git a/src/lib/mc-region-scope.ts b/src/lib/mc-region-scope.ts new file mode 100644 index 0000000..bf520c5 --- /dev/null +++ b/src/lib/mc-region-scope.ts @@ -0,0 +1,23 @@ +/** MeshCore region scope validation (matches meshflow-api common/mc_region_scope.py). */ + +export function normalizeRegionScope(value: string | null | undefined): string | null { + if (value == null) { + return null; + } + const raw = value.trim().toLowerCase().replace(/^#+/, ''); + if (!raw || raw === '*' || raw === 'none' || raw === 'null') { + return null; + } + if (new TextEncoder().encode(raw).length > 29) { + throw new Error('Region scope exceeds 29 UTF-8 bytes.'); + } + if (!/^[a-z0-9-]+$/.test(raw)) { + throw new Error('Region scope must use lowercase letters, digits, and hyphens only.'); + } + return raw; +} + +export function formatRegionScopeSuffix(scope: string | null | undefined): string { + const s = scope?.trim(); + return s ? ` · ${s}` : ''; +} diff --git a/src/lib/message-channels.test.ts b/src/lib/message-channels.test.ts index 1c568ab..491a1ab 100644 --- a/src/lib/message-channels.test.ts +++ b/src/lib/message-channels.test.ts @@ -15,7 +15,6 @@ describe('message-channels', () => { constellation: 1, protocol: 'meshcore', mc_channel_type: 'HASHTAG', - mc_hashtag: 'test', display_label: '#test', }, { id: 3, name: 'Legacy', constellation: 1 }, @@ -31,16 +30,16 @@ describe('message-channels', () => { expect(formatMessageChannelLabel(channels[0])).toBe('MT Primary'); }); - it('derives hashtag label when display_label is absent', () => { + it('derives hashtag label with region scope when display_label is absent', () => { const ch: MessageChannel = { id: 4, name: 'galloway', constellation: 1, protocol: 'meshcore', mc_channel_type: 'HASHTAG', - mc_hashtag: 'galloway', + region_scope: 'sample-west', }; - expect(formatMessageChannelLabel(ch)).toBe('#galloway'); + expect(formatMessageChannelLabel(ch)).toBe('#galloway · sample-west'); }); it('normalizes mc_channel_type integer to type label', () => { @@ -52,12 +51,12 @@ describe('message-channels', () => { it('formats hashtag label when mc_channel_type is API integer (2)', () => { const ch: MessageChannel = { id: 5, - name: 'Galloway', + name: 'galloway', constellation: 1, protocol: 'meshcore', mc_channel_type: 2, - mc_hashtag: 'galloway', + region_scope: 'uk-wide', }; - expect(formatMessageChannelLabel(ch)).toBe('#galloway'); + expect(formatMessageChannelLabel(ch)).toBe('#galloway · uk-wide'); }); }); diff --git a/src/lib/message-channels.ts b/src/lib/message-channels.ts index 6e5f8d2..cc32907 100644 --- a/src/lib/message-channels.ts +++ b/src/lib/message-channels.ts @@ -1,5 +1,6 @@ import type { MeshProtocol, MessageChannel } from '@/lib/models'; import type { ProtocolSlug } from '@/lib/mesh-protocol'; +import { formatRegionScopeSuffix } from '@/lib/mc-region-scope'; /** Normalize API protocol field (number or string) to a route slug; null when absent. */ export function protocolSlugFromApiValue( @@ -86,14 +87,15 @@ function isHashtagChannel(ch: MessageChannel): boolean { /** Operator-facing label for Messages / pickers (no device index). */ export function formatMessageChannelLabel(ch: MessageChannel): string { + if (ch.display_label?.trim()) { + return ch.display_label.trim(); + } + const scope = formatRegionScopeSuffix(ch.region_scope); if (isHashtagChannel(ch)) { - const tag = (ch.mc_hashtag ?? ch.name ?? '').replace(/^#+/, '').trim(); + const tag = (ch.name ?? '').replace(/^#+/, '').trim(); if (tag) { - return formatMcHashtagLabel(tag); + return `${formatMcHashtagLabel(tag)}${scope}`; } } - if (ch.display_label?.trim()) { - return ch.display_label.trim(); - } - return ch.name; + return `${ch.name}${scope}`; } diff --git a/src/lib/models.ts b/src/lib/models.ts index f102445..239d0d2 100644 --- a/src/lib/models.ts +++ b/src/lib/models.ts @@ -266,14 +266,14 @@ export interface McChannelSnapshot { name: string; mc_channel_idx: number; mc_channel_type: 'PUBLIC' | 'HASHTAG'; - mc_hashtag: string | null; + region_scope: string | null; } export interface McChannelApplyEntry { mc_channel_idx: number; name: string; mc_channel_type: 'PUBLIC' | 'HASHTAG'; - mc_hashtag?: string | null; + region_scope?: string | null; } // OwnedManagedNode extends ManagedNode with channel mappings @@ -653,7 +653,7 @@ export interface MessageChannel { /** Operator-facing label from API (#hashtag or public name). */ display_label?: string | null; mc_channel_type?: string | number | null; - mc_hashtag?: string | null; + region_scope?: string | null; } export interface Constellation {