feat(chat): add members tree

This commit is contained in:
ayn2op 2026-09-28 17:27:41 -04:00
parent 05e622e753
commit 08f27eb60d
14 changed files with 718 additions and 92 deletions

2
go.mod
View file

@ -14,7 +14,7 @@ require (
github.com/alecthomas/chroma/v2 v2.27.0
github.com/andybalholm/brotli v1.2.5
github.com/ayn2op/arikawa/v3 v3.0.0-20260928035447-6eac31cb96a7
github.com/ayn2op/ningen/v3 v3.0.1-0.20260928043244-6cde42c5dfdd
github.com/ayn2op/ningen/v3 v3.0.1-0.20260928223257-36eef7b8a0bc
github.com/ayn2op/tview v0.0.0-20260928205525-fa36e0ece3f0
github.com/bogdanfinn/fhttp v0.6.9
github.com/bogdanfinn/tls-client v1.16.0

6
go.sum
View file

@ -12,10 +12,8 @@ github.com/andybalholm/brotli v1.2.5 h1:BSI8V4zmx/3BAn6OKjF1PmfVq7Aoi52AdFsi6bpC
github.com/andybalholm/brotli v1.2.5/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/ayn2op/arikawa/v3 v3.0.0-20260928035447-6eac31cb96a7 h1:7N2ZeNeqFrGPUHUmTZ7654JMPE3thYd2WOeWckIbPsQ=
github.com/ayn2op/arikawa/v3 v3.0.0-20260928035447-6eac31cb96a7/go.mod h1:SHqcr+EmZKeNtVKZybHJn4HNz+ufA1XbeKNSTigBCEE=
github.com/ayn2op/ningen/v3 v3.0.1-0.20260928043244-6cde42c5dfdd h1:TvGZslFOdrfWW005pcNKjUXRGuR+XC04Us55O/8lr0I=
github.com/ayn2op/ningen/v3 v3.0.1-0.20260928043244-6cde42c5dfdd/go.mod h1:c3qDZseL0znZUq68SygG1TsILXyGMKLSyKyO5XjTDBM=
github.com/ayn2op/tview v0.0.0-20260928003911-758a51d24cad h1:NzrmJFrWVtRVY8u+Q9HUv0ow9dvlu4s+YdrqEnh4yGM=
github.com/ayn2op/tview v0.0.0-20260928003911-758a51d24cad/go.mod h1:oRPTSZgaEJPhv3dmmNvo6grBq/BemrLG3wFQLMTZJkY=
github.com/ayn2op/ningen/v3 v3.0.1-0.20260928223257-36eef7b8a0bc h1:uUaC5uzjNuKG29bTsRb1pUKyIoL2Mv8/dA7TGFMO/h0=
github.com/ayn2op/ningen/v3 v3.0.1-0.20260928223257-36eef7b8a0bc/go.mod h1:c3qDZseL0znZUq68SygG1TsILXyGMKLSyKyO5XjTDBM=
github.com/ayn2op/tview v0.0.0-20260928205525-fa36e0ece3f0 h1:mh5nA4XFDLYLmLs++EhN2X+6LODI0tTbRRQFp4Q4F/A=
github.com/ayn2op/tview v0.0.0-20260928205525-fa36e0ece3f0/go.mod h1:oRPTSZgaEJPhv3dmmNvo6grBq/BemrLG3wFQLMTZJkY=
github.com/bdandy/go-errors v1.2.2 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q=

View file

@ -132,14 +132,18 @@ type (
DM int `toml:"dm"`
}
SidebarConfig struct {
// Visible controls whether the guilds tree (sidebar) is shown by default at start-up.
// It can still be toggled at runtime with the toggle_guilds_tree keybind.
// PaneConfig is a side pane that can be toggled at runtime.
PaneConfig struct {
// Visible controls whether the pane is shown by default at start-up.
Visible bool `toml:"visible"`
// WidthPercent is the percentage of the total window width that the guilds tree (sidebar) occupies.
WidthPercent int `toml:"width_percent"`
Markers SidebarMarkersConfig `toml:"markers"`
Indents SidebarIndentsConfig `toml:"indents"`
// WidthPercent is the percentage of the total window width that the pane occupies.
WidthPercent int `toml:"width_percent"`
}
SidebarConfig struct {
PaneConfig
Markers SidebarMarkersConfig `toml:"markers"`
Indents SidebarIndentsConfig `toml:"indents"`
}
Config struct {
@ -164,6 +168,7 @@ type (
Notifications Notifications `toml:"notifications"`
TypingIndicator TypingIndicator `toml:"typing_indicator"`
Sidebar SidebarConfig `toml:"sidebar"`
MembersTree PaneConfig `toml:"members_tree"`
Composer ComposerConfig `toml:"composer"`
Icons Icons `toml:"icons"`
@ -228,8 +233,10 @@ func (cfg *Config) applyDefaults() {
cfg.Composer.MaxHeight = 10
}
if cfg.Sidebar.WidthPercent <= 0 || cfg.Sidebar.WidthPercent >= 100 {
cfg.Sidebar.WidthPercent = 20
for _, pane := range []*PaneConfig{&cfg.Sidebar.PaneConfig, &cfg.MembersTree} {
if pane.WidthPercent <= 0 || pane.WidthPercent >= 100 {
pane.WidthPercent = 20
}
}
if cfg.DateSeparator.Format == "" {

View file

@ -94,6 +94,14 @@ category = 1
channel = 2
forum = 2
[members_tree]
# Whether the members tree is visible by default at start-up.
# It can still be toggled at runtime with the `toggle_members_tree` keybind.
visible = true
# Percentage (%) of the width right of the guilds tree used by the members tree.
# Valid values are 1-99. Invalid values fall back to 20.
width_percent = 20
[icons]
guild_category = ""
guild_text = "#"
@ -110,13 +118,16 @@ guild_store = "s-"
# Esc: Reset message selection or close the channel selection popup.
[keybinds]
# Hide/show the guilds tree.
toggle_guilds_tree = "ctrl+b"
toggle_guilds_tree = "alt+g"
# Hide/show the members tree.
toggle_members_tree = "alt+m"
toggle_channels_picker = "ctrl+k"
# Hide/show keybind help.
toggle_help = "alt+."
# Switch between compact and full keybind help.
toggle_full_help = "ctrl+."
focus_guilds_tree = "ctrl+g"
focus_members_tree = "ctrl+y"
focus_messages_list = "ctrl+t"
focus_composer = "ctrl+i"
# Cycle focus between the widgets.
@ -149,6 +160,19 @@ collapse_all = "_"
collapse_parent_node = "-"
move_to_parent_node = "p"
# Only while focusing on the members tree
[keybinds.members_tree]
select_up = "k"
select_down = "j"
select_top = "g"
select_bottom = "G"
# Expand or collapse the highlighted group.
select_current = "enter"
yank_id = "i"
collapse_all = "_"
collapse_parent_node = "-"
move_to_parent_node = "p"
# Only while focusing on sent messages
[keybinds.messages_list]
select_up = "k"
@ -245,6 +269,17 @@ idle_style = { foreground = "yellow" }
dnd_style = { foreground = "red" }
offline_style = { foreground = "gray" }
[theme.members_tree]
# Give tree-like shape
graphics = true
graphics_color = "default"
# Styles of the status dot before each member.
online_style = { foreground = "green" }
idle_style = { foreground = "yellow" }
dnd_style = { foreground = "red" }
offline_style = { foreground = "gray" }
[theme.scroll_bar]
visibility = "auto"
# "minimal", "box_drawing", or "unicode"

View file

@ -115,12 +115,14 @@ type MentionsListKeybinds struct {
type Keybinds struct {
ToggleGuildsTree Keybind `toml:"toggle_guilds_tree"`
ToggleMembersTree Keybind `toml:"toggle_members_tree"`
ToggleChannelsPicker Keybind `toml:"toggle_channels_picker"`
ToggleHelp Keybind `toml:"toggle_help"`
ToggleFullHelp Keybind `toml:"toggle_full_help"`
Suspend Keybind `toml:"suspend"`
FocusGuildsTree Keybind `toml:"focus_guilds_tree"`
FocusMembersTree Keybind `toml:"focus_members_tree"`
FocusMessagesList Keybind `toml:"focus_messages_list"`
FocusComposer Keybind `toml:"focus_composer"`
@ -129,6 +131,7 @@ type Keybinds struct {
Picker PickerKeybinds `toml:"picker"`
GuildsTree TreeKeybinds `toml:"guilds_tree"`
MembersTree TreeKeybinds `toml:"members_tree"`
MessagesList MessagesListKeybinds `toml:"messages_list"`
Composer ComposerKeybinds `toml:"composer"`
MentionsList MentionsListKeybinds `toml:"mentions_list"`
@ -213,12 +216,14 @@ func defaultMentionsListKeybinds() MentionsListKeybinds {
func defaultKeybinds() Keybinds {
return Keybinds{
ToggleGuildsTree: desc("toggle guilds"),
ToggleMembersTree: desc("toggle members"),
ToggleChannelsPicker: desc("channels picker"),
ToggleHelp: desc("toggle help"),
ToggleFullHelp: desc("full help"),
Suspend: desc("suspend"),
FocusGuildsTree: desc("guilds"),
FocusMembersTree: desc("members"),
FocusMessagesList: desc("messages"),
FocusComposer: desc("composer"),
@ -230,6 +235,7 @@ func defaultKeybinds() Keybinds {
Picker: defaultPickerKeybinds(),
GuildsTree: defaultTreeKeybinds(),
MembersTree: defaultTreeKeybinds(),
MessagesList: defaultMessagesListKeybinds(),
Composer: defaultComposerKeybinds(),
MentionsList: defaultMentionsListKeybinds(),

View file

@ -273,6 +273,7 @@ type (
Footer FooterTheme `toml:"footer"`
Border BorderTheme `toml:"border"`
GuildsTree GuildsTreeTheme `toml:"guilds_tree"`
MembersTree CommonTreeTheme `toml:"members_tree"`
ScrollBar ScrollBarTheme `toml:"scroll_bar"`
MessagesList MessagesListTheme `toml:"messages_list"`
MentionsList MentionsListTheme `toml:"mentions_list"`

View file

@ -37,6 +37,8 @@ func (m Model) activeKeyMap() help.KeyMap {
return m.messagesList
case composerPane:
return m.composer
case membersTreePane:
return m.membersTree
default:
return nil
}
@ -54,7 +56,7 @@ func (m Model) baseFullHelp() [][]keybind.Keybind {
return [][]keybind.Keybind{
m.focusHelp(),
{cfg.FocusPrevious.Keybind, cfg.FocusNext.Keybind},
{cfg.ToggleGuildsTree.Keybind, cfg.ToggleChannelsPicker.Keybind},
{cfg.ToggleGuildsTree.Keybind, cfg.ToggleMembersTree.Keybind, cfg.ToggleChannelsPicker.Keybind},
{cfg.Logout.Keybind},
}
}
@ -62,7 +64,7 @@ func (m Model) baseFullHelp() [][]keybind.Keybind {
func (m Model) focusHelp() []keybind.Keybind {
kbs := m.cfg.Keybinds
focused := m.focused
focusKbs := make([]keybind.Keybind, 0, 3)
focusKbs := make([]keybind.Keybind, 0, 4)
if focused != guildsTreePane {
focusKbs = append(focusKbs, kbs.FocusGuildsTree.Keybind)
@ -73,6 +75,9 @@ func (m Model) focusHelp() []keybind.Keybind {
if !m.composer.Disabled() && focused != composerPane {
focusKbs = append(focusKbs, kbs.FocusComposer.Keybind)
}
if m.membersTreeShown() && focused != membersTreePane {
focusKbs = append(focusKbs, kbs.FocusMembersTree.Keybind)
}
return focusKbs
}

View file

@ -0,0 +1,35 @@
package memberstree
import (
"github.com/ayn2op/tview/help"
"github.com/ayn2op/tview/keybind"
)
var _ help.KeyMap = Model{}
func (m Model) ShortHelp() []keybind.Keybind {
kbs := m.cfg.Keybinds.MembersTree
short := []keybind.Keybind{kbs.SelectUp.Keybind, kbs.SelectDown.Keybind}
if node := m.selectionState.CurrentNode(); node != nil {
if _, ok := node.Reference().(groupRef); ok {
desc := "expand"
if node.Expanded() {
desc = "collapse"
}
short = append(short, kbs.SelectCurrent.WithHelp(kbs.SelectCurrent.Help().Key, desc))
}
}
if m.parent(m.selectionState.CurrentNode()) != nil {
short = append(short, kbs.CollapseParentNode.Keybind)
}
return short
}
func (m Model) FullHelp() [][]keybind.Keybind {
kbs := m.cfg.Keybinds.MembersTree
return [][]keybind.Keybind{
{kbs.SelectUp.Keybind, kbs.SelectDown.Keybind, kbs.SelectTop.Keybind, kbs.SelectBottom.Keybind},
{kbs.SelectCurrent.Keybind, kbs.MoveToParentNode.Keybind, kbs.CollapseParentNode.Keybind, kbs.CollapseAll.Keybind},
{kbs.YankID.Keybind},
}
}

View file

@ -0,0 +1,329 @@
package memberstree
import (
"cmp"
"fmt"
"slices"
"strings"
"github.com/ayn2op/arikawa/v3/discord"
"github.com/ayn2op/arikawa/v3/gateway"
"github.com/ayn2op/arikawa/v3/state"
"github.com/ayn2op/discordo/internal/config"
uitree "github.com/ayn2op/discordo/internal/ui/tree"
"github.com/ayn2op/ningen/v3"
"github.com/ayn2op/tview"
"github.com/ayn2op/tview/keybind"
"github.com/ayn2op/tview/richtext"
"github.com/ayn2op/tview/tree"
"github.com/gdamore/tcell/v3"
)
// groupRef is the ID of a member list group: a role ID, "online", or "offline".
type groupRef string
func (r groupRef) String() string {
return string(r)
}
// memberRef is a member and its index in the guild member list, which decides the chunk of the list to request.
type memberRef struct {
userID discord.UserID
index int
}
func (r memberRef) String() string {
return r.userID.String()
}
// Model is a tree of the members of the selected channel, grouped as Discord groups them.
type Model struct {
root *tree.Node
selectionState tree.SelectionState
cfg *config.Config
state *ningen.State
channel *discord.Channel
// nodes indexes the tree by group and user ID, so that a rebuild reuses the nodes and keeps the selection and collapsed groups.
nodes map[string]*tree.Node
}
func NewModel(cfg *config.Config, state *ningen.State) Model {
return Model{
root: tree.NewNode(""),
cfg: cfg,
state: state,
nodes: make(map[string]*tree.Node),
}
}
// Shown reports whether the selected channel has members to show: guild channels other than threads, and group DMs.
func (m Model) Shown() bool {
channel := m.channel
if channel == nil {
return false
}
switch channel.Type {
case discord.GroupDM:
return true
case discord.GuildPublicThread, discord.GuildPrivateThread, discord.GuildAnnouncementThread:
return false
default:
return channel.GuildID.IsValid()
}
}
// SetChannel shows the members of channel and requests its member list.
func (m *Model) SetChannel(channel *discord.Channel) tview.Cmd {
m.channel = channel
m.rebuild()
return m.requestMembers(0)
}
// View shows the tree in a box titled Members.
func (m Model) View(focused bool) tview.Element {
onChange := func(c tree.Change) tview.Msg { return Msg(c) }
return uitree.New(m.root, &m.selectionState, m.cfg, m.cfg.Theme.MembersTree, m.cfg.Keybinds.MembersTree, focused, onChange).Title("Members")
}
func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
cmd := m.update(msg)
return m, cmd
}
// update changes m in response to msg and returns a command to run, or nil.
func (m *Model) update(msg tview.Msg) tview.Cmd {
switch msg := msg.(type) {
case *gateway.ReadyEvent:
// ningen drops its member lists on READY, so request the list again.
m.rebuild()
return m.requestMembers(0)
case *gateway.GuildMemberListUpdateEvent:
if channel := m.channel; channel != nil && channel.GuildID == msg.GuildID && m.state.MemberState.ListID(channel) == msg.ID {
m.rebuild()
}
return nil
case *gateway.PresenceUpdateEvent:
m.refreshStatus(msg.User.ID)
return nil
case Msg:
m.selectionState.Apply(tree.Change(msg))
return m.requestSelectedMembers()
case tree.SelectedMsg:
m.selectionState.SetCurrentNode(msg.Node)
if _, ok := msg.Node.Reference().(groupRef); ok {
msg.Node.SetExpanded(!msg.Node.Expanded())
}
return m.requestSelectedMembers()
case tview.KeyMsg:
kbs := m.cfg.Keybinds.MembersTree
switch {
case keybind.Matches(msg, kbs.CollapseAll.Keybind):
for _, node := range m.root.Children() {
node.Collapse()
}
return nil
case keybind.Matches(msg, kbs.CollapseParentNode.Keybind):
if parent := m.parent(m.selectionState.CurrentNode()); parent != nil {
parent.Collapse()
m.selectionState.SetCurrentNode(parent)
}
return nil
case keybind.Matches(msg, kbs.YankID.Keybind):
return uitree.YankID(m.selectionState.CurrentNode())
}
}
return nil
}
// rebuild recreates the tree from the member list of the selected channel.
func (m *Model) rebuild() {
m.root.ClearChildren()
channel := m.channel
switch {
case !m.Shown():
case channel.Type == discord.GroupDM:
m.addRecipients(channel.DMRecipients)
default:
m.addGuildMembers(channel)
}
clear(m.nodes)
m.root.Walk(func(node, _ *tree.Node) bool {
if ref, ok := node.Reference().(fmt.Stringer); ok {
m.nodes[ref.String()] = node
}
return true
})
}
func (m *Model) addGuildMembers(channel *discord.Channel) {
list, err := m.state.MemberState.GetMemberList(channel.GuildID, channel.ID)
if err != nil {
// The list has yet to arrive.
return
}
list.ViewItems(func(items []gateway.GuildMemberListOpItem) {
var group *tree.Node
for i, item := range items {
switch {
case item.Group != nil:
group = m.addGroup(channel.GuildID, *item.Group)
case item.Member != nil:
// Members after an unloaded range belong to a group that has yet to arrive.
if group == nil {
group = m.addGroup(channel.GuildID, gateway.GuildMemberListGroup{})
}
m.addMember(group, channel.GuildID, item.Member.Member, i)
default:
group = nil
}
}
})
}
func (m *Model) addRecipients(recipients []discord.User) {
recipients = slices.Clone(recipients)
// Discord leaves the current user out of the recipients.
if me, err := m.state.Cabinet.Me(); err == nil {
recipients = append(recipients, *me)
}
slices.SortFunc(recipients, func(a, b discord.User) int {
return cmp.Or(
cmp.Compare(statusRank(m.status(discord.NullGuildID, a.ID)), statusRank(m.status(discord.NullGuildID, b.ID))),
strings.Compare(strings.ToLower(a.DisplayOrUsername()), strings.ToLower(b.DisplayOrUsername())),
)
})
group := m.addGroup(discord.NullGuildID, gateway.GuildMemberListGroup{ID: "members", Count: uint64(len(recipients))})
for _, recipient := range recipients {
m.addMember(group, discord.NullGuildID, discord.Member{User: recipient}, -1)
}
}
func (m *Model) addGroup(guildID discord.GuildID, group gateway.GuildMemberListGroup) *tree.Node {
name := "Members"
var style tcell.Style
switch group.ID {
case "online":
name = "Online"
case "offline":
name = "Offline"
default:
if id, err := discord.ParseSnowflake(group.ID); err == nil {
if role, err := m.state.Cabinet.Role(guildID, discord.RoleID(id)); err == nil {
name = role.Name
if role.Color != 0 {
style = style.Foreground(tcell.NewHexColor(int32(role.Color)))
}
}
}
}
if group.Count > 0 {
name = fmt.Sprintf("%s — %d", name, group.Count)
}
ref := groupRef(group.ID)
node := m.reuse(ref.String()).
SetLine(richtext.NewLine(richtext.NewSegment(name, style))).
SetReference(ref).
SetExpandable(true)
m.root.AddChild(node)
return node
}
// addMember adds a member as Discord shows it: a status dot, then the name in the color of their highest colored role, dimmed when offline.
func (m *Model) addMember(group *tree.Node, guildID discord.GuildID, member discord.Member, index int) {
name := member.Nick
if name == "" {
name = member.User.DisplayOrUsername()
}
var nameStyle tcell.Style
color, ok := state.MemberColor(&member, func(id discord.RoleID) *discord.Role {
r, _ := m.state.Cabinet.Role(guildID, id)
return r
})
if ok {
nameStyle = nameStyle.Foreground(tcell.NewHexColor(int32(color)))
}
line := richtext.NewLine(
richtext.NewSegment(statusMarker, tcell.StyleDefault),
richtext.NewSegment(" ", tcell.StyleDefault),
richtext.NewSegment(name, nameStyle),
)
m.setStatus(line, m.status(guildID, member.User.ID))
group.AddChild(m.reuse(member.User.ID.String()).SetLine(line).SetReference(memberRef{userID: member.User.ID, index: index}))
}
const statusMarker = "●"
// setStatus styles the status dot and name of a member line for status.
func (m *Model) setStatus(line richtext.Line, status discord.Status) {
line[0].Style = m.statusStyle(status)
line[2].Style = line[2].Style.Dim(status == discord.OfflineStatus)
}
// reuse takes the node of id out of the index, so that it is reused once, or else returns a new node. Either way the node has no children.
func (m *Model) reuse(id string) *tree.Node {
node, ok := m.nodes[id]
if !ok {
return tree.NewNode("")
}
delete(m.nodes, id)
return node.ClearChildren()
}
func (m *Model) refreshStatus(userID discord.UserID) {
if node, ok := m.nodes[userID.String()]; ok {
line := node.Line()
m.setStatus(line, m.status(m.channel.GuildID, userID))
node.SetLine(line)
}
}
func (m *Model) status(guildID discord.GuildID, userID discord.UserID) discord.Status {
presence, err := m.state.Cabinet.Presence(guildID, userID)
if err != nil {
return discord.OfflineStatus
}
return presence.Status
}
func (m *Model) statusStyle(status discord.Status) tcell.Style {
theme := &m.cfg.Theme.MembersTree
switch status {
case discord.OnlineStatus:
return theme.OnlineStyle.Style
case discord.IdleStatus:
return theme.IdleStyle.Style
case discord.DoNotDisturbStatus:
return theme.DNDStyle.Style
default:
return theme.OfflineStyle.Style
}
}
// statusRank orders members as Discord does: online, idle, do not disturb, then offline.
func statusRank(status discord.Status) int {
switch status {
case discord.OnlineStatus:
return 0
case discord.IdleStatus:
return 1
case discord.DoNotDisturbStatus:
return 2
default:
return 3
}
}
func (m *Model) parent(node *tree.Node) *tree.Node {
path := m.root.PathTo(node)
if len(path) < 3 {
return nil
}
return path[len(path)-2]
}

View file

@ -0,0 +1,146 @@
package memberstree
import (
"encoding/json"
"path/filepath"
"slices"
"testing"
"github.com/ayn2op/arikawa/v3/discord"
"github.com/ayn2op/arikawa/v3/gateway"
"github.com/ayn2op/arikawa/v3/state"
"github.com/ayn2op/discordo/internal/config"
"github.com/ayn2op/ningen/v3"
"github.com/ayn2op/tview/tree"
)
const guildID, channelID = 1, 2
func newTestModel(t *testing.T) Model {
t.Helper()
cfg, err := config.Load(filepath.Join(t.TempDir(), "config.toml"))
if err != nil {
t.Fatal(err)
}
return NewModel(cfg, ningen.FromState(state.New("")))
}
// setGuildMembers stores a guild channel whose member list holds items, given as JSON.
func setGuildMembers(t *testing.T, m Model, items string) *discord.Channel {
t.Helper()
channel := &discord.Channel{ID: channelID, GuildID: guildID, Type: discord.GuildText}
m.state.Cabinet.ChannelSet(channel, false)
m.state.Cabinet.RoleSet(guildID, &discord.Role{ID: 3, Name: "Mods"}, false)
event := &gateway.GuildMemberListUpdateEvent{ID: "everyone", GuildID: guildID}
event.Ops = []gateway.GuildMemberListOp{{Op: "SYNC", Range: [2]int{0, 99}}}
if err := json.Unmarshal([]byte(items), &event.Ops[0].Items); err != nil {
t.Fatal(err)
}
m.state.State.Handler.Call(event)
return channel
}
// lines returns the text of the groups and, indented, their members.
func lines(m Model) []string {
var lines []string
for _, group := range m.root.Children() {
lines = append(lines, text(group))
for _, member := range group.Children() {
lines = append(lines, " "+text(member))
}
}
return lines
}
func text(node *tree.Node) string {
var s string
for _, segment := range node.Line() {
s += segment.Text
}
return s
}
func TestModelShown(t *testing.T) {
for _, tt := range []struct {
name string
channel *discord.Channel
want bool
}{
{"none", nil, false},
{"guild channel", &discord.Channel{GuildID: guildID, Type: discord.GuildText}, true},
{"thread", &discord.Channel{GuildID: guildID, Type: discord.GuildPublicThread}, false},
{"group dm", &discord.Channel{Type: discord.GroupDM}, true},
{"dm", &discord.Channel{Type: discord.DirectMessage}, false},
} {
t.Run(tt.name, func(t *testing.T) {
m := newTestModel(t)
m.channel = tt.channel
if got := m.Shown(); got != tt.want {
t.Fatalf("Shown() = %v, want %v", got, tt.want)
}
})
}
}
func TestModelRebuild(t *testing.T) {
t.Run("guild members", func(t *testing.T) {
m := newTestModel(t)
m.channel = setGuildMembers(t, m, `[
{"group": {"id": "3", "count": 1}},
{"member": {"user": {"id": "10", "username": "a"}, "nick": "Alice"}},
{"group": {"id": "online", "count": 2}},
{"member": {"user": {"id": "11", "username": "b"}}},
{},
{"member": {"user": {"id": "12", "username": "c"}}}
]`)
m.rebuild()
// The member after the unloaded item belongs to a group that has yet to arrive.
want := []string{"Mods — 1", " ● Alice", "Online — 2", " ● b", "Members", " ● c"}
if got := lines(m); !slices.Equal(got, want) {
t.Fatalf("lines = %q, want %q", got, want)
}
})
t.Run("group dm recipients", func(t *testing.T) {
m := newTestModel(t)
m.state.Cabinet.MyselfSet(discord.User{ID: 13, Username: "me"}, false)
m.state.Cabinet.PresenceSet(discord.NullGuildID, &discord.Presence{User: discord.User{ID: 11}, Status: discord.OnlineStatus}, false)
m.channel = &discord.Channel{Type: discord.GroupDM, DMRecipients: []discord.User{
{ID: 10, Username: "a"},
{ID: 11, Username: "c"},
{ID: 12, Username: "B"},
}}
m.rebuild()
// Online members come first, then members by name, including the current user.
want := []string{"Members — 4", " ● c", " ● a", " ● B", " ● me"}
if got := lines(m); !slices.Equal(got, want) {
t.Fatalf("lines = %q, want %q", got, want)
}
})
t.Run("keeps selection and collapsed groups", func(t *testing.T) {
m := newTestModel(t)
m.channel = setGuildMembers(t, m, `[
{"group": {"id": "online", "count": 1}},
{"member": {"user": {"id": "10", "username": "a"}}},
{"group": {"id": "offline", "count": 1}},
{"member": {"user": {"id": "11", "username": "b"}}}
]`)
m.rebuild()
online, offline := m.root.Children()[0], m.root.Children()[1]
selected := online.Children()[0]
m.selectionState.SetCurrentNode(selected)
offline.Collapse()
m.rebuild()
if got := m.selectionState.CurrentNode(); got != selected || m.root.PathTo(got) == nil {
t.Fatal("the selected member was not kept in the tree")
}
if m.root.Children()[1].Expanded() {
t.Fatal("the collapsed group was expanded")
}
})
}

View file

@ -0,0 +1,36 @@
package memberstree
import (
"github.com/ayn2op/ningen/v3/states/member"
"github.com/ayn2op/tview"
"github.com/ayn2op/tview/tree"
)
// Msg moves or scrolls the tree.
type Msg tree.Change
// requestSelectedMembers requests the chunk of the member list after the selected member, so that the members below it load before they are reached.
func (m *Model) requestSelectedMembers() tview.Cmd {
node := m.selectionState.CurrentNode()
if node == nil {
return nil
}
ref, ok := node.Reference().(memberRef)
if !ok || ref.index < 0 {
return nil
}
return m.requestMembers(member.ChunkFromIndex(ref.index) + 1)
}
// requestMembers requests the member list of the selected guild channel up to chunk.
func (m *Model) requestMembers(chunk int) tview.Cmd {
channel := m.channel
if !m.Shown() || !channel.GuildID.IsValid() {
return nil
}
state := m.state
return func() tview.Msg {
state.MemberState.RequestMemberList(channel.GuildID, channel.ID, chunk)
return nil
}
}

View file

@ -21,6 +21,7 @@ import (
"github.com/ayn2op/discordo/internal/ui/chat/channelspicker"
"github.com/ayn2op/discordo/internal/ui/chat/composer"
"github.com/ayn2op/discordo/internal/ui/chat/guildstree"
"github.com/ayn2op/discordo/internal/ui/chat/memberstree"
"github.com/ayn2op/discordo/internal/ui/chat/mentionslist"
"github.com/ayn2op/discordo/internal/ui/chat/messageslist"
"github.com/ayn2op/ningen/v3"
@ -39,11 +40,14 @@ type Model struct {
focused pane
// guildsTreeVisible reports whether the guilds tree is shown left of the messages.
guildsTreeVisible bool
// membersTreeVisible reports whether the members tree is shown right of the messages when the selected channel has members.
membersTreeVisible bool
// channelsPickerOpen and attachmentsPickerOpen report whether that picker is shown on top and takes all input.
channelsPickerOpen bool
attachmentsPickerOpen bool
guildsTree guildstree.Model
membersTree memberstree.Model
messagesList messageslist.Model
composer composer.Model
channelsPicker channelspicker.Model
@ -99,12 +103,14 @@ func NewModel(cfg *config.Config, token string) Model {
m.state.OnRequest = append(m.state.OnRequest, httputil.WithHeaders(http.Headers()), onRequest)
m.guildsTree = guildstree.NewModel(cfg, m.state)
m.membersTree = memberstree.NewModel(cfg, m.state)
m.messagesList = messageslist.NewModel(cfg, m.state)
m.composer = composer.NewModel(cfg, m.state)
m.channelsPicker = channelspicker.NewModel(cfg)
m.attachmentsPicker = attachmentspicker.NewModel(cfg)
m.guildsTreeVisible = cfg.Sidebar.Visible
m.membersTreeVisible = cfg.MembersTree.Visible
if m.guildsTreeVisible {
// The guilds tree is focused first at start-up when visible.
m.focused = guildsTreePane
@ -114,9 +120,14 @@ func NewModel(cfg *config.Config, token string) Model {
return m
}
func (m *Model) setSelectedChannel(channel *discord.Channel) {
func (m *Model) setSelectedChannel(channel *discord.Channel) tview.Cmd {
m.selectedChannel = channel
m.composer.SetChannel(channel)
cmd := m.membersTree.SetChannel(channel)
if !m.canFocus(m.focused) {
m.focused = messagesListPane
}
return cmd
}
func (m *Model) togglePicker() tview.Cmd {
@ -167,68 +178,30 @@ func (m *Model) navigateToChannel(channelID discord.ChannelID) tview.Cmd {
return tview.Sequence(closeCmd, cmd)
}
func (m *Model) toggleGuildsTree() tview.Cmd {
if m.guildsTreeVisible {
if m.focused == guildsTreePane {
m.setFocus(messagesListPane)
}
m.guildsTreeVisible = false
} else {
m.guildsTreeVisible = true
m.setFocus(guildsTreePane)
}
return nil
}
func (m *Model) focusGuildsTree() bool {
m.setFocus(guildsTreePane)
return m.focused == guildsTreePane
}
func (m *Model) focusComposer() bool {
if !m.composer.Disabled() {
m.setFocus(composerPane)
return true
}
return false
}
func (m *Model) focusPrevious() {
switch m.focused {
case guildsTreePane:
if m.focusComposer() {
return
}
m.setFocus(messagesListPane)
case messagesListPane:
if m.focusGuildsTree() {
return
}
if m.focusComposer() {
return
}
m.setFocus(messagesListPane)
case composerPane:
m.setFocus(messagesListPane)
// toggle shows or hides the side pane p and focuses it when shown.
func (m *Model) toggle(visible *bool, p pane) {
*visible = !*visible
if *visible {
m.setFocus(p)
} else if m.focused == p {
m.focused = messagesListPane
}
}
func (m *Model) focusNext() {
switch m.focused {
case guildsTreePane:
m.setFocus(messagesListPane)
case messagesListPane:
if m.focusComposer() {
// membersTreeShown reports whether the members tree is visible and the selected channel has members to show.
func (m Model) membersTreeShown() bool {
return m.membersTreeVisible && m.membersTree.Shown()
}
// cycleFocus focuses the next pane that can take the focus, going backwards if step is -1.
func (m *Model) cycleFocus(step pane) {
p := m.focused
for range paneCount - 1 {
p = (p + step + paneCount) % paneCount
if m.canFocus(p) {
m.focused = p
return
}
if m.focusGuildsTree() {
return
}
case composerPane:
if m.focusGuildsTree() {
return
}
m.setFocus(messagesListPane)
}
}
@ -259,14 +232,14 @@ func (m *Model) update(msg tview.Msg) tview.Cmd {
return nil
}
m.setSelectedChannel(&msg.Channel)
membersCmd := m.setSelectedChannel(&msg.Channel)
m.clearTypers()
if m.cfg.AutoFocus {
m.focusComposer()
m.setFocus(composerPane)
}
title := ui.ChannelToString(msg.Channel, m.cfg.Icons, m.state) + " - " + consts.Name
return tview.Batch(tview.SetTitle(title), m.messagesList.SetChannel(&msg.Channel, msg.Messages))
return tview.Batch(tview.SetTitle(title), m.messagesList.SetChannel(&msg.Channel, msg.Messages), membersCmd)
case messageslist.Msg:
return m.updatePane(messagesListPane, msg)
case typingExpiredMsg:
@ -288,11 +261,11 @@ func (m *Model) update(msg tview.Msg) tview.Cmd {
return nil
case messageslist.ReplyMsg:
m.composer.StartReply(msg.Message, msg.Name, msg.Mention)
m.focusComposer()
m.setFocus(composerPane)
return nil
case messageslist.EditMsg:
m.composer.StartEdit(discord.Message(msg))
m.focusComposer()
m.setFocus(composerPane)
return nil
case composer.EditLastMsg:
if message, ok := m.messagesList.SelectLastOwn(); ok {
@ -308,25 +281,33 @@ func (m *Model) update(msg tview.Msg) tview.Cmd {
switch {
case keybind.Matches(msg, m.cfg.Keybinds.FocusGuildsTree.Keybind):
m.composer.CloseMentions()
m.focusGuildsTree()
m.setFocus(guildsTreePane)
return nil
case keybind.Matches(msg, m.cfg.Keybinds.FocusMembersTree.Keybind):
m.composer.CloseMentions()
m.setFocus(membersTreePane)
return nil
case keybind.Matches(msg, m.cfg.Keybinds.FocusMessagesList.Keybind):
m.composer.CloseMentions()
m.setFocus(messagesListPane)
return nil
case keybind.Matches(msg, m.cfg.Keybinds.FocusComposer.Keybind):
m.focusComposer()
m.setFocus(composerPane)
return nil
case keybind.Matches(msg, m.cfg.Keybinds.FocusPrevious.Keybind):
m.focusPrevious()
m.cycleFocus(-1)
return nil
case keybind.Matches(msg, m.cfg.Keybinds.FocusNext.Keybind):
m.focusNext()
m.cycleFocus(1)
return nil
case keybind.Matches(msg, m.cfg.Keybinds.ToggleGuildsTree.Keybind):
return m.toggleGuildsTree()
m.toggle(&m.guildsTreeVisible, guildsTreePane)
return nil
case keybind.Matches(msg, m.cfg.Keybinds.ToggleMembersTree.Keybind):
m.toggle(&m.membersTreeVisible, membersTreePane)
return nil
case keybind.Matches(msg, m.cfg.Keybinds.ToggleChannelsPicker.Keybind):
return m.togglePicker()
@ -371,6 +352,8 @@ func (m *Model) updatePane(p pane, msg tview.Msg) tview.Cmd {
m.messagesList, cmd = m.messagesList.Update(msg)
case composerPane:
m.composer, cmd = m.composer.Update(msg)
case membersTreePane:
m.membersTree, cmd = m.membersTree.Update(msg)
}
return cmd
}
@ -408,6 +391,8 @@ const (
guildsTreePane pane = iota
messagesListPane
composerPane
membersTreePane
paneCount
)
// paneView returns the view of p, focused if it has the focus and no picker is open, marked so that clicking it focuses it.
@ -421,15 +406,31 @@ func (m Model) paneView(p pane) tview.Element {
child = m.messagesList.View(focused)
case composerPane:
child = m.composer.View(focused)
case membersTreePane:
child = m.membersTree.View(focused)
}
return paneElement{pane: p, child: child}
}
func (m *Model) setFocus(target pane) {
if target == m.focused || (target == guildsTreePane && !m.guildsTreeVisible) {
return
// setFocus focuses p if it can take the focus.
func (m *Model) setFocus(p pane) {
if m.canFocus(p) {
m.focused = p
}
}
// canFocus reports whether p is shown and can take the focus.
func (m Model) canFocus(p pane) bool {
switch p {
case guildsTreePane:
return m.guildsTreeVisible
case composerPane:
return !m.composer.Disabled()
case membersTreePane:
return m.membersTreeShown()
default:
return true
}
m.focused = target
}
// View shows the guilds tree left of the messages above the composer, with the mentions list over the messages and an open picker on top.
@ -445,11 +446,18 @@ func (m Model) View() tview.Element {
}
main := right
if m.membersTreeShown() {
width := m.cfg.MembersTree.WidthPercent
main = row.New(
column.New(main).Width(tview.FillPortion(100-width)),
column.New(m.paneView(membersTreePane)).Width(tview.FillPortion(width)),
)
}
if m.guildsTreeVisible {
width := m.cfg.Sidebar.WidthPercent
main = row.New(
column.New(m.paneView(guildsTreePane)).Width(tview.FillPortion(width)),
column.New(right).Width(tview.FillPortion(100-width)),
column.New(main).Width(tview.FillPortion(100-width)),
)
}

View file

@ -79,3 +79,21 @@ func TestModelAddMessageOrNotify(t *testing.T) {
})
}
}
func TestModelCycleFocus(t *testing.T) {
m := newTestModel(t)
m.setSelectedChannel(&discord.Channel{ID: 1, GuildID: 2, Type: discord.GuildText})
m.focused = guildsTreePane
// The composer is disabled without permission to send messages, so it is skipped.
for _, want := range []pane{messagesListPane, membersTreePane, guildsTreePane} {
if m.cycleFocus(1); m.focused != want {
t.Fatalf("focused = %v, want %v", m.focused, want)
}
}
for _, want := range []pane{membersTreePane, messagesListPane, guildsTreePane} {
if m.cycleFocus(-1); m.focused != want {
t.Fatalf("focused = %v, want %v", m.focused, want)
}
}
}

View file

@ -112,14 +112,16 @@ func (m *Model) applyEvent(event gateway.Event) tview.Cmd {
m.onRaw(event)
case *gateway.ReadyEvent:
return m.loadGuildsTree(event)
return tview.Batch(m.loadGuildsTree(event), m.updatePane(membersTreePane, event))
case *gateway.MessageCreateEvent:
return m.addMessageOrNotify(event)
case *gateway.MessageUpdateEvent:
m.messagesList.UpdateMessage(event.Message)
case *gateway.PresenceUpdateEvent:
return m.updatePane(guildsTreePane, event)
return tview.Batch(m.updatePane(guildsTreePane, event), m.updatePane(membersTreePane, event))
case *gateway.GuildMemberListUpdateEvent:
return m.updatePane(membersTreePane, event)
case *gateway.MessageDeleteEvent:
m.messagesList.DeleteMessage(event.ChannelID, event.ID)
case *gateway.MessageReactionAddEvent: