Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/components/nodes/McChannelNameWithType.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { McChannelRowDisplay } from '@/lib/mc-channel-editor';

type McChannelNameWithTypeProps = McChannelRowDisplay;

/** Channel name with muted PUBLIC / HASHTAG suffix (MeshCore channel editor lists). */
export function McChannelNameWithType({ label, typeLabel }: McChannelNameWithTypeProps) {
return (
<span className="flex min-w-0 items-baseline gap-1.5">
<span className="truncate font-medium">{label}</span>
{typeLabel ? <span className="shrink-0 text-xs font-normal text-muted-foreground">{typeLabel}</span> : null}
</span>
);
}
22 changes: 11 additions & 11 deletions src/components/nodes/MeshCoreChannelEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,18 @@ import {
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useConstellationChannels } from '@/hooks/api/useConstellations';
import { useMeshtasticApi } from '@/hooks/api/useApi';
import { filterChannelsForProtocol, formatMessageChannelLabel } from '@/lib/message-channels';
import { filterChannelsForProtocol } from '@/lib/message-channels';
import {
assignedFromFeeder,
assignedMcChannelRowDisplay,
assignedOrderKey,
assignedToApplyEntries,
formatAssignedMcChannelLabel,
messageChannelRowDisplay,
newDraftChannel,
reorderAssigned,
type AssignedMcChannel,
} from '@/lib/mc-channel-editor';
import { McChannelNameWithType } from '@/components/nodes/McChannelNameWithType';
import type { OwnedManagedNode } from '@/lib/models';
import { cn } from '@/lib/utils';

Expand Down Expand Up @@ -183,7 +185,7 @@ export function MeshCoreChannelEditor({ node, open, onOpenChange }: MeshCoreChan
className="flex w-full items-center justify-between gap-2 rounded-md border border-transparent px-3 py-2.5 text-left text-sm hover:bg-muted/80 active:bg-muted"
onClick={() => assignFromCatalog(ch.id)}
>
<span className="font-medium truncate">{formatMessageChannelLabel(ch)}</span>
<McChannelNameWithType {...messageChannelRowDisplay(ch)} />
<Plus className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
</button>
</li>
Expand Down Expand Up @@ -267,11 +269,9 @@ export function MeshCoreChannelEditor({ node, open, onOpenChange }: MeshCoreChan
className="flex items-center gap-1 rounded-md border border-border bg-card pl-2 pr-1 py-1"
>
<span className="text-xs font-mono text-muted-foreground w-5 shrink-0">{index}</span>
<span className="flex-1 min-w-0 text-sm font-medium truncate py-1.5">
{formatAssignedMcChannelLabel(row, catalog, feederSnapshots)}
{row.draft ? (
<span className="ml-1 text-xs font-normal text-muted-foreground">(new)</span>
) : null}
<span className="flex flex-1 min-w-0 flex-wrap items-baseline gap-x-1.5 gap-y-0 text-sm py-1.5">
<McChannelNameWithType {...assignedMcChannelRowDisplay(row, catalog, feederSnapshots)} />
{row.draft ? <span className="text-xs font-normal text-muted-foreground">(new)</span> : null}
</span>
<div className="flex shrink-0 items-center">
<Button
Expand Down Expand Up @@ -345,9 +345,9 @@ export function MeshCoreChannelEditor({ node, open, onOpenChange }: MeshCoreChan
</DialogHeader>
<ul className="text-sm space-y-1 max-h-40 overflow-y-auto rounded-md border border-border p-2 bg-muted/30">
{assigned.map((row, index) => (
<li key={row.clientId} className="truncate">
<span className="font-mono text-muted-foreground mr-2">{index}</span>
{formatAssignedMcChannelLabel(row, catalog, feederSnapshots)}
<li key={row.clientId} className="flex items-baseline gap-2 min-w-0">
<span className="font-mono text-muted-foreground shrink-0">{index}</span>
<McChannelNameWithType {...assignedMcChannelRowDisplay(row, catalog, feederSnapshots)} />
</li>
))}
</ul>
Expand Down
25 changes: 25 additions & 0 deletions src/lib/mc-channel-editor.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { describe, expect, it } from 'vitest';
import {
assignedFromFeeder,
assignedMcChannelRowDisplay,
assignedOrderKey,
assignedToApplyEntries,
formatAssignedMcChannelLabel,
formatMcChannelDraftLabel,
messageChannelRowDisplay,
newDraftChannel,
reorderAssigned,
} from './mc-channel-editor';
Expand Down Expand Up @@ -55,6 +57,29 @@ describe('mc-channel-editor', () => {
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(
assignedMcChannelRowDisplay({ clientId: 'd', draft: newDraftChannel('PUBLIC', 'Scotland') }, catalog, [])
).toEqual({ label: 'Scotland', typeLabel: 'PUBLIC' });
});

it('formats hashtag labels when catalog mc_channel_type is API integer', () => {
const intCatalog: MessageChannel[] = [
{
id: 11,
name: 'test',
constellation: 1,
protocol: 'meshcore',
mc_channel_type: 2,
mc_hashtag: 'test',
},
];
expect(
formatAssignedMcChannelLabel({ clientId: 'a', catalogId: 11 }, intCatalog, feederSnapshots)
).toBe('#test');
});

it('maps assigned order to apply entries with slot indices', () => {
const assigned = [
{ clientId: 'd1', draft: newDraftChannel('HASHTAG', 'newtag') },
Expand Down
55 changes: 50 additions & 5 deletions src/lib/mc-channel-editor.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import type { McChannelApplyEntry, McChannelSnapshot, MessageChannel, OwnedManagedNode } from '@/lib/models';
import { formatMessageChannelLabel } from '@/lib/message-channels';
import {
formatMcHashtagLabel,
formatMessageChannelLabel,
isMcHashtagChannelType,
normalizeMcChannelTypeLabel,
type McChannelTypeLabel,
} from '@/lib/message-channels';

export type McChannelDraft = {
mc_channel_type: 'PUBLIC' | 'HASHTAG';
Expand All @@ -18,18 +24,57 @@ export function stripHashtagPrefix(value: string): string {
return value.replace(/^#+/, '').trim();
}

export function isHashtagType(type: string | undefined): boolean {
return String(type ?? '').toUpperCase() === 'HASHTAG';
export function isHashtagType(type: string | number | null | undefined): boolean {
return isMcHashtagChannelType(type);
}

export function formatMcChannelDraftLabel(draft: McChannelDraft): string {
if (isHashtagType(draft.mc_channel_type)) {
const tag = stripHashtagPrefix(draft.mc_hashtag ?? draft.name);
return tag ? `#${tag}` : 'Hashtag channel';
return tag ? formatMcHashtagLabel(tag) : 'Hashtag channel';
}
return (draft.name || 'Public channel').trim();
}

export type McChannelRowDisplay = {
label: string;
typeLabel: McChannelTypeLabel | null;
};

export function messageChannelRowDisplay(ch: MessageChannel): McChannelRowDisplay {
return {
label: formatMessageChannelLabel(ch),
typeLabel: normalizeMcChannelTypeLabel(ch.mc_channel_type),
};
}

export function assignedMcChannelRowDisplay(
assigned: AssignedMcChannel,
catalog: MessageChannel[],
feederSnapshots: McChannelSnapshot[]
): McChannelRowDisplay {
if (assigned.catalogId != null) {
const fromCatalog = catalog.find((c) => c.id === assigned.catalogId);
if (fromCatalog) {
return messageChannelRowDisplay(fromCatalog);
}
const fromFeeder = feederSnapshots.find((c) => c.id === assigned.catalogId);
if (fromFeeder) {
return {
label: snapshotToLabel(fromFeeder),
typeLabel: normalizeMcChannelTypeLabel(fromFeeder.mc_channel_type),
};
}
}
if (assigned.draft) {
return {
label: formatMcChannelDraftLabel(assigned.draft),
typeLabel: assigned.draft.mc_channel_type,
};
}
return { label: 'Channel', typeLabel: null };
}

export function formatAssignedMcChannelLabel(
assigned: AssignedMcChannel,
catalog: MessageChannel[],
Expand All @@ -54,7 +99,7 @@ export function formatAssignedMcChannelLabel(
function snapshotToLabel(ch: McChannelSnapshot): string {
if (isHashtagType(ch.mc_channel_type)) {
const tag = stripHashtagPrefix(ch.mc_hashtag ?? ch.name);
return tag ? `#${tag}` : ch.name;
return tag ? formatMcHashtagLabel(tag) : ch.name;
}
return ch.name;
}
Expand Down
24 changes: 23 additions & 1 deletion src/lib/message-channels.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { describe, it, expect } from 'vitest';
import { filterChannelsForProtocol, formatMessageChannelLabel } from './message-channels';
import {
filterChannelsForProtocol,
formatMessageChannelLabel,
normalizeMcChannelTypeLabel,
} from './message-channels';
import type { MessageChannel } from '@/lib/models';

describe('message-channels', () => {
Expand Down Expand Up @@ -38,4 +42,22 @@ describe('message-channels', () => {
};
expect(formatMessageChannelLabel(ch)).toBe('#galloway');
});

it('normalizes mc_channel_type integer to type label', () => {
expect(normalizeMcChannelTypeLabel(1)).toBe('PUBLIC');
expect(normalizeMcChannelTypeLabel(2)).toBe('HASHTAG');
expect(normalizeMcChannelTypeLabel('HASHTAG')).toBe('HASHTAG');
});

it('formats hashtag label when mc_channel_type is API integer (2)', () => {
const ch: MessageChannel = {
id: 5,
name: 'Galloway',
constellation: 1,
protocol: 'meshcore',
mc_channel_type: 2,
mc_hashtag: 'galloway',
};
expect(formatMessageChannelLabel(ch)).toBe('#galloway');
});
});
56 changes: 50 additions & 6 deletions src/lib/message-channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,21 +35,65 @@ export function filterChannelsForProtocol(channels: MessageChannel[], protocol:
});
}

export type McChannelTypeLabel = 'PUBLIC' | 'HASHTAG';

/** Normalize meshflow-api mc_channel_type (integer or wire string) to PUBLIC / HASHTAG. */
export function normalizeMcChannelTypeLabel(
mcChannelType: string | number | null | undefined
): McChannelTypeLabel | null {
if (mcChannelType === null || mcChannelType === undefined) {
return null;
}
if (typeof mcChannelType === 'number') {
if (mcChannelType === 2) {
return 'HASHTAG';
}
if (mcChannelType === 1) {
return 'PUBLIC';
}
return null;
}
const t = String(mcChannelType).trim().toUpperCase();
if (t === 'HASHTAG' || t === '2') {
return 'HASHTAG';
}
if (t === 'PUBLIC' || t === '1') {
return 'PUBLIC';
}
return null;
}

/** MeshCoreChannelType.HASHTAG in meshflow-api (integer or wire string). */
export function isMcHashtagChannelType(mcChannelType: string | number | null | undefined): boolean {
if (mcChannelType === null || mcChannelType === undefined) {
return false;
}
if (typeof mcChannelType === 'number') {
return mcChannelType === 2;
}
const t = String(mcChannelType).trim().toUpperCase();
return t === 'HASHTAG' || t === '2';
}

export function formatMcHashtagLabel(tag: string): string {
const normalized = tag.replace(/^#+/, '').trim();
return normalized ? `#${normalized}` : '#';
}

function isHashtagChannel(ch: MessageChannel): boolean {
const t = String(ch.mc_channel_type ?? '').toUpperCase();
return t === 'HASHTAG';
return isMcHashtagChannelType(ch.mc_channel_type);
}

/** 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();
}
if (isHashtagChannel(ch)) {
const tag = (ch.mc_hashtag ?? ch.name ?? '').replace(/^#+/, '').trim();
if (tag) {
return `#${tag}`;
return formatMcHashtagLabel(tag);
}
}
if (ch.display_label?.trim()) {
return ch.display_label.trim();
}
return ch.name;
}
2 changes: 1 addition & 1 deletion src/lib/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -651,7 +651,7 @@ export interface MessageChannel {
protocol?: MeshProtocol | 'meshtastic' | 'meshcore' | string;
/** Operator-facing label from API (#hashtag or public name). */
display_label?: string | null;
mc_channel_type?: string | null;
mc_channel_type?: string | number | null;
mc_hashtag?: string | null;
}

Expand Down
Loading