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
8 changes: 2 additions & 6 deletions src/renderer/components/ChatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ import { reticulumHashForNodeId, useReticulumPeerStore } from '../stores/reticul
import { useTimeFormatStore } from '../stores/timeFormatStore';
import { ChatComposer, type ChatComposerSendOpts } from './ChatComposer';
import { ChatPayloadText } from './ChatPayloadText';
import { ChatRfHopLabel } from './ChatRfHopLabel';
import { HelpTooltip } from './HelpTooltip';
import { MessageStatusBadge } from './MessageStatusBadge';
import { ChatDmRncpControl } from './remote/ChatDmRncpControl';
Expand Down Expand Up @@ -2717,12 +2718,7 @@ function ChatPanel({
<div className="mt-0.5 flex items-center justify-end gap-2">
{msg.rxHops != null &&
(msg.receivedVia === 'rf' || msg.receivedVia === 'both') && (
<span
className="text-[10px] text-gray-500"
title={t('nodeDetailModal.hopsFromRoutingTitle')}
>
{t('nodeDetailModal.hopLabel', { count: msg.rxHops })}
</span>
<ChatRfHopLabel rxHops={msg.rxHops} msg={msg} />
)}
{msg.viaStoreForward && <StoreForwardBadge />}
{msg.receivedVia && (
Expand Down
70 changes: 70 additions & 0 deletions src/renderer/components/ChatRfHopLabel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';
import { axe } from 'vitest-axe';

import { hydrateAxeThemeColors } from '../lib/a11yTestHelpers';
import {
markMeshcoreHopCorrected,
resetMeshcoreHopCorrectedMarksForTests,
} from '../lib/meshcoreLateRfHopEnrichment';
import { ChatRfHopLabel, chatRfHopLabelPresentation } from './ChatRfHopLabel';

/** Production Tailwind gray-400 / amber-400 on chat slate-800 for axe contrast. */
const HOP_LABEL_BG_SLATE_800 = '#1e293b';
const HOP_LABEL_GRAY_400 = '#9ca3af';
const HOP_LABEL_AMBER_400 = '#fbbf24';

/** jsdom has no Tailwind CSS — set chat-like slate + label colors for axe contrast. */
function prepareHopLabelForAxe(container: HTMLElement, label: HTMLElement, color: string): void {
container.style.backgroundColor = HOP_LABEL_BG_SLATE_800;
label.style.color = color;
hydrateAxeThemeColors(container);
}

describe('chatRfHopLabelPresentation', () => {
it('uses amber accent only when corrected and motion is allowed', () => {
expect(chatRfHopLabelPresentation(false, false).className).toContain('text-gray-400');
expect(chatRfHopLabelPresentation(true, false).className).toContain('text-amber-400');
expect(chatRfHopLabelPresentation(true, true).className).toContain('text-gray-400');
expect(chatRfHopLabelPresentation(true, true).refined).toBe(true);
expect(chatRfHopLabelPresentation(false, false).refined).toBe(false);
});
});

describe('ChatRfHopLabel', () => {
afterEach(() => {
cleanup();
resetMeshcoreHopCorrectedMarksForTests();
});

it('renders hop count with default title when not corrected', async () => {
const { container } = render(
<ChatRfHopLabel
rxHops={3}
msg={{ storeId: 'ch:0:1:x', sender_id: 2, timestamp: Date.now(), channel: 0 }}
/>,
);
const label = screen.getByText('3 hops');
expect(label).toBeInTheDocument();
expect(label).toHaveAttribute('title', expect.stringMatching(/hop|routing/i));
expect(label.className).toContain('text-gray-400');
prepareHopLabelForAxe(container, label, HOP_LABEL_GRAY_400);
expect(await axe(container)).toHaveNoViolations();
});

it('uses refined title when a correction mark is active', async () => {
markMeshcoreHopCorrected('ch:0:2:x');
const { container } = render(
<ChatRfHopLabel
rxHops={4}
msg={{ storeId: 'ch:0:2:x', sender_id: 2, timestamp: Date.now(), channel: 0 }}
/>,
);
const label = screen.getByText('4 hops');
expect(label).toHaveAttribute('title', 'Updated from RF path');
expect(label.className).toContain('text-amber-400');
prepareHopLabelForAxe(container, label, HOP_LABEL_AMBER_400);
expect(await axe(container)).toHaveNoViolations();
});
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
66 changes: 66 additions & 0 deletions src/renderer/components/ChatRfHopLabel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { useSyncExternalStore } from 'react';
import { useTranslation } from 'react-i18next';

import { useReduceMotion } from '@/renderer/lib/icons/iconMotionContext';
import {
isMeshcoreHopCorrected,
meshcoreChatHopUiKey,
subscribeMeshcoreHopCorrected,
} from '@/renderer/lib/meshcoreLateRfHopEnrichment';

export interface ChatRfHopLabelProps {
rxHops: number;
msg: {
storeId?: string;
id?: number;
sender_id: number;
timestamp: number;
channel: number;
};
}

/** Class/title for the hop pill when a late RF correction mark is active. */
export function chatRfHopLabelPresentation(
corrected: boolean,
reduceMotion: boolean,
): { className: string; refined: boolean } {
// gray-400 (#9ca3af) on chat slate-800 (#1e293b) keeps 4.5:1+ for text-[10px].
if (!corrected) {
return {
className: 'text-[10px] text-gray-400 transition-colors duration-500',
refined: false,
};
}
if (reduceMotion) {
return {
className: 'text-[10px] text-gray-400 transition-colors duration-500',
refined: true,
};
}
return {
className: 'text-[10px] text-amber-400/80 transition-colors duration-500',
refined: true,
};
}

/** Incoming RF hop count; briefly accents when late event 136 corrected a stored value. */
export function ChatRfHopLabel({ rxHops, msg }: ChatRfHopLabelProps) {
const { t } = useTranslation();
const reduceMotion = useReduceMotion();
const uiKey = meshcoreChatHopUiKey(msg);
const corrected = useSyncExternalStore(
subscribeMeshcoreHopCorrected,
() => isMeshcoreHopCorrected(uiKey),
() => false,
);
const { className, refined } = chatRfHopLabelPresentation(corrected, reduceMotion);
const title = refined
? t('chatPanel.hopCountRefinedFromRf')
: t('nodeDetailModal.hopsFromRoutingTitle');

return (
<span className={className} title={title} aria-label={title}>
{t('nodeDetailModal.hopLabel', { count: rxHops })}
</span>
);
}
2 changes: 1 addition & 1 deletion src/renderer/lib/ingest/meshcoreIngest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -612,7 +612,7 @@ describe('meshcoreIngest hop correlation (driver path)', () => {
it('correlates DM rxHops from TXT_MSG raw packet log when hopCount omitted', () => {
const detach = attachMeshcoreIngest(ID, {
rawPacketsForHopCorrelation: () => [
{ ts: now - 50, payloadTypeString: 'TXT_MSG', fromNodeId: null, hopCount: 1 },
{ ts: now - 50, payloadTypeString: 'TXT_MSG', fromNodeId: 0xabcd, hopCount: 1 },
],
});
upsertMessage(ID, {
Expand Down
7 changes: 6 additions & 1 deletion src/renderer/lib/ingest/meshcoreIngest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,12 @@ function handleTextMessage(
const isChannel = event.payload.id.startsWith('ch:');
const hopCount =
event.payload.hopCount ??
resolveMeshcoreIngestRxHops(options.rawPacketsForHopCorrelation?.() ?? [], isChannel);
resolveMeshcoreIngestRxHops(
options.rawPacketsForHopCorrelation?.() ?? [],
isChannel,
Date.now(),
isChannel ? undefined : { fromNodeId: event.payload.from },
);
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Identity bucket may be absent at runtime.
const fromNode = useNodeStore.getState().nodes[identityId]?.[event.payload.from];
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Node may be absent when its identity bucket is missing.
Expand Down
16 changes: 16 additions & 0 deletions src/renderer/lib/meshcore/meshcoreRfRxRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
meshtasticSenderIdForRawLogFallback,
type PacketClass,
} from '../foreignLoraDetection';
import { applyMeshcoreLateRfHopEnrichment } from '../meshcoreLateRfHopEnrichment';
import {
meshcoreRawPacketLogFromBytesFallback,
meshcoreRawPacketResolveFromParsed,
Expand Down Expand Up @@ -642,6 +643,21 @@ export function handleMeshcoreRfRx(payload: MeshcoreRfRxPayload, deps: MeshcoreR
const rxEntry = buildMeshcoreRfRawPacketEntry(ctx, effectiveFromNodeId, now, snr, rssi, rawU8);
pushMeshcoreRfRawPacketLog(deps, rxEntry);

if (
ctx.parseOk &&
(ctx.payloadTypeString === 'TXT_MSG' || ctx.payloadTypeString === 'GRP_TXT')
) {
applyMeshcoreLateRfHopEnrichment(deps.meshcoreIdentityIdRef.current, {
payloadTypeString: ctx.payloadTypeString,
hopCount: ctx.hopCount,
fromNodeId: effectiveFromNodeId,
messageFingerprintHex: ctx.messageFingerprintHex,
parseOk: true,
now,
myNodeNum: deps.myNodeNumRef.current,
});
}

mqttFields = buildMeshcoreRfMqttPacketLogFields(ctx, rawU8);
recordMeshcoreRfNoisePorts(ctx, effectiveFromNodeId);

Expand Down
Loading