Merge branch 'master' into sponsor-prefix

This commit is contained in:
Andrey Petrov 2020-08-03 11:41:19 -04:00 committed by GitHub
commit e0ab53500e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 144 additions and 21 deletions

View File

@ -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
},
})
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}

16
host.go
View File

@ -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()
}

View File

@ -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()
}

View File

@ -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 {