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