diff --git a/chat/command.go b/chat/command.go index d459fc5..f4bc903 100644 --- a/chat/command.go +++ b/chat/command.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "strings" + "time" "github.com/shazow/ssh-chat/chat/message" "github.com/shazow/ssh-chat/internal/sanitize" @@ -283,16 +284,34 @@ func InitCommands(c *Commands) { }) c.Add(Command{ - Prefix: "/timestamp", - Help: "Timestamps after 30min of inactivity.", + Prefix: "/timestamp", + PrefixHelp: "[TZINFO]", + Help: "Prefix messages with a timestamp. (Example: America/Toronto)", Handler: func(room *Room, msg message.CommandMsg) error { u := msg.From() cfg := u.Config() - cfg.Timestamp = !cfg.Timestamp + + args := msg.Args() + if len(args) >= 1 { + // FIXME: This is an annoying format to demand from users, but + // hopefully we can make it a non-primary flow if we add GeoIP + // someday. + timeLoc, err := time.LoadLocation(args[0]) + if err != nil { + err = fmt.Errorf("%s: Use a location name such as \"America/Toronto\" or refer to the IANA Time Zone database for the full list of names: https://wikipedia.org/wiki/List_of_tz_database_time_zones", err) + return err + } + cfg.Timezone = timeLoc + cfg.Timestamp = true + } else { + cfg.Timestamp = !cfg.Timestamp + } u.SetConfig(cfg) var body string - if cfg.Timestamp { + if cfg.Timestamp && cfg.Timezone != nil { + body = fmt.Sprintf("Timestamp is toggled ON with timezone %q", cfg.Timezone) + } else if cfg.Timestamp { body = "Timestamp is toggled ON" } else { body = "Timestamp is toggled OFF" diff --git a/chat/message/message.go b/chat/message/message.go index 5b4f76a..143096a 100644 --- a/chat/message/message.go +++ b/chat/message/message.go @@ -112,6 +112,7 @@ func (m PublicMsg) Render(t *Theme) string { return fmt.Sprintf("%s: %s", t.ColorName(m.from), m.body) } +// RenderFor renders the message for other users to see. func (m PublicMsg) RenderFor(cfg UserConfig) string { if cfg.Highlight == nil || cfg.Theme == nil { return m.Render(cfg.Theme) @@ -128,6 +129,11 @@ func (m PublicMsg) RenderFor(cfg UserConfig) string { return fmt.Sprintf("%s: %s", cfg.Theme.ColorName(m.from), body) } +// RenderSelf renders the message for when it's echoing your own message. +func (m PublicMsg) RenderSelf(cfg UserConfig) string { + return fmt.Sprintf("[%s] %s", cfg.Theme.ColorName(m.from), m.body) +} + func (m PublicMsg) String() string { return fmt.Sprintf("%s: %s", m.from.Name(), m.body) } diff --git a/chat/message/theme.go b/chat/message/theme.go index d2585b7..bc79f41 100644 --- a/chat/message/theme.go +++ b/chat/message/theme.go @@ -1,6 +1,11 @@ package message -import "fmt" +import ( + "fmt" + "time" +) + +const timestampLayout = "2006-01-02 15:04:05 MST" const ( // Reset resets the color @@ -122,43 +127,49 @@ type Theme struct { names *Palette } -func (t Theme) ID() string { - return t.id +func (theme Theme) ID() string { + return theme.id } // Colorize name string given some index -func (t Theme) ColorName(u *User) string { - if t.names == nil { +func (theme Theme) ColorName(u *User) string { + if theme.names == nil { return u.Name() } - return t.names.Get(u.colorIdx).Format(u.Name()) + return theme.names.Get(u.colorIdx).Format(u.Name()) } // Colorize the PM string -func (t Theme) ColorPM(s string) string { - if t.pm == nil { +func (theme Theme) ColorPM(s string) string { + if theme.pm == nil { return s } - return t.pm.Format(s) + return theme.pm.Format(s) } // Colorize the Sys message -func (t Theme) ColorSys(s string) string { - if t.sys == nil { +func (theme Theme) ColorSys(s string) string { + if theme.sys == nil { return s } - return t.sys.Format(s) + return theme.sys.Format(s) } // Highlight a matched string, usually name -func (t Theme) Highlight(s string) string { - if t.highlight == nil { +func (theme Theme) Highlight(s string) string { + if theme.highlight == nil { return s } - return t.highlight.Format(s) + return theme.highlight.Format(s) +} + +// Timestamp formats and colorizes the timestamp. +func (theme Theme) Timestamp(t time.Time) string { + // TODO: Change this per-theme? Or config? + return theme.sys.Format(t.Format(timestampLayout)) } // List of initialzied themes diff --git a/chat/message/user.go b/chat/message/user.go index d094bde..6a9e17c 100644 --- a/chat/message/user.go +++ b/chat/message/user.go @@ -16,7 +16,6 @@ const messageBuffer = 5 const messageTimeout = 5 * time.Second const reHighlight = `\b(%s)\b` const timestampTimeout = 30 * time.Minute -const timestampLayout = "2006-01-02 15:04:05 UTC" var ErrUserClosed = errors.New("user closed") @@ -158,17 +157,34 @@ func (u *User) SetHighlight(s string) error { func (u *User) render(m Message) string { cfg := u.Config() + var out string switch m := m.(type) { case PublicMsg: - return m.RenderFor(cfg) + Newline - case *PrivateMsg: - if cfg.Bell { - return m.Render(cfg.Theme) + Bel + Newline + if u == m.From() { + out += m.RenderSelf(cfg) + } else { + out += m.RenderFor(cfg) } - return m.Render(cfg.Theme) + Newline + case *PrivateMsg: + out += m.Render(cfg.Theme) + if cfg.Bell { + out += Bel + } + case *CommandMsg: + out += m.RenderSelf(cfg) default: - return m.Render(cfg.Theme) + Newline + out += m.Render(cfg.Theme) } + if cfg.Timestamp { + ts := m.Timestamp() + if cfg.Timezone != nil { + ts = ts.In(cfg.Timezone) + } else { + ts = ts.UTC() + } + return cfg.Theme.Timestamp(ts) + " " + out + Newline + } + return out + Newline } // writeMsg renders the message and attempts to write it, will Close the user @@ -186,20 +202,8 @@ 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() - cfg := u.config - lastMsg := u.lastMsg u.lastMsg = m.Timestamp() - injectTimestamp := !lastMsg.IsZero() && cfg.Timestamp && u.lastMsg.Sub(lastMsg) > timestampTimeout u.mu.Unlock() - - if injectTimestamp { - // Inject a timestamp at most once every timestampTimeout between message intervals - ts := NewSystemMsg(fmt.Sprintf("Timestamp: %s", m.Timestamp().UTC().Format(timestampLayout)), u) - if err := u.writeMsg(ts); err != nil { - return err - } - } - return u.writeMsg(m) } @@ -223,6 +227,7 @@ type UserConfig struct { Bell bool Quiet bool Timestamp bool + Timezone *time.Location Theme *Theme } diff --git a/chat/room.go b/chat/room.go index 71e3171..4fb56dd 100644 --- a/chat/room.go +++ b/chat/room.go @@ -90,12 +90,7 @@ func (r *Room) HandleMsg(m message.Message) { user := m.To() user.Send(m) default: - fromMsg, skip := m.(message.MessageFrom) - var skipUser *message.User - if skip { - skipUser = fromMsg.From() - } - + fromMsg, _ := m.(message.MessageFrom) r.history.Add(m) r.Members.Each(func(_ string, item set.Item) (err error) { user := item.Value().(*Member).User @@ -105,10 +100,6 @@ func (r *Room) HandleMsg(m message.Message) { return } - if skip && skipUser == user { - // Skip self - return - } if _, ok := m.(*message.AnnounceMsg); ok { if user.Config().Quiet { // Skip announcements diff --git a/host.go b/host.go index 068b969..176187b 100644 --- a/host.go +++ b/host.go @@ -162,6 +162,20 @@ func (h *Host) Connect(term *sshd.Terminal) { m := message.ParseInput(line, user) + // Gross hack to override line echo in golang.org/x/crypto/ssh/terminal + // It needs to live before we render the resulting message. + term.Write([]byte{ + 27, '[', 'A', // Up + 27, '[', '2', 'K', // Clear line + }) + // May the gods have mercy on our souls. + + if m, ok := m.(*message.CommandMsg); ok { + // Other messages render themselves by the room, commands we'll + // have to re-echo ourselves manually. + user.HandleMsg(m) + } + // FIXME: Any reason to use h.room.Send(m) instead? h.HandleMsg(m) diff --git a/host_test.go b/host_test.go index b4ee362..cfb88f4 100644 --- a/host_test.go +++ b/host_test.go @@ -6,6 +6,7 @@ import ( "crypto/rsa" "errors" "io" + mathRand "math/rand" "strings" "testing" @@ -15,22 +16,53 @@ import ( ) func stripPrompt(s string) string { - pos := strings.LastIndex(s, "\033[K") - if pos < 0 { - return s + // FIXME: Is there a better way to do this? + if endPos := strings.Index(s, "\x1b[K "); endPos > 0 { + return s[endPos+3:] + } + if endPos := strings.Index(s, "\x1b[2K "); endPos > 0 { + return s[endPos+4:] + } + if endPos := strings.Index(s, "] "); endPos > 0 { + return s[endPos+2:] + } + return s +} + +func TestStripPrompt(t *testing.T) { + tests := []struct { + Input string + Want string + }{ + { + Input: "\x1b[A\x1b[2K[quux] hello", + Want: "hello", + }, + { + Input: "[foo] \x1b[D\x1b[D\x1b[D\x1b[D\x1b[D\x1b[D\x1b[K * Guest1 joined. (Connected: 2)\r", + Want: " * Guest1 joined. (Connected: 2)\r", + }, + } + + for i, tc := range tests { + if got, want := stripPrompt(tc.Input), tc.Want; got != want { + t.Errorf("case #%d:\n got: %q\nwant: %q", i, got, want) + } } - return s[pos+3:] } func TestHostGetPrompt(t *testing.T) { var expected, actual string + // Make the random colors consistent across tests + mathRand.Seed(1) + u := message.NewUser(&Identity{id: "foo"}) actual = GetPrompt(u) expected = "[foo] " if actual != expected { - t.Errorf("Got: %q; Expected: %q", actual, expected) + t.Errorf("Invalid host prompt:\n Got: %q;\nWant: %q", actual, expected) } u.SetConfig(message.UserConfig{ @@ -39,7 +71,7 @@ func TestHostGetPrompt(t *testing.T) { actual = GetPrompt(u) expected = "[\033[38;05;88mfoo\033[0m] " if actual != expected { - t.Errorf("Got: %q; Expected: %q", actual, expected) + t.Errorf("Invalid host prompt:\n Got: %q;\nWant: %q", actual, expected) } } @@ -205,18 +237,23 @@ func TestHostKick(t *testing.T) { // Change nicks, make sure op sticks w.Write([]byte("/nick quux\r\n")) scanner.Scan() // Prompt + scanner.Scan() // Prompt echo scanner.Scan() // Nick change response + // Signal for the second client to connect + connected <- struct{}{} + // Block until second client is here connected <- struct{}{} scanner.Scan() // Connected message w.Write([]byte("/kick bar\r\n")) scanner.Scan() // Prompt + scanner.Scan() // Prompt echo - scanner.Scan() + scanner.Scan() // Kick result if actual, expected := stripPrompt(scanner.Text()), " * bar was kicked by quux.\r"; actual != expected { - t.Errorf("Got %q; expected %q", actual, expected) + t.Errorf("Failed to detect kick:\n Got: %q;\nWant: %q", actual, expected) } kicked <- struct{}{} @@ -231,6 +268,8 @@ func TestHostKick(t *testing.T) { }() go func() { + <-connected + // Second client err := sshd.ConnectShell(addr, "bar", func(r io.Reader, w io.WriteCloser) error { scanner := bufio.NewScanner(r)