refactor: move app and chat UI out of cmd (#680)

This commit is contained in:
Ayyan 2025-12-31 04:35:00 -05:00 • committed by GitHub
parent 870bc240ee
commit 91ca816ea0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 563 additions and 511 deletions

View file

@ -1,187 +0,0 @@
package cmd
import (
"log/slog"
"github.com/ayn2op/discordo/internal/config"
"github.com/ayn2op/discordo/internal/keyring"
"github.com/ayn2op/discordo/internal/ui"
"github.com/ayn2op/tview"
"github.com/diamondburned/arikawa/v3/discord"
"github.com/gdamore/tcell/v3"
)
const (
flexPageName = "flex"
mentionsListPageName = "mentionsList"
attachmentsListPageName = "attachmentsList"
confirmModalPageName = "confirmModal"
)
type chatView struct {
*tview.Pages
mainFlex *tview.Flex
rightFlex *tview.Flex
guildsTree *guildsTree
messagesList *messagesList
messageInput *messageInput
selectedChannel *discord.Channel
app *tview.Application
cfg *config.Config
}
func newChatView(app *tview.Application, cfg *config.Config) *chatView {
chatView := &chatView{
Pages: tview.NewPages(),
mainFlex: tview.NewFlex(),
rightFlex: tview.NewFlex(),
guildsTree: newGuildsTree(cfg),
messagesList: newMessagesList(cfg),
messageInput: newMessageInput(cfg),
app: app,
cfg: cfg,
}
chatView.SetInputCapture(chatView.onInputCapture)
chatView.buildLayout()
return chatView
}
func (cv *chatView) buildLayout() {
cv.Clear()
cv.rightFlex.Clear()
cv.mainFlex.Clear()
cv.rightFlex.
SetDirection(tview.FlexRow).
AddItem(cv.messagesList, 0, 1, false).
AddItem(cv.messageInput, 3, 1, false)
// The guilds tree is always focused first at start-up.
cv.mainFlex.
AddItem(cv.guildsTree, 0, 1, true).
AddItem(cv.rightFlex, 0, 4, false)
cv.AddAndSwitchToPage(flexPageName, cv.mainFlex, true)
}
func (cv *chatView) toggleGuildsTree() {
// The guilds tree is visible if the number of items is two.
if cv.mainFlex.GetItemCount() == 2 {
cv.mainFlex.RemoveItem(cv.guildsTree)
if cv.guildsTree.HasFocus() {
cv.app.SetFocus(cv.mainFlex)
}
} else {
cv.buildLayout()
cv.app.SetFocus(cv.guildsTree)
}
}
func (cv *chatView) focusGuildsTree() bool {
// The guilds tree is not hidden if the number of items is two.
if cv.mainFlex.GetItemCount() == 2 {
cv.app.SetFocus(cv.guildsTree)
return true
}
return false
}
func (cv *chatView) focusMessageInput() bool {
if !cv.messageInput.GetDisabled() {
cv.app.SetFocus(cv.messageInput)
return true
}
return false
}
func (cv *chatView) focusPrevious() {
switch cv.app.GetFocus() {
case cv.guildsTree:
cv.focusMessageInput()
case cv.messagesList: // Handle both a.messagesList and a.flex as well as other edge cases (if there is).
if ok := cv.focusGuildsTree(); !ok {
cv.app.SetFocus(cv.messageInput)
}
case cv.messageInput:
cv.app.SetFocus(cv.messagesList)
}
}
func (cv *chatView) focusNext() {
switch cv.app.GetFocus() {
case cv.guildsTree:
cv.app.SetFocus(cv.messagesList)
case cv.messagesList:
cv.focusMessageInput()
case cv.messageInput: // Handle both a.messageInput and a.flex as well as other edge cases (if there is).
if ok := cv.focusGuildsTree(); !ok {
cv.app.SetFocus(cv.messagesList)
}
}
}
func (cv *chatView) onInputCapture(event *tcell.EventKey) *tcell.EventKey {
switch event.Name() {
case cv.cfg.Keys.FocusGuildsTree:
cv.messageInput.removeMentionsList()
cv.focusGuildsTree()
return nil
case cv.cfg.Keys.FocusMessagesList:
cv.messageInput.removeMentionsList()
cv.app.SetFocus(cv.messagesList)
return nil
case cv.cfg.Keys.FocusMessageInput:
cv.focusMessageInput()
return nil
case cv.cfg.Keys.FocusPrevious:
cv.focusPrevious()
return nil
case cv.cfg.Keys.FocusNext:
cv.focusNext()
return nil
case cv.cfg.Keys.Logout:
app.quit()
if err := keyring.DeleteToken(); err != nil {
slog.Error("failed to delete token from keyring", "err", err)
return nil
}
return nil
case cv.cfg.Keys.ToggleGuildsTree:
cv.toggleGuildsTree()
return nil
}
return event
}
func (cv *chatView) showConfirmModal(prompt string, buttons []string, onDone func(label string)) {
previousFocus := cv.app.GetFocus()
modal := tview.NewModal().
SetText(prompt).
AddButtons(buttons).
SetDoneFunc(func(_ int, buttonLabel string) {
cv.RemovePage(confirmModalPageName).SwitchToPage(flexPageName)
cv.app.SetFocus(previousFocus)
if onDone != nil {
onDone(buttonLabel)
}
})
cv.
AddAndSwitchToPage(confirmModalPageName, ui.Centered(modal, 0, 0), true).
ShowPage(flexPageName)
}

View file

@ -6,20 +6,15 @@ import (
"log/slog"
"os"
"github.com/ayn2op/discordo/internal/app"
"github.com/ayn2op/discordo/internal/config"
"github.com/ayn2op/discordo/internal/consts"
"github.com/ayn2op/discordo/internal/keyring"
"github.com/ayn2op/discordo/internal/logger"
"github.com/diamondburned/arikawa/v3/utils/ws"
"github.com/diamondburned/ningen/v3"
"github.com/spf13/cobra"
)
var (
discordState *ningen.State
app *application
)
var (
token string
configPath string
@ -62,8 +57,7 @@ var (
}
}
app = newApplication(cfg)
return app.run(token)
return app.New(cfg).Run(token)
},
}

View file

@ -1,177 +0,0 @@
package cmd
import (
"context"
"log/slog"
"github.com/ayn2op/discordo/internal/http"
"github.com/ayn2op/discordo/internal/notifications"
"github.com/ayn2op/tview"
"github.com/diamondburned/arikawa/v3/gateway"
"github.com/diamondburned/arikawa/v3/session"
"github.com/diamondburned/arikawa/v3/state"
"github.com/diamondburned/arikawa/v3/state/store/defaultstore"
"github.com/diamondburned/arikawa/v3/utils/handler"
"github.com/diamondburned/arikawa/v3/utils/httputil"
"github.com/diamondburned/arikawa/v3/utils/httputil/httpdriver"
"github.com/diamondburned/arikawa/v3/utils/ws"
"github.com/diamondburned/ningen/v3"
"github.com/diamondburned/ningen/v3/states/read"
)
func openState(token string) error {
identifyProps := http.IdentifyProperties()
gateway.DefaultIdentity = identifyProps
gateway.DefaultPresence = &gateway.UpdatePresenceCommand{
Status: app.cfg.Status,
}
id := gateway.DefaultIdentifier(token)
id.Compress = false
session := session.NewCustom(id, http.NewClient(token), handler.New())
state := state.NewFromSession(session, defaultstore.New())
discordState = ningen.FromState(state)
// Handlers
discordState.AddHandler(onRaw)
discordState.AddHandler(onReady)
discordState.AddHandler(onMessageCreate)
discordState.AddHandler(onMessageUpdate)
discordState.AddHandler(onMessageDelete)
discordState.AddHandler(onReadUpdate)
discordState.AddHandler(func(event *gateway.GuildMembersChunkEvent) {
app.chatView.messagesList.setFetchingChunk(false, uint(len(event.Members)))
})
discordState.AddHandler(func(event *gateway.GuildMemberRemoveEvent) {
app.chatView.messageInput.cache.Invalidate(event.GuildID.String()+" "+event.User.Username, discordState.MemberState.SearchLimit)
})
discordState.StateLog = func(err error) {
slog.Error("state log", "err", err)
}
discordState.OnRequest = append(discordState.OnRequest, httputil.WithHeaders(http.Headers()), onRequest)
return discordState.Open(context.TODO())
}
func onRequest(r httpdriver.Request) error {
if req, ok := r.(*httpdriver.DefaultRequest); ok {
slog.Debug("new HTTP request", "method", req.Method, "url", req.URL)
}
return nil
}
func onRaw(event *ws.RawEvent) {
slog.Debug(
"new raw event",
"code", event.OriginalCode,
"type", event.OriginalType,
// "data", event.Raw,
)
}
func onReadUpdate(event *read.UpdateEvent) {
var guildNode *tview.TreeNode
app.chatView.guildsTree.
GetRoot().
Walk(func(node, parent *tview.TreeNode) bool {
switch node.GetReference() {
case event.GuildID:
node.SetTextStyle(app.chatView.guildsTree.getGuildNodeStyle(event.GuildID))
guildNode = node
return false
case event.ChannelID:
// private channel
if !event.GuildID.IsValid() {
style := app.chatView.guildsTree.getChannelNodeStyle(event.ChannelID)
node.SetTextStyle(style)
return false
}
}
return true
})
if guildNode != nil {
guildNode.Walk(func(node, parent *tview.TreeNode) bool {
if node.GetReference() == event.ChannelID {
node.SetTextStyle(app.chatView.guildsTree.getChannelNodeStyle(event.ChannelID))
return false
}
return true
})
}
app.Draw()
}
func onReady(r *gateway.ReadyEvent) {
dmNode := tview.NewTreeNode("Direct Messages")
root := app.chatView.guildsTree.
GetRoot().
ClearChildren().
AddChild(dmNode)
for _, folder := range r.UserSettings.GuildFolders {
if folder.ID == 0 && len(folder.GuildIDs) == 1 {
guild, err := discordState.Cabinet.Guild(folder.GuildIDs[0])
if err != nil {
slog.Error(
"failed to get guild from state",
"guild_id",
folder.GuildIDs[0],
"err",
err,
)
continue
}
app.chatView.guildsTree.createGuildNode(root, *guild)
} else {
app.chatView.guildsTree.createFolderNode(folder)
}
}
app.chatView.guildsTree.SetCurrentNode(root)
app.SetFocus(app.chatView.guildsTree)
app.Draw()
}
func onMessageCreate(message *gateway.MessageCreateEvent) {
if app.chatView.selectedChannel != nil &&
app.chatView.selectedChannel.ID == message.ChannelID {
app.chatView.messagesList.drawMessage(app.chatView.messagesList, message.Message)
app.Draw()
}
if err := notifications.Notify(discordState, message, app.cfg); err != nil {
slog.Error("failed to notify", "err", err, "channel_id", message.ChannelID, "message_id", message.ID)
}
}
func onMessageUpdate(message *gateway.MessageUpdateEvent) {
if app.chatView.selectedChannel != nil &&
app.chatView.selectedChannel.ID == message.ChannelID {
onMessageDelete(&gateway.MessageDeleteEvent{ID: message.ID, ChannelID: message.ChannelID, GuildID: message.GuildID})
}
}
func onMessageDelete(message *gateway.MessageDeleteEvent) {
if app.chatView.selectedChannel != nil &&
app.chatView.selectedChannel.ID == message.ChannelID {
messages, err := discordState.Cabinet.Messages(message.ChannelID)
if err != nil {
slog.Error("failed to get messages from state", "err", err, "channel_id", message.ChannelID)
return
}
app.chatView.messagesList.reset()
app.chatView.messagesList.drawMessages(messages)
app.Draw()
}
}

View file

@ -1,4 +1,4 @@
package cmd
package app
import (
"fmt"
@ -7,33 +7,34 @@ import (
"github.com/ayn2op/discordo/internal/clipboard"
"github.com/ayn2op/discordo/internal/config"
"github.com/ayn2op/discordo/internal/consts"
"github.com/ayn2op/discordo/internal/login"
"github.com/ayn2op/discordo/internal/ui/chat"
"github.com/ayn2op/discordo/internal/ui/login"
"github.com/ayn2op/tview"
"github.com/gdamore/tcell/v3"
)
type application struct {
*tview.Application
chatView *chatView
type App struct {
inner *tview.Application
chatView *chat.ChatView
cfg *config.Config
}
func newApplication(cfg *config.Config) *application {
func New(cfg *config.Config) *App {
tview.Styles = tview.Theme{}
app := &application{
Application: tview.NewApplication(),
cfg: cfg,
app := &App{
inner: tview.NewApplication(),
cfg: cfg,
}
if err := clipboard.Init(); err != nil {
slog.Error("failed to init clipboard", "err", err)
}
app.SetInputCapture(app.onInputCapture)
app.inner.SetInputCapture(app.onInputCapture)
return app
}
func (a *application) run(token string) error {
func (a *App) Run(token string) error {
screen, err := tcell.NewScreen()
if err != nil {
return fmt.Errorf("failed to create screen: %w", err)
@ -50,44 +51,44 @@ func (a *application) run(token string) error {
screen.SetTitle(consts.Name)
screen.EnablePaste()
screen.EnableFocus()
a.SetScreen(screen)
a.inner.SetScreen(screen)
if token == "" {
loginForm := login.NewForm(a.Application, a.cfg, func(token string) {
loginForm := login.NewForm(a.inner, a.cfg, func(token string) {
if err := a.showChatView(token); err != nil {
slog.Error("failed to show chat view", "err", err)
}
})
a.SetRoot(loginForm)
a.inner.SetRoot(loginForm)
} else {
if err := a.showChatView(token); err != nil {
return err
}
}
return a.Run()
return a.inner.Run()
}
func (a *application) showChatView(token string) error {
a.chatView = newChatView(a.Application, a.cfg)
if err := openState(token); err != nil {
func (a *App) showChatView(token string) error {
a.chatView = chat.NewChatView(a.inner, a.cfg, a.quit)
if err := a.chatView.OpenState(token); err != nil {
return err
}
a.SetRoot(a.chatView)
a.inner.SetRoot(a.chatView)
return nil
}
func (a *application) quit() {
if discordState != nil {
if err := discordState.Close(); err != nil {
func (a *App) quit() {
if a.chatView != nil {
if err := a.chatView.CloseState(); err != nil {
slog.Error("failed to close the session", "err", err)
}
}
a.Stop()
a.inner.Stop()
}
func (a *application) onInputCapture(event *tcell.EventKey) *tcell.EventKey {
func (a *App) onInputCapture(event *tcell.EventKey) *tcell.EventKey {
switch event.Name() {
case a.cfg.Keys.Quit:
a.quit()

View file

@ -0,0 +1,316 @@
package chat
import (
"log/slog"
"sync"
"github.com/ayn2op/discordo/internal/config"
"github.com/ayn2op/discordo/internal/keyring"
"github.com/ayn2op/discordo/internal/notifications"
"github.com/ayn2op/discordo/internal/ui"
"github.com/ayn2op/tview"
"github.com/diamondburned/arikawa/v3/discord"
"github.com/diamondburned/arikawa/v3/gateway"
"github.com/diamondburned/ningen/v3"
"github.com/diamondburned/ningen/v3/states/read"
"github.com/gdamore/tcell/v3"
)
const (
flexPageName = "flex"
mentionsListPageName = "mentionsList"
attachmentsListPageName = "attachmentsList"
confirmModalPageName = "confirmModal"
)
type ChatView struct {
*tview.Pages
mainFlex *tview.Flex
rightFlex *tview.Flex
guildsTree *guildsTree
messagesList *messagesList
messageInput *messageInput
selectedChannel *discord.Channel
selectedChannelMu sync.RWMutex
app *tview.Application
cfg *config.Config
state *ningen.State
onLogout func()
}
func NewChatView(app *tview.Application, cfg *config.Config, onLogout func()) *ChatView {
chatView := &ChatView{
Pages: tview.NewPages(),
mainFlex: tview.NewFlex(),
rightFlex: tview.NewFlex(),
app: app,
cfg: cfg,
onLogout: onLogout,
}
chatView.guildsTree = newGuildsTree(cfg, chatView)
chatView.messagesList = newMessagesList(cfg, chatView)
chatView.messageInput = newMessageInput(cfg, chatView)
chatView.SetInputCapture(chatView.onInputCapture)
chatView.buildLayout()
return chatView
}
func (cv *ChatView) SelectedChannel() *discord.Channel {
cv.selectedChannelMu.RLock()
defer cv.selectedChannelMu.RUnlock()
return cv.selectedChannel
}
func (cv *ChatView) SetSelectedChannel(channel *discord.Channel) {
cv.selectedChannelMu.Lock()
cv.selectedChannel = channel
cv.selectedChannelMu.Unlock()
}
func (cv *ChatView) buildLayout() {
cv.Clear()
cv.rightFlex.Clear()
cv.mainFlex.Clear()
cv.rightFlex.
SetDirection(tview.FlexRow).
AddItem(cv.messagesList, 0, 1, false).
AddItem(cv.messageInput, 3, 1, false)
// The guilds tree is always focused first at start-up.
cv.mainFlex.
AddItem(cv.guildsTree, 0, 1, true).
AddItem(cv.rightFlex, 0, 4, false)
cv.AddAndSwitchToPage(flexPageName, cv.mainFlex, true)
}
func (cv *ChatView) toggleGuildsTree() {
// The guilds tree is visible if the number of items is two.
if cv.mainFlex.GetItemCount() == 2 {
cv.mainFlex.RemoveItem(cv.guildsTree)
if cv.guildsTree.HasFocus() {
cv.app.SetFocus(cv.mainFlex)
}
} else {
cv.buildLayout()
cv.app.SetFocus(cv.guildsTree)
}
}
func (cv *ChatView) focusGuildsTree() bool {
// The guilds tree is not hidden if the number of items is two.
if cv.mainFlex.GetItemCount() == 2 {
cv.app.SetFocus(cv.guildsTree)
return true
}
return false
}
func (cv *ChatView) focusMessageInput() bool {
if !cv.messageInput.GetDisabled() {
cv.app.SetFocus(cv.messageInput)
return true
}
return false
}
func (cv *ChatView) focusPrevious() {
switch cv.app.GetFocus() {
case cv.guildsTree:
cv.focusMessageInput()
case cv.messagesList: // Handle both a.messagesList and a.flex as well as other edge cases (if there is).
if ok := cv.focusGuildsTree(); !ok {
cv.app.SetFocus(cv.messageInput)
}
case cv.messageInput:
cv.app.SetFocus(cv.messagesList)
}
}
func (cv *ChatView) focusNext() {
switch cv.app.GetFocus() {
case cv.guildsTree:
cv.app.SetFocus(cv.messagesList)
case cv.messagesList:
cv.focusMessageInput()
case cv.messageInput: // Handle both a.messageInput and a.flex as well as other edge cases (if there is).
if ok := cv.focusGuildsTree(); !ok {
cv.app.SetFocus(cv.messagesList)
}
}
}
func (cv *ChatView) onInputCapture(event *tcell.EventKey) *tcell.EventKey {
switch event.Name() {
case cv.cfg.Keys.FocusGuildsTree:
cv.messageInput.removeMentionsList()
cv.focusGuildsTree()
return nil
case cv.cfg.Keys.FocusMessagesList:
cv.messageInput.removeMentionsList()
cv.app.SetFocus(cv.messagesList)
return nil
case cv.cfg.Keys.FocusMessageInput:
cv.focusMessageInput()
return nil
case cv.cfg.Keys.FocusPrevious:
cv.focusPrevious()
return nil
case cv.cfg.Keys.FocusNext:
cv.focusNext()
return nil
case cv.cfg.Keys.Logout:
if cv.onLogout != nil {
cv.onLogout()
}
if err := keyring.DeleteToken(); err != nil {
slog.Error("failed to delete token from keyring", "err", err)
return nil
}
return nil
case cv.cfg.Keys.ToggleGuildsTree:
cv.toggleGuildsTree()
return nil
}
return event
}
func (cv *ChatView) showConfirmModal(prompt string, buttons []string, onDone func(label string)) {
previousFocus := cv.app.GetFocus()
modal := tview.NewModal().
SetText(prompt).
AddButtons(buttons).
SetDoneFunc(func(_ int, buttonLabel string) {
cv.RemovePage(confirmModalPageName).SwitchToPage(flexPageName)
cv.app.SetFocus(previousFocus)
if onDone != nil {
onDone(buttonLabel)
}
})
cv.
AddAndSwitchToPage(confirmModalPageName, ui.Centered(modal, 0, 0), true).
ShowPage(flexPageName)
}
func (cv *ChatView) onReadUpdate(event *read.UpdateEvent) {
var guildNode *tview.TreeNode
cv.guildsTree.
GetRoot().
Walk(func(node, parent *tview.TreeNode) bool {
switch node.GetReference() {
case event.GuildID:
node.SetTextStyle(cv.guildsTree.getGuildNodeStyle(event.GuildID))
guildNode = node
return false
case event.ChannelID:
// private channel
if !event.GuildID.IsValid() {
style := cv.guildsTree.getChannelNodeStyle(event.ChannelID)
node.SetTextStyle(style)
return false
}
}
return true
})
if guildNode != nil {
guildNode.Walk(func(node, parent *tview.TreeNode) bool {
if node.GetReference() == event.ChannelID {
node.SetTextStyle(cv.guildsTree.getChannelNodeStyle(event.ChannelID))
return false
}
return true
})
}
cv.app.Draw()
}
func (cv *ChatView) onReady(r *gateway.ReadyEvent) {
dmNode := tview.NewTreeNode("Direct Messages")
root := cv.guildsTree.
GetRoot().
ClearChildren().
AddChild(dmNode)
for _, folder := range r.UserSettings.GuildFolders {
if folder.ID == 0 && len(folder.GuildIDs) == 1 {
guild, err := cv.state.Cabinet.Guild(folder.GuildIDs[0])
if err != nil {
slog.Error(
"failed to get guild from state",
"guild_id",
folder.GuildIDs[0],
"err",
err,
)
continue
}
cv.guildsTree.createGuildNode(root, *guild)
} else {
cv.guildsTree.createFolderNode(folder)
}
}
cv.guildsTree.SetCurrentNode(root)
cv.app.SetFocus(cv.guildsTree)
cv.app.Draw()
}
func (cv *ChatView) onMessageCreate(message *gateway.MessageCreateEvent) {
if selected := cv.SelectedChannel(); selected != nil && selected.ID == message.ChannelID {
cv.messagesList.drawMessage(cv.messagesList, message.Message)
cv.app.Draw()
}
if err := notifications.Notify(cv.state, message, cv.cfg); err != nil {
slog.Error("failed to notify", "err", err, "channel_id", message.ChannelID, "message_id", message.ID)
}
}
func (cv *ChatView) onMessageUpdate(message *gateway.MessageUpdateEvent) {
if selected := cv.SelectedChannel(); selected != nil && selected.ID == message.ChannelID {
cv.onMessageDelete(&gateway.MessageDeleteEvent{ID: message.ID, ChannelID: message.ChannelID, GuildID: message.GuildID})
}
}
func (cv *ChatView) onMessageDelete(message *gateway.MessageDeleteEvent) {
if selected := cv.SelectedChannel(); selected != nil && selected.ID == message.ChannelID {
messages, err := cv.state.Cabinet.Messages(message.ChannelID)
if err != nil {
slog.Error("failed to get messages from state", "err", err, "channel_id", message.ChannelID)
return
}
cv.messagesList.reset()
cv.messagesList.drawMessages(messages)
cv.app.Draw()
}
}
func (cv *ChatView) onGuildMembersChunk(event *gateway.GuildMembersChunkEvent) {
cv.messagesList.setFetchingChunk(false, uint(len(event.Members)))
}
func (cv *ChatView) onGuildMemberRemove(event *gateway.GuildMemberRemoveEvent) {
cv.messageInput.cache.Invalidate(event.GuildID.String()+" "+event.User.Username, cv.state.MemberState.SearchLimit)
}

View file

@ -1,4 +1,4 @@
package cmd
package chat
import (
"cmp"
@ -18,13 +18,15 @@ import (
type guildsTree struct {
*tview.TreeView
cfg *config.Config
cfg *config.Config
chat *ChatView
}
func newGuildsTree(cfg *config.Config) *guildsTree {
func newGuildsTree(cfg *config.Config, chat *ChatView) *guildsTree {
gt := &guildsTree{
TreeView: tview.NewTreeView(),
cfg: cfg,
chat: chat,
}
gt.Box = ui.ConfigureBox(gt.Box, &cfg.Theme)
@ -50,7 +52,7 @@ func (gt *guildsTree) createFolderNode(folder gateway.GuildFolder) {
gt.GetRoot().AddChild(folderNode)
for _, gID := range folder.GuildIDs {
guild, err := discordState.Cabinet.Guild(gID)
guild, err := gt.chat.state.Cabinet.Guild(gID)
if err != nil {
slog.Error("failed to get guild from state", "guild_id", gID, "err", err)
continue
@ -76,12 +78,12 @@ func (gt *guildsTree) unreadStyle(indication ningen.UnreadIndication) tcell.Styl
}
func (gt *guildsTree) getGuildNodeStyle(guildID discord.GuildID) tcell.Style {
indication := discordState.GuildIsUnread(guildID, ningen.GuildUnreadOpts{UnreadOpts: ningen.UnreadOpts{IncludeMutedCategories: true}})
indication := gt.chat.state.GuildIsUnread(guildID, ningen.GuildUnreadOpts{UnreadOpts: ningen.UnreadOpts{IncludeMutedCategories: true}})
return gt.unreadStyle(indication)
}
func (gt *guildsTree) getChannelNodeStyle(channelID discord.ChannelID) tcell.Style {
indication := discordState.ChannelIsUnread(channelID, ningen.UnreadOpts{IncludeMutedCategories: true})
indication := gt.chat.state.ChannelIsUnread(channelID, ningen.UnreadOpts{IncludeMutedCategories: true})
return gt.unreadStyle(indication)
}
@ -93,7 +95,7 @@ func (gt *guildsTree) createGuildNode(n *tview.TreeNode, guild discord.Guild) {
}
func (gt *guildsTree) createChannelNode(node *tview.TreeNode, channel discord.Channel) {
if channel.Type != discord.DirectMessage && channel.Type != discord.GroupDM && !discordState.HasPermissions(channel.ID, discord.PermissionViewChannel) {
if channel.Type != discord.DirectMessage && channel.Type != discord.GroupDM && !gt.chat.state.HasPermissions(channel.ID, discord.PermissionViewChannel) {
return
}
@ -149,9 +151,9 @@ func (gt *guildsTree) onSelected(node *tview.TreeNode) {
switch ref := node.GetReference().(type) {
case discord.GuildID:
go discordState.MemberState.Subscribe(ref)
go gt.chat.state.MemberState.Subscribe(ref)
channels, err := discordState.Cabinet.Channels(ref)
channels, err := gt.chat.state.Cabinet.Channels(ref)
if err != nil {
slog.Error("failed to get channels", "err", err, "guild_id", ref)
return
@ -163,7 +165,7 @@ func (gt *guildsTree) onSelected(node *tview.TreeNode) {
gt.createChannelNodes(node, channels)
case discord.ChannelID:
channel, err := discordState.Cabinet.Channel(ref)
channel, err := gt.chat.state.Cabinet.Channel(ref)
if err != nil {
slog.Error("failed to get channel", "channel_id", ref)
return
@ -172,7 +174,7 @@ func (gt *guildsTree) onSelected(node *tview.TreeNode) {
// Handle forum channels differently - they contain threads, not direct messages
if channel.Type == discord.GuildForum {
// Get all channels from the guild - this includes active threads from GuildCreateEvent
allChannels, err := discordState.Cabinet.Channels(channel.GuildID)
allChannels, err := gt.chat.state.Cabinet.Channels(channel.GuildID)
if err != nil {
slog.Error("failed to get channels for forum threads", "err", err, "guild_id", channel.GuildID)
return
@ -198,38 +200,38 @@ func (gt *guildsTree) onSelected(node *tview.TreeNode) {
return
}
go discordState.ReadState.MarkRead(channel.ID, channel.LastMessageID)
go gt.chat.state.ReadState.MarkRead(channel.ID, channel.LastMessageID)
messages, err := discordState.Messages(channel.ID, uint(gt.cfg.MessagesLimit))
messages, err := gt.chat.state.Messages(channel.ID, uint(gt.cfg.MessagesLimit))
if err != nil {
slog.Error("failed to get messages", "err", err, "channel_id", channel.ID, "limit", gt.cfg.MessagesLimit)
return
}
if guildID := channel.GuildID; guildID.IsValid() {
app.chatView.messagesList.requestGuildMembers(guildID, messages)
gt.chat.messagesList.requestGuildMembers(guildID, messages)
}
app.chatView.selectedChannel = channel
gt.chat.SetSelectedChannel(channel)
app.chatView.messagesList.reset()
app.chatView.messagesList.setTitle(*channel)
app.chatView.messagesList.drawMessages(messages)
app.chatView.messagesList.ScrollToEnd()
gt.chat.messagesList.reset()
gt.chat.messagesList.setTitle(*channel)
gt.chat.messagesList.drawMessages(messages)
gt.chat.messagesList.ScrollToEnd()
hasNoPerm := channel.Type != discord.DirectMessage && channel.Type != discord.GroupDM && !discordState.HasPermissions(channel.ID, discord.PermissionSendMessages)
app.chatView.messageInput.SetDisabled(hasNoPerm)
hasNoPerm := channel.Type != discord.DirectMessage && channel.Type != discord.GroupDM && !gt.chat.state.HasPermissions(channel.ID, discord.PermissionSendMessages)
gt.chat.messageInput.SetDisabled(hasNoPerm)
if hasNoPerm {
app.chatView.messageInput.SetPlaceholder("You do not have permission to send messages in this channel.")
gt.chat.messageInput.SetPlaceholder("You do not have permission to send messages in this channel.")
} else {
app.chatView.messageInput.SetPlaceholder("Message...")
gt.chat.messageInput.SetPlaceholder("Message...")
if gt.cfg.AutoFocus {
app.SetFocus(app.chatView.messageInput)
gt.chat.app.SetFocus(gt.chat.messageInput)
}
}
case nil: // Direct messages folder
channels, err := discordState.PrivateChannels()
channels, err := gt.chat.state.PrivateChannels()
if err != nil {
slog.Error("failed to get private channels", "err", err)
return

View file

@ -1,4 +1,4 @@
package cmd
package chat
import (
"bytes"
@ -25,6 +25,7 @@ import (
"github.com/diamondburned/arikawa/v3/state"
"github.com/diamondburned/arikawa/v3/utils/json/option"
"github.com/diamondburned/arikawa/v3/utils/sendpart"
"github.com/diamondburned/ningen/v3"
"github.com/diamondburned/ningen/v3/discordmd"
"github.com/gdamore/tcell/v3"
"github.com/ncruces/zenity"
@ -38,7 +39,8 @@ var mentionRegex = regexp.MustCompile("@[a-zA-Z0-9._]+")
type messageInput struct {
*tview.TextArea
cfg *config.Config
cfg *config.Config
chat *ChatView
edit bool
sendMessageData *api.SendMessageData
@ -47,10 +49,11 @@ type messageInput struct {
lastSearch time.Time
}
func newMessageInput(cfg *config.Config) *messageInput {
func newMessageInput(cfg *config.Config, chat *ChatView) *messageInput {
mi := &messageInput{
TextArea: tview.NewTextArea(),
cfg: cfg,
chat: chat,
sendMessageData: &api.SendMessageData{},
cache: cache.NewCache(),
mentionsList: tview.NewList(),
@ -94,7 +97,7 @@ func (mi *messageInput) onInputCapture(event *tcell.EventKey) *tcell.EventKey {
return tcell.NewEventKey(tcell.KeyCtrlV, "", tcell.ModNone)
case mi.cfg.Keys.MessageInput.Send:
if app.chatView.GetVisibile(mentionsListPageName) {
if mi.chat.GetVisibile(mentionsListPageName) {
mi.tabComplete()
return nil
}
@ -110,7 +113,7 @@ func (mi *messageInput) onInputCapture(event *tcell.EventKey) *tcell.EventKey {
mi.openFilePicker()
return nil
case mi.cfg.Keys.MessageInput.Cancel:
if app.chatView.GetVisibile(mentionsListPageName) {
if mi.chat.GetVisibile(mentionsListPageName) {
mi.stopTabCompletion()
} else {
mi.reset()
@ -118,12 +121,12 @@ func (mi *messageInput) onInputCapture(event *tcell.EventKey) *tcell.EventKey {
return nil
case mi.cfg.Keys.MessageInput.TabComplete:
go app.QueueUpdateDraw(func() { mi.tabComplete() })
go mi.chat.app.QueueUpdateDraw(func() { mi.tabComplete() })
return nil
}
if mi.cfg.AutocompleteLimit > 0 {
if app.chatView.GetVisibile(mentionsListPageName) {
if mi.chat.GetVisibile(mentionsListPageName) {
switch event.Name() {
case mi.cfg.Keys.MentionsList.Up:
mi.mentionsList.InputHandler()(tcell.NewEventKey(tcell.KeyUp, "", tcell.ModNone), nil)
@ -134,7 +137,7 @@ func (mi *messageInput) onInputCapture(event *tcell.EventKey) *tcell.EventKey {
}
}
go app.QueueUpdateDraw(func() { mi.tabSuggestion() })
go mi.chat.app.QueueUpdateDraw(func() { mi.tabSuggestion() })
}
return event
@ -148,7 +151,8 @@ func (mi *messageInput) paste() {
}
func (mi *messageInput) send() {
if app.chatView.selectedChannel == nil {
selected := mi.chat.SelectedChannel()
if selected == nil {
return
}
@ -166,17 +170,17 @@ func (mi *messageInput) send() {
}
}()
text = processText(app.chatView.selectedChannel, []byte(text))
text = processText(mi.chat.state, selected, []byte(text))
if mi.edit {
m, err := app.chatView.messagesList.selectedMessage()
m, err := mi.chat.messagesList.selectedMessage()
if err != nil {
slog.Error("failed to get selected message", "err", err)
return
}
data := api.EditMessageData{Content: option.NewNullableString(text)}
if _, err := discordState.EditMessageComplex(m.ChannelID, m.ID, data); err != nil {
if _, err := mi.chat.state.EditMessageComplex(m.ChannelID, m.ID, data); err != nil {
slog.Error("failed to edit message", "err", err)
}
@ -184,17 +188,17 @@ func (mi *messageInput) send() {
} else {
data := mi.sendMessageData
data.Content = text
if _, err := discordState.SendMessageComplex(app.chatView.selectedChannel.ID, *data); err != nil {
slog.Error("failed to send message in channel", "channel_id", app.chatView.selectedChannel.ID, "err", err)
if _, err := mi.chat.state.SendMessageComplex(selected.ID, *data); err != nil {
slog.Error("failed to send message in channel", "channel_id", selected.ID, "err", err)
}
}
mi.reset()
app.chatView.messagesList.Highlight()
app.chatView.messagesList.ScrollToEnd()
mi.chat.messagesList.Highlight()
mi.chat.messagesList.ScrollToEnd()
}
func processText(channel *discord.Channel, src []byte) string {
func processText(state *ningen.State, channel *discord.Channel, src []byte) string {
var (
ranges [][2]int
canMention = true
@ -218,13 +222,13 @@ func processText(channel *discord.Channel, src []byte) string {
})
for _, rng := range ranges {
src = slices.Replace(src, rng[0], rng[1], expandMentions(channel, src[rng[0]:rng[1]])...)
src = slices.Replace(src, rng[0], rng[1], expandMentions(state, channel, src[rng[0]:rng[1]])...)
}
return string(src)
}
func expandMentions(c *discord.Channel, src []byte) []byte {
func expandMentions(state *ningen.State, c *discord.Channel, src []byte) []byte {
return mentionRegex.ReplaceAllFunc(src, func(input []byte) []byte {
output := input
name := string(input[1:])
@ -235,7 +239,7 @@ func expandMentions(c *discord.Channel, src []byte) []byte {
}
}
// self ping
me, err := discordState.Cabinet.Me()
me, err := state.Cabinet.Me()
if err != nil {
slog.Error("failed to get client user (me)", "err", err)
} else if strings.EqualFold(me.Username, name) {
@ -243,9 +247,9 @@ func expandMentions(c *discord.Channel, src []byte) []byte {
}
return output
}
discordState.MemberStore.Each(c.GuildID, func(m *discord.Member) bool {
state.MemberStore.Each(c.GuildID, func(m *discord.Member) bool {
if strings.EqualFold(m.User.Username, name) {
if channelHasUser(c.ID, m.User.ID) {
if channelHasUser(state, c.ID, m.User.ID) {
output = []byte(m.User.ID.Mention())
}
return true
@ -266,18 +270,22 @@ func (mi *messageInput) tabComplete() {
}
pos := posEnd - (len(name) + 1)
gID := app.chatView.selectedChannel.GuildID
selected := mi.chat.SelectedChannel()
if selected == nil {
return
}
gID := selected.GuildID
if mi.cfg.AutocompleteLimit == 0 {
if !gID.IsValid() {
users := app.chatView.selectedChannel.DMRecipients
users := selected.DMRecipients
res := fuzzy.FindFrom(name, userList(users))
if len(res) > 0 {
mi.Replace(pos, posEnd, "@"+users[res[0].Index].Username+" ")
}
} else {
mi.searchMember(gID, name)
members, err := discordState.Cabinet.Members(gID)
members, err := mi.chat.state.Cabinet.Members(gID)
if err != nil {
slog.Error("failed to get members from state", "guild_id", gID, "err", err)
return
@ -285,7 +293,7 @@ func (mi *messageInput) tabComplete() {
res := fuzzy.FindFrom(name, memberList(members))
for _, r := range res {
if channelHasUser(app.chatView.selectedChannel.ID, members[r.Index].User.ID) {
if channelHasUser(mi.chat.state, selected.ID, members[r.Index].User.ID) {
mi.Replace(pos, posEnd, "@"+members[r.Index].User.Username+" ")
return
}
@ -309,8 +317,12 @@ func (mi *messageInput) tabSuggestion() {
mi.stopTabCompletion()
return
}
gID := app.chatView.selectedChannel.GuildID
cID := app.chatView.selectedChannel.ID
selected := mi.chat.SelectedChannel()
if selected == nil {
return
}
gID := selected.GuildID
cID := selected.ID
mi.mentionsList.Clear()
var shown map[string]struct{}
@ -318,7 +330,7 @@ func (mi *messageInput) tabSuggestion() {
if name == "" {
shown = make(map[string]struct{})
// Don't show @me in the list of recent authors
me, err := discordState.Cabinet.Me()
me, err := mi.chat.state.Cabinet.Me()
if err != nil {
slog.Error("failed to get client user (me)", "err", err)
} else {
@ -329,7 +341,7 @@ func (mi *messageInput) tabSuggestion() {
// DMs have recipients, not members
if !gID.IsValid() {
if name == "" { // show recent messages' authors
msgs, err := discordState.Cabinet.Messages(cID)
msgs, err := mi.chat.state.Cabinet.Messages(cID)
if err != nil {
return
}
@ -341,8 +353,8 @@ func (mi *messageInput) tabSuggestion() {
mi.addMentionUser(&m.Author)
}
} else {
users := app.chatView.selectedChannel.DMRecipients
me, err := discordState.Cabinet.Me()
users := selected.DMRecipients
me, err := mi.chat.state.Cabinet.Me()
if err != nil {
slog.Error("failed to get client user (me)", "err", err)
} else {
@ -354,7 +366,7 @@ func (mi *messageInput) tabSuggestion() {
}
}
} else if name == "" { // show recent messages' authors
msgs, err := discordState.Cabinet.Messages(cID)
msgs, err := mi.chat.state.Cabinet.Messages(cID)
if err != nil {
return
}
@ -363,8 +375,8 @@ func (mi *messageInput) tabSuggestion() {
continue
}
shown[m.Author.Username] = userDone
discordState.MemberState.RequestMember(gID, m.Author.ID)
if mem, err := discordState.Cabinet.Member(gID, m.Author.ID); err == nil {
mi.chat.state.MemberState.RequestMember(gID, m.Author.ID)
if mem, err := mi.chat.state.Cabinet.Member(gID, m.Author.ID); err == nil {
if mi.addMentionMember(gID, mem) {
break
}
@ -372,7 +384,7 @@ func (mi *messageInput) tabSuggestion() {
}
} else {
mi.searchMember(gID, name)
mems, err := discordState.Cabinet.Members(gID)
mems, err := mi.chat.state.Cabinet.Members(gID)
if err != nil {
slog.Error("fetching members failed", "err", err)
return
@ -382,7 +394,7 @@ func (mi *messageInput) tabSuggestion() {
res = res[:int(mi.cfg.AutocompleteLimit)]
}
for _, r := range res {
if channelHasUser(cID, mems[r.Index].User.ID) &&
if channelHasUser(mi.chat.state, cID, mems[r.Index].User.ID) &&
mi.addMentionMember(gID, &mems[r.Index]) {
break
}
@ -417,8 +429,8 @@ func (ul userList) Len() int {
}
// channelHasUser checks if a user has permission to view the specified channel
func channelHasUser(channelID discord.ChannelID, userID discord.UserID) bool {
perms, err := discordState.Permissions(channelID, userID)
func channelHasUser(state *ningen.State, channelID discord.ChannelID, userID discord.UserID) bool {
perms, err := state.Permissions(channelID, userID)
if err != nil {
slog.Error("failed to get permissions", "err", err, "channel", channelID, "user", userID)
return false
@ -436,22 +448,22 @@ func (mi *messageInput) searchMember(gID discord.GuildID, name string) {
// everything starting with "ab". This will still be true even if a new
// member joins because arikawa loads new members into the state.
if k := key[:len(key)-1]; mi.cache.Exists(k) {
if c := mi.cache.Get(k); c < discordState.MemberState.SearchLimit {
if c := mi.cache.Get(k); c < mi.chat.state.MemberState.SearchLimit {
mi.cache.Create(key, c)
return
}
}
// 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 mi.lastSearch.Add(discordState.MemberState.SearchFrequency).After(time.Now()) {
if mi.lastSearch.Add(mi.chat.state.MemberState.SearchFrequency).After(time.Now()) {
return
}
mi.lastSearch = time.Now()
app.chatView.messagesList.waitForChunkEvent()
app.chatView.messagesList.setFetchingChunk(true, 0)
discordState.MemberState.SearchMember(gID, name)
mi.cache.Create(key, app.chatView.messagesList.waitForChunkEvent())
mi.chat.messagesList.waitForChunkEvent()
mi.chat.messagesList.setFetchingChunk(true, 0)
mi.chat.state.MemberState.SearchMember(gID, name)
mi.cache.Create(key, mi.chat.messagesList.waitForChunkEvent())
}
func (mi *messageInput) showMentionList() {
@ -462,7 +474,7 @@ func (mi *messageInput) showMentionList() {
l := mi.mentionsList
x, _, _, _ := mi.GetInnerRect()
_, y, _, _ := mi.GetRect()
_, _, maxW, maxH := app.chatView.messagesList.GetInnerRect()
_, _, maxW, maxH := mi.chat.messagesList.GetInnerRect()
if t := int(mi.cfg.Theme.MentionsList.MaxHeight); t != 0 {
maxH = min(maxH, t)
}
@ -485,10 +497,10 @@ func (mi *messageInput) showMentionList() {
l.SetRect(x, y, w, h)
app.chatView.
mi.chat.
AddAndSwitchToPage(mentionsListPageName, l, false).
ShowPage(flexPageName)
app.SetFocus(mi)
mi.chat.app.SetFocus(mi)
}
func (mi *messageInput) addMentionMember(gID discord.GuildID, m *discord.Member) bool {
@ -501,16 +513,16 @@ func (mi *messageInput) addMentionMember(gID discord.GuildID, m *discord.Member)
name = m.Nick
}
// this is WAY faster than discordState.MemberColor
// This avoids a slower member color lookup path.
color, ok := state.MemberColor(m, func(id discord.RoleID) *discord.Role {
r, _ := discordState.Cabinet.Role(gID, id)
r, _ := mi.chat.state.Cabinet.Role(gID, id)
return r
})
if ok {
name = fmt.Sprintf("[%s]%s[-]", color, name)
}
presence, err := discordState.Cabinet.Presence(gID, m.User.ID)
presence, err := mi.chat.state.Cabinet.Presence(gID, m.User.ID)
if err != nil {
slog.Info("failed to get presence from state", "guild_id", gID, "user_id", m.User.ID, "err", err)
} else if presence.Status == discord.OfflineStatus {
@ -527,7 +539,7 @@ func (mi *messageInput) addMentionUser(user *discord.User) {
}
name := user.DisplayOrUsername()
presence, err := discordState.Cabinet.Presence(discord.NullGuildID, user.ID)
presence, err := mi.chat.state.Cabinet.Presence(discord.NullGuildID, user.ID)
if err != nil {
slog.Info("failed to get presence from state", "user_id", user.ID, "err", err)
} else if presence.Status == discord.OfflineStatus {
@ -539,7 +551,7 @@ func (mi *messageInput) addMentionUser(user *discord.User) {
// used by chatView
func (mi *messageInput) removeMentionsList() {
app.chatView.
mi.chat.
RemovePage(mentionsListPageName).
SwitchToPage(flexPageName)
}
@ -548,7 +560,7 @@ func (mi *messageInput) stopTabCompletion() {
if mi.cfg.AutocompleteLimit > 0 {
mi.mentionsList.Clear()
mi.removeMentionsList()
app.SetFocus(mi)
mi.chat.app.SetFocus(mi)
}
}
@ -568,7 +580,7 @@ func (mi *messageInput) editor() {
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
app.Suspend(func() {
mi.chat.app.Suspend(func() {
err := cmd.Run()
if err != nil {
slog.Error("failed to run command", "args", cmd.Args, "err", err)
@ -586,7 +598,7 @@ func (mi *messageInput) editor() {
}
func (mi *messageInput) openFilePicker() {
if app.chatView.selectedChannel == nil {
if mi.chat.SelectedChannel() == nil {
return
}

View file

@ -1,4 +1,4 @@
package cmd
package chat
import (
"context"
@ -36,6 +36,7 @@ import (
type messagesList struct {
*tview.TextView
cfg *config.Config
chat *ChatView
selectedMessageID discord.MessageID
renderer *markdown.Renderer
@ -48,10 +49,11 @@ type messagesList struct {
}
}
func newMessagesList(cfg *config.Config) *messagesList {
func newMessagesList(cfg *config.Config, chat *ChatView) *messagesList {
ml := &messagesList{
TextView: tview.NewTextView(),
cfg: cfg,
chat: chat,
renderer: markdown.NewRenderer(cfg.Theme.MessagesList),
}
@ -98,7 +100,7 @@ func (ml *messagesList) drawMessage(writer io.Writer, message discord.Message) {
fmt.Fprintf(writer, `["%s"]`, message.ID)
if ml.cfg.HideBlockedUsers {
isBlocked := discordState.UserIsBlocked(message.Author.ID)
isBlocked := ml.chat.state.UserIsBlocked(message.Author.ID)
if isBlocked {
io.WriteString(writer, "[:red:b]Blocked message[:-:-]")
return
@ -146,7 +148,7 @@ func (ml *messagesList) drawAuthor(w io.Writer, message discord.Message) {
// Webhooks do not have nicknames or roles.
if message.GuildID.IsValid() && !message.WebhookID.IsValid() {
member, err := discordState.Cabinet.Member(message.GuildID, message.Author.ID)
member, err := ml.chat.state.Cabinet.Member(message.GuildID, message.Author.ID)
if err != nil {
slog.Error("failed to get member from state", "guild_id", message.GuildID, "member_id", message.Author.ID, "err", err)
} else {
@ -155,7 +157,7 @@ func (ml *messagesList) drawAuthor(w io.Writer, message discord.Message) {
}
color, ok := state.MemberColor(member, func(id discord.RoleID) *discord.Role {
r, _ := discordState.Cabinet.Role(message.GuildID, id)
r, _ := ml.chat.state.Cabinet.Role(message.GuildID, id)
return r
})
if ok {
@ -169,8 +171,8 @@ func (ml *messagesList) drawAuthor(w io.Writer, message discord.Message) {
func (ml *messagesList) drawContent(w io.Writer, message discord.Message) {
c := []byte(tview.Escape(message.Content))
if app.cfg.Markdown {
ast := discordmd.ParseWithMessage(c, *discordState.Cabinet, &message, false)
if ml.chat.cfg.Markdown {
ast := discordmd.ParseWithMessage(c, *ml.chat.state.Cabinet, &message, false)
ml.renderer.Render(w, c, ast)
} else {
w.Write(c) // write the content as is
@ -241,7 +243,12 @@ func (ml *messagesList) selectedMessage() (*discord.Message, error) {
return nil, errors.New("no message is currently selected")
}
m, err := discordState.Cabinet.Message(app.chatView.selectedChannel.ID, ml.selectedMessageID)
selected := ml.chat.SelectedChannel()
if selected == nil {
return nil, errors.New("no channel is currently selected")
}
m, err := ml.chat.state.Cabinet.Message(selected.ID, ml.selectedMessageID)
if err != nil {
return nil, fmt.Errorf("failed to retrieve selected message: %w", err)
}
@ -250,7 +257,12 @@ func (ml *messagesList) selectedMessage() (*discord.Message, error) {
}
func (ml *messagesList) selectedMessageIndex() (int, error) {
ms, err := discordState.Cabinet.Messages(app.chatView.selectedChannel.ID)
selected := ml.chat.SelectedChannel()
if selected == nil {
return -1, errors.New("no channel is currently selected")
}
ms, err := ml.chat.state.Cabinet.Messages(selected.ID)
if err != nil {
return -1, err
}
@ -296,13 +308,14 @@ func (ml *messagesList) onInputCapture(event *tcell.EventKey) *tcell.EventKey {
}
func (ml *messagesList) _select(name string) {
if app.chatView.selectedChannel == nil {
selected := ml.chat.SelectedChannel()
if selected == nil {
return
}
ms, err := discordState.Cabinet.Messages(app.chatView.selectedChannel.ID)
ms, err := ml.chat.state.Cabinet.Messages(selected.ID)
if err != nil {
slog.Error("failed to get messages", "err", err, "channel_id", app.chatView.selectedChannel.ID)
slog.Error("failed to get messages", "err", err, "channel_id", selected.ID)
return
}
@ -456,8 +469,8 @@ func (ml *messagesList) showAttachmentsList(urls []string, attachments []discord
SetHighlightFullLine(true).
ShowSecondaryText(false).
SetDoneFunc(func() {
app.chatView.RemovePage(attachmentsListPageName).SwitchToPage(flexPageName)
app.SetFocus(ml)
ml.chat.RemovePage(attachmentsListPageName).SwitchToPage(flexPageName)
ml.chat.app.SetFocus(ml)
})
list.
SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
@ -492,7 +505,7 @@ func (ml *messagesList) showAttachmentsList(urls []string, attachments []discord
})
}
app.chatView.
ml.chat.
AddAndSwitchToPage(attachmentsListPageName, ui.Centered(list, 0, 0), true).
ShowPage(flexPageName)
}
@ -545,7 +558,7 @@ func (ml *messagesList) reply(mention bool) {
name := msg.Author.DisplayOrUsername()
if msg.GuildID.IsValid() {
member, err := discordState.Cabinet.Member(msg.GuildID, msg.Author.ID)
member, err := ml.chat.state.Cabinet.Member(msg.GuildID, msg.Author.ID)
if err != nil {
slog.Error("failed to get member from state", "guild_id", msg.GuildID, "member_id", msg.Author.ID, "err", err)
} else {
@ -555,7 +568,7 @@ func (ml *messagesList) reply(mention bool) {
}
}
data := app.chatView.messageInput.sendMessageData
data := ml.chat.messageInput.sendMessageData
data.Reference = &discord.MessageReference{MessageID: ml.selectedMessageID}
data.AllowedMentions = &api.AllowedMentions{RepliedUser: option.False}
@ -565,9 +578,9 @@ func (ml *messagesList) reply(mention bool) {
title = "[@] " + title
}
app.chatView.messageInput.sendMessageData = data
app.chatView.messageInput.SetTitle(title + name)
app.SetFocus(app.chatView.messageInput)
ml.chat.messageInput.sendMessageData = data
ml.chat.messageInput.SetTitle(title + name)
ml.chat.app.SetFocus(ml.chat.messageInput)
}
func (ml *messagesList) edit() {
@ -577,7 +590,7 @@ func (ml *messagesList) edit() {
return
}
me, err := discordState.Cabinet.Me()
me, err := ml.chat.state.Cabinet.Me()
if err != nil {
slog.Error("failed to get client user (me)", "err", err)
return
@ -588,10 +601,10 @@ func (ml *messagesList) edit() {
return
}
app.chatView.messageInput.SetTitle("Editing")
app.chatView.messageInput.edit = true
app.chatView.messageInput.SetText(message.Content, true)
app.SetFocus(app.chatView.messageInput)
ml.chat.messageInput.SetTitle("Editing")
ml.chat.messageInput.edit = true
ml.chat.messageInput.SetText(message.Content, true)
ml.chat.app.SetFocus(ml.chat.messageInput)
}
func (ml *messagesList) confirmDelete() {
@ -601,7 +614,7 @@ func (ml *messagesList) confirmDelete() {
}
}
app.chatView.showConfirmModal(
ml.chat.showConfirmModal(
"Are you sure you want to delete this message?",
[]string{"Yes", "No"},
onChoice,
@ -616,28 +629,33 @@ func (ml *messagesList) delete() {
}
if msg.GuildID.IsValid() {
me, err := discordState.Cabinet.Me()
me, err := ml.chat.state.Cabinet.Me()
if err != nil {
slog.Error("failed to get client user (me)", "err", err)
return
}
if msg.Author.ID != me.ID && !discordState.HasPermissions(msg.ChannelID, discord.PermissionManageMessages) {
if msg.Author.ID != me.ID && !ml.chat.state.HasPermissions(msg.ChannelID, discord.PermissionManageMessages) {
slog.Error("failed to delete message; missing relevant permissions", "channel_id", msg.ChannelID, "message_id", msg.ID)
return
}
}
if err := discordState.DeleteMessage(app.chatView.selectedChannel.ID, msg.ID, ""); err != nil {
slog.Error("failed to delete message", "channel_id", app.chatView.selectedChannel.ID, "message_id", msg.ID, "err", err)
selected := ml.chat.SelectedChannel()
if selected == nil {
return
}
if err := ml.chat.state.DeleteMessage(selected.ID, msg.ID, ""); err != nil {
slog.Error("failed to delete message", "channel_id", selected.ID, "message_id", msg.ID, "err", err)
return
}
ml.selectedMessageID = 0
ml.Highlight()
if err := discordState.MessageRemove(app.chatView.selectedChannel.ID, msg.ID); err != nil {
slog.Error("failed to delete message", "channel_id", app.chatView.selectedChannel.ID, "message_id", msg.ID, "err", err)
if err := ml.chat.state.MessageRemove(selected.ID, msg.ID); err != nil {
slog.Error("failed to delete message", "channel_id", selected.ID, "message_id", msg.ID, "err", err)
return
}
@ -653,13 +671,13 @@ func (ml *messagesList) requestGuildMembers(gID discord.GuildID, ms []discord.Me
continue
}
if member, _ := discordState.Cabinet.Member(gID, m.Author.ID); member == nil {
if member, _ := ml.chat.state.Cabinet.Member(gID, m.Author.ID); member == nil {
usersToFetch = append(usersToFetch, m.Author.ID)
}
}
if len(usersToFetch) > 0 {
err := discordState.SendGateway(context.TODO(), &gateway.RequestGuildMembersCommand{
err := ml.chat.state.SendGateway(context.TODO(), &gateway.RequestGuildMembersCommand{
GuildIDs: []discord.GuildID{gID},
UserIDs: slices.Compact(usersToFetch),
})

73
internal/ui/chat/state.go Normal file
View file

@ -0,0 +1,73 @@
package chat
import (
"context"
"log/slog"
"github.com/ayn2op/discordo/internal/http"
"github.com/diamondburned/arikawa/v3/gateway"
"github.com/diamondburned/arikawa/v3/session"
"github.com/diamondburned/arikawa/v3/state"
"github.com/diamondburned/arikawa/v3/state/store/defaultstore"
"github.com/diamondburned/arikawa/v3/utils/handler"
"github.com/diamondburned/arikawa/v3/utils/httputil"
"github.com/diamondburned/arikawa/v3/utils/httputil/httpdriver"
"github.com/diamondburned/arikawa/v3/utils/ws"
"github.com/diamondburned/ningen/v3"
)
func (cv *ChatView) OpenState(token string) error {
identifyProps := http.IdentifyProperties()
gateway.DefaultIdentity = identifyProps
gateway.DefaultPresence = &gateway.UpdatePresenceCommand{
Status: cv.cfg.Status,
}
id := gateway.DefaultIdentifier(token)
id.Compress = false
session := session.NewCustom(id, http.NewClient(token), handler.New())
state := state.NewFromSession(session, defaultstore.New())
cv.state = ningen.FromState(state)
// Handlers
cv.state.AddHandler(cv.onRaw)
cv.state.AddHandler(cv.onReady)
cv.state.AddHandler(cv.onMessageCreate)
cv.state.AddHandler(cv.onMessageUpdate)
cv.state.AddHandler(cv.onMessageDelete)
cv.state.AddHandler(cv.onReadUpdate)
cv.state.AddHandler(cv.onGuildMembersChunk)
cv.state.AddHandler(cv.onGuildMemberRemove)
cv.state.StateLog = func(err error) {
slog.Error("state log", "err", err)
}
cv.state.OnRequest = append(cv.state.OnRequest, httputil.WithHeaders(http.Headers()), cv.onRequest)
return cv.state.Open(context.TODO())
}
func (cv *ChatView) CloseState() error {
if cv.state == nil {
return nil
}
return cv.state.Close()
}
func (cv *ChatView) onRequest(r httpdriver.Request) error {
if req, ok := r.(*httpdriver.DefaultRequest); ok {
slog.Debug("new HTTP request", "method", req.Method, "url", req.URL)
}
return nil
}
func (cv *ChatView) onRaw(event *ws.RawEvent) {
slog.Debug(
"new raw event",
"code", event.OriginalCode,
"type", event.OriginalType,
// "data", event.Raw,
)
}