diff --git a/chat/command.go b/chat/command.go index 182d00f..db88011 100644 --- a/chat/command.go +++ b/chat/command.go @@ -5,6 +5,7 @@ package chat import ( "errors" "fmt" + "sort" "strings" "time" @@ -189,6 +190,7 @@ func InitCommands(c *Commands) { } names := room.Members.ListPrefix("") + sort.Slice(names, func(i, j int) bool { return names[i].Key() < names[j].Key() }) colNames := make([]string, len(names)) for i, uname := range names { colNames[i] = colorize(uname.Value().(*Member).User) @@ -409,4 +411,51 @@ func InitCommands(c *Commands) { return nil }, }) + + c.Add(Command{ + Prefix: "/focus", + PrefixHelp: "[USER ...]", + Help: "Only show messages from focused users, or $ to reset.", + Handler: func(room *Room, msg message.CommandMsg) error { + ids := strings.TrimSpace(strings.TrimLeft(msg.Body(), "/focus")) + if ids == "" { + // Print focused names, if any. + var names []string + msg.From().Focused.Each(func(_ string, item set.Item) error { + names = append(names, item.Key()) + return nil + }) + + var systemMsg string + if len(names) == 0 { + systemMsg = "Unfocused." + } else { + systemMsg = fmt.Sprintf("Focusing on %d users: %s", len(names), strings.Join(names, ", ")) + } + + room.Send(message.NewSystemMsg(systemMsg, msg.From())) + return nil + } + + n := msg.From().Focused.Clear() + if ids == "$" { + room.Send(message.NewSystemMsg(fmt.Sprintf("Removed focus from %d users.", n), msg.From())) + return nil + } + + var focused []string + for _, name := range strings.Split(ids, " ") { + id := sanitize.Name(name) + if id == "" { + continue // Skip + } + focused = append(focused, id) + if err := msg.From().Focused.Set(set.Itemize(id, set.ZeroValue)); err != nil { + return err + } + } + room.Send(message.NewSystemMsg(fmt.Sprintf("Focusing: %s", strings.Join(focused, ", ")), msg.From())) + return nil + }, + }) } diff --git a/chat/message/message.go b/chat/message/message.go index 01de0ce..79244eb 100644 --- a/chat/message/message.go +++ b/chat/message/message.go @@ -131,6 +131,9 @@ func (m PublicMsg) RenderFor(cfg UserConfig) string { // RenderSelf renders the message for when it's echoing your own message. func (m PublicMsg) RenderSelf(cfg UserConfig) string { + if cfg.Theme == nil { + return fmt.Sprintf("[%s] %s", m.from.Name(), m.body) + } return fmt.Sprintf("[%s] %s", cfg.Theme.ColorName(m.from), m.body) } diff --git a/chat/message/user.go b/chat/message/user.go index 305de10..d2ffb80 100644 --- a/chat/message/user.go +++ b/chat/message/user.go @@ -22,9 +22,9 @@ var ErrUserClosed = errors.New("user closed") // User definition, implemented set Item interface and io.Writer type User struct { Identifier - Ignored *set.Set OnChange func() - + Ignored set.Interface + Focused set.Interface colorIdx int joined time.Time msg chan Message @@ -47,6 +47,7 @@ func NewUser(identity Identifier) *User { msg: make(chan Message, messageBuffer), done: make(chan struct{}), Ignored: set.New(), + Focused: set.New(), } u.setColorIdx(rand.Int()) @@ -64,6 +65,12 @@ func (u *User) Joined() time.Time { return u.joined } +func (u *User) LastMsg() time.Time { + u.mu.Lock() + defer u.mu.Unlock() + return u.lastMsg +} + func (u *User) Config() UserConfig { u.mu.Lock() defer u.mu.Unlock() @@ -171,10 +178,17 @@ func (u *User) render(m Message) string { switch m := m.(type) { case PublicMsg: if u == m.From() { + u.mu.Lock() + u.lastMsg = m.Timestamp() + u.mu.Unlock() + if !cfg.Echo { return "" } out += m.RenderSelf(cfg) + } else if u.Focused.Len() > 0 && !u.Focused.In(m.From().ID()) { + // Skip message during focus + return "" } else { out += m.RenderFor(cfg) } @@ -214,9 +228,6 @@ func (u *User) writeMsg(m Message) error { // HandleMsg will render the message to the screen, blocking. func (u *User) HandleMsg(m Message) error { - u.mu.Lock() - u.lastMsg = m.Timestamp() - u.mu.Unlock() return u.writeMsg(m) } @@ -258,7 +269,9 @@ func init() { // TODO: Seed random? } -// RecentActiveUsers is a slice of *Users that knows how to be sorted by the time of the last message. +// RecentActiveUsers is a slice of *Users that knows how to be sorted by the +// time of the last message. If no message has been sent, then fall back to the +// time joined instead. type RecentActiveUsers []*User func (a RecentActiveUsers) Len() int { return len(a) } @@ -268,5 +281,16 @@ func (a RecentActiveUsers) Less(i, j int) bool { defer a[i].mu.Unlock() a[j].mu.Lock() defer a[j].mu.Unlock() - return a[i].lastMsg.After(a[j].lastMsg) + + ai := a[i].lastMsg + if ai.IsZero() { + ai = a[i].joined + } + + aj := a[j].lastMsg + if aj.IsZero() { + aj = a[j].joined + } + + return ai.After(aj) } diff --git a/chat/room_test.go b/chat/room_test.go index c316e15..44c8f0c 100644 --- a/chat/room_test.go +++ b/chat/room_test.go @@ -388,17 +388,22 @@ func TestRoomNamesPrefix(t *testing.T) { } } + sendMsg := func(from *Member, body string) { + // lastMsg is set during render of self messags, so we can't use NewMsg + from.HandleMsg(message.NewPublicMsg(body, from.User)) + } + // Inject some activity - members[2].HandleMsg(message.NewMsg("hi")) // aac - members[0].HandleMsg(message.NewMsg("hi")) // aaa - members[3].HandleMsg(message.NewMsg("hi")) // foo - members[1].HandleMsg(message.NewMsg("hi")) // aab + sendMsg(members[2], "hi") // aac + sendMsg(members[0], "hi") // aaa + sendMsg(members[3], "hi") // foo + sendMsg(members[1], "hi") // aab if got, want := r.NamesPrefix("a"), []string{"aab", "aaa", "aac"}; !reflect.DeepEqual(got, want) { t.Errorf("got: %q; want: %q", got, want) } - members[2].HandleMsg(message.NewMsg("hi")) // aac + sendMsg(members[2], "hi") // aac if got, want := r.NamesPrefix("a"), []string{"aac", "aab", "aaa"}; !reflect.DeepEqual(got, want) { t.Errorf("got: %q; want: %q", got, want) } diff --git a/host.go b/host.go index 96331d8..2a67e39 100644 --- a/host.go +++ b/host.go @@ -237,14 +237,19 @@ func (h *Host) Serve() { h.listener.Serve() } -func (h *Host) completeName(partial string) string { +func (h *Host) completeName(partial string, skipName string) string { names := h.NamesPrefix(partial) if len(names) == 0 { // Didn't find anything return "" + } else if name := names[0]; name != skipName { + // First name is not the skipName, great + return name + } else if len(names) > 1 { + // Next candidate + return names[1] } - - return names[len(names)-1] + return "" } func (h *Host) completeCommand(partial string) string { @@ -294,7 +299,7 @@ func (h *Host) AutoCompleteFunction(u *message.User) func(line string, pos int, } } else { // Name - completed = h.completeName(partial) + completed = h.completeName(partial, u.Name()) if completed == "" { return } @@ -401,12 +406,11 @@ func (h *Host) InitCommands(c *chat.Commands) { if !ok { return errors.New("user not found") } - id := target.Identifier.(*Identity) var whois string switch room.IsOp(msg.From()) { case true: - whois = id.WhoisAdmin() + whois = id.WhoisAdmin(room) case false: whois = id.Whois() } diff --git a/identity.go b/identity.go index 148a6dd..7b3277f 100644 --- a/identity.go +++ b/identity.go @@ -2,8 +2,10 @@ package sshchat import ( "net" + "strings" "time" + "github.com/shazow/ssh-chat/chat" "github.com/shazow/ssh-chat/chat/message" "github.com/shazow/ssh-chat/internal/humantime" "github.com/shazow/ssh-chat/internal/sanitize" @@ -55,6 +57,7 @@ func (i Identity) Name() string { } // Whois returns a whois description for non-admin users. +// TODO: Add optional room context? func (i Identity) Whois() string { fingerprint := "(no public key)" if i.PublicKey() != nil { @@ -67,15 +70,31 @@ func (i Identity) Whois() string { } // WhoisAdmin returns a whois description for admin users. -func (i Identity) WhoisAdmin() string { +func (i Identity) WhoisAdmin(room *chat.Room) string { ip, _, _ := net.SplitHostPort(i.RemoteAddr().String()) fingerprint := "(no public key)" if i.PublicKey() != nil { fingerprint = sshd.Fingerprint(i.PublicKey()) } - return "name: " + i.Name() + message.Newline + + + out := strings.Builder{} + out.WriteString("name: " + i.Name() + message.Newline + " > ip: " + ip + message.Newline + " > fingerprint: " + fingerprint + message.Newline + " > client: " + sanitize.Data(string(i.ClientVersion()), 64) + message.Newline + - " > joined: " + humantime.Since(i.created) + " ago" + " > joined: " + humantime.Since(i.created) + " ago") + + if member, ok := room.MemberByID(i.ID()); ok { + // Add room-specific whois + // FIXME: Should these always be present, even if they're false? Maybe + // change that once we add room context to Whois() above. + if !member.LastMsg().IsZero() { + out.WriteString(message.Newline + " > room/messaged: " + humantime.Since(member.LastMsg()) + " ago") + } + if room.IsOp(member.User) { + out.WriteString(message.Newline + " > room/op: true") + } + } + + return out.String() } diff --git a/set/set.go b/set/set.go index 92ef1bb..0b08e8c 100644 --- a/set/set.go +++ b/set/set.go @@ -15,6 +15,25 @@ var ErrMissing = errors.New("item does not exist") // Returned when a nil item is added. Nil values are considered expired and invalid. var ErrNil = errors.New("item value must not be nil") +// ZeroValue can be used when we only care about the key, not about the value. +var ZeroValue = struct{}{} + +// Interface is the Set interface +type Interface interface { + Clear() int + Each(fn IterFunc) error + // Add only if the item does not already exist + Add(item Item) error + // Set item, override if it already exists + Set(item Item) error + Get(key string) (Item, error) + In(key string) bool + Len() int + ListPrefix(prefix string) []Item + Remove(key string) error + Replace(oldKey string, item Item) error +} + type IterFunc func(key string, item Item) error type Set struct {