commit 454d290

sewn  ·  2026-08-09 00:33:41 +0000 UTC
parent f67d09c
Mark selected message as replying/reacting to
7 files changed,  +144, -12
M app.go
M app.go
+20, -0
 1@@ -750,6 +750,16 @@ func (app *App) handleMouseEvent(ev vaxis.Mouse) {
 2 			}
 3 		}
 4 		if ev.Button == vaxis.MouseRightButton {
 5+			app.win.SelectMessageAt(y)
 6+			s := app.CurrentSession()
 7+			if line, flag := app.win.SelectedMessage(); line != nil && s != nil {
 8+				if flag == ui.MessageReply && s.CanReact() {
 9+					flag = ui.MessageReact
10+				} else if s.CanReply() {
11+					flag = ui.MessageReply
12+				}
13+				app.win.FlagSelectedMessage(flag)
14+			}
15 			app.win.Click(x, y, ev)
16 		}
17 	}
18@@ -913,6 +923,14 @@ func (app *App) handleAction(action string, args ...string) {
19 		app.win.SelectMessagePrevious()
20 	case "message-select-next":
21 		app.win.SelectMessageNext()
22+	case "message-react":
23+		if s := app.CurrentSession(); s != nil && s.CanReact() {
24+			app.win.FlagSelectedMessage(ui.MessageReact)
25+		}
26+	case "message-reply":
27+		if s := app.CurrentSession(); s != nil && s.CanReply() {
28+			app.win.FlagSelectedMessage(ui.MessageReply)
29+		}
30 	case "message-copy":
31 		app.win.CopySelectedMessage()
32 	case "toggle-topic":
33@@ -995,6 +1013,8 @@ var defaultCommands = map[string][]string{
34 	"Control+Up":      {"message-select-previous"},
35 	"Control+Down":    {"message-select-next"},
36 	"Control+y":       {"message-copy"},
37+	"Alt+r":           {"message-reply"},
38+	"Alt+e":           {"message-react"},
39 	"Alt+Right":       {"buffer-next"},
40 	"Shift+Right":     {"buffer-next-unread"},
41 	"Control+Right":   {"cursor-right-word"},
+19, -1
 1@@ -362,7 +362,24 @@ func noCommand(app *App, content string) error {
 2 		return errOffline
 3 	}
 4 
 5-	s.PrivMsg(buffer, content)
 6+	line, flag := app.win.SelectedMessage()
 7+
 8+	if line != nil && flag == ui.MessageReact {
 9+		s.React(buffer, line.ID, content)
10+		app.win.ClearMessageSelection()
11+		if !s.HasCapability("echo-message") {
12+			app.win.ApplyReact(netID, buffer, line.ID, s.Nick(), content, false)
13+		}
14+		return nil
15+	}
16+
17+	replyTo := ""
18+	if line != nil && flag == ui.MessageReply {
19+		replyTo = line.ID
20+		app.win.ClearMessageSelection()
21+	}
22+
23+	s.PrivMsgReply(buffer, content, replyTo)
24 	if !s.HasCapability("echo-message") {
25 		buffer, line := app.formatMessage(s, irc.MessageEvent{
26 			User:            s.Nick(),
27@@ -371,6 +388,7 @@ func noCommand(app *App, content string) error {
28 			Command:         "PRIVMSG",
29 			Content:         content,
30 			Time:            time.Now(),
31+			ReplyTo:         replyTo,
32 		})
33 		app.win.AddLine(netID, buffer, line)
34 	}
+3, -0
 1@@ -118,6 +118,9 @@ These shortcuts can be customized in the configuration, see senpai(5).
 2 *CTRL-D*, *PgDown*
 3 	Go down in the timeline.
 4 
 5+*ALT-R*, *ALT-E*
 6+	Mark the current message as reply/reacting to, respectively.
 7+
 8 *CTRL-N*, *ALT-RIGHT*
 9 	Go to the next buffer.
10 
+4, -0
 1@@ -266,6 +266,10 @@ shortcuts {
 2 :  select the previous message in the timeline
 3 |  message-select-next
 4 :  select the next message in the timeline
 5+|  message-react
 6+:  mark the selected message as reacting to using the editor
 7+|  message-reply
 8+:  copy the selected message as replying to using the editor
 9 |  message-copy
10 :  copy the selected message to the system clipboard
11 |  toggle-channel-list
+26, -1
 1@@ -257,6 +257,14 @@ func (s *Session) CanSendTag(clientTagWithoutThePlus string) bool {
 2 	return ok == s.clientTagListIsAllow
 3 }
 4 
 5+func (s *Session) CanReply() bool {
 6+	return s.HasCapability("message-tags") && s.CanSendTag("draft/reply")
 7+}
 8+
 9+func (s *Session) CanReact() bool {
10+	return s.CanReply() && s.CanSendTag("draft/react")
11+}
12+
13 func (s *Session) IsBouncer() bool {
14 	if s.HasCapability("soju.im/bouncer-networks") { // soju-compatible
15 		return true
16@@ -528,6 +536,10 @@ func splitChunks(s string, chunkLen int) (chunks []string) {
17 }
18 
19 func (s *Session) PrivMsg(target, content string) {
20+	s.PrivMsgReply(target, content, "")
21+}
22+
23+func (s *Session) PrivMsgReply(target, content, replyTo string) {
24 	hostLen := len(s.host)
25 	if hostLen == 0 {
26 		hostLen = len("255.255.255.255")
27@@ -540,12 +552,25 @@ func (s *Session) PrivMsg(target, content string) {
28 		len(target)
29 	chunks := splitChunks(content, maxMessageLen)
30 	for _, chunk := range chunks {
31-		s.out <- NewMessage("PRIVMSG", target, chunk)
32+		msg := NewMessage("PRIVMSG", target, chunk)
33+		if replyTo != "" && s.CanSendTag("draft/reply") {
34+			msg = msg.WithTag("+draft/reply", replyTo)
35+		}
36+		s.out <- msg
37 	}
38 	targetCf := s.Casemap(target)
39 	delete(s.typingStamps, targetCf)
40 }
41 
42+func (s *Session) React(target, msgid, react string) {
43+	if !s.HasCapability("message-tags") || !s.CanSendTag("draft/react") {
44+		return
45+	}
46+	s.out <- NewMessage("TAGMSG", target).
47+		WithTag("+draft/reply", msgid).
48+		WithTag("+draft/react", react)
49+}
50+
51 func (s *Session) Typing(target string) {
52 	if !s.HasCapability("message-tags") || !s.CanSendTag("typing") {
53 		return
+33, -6
 1@@ -33,6 +33,15 @@ const (
 2 	NotifyHighlight
 3 )
 4 
 5+type MessageSelection int
 6+
 7+const (
 8+	MessageNone MessageSelection = iota
 9+	MessageSelected
10+	MessageReply
11+	MessageReact
12+)
13+
14 type optional int
15 
16 const (
17@@ -254,7 +263,8 @@ type buffer struct {
18 	topicOffset int // offset in clusters that are skipped when rendering topic text
19 	isAtTop     bool
20 
21-	selected int
22+	selected     int
23+	selectedFlag MessageSelection
24 }
25 
26 type BufferList struct {
27@@ -561,6 +571,7 @@ func (bs *BufferList) AddLine(netID, title string, line Line) {
28 
29 	if b.selected >= len(b.lines) {
30 		b.selected = -1
31+		b.selectedFlag = MessageNone
32 	}
33 
34 	if line.Notify != NotifyNone && (!bs.focused || b != current) {
35@@ -733,6 +744,7 @@ func (bs *BufferList) AddLines(netID, title string, before, after []Line) {
36 	b.lines = lines
37 	if b.selected >= len(b.lines) {
38 		b.selected = -1
39+		b.selectedFlag = MessageNone
40 	}
41 	if b == bs.cur() && b.unreadSkip == optionalUnset && len(b.lines) > 0 {
42 		if b.unreadRuler.IsZero() || !b.lines[len(b.lines)-1].At.After(b.unreadRuler) {
43@@ -936,6 +948,7 @@ func (bs *BufferList) SelectNext() {
44 	b.selected++
45 	if b.selected >= len(b.lines) {
46 		b.selected = -1
47+		b.selectedFlag = MessageNone
48 	}
49 	bs.scrollToSelected()
50 }
51@@ -961,15 +974,25 @@ func (bs *BufferList) scrollToSelected() {
52 }
53 
54 func (bs *BufferList) ClearSelection() {
55-	bs.cur().selected = -1
56+	b := bs.cur()
57+	b.selected = -1
58+	b.selectedFlag = MessageNone
59 }
60 
61-func (bs *BufferList) Selected() *Line {
62+func (bs *BufferList) FlagSelected(flag MessageSelection) {
63 	b := bs.cur()
64 	if b.selected < 0 || b.selected >= len(b.lines) {
65-		return nil
66+		return
67 	}
68-	return &b.lines[b.selected]
69+	b.selectedFlag = flag
70+}
71+
72+func (bs *BufferList) Selected() (*Line, MessageSelection) {
73+	b := bs.cur()
74+	if b.selected < 0 || b.selected >= len(b.lines) {
75+		return nil, MessageNone
76+	}
77+	return &b.lines[b.selected], b.selectedFlag
78 }
79 
80 func (bs *BufferList) SelectAt(y0, screenY int) {
81@@ -988,7 +1011,11 @@ func (bs *BufferList) SelectAt(y0, screenY int) {
82 		return false
83 	})
84 	if idx >= 0 {
85-		bs.cur().selected = idx
86+		if b.selected == idx {
87+			return
88+		}
89+		b.selected = idx
90+		b.selectedFlag = MessageSelected
91 	}
92 }
93 
+39, -4
 1@@ -350,12 +350,16 @@ func (ui *UI) SelectMessageAt(y int) {
 2 	ui.bs.SelectAt(0, y)
 3 }
 4 
 5-func (ui *UI) SelectedMessage() *Line {
 6+func (ui *UI) SelectedMessage() (*Line, MessageSelection) {
 7 	return ui.bs.Selected()
 8 }
 9 
10+func (ui *UI) FlagSelectedMessage(flag MessageSelection) {
11+	ui.bs.FlagSelected(flag)
12+}
13+
14 func (ui *UI) CopySelectedMessage() {
15-	if line := ui.bs.Selected(); line != nil {
16+	if line, _ := ui.bs.Selected(); line != nil {
17 		ui.vx.ClipboardPush(line.Body.string)
18 	}
19 }
20@@ -870,17 +874,48 @@ func (ui *UI) drawVerticalLine(vx *Vaxis, x, y0, height int) {
21 func (ui *UI) drawStatusBar(x0, y, width int) {
22 	clearArea(ui.vx, x0, y, width, 1)
23 
24+	var s StyledStringBuilder
25+	x := x0 + 5 + ui.config.NickColWidth
26+
27+	if line, flag := ui.SelectedMessage(); flag > MessageSelected {
28+		x += 4
29+		var sb StyledStringBuilder
30+		sb.SetStyle(vaxis.Style{Foreground: ui.config.Colors.Gray})
31+
32+		switch flag {
33+		case MessageReact:
34+			sb.WriteString("reacting to ")
35+		case MessageReply:
36+			sb.WriteString("replying to ")
37+		}
38+
39+		sb.WriteStyledString(line.Head)
40+		sb.WriteStyledString(ColorString(": ", ui.config.Colors.Gray))
41+		printString(ui.vx, &x, y, sb.StyledString())
42+
43+		sb.Reset()
44+		snippet := strings.Map(func(r rune) rune {
45+			if r == '\n' || r == '\r' {
46+				return ' '
47+			}
48+			return r
49+		}, line.Body.string)
50+		snippet = truncate(ui.vx, snippet, width-(x-x0), "…")
51+		sb.WriteStyledString(ColorString(snippet, ui.config.Colors.Gray))
52+
53+		printString(ui.vx, &x, y, sb.StyledString())
54+		return
55+	}
56+
57 	if ui.status == "" {
58 		return
59 	}
60 
61-	var s StyledStringBuilder
62 	s.SetStyle(vaxis.Style{
63 		Foreground: ui.config.Colors.Gray,
64 	})
65 	s.WriteString("--")
66 
67-	x := x0 + 5 + ui.config.NickColWidth
68 	printString(ui.vx, &x, y, s.StyledString())
69 	x += 2
70