add error handling
This commit is contained in:
parent
5233bbb234
commit
784995728c
13 changed files with 127 additions and 42 deletions
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"chat/ws"
|
"chat/ws"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/coder/websocket"
|
||||||
"github.com/maddalax/htmgo/framework/h"
|
"github.com/maddalax/htmgo/framework/h"
|
||||||
"github.com/maddalax/htmgo/framework/service"
|
"github.com/maddalax/htmgo/framework/service"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -44,7 +45,15 @@ func (m *Manager) StartListener() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) OnConnected(e ws.SocketEvent) {
|
func (m *Manager) OnConnected(e ws.SocketEvent) {
|
||||||
|
room, _ := m.service.GetRoom(e.RoomId)
|
||||||
|
|
||||||
|
if room == nil {
|
||||||
|
m.socketManager.CloseWithError(e.Id, websocket.StatusPolicyViolation, "invalid room")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Printf("User %s connected to room %s\n", e.Id, e.RoomId)
|
fmt.Printf("User %s connected to room %s\n", e.Id, e.RoomId)
|
||||||
|
|
||||||
user, err := m.queries.GetUserBySessionId(context.Background(), e.Id)
|
user, err := m.queries.GetUserBySessionId(context.Background(), e.Id)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/maddalax/htmgo/framework/h"
|
"github.com/maddalax/htmgo/framework/h"
|
||||||
"github.com/maddalax/htmgo/framework/hx"
|
|
||||||
"github.com/maddalax/htmgo/framework/js"
|
"github.com/maddalax/htmgo/framework/js"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -18,7 +17,24 @@ func ChatRoom(ctx *h.RequestContext) *h.Page {
|
||||||
h.TriggerChildren(),
|
h.TriggerChildren(),
|
||||||
h.HxExtension("ws"),
|
h.HxExtension("ws"),
|
||||||
),
|
),
|
||||||
|
|
||||||
h.Attribute("ws-connect", fmt.Sprintf("/ws/chat/%s", roomId)),
|
h.Attribute("ws-connect", fmt.Sprintf("/ws/chat/%s", roomId)),
|
||||||
|
|
||||||
|
h.HxOnWsOpen(
|
||||||
|
js.ConsoleLog("Connected to chat room"),
|
||||||
|
),
|
||||||
|
|
||||||
|
h.HxOnWsClose(
|
||||||
|
js.EvalJs(`
|
||||||
|
const reason = e.detail.event.reason
|
||||||
|
if(['invalid room', 'no session'].includes(reason)) {
|
||||||
|
window.location.href = '/';
|
||||||
|
} else {
|
||||||
|
console.error('Connection closed:', e.detail.event)
|
||||||
|
}
|
||||||
|
`),
|
||||||
|
),
|
||||||
|
|
||||||
h.Class("flex flex-row min-h-screen bg-neutral-100"),
|
h.Class("flex flex-row min-h-screen bg-neutral-100"),
|
||||||
|
|
||||||
// Sidebar for connected users
|
// Sidebar for connected users
|
||||||
|
|
@ -28,11 +44,11 @@ func ChatRoom(ctx *h.RequestContext) *h.Page {
|
||||||
h.Div(
|
h.Div(
|
||||||
h.Class("flex flex-col flex-grow gap-4 bg-white rounded p-4"),
|
h.Class("flex flex-col flex-grow gap-4 bg-white rounded p-4"),
|
||||||
|
|
||||||
h.OnEvent("hx-on::ws-after-message",
|
h.HxAfterWsMessage(
|
||||||
// language=JavaScript
|
js.EvalJsOnSibling("#messages",
|
||||||
js.EvalJsOnSibling("#messages", `
|
// language=JavaScript
|
||||||
element.scrollTop = element.scrollHeight;
|
`element.scrollTop = element.scrollHeight;`),
|
||||||
`)),
|
),
|
||||||
|
|
||||||
// Chat Messages
|
// Chat Messages
|
||||||
h.Div(
|
h.Div(
|
||||||
|
|
@ -66,10 +82,9 @@ func MessageInput() *h.Element {
|
||||||
h.Class("p-4 rounded-md border border-slate-200 w-full"),
|
h.Class("p-4 rounded-md border border-slate-200 w-full"),
|
||||||
h.Name("message"),
|
h.Name("message"),
|
||||||
h.Placeholder("Type a message..."),
|
h.Placeholder("Type a message..."),
|
||||||
h.HxBeforeWsSend(
|
h.HxAfterWsSend(
|
||||||
js.SetValue(""),
|
js.SetValue(""),
|
||||||
),
|
),
|
||||||
h.OnEvent(hx.KeyDownEvent, js.SubmitFormOnEnter()),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"chat/components"
|
"chat/components"
|
||||||
"github.com/maddalax/htmgo/framework/h"
|
"github.com/maddalax/htmgo/framework/h"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func CreateOrJoinRoom(ctx *h.RequestContext) *h.Partial {
|
func CreateOrJoinRoom(ctx *h.RequestContext) *h.Partial {
|
||||||
|
|
@ -26,9 +27,10 @@ func CreateOrJoinRoom(ctx *h.RequestContext) *h.Partial {
|
||||||
|
|
||||||
var redirect = func(path string) *h.Partial {
|
var redirect = func(path string) *h.Partial {
|
||||||
cookie := &http.Cookie{
|
cookie := &http.Cookie{
|
||||||
Name: "session_id",
|
Name: "session_id",
|
||||||
Value: user.SessionID,
|
Value: user.SessionID,
|
||||||
Path: "/",
|
Path: "/",
|
||||||
|
Expires: time.Now().Add(24 * 30 * time.Hour),
|
||||||
}
|
}
|
||||||
return h.SwapManyPartialWithHeaders(
|
return h.SwapManyPartialWithHeaders(
|
||||||
ctx,
|
ctx,
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/maddalax/htmgo/framework/h"
|
"github.com/maddalax/htmgo/framework/h"
|
||||||
"github.com/maddalax/htmgo/framework/service"
|
"github.com/maddalax/htmgo/framework/service"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -14,31 +15,30 @@ func Handle() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
cc := r.Context().Value(h.RequestContextKey).(*h.RequestContext)
|
cc := r.Context().Value(h.RequestContextKey).(*h.RequestContext)
|
||||||
|
|
||||||
sessionCookie, err := r.Cookie("session_id")
|
sessionCookie, _ := r.Cookie("session_id")
|
||||||
|
|
||||||
cookies := r.Cookies()
|
|
||||||
|
|
||||||
println(cookies)
|
|
||||||
// no session
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c, err := websocket.Accept(w, r, nil)
|
c, err := websocket.Accept(w, r, nil)
|
||||||
|
|
||||||
locator := cc.ServiceLocator()
|
|
||||||
manager := service.Get[SocketManager](locator)
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if sessionCookie == nil {
|
||||||
|
slog.Error("session cookie not found")
|
||||||
|
c.Close(websocket.StatusPolicyViolation, "no session")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
locator := cc.ServiceLocator()
|
||||||
|
manager := service.Get[SocketManager](locator)
|
||||||
|
|
||||||
sessionId := sessionCookie.Value
|
sessionId := sessionCookie.Value
|
||||||
|
|
||||||
roomId := chi.URLParam(r, "id")
|
roomId := chi.URLParam(r, "id")
|
||||||
|
|
||||||
if roomId == "" {
|
if roomId == "" {
|
||||||
manager.CloseWithError(sessionId, "invalid room")
|
slog.Error("invalid room", slog.String("room_id", roomId))
|
||||||
|
manager.CloseWithError(sessionId, websocket.StatusPolicyViolation, "invalid room")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -52,7 +52,8 @@ func Handle() http.HandlerFunc {
|
||||||
var v map[string]any
|
var v map[string]any
|
||||||
err = wsjson.Read(context.Background(), c, &v)
|
err = wsjson.Read(context.Background(), c, &v)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
manager.CloseWithError(sessionId, "failed to read message")
|
slog.Error("failed to read message", slog.String("room_id", roomId))
|
||||||
|
manager.CloseWithError(sessionId, websocket.StatusInternalError, "failed to read message")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if v != nil {
|
if v != nil {
|
||||||
|
|
|
||||||
|
|
@ -106,18 +106,18 @@ func (manager *SocketManager) OnClose(id string) {
|
||||||
manager.sockets.Delete(id)
|
manager.sockets.Delete(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (manager *SocketManager) CloseWithError(id string, message string) {
|
func (manager *SocketManager) CloseWithError(id string, code websocket.StatusCode, message string) {
|
||||||
conn := manager.Get(id)
|
conn := manager.Get(id)
|
||||||
if conn != nil {
|
if conn != nil {
|
||||||
defer manager.OnClose(id)
|
go manager.OnClose(id)
|
||||||
conn.Conn.Close(websocket.StatusInternalError, message)
|
conn.Conn.Close(code, message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (manager *SocketManager) Disconnect(id string) {
|
func (manager *SocketManager) Disconnect(id string) {
|
||||||
conn := manager.Get(id)
|
conn := manager.Get(id)
|
||||||
if conn != nil {
|
if conn != nil {
|
||||||
defer manager.OnClose(id)
|
go manager.OnClose(id)
|
||||||
_ = conn.Conn.CloseNow()
|
_ = conn.Conn.CloseNow()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
4
framework/assets/dist/htmgo.js
vendored
4
framework/assets/dist/htmgo.js
vendored
File diff suppressed because one or more lines are too long
|
|
@ -4,6 +4,8 @@ function kebabEventName(str: string) {
|
||||||
return str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()
|
return str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ignoredEvents = ['htmx:beforeProcessNode', 'htmx:afterProcessNode', 'htmx:beforeSwap', 'htmx:afterSwap', 'htmx:beforeOnLoad', 'htmx:afterOnLoad', 'htmx:configRequest', 'htmx:configResponse', 'htmx:responseError'];
|
||||||
|
|
||||||
function makeEvent(eventName: string, detail: any) {
|
function makeEvent(eventName: string, detail: any) {
|
||||||
let evt
|
let evt
|
||||||
if (window.CustomEvent && typeof window.CustomEvent === 'function') {
|
if (window.CustomEvent && typeof window.CustomEvent === 'function') {
|
||||||
|
|
@ -17,7 +19,9 @@ function makeEvent(eventName: string, detail: any) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function triggerChildren(target: HTMLElement, name: string, event: CustomEvent, triggered: Set<HTMLElement>) {
|
function triggerChildren(target: HTMLElement, name: string, event: CustomEvent, triggered: Set<HTMLElement>) {
|
||||||
event.detail.meta = 'trigger-children';
|
if(ignoredEvents.includes(name)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if (target && target.children) {
|
if (target && target.children) {
|
||||||
Array.from(target.children).forEach((e) => {
|
Array.from(target.children).forEach((e) => {
|
||||||
const kehab = kebabEventName(name);
|
const kehab = kebabEventName(name);
|
||||||
|
|
@ -25,6 +29,7 @@ function triggerChildren(target: HTMLElement, name: string, event: CustomEvent,
|
||||||
if (!triggered.has(e as HTMLElement)) {
|
if (!triggered.has(e as HTMLElement)) {
|
||||||
if(e.hasAttribute(eventName)) {
|
if(e.hasAttribute(eventName)) {
|
||||||
const newEvent = makeEvent(eventName.replace("hx-on::", "htmx:"), event.detail)
|
const newEvent = makeEvent(eventName.replace("hx-on::", "htmx:"), event.detail)
|
||||||
|
newEvent.detail.meta = 'trigger-children'
|
||||||
e.dispatchEvent(newEvent)
|
e.dispatchEvent(newEvent)
|
||||||
triggered.add(e as HTMLElement);
|
triggered.add(e as HTMLElement);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -406,12 +406,13 @@ htmx.defineExtension('ws', {
|
||||||
|
|
||||||
// Try to create websockets when elements are processed
|
// Try to create websockets when elements are processed
|
||||||
case 'htmx:beforeProcessNode':
|
case 'htmx:beforeProcessNode':
|
||||||
forEach(queryAttributeOnThisOrChildren(parent, 'ws-connect'), (child) => {
|
if(parent.hasAttribute("ws-connect")) {
|
||||||
ensureWebSocket(child);
|
ensureWebSocket(parent as HTMLElement);
|
||||||
});
|
}
|
||||||
forEach(queryAttributeOnThisOrChildren(parent, 'ws-send'), (child) => {
|
|
||||||
ensureWebSocketSend(child);
|
if(parent.hasAttribute("ws-send")) {
|
||||||
});
|
ensureWebSocketSend(parent as HTMLElement);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,34 @@ func OnEvent(event hx.Event, cmd ...Command) *LifeCycle {
|
||||||
return NewLifeCycle().OnEvent(event, cmd...)
|
return NewLifeCycle().OnEvent(event, cmd...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func HxBeforeWsMessage(cmd ...Command) *LifeCycle {
|
||||||
|
return NewLifeCycle().OnEvent(hx.WsBeforeMessageEvent, cmd...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func HxAfterWsMessage(cmd ...Command) *LifeCycle {
|
||||||
|
return NewLifeCycle().OnEvent(hx.WsAfterMessageEvent, cmd...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func OnSubmit(cmd ...Command) *LifeCycle {
|
||||||
|
return NewLifeCycle().OnEvent(hx.SubmitEvent, cmd...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func HxOnWsError(cmd ...Command) *LifeCycle {
|
||||||
|
return NewLifeCycle().OnEvent(hx.WsErrorEvent, cmd...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func HxOnWsClose(cmd ...Command) *LifeCycle {
|
||||||
|
return NewLifeCycle().OnEvent(hx.WsClosedEvent, cmd...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func HxOnWsConnecting(cmd ...Command) *LifeCycle {
|
||||||
|
return NewLifeCycle().OnEvent(hx.WsConnectingEvent, cmd...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func HxOnWsOpen(cmd ...Command) *LifeCycle {
|
||||||
|
return NewLifeCycle().OnEvent(hx.WsConnectedEvent, cmd...)
|
||||||
|
}
|
||||||
|
|
||||||
func HxBeforeWsSend(cmd ...Command) *LifeCycle {
|
func HxBeforeWsSend(cmd ...Command) *LifeCycle {
|
||||||
return NewLifeCycle().HxBeforeWsSend(cmd...)
|
return NewLifeCycle().HxBeforeWsSend(cmd...)
|
||||||
}
|
}
|
||||||
|
|
@ -279,6 +307,16 @@ func EvalJs(js string) ComplexJsCommand {
|
||||||
return NewComplexJsCommand(js)
|
return NewComplexJsCommand(js)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func PreventDefault() SimpleJsCommand {
|
||||||
|
// language=JavaScript
|
||||||
|
return SimpleJsCommand{Command: "event.preventDefault()"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ConsoleLog(text string) SimpleJsCommand {
|
||||||
|
// language=JavaScript
|
||||||
|
return SimpleJsCommand{Command: fmt.Sprintf("console.log('%s')", text)}
|
||||||
|
}
|
||||||
|
|
||||||
func SetValue(value string) SimpleJsCommand {
|
func SetValue(value string) SimpleJsCommand {
|
||||||
// language=JavaScript
|
// language=JavaScript
|
||||||
return SimpleJsCommand{Command: fmt.Sprintf("this.value = '%s'", value)}
|
return SimpleJsCommand{Command: fmt.Sprintf("this.value = '%s'", value)}
|
||||||
|
|
@ -288,7 +326,10 @@ func SubmitFormOnEnter() ComplexJsCommand {
|
||||||
// language=JavaScript
|
// language=JavaScript
|
||||||
return EvalJs(`
|
return EvalJs(`
|
||||||
if (event.code === 'Enter') {
|
if (event.code === 'Enter') {
|
||||||
self.form.dispatchEvent(new Event('submit', { cancelable: true }));
|
console.log('submitting form');
|
||||||
|
setTimeout(() => {
|
||||||
|
self.form.dispatchEvent(new Event('submit', { cancelable: true }));
|
||||||
|
}, 250)
|
||||||
}
|
}
|
||||||
`)
|
`)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,8 @@ type RenderContext struct {
|
||||||
func (ctx *RenderContext) AddScript(funcName string, body string) {
|
func (ctx *RenderContext) AddScript(funcName string, body string) {
|
||||||
script := fmt.Sprintf(`
|
script := fmt.Sprintf(`
|
||||||
<script id="%s">
|
<script id="%s">
|
||||||
function %s(self) {
|
function %s(self, event) {
|
||||||
|
let e = event;
|
||||||
%s
|
%s
|
||||||
}
|
}
|
||||||
</script>`, funcName, funcName, body)
|
</script>`, funcName, funcName, body)
|
||||||
|
|
@ -223,10 +224,10 @@ func (l *LifeCycle) Render(context *RenderContext) {
|
||||||
for _, command := range commands {
|
for _, command := range commands {
|
||||||
switch c := command.(type) {
|
switch c := command.(type) {
|
||||||
case SimpleJsCommand:
|
case SimpleJsCommand:
|
||||||
m[event] += fmt.Sprintf("%s;", c.Command)
|
m[event] += fmt.Sprintf("var self=this;var e=event;%s;", c.Command)
|
||||||
case ComplexJsCommand:
|
case ComplexJsCommand:
|
||||||
context.AddScript(c.TempFuncName, c.Command)
|
context.AddScript(c.TempFuncName, c.Command)
|
||||||
m[event] += fmt.Sprintf("%s(this);", c.TempFuncName)
|
m[event] += fmt.Sprintf("%s(this, event);", c.TempFuncName)
|
||||||
case *AttributeMapOrdered:
|
case *AttributeMapOrdered:
|
||||||
c.Each(func(key string, value string) {
|
c.Each(func(key string, value string) {
|
||||||
l.fromAttributeMap(event, key, value, context)
|
l.fromAttributeMap(event, key, value, context)
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,12 @@ const (
|
||||||
XhrProgressEvent Event = "htmx:xhr:progress"
|
XhrProgressEvent Event = "htmx:xhr:progress"
|
||||||
BeforeWsSendEvent Event = "htmx:wsBeforeSend"
|
BeforeWsSendEvent Event = "htmx:wsBeforeSend"
|
||||||
AfterWsSendEvent Event = "htmx:wsAfterSend"
|
AfterWsSendEvent Event = "htmx:wsAfterSend"
|
||||||
|
WsConnectedEvent Event = "htmx:wsOpen"
|
||||||
|
WsConnectingEvent Event = "htmx:wsConnecting"
|
||||||
|
WsClosedEvent Event = "htmx:wsClose"
|
||||||
|
WsErrorEvent Event = "htmx:wsError"
|
||||||
|
WsBeforeMessageEvent Event = "htmx:wsBeforeMessage"
|
||||||
|
WsAfterMessageEvent Event = "htmx:wsAfterMessage"
|
||||||
|
|
||||||
// RevealedEvent Misc Events
|
// RevealedEvent Misc Events
|
||||||
RevealedEvent Event = "revealed"
|
RevealedEvent Event = "revealed"
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,9 @@ var EvalJsOnParent = h.EvalJsOnParent
|
||||||
var SetClassOnSibling = h.SetClassOnSibling
|
var SetClassOnSibling = h.SetClassOnSibling
|
||||||
var RemoveClassOnSibling = h.RemoveClassOnSibling
|
var RemoveClassOnSibling = h.RemoveClassOnSibling
|
||||||
var Remove = h.Remove
|
var Remove = h.Remove
|
||||||
|
var PreventDefault = h.PreventDefault
|
||||||
var EvalJs = h.EvalJs
|
var EvalJs = h.EvalJs
|
||||||
|
var ConsoleLog = h.ConsoleLog
|
||||||
var SetValue = h.SetValue
|
var SetValue = h.SetValue
|
||||||
var SubmitFormOnEnter = h.SubmitFormOnEnter
|
var SubmitFormOnEnter = h.SubmitFormOnEnter
|
||||||
var InjectScript = h.InjectScript
|
var InjectScript = h.InjectScript
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,8 @@ OnClick(cmd ...Command) *LifeCycle
|
||||||
HxOnAfterSwap(cmd ...Command) *LifeCycle
|
HxOnAfterSwap(cmd ...Command) *LifeCycle
|
||||||
HxOnLoad(cmd ...Command) *LifeCycle
|
HxOnLoad(cmd ...Command) *LifeCycle
|
||||||
```
|
```
|
||||||
|
**Note:** Each command you attach to the event handler will be passed 'self' and 'event' (if applicable) as arguments.
|
||||||
|
'self' is the current element, and 'event' is the event object.
|
||||||
|
|
||||||
If you use the OnEvent directly, event names may be any [HTML DOM](https://www.w3schools.com/jsref/dom_obj_event.asp) events, or any [HTMX events](https://htmx.org/events/).
|
If you use the OnEvent directly, event names may be any [HTML DOM](https://www.w3schools.com/jsref/dom_obj_event.asp) events, or any [HTMX events](https://htmx.org/events/).
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue