Multi-Room Chat (#4)
* # This is a combination of 4 commits. # This is the 1st commit message: Chatroom example # This is the commit message #2: Avatar styling # This is the commit message #3: Styling # This is the commit message #4: cleanup * Chatroom example Avatar styling Benchmark tests Cleanup ignore Files Cleanroom chatroom impl * Rewrite. * changes * Fix Deadlocks. Start the rooms. Fix styling. Random things. Bookmarklet. * Subset data * Rm file * Simplify User. Just Comparable. * Remove method.
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -33,3 +33,6 @@ go.work.sum
|
|||||||
|
|
||||||
# Via related
|
# Via related
|
||||||
.via
|
.via
|
||||||
|
|
||||||
|
# Air artifacts
|
||||||
|
*tmp/
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package via
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"github.com/go-via/via/h"
|
"github.com/go-via/via/h"
|
||||||
)
|
)
|
||||||
@@ -11,14 +12,85 @@ type actionTrigger struct {
|
|||||||
id string
|
id string
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnClick returns a via.h DOM node that triggers on click. It can be added
|
// ActionTriggerOption configures behavior of action triggers
|
||||||
// to element nodes in a view.
|
type ActionTriggerOption interface {
|
||||||
func (a *actionTrigger) OnClick() h.H {
|
apply(*triggerOpts)
|
||||||
return h.Data("on:click", fmt.Sprintf("@get('/_action/%s')", a.id))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnChange returns a via.h DOM node that triggers on input change. It can be added
|
type triggerOpts struct {
|
||||||
// to element nodes in a view.
|
hasSignal bool
|
||||||
func (a *actionTrigger) OnChange() h.H {
|
signalID string
|
||||||
return h.Data("on:change__debounce.200ms", fmt.Sprintf("@get('/_action/%s')", a.id))
|
value string
|
||||||
|
}
|
||||||
|
|
||||||
|
type withSignalOpt struct {
|
||||||
|
signalID string
|
||||||
|
value string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o withSignalOpt) apply(opts *triggerOpts) {
|
||||||
|
opts.hasSignal = true
|
||||||
|
opts.signalID = o.signalID
|
||||||
|
opts.value = o.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithSignal sets a signal value before triggering the action.
|
||||||
|
func WithSignal(sig *signal, value string) ActionTriggerOption {
|
||||||
|
return withSignalOpt{
|
||||||
|
signalID: sig.ID(),
|
||||||
|
value: fmt.Sprintf("'%s'", value),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithSignalInt sets a signal to an int value before triggering the action.
|
||||||
|
func WithSignalInt(sig *signal, value int) ActionTriggerOption {
|
||||||
|
return withSignalOpt{
|
||||||
|
signalID: sig.ID(),
|
||||||
|
value: strconv.Itoa(value),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildOnExpr(base string, opts *triggerOpts) string {
|
||||||
|
if !opts.hasSignal {
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("$%s=%s;%s", opts.signalID, opts.value, base)
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyOptions(options ...ActionTriggerOption) triggerOpts {
|
||||||
|
var opts triggerOpts
|
||||||
|
for _, opt := range options {
|
||||||
|
opt.apply(&opts)
|
||||||
|
}
|
||||||
|
return opts
|
||||||
|
}
|
||||||
|
|
||||||
|
func actionURL(id string) string {
|
||||||
|
return fmt.Sprintf("@get('/_action/%s')", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnClick returns a via.h DOM attribute that triggers on click. It can be added
|
||||||
|
// to element nodes in a view.
|
||||||
|
func (a *actionTrigger) OnClick(options ...ActionTriggerOption) h.H {
|
||||||
|
opts := applyOptions(options...)
|
||||||
|
return h.Data("on:click", buildOnExpr(actionURL(a.id), &opts))
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnChange returns a via.h DOM attribute that triggers on input change. It can be added
|
||||||
|
// to element nodes in a view.
|
||||||
|
func (a *actionTrigger) OnChange(options ...ActionTriggerOption) h.H {
|
||||||
|
opts := applyOptions(options...)
|
||||||
|
return h.Data("on:change__debounce.200ms", buildOnExpr(actionURL(a.id), &opts))
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnEnterKey returns a via.h DOM attribute that triggers when a key is pressed.
|
||||||
|
// key: optional, see https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key
|
||||||
|
// Example: OnKeyDown("Enter")
|
||||||
|
func (a *actionTrigger) OnKeyDown(key string, options ...ActionTriggerOption) h.H {
|
||||||
|
opts := applyOptions(options...)
|
||||||
|
var condition string
|
||||||
|
if key != "" {
|
||||||
|
condition = fmt.Sprintf("evt.key==='%s' &&", key)
|
||||||
|
}
|
||||||
|
return h.Data("on:keydown", fmt.Sprintf("%s%s", condition, buildOnExpr(actionURL(a.id), &opts)))
|
||||||
}
|
}
|
||||||
|
|||||||
8
go.mod
8
go.mod
@@ -4,11 +4,17 @@ go 1.25.3
|
|||||||
|
|
||||||
require maragu.dev/gomponents v1.2.0
|
require maragu.dev/gomponents v1.2.0
|
||||||
|
|
||||||
require github.com/starfederation/datastar-go v1.0.3
|
require (
|
||||||
|
github.com/starfederation/datastar-go v1.0.3
|
||||||
|
github.com/stretchr/testify v1.10.0
|
||||||
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/CAFxX/httpcompression v0.0.9 // indirect
|
github.com/CAFxX/httpcompression v0.0.9 // indirect
|
||||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/klauspost/compress v1.18.0 // indirect
|
github.com/klauspost/compress v1.18.0 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
10
internal/examples/chatroom/ADR.md
Normal file
10
internal/examples/chatroom/ADR.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
## ADR
|
||||||
|
|
||||||
|
- Support Multiple Rooms
|
||||||
|
Not single chat room toy problem.
|
||||||
|
|
||||||
|
- Rooms are generic
|
||||||
|
They know nothing of their data. Just store it. Reusable for different usecases.
|
||||||
|
|
||||||
|
- Server controls push frequency
|
||||||
|
Debounce to every 400ms, if dirty.
|
||||||
274
internal/examples/chatroom/main.go
Normal file
274
internal/examples/chatroom/main.go
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
|
||||||
|
"github.com/go-via/via"
|
||||||
|
"github.com/go-via/via/h"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
WithSignal = via.WithSignal
|
||||||
|
WithSignalInt = via.WithSignalInt
|
||||||
|
)
|
||||||
|
|
||||||
|
// To drive heavy traffic: start several browsers and put this in the console:
|
||||||
|
// setInterval(() => document.querySelector('input').dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true})), 500);
|
||||||
|
// Or, as a bookmarklet:
|
||||||
|
// javascript:(function(){setInterval(()=>{const input=document.querySelector('input');if(input){input.dispatchEvent(new KeyboardEvent('keydown',{key:'Enter',code:'Enter',keyCode:13,bubbles:true}))}},500)})();
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
v := via.New()
|
||||||
|
v.Config(via.Options{
|
||||||
|
DevMode: true,
|
||||||
|
DocumentTitle: "ViaChat",
|
||||||
|
LogLvl: via.LogLevelInfo,
|
||||||
|
Plugins: []via.Plugin{via.SigQuitPlugin},
|
||||||
|
})
|
||||||
|
|
||||||
|
v.AppendToHead(
|
||||||
|
h.Link(h.Rel("stylesheet"), h.Href("https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css")),
|
||||||
|
h.StyleEl(h.Raw(`
|
||||||
|
body { margin: 0; }
|
||||||
|
main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
nav[role="tab-control"] ul li a[aria-current="page"] {
|
||||||
|
background-color: var(--pico-primary-background);
|
||||||
|
color: var(--pico-primary-inverse);
|
||||||
|
border-bottom: 2px solid var(--pico-primary);
|
||||||
|
}
|
||||||
|
.chat-message { display: flex; gap: 0.75rem; }
|
||||||
|
.avatar {
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--pico-muted-border-color);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
.bubble { flex: 1; }
|
||||||
|
.bubble p { margin: 0; }
|
||||||
|
.chat-history {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-bottom: calc(88px + env(safe-area-inset-bottom));
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.chat-history::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.chat-input {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: var(--pico-background-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 1rem calc(0.75rem + env(safe-area-inset-bottom));
|
||||||
|
border-top: 1px solid var(--pico-muted-border-color);
|
||||||
|
}
|
||||||
|
.chat-input fieldset {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
`)), h.Script(h.Raw(`
|
||||||
|
function scrollChatToBottom() {
|
||||||
|
const chatHistory = document.querySelector('.chat-history');
|
||||||
|
if (chatHistory) chatHistory.scrollTop = chatHistory.scrollHeight;
|
||||||
|
}
|
||||||
|
setInterval(scrollChatToBottom, 100);
|
||||||
|
`)),
|
||||||
|
)
|
||||||
|
rooms := NewRooms[Chat, UserInfo]("Clojure", "Dotnet", "Go", "Java", "JS", "Kotlin", "Python", "Rust")
|
||||||
|
rooms.Start()
|
||||||
|
|
||||||
|
v.Page("/", func(c *via.Context) {
|
||||||
|
roomName := c.Signal("Go")
|
||||||
|
|
||||||
|
// Need to be careful about reading signals: can cause race conditions.
|
||||||
|
// So use a string as much as possible.
|
||||||
|
var roomNameString string
|
||||||
|
currentUser := NewUserInfo(randAnimal())
|
||||||
|
statement := c.Signal("")
|
||||||
|
|
||||||
|
var currentRoom *Room[Chat, UserInfo]
|
||||||
|
|
||||||
|
switchRoom := func() {
|
||||||
|
newRoom, _ := rooms.Get(string(roomName.String()))
|
||||||
|
fmt.Println(">> switchRoom to ", newRoom.Name)
|
||||||
|
if currentRoom != nil && currentRoom != newRoom {
|
||||||
|
fmt.Println("LEAVING old room")
|
||||||
|
currentRoom.Leave(¤tUser)
|
||||||
|
}
|
||||||
|
newRoom.Join(&UserAndSync[Chat, UserInfo]{user: ¤tUser, sync: c})
|
||||||
|
currentRoom = newRoom
|
||||||
|
roomNameString = newRoom.Name
|
||||||
|
c.Sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
switchRoomAction := c.Action(func() {
|
||||||
|
switchRoom()
|
||||||
|
})
|
||||||
|
|
||||||
|
switchRoom()
|
||||||
|
|
||||||
|
say := c.Action(func() {
|
||||||
|
msg := statement.String()
|
||||||
|
if msg == "" {
|
||||||
|
// For testing, generate random stuff.
|
||||||
|
msg = thingsDevsSay()
|
||||||
|
} else {
|
||||||
|
statement.SetValue("")
|
||||||
|
}
|
||||||
|
if currentRoom != nil {
|
||||||
|
currentRoom.UpdateData(func(chat *Chat) {
|
||||||
|
chat.Entries = append(chat.Entries, ChatEntry{
|
||||||
|
User: currentUser,
|
||||||
|
Message: msg,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
statement.SetValue("")
|
||||||
|
// Update the UI right away so feels snappy.
|
||||||
|
c.Sync()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
c.View(func() h.H {
|
||||||
|
var tabs []h.H
|
||||||
|
rooms.Visit(func(n string) {
|
||||||
|
if n == roomNameString {
|
||||||
|
tabs = append(tabs, h.Li(
|
||||||
|
h.A(
|
||||||
|
h.Href(""),
|
||||||
|
h.Attr("aria-current", "page"),
|
||||||
|
h.Text(n),
|
||||||
|
switchRoomAction.OnClick(WithSignal(roomName, n)),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
tabs = append(tabs, h.Li(
|
||||||
|
h.A(
|
||||||
|
h.Href("#"),
|
||||||
|
h.Text(n),
|
||||||
|
switchRoomAction.OnClick(via.WithSignal(roomName, n)),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
var messages []h.H
|
||||||
|
if currentRoom != nil {
|
||||||
|
chat := currentRoom.GetData(func(c *Chat) Chat {
|
||||||
|
n := len(c.Entries)
|
||||||
|
start := n - 50
|
||||||
|
if start < 0 {
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
trimmed := make([]ChatEntry, n-start)
|
||||||
|
copy(trimmed, c.Entries[start:])
|
||||||
|
out := *c
|
||||||
|
out.Entries = trimmed
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
for _, entry := range chat.Entries {
|
||||||
|
|
||||||
|
messageChildren := []h.H{h.Class("chat-message"), entry.User.Avatar()}
|
||||||
|
messageChildren = append(messageChildren,
|
||||||
|
h.Div(h.Class("bubble"),
|
||||||
|
h.P(h.Text(entry.Message)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
messages = append(messages, h.Div(messageChildren...))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chatHistory := []h.H{h.Class("chat-history")}
|
||||||
|
chatHistory = append(chatHistory, messages...)
|
||||||
|
|
||||||
|
return h.Main(h.Class("container"),
|
||||||
|
h.Nav(
|
||||||
|
h.Attr("role", "tab-control"),
|
||||||
|
h.Ul(tabs...),
|
||||||
|
),
|
||||||
|
h.Div(chatHistory...),
|
||||||
|
h.Div(
|
||||||
|
h.Class("chat-input"),
|
||||||
|
currentUser.Avatar(),
|
||||||
|
h.FieldSet(
|
||||||
|
h.Attr("role", "group"),
|
||||||
|
h.Input(
|
||||||
|
h.Type("text"),
|
||||||
|
h.Placeholder(fmt.Sprintf("%s says...", currentUser.Name)),
|
||||||
|
statement.Bind(),
|
||||||
|
h.Attr("autofocus"),
|
||||||
|
say.OnKeyDown("Enter"),
|
||||||
|
),
|
||||||
|
h.Button(h.Text("Say"), say.OnClick()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
v.Start()
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserInfo struct {
|
||||||
|
Name string
|
||||||
|
emoji string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUserInfo(name, emoji string) UserInfo {
|
||||||
|
return UserInfo{Name: name, emoji: emoji}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UserInfo) Avatar() h.H {
|
||||||
|
return h.Div(h.Class("avatar"), h.Attr("title", u.Name), h.Text(u.emoji))
|
||||||
|
}
|
||||||
|
|
||||||
|
type ChatEntry struct {
|
||||||
|
User UserInfo
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Chat struct {
|
||||||
|
Entries []ChatEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
func randAnimal() (string, string) {
|
||||||
|
adjectives := []string{"Happy", "Clever", "Brave", "Swift", "Gentle", "Wise", "Bold", "Calm", "Eager", "Fierce"}
|
||||||
|
|
||||||
|
animals := []string{"Panda", "Tiger", "Eagle", "Dolphin", "Fox", "Wolf", "Bear", "Hawk", "Otter", "Lion"}
|
||||||
|
whichAnimal := rand.Intn(len(animals))
|
||||||
|
|
||||||
|
emojis := []string{"🐼", "🐯", "🦅", "🐬", "🦊", "🐺", "🐻", "🦅", "🦦", "🦁"}
|
||||||
|
return adjectives[rand.Intn(len(adjectives))] + " " + animals[whichAnimal], emojis[whichAnimal]
|
||||||
|
}
|
||||||
|
|
||||||
|
var thingIdx = rand.Intn(len(things)) - 1
|
||||||
|
var things = []string{"I like turtles.", "How do you clean up signals?", "Just use Lisp.", "You're complecting things.",
|
||||||
|
"The internet is a series of tubes.", "Go is not a good language.", "I love Python.", "JavaScript is everywhere.", "Kotlin is great for Android.",
|
||||||
|
"Rust is memory safe.", "Dotnet is cross platform.", "Rewrite it in Rust", "Is it web scale?", "PRs welcome.", "Have you tried turning it off and on again?",
|
||||||
|
"Clojure has macros.", "Functional programming is the future.", "OOP is dead.", "Tabs are better than spaces.", "Spaces are better than tabs.",
|
||||||
|
"I use Emacs.", "Vim is the best editor.", "VSCode is bloated.", "I code in the browser.", "Serverless is the way to go.", "Containers are lightweight VMs.",
|
||||||
|
"Microservices are the future.", "Monoliths are easier to manage.", "Agile is just Scrum.", "Waterfall still has its place.", "DevOps is a culture.", "CI/CD is essential.",
|
||||||
|
"Testing is important.", "TDD saves time.", "BDD improves communication.", "Documentation is key.", "APIs should be RESTful.", "GraphQL is flexible.", "gRPC is efficient.",
|
||||||
|
"WebAssembly is the future of web apps.", "Progressive Web Apps are great.", "Single Page Applications can be overkill.", "Jamstack is modern web development.",
|
||||||
|
"CDNs improve performance.", "Edge computing reduces latency.", "5G will change everything.", "AI will take over coding.", "Machine learning is powerful.",
|
||||||
|
"Data science is in demand.", "Big data requires big storage.", "Cloud computing is ubiquitous.", "Hybrid cloud offers flexibility.", "Multi-cloud avoids vendor lock-in.",
|
||||||
|
"That can't possibly work", "First!", "Leeroy Jenkins!", "I love open source.", "Closed source has its place.", "Licensing is complicated."}
|
||||||
|
|
||||||
|
func thingsDevsSay() string {
|
||||||
|
|
||||||
|
thingIdx = (thingIdx + 1) % len(things)
|
||||||
|
return things[thingIdx]
|
||||||
|
|
||||||
|
}
|
||||||
163
internal/examples/chatroom/rooms.go
Normal file
163
internal/examples/chatroom/rooms.go
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Syncable interface {
|
||||||
|
Sync()
|
||||||
|
}
|
||||||
|
type UserAndSync[TR any, TU comparable] struct {
|
||||||
|
user *TU
|
||||||
|
sync Syncable
|
||||||
|
}
|
||||||
|
type Rooms[TR any, TU comparable] struct {
|
||||||
|
byName map[string]*Room[TR, TU]
|
||||||
|
names []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rs *Rooms[TR, TU]) Visit(fn func(n string)) {
|
||||||
|
for _, n := range rs.names {
|
||||||
|
fn(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rs *Rooms[TR, TU]) Get(n string) (*Room[TR, TU], bool) {
|
||||||
|
rm, ok := rs.byName[n]
|
||||||
|
return rm, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rs *Rooms[TR, TU]) Start() {
|
||||||
|
for _, rm := range rs.byName {
|
||||||
|
go rm.run()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rs *Rooms[TR, TU]) Stop() {
|
||||||
|
for _, rm := range rs.byName {
|
||||||
|
rm.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRooms seeds the rooms once at startup.
|
||||||
|
// Assumptions: rooms don't change. Should be sorted by name.
|
||||||
|
func NewRooms[TR any, TU comparable](names ...string) Rooms[TR, TU] {
|
||||||
|
byName := make(map[string]*Room[TR, TU])
|
||||||
|
for _, n := range names {
|
||||||
|
byName[n] = NewRoom[TR, TU](n)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Rooms[TR, TU]{byName, names}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Room[TR any, TU comparable] struct {
|
||||||
|
data TR
|
||||||
|
dataMu sync.RWMutex
|
||||||
|
members map[TU]Syncable
|
||||||
|
membersMu sync.RWMutex
|
||||||
|
Name string
|
||||||
|
join chan *UserAndSync[TR, TU]
|
||||||
|
leave chan *TU
|
||||||
|
done chan struct{}
|
||||||
|
stopChannel chan struct{}
|
||||||
|
dirty bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateData lets the calling function update the room data.
|
||||||
|
// Is called with a write lock - so should be *fast*
|
||||||
|
func (r *Room[TR, TU]) UpdateData(fn func(data *TR)) {
|
||||||
|
r.dataMu.Lock()
|
||||||
|
defer r.dataMu.Unlock()
|
||||||
|
fn(&r.data)
|
||||||
|
r.dirty = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Room[TR, TU]) Publish() {
|
||||||
|
r.dataMu.Lock()
|
||||||
|
if !r.dirty {
|
||||||
|
r.dataMu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
publishers := make([]Syncable, 0, len(r.members))
|
||||||
|
for _, sync := range r.members {
|
||||||
|
publishers = append(publishers, sync)
|
||||||
|
}
|
||||||
|
r.dirty = false
|
||||||
|
r.dataMu.Unlock()
|
||||||
|
|
||||||
|
// Now call Sync without holding the lock
|
||||||
|
for _, sync := range publishers {
|
||||||
|
sync.Sync()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get room data. This is a copy.
|
||||||
|
// Accepts an optional subset function to transform data before copying.
|
||||||
|
func (r *Room[TR, TU]) GetData(subsetFn ...func(*TR) TR) TR {
|
||||||
|
r.dataMu.RLock()
|
||||||
|
defer r.dataMu.RUnlock()
|
||||||
|
|
||||||
|
if len(subsetFn) == 0 || subsetFn[0] == nil {
|
||||||
|
return r.data
|
||||||
|
}
|
||||||
|
|
||||||
|
tmp := r.data
|
||||||
|
return subsetFn[0](&tmp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Room[TR, TU]) Join(us *UserAndSync[TR, TU]) {
|
||||||
|
r.join <- us
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Room[TR, TU]) Leave(u *TU) {
|
||||||
|
r.leave <- u
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Room[TR, TU]) MemberCount() int {
|
||||||
|
r.membersMu.RLock()
|
||||||
|
defer r.membersMu.RUnlock()
|
||||||
|
return len(r.members)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRoom[TR any, TU comparable](n string) *Room[TR, TU] {
|
||||||
|
return &Room[TR, TU]{
|
||||||
|
Name: n,
|
||||||
|
join: make(chan *UserAndSync[TR, TU], 5),
|
||||||
|
leave: make(chan *TU, 5),
|
||||||
|
stopChannel: make(chan struct{}),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
members: make(map[TU]Syncable),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Room[TR, TU]) run() {
|
||||||
|
defer close(r.done)
|
||||||
|
publishTicker := time.NewTicker(400 * time.Millisecond)
|
||||||
|
defer publishTicker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case usrAndSync := <-r.join:
|
||||||
|
fmt.Println("Joining: ", *usrAndSync.user)
|
||||||
|
r.membersMu.Lock()
|
||||||
|
r.members[*usrAndSync.user] = usrAndSync.sync
|
||||||
|
r.membersMu.Unlock()
|
||||||
|
case usr := <-r.leave:
|
||||||
|
fmt.Println("Leaving: ", *usr)
|
||||||
|
r.membersMu.Lock()
|
||||||
|
delete(r.members, *usr)
|
||||||
|
r.membersMu.Unlock()
|
||||||
|
case <-publishTicker.C:
|
||||||
|
r.Publish()
|
||||||
|
case <-r.stopChannel:
|
||||||
|
return // exit goroutine
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Room[TR, TU]) stop() {
|
||||||
|
close(r.stopChannel)
|
||||||
|
<-r.done // wait for run() to finish
|
||||||
|
}
|
||||||
100
internal/examples/chatroom/rooms_test.go
Normal file
100
internal/examples/chatroom/rooms_test.go
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Statement struct {
|
||||||
|
text string
|
||||||
|
author TestUserInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
type RoomData struct {
|
||||||
|
convo []Statement
|
||||||
|
}
|
||||||
|
|
||||||
|
type TestUserInfo struct {
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoomsZero(t *testing.T) {
|
||||||
|
rooms := NewRooms[RoomData, TestUserInfo]()
|
||||||
|
assert.NotNil(t, rooms)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoomsMany(t *testing.T) {
|
||||||
|
names := []string{"a", "b"}
|
||||||
|
rooms := NewRooms[RoomData, TestUserInfo](names...)
|
||||||
|
assert.NotNil(t, rooms)
|
||||||
|
assert.Equal(t, 2, len(rooms.names))
|
||||||
|
|
||||||
|
// Visit
|
||||||
|
seen := 0
|
||||||
|
rooms.Visit(func(name string) { seen++ })
|
||||||
|
assert.Equal(t, seen, 2)
|
||||||
|
|
||||||
|
// GetRoom fail
|
||||||
|
_, ok := rooms.Get("z")
|
||||||
|
assert.False(t, ok)
|
||||||
|
// GetRoom
|
||||||
|
rm, ok := rooms.Get("a")
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.NotNil(t, rm)
|
||||||
|
assert.Equal(t, string("a"), rm.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
type DummySyncable struct {
|
||||||
|
room *Room[RoomData, TestUserInfo]
|
||||||
|
timesCalled int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ds *DummySyncable) Sync() {
|
||||||
|
// Data() hits deadlock conditions from Publish()
|
||||||
|
ds.room.GetData()
|
||||||
|
ds.timesCalled++
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoomJoinLeaveChannels(t *testing.T) {
|
||||||
|
rooms := NewRooms[RoomData, TestUserInfo](string("a"))
|
||||||
|
rm, _ := rooms.Get("a")
|
||||||
|
u1 := TestUserInfo{"Bob"}
|
||||||
|
u1Context := DummySyncable{room: rm}
|
||||||
|
|
||||||
|
rooms.Start()
|
||||||
|
defer rooms.Stop()
|
||||||
|
uas := UserAndSync[RoomData, TestUserInfo]{user: &u1, sync: &u1Context}
|
||||||
|
|
||||||
|
// Joining a room does *not* mark it dirty. It's on the user to call Sync() -
|
||||||
|
// so the user gets the update immediately.
|
||||||
|
rm.Join(&uas)
|
||||||
|
|
||||||
|
// // Give it time to process
|
||||||
|
time.Sleep(1 * time.Millisecond)
|
||||||
|
|
||||||
|
assert.Equal(t, rm.dirty, false)
|
||||||
|
assert.Equal(t, rm.MemberCount(), 1)
|
||||||
|
|
||||||
|
// Room Data
|
||||||
|
rm.UpdateData(func(data *RoomData) {
|
||||||
|
data.convo = append(data.convo, Statement{"Hello", u1})
|
||||||
|
})
|
||||||
|
assert.Equal(t, rm.dirty, true)
|
||||||
|
|
||||||
|
data := rm.GetData()
|
||||||
|
assert.Equal(t, len(data.convo), 1)
|
||||||
|
|
||||||
|
// BROADCAST to connected users. Clears the dirty flag.
|
||||||
|
rm.Publish()
|
||||||
|
time.Sleep(1 * time.Millisecond)
|
||||||
|
assert.Equal(t, rm.dirty, false)
|
||||||
|
assert.Equal(t, u1Context.timesCalled, 1)
|
||||||
|
|
||||||
|
// Leave
|
||||||
|
rm.Leave(&u1)
|
||||||
|
time.Sleep(1 * time.Millisecond)
|
||||||
|
|
||||||
|
assert.Equal(t, rm.MemberCount(), 0)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user