That would have bugged me if I didn't do this.
[ssh-chat] / chat / message / user.go
1 package message
2
3 import (
4         "errors"
5         "fmt"
6         "io"
7         "math/rand"
8         "regexp"
9         "sync"
10         "time"
11
12         "github.com/shazow/ssh-chat/set"
13 )
14
15 const messageBuffer = 5
16 const messageTimeout = 5 * time.Second
17 const reHighlight = `\b(%s)\b`
18 const timestampTimeout = 30 * time.Minute
19
20 var ErrUserClosed = errors.New("user closed")
21
22 // User definition, implemented set Item interface and io.Writer
23 type User struct {
24         Identifier
25         OnChange func()
26         Ignored  set.Interface
27         Focused  set.Interface
28         colorIdx int
29         joined   time.Time
30         msg      chan Message
31         done     chan struct{}
32
33         screen    io.WriteCloser
34         closeOnce sync.Once
35
36         mu         sync.Mutex
37         config     UserConfig
38         replyTo    *User     // Set when user gets a /msg, for replying.
39         lastMsg    time.Time // When the last message was rendered.
40         awayReason string    // Away reason, "" when not away.
41         awaySince  time.Time // When away was set, 0 when not away.
42 }
43
44 func NewUser(identity Identifier) *User {
45         u := User{
46                 Identifier: identity,
47                 config:     DefaultUserConfig,
48                 joined:     time.Now(),
49                 msg:        make(chan Message, messageBuffer),
50                 done:       make(chan struct{}),
51                 Ignored:    set.New(),
52                 Focused:    set.New(),
53         }
54         u.setColorIdx(rand.Int())
55
56         return &u
57 }
58
59 func NewUserScreen(identity Identifier, screen io.WriteCloser) *User {
60         u := NewUser(identity)
61         u.screen = screen
62
63         return u
64 }
65
66 func (u *User) Joined() time.Time {
67         return u.joined
68 }
69
70 func (u *User) LastMsg() time.Time {
71         u.mu.Lock()
72         defer u.mu.Unlock()
73         return u.lastMsg
74 }
75
76 // SetAway sets the users away reason and state.
77 func (u *User) SetAway(msg string) {
78         u.mu.Lock()
79         defer u.mu.Unlock()
80         u.awayReason = msg
81         if msg == "" {
82                 u.awaySince = time.Time{}
83         } else {
84                 // Reset away timer even if already away
85                 u.awaySince = time.Now()
86         }
87 }
88
89 // GetAway returns if the user is away, when they went away, and the reason.
90 func (u *User) GetAway() (bool, time.Time, string) {
91         u.mu.Lock()
92         defer u.mu.Unlock()
93         return u.awayReason != "", u.awaySince, u.awayReason
94 }
95
96 func (u *User) Config() UserConfig {
97         u.mu.Lock()
98         defer u.mu.Unlock()
99         return u.config
100 }
101
102 func (u *User) SetConfig(cfg UserConfig) {
103         u.mu.Lock()
104         u.config = cfg
105         u.mu.Unlock()
106
107         if u.OnChange != nil {
108                 u.OnChange()
109         }
110 }
111
112 // Rename the user with a new Identifier.
113 func (u *User) SetID(id string) {
114         u.Identifier.SetID(id)
115         u.setColorIdx(rand.Int())
116
117         if u.OnChange != nil {
118                 u.OnChange()
119         }
120 }
121
122 // ReplyTo returns the last user that messaged this user.
123 func (u *User) ReplyTo() *User {
124         u.mu.Lock()
125         defer u.mu.Unlock()
126         return u.replyTo
127 }
128
129 // SetReplyTo sets the last user to message this user.
130 func (u *User) SetReplyTo(user *User) {
131         u.mu.Lock()
132         defer u.mu.Unlock()
133         u.replyTo = user
134 }
135
136 // setColorIdx will set the colorIdx to a specific value, primarily used for
137 // testing.
138 func (u *User) setColorIdx(idx int) {
139         u.colorIdx = idx
140 }
141
142 // Disconnect user, stop accepting messages
143 func (u *User) Close() {
144         u.closeOnce.Do(func() {
145                 if u.screen != nil {
146                         if err := u.screen.Close(); err != nil {
147                                 logger.Printf("Failed to close user %q screen: %s", u.ID(), err)
148                         }
149                 }
150                 // close(u.msg) TODO: Close?
151                 close(u.done)
152         })
153 }
154
155 // Consume message buffer into the handler. Will block, should be called in a
156 // goroutine.
157 func (u *User) Consume() {
158         for {
159                 select {
160                 case <-u.done:
161                         return
162                 case m, ok := <-u.msg:
163                         if !ok {
164                                 return
165                         }
166                         u.HandleMsg(m)
167                 }
168         }
169 }
170
171 // Consume one message and stop, mostly for testing
172 func (u *User) ConsumeOne() Message {
173         return <-u.msg
174 }
175
176 // Check if there are pending messages, used for testing
177 func (u *User) HasMessages() bool {
178         select {
179         case msg := <-u.msg:
180                 u.msg <- msg
181                 return true
182         default:
183                 return false
184         }
185 }
186
187 // SetHighlight sets the highlighting regular expression to match string.
188 func (u *User) SetHighlight(s string) error {
189         re, err := regexp.Compile(fmt.Sprintf(reHighlight, s))
190         if err != nil {
191                 return err
192         }
193         u.mu.Lock()
194         u.config.Highlight = re
195         u.mu.Unlock()
196         return nil
197 }
198
199 func (u *User) render(m Message) string {
200         cfg := u.Config()
201         var out string
202         switch m := m.(type) {
203         case PublicMsg:
204                 if u == m.From() {
205                         u.mu.Lock()
206                         u.lastMsg = m.Timestamp()
207                         u.mu.Unlock()
208
209                         if !cfg.Echo {
210                                 return ""
211                         }
212                         out += m.RenderSelf(cfg)
213                 } else if u.Focused.Len() > 0 && !u.Focused.In(m.From().ID()) {
214                         // Skip message during focus
215                         return ""
216                 } else {
217                         out += m.RenderFor(cfg)
218                 }
219         case *PrivateMsg:
220                 out += m.Render(cfg.Theme)
221                 if cfg.Bell {
222                         out += Bel
223                 }
224         case *CommandMsg:
225                 out += m.RenderSelf(cfg)
226         default:
227                 out += m.Render(cfg.Theme)
228         }
229         if cfg.Timeformat != nil {
230                 ts := m.Timestamp()
231                 if cfg.Timezone != nil {
232                         ts = ts.In(cfg.Timezone)
233                 } else {
234                         ts = ts.UTC()
235                 }
236                 return ts.Format(*cfg.Timeformat) + "  " + out + Newline
237         }
238         return out + Newline
239 }
240
241 // writeMsg renders the message and attempts to write it, will Close the user
242 // if it fails.
243 func (u *User) writeMsg(m Message) error {
244         r := u.render(m)
245         _, err := u.screen.Write([]byte(r))
246         if err != nil {
247                 logger.Printf("Write failed to %s, closing: %s", u.ID(), err)
248                 u.Close()
249         }
250         return err
251 }
252
253 // HandleMsg will render the message to the screen, blocking.
254 func (u *User) HandleMsg(m Message) error {
255         return u.writeMsg(m)
256 }
257
258 // Add message to consume by user
259 func (u *User) Send(m Message) error {
260         select {
261         case <-u.done:
262                 return ErrUserClosed
263         case u.msg <- m:
264         case <-time.After(messageTimeout):
265                 logger.Printf("Message buffer full, closing: %s", u.ID())
266                 u.Close()
267                 return ErrUserClosed
268         }
269         return nil
270 }
271
272 // Container for per-user configurations.
273 type UserConfig struct {
274         Highlight  *regexp.Regexp
275         Bell       bool
276         Quiet      bool
277         Echo       bool // Echo shows your own messages after sending, disabled for bots
278         Timeformat *string
279         Timezone   *time.Location
280         Theme      *Theme
281 }
282
283 // Default user configuration to use
284 var DefaultUserConfig UserConfig
285
286 func init() {
287         tfmt := "2006-01-02 15:04:05"
288         DefaultUserConfig = UserConfig{
289                 Bell:  true,
290                 Echo:  true,
291                 Quiet: false,
292                 Timeformat: &tfmt,
293         }
294
295         // TODO: Seed random?
296 }
297
298 // RecentActiveUsers is a slice of *Users that knows how to be sorted by the
299 // time of the last message. If no message has been sent, then fall back to the
300 // time joined instead.
301 type RecentActiveUsers []*User
302
303 func (a RecentActiveUsers) Len() int      { return len(a) }
304 func (a RecentActiveUsers) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
305 func (a RecentActiveUsers) Less(i, j int) bool {
306         a[i].mu.Lock()
307         defer a[i].mu.Unlock()
308         a[j].mu.Lock()
309         defer a[j].mu.Unlock()
310
311         ai := a[i].lastMsg
312         if ai.IsZero() {
313                 ai = a[i].joined
314         }
315
316         aj := a[j].lastMsg
317         if aj.IsZero() {
318                 aj = a[j].joined
319         }
320
321         return ai.After(aj)
322 }