refactor(ui): simplify models and messages
This commit is contained in:
parent
d63aafab28
commit
74f8438bc1
40 changed files with 1222 additions and 1262 deletions
4
go.mod
4
go.mod
|
|
@ -12,10 +12,10 @@ go 1.27.0
|
|||
require (
|
||||
github.com/BurntSushi/toml v1.6.0
|
||||
github.com/alecthomas/chroma/v2 v2.27.0
|
||||
github.com/andybalholm/brotli v1.2.5
|
||||
github.com/andybalholm/brotli v1.2.6
|
||||
github.com/ayn2op/arikawa/v3 v3.0.0-20260929021956-7d0802bf4d7c
|
||||
github.com/ayn2op/ningen/v3 v3.0.1-0.20260929010920-29794d5fa70e
|
||||
github.com/ayn2op/tview v0.0.0-20260930000753-22c02500c54e
|
||||
github.com/ayn2op/tview v0.0.0-20260930195008-a20956345053
|
||||
github.com/bogdanfinn/fhttp v0.6.9
|
||||
github.com/bogdanfinn/tls-client v1.16.0
|
||||
github.com/gdamore/tcell/v3 v3.5.0
|
||||
|
|
|
|||
8
go.sum
8
go.sum
|
|
@ -8,14 +8,14 @@ github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r
|
|||
github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8=
|
||||
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
|
||||
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/andybalholm/brotli v1.2.5 h1:BSI8V4zmx/3BAn6OKjF1PmfVq7Aoi52AdFsi6bpCx+s=
|
||||
github.com/andybalholm/brotli v1.2.5/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/andybalholm/brotli v1.2.6 h1:ftYnfj6usCp+UGV5kSJ3+chpMQgU+gJf/AxsUQ52REI=
|
||||
github.com/andybalholm/brotli v1.2.6/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/ayn2op/arikawa/v3 v3.0.0-20260929021956-7d0802bf4d7c h1:Qkl3W5RXzUQUR/PyYMMKmUHlmAksxaXRFigaTscoxtw=
|
||||
github.com/ayn2op/arikawa/v3 v3.0.0-20260929021956-7d0802bf4d7c/go.mod h1:SHqcr+EmZKeNtVKZybHJn4HNz+ufA1XbeKNSTigBCEE=
|
||||
github.com/ayn2op/ningen/v3 v3.0.1-0.20260929010920-29794d5fa70e h1:QAei/IVEyZV7GcpkpcFUh37+QRqwrt57z7OT3ciHrhI=
|
||||
github.com/ayn2op/ningen/v3 v3.0.1-0.20260929010920-29794d5fa70e/go.mod h1:RsvYiMzi4Si1F7vmml5NCL2DaKIZXkNUQC7QlGTMtCw=
|
||||
github.com/ayn2op/tview v0.0.0-20260930000753-22c02500c54e h1:ahNO+CbxUf2KUFT0GjNZQf1yYf+FmqhDTDdv4U7cfkc=
|
||||
github.com/ayn2op/tview v0.0.0-20260930000753-22c02500c54e/go.mod h1:oRPTSZgaEJPhv3dmmNvo6grBq/BemrLG3wFQLMTZJkY=
|
||||
github.com/ayn2op/tview v0.0.0-20260930195008-a20956345053 h1:cuiAx9jwMwy+fJfmLwWA5ONd3l3+YFrCIAHbIpHurJU=
|
||||
github.com/ayn2op/tview v0.0.0-20260930195008-a20956345053/go.mod h1:oRPTSZgaEJPhv3dmmNvo6grBq/BemrLG3wFQLMTZJkY=
|
||||
github.com/bdandy/go-errors v1.2.2 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q=
|
||||
github.com/bdandy/go-errors v1.2.2/go.mod h1:NkYHl4Fey9oRRdbB1CoC6e84tuqQHiqrOcZpqFEkBxM=
|
||||
github.com/bdandy/go-socks4 v1.2.3 h1:Q6Y2heY1GRjCtHbmlKfnwrKVU/k81LS8mRGLRlmDlic=
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
349
internal/ui/chat/composer/msg.go
Normal file
349
internal/ui/chat/composer/msg.go
Normal 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)))
|
||||
})
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
}
|
||||
|
|
|
|||
6
internal/ui/chat/mentionslist/msg.go
Normal file
6
internal/ui/chat/mentionslist/msg.go
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
package mentionslist
|
||||
|
||||
import "github.com/ayn2op/tview/list"
|
||||
|
||||
// Msg moves the list.
|
||||
type Msg list.Change
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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..."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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{}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import "github.com/ayn2op/tview"
|
|||
|
||||
type ModalButton struct {
|
||||
Label string
|
||||
Result tview.Msg
|
||||
Cmd tview.Cmd
|
||||
KeepOpen bool
|
||||
}
|
||||
|
||||
|
|
|
|||
6
internal/ui/picker/msg.go
Normal file
6
internal/ui/picker/msg.go
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
package picker
|
||||
|
||||
import "github.com/ayn2op/tview/picker"
|
||||
|
||||
// changeMsg changes the picker.
|
||||
type changeMsg picker.Change
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
29
internal/ui/tree/msg.go
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue