KEEP IMPLEMENTING THE NEW COMPONENT SYSTEM INSTEAD OF OLD WIDGETS.
This commit is contained in:
parent
185c8fc1f8
commit
1f52474082
12 changed files with 192 additions and 151 deletions
|
@ -7,6 +7,8 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/mojosa-software/got/tg"
|
"github.com/mojosa-software/got/tg"
|
||||||
|
"math/rand"
|
||||||
|
"strconv"
|
||||||
)
|
)
|
||||||
|
|
||||||
type BotData struct {
|
type BotData struct {
|
||||||
|
@ -73,13 +75,7 @@ var (
|
||||||
backButton,
|
backButton,
|
||||||
)
|
)
|
||||||
|
|
||||||
navKeyboard = tg.NewKeyboard().Row(
|
navKeyboard =
|
||||||
tg.NewButton("Inc/Dec").Go("/inc-dec"),
|
|
||||||
).Row(
|
|
||||||
tg.NewButton("Mutate messages").Go("/mutate-messages"),
|
|
||||||
).Row(
|
|
||||||
tg.NewButton("Send location").Go("/send-location"),
|
|
||||||
).Reply()
|
|
||||||
|
|
||||||
sendLocationKeyboard = tg.NewKeyboard().Row(
|
sendLocationKeyboard = tg.NewKeyboard().Row(
|
||||||
tg.NewButton("Send location").
|
tg.NewButton("Send location").
|
||||||
|
@ -107,21 +103,34 @@ WithInitFunc(func(c *tg.Context) {
|
||||||
c.Session.Data = &SessionData{}
|
c.Session.Data = &SessionData{}
|
||||||
}).WithRootNode(tg.NewRootNode(
|
}).WithRootNode(tg.NewRootNode(
|
||||||
// The "/" widget.
|
// The "/" widget.
|
||||||
tg.NewPage().
|
tg.WidgetFunc(func(c *tg.Context) tg.UIs {
|
||||||
WithInline(
|
return tg.UIs{
|
||||||
|
|
||||||
tg.NewKeyboard().Row(
|
tg.NewKeyboard().Row(
|
||||||
tg.NewButton("GoT Github page").
|
tg.NewButton("GoT Github page").
|
||||||
WithUrl("https://github.com/mojosa-software/got"),
|
WithUrl("https://github.com/mojosa-software/got"),
|
||||||
).Inline().Widget(
|
).Inline().Widget(
|
||||||
fmt.Sprint(
|
fmt.Sprintf(
|
||||||
"The testing bot started!\n",
|
"Hello, %s"
|
||||||
"You can see the basics of usage in the ",
|
"The testing bot started!\n",
|
||||||
"cmd/test/main.go file!",
|
"You can see the basics of usage in the ",
|
||||||
|
"cmd/test/main.go file!",
|
||||||
|
c.SentFrom().UserName,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
).WithReply(
|
tg.NewKeyboard().Row(
|
||||||
navKeyboard.Widget("Choose what you are interested in"),
|
tg.NewButton("Inc/Dec").Go("/inc-dec"),
|
||||||
),
|
).Row(
|
||||||
|
tg.NewButton("Mutate messages").Go("/mutate-messages"),
|
||||||
|
).Row(
|
||||||
|
tg.NewButton("Send location").Go("/send-location"),
|
||||||
|
).Reply().Widget(
|
||||||
|
"Choose the point of your interest",
|
||||||
|
),
|
||||||
|
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
tg.NewNode(
|
tg.NewNode(
|
||||||
"mutate-messages", tg.NewPage().WithReply(
|
"mutate-messages", tg.NewPage().WithReply(
|
||||||
|
@ -216,6 +225,32 @@ WithInitFunc(func(c *tg.Context) {
|
||||||
bd := c.Bot.Data.(*BotData)
|
bd := c.Bot.Data.(*BotData)
|
||||||
c.Sendf("My name is %q", bd.Name)
|
c.Sendf("My name is %q", bd.Name)
|
||||||
}),
|
}),
|
||||||
|
tg.NewCommand("dynamic").
|
||||||
|
Desc("check of the dynamic work").
|
||||||
|
WidgetFunc(func(c *tg.Context){
|
||||||
|
nRow, nBtn := rand.Int()%10, rand.Int()%5
|
||||||
|
rows := []tg.ButtonRow{}
|
||||||
|
for i:=0 ; i<nRow ; i++ {
|
||||||
|
row := []*tg.Button{}
|
||||||
|
for j:=0 ; j<nBtn ; j++ {
|
||||||
|
row = append(row, tg.NewButton(
|
||||||
|
strconv.Itoa(i) + " " + strconv.Itoa(j),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
rows = append(rows, row)
|
||||||
|
}
|
||||||
|
kbd := tg.NewKeyboard(rows...).ActionFunc(func(c *tg.Context){
|
||||||
|
c.Sendf(u.)
|
||||||
|
})Inline().Widget("sample text")
|
||||||
|
c.Send(kbd)
|
||||||
|
kbdChn := c.RunWidget(kbd)
|
||||||
|
for u := range c.Input() {
|
||||||
|
if kbd.Filter(u, nil) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kbdChn.Send(u)
|
||||||
|
}
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
var gBeh = tg.NewGroupBehaviour().
|
var gBeh = tg.NewGroupBehaviour().
|
||||||
|
|
|
@ -47,10 +47,12 @@ func (bot *Bot) Debug(debug bool) *Bot {
|
||||||
return bot
|
return bot
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Send the Renderable to the specified session client side.
|
||||||
|
// Can be used for both group and private sessions.
|
||||||
func (bot *Bot) Send(
|
func (bot *Bot) Send(
|
||||||
sid SessionId, v Sendable,
|
sid SessionId, v Renderable,
|
||||||
) (*Message, error) {
|
) (*Message, error) {
|
||||||
config := v.SendConfig(sid, bot)
|
config := v.Render(sid, bot)
|
||||||
if config.Error != nil {
|
if config.Error != nil {
|
||||||
return nil, config.Error
|
return nil, config.Error
|
||||||
}
|
}
|
||||||
|
|
42
tg/compo.go
Normal file
42
tg/compo.go
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
package tg
|
||||||
|
|
||||||
|
type UIs []UI
|
||||||
|
|
||||||
|
// The type describes dynamic screen widget.
|
||||||
|
type Widget interface {
|
||||||
|
UIs(*Context) UIs
|
||||||
|
}
|
||||||
|
|
||||||
|
// The way to describe custom function based Widgets.
|
||||||
|
type WidgetFunc func(c *Context) UIs
|
||||||
|
func (fn WidgetFunc) UIs(c *Context) UIs {
|
||||||
|
return fn(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The type describes interfaces
|
||||||
|
// needed to be implemented to be endpoint handlers.
|
||||||
|
type UI interface {
|
||||||
|
Renderable
|
||||||
|
|
||||||
|
SetMessage(*Message)
|
||||||
|
GetMessage() *Message
|
||||||
|
Filterer
|
||||||
|
|
||||||
|
Server
|
||||||
|
}
|
||||||
|
|
||||||
|
type UiFunc func()
|
||||||
|
|
||||||
|
// The type to embed into potential components.
|
||||||
|
// Implements empty versions of interfaces
|
||||||
|
// and contains
|
||||||
|
type Compo struct{
|
||||||
|
*Message
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defalut setting message
|
||||||
|
func (compo Compo) SetMessage(msg *Message) { compo.Message = msg }
|
||||||
|
func (compo Compo) GetMessage() *Message { return compo.Message }
|
||||||
|
// Default non filtering filter. Always returns false.
|
||||||
|
func (compo Compo) Filter(_ *Update, _ *Message) bool {return false}
|
||||||
|
|
|
@ -7,6 +7,15 @@ import (
|
||||||
//"path"
|
//"path"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// General type function for faster typing.
|
||||||
|
type Func func(*Context)
|
||||||
|
func (f Func) Act(c *Context) {
|
||||||
|
f(c)
|
||||||
|
}
|
||||||
|
func (f Func) Serve(c *Context) {
|
||||||
|
f(c)
|
||||||
|
}
|
||||||
|
|
||||||
// The way to determine where the context is
|
// The way to determine where the context is
|
||||||
// related to.
|
// related to.
|
||||||
type ContextScope uint8
|
type ContextScope uint8
|
||||||
|
@ -185,7 +194,7 @@ func (c *Context) PathExist(pth Path) bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run widget in background returning the new input channel for it.
|
// Run widget in background returning the new input channel for it.
|
||||||
func (c *Context) RunWidget(widget Widget, args ...any) *UpdateChan {
|
func (c *Context) runWidget(widget Widget, args ...any) {
|
||||||
if widget == nil {
|
if widget == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -197,19 +206,32 @@ func (c *Context) RunWidget(widget Widget, args ...any) *UpdateChan {
|
||||||
arg = args
|
arg = args
|
||||||
}
|
}
|
||||||
|
|
||||||
updates := NewUpdateChan()
|
uis := widget.UI()
|
||||||
go func() {
|
chns := make(map[UI] *UpdateChan)
|
||||||
widget.Serve(
|
for _, ui := range uis {
|
||||||
c.Copy().
|
msg := c.Send(ui.Render(c))
|
||||||
WithInput(updates).
|
ui.SetMessage(msg)
|
||||||
WithArg(arg),
|
updates := NewUpdateChan()
|
||||||
)
|
go func() {
|
||||||
// To let widgets finish themselves before
|
ui.Serve(
|
||||||
// the channel is closed.
|
c.Copy().
|
||||||
updates.Close()
|
WithInput(updates).
|
||||||
}()
|
WithArg(arg),
|
||||||
|
)
|
||||||
|
// To let widgets finish themselves before
|
||||||
|
// the channel is closed.
|
||||||
|
updates.Close()
|
||||||
|
}()
|
||||||
|
chns[ui] = updates
|
||||||
|
}
|
||||||
|
|
||||||
return updates
|
for u := range c.skippedUpdates.Chan() {
|
||||||
|
for ui := range uis {
|
||||||
|
if !ui.Filter() {
|
||||||
|
chns[ui] <- u
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simple way to read strings for widgets.
|
// Simple way to read strings for widgets.
|
||||||
|
|
18
tg/filter.go
Normal file
18
tg/filter.go
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
package tg
|
||||||
|
|
||||||
|
|
||||||
|
// Implementing the interface provides way
|
||||||
|
// to know exactly what kind of updates
|
||||||
|
// the widget needs.
|
||||||
|
type Filterer interface {
|
||||||
|
// Return true if should filter the update
|
||||||
|
// and not send it inside the widget.
|
||||||
|
Filter(*Update) bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type FilterFunc func(*Update, MessageMap) bool
|
||||||
|
func (f FilterFunc) Filter(
|
||||||
|
u *Update, msgs MessageMap,
|
||||||
|
) bool {
|
||||||
|
return f(u, msgs)
|
||||||
|
}
|
22
tg/inline.go
22
tg/inline.go
|
@ -32,15 +32,15 @@ func (kbd *Inline) ToApi() tgbotapi.InlineKeyboardMarkup {
|
||||||
}
|
}
|
||||||
|
|
||||||
// The type implements message with an inline keyboard.
|
// The type implements message with an inline keyboard.
|
||||||
type InlineWidget struct {
|
type InlineCompo struct {
|
||||||
|
Compo
|
||||||
Text string
|
Text string
|
||||||
*Inline
|
*Inline
|
||||||
}
|
}
|
||||||
|
|
||||||
// Implementing the Sendable interface.
|
// Implementing the Sendable interface.
|
||||||
func (widget *InlineWidget) SendConfig(
|
func (widget *InlineWidget) SendConfig(
|
||||||
sid SessionId,
|
c *Context,
|
||||||
bot *Bot,
|
|
||||||
) (*SendConfig) {
|
) (*SendConfig) {
|
||||||
var text string
|
var text string
|
||||||
if widget.Text != "" {
|
if widget.Text != "" {
|
||||||
|
@ -49,7 +49,8 @@ func (widget *InlineWidget) SendConfig(
|
||||||
text = ">"
|
text = ">"
|
||||||
}
|
}
|
||||||
|
|
||||||
msgConfig := tgbotapi.NewMessage(sid.ToApi(), text)
|
sid := c.Session.Id.ToApi()
|
||||||
|
msgConfig := tgbotapi.NewMessage(sid, text)
|
||||||
msgConfig.ReplyMarkup = widget.ToApi()
|
msgConfig.ReplyMarkup = widget.ToApi()
|
||||||
|
|
||||||
ret := &SendConfig{}
|
ret := &SendConfig{}
|
||||||
|
@ -58,7 +59,7 @@ func (widget *InlineWidget) SendConfig(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Implementing the Widget interface.
|
// Implementing the Widget interface.
|
||||||
func (widget *InlineWidget) Serve(c *Context) {
|
func (widget *InlineCompo) Serve(c *Context) {
|
||||||
for u := range c.Input() {
|
for u := range c.Input() {
|
||||||
var act Action
|
var act Action
|
||||||
if u.CallbackQuery == nil {
|
if u.CallbackQuery == nil {
|
||||||
|
@ -90,20 +91,13 @@ func (widget *InlineWidget) Serve(c *Context) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (widget *InlineWidget) Filter(
|
func (compo *InlineCompo) Filter(u *Update) bool {
|
||||||
u *Update,
|
|
||||||
msgs MessageMap,
|
|
||||||
) bool {
|
|
||||||
if widget == nil || u.CallbackQuery == nil {
|
if widget == nil || u.CallbackQuery == nil {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
inlineMsg, inlineOk := msgs[""]
|
|
||||||
if !inlineOk {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if u.CallbackQuery.Message.MessageID !=
|
if u.CallbackQuery.Message.MessageID !=
|
||||||
inlineMsg.MessageID {
|
compo.Message.MessageID {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
2
tg/make.go
Normal file
2
tg/make.go
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
package tg
|
||||||
|
|
|
@ -3,9 +3,7 @@ package tg
|
||||||
import (
|
import (
|
||||||
//tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
//tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
||||||
)
|
)
|
||||||
|
/*
|
||||||
// The basic widget to provide keyboard functionality
|
|
||||||
// without implementing much.
|
|
||||||
type Page struct {
|
type Page struct {
|
||||||
Action Action
|
Action Action
|
||||||
Text string
|
Text string
|
||||||
|
@ -129,5 +127,5 @@ func (p *Page) Serve(c *Context) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
35
tg/reply.go
35
tg/reply.go
|
@ -59,51 +59,45 @@ func (kbd *Reply) Widget(text string) *ReplyWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
// The type implements reply keyboard widget.
|
// The type implements reply keyboard widget.
|
||||||
type ReplyWidget struct {
|
type ReplyCompo struct {
|
||||||
Text string
|
Text string
|
||||||
*Reply
|
*Reply
|
||||||
}
|
}
|
||||||
|
|
||||||
// Implementing the sendable interface.
|
// Implementing the sendable interface.
|
||||||
func (widget *ReplyWidget) SendConfig(
|
func (compo *ReplyCompo) Render(
|
||||||
sid SessionId,
|
c *Context,
|
||||||
bot *Bot,
|
|
||||||
) (*SendConfig) {
|
) (*SendConfig) {
|
||||||
if widget == nil {
|
sid := c.Session.Id.ToApi()
|
||||||
msgConfig := tgbotapi.NewMessage(sid.ToApi(), ">")
|
if compo == nil {
|
||||||
|
msgConfig := tgbotapi.NewMessage(sid, ">")
|
||||||
msgConfig.ReplyMarkup = tgbotapi.NewRemoveKeyboard(true)
|
msgConfig.ReplyMarkup = tgbotapi.NewRemoveKeyboard(true)
|
||||||
return &SendConfig{
|
return &SendConfig{
|
||||||
Message: &msgConfig,
|
Message: &msgConfig,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var text string
|
var text string
|
||||||
if widget.Text != "" {
|
if compo.Text != "" {
|
||||||
text = widget.Text
|
text = compo.Text
|
||||||
} else {
|
} else {
|
||||||
text = ">"
|
text = ">"
|
||||||
}
|
}
|
||||||
|
|
||||||
msgConfig := tgbotapi.NewMessage(sid.ToApi(), text)
|
msgConfig := tgbotapi.NewMessage(sid, text)
|
||||||
msgConfig.ReplyMarkup = widget.ToApi()
|
msgConfig.ReplyMarkup = compo.ToApi()
|
||||||
|
|
||||||
ret := &SendConfig{}
|
ret := &SendConfig{}
|
||||||
ret.Message = &msgConfig
|
ret.Message = &msgConfig
|
||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
|
|
||||||
func (widget *ReplyWidget) Filter(
|
func (compo *ReplyCompo) Filter(
|
||||||
u *Update,
|
u *Update,
|
||||||
msgs MessageMap,
|
|
||||||
) bool {
|
) bool {
|
||||||
if widget == nil {
|
if compo == nil || u.Message == nil {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if u.Message == nil {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
_, ok := widget.ButtonMap()[u.Message.Text]
|
_, ok := widget.ButtonMap()[u.Message.Text]
|
||||||
if !ok {
|
if !ok {
|
||||||
if u.Message.Location != nil {
|
if u.Message.Location != nil {
|
||||||
|
@ -119,11 +113,8 @@ func (widget *ReplyWidget) Filter(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Implementing the Widget interface.
|
// Implementing the Widget interface.
|
||||||
func (widget *ReplyWidget) Serve(c *Context) {
|
func (compo *ReplyCompo) Serve(c *Context) {
|
||||||
for u := range c.Input() {
|
for u := range c.Input() {
|
||||||
if u.Message == nil || u.Message.Text == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
var btn *Button
|
var btn *Button
|
||||||
text := u.Message.Text
|
text := u.Message.Text
|
||||||
btns := widget.ButtonMap()
|
btns := widget.ButtonMap()
|
||||||
|
|
12
tg/send.go
12
tg/send.go
|
@ -5,16 +5,12 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type MessageId int64
|
type MessageId int64
|
||||||
type Image any
|
|
||||||
|
|
||||||
// Implementing the interface lets the
|
|
||||||
// value to be sent.
|
|
||||||
type Sendable interface {
|
|
||||||
SendConfig(SessionId, *Bot) *SendConfig
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Implementing the interface provides
|
||||||
|
// way to define what message will be
|
||||||
|
// sent to the side of a user.
|
||||||
type Renderable interface {
|
type Renderable interface {
|
||||||
Render(SessionId, *Bot) ([]*SendConfig)
|
Render(*Context) (*SendConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
type Errorer interface {
|
type Errorer interface {
|
||||||
|
|
8
tg/server.go
Normal file
8
tg/server.go
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
package tg
|
||||||
|
|
||||||
|
// Implementing the interface provides
|
||||||
|
// the way to define how to handle updates.
|
||||||
|
type Server interface {
|
||||||
|
Serve(*Context)
|
||||||
|
}
|
||||||
|
|
69
tg/widget.go
69
tg/widget.go
|
@ -1,77 +1,10 @@
|
||||||
package tg
|
package tg
|
||||||
|
|
||||||
import (
|
|
||||||
//tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
|
|
||||||
//"fmt"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Maker[V any] interface {
|
type Maker[V any] interface {
|
||||||
Make(*Context) V
|
Make(*Context) V
|
||||||
}
|
}
|
||||||
|
|
||||||
type MakeFunc[V any] func(*Context) V
|
type RootHandler interface {
|
||||||
func (fn MakeFunc[V]) Make(c *Context) V {
|
|
||||||
return fn(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
type ArgMap = map[string] any
|
|
||||||
type ArgSlice = []any
|
|
||||||
type ArgList[V any] []V
|
|
||||||
|
|
||||||
// Implementing the interface provides
|
|
||||||
// ability to build your own widgets,
|
|
||||||
// aka components.
|
|
||||||
type Widget interface {
|
|
||||||
// When the update channel is closed
|
|
||||||
// widget MUST end its work.
|
|
||||||
// Mostly made by looping over the
|
|
||||||
// updates range.
|
|
||||||
Serve(*Context)
|
|
||||||
}
|
|
||||||
|
|
||||||
type DynamicWidget[W Widget] interface {
|
|
||||||
Maker[W]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Implementing the interface provides ability to
|
|
||||||
// be used as the root widget for contexts.
|
|
||||||
type RootWidget interface {
|
|
||||||
Widget
|
|
||||||
SetSub(Widget)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Implementing the interface provides way
|
|
||||||
// to know exactly what kind of updates
|
|
||||||
// the widget needs.
|
|
||||||
type Filterer interface {
|
|
||||||
// Return true if should filter the update
|
|
||||||
// and not send it inside the widget.
|
|
||||||
Filter(*Update, MessageMap) bool
|
|
||||||
}
|
|
||||||
|
|
||||||
type FilterFunc func(*Update, MessageMap) bool
|
|
||||||
func (f FilterFunc) Filter(
|
|
||||||
u *Update, msgs MessageMap,
|
|
||||||
) bool {
|
|
||||||
return f(u, msgs)
|
|
||||||
}
|
|
||||||
|
|
||||||
// General type function for faster typing.
|
|
||||||
type Func func(*Context)
|
|
||||||
func (f Func) Act(c *Context) {
|
|
||||||
f(c)
|
|
||||||
}
|
|
||||||
func (f Func) Serve(c *Context) {
|
|
||||||
f(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// The function that implements the Widget
|
|
||||||
// interface.
|
|
||||||
type WidgetFunc func(*Context)
|
|
||||||
|
|
||||||
func (wf WidgetFunc) Serve(c *Context) {
|
|
||||||
wf(c)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue