On Windows the coarse clock can return the same time for two addTyper calls, so a stale expiry matched the extended indicator and removed it early. Compare the stored deadline against the current time instead.
500 lines
13 KiB
Go
500 lines
13 KiB
Go
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)
|
|
}
|