@yaebal/morda
dialogs engine + jsx/hooks layer. declarative windows, automatic callback routing, per-chat navigation stack — and an optional "react-for-telegram" surface where screens are components and state is managed with hooks.
install
pnpm add @yaebal/mordathe dialogs API
the core export is dialogs(def, options?). a dialog is a flat map of named
windows; each window is a function that returns { text, keyboard }. morda
encodes button ids into callback_data automatically and routes presses back to the
right onClick — no manual editMessageText or callback-data wrangling
needed.
import { Bot } from "@yaebal/core";
import { dialogs, switchTo, back, button } from "@yaebal/morda";
const bot = new Bot(process.env.BOT_TOKEN!)
.install(dialogs({
main: () => ({
text: "Main menu",
keyboard: [
[switchTo("Settings →", "settings")],
[button("Ping", { id: "ping", onClick: (ctx) => ctx.answerCallbackQuery({ text: "pong" }) })],
],
}),
settings: () => ({
text: "Settings",
keyboard: [[back("← Back")]],
}),
}));
bot.command("menu", (ctx) => ctx.dialog.start("main"));
bot.start();ctx.dialog
installing the plugin adds ctx.dialog on every update. the five methods cover all
navigation needs:
// ctx.dialog is available on every update after .install(dialogs(...))
// open a fresh dialog (sends a new message + initialises the stack)
await ctx.dialog.start("main");
// push a window onto the stack (edits the message)
await ctx.dialog.push("settings");
// replace the top window without growing the stack
await ctx.dialog.replace("confirm");
// pop one window; if the stack becomes empty the message is deleted
await ctx.dialog.back();
// re-render the current window in place after mutating external state
await ctx.dialog.rerender();| method | description |
|---|---|
start(windowId) | send a new message and start a fresh stack |
push(windowId) | push a window; edits the dialog message |
replace(windowId) | replace the top window without growing the stack |
back() | pop the stack; deletes the message when the stack empties |
rerender() | re-render the current window in place |
button helpers
three named helpers build Button objects without constructing them by hand:
import { switchTo, back, button } from "@yaebal/morda";
// navigate to another window on click
switchTo("Settings →", "settings");
// pop the stack (default label "← Назад")
back();
back("← Go back");
// arbitrary action button
button("Refresh", {
id: "refresh",
onClick: async (ctx) => {
await ctx.answerCallbackQuery({ text: "refreshed" });
await ctx.dialog.rerender();
},
});| helper | signature | description |
|---|---|---|
switchTo | (label, windowId) => Button | button that calls ctx.dialog.push(windowId) |
back | (label?) => Button | button that calls ctx.dialog.back(); default label "← Назад" |
button | (label, { id, onClick? }) => Button | arbitrary action button |
dialogs() options
| option | type | required | description |
|---|---|---|---|
storage | StorageAdapter<DialogState> | no | where to persist the navigation stack. defaults to MemoryStorage (lost on restart) |
onLeave | (chatId, windowId) => void | no | called when a window leaves the stack. the jsx layer uses this to evict hook state |
import { dialogs } from "@yaebal/morda";
import { type StorageAdapter } from "@yaebal/session";
import type { DialogState } from "@yaebal/morda";
class RedisDialogStorage implements StorageAdapter<DialogState> {
async get(key: string) { /* ... */ }
async set(key: string, value: DialogState) { /* ... */ }
async delete(key: string) { /* ... */ }
}
bot.install(dialogs(def, { storage: new RedisDialogStorage() }));public types
| export | description |
|---|---|
DialogContext | Context & { dialog: DialogControl } — context inside a window render or button onClick |
DialogControl | the five navigation methods on ctx.dialog |
DialogDef | Record<string, WindowRender> — the map passed to dialogs() |
WindowRender | (ctx: DialogContext) => WindowView | Promise<WindowView> |
WindowView | { text: string; keyboard?: Button[][] } |
Button | { id: string; label: string; onClick?: (ctx) => unknown } |
DialogState | persisted per-chat state: { stack: string[]; messageId: number; chatId: number } |
DialogsOptions | options interface for dialogs() |
jsx / hooks layer
@yaebal/morda/jsx is an optional higher-level surface. screens become zero-arg
components that return <Screen> trees, and React-style hooks manage local
state. the compiler must be configured to use morda's jsx transform.
// tsconfig.json — point the compiler at morda's jsx transform
{
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "@yaebal/morda"
}
}/** @jsxImportSource @yaebal/morda */
import { Bot } from "@yaebal/core";
import {
jsxDialogs,
Screen, ButtonRow, Button,
useState, useNavigation,
} from "@yaebal/morda/jsx";
function SettingsScreen() {
const nav = useNavigation();
return (
<Screen>
Settings page
<ButtonRow>
<Button id="back" onClick={() => nav.back()}>← Back</Button>
</ButtonRow>
</Screen>
);
}
function MainScreen() {
const [count, setCount] = useState(0);
const nav = useNavigation();
return (
<Screen>
{`You tapped ${count} time(s)`}
<ButtonRow>
<Button id="tap" onClick={() => setCount((n) => n + 1)}>Tap</Button>
<Button id="settings" onClick={() => nav.push(SettingsScreen)}>Settings</Button>
</ButtonRow>
</Screen>
);
}
const bot = new Bot(process.env.BOT_TOKEN!)
.install(jsxDialogs({ main: MainScreen, settings: SettingsScreen }));
bot.command("menu", (ctx) => ctx.dialog.start("main"));
bot.start();hooks
/** @jsxImportSource @yaebal/morda */
import {
Screen, Button,
useState, useEffect, useNavigation, useUser, useSession,
} from "@yaebal/morda/jsx";
function ProfileScreen() {
const user = useUser();
const session = useSession<{ visits: number }>();
const [loaded, setLoaded] = useState(false);
const nav = useNavigation();
useEffect(() => {
console.log("screen mounted");
}, []); // empty deps → runs once per mount
return (
<Screen>
{`Hello ${user?.first_name}! visits: ${session.visits}`}
<Button id="back" onClick={() => nav.back()}>← Back</Button>
</Screen>
);
}| hook | signature | description |
|---|---|---|
useState | <T>(initial: T | (() => T)) => [T, setter] | per-screen, per-chat state slot. calling the setter triggers ctx.dialog.rerender() |
useEffect | (fn, deps?) => void | fire-and-forget side-effect after render; deps gate re-runs. no cleanup support |
useNavigation | () => Navigation | returns { push, replace, back }; accepts a ScreenComponent or a window id string |
useUser | () => User | undefined | the Telegram user from the current update's ctx.from |
useSession | <S>() => S | raw access to ctx.session; requires @yaebal/session installed before the dialog |
useTranslation | () => { t, changeLanguage } | i18n helpers; requires @yaebal/i18n installed before the dialog |
jsx components
| component | props | description |
|---|---|---|
<Screen> | children | root element of every screen component — required |
<ButtonRow> | children | groups <Button> elements into one keyboard row |
<Button> | id, onClick?, children | a single inline button; children becomes the label |
registration
both the builder API and the jsx layer use .install() — the same method used by
every other yaebal plugin.
no auto re-render in the builder API. after mutating external state that a window reads, call
ctx.dialog.rerender() yourself. the jsx layer automates this via useState. useState fires one edit per call. calling the setter multiple times in one handler issues one
editMessageText per call — there is no batching. combine writes
if you need to update several slots at once. hooks must be unconditional. changing the number of hook calls between renders (e.g. hooks inside
if blocks) throws at runtime — same rule as React. hook state is in-memory.
useState slots are stored in a module-level Map and are lost on process restart. they are evicted cleanly when a window leaves
the stack, so reopened screens re-mount fresh.