commit 9fb4378

Hubert Hirtz  ·  2021-10-20 15:33:10 +0000 UTC
parent 40544a1
Support for soju.im/bouncer-networks

This patch also disable the highlight on reconnect. This might be an
issue (the user would want to know when senpai is online again?), but
with multiple connections, it's bothersome to have to unread all of
them on start (it wasn't a problem with only one connection since it was
read instantly).

Now, lastbuffer.txt also contains the network ID, otherwise the user
might end up on another buffer with the same name.

This patch does not extend /r to support multiple networks (it will send
the message to the latest query, whatever the current displayed network
is).
11 files changed,  +444, -275
M app.go
M app.go
+158, -118
  1@@ -17,13 +17,6 @@ import (
  2 
  3 const eventChanSize = 64
  4 
  5-type source int
  6-
  7-const (
  8-	uiEvent source = iota
  9-	ircEvent
 10-)
 11-
 12 func isCommand(input []rune) bool {
 13 	// Command can't start with two slashes because that's an escape for
 14 	// a literal slash in the message
 15@@ -70,30 +63,32 @@ func (b *bound) Update(line *ui.Line) {
 16 }
 17 
 18 type event struct {
 19-	src     source
 20+	src     string // "*" if UI, netID otherwise
 21 	content interface{}
 22 }
 23 
 24 type App struct {
 25-	win     *ui.UI
 26-	s       *irc.Session
 27-	pasting bool
 28-	events  chan event
 29+	win      *ui.UI
 30+	sessions map[string]*irc.Session
 31+	pasting  bool
 32+	events   chan event
 33 
 34 	cfg        Config
 35 	highlights []string
 36 
 37 	lastQuery     string
 38+	lastQueryNet  string
 39 	messageBounds map[string]bound
 40+	lastNetID     string
 41 	lastBuffer    string
 42 }
 43 
 44-func NewApp(cfg Config, lastBuffer string) (app *App, err error) {
 45+func NewApp(cfg Config) (app *App, err error) {
 46 	app = &App{
 47-		cfg:           cfg,
 48+		sessions:      map[string]*irc.Session{},
 49 		events:        make(chan event, eventChanSize),
 50+		cfg:           cfg,
 51 		messageBounds: map[string]bound{},
 52-		lastBuffer:    lastBuffer,
 53 	}
 54 
 55 	if cfg.Highlights != nil {
 56@@ -133,18 +128,28 @@ func NewApp(cfg Config, lastBuffer string) (app *App, err error) {
 57 
 58 func (app *App) Close() {
 59 	app.win.Close()
 60-	if app.s != nil {
 61-		app.s.Close()
 62+	for _, session := range app.sessions {
 63+		session.Close()
 64 	}
 65 }
 66 
 67+func (app *App) SwitchToBuffer(netID, buffer string) {
 68+	app.lastNetID = netID
 69+	app.lastBuffer = buffer
 70+}
 71+
 72 func (app *App) Run() {
 73 	go app.uiLoop()
 74-	go app.ircLoop()
 75+	go app.ircLoop("")
 76 	app.eventLoop()
 77 }
 78 
 79-func (app *App) CurrentBuffer() string {
 80+func (app *App) CurrentSession() *irc.Session {
 81+	netID, _ := app.win.CurrentBuffer()
 82+	return app.sessions[netID]
 83+}
 84+
 85+func (app *App) CurrentBuffer() (netID, buffer string) {
 86 	return app.win.CurrentBuffer()
 87 }
 88 
 89@@ -172,8 +177,10 @@ func (app *App) eventLoop() {
 90 			app.updatePrompt()
 91 			app.setBufferNumbers()
 92 			var currentMembers []irc.Member
 93-			if app.s != nil {
 94-				currentMembers = app.s.Names(app.win.CurrentBuffer())
 95+			netID, buffer := app.win.CurrentBuffer()
 96+			s := app.sessions[netID]
 97+			if s != nil && buffer != "" {
 98+				currentMembers = s.Names(buffer)
 99 			}
100 			app.win.Draw(currentMembers)
101 		}
102@@ -182,7 +189,7 @@ func (app *App) eventLoop() {
103 
104 // ircLoop maintains a connection to the IRC server by connecting and then
105 // forwarding IRC events to app.events repeatedly.
106-func (app *App) ircLoop() {
107+func (app *App) ircLoop(netID string) {
108 	var auth irc.SASLClient
109 	if app.cfg.Password != nil {
110 		auth = &irc.SASLPlain{
111@@ -194,45 +201,46 @@ func (app *App) ircLoop() {
112 		Nickname: app.cfg.Nick,
113 		Username: app.cfg.User,
114 		RealName: app.cfg.Real,
115+		NetID:    netID,
116 		Auth:     auth,
117 	}
118 	for !app.win.ShouldExit() {
119-		conn := app.connect()
120+		conn := app.connect(netID)
121 		in, out := irc.ChanInOut(conn)
122 		if app.cfg.Debug {
123-			out = app.debugOutputMessages(out)
124+			out = app.debugOutputMessages(netID, out)
125 		}
126 		session := irc.NewSession(out, params)
127 		app.events <- event{
128-			src:     ircEvent,
129+			src:     netID,
130 			content: session,
131 		}
132 		go func() {
133 			for stop := range session.TypingStops() {
134 				app.events <- event{
135-					src:     ircEvent,
136+					src:     netID,
137 					content: stop,
138 				}
139 			}
140 		}()
141 		for msg := range in {
142 			if app.cfg.Debug {
143-				app.queueStatusLine(ui.Line{
144+				app.queueStatusLine(netID, ui.Line{
145 					At:   time.Now(),
146 					Head: "IN --",
147 					Body: ui.PlainString(msg.String()),
148 				})
149 			}
150 			app.events <- event{
151-				src:     ircEvent,
152+				src:     netID,
153 				content: msg,
154 			}
155 		}
156 		app.events <- event{
157-			src:     ircEvent,
158+			src:     netID,
159 			content: nil,
160 		}
161-		app.queueStatusLine(ui.Line{
162+		app.queueStatusLine(netID, ui.Line{
163 			Head:      "!!",
164 			HeadColor: tcell.ColorRed,
165 			Body:      ui.PlainString("Connection lost"),
166@@ -241,9 +249,9 @@ func (app *App) ircLoop() {
167 	}
168 }
169 
170-func (app *App) connect() net.Conn {
171+func (app *App) connect(netID string) net.Conn {
172 	for {
173-		app.queueStatusLine(ui.Line{
174+		app.queueStatusLine(netID, ui.Line{
175 			Head: "--",
176 			Body: ui.PlainSprintf("Connecting to %s...", app.cfg.Addr),
177 		})
178@@ -251,7 +259,7 @@ func (app *App) connect() net.Conn {
179 		if err == nil {
180 			return conn
181 		}
182-		app.queueStatusLine(ui.Line{
183+		app.queueStatusLine(netID, ui.Line{
184 			Head:      "!!",
185 			HeadColor: tcell.ColorRed,
186 			Body:      ui.PlainSprintf("Connection failed: %v", err),
187@@ -295,11 +303,11 @@ func (app *App) tryConnect() (conn net.Conn, err error) {
188 	return
189 }
190 
191-func (app *App) debugOutputMessages(out chan<- irc.Message) chan<- irc.Message {
192+func (app *App) debugOutputMessages(netID string, out chan<- irc.Message) chan<- irc.Message {
193 	debugOut := make(chan irc.Message, cap(out))
194 	go func() {
195 		for msg := range debugOut {
196-			app.queueStatusLine(ui.Line{
197+			app.queueStatusLine(netID, ui.Line{
198 				At:   time.Now(),
199 				Head: "OUT --",
200 				Body: ui.PlainString(msg.String()),
201@@ -316,7 +324,7 @@ func (app *App) debugOutputMessages(out chan<- irc.Message) chan<- irc.Message {
202 func (app *App) uiLoop() {
203 	for ev := range app.win.Events {
204 		app.events <- event{
205-			src:     uiEvent,
206+			src:     "*",
207 			content: ev,
208 		}
209 	}
210@@ -325,13 +333,10 @@ func (app *App) uiLoop() {
211 // handleEvents handles a batch of events.
212 func (app *App) handleEvents(evs []event) {
213 	for _, ev := range evs {
214-		switch ev.src {
215-		case uiEvent:
216+		if ev.src == "*" {
217 			app.handleUIEvent(ev.content)
218-		case ircEvent:
219-			app.handleIRCEvent(ev.content)
220-		default:
221-			panic("unreachable")
222+		} else {
223+			app.handleIRCEvent(ev.src, ev.content)
224 		}
225 	}
226 }
227@@ -346,10 +351,10 @@ func (app *App) handleUIEvent(ev interface{}) {
228 		app.handleMouseEvent(ev)
229 	case *tcell.EventKey:
230 		app.handleKeyEvent(ev)
231-	case ui.Line:
232-		app.addStatusLine(ev)
233+	case statusLine:
234+		app.addStatusLine(ev.netID, ev.line)
235 	default:
236-		return
237+		panic("unreachable")
238 	}
239 }
240 
241@@ -472,11 +477,11 @@ func (app *App) handleKeyEvent(ev *tcell.EventKey) {
242 			app.typing()
243 		}
244 	case tcell.KeyCR, tcell.KeyLF:
245-		buffer := app.win.CurrentBuffer()
246+		netID, buffer := app.win.CurrentBuffer()
247 		input := app.win.InputEnter()
248 		err := app.handleInput(buffer, input)
249 		if err != nil {
250-			app.win.AddLine(app.win.CurrentBuffer(), ui.NotifyUnread, ui.Line{
251+			app.win.AddLine(netID, buffer, ui.NotifyUnread, ui.Line{
252 				At:        time.Now(),
253 				Head:      "!!",
254 				HeadColor: tcell.ColorRed,
255@@ -494,29 +499,35 @@ func (app *App) handleKeyEvent(ev *tcell.EventKey) {
256 // requestHistory is a wrapper around irc.Session.RequestHistory to only request
257 // history when needed.
258 func (app *App) requestHistory() {
259-	if app.s == nil {
260+	netID, buffer := app.win.CurrentBuffer()
261+	s := app.sessions[netID]
262+	if s == nil {
263 		return
264 	}
265-	buffer := app.win.CurrentBuffer()
266-	if app.win.IsAtTop() && buffer != Home {
267+	if app.win.IsAtTop() && buffer != "" {
268 		t := time.Now()
269 		if bound, ok := app.messageBounds[buffer]; ok {
270 			t = bound.first
271 		}
272-		app.s.NewHistoryRequest(buffer).
273+		s.NewHistoryRequest(buffer).
274 			WithLimit(100).
275 			Before(t)
276 	}
277 }
278 
279-func (app *App) handleIRCEvent(ev interface{}) {
280+func (app *App) handleIRCEvent(netID string, ev interface{}) {
281 	if ev == nil {
282-		app.s.Close()
283-		app.s = nil
284+		if s, ok := app.sessions[netID]; ok {
285+			s.Close()
286+			delete(app.sessions, netID)
287+		}
288 		return
289 	}
290 	if s, ok := ev.(*irc.Session); ok {
291-		app.s = s
292+		if s, ok := app.sessions[netID]; ok {
293+			s.Close()
294+		}
295+		app.sessions[netID] = s
296 		return
297 	}
298 	if _, ok := ev.(irc.Typing); ok {
299@@ -524,12 +535,19 @@ func (app *App) handleIRCEvent(ev interface{}) {
300 		return
301 	}
302 
303-	msg := ev.(irc.Message)
304+	msg, ok := ev.(irc.Message)
305+	if !ok {
306+		panic("unreachable")
307+	}
308+	s, ok := app.sessions[netID]
309+	if !ok {
310+		panic("unreachable")
311+	}
312 
313 	// Mutate IRC state
314-	ev, err := app.s.HandleMessage(msg)
315+	ev, err := s.HandleMessage(msg)
316 	if err != nil {
317-		app.win.AddLine(Home, ui.NotifyUnread, ui.Line{
318+		app.win.AddLine(netID, "", ui.NotifyUnread, ui.Line{
319 			Head:      "!!",
320 			HeadColor: tcell.ColorRed,
321 			Body:      ui.PlainSprintf("Received corrupt message %q: %s", msg.String(), err),
322@@ -543,26 +561,26 @@ func (app *App) handleIRCEvent(ev interface{}) {
323 		for _, channel := range app.cfg.Channels {
324 			// TODO: group JOIN messages
325 			// TODO: support autojoining channels with keys
326-			app.s.Join(channel, "")
327+			s.Join(channel, "")
328 		}
329 		body := "Connected to the server"
330-		if app.s.Nick() != app.cfg.Nick {
331-			body = fmt.Sprintf("Connected to the server as %s", app.s.Nick())
332+		if s.Nick() != app.cfg.Nick {
333+			body = fmt.Sprintf("Connected to the server as %s", s.Nick())
334 		}
335-		app.win.AddLine(Home, ui.NotifyUnread, ui.Line{
336+		app.win.AddLine(netID, "", ui.NotifyNone, ui.Line{
337 			At:   msg.TimeOrNow(),
338 			Head: "--",
339 			Body: ui.PlainString(body),
340 		})
341 	case irc.SelfNickEvent:
342 		var body ui.StyledStringBuilder
343-		body.WriteString(fmt.Sprintf("%s\u2192%s", ev.FormerNick, app.s.Nick()))
344+		body.WriteString(fmt.Sprintf("%s\u2192%s", ev.FormerNick, s.Nick()))
345 		textStyle := tcell.StyleDefault.Foreground(tcell.ColorGray)
346 		arrowStyle := tcell.StyleDefault
347 		body.AddStyle(0, textStyle)
348 		body.AddStyle(len(ev.FormerNick), arrowStyle)
349-		body.AddStyle(body.Len()-len(app.s.Nick()), textStyle)
350-		app.addStatusLine(ui.Line{
351+		body.AddStyle(body.Len()-len(s.Nick()), textStyle)
352+		app.addStatusLine(netID, ui.Line{
353 			At:        msg.TimeOrNow(),
354 			Head:      "--",
355 			HeadColor: tcell.ColorGray,
356@@ -577,8 +595,8 @@ func (app *App) handleIRCEvent(ev interface{}) {
357 		body.AddStyle(0, textStyle)
358 		body.AddStyle(len(ev.FormerNick), arrowStyle)
359 		body.AddStyle(body.Len()-len(ev.User), textStyle)
360-		for _, c := range app.s.ChannelsSharedWith(ev.User) {
361-			app.win.AddLine(c, ui.NotifyNone, ui.Line{
362+		for _, c := range s.ChannelsSharedWith(ev.User) {
363+			app.win.AddLine(netID, c, ui.NotifyNone, ui.Line{
364 				At:        msg.TimeOrNow(),
365 				Head:      "--",
366 				HeadColor: tcell.ColorGray,
367@@ -587,14 +605,14 @@ func (app *App) handleIRCEvent(ev interface{}) {
368 			})
369 		}
370 	case irc.SelfJoinEvent:
371-		i, added := app.win.AddBuffer(ev.Channel)
372+		i, added := app.win.AddBuffer(netID, "", ev.Channel)
373 		bounds, ok := app.messageBounds[ev.Channel]
374 		if added || !ok {
375-			app.s.NewHistoryRequest(ev.Channel).
376+			s.NewHistoryRequest(ev.Channel).
377 				WithLimit(200).
378 				Before(msg.TimeOrNow())
379 		} else {
380-			app.s.NewHistoryRequest(ev.Channel).
381+			s.NewHistoryRequest(ev.Channel).
382 				WithLimit(200).
383 				After(bounds.last)
384 		}
385@@ -602,13 +620,14 @@ func (app *App) handleIRCEvent(ev interface{}) {
386 			app.win.JumpBufferIndex(i)
387 		}
388 		if ev.Topic != "" {
389-			app.printTopic(ev.Channel)
390+			app.printTopic(netID, ev.Channel)
391 		}
392 
393 		// Restore last buffer
394-		lastBuffer := app.lastBuffer
395-		if ev.Channel == lastBuffer {
396-			app.win.JumpBuffer(lastBuffer)
397+		if netID == app.lastNetID && ev.Channel == app.lastBuffer {
398+			app.win.JumpBufferNetwork(app.lastNetID, app.lastBuffer)
399+			app.lastNetID = ""
400+			app.lastBuffer = ""
401 		}
402 	case irc.UserJoinEvent:
403 		var body ui.StyledStringBuilder
404@@ -617,7 +636,7 @@ func (app *App) handleIRCEvent(ev interface{}) {
405 		body.WriteByte('+')
406 		body.SetStyle(tcell.StyleDefault.Foreground(tcell.ColorGray))
407 		body.WriteString(ev.User)
408-		app.win.AddLine(ev.Channel, ui.NotifyNone, ui.Line{
409+		app.win.AddLine(netID, ev.Channel, ui.NotifyNone, ui.Line{
410 			At:        msg.TimeOrNow(),
411 			Head:      "--",
412 			HeadColor: tcell.ColorGray,
413@@ -634,7 +653,7 @@ func (app *App) handleIRCEvent(ev interface{}) {
414 		body.WriteByte('-')
415 		body.SetStyle(tcell.StyleDefault.Foreground(tcell.ColorGray))
416 		body.WriteString(ev.User)
417-		app.win.AddLine(ev.Channel, ui.NotifyNone, ui.Line{
418+		app.win.AddLine(netID, ev.Channel, ui.NotifyNone, ui.Line{
419 			At:        msg.TimeOrNow(),
420 			Head:      "--",
421 			HeadColor: tcell.ColorGray,
422@@ -649,7 +668,7 @@ func (app *App) handleIRCEvent(ev interface{}) {
423 		body.SetStyle(tcell.StyleDefault.Foreground(tcell.ColorGray))
424 		body.WriteString(ev.User)
425 		for _, c := range ev.Channels {
426-			app.win.AddLine(c, ui.NotifyNone, ui.Line{
427+			app.win.AddLine(netID, c, ui.NotifyNone, ui.Line{
428 				At:        msg.TimeOrNow(),
429 				Head:      "--",
430 				HeadColor: tcell.ColorGray,
431@@ -659,7 +678,7 @@ func (app *App) handleIRCEvent(ev interface{}) {
432 		}
433 	case irc.TopicChangeEvent:
434 		body := fmt.Sprintf("Topic changed to: %s", ev.Topic)
435-		app.win.AddLine(ev.Channel, ui.NotifyUnread, ui.Line{
436+		app.win.AddLine(netID, ev.Channel, ui.NotifyUnread, ui.Line{
437 			At:        msg.TimeOrNow(),
438 			Head:      "--",
439 			HeadColor: tcell.ColorGray,
440@@ -667,7 +686,7 @@ func (app *App) handleIRCEvent(ev interface{}) {
441 		})
442 	case irc.ModeChangeEvent:
443 		body := fmt.Sprintf("Mode change: %s", ev.Mode)
444-		app.win.AddLine(ev.Channel, ui.NotifyUnread, ui.Line{
445+		app.win.AddLine(netID, ev.Channel, ui.NotifyUnread, ui.Line{
446 			At:        msg.TimeOrNow(),
447 			Head:      "--",
448 			HeadColor: tcell.ColorGray,
449@@ -677,11 +696,11 @@ func (app *App) handleIRCEvent(ev interface{}) {
450 		var buffer string
451 		var notify ui.NotifyType
452 		var body string
453-		if app.s.IsMe(ev.Invitee) {
454-			buffer = Home
455+		if s.IsMe(ev.Invitee) {
456+			buffer = ""
457 			notify = ui.NotifyHighlight
458 			body = fmt.Sprintf("%s invited you to join %s", ev.Inviter, ev.Channel)
459-		} else if app.s.IsMe(ev.Inviter) {
460+		} else if s.IsMe(ev.Inviter) {
461 			buffer = ev.Channel
462 			notify = ui.NotifyNone
463 			body = fmt.Sprintf("You invited %s to join this channel", ev.Invitee)
464@@ -690,7 +709,7 @@ func (app *App) handleIRCEvent(ev interface{}) {
465 			notify = ui.NotifyUnread
466 			body = fmt.Sprintf("%s invited %s to join this channel", ev.Inviter, ev.Invitee)
467 		}
468-		app.win.AddLine(buffer, notify, ui.Line{
469+		app.win.AddLine(netID, buffer, notify, ui.Line{
470 			At:        msg.TimeOrNow(),
471 			Head:      "--",
472 			HeadColor: tcell.ColorGray,
473@@ -698,19 +717,20 @@ func (app *App) handleIRCEvent(ev interface{}) {
474 			Highlight: notify == ui.NotifyHighlight,
475 		})
476 	case irc.MessageEvent:
477-		buffer, line, hlNotification := app.formatMessage(ev)
478+		buffer, line, hlNotification := app.formatMessage(s, ev)
479 		var notify ui.NotifyType
480 		if hlNotification {
481 			notify = ui.NotifyHighlight
482 		} else {
483 			notify = ui.NotifyUnread
484 		}
485-		app.win.AddLine(buffer, notify, line)
486+		app.win.AddLine(netID, buffer, notify, line)
487 		if hlNotification {
488 			app.notifyHighlight(buffer, ev.User, line.Body.String())
489 		}
490-		if !app.s.IsChannel(msg.Params[0]) && !app.s.IsMe(ev.User) {
491+		if !s.IsChannel(msg.Params[0]) && !s.IsMe(ev.User) {
492 			app.lastQuery = msg.Prefix.Name
493+			app.lastQueryNet = netID
494 		}
495 		bounds := app.messageBounds[ev.Target]
496 		bounds.Update(&line)
497@@ -722,7 +742,7 @@ func (app *App) handleIRCEvent(ev interface{}) {
498 		for _, m := range ev.Messages {
499 			switch ev := m.(type) {
500 			case irc.MessageEvent:
501-				_, line, _ := app.formatMessage(ev)
502+				_, line, _ := app.formatMessage(s, ev)
503 				if hasBounds {
504 					c := bounds.Compare(&line)
505 					if c < 0 {
506@@ -735,7 +755,7 @@ func (app *App) handleIRCEvent(ev interface{}) {
507 				}
508 			}
509 		}
510-		app.win.AddLines(ev.Target, linesBefore, linesAfter)
511+		app.win.AddLines(netID, ev.Target, linesBefore, linesAfter)
512 		if len(linesBefore) != 0 {
513 			bounds.Update(&linesBefore[0])
514 			bounds.Update(&linesBefore[len(linesBefore)-1])
515@@ -745,6 +765,11 @@ func (app *App) handleIRCEvent(ev interface{}) {
516 			bounds.Update(&linesAfter[len(linesAfter)-1])
517 		}
518 		app.messageBounds[ev.Target] = bounds
519+	case irc.BouncerNetworkEvent:
520+		_, added := app.win.AddBuffer(ev.ID, ev.Name, "")
521+		if added {
522+			go app.ircLoop(ev.ID)
523+		}
524 	case irc.ErrorEvent:
525 		if isBlackListed(msg.Command) {
526 			break
527@@ -764,7 +789,7 @@ func (app *App) handleIRCEvent(ev interface{}) {
528 		default:
529 			panic("unreachable")
530 		}
531-		app.addStatusLine(ui.Line{
532+		app.addStatusLine(netID, ui.Line{
533 			At:   msg.TimeOrNow(),
534 			Head: head,
535 			Body: ui.PlainString(body),
536@@ -782,13 +807,13 @@ func isBlackListed(command string) bool {
537 }
538 
539 // isHighlight reports whether the given message content is a highlight.
540-func (app *App) isHighlight(content string) bool {
541-	contentCf := app.s.Casemap(content)
542+func (app *App) isHighlight(s *irc.Session, content string) bool {
543+	contentCf := s.Casemap(content)
544 	if app.highlights == nil {
545-		return strings.Contains(contentCf, app.s.NickCf())
546+		return strings.Contains(contentCf, s.NickCf())
547 	}
548 	for _, h := range app.highlights {
549-		if strings.Contains(contentCf, app.s.Casemap(h)) {
550+		if strings.Contains(contentCf, s.Casemap(h)) {
551 			return true
552 		}
553 	}
554@@ -805,8 +830,9 @@ func (app *App) notifyHighlight(buffer, nick, content string) {
555 	if err != nil {
556 		return
557 	}
558+	netID, curBuffer := app.win.CurrentBuffer()
559 	here := "0"
560-	if buffer == app.win.CurrentBuffer() {
561+	if buffer == curBuffer { // TODO also check netID
562 		here = "1"
563 	}
564 	cmd := exec.Command(sh, "-c", app.cfg.OnHighlight)
565@@ -819,7 +845,7 @@ func (app *App) notifyHighlight(buffer, nick, content string) {
566 	output, err := cmd.CombinedOutput()
567 	if err != nil {
568 		body := fmt.Sprintf("Failed to invoke on-highlight command: %v. Output: %q", err, string(output))
569-		app.addStatusLine(ui.Line{
570+		app.addStatusLine(netID, ui.Line{
571 			At:        time.Now(),
572 			Head:      "!!",
573 			HeadColor: tcell.ColorRed,
574@@ -831,32 +857,36 @@ func (app *App) notifyHighlight(buffer, nick, content string) {
575 // typing sends typing notifications to the IRC server according to the user
576 // input.
577 func (app *App) typing() {
578-	if app.s == nil || app.cfg.NoTypings {
579+	netID, buffer := app.win.CurrentBuffer()
580+	s := app.sessions[netID]
581+	if s == nil || app.cfg.NoTypings {
582 		return
583 	}
584-	buffer := app.win.CurrentBuffer()
585-	if buffer == Home {
586+	if buffer == "" {
587 		return
588 	}
589 	input := app.win.InputContent()
590 	if len(input) == 0 {
591-		app.s.TypingStop(buffer)
592+		s.TypingStop(buffer)
593 	} else if !isCommand(input) {
594-		app.s.Typing(app.win.CurrentBuffer())
595+		s.Typing(buffer)
596 	}
597 }
598 
599 // completions computes the list of completions given the input text and the
600 // cursor position.
601 func (app *App) completions(cursorIdx int, text []rune) []ui.Completion {
602-	var cs []ui.Completion
603-
604 	if len(text) == 0 {
605-		return cs
606+		return nil
607+	}
608+	netID, buffer := app.win.CurrentBuffer()
609+	s := app.sessions[netID]
610+	if s == nil {
611+		return nil
612 	}
613 
614-	buffer := app.win.CurrentBuffer()
615-	if app.s.IsChannel(buffer) {
616+	var cs []ui.Completion
617+	if buffer != "" {
618 		cs = app.completionsChannelTopic(cs, cursorIdx, text)
619 		cs = app.completionsChannelMembers(cs, cursorIdx, text)
620 	}
621@@ -878,17 +908,22 @@ func (app *App) completions(cursorIdx int, text []rune) []ui.Completion {
622 // - which buffer the message must be added to,
623 // - the UI line,
624 // - whether senpai must trigger the "on-highlight" command.
625-func (app *App) formatMessage(ev irc.MessageEvent) (buffer string, line ui.Line, hlNotification bool) {
626-	isFromSelf := app.s.IsMe(ev.User)
627-	isHighlight := app.isHighlight(ev.Content)
628+func (app *App) formatMessage(s *irc.Session, ev irc.MessageEvent) (buffer string, line ui.Line, hlNotification bool) {
629+	isFromSelf := s.IsMe(ev.User)
630+	isHighlight := app.isHighlight(s, ev.Content)
631 	isAction := strings.HasPrefix(ev.Content, "\x01ACTION")
632 	isQuery := !ev.TargetIsChannel && ev.Command == "PRIVMSG"
633 	isNotice := ev.Command == "NOTICE"
634 
635 	if !ev.TargetIsChannel && isNotice {
636-		buffer = app.win.CurrentBuffer()
637+		curNetID, curBuffer := app.win.CurrentBuffer()
638+		if curNetID == s.NetID() {
639+			buffer = curBuffer
640+		} else {
641+			isHighlight = true
642+		}
643 	} else if !ev.TargetIsChannel {
644-		buffer = Home
645+		buffer = ""
646 	} else {
647 		buffer = ev.Target
648 	}
649@@ -942,40 +977,45 @@ func (app *App) formatMessage(ev irc.MessageEvent) (buffer string, line ui.Line,
650 
651 // updatePrompt changes the prompt text according to the application context.
652 func (app *App) updatePrompt() {
653-	buffer := app.win.CurrentBuffer()
654+	netID, buffer := app.win.CurrentBuffer()
655+	s := app.sessions[netID]
656 	command := isCommand(app.win.InputContent())
657 	var prompt ui.StyledString
658-	if buffer == Home || command {
659+	if buffer == "" || command {
660 		prompt = ui.Styled(">",
661 			tcell.
662 				StyleDefault.
663 				Foreground(tcell.Color(app.cfg.Colors.Prompt)),
664 		)
665-	} else if app.s == nil {
666+	} else if s == nil {
667 		prompt = ui.Styled("<offline>",
668 			tcell.
669 				StyleDefault.
670 				Foreground(tcell.ColorRed),
671 		)
672 	} else {
673-		prompt = identString(app.s.Nick())
674+		prompt = identString(s.Nick())
675 	}
676 	app.win.SetPrompt(prompt)
677 }
678 
679-func (app *App) printTopic(buffer string) {
680+func (app *App) printTopic(netID, buffer string) (ok bool) {
681 	var body string
682-
683-	topic, who, at := app.s.Topic(buffer)
684+	s := app.sessions[netID]
685+	if s == nil {
686+		return false
687+	}
688+	topic, who, at := s.Topic(buffer)
689 	if who == nil {
690 		body = fmt.Sprintf("Topic: %s", topic)
691 	} else {
692 		body = fmt.Sprintf("Topic (by %s, %s): %s", who, at.Local().Format("Mon Jan 2 15:04:05"), topic)
693 	}
694-	app.win.AddLine(buffer, ui.NotifyNone, ui.Line{
695+	app.win.AddLine(netID, buffer, ui.NotifyNone, ui.Line{
696 		At:        time.Now(),
697 		Head:      "--",
698 		HeadColor: tcell.ColorGray,
699 		Body:      ui.Styled(body, tcell.StyleDefault.Foreground(tcell.ColorGray)),
700 	})
701+	return true
702 }
+14, -7
 1@@ -43,19 +43,21 @@ func main() {
 2 
 3 	cfg.Debug = cfg.Debug || debug
 4 
 5-	lastBuffer := getLastBuffer()
 6-
 7-	app, err := senpai.NewApp(cfg, lastBuffer)
 8+	app, err := senpai.NewApp(cfg)
 9 	if err != nil {
10 		panic(err)
11 	}
12 
13+	lastNetID, lastBuffer := getLastBuffer()
14+	app.SwitchToBuffer(lastNetID, lastBuffer)
15+
16 	app.Run()
17 	app.Close()
18 
19 	// Write last buffer on close
20 	lastBufferPath := getLastBufferPath()
21-	err = os.WriteFile(lastBufferPath, []byte(app.CurrentBuffer()), 0666)
22+	lastNetID, lastBuffer = app.CurrentBuffer()
23+	err = os.WriteFile(lastBufferPath, []byte(fmt.Sprintf("%s %s", lastNetID, lastBuffer)), 0666)
24 	if err != nil {
25 		fmt.Fprintf(os.Stderr, "failed to write last buffer at %q: %s\n", lastBufferPath, err)
26 	}
27@@ -76,11 +78,16 @@ func getLastBufferPath() string {
28 	return lastBufferPath
29 }
30 
31-func getLastBuffer() string {
32+func getLastBuffer() (netID, buffer string) {
33 	buf, err := ioutil.ReadFile(getLastBufferPath())
34 	if err != nil {
35-		return ""
36+		return "", ""
37+	}
38+
39+	fields := strings.SplitN(string(buf), " ", 2)
40+	if len(fields) < 2 {
41+		return "", ""
42 	}
43 
44-	return strings.TrimSpace(string(buf))
45+	return fields[0], fields[1]
46 }
+108, -96
  1@@ -136,28 +136,27 @@ func init() {
  2 	}
  3 }
  4 
  5-func noCommand(app *App, buffer, content string) error {
  6-	// You can't send messages to home buffer, and it might get
  7-	// delivered to a user "home" without a bouncer, which will be bad.
  8-	if buffer == Home {
  9-		return fmt.Errorf("can't send message to home")
 10+func noCommand(app *App, content string) error {
 11+	netID, buffer := app.win.CurrentBuffer()
 12+	if buffer == "" {
 13+		return fmt.Errorf("can't send message to this buffer")
 14 	}
 15-
 16-	if app.s == nil {
 17+	s := app.sessions[netID]
 18+	if s == nil {
 19 		return errOffline
 20 	}
 21 
 22-	app.s.PrivMsg(buffer, content)
 23-	if !app.s.HasCapability("echo-message") {
 24-		buffer, line, _ := app.formatMessage(irc.MessageEvent{
 25-			User:            app.s.Nick(),
 26+	s.PrivMsg(buffer, content)
 27+	if !s.HasCapability("echo-message") {
 28+		buffer, line, _ := app.formatMessage(s, irc.MessageEvent{
 29+			User:            s.Nick(),
 30 			Target:          buffer,
 31 			TargetIsChannel: true,
 32 			Command:         "PRIVMSG",
 33 			Content:         content,
 34 			Time:            time.Now(),
 35 		})
 36-		app.win.AddLine(buffer, ui.NotifyNone, line)
 37+		app.win.AddLine(netID, buffer, ui.NotifyNone, line)
 38 	}
 39 
 40 	return nil
 41@@ -180,8 +179,9 @@ func commandDoBuffer(app *App, args []string) error {
 42 
 43 func commandDoHelp(app *App, args []string) (err error) {
 44 	t := time.Now()
 45+	netID, buffer := app.win.CurrentBuffer()
 46 	if len(args) == 0 {
 47-		app.win.AddLine(app.win.CurrentBuffer(), ui.NotifyNone, ui.Line{
 48+		app.win.AddLine(netID, buffer, ui.NotifyNone, ui.Line{
 49 			At:   t,
 50 			Head: "--",
 51 			Body: ui.PlainString("Available commands:"),
 52@@ -190,22 +190,22 @@ func commandDoHelp(app *App, args []string) (err error) {
 53 			if cmd.Desc == "" {
 54 				continue
 55 			}
 56-			app.win.AddLine(app.win.CurrentBuffer(), ui.NotifyNone, ui.Line{
 57+			app.win.AddLine(netID, buffer, ui.NotifyNone, ui.Line{
 58 				At:   t,
 59 				Body: ui.PlainSprintf("  \x02%s\x02 %s", cmdName, cmd.Usage),
 60 			})
 61-			app.win.AddLine(app.win.CurrentBuffer(), ui.NotifyNone, ui.Line{
 62+			app.win.AddLine(netID, buffer, ui.NotifyNone, ui.Line{
 63 				At:   t,
 64 				Body: ui.PlainSprintf("    %s", cmd.Desc),
 65 			})
 66-			app.win.AddLine(app.win.CurrentBuffer(), ui.NotifyNone, ui.Line{
 67+			app.win.AddLine(netID, buffer, ui.NotifyNone, ui.Line{
 68 				At: t,
 69 			})
 70 		}
 71 	} else {
 72 		search := strings.ToUpper(args[0])
 73 		found := false
 74-		app.win.AddLine(app.win.CurrentBuffer(), ui.NotifyNone, ui.Line{
 75+		app.win.AddLine(netID, buffer, ui.NotifyNone, ui.Line{
 76 			At:   t,
 77 			Head: "--",
 78 			Body: ui.PlainSprintf("Commands that match \"%s\":", search),
 79@@ -221,21 +221,21 @@ func commandDoHelp(app *App, args []string) (err error) {
 80 			usage.SetStyle(tcell.StyleDefault)
 81 			usage.WriteByte(' ')
 82 			usage.WriteString(cmd.Usage)
 83-			app.win.AddLine(app.win.CurrentBuffer(), ui.NotifyNone, ui.Line{
 84+			app.win.AddLine(netID, buffer, ui.NotifyNone, ui.Line{
 85 				At:   t,
 86 				Body: usage.StyledString(),
 87 			})
 88-			app.win.AddLine(app.win.CurrentBuffer(), ui.NotifyNone, ui.Line{
 89+			app.win.AddLine(netID, buffer, ui.NotifyNone, ui.Line{
 90 				At:   t,
 91 				Body: ui.PlainSprintf("  %s", cmd.Desc),
 92 			})
 93-			app.win.AddLine(app.win.CurrentBuffer(), ui.NotifyNone, ui.Line{
 94+			app.win.AddLine(netID, buffer, ui.NotifyNone, ui.Line{
 95 				At: t,
 96 			})
 97 			found = true
 98 		}
 99 		if !found {
100-			app.win.AddLine(app.win.CurrentBuffer(), ui.NotifyNone, ui.Line{
101+			app.win.AddLine(netID, buffer, ui.NotifyNone, ui.Line{
102 				At:   t,
103 				Body: ui.PlainSprintf("  no command matches %q", args[0]),
104 			})
105@@ -245,75 +245,81 @@ func commandDoHelp(app *App, args []string) (err error) {
106 }
107 
108 func commandDoJoin(app *App, args []string) (err error) {
109-	if app.s == nil {
110+	s := app.CurrentSession()
111+	if s == nil {
112 		return errOffline
113 	}
114-
115+	channel := args[0]
116 	key := ""
117 	if len(args) == 2 {
118 		key = args[1]
119 	}
120-	app.s.Join(args[0], key)
121+	s.Join(channel, key)
122 	return nil
123 }
124 
125 func commandDoMe(app *App, args []string) (err error) {
126-	if app.s == nil {
127-		return errOffline
128-	}
129-
130-	buffer := app.win.CurrentBuffer()
131-	if buffer == Home {
132+	netID, buffer := app.win.CurrentBuffer()
133+	if buffer == "" {
134+		netID = app.lastQueryNet
135 		buffer = app.lastQuery
136 	}
137+	s := app.sessions[netID]
138+	if s == nil {
139+		return errOffline
140+	}
141 	content := fmt.Sprintf("\x01ACTION %s\x01", args[0])
142-	app.s.PrivMsg(buffer, content)
143-	if !app.s.HasCapability("echo-message") {
144-		buffer, line, _ := app.formatMessage(irc.MessageEvent{
145-			User:            app.s.Nick(),
146+	s.PrivMsg(buffer, content)
147+	if !s.HasCapability("echo-message") {
148+		buffer, line, _ := app.formatMessage(s, irc.MessageEvent{
149+			User:            s.Nick(),
150 			Target:          buffer,
151 			TargetIsChannel: true,
152 			Command:         "PRIVMSG",
153 			Content:         content,
154 			Time:            time.Now(),
155 		})
156-		app.win.AddLine(buffer, ui.NotifyNone, line)
157+		app.win.AddLine(netID, buffer, ui.NotifyNone, line)
158 	}
159 	return nil
160 }
161 
162 func commandDoMsg(app *App, args []string) (err error) {
163-	if app.s == nil {
164-		return errOffline
165-	}
166-
167 	target := args[0]
168 	content := args[1]
169-	app.s.PrivMsg(target, content)
170-	if !app.s.HasCapability("echo-message") {
171-		buffer, line, _ := app.formatMessage(irc.MessageEvent{
172-			User:            app.s.Nick(),
173+	netID, _ := app.win.CurrentBuffer()
174+	s := app.sessions[netID]
175+	if s == nil {
176+		return errOffline
177+	}
178+	s.PrivMsg(target, content)
179+	if !s.HasCapability("echo-message") {
180+		buffer, line, _ := app.formatMessage(s, irc.MessageEvent{
181+			User:            s.Nick(),
182 			Target:          target,
183 			TargetIsChannel: true,
184 			Command:         "PRIVMSG",
185 			Content:         content,
186 			Time:            time.Now(),
187 		})
188-		app.win.AddLine(buffer, ui.NotifyNone, line)
189+		app.win.AddLine(netID, buffer, ui.NotifyNone, line)
190 	}
191 	return nil
192 }
193 
194 func commandDoNames(app *App, args []string) (err error) {
195-	if app.s == nil {
196+	netID, buffer := app.win.CurrentBuffer()
197+	s := app.sessions[netID]
198+	if s == nil {
199 		return errOffline
200 	}
201-
202-	buffer := app.win.CurrentBuffer()
203+	if !s.IsChannel(buffer) {
204+		return fmt.Errorf("this is not a channel")
205+	}
206 	var sb ui.StyledStringBuilder
207 	sb.SetStyle(tcell.StyleDefault.Foreground(tcell.ColorGrey))
208 	sb.WriteString("Names: ")
209-	for _, name := range app.s.Names(buffer) {
210+	for _, name := range s.Names(buffer) {
211 		if name.PowerLevel != "" {
212 			sb.SetStyle(tcell.StyleDefault.Foreground(tcell.ColorGreen))
213 			sb.WriteString(name.PowerLevel)
214@@ -324,7 +330,7 @@ func commandDoNames(app *App, args []string) (err error) {
215 	}
216 	body := sb.StyledString()
217 	// TODO remove last space
218-	app.win.AddLine(buffer, ui.NotifyNone, ui.Line{
219+	app.win.AddLine(netID, buffer, ui.NotifyNone, ui.Line{
220 		At:        time.Now(),
221 		Head:      "--",
222 		HeadColor: tcell.ColorGray,
223@@ -334,40 +340,40 @@ func commandDoNames(app *App, args []string) (err error) {
224 }
225 
226 func commandDoNick(app *App, args []string) (err error) {
227-	if app.s == nil {
228-		return errOffline
229-	}
230-
231 	nick := args[0]
232-	if i := strings.IndexAny(nick, " :@!*?"); i >= 0 {
233+	if i := strings.IndexAny(nick, " :"); i >= 0 {
234 		return fmt.Errorf("illegal char %q in nickname", nick[i])
235 	}
236-	app.s.ChangeNick(nick)
237-	return nil
238-}
239-
240-func commandDoMode(app *App, args []string) (err error) {
241-	if app.s == nil {
242+	s := app.CurrentSession()
243+	if s == nil {
244 		return errOffline
245 	}
246+	s.ChangeNick(nick)
247+	return
248+}
249 
250+func commandDoMode(app *App, args []string) (err error) {
251 	channel := args[0]
252 	flags := args[1]
253 	modeArgs := args[2:]
254 
255-	app.s.ChangeMode(channel, flags, modeArgs)
256+	s := app.CurrentSession()
257+	if s == nil {
258+		return errOffline
259+	}
260+	s.ChangeMode(channel, flags, modeArgs)
261 	return nil
262 }
263 
264 func commandDoPart(app *App, args []string) (err error) {
265-	if app.s == nil {
266+	netID, channel := app.win.CurrentBuffer()
267+	s := app.sessions[netID]
268+	if s == nil {
269 		return errOffline
270 	}
271-
272-	channel := app.win.CurrentBuffer()
273 	reason := ""
274 	if 0 < len(args) {
275-		if app.s.IsChannel(args[0]) {
276+		if s.IsChannel(args[0]) {
277 			channel = args[0]
278 			if 1 < len(args) {
279 				reason = args[1]
280@@ -376,10 +382,11 @@ func commandDoPart(app *App, args []string) (err error) {
281 			reason = args[0]
282 		}
283 	}
284-	if channel == Home {
285-		return fmt.Errorf("cannot part home")
286+
287+	if channel == "" {
288+		return fmt.Errorf("cannot part this buffer")
289 	}
290-	app.s.Part(channel, reason)
291+	s.Part(channel, reason)
292 	return nil
293 }
294 
295@@ -388,68 +395,73 @@ func commandDoQuit(app *App, args []string) (err error) {
296 	if 0 < len(args) {
297 		reason = args[0]
298 	}
299-	if app.s != nil {
300-		app.s.Quit(reason)
301+	for _, session := range app.sessions {
302+		session.Quit(reason)
303 	}
304 	app.win.Exit()
305 	return nil
306 }
307 
308 func commandDoQuote(app *App, args []string) (err error) {
309-	if app.s == nil {
310+	s := app.CurrentSession()
311+	if s == nil {
312 		return errOffline
313 	}
314-
315-	app.s.SendRaw(args[0])
316+	s.SendRaw(args[0])
317 	return nil
318 }
319 
320 func commandDoR(app *App, args []string) (err error) {
321-	if app.s == nil {
322+	s := app.sessions[app.lastQueryNet]
323+	if s == nil {
324 		return errOffline
325 	}
326-
327-	app.s.PrivMsg(app.lastQuery, args[0])
328-	if !app.s.HasCapability("echo-message") {
329-		buffer, line, _ := app.formatMessage(irc.MessageEvent{
330-			User:            app.s.Nick(),
331+	s.PrivMsg(app.lastQuery, args[0])
332+	if !s.HasCapability("echo-message") {
333+		buffer, line, _ := app.formatMessage(s, irc.MessageEvent{
334+			User:            s.Nick(),
335 			Target:          app.lastQuery,
336 			TargetIsChannel: true,
337 			Command:         "PRIVMSG",
338 			Content:         args[0],
339 			Time:            time.Now(),
340 		})
341-		app.win.AddLine(buffer, ui.NotifyNone, line)
342+		app.win.AddLine(app.lastQueryNet, buffer, ui.NotifyNone, line)
343 	}
344 	return nil
345 }
346 
347 func commandDoTopic(app *App, args []string) (err error) {
348-	if app.s == nil {
349-		return errOffline
350-	}
351-
352+	netID, buffer := app.win.CurrentBuffer()
353+	var ok bool
354 	if len(args) == 0 {
355-		app.printTopic(app.win.CurrentBuffer())
356+		ok = app.printTopic(netID, buffer)
357 	} else {
358-		app.s.ChangeTopic(app.win.CurrentBuffer(), args[0])
359+		s := app.sessions[netID]
360+		if s != nil {
361+			s.ChangeTopic(buffer, args[0])
362+			ok = true
363+		}
364+	}
365+	if !ok {
366+		return errOffline
367 	}
368 	return nil
369 }
370 
371 func commandDoInvite(app *App, args []string) (err error) {
372-	if app.s == nil {
373+	nick := args[0]
374+	netID, channel := app.win.CurrentBuffer()
375+	s := app.sessions[netID]
376+	if s == nil {
377 		return errOffline
378 	}
379-
380-	nick := args[0]
381-	channel := app.win.CurrentBuffer()
382 	if len(args) == 2 {
383 		channel = args[1]
384-	} else if channel == Home {
385-		return fmt.Errorf("cannot invite to home")
386+	} else if channel == "" {
387+		return fmt.Errorf("either send this command from a channel, or specify the channel")
388 	}
389-	app.s.Invite(nick, channel)
390+	s.Invite(nick, channel)
391 	return nil
392 }
393 
394@@ -522,7 +534,7 @@ func (app *App) handleInput(buffer, content string) error {
395 
396 	cmdName, rawArgs, isCommand := parseCommand(content)
397 	if !isCommand {
398-		return noCommand(app, buffer, rawArgs)
399+		return noCommand(app, rawArgs)
400 	}
401 	if cmdName == "" {
402 		return fmt.Errorf("lone slash at the beginning")
403@@ -554,8 +566,8 @@ func (app *App) handleInput(buffer, content string) error {
404 	if len(args) < cmd.MinArgs {
405 		return fmt.Errorf("usage: %s %s", cmdName, cmd.Usage)
406 	}
407-	if buffer == Home && !cmd.AllowHome {
408-		return fmt.Errorf("command %q cannot be executed from home", cmdName)
409+	if buffer == "" && !cmd.AllowHome {
410+		return fmt.Errorf("command %q cannot be executed from a server buffer", cmdName)
411 	}
412 
413 	return cmd.Handle(app, args)
+12, -7
 1@@ -18,9 +18,11 @@ func (app *App) completionsChannelMembers(cs []ui.Completion, cursorIdx int, tex
 2 	if len(word) == 0 {
 3 		return cs
 4 	}
 5-	wordCf := app.s.Casemap(string(word))
 6-	for _, name := range app.s.Names(app.win.CurrentBuffer()) {
 7-		if strings.HasPrefix(app.s.Casemap(name.Name.Name), wordCf) {
 8+	netID, buffer := app.win.CurrentBuffer()
 9+	s := app.sessions[netID] // is not nil
10+	wordCf := s.Casemap(string(word))
11+	for _, name := range s.Names(buffer) {
12+		if strings.HasPrefix(s.Casemap(name.Name.Name), wordCf) {
13 			nickComp := []rune(name.Name.Name)
14 			if start == 0 {
15 				nickComp = append(nickComp, ':')
16@@ -45,7 +47,9 @@ func (app *App) completionsChannelTopic(cs []ui.Completion, cursorIdx int, text
17 	if !hasPrefix(text, []rune("/topic ")) {
18 		return cs
19 	}
20-	topic, _, _ := app.s.Topic(app.win.CurrentBuffer())
21+	netID, buffer := app.win.CurrentBuffer()
22+	s := app.sessions[netID] // is not nil
23+	topic, _, _ := s.Topic(buffer)
24 	if cursorIdx == len(text) {
25 		compText := append(text, []rune(topic)...)
26 		cs = append(cs, ui.Completion{
27@@ -60,6 +64,7 @@ func (app *App) completionsMsg(cs []ui.Completion, cursorIdx int, text []rune) [
28 	if !hasPrefix(text, []rune("/msg ")) {
29 		return cs
30 	}
31+	s := app.CurrentSession() // is not nil
32 	// Check if the first word (target) is already written and complete (in
33 	// which case we don't have completions to provide).
34 	var word string
35@@ -69,15 +74,15 @@ func (app *App) completionsMsg(cs []ui.Completion, cursorIdx int, text []rune) [
36 			return cs
37 		}
38 		if !hasMetALetter && text[i] != ' ' {
39-			word = app.s.Casemap(string(text[i:cursorIdx]))
40+			word = s.Casemap(string(text[i:cursorIdx]))
41 			hasMetALetter = true
42 		}
43 	}
44 	if word == "" {
45 		return cs
46 	}
47-	for _, user := range app.s.Users() {
48-		if strings.HasPrefix(app.s.Casemap(user), word) {
49+	for _, user := range s.Users() {
50+		if strings.HasPrefix(s.Casemap(user), word) {
51 			nickComp := append([]rune(user), ' ')
52 			c := make([]rune, len(text)+5+len(nickComp)-cursorIdx)
53 			copy(c[:5], []rune("/msg "))
+1, -0
1@@ -23,6 +23,7 @@ extensions, such as:
2 
3 - _CHATHISTORY_, senpai fetches history from the server instead of keeping logs,
4 - _@+typing_, senpai shows when others are typing a message,
5+- _BOUNCER_, senpai connects to all your networks at once automatically,
6 - and more to come!
7 
8 # CONFIGURATION
+5, -0
1@@ -75,3 +75,8 @@ type HistoryEvent struct {
2 	Target   string
3 	Messages []Event
4 }
5+
6+type BouncerNetworkEvent struct {
7+	ID   string
8+	Name string
9+}
+42, -6
  1@@ -57,7 +57,8 @@ var SupportedCapabilities = map[string]struct{}{
  2 	"sasl":          {},
  3 	"setname":       {},
  4 
  5-	"draft/chathistory": {},
  6+	"draft/chathistory":        {},
  7+	"soju.im/bouncer-networks": {},
  8 }
  9 
 10 // Values taken by the "@+typing=" client tag.  TypingUnspec means the value or
 11@@ -91,8 +92,8 @@ type SessionParams struct {
 12 	Nickname string
 13 	Username string
 14 	RealName string
 15-
 16-	Auth SASLClient
 17+	NetID    string
 18+	Auth     SASLClient
 19 }
 20 
 21 type Session struct {
 22@@ -108,6 +109,7 @@ type Session struct {
 23 	real   string
 24 	acct   string
 25 	host   string
 26+	netID  string
 27 	auth   SASLClient
 28 
 29 	availableCaps map[string]string
 30@@ -138,6 +140,7 @@ func NewSession(out chan<- Message, params SessionParams) *Session {
 31 		nickCf:          CasemapASCII(params.Nickname),
 32 		user:            params.Username,
 33 		real:            params.RealName,
 34+		netID:           params.NetID,
 35 		auth:            params.Auth,
 36 		availableCaps:   map[string]string{},
 37 		enabledCaps:     map[string]struct{}{},
 38@@ -180,6 +183,10 @@ func (s *Session) Nick() string {
 39 	return s.nick
 40 }
 41 
 42+func (s *Session) NetID() string {
 43+	return s.netID
 44+}
 45+
 46 // NickCf is our casemapped nickname.
 47 func (s *Session) NickCf() string {
 48 	return s.nickCf
 49@@ -486,10 +493,10 @@ func (s *Session) handleUnregistered(msg Message) (Event, error) {
 50 			return nil, err
 51 		}
 52 
 53-		s.out <- NewMessage("CAP", "END")
 54+		s.endRegistration()
 55 		s.host = ParsePrefix(userhost).Host
 56 	case errNicklocked, errSaslfail, errSasltoolong, errSaslaborted, errSaslalready, rplSaslmechs:
 57-		s.out <- NewMessage("CAP", "END")
 58+		s.endRegistration()
 59 	case "CAP":
 60 		var subcommand string
 61 		if err := msg.ParseParams(nil, &subcommand); err != nil {
 62@@ -525,7 +532,7 @@ func (s *Session) handleUnregistered(msg Message) (Event, error) {
 63 
 64 				_, ok := s.availableCaps["sasl"]
 65 				if s.auth == nil || !ok {
 66-					s.out <- NewMessage("CAP", "END")
 67+					s.endRegistration()
 68 				}
 69 			}
 70 		default:
 71@@ -1024,6 +1031,19 @@ func (s *Session) handleRegistered(msg Message) (Event, error) {
 72 				FormerNick: msg.Prefix.Name,
 73 			}, nil
 74 		}
 75+	case "BOUNCER":
 76+		if len(msg.Params) < 3 {
 77+			break
 78+		}
 79+		if msg.Params[0] != "NETWORK" || s.netID != "" {
 80+			break
 81+		}
 82+		id := msg.Params[1]
 83+		attrs := parseTags(msg.Params[2])
 84+		return BouncerNetworkEvent{
 85+			ID:   id,
 86+			Name: attrs["name"],
 87+		}, nil
 88 	case "PING":
 89 		var payload string
 90 		if err := msg.ParseParams(&payload); err != nil {
 91@@ -1137,6 +1157,8 @@ func (s *Session) updateFeatures(features []string) {
 92 
 93 	Switch:
 94 		switch key {
 95+		case "BOUNCER_NETID":
 96+			s.netID = value
 97 		case "CASEMAPPING":
 98 			switch value {
 99 			case "ascii":
100@@ -1175,3 +1197,17 @@ func (s *Session) updateFeatures(features []string) {
101 		}
102 	}
103 }
104+
105+func (s *Session) endRegistration() {
106+	if _, ok := s.enabledCaps["soju.im/bouncer-networks"]; !ok {
107+		s.out <- NewMessage("CAP", "END")
108+		return
109+	}
110+	if s.netID == "" {
111+		s.out <- NewMessage("CAP", "END")
112+		s.out <- NewMessage("BOUNCER", "LISTNETWORKS")
113+	} else {
114+		s.out <- NewMessage("BOUNCER", "BIND", s.netID)
115+		s.out <- NewMessage("CAP", "END")
116+	}
117+}
+1, -2
 1@@ -132,7 +132,6 @@ func escapeTagValue(unescaped string) string {
 2 }
 3 
 4 func parseTags(s string) (tags map[string]string) {
 5-	s = s[1:]
 6 	tags = map[string]string{}
 7 
 8 	for _, item := range strings.Split(s, ";") {
 9@@ -239,7 +238,7 @@ func ParseMessage(line string) (msg Message, err error) {
10 		var tags string
11 
12 		tags, line = word(line)
13-		msg.Tags = parseTags(tags)
14+		msg.Tags = parseTags(tags[1:])
15 	}
16 
17 	line = strings.TrimLeft(line, " ")
+61, -18
  1@@ -172,6 +172,8 @@ func (l *Line) NewLines(width int) []int {
  2 }
  3 
  4 type buffer struct {
  5+	netID      string
  6+	netName    string
  7 	title      string
  8 	highlights int
  9 	unread     bool
 10@@ -239,15 +241,39 @@ func (bs *BufferList) Previous() {
 11 	bs.list[bs.current].unread = false
 12 }
 13 
 14-func (bs *BufferList) Add(title string) (i int, added bool) {
 15+func (bs *BufferList) Add(netID, netName, title string) (i int, added bool) {
 16 	lTitle := strings.ToLower(title)
 17+	gotNetID := false
 18 	for i, b := range bs.list {
 19-		if strings.ToLower(b.title) == lTitle {
 20-			return i, false
 21+		lbTitle := strings.ToLower(b.title)
 22+		if b.netID == netID {
 23+			gotNetID = true
 24+			if lbTitle == lTitle {
 25+				return i, false
 26+			}
 27+		} else if gotNetID || (b.netID == netID && lbTitle < lTitle) {
 28+			b := buffer{
 29+				netID:   netID,
 30+				netName: netName,
 31+				title:   title,
 32+			}
 33+			bs.list = append(bs.list[:i+1], bs.list[i:]...)
 34+			bs.list[i] = b
 35+			if i <= bs.current && bs.current < len(bs.list) {
 36+				bs.current++
 37+			}
 38+			return i, true
 39 		}
 40 	}
 41 
 42-	bs.list = append(bs.list, buffer{title: title})
 43+	if netName == "" {
 44+		netName = netID
 45+	}
 46+	bs.list = append(bs.list, buffer{
 47+		netID:   netID,
 48+		netName: netName,
 49+		title:   title,
 50+	})
 51 	return len(bs.list) - 1, true
 52 }
 53 
 54@@ -266,8 +292,8 @@ func (bs *BufferList) Remove(title string) (ok bool) {
 55 	return
 56 }
 57 
 58-func (bs *BufferList) AddLine(title string, notify NotifyType, line Line) {
 59-	idx := bs.idx(title)
 60+func (bs *BufferList) AddLine(netID, title string, notify NotifyType, line Line) {
 61+	idx := bs.idx(netID, title)
 62 	if idx < 0 {
 63 		return
 64 	}
 65@@ -303,8 +329,8 @@ func (bs *BufferList) AddLine(title string, notify NotifyType, line Line) {
 66 	}
 67 }
 68 
 69-func (bs *BufferList) AddLines(title string, before, after []Line) {
 70-	idx := bs.idx(title)
 71+func (bs *BufferList) AddLines(netID, title string, before, after []Line) {
 72+	idx := bs.idx(netID, title)
 73 	if idx < 0 {
 74 		return
 75 	}
 76@@ -326,8 +352,9 @@ func (bs *BufferList) AddLines(title string, before, after []Line) {
 77 	}
 78 }
 79 
 80-func (bs *BufferList) Current() (title string) {
 81-	return bs.list[bs.current].title
 82+func (bs *BufferList) Current() (netID, title string) {
 83+	b := &bs.list[bs.current]
 84+	return b.netID, b.title
 85 }
 86 
 87 func (bs *BufferList) ScrollUp(n int) {
 88@@ -352,14 +379,10 @@ func (bs *BufferList) IsAtTop() bool {
 89 	return b.isAtTop
 90 }
 91 
 92-func (bs *BufferList) idx(title string) int {
 93-	if title == "" {
 94-		return bs.current
 95-	}
 96-
 97+func (bs *BufferList) idx(netID, title string) int {
 98 	lTitle := strings.ToLower(title)
 99 	for i, b := range bs.list {
100-		if strings.ToLower(b.title) == lTitle {
101+		if b.netID == netID && strings.ToLower(b.title) == lTitle {
102 			return i
103 		}
104 	}
105@@ -391,7 +414,18 @@ func (bs *BufferList) DrawVerticalBufferList(screen tcell.Screen, x0, y0, width,
106 			x = x0 + indexPadding
107 		}
108 
109-		title := truncate(b.title, width-(x-x0), "\u2026")
110+		var title string
111+		if b.title == "" {
112+			title = b.netName
113+		} else {
114+			if i == bs.clicked {
115+				screen.SetContent(x, y, ' ', nil, tcell.StyleDefault.Reverse(true))
116+				screen.SetContent(x+1, y, ' ', nil, tcell.StyleDefault.Reverse(true))
117+			}
118+			x += 2
119+			title = b.title
120+		}
121+		title = truncate(title, width-(x-x0), "\u2026")
122 		printString(screen, &x, y, Styled(title, st))
123 
124 		if i == bs.clicked {
125@@ -427,8 +461,17 @@ func (bs *BufferList) DrawHorizontalBufferList(screen tcell.Screen, x0, y0, widt
126 		if i == bs.clicked {
127 			st = st.Reverse(true)
128 		}
129-		title := truncate(b.title, width-x, "\u2026")
130+
131+		var title string
132+		if b.title == "" {
133+			st = st.Dim(true)
134+			title = b.netName
135+		} else {
136+			title = b.title
137+		}
138+		title = truncate(title, width-x, "\u2026")
139 		printString(screen, &x, y0, Styled(title, st))
140+
141 		if 0 < b.highlights {
142 			st = st.Foreground(tcell.ColorRed).Reverse(true)
143 			screen.SetContent(x, y0, ' ', nil, st)
+20, -7
 1@@ -82,7 +82,7 @@ func (ui *UI) Close() {
 2 	ui.screen.Fini()
 3 }
 4 
 5-func (ui *UI) CurrentBuffer() string {
 6+func (ui *UI) CurrentBuffer() (netID, title string) {
 7 	return ui.bs.Current()
 8 }
 9 
10@@ -147,8 +147,8 @@ func (ui *UI) IsAtTop() bool {
11 	return ui.bs.IsAtTop()
12 }
13 
14-func (ui *UI) AddBuffer(title string) (i int, added bool) {
15-	return ui.bs.Add(title)
16+func (ui *UI) AddBuffer(netID, netName, title string) (i int, added bool) {
17+	return ui.bs.Add(netID, netName, title)
18 }
19 
20 func (ui *UI) RemoveBuffer(title string) {
21@@ -156,12 +156,12 @@ func (ui *UI) RemoveBuffer(title string) {
22 	ui.memberOffset = 0
23 }
24 
25-func (ui *UI) AddLine(buffer string, notify NotifyType, line Line) {
26-	ui.bs.AddLine(buffer, notify, line)
27+func (ui *UI) AddLine(netID, buffer string, notify NotifyType, line Line) {
28+	ui.bs.AddLine(netID, buffer, notify, line)
29 }
30 
31-func (ui *UI) AddLines(buffer string, before, after []Line) {
32-	ui.bs.AddLines(buffer, before, after)
33+func (ui *UI) AddLines(netID, buffer string, before, after []Line) {
34+	ui.bs.AddLines(netID, buffer, before, after)
35 }
36 
37 func (ui *UI) JumpBuffer(sub string) bool {
38@@ -188,6 +188,19 @@ func (ui *UI) JumpBufferIndex(i int) bool {
39 	return false
40 }
41 
42+func (ui *UI) JumpBufferNetwork(netID, sub string) bool {
43+	subLower := strings.ToLower(sub)
44+	for i, b := range ui.bs.list {
45+		if b.netID == netID && strings.Contains(strings.ToLower(b.title), subLower) {
46+			if ui.bs.To(i) {
47+				ui.memberOffset = 0
48+			}
49+			return true
50+		}
51+	}
52+	return false
53+}
54+
55 func (ui *UI) SetStatus(status string) {
56 	ui.status = status
57 }
+22, -14
 1@@ -9,42 +9,50 @@ import (
 2 	"github.com/gdamore/tcell/v2"
 3 )
 4 
 5-var Home = "home"
 6-
 7 const welcomeMessage = "senpai dev build. See senpai(1) for a list of keybindings and commands. Private messages and status notices go here."
 8 
 9 func (app *App) initWindow() {
10-	app.win.AddBuffer(Home)
11-	app.win.AddLine(Home, ui.NotifyNone, ui.Line{
12+	app.win.AddBuffer("", "(home)", "")
13+	app.win.AddLine("", "", ui.NotifyNone, ui.Line{
14 		Head: "--",
15 		Body: ui.PlainString(welcomeMessage),
16 		At:   time.Now(),
17 	})
18 }
19 
20-func (app *App) queueStatusLine(line ui.Line) {
21+type statusLine struct {
22+	netID string
23+	line  ui.Line
24+}
25+
26+func (app *App) queueStatusLine(netID string, line ui.Line) {
27 	if line.At.IsZero() {
28 		line.At = time.Now()
29 	}
30 	app.events <- event{
31-		src:     uiEvent,
32-		content: line,
33+		src: "*",
34+		content: statusLine{
35+			netID: netID,
36+			line:  line,
37+		},
38 	}
39 }
40 
41-func (app *App) addStatusLine(line ui.Line) {
42-	buffer := app.win.CurrentBuffer()
43-	if buffer != Home {
44-		app.win.AddLine(Home, ui.NotifyNone, line)
45+func (app *App) addStatusLine(netID string, line ui.Line) {
46+	currentNetID, buffer := app.win.CurrentBuffer()
47+	if currentNetID == netID && buffer != "" {
48+		app.win.AddLine(netID, buffer, ui.NotifyNone, line)
49 	}
50-	app.win.AddLine(buffer, ui.NotifyNone, line)
51+	app.win.AddLine(netID, "", ui.NotifyNone, line)
52 }
53 
54 func (app *App) setStatus() {
55-	if app.s == nil {
56+	netID, buffer := app.win.CurrentBuffer()
57+	s := app.sessions[netID]
58+	if s == nil {
59 		return
60 	}
61-	ts := app.s.Typings(app.win.CurrentBuffer())
62+	ts := s.Typings(buffer)
63 	status := ""
64 	if 3 < len(ts) {
65 		status = "several people are typing..."