fix(chat): expire typing indicators by deadline instead of timestamp equality

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.
This commit is contained in:
ayn2op 2026-09-27 19:19:07 -04:00
parent 25bc35212e
commit f45dfd58ad
2 changed files with 8 additions and 9 deletions

View file

@ -257,7 +257,7 @@ func (m *Model) Update(msg tview.Msg) tview.Cmd {
case messageslist.Msg: case messageslist.Msg:
return m.messagesList.Update(msg) return m.messagesList.Update(msg)
case typingExpiredMsg: case typingExpiredMsg:
if m.typers[msg.userID] == msg.until { if until, ok := m.typers[msg.userID]; ok && !time.Now().Before(until) {
m.removeTyper(msg.userID) m.removeTyper(msg.userID)
} }
return nil return nil
@ -421,10 +421,9 @@ func (m *Model) View() tview.Element {
) )
} }
// typingExpiredMsg ends a user's typing indicator unless they typed again after it was set to expire at until. // typingExpiredMsg ends a user's typing indicator unless they typed again and extended it.
type typingExpiredMsg struct { type typingExpiredMsg struct {
userID discord.UserID userID discord.UserID
until time.Time
} }
func (m *Model) clearTypers() { func (m *Model) clearTypers() {
@ -434,12 +433,11 @@ func (m *Model) clearTypers() {
// addTyper shows userID as typing and returns a command that ends it after the typing duration. // 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 { func (m *Model) addTyper(userID discord.UserID) tview.Cmd {
until := time.Now().Add(composer.TypingDuration) m.typers[userID] = time.Now().Add(composer.TypingDuration)
m.typers[userID] = until
m.updateFooter() m.updateFooter()
return func() tview.Msg { return func() tview.Msg {
time.Sleep(composer.TypingDuration) time.Sleep(composer.TypingDuration)
return typingExpiredMsg{userID, until} return typingExpiredMsg{userID}
} }
} }

View file

@ -3,6 +3,7 @@ package chat
import ( import (
"path/filepath" "path/filepath"
"testing" "testing"
"time"
"github.com/ayn2op/arikawa/v3/discord" "github.com/ayn2op/arikawa/v3/discord"
"github.com/ayn2op/arikawa/v3/gateway" "github.com/ayn2op/arikawa/v3/gateway"
@ -41,13 +42,13 @@ func TestModelUpdate(t *testing.T) {
t.Run("typing expires unless extended", func(t *testing.T) { t.Run("typing expires unless extended", func(t *testing.T) {
m := newTestModel(t) m := newTestModel(t)
m.addTyper(1) m.addTyper(1)
first := m.typers[1]
m.addTyper(1) m.addTyper(1)
m.Update(typingExpiredMsg{userID: 1, until: first}) m.Update(typingExpiredMsg{userID: 1})
if _, ok := m.typers[1]; !ok { if _, ok := m.typers[1]; !ok {
t.Fatal("an extended typing indicator expired early") t.Fatal("an extended typing indicator expired early")
} }
m.Update(typingExpiredMsg{userID: 1, until: m.typers[1]}) m.typers[1] = time.Now().Add(-time.Second)
m.Update(typingExpiredMsg{userID: 1})
if _, ok := m.typers[1]; ok { if _, ok := m.typers[1]; ok {
t.Fatal("the typing indicator did not expire") t.Fatal("the typing indicator did not expire")
} }