package ws import ( "sync" "time" ) // Room is a set of clients that can be addressed together. Access types and join // types mirror the original service. type Room struct { ID string Name string Description string OwnerID string CreatedAt time.Time AccessType string // "public" | "private" JoinType string // "free" | "invite" | "password" | "lock" | "notify" NotifyActionInvite bool NotifyActionJoined bool NotifyActionEjected bool Credential string // sha256 hex, or "" when none hub *Hub mu sync.RWMutex clients map[string]*Client waitingInvited map[string]struct{} info map[string]any } // NewRoom creates an empty, unpublished room with a fresh id and the same field // defaults the Node constructor used. func NewRoom(hub *Hub) *Room { return &Room{ ID: newUUID(), CreatedAt: time.Now(), JoinType: "invite", NotifyActionJoined: true, NotifyActionEjected: true, hub: hub, clients: make(map[string]*Client), waitingInvited: make(map[string]struct{}), info: make(map[string]any), } } // Publish registers the room in the hub so it can be looked up by id. func (r *Room) Publish() { r.hub.addRoom(r) } // Size returns the current member count. func (r *Room) Size() int { r.mu.RLock() n := len(r.clients) r.mu.RUnlock() return n } // Has reports membership by client id. func (r *Room) Has(id string) bool { r.mu.RLock() _, ok := r.clients[id] r.mu.RUnlock() return ok } // snapshot returns the current members as a slice. The room lock is held only for // the cheap copy; callers then send without holding any lock, so a member that // disconnects mid-broadcast cannot deadlock or race the send (Client.Send drops // safely once the peer is closing). func (r *Room) snapshot() []*Client { r.mu.RLock() out := make([]*Client, 0, len(r.clients)) for _, c := range r.clients { out = append(out, c) } r.mu.RUnlock() return out } // Broadcast sends signal `name` with `payload` to every member except exceptID // (pass "" to include everyone). If filter is non-nil, only members for which it // returns true receive the message. func (r *Room) Broadcast(name string, payload any, exceptID string, filter func(*Client) bool) { for _, c := range r.snapshot() { if c.ID == exceptID { continue } if filter != nil && !filter(c) { continue } c.Signal(name, payload) } } // Members returns a snapshot of the room's current members. func (r *Room) Members() []*Client { return r.snapshot() } // FilterPeers returns the members whose info matches filter. func (r *Room) FilterPeers(filter map[string]any) []*Client { var out []*Client for _, c := range r.snapshot() { if c.Match(filter) { out = append(out, c) } } return out } // Join adds a client to the room. Existing members are notified first (so the new // member does not receive its own join), then membership is recorded on both the // room and the client. func (r *Room) Join(c *Client) { if r.NotifyActionJoined { r.Broadcast( "room/joined", map[string]any{"id": c.ID, "roomid": r.ID, "ownerid": r.OwnerID}, "", (*Client).PeerInfoNotifiable, ) } r.mu.Lock() r.clients[c.ID] = c r.mu.Unlock() c.addRoom(r.ID) } // Eject removes a client from the room, notifying the remaining members. When the // room empties it is taken down. func (r *Room) Eject(c *Client) { if r.NotifyActionEjected { r.Broadcast( "room/ejected", map[string]any{"id": c.ID, "roomid": r.ID, "ownerid": r.OwnerID}, c.ID, (*Client).PeerInfoNotifiable, ) } c.removeRoom(r.ID) r.mu.Lock() delete(r.clients, c.ID) empty := len(r.clients) == 0 r.mu.Unlock() if empty { r.Down() } } // Down closes the room: members are told, the room is unregistered, and each // member's membership record is cleared. func (r *Room) Down() { members := r.snapshot() for _, c := range members { c.Signal("room/closed", map[string]any{"roomid": r.ID, "ownerid": r.OwnerID}) c.removeRoom(r.ID) } r.hub.removeRoom(r.ID) } // ---- room info ----------------------------------------------------------- // SetInfo stores a room-level value. func (r *Room) SetInfo(name string, value any) { r.mu.Lock() r.info[name] = value r.mu.Unlock() } // InfoValue returns a single room value. func (r *Room) InfoValue(name string) (any, bool) { r.mu.RLock() v, ok := r.info[name] r.mu.RUnlock() return v, ok } // Info returns a copy of all room values. func (r *Room) Info() map[string]any { r.mu.RLock() out := make(map[string]any, len(r.info)) for k, v := range r.info { out[k] = v } r.mu.RUnlock() return out } // ---- invitations --------------------------------------------------------- // AddWaiting records a client awaiting an invite decision. func (r *Room) AddWaiting(id string) { r.mu.Lock() r.waitingInvited[id] = struct{}{} r.mu.Unlock() } // RemoveWaiting drops a client from the invite waiting list. func (r *Room) RemoveWaiting(id string) { r.mu.Lock() delete(r.waitingInvited, id) r.mu.Unlock() } // IsWaiting reports whether a client is on the invite waiting list. func (r *Room) IsWaiting(id string) bool { r.mu.RLock() _, ok := r.waitingInvited[id] r.mu.RUnlock() return ok } func (r *Room) waitingList() []string { r.mu.RLock() out := make([]string, 0, len(r.waitingInvited)) for id := range r.waitingInvited { out = append(out, id) } r.mu.RUnlock() return out } // ---- serialization ------------------------------------------------------- // ToJSON renders the room the way the SDK expects. When detailed is true the // sensitive/owner-only fields are included. func (r *Room) ToJSON(detailed bool) map[string]any { obj := map[string]any{ "id": r.ID, "accessType": r.AccessType, "createdAt": r.CreatedAt, "description": r.Description, "joinType": r.JoinType, "name": r.Name, "owner": r.OwnerID, "waitingInvited": r.waitingList(), } if detailed { obj["credential"] = r.Credential obj["notifyActionInvite"] = r.NotifyActionInvite obj["notifyActionJoined"] = r.NotifyActionJoined obj["notifyActionEjected"] = r.NotifyActionEjected r.mu.RLock() ids := make([]string, 0, len(r.clients)) for id := range r.clients { ids = append(ids, id) } r.mu.RUnlock() obj["clients"] = ids } return obj }