Skip to content

Commit e10aee0

Browse files
feat: example of polling mirror node to simulate TopicMessageQuery (#3180)
Signed-off-by: Ivaylo Nikolov <[email protected]>
1 parent e7f871c commit e10aee0

File tree

3 files changed

+396
-0
lines changed

3 files changed

+396
-0
lines changed
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import EventEmitter from "events";
2+
import { useEffect, useState } from "react";
3+
4+
const TopicListener = ({ topicId }) => {
5+
const [messages, setMessages] = useState([]);
6+
const [isConnected, setIsConnected] = useState(false);
7+
const [lastUpdate, setLastUpdate] = useState(null);
8+
const dataEmitter = new EventEmitter();
9+
const pollingIntervalRef = useRef(null);
10+
11+
useEffect(() => {
12+
if (topicId) {
13+
setIsConnected(true);
14+
pollMirrorNode(dataEmitter, topicId, pollingIntervalRef);
15+
} else {
16+
setIsConnected(false);
17+
}
18+
19+
return () => {
20+
if (pollingIntervalRef.current) {
21+
clearInterval(pollingIntervalRef.current);
22+
pollingIntervalRef.current = null;
23+
}
24+
};
25+
// eslint-disable-next-line react-hooks/exhaustive-deps
26+
}, [topicId]);
27+
28+
dataEmitter.on("newMessages", (message) => {
29+
setMessages((prevMessages) => [...prevMessages, message]);
30+
setLastUpdate(new Date());
31+
});
32+
33+
return (
34+
<div className="bg-gray-900 rounded-lg shadow-xl border border-gray-700 p-6 max-w-4xl mx-auto mt-5">
35+
{/* Header */}
36+
<div className="flex items-center justify-between mb-6">
37+
<div className="flex items-center space-x-3">
38+
<div
39+
className={`w-3 h-3 rounded-full ${isConnected ? "bg-green-500 animate-pulse" : "bg-red-500"}`}
40+
></div>
41+
<h1 className="text-2xl font-bold text-white">
42+
Topic Message Listener
43+
</h1>
44+
</div>
45+
<div className="text-sm text-gray-400">
46+
Topic ID:{" "}
47+
<span className="font-mono text-blue-400">
48+
{topicId || "Not set"}
49+
</span>
50+
</div>
51+
</div>
52+
53+
{/* Status Bar */}
54+
<div className="bg-gray-800 rounded-lg p-3 mb-4">
55+
<div className="flex items-center justify-between text-sm">
56+
<span
57+
className={`flex items-center space-x-2 ${isConnected ? "text-green-400" : "text-red-400"}`}
58+
>
59+
<span className="w-2 h-2 rounded-full bg-current"></span>
60+
<span>
61+
{isConnected ? "Connected" : "Disconnected"}
62+
</span>
63+
</span>
64+
{lastUpdate && (
65+
<span className="text-gray-400">
66+
Last message: {lastUpdate.toLocaleTimeString()}
67+
</span>
68+
)}
69+
<span className="text-gray-400">
70+
Total messages: {messages.length}
71+
</span>
72+
</div>
73+
</div>
74+
75+
{/* Messages Container */}
76+
<div className="bg-gray-800 rounded-lg border border-gray-700">
77+
<div className="p-4 border-b border-gray-700">
78+
<h2 className="text-lg font-semibold text-white">
79+
Messages
80+
</h2>
81+
</div>
82+
83+
<div className="max-h-96 overflow-y-auto">
84+
{messages.length === 0 ? (
85+
<div className="p-8 text-center">
86+
<div className="text-gray-500 mb-2">
87+
<svg
88+
className="w-12 h-12 mx-auto mb-4"
89+
fill="none"
90+
stroke="currentColor"
91+
viewBox="0 0 24 24"
92+
>
93+
<path
94+
strokeLinecap="round"
95+
strokeLinejoin="round"
96+
strokeWidth={1}
97+
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
98+
/>
99+
</svg>
100+
</div>
101+
<p className="text-gray-400">
102+
Waiting for messages...
103+
</p>
104+
<p className="text-sm text-gray-500 mt-1">
105+
Messages will appear here when sent to the topic
106+
</p>
107+
</div>
108+
) : (
109+
<div className="p-4 space-y-3">
110+
{messages.map((message, index) => (
111+
<div
112+
key={`${message}-${index}`}
113+
className="bg-gray-700 rounded-lg p-4 border-l-4 border-blue-500"
114+
>
115+
<div className="flex items-start justify-between">
116+
<div className="flex-1">
117+
<div className="flex items-center space-x-2 mb-2">
118+
<span className="text-xs bg-blue-600 text-white px-2 py-1 rounded-full">
119+
Message #{index + 1}
120+
</span>
121+
<span className="text-xs text-gray-400">
122+
{new Date().toLocaleTimeString()}
123+
</span>
124+
</div>
125+
<p className="text-white break-words">
126+
{message}
127+
</p>
128+
</div>
129+
</div>
130+
</div>
131+
))}
132+
</div>
133+
)}
134+
</div>
135+
</div>
136+
137+
{/* Footer */}
138+
<div className="mt-4 text-center">
139+
<p className="text-xs text-gray-500">
140+
Polling every 1 second • Messages are base64 decoded from
141+
the Hedera network
142+
</p>
143+
</div>
144+
</div>
145+
);
146+
};
147+
148+
export default TopicListener;
149+
150+
/**
151+
* @param {EventEmitter} dataEmitter
152+
* @param {string} topicId
153+
* @param {React.RefObject<NodeJS.Timeout | null>} pollingIntervaRef - A React ref that holds the interval ID, allowing the caller to clearInterval()
154+
* when the component unmounts or page changes to prevent indefinite polling
155+
* @returns {Promise<void>}
156+
*/
157+
async function pollMirrorNode(dataEmitter, topicId, pollingIntervaRef) {
158+
let lastMessagesLength = 0;
159+
const POLLING_INTERVAL = 1000;
160+
161+
pollingIntervaRef.current = setInterval(async () => {
162+
const BASE_URL = "https://testnet.mirrornode.hedera.com";
163+
const res = await fetch(
164+
`${BASE_URL}/api/v1/topics/${topicId}/messages`,
165+
);
166+
167+
/**
168+
* data.messages is an array of objects with a message property
169+
* @type {{messages: { message: string }[]}}
170+
*/
171+
const data = await res.json();
172+
173+
// Check if we have new messages (array length changed)
174+
const currentMessagesLength = data.messages ? data.messages.length : 0;
175+
176+
if (currentMessagesLength > lastMessagesLength) {
177+
// Get the latest message(s) - they are raw base64 encoded strings
178+
const newMessages = data.messages.slice(lastMessagesLength)[0];
179+
const decodedMessage = Buffer.from(
180+
newMessages.message,
181+
"base64",
182+
).toString("utf-8");
183+
dataEmitter.emit("newMessages", decodedMessage);
184+
lastMessagesLength = currentMessagesLength;
185+
}
186+
}, POLLING_INTERVAL);
187+
}
188+
189+
/**
190+
* @param {number} ms
191+
* @returns {Promise<void>}
192+
*/
193+
function sleep(ms) {
194+
return new Promise((resolve) => setTimeout(resolve, ms));
195+
}

examples/frontend-examples/src/app/layout.js

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,54 @@ export default function RootLayout({ children }) {
113113
>
114114
{item.title}
115115
</h3>
116+
<p
117+
className={
118+
styles.LinkDescription
119+
}
120+
>
121+
{item.description}
122+
</p>
123+
</Link>
124+
</li>
125+
))}
126+
</ul>
127+
</NavigationMenu.Content>
128+
</NavigationMenu.Item>
129+
<NavigationMenu.Item>
130+
<NavigationMenu.Trigger
131+
className={styles.Trigger}
132+
>
133+
Topic
134+
<NavigationMenu.Icon
135+
className={styles.Icon}
136+
>
137+
<ChevronDownIcon />
138+
</NavigationMenu.Icon>
139+
</NavigationMenu.Trigger>
140+
<NavigationMenu.Content
141+
className={styles.Content}
142+
>
143+
<ul className={styles.GridLinkList}>
144+
{topicLinks.map((item) => (
145+
<li key={item.href}>
146+
<Link
147+
className={styles.LinkCard}
148+
href={item.href}
149+
>
150+
<h3
151+
className={
152+
styles.LinkTitle
153+
}
154+
>
155+
{item.title}
156+
</h3>
157+
<p
158+
className={
159+
styles.LinkDescription
160+
}
161+
>
162+
{item.description}
163+
</p>
116164
</Link>
117165
</li>
118166
))}
@@ -215,3 +263,11 @@ const clientLinks = [
215263
description: "GRPC Web Proxy with dynamic network update",
216264
},
217265
];
266+
267+
const topicLinks = [
268+
{
269+
href: "/topic/message-query",
270+
title: "Message Query",
271+
description: "Query messages from a topic",
272+
},
273+
];

0 commit comments

Comments
 (0)