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}