master app.go
   1package senpai
   2
   3import (
   4	"context"
   5	"crypto/tls"
   6	"errors"
   7	"fmt"
   8	"io"
   9	"mime"
  10	"net"
  11	"net/http"
  12	"os"
  13	"os/exec"
  14	"runtime/debug"
  15	"strconv"
  16	"strings"
  17	"sync"
  18	"sync/atomic"
  19	"time"
  20	"unicode"
  21	"unicode/utf8"
  22
  23	"git.sr.ht/~rockorager/vaxis"
  24	"golang.org/x/net/proxy"
  25
  26	"git.sr.ht/~delthas/senpai/events"
  27	"git.sr.ht/~delthas/senpai/irc"
  28	"git.sr.ht/~delthas/senpai/ui"
  29)
  30
  31const eventChanSize = 1024
  32
  33func isCommand(input []rune) bool {
  34	// Command can't start with two slashes because that's an escape for
  35	// a literal slash in the message
  36	return len(input) >= 1 && input[0] == '/' && !(len(input) >= 2 && input[1] == '/')
  37}
  38
  39type bound struct {
  40	first time.Time
  41	last  time.Time
  42
  43	firstMessage string
  44	lastMessage  string
  45
  46	complete bool
  47}
  48
  49// Compare returns 0 if line is within bounds, -1 if before, 1 if after.
  50func (b *bound) Compare(line *ui.Line) int {
  51	at := line.At.Truncate(time.Second)
  52	if at.Before(b.first) {
  53		return -1
  54	}
  55	if at.After(b.last) {
  56		return 1
  57	}
  58	if at.Equal(b.first) && line.Body.String() != b.firstMessage {
  59		return -1
  60	}
  61	if at.Equal(b.last) && line.Body.String() != b.lastMessage {
  62		return -1
  63	}
  64	return 0
  65}
  66
  67// Update updates the bounds to include the given line.
  68func (b *bound) Update(line *ui.Line) {
  69	if line.At.IsZero() {
  70		return
  71	}
  72	at := line.At.Truncate(time.Second)
  73	if b.first.IsZero() || at.Before(b.first) {
  74		b.first = at
  75		b.firstMessage = line.Body.String()
  76	} else if b.last.IsZero() || at.After(b.last) {
  77		b.last = at
  78		b.lastMessage = line.Body.String()
  79	}
  80}
  81
  82// IsZero reports whether the bound is empty.
  83func (b *bound) IsZero() bool {
  84	return b.first.IsZero()
  85}
  86
  87type event struct {
  88	src     string // "*" if UI, netID otherwise
  89	content interface{}
  90}
  91
  92type boundKey struct {
  93	netID  string
  94	target string
  95}
  96
  97type pendingCompletion struct {
  98	id       int
  99	f        completionAsync
 100	deadline time.Time
 101}
 102
 103type keyMatch struct {
 104	keycode rune
 105	mods    vaxis.ModifierMask
 106}
 107
 108type App struct {
 109	win              *ui.UI
 110	sessions         map[string]*irc.Session // map of network IDs to their current session
 111	pasting          bool
 112	pastingInputOnly bool // true is pasting started when the editor input was empty
 113	formatting       bool // true for keybind to enable text formatting
 114
 115	// events MUST NOT be posted to directly; instead, use App.postEvent.
 116	events chan event
 117
 118	cfg        Config
 119	highlights []string
 120	shortcuts  map[keyMatch][]string
 121
 122	lastQuery     string
 123	lastQueryNet  string
 124	messageBounds map[boundKey]bound
 125	lastNetID     string
 126	lastBuffer    string
 127
 128	monitor map[string]map[string]struct{} // set of targets we want to monitor per netID, best-effort. netID->target->{}
 129
 130	networkLock sync.RWMutex        // locks networks
 131	networks    map[string]struct{} // set of network IDs we want to connect to; to be locked with networkLock
 132
 133	pendingCompletions    map[string][]pendingCompletion
 134	pendingCompletionsOff int
 135
 136	lastMessageTime time.Time
 137	lastCloseTime   time.Time
 138
 139	lastConfirm string
 140
 141	uploadingProgress *float64
 142
 143	shownBouncerNotice bool
 144	shownPasteHint     bool
 145
 146	closing atomic.Bool
 147}
 148
 149func NewApp(cfg Config) (app *App, err error) {
 150	if cfg.Addr == "" {
 151		return nil, errors.New("address is required")
 152	}
 153	if cfg.Nick == "" {
 154		return nil, errors.New("nick is required")
 155	}
 156	if cfg.User == "" {
 157		cfg.User = cfg.Nick
 158	}
 159	if cfg.Real == "" {
 160		cfg.Real = cfg.Nick
 161	}
 162
 163	app = &App{
 164		networks: map[string]struct{}{
 165			"": {}, // add the master network by default
 166		},
 167		pendingCompletions: make(map[string][]pendingCompletion),
 168		sessions:           map[string]*irc.Session{},
 169		events:             make(chan event, eventChanSize),
 170		cfg:                cfg,
 171		shortcuts:          make(map[keyMatch][]string),
 172		messageBounds:      map[boundKey]bound{},
 173		monitor:            make(map[string]map[string]struct{}),
 174	}
 175	for _, m := range []map[string][]string{defaultCommands, app.cfg.Shortcuts} {
 176		for name, actions := range m {
 177			k := keyNameMatch(name)
 178			if k == nil {
 179				return nil, fmt.Errorf("unknown key name: %v", name)
 180			}
 181			app.shortcuts[*k] = actions
 182		}
 183	}
 184
 185	if cfg.Highlights != nil {
 186		app.highlights = make([]string, len(cfg.Highlights))
 187		for i := range app.highlights {
 188			app.highlights[i] = strings.ToLower(cfg.Highlights[i])
 189		}
 190	}
 191
 192	app.win, app.cfg.Colors, err = ui.New(ui.Config{
 193		NickColWidth:     cfg.NickColWidth,
 194		ChanColWidth:     cfg.ChanColWidth,
 195		ChanColEnabled:   cfg.ChanColEnabled,
 196		MemberColWidth:   cfg.MemberColWidth,
 197		MemberColEnabled: cfg.MemberColEnabled,
 198		TextMaxWidth:     cfg.TextMaxWidth,
 199		AutoComplete: func(cursorIdx int, text []rune) []ui.Completion {
 200			return app.completions(cursorIdx, text)
 201		},
 202		Mouse: cfg.Mouse,
 203		MergeLine: func(former *ui.Line, addition ui.Line) {
 204			app.mergeLine(former, addition)
 205		},
 206		Colors:            cfg.Colors,
 207		LocalIntegrations: cfg.LocalIntegrations,
 208		WithTTY:           cfg.WithTTY,
 209		WithConsole:       cfg.WithConsole,
 210	})
 211	if err != nil {
 212		return
 213	}
 214
 215	ui.DBusStart(func(ev any) {
 216		app.postEvent(event{
 217			src:     "*",
 218			content: ev,
 219		})
 220	})
 221	app.win.SetPrompt(ui.Styled(">", vaxis.Style{
 222		Foreground: app.cfg.Colors.Prompt,
 223	}),
 224	)
 225
 226	app.initWindow()
 227
 228	return
 229}
 230
 231func (app *App) Close() {
 232	app.win.Exit()       // tell all instances of app.ircLoop to stop when possible
 233	app.postEvent(event{ // tell app.eventLoop to stop
 234		src:     "*",
 235		content: nil,
 236	})
 237	for _, session := range app.sessions {
 238		session.Close()
 239	}
 240	ui.DBusStop()
 241	app.closing.Store(true)
 242	go func() {
 243		// drain remaining events
 244		for {
 245			select {
 246			case <-app.events:
 247			default:
 248				return
 249			}
 250		}
 251	}()
 252}
 253
 254func (app *App) SwitchToBuffer(netID, buffer string) {
 255	app.lastNetID = netID
 256	app.lastBuffer = buffer
 257}
 258
 259func (app *App) Run() {
 260	if app.lastCloseTime.IsZero() {
 261		app.lastCloseTime = time.Now()
 262	}
 263	go app.uiLoop()
 264	go app.ircLoop("")
 265	app.eventLoop()
 266}
 267
 268func (app *App) CurrentSession() *irc.Session {
 269	netID, _ := app.win.CurrentBuffer()
 270	return app.sessions[netID]
 271}
 272
 273func (app *App) CurrentBuffer() (netID, buffer string) {
 274	return app.win.CurrentBuffer()
 275}
 276
 277func (app *App) LastMessageTime() time.Time {
 278	return app.lastMessageTime
 279}
 280
 281func (app *App) SetLastClose(t time.Time) {
 282	app.lastCloseTime = t
 283}
 284
 285// eventLoop retrieves events (in batches) from the event channel and handle
 286// them, then draws the interface after each batch is handled.
 287func (app *App) eventLoop() {
 288	defer app.win.Close()
 289
 290	for !app.win.ShouldExit() {
 291		ev := <-app.events
 292		if !app.handleEvent(ev) {
 293			return
 294		}
 295		deadline := time.NewTimer(200 * time.Millisecond)
 296	outer:
 297		for {
 298			select {
 299			case <-deadline.C:
 300				break outer
 301			case ev := <-app.events:
 302				if !app.handleEvent(ev) {
 303					return
 304				}
 305			default:
 306				if !deadline.Stop() {
 307					<-deadline.C
 308				}
 309				break outer
 310			}
 311		}
 312
 313		if !app.pasting {
 314			if app.win.Focused() {
 315				if netID, buffer, timestamp := app.win.UpdateRead(); buffer != "" {
 316					s := app.sessions[netID]
 317					if s != nil {
 318						s.ReadSet(buffer, timestamp)
 319					}
 320				}
 321			}
 322			app.maybeRequestHistory()
 323			app.setStatus()
 324			app.updatePrompt()
 325			app.setBufferNumbers()
 326			var currentMembers []irc.Member
 327			netID, buffer := app.win.CurrentBuffer()
 328			s := app.sessions[netID]
 329			if s != nil && buffer != "" {
 330				currentMembers = s.Names(buffer)
 331			}
 332			app.win.Draw(currentMembers)
 333			var title strings.Builder
 334			if higlights := app.win.Highlights(); higlights > 0 {
 335				fmt.Fprintf(&title, "(%d) ", higlights)
 336			}
 337			if netID != "" && buffer != "" {
 338				fmt.Fprintf(&title, "%s - ", buffer)
 339			}
 340			title.WriteString("senpai")
 341			app.win.SetTitle(title.String())
 342		}
 343	}
 344}
 345
 346func (app *App) postEvent(ev event) {
 347	if app.closing.Load() {
 348		return
 349	}
 350	app.events <- ev
 351}
 352
 353func (app *App) handleEvent(ev event) bool {
 354	if ev.src == "*" {
 355		if ev.content == nil {
 356			return false
 357		}
 358		if !app.handleUIEvent(ev.content) {
 359			return false
 360		}
 361	} else {
 362		app.handleIRCEvent(ev.src, ev.content)
 363	}
 364	return true
 365}
 366
 367func (app *App) wantsNetwork(netID string) bool {
 368	if app.win.ShouldExit() {
 369		return false
 370	}
 371	app.networkLock.RLock()
 372	_, ok := app.networks[netID]
 373	app.networkLock.RUnlock()
 374	return ok
 375}
 376
 377// ircLoop maintains a connection to the IRC server by connecting and then
 378// forwarding IRC events to app.events repeatedly.
 379func (app *App) ircLoop(netID string) {
 380	var auth irc.SASLClient
 381	if app.cfg.Password != nil {
 382		auth = &irc.SASLPlain{
 383			Username: app.cfg.User,
 384			Password: *app.cfg.Password,
 385		}
 386	}
 387	params := irc.SessionParams{
 388		Nickname: app.cfg.Nick,
 389		Username: app.cfg.User,
 390		RealName: app.cfg.Real,
 391		NetID:    netID,
 392		Auth:     auth,
 393	}
 394	const throttleInterval = 6 * time.Second
 395	const throttleMax = 1 * time.Minute
 396	var delay time.Duration = 0
 397	for app.wantsNetwork(netID) {
 398		time.Sleep(delay)
 399		if !app.wantsNetwork(netID) {
 400			break
 401		}
 402		if delay < throttleMax {
 403			delay += throttleInterval
 404		}
 405		conn := app.connect(netID)
 406		if conn == nil {
 407			continue
 408		}
 409		if !app.wantsNetwork(netID) {
 410			conn.Close()
 411			break
 412		}
 413		delay = throttleInterval
 414
 415		in, out := irc.ChanInOut(conn)
 416		if app.cfg.Debug {
 417			out = app.debugOutputMessages(netID, out)
 418		}
 419		session := irc.NewSession(out, params)
 420		app.postEvent(event{
 421			src:     netID,
 422			content: session,
 423		})
 424		go func() {
 425			for stop := range session.TypingStops() {
 426				app.postEvent(event{
 427					src:     netID,
 428					content: stop,
 429				})
 430			}
 431		}()
 432		for msg := range in {
 433			if app.cfg.Debug {
 434				app.queueStatusLine(netID, ui.Line{
 435					At:   time.Now(),
 436					Head: ui.PlainString("IN --"),
 437					Body: ui.PlainString(msg.String()),
 438				})
 439			}
 440			app.postEvent(event{
 441				src:     netID,
 442				content: msg,
 443			})
 444		}
 445		app.postEvent(event{
 446			src:     netID,
 447			content: nil,
 448		})
 449		app.queueStatusLine(netID, ui.Line{
 450			Head: ui.ColorString("!!", ui.ColorRed),
 451			Body: ui.PlainString("Connection lost"),
 452		})
 453	}
 454}
 455
 456func (app *App) connect(netID string) net.Conn {
 457	app.queueStatusLine(netID, ui.Line{
 458		Head: ui.PlainString("--"),
 459		Body: ui.PlainSprintf("Connecting to %s...", app.cfg.Addr),
 460	})
 461	conn, err := app.tryConnect()
 462	if err == nil {
 463		return conn
 464	}
 465	app.queueStatusLine(netID, ui.Line{
 466		Head: ui.ColorString("!!", ui.ColorRed),
 467		Body: ui.PlainSprintf("Connection failed: %v", err),
 468	})
 469	return nil
 470}
 471
 472func (app *App) tryConnect() (conn net.Conn, err error) {
 473	addr := app.cfg.Addr
 474	colonIdx := strings.LastIndexByte(addr, ':')
 475	bracketIdx := strings.LastIndexByte(addr, ']')
 476	if colonIdx <= bracketIdx {
 477		// either colonIdx < 0, or the last colon is before a ']' (end
 478		// of IPv6 address). -> missing port
 479		if app.cfg.TLS {
 480			addr += ":6697"
 481		} else {
 482			addr += ":6667"
 483		}
 484	}
 485
 486	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
 487	defer cancel()
 488
 489	dialer := &net.Dialer{
 490		Timeout: 10 * time.Second,
 491	}
 492	conn, err = proxy.FromEnvironmentUsing(dialer).(proxy.ContextDialer).DialContext(ctx, "tcp", addr)
 493	if err != nil {
 494		return nil, fmt.Errorf("connect: %v", err)
 495	}
 496
 497	if app.cfg.TLS {
 498		host, _, _ := net.SplitHostPort(addr) // should succeed since net.Dial did.
 499		conn = tls.Client(conn, &tls.Config{
 500			ServerName:         host,
 501			InsecureSkipVerify: app.cfg.TLSSkipVerify,
 502			NextProtos:         []string{"irc"},
 503		})
 504		err = conn.(*tls.Conn).HandshakeContext(ctx)
 505		if err != nil {
 506			conn.Close()
 507			return nil, fmt.Errorf("tls handshake: %v", err)
 508		}
 509	}
 510
 511	return
 512}
 513
 514func (app *App) debugOutputMessages(netID string, out chan<- irc.Message) chan<- irc.Message {
 515	debugOut := make(chan irc.Message, cap(out))
 516	go func() {
 517		for msg := range debugOut {
 518			const placeholder = "<removed>"
 519			d := msg
 520			if msg.Command == "PASS" && len(d.Params) >= 1 {
 521				d.Params = append([]string{placeholder}, d.Params[1:]...)
 522			} else if msg.Command == "OPER" && len(d.Params) >= 2 {
 523				d.Params = append([]string{d.Params[0], placeholder}, d.Params[2:]...)
 524			} else if msg.Command == "AUTHENTICATE" && len(d.Params) >= 1 {
 525				switch d.Params[0] {
 526				case "*", "PLAIN":
 527				default:
 528					d.Params = append([]string{placeholder}, d.Params[1:]...)
 529				}
 530			}
 531			app.queueStatusLine(netID, ui.Line{
 532				At:   time.Now(),
 533				Head: ui.PlainString("OUT --"),
 534				Body: ui.PlainString(d.String()),
 535			})
 536			out <- msg
 537		}
 538		close(out)
 539	}()
 540	return debugOut
 541}
 542
 543// uiLoop retrieves events from the UI and forwards them to app.events for
 544// handling in app.eventLoop().
 545func (app *App) uiLoop() {
 546	for ev := range app.win.Events {
 547		app.postEvent(event{
 548			src:     "*",
 549			content: ev,
 550		})
 551	}
 552}
 553
 554func (app *App) handleUIEvent(ev interface{}) bool {
 555	// TODO: when a no-modifier no-button mouse motion event is sent, just set the mouse cursor and avoid redrawing
 556	// TODO: eat QuitEvent here?
 557	switch ev := ev.(type) {
 558	case vaxis.Resize:
 559		app.win.SetWinPixels(ev.XPixel, ev.YPixel)
 560		app.win.Resize()
 561	case vaxis.PasteStartEvent:
 562		app.pasting = true
 563		app.pastingInputOnly = len(app.win.InputContent()) == 0
 564	case vaxis.PasteEndEvent:
 565		app.pasting = false
 566		if !app.pastingInputOnly {
 567			break
 568		}
 569		app.pastingInputOnly = false
 570
 571		path := string(app.win.InputContent())
 572		path = strings.TrimRight(path, "\n")
 573		if _, err := os.Stat(path); err != nil {
 574			path = dropBackslash(path)
 575			if _, err := os.Stat(path); err != nil {
 576				break
 577			}
 578		}
 579		app.win.InputSet(fmt.Sprintf("/upload %v", path))
 580	case vaxis.Mouse:
 581		app.handleMouseEvent(ev)
 582	case vaxis.Key:
 583		app.handleKeyEvent(ev)
 584	case vaxis.FocusIn:
 585		app.win.SetFocused(true)
 586	case vaxis.FocusOut:
 587		app.win.SetFocused(false)
 588	case vaxis.ColorThemeUpdate:
 589		app.win.SetColorTheme(ev.Mode)
 590	case *ui.NotifyEvent:
 591		app.win.JumpBufferNetwork(ev.NetID, ev.Buffer)
 592	case *ui.ScreenshotEvent:
 593		if err := commandDoUpload(app, []string{ev.Path}); err != nil {
 594			netID, buffer := app.win.CurrentBuffer()
 595			app.win.AddLine(netID, buffer, ui.Line{
 596				At:     time.Now(),
 597				Head:   ui.ColorString("!!", ui.ColorRed),
 598				Notify: ui.NotifyUnread,
 599				Body:   ui.PlainSprintf("SCREENSHOT: %s", err),
 600			})
 601			break
 602		}
 603	case statusLine:
 604		app.addStatusLine(ev.netID, ev.line)
 605	case *events.EventClickNick:
 606		app.handleNickEvent(ev)
 607	case *events.EventClickLink:
 608		app.handleLinkEvent(ev)
 609	case *events.EventClickChannel:
 610		app.handleChannelEvent(ev)
 611	case *events.EventFileUpload:
 612		if ev.Location != "" {
 613			app.uploadingProgress = nil
 614			if len(app.win.InputContent()) == 0 {
 615				app.win.InputSet(ev.Location)
 616			} else {
 617				netID, buffer := app.win.CurrentBuffer()
 618				app.win.AddLine(netID, buffer, ui.Line{
 619					At:   time.Now(),
 620					Head: ui.PlainString("--"),
 621					Body: ui.PlainString(fmt.Sprintf("File uploaded at: %v", ev.Location)),
 622				})
 623			}
 624		} else if ev.Error != "" {
 625			app.uploadingProgress = nil
 626			netID, buffer := app.win.CurrentBuffer()
 627			app.win.AddLine(netID, buffer, ui.Line{
 628				At:   time.Now(),
 629				Head: ui.ColorString("!!", ui.ColorRed),
 630				Body: ui.PlainString(fmt.Sprintf("File upload failed: %v", ev.Error)),
 631			})
 632		} else {
 633			app.uploadingProgress = &ev.Progress
 634		}
 635	default:
 636		// TODO: missing event types
 637	}
 638	return true
 639}
 640
 641func (app *App) handleMouseEvent(ev vaxis.Mouse) {
 642	x, y := ev.Col, ev.Row
 643	w, h := app.win.Size()
 644
 645	if ev.Button == vaxis.MouseLeftButton && (ev.EventType == vaxis.EventRelease || ev.EventType == vaxis.EventMotion) {
 646		if app.win.ChannelColClicked() {
 647			app.win.ResizeChannelCol(x + 1)
 648		} else if app.win.MemberColClicked() {
 649			app.win.ResizeMemberCol(w - x)
 650		}
 651	}
 652
 653	if ev.EventType == vaxis.EventPress {
 654		if ev.Button == vaxis.MouseWheelUp {
 655			if x < app.win.ChannelWidth() || (app.win.ChannelWidth() == 0 && y == h-1) {
 656				app.win.ScrollChannelUpBy(4)
 657			} else if x > w-app.win.MemberWidth() {
 658				app.win.ScrollMemberUpBy(4)
 659			} else if y == 0 {
 660				app.win.ScrollTopicLeftBy(12)
 661			} else {
 662				app.win.ScrollUpBy(4)
 663			}
 664		}
 665		if ev.Button == vaxis.MouseWheelDown {
 666			if x < app.win.ChannelWidth() || (app.win.ChannelWidth() == 0 && y == h-1) {
 667				app.win.ScrollChannelDownBy(4)
 668			} else if x > w-app.win.MemberWidth() {
 669				app.win.ScrollMemberDownBy(4)
 670			} else if y == 0 {
 671				app.win.ScrollTopicRightBy(12)
 672			} else {
 673				app.win.ScrollDownBy(4)
 674			}
 675		}
 676		if ev.Button == vaxis.MouseLeftButton {
 677			if x == app.win.ChannelWidth()-1 {
 678				app.win.ClickChannelCol(true)
 679			} else if x < app.win.ChannelWidth() {
 680				app.win.ClickBuffer(app.win.VerticalBufferOffset(y))
 681			} else if app.win.ChannelWidth() == 0 && y == h-1 {
 682				app.win.ClickBuffer(app.win.HorizontalBufferOffset(x))
 683			} else if x == w-app.win.MemberWidth() {
 684				app.win.ClickMemberCol(true)
 685			} else if x > w-app.win.MemberWidth() && y >= 2 {
 686				app.win.ClickMember(y - 2 + app.win.MemberOffset())
 687			} else {
 688				app.win.SelectMessageAt(y)
 689				app.win.Click(x, y, ev)
 690			}
 691		}
 692		if ev.Button == vaxis.MouseMiddleButton {
 693			i := -1
 694			if x < app.win.ChannelWidth() {
 695				i = app.win.VerticalBufferOffset(y)
 696			} else if app.win.ChannelWidth() == 0 && y == h-1 {
 697				i = app.win.HorizontalBufferOffset(x)
 698			}
 699			netID, channel, ok := app.win.Buffer(i)
 700			if ok && channel != "" {
 701				s := app.sessions[netID]
 702				if s != nil && s.IsChannel(channel) {
 703					s.Part(channel, "")
 704				} else {
 705					app.win.RemoveBuffer(netID, channel)
 706				}
 707			}
 708		}
 709		if ev.Button == vaxis.MouseRightButton {
 710			app.win.SelectMessageAt(y)
 711			s := app.CurrentSession()
 712			if line, flag := app.win.SelectedMessage(); line != nil && s != nil {
 713				if flag == ui.MessageReply && s.CanReact() {
 714					flag = ui.MessageReact
 715				} else if s.CanReply() {
 716					flag = ui.MessageReply
 717				}
 718				app.win.FlagSelectedMessage(flag)
 719			}
 720			app.win.Click(x, y, ev)
 721		}
 722	}
 723	if ev.EventType == vaxis.EventRelease {
 724		if x < app.win.ChannelWidth()-1 {
 725			if i := app.win.VerticalBufferOffset(y); i == app.win.ClickedBuffer() {
 726				app.win.GoToBufferNo(i)
 727				app.clearBufferCommand()
 728			}
 729		} else if app.win.ChannelWidth() == 0 && y == h-1 {
 730			if i := app.win.HorizontalBufferOffset(x); i >= 0 && i == app.win.ClickedBuffer() {
 731				app.win.GoToBufferNo(i)
 732				app.clearBufferCommand()
 733			}
 734		} else if x > w-app.win.MemberWidth() {
 735			if i := y - 2 + app.win.MemberOffset(); i >= 0 && i == app.win.ClickedMember() {
 736				netID, target := app.win.CurrentBuffer()
 737				if s := app.sessions[netID]; s != nil && target != "" {
 738					members := s.Names(target)
 739					if i < len(members) {
 740						buffer := members[i].Name.Name
 741						i, _ := app.addUserBuffer(netID, buffer, time.Time{})
 742						app.win.JumpBufferIndex(i)
 743					}
 744				}
 745			}
 746		}
 747		app.win.ClickBuffer(-1)
 748		app.win.ClickMember(-1)
 749		app.win.ClickChannelCol(false)
 750		app.win.ClickMemberCol(false)
 751	}
 752	if x == app.win.ChannelWidth()-1 || x == w-app.win.MemberWidth() {
 753		app.win.SetMouseShape(vaxis.MouseShapeResizeHorizontal)
 754	} else if x < app.win.ChannelWidth()-1 || x > w-app.win.MemberWidth() || app.win.HasEvent(x, y) {
 755		app.win.SetMouseShape(vaxis.MouseShapeClickable)
 756	} else {
 757		app.win.SetMouseShape(vaxis.MouseShapeDefault)
 758	}
 759}
 760
 761func (app *App) handleAction(action string, args ...string) {
 762	switch action {
 763	case "quit":
 764		if app.win.InputClear() {
 765			app.typing()
 766		} else {
 767			app.win.InputSet("/quit")
 768		}
 769	case "set-editor":
 770		if len(app.win.InputContent()) == 0 {
 771			app.win.InputSet(strings.Join(args, " "))
 772		}
 773	case "format":
 774		app.formatting = !app.formatting
 775	case "cursor-start":
 776		app.win.InputHome()
 777	case "cursor-end":
 778		app.win.InputEnd()
 779	case "redraw":
 780		app.win.Resize()
 781	case "scroll-up":
 782		app.win.ScrollUp()
 783	case "scroll-down":
 784		app.win.ScrollDown()
 785	case "buffer-next":
 786		app.win.NextBuffer()
 787		app.win.ScrollToBuffer()
 788	case "buffer-previous":
 789		app.win.PreviousBuffer()
 790		app.win.ScrollToBuffer()
 791	case "buffer-next-unread":
 792		app.win.NextUnreadBuffer()
 793		app.win.ScrollToBuffer()
 794	case "buffer-previous-unread":
 795		app.win.PreviousUnreadBuffer()
 796		app.win.ScrollToBuffer()
 797	case "cursor-right-word":
 798		app.win.InputRightWord()
 799	case "cursor-left-word":
 800		app.win.InputLeftWord()
 801	case "cursor-right":
 802		app.win.InputRight()
 803	case "cursor-left":
 804		app.win.InputLeft()
 805	case "cursor-up":
 806		app.win.InputUp()
 807	case "cursor-down":
 808		app.win.InputDown()
 809	case "cursor-delete-previous-word":
 810		if app.win.InputDeleteWord() {
 811			app.typing()
 812		}
 813	case "cursor-delete-next-word":
 814		if app.win.InputDeleteNextWord() {
 815			app.typing()
 816		}
 817	case "cursor-delete-previous":
 818		if app.win.InputBackspace() {
 819			app.typing()
 820		}
 821	case "cursor-delete-next":
 822		if app.win.InputDelete() {
 823			app.typing()
 824		}
 825	case "cursor-delete-before":
 826		if app.win.InputDeleteBefore() {
 827			app.typing()
 828		}
 829	case "cursor-delete-after":
 830		if app.win.InputDeleteAfter() {
 831			app.typing()
 832		}
 833	case "search-editor":
 834		app.win.InputBackSearch()
 835	case "auto-complete":
 836		if app.win.InputAutoComplete() {
 837			app.typing()
 838		}
 839	case "close-overlay":
 840		app.win.CloseOverlay()
 841		app.win.ClearMessageSelection()
 842	case "message-select-previous":
 843		app.win.SelectMessagePrevious()
 844	case "message-select-next":
 845		app.win.SelectMessageNext()
 846	case "message-react":
 847		if s := app.CurrentSession(); s != nil && s.CanReact() {
 848			app.win.FlagSelectedMessage(ui.MessageReact)
 849		}
 850	case "message-reply":
 851		if s := app.CurrentSession(); s != nil && s.CanReply() {
 852			app.win.FlagSelectedMessage(ui.MessageReply)
 853		}
 854	case "message-copy":
 855		app.win.CopySelectedMessage()
 856	case "toggle-topic":
 857		app.win.ToggleTopic()
 858	case "toggle-channel-list":
 859		app.win.ToggleChannelList()
 860	case "toggle-member-list":
 861		app.win.ToggleMemberList()
 862	case "send":
 863		if !app.win.InputEnter() {
 864			netID, buffer := app.win.CurrentBuffer()
 865			input := string(app.win.InputContent())
 866			var err error
 867			for _, part := range strings.Split(input, "\n") {
 868				if err = app.handleInput(buffer, part); err != nil {
 869					app.win.AddLine(netID, buffer, ui.Line{
 870						At:     time.Now(),
 871						Head:   ui.ColorString("!!", ui.ColorRed),
 872						Notify: ui.NotifyUnread,
 873						Body:   ui.PlainSprintf("%q: %s", input, err),
 874					})
 875					break
 876				}
 877			}
 878			if err == nil {
 879				app.win.InputFlush()
 880			}
 881		}
 882	case "scroll-next-highlight":
 883		app.win.ScrollDownHighlight()
 884	case "scroll-previous-highlight":
 885		app.win.ScrollUpHighlight()
 886	case "buffer":
 887		if len(args) > 0 {
 888			if n, err := strconv.Atoi(args[0]); err == nil && n >= 0 {
 889				app.win.GoToBufferNo(n)
 890			} else if args[0] == "last" {
 891				maxInt := int(^uint(0) >> 1)
 892				app.win.GoToBufferNo(maxInt)
 893			}
 894		}
 895	case "paste-hint":
 896		if !app.shownPasteHint {
 897			app.shownPasteHint = true
 898			netID, _ := app.win.CurrentBuffer()
 899			app.addStatusLine(netID, ui.Line{
 900				At:   time.Now(),
 901				Head: ui.PlainString("--"),
 902				Body: ui.PlainString("Use Control+Shift+V to paste text, or Control+Alt+V to upload clipboard content (e.g. images)"),
 903			})
 904		}
 905	case "none":
 906	default:
 907		netID, buffer := app.win.CurrentBuffer()
 908		app.win.AddLine(netID, buffer, ui.Line{
 909			At:     time.Now(),
 910			Head:   ui.ColorString("!!", ui.ColorRed),
 911			Notify: ui.NotifyUnread,
 912			Body:   ui.PlainSprintf("shortcut: action %q does not exist", action),
 913		})
 914	}
 915}
 916
 917var defaultCommands = map[string][]string{
 918	"Control+c":       {"quit"},
 919	"Control+f":       {"set-editor", "/search "},
 920	"Control+k":       {"set-editor", "/buffer "},
 921	"Control+v":       {"paste-hint"},
 922	"Control+Alt+v":   {"set-editor", "/upload"},
 923	"Control+s":       {"format"},
 924	"Control+a":       {"cursor-start"},
 925	"Control+e":       {"cursor-end"},
 926	"Control+l":       {"redraw"},
 927	"Control+u":       {"scroll-up"},
 928	"Page_Up":         {"scroll-up"},
 929	"Control+d":       {"scroll-down"},
 930	"Page_Down":       {"scroll-down"},
 931	"Control+n":       {"buffer-next"},
 932	"Control+p":       {"buffer-previous"},
 933	"Control+Up":      {"message-select-previous"},
 934	"Control+Down":    {"message-select-next"},
 935	"Control+y":       {"message-copy"},
 936	"Alt+r":           {"message-reply"},
 937	"Alt+e":           {"message-react"},
 938	"Alt+Right":       {"buffer-next"},
 939	"Shift+Right":     {"buffer-next-unread"},
 940	"Control+Right":   {"cursor-right-word"},
 941	"Right":           {"cursor-right"},
 942	"Alt+Left":        {"buffer-previous"},
 943	"Shift+Left":      {"buffer-previous-unread"},
 944	"Control+Left":    {"cursor-left-word"},
 945	"Left":            {"cursor-left"},
 946	"Alt+Up":          {"buffer-previous"},
 947	"Up":              {"cursor-up"},
 948	"Alt+Down":        {"buffer-next"},
 949	"Down":            {"cursor-down"},
 950	"Alt+Home":        {"buffer", "0"},
 951	"Home":            {"cursor-start"},
 952	"Alt+End":         {"buffer", "last"},
 953	"End":             {"cursor-end"},
 954	"Alt+BackSpace":   {"cursor-delete-previous-word"},
 955	"Alt+Delete":      {"cursor-delete-next-word"},
 956	"BackSpace":       {"cursor-delete-previous"},
 957	"Shift+BackSpace": {"cursor-delete-previous"},
 958	"Delete":          {"cursor-delete-next"},
 959	"Control+w":       {"cursor-delete-previous-word"},
 960	"Control+r":       {"search-editor"},
 961	"Tab":             {"auto-complete"},
 962	"Escape":          {"close-overlay"},
 963	"F6":              {"toggle-topic"},
 964	"F7":              {"toggle-channel-list"},
 965	"F8":              {"toggle-member-list"},
 966	"\n":              {"send"},
 967	"\r":              {"send"},
 968	"Control+j":       {"send"},
 969	"KP_Enter":        {"send"},
 970	"Alt+a":           {"buffer-next-unread"},
 971	"Alt+n":           {"scroll-next-highlight"},
 972	"Alt+p":           {"scroll-previous-highlight"},
 973	"Alt+1":           {"buffer", "0"},
 974	"Alt+KP_1":        {"buffer", "0"},
 975	"Alt+2":           {"buffer", "1"},
 976	"Alt+KP_2":        {"buffer", "1"},
 977	"Alt+3":           {"buffer", "2"},
 978	"Alt+KP_3":        {"buffer", "2"},
 979	"Alt+4":           {"buffer", "3"},
 980	"Alt+KP_4":        {"buffer", "3"},
 981	"Alt+5":           {"buffer", "4"},
 982	"Alt+KP_5":        {"buffer", "4"},
 983	"Alt+6":           {"buffer", "5"},
 984	"Alt+KP_6":        {"buffer", "5"},
 985	"Alt+7":           {"buffer", "6"},
 986	"Alt+KP_7":        {"buffer", "6"},
 987	"Alt+8":           {"buffer", "7"},
 988	"Alt+KP_8":        {"buffer", "7"},
 989	"Alt+9":           {"buffer", "8"},
 990	"Alt+KP_9":        {"buffer", "8"},
 991}
 992
 993func (app *App) handleKeyEvent(ev vaxis.Key) {
 994	switch ev.EventType {
 995	case vaxis.EventPress, vaxis.EventRepeat, vaxis.EventPaste:
 996	default:
 997		return
 998	}
 999	if len(ev.Text) == 1 && ev.Text[0] < ' ' {
1000		// Drop control characters text (sent by some terminal emulators)
1001		ev.Text = ""
1002	}
1003	if ev.Modifiers&(vaxis.ModCtrl|vaxis.ModAlt|vaxis.ModSuper|vaxis.ModMeta) != 0 {
1004		// Drop text when sent with modifiers preventing text
1005		ev.Text = ""
1006	}
1007	if ev.Text != "" {
1008		for _, r := range ev.Text {
1009			if app.formatting {
1010				f, ok := ui.FormattingChars[r]
1011				if ok {
1012					r = f
1013				}
1014			}
1015			app.win.InputRune(r)
1016		}
1017		app.typing()
1018		return
1019	}
1020
1021	if ev.EventType == vaxis.EventPaste {
1022		for _, k := range []keyMatch{
1023			{keycode: '\n'},
1024			{keycode: '\r'},
1025			{keycode: vaxis.KeyKeyPadEnter},
1026			{keycode: 'j', mods: vaxis.ModCtrl},
1027		} {
1028			for _, km := range keyMatches(ev) {
1029				if km == k {
1030					app.win.InputRune('\n')
1031					return
1032				}
1033			}
1034		}
1035	}
1036
1037	for _, km := range keyMatches(ev) {
1038		if d := app.shortcuts[km]; len(d) != 0 {
1039			app.handleAction(d[0], d[1:]...)
1040			return
1041		}
1042	}
1043}
1044
1045func (app *App) handleNickEvent(ev *events.EventClickNick) {
1046	s := app.sessions[ev.NetID]
1047	if s == nil {
1048		return
1049	}
1050	i, _ := app.addUserBuffer(ev.NetID, ev.Nick, time.Time{})
1051	app.win.JumpBufferIndex(i)
1052}
1053
1054func (app *App) handleChannelEvent(ev *events.EventClickChannel) {
1055	s := app.sessions[ev.NetID]
1056	if s == nil {
1057		return
1058	}
1059	if !app.win.JumpBufferNetwork(ev.NetID, ev.Channel) {
1060		s.Join(ev.Channel, "")
1061	}
1062}
1063
1064func (app *App) handleLinkEvent(ev *events.EventClickLink) {
1065	go func() {
1066		if strings.HasPrefix(ev.Link, "-") {
1067			// Avoid injection of parameters.
1068			// Sadly xdg-open does not support "--"...
1069			return
1070		}
1071		cmd := exec.Command("xdg-open", ev.Link)
1072		cmd.Run()
1073	}()
1074}
1075
1076func (app *App) upload(url string, r io.Reader, size int64, filename, mimetype string) (string, error) {
1077	c := http.Client{
1078		Timeout: 30 * time.Second,
1079	}
1080	rp := ReadProgress{
1081		Reader: r,
1082		period: 250 * time.Millisecond,
1083		f: func(n int64) {
1084			if size <= 0 {
1085				return
1086			}
1087			app.postEvent(event{
1088				src: "*",
1089				content: &events.EventFileUpload{
1090					Progress: float64(n) / float64(size),
1091				},
1092			})
1093		},
1094	}
1095	req, err := http.NewRequest("POST", url, &rp)
1096	if err != nil {
1097		return "", fmt.Errorf("creating upload request: %v", err)
1098	}
1099	if app.cfg.Password != nil {
1100		req.SetBasicAuth(app.cfg.User, *app.cfg.Password)
1101	}
1102	if size >= 0 {
1103		req.ContentLength = size
1104	}
1105	if filename != "" {
1106		req.Header.Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{
1107			"filename": filename,
1108		}))
1109	}
1110	if mimetype != "" {
1111		req.Header.Set("Content-Type", mimetype)
1112	}
1113	res, err := c.Do(req)
1114	if err != nil {
1115		return "", fmt.Errorf("uploading: %v", err)
1116	}
1117	if res.StatusCode == http.StatusRequestEntityTooLarge {
1118		var maxSize int64
1119		for _, entry := range strings.Split(res.Header.Get("Upload-Limit"), ",") {
1120			entry = strings.TrimSpace(entry)
1121			key, value, ok := strings.Cut(entry, "=")
1122			if !ok || key != "maxsize" {
1123				continue
1124			}
1125			if v, err := strconv.ParseInt(value, 10, 64); err == nil && v > 0 {
1126				maxSize = v
1127			}
1128		}
1129		if maxSize > 0 && size >= 0 {
1130			return "", fmt.Errorf("uploading: file too large: maximum %v per file (file was %v)", formatSize(maxSize), formatSize(size))
1131		} else if maxSize > 0 {
1132			return "", fmt.Errorf("uploading: file too large: maximum %v per file", formatSize(maxSize))
1133		} else {
1134			return "", fmt.Errorf("uploading: file too large")
1135		}
1136	}
1137	if res.StatusCode != http.StatusCreated {
1138		return "", fmt.Errorf("uploading: unexpected status code: %d", res.StatusCode)
1139	}
1140	location, err := res.Location()
1141	if err != nil {
1142		return "", fmt.Errorf("uploading: reading file URL: %v", err)
1143	}
1144	return location.String(), nil
1145}
1146
1147func (app *App) handleUpload(url string, r io.Reader, size int64, filename, mimetype string, closer io.Closer) {
1148	var progress float64 = 0
1149	app.uploadingProgress = &progress
1150	go func() {
1151		if closer != nil {
1152			defer closer.Close()
1153		}
1154		location, err := app.upload(url, r, size, filename, mimetype)
1155		if err != nil {
1156			app.postEvent(event{
1157				src: "*",
1158				content: &events.EventFileUpload{
1159					Error: err.Error(),
1160				},
1161			})
1162		} else {
1163			app.postEvent(event{
1164				src: "*",
1165				content: &events.EventFileUpload{
1166					Location: location,
1167				},
1168			})
1169		}
1170	}()
1171}
1172
1173// maybeRequestHistory is a wrapper around irc.Session.RequestHistory to only request
1174// history when needed.
1175func (app *App) maybeRequestHistory() {
1176	if app.win.HasOverlay() {
1177		return
1178	}
1179	netID, buffer := app.win.CurrentBuffer()
1180	s := app.sessions[netID]
1181	if s == nil {
1182		return
1183	}
1184	bk := boundKey{netID, s.Casemap(buffer)}
1185	if app.messageBounds[bk].complete {
1186		return
1187	}
1188	_, h := app.win.Size()
1189	if l := app.win.LinesAboveOffset(); l < h*2 && buffer != "" {
1190		if bound, ok := app.messageBounds[bk]; ok {
1191			s.NewHistoryRequest(buffer).
1192				WithLimit(200).
1193				Before(bound.first)
1194		} else {
1195			s.NewHistoryRequest(buffer).
1196				WithLimit(200).
1197				Latest()
1198		}
1199	}
1200}
1201
1202func (app *App) handleIRCEvent(netID string, ev interface{}) {
1203	if ev == nil {
1204		if s, ok := app.sessions[netID]; ok {
1205			s.Close()
1206			delete(app.sessions, netID)
1207		}
1208		return
1209	}
1210	if s, ok := ev.(*irc.Session); ok {
1211		if s, ok := app.sessions[netID]; ok {
1212			s.Close()
1213		}
1214		if !app.wantsNetwork(netID) {
1215			delete(app.sessions, netID)
1216			delete(app.monitor, netID)
1217			s.Close()
1218			return
1219		}
1220		app.sessions[netID] = s
1221		if _, ok := app.monitor[netID]; !ok {
1222			app.monitor[netID] = make(map[string]struct{})
1223		}
1224		return
1225	}
1226	if _, ok := ev.(irc.Typing); ok {
1227		// Just refresh the screen.
1228		return
1229	}
1230
1231	msg, ok := ev.(irc.Message)
1232	if !ok {
1233		panic("unreachable")
1234	}
1235	s, ok := app.sessions[netID]
1236	if !ok {
1237		panic(fmt.Sprintf("cannot find session %q for message %q", netID, msg.String()))
1238	}
1239
1240	// Mutate IRC state
1241	ev, err := s.HandleMessage(msg)
1242	if err != nil {
1243		app.win.AddLine(netID, "", ui.Line{
1244			Head:   ui.ColorString("!!", ui.ColorRed),
1245			Notify: ui.NotifyUnread,
1246			Body:   ui.PlainSprintf("Received corrupt message %q: %s", msg.String(), err),
1247		})
1248		return
1249	}
1250	t := msg.TimeOrNow()
1251	if t.After(app.lastMessageTime) {
1252		app.lastMessageTime = t
1253	}
1254
1255	if cs, ok := app.pendingCompletions[netID]; ok {
1256		now := time.Now()
1257		for i := 0; i < len(cs); i++ {
1258			c := &cs[i]
1259			var r []ui.Completion
1260			eat := false
1261			if c.deadline.After(now) {
1262				r = c.f(ev)
1263				if r == nil {
1264					continue
1265				}
1266				eat = true
1267			}
1268			app.win.AsyncCompletions(c.id, r)
1269			copy(cs[i:], cs[i+1:])
1270			app.pendingCompletions[netID] = app.pendingCompletions[netID][:len(cs)-1]
1271			i--
1272			if eat {
1273				return
1274			}
1275		}
1276	}
1277
1278	// Mutate UI state
1279	switch ev := ev.(type) {
1280	case irc.RegisteredEvent:
1281		for _, channel := range app.cfg.Channels {
1282			// TODO: group JOIN messages
1283			// TODO: support autojoining channels with keys
1284			s.Join(channel, "")
1285		}
1286		s.NewHistoryRequest("").
1287			WithLimit(1000).
1288			Targets(app.lastCloseTime, msg.TimeOrNow())
1289		body := "Connected to the server"
1290		if s.Nick() != app.cfg.Nick {
1291			body = fmt.Sprintf("Connected to the server as %s", s.Nick())
1292		}
1293		app.addStatusLine(netID, ui.Line{
1294			At:   msg.TimeOrNow(),
1295			Head: ui.PlainString("--"),
1296			Body: ui.PlainString(body),
1297		})
1298		if !app.shownBouncerNotice && !s.IsBouncer() {
1299			app.shownBouncerNotice = true
1300			for _, line := range []string{
1301				"senpai appears to be directly connected to an IRC server, rather than to an \x02IRC bouncer\x02. This is supported, but provides a limited IRC experience.",
1302				"In order to connect to multiple networks, keep message history, search through your messages, and upload files, use an \x02IRC bouncer\x02 and point senpai to the bouncer.",
1303				"Most senpai users use senpai with the IRC bouncer software \x02soju\x02.",
1304				"* You can self-host \x02soju\x02 yourself (it is free and open-source): https://soju.im/",
1305				"* You can also use a commercial hosted bouncer (uses \x02soju\x02 underneath), endorsed by senpai: \x02https://irctoday.com/\x02",
1306			} {
1307				app.addStatusLine(netID, ui.Line{
1308					At:   msg.TimeOrNow(),
1309					Head: ui.PlainString("Bouncer --"),
1310					Body: ui.IRCString(line),
1311				})
1312			}
1313		}
1314		for target := range app.monitor[s.NetID()] {
1315			// TODO: batch MONITOR +
1316			s.MonitorAdd(target)
1317		}
1318	case irc.SelfNickEvent:
1319		if !app.cfg.StatusEnabled {
1320			break
1321		}
1322		var body ui.StyledStringBuilder
1323		body.WriteString(fmt.Sprintf("%s\u2192%s", ev.FormerNick, s.Nick()))
1324		textStyle := vaxis.Style{
1325			Foreground: app.cfg.Colors.Status,
1326		}
1327		var arrowStyle vaxis.Style
1328		body.AddStyle(0, textStyle)
1329		body.AddStyle(len(ev.FormerNick), arrowStyle)
1330		body.AddStyle(body.Len()-len(s.Nick()), textStyle)
1331		app.addStatusLine(netID, ui.Line{
1332			At:        msg.TimeOrNow(),
1333			Head:      ui.ColorString("--", app.cfg.Colors.Status),
1334			Body:      body.StyledString(),
1335			Highlight: true,
1336			Readable:  true,
1337		})
1338	case irc.UserNickEvent:
1339		if !app.cfg.StatusEnabled {
1340			break
1341		}
1342		line := app.formatEvent(ev)
1343		for _, c := range s.ChannelsSharedWith(ev.User) {
1344			app.win.AddLine(netID, c, line)
1345		}
1346	case irc.SelfJoinEvent:
1347		i, added := app.win.AddBuffer(netID, "", ev.Channel)
1348		i = app.win.SetMuted(netID, ev.Channel, s.MutedGet(ev.Channel))
1349		i = app.win.SetPinned(netID, ev.Channel, s.PinnedGet(ev.Channel))
1350		if !ev.Read.IsZero() {
1351			app.win.SetRead(netID, ev.Channel, ev.Read)
1352		}
1353		bk := boundKey{netID, s.Casemap(ev.Channel)}
1354		bounds, ok := app.messageBounds[bk]
1355		if added || !ok {
1356			if t, ok := msg.Time(); ok {
1357				s.NewHistoryRequest(ev.Channel).
1358					WithLimit(500).
1359					Before(t)
1360			} else {
1361				s.NewHistoryRequest(ev.Channel).
1362					WithLimit(500).
1363					Latest()
1364			}
1365		} else {
1366			s.NewHistoryRequest(ev.Channel).
1367				WithLimit(1000).
1368				After(bounds.last)
1369		}
1370		if ev.Requested {
1371			app.win.JumpBufferIndex(i)
1372		}
1373		if ev.Topic != "" {
1374			topic := ui.IRCString(ev.Topic).ParseURLs()
1375			app.win.SetTopic(netID, ev.Channel, topic)
1376		}
1377
1378		// Restore last buffer
1379		if netID == app.lastNetID && ev.Channel == app.lastBuffer {
1380			app.win.JumpBufferNetwork(app.lastNetID, app.lastBuffer)
1381			app.win.ScrollToBuffer()
1382			app.lastNetID = ""
1383			app.lastBuffer = ""
1384		}
1385	case irc.UserJoinEvent:
1386		if !app.cfg.StatusEnabled {
1387			break
1388		}
1389		line := app.formatEvent(ev)
1390		app.win.AddLine(netID, ev.Channel, line)
1391	case irc.SelfPartEvent:
1392		app.win.RemoveBuffer(netID, ev.Channel)
1393		delete(app.messageBounds, boundKey{netID, s.Casemap(ev.Channel)})
1394	case irc.UserPartEvent:
1395		if !app.cfg.StatusEnabled {
1396			break
1397		}
1398		line := app.formatEvent(ev)
1399		app.win.AddLine(netID, ev.Channel, line)
1400	case irc.UserQuitEvent:
1401		if !app.cfg.StatusEnabled {
1402			break
1403		}
1404		line := app.formatEvent(ev)
1405		for _, c := range ev.Channels {
1406			app.win.AddLine(netID, c, line)
1407		}
1408	case irc.TopicChangeEvent:
1409		line := app.formatEvent(ev)
1410		app.win.AddLine(netID, ev.Channel, line)
1411		topic := ui.IRCString(ev.Topic).ParseURLs()
1412		app.win.SetTopic(netID, ev.Channel, topic)
1413	case irc.ModeChangeEvent:
1414		if !app.cfg.StatusEnabled {
1415			break
1416		}
1417		line := app.formatEvent(ev)
1418		app.win.AddLine(netID, ev.Channel, line)
1419	case irc.InviteEvent:
1420		var buffer string
1421		var notify ui.NotifyType
1422		var body string
1423		if s.IsMe(ev.Invitee) {
1424			buffer = ""
1425			notify = ui.NotifyHighlight
1426			body = fmt.Sprintf("%s invited you to join %s", ev.Inviter, ev.Channel)
1427		} else if s.IsMe(ev.Inviter) {
1428			buffer = ev.Channel
1429			notify = ui.NotifyNone
1430			body = fmt.Sprintf("You invited %s to join this channel", ev.Invitee)
1431		} else {
1432			buffer = ev.Channel
1433			notify = ui.NotifyUnread
1434			body = fmt.Sprintf("%s invited %s to join this channel", ev.Inviter, ev.Invitee)
1435		}
1436		app.win.AddLine(netID, buffer, ui.Line{
1437			At:     msg.TimeOrNow(),
1438			Head:   ui.ColorString("--", app.cfg.Colors.Status),
1439			Notify: notify,
1440			Body: ui.Styled(body, vaxis.Style{
1441				Foreground: app.cfg.Colors.Status,
1442			}),
1443			Highlight: notify == ui.NotifyHighlight,
1444			Readable:  true,
1445		})
1446	case irc.MessageEvent:
1447		buffer, line := app.formatMessage(s, ev)
1448		if buffer != "" && !s.IsChannel(buffer) {
1449			t, ok := msg.Time()
1450			if !ok {
1451				t = time.Time{}
1452			}
1453			app.addUserBuffer(netID, buffer, t)
1454		}
1455		app.win.AddLine(netID, buffer, line)
1456		if line.Notify == ui.NotifyHighlight {
1457			curNetID, curBuffer := app.win.CurrentBuffer()
1458			current := app.win.Focused() && curNetID == netID && s.Casemap(curBuffer) == s.Casemap(buffer)
1459			app.notifyHighlight(buffer, ev.User, line.Body.String(), current)
1460		}
1461		if !ev.TargetIsChannel && !s.IsMe(ev.User) {
1462			app.lastQuery = ev.User
1463			app.lastQueryNet = netID
1464		}
1465		bk := boundKey{netID, s.Casemap(buffer)}
1466		bounds := app.messageBounds[bk]
1467		bounds.Update(&line)
1468		app.messageBounds[bk] = bounds
1469	case irc.HistoryTargetsEvent:
1470		type target struct {
1471			name string
1472			last time.Time
1473		}
1474		// try to fetch the history of the last opened buffer first
1475		targets := make([]target, 0, len(ev.Targets))
1476		if app.lastNetID == netID {
1477			if last, ok := ev.Targets[app.lastBuffer]; ok {
1478				targets = append(targets, target{app.lastBuffer, last})
1479				delete(ev.Targets, app.lastBuffer)
1480			}
1481		}
1482		for name, last := range ev.Targets {
1483			targets = append(targets, target{name, last})
1484		}
1485		for _, target := range targets {
1486			if s.IsChannel(target.name) {
1487				continue
1488			}
1489			// CHATHISTORY BEFORE excludes its bound, so add 1ms
1490			// (precision of the time tag) to include that last message.
1491			target.last = target.last.Add(1 * time.Millisecond)
1492			app.addUserBuffer(netID, target.name, target.last)
1493		}
1494	case irc.HistoryEvent:
1495		var linesBefore []ui.Line
1496		var linesAfter []ui.Line
1497		bk := boundKey{netID, s.Casemap(ev.Target)}
1498		bounds, hasBounds := app.messageBounds[bk]
1499		boundsNew := bounds
1500		for _, m := range ev.Messages {
1501			if re, ok := m.(irc.ReactEvent); ok {
1502				found := false
1503				for i := len(linesAfter) - 1; !found && i >= 0; i-- {
1504					if linesAfter[i].ID == re.ID {
1505						panic("after")
1506						linesAfter[i].ApplyReact(re.User, re.React, re.Removal)
1507						found = true
1508					}
1509				}
1510				if found {
1511					continue
1512				}
1513				for i := len(linesBefore) - 1; !found && i >= 0; i-- {
1514					if linesBefore[i].ID == re.ID {
1515						linesBefore[i].ApplyReact(re.User, re.React, re.Removal)
1516						found = true
1517					}
1518				}
1519				if !found {
1520					app.win.ApplyReact(netID, ev.Target, re.ID, re.User, re.React, re.Removal)
1521				}
1522				continue
1523			}
1524			var line ui.Line
1525			switch ev := m.(type) {
1526			case irc.MessageEvent:
1527				_, line = app.formatMessage(s, ev)
1528			default:
1529				line = app.formatEvent(ev)
1530			}
1531			boundsNew.Update(&line)
1532			if _, ok := m.(irc.MessageEvent); !ok && !app.cfg.StatusEnabled {
1533				continue
1534			}
1535			if hasBounds {
1536				c := bounds.Compare(&line)
1537				if c < 0 {
1538					linesBefore = append(linesBefore, line)
1539				} else if c > 0 {
1540					linesAfter = append(linesAfter, line)
1541				}
1542			} else {
1543				linesBefore = append(linesBefore, line)
1544			}
1545		}
1546		app.win.AddLines(netID, ev.Target, linesBefore, linesAfter)
1547
1548		if !boundsNew.IsZero() {
1549			app.messageBounds[bk] = boundsNew
1550		}
1551		if len(ev.Messages) < 10 || ev.End {
1552			// We're getting a non-full page, or the server told us via the draft/chathistory-end tag
1553			// that this is the end of available history: mark as complete to avoid indefinitely
1554			// fetching the history.
1555			// The < 10 heuristic remains for servers that don't yet emit the tag. It should ideally
1556			// be equal to the CHATHISTORY LIMIT, but it can be non advertised, or a full page could
1557			// sometimes be less than a limit (because it could be filtered). It is also not zero,
1558			// because bounds are inclusive, and not one, because we truncate based on the second of
1559			// the message (because some bouncers have a second-level resolution). Be safe and pick
1560			// 10 messages: less messages means that this was not a full page and we are done with
1561			// fetching the backlog.
1562			b := app.messageBounds[bk]
1563			b.complete = true
1564			app.messageBounds[bk] = b
1565		}
1566	case irc.SearchEvent:
1567		app.win.OpenOverlay("Press Escape to close the search results")
1568		lines := make([]ui.Line, 0, len(ev.Messages))
1569		for _, m := range ev.Messages {
1570			_, line := app.formatMessage(s, m)
1571			if line.IsZero() {
1572				continue
1573			}
1574			lines = append(lines, line)
1575		}
1576		app.win.AddLines("", ui.Overlay, lines, nil)
1577	case irc.ReactEvent:
1578		app.win.ApplyReact(netID, ev.Target, ev.ID, ev.User, ev.React, ev.Removal)
1579	case irc.ReadEvent:
1580		app.win.SetRead(netID, ev.Target, ev.Timestamp)
1581	case irc.MetadataChangeEvent:
1582		app.win.SetPinned(netID, ev.Target, ev.Pinned)
1583		app.win.SetMuted(netID, ev.Target, ev.Muted)
1584		if ev.Pinned && !s.IsChannel(ev.Target) {
1585			app.addUserBuffer(netID, ev.Target, time.Time{})
1586		}
1587	case irc.BouncerNetworkEvent:
1588		if !ev.Delete {
1589			_, added := app.win.AddBuffer(ev.ID, ev.Name, "")
1590			if added {
1591				app.networkLock.Lock()
1592				app.networks[ev.ID] = struct{}{}
1593				app.networkLock.Unlock()
1594				go app.ircLoop(ev.ID)
1595			}
1596		} else {
1597			app.networkLock.Lock()
1598			delete(app.networks, ev.ID)
1599			app.networkLock.Unlock()
1600			// if a session was already opened, close it now.
1601			// otherwise, we'll close it when it sends a new session event.
1602			if s, ok := app.sessions[ev.ID]; ok {
1603				s.Close()
1604				delete(app.sessions, ev.ID)
1605				delete(app.monitor, ev.ID)
1606			}
1607			app.win.RemoveNetworkBuffers(ev.ID)
1608		}
1609	case irc.ListEvent:
1610		for _, item := range ev {
1611			text := fmt.Sprintf("There are %4s users on channel %s", item.Count, item.Channel)
1612			if item.Topic != "" {
1613				text += " -- " + item.Topic
1614			}
1615			app.addStatusLine(netID, ui.Line{
1616				At:   msg.TimeOrNow(),
1617				Head: ui.ColorString("List --", app.cfg.Colors.Status),
1618				Body: ui.Styled(text, vaxis.Style{
1619					Foreground: app.cfg.Colors.Status,
1620				}),
1621			})
1622		}
1623	case irc.InfoEvent:
1624		var head string
1625		if ev.Prefix != "" {
1626			head = ev.Prefix + " --"
1627		} else {
1628			head = "--"
1629		}
1630		app.addStatusLine(netID, ui.Line{
1631			At:   msg.TimeOrNow(),
1632			Head: ui.ColorString(head, app.cfg.Colors.Status),
1633			Body: ui.Styled(ev.Message, vaxis.Style{
1634				Foreground: app.cfg.Colors.Status,
1635			}),
1636		})
1637		return
1638	case irc.ErrorEvent:
1639		var head string
1640		var body string
1641		switch ev.Severity {
1642		case irc.SeverityNote:
1643			app.addStatusLine(netID, ui.Line{
1644				At:   msg.TimeOrNow(),
1645				Head: ui.ColorString(fmt.Sprintf("(%s) --", ev.Code), app.cfg.Colors.Status),
1646				Body: ui.Styled(ev.Message, vaxis.Style{
1647					Foreground: app.cfg.Colors.Status,
1648				}),
1649			})
1650			return
1651		case irc.SeverityFail:
1652			head = "--"
1653			body = fmt.Sprintf("Error (code %s): %s", ev.Code, ev.Message)
1654		case irc.SeverityWarn:
1655			head = "--"
1656			body = fmt.Sprintf("Warning (code %s): %s", ev.Code, ev.Message)
1657		default:
1658			panic("unreachable")
1659		}
1660		app.addStatusLine(netID, ui.Line{
1661			At:   msg.TimeOrNow(),
1662			Head: ui.PlainString(head),
1663			Body: ui.PlainString(body),
1664		})
1665	}
1666}
1667
1668func isWordBoundary(r rune) bool {
1669	switch r {
1670	case '-', '_', '|': // inspired from weechat.look.highlight_regex
1671		return false
1672	default:
1673		return !unicode.IsLetter(r) && !unicode.IsNumber(r)
1674	}
1675}
1676
1677func isHighlight(text, nick string) bool {
1678	for {
1679		i := strings.Index(text, nick)
1680		if i < 0 {
1681			return false
1682		}
1683
1684		left, _ := utf8.DecodeLastRuneInString(text[:i])
1685		right, _ := utf8.DecodeRuneInString(text[i+len(nick):])
1686		if isWordBoundary(left) && isWordBoundary(right) {
1687			return true
1688		}
1689
1690		text = text[i+len(nick):]
1691	}
1692}
1693
1694// isHighlight reports whether the given message content is a highlight.
1695func (app *App) isHighlight(s *irc.Session, content string) bool {
1696	contentCf := s.Casemap(content)
1697	if app.highlights == nil {
1698		return isHighlight(contentCf, s.NickCf())
1699	}
1700	for _, h := range app.highlights {
1701		if isHighlight(contentCf, s.Casemap(h)) {
1702			return true
1703		}
1704	}
1705	return false
1706}
1707
1708// notifyHighlight executes the script at "on-highlight-path" according to the given
1709// message context.
1710func (app *App) notifyHighlight(buffer, nick, content string, current bool) {
1711	if !current && app.cfg.OnHighlightBeep {
1712		app.win.Beep()
1713	}
1714
1715	if app.cfg.Transient {
1716		return
1717	}
1718
1719	path := app.cfg.OnHighlightPath
1720	if path == "" {
1721		defaultHighlightPath, err := DefaultHighlightPath()
1722		if err != nil {
1723			return
1724		}
1725		path = defaultHighlightPath
1726	}
1727
1728	netID, _ := app.win.CurrentBuffer()
1729	if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
1730		// only error out if the user specified a highlight path
1731		// if default path unreachable, simple bail
1732		if app.cfg.OnHighlightPath != "" {
1733			body := fmt.Sprintf("Unable to find on-highlight command at path: %q", path)
1734			app.addStatusLine(netID, ui.Line{
1735				At:   time.Now(),
1736				Head: ui.ColorString("!!", ui.ColorRed),
1737				Body: ui.PlainString(body),
1738			})
1739		}
1740		return
1741	}
1742	here := "0"
1743	if current {
1744		here = "1"
1745	}
1746	cmd := exec.Command(path)
1747	cmd.Env = append(os.Environ(),
1748		fmt.Sprintf("BUFFER=%s", buffer),
1749		fmt.Sprintf("HERE=%s", here),
1750		fmt.Sprintf("SENDER=%s", nick),
1751		fmt.Sprintf("MESSAGE=%s", content),
1752	)
1753	output, err := cmd.CombinedOutput()
1754	if err != nil {
1755		body := fmt.Sprintf("Failed to invoke on-highlight command at path: %v. Output: %q", err, string(output))
1756		app.addStatusLine(netID, ui.Line{
1757			At:   time.Now(),
1758			Head: ui.ColorString("!!", ui.ColorRed),
1759			Body: ui.PlainString(body),
1760		})
1761	}
1762}
1763
1764// typing sends typing notifications to the IRC server according to the user
1765// input.
1766func (app *App) typing() {
1767	netID, buffer := app.win.CurrentBuffer()
1768	s := app.sessions[netID]
1769	if s == nil || !app.cfg.Typings {
1770		return
1771	}
1772	if buffer == "" {
1773		return
1774	}
1775	input := app.win.InputContent()
1776	if len(input) == 0 {
1777		s.TypingStop(buffer)
1778	} else if !isCommand(input) {
1779		s.Typing(buffer)
1780	}
1781}
1782
1783// completions computes the list of completions given the input text and the
1784// cursor position.
1785func (app *App) completions(cursorIdx int, text []rune) []ui.Completion {
1786	if len(text) == 0 {
1787		return nil
1788	}
1789	netID, buffer := app.win.CurrentBuffer()
1790	s := app.sessions[netID]
1791	if s == nil {
1792		return nil
1793	}
1794
1795	var cs []ui.Completion
1796	if buffer != "" {
1797		cs = app.completionsChannelTopic(cs, cursorIdx, text)
1798		cs = app.completionsChannelMembers(cs, cursorIdx, text)
1799	}
1800	cs = app.completionsJoin(cs, cursorIdx, text)
1801	cs = app.completionsUpload(cs, cursorIdx, text)
1802	cs = app.completionsMsg(cs, cursorIdx, text)
1803	cs = app.completionsCommands(cs, cursorIdx, text)
1804	cs = app.completionsEmoji(cs, cursorIdx, text)
1805
1806	for i := 0; i < len(cs); i++ {
1807		c := &cs[i]
1808		if c.Async == nil {
1809			continue
1810		}
1811		c.AsyncID = app.pendingCompletionsOff
1812		app.pendingCompletionsOff++
1813		app.pendingCompletions[netID] = append(app.pendingCompletions[netID], pendingCompletion{
1814			id:       c.AsyncID,
1815			f:        c.Async.(completionAsync),
1816			deadline: time.Now().Add(4 * time.Second),
1817		})
1818	}
1819
1820	return cs
1821}
1822
1823type mergedEvent struct {
1824	oldNick        string
1825	nick           string
1826	nickCf         string
1827	firstConnected int // -1: offline; 1: online
1828	lastConnected  int // -1: offline; 1: online
1829	modeSet        string
1830	modeUnset      string
1831	channelMode    string
1832}
1833
1834// formatEvent returns a formatted ui.Line for an irc.Event.
1835func (app *App) formatEvent(ev irc.Event) ui.Line {
1836	switch ev := ev.(type) {
1837	case irc.UserNickEvent:
1838		var body ui.StyledStringBuilder
1839		body.WriteString(fmt.Sprintf("%s\u2192%s", ev.FormerNick, ev.User))
1840		textStyle := vaxis.Style{
1841			Foreground: app.cfg.Colors.Status,
1842		}
1843		var arrowStyle vaxis.Style
1844		body.AddStyle(0, textStyle)
1845		body.AddStyle(len(ev.FormerNick), arrowStyle)
1846		body.AddStyle(body.Len()-len(ev.User), textStyle)
1847		return ui.Line{
1848			At:        ev.Time,
1849			Head:      ui.ColorString("--", app.cfg.Colors.Status),
1850			Body:      body.StyledString(),
1851			Mergeable: true,
1852			Data:      []irc.Event{ev},
1853			Readable:  true,
1854		}
1855	case irc.UserJoinEvent:
1856		var body ui.StyledStringBuilder
1857		body.Grow(len(ev.User) + 1)
1858		body.SetStyle(vaxis.Style{
1859			Foreground: ui.ColorGreen,
1860		})
1861		body.WriteByte('+')
1862		body.SetStyle(vaxis.Style{
1863			Foreground: app.cfg.Colors.Status,
1864		})
1865		body.WriteString(ev.User)
1866		return ui.Line{
1867			At:        ev.Time,
1868			Head:      ui.ColorString("--", app.cfg.Colors.Status),
1869			Body:      body.StyledString(),
1870			Mergeable: true,
1871			Data:      []irc.Event{ev},
1872			Readable:  true,
1873		}
1874	case irc.UserPartEvent:
1875		var body ui.StyledStringBuilder
1876		body.Grow(len(ev.User) + 1)
1877		body.SetStyle(vaxis.Style{
1878			Foreground: ui.ColorRed,
1879		})
1880		body.WriteByte('x')
1881		body.SetStyle(vaxis.Style{
1882			Foreground: app.cfg.Colors.Status,
1883		})
1884		body.WriteString(ev.User)
1885		if ev.Who != "" {
1886			body.WriteString(" by ")
1887			body.WriteString(ev.Who)
1888		}
1889		if ev.Who != "" || (app.cfg.QuitMessages && ev.Message != "" &&
1890			// Few checks for useless messages
1891			ev.Message != "Quit: " && ev.Message != "Client Quit" &&
1892			ev.Message != "connection closed" &&
1893			ev.Message != "Remote host closed the connection") {
1894			body.WriteString(" (")
1895			body.WriteString(ev.Message)
1896			body.WriteByte(')')
1897		}
1898		return ui.Line{
1899			At:        ev.Time,
1900			Head:      ui.ColorString("--", app.cfg.Colors.Status),
1901			Body:      body.StyledString(),
1902			Mergeable: true,
1903			Data:      []irc.Event{ev},
1904			Readable:  true,
1905		}
1906	case irc.UserQuitEvent:
1907		var body ui.StyledStringBuilder
1908		body.Grow(len(ev.User) + 1)
1909		body.SetStyle(vaxis.Style{
1910			Foreground: ui.ColorRed,
1911		})
1912		body.WriteByte('-')
1913		body.SetStyle(vaxis.Style{
1914			Foreground: app.cfg.Colors.Status,
1915		})
1916		body.WriteString(ev.User)
1917		if app.cfg.QuitMessages && ev.Message != "" {
1918			body.WriteString(" (")
1919			body.WriteString(ev.Message)
1920			body.WriteByte(')')
1921		}
1922		return ui.Line{
1923			At:        ev.Time,
1924			Head:      ui.ColorString("--", app.cfg.Colors.Status),
1925			Body:      body.StyledString(),
1926			Mergeable: true,
1927			Data:      []irc.Event{ev},
1928			Readable:  true,
1929		}
1930	case irc.TopicChangeEvent:
1931		topic := ui.IRCString(ev.Topic).String()
1932		who := ui.IRCString(ev.Who).String()
1933		body := fmt.Sprintf("Topic changed by %s to: %s", who, topic)
1934		return ui.Line{
1935			At:     ev.Time,
1936			Head:   ui.ColorString("--", app.cfg.Colors.Status),
1937			Notify: ui.NotifyUnread,
1938			Body: ui.Styled(body, vaxis.Style{
1939				Foreground: app.cfg.Colors.Status,
1940			}),
1941			Readable: true,
1942		}
1943	case irc.ModeChangeEvent:
1944		body := fmt.Sprintf("[%s] by %s", ev.Mode, ev.Who)
1945		return ui.Line{
1946			At:   ev.Time,
1947			Head: ui.ColorString("--", app.cfg.Colors.Status),
1948			Body: ui.Styled(body, vaxis.Style{
1949				Foreground: app.cfg.Colors.Status,
1950			}),
1951			Mergeable: true,
1952			Data:      []irc.Event{ev},
1953			Readable:  true,
1954		}
1955	case *mergedEvent:
1956		var body ui.StyledStringBuilder
1957		if ev.nick != "" && ((ev.firstConnected != 0 && ev.firstConnected == ev.lastConnected) || ev.modeSet != "" || ev.modeUnset != "" || (ev.oldNick != "" && ev.oldNick != ev.nick)) {
1958			if ev.firstConnected != 0 && ev.firstConnected == ev.lastConnected {
1959				if ev.firstConnected == -1 {
1960					body.SetStyle(vaxis.Style{
1961						Foreground: ui.ColorRed,
1962					})
1963					body.WriteByte('-')
1964				} else {
1965					body.SetStyle(vaxis.Style{
1966						Foreground: ui.ColorGreen,
1967					})
1968					body.WriteByte('+')
1969				}
1970			}
1971			if ev.modeSet != "" || ev.modeUnset != "" {
1972				body.SetStyle(vaxis.Style{
1973					Foreground: app.cfg.Colors.Status,
1974				})
1975				body.WriteByte('[')
1976				if ev.modeSet != "" {
1977					body.WriteByte('+')
1978					body.WriteString(ev.modeSet)
1979				}
1980				if ev.modeUnset != "" {
1981					body.WriteByte('-')
1982					body.WriteString(ev.modeSet)
1983				}
1984				body.WriteByte(']')
1985			}
1986			if ev.oldNick != "" && ev.oldNick != ev.nick {
1987				body.SetStyle(vaxis.Style{
1988					Foreground: app.cfg.Colors.Status,
1989				})
1990				body.WriteString(ev.oldNick)
1991				body.SetStyle(vaxis.Style{})
1992				body.WriteString("\u2192")
1993			}
1994			body.SetStyle(vaxis.Style{
1995				Foreground: app.cfg.Colors.Status,
1996			})
1997			body.WriteString(ev.nick)
1998		} else if ev.nick == "" && ev.channelMode != "" {
1999			body.SetStyle(vaxis.Style{
2000				Foreground: app.cfg.Colors.Status,
2001			})
2002			fmt.Fprintf(&body, "[%s]", ev.channelMode)
2003		} else {
2004			return ui.Line{}
2005		}
2006		return ui.Line{
2007			// Only the Body is used for merged events
2008			Body: body.StyledString(),
2009		}
2010	default:
2011		return ui.Line{}
2012	}
2013}
2014
2015// formatMessage sets how a given message must be formatted.
2016//
2017// It computes three things:
2018// - which buffer the message must be added to,
2019// - the UI line.
2020func (app *App) formatMessage(s *irc.Session, ev irc.MessageEvent) (buffer string, line ui.Line) {
2021	isFromSelf := s.IsMe(ev.User)
2022	isToSelf := s.IsMe(ev.Target)
2023	isHighlight := ev.TargetIsChannel && app.isHighlight(s, ev.Content)
2024	isQuery := !ev.TargetIsChannel && ev.Command == "PRIVMSG"
2025	isNotice := ev.Command == "NOTICE"
2026
2027	content := strings.TrimSuffix(ev.Content, "\x01")
2028	content = strings.TrimRightFunc(content, unicode.IsSpace)
2029
2030	isAction := false
2031	if strings.HasPrefix(content, "\x01") {
2032		parts := strings.SplitN(content[1:], " ", 2)
2033		if len(parts) < 2 {
2034			return
2035		}
2036		switch parts[0] {
2037		case "ACTION":
2038			isAction = true
2039		default:
2040			return
2041		}
2042		content = parts[1]
2043	}
2044
2045	if !ev.TargetIsChannel && (isNotice || ev.User == s.BouncerService()) {
2046		curNetID, curBuffer := app.win.CurrentBuffer()
2047		if curNetID == s.NetID() {
2048			buffer = curBuffer
2049		}
2050	} else if isToSelf {
2051		buffer = ev.User
2052	} else {
2053		buffer = ev.Target
2054	}
2055
2056	var notification ui.NotifyType
2057	hlLine := ev.TargetIsChannel && isHighlight && !isFromSelf
2058	if isFromSelf {
2059		notification = ui.NotifyNone
2060	} else if isHighlight || isQuery {
2061		notification = ui.NotifyHighlight
2062	} else {
2063		notification = ui.NotifyUnread
2064	}
2065
2066	var membershipPrefix string
2067	if app.cfg.NickPrefix && ev.TargetIsChannel {
2068		if m := s.Membership(ev.Target, ev.User); m != "" {
2069			membershipPrefix = m[:1]
2070		}
2071	}
2072
2073	var head ui.StyledStringBuilder
2074	if ev.TargetPrefix != "" {
2075		head.WriteStyledString(ui.ColorString(ev.TargetPrefix, ui.ColorGreen))
2076	}
2077
2078	if isAction || isNotice {
2079		if head.Len() == 0 {
2080			head.WriteStyledString(ui.PlainString("*"))
2081		}
2082	} else {
2083		if head.Len() > 0 {
2084			head.WriteStyledString(ui.PlainString(" "))
2085		}
2086		if membershipPrefix != "" {
2087			head.WriteStyledString(ui.ColorString(membershipPrefix, ui.ColorGreen))
2088		}
2089		c := app.win.IdentColor(app.cfg.Colors.Nicks, ev.User, isFromSelf)
2090		head.WriteStyledString(ui.ColorString(ev.User, c))
2091	}
2092
2093	var body ui.StyledStringBuilder
2094	if isNotice {
2095		if membershipPrefix != "" {
2096			body.WriteStyledString(ui.ColorString(membershipPrefix, ui.ColorGreen))
2097		}
2098		color := app.win.IdentColor(app.cfg.Colors.Nicks, ev.User, isFromSelf)
2099		body.SetStyle(vaxis.Style{
2100			Foreground: color,
2101		})
2102		body.WriteString(ev.User)
2103		body.SetStyle(vaxis.Style{})
2104		body.WriteString(": ")
2105		body.WriteStyledString(ui.IRCString(content))
2106	} else if isAction {
2107		if membershipPrefix != "" {
2108			body.WriteStyledString(ui.ColorString(membershipPrefix, ui.ColorGreen))
2109		}
2110		color := app.win.IdentColor(app.cfg.Colors.Nicks, ev.User, isFromSelf)
2111		body.SetStyle(vaxis.Style{
2112			Foreground: color,
2113		})
2114		body.WriteString(ev.User)
2115		body.SetStyle(vaxis.Style{})
2116		body.WriteString(" ")
2117		body.WriteStyledString(ui.IRCString(content))
2118	} else {
2119		body.WriteStyledString(ui.IRCString(content))
2120	}
2121
2122	line = ui.Line{
2123		At:        ev.Time,
2124		ID:        ev.ID,
2125		ReplyTo:   ev.ReplyTo,
2126		Head:      head.StyledString(),
2127		Notify:    notification,
2128		Body:      body.StyledString(),
2129		Highlight: hlLine,
2130		Readable:  true,
2131	}
2132	return
2133}
2134
2135func (app *App) mergeLine(former *ui.Line, addition ui.Line) {
2136	events := append(former.Data.([]irc.Event), addition.Data.([]irc.Event)...)
2137	flows := make([]*mergedEvent, 0, len(events))
2138	flowNick := func(nick string) *mergedEvent {
2139		nickCf := strings.ToLower(nick)
2140		for _, f := range flows {
2141			if f.nickCf == nickCf {
2142				return f
2143			}
2144		}
2145		return nil
2146	}
2147
2148	for _, ev := range events {
2149		switch ev := ev.(type) {
2150		case irc.UserNickEvent:
2151			if f := flowNick(ev.User); f != nil {
2152				// Drop any existing flow on the target user, effectively replacing it
2153				// with this new nick. Not very "accurate", but handles disconnects/reconnects/alternate nicks
2154				// quietly enough.
2155				for i, ff := range flows {
2156					if f == ff {
2157						flows = append(flows[:i], flows[i+1:]...)
2158						break
2159					}
2160				}
2161			}
2162			f := flowNick(ev.FormerNick)
2163			if f != nil {
2164				f.nick = ev.User
2165				f.nickCf = strings.ToLower(ev.User)
2166			} else {
2167				flows = append(flows, &mergedEvent{
2168					oldNick: ev.FormerNick,
2169					nick:    ev.User,
2170					nickCf:  strings.ToLower(ev.User),
2171				})
2172			}
2173		case irc.UserJoinEvent:
2174			f := flowNick(ev.User)
2175			if f != nil {
2176				if f.firstConnected == 0 {
2177					f.firstConnected = 1
2178				}
2179				f.lastConnected = 1
2180				f.modeSet = ""
2181				f.modeUnset = ""
2182			} else {
2183				flows = append(flows, &mergedEvent{
2184					nick:           ev.User,
2185					nickCf:         strings.ToLower(ev.User),
2186					firstConnected: 1,
2187					lastConnected:  1,
2188				})
2189			}
2190		case irc.UserPartEvent:
2191			f := flowNick(ev.User)
2192			if f != nil {
2193				if f.firstConnected == 0 {
2194					f.firstConnected = -1
2195				}
2196				f.lastConnected = -1
2197				f.modeSet = ""
2198				f.modeUnset = ""
2199			} else {
2200				flows = append(flows, &mergedEvent{
2201					nick:           ev.User,
2202					nickCf:         strings.ToLower(ev.User),
2203					firstConnected: -1,
2204					lastConnected:  -1,
2205				})
2206			}
2207		case irc.UserQuitEvent:
2208			f := flowNick(ev.User)
2209			if f != nil {
2210				if f.firstConnected == 0 {
2211					f.firstConnected = -1
2212				}
2213				f.lastConnected = -1
2214				f.modeSet = ""
2215				f.modeUnset = ""
2216			} else {
2217				flows = append(flows, &mergedEvent{
2218					nick:           ev.User,
2219					nickCf:         strings.ToLower(ev.User),
2220					firstConnected: -1,
2221					lastConnected:  -1,
2222				})
2223			}
2224		case irc.ModeChangeEvent:
2225			// best-effort heuristic for guessing simple user mode changes:
2226			// expect "<+/-><chars> <args...>" with as many chars as args
2227
2228			mode := strings.Split(ev.Mode, " ")
2229			modeStr := mode[0]
2230			modeArgs := mode[1:]
2231			if len(modeStr) > 0 && (modeStr[0] == '+' || modeStr[0] == '-') && len(modeArgs) == len(modeStr)-1 {
2232				set := modeStr[0] == '+'
2233				for i, nick := range modeArgs {
2234					f := flowNick(nick)
2235					if f == nil {
2236						f = &mergedEvent{
2237							nick:   nick,
2238							nickCf: strings.ToLower(nick),
2239						}
2240						flows = append(flows, f)
2241					}
2242
2243					mode := string(modeStr[i+1])
2244					if set {
2245						if strings.Contains(f.modeUnset, mode) {
2246							f.modeUnset = strings.ReplaceAll(f.modeUnset, mode, "")
2247						} else if !strings.Contains(f.modeSet, mode) {
2248							f.modeSet += mode
2249						}
2250					} else {
2251						if strings.Contains(f.modeSet, mode) {
2252							f.modeSet = strings.ReplaceAll(f.modeSet, mode, "")
2253						} else if !strings.Contains(f.modeUnset, mode) {
2254							f.modeUnset += mode
2255						}
2256					}
2257				}
2258			} else {
2259				if f := flowNick(""); f != nil && f.channelMode == ev.Mode {
2260					// setting the same channel mode string, ignore
2261				} else {
2262					flows = append(flows, &mergedEvent{
2263						channelMode: ev.Mode,
2264					})
2265				}
2266			}
2267		}
2268	}
2269
2270	newBody := new(ui.StyledStringBuilder)
2271	newBody.Grow(128)
2272	first := true
2273	for _, f := range flows {
2274		l := app.formatEvent(f)
2275		if l.IsZero() {
2276			continue
2277		}
2278		if first {
2279			first = false
2280		} else {
2281			newBody.WriteString("  ")
2282		}
2283		newBody.WriteStyledString(l.Body)
2284	}
2285	former.Body = newBody.StyledString()
2286	former.Data = events
2287}
2288
2289// updatePrompt changes the prompt text according to the application context.
2290func (app *App) updatePrompt() {
2291	netID, buffer := app.win.CurrentBuffer()
2292	s := app.sessions[netID]
2293	command := isCommand(app.win.InputContent())
2294	var prompt ui.StyledString
2295	if buffer == "" || command {
2296		prompt = ui.Styled(">", vaxis.Style{
2297			Foreground: app.cfg.Colors.Prompt,
2298		})
2299	} else if app.formatting {
2300		prompt = ui.Styled("<fmt>", vaxis.Style{
2301			Attribute: vaxis.AttrBold,
2302		})
2303	} else if s == nil {
2304		prompt = ui.Styled("<offline>", vaxis.Style{
2305			Foreground: ui.ColorRed,
2306		})
2307	} else {
2308		prompt = app.win.IdentString(app.cfg.Colors.Nicks, s.Nick(), true)
2309	}
2310	app.win.SetPrompt(prompt)
2311}
2312
2313func (app *App) printTopic(netID, buffer string) (ok bool) {
2314	var body string
2315	s := app.sessions[netID]
2316	if s == nil {
2317		return false
2318	}
2319	topic, who, at := s.Topic(buffer)
2320	topic = ui.IRCString(topic).String()
2321	if who == nil {
2322		body = fmt.Sprintf("Topic: %s", topic)
2323	} else {
2324		body = fmt.Sprintf("Topic (set by %s on %s): %s", who.Name, at.Local().Format("January 2 2006 at 15:04:05"), topic)
2325	}
2326	app.win.AddLine(netID, buffer, ui.Line{
2327		At:   time.Now(),
2328		Head: ui.ColorString("--", app.cfg.Colors.Status),
2329		Body: ui.Styled(body, vaxis.Style{
2330			Foreground: app.cfg.Colors.Status,
2331		}),
2332	})
2333	return true
2334}
2335
2336func (app *App) addUserBuffer(netID, buffer string, t time.Time) (i int, added bool) {
2337	i, added = app.win.AddBuffer(netID, "", buffer)
2338	if !added {
2339		return
2340	}
2341	s := app.sessions[netID]
2342	if s == nil {
2343		return
2344	}
2345	i = app.win.SetMuted(netID, buffer, s.MutedGet(buffer))
2346	i = app.win.SetPinned(netID, buffer, s.PinnedGet(buffer))
2347	app.monitor[netID][buffer] = struct{}{}
2348	s.MonitorAdd(buffer)
2349	s.ReadGet(buffer)
2350	if !t.IsZero() {
2351		s.NewHistoryRequest(buffer).
2352			WithLimit(500).
2353			Before(t)
2354	} else {
2355		s.NewHistoryRequest(buffer).
2356			WithLimit(500).
2357			Latest()
2358	}
2359	return
2360}
2361
2362func keyNameMatch(name string) *keyMatch {
2363	parts := strings.Split(name, "+")
2364	mods := parts[:len(parts)-1]
2365	key := parts[len(parts)-1]
2366
2367	var m vaxis.ModifierMask
2368	for _, mod := range mods {
2369		switch mod {
2370		case "Control":
2371			m |= vaxis.ModCtrl
2372		case "Shift":
2373			m |= vaxis.ModShift
2374		case "Alt":
2375			m |= vaxis.ModAlt
2376		case "Super":
2377			m |= vaxis.ModSuper
2378		default:
2379			return nil
2380		}
2381	}
2382	if r, n := utf8.DecodeRuneInString(key); n == len(key) {
2383		return &keyMatch{
2384			keycode: r,
2385			mods:    m,
2386		}
2387	}
2388	if r := ui.KeyNames[key]; r > 0 {
2389		return &keyMatch{
2390			keycode: r,
2391			mods:    m,
2392		}
2393	}
2394	return nil
2395}
2396
2397func keyMatches(k vaxis.Key) []keyMatch {
2398	m := k.Modifiers
2399	m &^= vaxis.ModCapsLock
2400	m &^= vaxis.ModNumLock
2401
2402	keys := []keyMatch{
2403		{
2404			keycode: k.Keycode,
2405			mods:    m,
2406		},
2407	}
2408	if m&vaxis.ModShift != 0 && k.ShiftedCode != 0 {
2409		// ctrl+. and user pressed ctrl+shift+; on a French keyboard
2410		m &^= vaxis.ModShift
2411		keys = append(keys, keyMatch{
2412			keycode: k.ShiftedCode,
2413			mods:    m &^ vaxis.ModShift,
2414		})
2415	}
2416	return keys
2417}
2418
2419func formatSize(v int64) string {
2420	suffixes := []string{"B", "kB", "MB", "GB"}
2421	for i, suffix := range suffixes {
2422		if v < 1024 || i == len(suffixes)-1 {
2423			return fmt.Sprintf("%v%v", v, suffix)
2424		}
2425		v /= 1024
2426	}
2427	panic("unreachable")
2428}
2429
2430func dropBackslash(s string) string {
2431	// Naive implementation, just enough to unescape file paths escaped by some terminals.
2432	var sb strings.Builder
2433	esc := false
2434	for _, r := range s {
2435		if !esc && r == '\\' {
2436			esc = true
2437			continue
2438		}
2439		sb.WriteRune(r)
2440		esc = false
2441	}
2442	return sb.String()
2443}
2444
2445// version is set via -ldflags "-X git.sr.ht/~delthas/senpai.version=...".
2446var version string
2447
2448func BuildVersion() (string, bool) {
2449	if version != "" {
2450		return version, true
2451	}
2452	if bi, ok := debug.ReadBuildInfo(); ok && bi.Main.Version != "" && bi.Main.Version != "(devel)" {
2453		return bi.Main.Version, true
2454	}
2455	return "", false
2456}
2457
2458type ReadProgress struct {
2459	io.Reader
2460	period time.Duration
2461	f      func(int64)
2462
2463	n    int64
2464	last time.Time
2465}
2466
2467func (r *ReadProgress) Read(buf []byte) (int, error) {
2468	n, err := r.Reader.Read(buf)
2469	r.n += int64(n)
2470	now := time.Now()
2471	if now.Sub(r.last) > r.period {
2472		r.last = now
2473		r.f(r.n)
2474	}
2475	return n, err
2476}