package chat import ( "fmt" "log/slog" "time" "github.com/ayn2op/arikawa/v3/discord" "github.com/ayn2op/arikawa/v3/gateway" "github.com/ayn2op/arikawa/v3/session" "github.com/ayn2op/arikawa/v3/state" "github.com/ayn2op/arikawa/v3/state/store/defaultstore" "github.com/ayn2op/arikawa/v3/utils/handler" "github.com/ayn2op/arikawa/v3/utils/httputil" "github.com/ayn2op/discordo/internal/config" "github.com/ayn2op/discordo/internal/consts" clientgateway "github.com/ayn2op/discordo/internal/gateway" "github.com/ayn2op/discordo/internal/http" "github.com/ayn2op/discordo/internal/ui" "github.com/ayn2op/discordo/internal/ui/chat/attachmentspicker" "github.com/ayn2op/discordo/internal/ui/chat/channelspicker" "github.com/ayn2op/discordo/internal/ui/chat/composer" "github.com/ayn2op/discordo/internal/ui/chat/guildstree" "github.com/ayn2op/discordo/internal/ui/chat/mentionslist" "github.com/ayn2op/discordo/internal/ui/chat/messageslist" "github.com/ayn2op/ningen/v3" "github.com/ayn2op/tview" "github.com/ayn2op/tview/backdrop" "github.com/ayn2op/tview/center" "github.com/ayn2op/tview/column" "github.com/ayn2op/tview/keybind" "github.com/ayn2op/tview/opaque" "github.com/ayn2op/tview/row" "github.com/ayn2op/tview/stack" ) type Model struct { // focused is the pane that receives keys: guildsTree, messagesList, composer, or nil. focused pane // guildsTreeVisible reports whether the guilds tree is shown left of the messages. guildsTreeVisible bool // channelsPickerOpen and attachmentsPickerOpen report whether that picker is shown on top and takes all input. channelsPickerOpen bool attachmentsPickerOpen bool guildsTree *guildstree.Model messagesList *messageslist.Model composer *composer.Model channelsPicker *channelspicker.Model // attachmentsPicker picks an attachment or link of the selected message. attachmentsPicker *attachmentspicker.Model selectedChannel *discord.Channel // windowUnfocused reports whether the terminal window lost focus. windowUnfocused bool state *ningen.State events chan gateway.Event // typers maps the users typing in the selected channel to when their typing indicator expires. typers map[discord.UserID]time.Time cfg *config.Config } func NewModel(cfg *config.Config, token string) *Model { m := &Model{ typers: make(map[discord.UserID]time.Time), cfg: cfg, } id := gateway.NewIdentifier(gateway.IdentifyCommand{ Token: token, Properties: http.IdentifyProperties(), Capabilities: gateway.LazyUserNotes | gateway.VersionedReadStates | gateway.VersionedUserGuildSetttings | gateway.DedupeUserObjects | gateway.PrioritizedReadyPayload | gateway.MultipleGuildExperimentPopulations | gateway.NonChannelReadStates | gateway.AuthTokenRefresh | gateway.DebounceMessageReactions, }) session := session.NewWithGateway(clientgateway.New(id), handler.New()) session.Client = http.NewClient(token) 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.guildsTree = guildstree.NewModel(cfg, m.state) m.messagesList = messageslist.NewModel(cfg, m.state) m.composer = composer.NewModel(cfg, m.state) m.channelsPicker = channelspicker.NewModel(cfg) m.attachmentsPicker = attachmentspicker.NewModel(cfg) m.guildsTreeVisible = cfg.Sidebar.Visible if m.guildsTreeVisible { // The guilds tree is focused first at start-up when visible. m.focused = m.guildsTree } else { m.focused = m.messagesList } return m } func (m *Model) setSelectedChannel(channel *discord.Channel) { m.selectedChannel = channel m.composer.SetChannel(channel) } 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 } // picker returns the picker shown on top, or nil if none is open. func (m *Model) picker() tview.Model { switch { case m.channelsPickerOpen: return m.channelsPicker case m.attachmentsPickerOpen: return m.attachmentsPicker default: return nil } } func (m *Model) navigateToChannel(channelID discord.ChannelID) tview.Cmd { return tview.Sequence( m.closePicker(), m.guildsTree.Update(guildstree.NavigateMsg{ChannelID: channelID}), ) } func (m *Model) toggleGuildsTree() tview.Cmd { if m.guildsTreeVisible { if m.focused == m.guildsTree { m.setFocus(m.messagesList) } m.guildsTreeVisible = false } else { m.guildsTreeVisible = true m.setFocus(m.guildsTree) } return nil } func (m *Model) focusGuildsTree() bool { m.setFocus(m.guildsTree) return m.focused == m.guildsTree } func (m *Model) focusComposer() bool { if !m.composer.Disabled() { m.setFocus(m.composer) return true } return false } func (m *Model) focusPrevious() { switch m.focused { case m.guildsTree: if m.focusComposer() { return } m.setFocus(m.messagesList) case m.messagesList: if m.focusGuildsTree() { return } if m.focusComposer() { return } m.setFocus(m.messagesList) case m.composer: m.setFocus(m.messagesList) } } func (m *Model) focusNext() { switch m.focused { case m.guildsTree: m.setFocus(m.messagesList) case m.messagesList: if m.focusComposer() { return } if m.focusGuildsTree() { return } case m.composer: if m.focusGuildsTree() { return } m.setFocus(m.messagesList) } } func (m *Model) Init() tview.Cmd { return tview.Batch(openState(m.state), listen(m.events)) } func (m *Model) Update(msg tview.Msg) tview.Cmd { switch msg := msg.(type) { case gateway.Event: return tview.Batch(m.applyEvent(msg), listen(m.events)) case tview.FocusMsg: m.windowUnfocused = !msg.Focused return nil case guildstree.ChannelLoadedMsg: node := m.guildsTree.CurrentNode() if node == nil { return nil } channelID, ok := node.Reference().(discord.ChannelID) if !ok || channelID != msg.Channel.ID { return nil } m.setSelectedChannel(&msg.Channel) m.clearTypers() if m.cfg.AutoFocus { m.focusComposer() } 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)) case messageslist.Msg: return m.messagesList.Update(msg) case typingExpiredMsg: if until, ok := m.typers[msg.userID]; ok && !time.Now().Before(until) { m.removeTyper(msg.userID) } return nil case channelspicker.SelectedMsg: return m.navigateToChannel(msg.ChannelID) case channelspicker.CancelMsg: return m.closePicker() case attachmentspicker.SelectedMsg: return tview.Sequence(msg.Action, m.closeAttachmentsPicker()) case attachmentspicker.CancelMsg: return m.closeAttachmentsPicker() case messageslist.ShowAttachmentsMsg: m.attachmentsPicker.SetItems(msg) m.attachmentsPickerOpen = true return nil case messageslist.ReplyMsg: m.composer.StartReply(msg.Message, msg.Name, msg.Mention) m.focusComposer() return nil case messageslist.EditMsg: m.composer.StartEdit(discord.Message(msg)) m.focusComposer() return nil case composer.EditLastMsg: if message, ok := m.messagesList.SelectLastOwn(); ok { m.composer.StartEdit(message) } return nil case composer.SentMsg: m.messagesList.ShowNewest() return nil case QuitMsg: return closeState(m.state) case tview.KeyMsg: switch { case keybind.Matches(msg, m.cfg.Keybinds.FocusGuildsTree.Keybind): m.composer.CloseMentions() m.focusGuildsTree() return nil case keybind.Matches(msg, m.cfg.Keybinds.FocusMessagesList.Keybind): m.composer.CloseMentions() m.setFocus(m.messagesList) return nil case keybind.Matches(msg, m.cfg.Keybinds.FocusComposer.Keybind): m.focusComposer() return nil case keybind.Matches(msg, m.cfg.Keybinds.FocusPrevious.Keybind): m.focusPrevious() return nil case keybind.Matches(msg, m.cfg.Keybinds.FocusNext.Keybind): m.focusNext() return nil case keybind.Matches(msg, m.cfg.Keybinds.ToggleGuildsTree.Keybind): return m.toggleGuildsTree() case keybind.Matches(msg, m.cfg.Keybinds.ToggleChannelsPicker.Keybind): return m.togglePicker() case keybind.Matches(msg, m.cfg.Keybinds.Logout.Keybind): return tview.Sequence(closeState(m.state), logout()) } case composer.TabSuggestMsg, mentionslist.Msg: return m.composer.Update(msg) case paneMsg: if msg.focus { m.setFocus(msg.pane) } if msg.msg == nil { return nil } return msg.pane.Update(msg.msg) } return m.route(msg) } // 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 { if picker := m.picker(); picker != nil { return picker.Update(msg) } if m.focused != nil { return m.focused.Update(msg) } return nil } // 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 child tview.Element } func (p paneElement) Draw(screen tview.Screen, area tview.Rectangle) { p.child.Draw(screen, area) } func (p paneElement) Handle(msg tview.Msg, area tview.Rectangle) tview.Msg { out := p.child.Handle(msg, area) if mouse, ok := msg.(tview.MouseMsg); ok && area.Contains(mouse.Position()) { return paneMsg{pane: p.pane, msg: out, focus: mouse.Action == tview.MouseLeftDown} } // Keys and paste reach only the focused pane, so what it makes of them goes to that pane through route. return out } // pane is a model shown side by side with the others, one of which has the focus. type pane interface { Update(tview.Msg) tview.Cmd View(focused bool) tview.Element } // 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 { return paneElement{pane: p, child: p.View(m.focused == p && m.picker() == nil)} } func (m *Model) setFocus(target pane) { if target == nil || target == m.focused || (target == m.guildsTree && !m.guildsTreeVisible) { return } m.focused = target } // View shows the guilds tree left of the messages above the composer, with the mentions list over the messages and an open picker on top. func (m *Model) View() tview.Element { picker := m.picker() var right tview.Element = column.New( m.paneView(m.messagesList), column.New(m.paneView(m.composer)).Height(tview.Fixed(m.composer.Height())), ) if mentions := m.composer.MentionsView(); mentions != nil { right = stack.New(right, mentions) } main := right if m.guildsTreeVisible { width := m.cfg.Sidebar.WidthPercent main = row.New( column.New(m.paneView(m.guildsTree)).Width(tview.FillPortion(width)), column.New(right).Width(tview.FillPortion(100-width)), ) } if picker == nil { return main } return stack.New( main, // The backdrop keeps clicks from reaching the panes behind the picker. opaque.New(backdrop.New().Style(m.cfg.Theme.Dialog.BackgroundStyle.Style)), center.New(column.New(picker.View()).Width(tview.Fixed(m.cfg.Picker.Width)).Height(tview.Fixed(m.cfg.Picker.Height))), ) } // 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} } } 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 } } } if name != "" { names = append(names, name) } } 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..." } } m.messagesList.SetFooter(footer) }