Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 160 additions & 0 deletions packages/sdk/src/features/chat/ChatClient.autoFavorite.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
25 changes: 25 additions & 0 deletions packages/sdk/src/features/chat/ChatClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,10 @@ export class ChatClient {
if (packet.from !== client.myNodeNum) {
this.bumpUnread(key);
}

if (conv.kind === "direct") {
this.autoFavorite(conv.peer);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
});

client.events.onRoutingPacket.subscribe((packet) => {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -433,6 +441,23 @@ export class ChatClient {
this.unreadByKey.value = next;
}

private readonly favoriteRequested = new Set<number>();

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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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();
Expand Down