From 3562b4d546900db958d3830721957689828c4a5a Mon Sep 17 00:00:00 2001 From: ayn2op Date: Thu, 15 Oct 2026 16:24:34 -0400 Subject: [PATCH] fix(messageslist): fetch older messages once on scrolling to the top --- go.mod | 4 +- go.sum | 8 +-- internal/ui/chat/messageslist/model.go | 34 ++++++++---- internal/ui/chat/messageslist/model_test.go | 57 +++++++++++++++++++++ 4 files changed, 88 insertions(+), 15 deletions(-) diff --git a/go.mod b/go.mod index 1d1c2bf..4cc0c82 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,7 @@ require ( github.com/andybalholm/brotli v1.2.6 github.com/ayn2op/arikawa/v3 v3.0.0-20261014035141-32b58d8f3755 github.com/ayn2op/ningen/v3 v3.0.1-0.20261014035153-5e43590a1426 - github.com/ayn2op/tview v0.0.0-20261013225942-7c4cbe85112a + github.com/ayn2op/tview v0.0.0-20261015202917-18bf0788a772 github.com/bogdanfinn/fhttp v0.6.9 github.com/bogdanfinn/tls-client v1.16.0 github.com/gdamore/tcell/v3 v3.6.0 @@ -60,7 +60,7 @@ require ( github.com/randall77/makefat v0.0.0-20260406194835-1b91746796b7 // indirect github.com/tam7t/hpkp v0.0.0-20160821193359-2b70b4024ed5 // indirect github.com/thomas-vilte/mls-go v1.6.0 // indirect - github.com/tphakala/simd v1.11.0 // indirect + github.com/tphakala/simd v1.12.0 // indirect github.com/twmb/murmur3 v1.2.1 // indirect go4.org v0.0.0-20260112195520-a5071408f32f // indirect golang.design/x/x11 v0.2.0 // indirect diff --git a/go.sum b/go.sum index 6d91b3d..3397fb7 100644 --- a/go.sum +++ b/go.sum @@ -14,8 +14,8 @@ github.com/ayn2op/arikawa/v3 v3.0.0-20261014035141-32b58d8f3755 h1:XHo9kizImaAEt github.com/ayn2op/arikawa/v3 v3.0.0-20261014035141-32b58d8f3755/go.mod h1:YARcqZmk7hDjXB+lexlXX4wDFHULafbGnF0gsD0tuNw= github.com/ayn2op/ningen/v3 v3.0.1-0.20261014035153-5e43590a1426 h1:0ndRWpAiSSgR0z9b7K+pwMlCHEk3VzQeS5ls+zWtbr0= github.com/ayn2op/ningen/v3 v3.0.1-0.20261014035153-5e43590a1426/go.mod h1:4S2B9frwrjELeAldoqPgasA/VfnOySgRlektwYLuVks= -github.com/ayn2op/tview v0.0.0-20261013225942-7c4cbe85112a h1:b92yhUuO4jsY8eTqTEEJO+SjpkIIngSzhnJA//FEw6M= -github.com/ayn2op/tview v0.0.0-20261013225942-7c4cbe85112a/go.mod h1:3aWH6S5uOqOkZv7JFePEVHukTj9hPb6Lbynm59Sks2E= +github.com/ayn2op/tview v0.0.0-20261015202917-18bf0788a772 h1:XXNOUzYpwH2S/Q5XA/kHJCkbz4HwXtM/Unh3HbhqRIw= +github.com/ayn2op/tview v0.0.0-20261015202917-18bf0788a772/go.mod h1:3aWH6S5uOqOkZv7JFePEVHukTj9hPb6Lbynm59Sks2E= github.com/bdandy/go-errors v1.2.2 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q= github.com/bdandy/go-errors v1.2.2/go.mod h1:NkYHl4Fey9oRRdbB1CoC6e84tuqQHiqrOcZpqFEkBxM= github.com/bdandy/go-socks4 v1.2.3 h1:Q6Y2heY1GRjCtHbmlKfnwrKVU/k81LS8mRGLRlmDlic= @@ -96,8 +96,8 @@ github.com/thomas-vilte/mls-go v1.6.0 h1:HGxrRe0kRfEAXu7MtTemNzqGmC/758rfGkarhYG github.com/thomas-vilte/mls-go v1.6.0/go.mod h1:Cxl407ecAW94wwYLOycpQUp0UyfBk6dNmvs1hkw2yLg= github.com/tphakala/go-opus v1.2.1 h1:j3DlcDf+9VTUzOEZ6kODoZoLMb/MTN2+ft24VuO8FaQ= github.com/tphakala/go-opus v1.2.1/go.mod h1:CzYWGAXjMJHIqu/aTowxgmut2D0+OFJCjQ9+YJHDJV0= -github.com/tphakala/simd v1.11.0 h1:3rtUU/6mO76R4rq3hQiEX+DC4yKc/twIGU62bkmIAYA= -github.com/tphakala/simd v1.11.0/go.mod h1:eO53GkWg40mRbr2tzs0Xb1IMZQ5U8cMR+Aa1RlXUinE= +github.com/tphakala/simd v1.12.0 h1:qjCO4f/ega4+2znt8bp+lb6R8a7bSri+/KpH+cGHOrA= +github.com/tphakala/simd v1.12.0/go.mod h1:eO53GkWg40mRbr2tzs0Xb1IMZQ5U8cMR+Aa1RlXUinE= github.com/twmb/murmur3 v1.2.1 h1:TJzZGygOc1VJ7jiBpy8m7KAAzm67zy1jvkUxxEjJp+g= github.com/twmb/murmur3 v1.2.1/go.mod h1:LVqs/avIy0flspVtmBd4VSbogDepFl6Wkj0i0ToccHE= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= diff --git a/internal/ui/chat/messageslist/model.go b/internal/ui/chat/messageslist/model.go index b8e09ad..a471154 100644 --- a/internal/ui/chat/messageslist/model.go +++ b/internal/ui/chat/messageslist/model.go @@ -128,7 +128,8 @@ func (m Model) listView(focused bool) viewport.Widget { return viewport.New(l, m.scrollState). Target(l.Target(m.target)). ScrollBar(bar, cfg.Visibility.ScrollBarVisibility). - TrackEnd(true). + // Older messages are added above, so the position is kept from the newest, which also keeps the newest in view. + AnchorBottom(). Keybind(ui.ScrollKeybind(m.cfg.Keybinds.MessagesList.ScrollKeybinds)). Focused(focused). OnChange(func(s *viewport.ScrollState) tview.Msg { return scrollMsg(*s) }) @@ -359,7 +360,7 @@ func (m Model) renderMessage(message discord.Message, baseStyle tview.Style, wid // rebuildItems replaces date separators while retaining message views and selection. func (m *Model) rebuildItems() { items := make([]messageItem, 0, len(m.items)) - cursor, selected := m.cursor, -1 + cursor, target := -1, -1 var previous discord.Timestamp for i, item := range m.items { if item.separator { @@ -368,14 +369,18 @@ func (m *Model) rebuildItems() { if m.cfg.DateSeparator.Enabled && (len(items) == 0 || !sameLocalDate(previous, item.message.Timestamp)) { items = append(items, messageItem{separator: true}) } - if i == cursor { - selected = len(items) + if i == m.cursor { + cursor = len(items) + } + if i == m.target { + target = len(items) } items = append(items, item) previous = item.message.Timestamp } m.items = items - m.setCursor(selected) + // The selected and target messages only move with the separators, so they are not scrolled to. + m.cursor, m.target = cursor, target } func sameLocalDate(a discord.Timestamp, b discord.Timestamp) bool { @@ -1014,15 +1019,17 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) { if selectedChannel == nil || selectedChannel.ID != msg.ChannelID { return m, nil } - prevCursor := m.cursor - older := make([]messageItem, len(msg.Older)) for i, message := range msg.Older { older[i] = messageItem{message: message} } m.items = slices.Concat(older, m.items) - if prevCursor >= 0 { - m.setCursor(prevCursor + len(older)) + // The selected and target messages stay the same, so they are not scrolled to. + if m.cursor >= 0 { + m.cursor += len(older) + } + if m.target >= 0 { + m.target += len(older) } m.rebuildItems() if selectedChannel.GuildID.IsValid() { @@ -1093,6 +1100,10 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) { } case scrollMsg: m.scrollState = viewport.ScrollState(msg) + // Older messages are fetched once as the list is scrolled to the top, rather than on each scroll there. + if !scrolledToTop(m.scrollState.Origin()) && scrolledToTop(m.scrollState.Viewport()) { + return m, m.fetchOlderMessages() + } case listMsg: top := m.atTop() m.cursor, m.target = int(msg), int(msg) @@ -1104,6 +1115,11 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) { return m, nil } +// scrolledToTop reports whether v is scrolled to the oldest message shown, which is false while the messages fit, as there is nothing to scroll. +func scrolledToTop(v viewport.Viewport) bool { + return v.Content().Height > v.Bounds().Height && v.AbsoluteOffsetReversed().Y == 0 +} + // atTop reports whether the oldest message shown is selected. func (m Model) atTop() bool { return m.cursor >= 0 && m.cursor == m.messageIndex(-1, 1) diff --git a/internal/ui/chat/messageslist/model_test.go b/internal/ui/chat/messageslist/model_test.go index 49e5fe4..acdcf16 100644 --- a/internal/ui/chat/messageslist/model_test.go +++ b/internal/ui/chat/messageslist/model_test.go @@ -13,6 +13,7 @@ import ( "github.com/ayn2op/ningen/v3" "github.com/ayn2op/tview" "github.com/ayn2op/tview/layout" + "github.com/gdamore/tcell/v3" ) func testConfig(t *testing.T) *config.Config { @@ -246,3 +247,59 @@ func TestMessageViewLayout(t *testing.T) { } } } + +func TestModelScrollToTop(t *testing.T) { + ml := newTestModel(t, testConfig(t)) + messages := make([]discord.Message, 20) + for i := range messages { + messages[i] = discord.Message{ID: discord.MessageID(len(messages) + 10 - i), Content: "hi"} + } + ml.setMessages(messages) + area := tview.Rectangle{Width: 40, Height: 5} + up := tview.MouseMsg{EventMouse: tcell.NewEventMouse(0, 0, tcell.ButtonNone, tcell.ModNone), Action: tview.MouseScrollUp} + // scrollUp scrolls up a row and returns what fetching older messages that does, and whether it scrolled. + scrollUp := func() (tview.Cmd, bool) { + t.Helper() + msg, ok := ml.listView(true).Handle(up, area).(scrollMsg) + if !ok { + return nil, false + } + var cmd tview.Cmd + *ml, cmd = ml.Update(msg) + return cmd, true + } + // scrollToTop scrolls to the top and returns how many times older messages were fetched on the way. + scrollToTop := func() (fetches int) { + t.Helper() + for { + cmd, scrolled := scrollUp() + if !scrolled { + return fetches + } + if cmd != nil { + fetches++ + } + } + } + + if fetches := scrollToTop(); fetches != 1 { + t.Fatalf("scrolling to the top fetched %d times, want once", fetches) + } + if cmd, _ := scrollUp(); cmd != nil { + t.Fatal("older messages were fetched again while at the top") + } + + older := []discord.Message{{ID: 1, Content: "hi"}, {ID: 2, Content: "hi"}} + *ml, _ = ml.Update(olderMessagesLoadedMsg{ChannelID: 1, Older: older}) + cmd, _ := scrollUp() + if cmd != nil { + t.Fatal("older messages were fetched again before scrolling to the new top") + } + if ml.scrollState.Viewport().AbsoluteOffsetReversed().Y == 0 { + t.Fatal("the older messages were scrolled into view rather than added above it") + } + + if fetches := scrollToTop(); fetches != 1 { + t.Fatalf("scrolling to the new top fetched %d times, want once", fetches) + } +}