Skip to content

Fresh data with Prop and load()

Some things should be read fresh on every click instead of saved in the message, such as a database row. Mark them risa.Prop and fill them in load().

A leaderboard

game is a field like any other, saved in the message. rows is a risa.Prop, and load() fills it. Scores stands in for your database, and you can find it in the full file.

@risa.register(name="leaderboard")
class Leaderboard(risa.View):
    game: str = "chess"
    rows: risa.Prop[list[str]] = msgspec.field(default_factory=list[str])

    async def load(self, scores: Scores) -> None:
        self.rows = await scores.top(self.game)

load() names Scores as a parameter, as a handler would, and main() registers it. Without that, client.build raises linkd's DependencyNotSatisfiableException. msgspec.field(default_factory=list[str]) gives each view a new empty list.

render() reads a prop as it reads a field. Each game's button runs switch, and _TOP at the top of the file holds each game's top players.

def render(self) -> ui.Layout:
    lines = [f"{n}. {name}" for n, name in enumerate(self.rows, start=1)]
    buttons = [
        ui.Button(
            risa.bind(self.switch, game),
            label=game.title(),
            disabled=game == self.game,
        )
        for game in _TOP
    ]
    return ui.Container(
        ui.TextDisplay(f"## Top players in {self.game}"),
        ui.TextDisplay("\n".join(lines)),
        ui.Row(*buttons, ui.Button(self.refresh, label="Refresh")),
    )

In Discord

Risa BotAPPToday at 12:00

Top players in chess

1. Mika
2. Jo
3. Sam

ChessGoRefresh

What a prop costs

A prop takes no room in the message: nothing about rows goes into the buttons' ids, however many rows load() returns. It needs a default, because each click builds the view with its props at their defaults before load() runs, and without one @risa.register raises ViewDeclarationError.

A prop is never saved, so what a handler puts in one lasts for that click's rerender, and the next click starts from the default again.

When load() runs

  1. 1Fields returnFrom the message.
  2. 2load()Fills the props.
  3. 3The handlerChanges fields.
  4. 4rerender()Draws fields and props.

client.build runs it too, before render(), so the first message has its rows. On a click, refresh only rerenders, and the rows are new because load() has just read them.

@risa.handler
async def refresh(self, ctx: risa.ComponentContext) -> None:
    await ctx.rerender()

load() runs inside the three seconds from Answering in time, and its time counts toward auto-defer's 2 seconds.

A handler that changes what load() reads

load() runs before the handler, and not again.

A handler that sets self.game and calls ctx.rerender() draws the new heading over the old rows, because load() read them while game still held the old value.

Read them again in the handler. switch takes the game as a wire argument, as in Handlers and arguments, and after it scores, which risa injects as it does for load(). A handler's injected parameter needs no default.

@risa.handler
async def switch(
    self,
    ctx: risa.ComponentContext,
    game: str,
    scores: Scores,
) -> None:
    self.game = game
    self.rows = await scores.top(game)
    await ctx.rerender()

After a click on Go

Risa BotAPPToday at 12:00

Top players in go

1. Noor
2. Mika
3. Ren

ChessGoRefresh
Full file
"""A field for what to show, and a prop for what the database says right now."""

from __future__ import annotations

import asyncio
import os

import hikari
import msgspec

import risa
from risa import ui

_TOP = {
    "chess": ["Mika", "Jo", "Sam"],
    "go": ["Noor", "Mika", "Ren"],
}


class Scores:
    async def top(self, game: str) -> list[str]:
        await asyncio.sleep(0.1)
        return _TOP[game]


@risa.register(name="leaderboard")
class Leaderboard(risa.View):
    game: str = "chess"
    rows: risa.Prop[list[str]] = msgspec.field(default_factory=list[str])

    async def load(self, scores: Scores) -> None:
        self.rows = await scores.top(self.game)

    def render(self) -> ui.Layout:
        lines = [f"{n}. {name}" for n, name in enumerate(self.rows, start=1)]
        buttons = [
            ui.Button(
                risa.bind(self.switch, game),
                label=game.title(),
                disabled=game == self.game,
            )
            for game in _TOP
        ]
        return ui.Container(
            ui.TextDisplay(f"## Top players in {self.game}"),
            ui.TextDisplay("\n".join(lines)),
            ui.Row(*buttons, ui.Button(self.refresh, label="Refresh")),
        )

    @risa.handler
    async def refresh(self, ctx: risa.ComponentContext) -> None:
        await ctx.rerender()

    @risa.handler
    async def switch(
        self,
        ctx: risa.ComponentContext,
        game: str,
        scores: Scores,
    ) -> None:
        self.game = game
        self.rows = await scores.top(game)
        await ctx.rerender()


def add_dependencies(client: risa.Client) -> None:
    registry = client.di.registry_for(risa.Contexts.DEFAULT)
    registry.register_value(Scores, Scores())


def main() -> None:
    bot = hikari.GatewayBot(
        os.environ["DISCORD_TOKEN"],
        intents=hikari.Intents.ALL_UNPRIVILEGED | hikari.Intents.MESSAGE_CONTENT,
    )
    client = risa.client_from_app(bot)
    add_dependencies(client)
    client.add_view(Leaderboard)

    @bot.listen()
    async def on_message(event: hikari.GuildMessageCreateEvent) -> None:
        if event.content == "!leaderboard":
            rendered = await client.build(Leaderboard())
            await rendered.send_to(bot.rest, event.channel_id)

    bot.run()


if __name__ == "__main__":
    main()