diff --git a/packages/sdk/src/features/chat/ChatClient.autoFavorite.test.ts b/packages/sdk/src/features/chat/ChatClient.autoFavorite.test.ts new file mode 100644 index 000000000..4a90be155 --- /dev/null +++ b/packages/sdk/src/features/chat/ChatClient.autoFavorite.test.ts @@ -0,0 +1,160 @@ +import { create } from "@bufbuild/protobuf"; +import * as Protobuf from "@meshtastic/protobufs"; +import { describe, expect, it, vi } from "vitest"; +import { MeshClient } from "../../core/client/MeshClient.ts"; +import { createFakeTransport } from "../../core/testing/createFakeTransport.ts"; +import { ChannelNumber } from "../../core/types.ts"; +import { Result } from "better-result"; + +const MY_NODE = 0xdeadbeef; + +function setMyNode(client: MeshClient): void { + client.events.onMyNodeInfo.dispatch( + create(Protobuf.Mesh.MyNodeInfoSchema, { myNodeNum: MY_NODE }), + ); +} + +function setDeviceRole( + client: MeshClient, + role: Protobuf.Config.Config_DeviceConfig_Role, +): void { + client.events.onConfigPacket.dispatch( + create(Protobuf.Config.ConfigSchema, { + payloadVariant: { + case: "device", + value: create(Protobuf.Config.Config_DeviceConfigSchema, { role }), + }, + }), + ); +} + +function dispatchDirectMessage( + client: MeshClient, + opts: { from: number; to: number; ms: number }, +): void { + client.events.onMessagePacket.dispatch({ + id: opts.ms, + from: opts.from, + to: opts.to, + channel: ChannelNumber.Primary, + type: "direct", + rxTime: new Date(opts.ms), + data: "hello", + } as never); +} + +describe("ChatClient auto-favorite on direct message", () => { + it("auto-favorites the peer on inbound direct message", () => { + const { transport } = createFakeTransport(); + const client = new MeshClient({ transport }); + setMyNode(client); + setDeviceRole(client, Protobuf.Config.Config_DeviceConfig_Role.CLIENT); + + const favoriteSpy = vi + .spyOn(client.nodes, "favorite") + .mockResolvedValue(Result.ok(1)); + + dispatchDirectMessage(client, { from: 100, to: MY_NODE, ms: 1000 }); + + expect(favoriteSpy).toHaveBeenCalledWith(100); + }); + + it("skips auto-favorite when peer is already favorited", () => { + const { transport } = createFakeTransport(); + const client = new MeshClient({ transport }); + setMyNode(client); + setDeviceRole(client, Protobuf.Config.Config_DeviceConfig_Role.CLIENT); + + client.events.onNodeInfoPacket.dispatch( + create(Protobuf.Mesh.NodeInfoSchema, { + num: 100, + isFavorite: true, + }), + ); + + const favoriteSpy = vi + .spyOn(client.nodes, "favorite") + .mockResolvedValue(Result.ok(1)); + + dispatchDirectMessage(client, { from: 100, to: MY_NODE, ms: 1000 }); + + expect(favoriteSpy).not.toHaveBeenCalled(); + }); + + it("does not auto-favorite when local role is CLIENT_BASE", () => { + const { transport } = createFakeTransport(); + const client = new MeshClient({ transport }); + setMyNode(client); + setDeviceRole(client, Protobuf.Config.Config_DeviceConfig_Role.CLIENT_BASE); + + const favoriteSpy = vi + .spyOn(client.nodes, "favorite") + .mockResolvedValue(Result.ok(1)); + + dispatchDirectMessage(client, { from: 200, to: MY_NODE, ms: 1000 }); + + expect(favoriteSpy).not.toHaveBeenCalled(); + }); + + it("calls favorite only once for repeated messages from the same peer", () => { + const { transport } = createFakeTransport(); + const client = new MeshClient({ transport }); + setMyNode(client); + setDeviceRole(client, Protobuf.Config.Config_DeviceConfig_Role.CLIENT); + + const favoriteSpy = vi + .spyOn(client.nodes, "favorite") + .mockResolvedValue(Result.ok(1)); + + dispatchDirectMessage(client, { from: 100, to: MY_NODE, ms: 1000 }); + dispatchDirectMessage(client, { from: 100, to: MY_NODE, ms: 2000 }); + dispatchDirectMessage(client, { from: 100, to: MY_NODE, ms: 3000 }); + + expect(favoriteSpy).toHaveBeenCalledTimes(1); + }); + + it("re-attempts favorite after the in-flight request settles", async () => { + const { transport } = createFakeTransport(); + const client = new MeshClient({ transport }); + setMyNode(client); + setDeviceRole(client, Protobuf.Config.Config_DeviceConfig_Role.CLIENT); + + const favoriteSpy = vi + .spyOn(client.nodes, "favorite") + .mockResolvedValue(Result.ok(1)); + + dispatchDirectMessage(client, { from: 100, to: MY_NODE, ms: 1000 }); + expect(favoriteSpy).toHaveBeenCalledTimes(1); + + await vi.waitFor(() => { + expect(favoriteSpy).toHaveBeenCalledTimes(1); + }); + await new Promise((r) => setTimeout(r, 0)); + + dispatchDirectMessage(client, { from: 100, to: MY_NODE, ms: 2000 }); + expect(favoriteSpy).toHaveBeenCalledTimes(2); + }); + + it("auto-favorites the peer on outbound direct message via send()", async () => { + const { transport } = createFakeTransport(); + const client = new MeshClient({ transport }); + setMyNode(client); + setDeviceRole(client, Protobuf.Config.Config_DeviceConfig_Role.CLIENT); + + const favoriteSpy = vi + .spyOn(client.nodes, "favorite") + .mockResolvedValue(Result.ok(1)); + + const flush = setInterval(() => { + for (const item of client.queue.getState()) + client.queue.processAck(item.id); + }, 5); + try { + await client.chat.send({ text: "hey", destination: 300 }); + } finally { + clearInterval(flush); + } + + expect(favoriteSpy).toHaveBeenCalledWith(300); + }); +}); diff --git a/packages/sdk/src/features/chat/ChatClient.ts b/packages/sdk/src/features/chat/ChatClient.ts index f2b663a74..0f07c0ec8 100644 --- a/packages/sdk/src/features/chat/ChatClient.ts +++ b/packages/sdk/src/features/chat/ChatClient.ts @@ -142,6 +142,10 @@ export class ChatClient { if (packet.from !== client.myNodeNum) { this.bumpUnread(key); } + + if (conv.kind === "direct") { + this.autoFavorite(conv.peer); + } }); client.events.onRoutingPacket.subscribe((packet) => { @@ -309,6 +313,10 @@ export class ChatClient { ? { kind: "direct", peer: input.destination } : { kind: "channel", channel: input.channel ?? 0 }; + if (conv.kind === "direct") { + this.autoFavorite(conv.peer); + } + // Optimistic-append BEFORE awaiting `sendText`. The send pipeline // resolves only after `queue.processAck(id)` fires (firmware ack — // can be 1–3 s on LoRa) and we don't want the UI to wait that long @@ -433,6 +441,23 @@ export class ChatClient { this.unreadByKey.value = next; } + private readonly favoriteRequested = new Set(); + + private autoFavorite(peer: number): void { + if (this.favoriteRequested.has(peer)) return; + const node = this.client.nodes.byNum(peer); + if (node?.isFavorite) return; + const role = this.client.config.radio.value.device?.role; + if (role === Protobuf.Config.Config_DeviceConfig_Role.CLIENT_BASE) { + return; + } + this.favoriteRequested.add(peer); + void this.client.nodes + .favorite(peer) + .finally(() => this.favoriteRequested.delete(peer)) + .catch(() => {}); + } + private markRead(key: ConversationKey): void { const k = this.keyFor(key); const current = this.unreadByKey.peek();