From a037cdc1e37cb7e9c572e79369d1b4aab5ad8ed0 Mon Sep 17 00:00:00 2001 From: Patrick Skillen Date: Wed, 3 Jun 2026 15:31:32 +0100 Subject: [PATCH 1/2] feat(meshcore): region scope in channel editor and labels (#391) Optional region scope on new channels; picker labels show scope suffix. Replace mc_hashtag with region_scope in apply/sync types. --- .../nodes/MeshCoreChannelEditor.tsx | 37 +++++++++++--- src/lib/mc-channel-editor.test.ts | 25 +++++----- src/lib/mc-channel-editor.ts | 48 +++++++++++-------- src/lib/mc-region-scope.ts | 23 +++++++++ src/lib/message-channels.test.ts | 13 +++-- src/lib/message-channels.ts | 14 +++--- src/lib/models.ts | 6 +-- 7 files changed, 112 insertions(+), 54 deletions(-) create mode 100644 src/lib/mc-region-scope.ts diff --git a/src/components/nodes/MeshCoreChannelEditor.tsx b/src/components/nodes/MeshCoreChannelEditor.tsx index 23a005c..022d5bc 100644 --- a/src/components/nodes/MeshCoreChannelEditor.tsx +++ b/src/components/nodes/MeshCoreChannelEditor.tsx @@ -70,6 +70,8 @@ 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); useEffect(() => { @@ -78,6 +80,8 @@ export function MeshCoreChannelEditor({ node, open, onOpenChange }: MeshCoreChan setInitialOrderKey(assignedOrderKey(next)); setShowNewForm(false); setNewNameInput(''); + setNewRegionScope(''); + setNewChannelError(null); }, [node]); const assignedCatalogIds = useMemo( @@ -119,14 +123,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 +239,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 b2f2fdd..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, @@ -46,7 +48,41 @@ 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', () => { diff --git a/src/lib/mc-channel-editor.ts b/src/lib/mc-channel-editor.ts index 8120a80..71eed14 100644 --- a/src/lib/mc-channel-editor.ts +++ b/src/lib/mc-channel-editor.ts @@ -124,9 +124,66 @@ export function messageChannelToDraft(ch: MessageChannel): McChannelDraft { }; } +/** 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, })); }