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
28 changes: 3 additions & 25 deletions src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1421,31 +1421,9 @@ function AppContent() {
[meshcoreUiNodes],
);
const meshcorePublicKeyHexByNodeId = useMemo(() => {
const m = new Map<number, string>();
if (!meshcoreCapabilities.hasContactImportExport) return m;
const self = meshcoreRuntime.selfInfo;
if (self?.publicKey?.length === 32) {
m.set(
pubkeyToNodeId(self.publicKey),
Array.from(self.publicKey)
.map((b) => b.toString(16).padStart(2, '0'))
.join(''),
);
}
for (const c of meshcoreRuntime.meshcoreContactsForTelemetry) {
m.set(
pubkeyToNodeId(c.publicKey),
Array.from(c.publicKey)
.map((b) => b.toString(16).padStart(2, '0'))
.join(''),
);
}
return m;
}, [
meshcoreCapabilities.hasContactImportExport,
meshcoreRuntime.selfInfo,
meshcoreRuntime.meshcoreContactsForTelemetry,
]);
if (!meshcoreCapabilities.hasContactImportExport) return new Map<number, string>();
return meshcoreRuntime.meshcorePubKeyHexByNodeId;
}, [meshcoreCapabilities.hasContactImportExport, meshcoreRuntime.meshcorePubKeyHexByNodeId]);

const capabilities = activeProtocolCapabilities;
const nodeCountLabel = capabilities.nodeListTabUsesContactsLabel
Expand Down
65 changes: 65 additions & 0 deletions src/renderer/components/NodeDetailModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ vi.mock('../lib/downloadBlob', () => ({
downloadBlob: vi.fn(),
}));

vi.mock('@/renderer/lib/writeClipboardText', () => ({
writeClipboardText: vi.fn().mockResolvedValue(undefined),
}));

const mockNode: MeshNode = {
node_id: 0xdeadbeef,
short_name: 'TEST',
Expand Down Expand Up @@ -304,6 +308,67 @@ describe('NodeDetailModal MeshCore actions', () => {
expect(screen.getByRole('button', { name: '📊 Request Status' })).toBeDisabled();
});

it('renders the full public key with a copy button and copies it on click', async () => {
const { writeClipboardText } = await import('@/renderer/lib/writeClipboardText');
const pubkeyHex = 'ab'.repeat(32);
vi.mocked(window.electronAPI.db.getMeshcoreContactById).mockResolvedValue({
public_key: pubkeyHex,
on_radio: 1,
} as unknown as Awaited<ReturnType<typeof window.electronAPI.db.getMeshcoreContactById>>);
const user = userEvent.setup();
const { container } = renderMeshcoreModal();

const pubkeyEl = await screen.findByText(pubkeyHex);
expect(pubkeyEl).toBeInTheDocument();

hydrateAxeThemeColors(container);
expect(await axe(container)).toHaveNoViolations();

const copyButton = screen.getByRole('button', { name: 'Copy public key' });
await user.click(copyButton);
expect(writeClipboardText).toHaveBeenCalledWith(pubkeyHex);
expect(await screen.findByText('Public key copied to clipboard.')).toBeInTheDocument();
});

it.each(['Chat', 'Sensor'])(
'shows a DM-capable key badge for a MeshCore %s contact with a public key',
async (hwModel) => {
vi.mocked(window.electronAPI.db.getMeshcoreContactById).mockResolvedValue({
public_key: 'ab'.repeat(32),
on_radio: 1,
} as unknown as Awaited<ReturnType<typeof window.electronAPI.db.getMeshcoreContactById>>);
const { container } = renderMeshcoreModal({
node: { ...meshcoreRepeaterNode, hw_model: hwModel },
});

const badge = await screen.findByTitle('Has public key - can send DMs');
expect(badge).toHaveTextContent('🔑 DM');
expect(screen.queryByTitle('Has public key (no direct messages)')).not.toBeInTheDocument();
hydrateAxeThemeColors(container);
expect(await axe(container)).toHaveNoViolations();
},
);

it.each(['Repeater', 'Room'])(
'shows a key-only badge (no DM) for a MeshCore %s contact with a public key',
async (hwModel) => {
vi.mocked(window.electronAPI.db.getMeshcoreContactById).mockResolvedValue({
public_key: 'ab'.repeat(32),
on_radio: 1,
} as unknown as Awaited<ReturnType<typeof window.electronAPI.db.getMeshcoreContactById>>);
const { container } = renderMeshcoreModal({
node: { ...meshcoreRepeaterNode, hw_model: hwModel },
});

const badge = await screen.findByTitle('Has public key (no direct messages)');
expect(badge).toHaveTextContent('🔑');
expect(badge).not.toHaveTextContent('DM');
expect(screen.queryByTitle('Has public key - can send DMs')).not.toBeInTheDocument();
hydrateAxeThemeColors(container);
expect(await axe(container)).toHaveNoViolations();
},
);

it('enables Message when live store has pubkey but DB contact row does not', async () => {
const chatNode: MeshNode = { ...meshcoreRepeaterNode, hw_model: 'Chat' };
const pubKey = new Uint8Array(32).fill(0xab);
Expand Down
41 changes: 38 additions & 3 deletions src/renderer/components/NodeDetailModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
normalizeMeshtasticAdminKeyInput,
} from '@/renderer/lib/meshtasticRemoteAdminKeyStorage';
import { getOfflineIdentityIdForProtocol } from '@/renderer/lib/offlineProtocolIdentities';
import { writeClipboardText } from '@/renderer/lib/writeClipboardText';
import { formatIsoDateTime } from '@/shared/formatIsoDate';
import { buildMeshcoreContactAddUri, type MeshcoreContactType } from '@/shared/meshClientDeepLink';
import { isDeleteActiveMqttIdentityError } from '@/shared/meshtasticDeleteNodeError';
Expand Down Expand Up @@ -47,6 +48,7 @@ import {
MESHCORE_CONTACTS_CRITICAL_THRESHOLD,
MESHCORE_MAX_CONTACTS,
meshcoreContactTypeFromHwModel,
meshcorePubkeyShortId,
meshcoreTracePathLenToHops,
} from '../lib/meshcoreUtils';
import {
Expand Down Expand Up @@ -602,7 +604,10 @@ export default function NodeDetailModal({

if (!node) return null;

const hexId = formatMeshtasticNodeId(node.node_id);
const hexId =
protocol === 'meshcore'
? (meshcorePubkeyShortId(contactPubkey) ?? formatMeshtasticNodeId(node.node_id))
: formatMeshtasticNodeId(node.node_id);
const awaitingNodeInfo =
protocol === 'meshtastic' && meshtasticNodeAwaitingNodeInfo(node, { isConnected });
const displayName = node.short_name || node.long_name || hexId;
Expand Down Expand Up @@ -724,9 +729,13 @@ export default function NodeDetailModal({
{protocol === 'meshcore' && contactPubkey && (
<span
className="shrink-0 rounded border border-green-500/30 bg-green-500/20 px-1.5 py-0.5 text-[10px] font-medium text-green-300"
title={t('nodeDetailModal.hasPublicKey')}
title={
isMeshcoreDmExcludedHwModel(node.hw_model)
? t('nodeDetailModal.hasPublicKeyNoDm')
: t('nodeDetailModal.hasPublicKey')
}
>
🔑 DM
{isMeshcoreDmExcludedHwModel(node.hw_model) ? '🔑' : '🔑 DM'}
</span>
)}
{protocol === 'meshcore' &&
Expand Down Expand Up @@ -778,6 +787,32 @@ export default function NodeDetailModal({
</span>
)}
</div>
{protocol === 'meshcore' && contactPubkey && (
<div className="mt-1 flex w-full items-start gap-2">
<span className="text-muted font-mono text-[10px] break-all whitespace-normal">
{contactPubkey}
</span>
<button
type="button"
aria-label={t('nodeDetailModal.copyPublicKey')}
title={t('nodeDetailModal.copyPublicKey')}
onClick={() => {
void writeClipboardText(contactPubkey)
.then(() => {
setActionStatus(t('nodeDetailModal.publicKeyCopied'));
})
.catch((e: unknown) => {
console.warn(
'[NodeDetailModal] copy pubkey failed ' + errLikeToLogString(e),
);
});
}}
className="shrink-0 text-xs text-gray-400 hover:text-gray-200"
>
📋
</button>
</div>
)}
</div>
<div className="ml-3 flex shrink-0 flex-col items-end gap-1">
<div className="flex items-center gap-1">
Expand Down
90 changes: 90 additions & 0 deletions src/renderer/components/NodeListPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -620,6 +620,96 @@ describe('NodeListPanel import contacts', () => {
);
expect(screen.getByText(hex)).toBeInTheDocument();
});

it('drops the MeshCore ID column (no ID header, no !-prefixed id text)', () => {
const nodeId = 0xdeadbeef;
const nodes = new Map<number, MeshNode>([
[nodeId, makeNode({ node_id: nodeId, long_name: 'Peer', hw_model: 'Chat' })],
]);
render(
<NodeListPanel
nodes={nodes}
myNodeNum={0}
onNodeClick={vi.fn()}
locationFilter={defaultFilter}
onToggleFavorite={vi.fn()}
mode="meshcore"
meshcorePublicKeyHexByNodeId={new Map([[nodeId, 'aa'.repeat(32)]])}
/>,
);
expect(screen.queryByRole('columnheader', { name: /^ID$/ })).not.toBeInTheDocument();
expect(screen.queryByText(/^!/)).not.toBeInTheDocument();
});

it.each(['Chat', 'Sensor', 'Repeater', 'Room'])(
'shows the key icon for any MeshCore %s contact with a known public key',
async (hwModel) => {
const nodeId = 0xdeadbeef;
const nodes = new Map<number, MeshNode>([
[nodeId, makeNode({ node_id: nodeId, long_name: 'Peer', hw_model: hwModel })],
]);
const { container } = render(
<NodeListPanel
nodes={nodes}
myNodeNum={0}
onNodeClick={vi.fn()}
locationFilter={defaultFilter}
onToggleFavorite={vi.fn()}
mode="meshcore"
meshcorePublicKeyHexByNodeId={new Map([[nodeId, 'aa'.repeat(32)]])}
/>,
);
expect(screen.getByLabelText('Has public key')).toBeInTheDocument();
hydrateAxeThemeColors(container);
expect(await axe(container)).toHaveNoViolations();
},
);

it('hides the key icon when the MeshCore contact has no known public key', () => {
const nodeId = 0xdeadbeef;
const nodes = new Map<number, MeshNode>([
[nodeId, makeNode({ node_id: nodeId, long_name: 'Peer', hw_model: 'Chat' })],
]);
render(
<NodeListPanel
nodes={nodes}
myNodeNum={0}
onNodeClick={vi.fn()}
locationFilter={defaultFilter}
onToggleFavorite={vi.fn()}
mode="meshcore"
meshcorePublicKeyHexByNodeId={new Map()}
/>,
);
expect(screen.queryByLabelText('Has public key')).not.toBeInTheDocument();
});

it('labels the first column "Health" for both Meshtastic and MeshCore', () => {
const meshtastic = render(
<NodeListPanel
nodes={new Map()}
myNodeNum={0}
onNodeClick={vi.fn()}
locationFilter={defaultFilter}
onToggleFavorite={vi.fn()}
mode="meshtastic"
/>,
);
expect(meshtastic.getByRole('columnheader', { name: /Health/ })).toBeInTheDocument();
meshtastic.unmount();

render(
<NodeListPanel
nodes={new Map()}
myNodeNum={0}
onNodeClick={vi.fn()}
locationFilter={defaultFilter}
onToggleFavorite={vi.fn()}
mode="meshcore"
/>,
);
expect(screen.getByRole('columnheader', { name: /Health/ })).toBeInTheDocument();
});
});

describe('NodeListPanel flood advert (MeshCore)', () => {
Expand Down
54 changes: 33 additions & 21 deletions src/renderer/components/NodeListPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1012,25 +1012,27 @@ export default function NodeListPanel({
<caption className="sr-only">{t('nodeListPanel.tableCaptionMeshNodes')}</caption>
<thead>
<tr className="bg-deep-black text-muted sticky top-0 z-10 text-left whitespace-nowrap">
<th scope="col" className="w-8 px-3 py-2">
<span className="sr-only">{t('nodeListPanel.columnStatus')}</span>
<th scope="col" className="w-16 px-3 py-2">
{t('nodeListPanel.columnHealth')}
</th>
<th scope="col" className="w-6 px-2 py-2" title={t('nodeListPanel.favoritesColumn')}>
<span className="sr-only">{t('nodeListPanel.columnFavorite')}</span>
</th>
<th
scope="col"
aria-sort={
sortField === 'node_id' ? (sortAsc ? 'ascending' : 'descending') : 'none'
}
className="cursor-pointer px-3 py-2 transition-colors select-none hover:text-gray-200"
onClick={() => {
handleSort('node_id');
}}
>
{t('nodeListPanel.columnId')}{' '}
<SortIcon field="node_id" sortField={sortField} sortAsc={sortAsc} />
</th>
{mode !== 'meshcore' && (
<th
scope="col"
aria-sort={
sortField === 'node_id' ? (sortAsc ? 'ascending' : 'descending') : 'none'
}
className="cursor-pointer px-3 py-2 transition-colors select-none hover:text-gray-200"
onClick={() => {
handleSort('node_id');
}}
>
{t('nodeListPanel.columnId')}{' '}
<SortIcon field="node_id" sortField={sortField} sortAsc={sortAsc} />
</th>
)}
<th
scope="col"
aria-sort={
Expand Down Expand Up @@ -1412,12 +1414,11 @@ export default function NodeListPanel({
</button>
)}
</td>
<td className="text-muted px-3 py-2 font-mono text-xs">
{formatMeshtasticNodeId(node.node_id)}
{mode === 'meshcore' && meshcorePublicKeyHexByNodeId?.has(node.node_id) && (
<span className="ml-1">🔑</span>
)}
</td>
{mode !== 'meshcore' && (
<td className="text-muted px-3 py-2 font-mono text-xs">
{formatMeshtasticNodeId(node.node_id)}
</td>
)}
<td
className={`px-3 py-2 ${isSelf ? 'text-bright-green font-medium' : 'text-gray-200'} ${isMqttOnlyDimmed ? 'line-through' : ''}`}
>
Expand All @@ -1433,6 +1434,17 @@ export default function NodeListPanel({
</span>
)}
</span>
{mode === 'meshcore' &&
meshcorePublicKeyHexByNodeId?.has(node.node_id) && (
<span
role="img"
className="shrink-0"
aria-label={t('nodeListPanel.hasPublicKeyTitle')}
title={t('nodeListPanel.hasPublicKeyTitle')}
>
🔑
</span>
)}
{!isSelf &&
(() => {
const routingRow = getRoutingRowForNode(
Expand Down
Loading