@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

terminal
pnpm add @yaebal/morda

the 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.

menu.ts
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:

navigation.ts
// 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();
methoddescription
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:

buttons.ts
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();
  },
});
helpersignaturedescription
switchTo(label, windowId) => Buttonbutton that calls ctx.dialog.push(windowId)
back(label?) => Buttonbutton that calls ctx.dialog.back(); default label "← Назад"
button(label, { id, onClick? }) => Buttonarbitrary action button

dialogs() options

optiontyperequireddescription
storageStorageAdapter<DialogState>nowhere to persist the navigation stack. defaults to MemoryStorage (lost on restart)
onLeave(chatId, windowId) => voidnocalled when a window leaves the stack. the jsx layer uses this to evict hook state
custom-storage.ts
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

exportdescription
DialogContextContext & { dialog: DialogControl } — context inside a window render or button onClick
DialogControlthe five navigation methods on ctx.dialog
DialogDefRecord<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 }
DialogStatepersisted per-chat state: { stack: string[]; messageId: number; chatId: number }
DialogsOptionsoptions 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
// tsconfig.json — point the compiler at morda's jsx transform
{
  "compilerOptions": {
    "jsx": "react-jsx",
    "jsxImportSource": "@yaebal/morda"
  }
}
bot.tsx
/** @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

hooks.tsx
/** @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>
  );
}
hooksignaturedescription
useState<T>(initial: T | (() => T)) => [T, setter]per-screen, per-chat state slot. calling the setter triggers ctx.dialog.rerender()
useEffect(fn, deps?) => voidfire-and-forget side-effect after render; deps gate re-runs. no cleanup support
useNavigation() => Navigationreturns { push, replace, back }; accepts a ScreenComponent or a window id string
useUser() => User | undefinedthe Telegram user from the current update's ctx.from
useSession<S>() => Sraw 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

componentpropsdescription
<Screen>childrenroot element of every screen component — required
<ButtonRow>childrengroups <Button> elements into one keyboard row
<Button>id, onClick?, childrena 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.

stale-press guard. morda ignores button presses whose window id does not match the live stack top — a double-tap or a press on an old keyboard before the edit landed is silently discarded and the spinner is cleared.

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.