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 - - - - - - - - - - - - - - - - - - - - - -
- -
-
-
Chatroom 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) { +
+
+
ChatroomDemo
+ @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_6 := `Chatroom` + _, err = templBuffer.WriteString(var_6) + if err != nil { + return err + } + _, 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 + } + 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 }