diff --git a/src/renderer/hooks/meshcore/meshcoreConnSideEffects.test.ts b/src/renderer/hooks/meshcore/meshcoreConnSideEffects.test.ts index 2d2d5e306..b4581b5e9 100644 --- a/src/renderer/hooks/meshcore/meshcoreConnSideEffects.test.ts +++ b/src/renderer/hooks/meshcore/meshcoreConnSideEffects.test.ts @@ -8,6 +8,7 @@ import type { RxPacketEntry, } from '@/renderer/lib/meshcore/meshcoreHookTypes'; import * as meshcoreRepeaterRpcInFlight from '@/renderer/lib/meshcoreRepeaterRpcInFlight'; +import { meshcoreChatStubNodeIdFromDisplayName } from '@/renderer/lib/meshcoreUtils'; import { beginMeshcoreSilentBulkAttempt, resetMeshcoreWaitingMessagesDrainState, @@ -832,6 +833,100 @@ describe('attachMeshcoreConnSideEffects', () => { }); }); + it('preserves concurrent advert longName when flushing waiting-drain last_heard', async () => { + const prefix = new Uint8Array([0xaa, 0xbb]); + useNodeStore.setState({ + nodes: { + [ID]: { + 42: { + nodeId: 42, + longName: 'OldPeer', + shortName: 'P', + snr: 5, + rssi: -80, + lastHeardAt: 100, + source: 'rf', + }, + }, + }, + }); + const h = makeHarness(); + h.ctx.pubKeyPrefixMapRef.current.set('aabb', 42); + vi.mocked(h.conn.getWaitingMessages).mockImplementation(() => { + // Concurrent on-air advert rename while drain still holds OldPeer in workingNodes. + useNodeStore.setState((s) => ({ + nodes: { + ...s.nodes, + [ID]: { + ...s.nodes[ID], + 42: { + ...s.nodes[ID]?.[42], + nodeId: 42, + longName: 'NewPeer', + snr: 9, + rssi: -40, + }, + }, + }, + })); + return Promise.resolve([ + { + contactMessage: { + pubKeyPrefix: prefix, + text: 'hello from queue', + senderTimestamp: 1_700_000_100, + }, + }, + ]); + }); + detach = attachMeshcoreConnSideEffects(h.conn, h.ctx); + + await h.ctx.processWaitingMessagesRef.current?.({ showSyncBanner: true }); + + expect(useNodeStore.getState().nodes[ID]?.[42]).toMatchObject({ + longName: 'NewPeer', + snr: 9, + rssi: -40, + lastHeardAt: 1_700_000_100, + }); + }); + + it('applies waiting-drain longName when live name is placeholder', async () => { + const nodeId = meshcoreChatStubNodeIdFromDisplayName('RealPeer'); + const placeholder = `Node-${nodeId.toString(16).toUpperCase()}`; + useNodeStore.setState({ + nodes: { + [ID]: { + [nodeId]: { + nodeId, + longName: placeholder, + shortName: '', + lastHeardAt: 100, + source: 'rf', + }, + }, + }, + }); + const h = makeHarness(); + vi.mocked(h.conn.getWaitingMessages).mockResolvedValue([ + { + channelMessage: { + channelIdx: 0, + text: 'RealPeer: queued channel message', + senderTimestamp: 1_700_000_200, + }, + }, + ]); + detach = attachMeshcoreConnSideEffects(h.conn, h.ctx); + + await h.ctx.processWaitingMessagesRef.current?.({ showSyncBanner: true }); + + expect(useNodeStore.getState().nodes[ID]?.[nodeId]).toMatchObject({ + longName: 'RealPeer', + lastHeardAt: 1_700_000_200, + }); + }); + it('tears down the session and requests reconnect on disconnect', async () => { const h = makeHarness(); detach = attachMeshcoreConnSideEffects(h.conn, h.ctx); diff --git a/src/renderer/hooks/meshcore/meshcoreConnSideEffects.ts b/src/renderer/hooks/meshcore/meshcoreConnSideEffects.ts index 399ec53fe..514828889 100644 --- a/src/renderer/hooks/meshcore/meshcoreConnSideEffects.ts +++ b/src/renderer/hooks/meshcore/meshcoreConnSideEffects.ts @@ -25,6 +25,7 @@ import { processMeshcoreWaitingMessageItem } from '../../lib/meshcoreProcessWait import { resetMeshcoreRepeaterRpcInFlightOnDisconnect } from '../../lib/meshcoreRepeaterRpcInFlight'; import { meshcoreSortedStorePrior } from '../../lib/meshcoreStoreDedup'; import { resetMeshcoreTracePathMultiplexOnDisconnect } from '../../lib/meshcoreTracePathMultiplex'; +import { meshcoreIsPlaceholderNodeLongName } from '../../lib/meshcoreUtils'; import { normalizeMeshcoreWaitingMessageBatch, normalizeMeshcoreWaitingMessageItem, @@ -118,8 +119,9 @@ interface MeshcoreWaitingMessagesDrainState { /** * Waiting-drain mutations are last_heard (+ optional channel display-name). Rebuild patches - * against the live store row so concurrent RF SNR/RSSI writes aren't overwritten by the - * start-of-drain `workingNodes` snapshot. + * against the live store row so concurrent RF SNR/RSSI / advert-name writes aren't overwritten + * by the start-of-drain `workingNodes` snapshot. Only apply snapshot names when live is empty + * or a Node-HEX placeholder (channel enrichment upgrades placeholders only). */ function collectDirtyWaitingNodeRecords( identityId: string, @@ -140,10 +142,20 @@ function collectDirtyWaitingNodeRecords( const patch: NodeRecord = { nodeId, lastHeardAt: nextLastHeard }; const workingLong = working.long_name?.trim(); const workingShort = working.short_name?.trim(); - if (workingLong && workingLong !== (live.longName ?? '').trim()) { + const liveLong = (live.longName ?? '').trim(); + const liveShort = (live.shortName ?? '').trim(); + if ( + workingLong && + workingLong !== liveLong && + (!liveLong || meshcoreIsPlaceholderNodeLongName(liveLong, nodeId)) + ) { patch.longName = workingLong; } - if (workingShort && workingShort !== (live.shortName ?? '').trim()) { + if ( + workingShort && + workingShort !== liveShort && + (!liveShort || meshcoreIsPlaceholderNodeLongName(liveShort, nodeId)) + ) { patch.shortName = workingShort; } if ( diff --git a/src/renderer/lib/meshcoreUtils.test.ts b/src/renderer/lib/meshcoreUtils.test.ts index 59698dc00..5c7dd429e 100644 --- a/src/renderer/lib/meshcoreUtils.test.ts +++ b/src/renderer/lib/meshcoreUtils.test.ts @@ -594,13 +594,13 @@ describe('meshcoreMergeContactAdvNameFromPrevious', () => { ).toBe('Bob'); }); - it('takes radio name when radio lastAdvert is strictly newer', () => { + it('keeps live advert rename when radio lastAdvert is newer but advName is still old', () => { expect( - meshcoreMergeContactAdvNameFromPrevious('FirmwareRename', 'OldName', nodeId, { + meshcoreMergeContactAdvNameFromPrevious('OldRoom', 'NewRoom', nodeId, { prevLastHeard: 1_700_000_000, radioLastAdvert: 1_700_000_500, }), - ).toBe('FirmwareRename'); + ).toBe('NewRoom'); }); it('keeps previous when radio lastAdvert is 0', () => { @@ -663,6 +663,25 @@ describe('buildNodesFromContacts advert-name merge (path-updated rebuild)', () = expect(merged).toBe('NewRoom'); }); + it('keeps live rename when getContacts bumps lastAdvert without updating advName', () => { + const contact = { + publicKey: key32, + type: 3, + advName: 'OldRoom', + lastAdvert: 1_700_000_500, + advLat: 0, + advLon: 0, + }; + const radio = meshcoreContactToMeshNode(contact); + const merged = meshcoreMergeContactAdvNameFromPrevious( + radio.long_name, + 'NewRoom', + radio.node_id, + { prevLastHeard: 1_700_000_100, radioLastAdvert: contact.lastAdvert }, + ); + expect(merged).toBe('NewRoom'); + }); + it('keeps stored advert name when nickname overlays long name and radio is stale or placeholder', () => { const contact = { publicKey: key32, diff --git a/src/renderer/lib/meshcoreUtils.ts b/src/renderer/lib/meshcoreUtils.ts index a1ea3bf44..49a2250cf 100644 --- a/src/renderer/lib/meshcoreUtils.ts +++ b/src/renderer/lib/meshcoreUtils.ts @@ -647,15 +647,17 @@ export function meshcorePreviousAdvertNameForRebuild( /** * When rebuilding from `getContacts`, companion firmware often keeps the name from when the - * contact was first stored. Prefer a live advert name already in the UI unless the radio dump - * is strictly newer (connect/refresh after firmware actually renamed the contact). + * contact was first stored and may bump `lastAdvert` without renaming. Prefer a live advert + * name already in the UI whenever both names are real and differ; on-air adverts / local + * setAdvertName are the authoritative rename paths (`opts` kept for call-site compat). */ export function meshcoreMergeContactAdvNameFromPrevious( radioAdvName: string | undefined, prevLongName: string | undefined, nodeId: number, - opts?: MeshcoreMergeContactAdvNameOpts, + _opts?: MeshcoreMergeContactAdvNameOpts, ): string { + void _opts; const radioTrim = (radioAdvName ?? '').trim(); const prevTrim = (prevLongName ?? '').trim(); const radioReal = radioTrim.length > 0 && !meshcoreIsPlaceholderNodeLongName(radioTrim, nodeId); @@ -665,20 +667,9 @@ export function meshcoreMergeContactAdvNameFromPrevious( if (!radioReal && prevReal) return prevTrim; if (!prevReal) return radioTrim || prevTrim || hexFallback; if (radioTrim === prevTrim) return radioTrim; - - const radioAdvert = - opts?.radioLastAdvert != null && - Number.isFinite(opts.radioLastAdvert) && - opts.radioLastAdvert > 0 - ? opts.radioLastAdvert - : 0; - const prevHeard = - opts?.prevLastHeard != null && Number.isFinite(opts.prevLastHeard) ? opts.prevLastHeard : 0; - // Tie or missing radio time: companion often updates lastAdvert without renaming. - if (radioAdvert === 0 || prevHeard >= radioAdvert) { - return prevTrim; - } - return radioTrim; + // Companion dump disagrees with a real live/previous name — keep the live name. lastAdvert + // is not a reliable name version (path hears often bump time without updating advName). + return prevTrim; } /** Result of mapping a heard RF advert (push 0x80) into UI + DB when the node is not yet a contact. */