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
+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
M
ui/ui.go
+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