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