refactor(ui): simplify models and messages

This commit is contained in:
ayn2op 2026-09-30 16:40:08 -04:00
parent d63aafab28
commit 74f8438bc1
40 changed files with 1222 additions and 1262 deletions

4
go.mod
View file

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

8
go.sum
View file

@ -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=

View file

@ -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}
}

View file

@ -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 {

View file

@ -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)

View file

@ -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)))
})
}

View file

@ -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)
}

View file

@ -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:

View file

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

View file

@ -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)
}

View file

@ -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)

View file

@ -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()

View file

@ -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()}
}

View file

@ -0,0 +1,6 @@
package mentionslist
import "github.com/ayn2op/tview/list"
// Msg moves the list.
type Msg list.Change

View file

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

View file

@ -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
}
}

View file

@ -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..."
}
}

View file

@ -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)
}

View file

@ -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}
}
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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)

View file

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

View file

@ -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"},
)
}

View file

@ -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"},
)
}

View file

@ -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)
}

View file

@ -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)
}
})

View file

@ -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()

View file

@ -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")

View file

@ -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{}

View file

@ -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)
}

View file

@ -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)

View file

@ -4,7 +4,7 @@ import "github.com/ayn2op/tview"
type ModalButton struct {
Label string
Result tview.Msg
Cmd tview.Cmd
KeepOpen bool
}

View file

@ -0,0 +1,6 @@
package picker
import "github.com/ayn2op/tview/picker"
// changeMsg changes the picker.
type changeMsg picker.Change

View file

@ -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.

View file

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

View file

@ -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 }
}

View file

@ -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
}

29
internal/ui/tree/msg.go Normal file
View file

@ -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
}
}

View file

@ -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
}
}