diff --git a/go.mod b/go.mod index f3c02de..201324a 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 014e6a4..f3f639b 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/config/config.go b/internal/config/config.go index 9efa482..66f7312 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 == "" { diff --git a/internal/config/config.toml b/internal/config/config.toml index 4bbf60b..0a81d18 100644 --- a/internal/config/config.toml +++ b/internal/config/config.toml @@ -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" diff --git a/internal/config/keybinds.go b/internal/config/keybinds.go index 3d661b8..646258b 100644 --- a/internal/config/keybinds.go +++ b/internal/config/keybinds.go @@ -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(), diff --git a/internal/config/theme.go b/internal/config/theme.go index 25d808d..69e8970 100644 --- a/internal/config/theme.go +++ b/internal/config/theme.go @@ -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"` diff --git a/internal/ui/chat/keymap.go b/internal/ui/chat/keymap.go index 775970a..303dbc6 100644 --- a/internal/ui/chat/keymap.go +++ b/internal/ui/chat/keymap.go @@ -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 } diff --git a/internal/ui/chat/memberstree/keymap.go b/internal/ui/chat/memberstree/keymap.go new file mode 100644 index 0000000..a8d9e8e --- /dev/null +++ b/internal/ui/chat/memberstree/keymap.go @@ -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}, + } +} diff --git a/internal/ui/chat/memberstree/model.go b/internal/ui/chat/memberstree/model.go new file mode 100644 index 0000000..6700af1 --- /dev/null +++ b/internal/ui/chat/memberstree/model.go @@ -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] +} diff --git a/internal/ui/chat/memberstree/model_test.go b/internal/ui/chat/memberstree/model_test.go new file mode 100644 index 0000000..55a39d1 --- /dev/null +++ b/internal/ui/chat/memberstree/model_test.go @@ -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") + } + }) +} diff --git a/internal/ui/chat/memberstree/msg.go b/internal/ui/chat/memberstree/msg.go new file mode 100644 index 0000000..6bc8f9f --- /dev/null +++ b/internal/ui/chat/memberstree/msg.go @@ -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 + } +} diff --git a/internal/ui/chat/model.go b/internal/ui/chat/model.go index ccbd5c8..943b7c4 100644 --- a/internal/ui/chat/model.go +++ b/internal/ui/chat/model.go @@ -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)), ) } diff --git a/internal/ui/chat/model_test.go b/internal/ui/chat/model_test.go index c399ed9..cd9e9a3 100644 --- a/internal/ui/chat/model_test.go +++ b/internal/ui/chat/model_test.go @@ -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) + } + } +} diff --git a/internal/ui/chat/state.go b/internal/ui/chat/state.go index 7b0008f..97d115e 100644 --- a/internal/ui/chat/state.go +++ b/internal/ui/chat/state.go @@ -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: