From 74f8438bc1b93068f7c6ce573ecd53e29f182701 Mon Sep 17 00:00:00 2001 From: ayn2op Date: Wed, 30 Sep 2026 16:40:08 -0400 Subject: [PATCH] refactor(ui): simplify models and messages --- go.mod | 4 +- go.sum | 8 +- internal/ui/chat/channelspicker/model.go | 10 +- internal/ui/chat/composer/model.go | 460 +++--------------- internal/ui/chat/composer/model_test.go | 4 +- internal/ui/chat/composer/msg.go | 349 +++++++++++++ internal/ui/chat/guildstree/keymap.go | 11 +- internal/ui/chat/guildstree/model.go | 8 +- internal/ui/chat/guildstree/msg.go | 8 + internal/ui/chat/keymap.go | 18 +- internal/ui/chat/memberstree/model.go | 18 +- internal/ui/chat/memberstree/msg.go | 5 + internal/ui/chat/mentionslist/model.go | 3 - internal/ui/chat/mentionslist/msg.go | 6 + internal/ui/chat/messageslist/model.go | 378 +------------- internal/ui/chat/messageslist/msg.go | 345 ++++++++++++- internal/ui/chat/model.go | 281 +++++------ internal/ui/chat/model_test.go | 6 +- internal/ui/chat/msg.go | 42 ++ internal/ui/chat/state.go | 16 +- internal/ui/login/form/model.go | 95 ---- internal/ui/login/keymap.go | 6 +- internal/ui/login/keymap_test.go | 6 +- internal/ui/login/model.go | 47 +- internal/ui/login/msg.go | 15 +- internal/ui/login/password/model.go | 73 ++- .../ui/login/{form => password}/model_test.go | 12 +- internal/ui/login/password/msg.go | 8 + internal/ui/login/qr/model.go | 6 - internal/ui/login/qr/msg.go | 7 + internal/ui/login/token/model.go | 53 +- internal/ui/login/token/msg.go | 7 + internal/ui/modal.go | 2 +- internal/ui/picker/msg.go | 6 + internal/ui/picker/picker.go | 3 - internal/ui/root/keymap.go | 4 +- internal/ui/root/model.go | 65 +-- internal/ui/root/msg.go | 30 ++ internal/ui/tree/msg.go | 29 ++ internal/ui/tree/tree.go | 30 +- 40 files changed, 1222 insertions(+), 1262 deletions(-) create mode 100644 internal/ui/chat/composer/msg.go create mode 100644 internal/ui/chat/mentionslist/msg.go delete mode 100644 internal/ui/login/form/model.go rename internal/ui/login/{form => password}/model_test.go (70%) create mode 100644 internal/ui/picker/msg.go create mode 100644 internal/ui/tree/msg.go diff --git a/go.mod b/go.mod index e120039..12efa01 100644 --- a/go.mod +++ b/go.mod @@ -12,10 +12,10 @@ go 1.27.0 require ( github.com/BurntSushi/toml v1.6.0 github.com/alecthomas/chroma/v2 v2.27.0 - github.com/andybalholm/brotli v1.2.5 + github.com/andybalholm/brotli v1.2.6 github.com/ayn2op/arikawa/v3 v3.0.0-20260929021956-7d0802bf4d7c github.com/ayn2op/ningen/v3 v3.0.1-0.20260929010920-29794d5fa70e - github.com/ayn2op/tview v0.0.0-20260930000753-22c02500c54e + github.com/ayn2op/tview v0.0.0-20260930195008-a20956345053 github.com/bogdanfinn/fhttp v0.6.9 github.com/bogdanfinn/tls-client v1.16.0 github.com/gdamore/tcell/v3 v3.5.0 diff --git a/go.sum b/go.sum index 0c95f6d..581bbde 100644 --- a/go.sum +++ b/go.sum @@ -8,14 +8,14 @@ github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8= github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= -github.com/andybalholm/brotli v1.2.5 h1:BSI8V4zmx/3BAn6OKjF1PmfVq7Aoi52AdFsi6bpCx+s= -github.com/andybalholm/brotli v1.2.5/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/andybalholm/brotli v1.2.6 h1:ftYnfj6usCp+UGV5kSJ3+chpMQgU+gJf/AxsUQ52REI= +github.com/andybalholm/brotli v1.2.6/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/ayn2op/arikawa/v3 v3.0.0-20260929021956-7d0802bf4d7c h1:Qkl3W5RXzUQUR/PyYMMKmUHlmAksxaXRFigaTscoxtw= github.com/ayn2op/arikawa/v3 v3.0.0-20260929021956-7d0802bf4d7c/go.mod h1:SHqcr+EmZKeNtVKZybHJn4HNz+ufA1XbeKNSTigBCEE= github.com/ayn2op/ningen/v3 v3.0.1-0.20260929010920-29794d5fa70e h1:QAei/IVEyZV7GcpkpcFUh37+QRqwrt57z7OT3ciHrhI= github.com/ayn2op/ningen/v3 v3.0.1-0.20260929010920-29794d5fa70e/go.mod h1:RsvYiMzi4Si1F7vmml5NCL2DaKIZXkNUQC7QlGTMtCw= -github.com/ayn2op/tview v0.0.0-20260930000753-22c02500c54e h1:ahNO+CbxUf2KUFT0GjNZQf1yYf+FmqhDTDdv4U7cfkc= -github.com/ayn2op/tview v0.0.0-20260930000753-22c02500c54e/go.mod h1:oRPTSZgaEJPhv3dmmNvo6grBq/BemrLG3wFQLMTZJkY= +github.com/ayn2op/tview v0.0.0-20260930195008-a20956345053 h1:cuiAx9jwMwy+fJfmLwWA5ONd3l3+YFrCIAHbIpHurJU= +github.com/ayn2op/tview v0.0.0-20260930195008-a20956345053/go.mod h1:oRPTSZgaEJPhv3dmmNvo6grBq/BemrLG3wFQLMTZJkY= github.com/bdandy/go-errors v1.2.2 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q= github.com/bdandy/go-errors v1.2.2/go.mod h1:NkYHl4Fey9oRRdbB1CoC6e84tuqQHiqrOcZpqFEkBxM= github.com/bdandy/go-socks4 v1.2.3 h1:Q6Y2heY1GRjCtHbmlKfnwrKVU/k81LS8mRGLRlmDlic= diff --git a/internal/ui/chat/channelspicker/model.go b/internal/ui/chat/channelspicker/model.go index 3601860..5b1a7be 100644 --- a/internal/ui/chat/channelspicker/model.go +++ b/internal/ui/chat/channelspicker/model.go @@ -2,7 +2,6 @@ package channelspicker import ( "log/slog" - "strings" "github.com/ayn2op/arikawa/v3/discord" "github.com/ayn2op/discordo/internal/config" @@ -77,14 +76,9 @@ func (m *Model) RefreshChannels(state *ningen.State) { } func (m Model) channelItem(state *ningen.State, guild *discord.Guild, channel discord.Channel) picker.Item { - var b strings.Builder - b.WriteString(ui.ChannelToString(channel, m.cfg.Icons, state)) - + name := ui.ChannelToString(channel, m.cfg.Icons, state) if guild != nil { - b.WriteString(" - ") - b.WriteString(guild.Name) + name += " - " + guild.Name } - - name := b.String() return picker.Item{Text: name, FilterText: name, Reference: channel.ID} } diff --git a/internal/ui/chat/composer/model.go b/internal/ui/chat/composer/model.go index 1a82d17..b54b054 100644 --- a/internal/ui/chat/composer/model.go +++ b/internal/ui/chat/composer/model.go @@ -2,11 +2,10 @@ package composer import ( "bytes" + "cmp" "context" "io" "log/slog" - "os" - "path/filepath" "regexp" "slices" "strings" @@ -32,9 +31,7 @@ import ( "github.com/ayn2op/tview/keybind" "github.com/ayn2op/tview/textarea" "github.com/gdamore/tcell/v3" - "github.com/ncruces/zenity" "github.com/rivo/uniseg" - "github.com/sahilm/fuzzy" "github.com/yuin/goldmark/ast" "golang.design/x/clipboard" ) @@ -48,10 +45,9 @@ const ( var mentionRegex = regexp.MustCompile("@[a-zA-Z0-9._]+") type Model struct { - title, footer string - editState textarea.EditState - placeholder string - disabled bool + editState textarea.EditState + placeholder string + disabled bool state *ningen.State // channel is the selected channel, or nil for none. @@ -59,14 +55,14 @@ type Model struct { cfg *config.Config - editing *discord.Message + editing *discord.Message + // replyName is the name of the author of the message being replied to. + replyName string sendMessageData *api.SendMessageData memberSearchCache map[string]uint mentionsList mentionslist.Model lastSearch time.Time - // height is the number of rows the composer takes, including its border. - height int // mentionsVisible reports whether the mentions list is shown above the composer. mentionsVisible bool @@ -76,19 +72,6 @@ type Model struct { // TypingDuration is how long Discord shows someone as typing after they start. const TypingDuration = 10 * time.Second -// TabSuggestMsg suggests mentions for the word before the cursor. -type TabSuggestMsg struct{} - -// EditLastMsg asks to edit the user's last message. -type EditLastMsg struct{} - -// SentMsg reports that the composer sent a message. -type SentMsg struct{} -type editorMsg string - -// editMsg edits the composer's text. -type editMsg textarea.Change - var _ help.KeyMap = Model{} func NewModel(cfg *config.Config, state *ningen.State) Model { @@ -100,7 +83,6 @@ func NewModel(cfg *config.Config, state *ningen.State) Model { sendMessageData: &api.SendMessageData{}, memberSearchCache: make(map[string]uint), mentionsList: mentionslist.NewModel(cfg), - height: 3, } return c } @@ -118,7 +100,8 @@ func (c *Model) SetChannel(channel *discord.Channel) { // Height returns the number of rows the composer takes, including its border. func (c *Model) Height() int { - return c.height + _, frame := c.frame() + return min(strings.Count(c.editState.Value(), "\n")+1, max(c.cfg.Composer.MaxHeight, 1)) + frame } func (c *Model) Disabled() bool { @@ -153,7 +136,33 @@ func (c *Model) box(focused bool) box.Widget { Keybind(c.editAction). Focused(focused && !c.disabled). OnChange(func(a textarea.Change) tview.Msg { return editMsg(a) }) - return ui.Box(text, &c.cfg.Theme, focused).Title(c.title).Footer(c.footer) + return ui.Box(text, &c.cfg.Theme, focused).Title(c.title()).Footer(c.footer()) +} + +// title returns what the composer is doing: editing, replying, or nothing. +func (c *Model) title() string { + switch data := c.sendMessageData; { + case c.editing != nil: + return "Editing" + case data.Reference != nil: + if mention := data.AllowedMentions.RepliedUser; mention != nil && *mention { + return "[@] Replying to " + c.replyName + } + return "Replying to " + c.replyName + } + return "" +} + +// footer returns the names of the attached files, or "" if there are none. +func (c *Model) footer() string { + if len(c.sendMessageData.Files) == 0 { + return "" + } + var names []string + for _, file := range c.sendMessageData.Files { + names = append(names, file.Name) + } + return "Attached " + humanJoin(names) } // isBound reports whether key is one of the composer's keybinds. @@ -167,7 +176,7 @@ func (c *Model) isBound(key tview.KeyMsg) bool { // insert inserts text at the cursor. func (c *Model) insert(text string) { - c.replace(c.editState.Cursor(), c.editState.Cursor(), text) + c.editState.Replace(c.editState.Cursor(), c.editState.Cursor(), text) } // pasteText inserts the text on the clipboard at the cursor. @@ -187,39 +196,10 @@ func (c *Model) frame() (left, vertical int) { return inner.X, size - inner.Height } -func (c *Model) resizeForContent() { - _, frame := c.frame() - c.height = min(strings.Count(c.editState.Value(), "\n")+1, max(c.cfg.Composer.MaxHeight, 1)) + frame -} - func (c *Model) reset() { c.editing = nil c.sendMessageData = &api.SendMessageData{} - c.setTitle("") - c.setFooter("") - c.setText("") -} - -// setText replaces the text and moves the cursor to its end. -func (c *Model) setText(text string) { - c.editState.SetValue(text) - c.resizeForContent() -} - -// replace replaces the bytes from start to end of the text and moves the cursor after it. -func (c *Model) replace(start, end int, text string) { - c.editState.Replace(start, end, text) - c.resizeForContent() -} - -func (c *Model) setTitle(title string) { - c.title = title - c.resizeForContent() -} - -func (c *Model) setFooter(footer string) { - c.footer = footer - c.resizeForContent() + c.editState.SetValue("") } func (c Model) Update(msg tview.Msg) (Model, tview.Cmd) { @@ -248,11 +228,10 @@ func (c *Model) update(msg tview.Msg) tview.Cmd { } return nil case editorMsg: - c.setText(string(msg)) + c.editState.SetValue(string(msg)) return nil case editMsg: c.editState.Apply(textarea.Change(msg)) - c.resizeForContent() typingCmd := c.sendTyping() if c.cfg.AutocompleteLimit > 0 { return tview.Batch(typingCmd, c.tabSuggest()) @@ -272,12 +251,15 @@ func (c *Model) update(msg tview.Msg) tview.Cmd { } return c.send() case keybind.Matches(msg, c.cfg.Keybinds.Composer.OpenEditor.Keybind): - return tview.Sequence(c.stopTabCompletion(), c.openEditor()) + c.stopTabCompletion() + return c.openEditor() case keybind.Matches(msg, c.cfg.Keybinds.Composer.OpenFilePicker.Keybind): - return tview.Sequence(c.stopTabCompletion(), c.pickFiles()) + c.stopTabCompletion() + return c.pickFiles() case keybind.Matches(msg, c.cfg.Keybinds.Composer.Cancel.Keybind): if c.mentionsVisible { - return c.stopTabCompletion() + c.stopTabCompletion() + return nil } c.reset() return nil @@ -292,7 +274,6 @@ func (c *Model) update(msg tview.Msg) tview.Cmd { return nil case keybind.Matches(msg, c.cfg.Keybinds.Composer.Undo.Keybind): c.editState.Undo() - c.resizeForContent() return nil } case mentionslist.Msg: @@ -309,15 +290,10 @@ func (c *Model) canEditLastMessage() bool { !c.mentionsVisible } -func (c *Model) editLastMessage() tview.Cmd { - return func() tview.Msg { return EditLastMsg{} } -} - // StartEdit puts message in the composer to be edited. func (c *Model) StartEdit(message discord.Message) { c.editing = &message - c.setTitle("Editing") - c.setText(message.Content) + c.editState.SetValue(message.Content) } // StartReply makes the next message a reply to message by name, mentioning its author if mention is set. @@ -325,11 +301,7 @@ func (c *Model) StartReply(message discord.Message, name string, mention bool) { data := c.sendMessageData data.Reference = &discord.MessageReference{MessageID: message.ID} data.AllowedMentions = &api.AllowedMentions{RepliedUser: option.Some(mention)} - title := "Replying to " - if mention { - title = "[@] " + title - } - c.setTitle(title + name) + c.replyName = name } func (c *Model) toggleReplyMention() { @@ -338,131 +310,7 @@ func (c *Model) toggleReplyMention() { return } - mention := !*data.AllowedMentions.RepliedUser - data.AllowedMentions.RepliedUser = option.Some(mention) - title := strings.TrimPrefix(c.title, "[@] ") - if mention { - title = "[@] " + title - } - c.setTitle(title) -} - -type imagePastedMsg []byte - -func pasteImage() tview.Cmd { - return func() tview.Msg { - data, err := clipboard.Read(context.Background(), clipboard.FmtImage) - if err != nil { - slog.Error("failed to read from clipboard", "err", err) - return nil - } - return imagePastedMsg(data) - } -} - -type filesPickedMsg struct { - channelID discord.ChannelID - files []sendpart.File -} - -func (c *Model) pickFiles() tview.Cmd { - selectedChannel := c.channel - if selectedChannel == nil { - return nil - } - channelID := selectedChannel.ID - - return func() tview.Msg { - paths, err := zenity.SelectFileMultiple() - if err != nil { - slog.Error("failed to open file dialog", "err", err) - return nil - } - - files := make([]sendpart.File, 0, len(paths)) - for _, path := range paths { - file, err := os.Open(path) - if err != nil { - slog.Error("failed to open file", "path", path, "err", err) - continue - } - files = append(files, sendpart.File{Name: filepath.Base(path), Reader: file}) - } - if len(files) == 0 { - return nil - } - return filesPickedMsg{channelID: channelID, files: files} - } -} - -func closeFiles(files []sendpart.File) tview.Cmd { - return func() tview.Msg { - for _, file := range files { - if closer, ok := file.Reader.(io.Closer); ok { - closer.Close() - } - } - return nil - } -} - -func (c *Model) sendTyping() tview.Cmd { - if !c.cfg.TypingIndicator.Send { - return nil - } - - now := time.Now() - if now.Before(c.typingUntil) { - return nil - } - c.typingUntil = now.Add(TypingDuration) - - selectedChannel := c.channel - if selectedChannel == nil { - return nil - } - channelID := selectedChannel.ID - return func() tview.Msg { - c.state.Typing(channelID) - return nil - } -} - -func (c *Model) send() tview.Cmd { - selectedChannel := c.channel - if selectedChannel == nil { - return nil - } - - text := strings.TrimSpace(c.editState.Value()) - if text == "" && len(c.sendMessageData.Files) == 0 { - return nil - } - - text = c.processText(selectedChannel, []byte(text)) - data := *c.sendMessageData - data.Files = slices.Clone(data.Files) - - editing := c.editing - c.typingUntil = time.Time{} - c.reset() - - sent := func() tview.Msg { return SentMsg{} } - return tview.Batch(sent, func() tview.Msg { - defer closeFiles(data.Files)() - if editing != nil { - editData := api.EditMessageData{Content: option.SomeNullable(text)} - if _, err := c.state.EditMessageComplex(editing.ChannelID, editing.ID, editData); err != nil { - slog.Error("failed to edit message", "err", err) - } - return nil - } - data.Content = text - if _, err := c.state.SendMessageComplex(selectedChannel.ID, data); err != nil { - slog.Error("failed to send message in channel", "channel_id", selectedChannel.ID, "err", err) - } - return nil - }) + data.AllowedMentions.RepliedUser = option.Some(!*data.AllowedMentions.RepliedUser) } func (c *Model) processText(channel *discord.Channel, src []byte) string { @@ -552,107 +400,6 @@ func isMentionChar(r rune) bool { return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '.' } -func (c *Model) tabComplete() tview.Cmd { - posEnd, name, r := wordBeforeCursor(c.editState.Value(), c.editState.Cursor(), isMentionChar) - if r != '@' { - return c.stopTabCompletion() - } - pos := posEnd - (len(name) + 1) - - selectedChannel := c.channel - if selectedChannel == nil { - return nil - } - gID := selectedChannel.GuildID - - if c.cfg.AutocompleteLimit == 0 { - if !gID.IsValid() { - users := selectedChannel.DMRecipients - res := fuzzy.FindFrom(name, userList(users)) - if len(res) > 0 { - c.replace(pos, posEnd, "@"+users[res[0].Index].Username+" ") - } - } else { - cmd := c.searchMember(gID, name) - members, err := c.state.Cabinet.Members(gID) - if err != nil { - slog.Error("failed to get members from state", "guild_id", gID, "err", err) - return cmd - } - - res := fuzzy.FindFrom(name, memberList(members)) - for _, r := range res { - if channelHasUser(c.state, selectedChannel.ID, members[r.Index].User.ID) { - c.replace(pos, posEnd, "@"+members[r.Index].User.Username+" ") - return cmd - } - } - return cmd - } - return nil - } - if c.mentionsList.ItemCount() == 0 { - return nil - } - name, ok := c.mentionsList.SelectedInsertText() - if !ok { - return nil - } - c.replace(pos, posEnd, "@"+name+" ") - return c.stopTabCompletion() -} - -func (c *Model) tabSuggest() tview.Cmd { - _, name, r := wordBeforeCursor(c.editState.Value(), c.editState.Cursor(), isMentionChar) - if r != '@' { - return c.stopTabCompletion() - } - channel := c.channel - if channel == nil { - return nil - } - c.mentionsList.Clear() - - gID := channel.GuildID - switch { - case name == "": - c.suggestRecentAuthors(channel) - case !gID.IsValid(): - // DMs have recipients, not members. - me, _ := c.state.Cabinet.Me() - users := append(slices.Clone(channel.DMRecipients), *me) - for _, r := range fuzzy.FindFrom(name, userList(users)) { - c.addMentionUser(&users[r.Index]) - } - default: - searchCmd := c.searchMember(gID, name) - mems, err := c.state.Cabinet.Members(gID) - if err != nil { - slog.Error("fetching members failed", "err", err) - return searchCmd - } - res := fuzzy.FindFrom(name, memberList(mems)) - if len(res) > int(c.cfg.AutocompleteLimit) { - res = res[:int(c.cfg.AutocompleteLimit)] - } - for _, r := range res { - if channelHasUser(c.state, channel.ID, mems[r.Index].User.ID) && - c.addMentionMember(gID, &mems[r.Index]) { - break - } - } - if c.mentionsList.ItemCount() == 0 { - return tview.Batch(c.stopTabCompletion(), searchCmd) - } - } - - if c.mentionsList.ItemCount() == 0 { - return c.stopTabCompletion() - } - c.mentionsList.Rebuild() - return c.showMentionsList() -} - // suggestRecentAuthors suggests the authors of the channel's recent messages, other than the user. func (c *Model) suggestRecentAuthors(channel *discord.Channel) { messages, err := c.state.Cabinet.Messages(channel.ID) @@ -706,48 +453,6 @@ func channelHasUser(state *ningen.State, channelID discord.ChannelID, userID dis return perms.Has(discord.PermissionViewChannel) } -// searchMember performs member discovery in a command goroutine. -// It emits a follow-up suggestion message once results are loaded. -func (c *Model) searchMember(gID discord.GuildID, name string) tview.Cmd { - if name == "" { - return nil - } - - key := gID.String() + " " + name - if _, ok := c.memberSearchCache[key]; ok { - return nil - } - // If searching for "ab" returns less than SearchLimit, then "abc" would not return anything new because we already searched everything starting with "ab". - // This will still be true even if a new member joins because arikawa loads new members into the state. - if count, ok := c.memberSearchCache[key[:len(key)-1]]; ok { - if count < c.state.MemberState.SearchLimit { - c.memberSearchCache[key] = count - return nil - } - } - - now := time.Now() - // Rate limit on our side because we can't distinguish between a successful search and SearchMember not doing anything because of its internal rate limit that we can't detect - if c.lastSearch.Add(c.state.MemberState.SearchFrequency).After(now) { - return nil - } - - c.lastSearch = now - nonce := memberSearchNonce + key - return func() tview.Msg { - if err := c.state.SendGateway(context.Background(), &gateway.RequestGuildMembersCommand{ - GuildIDs: []discord.GuildID{gID}, - Query: option.Some(name), - Presences: c.state.MemberState.RequestPresences, - Limit: c.state.MemberState.SearchLimit, - Nonce: nonce, - }); err != nil { - slog.Error("failed to search guild members", "err", err, "guild_id", gID, "query", name) - } - return nil - } -} - func (c *Model) CacheMemberSearch(event *gateway.GuildMembersChunkEvent) tview.Cmd { key, ok := strings.CutPrefix(event.Nonce, memberSearchNonce) if !ok { @@ -760,11 +465,6 @@ func (c *Model) CacheMemberSearch(event *gateway.GuildMembersChunkEvent) tview.C } } -func (c *Model) showMentionsList() tview.Cmd { - c.mentionsVisible = true - return nil -} - // OnGuildMemberRemove forgets cached member searches that the removed member may have filled. func (c *Model) OnGuildMemberRemove(event *gateway.GuildMemberRemoveEvent) { for name := event.GuildID.String() + " " + event.User.Username; name != ""; name = name[:len(name)-1] { @@ -808,7 +508,7 @@ func (p mentionsPopup) area(area tview.Rectangle) tview.Rectangle { borders = 1 } x, _ := c.frame() - maxW, maxH := area.Width, area.Height-c.height + maxW, maxH := area.Width, area.Height-c.Height() if t := int(c.cfg.Theme.MentionsList.MaxHeight); t != 0 { maxH = min(maxH, t) } @@ -822,7 +522,7 @@ func (p mentionsPopup) area(area tview.Rectangle) tview.Rectangle { col := uniseg.StringWidth(value[strings.LastIndex(value[:cursor], "\n")+1 : cursor]) x += min(col, maxW-w) } - return tview.Rectangle{X: area.X + x, Y: area.Y + area.Height - c.height - h, Width: w, Height: h} + return tview.Rectangle{X: area.X + x, Y: area.Y + area.Height - c.Height() - h, Width: w, Height: h} } func (c *Model) addMentionMember(gID discord.GuildID, m *discord.Member) bool { @@ -830,10 +530,7 @@ func (c *Model) addMentionMember(gID discord.GuildID, m *discord.Member) bool { return false } - name := m.User.DisplayOrUsername() - if m.Nick != "" { - name = m.Nick - } + name := cmp.Or(m.Nick, m.User.DisplayName, m.User.Username) style := tcell.StyleDefault @@ -882,67 +579,20 @@ func (c *Model) addMentionUser(user *discord.User) { }) } -func (c *Model) CloseMentions() { - c.mentionsVisible = false -} - -func (c *Model) stopTabCompletion() tview.Cmd { +// stopTabCompletion clears and closes the mentions list when it is in use. +func (c *Model) stopTabCompletion() { if c.cfg.AutocompleteLimit > 0 { c.mentionsList.Clear() c.CloseMentions() } - return nil } -func (c *Model) openEditor() tview.Cmd { - if c.cfg.Editor == "" { - return func() tview.Msg { - slog.Warn("Attempt to open file with editor, but no editor is set") - return nil - } - } - text := c.editState.Value() - cfg := c.cfg - return tview.Suspend(func() tview.Msg { - file, err := os.CreateTemp("", tmpFilePattern) - if err != nil { - slog.Error("failed to create tmp file", "err", err) - return nil - } - name := file.Name() - defer os.Remove(name) - _, _ = file.WriteString(text) - _ = file.Close() - - cmd, err := cfg.EditorCommand(name) - if err != nil { - slog.Error("failed to create editor command", "err", err) - return nil - } - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - slog.Error("failed to run command", "args", cmd.Args, "err", err) - return nil - } - msg, err := os.ReadFile(name) - if err != nil { - slog.Error("failed to read tmp file", "name", name, "err", err) - return nil - } - return editorMsg(strings.TrimSpace(string(msg))) - }) +func (c *Model) CloseMentions() { + c.mentionsVisible = false } func (c *Model) attach(name string, reader io.Reader) { c.sendMessageData.Files = append(c.sendMessageData.Files, sendpart.File{Name: name, Reader: reader}) - - var names []string - for _, file := range c.sendMessageData.Files { - names = append(names, file.Name) - } - c.setFooter("Attached " + humanJoin(names)) } func (c *Model) canAttachFiles() bool { diff --git a/internal/ui/chat/composer/model_test.go b/internal/ui/chat/composer/model_test.go index 2feee7a..797a8bc 100644 --- a/internal/ui/chat/composer/model_test.go +++ b/internal/ui/chat/composer/model_test.go @@ -36,7 +36,7 @@ func TestModelUpdate(t *testing.T) { want bool }{ {"empty", func(*Model, *config.Config) tview.KeyMsg { return up }, true}, - {"draft", func(c *Model, _ *config.Config) tview.KeyMsg { c.setText("draft"); return up }, false}, + {"draft", func(c *Model, _ *config.Config) tview.KeyMsg { c.editState.SetValue("draft"); return up }, false}, {"reply", func(c *Model, _ *config.Config) tview.KeyMsg { c.StartReply(discord.Message{ID: 3}, "name", false) return up @@ -139,7 +139,7 @@ func TestModelTabSuggest(t *testing.T) { } } c.SetChannel(&channel) - c.setText("@") + c.editState.SetValue("@") c.tabSuggest() if got := c.mentionsList.ItemCount(); got != 2 { t.Fatalf("suggested %d authors, want 2", got) diff --git a/internal/ui/chat/composer/msg.go b/internal/ui/chat/composer/msg.go new file mode 100644 index 0000000..44cfff3 --- /dev/null +++ b/internal/ui/chat/composer/msg.go @@ -0,0 +1,349 @@ +package composer + +import ( + "context" + "io" + "log/slog" + "os" + "path/filepath" + "slices" + "strings" + "time" + + "github.com/ayn2op/arikawa/v3/api" + "github.com/ayn2op/arikawa/v3/discord" + "github.com/ayn2op/arikawa/v3/gateway" + "github.com/ayn2op/arikawa/v3/utils/json/option" + "github.com/ayn2op/arikawa/v3/utils/sendpart" + "github.com/ayn2op/tview" + "github.com/ayn2op/tview/textarea" + "github.com/ncruces/zenity" + "github.com/sahilm/fuzzy" + "golang.design/x/clipboard" +) + +// TabSuggestMsg suggests mentions for the word before the cursor. +type TabSuggestMsg struct{} + +// EditLastMsg asks to edit the user's last message. +type EditLastMsg struct{} + +// SentMsg reports that the composer sent a message. +type SentMsg struct{} + +type editorMsg string + +// editMsg edits the composer's text. +type editMsg textarea.Change + +func (c *Model) editLastMessage() tview.Cmd { + return func() tview.Msg { return EditLastMsg{} } +} + +type imagePastedMsg []byte + +func pasteImage() tview.Cmd { + return func() tview.Msg { + data, err := clipboard.Read(context.Background(), clipboard.FmtImage) + if err != nil { + slog.Error("failed to read from clipboard", "err", err) + return nil + } + return imagePastedMsg(data) + } +} + +type filesPickedMsg struct { + channelID discord.ChannelID + files []sendpart.File +} + +func (c *Model) pickFiles() tview.Cmd { + selectedChannel := c.channel + if selectedChannel == nil { + return nil + } + channelID := selectedChannel.ID + + return func() tview.Msg { + paths, err := zenity.SelectFileMultiple() + if err != nil { + slog.Error("failed to open file dialog", "err", err) + return nil + } + + files := make([]sendpart.File, 0, len(paths)) + for _, path := range paths { + file, err := os.Open(path) + if err != nil { + slog.Error("failed to open file", "path", path, "err", err) + continue + } + files = append(files, sendpart.File{Name: filepath.Base(path), Reader: file}) + } + if len(files) == 0 { + return nil + } + return filesPickedMsg{channelID: channelID, files: files} + } +} + +func closeFiles(files []sendpart.File) tview.Cmd { + return func() tview.Msg { + for _, file := range files { + if closer, ok := file.Reader.(io.Closer); ok { + closer.Close() + } + } + return nil + } +} + +func (c *Model) sendTyping() tview.Cmd { + if !c.cfg.TypingIndicator.Send { + return nil + } + + now := time.Now() + if now.Before(c.typingUntil) { + return nil + } + c.typingUntil = now.Add(TypingDuration) + + selectedChannel := c.channel + if selectedChannel == nil { + return nil + } + channelID := selectedChannel.ID + return func() tview.Msg { + c.state.Typing(channelID) + return nil + } +} + +func (c *Model) send() tview.Cmd { + selectedChannel := c.channel + if selectedChannel == nil { + return nil + } + + text := strings.TrimSpace(c.editState.Value()) + if text == "" && len(c.sendMessageData.Files) == 0 { + return nil + } + + text = c.processText(selectedChannel, []byte(text)) + data := *c.sendMessageData + data.Files = slices.Clone(data.Files) + + editing := c.editing + c.typingUntil = time.Time{} + c.reset() + + sent := func() tview.Msg { return SentMsg{} } + return tview.Batch(sent, func() tview.Msg { + defer closeFiles(data.Files)() + if editing != nil { + editData := api.EditMessageData{Content: option.SomeNullable(text)} + if _, err := c.state.EditMessageComplex(editing.ChannelID, editing.ID, editData); err != nil { + slog.Error("failed to edit message", "err", err) + } + return nil + } + data.Content = text + if _, err := c.state.SendMessageComplex(selectedChannel.ID, data); err != nil { + slog.Error("failed to send message in channel", "channel_id", selectedChannel.ID, "err", err) + } + return nil + }) +} + +func (c *Model) tabComplete() tview.Cmd { + posEnd, name, r := wordBeforeCursor(c.editState.Value(), c.editState.Cursor(), isMentionChar) + if r != '@' { + c.stopTabCompletion() + return nil + } + pos := posEnd - (len(name) + 1) + + selectedChannel := c.channel + if selectedChannel == nil { + return nil + } + gID := selectedChannel.GuildID + + if c.cfg.AutocompleteLimit == 0 { + if !gID.IsValid() { + users := selectedChannel.DMRecipients + res := fuzzy.FindFrom(name, userList(users)) + if len(res) > 0 { + c.editState.Replace(pos, posEnd, "@"+users[res[0].Index].Username+" ") + } + } else { + cmd := c.searchMember(gID, name) + members, err := c.state.Cabinet.Members(gID) + if err != nil { + slog.Error("failed to get members from state", "guild_id", gID, "err", err) + return cmd + } + + res := fuzzy.FindFrom(name, memberList(members)) + for _, r := range res { + if channelHasUser(c.state, selectedChannel.ID, members[r.Index].User.ID) { + c.editState.Replace(pos, posEnd, "@"+members[r.Index].User.Username+" ") + return cmd + } + } + return cmd + } + return nil + } + if c.mentionsList.ItemCount() == 0 { + return nil + } + name, ok := c.mentionsList.SelectedInsertText() + if !ok { + return nil + } + c.editState.Replace(pos, posEnd, "@"+name+" ") + c.stopTabCompletion() + return nil +} + +func (c *Model) tabSuggest() tview.Cmd { + _, name, r := wordBeforeCursor(c.editState.Value(), c.editState.Cursor(), isMentionChar) + if r != '@' { + c.stopTabCompletion() + return nil + } + channel := c.channel + if channel == nil { + return nil + } + c.mentionsList.Clear() + + gID := channel.GuildID + switch { + case name == "": + c.suggestRecentAuthors(channel) + case !gID.IsValid(): + // DMs have recipients, not members. + me, _ := c.state.Cabinet.Me() + users := append(slices.Clone(channel.DMRecipients), *me) + for _, r := range fuzzy.FindFrom(name, userList(users)) { + c.addMentionUser(&users[r.Index]) + } + default: + searchCmd := c.searchMember(gID, name) + mems, err := c.state.Cabinet.Members(gID) + if err != nil { + slog.Error("fetching members failed", "err", err) + return searchCmd + } + res := fuzzy.FindFrom(name, memberList(mems)) + if len(res) > int(c.cfg.AutocompleteLimit) { + res = res[:int(c.cfg.AutocompleteLimit)] + } + for _, r := range res { + if channelHasUser(c.state, channel.ID, mems[r.Index].User.ID) && + c.addMentionMember(gID, &mems[r.Index]) { + break + } + } + if c.mentionsList.ItemCount() == 0 { + c.stopTabCompletion() + return searchCmd + } + } + + if c.mentionsList.ItemCount() == 0 { + c.stopTabCompletion() + return nil + } + c.mentionsList.Rebuild() + c.mentionsVisible = true + return nil +} + +// searchMember performs member discovery in a command goroutine. +// It emits a follow-up suggestion message once results are loaded. +func (c *Model) searchMember(gID discord.GuildID, name string) tview.Cmd { + if name == "" { + return nil + } + + key := gID.String() + " " + name + if _, ok := c.memberSearchCache[key]; ok { + return nil + } + // If searching for "ab" returns less than SearchLimit, then "abc" would not return anything new because we already searched everything starting with "ab". + // This will still be true even if a new member joins because arikawa loads new members into the state. + if count, ok := c.memberSearchCache[key[:len(key)-1]]; ok { + if count < c.state.MemberState.SearchLimit { + c.memberSearchCache[key] = count + return nil + } + } + + now := time.Now() + // Rate limit on our side because we can't distinguish between a successful search and SearchMember not doing anything because of its internal rate limit that we can't detect + if c.lastSearch.Add(c.state.MemberState.SearchFrequency).After(now) { + return nil + } + + c.lastSearch = now + nonce := memberSearchNonce + key + return func() tview.Msg { + if err := c.state.SendGateway(context.Background(), &gateway.RequestGuildMembersCommand{ + GuildIDs: []discord.GuildID{gID}, + Query: option.Some(name), + Presences: c.state.MemberState.RequestPresences, + Limit: c.state.MemberState.SearchLimit, + Nonce: nonce, + }); err != nil { + slog.Error("failed to search guild members", "err", err, "guild_id", gID, "query", name) + } + return nil + } +} + +func (c *Model) openEditor() tview.Cmd { + if c.cfg.Editor == "" { + return func() tview.Msg { + slog.Warn("Attempt to open file with editor, but no editor is set") + return nil + } + } + text := c.editState.Value() + cfg := c.cfg + return tview.Suspend(func() tview.Msg { + file, err := os.CreateTemp("", tmpFilePattern) + if err != nil { + slog.Error("failed to create tmp file", "err", err) + return nil + } + name := file.Name() + defer os.Remove(name) + _, _ = file.WriteString(text) + _ = file.Close() + + cmd, err := cfg.EditorCommand(name) + if err != nil { + slog.Error("failed to create editor command", "err", err) + return nil + } + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + slog.Error("failed to run command", "args", cmd.Args, "err", err) + return nil + } + msg, err := os.ReadFile(name) + if err != nil { + slog.Error("failed to read tmp file", "name", name, "err", err) + return nil + } + return editorMsg(strings.TrimSpace(string(msg))) + }) +} diff --git a/internal/ui/chat/guildstree/keymap.go b/internal/ui/chat/guildstree/keymap.go index f3cacb8..42eb761 100644 --- a/internal/ui/chat/guildstree/keymap.go +++ b/internal/ui/chat/guildstree/keymap.go @@ -1,6 +1,8 @@ package guildstree import ( + "slices" + "github.com/ayn2op/arikawa/v3/discord" "github.com/ayn2op/tview/help" "github.com/ayn2op/tview/keybind" @@ -69,12 +71,5 @@ func (m *Model) canCollapseParent(node *tree.Node) bool { } func (m *Model) canCollapseAll() bool { - var can bool - for _, node := range m.root.Children() { - if node.Expanded() { - can = true - break - } - } - return can + return slices.ContainsFunc(m.root.Children(), (*tree.Node).Expanded) } diff --git a/internal/ui/chat/guildstree/model.go b/internal/ui/chat/guildstree/model.go index 2e74044..a3791cc 100644 --- a/internal/ui/chat/guildstree/model.go +++ b/internal/ui/chat/guildstree/model.go @@ -46,13 +46,11 @@ func (m *Model) CurrentNode() *tree.Node { return m.selectionState.CurrentNode() } -// Msg moves or scrolls the tree. -type Msg tree.Change - // View shows the tree in a box titled Guilds. 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.GuildsTree.CommonTreeTheme, m.cfg.Keybinds.GuildsTree, focused, onChange).Title("Guilds") + onSelect := func(n *tree.Node) tview.Msg { return SelectedMsg{Node: n} } + return uitree.New(m.root, &m.selectionState, m.cfg, m.cfg.Theme.GuildsTree.CommonTreeTheme, m.cfg.Keybinds.GuildsTree, focused, onChange, onSelect).Title("Guilds") } func (m *Model) reset() *tree.Node { @@ -334,7 +332,7 @@ func (m *Model) update(msg tview.Msg) tview.Cmd { case Msg: m.selectionState.Apply(tree.Change(msg)) return nil - case tree.SelectedMsg: + case SelectedMsg: m.selectionState.SetCurrentNode(msg.Node) return m.selectNode(msg.Node) case tview.KeyMsg: diff --git a/internal/ui/chat/guildstree/msg.go b/internal/ui/chat/guildstree/msg.go index af6b7cb..d2d470a 100644 --- a/internal/ui/chat/guildstree/msg.go +++ b/internal/ui/chat/guildstree/msg.go @@ -10,6 +10,14 @@ import ( "github.com/ayn2op/tview/tree" ) +// Msg moves or scrolls the tree. +type Msg tree.Change + +// SelectedMsg is sent when the user selects a node. +type SelectedMsg struct { + Node *tree.Node +} + type ChannelLoadedMsg struct { Channel discord.Channel Messages []discord.Message diff --git a/internal/ui/chat/keymap.go b/internal/ui/chat/keymap.go index 303dbc6..46a3ec0 100644 --- a/internal/ui/chat/keymap.go +++ b/internal/ui/chat/keymap.go @@ -26,18 +26,18 @@ func (m Model) FullHelp() [][]keybind.Keybind { } func (m Model) activeKeyMap() help.KeyMap { - if m.pickerOpen() { + if m.overlay != overlayNone { return m.cfg.Keybinds.Picker } switch m.focused { - case guildsTreePane: + case paneGuildsTree: return m.guildsTree - case messagesListPane: + case paneMessagesList: return m.messagesList - case composerPane: + case paneComposer: return m.composer - case membersTreePane: + case paneMembersTree: return m.membersTree default: return nil @@ -66,16 +66,16 @@ func (m Model) focusHelp() []keybind.Keybind { focused := m.focused focusKbs := make([]keybind.Keybind, 0, 4) - if focused != guildsTreePane { + if focused != paneGuildsTree { focusKbs = append(focusKbs, kbs.FocusGuildsTree.Keybind) } - if focused != messagesListPane { + if focused != paneMessagesList { focusKbs = append(focusKbs, kbs.FocusMessagesList.Keybind) } - if !m.composer.Disabled() && focused != composerPane { + if !m.composer.Disabled() && focused != paneComposer { focusKbs = append(focusKbs, kbs.FocusComposer.Keybind) } - if m.membersTreeShown() && focused != membersTreePane { + if m.membersTreeShown() && focused != paneMembersTree { focusKbs = append(focusKbs, kbs.FocusMembersTree.Keybind) } diff --git a/internal/ui/chat/memberstree/model.go b/internal/ui/chat/memberstree/model.go index 6700af1..e9c3a4e 100644 --- a/internal/ui/chat/memberstree/model.go +++ b/internal/ui/chat/memberstree/model.go @@ -84,7 +84,8 @@ func (m *Model) SetChannel(channel *discord.Channel) tview.Cmd { // 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") + onSelect := func(n *tree.Node) tview.Msg { return SelectedMsg{Node: n} } + return uitree.New(m.root, &m.selectionState, m.cfg, m.cfg.Theme.MembersTree, m.cfg.Keybinds.MembersTree, focused, onChange, onSelect).Title("Members") } func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) { @@ -111,7 +112,7 @@ func (m *Model) update(msg tview.Msg) tview.Cmd { case Msg: m.selectionState.Apply(tree.Change(msg)) return m.requestSelectedMembers() - case tree.SelectedMsg: + case SelectedMsg: m.selectionState.SetCurrentNode(msg.Node) if _, ok := msg.Node.Reference().(groupRef); ok { msg.Node.SetExpanded(!msg.Node.Expanded()) @@ -192,10 +193,10 @@ func (m *Model) addRecipients(recipients []discord.User) { 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())), - ) + if c := cmp.Compare(statusRank(m.status(discord.NullGuildID, a.ID)), statusRank(m.status(discord.NullGuildID, b.ID))); c != 0 { + return c + } + return strings.Compare(strings.ToLower(a.DisplayOrUsername()), strings.ToLower(b.DisplayOrUsername())) }) group := m.addGroup(discord.NullGuildID, gateway.GuildMemberListGroup{ID: "members", Count: uint64(len(recipients))}) @@ -237,10 +238,7 @@ func (m *Model) addGroup(guildID discord.GuildID, group gateway.GuildMemberListG // 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() - } + name := cmp.Or(member.Nick, member.User.DisplayName, member.User.Username) var nameStyle tcell.Style color, ok := state.MemberColor(&member, func(id discord.RoleID) *discord.Role { r, _ := m.state.Cabinet.Role(guildID, id) diff --git a/internal/ui/chat/memberstree/msg.go b/internal/ui/chat/memberstree/msg.go index 6bc8f9f..c446cac 100644 --- a/internal/ui/chat/memberstree/msg.go +++ b/internal/ui/chat/memberstree/msg.go @@ -9,6 +9,11 @@ import ( // Msg moves or scrolls the tree. type Msg tree.Change +// SelectedMsg is sent when the user selects a node. +type SelectedMsg struct { + Node *tree.Node +} + // 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() diff --git a/internal/ui/chat/mentionslist/model.go b/internal/ui/chat/mentionslist/model.go index 627d714..523da19 100644 --- a/internal/ui/chat/mentionslist/model.go +++ b/internal/ui/chat/mentionslist/model.go @@ -16,9 +16,6 @@ type Model struct { items []Item } -// Msg moves the list. -type Msg list.Change - func NewModel(cfg *config.Config) Model { return Model{cfg: cfg, selectionState: list.NewSelectionState()} } diff --git a/internal/ui/chat/mentionslist/msg.go b/internal/ui/chat/mentionslist/msg.go new file mode 100644 index 0000000..f2bdb79 --- /dev/null +++ b/internal/ui/chat/mentionslist/msg.go @@ -0,0 +1,6 @@ +package mentionslist + +import "github.com/ayn2op/tview/list" + +// Msg moves the list. +type Msg list.Change diff --git a/internal/ui/chat/messageslist/model.go b/internal/ui/chat/messageslist/model.go index da83d74..f1bf118 100644 --- a/internal/ui/chat/messageslist/model.go +++ b/internal/ui/chat/messageslist/model.go @@ -1,16 +1,12 @@ package messageslist import ( - "context" - "errors" "fmt" "io" "log/slog" - "mime" "net/http" "net/url" "os" - "path/filepath" "slices" "strconv" "strings" @@ -18,11 +14,9 @@ import ( "unicode/utf8" "github.com/ayn2op/arikawa/v3/discord" - "github.com/ayn2op/arikawa/v3/gateway" md "github.com/ayn2op/arikawa/v3/markdown" "github.com/ayn2op/arikawa/v3/state" "github.com/ayn2op/discordo/internal/config" - "github.com/ayn2op/discordo/internal/consts" "github.com/ayn2op/discordo/internal/markdown" "github.com/ayn2op/discordo/internal/ui" "github.com/ayn2op/ningen/v3" @@ -31,17 +25,13 @@ import ( tviewimage "github.com/ayn2op/tview/image" "github.com/ayn2op/tview/keybind" "github.com/ayn2op/tview/list" - "github.com/ayn2op/tview/picker" "github.com/ayn2op/tview/richtext" "github.com/ayn2op/tview/scrollbar" "github.com/ayn2op/tview/textview" "github.com/gdamore/tcell/v3" "github.com/gdamore/tcell/v3/color" - "github.com/ncruces/zenity" "github.com/rivo/uniseg" - "github.com/skratchdot/open-golang/open" "github.com/yuin/goldmark/ast" - "golang.design/x/clipboard" ) type messageItem struct { @@ -55,7 +45,6 @@ type messageItem struct { } type Model struct { - title, footer string selectionState list.SelectionState scrollBar scrollbar.Widget @@ -82,7 +71,6 @@ var _ help.KeyMap = Model{} func NewModel(cfg *config.Config, state *ningen.State) Model { ml := Model{ - title: "Messages", selectionState: list.NewSelectionState(), cfg: cfg, state: state, @@ -100,12 +88,21 @@ func NewModel(cfg *config.Config, state *ningen.State) Model { return ml } -// listMsg moves or scrolls the messages list. -type listMsg list.Change +// View shows the messages in a box titled with the channel and footed with footer, such as who is typing. +func (ml Model) View(focused bool, footer string) tview.Element { + return ui.Box(ml.listView(focused), &ml.cfg.Theme, focused).Title(ml.title()).Footer(footer) +} -// View shows the messages in a box titled with the channel and footed with who is typing. -func (ml Model) View(focused bool) tview.Element { - return ui.Box(ml.listView(focused), &ml.cfg.Theme, focused).Title(ml.title).Footer(ml.footer) +// title returns the selected channel and its topic, or "Messages" when none is selected. +func (ml *Model) title() string { + if ml.channel == nil { + return "Messages" + } + title := ui.ChannelToString(*ml.channel, ml.cfg.Icons, ml.state) + if topic := ml.channel.Topic; topic != "" { + title += " - " + topic + } + return title } func (ml *Model) listView(focused bool) list.Widget { @@ -146,7 +143,6 @@ func (ml *Model) setCursor(index int) { func (ml *Model) SetChannel(channel *discord.Channel, messages []discord.Message) tview.Cmd { ml.channel = channel ml.reset() - ml.setTitle(*channel) ml.setMessages(messages) if channel.GuildID.IsValid() { return tview.Batch(ml.requestGuildMembers(channel.GuildID, messages), ml.loadPreviews()) @@ -154,11 +150,6 @@ func (ml *Model) SetChannel(channel *discord.Channel, messages []discord.Message return ml.loadPreviews() } -// SetFooter sets the text below the messages, such as who is typing. -func (ml *Model) SetFooter(footer string) { - ml.footer = footer -} - // ShowNewest clears the selection and scrolls to the newest message, which then stays in view as messages arrive until the user scrolls up. func (ml *Model) ShowNewest() { ml.clearSelection() @@ -196,16 +187,6 @@ func (ml *Model) reset() { ml.selectionState = list.NewSelectionState() ml.selectionState.SetTrackEnd(true) ml.selectionState.ScrollToEnd() - ml.title = "" -} - -func (ml *Model) setTitle(channel discord.Channel) { - title := ui.ChannelToString(channel, ml.cfg.Icons, ml.state) - if topic := channel.Topic; topic != "" { - title += " - " + topic - } - - ml.title = title } func (ml *Model) setMessages(messages []discord.Message) { @@ -937,10 +918,6 @@ func (ml *Model) update(msg tview.Msg) tview.Cmd { return tview.Batch(ml.requestGuildMembers(selectedChannel.GuildID, msg.Older), ml.loadPreviews()) } return ml.loadPreviews() - case deleteMessageMsg: - return ml.requestDelete(discord.Message(msg)) - case attachmentActionMsg: - return msg.Action case previewLoadedMsg: index := slices.IndexFunc(ml.items, func(item messageItem) bool { return !item.separator && item.message.ID == msg.messageID }) if index < 0 || ml.items[index].previews == nil { @@ -973,18 +950,6 @@ func (ml *Model) update(msg tview.Msg) tview.Cmd { return nil } -func (ml *Model) selectUp() tview.Cmd { - cursor := ml.cursor() - if cursor == -1 { - ml.selectBottom() - } else if previous := ml.messageIndex(cursor, -1); previous >= 0 { - ml.setCursor(previous) - } else { - return ml.fetchOlderMessages() - } - return nil -} - func (ml *Model) selectDown() { if ml.cursor() == -1 { ml.selectBottom() @@ -1003,12 +968,8 @@ func (ml *Model) selectBottom() { func (ml *Model) selectReply() { messages := ml.items - if len(messages) == 0 { - return - } - cursor := ml.cursor() - if cursor == -1 || cursor >= len(messages) { + if cursor < 0 || cursor >= len(messages) { return } @@ -1022,121 +983,6 @@ func (ml *Model) selectReply() { } } -func (ml *Model) fetchOlderMessages() tview.Cmd { - selectedChannel := ml.channel - if selectedChannel == nil { - return nil - } - - channelID := selectedChannel.ID - first := ml.messageIndex(-1, 1) - if first == -1 { - return nil - } - before := ml.items[first].message.ID - limit := uint(ml.cfg.MessagesLimit) - return func() tview.Msg { - messages, err := ml.state.MessagesBefore(channelID, before, limit) - if err != nil { - slog.Error("failed to fetch older messages", "err", err) - return nil - } - if len(messages) == 0 { - return nil - } - - older := slices.Clone(messages) - slices.Reverse(older) - return olderMessagesLoadedMsg{ChannelID: channelID, Older: older} - } -} - -func (ml *Model) yankMessageID() tview.Cmd { - selectedMessage, ok := ml.selectedMessage() - if !ok { - return nil - } - - return func() tview.Msg { - if _, err := clipboard.Write(context.Background(), clipboard.FmtText, []byte(selectedMessage.ID.String())); err != nil { - slog.Error("failed to write to clipboard", "err", err) - } - return nil - } -} - -func (ml *Model) yankContent() tview.Cmd { - selectedMessage, ok := ml.selectedMessage() - if !ok { - return nil - } - - return func() tview.Msg { - if _, err := clipboard.Write(context.Background(), clipboard.FmtText, []byte(selectedMessage.Content)); err != nil { - slog.Error("failed to write to clipboard", "err", err) - } - return nil - } -} - -func (ml *Model) yankURL() tview.Cmd { - selectedMessage, ok := ml.selectedMessage() - if !ok { - return nil - } - - return func() tview.Msg { - if _, err := clipboard.Write(context.Background(), clipboard.FmtText, []byte(selectedMessage.URL())); err != nil { - slog.Error("failed to write to clipboard", "err", err) - } - return nil - } -} - -func (ml *Model) open() tview.Cmd { - return ml.openWith(ml.openAttachment) -} - -func (ml *Model) openInBrowser() tview.Cmd { - return ml.openWith(func(attachment discord.Attachment) tview.Cmd { return openURL(attachment.URL) }) -} - -func (ml *Model) openWith(openAttachment func(discord.Attachment) tview.Cmd) tview.Cmd { - selectedMessage, ok := ml.selectedMessage() - if !ok { - return nil - } - - urls := messageURLs(*selectedMessage) - switch total := len(urls) + len(selectedMessage.Attachments); { - case total == 0: - return nil - case total > 1: - return ml.showAttachmentsList(urls, selectedMessage.Attachments, openAttachment) - case len(urls) == 1: - return openURL(urls[0]) - } - - return openAttachment(selectedMessage.Attachments[0]) -} - -func (ml *Model) download() tview.Cmd { - selectedMessage, ok := ml.selectedMessage() - if !ok || len(selectedMessage.Attachments) == 0 { - return nil - } - if len(selectedMessage.Attachments) == 1 { - attachment := selectedMessage.Attachments[0] - return ml.confirmAttachment(attachment, saveAttachment(attachment)) - } - - items := make(picker.Items, len(selectedMessage.Attachments)) - for i, attachment := range selectedMessage.Attachments { - items[i] = picker.Item{Text: attachment.Filename, Reference: ml.confirmAttachment(attachment, saveAttachment(attachment))} - } - return ml.showAttachmentsPicker(items) -} - func extractURLs(content string) []string { src := []byte(content) node := md.Parse(src) @@ -1197,92 +1043,11 @@ func messageURLs(msg discord.Message) []string { return urls } -func (ml *Model) showAttachmentsList(urls []string, attachments []discord.Attachment, openAttachment func(discord.Attachment) tview.Cmd) tview.Cmd { - var items picker.Items - for _, attachment := range attachments { - items = append(items, picker.Item{Text: attachment.Filename, Reference: openAttachment(attachment)}) - } - for _, url := range urls { - items = append(items, picker.Item{Text: url, Reference: openURL(url)}) - } - return ml.showAttachmentsPicker(items) -} - -func (ml *Model) showAttachmentsPicker(items picker.Items) tview.Cmd { - return func() tview.Msg { return ShowAttachmentsMsg(items) } -} - -func (ml *Model) openAttachment(attachment discord.Attachment) tview.Cmd { - return ml.confirmAttachment(attachment, openDownloadedAttachment(attachment)) -} - -func (ml *Model) confirmAttachment(attachment discord.Attachment, action tview.Cmd) tview.Cmd { - if !ml.cfg.Attachments.AllowedMIMETypes.Has(attachment.ContentType) { - return ui.ShowModal( - "This attachment type is not allowed and may be unsafe. Continue anyway?", - ui.ModalButton{Label: "No"}, - ui.ModalButton{Label: "Yes", Result: attachmentActionMsg{action}}, - ) - } - return action -} - -func openDownloadedAttachment(attachment discord.Attachment) tview.Cmd { - return func() tview.Msg { - extension := filepath.Ext(attachment.Filename) - if extension == "" { - mediaType, _, _ := mime.ParseMediaType(attachment.ContentType) - if extensions, _ := mime.ExtensionsByType(mediaType); len(extensions) != 0 { - extension = extensions[0] - } - } - - dir := filepath.Join(consts.CacheDir(), "attachments") - if err := os.MkdirAll(dir, 0o700); err != nil { - return attachmentErr("create attachments directory", err) - } - - file, err := os.CreateTemp(dir, "attachment-*"+extension) - if err != nil { - return attachmentErr("create attachment file", err) - } - defer file.Close() - - path := file.Name() - - if err := downloadAttachment(attachment, path); err != nil { - os.Remove(path) - return attachmentErr("download attachment", err) - } else if err := open.Start(path); err != nil { - return attachmentErr("open attachment file", err) - } - - return nil - } -} - func attachmentErr(what string, err error) tview.Msg { slog.Error("failed to "+what, "err", err) return ui.ModalMsg{Text: "Failed to " + what + ": " + err.Error(), Buttons: []ui.ModalButton{{Label: "OK"}}} } -func saveAttachment(attachment discord.Attachment) tview.Cmd { - return func() tview.Msg { - destination, err := zenity.SelectFileSave(zenity.Filename(filepath.Base(attachment.Filename)), zenity.ConfirmOverwrite()) - if errors.Is(err, zenity.ErrCanceled) { - return nil - } - if err != nil { - return attachmentErr("select attachment destination", err) - } - - if err := downloadAttachment(attachment, destination); err != nil { - return attachmentErr("download attachment", err) - } - return nil - } -} - func downloadAttachment(attachment discord.Attachment, destination string) error { resp, err := http.Get(attachment.URL) if err != nil { @@ -1301,45 +1066,6 @@ func downloadAttachment(attachment discord.Attachment, destination string) error return err } -func openURL(url string) tview.Cmd { - return func() tview.Msg { - if err := open.Start(url); err != nil { - return attachmentErr("open URL", err) - } - return nil - } -} - -func (ml *Model) reply(mention bool) tview.Cmd { - selectedMessage, ok := ml.selectedMessage() - if !ok { - return nil - } - - name := selectedMessage.Author.DisplayOrUsername() - if member := ml.memberForMessage(*selectedMessage); member != nil && member.Nick != "" { - name = member.Nick - } - - message := *selectedMessage - return func() tview.Msg { return ReplyMsg{Message: message, Name: name, Mention: mention} } -} - -func (ml *Model) editSelectedMessage() tview.Cmd { - selectedMessage, ok := ml.selectedMessage() - if !ok { - return nil - } - - if !ui.IsMe(ml.state, selectedMessage.Author.ID) { - slog.Error("failed to edit message; not the author", "channel_id", selectedMessage.ChannelID, "message_id", selectedMessage.ID) - return nil - } - - message := *selectedMessage - return func() tview.Msg { return EditMsg(message) } -} - // SelectLastOwn selects the last message in the selected channel that the user can edit, and returns it. func (ml *Model) SelectLastOwn() (discord.Message, bool) { channel := ml.channel @@ -1359,85 +1085,11 @@ func (ml *Model) SelectLastOwn() (discord.Message, bool) { return discord.Message{}, false } -func (ml *Model) confirmDelete() tview.Cmd { - selectedMessage, ok := ml.selectedMessage() - if !ok || !ml.canDeleteMessage(*selectedMessage) { - return nil - } - message := *selectedMessage - return ui.ShowModal( - "Are you sure you want to delete this message?", - ui.ModalButton{Label: "Yes", Result: deleteMessageMsg(message)}, - ui.ModalButton{Label: "No"}, - ) -} - -func (ml *Model) deleteSelectedMessage() tview.Cmd { - selectedMessage, ok := ml.selectedMessage() - if !ok { - return nil - } - return ml.requestDelete(*selectedMessage) -} - -func (ml *Model) requestDelete(message discord.Message) tview.Cmd { - return func() tview.Msg { - if !ml.canDeleteMessage(message) { - slog.Error("failed to delete message; missing relevant permissions", "channel_id", message.ChannelID, "message_id", message.ID) - return nil - } - - if err := ml.state.DeleteMessage(message.ChannelID, message.ID, ""); err != nil { - slog.Error("failed to delete message", "channel_id", message.ChannelID, "message_id", message.ID, "err", err) - return nil - } - - if err := ml.state.MessageRemove(message.ChannelID, message.ID); err != nil { - slog.Error("failed to delete message", "channel_id", message.ChannelID, "message_id", message.ID, "err", err) - } - return nil - } -} - func (ml *Model) canDeleteMessage(message discord.Message) bool { return ui.IsMe(ml.state, message.Author.ID) || (message.GuildID.IsValid() && ml.state.HasPermissions(message.ChannelID, discord.PermissionManageMessages)) } -func (ml *Model) requestGuildMembers(guildID discord.GuildID, messages []discord.Message) tview.Cmd { - usersToFetch := make([]discord.UserID, 0, len(messages)) - seen := make(map[discord.UserID]struct{}, len(messages)) - - for _, message := range messages { - // Do not fetch member for a webhook message. - if message.WebhookID.IsValid() { - continue - } - - if member, _ := ml.state.Cabinet.Member(guildID, message.Author.ID); member == nil { - userID := message.Author.ID - if _, ok := seen[userID]; !ok { - seen[userID] = struct{}{} - usersToFetch = append(usersToFetch, userID) - } - } - } - - if len(usersToFetch) == 0 { - return nil - } - - return func() tview.Msg { - if err := ml.state.SendGateway(context.Background(), &gateway.RequestGuildMembersCommand{ - GuildIDs: []discord.GuildID{guildID}, - UserIDs: usersToFetch, - }); err != nil { - slog.Error("failed to request guild members", "guild_id", guildID, "err", err) - } - return nil - } -} - func (ml *Model) InvalidateRendered() { for i := range ml.items { ml.items[i].view = nil diff --git a/internal/ui/chat/messageslist/msg.go b/internal/ui/chat/messageslist/msg.go index 3d27712..64777e4 100644 --- a/internal/ui/chat/messageslist/msg.go +++ b/internal/ui/chat/messageslist/msg.go @@ -1,9 +1,24 @@ package messageslist import ( + "context" + "errors" + "log/slog" + "mime" + "os" + "path/filepath" + "slices" + "github.com/ayn2op/arikawa/v3/discord" + "github.com/ayn2op/arikawa/v3/gateway" + "github.com/ayn2op/discordo/internal/consts" + "github.com/ayn2op/discordo/internal/ui" "github.com/ayn2op/tview" + "github.com/ayn2op/tview/list" "github.com/ayn2op/tview/picker" + "github.com/ncruces/zenity" + "github.com/skratchdot/open-golang/open" + "golang.design/x/clipboard" ) // Msg is implemented by the messages the list sends itself, which must reach it whatever has the focus. @@ -14,13 +29,7 @@ type olderMessagesLoadedMsg struct { Older []discord.Message } -type deleteMessageMsg discord.Message - -type attachmentActionMsg struct{ Action tview.Cmd } - func (olderMessagesLoadedMsg) messagesList() {} -func (deleteMessageMsg) messagesList() {} -func (attachmentActionMsg) messagesList() {} // ReplyMsg asks to reply to Message by Name, mentioning its author if Mention is set. type ReplyMsg struct { @@ -34,3 +43,327 @@ type EditMsg discord.Message // ShowAttachmentsMsg asks to pick one of the attachments and links of the selected message. type ShowAttachmentsMsg picker.Items + +// listMsg moves or scrolls the messages list. +type listMsg list.Change + +func (ml *Model) selectUp() tview.Cmd { + cursor := ml.cursor() + if cursor == -1 { + ml.selectBottom() + } else if previous := ml.messageIndex(cursor, -1); previous >= 0 { + ml.setCursor(previous) + } else { + return ml.fetchOlderMessages() + } + return nil +} + +func (ml *Model) fetchOlderMessages() tview.Cmd { + selectedChannel := ml.channel + if selectedChannel == nil { + return nil + } + + channelID := selectedChannel.ID + first := ml.messageIndex(-1, 1) + if first == -1 { + return nil + } + before := ml.items[first].message.ID + limit := uint(ml.cfg.MessagesLimit) + return func() tview.Msg { + messages, err := ml.state.MessagesBefore(channelID, before, limit) + if err != nil { + slog.Error("failed to fetch older messages", "err", err) + return nil + } + if len(messages) == 0 { + return nil + } + + older := slices.Clone(messages) + slices.Reverse(older) + return olderMessagesLoadedMsg{ChannelID: channelID, Older: older} + } +} + +func (ml *Model) yankMessageID() tview.Cmd { + selectedMessage, ok := ml.selectedMessage() + if !ok { + return nil + } + + return func() tview.Msg { + if _, err := clipboard.Write(context.Background(), clipboard.FmtText, []byte(selectedMessage.ID.String())); err != nil { + slog.Error("failed to write to clipboard", "err", err) + } + return nil + } +} + +func (ml *Model) yankContent() tview.Cmd { + selectedMessage, ok := ml.selectedMessage() + if !ok { + return nil + } + + return func() tview.Msg { + if _, err := clipboard.Write(context.Background(), clipboard.FmtText, []byte(selectedMessage.Content)); err != nil { + slog.Error("failed to write to clipboard", "err", err) + } + return nil + } +} + +func (ml *Model) yankURL() tview.Cmd { + selectedMessage, ok := ml.selectedMessage() + if !ok { + return nil + } + + return func() tview.Msg { + if _, err := clipboard.Write(context.Background(), clipboard.FmtText, []byte(selectedMessage.URL())); err != nil { + slog.Error("failed to write to clipboard", "err", err) + } + return nil + } +} + +func (ml *Model) open() tview.Cmd { + return ml.openWith(ml.openAttachment) +} + +func (ml *Model) openInBrowser() tview.Cmd { + return ml.openWith(func(attachment discord.Attachment) tview.Cmd { return openURL(attachment.URL) }) +} + +func (ml *Model) openWith(openAttachment func(discord.Attachment) tview.Cmd) tview.Cmd { + selectedMessage, ok := ml.selectedMessage() + if !ok { + return nil + } + + urls := messageURLs(*selectedMessage) + switch total := len(urls) + len(selectedMessage.Attachments); { + case total == 0: + return nil + case total > 1: + return ml.showAttachmentsList(urls, selectedMessage.Attachments, openAttachment) + case len(urls) == 1: + return openURL(urls[0]) + } + + return openAttachment(selectedMessage.Attachments[0]) +} + +func (ml *Model) download() tview.Cmd { + selectedMessage, ok := ml.selectedMessage() + if !ok || len(selectedMessage.Attachments) == 0 { + return nil + } + if len(selectedMessage.Attachments) == 1 { + attachment := selectedMessage.Attachments[0] + return ml.confirmAttachment(attachment, saveAttachment(attachment)) + } + + items := make(picker.Items, len(selectedMessage.Attachments)) + for i, attachment := range selectedMessage.Attachments { + items[i] = picker.Item{Text: attachment.Filename, Reference: ml.confirmAttachment(attachment, saveAttachment(attachment))} + } + return ml.showAttachmentsPicker(items) +} + +func (ml *Model) showAttachmentsList(urls []string, attachments []discord.Attachment, openAttachment func(discord.Attachment) tview.Cmd) tview.Cmd { + var items picker.Items + for _, attachment := range attachments { + items = append(items, picker.Item{Text: attachment.Filename, Reference: openAttachment(attachment)}) + } + for _, url := range urls { + items = append(items, picker.Item{Text: url, Reference: openURL(url)}) + } + return ml.showAttachmentsPicker(items) +} + +func (ml *Model) showAttachmentsPicker(items picker.Items) tview.Cmd { + return func() tview.Msg { return ShowAttachmentsMsg(items) } +} + +func (ml *Model) openAttachment(attachment discord.Attachment) tview.Cmd { + return ml.confirmAttachment(attachment, openDownloadedAttachment(attachment)) +} + +func (ml *Model) confirmAttachment(attachment discord.Attachment, action tview.Cmd) tview.Cmd { + if !ml.cfg.Attachments.AllowedMIMETypes.Has(attachment.ContentType) { + return ui.ShowModal( + "This attachment type is not allowed and may be unsafe. Continue anyway?", + ui.ModalButton{Label: "No"}, + ui.ModalButton{Label: "Yes", Cmd: action}, + ) + } + return action +} + +func openDownloadedAttachment(attachment discord.Attachment) tview.Cmd { + return func() tview.Msg { + extension := filepath.Ext(attachment.Filename) + if extension == "" { + mediaType, _, _ := mime.ParseMediaType(attachment.ContentType) + if extensions, _ := mime.ExtensionsByType(mediaType); len(extensions) != 0 { + extension = extensions[0] + } + } + + dir := filepath.Join(consts.CacheDir(), "attachments") + if err := os.MkdirAll(dir, 0o700); err != nil { + return attachmentErr("create attachments directory", err) + } + + file, err := os.CreateTemp(dir, "attachment-*"+extension) + if err != nil { + return attachmentErr("create attachment file", err) + } + defer file.Close() + + path := file.Name() + + if err := downloadAttachment(attachment, path); err != nil { + os.Remove(path) + return attachmentErr("download attachment", err) + } else if err := open.Start(path); err != nil { + return attachmentErr("open attachment file", err) + } + + return nil + } +} + +func saveAttachment(attachment discord.Attachment) tview.Cmd { + return func() tview.Msg { + destination, err := zenity.SelectFileSave(zenity.Filename(filepath.Base(attachment.Filename)), zenity.ConfirmOverwrite()) + if errors.Is(err, zenity.ErrCanceled) { + return nil + } + if err != nil { + return attachmentErr("select attachment destination", err) + } + + if err := downloadAttachment(attachment, destination); err != nil { + return attachmentErr("download attachment", err) + } + return nil + } +} + +func openURL(url string) tview.Cmd { + return func() tview.Msg { + if err := open.Start(url); err != nil { + return attachmentErr("open URL", err) + } + return nil + } +} + +func (ml *Model) reply(mention bool) tview.Cmd { + selectedMessage, ok := ml.selectedMessage() + if !ok { + return nil + } + + name := selectedMessage.Author.DisplayOrUsername() + if member := ml.memberForMessage(*selectedMessage); member != nil && member.Nick != "" { + name = member.Nick + } + + message := *selectedMessage + return func() tview.Msg { return ReplyMsg{Message: message, Name: name, Mention: mention} } +} + +func (ml *Model) editSelectedMessage() tview.Cmd { + selectedMessage, ok := ml.selectedMessage() + if !ok { + return nil + } + + if !ui.IsMe(ml.state, selectedMessage.Author.ID) { + slog.Error("failed to edit message; not the author", "channel_id", selectedMessage.ChannelID, "message_id", selectedMessage.ID) + return nil + } + + message := *selectedMessage + return func() tview.Msg { return EditMsg(message) } +} + +func (ml *Model) confirmDelete() tview.Cmd { + selectedMessage, ok := ml.selectedMessage() + if !ok || !ml.canDeleteMessage(*selectedMessage) { + return nil + } + message := *selectedMessage + return ui.ShowModal( + "Are you sure you want to delete this message?", + ui.ModalButton{Label: "Yes", Cmd: ml.requestDelete(message)}, + ui.ModalButton{Label: "No"}, + ) +} + +func (ml *Model) deleteSelectedMessage() tview.Cmd { + selectedMessage, ok := ml.selectedMessage() + if !ok { + return nil + } + return ml.requestDelete(*selectedMessage) +} + +func (ml *Model) requestDelete(message discord.Message) tview.Cmd { + return func() tview.Msg { + if !ml.canDeleteMessage(message) { + slog.Error("failed to delete message; missing relevant permissions", "channel_id", message.ChannelID, "message_id", message.ID) + return nil + } + + if err := ml.state.DeleteMessage(message.ChannelID, message.ID, ""); err != nil { + slog.Error("failed to delete message", "channel_id", message.ChannelID, "message_id", message.ID, "err", err) + return nil + } + + if err := ml.state.MessageRemove(message.ChannelID, message.ID); err != nil { + slog.Error("failed to delete message", "channel_id", message.ChannelID, "message_id", message.ID, "err", err) + } + return nil + } +} + +func (ml *Model) requestGuildMembers(guildID discord.GuildID, messages []discord.Message) tview.Cmd { + usersToFetch := make([]discord.UserID, 0, len(messages)) + seen := make(map[discord.UserID]struct{}, len(messages)) + + for _, message := range messages { + // Do not fetch member for a webhook message. + if message.WebhookID.IsValid() { + continue + } + + if member, _ := ml.state.Cabinet.Member(guildID, message.Author.ID); member == nil { + userID := message.Author.ID + if _, ok := seen[userID]; !ok { + seen[userID] = struct{}{} + usersToFetch = append(usersToFetch, userID) + } + } + } + + if len(usersToFetch) == 0 { + return nil + } + + return func() tview.Msg { + if err := ml.state.SendGateway(context.Background(), &gateway.RequestGuildMembersCommand{ + GuildIDs: []discord.GuildID{guildID}, + UserIDs: usersToFetch, + }); err != nil { + slog.Error("failed to request guild members", "guild_id", guildID, "err", err) + } + return nil + } +} diff --git a/internal/ui/chat/model.go b/internal/ui/chat/model.go index 89f0397..f8fc0d3 100644 --- a/internal/ui/chat/model.go +++ b/internal/ui/chat/model.go @@ -1,6 +1,7 @@ package chat import ( + "cmp" "fmt" "log/slog" "time" @@ -43,9 +44,8 @@ type Model struct { 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 + // overlay is the picker shown on top, which takes all input. + overlay overlay guildsTree guildstree.Model membersTree memberstree.Model @@ -114,69 +114,51 @@ func NewModel(cfg *config.Config, token string) Model { m.membersTreeVisible = cfg.MembersTree.Visible if m.guildsTreeVisible { // The guilds tree is focused first at start-up when visible. - m.focused = guildsTreePane + m.focused = paneGuildsTree } else { - m.focused = messagesListPane + m.focused = paneMessagesList } return m } -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 -} +// overlay is the picker shown on top of the panes, if any. +type overlay int -func (m *Model) togglePicker() tview.Cmd { - if m.channelsPickerOpen { - return m.closePicker() - } - return m.openPicker() -} - -func (m *Model) openPicker() tview.Cmd { - m.channelsPickerOpen = true - m.channelsPicker.RefreshChannels(m.state) - return nil -} - -func (m *Model) closePicker() tview.Cmd { - m.channelsPickerOpen = false - m.channelsPicker.Reset() - return nil -} - -func (m *Model) closeAttachmentsPicker() tview.Cmd { - m.attachmentsPickerOpen = false - return nil -} - -// pickerOpen reports whether a picker is shown on top. -func (m Model) pickerOpen() bool { - return m.channelsPickerOpen || m.attachmentsPickerOpen -} +const ( + overlayNone overlay = iota + overlayChannelsPicker + overlayAttachmentsPicker +) // pickerView returns the view of the picker shown on top, or nil if none is open. func (m Model) pickerView() tview.Element { - switch { - case m.channelsPickerOpen: + switch m.overlay { + case overlayChannelsPicker: return m.channelsPicker.View() - case m.attachmentsPickerOpen: + case overlayAttachmentsPicker: return m.attachmentsPicker.View() default: return nil } } -func (m *Model) navigateToChannel(channelID discord.ChannelID) tview.Cmd { - closeCmd := m.closePicker() - var cmd tview.Cmd - m.guildsTree, cmd = m.guildsTree.Update(guildstree.NavigateMsg{ChannelID: channelID}) - return tview.Sequence(closeCmd, cmd) +// togglePicker opens the channels picker, or closes it if it is open. +func (m *Model) togglePicker() { + if m.overlay == overlayChannelsPicker { + m.closePicker() + } else { + m.openPicker() + } +} + +func (m *Model) openPicker() { + m.overlay = overlayChannelsPicker + m.channelsPicker.RefreshChannels(m.state) +} + +func (m *Model) closePicker() { + m.overlay = overlayNone + m.channelsPicker.Reset() } // toggle shows or hides the side pane p and focuses it when shown. @@ -185,7 +167,7 @@ func (m *Model) toggle(visible *bool, p pane) { if *visible { m.setFocus(p) } else if m.focused == p { - m.focused = messagesListPane + m.focused = paneMessagesList } } @@ -234,39 +216,42 @@ func (m *Model) update(msg tview.Msg) tview.Cmd { } membersCmd := m.setSelectedChannel(&msg.Channel) - m.clearTypers() + clear(m.typers) if m.cfg.AutoFocus { - m.setFocus(composerPane) + m.setFocus(paneComposer) } 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), membersCmd) case messageslist.Msg, tview.TerminalInfoMsg: - return m.updatePane(messagesListPane, msg) + return m.updatePane(paneMessagesList, msg) case typingExpiredMsg: if until, ok := m.typers[msg.userID]; ok && !time.Now().Before(until) { - m.removeTyper(msg.userID) + delete(m.typers, msg.userID) } return nil case channelspicker.SelectedMsg: return m.navigateToChannel(msg.ChannelID) case channelspicker.CancelMsg: - return m.closePicker() + m.closePicker() + return nil case attachmentspicker.SelectedMsg: - return tview.Sequence(msg.Action, m.closeAttachmentsPicker()) + m.overlay = overlayNone + return msg.Action case attachmentspicker.CancelMsg: - return m.closeAttachmentsPicker() + m.overlay = overlayNone + return nil case messageslist.ShowAttachmentsMsg: m.attachmentsPicker.SetItems(picker.Items(msg)) - m.attachmentsPickerOpen = true + m.overlay = overlayAttachmentsPicker return nil case messageslist.ReplyMsg: m.composer.StartReply(msg.Message, msg.Name, msg.Mention) - m.setFocus(composerPane) + m.setFocus(paneComposer) return nil case messageslist.EditMsg: m.composer.StartEdit(discord.Message(msg)) - m.setFocus(composerPane) + m.setFocus(paneComposer) return nil case composer.EditLastMsg: if message, ok := m.messagesList.SelectLastOwn(); ok { @@ -282,18 +267,18 @@ func (m *Model) update(msg tview.Msg) tview.Cmd { switch { case keybind.Matches(msg, m.cfg.Keybinds.FocusGuildsTree.Keybind): m.composer.CloseMentions() - m.setFocus(guildsTreePane) + m.setFocus(paneGuildsTree) return nil case keybind.Matches(msg, m.cfg.Keybinds.FocusMembersTree.Keybind): m.composer.CloseMentions() - m.setFocus(membersTreePane) + m.setFocus(paneMembersTree) return nil case keybind.Matches(msg, m.cfg.Keybinds.FocusMessagesList.Keybind): m.composer.CloseMentions() - m.setFocus(messagesListPane) + m.setFocus(paneMessagesList) return nil case keybind.Matches(msg, m.cfg.Keybinds.FocusComposer.Keybind): - m.setFocus(composerPane) + m.setFocus(paneComposer) return nil case keybind.Matches(msg, m.cfg.Keybinds.FocusPrevious.Keybind): @@ -304,19 +289,20 @@ func (m *Model) update(msg tview.Msg) tview.Cmd { return nil case keybind.Matches(msg, m.cfg.Keybinds.ToggleGuildsTree.Keybind): - m.toggle(&m.guildsTreeVisible, guildsTreePane) + m.toggle(&m.guildsTreeVisible, paneGuildsTree) return nil case keybind.Matches(msg, m.cfg.Keybinds.ToggleMembersTree.Keybind): - m.toggle(&m.membersTreeVisible, membersTreePane) + m.toggle(&m.membersTreeVisible, paneMembersTree) return nil case keybind.Matches(msg, m.cfg.Keybinds.ToggleChannelsPicker.Keybind): - return m.togglePicker() + m.togglePicker() + return nil case keybind.Matches(msg, m.cfg.Keybinds.Logout.Keybind): return tview.Sequence(closeState(m.state), logout()) } case composer.TabSuggestMsg, mentionslist.Msg: - return m.updatePane(composerPane, msg) + return m.updatePane(paneComposer, msg) case paneMsg: if msg.focus { m.setFocus(msg.pane) @@ -332,10 +318,10 @@ func (m *Model) update(msg tview.Msg) tview.Cmd { // route sends msg to the open picker, which takes all input, and otherwise to the focused pane. func (m *Model) route(msg tview.Msg) tview.Cmd { var cmd tview.Cmd - switch { - case m.channelsPickerOpen: + switch m.overlay { + case overlayChannelsPicker: m.channelsPicker, cmd = m.channelsPicker.Update(msg) - case m.attachmentsPickerOpen: + case overlayAttachmentsPicker: m.attachmentsPicker, cmd = m.attachmentsPicker.Update(msg) default: cmd = m.updatePane(m.focused, msg) @@ -347,25 +333,18 @@ func (m *Model) route(msg tview.Msg) tview.Cmd { func (m *Model) updatePane(p pane, msg tview.Msg) tview.Cmd { var cmd tview.Cmd switch p { - case guildsTreePane: + case paneGuildsTree: m.guildsTree, cmd = m.guildsTree.Update(msg) - case messagesListPane: + case paneMessagesList: m.messagesList, cmd = m.messagesList.Update(msg) - case composerPane: + case paneComposer: m.composer, cmd = m.composer.Update(msg) - case membersTreePane: + case paneMembersTree: m.membersTree, cmd = m.membersTree.Update(msg) } return cmd } -// paneMsg is what a pane's element made of a mouse message within it. A left mouse button press also focuses the pane. -type paneMsg struct { - pane pane - msg tview.Msg - focus bool -} - // paneElement passes input to the element of a pane and marks mouse input within the pane as meant for it. type paneElement struct { pane pane @@ -389,25 +368,25 @@ func (p paneElement) Handle(msg tview.Msg, area tview.Rectangle) tview.Msg { type pane int const ( - guildsTreePane pane = iota - messagesListPane - composerPane - membersTreePane + paneGuildsTree pane = iota + paneMessagesList + paneComposer + paneMembersTree 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. func (m Model) paneView(p pane) tview.Element { - focused := m.focused == p && !m.pickerOpen() + focused := m.focused == p && m.overlay == overlayNone var child tview.Element switch p { - case guildsTreePane: + case paneGuildsTree: child = m.guildsTree.View(focused) - case messagesListPane: - child = m.messagesList.View(focused) - case composerPane: + case paneMessagesList: + child = m.messagesList.View(focused, m.typingFooter()) + case paneComposer: child = m.composer.View(focused) - case membersTreePane: + case paneMembersTree: child = m.membersTree.View(focused) } return paneElement{pane: p, child: child} @@ -423,11 +402,11 @@ func (m *Model) setFocus(p pane) { // canFocus reports whether p is shown and can take the focus. func (m Model) canFocus(p pane) bool { switch p { - case guildsTreePane: + case paneGuildsTree: return m.guildsTreeVisible - case composerPane: + case paneComposer: return !m.composer.Disabled() - case membersTreePane: + case paneMembersTree: return m.membersTreeShown() default: return true @@ -439,8 +418,8 @@ func (m Model) View() tview.Element { picker := m.pickerView() var right tview.Element = column.New( - m.paneView(messagesListPane), - column.New(m.paneView(composerPane)).Height(tview.Fixed(m.composer.Height())), + m.paneView(paneMessagesList), + column.New(m.paneView(paneComposer)).Height(tview.Fixed(m.composer.Height())), ) if mentions := m.composer.MentionsView(); mentions != nil { right = stack.New(right, mentions) @@ -451,13 +430,13 @@ func (m Model) View() tview.Element { 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)), + column.New(m.paneView(paneMembersTree)).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(m.paneView(paneGuildsTree)).Width(tview.FillPortion(width)), column.New(main).Width(tview.FillPortion(100-width)), ) } @@ -473,80 +452,44 @@ func (m Model) View() tview.Element { ) } -// typingExpiredMsg ends a user's typing indicator unless they typed again and extended it. -type typingExpiredMsg struct { - userID discord.UserID -} - -func (m *Model) clearTypers() { - clear(m.typers) - m.updateFooter() -} - -// addTyper shows userID as typing and returns a command that ends it after the typing duration. -func (m *Model) addTyper(userID discord.UserID) tview.Cmd { - m.typers[userID] = time.Now().Add(composer.TypingDuration) - m.updateFooter() - return func() tview.Msg { - time.Sleep(composer.TypingDuration) - return typingExpiredMsg{userID} +// typingFooter returns who is typing in the selected channel, or "" if nobody is. +func (m Model) typingFooter() string { + channel := m.selectedChannel + if channel == nil || len(m.typers) == 0 { + return "" } -} - -func (m *Model) removeTyper(userID discord.UserID) { - delete(m.typers, userID) - m.updateFooter() -} - -func (m *Model) updateFooter() { - selectedChannel := m.selectedChannel - if selectedChannel == nil { - return - } - guildID := selectedChannel.GuildID - - var footer string - if len(m.typers) > 0 { - var names []string - for userID := range m.typers { - var name string - if guildID.IsValid() { - member, err := m.state.Cabinet.Member(guildID, userID) - if err != nil { - slog.Error("failed to get member from state", "err", err, "guild_id", guildID, "user_id", userID) - continue - } - - if member.Nick != "" { - name = member.Nick - } else { - name = member.User.DisplayOrUsername() - } - } else { - for _, recipient := range selectedChannel.DMRecipients { - if recipient.ID == userID { - name = recipient.DisplayOrUsername() - break - } - } + var names []string + for userID := range m.typers { + var name string + if channel.GuildID.IsValid() { + member, err := m.state.Cabinet.Member(channel.GuildID, userID) + if err != nil { + continue } - - if name != "" { - names = append(names, name) + name = cmp.Or(member.Nick, member.User.DisplayName, member.User.Username) + } else { + for _, recipient := range channel.DMRecipients { + if recipient.ID == userID { + name = recipient.DisplayOrUsername() + break + } } } - - switch len(names) { - case 1: - footer = names[0] + " is typing..." - case 2: - footer = fmt.Sprintf("%s and %s are typing...", names[0], names[1]) - case 3: - footer = fmt.Sprintf("%s, %s, and %s are typing...", names[0], names[1], names[2]) - default: - footer = "Several people are typing..." + if name != "" { + names = append(names, name) } } - m.messagesList.SetFooter(footer) + switch len(names) { + case 0: + return "" + case 1: + return names[0] + " is typing..." + case 2: + return fmt.Sprintf("%s and %s are typing...", names[0], names[1]) + case 3: + return fmt.Sprintf("%s, %s, and %s are typing...", names[0], names[1], names[2]) + default: + return "Several people are typing..." + } } diff --git a/internal/ui/chat/model_test.go b/internal/ui/chat/model_test.go index cd9e9a3..2cbeeb5 100644 --- a/internal/ui/chat/model_test.go +++ b/internal/ui/chat/model_test.go @@ -83,15 +83,15 @@ 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 + m.focused = paneGuildsTree // The composer is disabled without permission to send messages, so it is skipped. - for _, want := range []pane{messagesListPane, membersTreePane, guildsTreePane} { + for _, want := range []pane{paneMessagesList, paneMembersTree, paneGuildsTree} { if m.cycleFocus(1); m.focused != want { t.Fatalf("focused = %v, want %v", m.focused, want) } } - for _, want := range []pane{membersTreePane, messagesListPane, guildsTreePane} { + for _, want := range []pane{paneMembersTree, paneMessagesList, paneGuildsTree} { if m.cycleFocus(-1); m.focused != want { t.Fatalf("focused = %v, want %v", m.focused, want) } diff --git a/internal/ui/chat/msg.go b/internal/ui/chat/msg.go index a616915..b0ffec7 100644 --- a/internal/ui/chat/msg.go +++ b/internal/ui/chat/msg.go @@ -3,8 +3,12 @@ package chat import ( "context" "log/slog" + "time" + "github.com/ayn2op/arikawa/v3/discord" "github.com/ayn2op/arikawa/v3/gateway" + "github.com/ayn2op/discordo/internal/ui/chat/composer" + "github.com/ayn2op/discordo/internal/ui/chat/guildstree" "github.com/ayn2op/ningen/v3" "github.com/ayn2op/tview" ) @@ -45,3 +49,41 @@ func logout() tview.Cmd { } type QuitMsg struct{} + +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 = paneMessagesList + } + return cmd +} + +func (m *Model) navigateToChannel(channelID discord.ChannelID) tview.Cmd { + m.closePicker() + var cmd tview.Cmd + m.guildsTree, cmd = m.guildsTree.Update(guildstree.NavigateMsg{ChannelID: channelID}) + return cmd +} + +// paneMsg is what a pane's element made of a mouse message within it. A left mouse button press also focuses the pane. +type paneMsg struct { + pane pane + msg tview.Msg + focus bool +} + +// typingExpiredMsg ends a user's typing indicator unless they typed again and extended it. +type typingExpiredMsg struct { + userID discord.UserID +} + +// addTyper shows userID as typing and returns a command that ends it after the typing duration. +func (m *Model) addTyper(userID discord.UserID) tview.Cmd { + m.typers[userID] = time.Now().Add(composer.TypingDuration) + return func() tview.Msg { + time.Sleep(composer.TypingDuration) + return typingExpiredMsg{userID} + } +} diff --git a/internal/ui/chat/state.go b/internal/ui/chat/state.go index ef6b303..f43aa2c 100644 --- a/internal/ui/chat/state.go +++ b/internal/ui/chat/state.go @@ -29,16 +29,16 @@ func (m *Model) onRaw(event *ws.RawEvent) { } func (m *Model) loadGuildsTree(event *gateway.ReadyEvent) tview.Cmd { - cmd := m.updatePane(guildsTreePane, event) - m.setFocus(guildsTreePane) + cmd := m.updatePane(paneGuildsTree, event) + m.setFocus(paneGuildsTree) return cmd } func (m *Model) addMessageOrNotify(message *gateway.MessageCreateEvent) tview.Cmd { - cmd := m.updatePane(guildsTreePane, message) + cmd := m.updatePane(paneGuildsTree, message) if channel := m.selectedChannel; channel != nil && channel.ID == message.ChannelID { - m.removeTyper(message.Author.ID) + delete(m.typers, message.Author.ID) cmd = tview.Batch(cmd, m.messagesList.AddMessage(message.Message)) if !m.windowUnfocused || !m.cfg.Notifications.WhenUnfocused { return cmd @@ -112,16 +112,16 @@ func (m *Model) applyEvent(event gateway.Event) tview.Cmd { m.onRaw(event) case *gateway.ReadyEvent: - return tview.Batch(m.loadGuildsTree(event), m.updatePane(membersTreePane, event)) + return tview.Batch(m.loadGuildsTree(event), m.updatePane(paneMembersTree, event)) case *gateway.MessageCreateEvent: return m.addMessageOrNotify(event) case *gateway.MessageUpdateEvent: return m.messagesList.UpdateMessage(event.Message) case *gateway.PresenceUpdateEvent: - return tview.Batch(m.updatePane(guildsTreePane, event), m.updatePane(membersTreePane, event)) + return tview.Batch(m.updatePane(paneGuildsTree, event), m.updatePane(paneMembersTree, event)) case *gateway.GuildMemberListUpdateEvent: - return m.updatePane(membersTreePane, event) + return m.updatePane(paneMembersTree, event) case *gateway.MessageDeleteEvent: m.messagesList.DeleteMessage(event.ChannelID, event.ID) case *gateway.MessageReactionAddEvent: @@ -146,7 +146,7 @@ func (m *Model) applyEvent(event gateway.Event) tview.Cmd { } case *read.UpdateEvent: - return m.updatePane(guildsTreePane, event) + return m.updatePane(paneGuildsTree, event) } return nil } diff --git a/internal/ui/login/form/model.go b/internal/ui/login/form/model.go deleted file mode 100644 index 36813b1..0000000 --- a/internal/ui/login/form/model.go +++ /dev/null @@ -1,95 +0,0 @@ -// Package form lays out labeled text inputs above a submit button. -package form - -import ( - "slices" - - "github.com/ayn2op/tview" - "github.com/ayn2op/tview/button" - "github.com/ayn2op/tview/column" - "github.com/ayn2op/tview/row" - "github.com/ayn2op/tview/text" - "github.com/ayn2op/tview/textinput" - "github.com/gdamore/tcell/v3" - "github.com/rivo/uniseg" -) - -// Field is a labeled text input. A non-empty Mask hides what is typed, such as for passwords. -type Field struct { - Label, Mask string - EditState textinput.EditState -} - -// Model is a column of fields above a button. Tab and the arrow keys move the focus, Enter in a field moves to the next one, and Enter on the button or clicking it submits. -type Model struct { - fields []Field - button string - focus int -} - -// SubmitMsg is returned when the button is pressed. -type SubmitMsg struct{} - -type editMsg struct { - index int - change textinput.Change -} - -type nextMsg struct{} - -func New(button string, fields ...Field) Model { - return Model{fields: fields, button: button} -} - -var _ tview.Model[Model] = Model{} - -// Value returns the text of field index. -func (m Model) Value(index int) string { - return m.fields[index].EditState.Value() -} - -func (Model) Init() tview.Cmd { return nil } - -func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) { - count := len(m.fields) + 1 - switch msg := msg.(type) { - case editMsg: - // Earlier copies of the model share the fields, so change a copy of them. - m.fields = slices.Clone(m.fields) - m.fields[msg.index].EditState.Apply(msg.change) - case nextMsg: - m.focus = (m.focus + 1) % count - case tview.KeyMsg: - switch msg.Key() { - case tcell.KeyTab, tcell.KeyDown: - m.focus = (m.focus + 1) % count - case tcell.KeyBacktab, tcell.KeyUp: - m.focus = (m.focus + count - 1) % count - } - } - return m, nil -} - -func (m Model) View() tview.Element { - labelWidth := 0 - for _, f := range m.fields { - labelWidth = max(labelWidth, uniseg.StringWidth(f.Label)+1) - } - layout := column.New().Spacing(1) - for i := range m.fields { - f := &m.fields[i] - input := textinput.New(&f.EditState). - Mask(f.Mask). - Focused(m.focus == i). - OnChange(func(a textinput.Change) tview.Msg { return editMsg{index: i, change: a} }). - OnSubmit(nextMsg{}) - layout = layout.Push(row.New(column.New(text.New(f.Label)).Width(tview.Fixed(labelWidth)), input).Height(tview.Fixed(1))) - } - submit := button.New(). - Label(m.button). - Width(tview.Fixed(uniseg.StringWidth(m.button) + 4)). - Height(tview.Fixed(1)). - Focused(m.focus == len(m.fields)). - OnClick(SubmitMsg{}) - return layout.Push(submit) -} diff --git a/internal/ui/login/keymap.go b/internal/ui/login/keymap.go index afc8a15..bf5a6ab 100644 --- a/internal/ui/login/keymap.go +++ b/internal/ui/login/keymap.go @@ -59,11 +59,11 @@ func (m Model) navHelp() []keybind.Keybind { func (m Model) activeKeyMap() help.KeyMap { var active any switch m.active { - case passwordTab: + case tabPassword: active = m.password - case qrTab: + case tabQR: active = m.qr - case tokenTab: + case tabToken: active = m.token } keyMap, _ := active.(help.KeyMap) diff --git a/internal/ui/login/keymap_test.go b/internal/ui/login/keymap_test.go index 292635e..96db5c4 100644 --- a/internal/ui/login/keymap_test.go +++ b/internal/ui/login/keymap_test.go @@ -20,9 +20,9 @@ func TestModelShortHelp(t *testing.T) { active tab wantPrev, wantNext bool }{ - {"first tab", passwordTab, false, true}, - {"middle tab", qrTab, true, true}, - {"last tab", tokenTab, true, false}, + {"first tab", tabPassword, false, true}, + {"middle tab", tabQR, true, true}, + {"last tab", tabToken, true, false}, } { t.Run(tt.name, func(t *testing.T) { m.active = tt.active diff --git a/internal/ui/login/model.go b/internal/ui/login/model.go index 3fb839b..335a418 100644 --- a/internal/ui/login/model.go +++ b/internal/ui/login/model.go @@ -1,8 +1,6 @@ package login import ( - "log/slog" - "github.com/ayn2op/tview/tabs" "github.com/ayn2op/discordo/internal/config" @@ -17,9 +15,9 @@ import ( type tab int const ( - passwordTab tab = iota - qrTab - tokenTab + tabPassword tab = iota + tabQR + tabToken tabCount ) @@ -35,39 +33,31 @@ type Model struct { var _ tview.Model[Model] = Model{} func NewModel(cfg *config.Config) Model { - return Model{ - cfg: cfg, - password: password.NewModel(), - qr: qr.NewModel(), - token: token.NewModel(), - } + return Model{cfg: cfg, qr: qr.NewModel()} } // Init initializes the active tab. func (m Model) Init() tview.Cmd { switch m.active { - case passwordTab: + case tabPassword: return m.password.Init() - case qrTab: + case tabQR: return m.qr.Init() - case tokenTab: + case tabToken: return m.token.Init() } return nil } -// selectTabMsg switches to the tab at an index. -type selectTabMsg int - // View shows the tabs in a box. func (m Model) View() tview.Element { var content tview.Element switch m.active { - case passwordTab: + case tabPassword: content = m.password.View() - case qrTab: + case tabQR: content = m.qr.View() - case tokenTab: + case tabToken: content = m.token.View() } t := tabs.New(m.password.Label(), m.qr.Label(), m.token.Label()). @@ -87,27 +77,16 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) { case error: // The active tab still gets the error, so the QR tab can close its connection. errCmd = showErrorDialog(msg) - case copyErrorMsg: - return m, setClipboard(string(msg)) } var cmd tview.Cmd switch m.active { - case passwordTab: + case tabPassword: m.password, cmd = m.password.Update(msg) - case qrTab: + case tabQR: m.qr, cmd = m.qr.Update(msg) - case tokenTab: + case tabToken: m.token, cmd = m.token.Update(msg) } return m, tview.Batch(errCmd, cmd) } - -func showErrorDialog(err error) tview.Cmd { - slog.Error("failed to login", "err", err) - message := err.Error() - return ui.ShowModal(message, - ui.ModalButton{Label: "Copy", Result: copyErrorMsg(message), KeepOpen: true}, - ui.ModalButton{Label: "Close"}, - ) -} diff --git a/internal/ui/login/msg.go b/internal/ui/login/msg.go index 724d627..13bc088 100644 --- a/internal/ui/login/msg.go +++ b/internal/ui/login/msg.go @@ -4,12 +4,11 @@ import ( "context" "log/slog" + "github.com/ayn2op/discordo/internal/ui" "github.com/ayn2op/tview" "golang.design/x/clipboard" ) -type copyErrorMsg string - func setClipboard(content string) tview.Cmd { return func() tview.Msg { if _, err := clipboard.Write(context.Background(), clipboard.FmtText, []byte(content)); err != nil { @@ -18,3 +17,15 @@ func setClipboard(content string) tview.Cmd { return nil } } + +// selectTabMsg switches to the tab at an index. +type selectTabMsg int + +func showErrorDialog(err error) tview.Cmd { + slog.Error("failed to login", "err", err) + message := err.Error() + return ui.ShowModal(message, + ui.ModalButton{Label: "Copy", Cmd: setClipboard(message), KeepOpen: true}, + ui.ModalButton{Label: "Close"}, + ) +} diff --git a/internal/ui/login/password/model.go b/internal/ui/login/password/model.go index b464558..f49e98d 100644 --- a/internal/ui/login/password/model.go +++ b/internal/ui/login/password/model.go @@ -1,33 +1,84 @@ package password import ( - "github.com/ayn2op/discordo/internal/ui/login/form" "github.com/ayn2op/tview" + "github.com/ayn2op/tview/button" + "github.com/ayn2op/tview/column" + "github.com/ayn2op/tview/row" + "github.com/ayn2op/tview/text" + "github.com/ayn2op/tview/textinput" + "github.com/gdamore/tcell/v3" ) +// focus is the input or button that receives keys. +type focus int + +const ( + focusLogin focus = iota + focusPassword + focusButton + focusCount +) + +// Model is a login and a password input above a button. Tab and the arrow keys move the focus, Enter in an input moves to the next one, and Enter on the button or clicking it logs in. type Model struct { - form.Model + login, password textinput.EditState + focus focus } var _ tview.Model[Model] = Model{} -func NewModel() Model { - return Model{Model: form.New("Login", form.Field{Label: "Login"}, form.Field{Label: "Password", Mask: "*"})} -} - func (Model) Label() string { return "Password" } +func (Model) Init() tview.Cmd { return nil } + func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) { - if _, ok := msg.(form.SubmitMsg); ok { - login, password := m.Value(0), m.Value(1) + switch msg := msg.(type) { + case loginMsg: + m.login.Apply(textinput.Change(msg)) + case passwordMsg: + m.password.Apply(textinput.Change(msg)) + case focusMsg: + m.focus = focus(msg) + case tview.KeyMsg: + switch msg.Key() { + case tcell.KeyTab, tcell.KeyDown: + m.focus = (m.focus + 1) % focusCount + case tcell.KeyBacktab, tcell.KeyUp: + m.focus = (m.focus + focusCount - 1) % focusCount + } + case submitMsg: + login, password := m.login.Value(), m.password.Value() if login == "" || password == "" { return m, nil } return m, submitLogin(login, password) } - var cmd tview.Cmd - m.Model, cmd = m.Model.Update(msg) - return m, cmd + return m, nil +} + +func (m Model) View() tview.Element { + labelWidth := tview.Fixed(len("Password") + 1) + login := textinput.New(&m.login). + Focused(m.focus == focusLogin). + OnChange(func(c textinput.Change) tview.Msg { return loginMsg(c) }). + OnSubmit(focusMsg(focusPassword)) + password := textinput.New(&m.password). + Mask("*"). + Focused(m.focus == focusPassword). + OnChange(func(c textinput.Change) tview.Msg { return passwordMsg(c) }). + OnSubmit(focusMsg(focusButton)) + submit := button.New(). + Label("Login"). + Width(tview.Fixed(len("Login") + 4)). + Height(tview.Fixed(1)). + Focused(m.focus == focusButton). + OnClick(submitMsg{}) + return column.New( + row.New(column.New(text.New("Login")).Width(labelWidth), login).Height(tview.Fixed(1)), + row.New(column.New(text.New("Password")).Width(labelWidth), password).Height(tview.Fixed(1)), + submit, + ).Spacing(1) } diff --git a/internal/ui/login/form/model_test.go b/internal/ui/login/password/model_test.go similarity index 70% rename from internal/ui/login/form/model_test.go rename to internal/ui/login/password/model_test.go index 27dd747..8ef2d77 100644 --- a/internal/ui/login/form/model_test.go +++ b/internal/ui/login/password/model_test.go @@ -1,4 +1,4 @@ -package form +package password import ( "testing" @@ -18,18 +18,18 @@ func TestModelUpdate(t *testing.T) { } key := func(k tcell.Key, str string) tview.KeyMsg { return tcell.NewEventKey(k, str, tcell.ModNone) } - m := New("Login", Field{Label: "Login"}, Field{Label: "Password", Mask: "*"}) + var m Model send(&m, key(tcell.KeyRune, "a")) send(&m, key(tcell.KeyEnter, "")) send(&m, key(tcell.KeyRune, "b")) - t.Run("typing fills the focused field and Enter moves on", func(t *testing.T) { - if m.Value(0) != "a" || m.Value(1) != "b" { - t.Fatalf("values = %q, %q", m.Value(0), m.Value(1)) + t.Run("typing fills the focused input and Enter moves on", func(t *testing.T) { + if m.login.Value() != "a" || m.password.Value() != "b" { + t.Fatalf("values = %q, %q", m.login.Value(), m.password.Value()) } }) t.Run("enter on the button submits", func(t *testing.T) { send(&m, key(tcell.KeyTab, "")) - if got := send(&m, key(tcell.KeyEnter, "")); got != (SubmitMsg{}) { + if got := send(&m, key(tcell.KeyEnter, "")); got != (submitMsg{}) { t.Fatalf("got %v", got) } }) diff --git a/internal/ui/login/password/msg.go b/internal/ui/login/password/msg.go index 836def2..2385e66 100644 --- a/internal/ui/login/password/msg.go +++ b/internal/ui/login/password/msg.go @@ -6,10 +6,18 @@ import ( "github.com/ayn2op/arikawa/v3/utils/httputil" "github.com/ayn2op/discordo/internal/http" "github.com/ayn2op/tview" + "github.com/ayn2op/tview/textinput" ) type TokenMsg string +type ( + loginMsg textinput.Change + passwordMsg textinput.Change + focusMsg focus + submitMsg struct{} +) + func submitLogin(login, password string) tview.Cmd { return func() tview.Msg { headers := http.Headers() diff --git a/internal/ui/login/qr/model.go b/internal/ui/login/qr/model.go index bb5c5fa..8b24c38 100644 --- a/internal/ui/login/qr/model.go +++ b/internal/ui/login/qr/model.go @@ -27,12 +27,6 @@ type Model struct { status string } -// scrollMsg scrolls the code. -type scrollMsg textview.Change - -// startMsg starts connecting to the Remote Auth Gateway. -type startMsg struct{} - func NewModel() Model { var m Model m.setStatus("Press Ctrl+N to open QR login") diff --git a/internal/ui/login/qr/msg.go b/internal/ui/login/qr/msg.go index 88d5aa6..16e574e 100644 --- a/internal/ui/login/qr/msg.go +++ b/internal/ui/login/qr/msg.go @@ -18,6 +18,7 @@ import ( "github.com/ayn2op/discordo/internal/gateway" "github.com/ayn2op/discordo/internal/http" "github.com/ayn2op/tview" + "github.com/ayn2op/tview/textview" "github.com/gorilla/websocket" "github.com/skip2/go-qrcode" ) @@ -299,3 +300,9 @@ func exchangeTicket(fingerprint string, privateKey *rsa.PrivateKey, ticket strin return TokenMsg(string(decryptedToken)) } } + +// scrollMsg scrolls the code. +type scrollMsg textview.Change + +// startMsg starts connecting to the Remote Auth Gateway. +type startMsg struct{} diff --git a/internal/ui/login/token/model.go b/internal/ui/login/token/model.go index e293a90..842db94 100644 --- a/internal/ui/login/token/model.go +++ b/internal/ui/login/token/model.go @@ -1,33 +1,64 @@ package token import ( - "github.com/ayn2op/discordo/internal/ui/login/form" "github.com/ayn2op/tview" + "github.com/ayn2op/tview/button" + "github.com/ayn2op/tview/column" + "github.com/ayn2op/tview/row" + "github.com/ayn2op/tview/text" + "github.com/ayn2op/tview/textinput" + "github.com/gdamore/tcell/v3" ) +// Model is a token input above a button. Tab and the arrow keys move the focus, Enter in the input moves to the button, and Enter on the button or clicking it logs in. type Model struct { - form.Model + token textinput.EditState + buttonFocused bool } var _ tview.Model[Model] = Model{} -func NewModel() Model { - return Model{Model: form.New("Login", form.Field{Label: "Token", Mask: "*"})} -} - func (Model) Label() string { return "Token" } +func (Model) Init() tview.Cmd { return nil } + func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) { - if _, ok := msg.(form.SubmitMsg); ok { - token := m.Value(0) + switch msg := msg.(type) { + case tokenMsg: + m.token.Apply(textinput.Change(msg)) + case focusButtonMsg: + m.buttonFocused = true + case tview.KeyMsg: + switch msg.Key() { + case tcell.KeyTab, tcell.KeyDown, tcell.KeyBacktab, tcell.KeyUp: + m.buttonFocused = !m.buttonFocused + } + case submitMsg: + token := m.token.Value() if token == "" { return m, nil } return m, submitToken(token) } - var cmd tview.Cmd - m.Model, cmd = m.Model.Update(msg) - return m, cmd + return m, nil +} + +func (m Model) View() tview.Element { + input := textinput.New(&m.token). + Mask("*"). + Focused(!m.buttonFocused). + OnChange(func(c textinput.Change) tview.Msg { return tokenMsg(c) }). + OnSubmit(focusButtonMsg{}) + submit := button.New(). + Label("Login"). + Width(tview.Fixed(len("Login") + 4)). + Height(tview.Fixed(1)). + Focused(m.buttonFocused). + OnClick(submitMsg{}) + return column.New( + row.New(column.New(text.New("Token")).Width(tview.Fixed(len("Token")+1)), input).Height(tview.Fixed(1)), + submit, + ).Spacing(1) } diff --git a/internal/ui/login/token/msg.go b/internal/ui/login/token/msg.go index 721ed5c..1eb2a03 100644 --- a/internal/ui/login/token/msg.go +++ b/internal/ui/login/token/msg.go @@ -2,10 +2,17 @@ package token import ( "github.com/ayn2op/tview" + "github.com/ayn2op/tview/textinput" ) type TokenMsg string +type ( + tokenMsg textinput.Change + focusButtonMsg struct{} + submitMsg struct{} +) + func submitToken(token string) tview.Cmd { return func() tview.Msg { return TokenMsg(token) diff --git a/internal/ui/modal.go b/internal/ui/modal.go index 7f109dc..f0c08c4 100644 --- a/internal/ui/modal.go +++ b/internal/ui/modal.go @@ -4,7 +4,7 @@ import "github.com/ayn2op/tview" type ModalButton struct { Label string - Result tview.Msg + Cmd tview.Cmd KeepOpen bool } diff --git a/internal/ui/picker/msg.go b/internal/ui/picker/msg.go new file mode 100644 index 0000000..f0beacb --- /dev/null +++ b/internal/ui/picker/msg.go @@ -0,0 +1,6 @@ +package picker + +import "github.com/ayn2op/tview/picker" + +// changeMsg changes the picker. +type changeMsg picker.Change diff --git a/internal/ui/picker/picker.go b/internal/ui/picker/picker.go index cc1c7a2..5417906 100644 --- a/internal/ui/picker/picker.go +++ b/internal/ui/picker/picker.go @@ -22,9 +22,6 @@ func NewModel(cfg *config.Config, title string) Model { return Model{searchState: picker.NewSearchState(), cfg: cfg, title: title} } -// changeMsg changes the picker. -type changeMsg picker.Change - func (Model) Init() tview.Cmd { return nil } // View shows the picker, turning the selected item into the message onSelect returns and canceling with onCancel. diff --git a/internal/ui/root/keymap.go b/internal/ui/root/keymap.go index 16e7a87..db9a9f8 100644 --- a/internal/ui/root/keymap.go +++ b/internal/ui/root/keymap.go @@ -37,9 +37,9 @@ func (m Model) FullHelp() [][]keybind.Keybind { func (m Model) activeKeyMap() help.KeyMap { switch m.screen { - case loginScreen: + case screenLogin: return m.login - case chatScreen: + case screenChat: return m.chat } return nil diff --git a/internal/ui/root/model.go b/internal/ui/root/model.go index cee6ced..925a4af 100644 --- a/internal/ui/root/model.go +++ b/internal/ui/root/model.go @@ -29,9 +29,9 @@ const tokenEnvVarKey = "DISCORDO_TOKEN" type screen int const ( - noScreen screen = iota - loginScreen - chatScreen + screenNone screen = iota + screenLogin + screenChat ) type Model struct { @@ -94,7 +94,10 @@ func (m *Model) update(msg tview.Msg) tview.Cmd { deleteToken(), ) case ui.ModalMsg: - return m.showModal(msg) + if m.modal == nil { + m.modal, m.dialogFocus = &msg, 0 + } + return nil case dialogFocusMsg: m.dialogFocus = int(msg) return nil @@ -133,9 +136,9 @@ func (m *Model) update(msg tview.Msg) tview.Cmd { func (m *Model) updateScreen(msg tview.Msg) tview.Cmd { var cmd tview.Cmd switch m.screen { - case loginScreen: + case screenLogin: m.login, cmd = m.login.Update(msg) - case chatScreen: + case screenChat: m.chat, cmd = m.chat.Update(msg) } return cmd @@ -145,9 +148,9 @@ func (m *Model) updateScreen(msg tview.Msg) tview.Cmd { func (m Model) View() tview.Element { var innerView, helpView tview.Element switch m.screen { - case loginScreen: + case screenLogin: innerView = m.login.View() - case chatScreen: + case screenChat: innerView = m.chat.View() } if m.helpVisible { @@ -161,30 +164,6 @@ func (m Model) View() tview.Element { return stack.New(inert.New(content), backdrop.New().Style(m.cfg.Theme.Dialog.BackgroundStyle.Style), m.dialogView()) } -func (m *Model) showModal(request ui.ModalMsg) tview.Cmd { - if m.modal != nil { - return nil - } - m.modal, m.dialogFocus = &request, 0 - return nil -} - -func (m *Model) showLogin() tview.Cmd { - m.screen, m.login, m.chat = loginScreen, login.NewModel(m.cfg), chat.Model{} - return m.show(m.login.Init()) -} - -func (m *Model) showChat(token string) tview.Cmd { - m.screen, m.chat, m.login = chatScreen, chat.NewModel(m.cfg, token), login.Model{} - return m.show(m.chat.Init()) -} - -// show closes any open modal after the shown model changed and returns what it needs done, with init its first command. -func (m *Model) show(init tview.Cmd) tview.Cmd { - m.modal = nil - return tview.Batch(tview.SetTitle(consts.Name), init) -} - // helpView returns the help for the keybinds that currently apply. func (m Model) helpView() help.Widget { cfg := m.cfg @@ -234,25 +213,3 @@ func (m *Model) toggleHelp() { m.helpVisible = !m.helpVisible } } - -func (m *Model) finishModal(index int) tview.Cmd { - state := m.modal - if state == nil { - return nil - } - - var result tview.Msg - if index >= 0 && index < len(state.Buttons) { - button := state.Buttons[index] - if button.KeepOpen { - return func() tview.Msg { return button.Result } - } - result = button.Result - } - - m.modal = nil - if result == nil { - return nil - } - return func() tview.Msg { return result } -} diff --git a/internal/ui/root/msg.go b/internal/ui/root/msg.go index 6bd9081..c50a096 100644 --- a/internal/ui/root/msg.go +++ b/internal/ui/root/msg.go @@ -4,6 +4,8 @@ import ( "log/slog" "github.com/ayn2op/discordo/internal/consts" + "github.com/ayn2op/discordo/internal/ui/chat" + "github.com/ayn2op/discordo/internal/ui/login" "github.com/ayn2op/tview" "github.com/zalando/go-keyring" "golang.design/x/clipboard" @@ -61,3 +63,31 @@ func initClipboard() tview.Cmd { return nil } } + +func (m *Model) showLogin() tview.Cmd { + m.screen, m.login, m.chat = screenLogin, login.NewModel(m.cfg), chat.Model{} + return m.show(m.login.Init()) +} + +func (m *Model) showChat(token string) tview.Cmd { + m.screen, m.chat, m.login = screenChat, chat.NewModel(m.cfg, token), login.Model{} + return m.show(m.chat.Init()) +} + +// show closes any open modal after the shown model changed and returns what it needs done, with init its first command. +func (m *Model) show(init tview.Cmd) tview.Cmd { + m.modal = nil + return tview.Batch(tview.SetTitle(consts.Name), init) +} + +func (m *Model) finishModal(index int) tview.Cmd { + if m.modal == nil || index < 0 || index >= len(m.modal.Buttons) { + m.modal = nil + return nil + } + button := m.modal.Buttons[index] + if !button.KeepOpen { + m.modal = nil + } + return button.Cmd +} diff --git a/internal/ui/tree/msg.go b/internal/ui/tree/msg.go new file mode 100644 index 0000000..5cb1990 --- /dev/null +++ b/internal/ui/tree/msg.go @@ -0,0 +1,29 @@ +package tree + +import ( + "context" + "fmt" + "log/slog" + + "github.com/ayn2op/tview" + "github.com/ayn2op/tview/tree" + "golang.design/x/clipboard" +) + +// YankID copies the ID of node to the clipboard. +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/tree/tree.go b/internal/ui/tree/tree.go index 8831f9c..a7a81ae 100644 --- a/internal/ui/tree/tree.go +++ b/internal/ui/tree/tree.go @@ -2,10 +2,6 @@ package tree import ( - "context" - "fmt" - "log/slog" - "github.com/ayn2op/discordo/internal/config" "github.com/ayn2op/discordo/internal/ui" "github.com/ayn2op/tview" @@ -13,11 +9,10 @@ import ( "github.com/ayn2op/tview/keybind" "github.com/ayn2op/tview/tree" "github.com/gdamore/tcell/v3" - "golang.design/x/clipboard" ) -// New returns a boxed tree of the nodes under root, drawn with theme and moved with kbs, that turns changes into the message onChange returns. -func New(root *tree.Node, selectionState *tree.SelectionState, cfg *config.Config, theme config.CommonTreeTheme, kbs config.TreeKeybinds, focused bool, onChange func(tree.Change) tview.Msg) box.Widget { +// New returns a boxed tree of the nodes under root, drawn with theme and moved with kbs, that turns changes and selected nodes into the messages onChange and onSelect return. +func New(root *tree.Node, selectionState *tree.SelectionState, cfg *config.Config, theme config.CommonTreeTheme, kbs config.TreeKeybinds, focused bool, onChange func(tree.Change) tview.Msg, onSelect func(*tree.Node) tview.Msg) box.Widget { set := cfg.Theme.Border.NormalSet.BorderSet if focused { set = cfg.Theme.Border.ActiveSet.BorderSet @@ -34,7 +29,8 @@ func New(root *tree.Node, selectionState *tree.SelectionState, cfg *config.Confi GraphicsStyle(tcell.StyleDefault.Foreground(tcell.GetColor(theme.GraphicsColor))). Keybind(bindKeys(kbs)). Focused(focused). - OnChange(onChange) + OnChange(onChange). + OnSelect(onSelect) return ui.Box(t, &cfg.Theme, focused) } @@ -57,21 +53,3 @@ func bindKeys(kbs config.TreeKeybinds) func(tview.KeyMsg) (tree.Action, bool) { return 0, false } } - -// YankID copies the ID of node to the clipboard. -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 - } -}