commit f5c599e
delthas
·
2024-09-02 08:47:10 +0000 UTC
parent f61411c
Enable pinning and muting buffers This is done with metadata-2 and stored server side. This requires soju > 0.8.2 (so, master at the time of writing). Fixes: https://todo.sr.ht/~delthas/senpai/84
9 files changed,
+398,
-43
M
app.go
+56,
-18
1@@ -578,6 +578,7 @@ func (app *App) handleUIEvent(ev interface{}) bool {
2 }
3
4 func (app *App) handleMouseEvent(ev vaxis.Mouse) {
5+ const memberItems = 3
6 x, y := ev.Col, ev.Row
7 w, h := app.win.Size()
8
9@@ -601,7 +602,7 @@ func (app *App) handleMouseEvent(ev vaxis.Mouse) {
10 if ev.Button == vaxis.MouseWheelUp {
11 if x < app.win.ChannelWidth() || (app.win.ChannelWidth() == 0 && y == h-1) {
12 app.win.ScrollChannelUpBy(4)
13- } else if x > w-app.win.MemberWidth() {
14+ } else if x > w-app.win.MemberWidth() && y < h-memberItems*2 {
15 app.win.ScrollMemberUpBy(4)
16 } else {
17 app.win.ScrollUpBy(4)
18@@ -610,7 +611,7 @@ func (app *App) handleMouseEvent(ev vaxis.Mouse) {
19 if ev.Button == vaxis.MouseWheelDown {
20 if x < app.win.ChannelWidth() || (app.win.ChannelWidth() == 0 && y == h-1) {
21 app.win.ScrollChannelDownBy(4)
22- } else if x > w-app.win.MemberWidth() {
23+ } else if x > w-app.win.MemberWidth() && y < h-memberItems*2 {
24 app.win.ScrollMemberDownBy(4)
25 } else {
26 app.win.ScrollDownBy(4)
27@@ -625,8 +626,43 @@ func (app *App) handleMouseEvent(ev vaxis.Mouse) {
28 app.win.ClickBuffer(app.win.HorizontalBufferOffset(x))
29 } else if x == w-app.win.MemberWidth() {
30 app.win.ClickMemberCol(true)
31- } else if x > w-app.win.MemberWidth() && y >= 2 {
32+ } else if x > w-app.win.MemberWidth() && y >= 2 && y < h-memberItems*2 {
33 app.win.ClickMember(y - 2 + app.win.MemberOffset())
34+ } else if x > w-app.win.MemberWidth() && y >= h-memberItems*2 && (y-(h-memberItems*2))%2 == 1 {
35+ netID, target := app.win.CurrentBuffer()
36+ var failed bool
37+ switch (y - (h - memberItems*2)) / 2 {
38+ case 0:
39+ muted := app.win.GetMuted(netID, target)
40+ if s := app.sessions[netID]; s != nil && target != "" {
41+ if !s.MutedSet(target, !muted) {
42+ failed = true
43+ }
44+ }
45+ case 1:
46+ pinned := app.win.GetPinned(netID, target)
47+ if s := app.sessions[netID]; s != nil && target != "" {
48+ if !s.PinnedSet(target, !pinned) {
49+ failed = true
50+ }
51+ }
52+ case 2:
53+ s := app.sessions[netID]
54+ if s != nil && s.IsChannel(target) {
55+ s.Part(target, "")
56+ } else {
57+ app.win.RemoveBuffer(netID, target)
58+ }
59+ }
60+ if failed {
61+ netID, buffer := app.win.CurrentBuffer()
62+ app.win.AddLine(netID, buffer, ui.Line{
63+ At: time.Now(),
64+ Head: "!!",
65+ HeadColor: ui.ColorRed,
66+ Body: ui.PlainString(errNotSupported.Error()),
67+ })
68+ }
69 } else {
70 app.win.Click(x, y, ev)
71 }
72@@ -663,7 +699,7 @@ func (app *App) handleMouseEvent(ev vaxis.Mouse) {
73 app.win.GoToBufferNo(i)
74 app.clearBufferCommand()
75 }
76- } else if x > w-app.win.MemberWidth() {
77+ } else if x > w-app.win.MemberWidth() && y < h-memberItems*2 {
78 if i := y - 2 + app.win.MemberOffset(); i >= 0 && i == app.win.ClickedMember() {
79 netID, target := app.win.CurrentBuffer()
80 if target == "" {
81@@ -704,19 +740,16 @@ func (app *App) handleMouseEvent(ev vaxis.Mouse) {
82 })
83 app.win.InputSet("/query ")
84 }
85- } else {
86- s := app.sessions[netID]
87- if s != nil && target != "" {
88- members := s.Names(target)
89- if i < len(members) {
90- buffer := members[i].Name.Name
91- i, added := app.win.AddBuffer(netID, "", buffer)
92- app.win.JumpBufferIndex(i)
93- if added {
94- s.MonitorAdd(buffer)
95- s.ReadGet(buffer)
96- s.NewHistoryRequest(buffer).WithLimit(500).Latest()
97- }
98+ } else if s := app.sessions[netID]; s != nil {
99+ members := s.Names(target)
100+ if i < len(members) {
101+ buffer := members[i].Name.Name
102+ i, added := app.win.AddBuffer(netID, "", buffer)
103+ app.win.JumpBufferIndex(i)
104+ if added {
105+ s.MonitorAdd(buffer)
106+ s.ReadGet(buffer)
107+ s.NewHistoryRequest(buffer).WithLimit(500).Latest()
108 }
109 }
110 }
111@@ -729,7 +762,7 @@ func (app *App) handleMouseEvent(ev vaxis.Mouse) {
112 }
113 if x == app.win.ChannelWidth()-1 || x == w-app.win.MemberWidth() {
114 app.win.SetMouseShape(vaxis.MouseShapeResizeHorizontal)
115- } else if app.win.HasEvent(x, y) {
116+ } else if x < app.win.ChannelWidth()-1 || x > w-app.win.MemberWidth() || app.win.HasEvent(x, y) {
117 app.win.SetMouseShape(vaxis.MouseShapeClickable)
118 } else {
119 app.win.SetMouseShape(vaxis.MouseShapeDefault)
120@@ -1334,6 +1367,8 @@ func (app *App) handleIRCEvent(netID string, ev interface{}) {
121 topic := ui.IRCString(ev.Topic).ParseURLs()
122 app.win.SetTopic(netID, ev.Channel, topic)
123 }
124+ app.win.SetPinned(netID, ev.Channel, ev.Pinned)
125+ app.win.SetMuted(netID, ev.Channel, ev.Muted)
126
127 // Restore last buffer
128 if netID == app.lastNetID && ev.Channel == app.lastBuffer {
129@@ -1529,6 +1564,9 @@ func (app *App) handleIRCEvent(netID string, ev interface{}) {
130 app.win.AddLines("", ui.Overlay, lines, nil)
131 case irc.ReadEvent:
132 app.win.SetRead(netID, ev.Target, ev.Timestamp)
133+ case irc.MetadataChangeEvent:
134+ app.win.SetPinned(netID, ev.Target, ev.Pinned)
135+ app.win.SetMuted(netID, ev.Target, ev.Muted)
136 case irc.BouncerNetworkEvent:
137 if !ev.Delete {
138 _, added := app.win.AddBuffer(ev.ID, ev.Name, "")
+66,
-1
1@@ -19,7 +19,8 @@ import (
2 )
3
4 var (
5- errOffline = fmt.Errorf("you are disconnected from the server, retry later")
6+ errOffline = fmt.Errorf("you are disconnected from the server, retry later")
7+ errNotSupported = fmt.Errorf("command not supported by the server; try using latest soju")
8 )
9
10 const maxArgsInfinite = -1
11@@ -170,6 +171,22 @@ func init() {
12 Desc: "show or set the topic of the current channel",
13 Handle: commandDoTopic,
14 },
15+ "MUTE": {
16+ Desc: "mute the current channel (preventing color on new non-highlight messages)",
17+ Handle: commandDoMute,
18+ },
19+ "UNMUTE": {
20+ Desc: "unmute the current channel",
21+ Handle: commandDoUnmute,
22+ },
23+ "PIN": {
24+ Desc: "pin the current channel (moving it to the top of the channel list)",
25+ Handle: commandDoPin,
26+ },
27+ "UNPIN": {
28+ Desc: "unpin the current channel",
29+ Handle: commandDoUnpin,
30+ },
31 "BUFFER": {
32 AllowHome: true,
33 MinArgs: 1,
34@@ -744,6 +761,54 @@ func commandDoTopic(app *App, args []string) (err error) {
35 return nil
36 }
37
38+func commandDoMute(app *App, args []string) (err error) {
39+ netID, buffer := app.win.CurrentBuffer()
40+ s := app.sessions[netID]
41+ if s == nil {
42+ return errOffline
43+ }
44+ if !s.MutedSet(buffer, true) {
45+ return errNotSupported
46+ }
47+ return nil
48+}
49+
50+func commandDoUnmute(app *App, args []string) (err error) {
51+ netID, buffer := app.win.CurrentBuffer()
52+ s := app.sessions[netID]
53+ if s == nil {
54+ return errOffline
55+ }
56+ if !s.MutedSet(buffer, false) {
57+ return errNotSupported
58+ }
59+ return nil
60+}
61+
62+func commandDoPin(app *App, args []string) (err error) {
63+ netID, buffer := app.win.CurrentBuffer()
64+ s := app.sessions[netID]
65+ if s == nil {
66+ return errOffline
67+ }
68+ if !s.PinnedSet(buffer, true) {
69+ return errNotSupported
70+ }
71+ return nil
72+}
73+
74+func commandDoUnpin(app *App, args []string) (err error) {
75+ netID, buffer := app.win.CurrentBuffer()
76+ s := app.sessions[netID]
77+ if s == nil {
78+ return errOffline
79+ }
80+ if !s.PinnedSet(buffer, false) {
81+ return errNotSupported
82+ }
83+ return nil
84+}
85+
86 func commandDoWhois(app *App, args []string) (err error) {
87 netID, channel := app.win.CurrentBuffer()
88 s := app.sessions[netID]
+18,
-0
1@@ -189,6 +189,24 @@ _name_ is matched case-insensitively. It can be one of the following:
2
3 Otherwise, change the topic of the current channel to _topic_.
4
5+*MUTE*
6+ Mute the current channel. This prevents being distracted from new messages
7+ on unimportant channels.
8+
9+ This moves the channel to the end of the server, and greys its name out so
10+ that new messages do not change its color in the list. Highlights will still
11+ send a notification.
12+
13+*UNMUTE*
14+ Unmute the current channel. See *MUTE*.
15+
16+*PIN*
17+ Pin the current channel. This moves the channel to the start of the buffer
18+ list.
19+
20+*UNPIN*
21+ Unpin the current channel. See *PIN*.
22+
23 *MSG* <target> <content>
24 Send _content_ to _target_.
25
+3,
-0
1@@ -57,6 +57,9 @@ Some settings are required, the others are optional.
2 at startup and server reconnect. This directive can be specified multiple
3 times.
4
5+ This directive should not be used when using a bouncer, as the bouncer
6+ already remembers and joins senpai to its saved channels automatically.
7+
8 *highlight*
9 A space separated list of keywords that will trigger a notification and a
10 display indicator when said by others. This directive can be specified
+8,
-0
1@@ -32,6 +32,8 @@ type SelfJoinEvent struct {
2 Requested bool // whether we recently requested to join that channel
3 Topic string
4 Read time.Time
5+ Pinned bool
6+ Muted bool
7 }
8
9 type UserJoinEvent struct {
10@@ -118,6 +120,12 @@ type SearchEvent struct {
11 Messages []MessageEvent
12 }
13
14+type MetadataChangeEvent struct {
15+ Target string
16+ Pinned bool
17+ Muted bool
18+}
19+
20 type BouncerNetworkEvent struct {
21 ID string
22 Name string
+8,
-0
1@@ -123,6 +123,14 @@ const (
2 rplEndofmonlist = "733" // <nick> :End of MONITOR list
3 errMonlistisfull = "734" // <nick> <limit> <targets> :Monitor list is full.
4
5+ rplWhoiskeyvalue = "760" // <Target> <Key> <Visibility> :<Value>
6+ rplKeyvalue = "761" // <Target> <Key> <Visibility> :<Value>
7+ rplKeynotset = "766" // <Target> <Key> :key not set
8+ rplMetadatasubok = "770" // <Key1> [<Key2> ...]
9+ rplMetadataunsubok = "771" // <Key1> [<Key2> ...]
10+ rplMetadatasubs = "772" // <Key1> [<Key2> ...]
11+ rplMetadatasynclater = "774" // <Target> [<RetryAfter>]
12+
13 rplLoggedin = "900" // <nick> <nick>!<ident>@<host> <account> :You are now logged in as <user>
14 rplLoggedout = "901" // <nick> <nick>!<ident>@<host> :You are now logged out
15 errNicklocked = "902" // :You must use a nick assigned to you
+82,
-0
1@@ -67,6 +67,7 @@ var SupportedCapabilities = map[string]struct{}{
2
3 "draft/chathistory": {},
4 "draft/event-playback": {},
5+ "draft/metadata-2": {},
6 "draft/read-marker": {},
7 "soju.im/bouncer-networks-notify": {},
8 "soju.im/bouncer-networks": {},
9@@ -97,6 +98,8 @@ type Channel struct {
10 TopicWho *Prefix // the name of the last user who set the topic.
11 TopicTime time.Time // the last time the topic has been changed.
12 Read time.Time // the time until which messages were read.
13+ Pinned bool
14+ Muted bool
15
16 complete bool // whether this structure is fully initialized.
17 }
18@@ -128,6 +131,7 @@ type Session struct {
19
20 availableCaps map[string]string
21 enabledCaps map[string]struct{}
22+ metadataSubs map[string]struct{}
23
24 serverName string
25 defaultPrefix *Prefix
26@@ -174,6 +178,7 @@ func NewSession(out chan<- Message, params SessionParams) *Session {
27 auth: params.Auth,
28 availableCaps: map[string]string{},
29 enabledCaps: map[string]struct{}{},
30+ metadataSubs: map[string]struct{}{},
31 casemap: CasemapRFC1459,
32 chantypes: "#&",
33 linelen: 512,
34@@ -554,6 +559,34 @@ func (s *Session) ReadSet(target string, timestamp time.Time) {
35 }
36 }
37
38+func (s *Session) MutedSet(target string, muted bool) (ok bool) {
39+ var v string
40+ if muted {
41+ v = "1"
42+ } else {
43+ v = "0"
44+ }
45+ k := "soju.im/muted"
46+ if _, ok = s.metadataSubs[k]; ok {
47+ s.out <- NewMessage("METADATA", target, "SET", k, v)
48+ }
49+ return
50+}
51+
52+func (s *Session) PinnedSet(target string, pinned bool) (ok bool) {
53+ var v string
54+ if pinned {
55+ v = "1"
56+ } else {
57+ v = "0"
58+ }
59+ k := "soju.im/pinned"
60+ if _, ok = s.metadataSubs[k]; ok {
61+ s.out <- NewMessage("METADATA", target, "SET", "soju.im/pinned", v)
62+ }
63+ return
64+}
65+
66 func (s *Session) MonitorAdd(target string) {
67 targetCf := s.casemap(target)
68 if _, ok := s.monitors[targetCf]; !ok {
69@@ -1123,6 +1156,8 @@ func (s *Session) handleMessageRegistered(msg Message, playback bool) (Event, er
70 Channel: c.Name,
71 Topic: c.Topic,
72 Read: c.Read,
73+ Pinned: c.Pinned,
74+ Muted: c.Muted,
75 }
76 if stamp, ok := s.pendingChannels[channelCf]; ok && time.Since(stamp) < 5*time.Second {
77 ev.Requested = true
78@@ -1441,6 +1476,34 @@ func (s *Session) handleMessageRegistered(msg Message, playback bool) (Event, er
79 Target: target,
80 Timestamp: t,
81 }, nil
82+ case "METADATA":
83+ // METADATA <Target> <Key> <Visibility> <Value>
84+ if len(msg.Params) < 4 {
85+ break
86+ }
87+ var target, key, value string
88+ if err := msg.ParseParams(&target, &key, nil, &value); err != nil {
89+ return nil, err
90+ }
91+ channelCf := s.Casemap(target)
92+ c, ok := s.channels[channelCf]
93+ if !ok {
94+ return nil, nil
95+ }
96+ switch key {
97+ case "soju.im/pinned":
98+ c.Pinned = value == "1"
99+ case "soju.im/muted":
100+ c.Muted = value == "1"
101+ }
102+ s.channels[channelCf] = c
103+ if c.complete {
104+ return MetadataChangeEvent{
105+ Target: target,
106+ Pinned: c.Pinned,
107+ Muted: c.Muted,
108+ }, nil
109+ }
110 case "BOUNCER":
111 if len(msg.Params) < 3 {
112 break
113@@ -1475,6 +1538,11 @@ func (s *Session) handleMessageRegistered(msg Message, playback bool) (Event, er
114 return nil, err
115 }
116
117+ switch code {
118+ case "KEY_INVALID": // METADATA SUB failed: ignore
119+ return nil, nil
120+ }
121+
122 switch msg.Command {
123 case "FAIL":
124 severity = SeverityFail
125@@ -1521,6 +1589,8 @@ func (s *Session) handleMessageRegistered(msg Message, playback bool) (Event, er
126 // useless info delimiter
127 case rplEndofhelp:
128 // useless help delimiter
129+ case rplWhoiskeyvalue, rplKeyvalue, rplKeynotset, rplMetadataunsubok, rplMetadatasubs, rplMetadatasynclater:
130+ // useless metadata replies
131 case rplStatscommands:
132 var command, count string
133 if err := msg.ParseParams(nil, &command, &count); err != nil {
134@@ -1939,6 +2009,14 @@ func (s *Session) handleMessageRegistered(msg Message, playback bool) (Event, er
135 Prefix: "User",
136 Message: fmt.Sprintf("%s %s", nick, text),
137 }, nil
138+ case rplMetadatasubok:
139+ if err := msg.ParseParams(nil); err != nil {
140+ return nil, err
141+ }
142+ for _, key := range msg.Params[1:] {
143+ s.metadataSubs[key] = struct{}{}
144+ }
145+ return nil, nil
146 case rplHelpstart, rplHelptxt:
147 var text string
148 if err := msg.ParseParams(nil, nil, &text); err != nil {
149@@ -2109,6 +2187,10 @@ func (s *Session) endRegistration() {
150 if s.registered {
151 return
152 }
153+ if len(s.enabledCaps) == 0 || s.HasCapability("draft/metadata-2") {
154+ // Best effort to avoid a round trip: subscribe to metadata if explicitly supported or if CAPs are not yet known
155+ s.out <- NewMessage("METADATA", "*", "SUB", "soju.im/pinned", "soju.im/muted")
156+ }
157 if s.netID != "" {
158 s.out <- NewMessage("BOUNCER", "BIND", s.netID)
159 s.out <- NewMessage("CAP", "END")
+110,
-15
1@@ -3,6 +3,7 @@ package ui
2 import (
3 "fmt"
4 "math"
5+ "sort"
6 "strings"
7 "time"
8
9@@ -204,6 +205,9 @@ type buffer struct {
10 read time.Time
11 openedOnce bool
12
13+ pinned bool
14+ muted bool
15+
16 // This is the "last read" timestamp when the buffer was last focused.
17 // If the "last read" timestamp changes while the buffer is focused,
18 // the ruler should not move.
19@@ -319,7 +323,7 @@ func (bs *BufferList) Previous() {
20 func (bs *BufferList) NextUnread() {
21 for i := 0; i < len(bs.list); i++ {
22 c := (bs.current + i) % len(bs.list)
23- if bs.list[c].unread {
24+ if bs.list[c].unread && !bs.list[c].muted {
25 bs.To(c)
26 return
27 }
28@@ -329,7 +333,7 @@ func (bs *BufferList) NextUnread() {
29 func (bs *BufferList) PreviousUnread() {
30 for i := 0; i < len(bs.list); i++ {
31 c := (bs.current - i + len(bs.list)) % len(bs.list)
32- if bs.list[c].unread {
33+ if bs.list[c].unread && !bs.list[c].muted {
34 bs.To(c)
35 return
36 }
37@@ -337,17 +341,26 @@ func (bs *BufferList) PreviousUnread() {
38 }
39
40 func (bs *BufferList) Add(netID, netName, title string) (i int, added bool) {
41- i = 0
42- lTitle := strings.ToLower(title)
43- for bi, b := range bs.list {
44+ for _, b := range bs.list {
45 if netName == "" && b.netID == netID {
46 netName = b.netName
47+ break
48+ }
49+ }
50+ if netName != "" {
51+ if i, b := bs.at(netID, title); b != nil {
52+ return i, false
53 }
54- if netName == "" || b.netName < netName {
55+ }
56+
57+ i = 0
58+ lTitle := strings.ToLower(title)
59+ for bi, b := range bs.list {
60+ if b.pinned || b.netName < netName {
61 i = bi + 1
62 continue
63 }
64- if b.netName > netName {
65+ if b.muted || b.netName > netName {
66 break
67 }
68 lbTitle := strings.ToLower(b.title)
69@@ -355,9 +368,6 @@ func (bs *BufferList) Add(netID, netName, title string) (i int, added bool) {
70 i = bi + 1
71 continue
72 }
73- if lbTitle == lTitle {
74- return i, false
75- }
76 break
77 }
78
79@@ -429,6 +439,48 @@ func (bs *BufferList) RemoveNetwork(netID string) {
80 }
81 }
82
83+func (bs *BufferList) reorder() {
84+ netID, title := bs.Current()
85+ sort.Slice(bs.list, func(i, j int) bool {
86+ bi := &bs.list[i]
87+ bj := &bs.list[j]
88+ if bi.netID == "" && bj.netID != "" {
89+ return true
90+ }
91+ if bi.netID != "" && bj.netID == "" {
92+ return false
93+ }
94+ if bi.pinned && !bj.pinned {
95+ return true
96+ }
97+ if !bi.pinned && bj.pinned {
98+ return false
99+ }
100+ if c := strings.Compare(bi.netName, bj.netName); c != 0 {
101+ return c == -1
102+ }
103+ if bi.title == "" && bj.title != "" {
104+ return true
105+ }
106+ if bi.title != "" && bj.title == "" {
107+ return false
108+ }
109+ if bi.muted && !bj.muted {
110+ return false
111+ }
112+ if !bi.muted && bj.muted {
113+ return true
114+ }
115+ bti := strings.ToLower(bi.title)
116+ btj := strings.ToLower(bj.title)
117+ return strings.Compare(bti, btj) == -1
118+ })
119+ i, _ := bs.at(netID, title)
120+ if i >= 0 {
121+ bs.current = i
122+ }
123+}
124+
125 func (bs *BufferList) mergeLine(former *Line, addition Line) (keepLine bool) {
126 bs.ui.config.MergeLine(former, addition)
127 if former.Body.string == "" {
128@@ -546,6 +598,40 @@ func (bs *BufferList) SetTopic(netID, title string, topic StyledString) {
129 b.topic = topic
130 }
131
132+func (bs *BufferList) GetPinned(netID, title string) bool {
133+ _, b := bs.at(netID, title)
134+ if b == nil {
135+ return false
136+ }
137+ return b.pinned
138+}
139+
140+func (bs *BufferList) SetPinned(netID, title string, pinned bool) {
141+ _, b := bs.at(netID, title)
142+ if b == nil {
143+ return
144+ }
145+ b.pinned = pinned
146+ bs.reorder()
147+}
148+
149+func (bs *BufferList) GetMuted(netID, title string) bool {
150+ _, b := bs.at(netID, title)
151+ if b == nil {
152+ return false
153+ }
154+ return b.muted
155+}
156+
157+func (bs *BufferList) SetMuted(netID, title string, muted bool) {
158+ _, b := bs.at(netID, title)
159+ if b == nil {
160+ return
161+ }
162+ b.muted = muted
163+ bs.reorder()
164+}
165+
166 func (bs *BufferList) clearRead(i int) {
167 b := &bs.list[i]
168 b.highlights = 0
169@@ -736,12 +822,14 @@ func (bs *BufferList) DrawVerticalBufferList(vx *Vaxis, x0, y0, width, height in
170 bi := off + i
171 x := x0
172 var st vaxis.Style
173- if b.unread {
174+ if b.unread && !b.muted {
175 st.Attribute |= vaxis.AttrBold
176 st.Foreground = bs.ui.config.Colors.Unread
177 }
178 if bi == bs.current || bi == bs.clicked {
179 st.Attribute |= vaxis.AttrReverse
180+ } else if b.muted {
181+ st.Foreground = ColorGray
182 }
183
184 var title string
185@@ -763,10 +851,15 @@ func (bs *BufferList) DrawVerticalBufferList(vx *Vaxis, x0, y0, width, height in
186 }
187
188 if b.title != "" {
189+ var st vaxis.Style
190 if bi == bs.current || bi == bs.clicked {
191- st := vaxis.Style{
192- Attribute: vaxis.AttrReverse,
193- }
194+ st.Attribute |= vaxis.AttrReverse
195+ }
196+ if b.pinned {
197+ st.Attribute |= vaxis.AttrBold
198+ setCell(vx, x, y, '⚲', st)
199+ setCell(vx, x+1, y, ' ', st)
200+ } else {
201 setCell(vx, x, y, ' ', st)
202 setCell(vx, x+1, y, ' ', st)
203 }
204@@ -917,7 +1010,7 @@ func (bs *BufferList) DrawHorizontalBufferList(vx *Vaxis, x0, y0, width int, off
205 break
206 }
207 var st vaxis.Style
208- if b.unread {
209+ if b.unread && !b.muted {
210 st.Attribute |= vaxis.AttrBold
211 st.Foreground = bs.ui.config.Colors.Unread
212 } else if i == bs.current {
213@@ -925,6 +1018,8 @@ func (bs *BufferList) DrawHorizontalBufferList(vx *Vaxis, x0, y0, width int, off
214 }
215 if i == bs.clicked {
216 st.Attribute |= vaxis.AttrReverse
217+ } else if b.muted {
218+ st.Foreground = ColorGray
219 }
220
221 var title string
M
ui/ui.go
+47,
-9
1@@ -466,6 +466,22 @@ func (ui *UI) SetTopic(netID, buffer string, topic StyledString) {
2 ui.bs.SetTopic(netID, buffer, topic)
3 }
4
5+func (ui *UI) GetPinned(netID, buffer string) bool {
6+ return ui.bs.GetPinned(netID, buffer)
7+}
8+
9+func (ui *UI) SetPinned(netID, buffer string, pinned bool) {
10+ ui.bs.SetPinned(netID, buffer, pinned)
11+}
12+
13+func (ui *UI) GetMuted(netID, buffer string) bool {
14+ return ui.bs.GetMuted(netID, buffer)
15+}
16+
17+func (ui *UI) SetMuted(netID, buffer string, muted bool) {
18+ ui.bs.SetMuted(netID, buffer, muted)
19+}
20+
21 func (ui *UI) SetRead(netID, buffer string, timestamp time.Time) {
22 ui.bs.SetRead(netID, buffer, timestamp)
23 }
24@@ -657,7 +673,7 @@ func (ui *UI) Draw(members []irc.Member) {
25 ui.bs.DrawVerticalBufferList(ui.vx, 0, 0, ui.channelWidth, h, &ui.channelOffset)
26 }
27 if ui.memberWidth != 0 {
28- ui.drawVerticalMemberList(ui.vx, w-ui.memberWidth, 0, ui.memberWidth, h, members, &ui.memberOffset)
29+ ui.drawVerticalMemberList(ui.vx, w-ui.memberWidth, 0, ui.memberWidth, h, ui.bs.cur(), members, &ui.memberOffset)
30 }
31 if ui.channelWidth == 0 {
32 ui.drawStatusBar(ui.channelWidth, h-3, w-ui.memberWidth)
33@@ -745,14 +761,7 @@ func (ui *UI) drawStatusBar(x0, y, width int) {
34 printString(ui.vx, &x, y, s.StyledString())
35 }
36
37-func (ui *UI) drawVerticalMemberList(vx *Vaxis, x0, y0, width, height int, members []irc.Member, offset *int) {
38- if y0+len(members)-*offset < height {
39- *offset = y0 + len(members) - height
40- if *offset < 0 {
41- *offset = 0
42- }
43- }
44-
45+func (ui *UI) drawVerticalMemberList(vx *Vaxis, x0, y0, width, height int, b *buffer, members []irc.Member, offset *int) {
46 drawVerticalLine(vx, x0, y0, height)
47 x0++
48 width--
49@@ -807,6 +816,25 @@ func (ui *UI) drawVerticalMemberList(vx *Vaxis, x0, y0, width, height int, membe
50 y0++
51 height--
52
53+ var actions []string
54+ if b.muted {
55+ actions = append(actions, "→ Unmute")
56+ } else {
57+ actions = append(actions, "→ Mute")
58+ }
59+ if b.pinned {
60+ actions = append(actions, "→ Unpin")
61+ } else {
62+ actions = append(actions, "→ Pin")
63+ }
64+ actions = append(actions, "→ Leave")
65+ for i, action := range actions {
66+ x := x0
67+ drawHorizontalLine(vx, x0, y0+height-(len(actions)-i)*2, width)
68+ printString(vx, &x, y0+height-(len(actions)-i)*2+1, Styled(action, vaxis.Style{}))
69+ }
70+ height -= 2 * len(actions)
71+
72 padding := 1
73 for _, m := range members {
74 if m.Disconnected {
75@@ -815,7 +843,17 @@ func (ui *UI) drawVerticalMemberList(vx *Vaxis, x0, y0, width, height int, membe
76 }
77 }
78
79+ if y0+len(members)-*offset < height {
80+ *offset = y0 + len(members) - height
81+ if *offset < 0 {
82+ *offset = 0
83+ }
84+ }
85+
86 for i, m := range members[*offset:] {
87+ if i >= height {
88+ break
89+ }
90 var attr vaxis.AttributeMask
91 if i+*offset == ui.memberClicked {
92 attr |= vaxis.AttrReverse