mirror of
https://github.com/meshtastic/web.git
synced 2026-07-31 06:56:28 -04:00
feat(sdk,sdk-react,web): unread counts on the SDK ChatClient
ChatClient gains a `chat.unread` namespace mirroring `chat.drafts`: - byKey: ReadonlySignal<Map<conversation-key-string, number>> - total: ReadonlySignal<number> - count(key): ReadonlySignal<number> - markRead(key): void Increments happen in the existing onMessagePacket subscriber when packet.from !== client.myNodeNum (so the outbound echo of our own send doesn't bump anything). Uses the same ConversationKey shape the rest of the chat slice already keys by, so direct messages keyed by peer and broadcasts by channel can never collide. Counts are in-memory only — persistence will come later if needed (would require tracking a lastReadAt timestamp per conversation in the message repository and computing unread on hydrate). sdk-react: new useTotalUnread / useUnreadCount(key) / useUnreadByKey hooks. All fall back to safe empty values when no client is active. Web migration: - Sidebar reads useTotalUnread instead of summing deviceStore.unreadCounts. - MessagesPage: per-channel SidebarButton counts read from useUnreadByKey()'s `channel:N` keys; per-direct-message counts from `direct:N` keys. Click handlers call meshClient.chat.unread.markRead with the typed ConversationKey instead of resetUnread. - subscriptions.ts: drops onMessagePacket -> incrementUnread mirror (SDK now owns it). myNodeNum local goes away too — nothing else consumed it. - deviceStore: removes unreadCounts field + incrementUnread / resetUnread / getUnreadCount / getAllUnreadCount methods. The mock + test block in deviceStore.test.ts also drop their unread coverage. - deviceStore.mock.ts: also strips the dead changeRegistry / setChange / hasConfigChange / etc. methods that survived from the earlier changeRegistry deletion. Tests: SDK 61 -> 65 (+4 ChatClient.unread.test), sdk-react 8, web 236. Build clean.
This commit is contained in:
@@ -28,6 +28,7 @@ export { useNode } from "./src/hooks/useNode.ts";
|
||||
export { useChannels, useChannel } from "./src/hooks/useChannels.ts";
|
||||
export { useConfig, useModuleConfig } from "./src/hooks/useConfig.ts";
|
||||
export { useConfigEditor } from "./src/hooks/useConfigEditor.ts";
|
||||
export { useTotalUnread, useUnreadByKey, useUnreadCount } from "./src/hooks/useUnread.ts";
|
||||
export { useTelemetry } from "./src/hooks/useTelemetry.ts";
|
||||
export type { UseTelemetryResult } from "./src/hooks/useTelemetry.ts";
|
||||
export { usePosition } from "./src/hooks/usePosition.ts";
|
||||
|
||||
41
packages/sdk-react/src/hooks/useUnread.ts
Normal file
41
packages/sdk-react/src/hooks/useUnread.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { ConversationKey } from "@meshtastic/sdk";
|
||||
import { useActiveClient } from "../adapters/useActiveClient.ts";
|
||||
import { useSignal } from "../adapters/useSignal.ts";
|
||||
|
||||
const EMPTY_NUMBER_SIGNAL = {
|
||||
value: 0,
|
||||
peek: () => 0,
|
||||
subscribe: () => () => {},
|
||||
} as const;
|
||||
|
||||
const EMPTY_MAP_SIGNAL = {
|
||||
value: new Map<string, number>() as ReadonlyMap<string, number>,
|
||||
peek: () => new Map<string, number>() as ReadonlyMap<string, number>,
|
||||
subscribe: () => () => {},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Total unread count across every conversation on the active client.
|
||||
* Returns 0 when no client is active.
|
||||
*/
|
||||
export function useTotalUnread(): number {
|
||||
const client = useActiveClient();
|
||||
return useSignal(client?.chat.unread.total ?? EMPTY_NUMBER_SIGNAL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unread count for a single conversation. Returns 0 when no client is active.
|
||||
*/
|
||||
export function useUnreadCount(key: ConversationKey): number {
|
||||
const client = useActiveClient();
|
||||
return useSignal(client?.chat.unread.count(key) ?? EMPTY_NUMBER_SIGNAL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whole unread map (`<conversation-key-string, count>`). Useful for sidebar
|
||||
* badges that walk every saved conversation.
|
||||
*/
|
||||
export function useUnreadByKey(): ReadonlyMap<string, number> {
|
||||
const client = useActiveClient();
|
||||
return useSignal(client?.chat.unread.byKey ?? EMPTY_MAP_SIGNAL);
|
||||
}
|
||||
@@ -56,6 +56,7 @@ export { ChatClient } from "./src/features/chat/index.ts";
|
||||
export type {
|
||||
ChatClientOptions,
|
||||
ChatDrafts,
|
||||
ChatUnread,
|
||||
ConversationKey,
|
||||
DraftRepository,
|
||||
Message,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { signal } from "@preact/signals-core";
|
||||
import type { ResultType } from "better-result";
|
||||
import type { MeshClient } from "../../core/client/MeshClient.ts";
|
||||
import { Constants } from "../../core/constants/index.ts";
|
||||
import type { ReadonlySignal } from "../../core/signals/createStore.ts";
|
||||
import { type ReadonlySignal, toReadonly } from "../../core/signals/createStore.ts";
|
||||
import type { ChannelNumber } from "../../core/types.ts";
|
||||
import type { DraftRepository } from "./domain/DraftRepository.ts";
|
||||
import type { Message } from "./domain/Message.ts";
|
||||
@@ -36,6 +37,23 @@ export interface ChatDrafts {
|
||||
clear(key: ConversationKey): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unread-counts namespace. Counts inbound messages per conversation; resets
|
||||
* on `markRead(key)`. Counts are in-memory only (no persistence) — the
|
||||
* recipe for persistence later is to track a `lastReadAt` timestamp per
|
||||
* conversation in the message repository and compute unread on hydrate.
|
||||
*/
|
||||
export interface ChatUnread {
|
||||
/** Map of conversation-key string -> unread count. */
|
||||
readonly byKey: ReadonlySignal<ReadonlyMap<string, number>>;
|
||||
/** Sum of all unread counts across conversations. */
|
||||
readonly total: ReadonlySignal<number>;
|
||||
/** Unread count for a single conversation. */
|
||||
count(key: ConversationKey): ReadonlySignal<number>;
|
||||
/** Zero out the unread count for a conversation. */
|
||||
markRead(key: ConversationKey): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat slice facade. Exposes message buckets keyed by channel or peer, drafts
|
||||
* keyed the same way, and the `send` command for outbound text. Optional
|
||||
@@ -51,8 +69,10 @@ export class ChatClient {
|
||||
private readonly initialLoadLimit: number;
|
||||
private readonly hydrated = new Set<string>();
|
||||
private readonly draftsHydrated = new Set<string>();
|
||||
private readonly unreadByKey = signal<ReadonlyMap<string, number>>(new Map());
|
||||
|
||||
public readonly drafts: ChatDrafts;
|
||||
public readonly unread: ChatUnread;
|
||||
|
||||
constructor(client: MeshClient, options: ChatClientOptions = {}) {
|
||||
this.client = client;
|
||||
@@ -69,6 +89,13 @@ export class ChatClient {
|
||||
clear: (key) => this.setDraft(key, ""),
|
||||
};
|
||||
|
||||
this.unread = {
|
||||
byKey: toReadonly(this.unreadByKey),
|
||||
total: this.deriveTotalUnread(),
|
||||
count: (key) => this.deriveUnreadCount(key),
|
||||
markRead: (key) => this.markRead(key),
|
||||
};
|
||||
|
||||
client.events.onMessagePacket.subscribe((packet) => {
|
||||
const message = MessageMapper.fromPacket(packet);
|
||||
const conv: ConversationKey =
|
||||
@@ -78,6 +105,12 @@ export class ChatClient {
|
||||
const key = this.keyFor(conv);
|
||||
this.store.append(key, message);
|
||||
void this.persistAppend(message);
|
||||
|
||||
// Increment unread count when the message is inbound (not from us). The
|
||||
// outbound echo of our own send doesn't count as unread.
|
||||
if (packet.from !== client.myNodeNum) {
|
||||
this.bumpUnread(key);
|
||||
}
|
||||
});
|
||||
|
||||
client.events.onRoutingPacket.subscribe((packet) => {
|
||||
@@ -200,4 +233,70 @@ export class ChatClient {
|
||||
? this.store.channelKey(conv.channel)
|
||||
: this.store.directKey(conv.peer);
|
||||
}
|
||||
|
||||
private bumpUnread(key: string): void {
|
||||
const next = new Map(this.unreadByKey.peek());
|
||||
next.set(key, (next.get(key) ?? 0) + 1);
|
||||
this.unreadByKey.value = next;
|
||||
}
|
||||
|
||||
private markRead(key: ConversationKey): void {
|
||||
const k = this.keyFor(key);
|
||||
const current = this.unreadByKey.peek();
|
||||
if ((current.get(k) ?? 0) === 0) return;
|
||||
const next = new Map(current);
|
||||
next.delete(k);
|
||||
this.unreadByKey.value = next;
|
||||
}
|
||||
|
||||
private deriveTotalUnread(): ReadonlySignal<number> {
|
||||
const src = this.unreadByKey;
|
||||
return {
|
||||
get value() {
|
||||
let total = 0;
|
||||
for (const c of src.value.values()) total += c;
|
||||
return total;
|
||||
},
|
||||
peek() {
|
||||
let total = 0;
|
||||
for (const c of src.peek().values()) total += c;
|
||||
return total;
|
||||
},
|
||||
subscribe(listener) {
|
||||
let first = true;
|
||||
return src.subscribe((m) => {
|
||||
if (first) {
|
||||
first = false;
|
||||
return;
|
||||
}
|
||||
let total = 0;
|
||||
for (const c of m.values()) total += c;
|
||||
listener(total);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private deriveUnreadCount(key: ConversationKey): ReadonlySignal<number> {
|
||||
const k = this.keyFor(key);
|
||||
const src = this.unreadByKey;
|
||||
return {
|
||||
get value() {
|
||||
return src.value.get(k) ?? 0;
|
||||
},
|
||||
peek() {
|
||||
return src.peek().get(k) ?? 0;
|
||||
},
|
||||
subscribe(listener) {
|
||||
let first = true;
|
||||
return src.subscribe((m) => {
|
||||
if (first) {
|
||||
first = false;
|
||||
return;
|
||||
}
|
||||
listener(m.get(k) ?? 0);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
85
packages/sdk/src/features/chat/ChatClient.unread.test.ts
Normal file
85
packages/sdk/src/features/chat/ChatClient.unread.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { create } from "@bufbuild/protobuf";
|
||||
import * as Protobuf from "@meshtastic/protobufs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { MeshClient } from "../../core/client/MeshClient.ts";
|
||||
import { createFakeTransport } from "../../core/testing/createFakeTransport.ts";
|
||||
import { ChannelNumber } from "../../core/types.ts";
|
||||
|
||||
const MY_NODE = 0xdeadbeef;
|
||||
|
||||
function dispatchInbound(
|
||||
client: MeshClient,
|
||||
opts: {
|
||||
from: number;
|
||||
to?: number;
|
||||
channel?: ChannelNumber;
|
||||
type?: "direct" | "broadcast";
|
||||
ms: number;
|
||||
text?: string;
|
||||
},
|
||||
): void {
|
||||
client.events.onMessagePacket.dispatch({
|
||||
id: opts.ms,
|
||||
from: opts.from,
|
||||
to: opts.to ?? 0,
|
||||
channel: opts.channel ?? ChannelNumber.Primary,
|
||||
type: opts.type ?? "broadcast",
|
||||
rxTime: new Date(opts.ms),
|
||||
data: opts.text ?? "hi",
|
||||
} as never);
|
||||
}
|
||||
|
||||
function setMyNode(client: MeshClient): void {
|
||||
client.events.onMyNodeInfo.dispatch(
|
||||
create(Protobuf.Mesh.MyNodeInfoSchema, { myNodeNum: MY_NODE }),
|
||||
);
|
||||
}
|
||||
|
||||
describe("ChatClient unread counts", () => {
|
||||
it("starts at zero for every conversation", () => {
|
||||
const { transport } = createFakeTransport();
|
||||
const client = new MeshClient({ transport });
|
||||
expect(client.chat.unread.total.value).toBe(0);
|
||||
expect(
|
||||
client.chat.unread.count({ kind: "channel", channel: ChannelNumber.Primary }).value,
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("increments on inbound broadcast and decrements on markRead", () => {
|
||||
const { transport } = createFakeTransport();
|
||||
const client = new MeshClient({ transport });
|
||||
setMyNode(client);
|
||||
|
||||
dispatchInbound(client, { from: 100, ms: 1000 });
|
||||
dispatchInbound(client, { from: 200, ms: 2000 });
|
||||
|
||||
expect(
|
||||
client.chat.unread.count({ kind: "channel", channel: ChannelNumber.Primary }).value,
|
||||
).toBe(2);
|
||||
expect(client.chat.unread.total.value).toBe(2);
|
||||
|
||||
client.chat.unread.markRead({ kind: "channel", channel: ChannelNumber.Primary });
|
||||
expect(client.chat.unread.total.value).toBe(0);
|
||||
});
|
||||
|
||||
it("does not increment for outbound echoes (from === myNodeNum)", () => {
|
||||
const { transport } = createFakeTransport();
|
||||
const client = new MeshClient({ transport });
|
||||
setMyNode(client);
|
||||
|
||||
dispatchInbound(client, { from: MY_NODE, ms: 1000 });
|
||||
expect(client.chat.unread.total.value).toBe(0);
|
||||
});
|
||||
|
||||
it("keys direct messages by peer, not channel", () => {
|
||||
const { transport } = createFakeTransport();
|
||||
const client = new MeshClient({ transport });
|
||||
setMyNode(client);
|
||||
|
||||
dispatchInbound(client, { from: 50, to: MY_NODE, type: "direct", ms: 1000 });
|
||||
expect(client.chat.unread.count({ kind: "direct", peer: 50 }).value).toBe(1);
|
||||
expect(
|
||||
client.chat.unread.count({ kind: "channel", channel: ChannelNumber.Primary }).value,
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
export { ChatClient } from "./ChatClient.ts";
|
||||
export type { ChatClientOptions, ChatDrafts } from "./ChatClient.ts";
|
||||
export type { ChatClientOptions, ChatDrafts, ChatUnread } from "./ChatClient.ts";
|
||||
export type { DraftRepository } from "./domain/DraftRepository.ts";
|
||||
export { InMemoryDraftRepository } from "./infrastructure/repositories/InMemoryDraftRepository.ts";
|
||||
export type { Message } from "./domain/Message.ts";
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
useSidebar,
|
||||
} from "@core/stores";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import { useTotalUnread } from "@meshtastic/sdk-react";
|
||||
import { useLocation, useNavigate } from "@tanstack/react-router";
|
||||
import {
|
||||
CircleChevronLeft,
|
||||
@@ -67,7 +68,8 @@ const CollapseToggleButton = () => {
|
||||
};
|
||||
|
||||
export const Sidebar = ({ children }: SidebarProps) => {
|
||||
const { metadata, unreadCounts, setDialogOpen } = useDevice();
|
||||
const { metadata, setDialogOpen } = useDevice();
|
||||
const numUnread = useTotalUnread();
|
||||
const allNodes = useNodesAsProto();
|
||||
const { setCommandPaletteOpen } = useAppStore();
|
||||
const myNode = useMyNodeAsProto();
|
||||
@@ -90,8 +92,6 @@ export const Sidebar = ({ children }: SidebarProps) => {
|
||||
|
||||
const myMetadata = metadata.get(0);
|
||||
|
||||
const numUnread = [...unreadCounts.values()].reduce((sum, v) => sum + v, 0);
|
||||
|
||||
const [displayedNodeCount, setDisplayedNodeCount] = useState(() =>
|
||||
Math.max(getNodesLength() - 1, 0),
|
||||
);
|
||||
|
||||
@@ -19,7 +19,6 @@ export const mockDeviceStore: Device = {
|
||||
channels: new Map(),
|
||||
config: {} as Protobuf.LocalOnly.LocalConfig,
|
||||
moduleConfig: {} as Protobuf.LocalOnly.LocalModuleConfig,
|
||||
changeRegistry: { changes: new Map() },
|
||||
hardware: {} as Protobuf.Mesh.MyNodeInfo,
|
||||
metadata: new Map(),
|
||||
traceroutes: new Map(),
|
||||
@@ -28,7 +27,6 @@ export const mockDeviceStore: Device = {
|
||||
waypoints: [],
|
||||
pendingSettingsChanges: false,
|
||||
messageDraft: "",
|
||||
unreadCounts: new Map(),
|
||||
dialog: {
|
||||
import: false,
|
||||
QR: false,
|
||||
@@ -69,31 +67,10 @@ export const mockDeviceStore: Device = {
|
||||
setDialogOpen: vi.fn(),
|
||||
getDialogOpen: vi.fn().mockReturnValue(false),
|
||||
setMessageDraft: vi.fn(),
|
||||
incrementUnread: vi.fn(),
|
||||
resetUnread: vi.fn(),
|
||||
sendAdminMessage: vi.fn(),
|
||||
addClientNotification: vi.fn(),
|
||||
removeClientNotification: vi.fn(),
|
||||
getClientNotification: vi.fn(),
|
||||
getAllUnreadCount: vi.fn().mockReturnValue(0),
|
||||
getUnreadCount: vi.fn().mockReturnValue(0),
|
||||
getNeighborInfo: vi.fn(),
|
||||
addNeighborInfo: vi.fn(),
|
||||
|
||||
// New unified change tracking methods
|
||||
setChange: vi.fn(),
|
||||
removeChange: vi.fn(),
|
||||
hasChange: vi.fn().mockReturnValue(false),
|
||||
getChange: vi.fn(),
|
||||
clearAllChanges: vi.fn(),
|
||||
hasConfigChange: vi.fn().mockReturnValue(false),
|
||||
hasModuleConfigChange: vi.fn().mockReturnValue(false),
|
||||
hasChannelChange: vi.fn().mockReturnValue(false),
|
||||
hasUserChange: vi.fn().mockReturnValue(false),
|
||||
getConfigChangeCount: vi.fn().mockReturnValue(0),
|
||||
getModuleConfigChangeCount: vi.fn().mockReturnValue(0),
|
||||
getChannelChangeCount: vi.fn().mockReturnValue(0),
|
||||
getAllConfigChanges: vi.fn().mockReturnValue([]),
|
||||
getAllModuleConfigChanges: vi.fn().mockReturnValue([]),
|
||||
getAllChannelChanges: vi.fn().mockReturnValue([]),
|
||||
};
|
||||
|
||||
@@ -126,24 +126,6 @@ describe("DeviceStore – metadata, dialogs, unread counts, message draft", () =
|
||||
expect(() => device.getDialogOpen("reboot")).toThrow(/Device 5 not found/);
|
||||
});
|
||||
|
||||
it("unread counts: increment/get/getAll/reset", async () => {
|
||||
const { useDeviceStore } = await freshStore(false);
|
||||
const state = useDeviceStore.getState();
|
||||
const device = state.addDevice(2);
|
||||
|
||||
expect(device.getUnreadCount(10)).toBe(0);
|
||||
device.incrementUnread(10);
|
||||
device.incrementUnread(10);
|
||||
device.incrementUnread(11);
|
||||
expect(device.getUnreadCount(10)).toBe(2);
|
||||
expect(device.getUnreadCount(11)).toBe(1);
|
||||
expect(device.getAllUnreadCount()).toBe(3);
|
||||
|
||||
device.resetUnread(10);
|
||||
expect(device.getUnreadCount(10)).toBe(0);
|
||||
expect(device.getAllUnreadCount()).toBe(1);
|
||||
});
|
||||
|
||||
it("setMessageDraft stores the text", async () => {
|
||||
const { useDeviceStore } = await freshStore(false);
|
||||
const device = useDeviceStore.getState().addDevice(3);
|
||||
|
||||
@@ -46,7 +46,6 @@ export interface Device extends DeviceData {
|
||||
activeNode: number;
|
||||
pendingSettingsChanges: boolean;
|
||||
messageDraft: string;
|
||||
unreadCounts: Map<number, number>;
|
||||
dialog: Dialogs;
|
||||
clientNotifications: Protobuf.Mesh.ClientNotification[];
|
||||
|
||||
@@ -79,10 +78,6 @@ export interface Device extends DeviceData {
|
||||
setDialogOpen: (dialog: DialogVariant, open: boolean) => void;
|
||||
getDialogOpen: (dialog: DialogVariant) => boolean;
|
||||
setMessageDraft: (message: string) => void;
|
||||
incrementUnread: (nodeNum: number) => void;
|
||||
resetUnread: (nodeNum: number) => void;
|
||||
getUnreadCount: (nodeNum: number) => number;
|
||||
getAllUnreadCount: () => number;
|
||||
sendAdminMessage: (message: Protobuf.Admin.AdminMessage) => void;
|
||||
addClientNotification: (clientNotificationPacket: Protobuf.Mesh.ClientNotification) => void;
|
||||
removeClientNotification: (index: number) => void;
|
||||
@@ -173,7 +168,6 @@ function deviceFactory(
|
||||
},
|
||||
pendingSettingsChanges: false,
|
||||
messageDraft: "",
|
||||
unreadCounts: new Map(),
|
||||
clientNotifications: [],
|
||||
|
||||
setStatus: (status: Types.DeviceStatusEnum) => {
|
||||
@@ -542,51 +536,6 @@ function deviceFactory(
|
||||
}),
|
||||
);
|
||||
},
|
||||
incrementUnread: (nodeNum: number) => {
|
||||
set(
|
||||
produce<PrivateDeviceState>((draft) => {
|
||||
const device = draft.devices.get(id);
|
||||
if (!device) {
|
||||
return;
|
||||
}
|
||||
const currentCount = device.unreadCounts.get(nodeNum) ?? 0;
|
||||
device.unreadCounts.set(nodeNum, currentCount + 1);
|
||||
}),
|
||||
);
|
||||
},
|
||||
getUnreadCount: (nodeNum: number): number => {
|
||||
const device = get().devices.get(id);
|
||||
if (!device) {
|
||||
return 0;
|
||||
}
|
||||
return device.unreadCounts.get(nodeNum) ?? 0;
|
||||
},
|
||||
getAllUnreadCount: (): number => {
|
||||
const device = get().devices.get(id);
|
||||
if (!device) {
|
||||
return 0;
|
||||
}
|
||||
let totalUnread = 0;
|
||||
device.unreadCounts.forEach((count) => {
|
||||
totalUnread += count;
|
||||
});
|
||||
return totalUnread;
|
||||
},
|
||||
resetUnread: (nodeNum: number) => {
|
||||
set(
|
||||
produce<PrivateDeviceState>((draft) => {
|
||||
const device = draft.devices.get(id);
|
||||
if (!device) {
|
||||
return;
|
||||
}
|
||||
device.unreadCounts.set(nodeNum, 0);
|
||||
if (device.unreadCounts.get(nodeNum) === 0) {
|
||||
device.unreadCounts.delete(nodeNum);
|
||||
}
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
sendAdminMessage(message: Protobuf.Admin.AdminMessage) {
|
||||
const device = get().devices.get(id);
|
||||
if (!device) {
|
||||
|
||||
@@ -13,8 +13,6 @@ import { type MeshDevice, Protobuf } from "@meshtastic/sdk";
|
||||
* open triggers, unread counts).
|
||||
*/
|
||||
export const subscribeAll = (device: Device, connection: MeshDevice) => {
|
||||
let myNodeNum = 0;
|
||||
|
||||
connection.events.onDeviceMetadataPacket.subscribe((metadataPacket) => {
|
||||
device.addMetadata(metadataPacket.from, metadataPacket.data);
|
||||
});
|
||||
@@ -54,7 +52,6 @@ export const subscribeAll = (device: Device, connection: MeshDevice) => {
|
||||
|
||||
connection.events.onMyNodeInfo.subscribe((nodeInfo) => {
|
||||
useNewNodeNum(device.id, nodeInfo);
|
||||
myNodeNum = nodeInfo.myNodeNum;
|
||||
});
|
||||
|
||||
// onUserPacket / onPositionPacket / onNodeInfoPacket are handled by the
|
||||
@@ -70,19 +67,8 @@ export const subscribeAll = (device: Device, connection: MeshDevice) => {
|
||||
device.setModuleConfig(moduleConfig);
|
||||
});
|
||||
|
||||
connection.events.onMessagePacket.subscribe((messagePacket) => {
|
||||
// Message persistence is handled by the SDK chat slice via the
|
||||
// SqlocalMessageRepository wired in useConnections. This handler exists
|
||||
// only to drive the legacy unread-count tracking on the device store.
|
||||
const isDirect = messagePacket.type === "direct";
|
||||
if (isDirect) {
|
||||
if (messagePacket.to === myNodeNum) {
|
||||
device.incrementUnread(messagePacket.from);
|
||||
}
|
||||
} else if (messagePacket.from !== myNodeNum) {
|
||||
device.incrementUnread(messagePacket.channel);
|
||||
}
|
||||
});
|
||||
// Inbound message handling (persistence, unread counts) lives entirely on
|
||||
// the SDK ChatClient now — see ChatClient + chat.unread.
|
||||
|
||||
connection.events.onTraceRoutePacket.subscribe((traceRoutePacket) => {
|
||||
device.addTraceRoute({
|
||||
|
||||
@@ -10,10 +10,10 @@ import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
|
||||
import { useChatAsLegacyMessages } from "@core/hooks/useChatAsLegacyMessages.ts";
|
||||
import { useNodesAsProto } from "@core/hooks/useNodesAsProto.ts";
|
||||
import { useToast } from "@core/hooks/useToast.ts";
|
||||
import { MessageType, useDevice, useSidebar } from "@core/stores";
|
||||
import { MessageType, useSidebar } from "@core/stores";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import { Protobuf, Types } from "@meshtastic/sdk";
|
||||
import { useActiveClient, useChannels, useNodeErrors } from "@meshtastic/sdk-react";
|
||||
import { useActiveClient, useChannels, useNodeErrors, useUnreadByKey } from "@meshtastic/sdk-react";
|
||||
import { useNavigate, useParams } from "@tanstack/react-router";
|
||||
import { HashIcon, LockIcon, LockOpenIcon } from "lucide-react";
|
||||
import { useCallback, useDeferredValue, useEffect, useMemo, useState } from "react";
|
||||
@@ -32,14 +32,28 @@ function SelectMessageChat() {
|
||||
}
|
||||
|
||||
export const MessagesPage = () => {
|
||||
const { getUnreadCount, resetUnread } = useDevice();
|
||||
const channels = useChannels();
|
||||
const unreadByKey = useUnreadByKey();
|
||||
const meshClient = useActiveClient();
|
||||
const directUnread = (peer: number): number => unreadByKey.get(`direct:${peer}`) ?? 0;
|
||||
const channelUnread = (idx: number): number => unreadByKey.get(`channel:${idx}`) ?? 0;
|
||||
const markChannelRead = useCallback(
|
||||
(idx: number) =>
|
||||
meshClient?.chat.unread.markRead({
|
||||
kind: "channel",
|
||||
channel: idx as Types.ChannelNumber,
|
||||
}),
|
||||
[meshClient],
|
||||
);
|
||||
const markDirectRead = useCallback(
|
||||
(peer: number) => meshClient?.chat.unread.markRead({ kind: "direct", peer }),
|
||||
[meshClient],
|
||||
);
|
||||
const allNodes = useNodesAsProto();
|
||||
const getNode = (n: number) => allNodes.find((node) => node.num === n);
|
||||
const errors = useNodeErrors();
|
||||
const errorSet = useMemo(() => new Set(errors.map((e) => e.node)), [errors]);
|
||||
const hasNodeError = useCallback((num: number) => errorSet.has(num), [errorSet]);
|
||||
const meshClient = useActiveClient();
|
||||
|
||||
const { type, chatId } = useParams({ from: messagesWithParamsRoute.id });
|
||||
|
||||
@@ -90,7 +104,7 @@ export const MessagesPage = () => {
|
||||
})
|
||||
.map((node: Protobuf.Mesh.NodeInfo) => ({
|
||||
...node,
|
||||
unreadCount: getUnreadCount(node.num) ?? 0,
|
||||
unreadCount: directUnread(node.num),
|
||||
}))
|
||||
.sort((a: NodeInfoWithUnread, b: NodeInfoWithUnread) => {
|
||||
const diff = b.unreadCount - a.unreadCount;
|
||||
@@ -99,7 +113,7 @@ export const MessagesPage = () => {
|
||||
}
|
||||
return Number(b.isFavorite) - Number(a.isFavorite);
|
||||
});
|
||||
}, [deferredSearch, allNodes, getUnreadCount]);
|
||||
}, [deferredSearch, allNodes, directUnread]);
|
||||
|
||||
const sendText = useCallback(
|
||||
async (message: string) => {
|
||||
@@ -146,7 +160,7 @@ export const MessagesPage = () => {
|
||||
{filteredChannels?.map((channel) => (
|
||||
<SidebarButton
|
||||
key={channel.index}
|
||||
count={getUnreadCount(channel.index)}
|
||||
count={channelUnread(channel.index)}
|
||||
label={
|
||||
channel.settings?.name ||
|
||||
(channel.index === 0
|
||||
@@ -159,7 +173,7 @@ export const MessagesPage = () => {
|
||||
active={numericChatId === channel.index && chatType === MessageType.Broadcast}
|
||||
onClick={() => {
|
||||
navigateToChat(MessageType.Broadcast, channel.index.toString());
|
||||
resetUnread(channel.index);
|
||||
markChannelRead(channel.index);
|
||||
}}
|
||||
>
|
||||
<HashIcon size={16} className={cn(isCollapsed ? "mr-0 mt-2" : "mr-2")} />
|
||||
@@ -173,9 +187,9 @@ export const MessagesPage = () => {
|
||||
numericChatId,
|
||||
chatType,
|
||||
isCollapsed,
|
||||
getUnreadCount,
|
||||
channelUnread,
|
||||
navigateToChat,
|
||||
resetUnread,
|
||||
markChannelRead,
|
||||
t,
|
||||
],
|
||||
);
|
||||
@@ -205,7 +219,7 @@ export const MessagesPage = () => {
|
||||
active={numericChatId === node.num && chatType === MessageType.Direct}
|
||||
onClick={() => {
|
||||
navigateToChat(MessageType.Direct, node.num.toString());
|
||||
resetUnread(node.num);
|
||||
markDirectRead(node.num);
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
|
||||
Reference in New Issue
Block a user