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
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
+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