From 5df8f14d7b73ce473f0e552bb6912ba1b0d0eafc Mon Sep 17 00:00:00 2001 From: ayn2op Date: Mon, 31 Aug 2026 22:35:16 -0400 Subject: [PATCH] refactor(ui): modularize guilds tree --- internal/ui/chat/guilds_tree.go | 522 ------------------ internal/ui/chat/guildstree/model.go | 499 +++++++++++++++++ .../model_test.go} | 19 +- internal/ui/chat/guildstree/msg.go | 125 +++++ internal/ui/chat/model.go | 30 +- internal/ui/chat/msg.go | 5 - internal/ui/chat/state.go | 89 +-- 7 files changed, 645 insertions(+), 644 deletions(-) delete mode 100644 internal/ui/chat/guilds_tree.go create mode 100644 internal/ui/chat/guildstree/model.go rename internal/ui/chat/{guilds_tree_test.go => guildstree/model_test.go} (72%) create mode 100644 internal/ui/chat/guildstree/msg.go diff --git a/internal/ui/chat/guilds_tree.go b/internal/ui/chat/guilds_tree.go deleted file mode 100644 index cbde2e3..0000000 --- a/internal/ui/chat/guilds_tree.go +++ /dev/null @@ -1,522 +0,0 @@ -package chat - -import ( - "context" - "fmt" - "log/slog" - "slices" - - "github.com/ayn2op/arikawa/v3/discord" - "github.com/ayn2op/arikawa/v3/gateway" - "github.com/ayn2op/discordo/internal/config" - "github.com/ayn2op/discordo/internal/ui" - "github.com/ayn2op/ningen/v3" - "github.com/ayn2op/tview" - "github.com/ayn2op/tview/help" - "github.com/ayn2op/tview/keybind" - "github.com/ayn2op/tview/tree" - "github.com/gdamore/tcell/v3" - "golang.design/x/clipboard" -) - -type dmNode struct{} - -type guildsTree struct { - *tree.Model - - cfg *config.Config - state *ningen.State - - // Fast-path indexes for frequent event handlers (read updates, picker - // navigation). They mirror the current rendered tree and are rebuilt on - // READY before nodes are added. - guildNodeByID map[discord.GuildID]*tree.Node - channelNodeByID map[discord.ChannelID]*tree.Node - dmRootNode *tree.Node -} - -func newGuildsTree(cfg *config.Config, state *ningen.State) *guildsTree { - gt := &guildsTree{ - Model: tree.NewModel(), - cfg: cfg, - state: state, - - guildNodeByID: make(map[discord.GuildID]*tree.Node), - channelNodeByID: make(map[discord.ChannelID]*tree.Node), - } - ui.ConfigureBox(gt.Box, &cfg.Theme) - gt. - SetRoot(tree.NewNode("")). - SetTopLevel(1). - SetMarkers(tree.Markers{ - Expanded: cfg.Sidebar.Markers.Expanded, - Collapsed: cfg.Sidebar.Markers.Collapsed, - Leaf: cfg.Sidebar.Markers.Leaf, - }). - SetGraphics(cfg.Theme.GuildsTree.Graphics). - SetGraphicsColor(tcell.GetColor(cfg.Theme.GuildsTree.GraphicsColor)). - SetTitle("Guilds") - gt.SetKeybinds(tree.Keybinds{ - Up: cfg.Keybinds.GuildsTree.SelectUp.Keybind, - Down: cfg.Keybinds.GuildsTree.SelectDown.Keybind, - Top: cfg.Keybinds.GuildsTree.SelectTop.Keybind, - Bottom: cfg.Keybinds.GuildsTree.SelectBottom.Keybind, - MoveToParent: cfg.Keybinds.GuildsTree.MoveToParentNode.Keybind, - Select: cfg.Keybinds.GuildsTree.SelectCurrent.Keybind, - }) - - return gt -} - -func (gt *guildsTree) resetNodeIndex() { - // Keep allocated map capacity; READY can rebuild often during reconnects. - clear(gt.guildNodeByID) - clear(gt.channelNodeByID) - gt.dmRootNode = nil -} - -func (gt *guildsTree) updateDMNodeStyle(userID discord.UserID) { - channel, err := gt.state.Cabinet.CreatePrivateChannel(userID) - if err != nil { - return - } - - node, ok := gt.channelNodeByID[channel.ID] - if node == nil || !ok { - return - } - gt.setNodeLineStyle(node, gt.channelNodeStyle(*channel)) -} - -func (gt *guildsTree) moveDMToFront(channelID discord.ChannelID) { - if gt.dmRootNode == nil { - return - } - - node := gt.channelNodeByID[channelID] - children := gt.dmRootNode.Children() - if index := slices.Index(children, node); index > 0 { - copy(children[1:index+1], children[:index]) - children[0] = node - } -} - -func (gt *guildsTree) createFolderNode(folder gateway.GuildFolder, guildsByID map[discord.GuildID]*gateway.GuildCreateEvent) { - name := "Folder" - if folder.Name != "" { - name = folder.Name - } - - folderNode := tree.NewNode(name).SetExpanded(gt.cfg.Theme.GuildsTree.AutoExpandFolders) - if folder.Color != 0 { - folderStyle := tcell.StyleDefault.Foreground(tcell.NewHexColor(int32(folder.Color))) - gt.setNodeLineStyle(folderNode, folderStyle) - } - gt.Root().AddChild(folderNode) - - for _, guildID := range folder.GuildIDs { - if guildEvent, ok := guildsByID[guildID]; ok { - gt.createGuildNode(folderNode, guildEvent.Guild) - } - } -} - -func (gt *guildsTree) unreadStyle(indication ningen.UnreadIndication) tcell.Style { - var style tcell.Style - switch indication { - case ningen.ChannelRead: - style = style.Dim(true) - case ningen.ChannelMentioned: - style = style.Underline(true) - fallthrough - case ningen.ChannelUnread: - style = style.Bold(true) - } - - return style -} - -func (gt *guildsTree) guildNodeStyle(guildID discord.GuildID) tcell.Style { - indication := gt.state.GuildIsUnread(guildID, ningen.GuildUnreadOpts{IncludeMutedCategories: true}) - return gt.unreadStyle(indication) -} - -func (gt *guildsTree) channelNodeStyle(channel discord.Channel) tcell.Style { - unread := gt.unreadStyle(gt.state.ChannelIsUnread(channel.ID, ningen.UnreadOpts{IncludeMutedCategories: true})) - if channel.Type != discord.DirectMessage || len(channel.DMRecipients) != 1 { - return unread - } - - recipient := channel.DMRecipients[0] - presence, err := gt.state.Cabinet.Presence(discord.NullGuildID, recipient.ID) - if err != nil { - return tview.MergeStyle(gt.dmStatusStyle(discord.OfflineStatus), unread) - } - - return tview.MergeStyle(gt.dmStatusStyle(presence.Status), unread) -} - -func (gt *guildsTree) dmStatusStyle(status discord.Status) tcell.Style { - switch status { - case discord.DoNotDisturbStatus: - return gt.cfg.Theme.GuildsTree.DNDStyle.Style - case discord.IdleStatus: - return gt.cfg.Theme.GuildsTree.IdleStyle.Style - case discord.OnlineStatus: - return gt.cfg.Theme.GuildsTree.OnlineStyle.Style - default: - return gt.cfg.Theme.GuildsTree.OfflineStyle.Style - } -} - -func (gt *guildsTree) createGuildNode(parent *tree.Node, guild discord.Guild) { - guildNode := tree.NewNode(guild.Name). - SetReference(guild.ID). - SetExpandable(true). - SetExpanded(false). - SetIndent(gt.cfg.Sidebar.Indents.Guild) - gt.setNodeLineStyle(guildNode, gt.guildNodeStyle(guild.ID)) - parent.AddChild(guildNode) - gt.guildNodeByID[guild.ID] = guildNode -} - -func (gt *guildsTree) createChannelNode(parent *tree.Node, channel discord.Channel) { - if channel.Type != discord.DirectMessage && channel.Type != discord.GroupDM && channel.Type != discord.GuildCategory && !gt.state.HasPermissions(channel.ID, discord.PermissionViewChannel) { - return - } - - indents := gt.cfg.Sidebar.Indents - channelNode := tree.NewNode(ui.ChannelToString(channel, gt.cfg.Icons, gt.state)).SetReference(channel.ID) - gt.setNodeLineStyle(channelNode, gt.channelNodeStyle(channel)) - switch channel.Type { - case discord.DirectMessage: - channelNode.SetIndent(indents.DM) - case discord.GroupDM: - channelNode.SetIndent(indents.GroupDM) - case discord.GuildCategory: - channelNode.SetIndent(indents.Category) - channelNode.SetExpandable(true).SetExpanded(true) - case discord.GuildForum: - channelNode.SetIndent(indents.Forum) - channelNode.SetExpandable(true).SetExpanded(false) - default: - channelNode.SetIndent(indents.Channel) - } - parent.AddChild(channelNode) - gt.channelNodeByID[channel.ID] = channelNode -} - -func (gt *guildsTree) setNodeLineStyle(node *tree.Node, style tcell.Style) { - line := node.Line() - for i := range line { - line[i].Style = style - } - node.SetLine(line) -} - -func (gt *guildsTree) createChannelNodes(node *tree.Node, channels []discord.Channel) { - // Preserve exact ordering semantics: - // 1) top-level non-categories (in input order), - // 2) categories that have at least one child in the source slice (in input order), - // 3) parented channels under already-created categories (in input order). - // - // We precompute parent presence once to avoid the O(n^2) category-child scan. - hasChildByParentID := make(map[discord.ChannelID]struct{}, len(channels)) - for _, channel := range channels { - if channel.ParentID.IsValid() { - hasChildByParentID[channel.ParentID] = struct{}{} - } - } - - for _, channel := range channels { - if channel.Type != discord.GuildCategory && !channel.ParentID.IsValid() { - gt.createChannelNode(node, channel) - } - } - - for _, channel := range channels { - if channel.Type == discord.GuildCategory { - if _, ok := hasChildByParentID[channel.ID]; ok { - gt.createChannelNode(node, channel) - } - } - } - - for _, channel := range channels { - if channel.ParentID.IsValid() { - // Parent categories are inserted earlier in this function, so this - // lookup is O(1) and avoids per-channel subtree walks. - parent := gt.channelNodeByID[channel.ParentID] - if parent != nil { - gt.createChannelNode(parent, channel) - } - } - } -} - -func isThread(t discord.ChannelType) bool { - switch t { - case discord.GuildPublicThread, discord.GuildPrivateThread, discord.GuildAnnouncementThread: - return true - default: - return false - } -} - -func (gt *guildsTree) onSelected(node *tree.Node) tview.Cmd { - if len(node.Children()) != 0 { - node.SetExpanded(!node.Expanded()) - return nil - } - - switch ref := node.Reference().(type) { - case discord.GuildID: - go gt.state.MemberState.Subscribe(ref) - - channels, err := gt.state.Cabinet.Channels(ref) - if err != nil { - slog.Error("failed to get channels", "err", err, "guild_id", ref) - return nil - } - - ui.SortGuildChannels(channels) - gt.createChannelNodes(node, channels) - node.Expand() - return nil - case discord.ChannelID: - channel, err := gt.state.Cabinet.Channel(ref) - if err != nil { - slog.Error("failed to get channel from state", "err", err, "channel_id", ref) - return nil - } - - // Forums contain threads, not messages; load the threads as children. - if channel.Type == discord.GuildForum { - allChannels, err := gt.state.Cabinet.Channels(channel.GuildID) - if err != nil { - slog.Error("failed to get channels for forum threads", "err", err, "guild_id", channel.GuildID) - return nil - } - - for _, ch := range allChannels { - if ch.ParentID == channel.ID && isThread(ch.Type) { - gt.createChannelNode(node, ch) - } - } - node.Expand() - return nil - } - - return gt.loadChannel(*channel) - case dmNode: // Direct messages folder - channels, err := gt.state.PrivateChannels() - if err != nil { - slog.Error("failed to get private channels", "err", err) - return nil - } - - ui.SortPrivateChannels(channels) - for _, c := range channels { - gt.createChannelNode(node, c) - } - node.Expand() - return nil - } - return nil -} - -func (gt *guildsTree) loadChannel(channel discord.Channel) tview.Cmd { - limit := uint(gt.cfg.MessagesLimit) - return func() tview.Msg { - messages, err := gt.state.Messages(channel.ID, limit) - if err != nil { - slog.Error("failed to get messages", "err", err, "channel_id", channel.ID, "limit", limit) - return nil - } - - // The tree node may hold an older channel snapshot. - if lastMessageID := gt.state.LastMessage(channel.ID); lastMessageID.IsValid() { - go gt.state.ReadState.MarkRead(channel.ID, lastMessageID) - } - - return channelLoadedMsg{Channel: channel, Messages: messages} - } -} - -func (gt *guildsTree) collapseParentNode(node *tree.Node) { - path := gt.GetPath(node) - if len(path) < 3 { - return - } - parent := path[len(path)-2] - parent.Collapse() - gt.SetCurrentNode(parent) -} - -func (gt *guildsTree) Update(msg tview.Msg) tview.Cmd { - switch msg := msg.(type) { - case tree.SelectedMsg: - return gt.onSelected(msg.Node) - case tview.KeyMsg: - switch { - case keybind.Matches(msg, gt.cfg.Keybinds.GuildsTree.CollapseAll.Keybind): - for _, node := range gt.Root().Children() { - node.CollapseAll() - } - return nil - case keybind.Matches(msg, gt.cfg.Keybinds.GuildsTree.CollapseParentNode.Keybind): - gt.collapseParentNode(gt.CurrentNode()) - return nil - case keybind.Matches(msg, gt.cfg.Keybinds.GuildsTree.YankID.Keybind): - return gt.yankID() - } - } - return gt.Model.Update(msg) -} - -func (gt *guildsTree) yankID() tview.Cmd { - node := gt.CurrentNode() - if node == nil { - return nil - } - - // Reference of a tree node in the guilds tree is its ID. - // discord.Snowflake (discord.GuildID and discord.ChannelID) have the String method. - if id, ok := node.Reference().(fmt.Stringer); ok { - return func() tview.Msg { - if _, err := clipboard.Write(context.Background(), clipboard.FmtText, []byte(id.String())); err != nil { - slog.Error("failed to write to clipboard", "err", err) - return nil - } - return nil - } - } - return nil -} - -func (gt *guildsTree) findNodeByReference(reference any) *tree.Node { - switch ref := reference.(type) { - case discord.GuildID: - return gt.guildNodeByID[ref] - case discord.ChannelID: - return gt.channelNodeByID[ref] - case dmNode: - return gt.dmRootNode - default: - // Fallback keeps this helper safe for non-indexed custom references. - var found *tree.Node - gt.Root().Walk(func(node, _ *tree.Node) bool { - if node.Reference() == reference { - found = node - return false - } - return true - }) - return found - } -} - -func (gt *guildsTree) findNodeByChannelID(channelID discord.ChannelID) *tree.Node { - channel, err := gt.state.Cabinet.Channel(channelID) - if err != nil { - slog.Error("failed to get channel", "channel_id", channelID, "err", err) - return nil - } - - var reference any - if guildID := channel.GuildID; guildID.IsValid() { - reference = guildID - } else { - reference = dmNode{} - } - if parent := gt.findNodeByReference(reference); parent != nil { - if len(parent.Children()) == 0 { - gt.onSelected(parent) - } - } - - node := gt.findNodeByReference(channelID) - return node -} - -func (gt *guildsTree) expandPathToNode(node *tree.Node) { - if node == nil { - return - } - for _, n := range gt.GetPath(node) { - n.Expand() - } -} - -var _ help.KeyMap = (*guildsTree)(nil) - -func (gt *guildsTree) selectCurrentKeybind() keybind.Keybind { - selectCurrent := gt.cfg.Keybinds.GuildsTree.SelectCurrent.Keybind - selectHelp := selectCurrent.Help() - selectDesc := selectHelp.Desc - if node := gt.CurrentNode(); node != nil { - if len(node.Children()) > 0 { - if node.Expanded() { - selectDesc = "collapse" - } else { - selectDesc = "expand" - } - } else { - switch node.Reference().(type) { - case discord.GuildID, dmNode: - selectDesc = "expand" - } - } - } - selectCurrent.SetHelp(selectHelp.Key, selectDesc) - return selectCurrent -} - -func (gt *guildsTree) ShortHelp() []keybind.Keybind { - cfg := gt.cfg.Keybinds.GuildsTree - shortHelp := []keybind.Keybind{cfg.SelectUp.Keybind, cfg.SelectDown.Keybind, gt.selectCurrentKeybind()} - if gt.canCollapseParent(gt.CurrentNode()) { - shortHelp = append(shortHelp, cfg.CollapseParentNode.Keybind) - } - return shortHelp -} - -func (gt *guildsTree) FullHelp() [][]keybind.Keybind { - cfg := gt.cfg.Keybinds.GuildsTree - selectGroup := []keybind.Keybind{gt.selectCurrentKeybind(), cfg.MoveToParentNode.Keybind} - selectGroup = append(selectGroup, gt.collapseKeybinds()...) - - return [][]keybind.Keybind{ - {cfg.SelectUp.Keybind, cfg.SelectDown.Keybind, cfg.SelectTop.Keybind, cfg.SelectBottom.Keybind}, - selectGroup, - {cfg.YankID.Keybind}, - } -} - -func (gt *guildsTree) collapseKeybinds() []keybind.Keybind { - cfg := gt.cfg.Keybinds.GuildsTree - - var keybinds []keybind.Keybind - if gt.canCollapseParent(gt.CurrentNode()) { - keybinds = append(keybinds, cfg.CollapseParentNode.Keybind) - } - if gt.canCollapseAll() { - keybinds = append(keybinds, cfg.CollapseAll.Keybind) - } - return keybinds -} - -func (gt *guildsTree) canCollapseParent(node *tree.Node) bool { - return node != nil && len(gt.GetPath(node)) >= 3 -} - -func (gt *guildsTree) canCollapseAll() bool { - var can bool - for _, node := range gt.Root().Children() { - if node.Expanded() { - can = true - break - } - } - return can -} diff --git a/internal/ui/chat/guildstree/model.go b/internal/ui/chat/guildstree/model.go new file mode 100644 index 0000000..907b0e1 --- /dev/null +++ b/internal/ui/chat/guildstree/model.go @@ -0,0 +1,499 @@ +package guildstree + +import ( + "log/slog" + "slices" + + "github.com/ayn2op/arikawa/v3/discord" + "github.com/ayn2op/arikawa/v3/gateway" + "github.com/ayn2op/discordo/internal/config" + "github.com/ayn2op/discordo/internal/ui" + "github.com/ayn2op/ningen/v3" + "github.com/ayn2op/ningen/v3/states/read" + "github.com/ayn2op/tview" + "github.com/ayn2op/tview/help" + "github.com/ayn2op/tview/keybind" + "github.com/ayn2op/tview/tree" + "github.com/gdamore/tcell/v3" +) + +type dmNode struct{} + +type Model struct { + *tree.Model + + cfg *config.Config + state *ningen.State + + // Fast-path indexes for frequent event handlers (read updates, picker + // navigation). They mirror the current rendered tree and are rebuilt on + // READY before nodes are added. + guildNodeByID map[discord.GuildID]*tree.Node + channelNodeByID map[discord.ChannelID]*tree.Node + dmRootNode *tree.Node +} + +func NewModel(cfg *config.Config, state *ningen.State) *Model { + m := &Model{ + Model: tree.NewModel(), + cfg: cfg, + state: state, + + guildNodeByID: make(map[discord.GuildID]*tree.Node), + channelNodeByID: make(map[discord.ChannelID]*tree.Node), + } + ui.ConfigureBox(m.Box, &cfg.Theme) + m. + SetRoot(tree.NewNode("")). + SetTopLevel(1). + SetMarkers(tree.Markers{ + Expanded: cfg.Sidebar.Markers.Expanded, + Collapsed: cfg.Sidebar.Markers.Collapsed, + Leaf: cfg.Sidebar.Markers.Leaf, + }). + SetGraphics(cfg.Theme.GuildsTree.Graphics). + SetGraphicsColor(tcell.GetColor(cfg.Theme.GuildsTree.GraphicsColor)). + SetTitle("Guilds") + m.SetKeybinds(tree.Keybinds{ + Up: cfg.Keybinds.GuildsTree.SelectUp.Keybind, + Down: cfg.Keybinds.GuildsTree.SelectDown.Keybind, + Top: cfg.Keybinds.GuildsTree.SelectTop.Keybind, + Bottom: cfg.Keybinds.GuildsTree.SelectBottom.Keybind, + MoveToParent: cfg.Keybinds.GuildsTree.MoveToParentNode.Keybind, + Select: cfg.Keybinds.GuildsTree.SelectCurrent.Keybind, + }) + + return m +} + +func (m *Model) reset() *tree.Node { + // Keep allocated map capacity; READY can rebuild often during reconnects. + clear(m.guildNodeByID) + clear(m.channelNodeByID) + m.dmRootNode = tree.NewNode("Direct Messages"). + SetReference(dmNode{}). + SetExpandable(true). + SetExpanded(false) + return m.Root().ClearChildren().AddChild(m.dmRootNode) +} + +func (m *Model) rebuild(event *gateway.ReadyEvent) { + root := m.reset() + guildsInFolders := make(map[discord.GuildID]bool) + for _, folder := range event.UserSettings.GuildFolders { + for _, guildID := range folder.GuildIDs { + guildsInFolders[guildID] = true + } + } + + guildsByID := make(map[discord.GuildID]*gateway.GuildCreateEvent, len(event.Guilds)) + for i := range event.Guilds { + guildsByID[event.Guilds[i].ID] = &event.Guilds[i] + } + + positions := event.UserSettings.GuildPositions + if len(positions) == 0 { + positions = make([]discord.GuildID, 0, len(event.Guilds)) + for _, guild := range event.Guilds { + positions = append(positions, guild.ID) + } + } + for _, guildID := range positions { + if guild := guildsByID[guildID]; guild != nil && !guildsInFolders[guildID] { + m.createGuildNode(root, guild.Guild) + } + } + + for _, folder := range event.UserSettings.GuildFolders { + if folder.ID == 0 && len(folder.GuildIDs) == 1 { + if guild := guildsByID[folder.GuildIDs[0]]; guild != nil { + m.createGuildNode(root, guild.Guild) + } + } else { + m.createFolderNode(folder, guildsByID) + } + } + m.SetCurrentNode(root) +} + +func (m *Model) refreshReadStyles(event *read.UpdateEvent) { + if event.GuildID.IsValid() { + if node := m.findNodeByReference(event.GuildID); node != nil { + m.setNodeLineStyle(node, m.guildNodeStyle(event.GuildID)) + } + } + + node := m.findNodeByReference(event.ChannelID) + if node == nil { + return + } + channel, err := m.state.Cabinet.Channel(event.ChannelID) + if err != nil { + indication := m.state.ChannelIsUnread(event.ChannelID, ningen.UnreadOpts{IncludeMutedCategories: true}) + m.setNodeLineStyle(node, unreadStyle(indication)) + return + } + m.setNodeLineStyle(node, m.channelNodeStyle(*channel)) +} + +func (m *Model) updateDMNodeStyle(userID discord.UserID) { + channel, err := m.state.Cabinet.CreatePrivateChannel(userID) + if err != nil { + return + } + + node, ok := m.channelNodeByID[channel.ID] + if node == nil || !ok { + return + } + m.setNodeLineStyle(node, m.channelNodeStyle(*channel)) +} + +func (m *Model) moveDMToFront(channelID discord.ChannelID) { + if m.dmRootNode == nil { + return + } + + node := m.channelNodeByID[channelID] + children := m.dmRootNode.Children() + if index := slices.Index(children, node); index > 0 { + copy(children[1:index+1], children[:index]) + children[0] = node + } +} + +func (m *Model) createFolderNode(folder gateway.GuildFolder, guildsByID map[discord.GuildID]*gateway.GuildCreateEvent) { + name := "Folder" + if folder.Name != "" { + name = folder.Name + } + + folderNode := tree.NewNode(name).SetExpanded(m.cfg.Theme.GuildsTree.AutoExpandFolders) + if folder.Color != 0 { + folderStyle := tcell.StyleDefault.Foreground(tcell.NewHexColor(int32(folder.Color))) + m.setNodeLineStyle(folderNode, folderStyle) + } + m.Root().AddChild(folderNode) + + for _, guildID := range folder.GuildIDs { + if guildEvent, ok := guildsByID[guildID]; ok { + m.createGuildNode(folderNode, guildEvent.Guild) + } + } +} + +func (m *Model) guildNodeStyle(guildID discord.GuildID) tcell.Style { + indication := m.state.GuildIsUnread(guildID, ningen.GuildUnreadOpts{IncludeMutedCategories: true}) + return unreadStyle(indication) +} + +func (m *Model) channelNodeStyle(channel discord.Channel) tcell.Style { + unread := unreadStyle(m.state.ChannelIsUnread(channel.ID, ningen.UnreadOpts{IncludeMutedCategories: true})) + if channel.Type != discord.DirectMessage || len(channel.DMRecipients) != 1 { + return unread + } + + recipient := channel.DMRecipients[0] + presence, err := m.state.Cabinet.Presence(discord.NullGuildID, recipient.ID) + if err != nil { + return tview.MergeStyle(m.dmStatusStyle(discord.OfflineStatus), unread) + } + + return tview.MergeStyle(m.dmStatusStyle(presence.Status), unread) +} + +func (m *Model) dmStatusStyle(status discord.Status) tcell.Style { + switch status { + case discord.DoNotDisturbStatus: + return m.cfg.Theme.GuildsTree.DNDStyle.Style + case discord.IdleStatus: + return m.cfg.Theme.GuildsTree.IdleStyle.Style + case discord.OnlineStatus: + return m.cfg.Theme.GuildsTree.OnlineStyle.Style + default: + return m.cfg.Theme.GuildsTree.OfflineStyle.Style + } +} + +func (m *Model) createGuildNode(parent *tree.Node, guild discord.Guild) { + guildNode := tree.NewNode(guild.Name). + SetReference(guild.ID). + SetExpandable(true). + SetExpanded(false). + SetIndent(m.cfg.Sidebar.Indents.Guild) + m.setNodeLineStyle(guildNode, m.guildNodeStyle(guild.ID)) + parent.AddChild(guildNode) + m.guildNodeByID[guild.ID] = guildNode +} + +func (m *Model) createChannelNode(parent *tree.Node, channel discord.Channel) { + if channel.Type != discord.DirectMessage && channel.Type != discord.GroupDM && channel.Type != discord.GuildCategory && !m.state.HasPermissions(channel.ID, discord.PermissionViewChannel) { + return + } + + indents := m.cfg.Sidebar.Indents + channelNode := tree.NewNode(ui.ChannelToString(channel, m.cfg.Icons, m.state)).SetReference(channel.ID) + m.setNodeLineStyle(channelNode, m.channelNodeStyle(channel)) + switch channel.Type { + case discord.DirectMessage: + channelNode.SetIndent(indents.DM) + case discord.GroupDM: + channelNode.SetIndent(indents.GroupDM) + case discord.GuildCategory: + channelNode.SetIndent(indents.Category) + channelNode.SetExpandable(true).SetExpanded(true) + case discord.GuildForum: + channelNode.SetIndent(indents.Forum) + channelNode.SetExpandable(true).SetExpanded(false) + default: + channelNode.SetIndent(indents.Channel) + } + parent.AddChild(channelNode) + m.channelNodeByID[channel.ID] = channelNode +} + +func (m *Model) setNodeLineStyle(node *tree.Node, style tcell.Style) { + line := node.Line() + for i := range line { + line[i].Style = style + } + node.SetLine(line) +} + +func (m *Model) createChannelNodes(node *tree.Node, channels []discord.Channel) { + // Preserve exact ordering semantics: + // 1) top-level non-categories (in input order), + // 2) categories that have at least one child in the source slice (in input order), + // 3) parented channels under already-created categories (in input order). + // + // We precompute parent presence once to avoid the O(n^2) category-child scan. + hasChildByParentID := make(map[discord.ChannelID]struct{}, len(channels)) + for _, channel := range channels { + if channel.ParentID.IsValid() { + hasChildByParentID[channel.ParentID] = struct{}{} + } + } + + for _, channel := range channels { + if channel.Type != discord.GuildCategory && !channel.ParentID.IsValid() { + m.createChannelNode(node, channel) + } + } + + for _, channel := range channels { + if channel.Type == discord.GuildCategory { + if _, ok := hasChildByParentID[channel.ID]; ok { + m.createChannelNode(node, channel) + } + } + } + + for _, channel := range channels { + if channel.ParentID.IsValid() { + // Parent categories are inserted earlier in this function, so this + // lookup is O(1) and avoids per-channel subtree walks. + parent := m.channelNodeByID[channel.ParentID] + if parent != nil { + m.createChannelNode(parent, channel) + } + } + } +} + +func isThread(t discord.ChannelType) bool { + switch t { + case discord.GuildPublicThread, discord.GuildPrivateThread, discord.GuildAnnouncementThread: + return true + default: + return false + } +} + +func (m *Model) collapseParentNode(node *tree.Node) { + path := m.GetPath(node) + if len(path) < 3 { + return + } + parent := path[len(path)-2] + parent.Collapse() + m.SetCurrentNode(parent) +} + +func (m *Model) Update(msg tview.Msg) tview.Cmd { + switch msg := msg.(type) { + case *gateway.ReadyEvent: + m.rebuild(msg) + return nil + case *gateway.MessageCreateEvent: + if !msg.GuildID.IsValid() { + m.moveDMToFront(msg.ChannelID) + } + return nil + case *gateway.PresenceUpdateEvent: + m.updateDMNodeStyle(msg.User.ID) + return nil + case *read.UpdateEvent: + m.refreshReadStyles(msg) + return nil + + case NavigateMsg: + return m.navigate(msg.ChannelID) + case tree.SelectedMsg: + return m.selectNode(msg.Node) + case tview.KeyMsg: + switch { + case keybind.Matches(msg, m.cfg.Keybinds.GuildsTree.CollapseAll.Keybind): + for _, node := range m.Root().Children() { + node.CollapseAll() + } + return nil + case keybind.Matches(msg, m.cfg.Keybinds.GuildsTree.CollapseParentNode.Keybind): + m.collapseParentNode(m.CurrentNode()) + return nil + case keybind.Matches(msg, m.cfg.Keybinds.GuildsTree.YankID.Keybind): + return yankID(m.CurrentNode()) + } + } + return m.Model.Update(msg) +} + +func (m *Model) findNodeByReference(reference any) *tree.Node { + switch ref := reference.(type) { + case discord.GuildID: + return m.guildNodeByID[ref] + case discord.ChannelID: + return m.channelNodeByID[ref] + case dmNode: + return m.dmRootNode + default: + // Fallback keeps this helper safe for non-indexed custom references. + var found *tree.Node + m.Root().Walk(func(node, _ *tree.Node) bool { + if node.Reference() == reference { + found = node + return false + } + return true + }) + return found + } +} + +func (m *Model) findNodeByChannelID(channelID discord.ChannelID) *tree.Node { + channel, err := m.state.Cabinet.Channel(channelID) + if err != nil { + slog.Error("failed to get channel", "channel_id", channelID, "err", err) + return nil + } + + var reference any + if guildID := channel.GuildID; guildID.IsValid() { + reference = guildID + } else { + reference = dmNode{} + } + if parent := m.findNodeByReference(reference); parent != nil { + if len(parent.Children()) == 0 { + m.selectNode(parent) + } + } + + node := m.findNodeByReference(channelID) + return node +} + +func (m *Model) expandPathToNode(node *tree.Node) { + if node == nil { + return + } + for _, n := range m.GetPath(node) { + n.Expand() + } +} + +var _ help.KeyMap = (*Model)(nil) + +func (m *Model) selectCurrentKeybind() keybind.Keybind { + selectCurrent := m.cfg.Keybinds.GuildsTree.SelectCurrent.Keybind + selectHelp := selectCurrent.Help() + selectDesc := selectHelp.Desc + if node := m.CurrentNode(); node != nil { + if len(node.Children()) > 0 { + if node.Expanded() { + selectDesc = "collapse" + } else { + selectDesc = "expand" + } + } else { + switch node.Reference().(type) { + case discord.GuildID, dmNode: + selectDesc = "expand" + } + } + } + selectCurrent.SetHelp(selectHelp.Key, selectDesc) + return selectCurrent +} + +func (m *Model) ShortHelp() []keybind.Keybind { + cfg := m.cfg.Keybinds.GuildsTree + shortHelp := []keybind.Keybind{cfg.SelectUp.Keybind, cfg.SelectDown.Keybind, m.selectCurrentKeybind()} + if m.canCollapseParent(m.CurrentNode()) { + shortHelp = append(shortHelp, cfg.CollapseParentNode.Keybind) + } + return shortHelp +} + +func (m *Model) FullHelp() [][]keybind.Keybind { + cfg := m.cfg.Keybinds.GuildsTree + selectGroup := []keybind.Keybind{m.selectCurrentKeybind(), cfg.MoveToParentNode.Keybind} + selectGroup = append(selectGroup, m.collapseKeybinds()...) + + return [][]keybind.Keybind{ + {cfg.SelectUp.Keybind, cfg.SelectDown.Keybind, cfg.SelectTop.Keybind, cfg.SelectBottom.Keybind}, + selectGroup, + {cfg.YankID.Keybind}, + } +} + +func (m *Model) collapseKeybinds() []keybind.Keybind { + cfg := m.cfg.Keybinds.GuildsTree + + var keybinds []keybind.Keybind + if m.canCollapseParent(m.CurrentNode()) { + keybinds = append(keybinds, cfg.CollapseParentNode.Keybind) + } + if m.canCollapseAll() { + keybinds = append(keybinds, cfg.CollapseAll.Keybind) + } + return keybinds +} + +func (m *Model) canCollapseParent(node *tree.Node) bool { + return node != nil && len(m.GetPath(node)) >= 3 +} + +func (m *Model) canCollapseAll() bool { + var can bool + for _, node := range m.Root().Children() { + if node.Expanded() { + can = true + break + } + } + return can +} + +func unreadStyle(indication ningen.UnreadIndication) tcell.Style { + var style tcell.Style + switch indication { + case ningen.ChannelRead: + style = style.Dim(true) + case ningen.ChannelMentioned: + style = style.Underline(true) + fallthrough + case ningen.ChannelUnread: + style = style.Bold(true) + } + return style +} diff --git a/internal/ui/chat/guilds_tree_test.go b/internal/ui/chat/guildstree/model_test.go similarity index 72% rename from internal/ui/chat/guilds_tree_test.go rename to internal/ui/chat/guildstree/model_test.go index 0e53bbb..21ff5a3 100644 --- a/internal/ui/chat/guilds_tree_test.go +++ b/internal/ui/chat/guildstree/model_test.go @@ -1,15 +1,14 @@ -package chat +package guildstree import ( "slices" "testing" "github.com/ayn2op/arikawa/v3/discord" - "github.com/ayn2op/arikawa/v3/gateway" "github.com/ayn2op/tview/tree" ) -func TestMessageCreatePromotesDMChannel(t *testing.T) { +func TestMoveDMToFront(t *testing.T) { firstID := discord.ChannelID(1) secondID := discord.ChannelID(2) thirdID := discord.ChannelID(3) @@ -18,16 +17,16 @@ func TestMessageCreatePromotesDMChannel(t *testing.T) { second := tree.NewNode("second").SetReference(secondID) third := tree.NewNode("third").SetReference(thirdID) dmRoot := tree.NewNode("Direct Messages").SetChildren([]*tree.Node{first, second, third}) - m := Model{guildsTree: &guildsTree{ + m := Model{ dmRootNode: dmRoot, channelNodeByID: map[discord.ChannelID]*tree.Node{ firstID: first, secondID: second, thirdID: third, }, - }} + } - m.onMessageCreate(&gateway.MessageCreateEvent{Message: discord.Message{ChannelID: thirdID}}) + m.moveDMToFront(thirdID) want := []*tree.Node{third, first, second} if !slices.Equal(dmRoot.Children(), want) { @@ -35,16 +34,16 @@ func TestMessageCreatePromotesDMChannel(t *testing.T) { } } -func TestMessageCreateIgnoresUnrenderedDMChannel(t *testing.T) { +func TestMoveDMToFrontIgnoresUnrenderedChannel(t *testing.T) { firstID := discord.ChannelID(1) first := tree.NewNode("first").SetReference(firstID) dmRoot := tree.NewNode("Direct Messages").SetChildren([]*tree.Node{first}) - m := Model{guildsTree: &guildsTree{ + m := Model{ dmRootNode: dmRoot, channelNodeByID: map[discord.ChannelID]*tree.Node{}, - }} + } - m.onMessageCreate(&gateway.MessageCreateEvent{Message: discord.Message{ChannelID: 2}}) + m.moveDMToFront(discord.ChannelID(2)) children := dmRoot.Children() if len(children) != 1 || children[0] != first { diff --git a/internal/ui/chat/guildstree/msg.go b/internal/ui/chat/guildstree/msg.go new file mode 100644 index 0000000..2a92807 --- /dev/null +++ b/internal/ui/chat/guildstree/msg.go @@ -0,0 +1,125 @@ +package guildstree + +import ( + "context" + "fmt" + "log/slog" + + "github.com/ayn2op/arikawa/v3/discord" + "github.com/ayn2op/discordo/internal/ui" + "github.com/ayn2op/ningen/v3" + "github.com/ayn2op/tview" + "github.com/ayn2op/tview/tree" + "golang.design/x/clipboard" +) + +type ChannelLoadedMsg struct { + Channel discord.Channel + Messages []discord.Message +} + +type NavigateMsg struct{ ChannelID discord.ChannelID } + +func (m *Model) navigate(channelID discord.ChannelID) tview.Cmd { + channel, err := m.state.Cabinet.Channel(channelID) + if err != nil { + slog.Error("failed to get channel from state", "err", err, "channel_id", channelID) + return nil + } + node := m.findNodeByChannelID(channelID) + if node == nil { + slog.Error("failed to locate channel in tree", "channel_id", channelID) + return nil + } + m.expandPathToNode(node) + m.SetCurrentNode(node) + if channel.Type == discord.GuildCategory { + return nil + } + return m.selectNode(node) +} + +func (m *Model) selectNode(node *tree.Node) tview.Cmd { + if len(node.Children()) != 0 { + node.SetExpanded(!node.Expanded()) + return nil + } + + switch ref := node.Reference().(type) { + case discord.GuildID: + go m.state.MemberState.Subscribe(ref) + channels, err := m.state.Cabinet.Channels(ref) + if err != nil { + slog.Error("failed to get channels", "err", err, "guild_id", ref) + return nil + } + ui.SortGuildChannels(channels) + m.createChannelNodes(node, channels) + node.Expand() + case discord.ChannelID: + channel, err := m.state.Cabinet.Channel(ref) + if err != nil { + slog.Error("failed to get channel from state", "err", err, "channel_id", ref) + return nil + } + if channel.Type != discord.GuildForum { + return loadChannel(m.state, uint(m.cfg.MessagesLimit), *channel) + } + channels, err := m.state.Cabinet.Channels(channel.GuildID) + if err != nil { + slog.Error("failed to get channels for forum threads", "err", err, "guild_id", channel.GuildID) + return nil + } + for _, child := range channels { + if child.ParentID == channel.ID && isThread(child.Type) { + m.createChannelNode(node, child) + } + } + node.Expand() + case dmNode: + channels, err := m.state.PrivateChannels() + if err != nil { + slog.Error("failed to get private channels", "err", err) + return nil + } + ui.SortPrivateChannels(channels) + for _, channel := range channels { + m.createChannelNode(node, channel) + } + node.Expand() + } + return nil +} + +func loadChannel(state *ningen.State, limit uint, channel discord.Channel) tview.Cmd { + return func() tview.Msg { + messages, err := state.Messages(channel.ID, limit) + if err != nil { + slog.Error("failed to get messages", "err", err, "channel_id", channel.ID, "limit", limit) + return nil + } + + if lastMessageID := state.LastMessage(channel.ID); lastMessageID.IsValid() { + go state.ReadState.MarkRead(channel.ID, lastMessageID) + } + + return ChannelLoadedMsg{Channel: channel, Messages: messages} + } +} + +func yankID(node *tree.Node) tview.Cmd { + if node == nil { + return nil + } + + id, ok := node.Reference().(fmt.Stringer) + if !ok { + return nil + } + return func() tview.Msg { + if _, err := clipboard.Write(context.Background(), clipboard.FmtText, []byte(id.String())); err != nil { + slog.Error("failed to write to clipboard", "err", err) + } + return nil + } +} diff --git a/internal/ui/chat/model.go b/internal/ui/chat/model.go index ce319c6..8d26db6 100644 --- a/internal/ui/chat/model.go +++ b/internal/ui/chat/model.go @@ -20,6 +20,7 @@ import ( "github.com/ayn2op/discordo/internal/ui" "github.com/ayn2op/discordo/internal/ui/chat/attachmentspicker" "github.com/ayn2op/discordo/internal/ui/chat/channelspicker" + "github.com/ayn2op/discordo/internal/ui/chat/guildstree" "github.com/ayn2op/ningen/v3" "github.com/ayn2op/ningen/v3/states/read" "github.com/ayn2op/tview" @@ -48,7 +49,7 @@ type Model struct { // messagesList + composer rightFlex *flex.Model - guildsTree *guildsTree + guildsTree *guildstree.Model messagesList *messagesList composer *composer channelsPicker *channelspicker.Model @@ -103,7 +104,7 @@ func NewModel(cfg *config.Config, token string) *Model { } m.state.OnRequest = append(m.state.OnRequest, httputil.WithHeaders(http.Headers()), m.onRequest) - m.guildsTree = newGuildsTree(cfg, m.state) + m.guildsTree = guildstree.NewModel(cfg, m.state) m.messagesList = newMessagesList(cfg, m) m.composer = newComposer(cfg, m) m.channelsPicker = channelspicker.NewModel(cfg) @@ -185,25 +186,10 @@ func (m *Model) closeAttachmentsPicker() tview.Cmd { } func (m *Model) navigateToChannel(channelID discord.ChannelID) tview.Cmd { - channel, err := m.state.Cabinet.Channel(channelID) - if err != nil { - slog.Error("failed to get channel from state", "err", err, "channel_id", channelID) - return nil - } - - node := m.guildsTree.findNodeByChannelID(channel.ID) - if node == nil { - slog.Error("failed to locate channel in tree", "channel_id", channel.ID) - return nil - } - - m.guildsTree.expandPathToNode(node) - m.guildsTree.SetCurrentNode(node) - focus := m.closePicker() - if channel.Type != discord.GuildCategory { - return tview.Sequence(focus, m.guildsTree.onSelected(node)) - } - return focus + return tview.Sequence( + m.closePicker(), + m.guildsTree.Update(guildstree.NavigateMsg{ChannelID: channelID}), + ) } func (m *Model) toggleGuildsTree() tview.Cmd { @@ -319,7 +305,7 @@ func (m *Model) Update(msg tview.Msg) tview.Cmd { m.onReadUpdate(eventMsg) } return listen(m.events) - case channelLoadedMsg: + case guildstree.ChannelLoadedMsg: node := m.guildsTree.CurrentNode() if node == nil { return nil diff --git a/internal/ui/chat/msg.go b/internal/ui/chat/msg.go index 37f25a4..0701082 100644 --- a/internal/ui/chat/msg.go +++ b/internal/ui/chat/msg.go @@ -40,11 +40,6 @@ func listen(events <-chan gateway.Event) tview.Cmd { } } -type channelLoadedMsg struct { - Channel discord.Channel - Messages []discord.Message -} - type olderMessagesLoadedMsg struct { ChannelID discord.ChannelID Older []discord.Message diff --git a/internal/ui/chat/state.go b/internal/ui/chat/state.go index acabddc..043f49f 100644 --- a/internal/ui/chat/state.go +++ b/internal/ui/chat/state.go @@ -8,10 +8,8 @@ import ( "github.com/ayn2op/arikawa/v3/gateway" "github.com/ayn2op/arikawa/v3/utils/httputil/httpdriver" "github.com/ayn2op/arikawa/v3/utils/ws" - "github.com/ayn2op/ningen/v3" "github.com/ayn2op/ningen/v3/states/read" "github.com/ayn2op/tview" - "github.com/ayn2op/tview/tree" ) func (m *Model) onRequest(r httpdriver.Request) error { @@ -31,74 +29,13 @@ func (m *Model) onRaw(event *ws.RawEvent) { } func (m *Model) onReady(event *gateway.ReadyEvent) tview.Cmd { - // Rebuild indexes from scratch so reconnects and account switches do not - // retain pointers to detached tree nodes. - m.guildsTree.resetNodeIndex() - - dmNode := tree.NewNode("Direct Messages").SetReference(dmNode{}).SetExpandable(true).SetExpanded(false) - m.guildsTree.dmRootNode = dmNode - - root := m.guildsTree. - Root(). - ClearChildren(). - AddChild(dmNode) - - // Track guilds already in folders to find orphans. - // Newly joined guilds may not be synced to GuildFolders yet but always appear in guild positions. - guildsInFolders := make(map[discord.GuildID]bool) - for _, folder := range event.UserSettings.GuildFolders { - for _, guildID := range folder.GuildIDs { - guildsInFolders[guildID] = true - } - } - - guildsByID := make(map[discord.GuildID]*gateway.GuildCreateEvent, len(event.Guilds)) - for index := range event.Guilds { - guildsByID[event.Guilds[index].ID] = &event.Guilds[index] - } - - // Use GuildPositions for ordering (it's the canonical order). - // Guilds not in any folder are "orphans" - add them directly to root. - positions := event.UserSettings.GuildPositions - // GuildPositions is normally set; fall back to event order if not. - if len(positions) == 0 { - positions = make([]discord.GuildID, 0, len(event.Guilds)) - for _, guildEvent := range event.Guilds { - positions = append(positions, guildEvent.ID) - } - } - - for _, guildID := range positions { - // Already handled in folder processing below. - if guildsInFolders[guildID] { - continue - } - - if guildEvent, ok := guildsByID[guildID]; ok { - m.guildsTree.createGuildNode(root, guildEvent.Guild) - } - } - - // Process folders (real folders and single-guild "folders") - for _, folder := range event.UserSettings.GuildFolders { - if folder.ID == 0 && len(folder.GuildIDs) == 1 { - if guild, ok := guildsByID[folder.GuildIDs[0]]; ok { - m.guildsTree.createGuildNode(root, guild.Guild) - } - } else { - m.guildsTree.createFolderNode(folder, guildsByID) - } - } - - m.guildsTree.SetCurrentNode(root) + m.guildsTree.Update(event) m.setFocus(m.guildsTree) return nil } func (m *Model) onMessageCreate(message *gateway.MessageCreateEvent) tview.Cmd { - if !message.GuildID.IsValid() { - m.guildsTree.moveDMToFront(message.ChannelID) - } + m.guildsTree.Update(message) selectedChannel, ok := m.SelectedChannel() if ok && selectedChannel.ID == message.ChannelID { @@ -157,7 +94,7 @@ func (m *Model) notify(message gateway.MessageCreateEvent) tview.Cmd { } func (m *Model) onPresenceUpdate(presence *gateway.PresenceUpdateEvent) { - m.guildsTree.updateDMNodeStyle(presence.User.ID) + m.guildsTree.Update(presence) } func (m *Model) onMessageUpdate(message *gateway.MessageUpdateEvent) { @@ -250,23 +187,5 @@ func (m *Model) onTypingStart(event *gateway.TypingStartEvent) { } func (m *Model) onReadUpdate(event *read.UpdateEvent) { - // Use indexed node lookup to avoid walking the whole tree on every read event. - // This runs frequently while reading/typing across channels. - if event.GuildID.IsValid() { - if guildNode := m.guildsTree.findNodeByReference(event.GuildID); guildNode != nil { - m.guildsTree.setNodeLineStyle(guildNode, m.guildsTree.guildNodeStyle(event.GuildID)) - } - } - - // Channel style is always updated for the target channel regardless of - // whether it's in a guild or DM. - if channelNode := m.guildsTree.findNodeByReference(event.ChannelID); channelNode != nil { - channel, err := m.state.Cabinet.Channel(event.ChannelID) - if err != nil { - indication := m.state.ChannelIsUnread(event.ChannelID, ningen.UnreadOpts{IncludeMutedCategories: true}) - m.guildsTree.setNodeLineStyle(channelNode, m.guildsTree.unreadStyle(indication)) - return - } - m.guildsTree.setNodeLineStyle(channelNode, m.guildsTree.channelNodeStyle(*channel)) - } + m.guildsTree.Update(event) }