This is an early prototype for Grammy Views — UI framework for grammY
Grammy Views is a UI framework for Telegram bots that provides abstractions for controlling UI states.
yarn add @loskir/grammy-views
npm i --save @loskir/grammy-views
import { View } from "https://github.com/Loskir/grammy-views/raw/master/src/mod.ts";
The Grammy Router is a basic implementation of the Finite State Machine, a concept for separating handlers into groups that are active only when the user is in a particular route.
Grammy Views enables this behavior too, but provides higher-level abstractions (e.g. local states for each View).
Telegraf Scenes
Telegraf has Scenes, a similar abstraction which was the inspiration for this library. Grammy Views uses almost the same concepts as Telegraf Scenes. The main difference is the type safety.
Telegraf term | Grammy Views term | Description |
---|---|---|
Scene | View | Basic building block for the UI. Represents an isolated state with its own handlers |
Stage |
ViewController |
Middleware that registers all views and provides context flavor |
ctx.scene.session |
ctx.view.state |
Persistent storage that is bound to this view/scene |
scene.enter |
view.onRender |
Middleware that is executed upon entering the view/scene |
scene.leave |
view.onLeave |
Middleware that is executed upon leaving the view/scene |
ctx.scene.enter('name') |
SomeView.enter(ctx) |
Entering another view |
You have to use ViewContextFlavor
on your context in order for the types to be complete. It is additive and does not take any type parameters.
export type CustomContext = Context & ViewContextFlavor;
A class that represents an isolated stage of the interface with its own view (rendered with onRender
middleware) and handlers (local of global). (Similar to BaseScene in Telegraf)
const SomeView = createView("some-view");
Each view must have a unique name.
Each view can have a render function.
It's called when the view is entered.
Its purpose is to render the view.
Usually it's done via editing a message or by sending a new one.
Render functions are set via .onRender
method.
Several render middlewares can be applied.
const SomeView = createView("some-view");
SomeView.onRender((ctx) => ctx.reply("Hello from some view!"));
import { SomeView } from "./someView";
bot.command("enter", (ctx) => SomeView.enter(ctx));
There are 3 ways to handle updates on the view:
- Local handlers
- Global handlers
- Override handlers
Local handlers are defined the same way as with Composer
and only work when the user is inside this view.
const SomeView = createView("some-view");
SomeView.command("test", (ctx) => ctx.reply("hello!"));
bot.command("enter", (ctx) => SomeView.enter(ctx));
> /test
< // nothing
> /enter
< // now we are inside the view
> /test
< hello!
Global handlers are defined using .global
prefix and work both inside and outside the view.
They are useful for defining global entrypoints for the view.
const SomeView = createView("some-view");
SomeView.global.command("enter_some_view", (ctx) => SomeView.enter(ctx));
> /enter_some_view
< // now we are inside the view, even if we were in different view before
Override handlers are defined using .override
prefix and only work inside the view.
They have the highest priority of all three ways.
Override handlers > Global handlers > Local handlers.
Override handlers are useful for overriding other global handlers to provide similar behavior, but with some state-dependent changes.
const SomeView = createView<CustomContext, { a: string }>("some-view");
SomeView.global.command("enter_some_view", (ctx) => {
return SomeView.enter(ctx, { a: "we came from global handler" });
});
const SomeOtherView = createView("some-other-view");
SomeOtherView.override.command("enter_some_view", (ctx) => {
return SomeView.enter(ctx, { a: "we came from SomeOtherView" });
});
// ❌ this won't work because global handlers have higher priority than local ones
SomeOtherView.command("enter_some_view", (ctx) => {
return SomeView.enter(ctx, { a: "we came from SomeOtherView" });
});
View can have state.
It's used for both external data (like props) and internal data.
It is defined via the second type parameter of createView
function (the first is used to pass custom Context
types).
const SomeView = createView<CustomContext, { a: string }>("some-view");
When entering a stateful view, it is required to pass appropriate state.
bot.command("enter", (ctx) => SomeView.enter(ctx, { a: "123" }));
View.enter
method is strictly typed, so you'll get compilation error if you forgot some properties or confuse the types.
To define a default state, you use .setDefaultState
method.
const SomeView = createView<CustomContext, { a: string }>("some-view")
.setDefaultState(() => ({ a: "default a" }));
You don't have to pass properties from default state on enter (but you still can override them if you want)
// ✅ both are correct
bot.command("enter", (ctx) => SomeView.enter(ctx));
bot.command("enter", (ctx) => SomeView.enter(ctx, { a: "override" }));
Notice that .setDefaultState
returns a new instance of View
, so you can't call it on created instance.
// ✅ correct
const SomeView = createView<CustomContext, { a: string }>("some-view")
.setDefaultState(() => ({ a: "default a" }));
// ❌ incorrect
const SomeView = createView<CustomContext, { a: string }>("some-view");
SomeView.setDefaultState(() => ({ a: "default a" }));
View state is stored inside session an therefore is persisted between updates.
It can be accessed via ctx.view.state
in render middleware, local handlers and override handlers (but not in global handlers).
const SomeView = createView<CustomContext, { a: string }>("some-view");
SomeView.onRender((ctx) => {
return ctx.reply(ctx.view.state.a); // ✅
});
SomeView.callbackQuery("a", (ctx) => {
return ctx.answerCallbackQuery(ctx.view.state.a); // ✅
});
SomeView.override.callbackQuery("a", (ctx) => {
return ctx.answerCallbackQuery(ctx.view.state.a); // ✅
});
SomeView.global.callbackQuery("a", (ctx) => {
// ❌ no state here
});
A class that provides ctx.view
property in the context and manages all the activities involving views. (Similar to Stage in Telegraf)
You have to .use
it on your bot instance in order to be able to use ctx.view
methods.
You have to register your views in this controller in order for them to work.
const viewController = new ViewController<CustomContext>();
viewController.register(
MainView,
OtherView,
);
bot.use(viewController);