diff --git a/packages/sdk-react/mod.ts b/packages/sdk-react/mod.ts index 249b8ed1..120b6d77 100644 --- a/packages/sdk-react/mod.ts +++ b/packages/sdk-react/mod.ts @@ -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"; diff --git a/packages/sdk-react/src/hooks/useUnread.ts b/packages/sdk-react/src/hooks/useUnread.ts new file mode 100644 index 00000000..b0fede6a --- /dev/null +++ b/packages/sdk-react/src/hooks/useUnread.ts @@ -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() as ReadonlyMap, + peek: () => new Map() as ReadonlyMap, + 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 (``). Useful for sidebar + * badges that walk every saved conversation. + */ +export function useUnreadByKey(): ReadonlyMap { + const client = useActiveClient(); + return useSignal(client?.chat.unread.byKey ?? EMPTY_MAP_SIGNAL); +} diff --git a/packages/sdk/mod.ts b/packages/sdk/mod.ts index 394915c5..ad9d2204 100644 --- a/packages/sdk/mod.ts +++ b/packages/sdk/mod.ts @@ -56,6 +56,7 @@ export { ChatClient } from "./src/features/chat/index.ts"; export type { ChatClientOptions, ChatDrafts, + ChatUnread, ConversationKey, DraftRepository, Message, diff --git a/packages/sdk/src/features/chat/ChatClient.ts b/packages/sdk/src/features/chat/ChatClient.ts index 9592b519..00474980 100644 --- a/packages/sdk/src/features/chat/ChatClient.ts +++ b/packages/sdk/src/features/chat/ChatClient.ts @@ -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>; + /** Sum of all unread counts across conversations. */ + readonly total: ReadonlySignal; + /** Unread count for a single conversation. */ + count(key: ConversationKey): ReadonlySignal; + /** 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(); private readonly draftsHydrated = new Set(); + private readonly unreadByKey = signal>(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 { + 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 { + 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); + }); + }, + }; + } } diff --git a/packages/sdk/src/features/chat/ChatClient.unread.test.ts b/packages/sdk/src/features/chat/ChatClient.unread.test.ts new file mode 100644 index 00000000..51b153a2 --- /dev/null +++ b/packages/sdk/src/features/chat/ChatClient.unread.test.ts @@ -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); + }); +}); diff --git a/packages/sdk/src/features/chat/index.ts b/packages/sdk/src/features/chat/index.ts index 43d09e7d..b7bc5756 100644 --- a/packages/sdk/src/features/chat/index.ts +++ b/packages/sdk/src/features/chat/index.ts @@ -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"; diff --git a/packages/web/src/components/Sidebar.tsx b/packages/web/src/components/Sidebar.tsx index 1ee3df5e..11a88c13 100644 --- a/packages/web/src/components/Sidebar.tsx +++ b/packages/web/src/components/Sidebar.tsx @@ -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), ); diff --git a/packages/web/src/core/stores/deviceStore/deviceStore.mock.ts b/packages/web/src/core/stores/deviceStore/deviceStore.mock.ts index 3b4e2bbe..5edbd4ec 100644 --- a/packages/web/src/core/stores/deviceStore/deviceStore.mock.ts +++ b/packages/web/src/core/stores/deviceStore/deviceStore.mock.ts @@ -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([]), }; diff --git a/packages/web/src/core/stores/deviceStore/deviceStore.test.ts b/packages/web/src/core/stores/deviceStore/deviceStore.test.ts index 67df0042..9edc1157 100644 --- a/packages/web/src/core/stores/deviceStore/deviceStore.test.ts +++ b/packages/web/src/core/stores/deviceStore/deviceStore.test.ts @@ -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); diff --git a/packages/web/src/core/stores/deviceStore/index.ts b/packages/web/src/core/stores/deviceStore/index.ts index b6bef2ee..eb5fceb7 100644 --- a/packages/web/src/core/stores/deviceStore/index.ts +++ b/packages/web/src/core/stores/deviceStore/index.ts @@ -46,7 +46,6 @@ export interface Device extends DeviceData { activeNode: number; pendingSettingsChanges: boolean; messageDraft: string; - unreadCounts: Map; 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((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((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) { diff --git a/packages/web/src/core/subscriptions.ts b/packages/web/src/core/subscriptions.ts index 245214f7..a8d18429 100644 --- a/packages/web/src/core/subscriptions.ts +++ b/packages/web/src/core/subscriptions.ts @@ -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({ diff --git a/packages/web/src/pages/Messages.tsx b/packages/web/src/pages/Messages.tsx index 3ec99681..8fe17201 100644 --- a/packages/web/src/pages/Messages.tsx +++ b/packages/web/src/pages/Messages.tsx @@ -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) => ( { active={numericChatId === channel.index && chatType === MessageType.Broadcast} onClick={() => { navigateToChat(MessageType.Broadcast, channel.index.toString()); - resetUnread(channel.index); + markChannelRead(channel.index); }} > @@ -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); }} >