fix(messageslist): fetch older messages once on scrolling to the top

This commit is contained in:
ayn2op 2026-10-15 16:24:34 -04:00
parent fa82fad291
commit 3562b4d546
4 changed files with 88 additions and 15 deletions

4
go.mod
View file

@ -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

8
go.sum
View file

@ -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=

View file

@ -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)

View file

@ -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)
}
}