refactor(ui/chat): unify gateway and ui updates under event commands (#775)
This commit is contained in:
parent
c8362d57f2
commit
6e3f340b55
6 changed files with 299 additions and 229 deletions
|
|
@ -48,12 +48,12 @@ func (cp *channelsPicker) HandleEvent(event tview.Event) tview.Command {
|
|||
|
||||
cp.chatView.guildsTree.expandPathToNode(node)
|
||||
cp.chatView.guildsTree.SetCurrentNode(node)
|
||||
var selectCmd tview.Command
|
||||
if channel.Type != discord.GuildCategory {
|
||||
cp.chatView.guildsTree.onSelected(node)
|
||||
selectCmd = cp.chatView.guildsTree.onSelected(node)
|
||||
}
|
||||
cp.chatView.closePicker()
|
||||
cp.chatView.focusMessageInput()
|
||||
return nil
|
||||
return selectCmd
|
||||
case *picker.CancelEvent:
|
||||
cp.chatView.closePicker()
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -1,22 +1,25 @@
|
|||
package chat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"github.com/ayn2op/tview"
|
||||
"github.com/diamondburned/arikawa/v3/discord"
|
||||
"github.com/diamondburned/arikawa/v3/gateway"
|
||||
"github.com/gdamore/tcell/v3"
|
||||
)
|
||||
|
||||
type LogoutEvent struct{ tcell.EventTime }
|
||||
|
||||
func (m *Model) logout() tview.Command {
|
||||
func (m *Model) openState() tview.Command {
|
||||
return func() tview.Event {
|
||||
return &LogoutEvent{}
|
||||
if err := m.state.Open(context.Background()); err != nil {
|
||||
slog.Error("failed to open chat state", "err", err)
|
||||
return tcell.NewEventError(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
type QuitEvent struct{ tcell.EventTime }
|
||||
|
||||
func (m *Model) closeState() tview.Command {
|
||||
return func() tview.Event {
|
||||
if m.state != nil {
|
||||
|
|
@ -29,6 +32,47 @@ func (m *Model) closeState() tview.Command {
|
|||
}
|
||||
}
|
||||
|
||||
type gatewayEvent struct {
|
||||
tcell.EventTime
|
||||
gateway.Event
|
||||
}
|
||||
|
||||
func (m *Model) listen() tview.Command {
|
||||
return func() tview.Event {
|
||||
return &gatewayEvent{Event: <-m.events}
|
||||
}
|
||||
}
|
||||
|
||||
type channelLoadedEvent struct {
|
||||
tcell.EventTime
|
||||
Channel discord.Channel
|
||||
Messages []discord.Message
|
||||
}
|
||||
|
||||
func newChannelLoadedEvent(channel discord.Channel, messages []discord.Message) *channelLoadedEvent {
|
||||
return &channelLoadedEvent{Channel: channel, Messages: messages}
|
||||
}
|
||||
|
||||
type olderMessagesLoadedEvent struct {
|
||||
tcell.EventTime
|
||||
ChannelID discord.ChannelID
|
||||
Older []discord.Message
|
||||
}
|
||||
|
||||
func newOlderMessagesLoadedEvent(channelID discord.ChannelID, older []discord.Message) *olderMessagesLoadedEvent {
|
||||
return &olderMessagesLoadedEvent{ChannelID: channelID, Older: older}
|
||||
}
|
||||
|
||||
type LogoutEvent struct{ tcell.EventTime }
|
||||
|
||||
func (m *Model) logout() tview.Command {
|
||||
return func() tview.Event {
|
||||
return &LogoutEvent{}
|
||||
}
|
||||
}
|
||||
|
||||
type QuitEvent struct{ tcell.EventTime }
|
||||
|
||||
type closeLayerEvent struct {
|
||||
tcell.EventTime
|
||||
name string
|
||||
|
|
|
|||
|
|
@ -277,10 +277,10 @@ func (gt *guildsTree) createChannelNodes(node *tview.TreeNode, channels []discor
|
|||
}
|
||||
}
|
||||
|
||||
func (gt *guildsTree) onSelected(node *tview.TreeNode) {
|
||||
func (gt *guildsTree) onSelected(node *tview.TreeNode) tview.Command {
|
||||
if len(node.GetChildren()) != 0 {
|
||||
node.SetExpanded(!node.IsExpanded())
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
switch ref := node.GetReference().(type) {
|
||||
|
|
@ -290,17 +290,18 @@ func (gt *guildsTree) onSelected(node *tview.TreeNode) {
|
|||
channels, err := gt.chat.state.Cabinet.Channels(ref)
|
||||
if err != nil {
|
||||
slog.Error("failed to get channels", "err", err, "guild_id", ref)
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
ui.SortGuildChannels(channels)
|
||||
gt.createChannelNodes(node, channels)
|
||||
node.Expand()
|
||||
return nil
|
||||
case discord.ChannelID:
|
||||
channel, err := gt.chat.state.Cabinet.Channel(ref)
|
||||
if err != nil {
|
||||
slog.Error("failed to get channel from state", "err", err, "channel_id", ref)
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
// Handle forum channels differently - they contain threads, not direct messages
|
||||
|
|
@ -309,7 +310,7 @@ func (gt *guildsTree) onSelected(node *tview.TreeNode) {
|
|||
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
|
||||
return nil
|
||||
}
|
||||
|
||||
// Filter for threads that belong to this forum channel
|
||||
|
|
@ -327,14 +328,34 @@ func (gt *guildsTree) onSelected(node *tview.TreeNode) {
|
|||
gt.createChannelNode(node, thread)
|
||||
}
|
||||
node.Expand()
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
limit := gt.cfg.MessagesLimit
|
||||
messages, err := gt.chat.state.Messages(channel.ID, uint(limit))
|
||||
return gt.loadChannel(*channel)
|
||||
case dmNode: // Direct messages folder
|
||||
channels, err := gt.chat.state.PrivateChannels()
|
||||
if err != nil {
|
||||
slog.Error("failed to get private channels", "err", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
ui.SortPrivateChannels(channels)
|
||||
for _, c := range channels {
|
||||
gt.createChannelNode(node, c)
|
||||
}
|
||||
node.Expand()
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (gt *guildsTree) loadChannel(channel discord.Channel) tview.Command {
|
||||
limit := uint(gt.cfg.MessagesLimit)
|
||||
return func() tview.Event {
|
||||
messages, err := gt.chat.state.Messages(channel.ID, limit)
|
||||
if err != nil {
|
||||
slog.Error("failed to get messages", "err", err, "channel_id", channel.ID, "limit", limit)
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
go gt.chat.state.ReadState.MarkRead(channel.ID, channel.LastMessageID)
|
||||
|
|
@ -343,39 +364,7 @@ func (gt *guildsTree) onSelected(node *tview.TreeNode) {
|
|||
gt.chat.messagesList.requestGuildMembers(guildID, messages)
|
||||
}
|
||||
|
||||
gt.chat.SetSelectedChannel(channel)
|
||||
gt.chat.clearTypers()
|
||||
gt.chat.messageInput.stopTypingTimer()
|
||||
|
||||
gt.chat.messagesList.reset()
|
||||
gt.chat.messagesList.setTitle(*channel)
|
||||
gt.chat.messagesList.setMessages(messages)
|
||||
gt.chat.messagesList.ScrollBottom()
|
||||
|
||||
hasNoPerm := channel.Type != discord.DirectMessage && channel.Type != discord.GroupDM && !gt.chat.state.HasPermissions(channel.ID, discord.PermissionSendMessages)
|
||||
gt.chat.messageInput.SetDisabled(hasNoPerm)
|
||||
var text string
|
||||
if hasNoPerm {
|
||||
text = "You do not have permission to send messages in this channel."
|
||||
} else {
|
||||
text = "Message..."
|
||||
if gt.cfg.AutoFocus {
|
||||
gt.chat.app.SetFocus(gt.chat.messageInput)
|
||||
}
|
||||
}
|
||||
gt.chat.messageInput.SetPlaceholder(tview.NewLine(tview.NewSegment(text, tcell.StyleDefault.Dim(true))))
|
||||
case dmNode: // Direct messages folder
|
||||
channels, err := gt.chat.state.PrivateChannels()
|
||||
if err != nil {
|
||||
slog.Error("failed to get private channels", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
ui.SortPrivateChannels(channels)
|
||||
for _, c := range channels {
|
||||
gt.createChannelNode(node, c)
|
||||
}
|
||||
node.Expand()
|
||||
return newChannelLoadedEvent(channel, messages)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -396,8 +385,7 @@ func (gt *guildsTree) collapseParentNode(node *tview.TreeNode) {
|
|||
func (gt *guildsTree) HandleEvent(event tview.Event) tview.Command {
|
||||
switch event := event.(type) {
|
||||
case *tview.TreeViewSelectedEvent:
|
||||
gt.onSelected(event.Node)
|
||||
return nil
|
||||
return gt.onSelected(event.Node)
|
||||
case *tview.KeyEvent:
|
||||
handler := gt.TreeView.HandleEvent
|
||||
|
||||
|
|
|
|||
|
|
@ -852,8 +852,7 @@ func (ml *messagesList) HandleEvent(event tview.Event) tview.Command {
|
|||
ml.clearSelection()
|
||||
return nil
|
||||
case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.SelectUp.Keybind):
|
||||
ml.selectUp()
|
||||
return nil
|
||||
return ml.selectUp()
|
||||
case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.SelectDown.Keybind):
|
||||
ml.selectDown()
|
||||
return nil
|
||||
|
|
@ -891,14 +890,29 @@ func (ml *messagesList) HandleEvent(event tview.Event) tview.Command {
|
|||
return nil
|
||||
}
|
||||
return ml.Model.HandleEvent(event)
|
||||
|
||||
case *olderMessagesLoadedEvent:
|
||||
selectedChannel := ml.chatView.SelectedChannel()
|
||||
if selectedChannel == nil || selectedChannel.ID != event.ChannelID {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Defensive invalidation if Discord returns overlapping windows.
|
||||
for _, message := range event.Older {
|
||||
delete(ml.itemByID, message.ID)
|
||||
}
|
||||
ml.messages = slices.Concat(event.Older, ml.messages)
|
||||
ml.invalidateRows()
|
||||
ml.SetCursor(len(event.Older) - 1)
|
||||
return nil
|
||||
}
|
||||
return ml.Model.HandleEvent(event)
|
||||
}
|
||||
|
||||
func (ml *messagesList) selectUp() {
|
||||
func (ml *messagesList) selectUp() tview.Command {
|
||||
messages := ml.messages
|
||||
if len(messages) == 0 {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
cursor := ml.Cursor()
|
||||
|
|
@ -908,14 +922,11 @@ func (ml *messagesList) selectUp() {
|
|||
case cursor > 0:
|
||||
cursor--
|
||||
case cursor == 0:
|
||||
added := ml.prependOlderMessages()
|
||||
if added == 0 {
|
||||
return
|
||||
}
|
||||
cursor = added - 1
|
||||
return ml.fetchOlderMessages()
|
||||
}
|
||||
|
||||
ml.SetCursor(cursor)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ml *messagesList) selectDown() {
|
||||
|
|
@ -970,38 +981,33 @@ func (ml *messagesList) selectReply() {
|
|||
}
|
||||
}
|
||||
|
||||
func (ml *messagesList) prependOlderMessages() int {
|
||||
func (ml *messagesList) fetchOlderMessages() tview.Command {
|
||||
selectedChannel := ml.chatView.SelectedChannel()
|
||||
if selectedChannel == nil {
|
||||
return 0
|
||||
return nil
|
||||
}
|
||||
|
||||
channelID := selectedChannel.ID
|
||||
before := ml.messages[0].ID
|
||||
limit := uint(ml.cfg.MessagesLimit)
|
||||
messages, err := ml.chatView.state.MessagesBefore(channelID, before, limit)
|
||||
if err != nil {
|
||||
slog.Error("failed to fetch older messages", "err", err)
|
||||
return 0
|
||||
}
|
||||
if len(messages) == 0 {
|
||||
return 0
|
||||
}
|
||||
return func() tview.Event {
|
||||
messages, err := ml.chatView.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
|
||||
}
|
||||
|
||||
if guildID := selectedChannel.GuildID; guildID.IsValid() {
|
||||
ml.requestGuildMembers(guildID, messages)
|
||||
}
|
||||
if guildID := selectedChannel.GuildID; guildID.IsValid() {
|
||||
ml.requestGuildMembers(guildID, messages)
|
||||
}
|
||||
|
||||
older := slices.Clone(messages)
|
||||
slices.Reverse(older)
|
||||
|
||||
// Defensive invalidation if Discord returns overlapping windows.
|
||||
for _, message := range older {
|
||||
delete(ml.itemByID, message.ID)
|
||||
older := slices.Clone(messages)
|
||||
slices.Reverse(older)
|
||||
return newOlderMessagesLoadedEvent(channelID, older)
|
||||
}
|
||||
ml.messages = slices.Concat(older, ml.messages)
|
||||
ml.invalidateRows()
|
||||
return len(messages)
|
||||
}
|
||||
|
||||
func (ml *messagesList) yankMessageID() tview.Command {
|
||||
|
|
|
|||
|
|
@ -7,12 +7,20 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/ayn2op/discordo/internal/config"
|
||||
"github.com/ayn2op/discordo/internal/http"
|
||||
"github.com/ayn2op/discordo/internal/ui"
|
||||
"github.com/ayn2op/tview"
|
||||
"github.com/ayn2op/tview/flex"
|
||||
"github.com/ayn2op/tview/keybind"
|
||||
"github.com/ayn2op/tview/layers"
|
||||
"github.com/diamondburned/arikawa/v3/discord"
|
||||
"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/ws"
|
||||
"github.com/diamondburned/ningen/v3"
|
||||
"github.com/diamondburned/ningen/v3/states/read"
|
||||
"github.com/gdamore/tcell/v3"
|
||||
|
|
@ -44,16 +52,17 @@ type Model struct {
|
|||
selectedChannel *discord.Channel
|
||||
selectedChannelMu sync.RWMutex
|
||||
|
||||
typersMu sync.RWMutex
|
||||
typers map[discord.UserID]*time.Timer
|
||||
|
||||
confirmModalDone func(label string)
|
||||
confirmModalPreviousFocus tview.Model
|
||||
|
||||
app *tview.Application
|
||||
cfg *config.Config
|
||||
state *ningen.State
|
||||
token string
|
||||
state *ningen.State
|
||||
events chan gateway.Event
|
||||
|
||||
typersMu sync.RWMutex
|
||||
typers map[discord.UserID]*time.Timer
|
||||
|
||||
app *tview.Application
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
func NewModel(app *tview.Application, cfg *config.Config, token string) *Model {
|
||||
|
|
@ -65,9 +74,8 @@ func NewModel(app *tview.Application, cfg *config.Config, token string) *Model {
|
|||
|
||||
typers: make(map[discord.UserID]*time.Timer),
|
||||
|
||||
app: app,
|
||||
cfg: cfg,
|
||||
token: token,
|
||||
app: app,
|
||||
cfg: cfg,
|
||||
}
|
||||
|
||||
m.guildsTree = newGuildsTree(cfg, m)
|
||||
|
|
@ -75,6 +83,26 @@ func NewModel(app *tview.Application, cfg *config.Config, token string) *Model {
|
|||
m.messageInput = newMessageInput(cfg, m)
|
||||
m.channelsPicker = newChannelsPicker(cfg, m)
|
||||
|
||||
identifyProps := http.IdentifyProperties()
|
||||
gateway.DefaultIdentity = identifyProps
|
||||
gateway.DefaultPresence = &gateway.UpdatePresenceCommand{
|
||||
Status: m.cfg.Status,
|
||||
}
|
||||
|
||||
id := gateway.DefaultIdentifier(token)
|
||||
id.Compress = false
|
||||
|
||||
session := session.NewCustom(id, http.NewClient(token), handler.New())
|
||||
state := state.NewFromSession(session, defaultstore.New())
|
||||
m.state = ningen.FromState(state)
|
||||
|
||||
m.events = make(chan gateway.Event)
|
||||
m.state.AddHandler(m.events)
|
||||
m.state.StateLog = func(err error) {
|
||||
slog.Error("state log", "err", err)
|
||||
}
|
||||
m.state.OnRequest = append(m.state.OnRequest, httputil.WithHeaders(http.Headers()), m.onRequest)
|
||||
|
||||
m.SetBackgroundLayerStyle(m.cfg.Theme.Dialog.BackgroundStyle.Style)
|
||||
m.buildLayout()
|
||||
return m
|
||||
|
|
@ -219,13 +247,67 @@ func (m *Model) focusNext() tview.Command {
|
|||
func (m *Model) HandleEvent(event tview.Event) tview.Command {
|
||||
switch event := event.(type) {
|
||||
case *tview.InitEvent:
|
||||
return func() tview.Event {
|
||||
if err := m.OpenState(m.token); err != nil {
|
||||
slog.Error("failed to open chat state", "err", err)
|
||||
return tcell.NewEventError(err)
|
||||
return tview.Batch(m.openState(), m.listen())
|
||||
case *gatewayEvent:
|
||||
switch event := event.Event.(type) {
|
||||
case *ws.RawEvent:
|
||||
m.onRaw(event)
|
||||
|
||||
case *gateway.ReadyEvent:
|
||||
m.onReady(event)
|
||||
|
||||
case *gateway.MessageCreateEvent:
|
||||
m.onMessageCreate(event)
|
||||
case *gateway.MessageUpdateEvent:
|
||||
m.onMessageUpdate(event)
|
||||
case *gateway.MessageDeleteEvent:
|
||||
m.onMessageDelete(event)
|
||||
|
||||
case *gateway.GuildMembersChunkEvent:
|
||||
m.onGuildMembersChunk(event)
|
||||
case *gateway.GuildMemberRemoveEvent:
|
||||
m.onGuildMemberRemove(event)
|
||||
|
||||
case *gateway.TypingStartEvent:
|
||||
if m.cfg.TypingIndicator.Receive {
|
||||
m.onTypingStart(event)
|
||||
}
|
||||
|
||||
case *read.UpdateEvent:
|
||||
m.onReadUpdate(event)
|
||||
}
|
||||
return m.listen()
|
||||
case *channelLoadedEvent:
|
||||
node := m.guildsTree.GetCurrentNode()
|
||||
if node == nil {
|
||||
return nil
|
||||
}
|
||||
channelID, ok := node.GetReference().(discord.ChannelID)
|
||||
if !ok || channelID != event.Channel.ID {
|
||||
return nil
|
||||
}
|
||||
|
||||
m.SetSelectedChannel(&event.Channel)
|
||||
m.clearTypers()
|
||||
m.messageInput.stopTypingTimer()
|
||||
|
||||
m.messagesList.reset()
|
||||
m.messagesList.setTitle(event.Channel)
|
||||
m.messagesList.setMessages(event.Messages)
|
||||
m.messagesList.ScrollBottom()
|
||||
|
||||
hasNoPerm := event.Channel.Type != discord.DirectMessage && event.Channel.Type != discord.GroupDM && !m.state.HasPermissions(event.Channel.ID, discord.PermissionSendMessages)
|
||||
m.messageInput.SetDisabled(hasNoPerm)
|
||||
text := "Message..."
|
||||
|
||||
var focusCommand tview.Command
|
||||
if hasNoPerm {
|
||||
text = "You do not have permission to send messages in this channel."
|
||||
} else if m.cfg.AutoFocus {
|
||||
focusCommand = m.focusMessageInput()
|
||||
}
|
||||
m.messageInput.SetPlaceholder(tview.NewLine(tview.NewSegment(text, tcell.StyleDefault.Dim(true))))
|
||||
return focusCommand
|
||||
case *QuitEvent:
|
||||
return tview.Batch(
|
||||
m.closeState(),
|
||||
|
|
@ -298,24 +380,6 @@ func (m *Model) showConfirmModal(prompt string, buttons []string, onDone func(la
|
|||
SendToFront(confirmModalLayerName)
|
||||
}
|
||||
|
||||
func (m *Model) onReadUpdate(event *read.UpdateEvent) {
|
||||
m.app.QueueUpdateDraw(func() {
|
||||
// Use indexed node lookup to avoid walking the whole tree on every read
|
||||
// event. This runs frequently while reading/typing across channels.
|
||||
if event.GuildID.IsValid() {
|
||||
if guildNode := m.guildsTree.findNodeByReference(event.GuildID); guildNode != nil {
|
||||
m.guildsTree.setNodeLineStyle(guildNode, m.guildsTree.getGuildNodeStyle(event.GuildID))
|
||||
}
|
||||
}
|
||||
|
||||
// Channel style is always updated for the target channel regardless of
|
||||
// whether it's in a guild or DM.
|
||||
if channelNode := m.guildsTree.findNodeByReference(event.ChannelID); channelNode != nil {
|
||||
m.guildsTree.setNodeLineStyle(channelNode, m.guildsTree.getChannelNodeStyle(event.ChannelID))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Model) clearTypers() {
|
||||
m.typersMu.Lock()
|
||||
for _, timer := range m.typers {
|
||||
|
|
@ -403,5 +467,5 @@ func (m *Model) updateFooter() {
|
|||
}
|
||||
}
|
||||
|
||||
go m.app.QueueUpdateDraw(func() { m.messagesList.SetFooter(footer) })
|
||||
m.messagesList.SetFooter(footer)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,66 +1,22 @@
|
|||
package chat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"slices"
|
||||
|
||||
"github.com/ayn2op/discordo/internal/http"
|
||||
"github.com/ayn2op/discordo/internal/notifications"
|
||||
"github.com/ayn2op/tview"
|
||||
"github.com/diamondburned/arikawa/v3/discord"
|
||||
"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 (m *Model) OpenState(token string) error {
|
||||
identifyProps := http.IdentifyProperties()
|
||||
gateway.DefaultIdentity = identifyProps
|
||||
gateway.DefaultPresence = &gateway.UpdatePresenceCommand{
|
||||
Status: m.cfg.Status,
|
||||
}
|
||||
|
||||
id := gateway.DefaultIdentifier(token)
|
||||
id.Compress = false
|
||||
|
||||
session := session.NewCustom(id, http.NewClient(token), handler.New())
|
||||
state := state.NewFromSession(session, defaultstore.New())
|
||||
m.state = ningen.FromState(state)
|
||||
|
||||
// Handlers
|
||||
m.state.AddHandler(m.onRaw)
|
||||
m.state.AddHandler(m.onReady)
|
||||
m.state.AddHandler(m.onMessageCreate)
|
||||
m.state.AddHandler(m.onMessageUpdate)
|
||||
m.state.AddHandler(m.onMessageDelete)
|
||||
m.state.AddHandler(m.onReadUpdate)
|
||||
m.state.AddHandler(m.onGuildMembersChunk)
|
||||
m.state.AddHandler(m.onGuildMemberRemove)
|
||||
|
||||
if m.cfg.TypingIndicator.Receive {
|
||||
m.state.AddHandler(m.onTypingStart)
|
||||
}
|
||||
|
||||
m.state.StateLog = func(err error) {
|
||||
slog.Error("state log", "err", err)
|
||||
}
|
||||
|
||||
m.state.OnRequest = append(m.state.OnRequest, httputil.WithHeaders(http.Headers()), m.onRequest)
|
||||
return m.state.Open(context.Background())
|
||||
}
|
||||
|
||||
func (m *Model) 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
|
||||
}
|
||||
|
||||
|
|
@ -74,79 +30,76 @@ func (m *Model) onRaw(event *ws.RawEvent) {
|
|||
}
|
||||
|
||||
func (m *Model) onReady(event *gateway.ReadyEvent) {
|
||||
m.app.QueueUpdateDraw(func() {
|
||||
// Rebuild indexes from scratch so reconnects and account switches do not
|
||||
// retain pointers to detached tree nodes.
|
||||
m.guildsTree.resetNodeIndex()
|
||||
// Rebuild indexes from scratch so reconnects and account switches do not
|
||||
// retain pointers to detached tree nodes.
|
||||
m.guildsTree.resetNodeIndex()
|
||||
|
||||
dmNode := tview.NewTreeNode("Direct Messages").SetReference(dmNode{}).SetExpandable(true).SetExpanded(false)
|
||||
m.guildsTree.dmRootNode = dmNode
|
||||
dmNode := tview.NewTreeNode("Direct Messages").SetReference(dmNode{}).SetExpandable(true).SetExpanded(false)
|
||||
m.guildsTree.dmRootNode = dmNode
|
||||
|
||||
root := m.guildsTree.
|
||||
GetRoot().
|
||||
ClearChildren().
|
||||
AddChild(dmNode)
|
||||
root := m.guildsTree.
|
||||
GetRoot().
|
||||
ClearChildren().
|
||||
AddChild(dmNode)
|
||||
|
||||
// Track guilds already in folders to find orphans (newly joined guilds may not be synced to GuildFolders yet but always appear in GuildPositions)
|
||||
guildsInFolders := make(map[discord.GuildID]bool)
|
||||
for _, folder := range event.UserSettings.GuildFolders {
|
||||
for _, guildID := range folder.GuildIDs {
|
||||
guildsInFolders[guildID] = true
|
||||
}
|
||||
// Track guilds already in folders to find orphans.
|
||||
// Newly joined guilds may not be synced to GuildFolders yet but always appear in guild positions.
|
||||
guildsInFolders := make(map[discord.GuildID]bool)
|
||||
for _, folder := range event.UserSettings.GuildFolders {
|
||||
for _, guildID := range folder.GuildIDs {
|
||||
guildsInFolders[guildID] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Build index of all available guilds.
|
||||
guildsByID := make(map[discord.GuildID]*gateway.GuildCreateEvent, len(event.Guilds))
|
||||
for index := range event.Guilds {
|
||||
guildsByID[event.Guilds[index].ID] = &event.Guilds[index]
|
||||
}
|
||||
|
||||
// Use GuildPositions for ordering (it's the canonical order).
|
||||
// Guilds not in any folder are "orphans" - add them directly to root.
|
||||
positions := event.UserSettings.GuildPositions
|
||||
// Fallback: GuildPositions shouldn't be nil but handle gracefully
|
||||
if len(positions) == 0 {
|
||||
positions = make([]discord.GuildID, 0, len(event.Guilds))
|
||||
for _, guildEvent := range event.Guilds {
|
||||
positions = append(positions, guildEvent.ID)
|
||||
}
|
||||
}
|
||||
|
||||
for _, guildID := range positions {
|
||||
// Already handled in folder processing below
|
||||
if guildsInFolders[guildID] {
|
||||
continue
|
||||
}
|
||||
|
||||
// Build index of all available guilds.
|
||||
guildsByID := make(map[discord.GuildID]*gateway.GuildCreateEvent, len(event.Guilds))
|
||||
for index := range event.Guilds {
|
||||
guildsByID[event.Guilds[index].ID] = &event.Guilds[index]
|
||||
// Orphan guild - add directly to root in order
|
||||
if guildEvent, ok := guildsByID[guildID]; ok {
|
||||
m.guildsTree.createGuildNode(root, guildEvent.Guild)
|
||||
}
|
||||
}
|
||||
|
||||
// Use GuildPositions for ordering (it's the canonical order).
|
||||
// Guilds not in any folder are "orphans" - add them directly to root.
|
||||
positions := event.UserSettings.GuildPositions
|
||||
// Fallback: GuildPositions shouldn't be nil but handle gracefully
|
||||
if len(positions) == 0 {
|
||||
positions = make([]discord.GuildID, 0, len(event.Guilds))
|
||||
for _, guildEvent := range event.Guilds {
|
||||
positions = append(positions, guildEvent.ID)
|
||||
// Process folders (real folders and single-guild "folders")
|
||||
for _, folder := range event.UserSettings.GuildFolders {
|
||||
if folder.ID == 0 && len(folder.GuildIDs) == 1 {
|
||||
if guild, ok := guildsByID[folder.GuildIDs[0]]; ok {
|
||||
m.guildsTree.createGuildNode(root, guild.Guild)
|
||||
}
|
||||
} else {
|
||||
m.guildsTree.createFolderNode(folder, guildsByID)
|
||||
}
|
||||
}
|
||||
|
||||
for _, guildID := range positions {
|
||||
// Already handled in folder processing below
|
||||
if guildsInFolders[guildID] {
|
||||
continue
|
||||
}
|
||||
|
||||
// Orphan guild - add directly to root in order
|
||||
if guildEvent, ok := guildsByID[guildID]; ok {
|
||||
m.guildsTree.createGuildNode(root, guildEvent.Guild)
|
||||
}
|
||||
}
|
||||
|
||||
// Process folders (real folders and single-guild "folders")
|
||||
for _, folder := range event.UserSettings.GuildFolders {
|
||||
if folder.ID == 0 && len(folder.GuildIDs) == 1 {
|
||||
if guild, ok := guildsByID[folder.GuildIDs[0]]; ok {
|
||||
m.guildsTree.createGuildNode(root, guild.Guild)
|
||||
}
|
||||
} else {
|
||||
m.guildsTree.createFolderNode(folder, guildsByID)
|
||||
}
|
||||
}
|
||||
|
||||
m.guildsTree.SetCurrentNode(root)
|
||||
m.app.SetFocus(m.guildsTree)
|
||||
})
|
||||
m.guildsTree.SetCurrentNode(root)
|
||||
m.app.SetFocus(m.guildsTree)
|
||||
}
|
||||
|
||||
func (m *Model) onMessageCreate(message *gateway.MessageCreateEvent) {
|
||||
selectedChannel := m.SelectedChannel()
|
||||
if selectedChannel != nil && selectedChannel.ID == message.ChannelID {
|
||||
m.removeTyper(message.Author.ID)
|
||||
m.app.QueueUpdateDraw(func() {
|
||||
m.messagesList.addMessage(message.Message)
|
||||
})
|
||||
m.messagesList.addMessage(message.Message)
|
||||
} else {
|
||||
if err := notifications.Notify(m.state, message, m.cfg); err != nil {
|
||||
slog.Error("failed to notify", "err", err, "channel_id", message.ChannelID, "message_id", message.ID)
|
||||
|
|
@ -163,14 +116,17 @@ func (m *Model) onMessageUpdate(message *gateway.MessageUpdateEvent) {
|
|||
return
|
||||
}
|
||||
|
||||
m.app.QueueUpdateDraw(func() {
|
||||
m.messagesList.setMessage(index, message.Message)
|
||||
})
|
||||
m.messagesList.setMessage(index, message.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) onMessageDelete(message *gateway.MessageDeleteEvent) {
|
||||
if selected := m.SelectedChannel(); selected != nil && selected.ID == message.ChannelID {
|
||||
selectedChannel := m.SelectedChannel()
|
||||
if selectedChannel == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if selectedChannel.ID == message.ChannelID {
|
||||
prevCursor := m.messagesList.Cursor()
|
||||
deletedIndex := slices.IndexFunc(m.messagesList.messages, func(m discord.Message) bool {
|
||||
return m.ID == message.ID
|
||||
|
|
@ -179,9 +135,7 @@ func (m *Model) onMessageDelete(message *gateway.MessageDeleteEvent) {
|
|||
return
|
||||
}
|
||||
|
||||
m.app.QueueUpdateDraw(func() {
|
||||
m.messagesList.deleteMessage(deletedIndex)
|
||||
})
|
||||
m.messagesList.deleteMessage(deletedIndex)
|
||||
|
||||
// Keep cursor stable when possible after removal.
|
||||
newCursor := prevCursor
|
||||
|
|
@ -201,9 +155,7 @@ func (m *Model) onMessageDelete(message *gateway.MessageDeleteEvent) {
|
|||
}
|
||||
if newCursor != prevCursor {
|
||||
// Avoid redundant cursor updates if nothing changed.
|
||||
m.app.QueueUpdateDraw(func() {
|
||||
m.messagesList.SetCursor(newCursor)
|
||||
})
|
||||
m.messagesList.SetCursor(newCursor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -233,3 +185,19 @@ func (m *Model) onTypingStart(event *gateway.TypingStartEvent) {
|
|||
|
||||
m.addTyper(event.UserID)
|
||||
}
|
||||
|
||||
func (m *Model) onReadUpdate(event *read.UpdateEvent) {
|
||||
// Use indexed node lookup to avoid walking the whole tree on every read
|
||||
// event. This runs frequently while reading/typing across channels.
|
||||
if event.GuildID.IsValid() {
|
||||
if guildNode := m.guildsTree.findNodeByReference(event.GuildID); guildNode != nil {
|
||||
m.guildsTree.setNodeLineStyle(guildNode, m.guildsTree.getGuildNodeStyle(event.GuildID))
|
||||
}
|
||||
}
|
||||
|
||||
// Channel style is always updated for the target channel regardless of
|
||||
// whether it's in a guild or DM.
|
||||
if channelNode := m.guildsTree.findNodeByReference(event.ChannelID); channelNode != nil {
|
||||
m.guildsTree.setNodeLineStyle(channelNode, m.guildsTree.getChannelNodeStyle(event.ChannelID))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue