diff --git a/chat.go b/chat.go
deleted file mode 100644
index b3ba802..0000000
--- a/chat.go
+++ /dev/null
@@ -1,147 +0,0 @@
-package main
-
-import (
- "container/ring"
- "errors"
- "strings"
- "sync"
- "time"
-
- goaway "github.com/TwiN/go-away"
- "github.com/enescakir/emoji"
- "github.com/rs/xid"
- "golang.org/x/exp/slog"
- "golang.org/x/net/websocket"
-)
-
-const maxMessageSize = 256
-
-type message struct {
- User *user
- Content string
- Time time.Time
-}
-
-func newMessage(u *user, content string) (*message, error) {
- content = strings.TrimSpace(content)
- if content == "" {
- return nil, errors.New("message content cannot be empty")
- }
-
- rc := []rune(content)
- if len(rc) > maxMessageSize {
- content = string(rc[:maxMessageSize]) + "..."
- }
-
- content = goaway.Censor(emoji.Parse(content))
-
- return &message{
- User: u,
- Content: content,
- Time: time.Now().UTC(),
- }, nil
-}
-
-type client struct {
- user *user
- conns map[*websocket.Conn]struct{}
-}
-
-type room struct {
- mu sync.RWMutex
- clients map[string]*client
- messages *ring.Ring
-}
-
-func newRoom() *room {
- return &room{
- clients: make(map[string]*client),
- messages: ring.New(100),
- }
-}
-
-func (r *room) addClient(u *user, ws *websocket.Conn) bool {
- r.mu.Lock()
- defer r.mu.Unlock()
- id := u.ID.String()
- var added bool
- if _, found := r.clients[id]; !found {
- r.clients[id] = &client{
- user: u,
- conns: make(map[*websocket.Conn]struct{}),
- }
- added = true
- }
- r.clients[id].conns[ws] = struct{}{}
-
- return added
-}
-
-func (r *room) removeClient(id xid.ID, ws *websocket.Conn) bool {
- r.mu.Lock()
- defer r.mu.Unlock()
- _, found := r.clients[id.String()]
- if !found {
- return false
- }
-
- delete(r.clients[id.String()].conns, ws)
- if len(r.clients[id.String()].conns) == 0 {
- delete(r.clients, id.String())
- return true
- }
-
- return false
-}
-
-func (r *room) numUsers() int {
- r.mu.RLock()
- defer r.mu.RUnlock()
-
- return len(r.clients)
-}
-
-func (r *room) addMessage(m *message) {
- r.mu.Lock()
- r.messages.Value = m
- r.messages = r.messages.Next()
- r.mu.Unlock()
-}
-
-func (r *room) listMessages() []*message {
- r.mu.RLock()
- defer r.mu.RUnlock()
-
- messages := make([]*message, 0)
- r.messages.Do(func(m any) {
- messages = append(messages, m.(*message))
- })
-
- return messages
-}
-
-func (r *room) broadcast(b string) {
- r.mu.RLock()
- defer r.mu.RUnlock()
-
- for _, c := range r.clients {
- for conn := range c.conns {
- if err := websocket.Message.Send(conn, b); err != nil {
- slog.WarnContext(conn.Request().Context(), "send message", "err", "user.id", c.user.ID)
- }
- }
- }
-}
-
-func (r *room) broadcastCustom(fn func(u *user, conn *websocket.Conn) error) {
- r.mu.RLock()
- defer r.mu.RUnlock()
-
- for _, c := range r.clients {
- for conn := range c.conns {
- if err := fn(c.user, conn); err != nil {
- slog.WarnContext(conn.Request().Context(), "send message", "err", "user.id", c.user.ID)
- }
- }
- }
-}
diff --git a/chat/chat.go b/chat/chat.go
new file mode 100644
index 0000000..d291a2b
--- /dev/null
+++ b/chat/chat.go
@@ -0,0 +1,167 @@
+package chat
+
+import (
+ "container/ring"
+ "errors"
+ "io"
+ "strings"
+ "sync"
+ "time"
+
+ goaway "github.com/TwiN/go-away"
+ "github.com/enescakir/emoji"
+ "github.com/mgjules/chat-demo/user"
+ "github.com/rs/xid"
+ "golang.org/x/exp/slog"
+ "golang.org/x/net/websocket"
+)
+
+const maxMessageSize = 256
+
+// Message represents a single chat message.
+type Message struct {
+ User *user.User
+ Content string
+ Time time.Time
+}
+
+// NewMessage creates a new Message.
+func NewMessage(u *user.User, content string) (*Message, error) {
+ content = strings.TrimSpace(content)
+ if content == "" {
+ return nil, errors.New("message content cannot be empty")
+ }
+
+ rc := []rune(content)
+ if len(rc) > maxMessageSize {
+ content = string(rc[:maxMessageSize]) + "..."
+ }
+
+ content = goaway.Censor(emoji.Parse(content))
+
+ return &Message{
+ User: u,
+ Content: content,
+ Time: time.Now().UTC(),
+ }, nil
+}
+
+// Client represents the relationship between a user and websocket connections.
+type Client struct {
+ user *user.User
+ conns map[*websocket.Conn]struct{}
+}
+
+// Room holds the state of a single chat room.
+type Room struct {
+ mu sync.RWMutex
+ clients map[string]*Client
+ messages *ring.Ring
+}
+
+// NewRoom creates a new Room.
+func NewRoom() *Room {
+ return &Room{
+ clients: make(map[string]*Client),
+ messages: ring.New(100),
+ }
+}
+
+// AddClient adds a websocket connection to a user as a client
+// If the user does not already have a connection, thus no client
+// it will be created and the method will return true.
+func (r *Room) AddClient(u *user.User, ws *websocket.Conn) bool {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ id := u.ID.String()
+ var added bool
+ if _, found := r.clients[id]; !found {
+ r.clients[id] = &Client{
+ user: u,
+ conns: make(map[*websocket.Conn]struct{}),
+ }
+ added = true
+ }
+ r.clients[id].conns[ws] = struct{}{}
+
+ return added
+}
+
+// RemoveClient removes a websocket connection from a user.
+// If the user does not have any websocket connection, its client will be removed
+// and the method will return true.
+func (r *Room) RemoveClient(id xid.ID, ws *websocket.Conn) bool {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ _, found := r.clients[id.String()]
+ if !found {
+ return false
+ }
+
+ delete(r.clients[id.String()].conns, ws)
+ if len(r.clients[id.String()].conns) == 0 {
+ delete(r.clients, id.String())
+ return true
+ }
+
+ return false
+}
+
+// NumUsers return the current number of users as clients.
+func (r *Room) NumUsers() uint64 {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+
+ return uint64(len(r.clients))
+}
+
+// AddMessage adds a new chat message.
+func (r *Room) AddMessage(m *Message) {
+ r.mu.Lock()
+ r.messages.Value = m
+ r.messages = r.messages.Next()
+ r.mu.Unlock()
+}
+
+// Messages returns the list of messages.
+func (r *Room) Messages() []*Message {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+
+ messages := make([]*Message, 0)
+ r.messages.Do(func(m any) {
+ messages = append(messages, m.(*Message))
+ })
+
+ return messages
+}
+
+// Write implements the io.Writer interface.
+func (r *Room) Write(p []byte) (int, error) {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+
+ writers := make([]io.Writer, 0)
+ for _, c := range r.clients {
+ for conn := range c.conns {
+ writers = append(writers, conn)
+ }
+ }
+
+ return io.MultiWriter(writers...).Write(p)
+}
+
+// IterateClients executes a function fn
+// (e.g. a custom send mechanism or personalized messages per client) for all the clients.
+func (r *Room) IterateClients(fn func(u *user.User, conn *websocket.Conn) error) {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+
+ for _, c := range r.clients {
+ for conn := range c.conns {
+ if err := fn(c.user, conn); err != nil {
+ slog.WarnContext(conn.Request().Context(), "send message", "err", "user.id", c.user.ID)
+ }
+ }
+ }
+}
diff --git a/go.mod b/go.mod
index 2273bca..abc04d7 100644
--- a/go.mod
+++ b/go.mod
@@ -4,8 +4,8 @@ go 1.20
require (
github.com/TwiN/go-away v1.6.10
+ github.com/a-h/templ v0.2.334
github.com/enescakir/emoji v1.0.0
- github.com/flosch/pongo2/v6 v6.0.0
github.com/go-chi/chi/v5 v5.0.10
github.com/go-chi/jwtauth/v5 v5.1.1
github.com/go-faker/faker/v4 v4.1.1
@@ -20,7 +20,6 @@ require (
require (
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
- github.com/kr/pretty v0.3.1 // indirect
github.com/lestrrat-go/blackmagic v1.0.1 // indirect
github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/httprc v1.0.4 // indirect
diff --git a/go.sum b/go.sum
index f1effd5..92a0414 100644
--- a/go.sum
+++ b/go.sum
@@ -1,6 +1,7 @@
github.com/TwiN/go-away v1.6.10 h1:ScxGvhyJPu7VqLJJCpVx9vXBlQXi4wme3Vwx4z1WeC4=
github.com/TwiN/go-away v1.6.10/go.mod h1:e0adzvKFM6LIbU+K8pczlqYMaoH/6OwdvQEqg9wSRSU=
-github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/a-h/templ v0.2.334 h1:/mKupkgHGeSSeC0KiGRvmUoRGQJuku9VGVhRP1CeWgY=
+github.com/a-h/templ v0.2.334/go.mod h1:6Lfhsl3Z4/vXl7jjEjkJRCqoWDGjDnuKgzjYMDSddas=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -9,8 +10,6 @@ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etly
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0=
github.com/enescakir/emoji v1.0.0 h1:W+HsNql8swfCQFtioDGDHCHri8nudlK1n5p2rHCJoog=
github.com/enescakir/emoji v1.0.0/go.mod h1:Bt1EKuLnKDTYpLALApstIkAjdDrS/8IAgTkKp+WKFD0=
-github.com/flosch/pongo2/v6 v6.0.0 h1:lsGru8IAzHgIAw6H2m4PCyleO58I40ow6apih0WprMU=
-github.com/flosch/pongo2/v6 v6.0.0/go.mod h1:CuDpFm47R0uGGE7z13/tTlt1Y6zdxvr2RLT5LJhsHEU=
github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk=
github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/jwtauth/v5 v5.1.1 h1:Pjixqu5YkjE9sCLpzE01L0Q4sQzJIPdo7uz9r8ftp/c=
@@ -19,12 +18,9 @@ github.com/go-faker/faker/v4 v4.1.1 h1:zkxj/JH/aezB4R6cTEMKU7qcVScGhlB3qRtF3D7K+
github.com/go-faker/faker/v4 v4.1.1/go.mod h1:uuNc0PSRxF8nMgjGrrrU4Nw5cF30Jc6Kd0/FUTTYbhg=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
+github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
-github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
-github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
-github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
-github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lestrrat-go/blackmagic v1.0.1 h1:lS5Zts+5HIC/8og6cGHb0uCcNCa3OUt1ygh3Qz2Fe80=
github.com/lestrrat-go/blackmagic v1.0.1/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU=
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
@@ -38,11 +34,8 @@ github.com/lestrrat-go/jwx/v2 v2.0.12/go.mod h1:Mq4KN1mM7bp+5z/W5HS8aCNs5RKZ911G
github.com/lestrrat-go/option v1.0.0/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
-github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
-github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
@@ -105,7 +98,6 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/index.html b/index.html
deleted file mode 100644
index 098121b..0000000
--- a/index.html
+++ /dev/null
@@ -1,166 +0,0 @@
-
-
-
-
-
-
- Chat Demo
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Demo
- {% block online %}
-
{{ num_users }} user{{ num_users|pluralize }}
- {% endblock %}
-
-
-
{{ user.Name }}
-
-
-
-
-
-
- {% block form %}
-
- {% endblock %}
-
-
- {% block error %}
-
- {% if error %}
-
{{ error }}
- {% endif %}
-
- {% endblock %}
-
-
-
Copyright (c) {% now "2006" %}. All rights
- reserved.
-
-
-
-
-
\ No newline at end of file
diff --git a/limiter.go b/limiter.go
index 869acbf..dffae64 100644
--- a/limiter.go
+++ b/limiter.go
@@ -4,6 +4,7 @@ import (
"sync"
"time"
+ "github.com/mgjules/chat-demo/user"
"golang.org/x/time/rate"
)
@@ -18,7 +19,7 @@ func newLimiters() *limiters {
}
}
-func (l *limiters) add(u *user, d time.Duration, b int) *rate.Limiter {
+func (l *limiters) add(u *user.User, d time.Duration, b int) *rate.Limiter {
l.mu.RLock()
limiter, found := l.limiters[u.ID.String()]
l.mu.RUnlock()
@@ -34,7 +35,7 @@ func (l *limiters) add(u *user, d time.Duration, b int) *rate.Limiter {
return limiter
}
-func (l *limiters) remove(u *user) {
+func (l *limiters) remove(u *user.User) {
l.mu.Lock()
delete(l.limiters, u.ID.String())
l.mu.Unlock()
diff --git a/main.go b/main.go
index 7d0dbaf..5f07d2b 100644
--- a/main.go
+++ b/main.go
@@ -1,7 +1,6 @@
package main
import (
- "embed"
"errors"
"fmt"
"io"
@@ -9,7 +8,6 @@ import (
"os"
"time"
- "github.com/flosch/pongo2/v6"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/jwtauth/v5"
@@ -17,14 +15,14 @@ import (
"github.com/go-faker/faker/v4/pkg/options"
"github.com/joho/godotenv"
"github.com/lestrrat-go/jwx/v2/jwt"
+ "github.com/mgjules/chat-demo/chat"
+ "github.com/mgjules/chat-demo/templates"
+ "github.com/mgjules/chat-demo/user"
"github.com/rs/xid"
"golang.org/x/exp/slog"
"golang.org/x/net/websocket"
)
-//go:embed *.html
-var tpls embed.FS
-
func main() {
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
slog.SetDefault(logger)
@@ -61,20 +59,14 @@ func run() error {
r.Use(middleware.Heartbeat("/ping"))
r.Use(jwtauth.Verifier(jwt))
- ts := pongo2.NewSet("tpls", pongo2.NewFSLoader(tpls))
- t, err := ts.FromFile("index.html")
- if err != nil {
- return fmt.Errorf("failed to load index.html template: %w", err)
- }
-
- room := newRoom()
+ room := chat.NewRoom()
// Seeding random messages in room.
for i := 0; i < 1000; i++ {
- msg, _ := newMessage(
- newUser(),
+ msg, _ := chat.NewMessage(
+ user.New(),
faker.Sentence(options.WithGenerateUniqueValues(false)),
)
- room.addMessage(msg)
+ room.AddMessage(msg)
}
l := newLimiters()
@@ -83,8 +75,8 @@ func run() error {
r.Group(func(r chi.Router) {
r.Use(protected)
- r.Get("/", index(t, room))
- r.Handle("/chatroom", websocket.Handler(chat(t, room, l)))
+ r.Get("/", index(room))
+ r.Handle("/chatroom", websocket.Handler(chatroom(room, l)))
})
r.Get("/login", login(jwt))
@@ -109,7 +101,7 @@ func login(auth *jwtauth.JWTAuth) http.HandlerFunc {
// Create a fake user and use it as claim to encode a jwt token.
_, t, err := auth.Encode(map[string]any{
- "user": newUser(),
+ "user": user.New(),
})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -147,7 +139,7 @@ func protected(next http.Handler) http.Handler {
return
}
- ctx := addUserToContext(r.Context(), &user{
+ ctx := user.AddToContext(r.Context(), &user.User{
ID: id,
Name: u["Name"].(string),
})
@@ -156,17 +148,12 @@ func protected(next http.Handler) http.Handler {
})
}
-func index(t *pongo2.Template, room *room) http.HandlerFunc {
+func index(room *chat.Room) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
- user := userFromContext(r.Context())
+ user := user.FromContext(r.Context())
w.Header().Set("Content-Type", "text/html; charset=utf-8")
- if err := t.ExecuteWriter(pongo2.Context{
- "user": user,
- "messages": room.listMessages(),
- "num_users": room.numUsers(),
- "disabled": false,
- }, w); err != nil {
+ if err := templates.Page(user, room, false, "").Render(r.Context(), w); err != nil {
slog.ErrorContext(r.Context(), "render index template", "err", err, "user.id", user.ID)
w.Write([]byte("failed to render index template"))
}
@@ -178,44 +165,35 @@ type data struct {
Headers map[string]string `json:"HEADERS"`
}
-func chat(t *pongo2.Template, r *room, l *limiters) func(ws *websocket.Conn) {
+func chatroom(r *chat.Room, l *limiters) func(ws *websocket.Conn) {
return func(ws *websocket.Conn) {
ws.MaxPayloadBytes = 2 << 10 // 2KB
defer ws.Close()
// Retrieve user from context.
ctx := ws.Request().Context()
- u := userFromContext(ctx)
- added := r.addClient(u, ws)
+ u := user.FromContext(ctx)
+ added := r.AddClient(u, ws)
logger := slog.Default().With("user.id", u.ID)
// Remove client from room when user disconnects.
defer func() {
- if r.removeClient(u.ID, ws) {
+ if r.RemoveClient(u.ID, ws) {
// If user is fully disconnected, remove limiter.
l.remove(u)
// Update number of user online for all users.
- res, err := t.ExecuteBlocks(pongo2.Context{
- "num_users": r.numUsers(),
- }, []string{"online"})
- if err != nil {
+ if err := templates.ChatHeaderNumUsers(r.NumUsers()).Render(ctx, r); err != nil {
logger.ErrorContext(ctx, "render online template", "err", err)
- return
}
- r.broadcast(res["online"])
}
}()
// If added, update number of user online for all users.
if added {
- res, err := t.ExecuteBlocks(pongo2.Context{
- "num_users": r.numUsers(),
- }, []string{"online"})
- if err != nil {
+ if err := templates.ChatHeaderNumUsers(r.NumUsers()).Render(ctx, r); err != nil {
logger.ErrorContext(ctx, "render online template", "err", err)
return
}
- r.broadcast(res["online"])
}
limiter := l.add(u, 5*time.Second, 3)
@@ -231,16 +209,10 @@ func chat(t *pongo2.Template, r *room, l *limiters) func(ws *websocket.Conn) {
logger.ErrorContext(ctx, "receive message", "err", err)
// Inform user something went wrong.
- res, err := t.ExecuteBlocks(pongo2.Context{
- "error": "could not read your message",
- }, []string{"error"})
- if err != nil {
+ if err := templates.ChatError("could not read your message").Render(ctx, ws); err != nil {
logger.ErrorContext(ctx, "render error template", "err", err)
break
}
- if err := websocket.Message.Send(ws, res["error"]); err != nil {
- logger.ErrorContext(ctx, "send message", "err", err)
- }
continue
}
@@ -249,18 +221,16 @@ func chat(t *pongo2.Template, r *room, l *limiters) func(ws *websocket.Conn) {
if !limiter.Allow() {
// Inform the current user to slow down and
// disable the form until limiter allows.
- res, err := t.ExecuteBlocks(pongo2.Context{
- "error": "why so fast?",
- "disabled": true,
- }, []string{"error", "form"})
- if err != nil {
- logger.ErrorContext(ctx, "render error and form templates", "err", err)
+ if err := templates.ChatForm(true).Render(ctx, ws); err != nil {
+ logger.ErrorContext(ctx, "render form template", "err", err)
break
}
- if err := websocket.Message.Send(ws, res["error"]+res["form"]); err != nil {
- logger.ErrorContext(ctx, "send message", "err", err)
+ if err := templates.ChatError("please slow down").Render(ctx, ws); err != nil {
+ logger.ErrorContext(ctx, "render error template", "err", err)
+ break
}
+ // Wait until user is no more rate-limited
if err := limiter.Wait(ctx); err != nil {
logger.ErrorContext(ctx, "limiter wait", "err", err)
continue
@@ -268,72 +238,49 @@ func chat(t *pongo2.Template, r *room, l *limiters) func(ws *websocket.Conn) {
// Re-enable the form.
// Clear the error for the current user.
- res, err = t.ExecuteBlocks(pongo2.Context{
- "error": "",
- "disabled": false,
- }, []string{"error", "form"})
- if err != nil {
- logger.ErrorContext(ctx, "render error and form templates", "err", err)
+ if err := templates.ChatForm(false).Render(ctx, ws); err != nil {
+ logger.ErrorContext(ctx, "render form template", "err", err)
break
}
- if err := websocket.Message.Send(ws, res["error"]+res["form"]); err != nil {
- logger.ErrorContext(ctx, "send message", "err", err)
+ if err := templates.ChatError("").Render(ctx, ws); err != nil {
+ logger.ErrorContext(ctx, "render error template", "err", err)
+ break
}
continue
}
// Create and add the message to the room.
- msg, err := newMessage(u, d.Message)
+ msg, err := chat.NewMessage(u, d.Message)
if err != nil {
// Send back an error if we could not create message.
// Could be a validation error.
- res, err := t.ExecuteBlocks(pongo2.Context{
- "error": err.Error(),
- }, []string{"error"})
- if err != nil {
+ if err := templates.ChatError(err.Error()).Render(ctx, ws); err != nil {
logger.ErrorContext(ctx, "render error template", "err", err)
break
}
- if err := websocket.Message.Send(ws, res["error"]); err != nil {
- logger.ErrorContext(ctx, "send message", "err", err)
- }
continue
}
- r.addMessage(msg)
-
- // Broadcast message to all clients including the current user.
- r.broadcastCustom(func(u *user, conn *websocket.Conn) error {
- // Broadcast message to all clients including the current user.
- res, err := t.ExecuteBlocks(pongo2.Context{
- "user": u,
- "msg": msg,
- }, []string{"message"})
- if err != nil {
+ r.AddMessage(msg)
+
+ // Broadcast personalized message to all clients including the current user.
+ r.IterateClients(func(u *user.User, conn *websocket.Conn) error {
+ if err := templates.ChatMessageWrapped(u, msg).Render(ctx, conn); err != nil {
return fmt.Errorf("render message template: %w", err)
}
- if err := websocket.Message.Send(
- conn,
- ``+res["message"]+`
`,
- ); err != nil {
- return fmt.Errorf("send message: %w", err)
- }
return nil
})
// Reset the form and clear the error for the current user.
- res, err := t.ExecuteBlocks(pongo2.Context{
- "error": "",
- "disabled": false,
- }, []string{"error", "form"})
- if err != nil {
- logger.ErrorContext(ctx, "render error and form templates", "err", err)
+ if err := templates.ChatForm(false).Render(ctx, ws); err != nil {
+ logger.ErrorContext(ctx, "render form template", "err", err)
break
}
- if err := websocket.Message.Send(ws, res["error"]+res["form"]); err != nil {
- logger.ErrorContext(ctx, "send message", "err", err)
+ if err := templates.ChatError("").Render(ctx, ws); err != nil {
+ logger.ErrorContext(ctx, "render error template", "err", err)
+ break
}
}
}
diff --git a/templates/chat.templ b/templates/chat.templ
new file mode 100644
index 0000000..77ae0f8
--- /dev/null
+++ b/templates/chat.templ
@@ -0,0 +1,138 @@
+package templates
+
+import (
+ "strconv"
+ "time"
+
+ "github.com/mgjules/chat-demo/chat"
+ "github.com/mgjules/chat-demo/user"
+)
+
+templ ChatHeaderNumUsers(numUsers uint64) {
+
+ { strconv.Itoa(int(numUsers)) }
+ user
+ if numUsers > 1 {
+ s
+ }
+
+}
+
+templ ChatHeader(numUsers uint64, userName string) {
+
+
+
Demo
+ @ChatHeaderNumUsers(numUsers)
+
+
{ userName }
+
+}
+
+templ ChatMessageWrapped(user *user.User, message *chat.Message) {
+
+ @ChatMessage(user, message)
+
+}
+
+templ ChatMessage(user *user.User, message *chat.Message) {
+
+
+ if user.ID != message.User.ID {
+
{ message.User.Name }
+ }
+
+
{ message.Content }
+
+
+
+
+}
+
+templ ChatMessages(user *user.User, messages []*chat.Message) {
+
+}
+
+templ ChatForm(disabled bool) {
+
+}
+
+templ ChatError(cErr string) {
+
+ if cErr != "" {
+
{ cErr }
+ }
+
+}
+
+templ ChatFooter() {
+ Copyright (c) { time.Now().Format("2006") }. All rights reserved.
+}
+
+templ Chat(user *user.User, room *chat.Room, disabled bool, cErr string) {
+
+
+
+ @ChatHeader(room.NumUsers(), user.Name)@ChatMessages(user, room.Messages())@ChatForm(disabled)@ChatError(cErr)@ChatFooter()
+}
+
diff --git a/templates/chat_templ.go b/templates/chat_templ.go
new file mode 100644
index 0000000..6c2cac7
--- /dev/null
+++ b/templates/chat_templ.go
@@ -0,0 +1,533 @@
+// Code generated by templ@v0.2.334 DO NOT EDIT.
+
+package templates
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import "context"
+import "io"
+import "bytes"
+
+import (
+ "strconv"
+ "time"
+
+ "github.com/mgjules/chat-demo/chat"
+ "github.com/mgjules/chat-demo/user"
+)
+
+func ChatHeaderNumUsers(numUsers uint64) templ.Component {
+ return templ.ComponentFunc(func(ctx context.Context, w io.Writer) (err error) {
+ templBuffer, templIsBuffer := w.(*bytes.Buffer)
+ if !templIsBuffer {
+ templBuffer = templ.GetBuffer()
+ defer templ.ReleaseBuffer(templBuffer)
+ }
+ ctx = templ.InitializeContext(ctx)
+ var_1 := templ.GetChildren(ctx)
+ if var_1 == nil {
+ var_1 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ _, err = templBuffer.WriteString("")
+ if err != nil {
+ return err
+ }
+ var var_2 string = strconv.Itoa(int(numUsers))
+ _, err = templBuffer.WriteString(templ.EscapeString(var_2))
+ if err != nil {
+ return err
+ }
+ _, err = templBuffer.WriteString(" ")
+ if err != nil {
+ return err
+ }
+ var_3 := `user`
+ _, err = templBuffer.WriteString(var_3)
+ if err != nil {
+ return err
+ }
+ _, err = templBuffer.WriteString(" ")
+ if err != nil {
+ return err
+ }
+ if numUsers > 1 {
+ var_4 := `s`
+ _, err = templBuffer.WriteString(var_4)
+ if err != nil {
+ return err
+ }
+ }
+ _, err = templBuffer.WriteString("
")
+ if err != nil {
+ return err
+ }
+ if !templIsBuffer {
+ _, err = templBuffer.WriteTo(w)
+ }
+ return err
+ })
+}
+
+func ChatHeader(numUsers uint64, userName string) templ.Component {
+ return templ.ComponentFunc(func(ctx context.Context, w io.Writer) (err error) {
+ templBuffer, templIsBuffer := w.(*bytes.Buffer)
+ if !templIsBuffer {
+ templBuffer = templ.GetBuffer()
+ defer templ.ReleaseBuffer(templBuffer)
+ }
+ ctx = templ.InitializeContext(ctx)
+ var_5 := templ.GetChildren(ctx)
+ if var_5 == nil {
+ var_5 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ _, err = templBuffer.WriteString("")
+ if err != nil {
+ return err
+ }
+ var_7 := `Demo`
+ _, err = templBuffer.WriteString(var_7)
+ if err != nil {
+ return err
+ }
+ _, err = templBuffer.WriteString("
")
+ if err != nil {
+ return err
+ }
+ err = ChatHeaderNumUsers(numUsers).Render(ctx, templBuffer)
+ if err != nil {
+ return err
+ }
+ _, err = templBuffer.WriteString("
")
+ if err != nil {
+ return err
+ }
+ var var_8 string = userName
+ _, err = templBuffer.WriteString(templ.EscapeString(var_8))
+ if err != nil {
+ return err
+ }
+ _, err = templBuffer.WriteString("
")
+ if err != nil {
+ return err
+ }
+ if !templIsBuffer {
+ _, err = templBuffer.WriteTo(w)
+ }
+ return err
+ })
+}
+
+func ChatMessageWrapped(user *user.User, message *chat.Message) templ.Component {
+ return templ.ComponentFunc(func(ctx context.Context, w io.Writer) (err error) {
+ templBuffer, templIsBuffer := w.(*bytes.Buffer)
+ if !templIsBuffer {
+ templBuffer = templ.GetBuffer()
+ defer templ.ReleaseBuffer(templBuffer)
+ }
+ ctx = templ.InitializeContext(ctx)
+ var_9 := templ.GetChildren(ctx)
+ if var_9 == nil {
+ var_9 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ _, err = templBuffer.WriteString("")
+ if err != nil {
+ return err
+ }
+ err = ChatMessage(user, message).Render(ctx, templBuffer)
+ if err != nil {
+ return err
+ }
+ _, err = templBuffer.WriteString("
")
+ if err != nil {
+ return err
+ }
+ if !templIsBuffer {
+ _, err = templBuffer.WriteTo(w)
+ }
+ return err
+ })
+}
+
+func ChatMessage(user *user.User, message *chat.Message) templ.Component {
+ return templ.ComponentFunc(func(ctx context.Context, w io.Writer) (err error) {
+ templBuffer, templIsBuffer := w.(*bytes.Buffer)
+ if !templIsBuffer {
+ templBuffer = templ.GetBuffer()
+ defer templ.ReleaseBuffer(templBuffer)
+ }
+ ctx = templ.InitializeContext(ctx)
+ var_10 := templ.GetChildren(ctx)
+ if var_10 == nil {
+ var_10 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ var var_11 = []any{templ.KV("flex justify-end", user.ID == message.User.ID), "overflow-anchor-none"}
+ err = templ.RenderCSSItems(ctx, templBuffer, var_11...)
+ if err != nil {
+ return err
+ }
+ _, err = templBuffer.WriteString("")
+ if err != nil {
+ return err
+ }
+ if user.ID != message.User.ID {
+ _, err = templBuffer.WriteString("
")
+ if err != nil {
+ return err
+ }
+ var var_12 string = message.User.Name
+ _, err = templBuffer.WriteString(templ.EscapeString(var_12))
+ if err != nil {
+ return err
+ }
+ _, err = templBuffer.WriteString("
")
+ if err != nil {
+ return err
+ }
+ }
+ var var_13 = []any{templ.KV("mt-1", user.ID != message.User.ID), "flex flex-justify-between gap-2"}
+ err = templ.RenderCSSItems(ctx, templBuffer, var_13...)
+ if err != nil {
+ return err
+ }
+ _, err = templBuffer.WriteString("
")
+ if err != nil {
+ return err
+ }
+ var var_14 string = message.Content
+ _, err = templBuffer.WriteString(templ.EscapeString(var_14))
+ if err != nil {
+ return err
+ }
+ _, err = templBuffer.WriteString("
")
+ if err != nil {
+ return err
+ }
+ if !templIsBuffer {
+ _, err = templBuffer.WriteTo(w)
+ }
+ return err
+ })
+}
+
+func ChatMessages(user *user.User, messages []*chat.Message) templ.Component {
+ return templ.ComponentFunc(func(ctx context.Context, w io.Writer) (err error) {
+ templBuffer, templIsBuffer := w.(*bytes.Buffer)
+ if !templIsBuffer {
+ templBuffer = templ.GetBuffer()
+ defer templ.ReleaseBuffer(templBuffer)
+ }
+ ctx = templ.InitializeContext(ctx)
+ var_15 := templ.GetChildren(ctx)
+ if var_15 == nil {
+ var_15 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ _, err = templBuffer.WriteString("")
+ if err != nil {
+ return err
+ }
+ for _, msg := range messages {
+ err = ChatMessage(user, msg).Render(ctx, templBuffer)
+ if err != nil {
+ return err
+ }
+ }
+ _, err = templBuffer.WriteString("
")
+ if err != nil {
+ return err
+ }
+ if !templIsBuffer {
+ _, err = templBuffer.WriteTo(w)
+ }
+ return err
+ })
+}
+
+func ChatForm(disabled bool) templ.Component {
+ return templ.ComponentFunc(func(ctx context.Context, w io.Writer) (err error) {
+ templBuffer, templIsBuffer := w.(*bytes.Buffer)
+ if !templIsBuffer {
+ templBuffer = templ.GetBuffer()
+ defer templ.ReleaseBuffer(templBuffer)
+ }
+ ctx = templ.InitializeContext(ctx)
+ var_16 := templ.GetChildren(ctx)
+ if var_16 == nil {
+ var_16 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ _, err = templBuffer.WriteString("")
+ if err != nil {
+ return err
+ }
+ if !templIsBuffer {
+ _, err = templBuffer.WriteTo(w)
+ }
+ return err
+ })
+}
+
+func ChatError(cErr string) templ.Component {
+ return templ.ComponentFunc(func(ctx context.Context, w io.Writer) (err error) {
+ templBuffer, templIsBuffer := w.(*bytes.Buffer)
+ if !templIsBuffer {
+ templBuffer = templ.GetBuffer()
+ defer templ.ReleaseBuffer(templBuffer)
+ }
+ ctx = templ.InitializeContext(ctx)
+ var_17 := templ.GetChildren(ctx)
+ if var_17 == nil {
+ var_17 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ _, err = templBuffer.WriteString("")
+ if err != nil {
+ return err
+ }
+ if cErr != "" {
+ _, err = templBuffer.WriteString("
")
+ if err != nil {
+ return err
+ }
+ var var_18 string = cErr
+ _, err = templBuffer.WriteString(templ.EscapeString(var_18))
+ if err != nil {
+ return err
+ }
+ _, err = templBuffer.WriteString("
")
+ if err != nil {
+ return err
+ }
+ }
+ _, err = templBuffer.WriteString("
")
+ if err != nil {
+ return err
+ }
+ if !templIsBuffer {
+ _, err = templBuffer.WriteTo(w)
+ }
+ return err
+ })
+}
+
+func ChatFooter() templ.Component {
+ return templ.ComponentFunc(func(ctx context.Context, w io.Writer) (err error) {
+ templBuffer, templIsBuffer := w.(*bytes.Buffer)
+ if !templIsBuffer {
+ templBuffer = templ.GetBuffer()
+ defer templ.ReleaseBuffer(templBuffer)
+ }
+ ctx = templ.InitializeContext(ctx)
+ var_19 := templ.GetChildren(ctx)
+ if var_19 == nil {
+ var_19 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ _, err = templBuffer.WriteString("")
+ if err != nil {
+ return err
+ }
+ var_20 := `Copyright (c) `
+ _, err = templBuffer.WriteString(var_20)
+ if err != nil {
+ return err
+ }
+ var var_21 string = time.Now().Format("2006")
+ _, err = templBuffer.WriteString(templ.EscapeString(var_21))
+ if err != nil {
+ return err
+ }
+ var_22 := `. All rights reserved.`
+ _, err = templBuffer.WriteString(var_22)
+ if err != nil {
+ return err
+ }
+ _, err = templBuffer.WriteString("
")
+ if err != nil {
+ return err
+ }
+ if !templIsBuffer {
+ _, err = templBuffer.WriteTo(w)
+ }
+ return err
+ })
+}
+
+func Chat(user *user.User, room *chat.Room, disabled bool, cErr string) templ.Component {
+ return templ.ComponentFunc(func(ctx context.Context, w io.Writer) (err error) {
+ templBuffer, templIsBuffer := w.(*bytes.Buffer)
+ if !templIsBuffer {
+ templBuffer = templ.GetBuffer()
+ defer templ.ReleaseBuffer(templBuffer)
+ }
+ ctx = templ.InitializeContext(ctx)
+ var_23 := templ.GetChildren(ctx)
+ if var_23 == nil {
+ var_23 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ _, err = templBuffer.WriteString("")
+ if err != nil {
+ return err
+ }
+ err = ChatHeader(room.NumUsers(), user.Name).Render(ctx, templBuffer)
+ if err != nil {
+ return err
+ }
+ err = ChatMessages(user, room.Messages()).Render(ctx, templBuffer)
+ if err != nil {
+ return err
+ }
+ err = ChatForm(disabled).Render(ctx, templBuffer)
+ if err != nil {
+ return err
+ }
+ err = ChatError(cErr).Render(ctx, templBuffer)
+ if err != nil {
+ return err
+ }
+ err = ChatFooter().Render(ctx, templBuffer)
+ if err != nil {
+ return err
+ }
+ _, err = templBuffer.WriteString("
")
+ if err != nil {
+ return err
+ }
+ if !templIsBuffer {
+ _, err = templBuffer.WriteTo(w)
+ }
+ return err
+ })
+}
diff --git a/templates/generate.go b/templates/generate.go
new file mode 100644
index 0000000..e0d4b07
--- /dev/null
+++ b/templates/generate.go
@@ -0,0 +1,3 @@
+package templates
+
+//go:generate templ generate
diff --git a/templates/page.templ b/templates/page.templ
new file mode 100644
index 0000000..08da684
--- /dev/null
+++ b/templates/page.templ
@@ -0,0 +1,37 @@
+package templates
+
+import (
+ "github.com/mgjules/chat-demo/chat"
+ "github.com/mgjules/chat-demo/user"
+)
+
+templ Page(user *user.User, room *chat.Room, disabled bool, cErr string) {
+
+
+
+
+
+ Chat Demo
+
+
+
+
+
+
+
+ @Chat(user, room, disabled, cErr)
+
+}
+
diff --git a/templates/page_templ.go b/templates/page_templ.go
new file mode 100644
index 0000000..8f6ee31
--- /dev/null
+++ b/templates/page_templ.go
@@ -0,0 +1,104 @@
+// Code generated by templ@v0.2.334 DO NOT EDIT.
+
+package templates
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import "context"
+import "io"
+import "bytes"
+
+import (
+ "github.com/mgjules/chat-demo/chat"
+ "github.com/mgjules/chat-demo/user"
+)
+
+func Page(user *user.User, room *chat.Room, disabled bool, cErr string) templ.Component {
+ return templ.ComponentFunc(func(ctx context.Context, w io.Writer) (err error) {
+ templBuffer, templIsBuffer := w.(*bytes.Buffer)
+ if !templIsBuffer {
+ templBuffer = templ.GetBuffer()
+ defer templ.ReleaseBuffer(templBuffer)
+ }
+ ctx = templ.InitializeContext(ctx)
+ var_1 := templ.GetChildren(ctx)
+ if var_1 == nil {
+ var_1 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ _, err = templBuffer.WriteString("")
+ if err != nil {
+ return err
+ }
+ var_2 := `Chat Demo`
+ _, err = templBuffer.WriteString(var_2)
+ if err != nil {
+ return err
+ }
+ _, err = templBuffer.WriteString("")
+ if err != nil {
+ return err
+ }
+ err = Chat(user, room, disabled, cErr).Render(ctx, templBuffer)
+ if err != nil {
+ return err
+ }
+ _, err = templBuffer.WriteString("")
+ if err != nil {
+ return err
+ }
+ if !templIsBuffer {
+ _, err = templBuffer.WriteTo(w)
+ }
+ return err
+ })
+}
diff --git a/user.go b/user/user.go
similarity index 64%
rename from user.go
rename to user/user.go
index 976b0f9..ca34055 100644
--- a/user.go
+++ b/user/user.go
@@ -1,4 +1,4 @@
-package main
+package user
import (
"context"
@@ -13,19 +13,19 @@ type userContextKey string
const userCtxKey userContextKey = "user"
-// user holds information about a user.
-type user struct {
+// User holds information about a user.
+type User struct {
ID xid.ID
Name string
}
-// newUser creates a new user.
-func newUser() *user {
+// New creates a new User.
+func New() *User {
id := xid.New()
// Prevents faker from tracking duplicates since it does that in a non-threadsafe manner.
// Instead we seed the Name with sections of the ID.
nonunique := options.WithGenerateUniqueValues(false)
- return &user{
+ return &User{
ID: id,
Name: fmt.Sprintf("%s %s (%s%s)",
faker.FirstName(nonunique), faker.LastName(nonunique), id.String()[4:8], id.String()[15:],
@@ -33,12 +33,14 @@ func newUser() *user {
}
}
-func addUserToContext(ctx context.Context, user *user) context.Context {
+// AddToContext adds a user to the context.
+func AddToContext(ctx context.Context, user *User) context.Context {
return context.WithValue(ctx, userCtxKey, user)
}
-func userFromContext(ctx context.Context) *user {
- u, ok := ctx.Value(userCtxKey).(*user)
+// FromContext retrieves a user from the context.
+func FromContext(ctx context.Context) *User {
+ u, ok := ctx.Value(userCtxKey).(*User)
if !ok {
return nil
}