diff --git a/README.md b/README.md index a60471690..1f257e95e 100644 --- a/README.md +++ b/README.md @@ -172,7 +172,7 @@ Mesh-Client supports **three mesh stacks** in one desktop app. Use the **protoco - **Certificate management**: self-signed CA + server + client certificates via node-forge (server cert includes DNS + LAN IP in Subject Alternative Name so EUDs can verify TLS); regenerate anytime from the TAK tab - **Data package generator**: export ATAK-compatible (truststore.p12, client.p12, connection.pref) for direct import on TAK devices — connect host is your LAN IP and must match the server cert SAN; re-generate the package after certificate regeneration or a LAN IP change - **Channels as ATAK contacts**: under **MeshCore messaging in ATAK** and **Meshtastic messaging in ATAK**, choose Off, All channels, or Selected channels. Each served channel becomes a contact on every connected ATAK device, named with its protocol (`#MC Public`, `#MC ops` for a hashtag channel, `#MT 🔒 Ops` for a private one) unless you type your own in its **ATAK name** field, and what the channel hears arrives from that contact. Tick **ATAK can send** for a channel and a message to its contact is transmitted on that channel from this radio, prefixed with the sender's callsign (`[VIPER] on scene`), and shows in Chat like any other; left off, the channel is receive only. Tick **Remote server** to also stream the channel, read only, to the remote TAK server. Sending is off by default, cut to fit one mesh message, and limited to 10 messages a minute per device. Up to 50 messages heard while no ATAK device is connected are held for the first one to connect. Settings from earlier versions carry over: a channel that had a GeoChat room is served, keeps its send setting and its room name (as its ATAK name), and streams to the remote server. Channel settings are kept per channel, not per slot, so a channel moving into another's slot never inherits its settings, and ATAK devices are told to drop the old room contacts. -- **Direct messages with companions**: tick **Direct messages** under a radio's messaging section and every companion that radio can message (a MeshCore companion in its contacts, any Meshtastic node) can be messaged from its marker in ATAK. The message goes out from this radio as a direct message, prefixed with the sender's callsign, and shows in Chat like any other. ATAK marks it delivered when the companion acknowledges it; if it still isn't acknowledged after mesh-client's automatic resends, a "Not confirmed" note says so (for MeshCore, with a reminder that a flood advert from each radio usually fixes a DM that keeps failing). A companion's replies go to the ATAK device that last wrote to it, or to every connected device when none has, and are held for that device while it is away. A companion with no marker on the map appears at 0, 0 for a day after a message. Direct messages are never sent to the remote TAK server, and a message ATAK cannot deliver (direct messages off, or not a companion this radio can reach) gets a note in its conversation saying why. +- **Direct messages with companions**: tick **Direct messages** under a radio's messaging section and every companion that radio can message (a MeshCore companion in its contacts, any Meshtastic node, a Reticulum LXMF peer whose delivery address is known) can be messaged from its marker in ATAK. Reticulum has its own **Reticulum messaging in ATAK** section for this; an LXMF peer with no position appears at 0, 0 only once mesh-client has a conversation with it, so ATAK's contact list does not fill with every peer heard. The message goes out from this radio as a direct message, prefixed with the sender's callsign, and shows in Chat like any other. ATAK marks it delivered when the companion acknowledges it; if it still isn't acknowledged after mesh-client's automatic resends, a "Not confirmed" note says so (for MeshCore, with a reminder that a flood advert from each radio usually fixes a DM that keeps failing). A companion's replies go to the ATAK device that last wrote to it, or to every connected device when none has, and are held for that device while it is away. A companion with no marker on the map appears at 0, 0 for a day after a message. Direct messages are never sent to the remote TAK server, and a message ATAK cannot deliver (direct messages off, or not a companion this radio can reach) gets a note in its conversation saying why. - **Unit filters**: under **Relayed unit styles**, restyle relayed nodes by name, node type (companions and clients, repeaters and routers, room servers, sensors, trackers), protocol, or a combination, choosing the TAK unit type, team color, and team role. A filter with no name pattern applies to every node of its type or protocol; the first enabled filter that matches wins - **Repeater and room server icons**: under **Relayed unit styles**, give repeaters (MeshCore repeaters, Meshtastic routers, Reticulum interfaces) and room servers each an icon from an ATAK icon set, as a path (`//`) or from the GeoOps WildFire presets that MeshCore TAK Bridge offers (Repeater, Repeater Mobile Relay, Ranger Station and others). The icon follows the node type, so a filter can change a room server's team and role and it keeps its icon. Leave a field empty for the standard symbol. Each TAK device needs the same icon set installed; a device without it draws the standard symbol - Auto-start option (off by default); status indicator in header when running diff --git a/src/main/index.ipc-security.test.ts b/src/main/index.ipc-security.test.ts index 032aa98ed..32a3370f8 100644 --- a/src/main/index.ipc-security.test.ts +++ b/src/main/index.ipc-security.test.ts @@ -457,6 +457,7 @@ describe('GPS/TAK IPC sender validation (source contract)', () => { 'tak:pushDirectMessage', 'tak:setDirectMessaging', 'tak:dmStatus', + 'tak:setDirectPeers', ] as const; it.each(takChannels)('tak handler %s calls assertIpcSender', (channel) => { diff --git a/src/main/ipc/tak-handlers.test.ts b/src/main/ipc/tak-handlers.test.ts index 2ff2f7522..3b8c98e90 100644 --- a/src/main/ipc/tak-handlers.test.ts +++ b/src/main/ipc/tak-handlers.test.ts @@ -620,6 +620,17 @@ describe('direct message handlers', () => { expect(idle).not.toHaveBeenCalled(); }); + it('hands the conversation peers to the manager, once it exists', async () => { + const setDirectPeers = vi.fn(); + const handler = await register({ setDirectPeers }, 'tak:setDirectPeers'); + const peer = { protocol: 'reticulum', nodeId: 789, callsign: 'Field Laptop' }; + handler({}, [peer]); + expect(setDirectPeers).toHaveBeenCalledWith([peer]); + expect(assertIpcSender).toHaveBeenCalledWith({}, 'tak:setDirectPeers'); + expect(() => handler({}, 'peers')).toThrow(/tak:setDirectPeers/); + expect((await register(null, 'tak:setDirectPeers'))({}, [])).toBeUndefined(); + }); + it('hands the radios with direct messages on to the manager, once it exists', async () => { const setDirectMessaging = vi.fn(); const handler = await register({ setDirectMessaging }, 'tak:setDirectMessaging'); diff --git a/src/main/ipc/tak-handlers.ts b/src/main/ipc/tak-handlers.ts index 017982e1c..07241dd45 100644 --- a/src/main/ipc/tak-handlers.ts +++ b/src/main/ipc/tak-handlers.ts @@ -19,6 +19,7 @@ import { parseTakChannelChatMessage, parseTakChatChannels, parseTakDirectMessage, + parseTakDirectPeers, parseTakDmProtocols, parseTakDmStatus, parseTakRetiredRooms, @@ -309,6 +310,20 @@ export function registerTakIpcHandlers(deps: TakIpcDeps): void { m.setDirectMessaging(parseTakDmProtocols(protocols)); }); + const directPeerUpdates = createIpcRateLimiter({ + max: 60, + windowMs: MS_PER_MINUTE, + label: 'tak:setDirectPeers', + }); + ipcMain.handle('tak:setDirectPeers', (event, peers: unknown) => { + assertIpcSender(event, 'tak:setDirectPeers'); + // As for channels: the renderer sends the list again when a sink starts. + const m = getTakServerManager(); + if (!m) return; + directPeerUpdates.checkOrThrow(); + m.setDirectPeers(parseTakDirectPeers(peers)); + }); + const dmStatusUpdates = createIpcRateLimiter({ max: 120, windowMs: MS_PER_MINUTE, diff --git a/src/main/tak-server-manager.test.ts b/src/main/tak-server-manager.test.ts index cf4565fb1..d99bb1830 100644 --- a/src/main/tak-server-manager.test.ts +++ b/src/main/tak-server-manager.test.ts @@ -1222,10 +1222,10 @@ describe('TakServerManager direct messages', () => { vi.useRealTimers(); }); - /** A manager with the local server running and direct messages on for both radios. */ + /** A manager with the local server running and direct messages on for every radio. */ function dmManager(): TakServerManager { const manager = chatManager([]); - manager.setDirectMessaging(['meshcore', 'meshtastic']); + manager.setDirectMessaging(['meshcore', 'meshtastic', 'reticulum']); return manager; } @@ -1343,12 +1343,16 @@ describe('TakServerManager direct messages', () => { expect(inbound).toEqual([]); const notes = chats(sender); expect(notes).toHaveLength(4); - expect(notes[0]).toContain('uid0="MESH-456"'); - expect(notes[0]).toContain('mesh-client: Not sent: direct messages are off for this radio.'); + for (const [i, uid] of [ + [0, 'MESH-456'], + [3, 'RN-42'], + ] as const) { + expect(notes[i]).toContain(`uid0="${uid}"`); + expect(notes[i]).toContain('mesh-client: Not sent: direct messages are off for this radio.'); + } for (const [i, uid] of [ [1, 'MC-7'], [2, 'MC-999'], - [3, 'RN-42'], ] as const) { expect(notes[i]).toContain(`uid0="${uid}"`); expect(notes[i]).toContain( @@ -1494,6 +1498,70 @@ describe('TakServerManager direct messages', () => { expect(chats(watcher)).toEqual([]); }); + describe('conversation peers', () => { + const PEER = { protocol: 'reticulum' as const, nodeId: 789, callsign: 'Field Laptop' }; + const contactFor = (socket: tls.TLSSocket, uid: string) => + written(socket).filter((l) => l.includes(`uid="${uid}"`) && l.includes('a-f-G-U-C')); + + it('gives a peer with no marker a contact at 0, 0, and takes it away when dropped', () => { + const manager = dmManager(); + const socket = connect(manager, ['ANDROID-1', 'VIPER']); + manager.setDirectPeers([PEER]); + const [contact] = contactFor(socket, 'RN-789'); + expect(contact).toContain(''); + + // The same list again changes nothing. + vi.mocked(socket.write).mockClear(); + manager.setDirectPeers([PEER]); + expect(written(socket)).toEqual([]); + + // A device connecting later gets it with the channel contacts. + const late = mockTlsSocket(); + (manager as unknown as ChatInternals)._handleClient(late); + expect(contactFor(late, 'RN-789')).toHaveLength(1); + + manager.setDirectPeers([]); + expect(written(socket).some((l) => l.includes('uid="RN-789"') && l.includes('t-x-d-d'))).toBe( + true, + ); + }); + + it('lets a device message a conversation peer, which has no marker', () => { + const manager = dmManager(); + manager.setDirectPeers([PEER]); + const inbound = listen(manager); + const viper = connect(manager, ['ANDROID-1', 'VIPER']); + viper.emit('data', Buffer.from(DM('carry on', 'RN-789', { room: 'Field Laptop' }))); + expect(inbound.map((m) => m.target)).toEqual([ + { kind: 'direct', protocol: 'reticulum', nodeId: 789 }, + ]); + }); + + it('lets the marker stand in for a peer that has one', () => { + const manager = dmManager(); + const socket = connect(manager, ['ANDROID-1', 'VIPER']); + manager.onNodeUpdate({ ...ALICE, protocol: 'reticulum', node_id: 789, chatReachable: false }); + vi.mocked(socket.write).mockClear(); + manager.setDirectPeers([PEER]); + expect(written(socket)).toEqual([]); + }); + + it('shows no contact while direct messages are off for the radio, and adds it when on', () => { + const manager = chatManager([]); + manager.setDirectMessaging(['meshcore']); + const socket = connect(manager, ['ANDROID-1', 'VIPER']); + manager.setDirectPeers([PEER]); + expect(contactFor(socket, 'RN-789')).toEqual([]); + manager.setDirectMessaging(['meshcore', 'reticulum']); + expect(contactFor(socket, 'RN-789')).toHaveLength(1); + manager.setDirectMessaging(['meshcore']); + expect(written(socket).some((l) => l.includes('uid="RN-789"') && l.includes('t-x-d-d'))).toBe( + true, + ); + }); + }); + describe('outcomes', () => { const receipts = (socket: tls.TLSSocket) => written(socket).filter((l) => l.includes('type="b-t-f-d"')); diff --git a/src/main/tak-server-manager.ts b/src/main/tak-server-manager.ts index 290175947..55b0ded7f 100644 --- a/src/main/tak-server-manager.ts +++ b/src/main/tak-server-manager.ts @@ -14,6 +14,7 @@ import type { TAKContact, TAKContactsUpdate, TAKDirectMessage, + TakDirectPeer, TakDmProtocol, TakDmStatusUpdate, TAKGeochatMessage, @@ -181,6 +182,14 @@ type HeldChat = heldAtMs: number; }; +/** A peer this app already has a direct conversation with. */ +interface ConversationPeer { + uid: string; + protocol: TakDmProtocol; + nodeId: number; + callsign: string; +} + /** A companion a direct message has passed with, either way, in the last day. */ interface DirectContact { uid: string; @@ -233,6 +242,11 @@ export class TakServerManager extends EventEmitter { private lastWriters = new Map(); /** Direct messages from TAK clients awaiting their outcome, by dmRef, oldest first. */ private pendingDms = new Map(); + /** + * Peers the renderer already has a direct conversation with, by uid. Each is messageable and, + * while it has no marker, has a contact at 0, 0. + */ + private conversationPeers = new Map(); private chatRoomAnnounceTimer: ReturnType | null = null; private styleSettings: TakStyleSettings; @@ -493,7 +507,17 @@ export class TakServerManager extends EventEmitter { this.heldChat = this.heldChat.filter( (h) => h.kind === 'channel' || this.directContacts.has(h.uid), ); + const previous = this.dmProtocols; this.dmProtocols = next; + for (const peer of this.conversationPeers.values()) { + if (next.has(peer.protocol) === previous.has(peer.protocol)) continue; + if (this.hasFreshMarker(peer.protocol, peer.nodeId, now)) continue; + this.writeToClients( + next.has(peer.protocol) + ? this.conversationPeerCot(peer, now) + : cotDeleteEvent(peer.uid, now), + ); + } const cutoff = now - COT_STALE_MS; for (const node of this.nodeCache.values()) { if (node.cachedAtMs < cutoff || !changed.includes(node.protocol)) continue; @@ -503,6 +527,40 @@ export class TakServerManager extends EventEmitter { this.scheduleChatRoomAnnouncements(); } + /** + * Replace the peers the renderer has a direct conversation with. A peer with no marker gets + * its contact at 0, 0 at once, while direct messages are on for its radio; one dropped from + * the list loses it, unless a direct message passed with it in the last day. + */ + setDirectPeers(peers: TakDirectPeer[]): void { + const now = Date.now(); + const next = new Map(); + for (const peer of peers) { + const uid = cotUidFor({ node_id: peer.nodeId }, peer.protocol); + next.set(uid, { uid, ...peer }); + } + const shown = (peer: ConversationPeer): boolean => + this.dmProtocols.has(peer.protocol) && + !this.directContacts.has(peer.uid) && + !this.hasFreshMarker(peer.protocol, peer.nodeId, now); + for (const old of this.conversationPeers.values()) { + if (!next.has(old.uid) && shown(old)) this.writeToClients(cotDeleteEvent(old.uid, now)); + } + for (const peer of next.values()) { + const old = this.conversationPeers.get(peer.uid); + if (old?.callsign === peer.callsign || !shown(peer)) continue; + this.writeToClients(this.conversationPeerCot(peer, now)); + } + this.conversationPeers = next; + this.scheduleChatRoomAnnouncements(); + } + + /** A conversation peer's contact at 0, 0, under the name its marker would have. */ + private conversationPeerCot(peer: ConversationPeer, nowMs: number): string { + const callsign = this.directCallsign(peer.protocol, peer.nodeId, peer.callsign); + return buildDirectContactCot({ uid: peer.uid, callsign }, nowMs, nowMs); + } + /** Keep a message for a client yet to identify itself, dropping the oldest past the cap. */ private holdChat(item: HeldChat): void { this.heldChat.push(item); @@ -558,7 +616,10 @@ export class TakServerManager extends EventEmitter { if (node.uid !== undefined) return false; if (!isTakDmProtocol(node.protocol) || !this.dmProtocols.has(node.protocol)) return false; if (advertisedNodeKind(node, node.protocol) !== 'companion') return false; - return node.chatReachable === true || this.directContacts.has(cotUidFor(node, node.protocol)); + const uid = cotUidFor(node, node.protocol); + return ( + node.chatReachable === true || this.directContacts.has(uid) || this.conversationPeers.has(uid) + ); } /** Whether local clients show the node's own marker now, standing in for its contact. */ @@ -675,7 +736,8 @@ export class TakServerManager extends EventEmitter { * expire in ATAK. Runs while there is any contact to announce. */ private scheduleChatRoomAnnouncements(): void { - const needed = this.chatChannels.size > 0 || this.directContacts.size > 0; + const needed = + this.chatChannels.size > 0 || this.directContacts.size > 0 || this.conversationPeers.size > 0; if (needed === (this.chatRoomAnnounceTimer !== null)) return; if (this.chatRoomAnnounceTimer) { clearInterval(this.chatRoomAnnounceTimer); @@ -708,6 +770,12 @@ export class TakServerManager extends EventEmitter { send(buildDirectContactCot(contact, contact.lastDmMs, now)); } } + for (const peer of this.conversationPeers.values()) { + if (!this.dmProtocols.has(peer.protocol) || this.directContacts.has(peer.uid)) continue; + if (!this.hasFreshMarker(peer.protocol, peer.nodeId, now)) { + send(this.conversationPeerCot(peer, now)); + } + } } /** The uid and callsign a client reported for itself, once it has reported both. */ @@ -1089,7 +1157,8 @@ export class TakServerManager extends EventEmitter { return; } const node = this.cachedNodeFor(protocol, nodeId); - if (!this.directContacts.has(uid) && !(node && this.isChatReachable(node))) { + const known = this.directContacts.has(uid) || this.conversationPeers.has(uid); + if (!known && !(node && this.isChatReachable(node))) { note(NOT_REACHABLE_NOTE); return; } diff --git a/src/main/tak/geochat-cot.test.ts b/src/main/tak/geochat-cot.test.ts index 040682767..4c015bf2e 100644 --- a/src/main/tak/geochat-cot.test.ts +++ b/src/main/tak/geochat-cot.test.ts @@ -12,6 +12,7 @@ import { parseTakChannelChatMessage, parseTakChatChannels, parseTakDirectMessage, + parseTakDirectPeers, parseTakDmProtocols, parseTakDmStatus, parseTakRetiredRooms, @@ -220,7 +221,7 @@ describe('direct messages', () => { }); for (const bad of [ null, - { ...msg, protocol: 'reticulum' }, + { ...msg, protocol: 'rrc' }, { ...msg, nodeId: 0 }, { ...msg, nodeId: 2 ** 32 }, { ...msg, nodeId: 1.5 }, @@ -234,15 +235,34 @@ describe('direct messages', () => { }); it('accepts the radios with direct messages on, without repeats', () => { - expect(parseTakDmProtocols(['meshcore', 'meshtastic', 'meshcore'])).toEqual([ + expect(parseTakDmProtocols(['meshcore', 'meshtastic', 'reticulum', 'meshcore'])).toEqual([ 'meshcore', 'meshtastic', + 'reticulum', ]); expect(parseTakDmProtocols([])).toEqual([]); - expect(() => parseTakDmProtocols(['reticulum'])).toThrow(/tak:setDirectMessaging/); + expect(() => parseTakDmProtocols(['rrc'])).toThrow(/tak:setDirectMessaging/); expect(() => parseTakDmProtocols('meshcore')).toThrow(/tak:setDirectMessaging/); }); + it('accepts conversation peers, without repeats, and rejects malformed ones', () => { + const peer = { protocol: 'reticulum', nodeId: 789, callsign: ' Field Laptop ' }; + expect(parseTakDirectPeers([peer, { ...peer, callsign: 'again' }])).toEqual([ + { protocol: 'reticulum', nodeId: 789, callsign: 'Field Laptop' }, + ]); + expect(parseTakDirectPeers([])).toEqual([]); + for (const bad of [ + 'peers', + [null], + [{ ...peer, protocol: 'rrc' }], + [{ ...peer, nodeId: 0 }], + [{ ...peer, callsign: 'a\nb' }], + Array.from({ length: 65 }, (_, i) => ({ ...peer, nodeId: i + 1 })), + ]) { + expect(() => parseTakDirectPeers(bad)).toThrow(/^tak:setDirectPeers: /); + } + }); + const REF = '0c2f8e1a-5b3d-4c7e-9a10-2b4d6f8a0c1e'; it('accepts a direct message outcome for a message reference', () => { diff --git a/src/main/tak/geochat-cot.ts b/src/main/tak/geochat-cot.ts index dfc34c9de..f473bf36d 100644 --- a/src/main/tak/geochat-cot.ts +++ b/src/main/tak/geochat-cot.ts @@ -6,13 +6,16 @@ import { isTakDmProtocol, TAK_CHAT_CHANNELS_MAX, TAK_CHAT_ROOM_UID_PREFIX, + TAK_DIRECT_PEERS_MAX, TAK_DM_OUTCOMES, + TAK_DM_PROTOCOLS, TAK_GEOCHAT_CALLSIGN_MAX_LEN, TAK_GEOCHAT_ROOM_MAX_LEN, TAK_GEOCHAT_TEXT_MAX_LEN, type TAKChannelChatMessage, type TakChatChannel, type TAKDirectMessage, + type TakDirectPeer, type TakDmOutcome, type TakDmProtocol, type TakDmStatusUpdate, @@ -238,7 +241,7 @@ export function parseTakDirectMessage(raw: unknown): TAKDirectMessage { if (!raw || typeof raw !== 'object') throw new Error(`${channel}: message must be an object`); const m = raw as Record; if (!isTakDmProtocol(m.protocol)) { - throw new Error(`${channel}: protocol must be meshcore or meshtastic`); + throw new Error(`${channel}: protocol must be one of ${TAK_DM_PROTOCOLS.join(', ')}`); } const nodeId = m.nodeId; if ( @@ -265,11 +268,40 @@ export function parseTakDirectMessage(raw: unknown): TAKDirectMessage { /** Validate the renderer's list of radios with direct messages on. Throws when malformed. */ export function parseTakDmProtocols(raw: unknown): TakDmProtocol[] { if (!Array.isArray(raw) || !raw.every(isTakDmProtocol)) { - throw new Error('tak:setDirectMessaging: expected a list of meshcore and meshtastic'); + throw new Error(`tak:setDirectMessaging: expected a list of ${TAK_DM_PROTOCOLS.join(', ')}`); } return [...new Set(raw)]; } +/** Validate the renderer's list of conversation peers. Throws when malformed. */ +export function parseTakDirectPeers(raw: unknown): TakDirectPeer[] { + const channel = 'tak:setDirectPeers'; + if (!Array.isArray(raw) || raw.length > TAK_DIRECT_PEERS_MAX) { + throw new Error(`${channel}: expected a list of at most ${TAK_DIRECT_PEERS_MAX} peers`); + } + const byKey = new Map(); + for (const item of raw) { + if (!item || typeof item !== 'object') throw new Error(`${channel}: peer must be an object`); + const p = item as Record; + if (!isTakDmProtocol(p.protocol)) { + throw new Error(`${channel}: protocol must be one of ${TAK_DM_PROTOCOLS.join(', ')}`); + } + const nodeId = p.nodeId; + if ( + typeof nodeId !== 'number' || + !Number.isInteger(nodeId) || + nodeId <= 0 || + nodeId > MAX_NODE_ID + ) { + throw new Error(`${channel}: nodeId must be a node number`); + } + const callsign = label(p.callsign, TAK_GEOCHAT_CALLSIGN_MAX_LEN, 'callsign', channel); + const key = `${p.protocol}:${nodeId}`; + if (!byKey.has(key)) byKey.set(key, { protocol: p.protocol, nodeId, callsign }); + } + return [...byKey.values()]; +} + const DM_REF_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; /** Validate an untrusted direct message outcome from the renderer. Throws when malformed. */ diff --git a/src/preload/index.ts b/src/preload/index.ts index 57d5cf1e7..b3d28fb07 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -56,6 +56,7 @@ import type { TAKContact, TAKContactsUpdate, TAKDirectMessage, + TakDirectPeer, TakDmProtocol, TakDmStatusUpdate, TAKEnrollmentRequest, @@ -1391,6 +1392,8 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('tak:setDirectMessaging', protocols), dmStatus: (update: TakDmStatusUpdate): Promise => ipcRenderer.invoke('tak:dmStatus', update), + setDirectPeers: (peers: TakDirectPeer[]): Promise => + ipcRenderer.invoke('tak:setDirectPeers', peers), getStyleSettings: (): Promise => ipcRenderer.invoke('tak:getStyleSettings'), setStyleSettings: (settings: TakStyleSettings): Promise => ipcRenderer.invoke('tak:setStyleSettings', settings), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 3654d2088..10785321e 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1261,6 +1261,7 @@ function AppContent() { identityId: meshtasticIdentityId ?? null, channelConfigs: meshtasticRuntime.channelConfigs, }, + reticulum: { identityId: reticulumIdentityId ?? null }, }); useTakNodeReplicator({ active: takStatus.running || takRemoteStatus.state !== 'disconnected', @@ -5651,6 +5652,7 @@ function AppContent() { capabilities={capabilities} meshtasticIdentityId={meshtasticIdentityId} meshcoreIdentityId={meshcoreIdentityId} + reticulumIdentityId={reticulumIdentityId} /> diff --git a/src/renderer/components/TakChannelRelaySection.test.tsx b/src/renderer/components/TakChannelRelaySection.test.tsx index c96c8bbdd..a6777d6c6 100644 --- a/src/renderer/components/TakChannelRelaySection.test.tsx +++ b/src/renderer/components/TakChannelRelaySection.test.tsx @@ -8,7 +8,10 @@ import type { IdentityId } from '../lib/types'; import { setTakChannels, useTakChannelStore } from '../stores/takChannelStore'; import { getTakRelayPrefs, useTakRelayPrefsStore } from '../stores/takRelayPrefsStore'; import { setTakSinkActive } from '../stores/takSinkStore'; -import TakChannelRelaySection, { TakDirectMessagesToggle } from './TakChannelRelaySection'; +import TakChannelRelaySection, { + TakDirectMessagesSection, + TakDirectMessagesToggle, +} from './TakChannelRelaySection'; const ID = 'tak-channel-relay-test'; const PUBLIC = '0123456789abcdef'; @@ -201,4 +204,29 @@ describe('TakChannelRelaySection', () => { }); expect(screen.getByLabelText('Let ATAK devices send to Ops')).not.toBeChecked(); }); + + it('gives Reticulum a section with its direct message switch alone, while a sink is up', async () => { + const user = userEvent.setup(); + const section = ( + + ); + const { container, rerender } = render(section); + expect(screen.getByText('Reticulum messaging in ATAK')).toBeInTheDocument(); + expect(screen.queryByLabelText('Channels in ATAK')).toBeNull(); + await user.click(screen.getByLabelText('Direct messages')); + expect(getTakRelayPrefs('rn').directMessages).toBe(true); + hydrateAxeThemeColors(container); + expect(await axe(container)).toHaveNoViolations(); + + act(() => { + setTakSinkActive(false); + }); + rerender(section); + expect(container).toBeEmptyDOMElement(); + }); }); diff --git a/src/renderer/components/TakChannelRelaySection.tsx b/src/renderer/components/TakChannelRelaySection.tsx index ab8e07f2e..92809e1f5 100644 --- a/src/renderer/components/TakChannelRelaySection.tsx +++ b/src/renderer/components/TakChannelRelaySection.tsx @@ -281,3 +281,32 @@ export function TakDirectMessagesToggle({ identityId, anchorId }: DirectMessages ); } + +interface DirectMessagesSectionProps { + identityId: IdentityId | null; + title: string; + description: string; + /** Settings search anchor for the switch. */ + anchorId: string; +} + +/** + * Messaging in ATAK for a radio with no channels to serve (Reticulum): its direct message + * switch alone. Only shown while a TAK sink is up. + */ +export function TakDirectMessagesSection({ + identityId, + title, + description, + anchorId, +}: DirectMessagesSectionProps) { + const sinkActive = useTakSinkStore((s) => s.active); + if (!sinkActive || !identityId) return null; + return ( +
+

{title}

+

{description}

+ +
+ ); +} diff --git a/src/renderer/components/TakServerPanel.tsx b/src/renderer/components/TakServerPanel.tsx index 86aade33b..602782a53 100644 --- a/src/renderer/components/TakServerPanel.tsx +++ b/src/renderer/components/TakServerPanel.tsx @@ -11,7 +11,10 @@ import type { TAKSettings } from '@/shared/tak-types'; import { useTakServer } from '../hooks/useTakServer'; import type { IdentityId } from '../lib/types'; -import TakChannelRelaySection, { TakDirectMessagesToggle } from './TakChannelRelaySection'; +import TakChannelRelaySection, { + TakDirectMessagesSection, + TakDirectMessagesToggle, +} from './TakChannelRelaySection'; import TakRemoteRelaySection from './TakRemoteRelaySection'; import TakUnitFiltersSection from './TakUnitFiltersSection'; import { INPUT_BOX_CLASS } from './ui/formClasses'; @@ -62,6 +65,8 @@ interface Props { meshtasticIdentityId?: IdentityId | null; /** MeshCore identity whose channels may feed tracker fixes and be served to TAK. */ meshcoreIdentityId?: IdentityId | null; + /** Reticulum identity whose LXMF peers TAK devices may message. */ + reticulumIdentityId?: IdentityId | null; } export default function TakServerPanel({ @@ -69,6 +74,7 @@ export default function TakServerPanel({ capabilities, meshtasticIdentityId, meshcoreIdentityId, + reticulumIdentityId, }: Props) { const { t } = useTranslation(); const id = useId(); @@ -328,6 +334,13 @@ export default function TakServerPanel({ /> + + {/* ATAK Plugin Messages from Mesh */} {capabilities?.hasAtakPlugin && (
diff --git a/src/renderer/hooks/useTakInboundChat.test.tsx b/src/renderer/hooks/useTakInboundChat.test.tsx index 95b507ff5..e97e35b4f 100644 --- a/src/renderer/hooks/useTakInboundChat.test.tsx +++ b/src/renderer/hooks/useTakInboundChat.test.tsx @@ -4,6 +4,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { computeChannelIdentityKey } from '@/shared/channelIdentityKey'; import type { TakChatChannel } from '@/shared/tak-types'; +import { + clearReticulumHashRegistry, + registerReticulumDestinationHash, +} from '../lib/reticulum/destHash'; +import { setConnection, useConnectionStore } from '../stores/connectionStore'; +import { addMessage, useMessageStore } from '../stores/messageStore'; import { getTakChannels, getTakRadio, useTakChannelStore } from '../stores/takChannelStore'; import { useTakRelayPrefsStore } from '../stores/takRelayPrefsStore'; import { useTakSinkStore } from '../stores/takSinkStore'; @@ -16,6 +22,7 @@ import { /** The mocked IPC call the hook reports the served channels through. */ const setChatChannels = () => vi.mocked(window.electronAPI.tak.setChatChannels); const setDirectMessaging = () => vi.mocked(window.electronAPI.tak.setDirectMessaging); +const setDirectPeers = () => vi.mocked(window.electronAPI.tak.setDirectPeers); const PUBLIC = Uint8Array.from('8b3387e9c5cdea6ac9e5edbaa115cd72'.match(/../g) ?? [], (b) => parseInt(b, 16), @@ -55,6 +62,7 @@ describe('useTakInboundChat', () => { beforeEach(() => { setChatChannels().mockClear(); setDirectMessaging().mockClear(); + setDirectPeers().mockClear(); useTakSinkStore.setState({ active: false }); useTakRelayPrefsStore.setState({ byIdentity: {} }); useTakChannelStore.setState({ channelsByIdentity: {} }); @@ -94,6 +102,38 @@ describe('useTakInboundChat', () => { expect(getTakRadio('meshtastic')).toBe('mt'); }); + it('tells main which LXMF peers have a conversation, once Reticulum takes direct messages', () => { + clearReticulumHashRegistry(); + registerReticulumDestinationHash(789, 'ab'.repeat(16)); + useMessageStore.setState({ messages: {} }); + useConnectionStore.setState({ connections: {} }); + setConnection('rn', { myNodeNum: 3 }); + addMessage('rn', { id: 'a', from: 789, to: 3, payload: 'hi', channelIndex: 0, timestamp: 1 }); + mount({ ...sources(), reticulum: { identityId: 'rn' } }); + expect(getTakRadio('reticulum')).toBe('rn'); + act(() => { + useTakSinkStore.setState({ active: true }); + }); + // Off by default: no peers go to ATAK. + expect(setDirectPeers()).toHaveBeenLastCalledWith([]); + act(() => { + useTakRelayPrefsStore.getState().setDirectMessages('rn', true); + }); + expect(setDirectMessaging()).toHaveBeenLastCalledWith(['reticulum']); + expect(setDirectPeers()).toHaveBeenLastCalledWith([ + { protocol: 'reticulum', nodeId: 789, callsign: '789' }, + ]); + // A conversation with another peer adds it. + registerReticulumDestinationHash(790, 'cd'.repeat(16)); + act(() => { + addMessage('rn', { id: 'b', from: 3, to: 790, payload: 'yo', channelIndex: 0, timestamp: 2 }); + }); + expect(setDirectPeers().mock.lastCall?.[0].map((p) => p.nodeId)).toEqual([790, 789]); + useMessageStore.setState({ messages: {} }); + useConnectionStore.setState({ connections: {} }); + clearReticulumHashRegistry(); + }); + it('tells main which radios take direct messages, when a sink starts and when it changes', () => { mount(); expect(setDirectMessaging()).not.toHaveBeenCalled(); diff --git a/src/renderer/hooks/useTakInboundChat.ts b/src/renderer/hooks/useTakInboundChat.ts index 01cec0049..32bdfb70d 100644 --- a/src/renderer/hooks/useTakInboundChat.ts +++ b/src/renderer/hooks/useTakInboundChat.ts @@ -1,9 +1,9 @@ import { useEffect, useMemo } from 'react'; -import type { TakChatChannel, TakDmProtocol } from '@/shared/tak-types'; +import type { TakChatChannel, TakDirectPeer, TakDmProtocol } from '@/shared/tak-types'; import { errLikeToLogString } from '../lib/errLikeToLogString'; -import { listTakDmProtocols } from '../lib/tak/takDirectMessages'; +import { listTakConversationPeers, listTakDmProtocols } from '../lib/tak/takDirectMessages'; import { startTakDmStatus } from '../lib/tak/takDmStatus'; import { enqueueTakChatRelay } from '../lib/tak/takInboundChat'; import { sendTakTextOnMesh } from '../lib/tak/takMeshSend'; @@ -14,6 +14,9 @@ import { toTakChatChannels, } from '../lib/tak/takServedChannels'; import type { IdentityId } from '../lib/types'; +import { useConnectionStore } from '../stores/connectionStore'; +import { useMessageStore } from '../stores/messageStore'; +import { useNodeStore } from '../stores/nodeStore'; import { setTakChannels, setTakRadio, type TakChannelInfo } from '../stores/takChannelStore'; import { bindTakPendingSlots, @@ -64,11 +67,15 @@ const sendChatChannels = (value: unknown): Promise => window.electronAPI.tak.setChatChannels(value as TakChatChannel[]); const sendDirectMessaging = (value: unknown): Promise => window.electronAPI.tak.setDirectMessaging(value as TakDmProtocol[]); +const sendDirectPeers = (value: unknown): Promise => + window.electronAPI.tak.setDirectPeers(value as TakDirectPeer[]); /** Each radio's identity and its runtime channel list, as App has them. */ export interface TakChannelSources { meshcore: { identityId: IdentityId | null; channels: readonly unknown[] }; meshtastic: { identityId: IdentityId | null; channelConfigs: readonly unknown[] }; + /** The Reticulum identity, whose LXMF peers TAK clients may message; it has no channels. */ + reticulum?: { identityId: IdentityId | null }; } /** @@ -83,6 +90,7 @@ export function useTakInboundChat(sources: TakChannelSources): void { const meshcoreChannels = sources.meshcore.channels; const meshtasticId = sources.meshtastic.identityId; const meshtasticConfigs = sources.meshtastic.channelConfigs; + const reticulumId = sources.reticulum?.identityId ?? null; const meshcoreInfos = useMemo(() => meshcoreTakChannels(meshcoreChannels), [meshcoreChannels]); const meshtasticInfos = useMemo( @@ -111,7 +119,8 @@ export function useTakInboundChat(sources: TakChannelSources): void { useEffect(() => { setTakRadio('meshcore', meshcoreId); setTakRadio('meshtastic', meshtasticId); - }, [meshcoreId, meshtasticId]); + setTakRadio('reticulum', reticulumId); + }, [meshcoreId, meshtasticId, reticulumId]); useEffect(() => { if (!meshtasticId) return; @@ -149,7 +158,32 @@ export function useTakInboundChat(sources: TakChannelSources): void { const radios: Partial> = {}; if (meshcoreId) radios.meshcore = meshcoreId; if (meshtasticId) radios.meshtastic = meshtasticId; + if (reticulumId) radios.reticulum = reticulumId; return JSON.stringify(listTakDmProtocols(radios, relayPrefs)); - }, [meshcoreId, meshtasticId, relayPrefs]); + }, [meshcoreId, meshtasticId, reticulumId, relayPrefs]); useSendWhileSinkActive(sinkActive, dmJson, sendDirectMessaging, 'setDirectMessaging'); + + // LXMF peers with a conversation in mesh-client, so ATAK can carry it on with peers that + // have no marker. Worked out only while TAK is up and the Reticulum radio takes DMs. + const reticulumDms = + reticulumId !== null && sinkActive && relayPrefs[reticulumId]?.directMessages; + const reticulumMessages = useMessageStore((s) => + reticulumDms && reticulumId ? s.messages[reticulumId] : undefined, + ); + const reticulumNodes = useNodeStore((s) => + reticulumDms && reticulumId ? s.nodes[reticulumId] : undefined, + ); + const reticulumSelf = useConnectionStore((s) => + reticulumId ? (s.connections[reticulumId]?.myNodeNum ?? 0) : 0, + ); + const peersJson = useMemo( + () => + JSON.stringify( + reticulumDms + ? listTakConversationPeers(reticulumMessages, reticulumNodes, reticulumSelf) + : [], + ), + [reticulumDms, reticulumMessages, reticulumNodes, reticulumSelf], + ); + useSendWhileSinkActive(sinkActive, peersJson, sendDirectPeers, 'setDirectPeers'); } diff --git a/src/renderer/lib/devElectronApiStub.ts b/src/renderer/lib/devElectronApiStub.ts index 3d4523371..17a83b2cb 100644 --- a/src/renderer/lib/devElectronApiStub.ts +++ b/src/renderer/lib/devElectronApiStub.ts @@ -404,6 +404,7 @@ export function createDevElectronApiStub(): typeof window.electronAPI { pushDirectMessage: async () => {}, setDirectMessaging: async () => {}, dmStatus: async () => {}, + setDirectPeers: async () => {}, onInboundChat: noopUnsub, }, bleCoexistence: { diff --git a/src/renderer/lib/ingest/reticulumIngest.test.ts b/src/renderer/lib/ingest/reticulumIngest.test.ts index a4abbc6ce..1f12d2cb4 100644 --- a/src/renderer/lib/ingest/reticulumIngest.test.ts +++ b/src/renderer/lib/ingest/reticulumIngest.test.ts @@ -50,7 +50,13 @@ vi.mock('@/renderer/stores/nodeStore', () => ({ }, })); +const relayDirectMessageToTak = vi.fn(); +vi.mock('@/renderer/lib/tak/takDirectMessages', () => ({ + relayDirectMessageToTak: (...args: unknown[]) => relayDirectMessageToTak(...args), +})); + beforeEach(() => { + relayDirectMessageToTak.mockReset(); upsertReticulumDestination.mockReset(); upsertReticulumDestination.mockResolvedValue(undefined); saveReticulumMessage.mockReset(); @@ -516,3 +522,48 @@ describe('Reticulum per-message radio evidence', () => { }, ); }); + +describe('reticulumIngest TAK relay', () => { + const SENDER = 'aa'.repeat(16); + const inbound = { + sender_hash: SENDER, + sender_name: 'Field Laptop', + text: 'are you there?', + timestamp: 1_700_000_000_000, + message_hash: 'cc'.repeat(16), + direction: 'inbound', + }; + + beforeEach(() => { + upsertMessage.mockClear(); + messagesState = {}; + useBlockStore.setState({ + byProtocol: { + reticulum: { identityId: 'id-rt', hashes: new Set(), entries: [], loaded: true }, + }, + }); + }); + + it('hands a newly stored LXMF message from a peer to TAK', () => { + expect(ingestReticulumLxmfPayload('id-rt', inbound)).toBe(true); + expect(relayDirectMessageToTak).toHaveBeenCalledExactlyOnceWith('id-rt', 'reticulum', { + nodeId: Number.parseInt(SENDER.slice(0, 12), 16) >>> 0, + senderName: 'Field Laptop', + text: 'are you there?', + timestampMs: 1_700_000_000_000, + }); + }); + + it('leaves out a message already stored, our own sends, and reactions', () => { + messagesState = { 'id-rt': { ['cc'.repeat(16)]: { id: 'cc'.repeat(16) } as MessageRecord } }; + ingestReticulumLxmfPayload('id-rt', inbound); + messagesState = {}; + ingestReticulumLxmfPayload('id-rt', { ...inbound, direction: 'outbound' }); + ingestReticulumLxmfPayload('id-rt', { + ...inbound, + message_hash: 'dd'.repeat(16), + reaction_target: 'bb'.repeat(16), + }); + expect(relayDirectMessageToTak).not.toHaveBeenCalled(); + }); +}); diff --git a/src/renderer/lib/ingest/reticulumIngest.ts b/src/renderer/lib/ingest/reticulumIngest.ts index 000e785d7..52e2f78d8 100644 --- a/src/renderer/lib/ingest/reticulumIngest.ts +++ b/src/renderer/lib/ingest/reticulumIngest.ts @@ -23,6 +23,7 @@ import { reticulumMessageHashesEqual, } from '@/renderer/lib/reticulum/reticulumMessageHash'; import { reticulumDbRowToMessageRecord } from '@/renderer/lib/storeRecordAdapters'; +import { relayDirectMessageToTak } from '@/renderer/lib/tak/takDirectMessages'; import type { IdentityId } from '@/renderer/lib/types'; import { useBlockStore } from '@/renderer/stores/blockStore'; import type { MessageRecord, MessageStatus } from '@/renderer/stores/messageStore'; @@ -214,10 +215,22 @@ export function ingestReticulumLxmfPayload( } const record = payloadToMessageRecord(p, identityId); if (!record) return false; - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Identity bucket may be absent at runtime. - const existing = useMessageStore.getState().messages[identityId]?.[record.id]; + // Either level can miss: no messages yet for the identity, or none with this id. + const bucket = useMessageStore.getState().messages[identityId] as + Record | undefined; + const existing = bucket?.[record.id]; const merged = mergeReticulumIngestRecord(existing, record, p, ctx); upsertMessage(identityId, merged); + // A newly stored LXMF message from a peer goes on to TAK; status updates and our own sends + // come through here too and are left out. + if (!existing && p.direction === 'inbound' && !record.tapback) { + relayDirectMessageToTak(identityId, 'reticulum', { + nodeId: record.from, + senderName: record.senderName ?? '', + text: record.payload, + timestampMs: record.timestamp, + }); + } return true; } diff --git a/src/renderer/lib/settingsSearchEntries/tak.ts b/src/renderer/lib/settingsSearchEntries/tak.ts index c66c44ef9..f90ccae17 100644 --- a/src/renderer/lib/settingsSearchEntries/tak.ts +++ b/src/renderer/lib/settingsSearchEntries/tak.ts @@ -193,6 +193,23 @@ const takEntries: readonly SettingSearchEntry[] = [ sectionKey: 'takServerPanel.messagingTitleMeshtastic', keywords: ['tak', 'atak', 'meshtastic', 'dm', 'direct message', 'companion', 'contact', 'chat'], }, + { + id: 'tak.directMessages.reticulum', + slot: 'TAK', + labelKey: 'takServerPanel.directMessages', + sectionKey: 'takServerPanel.messagingTitleReticulum', + keywords: [ + 'tak', + 'atak', + 'reticulum', + 'lxmf', + 'dm', + 'direct message', + 'peer', + 'contact', + 'chat', + ], + }, { id: 'tak.directMessages.meshcore', slot: 'TAK', @@ -252,6 +269,7 @@ export const takSurface: SettingsSearchSurface = { 'takServerPanel.unitStyles*IconPreset': 'GeoOps menu beside an indexed icon path field', 'takServerPanel.unitIcon*': 'preset prompt and validation note of the indexed icon fields', 'takServerPanel.messagingTitleMeshcore': 'section heading; the section is indexed', + 'takServerPanel.messagingTitleReticulum': 'section heading; its one switch is indexed', 'takServerPanel.messagingIntro*': 'section prose; the section is indexed', 'takServerPanel.channelMode*': 'channel menu at the top of each indexed messaging section', 'takServerPanel.channelAtakName*': 'per-channel name field in each indexed messaging section', diff --git a/src/renderer/lib/tak/takDirectMessages.test.ts b/src/renderer/lib/tak/takDirectMessages.test.ts index 6035b5787..9001019fe 100644 --- a/src/renderer/lib/tak/takDirectMessages.test.ts +++ b/src/renderer/lib/tak/takDirectMessages.test.ts @@ -3,13 +3,20 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { MESHTASTIC_BROADCAST_NODE_NUM } from '@/shared/nodeNameUtils'; import { setConnection, useConnectionStore } from '../../stores/connectionStore'; +import type { MessageRecord } from '../../stores/messageStore'; import { useNodeStore } from '../../stores/nodeStore'; +import { useReticulumIdentityActivityStore } from '../../stores/reticulumIdentityActivityStore'; import { setTakRadio, useTakChannelStore } from '../../stores/takChannelStore'; import { getTakRelayPrefs, useTakRelayPrefsStore } from '../../stores/takRelayPrefsStore'; import { setTakSinkActive } from '../../stores/takSinkStore'; +import { + clearReticulumHashRegistry, + registerReticulumDestinationHash, +} from '../reticulum/destHash'; import type { MeshNode } from '../types'; import { canTakDirectMessage, + listTakConversationPeers, listTakDmProtocols, relayDirectMessageToTak, resetTakRelayedDirectMessages, @@ -17,6 +24,8 @@ import { const MC = 'tak-dm-meshcore'; const MT = 'tak-dm-meshtastic'; +const RN = 'tak-dm-reticulum'; +const LXMF = 'ab'.repeat(16); const NOW = Date.parse('2026-10-16T12:00:00Z'); function node(overrides: Partial = {}): MeshNode { @@ -43,6 +52,10 @@ beforeEach(() => { setTakRadio('meshtastic', MT); setConnection(MC, { myNodeNum: 1 }); setConnection(MT, { myNodeNum: 2 }); + setTakRadio('reticulum', RN); + setConnection(RN, { myNodeNum: 3 }); + clearReticulumHashRegistry(); + useReticulumIdentityActivityStore.setState({ byDestination: new Map() }); }); afterEach(() => { @@ -74,13 +87,113 @@ describe('canTakDirectMessage', () => { ).toBe(false); }); - it('refuses everything without a TAK radio for the protocol, and Reticulum for now', () => { - expect(canTakDirectMessage(node(), 'reticulum')).toBe(false); + it('allows a Reticulum peer whose LXMF destination is known under its own id', () => { + const peer = node({ node_id: 789, public_key_hex: undefined, hw_model: undefined }); + expect(canTakDirectMessage(peer, 'reticulum')).toBe(false); + registerReticulumDestinationHash(789, LXMF); + expect(canTakDirectMessage(peer, 'reticulum')).toBe(true); + expect(canTakDirectMessage({ ...peer, node_id: 3 }, 'reticulum')).toBe(false); + }); + + it("refuses a Reticulum peer whose LXMF address is another destination's", () => { + // A telephony destination of an identity whose LXMF delivery hash is a different one: + // replies would come from that other destination, not from this marker. + const identity = '0f79468863d76b3ba574baa92606ffcb'; + const lxmf = 'e3359f1314aff4fb6261400a8202149b'; + const telephony = 'ab1d53d6923d6983dfb4451e3869b878'; + useReticulumIdentityActivityStore.setState({ + byDestination: new Map([ + [ + telephony, + [ + { + destination_hash: telephony, + aspect: 'lxst.telephony', + identity_hash: identity, + last_seen: 2, + }, + ], + ], + [ + lxmf, + [ + { + destination_hash: lxmf, + aspect: 'lxmf.delivery', + identity_hash: identity, + last_seen: 1, + }, + ], + ], + ]), + }); + registerReticulumDestinationHash(790, telephony); + expect(canTakDirectMessage(node({ node_id: 790 }), 'reticulum')).toBe(false); + }); + + it('refuses everything without a TAK radio for the protocol', () => { setTakRadio('meshcore', null); expect(canTakDirectMessage(node(), 'meshcore')).toBe(false); }); }); +describe('listTakConversationPeers', () => { + const msg = ( + id: string, + from: number, + to: number, + timestamp: number, + extra = {}, + ): MessageRecord => ({ + id, + from, + to, + payload: 'hi', + channelIndex: 0, + timestamp, + ...extra, + }); + + it('lists the peers with a conversation, most recent first, named as the node store has them', () => { + for (const id of [789, 790, 791]) registerReticulumDestinationHash(id, LXMF); + const messages = { + a: msg('a', 789, 3, 100), + b: msg('b', 3, 790, 300), + c: msg('c', 789, 3, 200), + // A reaction is not a conversation, and peers with no known LXMF destination stay out. + d: msg('d', 791, 3, 400, { tapback: true }), + e: msg('e', 792, 3, 500), + }; + const nodes = { + 789: { nodeId: 789, longName: ' Field Laptop ' }, + 790: { nodeId: 790, shortName: 'BOB' }, + }; + expect(listTakConversationPeers(messages, nodes, 3)).toEqual([ + { protocol: 'reticulum', nodeId: 790, callsign: 'BOB' }, + { protocol: 'reticulum', nodeId: 789, callsign: 'Field Laptop' }, + ]); + }); + + it('names a peer with no name by its node id, and lists nothing without messages', () => { + registerReticulumDestinationHash(789, LXMF); + expect(listTakConversationPeers({ a: msg('a', 789, 3, 1) }, undefined, 3)).toEqual([ + { protocol: 'reticulum', nodeId: 789, callsign: '789' }, + ]); + expect(listTakConversationPeers(undefined, undefined, 3)).toEqual([]); + }); + + it('keeps the list to the most recent 64 peers', () => { + const messages: Record = {}; + for (let i = 1; i <= 70; i++) { + registerReticulumDestinationHash(1000 + i, LXMF); + messages[String(i)] = msg(String(i), 1000 + i, 3, i); + } + const peers = listTakConversationPeers(messages, undefined, 3); + expect(peers).toHaveLength(64); + expect(peers[0]?.nodeId).toBe(1070); + }); +}); + describe('listTakDmProtocols', () => { it('lists the protocols whose TAK radio has direct messages on', () => { const prefs = { diff --git a/src/renderer/lib/tak/takDirectMessages.ts b/src/renderer/lib/tak/takDirectMessages.ts index 7be20b496..6be7bb1c1 100644 --- a/src/renderer/lib/tak/takDirectMessages.ts +++ b/src/renderer/lib/tak/takDirectMessages.ts @@ -2,32 +2,52 @@ import type { MeshProtocol } from '@/shared/meshProtocol'; import { isMeshtasticBroadcastNodeNum } from '@/shared/nodeNameUtils'; import { isTakDmProtocol, + TAK_DIRECT_PEERS_MAX, TAK_DM_PROTOCOLS, TAK_GEOCHAT_CALLSIGN_MAX_LEN, TAK_GEOCHAT_TEXT_MAX_LEN, + type TakDirectPeer, type TakDmProtocol, } from '@/shared/tak-types'; import { getConnection } from '../../stores/connectionStore'; +import type { MessageRecord } from '../../stores/messageStore'; import { type NodeRecord, useNodeStore } from '../../stores/nodeStore'; +import { reticulumHashForNodeId } from '../../stores/reticulumPeerStore'; import { getTakRadio } from '../../stores/takChannelStore'; import { getTakRelayPrefs, type TakRelayIdentityPrefs } from '../../stores/takRelayPrefsStore'; import { isTakSinkActive } from '../../stores/takSinkStore'; import { errLikeToLogString } from '../errLikeToLogString'; import { isMeshcoreDmExcludedHwModel } from '../meshcoreUtils'; +import { resolveReticulumChatLxmfDestination } from '../reticulum/resolveReticulumChatLxmfDest'; import type { IdentityId, MeshNode } from '../types'; +/** + * Whether a Reticulum peer's LXMF delivery destination is known under its own node id, so a + * direct message to its marker goes to it and its replies come back from the same contact. A + * peer whose LXMF address belongs to another destination (a telephony or bare identity hash + * remapped) stays a plain marker. + */ +export function hasOwnLxmfDestination(nodeId: number): boolean { + const hash = reticulumHashForNodeId(nodeId); + if (!hash) return false; + const resolved = resolveReticulumChatLxmfDestination(hash); + return resolved.status === 'ok' && !resolved.remapped; +} + /** * Whether this app can send `node` a direct message on the radio TAK uses for `protocol`, by - * the rule Chat and Rooms use: any other Meshtastic node, or a MeshCore node that is not a - * repeater, room server or sensor and whose public key this app holds. Main still checks the - * node is a companion and that direct messages are on for the radio. + * the rule Chat and Rooms use: any other Meshtastic node; a MeshCore node that is not a + * repeater, room server or sensor and whose public key this app holds; or a Reticulum peer + * whose LXMF delivery destination is known. Main still checks the node is a companion and + * that direct messages are on for the radio. */ export function canTakDirectMessage(node: MeshNode, protocol: MeshProtocol): boolean { if (!isTakDmProtocol(protocol) || node.node_id <= 0) return false; const identityId = getTakRadio(protocol); if (!identityId || node.node_id === getConnection(identityId)?.myNodeNum) return false; if (protocol === 'meshtastic') return !isMeshtasticBroadcastNodeNum(node.node_id); + if (protocol === 'reticulum') return hasOwnLxmfDestination(node.node_id); return !isMeshcoreDmExcludedHwModel(node.hw_model) && Boolean(node.public_key_hex?.trim()); } @@ -112,3 +132,37 @@ export function relayDirectMessageToTak( console.warn('[TakDirectMessages] pushDirectMessage failed ' + errLikeToLogString(e)); }); } + +/** + * The Reticulum peers this app has a direct conversation with, most recent first, for main: a + * message sent to or heard from each, whether or not TAK was running then. Only peers whose + * LXMF destination is known are listed, so each can be messaged from ATAK; one with no marker + * gets a contact at 0, 0. A peer never messaged stays out of ATAK, however many are heard. + */ +export function listTakConversationPeers( + messages: Record | undefined, + nodes: Record | undefined, + selfNodeId: number, +): TakDirectPeer[] { + const lastMs = new Map(); + for (const m of Object.values(messages ?? {})) { + if (m.tapback) continue; + const peer = (m.from === selfNodeId ? m.to : m.from) >>> 0; + if (peer === 0 || peer === selfNodeId) continue; + lastMs.set(peer, Math.max(lastMs.get(peer) ?? 0, m.timestamp)); + } + const peers: TakDirectPeer[] = []; + for (const [nodeId] of [...lastMs].sort((a, b) => b[1] - a[1])) { + if (peers.length >= TAK_DIRECT_PEERS_MAX) break; + if (!hasOwnLxmfDestination(nodeId)) continue; + const node = nodes?.[nodeId]; + const name = node?.longName?.trim() || node?.shortName?.trim() || ''; + const callsign = + name + .replace(/\p{Cc}+/gu, ' ') + .slice(0, TAK_GEOCHAT_CALLSIGN_MAX_LEN) + .trim() || String(nodeId); + peers.push({ protocol: 'reticulum', nodeId, callsign }); + } + return peers; +} diff --git a/src/renderer/lib/tak/takInboundChat.test.ts b/src/renderer/lib/tak/takInboundChat.test.ts index 1a31fa815..dd6882324 100644 --- a/src/renderer/lib/tak/takInboundChat.test.ts +++ b/src/renderer/lib/tak/takInboundChat.test.ts @@ -22,7 +22,17 @@ import { } from './takInboundChat'; /** The identity each protocol's tab uses now, as the identity store resolves it. */ -const currentIdentity: Partial> = { meshcore: 'mc', meshtastic: 'mt' }; +const currentIdentity: Partial> = { + meshcore: 'mc', + meshtastic: 'mt', + reticulum: 'rn', +}; +/** Reticulum peers whose LXMF destination is known under their own node id. */ +const ownLxmf = new Set(); +vi.mock('./takDirectMessages', () => ({ + hasOwnLxmfDestination: (nodeId: number) => ownLxmf.has(nodeId), +})); + vi.mock('../identityByProtocol', () => ({ getIdentityIdForProtocol: (protocol: MeshProtocol) => currentIdentity[protocol] ?? null, })); @@ -234,6 +244,19 @@ describe('direct messages from TAK', () => { expect(findTakSendTargets(direct('meshcore', 123))).toEqual([]); }); + it('sends to a Reticulum peer only while its LXMF destination is its own', () => { + setTakRadio('reticulum', 'rn'); + useTakRelayPrefsStore.getState().setDirectMessages('rn', true); + const peer = { kind: 'direct' as const, protocol: 'reticulum' as const, nodeId: 789 }; + ownLxmf.add(789); + expect(findTakSendTargets(peer)).toEqual([ + { identityId: 'rn', protocol: 'reticulum', channelIndex: 0, destination: 789 }, + ]); + // Its address now remaps to another destination: nothing is sent. + ownLxmf.delete(789); + expect(findTakSendTargets(peer)).toEqual([]); + }); + it('sends nothing while the recorded radio is no longer the one in use for the protocol', () => { setTakRadio('meshcore', 'mc'); useTakRelayPrefsStore.getState().setDirectMessages('mc', true); diff --git a/src/renderer/lib/tak/takInboundChat.ts b/src/renderer/lib/tak/takInboundChat.ts index 7ebc6f2b6..3d0c0ea79 100644 --- a/src/renderer/lib/tak/takInboundChat.ts +++ b/src/renderer/lib/tak/takInboundChat.ts @@ -5,6 +5,7 @@ import { getTakRelayPrefs } from '../../stores/takRelayPrefsStore'; import { errLikeToLogString } from '../errLikeToLogString'; import { getIdentityIdForProtocol } from '../identityByProtocol'; import type { IdentityId } from '../types'; +import { hasOwnLxmfDestination } from './takDirectMessages'; import { reportTakDmOutcome } from './takDmStatus'; import { consumeTakEcho, rememberTakSend } from './takEchoGuard'; import { listTakServedChannels } from './takServedChannels'; @@ -54,7 +55,7 @@ export function formatTakMeshText( export interface TakSendTarget { identityId: IdentityId; - protocol: 'meshtastic' | 'meshcore'; + protocol: 'meshtastic' | 'meshcore' | 'reticulum'; channelIndex: number; /** The companion a direct message goes to; unset for a channel message. */ destination?: number; @@ -74,6 +75,10 @@ export function findTakSendTargets(target: TakChatTarget): TakSendTarget[] { // The radio is recorded after a render, so for a moment after a switch to another radio of // the same protocol it can still name the old one; never send from that. if (identityId !== getIdentityIdForProtocol(target.protocol)) return []; + // A peer ATAK may still message (from an earlier DM, or a conversation) can have its LXMF + // address remap to another destination since; the message would go there, and the reply + // come back under a different contact. + if (target.protocol === 'reticulum' && !hasOwnLxmfDestination(target.nodeId)) return []; return [{ identityId, protocol: target.protocol, channelIndex: 0, destination: target.nodeId }]; } return listTakServedChannels() diff --git a/src/renderer/locales/cs/translation.json b/src/renderer/locales/cs/translation.json index 0061d10b0..990688424 100644 --- a/src/renderer/locales/cs/translation.json +++ b/src/renderer/locales/cs/translation.json @@ -5850,7 +5850,9 @@ "channelRemoteFor": "Stream {{channel}} na vzdálený TAK server", "channelTrackersFor": "Opravy trackeru od {{channel}}", "directMessages": "Přímé zprávy:", - "directMessagesHint": "Nechte zařízení Atak odeslat zprávu společníkům tohoto rádia. Každý společník, kterému může toto rádio poslat zprávu, dostane možnost chatu v atak a zpráva na něj vyjde z tohoto rádia s předponou volací značky odesílatele. Jeho odpovědi jdou do zařízení atak, které na něj naposledy napsalo, nebo do každého zařízení, pokud žádné nemá. Společník bez pozice se zobrazí na 0, 0 po dobu jednoho dne po zprávě." + "directMessagesHint": "Nechte zařízení Atak odeslat zprávu společníkům tohoto rádia. Každý společník, kterému může toto rádio poslat zprávu, dostane možnost chatu v atak a zpráva na něj vyjde z tohoto rádia s předponou volací značky odesílatele. Jeho odpovědi jdou do zařízení atak, které na něj naposledy napsalo, nebo do každého zařízení, pokud žádné nemá. Společník bez pozice se zobrazí na 0, 0 po dobu jednoho dne po zprávě.", + "messagingTitleReticulum": "Reticulum zasílání zpráv v atak", + "messagingIntroReticulum": "Nechte zařízení atak komunikovat LXMF vrstevníky. Protějšek s pozicí může být odeslán ze své značky; ten, kdo nemá žádnou pozici, se objeví na 0, 0, jakmile s ním mesh-client konverzuje, takže seznam kontaktů se nezaplní každým slyšeným protějškem." }, "telemetryPanel": { "title": "Telemetrie", diff --git a/src/renderer/locales/de/translation.json b/src/renderer/locales/de/translation.json index 31ef7efc7..1ffbedc7c 100644 --- a/src/renderer/locales/de/translation.json +++ b/src/renderer/locales/de/translation.json @@ -5805,7 +5805,9 @@ "channelRemoteFor": "{{channel}} zum entfernten TAK -Server streamen", "channelTrackersFor": "Tracker-Fixes von {{channel}}", "directMessages": "Direktnachrichten", - "directMessagesHint": "Lassen Sie ATAK-Geräte die Begleiter dieses Radios benachrichtigen. Jeder Begleiter, der diese Funknachricht empfangen kann, erhält eine Chat-Option in ATAK, und eine Nachricht an ihn geht von diesem Funkgerät aus, der das Rufzeichen des Absenders vorangestellt ist. Die Antworten gehen an das ATAK-Gerät, das zuletzt an es geschrieben hat, oder an jedes Gerät, wenn dies nicht der Fall ist. Ein Begleiter ohne Position erscheint für einen Tag nach einer Nachricht bei 0, 0." + "directMessagesHint": "Lassen Sie ATAK-Geräte die Begleiter dieses Radios benachrichtigen. Jeder Begleiter, der diese Funknachricht empfangen kann, erhält eine Chat-Option in ATAK, und eine Nachricht an ihn geht von diesem Funkgerät aus, der das Rufzeichen des Absenders vorangestellt ist. Die Antworten gehen an das ATAK-Gerät, das zuletzt an es geschrieben hat, oder an jedes Gerät, wenn dies nicht der Fall ist. Ein Begleiter ohne Position erscheint für einen Tag nach einer Nachricht bei 0, 0.", + "messagingTitleReticulum": "Reticulum Messaging in ATAK", + "messagingIntroReticulum": "Lassen Sie ATAK-Geräte LXMF Kollegen benachrichtigen. Ein Kollege mit einer Position kann von seinem Marker aus benachrichtigt werden; einer ohne Position erscheint bei 0, 0, sobald mesh-client ein Gespräch mit ihm führt, so dass die Kontaktliste nicht mit jedem gehörten Kollegen gefüllt wird." }, "telemetryPanel": { "title": "Telemetrie", diff --git a/src/renderer/locales/en/translation.json b/src/renderer/locales/en/translation.json index 3477ea872..040bac388 100644 --- a/src/renderer/locales/en/translation.json +++ b/src/renderer/locales/en/translation.json @@ -6786,6 +6786,8 @@ "messagingIntroMeshcore": "Choose which MeshCore channels appear in ATAK. Each becomes a contact on every connected ATAK device, and what the channel hears arrives from that contact. With ATAK can send on, a message to the contact goes out on the channel from this radio, prefixed with the sender's callsign; with it off, the channel is receive only. Remote server also streams the channel, read only, to the remote TAK server. Tracker fixes turns MeshCore tracker position messages (!MT1) into map markers. Messages heard while no ATAK device is connected (up to 50, for a day) are held for the first device that connects, and only that one.", "messagingTitleMeshtastic": "Meshtastic messaging in ATAK", "messagingIntroMeshtastic": "Choose which Meshtastic channels appear in ATAK. Each becomes a contact on every connected ATAK device, and what the channel hears, including over MQTT, arrives from that contact. With ATAK can send on, a message to the contact goes out on the channel from this radio, prefixed with the sender's callsign; with it off, the channel is receive only. Remote server also streams the channel, read only, to the remote TAK server. Messages heard while no ATAK device is connected (up to 50, for a day) are held for the first device that connects, and only that one.", + "messagingTitleReticulum": "Reticulum messaging in ATAK", + "messagingIntroReticulum": "Let ATAK devices message LXMF peers. A peer with a position can be messaged from its marker; one with no position appears at 0, 0 once mesh-client has a conversation with it, so the contact list does not fill with every peer heard.", "channelModeLabel": "Channels in ATAK", "channelModeOff": "Off", "channelModeAll": "All channels", diff --git a/src/renderer/locales/es/translation.json b/src/renderer/locales/es/translation.json index f5067f9e1..276e5cc89 100644 --- a/src/renderer/locales/es/translation.json +++ b/src/renderer/locales/es/translation.json @@ -5805,7 +5805,9 @@ "channelRemoteFor": "Transmite {{channel}} al servidor TAK remoto", "channelTrackersFor": "Correcciones del rastreador de {{channel}}", "directMessages": "Mensajes directos", - "directMessagesHint": "Deje que los dispositivos ATAK envíen mensajes a los compañeros de esta radio. Cada acompañante de este mensaje de radio Can recibe una opción de chat en ATAK, y sale un mensaje de esta radio, con el prefijo del indicativo del remitente. Sus respuestas van al dispositivo ATAK que le escribió por última vez, o a todos los dispositivos cuando ninguno lo ha hecho. Un compañero sin posición aparece en 0, 0 durante un día después de un mensaje." + "directMessagesHint": "Deje que los dispositivos ATAK envíen mensajes a los compañeros de esta radio. Cada acompañante de este mensaje de radio Can recibe una opción de chat en ATAK, y sale un mensaje de esta radio, con el prefijo del indicativo del remitente. Sus respuestas van al dispositivo ATAK que le escribió por última vez, o a todos los dispositivos cuando ninguno lo ha hecho. Un compañero sin posición aparece en 0, 0 durante un día después de un mensaje.", + "messagingTitleReticulum": "Reticulum mensajes en ATAK", + "messagingIntroReticulum": "Permite que los dispositivos ATAK envíen un mensaje a LXMF Peers. Se puede enviar un mensaje a un compañero con una posición desde su marcador; uno sin posición aparece en 0, 0 una vez que mesh-client tiene una conversación con él, por lo que la lista de contactos no se llena con todos los compañeros escuchados." }, "telemetryPanel": { "title": "Telemetría", diff --git a/src/renderer/locales/fr/translation.json b/src/renderer/locales/fr/translation.json index f30ec0b97..7c15b2304 100644 --- a/src/renderer/locales/fr/translation.json +++ b/src/renderer/locales/fr/translation.json @@ -5805,7 +5805,9 @@ "channelRemoteFor": "Flux {{channel}} vers le serveur TAK distant", "channelTrackersFor": "Correctifs de suivi de {{channel}}", "directMessages": "Messages directs", - "directMessagesHint": "Laissez les appareils ATAK envoyer un message aux compagnons de cette radio. Chaque compagnon de ce message radio CAN obtient une option de chat dans ATAK, et un message lui est envoyé par cette radio, précédé de l'indicatif d'appel de l'expéditeur. Ses réponses vont à l'appareil ATAK qui lui a écrit en dernier, ou à chaque appareil quand aucun ne l'a fait. Un compagnon sans position apparaît à 0, 0 pendant un jour après un message." + "directMessagesHint": "Laissez les appareils ATAK envoyer un message aux compagnons de cette radio. Chaque compagnon de ce message radio CAN obtient une option de chat dans ATAK, et un message lui est envoyé par cette radio, précédé de l'indicatif d'appel de l'expéditeur. Ses réponses vont à l'appareil ATAK qui lui a écrit en dernier, ou à chaque appareil quand aucun ne l'a fait. Un compagnon sans position apparaît à 0, 0 pendant un jour après un message.", + "messagingTitleReticulum": "Messagerie Reticulum dans ATAK", + "messagingIntroReticulum": "Laissez les appareils ATAK envoyer un message à LXMF pairs. Un pair avec un poste peut être envoyé par message à partir de son marqueur ; celui sans poste apparaît à 0, 0 une fois que mesh-client a une conversation avec lui, de sorte que la liste de contacts ne se remplit pas avec tous les pairs entendus." }, "telemetryPanel": { "title": "Télémétrie", diff --git a/src/renderer/locales/id/translation.json b/src/renderer/locales/id/translation.json index 778fb7619..bb56252fb 100644 --- a/src/renderer/locales/id/translation.json +++ b/src/renderer/locales/id/translation.json @@ -5805,7 +5805,9 @@ "channelRemoteFor": "Alirkan {{channel}} ke server TAK jarak jauh", "channelTrackersFor": "Perbaikan pelacak dari {{channel}}", "directMessages": "Direct message", - "directMessagesHint": "Biarkan perangkat atak mengirim pesan ke pendamping radio ini. Setiap pendamping radio ini dapat mengirim pesan mendapatkan opsi obrolan di atak, dan pesan ke sana keluar dari radio ini, diawali dengan tanda panggil pengirim. Balasannya masuk ke perangkat atak yang terakhir menulisnya, atau ke setiap perangkat ketika tidak ada. Pendamping tanpa posisi muncul di 0, 0 untuk sehari setelah pesan." + "directMessagesHint": "Biarkan perangkat atak mengirim pesan ke pendamping radio ini. Setiap pendamping radio ini dapat mengirim pesan mendapatkan opsi obrolan di atak, dan pesan ke sana keluar dari radio ini, diawali dengan tanda panggil pengirim. Balasannya masuk ke perangkat atak yang terakhir menulisnya, atau ke setiap perangkat ketika tidak ada. Pendamping tanpa posisi muncul di 0, 0 untuk sehari setelah pesan.", + "messagingTitleReticulum": "Reticulum pengiriman pesan di atak", + "messagingIntroReticulum": "Biarkan perangkat atak mengirimkan pesan kepada LXMF rekan. Seorang rekan dengan posisi dapat dikirimi pesan dari penanda; satu tanpa posisi muncul di 0, 0 setelah mesh-client melakukan percakapan dengannya, sehingga daftar kontak tidak diisi dengan setiap rekan yang didengar." }, "telemetryPanel": { "title": "Telemetri", diff --git a/src/renderer/locales/it/translation.json b/src/renderer/locales/it/translation.json index ec0e5932f..556c52916 100644 --- a/src/renderer/locales/it/translation.json +++ b/src/renderer/locales/it/translation.json @@ -5805,7 +5805,9 @@ "channelRemoteFor": "Trasmetti {{channel}} in streaming sul server TAK remoto", "channelTrackersFor": "Correzioni del tracker da {{channel}}", "directMessages": "Messaggi diretti", - "directMessagesHint": "Lascia che i dispositivi ATAK inviino messaggi ai compagni di questa radio. Ogni compagno di questo messaggio radio can ottiene un'opzione di chat in ATAK e da questa radio esce un messaggio ad esso, preceduto dal nominativo del mittente. Le sue risposte vanno al dispositivo ATAK che ha scritto per ultimo, o a ogni dispositivo quando nessuno ne ha scritto. Un compagno senza posizione appare a 0, 0 per un giorno dopo un messaggio." + "directMessagesHint": "Lascia che i dispositivi ATAK inviino messaggi ai compagni di questa radio. Ogni compagno di questo messaggio radio can ottiene un'opzione di chat in ATAK e da questa radio esce un messaggio ad esso, preceduto dal nominativo del mittente. Le sue risposte vanno al dispositivo ATAK che ha scritto per ultimo, o a ogni dispositivo quando nessuno ne ha scritto. Un compagno senza posizione appare a 0, 0 per un giorno dopo un messaggio.", + "messagingTitleReticulum": "Reticulum messaggi in ATAK", + "messagingIntroReticulum": "Lascia che i dispositivi ATAK inviino un messaggio ai colleghi LXMF. Un collega con una posizione può ricevere messaggi dal suo marcatore; uno senza posizione appare a 0, 0 una volta che mesh-client ha una conversazione con lui, quindi l'elenco dei contatti non viene riempito con tutti i colleghi ascoltati." }, "telemetryPanel": { "title": "Telemetria", diff --git a/src/renderer/locales/ja/translation.json b/src/renderer/locales/ja/translation.json index 89612cb93..dc9a76d03 100644 --- a/src/renderer/locales/ja/translation.json +++ b/src/renderer/locales/ja/translation.json @@ -5805,7 +5805,9 @@ "channelRemoteFor": "リモートのTAKサーバーに{{channel}}をストリーミング", "channelTrackersFor": "{{channel}}からのトラッカーの修正", "directMessages": "ダイレクトメッセージ", - "directMessagesHint": "ATAKデバイスがこのラジオの仲間にメッセージを送るようにしましょう。このラジオCANメッセージの各コンパニオンは、ATAKでチャットオプションを取得し、送信者のコールサインをプレフィックスしたメッセージがこのラジオから送信されます。応答は、最後に書き込まれたATAKデバイス、または書き込まれていないすべてのデバイスに送信されます。メッセージの1日後、位置のないコンパニオンが0、0に表示されます。" + "directMessagesHint": "ATAKデバイスがこのラジオの仲間にメッセージを送るようにしましょう。このラジオCANメッセージの各コンパニオンは、ATAKでチャットオプションを取得し、送信者のコールサインをプレフィックスしたメッセージがこのラジオから送信されます。応答は、最後に書き込まれたATAKデバイス、または書き込まれていないすべてのデバイスに送信されます。メッセージの1日後、位置のないコンパニオンが0、0に表示されます。", + "messagingTitleReticulum": "ATAKでのReticulumメッセージ", + "messagingIntroReticulum": "ATAKデバイスがLXMFピアにメッセージを送信できるようにします。ポジションを持つピアは、そのマーカーからメッセージを送ることができます。ポジションを持たないピアは、mesh-clientがそれと会話をすると0、0に表示されるため、連絡先リストはすべてのピアで満たされるわけではありません。" }, "telemetryPanel": { "title": "テレメトリー", diff --git a/src/renderer/locales/ko/translation.json b/src/renderer/locales/ko/translation.json index f81ed1606..fd9d0f264 100644 --- a/src/renderer/locales/ko/translation.json +++ b/src/renderer/locales/ko/translation.json @@ -5805,7 +5805,9 @@ "channelRemoteFor": "원격 TAK 서버로 {{channel}} 스트리밍", "channelTrackersFor": "{{channel}} 의 트래커 보완 사항", "directMessages": "다이렉트 메시지", - "directMessagesHint": "ATAK 장치가 이 라디오의 동반자에게 메시지를 보냅니다. 이 라디오 캔 메시지의 각 동반자는 ATAK의 채팅 옵션을 받게 되며, 이 라디오에서 발신자의 콜사인이 붙은 메시지가 나옵니다. 응답은 마지막으로 쓴 ATAK 장치 또는 아무도 없는 모든 장치로 전송됩니다. 포지션이 없는 동반자는 메시지 후 하루 동안 0, 0으로 나타납니다." + "directMessagesHint": "ATAK 장치가 이 라디오의 동반자에게 메시지를 보냅니다. 이 라디오 캔 메시지의 각 동반자는 ATAK의 채팅 옵션을 받게 되며, 이 라디오에서 발신자의 콜사인이 붙은 메시지가 나옵니다. 응답은 마지막으로 쓴 ATAK 장치 또는 아무도 없는 모든 장치로 전송됩니다. 포지션이 없는 동반자는 메시지 후 하루 동안 0, 0으로 나타납니다.", + "messagingTitleReticulum": "Reticulum ATAK에서 메시지 보내기", + "messagingIntroReticulum": "ATAK 장치가 LXMF 피어에게 메시지를 보냅니다. 포지션이 있는 피어는 마커에서 메시지를 보낼 수 있습니다. 포지션이 없는 피어는 mesh-client 이 (가) 대화를 나누면 0, 0으로 표시되므로 연락처 목록이 들리는 모든 피어로 채워지지 않습니다." }, "telemetryPanel": { "title": "원격 측정", diff --git a/src/renderer/locales/nl/translation.json b/src/renderer/locales/nl/translation.json index 06039715c..bee2e5fba 100644 --- a/src/renderer/locales/nl/translation.json +++ b/src/renderer/locales/nl/translation.json @@ -5805,7 +5805,9 @@ "channelRemoteFor": "Stream {{channel}} naar de externe TAK -server", "channelTrackersFor": "Trackerfixes van {{channel}}", "directMessages": "Directe berichten", - "directMessagesHint": "Laat ATAK-apparaten berichten sturen naar de metgezellen van deze radio. Elke metgezel van dit radiobericht krijgt een chatoptie in ATAK en er gaat een bericht naar toe van deze radio, voorafgegaan door het roepnaam van de afzender. De antwoorden gaan naar het ATAK-apparaat dat er het laatst naar heeft geschreven, of naar elk apparaat dat er geen heeft. Een metgezel zonder positie verschijnt een dag na een bericht op 0, 0." + "directMessagesHint": "Laat ATAK-apparaten berichten sturen naar de metgezellen van deze radio. Elke metgezel van dit radiobericht krijgt een chatoptie in ATAK en er gaat een bericht naar toe van deze radio, voorafgegaan door het roepnaam van de afzender. De antwoorden gaan naar het ATAK-apparaat dat er het laatst naar heeft geschreven, of naar elk apparaat dat er geen heeft. Een metgezel zonder positie verschijnt een dag na een bericht op 0, 0.", + "messagingTitleReticulum": "Reticulum berichten in ATAK", + "messagingIntroReticulum": "Laat ATAK-apparaten LXMF peers berichten. Een collega met een functie kan worden gecommuniceerd vanuit de markering; een zonder positie verschijnt op 0, 0 zodra mesh-client er een gesprek mee heeft, dus de lijst met contactpersonen wordt niet gevuld met elke collega die wordt gehoord." }, "telemetryPanel": { "title": "Telemetrie", diff --git a/src/renderer/locales/pl/translation.json b/src/renderer/locales/pl/translation.json index 7bb295547..9e6534779 100644 --- a/src/renderer/locales/pl/translation.json +++ b/src/renderer/locales/pl/translation.json @@ -5889,7 +5889,9 @@ "channelRemoteFor": "Przesyłaj strumieniowo {{channel}} na zdalny serwer TAK", "channelTrackersFor": "Poprawki trackera od {{channel}}", "directMessages": "Bezpośrednie wiadomości", - "directMessagesHint": "Niech urządzenia atak wyślą wiadomość do towarzyszy tego radia. Każdy towarzysz tej wiadomości can radiowej otrzymuje opcję czatu w atak, a wiadomość do niego wychodzi z tego radia, poprzedzoną znakiem wywoławczym nadawcy. Jego odpowiedzi trafiają do urządzenia atak, które ostatnio do niego pisało, lub do każdego urządzenia, gdy żadne tego nie zrobiło. Towarzysz bez pozycji pojawia się w 0, 0 przez dzień po wiadomości." + "directMessagesHint": "Niech urządzenia atak wyślą wiadomość do towarzyszy tego radia. Każdy towarzysz tej wiadomości can radiowej otrzymuje opcję czatu w atak, a wiadomość do niego wychodzi z tego radia, poprzedzoną znakiem wywoławczym nadawcy. Jego odpowiedzi trafiają do urządzenia atak, które ostatnio do niego pisało, lub do każdego urządzenia, gdy żadne tego nie zrobiło. Towarzysz bez pozycji pojawia się w 0, 0 przez dzień po wiadomości.", + "messagingTitleReticulum": "Reticulum wiadomości w atak", + "messagingIntroReticulum": "Pozwól, aby urządzenia Atak wysłały wiadomość do współpracowników LXMF. Rówieśnik z pozycją może otrzymywać wiadomości ze swojego znacznika; osoba bez pozycji pojawia się w 0, 0, gdy mesh-client prowadzi z nią rozmowę, więc lista kontaktów nie wypełnia się każdym usłyszanym rówieśnikiem." }, "telemetryPanel": { "title": "Telemetria", diff --git a/src/renderer/locales/pt-BR/translation.json b/src/renderer/locales/pt-BR/translation.json index 037bafedd..8052bd95e 100644 --- a/src/renderer/locales/pt-BR/translation.json +++ b/src/renderer/locales/pt-BR/translation.json @@ -5805,7 +5805,9 @@ "channelRemoteFor": "Transmita {{channel}} para o servidor TAK remoto", "channelTrackersFor": "Correções do rastreador de {{channel}}", "directMessages": "Mensagens diretas", - "directMessagesHint": "Deixe os dispositivos ATAK enviarem mensagens aos companheiros deste rádio. Cada acompanhante desta mensagem de rádio can recebe uma opção de bate-papo no ATAK, e uma mensagem para ela sai deste rádio, prefixada com o indicativo do remetente. Suas respostas vão para o dispositivo ATAK que escreveu pela última vez nele, ou para todos os dispositivos quando nenhum o fez. Um acompanhante sem posição aparece em 0, 0 por um dia após uma mensagem." + "directMessagesHint": "Deixe os dispositivos ATAK enviarem mensagens aos companheiros deste rádio. Cada acompanhante desta mensagem de rádio can recebe uma opção de bate-papo no ATAK, e uma mensagem para ela sai deste rádio, prefixada com o indicativo do remetente. Suas respostas vão para o dispositivo ATAK que escreveu pela última vez nele, ou para todos os dispositivos quando nenhum o fez. Um acompanhante sem posição aparece em 0, 0 por um dia após uma mensagem.", + "messagingTitleReticulum": "Reticulum mensagens no ATAK", + "messagingIntroReticulum": "Deixe os dispositivos ATAK enviarem mensagens para LXMF colegas. Um par com uma posição pode receber mensagens de seu marcador; um sem posição aparece em 0, 0 uma vez que mesh-client tem uma conversa com ele, então a lista de contatos não é preenchida com todos os pares ouvidos." }, "telemetryPanel": { "title": "Telemetria", diff --git a/src/renderer/locales/ru/translation.json b/src/renderer/locales/ru/translation.json index 1a875575d..1bf597b9a 100644 --- a/src/renderer/locales/ru/translation.json +++ b/src/renderer/locales/ru/translation.json @@ -5889,7 +5889,9 @@ "channelRemoteFor": "Поток {{channel}} на удаленный сервер TAK", "channelTrackersFor": "Исправления в трекере от {{channel}}", "directMessages": "Прямые сообщения", - "directMessagesHint": "Позвольте устройствам ATAK отправлять сообщения спутникам этого радио. Каждому собеседнику это радиосообщение can получает опцию чата в ATAK, и сообщение ему отправляется с этого радио, с префиксом позывного отправителя. Его ответы отправляются на устройство ATAK, которое в последний раз писало на него, или на каждое устройство, когда его нет. Компаньон без позиции появляется на 0, 0 в течение дня после сообщения." + "directMessagesHint": "Позвольте устройствам ATAK отправлять сообщения спутникам этого радио. Каждому собеседнику это радиосообщение can получает опцию чата в ATAK, и сообщение ему отправляется с этого радио, с префиксом позывного отправителя. Его ответы отправляются на устройство ATAK, которое в последний раз писало на него, или на каждое устройство, когда его нет. Компаньон без позиции появляется на 0, 0 в течение дня после сообщения.", + "messagingTitleReticulum": "Reticulum обмен сообщениями в ATAK", + "messagingIntroReticulum": "Пусть устройства ATAK сообщают LXMF одноранговым узлам. Одноранговому узлу с позицией можно отправить сообщение со своего маркера; один без позиции появляется в 0, 0, когда mesh-client разговаривает с ним, поэтому список контактов не заполняется каждым услышанным узлом." }, "telemetryPanel": { "title": "Телеметрия", diff --git a/src/renderer/locales/tr/translation.json b/src/renderer/locales/tr/translation.json index 7d17b86fd..e31ebc9c4 100644 --- a/src/renderer/locales/tr/translation.json +++ b/src/renderer/locales/tr/translation.json @@ -5805,7 +5805,9 @@ "channelRemoteFor": "{{channel}} 'ı uzaktaki TAK sunucusuna aktarın", "channelTrackersFor": "{{channel}} 'dan itibaren izleyici düzeltmeleri", "directMessages": "Direkt Mesajlar", - "directMessagesHint": "Atak cihazları bu radyonun arkadaşlarına mesaj göndersin. Bu telsiz mesajının her arkadaşı atak'ta bir sohbet seçeneği alır ve bu telsizden gönderenin çağrı işareti ile başlayan bir mesaj çıkar. Yanıtları, en son kendisine yazan atak cihazına veya hiçbiri yazmadığında her cihaza gider. Pozisyonu olmayan bir refakatçi, bir mesajdan sonra bir gün boyunca 0, 0 'da görünür." + "directMessagesHint": "Atak cihazları bu radyonun arkadaşlarına mesaj göndersin. Bu telsiz mesajının her arkadaşı atak'ta bir sohbet seçeneği alır ve bu telsizden gönderenin çağrı işareti ile başlayan bir mesaj çıkar. Yanıtları, en son kendisine yazan atak cihazına veya hiçbiri yazmadığında her cihaza gider. Pozisyonu olmayan bir refakatçi, bir mesajdan sonra bir gün boyunca 0, 0 'da görünür.", + "messagingTitleReticulum": "Atak'ta Reticulum mesajlaşma", + "messagingIntroReticulum": "Atak cihazlarının LXMF akranlarına mesaj göndermesine izin verin. Bir pozisyona sahip bir akran, işaretçisinden mesajlaşabilir; mesh-client onunla bir konuşma yaptıktan sonra 0, 0 konumunda olmayan bir akran görünür, bu nedenle kişi listesi duyulan her akranla dolu değildir." }, "telemetryPanel": { "title": "Telemetri", diff --git a/src/renderer/locales/uk/translation.json b/src/renderer/locales/uk/translation.json index 176405ae1..1f409b31a 100644 --- a/src/renderer/locales/uk/translation.json +++ b/src/renderer/locales/uk/translation.json @@ -5889,7 +5889,9 @@ "channelRemoteFor": "Потік {{channel}} на віддалений сервер TAK", "channelTrackersFor": "Виправлення відстеження від {{channel}}", "directMessages": "Приватні повідомлення", - "directMessagesHint": "Дозвольте пристроям ATAK надсилати повідомлення компаньйонам цього радіо. Кожен супутник цього радіоповідомлення can отримує опцію чату в ATAK, і повідомлення до нього виходить з цього радіо з префіксом позивного відправника. Його відповіді надходять на пристрій ATAK, який востаннє писав на нього, або на кожен пристрій, коли його немає. Компаньйон без позиції з'являється на 0, 0 протягом дня після повідомлення." + "directMessagesHint": "Дозвольте пристроям ATAK надсилати повідомлення компаньйонам цього радіо. Кожен супутник цього радіоповідомлення can отримує опцію чату в ATAK, і повідомлення до нього виходить з цього радіо з префіксом позивного відправника. Його відповіді надходять на пристрій ATAK, який востаннє писав на нього, або на кожен пристрій, коли його немає. Компаньйон без позиції з'являється на 0, 0 протягом дня після повідомлення.", + "messagingTitleReticulum": "Reticulum обмін повідомленнями в ATAK", + "messagingIntroReticulum": "Дозвольте пристроям ATAK повідомляти вузли LXMF. Одноранговому вузлу з позицією можна надіслати повідомлення з його маркера; один без позиції з'являється на 0, 0, коли mesh-client веде з ним розмову, тому список контактів не заповнюється кожним почутим вузлом." }, "telemetryPanel": { "title": "Телеметрія", diff --git a/src/renderer/locales/zh/translation.json b/src/renderer/locales/zh/translation.json index 30ed1c4dc..78a7c974d 100644 --- a/src/renderer/locales/zh/translation.json +++ b/src/renderer/locales/zh/translation.json @@ -5805,7 +5805,9 @@ "channelRemoteFor": "将{{channel}}流式传输到远程TAK服务器", "channelTrackersFor": "来自{{channel}}的跟踪器修复", "directMessages": "实时消息", - "directMessagesHint": "让ATAK设备向此收音机的同伴发送消息。此收音机CAN消息的每个伴侣都会在ATAK中获得聊天选项,并从此收音机发出消息,前缀为发件人的呼号。它的回复会发送到上次写入它的ATAK设备,或者在没有设备时发送到每个设备。没有位置的伴侣在消息后一天出现在0 , 0。" + "directMessagesHint": "让ATAK设备向此收音机的同伴发送消息。此收音机CAN消息的每个伴侣都会在ATAK中获得聊天选项,并从此收音机发出消息,前缀为发件人的呼号。它的回复会发送到上次写入它的ATAK设备,或者在没有设备时发送到每个设备。没有位置的伴侣在消息后一天出现在0 , 0。", + "messagingTitleReticulum": "ATAK中的Reticulum消息", + "messagingIntroReticulum": "让ATAK设备向LXMF个同行发送消息。可以从其标记向具有位置的同行发送消息;一旦mesh-client与之进行对话,没有位置的同行将显示为0、0 ,因此联系人列表中不会填写每个听到的同行。" }, "telemetryPanel": { "title": "遥测", diff --git a/src/renderer/vitest.electronApiMock.ts b/src/renderer/vitest.electronApiMock.ts index 147ec6eec..cfd80e85c 100644 --- a/src/renderer/vitest.electronApiMock.ts +++ b/src/renderer/vitest.electronApiMock.ts @@ -511,6 +511,7 @@ export function createElectronAPIMock(): ElectronAPI { pushDirectMessage: vi.fn().mockResolvedValue(undefined), setDirectMessaging: vi.fn().mockResolvedValue(undefined), dmStatus: vi.fn().mockResolvedValue(undefined), + setDirectPeers: vi.fn().mockResolvedValue(undefined), onInboundChat: vi.fn().mockReturnValue(() => {}), }, bleCoexistence: { diff --git a/src/shared/electron-api.types.ts b/src/shared/electron-api.types.ts index ccfdac516..2086f0412 100644 --- a/src/shared/electron-api.types.ts +++ b/src/shared/electron-api.types.ts @@ -78,6 +78,7 @@ import type { TAKContact, TAKContactsUpdate, TAKDirectMessage, + TakDirectPeer, TakDmProtocol, TakDmStatusUpdate, TAKEnrollmentRequest, @@ -1474,6 +1475,11 @@ export interface ElectronAPI { * dropped with no sink. */ dmStatus: (update: TakDmStatusUpdate) => Promise; + /** + * Set the peers this app has a direct conversation with, which TAK clients may message even + * with no marker; ignored while no sink has started. + */ + setDirectPeers: (peers: TakDirectPeer[]) => Promise; /** How relayed nodes are styled: user unit filters over each node's advertised role. */ getStyleSettings: () => Promise; /** Validate, save, and apply style settings; resolves with what was saved. */ diff --git a/src/shared/tak-types.ts b/src/shared/tak-types.ts index 10ff1cc29..46cf2fe59 100644 --- a/src/shared/tak-types.ts +++ b/src/shared/tak-types.ts @@ -268,8 +268,8 @@ export interface TAKChannelChatMessage { longitude?: number; } -/** Radios whose companions TAK clients may message directly. */ -export const TAK_DM_PROTOCOLS = ['meshcore', 'meshtastic'] as const; +/** Radios whose companions (on Reticulum, LXMF peers) TAK clients may message directly. */ +export const TAK_DM_PROTOCOLS = ['meshcore', 'meshtastic', 'reticulum'] as const; export type TakDmProtocol = (typeof TAK_DM_PROTOCOLS)[number]; export function isTakDmProtocol(value: unknown): value is TakDmProtocol { @@ -311,6 +311,20 @@ export interface TakDmStatusUpdate { outcome: TakDmOutcome; } +/** + * A peer this app already has a direct conversation with, which TAK clients may message even + * with no marker on the map: main gives it a contact at 0, 0 while it has none. + */ +export interface TakDirectPeer { + protocol: TakDmProtocol; + nodeId: number; + /** The name it is shown by when it has no marker. */ + callsign: string; +} + +/** Most conversation peers the renderer hands main; the most recent come first. */ +export const TAK_DIRECT_PEERS_MAX = 64; + /** A direct message heard from a companion, for TAK clients. */ export interface TAKDirectMessage { protocol: TakDmProtocol;