refactor(ui/chat): use tview.ScrollList for messagesList (#708)

This commit is contained in:
Ayyan 2026-01-16 05:42:31 -05:00 • committed by GitHub
parent ef0b5bfe1f
commit 2015c7d9fe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 172 additions and 127 deletions

4
go.mod
View file

@ -7,7 +7,7 @@ go 1.25.3
require (
github.com/BurntSushi/toml v1.6.0
github.com/andybalholm/brotli v1.2.0
github.com/ayn2op/tview v0.0.0-20260109203839-9dc13ab4188e
github.com/ayn2op/tview v0.0.0-20260116090706-6950371b84c4
github.com/deckarep/gosx-notifier v0.0.0-20180201035817-e127226297fb
github.com/diamondburned/arikawa/v3 v3.6.1-0.20250928004212-a891a653eb26
github.com/diamondburned/ningen/v3 v3.0.1-0.20250920191746-98fbd92e134d
@ -16,7 +16,7 @@ require (
github.com/google/go-cmp v0.6.0
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/klauspost/compress v1.18.2
github.com/klauspost/compress v1.18.3
github.com/ncruces/zenity v0.10.14
github.com/sahilm/fuzzy v0.1.1
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e

8
go.sum
View file

@ -8,8 +8,8 @@ github.com/akavel/rsrc v0.10.2 h1:Zxm8V5eI1hW4gGaYsJQUhxpjkENuG91ki8B4zCrvEsw=
github.com/akavel/rsrc v0.10.2/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/ayn2op/tview v0.0.0-20260109203839-9dc13ab4188e h1:3BbJN6zXY3AskufYkkFerQF+n42MUyeYi6SLrqBaoJk=
github.com/ayn2op/tview v0.0.0-20260109203839-9dc13ab4188e/go.mod h1:i95d/64QBCxhpf8Q7ufI/YTNX8J0y/8sWTbwTH4tjkY=
github.com/ayn2op/tview v0.0.0-20260116090706-6950371b84c4 h1:+CTr6AUbwBPY7TcsDFFS9xkiX7W4RgV5/HYMP8bjeF8=
github.com/ayn2op/tview v0.0.0-20260116090706-6950371b84c4/go.mod h1:i95d/64QBCxhpf8Q7ufI/YTNX8J0y/8sWTbwTH4tjkY=
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@ -50,8 +50,8 @@ github.com/jackmordaunt/icns/v3 v3.0.1 h1:xxot6aNuGrU+lNgxz5I5H0qSeCjNKp8uTXB1j8
github.com/jackmordaunt/icns/v3 v3.0.1/go.mod h1:5sHL59nqTd2ynTnowxB/MDQFhKNqkK8X687uKNygaSQ=
github.com/josephspurrier/goversioninfo v1.5.0 h1:9TJtORoyf4YMoWSOo/cXFN9A/lB3PniJ91OxIH6e7Zg=
github.com/josephspurrier/goversioninfo v1.5.0/go.mod h1:6MoTvFZ6GKJkzcdLnU5T/RGYUbHQbKpYeNP0AgQLd2o=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=

View file

@ -220,7 +220,7 @@ func (mi *messageInput) send() {
mi.typingTimer = nil
}
mi.reset()
mi.chatView.messagesList.Highlight()
mi.chatView.messagesList.clearSelection()
mi.chatView.messagesList.ScrollToEnd()
}

View file

@ -34,10 +34,10 @@ import (
)
type messagesList struct {
*tview.TextView
cfg *config.Config
chatView *View
selectedMessageID discord.MessageID
*tview.ScrollList
cfg *config.Config
chatView *View
messages []discord.Message
renderer *markdown.Renderer
@ -51,29 +51,25 @@ type messagesList struct {
func newMessagesList(cfg *config.Config, chatView *View) *messagesList {
ml := &messagesList{
TextView: tview.NewTextView(),
cfg: cfg,
chatView: chatView,
renderer: markdown.NewRenderer(cfg.Theme.MessagesList),
ScrollList: tview.NewScrollList(),
cfg: cfg,
chatView: chatView,
renderer: markdown.NewRenderer(cfg.Theme.MessagesList),
}
ml.Box = ui.ConfigureBox(ml.Box, &cfg.Theme)
ml.
SetDynamicColors(true).
SetRegions(true).
SetWordWrap(true).
ScrollToEnd().
SetHighlightedFunc(ml.onHighlighted).
SetTitle("Messages").
SetInputCapture(ml.onInputCapture)
ml.SetTitle("Messages")
ml.SetBuilder(ml.buildItem)
ml.SetTrackEnd(true)
ml.SetInputCapture(ml.onInputCapture)
return ml
}
func (ml *messagesList) reset() {
ml.selectedMessageID = 0
ml.messages = nil
ml.
Clear().
Highlight().
SetBuilder(ml.buildItem).
SetTitle("")
}
@ -87,18 +83,60 @@ func (ml *messagesList) setTitle(channel discord.Channel) {
}
func (ml *messagesList) drawMessages(messages []discord.Message) {
writer := ml.BatchWriter()
defer writer.Close()
for _, m := range slices.Backward(messages) {
ml.drawMessage(writer, m)
}
ml.messages = slices.Grow(ml.messages[:0], len(messages))
ml.messages = ml.messages[:len(messages)]
copy(ml.messages, messages)
slices.Reverse(ml.messages)
}
func (ml *messagesList) drawMessage(writer io.Writer, message discord.Message) {
// Region tags are square brackets that contain a region ID in double quotes
// https://pkg.go.dev/github.com/ayn2op/tview#hdr-Regions_and_Highlights
fmt.Fprintf(writer, `["%s"]`, message.ID)
func (ml *messagesList) addMessage(message discord.Message) {
ml.messages = append(ml.messages, message)
}
func (ml *messagesList) setMessage(index int, message discord.Message) {
if index < 0 || index >= len(ml.messages) {
return
}
ml.messages[index] = message
}
func (ml *messagesList) deleteMessage(index int) {
if index < 0 || index >= len(ml.messages) {
return
}
ml.messages = append(ml.messages[:index], ml.messages[index+1:]...)
}
func (ml *messagesList) clearSelection() {
ml.SetCursor(-1)
}
func (ml *messagesList) buildItem(index int, cursor int) tview.ScrollListItem {
if index < 0 || index >= len(ml.messages) {
return nil
}
message := ml.messages[index]
tv := tview.NewTextView().
SetWrap(true).
SetWordWrap(true).
SetDynamicColors(true).
SetText(ml.renderMessage(message))
if index == cursor {
tv.SetTextStyle(tcell.StyleDefault.Reverse(true))
}
return tv
}
func (ml *messagesList) renderMessage(message discord.Message) string {
var b strings.Builder
ml.writeMessage(&b, message)
return b.String()
}
func (ml *messagesList) writeMessage(writer io.Writer, message discord.Message) {
if ml.cfg.HideBlockedUsers {
isBlocked := ml.chatView.state.UserIsBlocked(message.Author.ID)
if isBlocked {
@ -129,9 +167,6 @@ func (ml *messagesList) drawMessage(writer io.Writer, message discord.Message) {
ml.drawTimestamps(writer, message.Timestamp)
ml.drawAuthor(writer, message)
}
// Tags with no region ID ([""]) don't start new regions. They can therefore be used to mark the end of a region.
io.WriteString(writer, "[\"\"]\n")
}
func (ml *messagesList) formatTimestamp(ts discord.Timestamp) string {
@ -239,138 +274,117 @@ func (ml *messagesList) drawPinnedMessage(w io.Writer, message discord.Message)
}
func (ml *messagesList) selectedMessage() (*discord.Message, error) {
if !ml.selectedMessageID.IsValid() {
if len(ml.messages) == 0 {
return nil, errors.New("no messages available")
}
cursor := ml.Cursor()
if cursor == -1 || cursor >= len(ml.messages) {
return nil, errors.New("no message is currently selected")
}
selected := ml.chatView.SelectedChannel()
if selected == nil {
return nil, errors.New("no channel is currently selected")
}
m, err := ml.chatView.state.Cabinet.Message(selected.ID, ml.selectedMessageID)
if err != nil {
return nil, fmt.Errorf("failed to retrieve selected message: %w", err)
}
return m, nil
return &ml.messages[cursor], nil
}
func (ml *messagesList) onInputCapture(event *tcell.EventKey) *tcell.EventKey {
switch event.Name() {
case ml.cfg.Keys.MessagesList.ScrollUp:
return tcell.NewEventKey(tcell.KeyUp, "", tcell.ModNone)
ml.ScrollUp()
return nil
case ml.cfg.Keys.MessagesList.ScrollDown:
return tcell.NewEventKey(tcell.KeyDown, "", tcell.ModNone)
ml.ScrollDown()
return nil
case ml.cfg.Keys.MessagesList.ScrollTop:
return tcell.NewEventKey(tcell.KeyHome, "", tcell.ModNone)
ml.SetCursor(0)
return nil
case ml.cfg.Keys.MessagesList.ScrollBottom:
return tcell.NewEventKey(tcell.KeyEnd, "", tcell.ModNone)
ml.ScrollToEnd()
return nil
case ml.cfg.Keys.MessagesList.Cancel:
ml.selectedMessageID = 0
ml.Highlight()
ml.clearSelection()
return nil
case ml.cfg.Keys.MessagesList.SelectPrevious, ml.cfg.Keys.MessagesList.SelectNext, ml.cfg.Keys.MessagesList.SelectFirst, ml.cfg.Keys.MessagesList.SelectLast, ml.cfg.Keys.MessagesList.SelectReply:
ml._select(event.Name())
return nil
case ml.cfg.Keys.MessagesList.YankID:
ml.yankID()
return nil
case ml.cfg.Keys.MessagesList.YankContent:
ml.yankContent()
return nil
case ml.cfg.Keys.MessagesList.YankURL:
ml.yankURL()
return nil
case ml.cfg.Keys.MessagesList.Open:
ml.open()
return nil
case ml.cfg.Keys.MessagesList.Reply:
ml.reply(false)
return nil
case ml.cfg.Keys.MessagesList.ReplyMention:
ml.reply(true)
return nil
case ml.cfg.Keys.MessagesList.Edit:
ml.edit()
return nil
case ml.cfg.Keys.MessagesList.Delete:
ml.delete()
return nil
case ml.cfg.Keys.MessagesList.DeleteConfirm:
ml.confirmDelete()
return nil
}
return nil
return event
}
func (ml *messagesList) _select(name string) {
selectedChannel := ml.chatView.SelectedChannel()
if selectedChannel == nil {
return
}
messages, err := ml.chatView.state.Cabinet.Messages(selectedChannel.ID)
if err != nil {
slog.Error("failed to get messages", "err", err, "channel_id", selectedChannel.ID)
return
}
messages := ml.messages
if len(messages) == 0 {
return
}
messageIdx := slices.IndexFunc(messages, func(m discord.Message) bool {
return m.ID == ml.selectedMessageID
})
// Allow "no highlight yet" to fall through and pick the latest message.
if len(ml.GetHighlights()) != 0 && messageIdx == -1 {
return
}
cursor := ml.Cursor()
switch name {
case ml.cfg.Keys.MessagesList.SelectPrevious:
// If no message is currently selected, select the latest message.
if len(ml.GetHighlights()) == 0 {
ml.selectedMessageID = messages[0].ID
} else if messageIdx < len(messages)-1 {
ml.selectedMessageID = messages[messageIdx+1].ID
} else {
return
switch {
case cursor == -1:
cursor = len(messages) - 1
case cursor > 0:
cursor--
}
case ml.cfg.Keys.MessagesList.SelectNext:
// If no message is currently selected, select the latest message.
if len(ml.GetHighlights()) == 0 {
ml.selectedMessageID = messages[0].ID
} else if messageIdx > 0 {
ml.selectedMessageID = messages[messageIdx-1].ID
} else {
return
switch {
case cursor == -1:
cursor = len(messages) - 1
case cursor < len(messages)-1:
cursor++
}
case ml.cfg.Keys.MessagesList.SelectFirst:
ml.selectedMessageID = messages[len(messages)-1].ID
cursor = 0
case ml.cfg.Keys.MessagesList.SelectLast:
ml.selectedMessageID = messages[0].ID
cursor = len(messages) - 1
case ml.cfg.Keys.MessagesList.SelectReply:
if ml.selectedMessageID == 0 {
if cursor == -1 || cursor >= len(messages) {
return
}
if ref := messages[messageIdx].ReferencedMessage; ref != nil {
if ref := messages[cursor].ReferencedMessage; ref != nil {
refIdx := slices.IndexFunc(messages, func(m discord.Message) bool {
return m.ID == ref.ID
})
if refIdx != -1 {
ml.selectedMessageID = messages[refIdx].ID
cursor = refIdx
}
}
}
ml.Highlight(ml.selectedMessageID.String())
ml.ScrollToHighlight()
}
func (ml *messagesList) onHighlighted(added, removed, remaining []string) {
if len(added) > 0 {
id, err := discord.ParseSnowflake(added[0])
if err != nil {
slog.Error("failed to parse region id as int to use as message id", "err", err)
} else {
return
}
ml.selectedMessageID = discord.MessageID(id)
}
ml.SetCursor(cursor)
}
func (ml *messagesList) yankID() {
@ -545,17 +559,17 @@ func (ml *messagesList) openURL(url string) {
}
func (ml *messagesList) reply(mention bool) {
msg, err := ml.selectedMessage()
message, err := ml.selectedMessage()
if err != nil {
slog.Error("failed to get selected message", "err", err)
return
}
name := msg.Author.DisplayOrUsername()
if msg.GuildID.IsValid() {
member, err := ml.chatView.state.Cabinet.Member(msg.GuildID, msg.Author.ID)
name := message.Author.DisplayOrUsername()
if message.GuildID.IsValid() {
member, err := ml.chatView.state.Cabinet.Member(message.GuildID, message.Author.ID)
if err != nil {
slog.Error("failed to get member from state", "guild_id", msg.GuildID, "member_id", msg.Author.ID, "err", err)
slog.Error("failed to get member from state", "guild_id", message.GuildID, "member_id", message.Author.ID, "err", err)
} else {
if member.Nick != "" {
name = member.Nick
@ -564,7 +578,7 @@ func (ml *messagesList) reply(mention bool) {
}
data := ml.chatView.messageInput.sendMessageData
data.Reference = &discord.MessageReference{MessageID: ml.selectedMessageID}
data.Reference = &discord.MessageReference{MessageID: message.ID}
data.AllowedMentions = &api.AllowedMentions{RepliedUser: option.False}
title := "Replying to "
@ -646,16 +660,10 @@ func (ml *messagesList) delete() {
return
}
ml.selectedMessageID = 0
ml.Highlight()
if err := ml.chatView.state.MessageRemove(selected.ID, msg.ID); err != nil {
slog.Error("failed to delete message", "channel_id", selected.ID, "message_id", msg.ID, "err", err)
return
}
// No need to redraw messages after deletion, onMessageDelete will do
// its work after the event returns
}
func (ml *messagesList) requestGuildMembers(guildID discord.GuildID, messages []discord.Message) {

View file

@ -3,10 +3,12 @@ package chat
import (
"context"
"log/slog"
"slices"
"github.com/ayn2op/discordo/internal/http"
"github.com/ayn2op/discordo/internal/notifications"
"github.com/ayn2op/tview"
"github.com/diamondburned/arikawa/v3/discord"
"github.com/diamondburned/arikawa/v3/gateway"
"github.com/diamondburned/arikawa/v3/session"
"github.com/diamondburned/arikawa/v3/state"
@ -114,8 +116,9 @@ func (v *View) onMessageCreate(message *gateway.MessageCreateEvent) {
selectedChannel := v.SelectedChannel()
if selectedChannel != nil && selectedChannel.ID == message.ChannelID {
v.removeTyper(message.Author.ID)
v.messagesList.drawMessage(v.messagesList, message.Message)
v.app.Draw()
v.app.QueueUpdateDraw(func() {
v.messagesList.addMessage(message.Message)
})
} else {
if err := notifications.Notify(v.state, message, v.cfg); err != nil {
slog.Error("failed to notify", "err", err, "channel_id", message.ChannelID, "message_id", message.ID)
@ -125,21 +128,55 @@ func (v *View) onMessageCreate(message *gateway.MessageCreateEvent) {
func (v *View) onMessageUpdate(message *gateway.MessageUpdateEvent) {
if selected := v.SelectedChannel(); selected != nil && selected.ID == message.ChannelID {
v.onMessageDelete(&gateway.MessageDeleteEvent{ID: message.ID, ChannelID: message.ChannelID, GuildID: message.GuildID})
index := slices.IndexFunc(v.messagesList.messages, func(m discord.Message) bool {
return m.ID == message.ID
})
if index < 0 {
return
}
v.app.QueueUpdateDraw(func() {
v.messagesList.setMessage(index, message.Message)
})
}
}
func (v *View) onMessageDelete(message *gateway.MessageDeleteEvent) {
if selected := v.SelectedChannel(); selected != nil && selected.ID == message.ChannelID {
messages, err := v.state.Cabinet.Messages(message.ChannelID)
if err != nil {
slog.Error("failed to get messages from state", "err", err, "channel_id", message.ChannelID)
prevCursor := v.messagesList.Cursor()
deletedIndex := slices.IndexFunc(v.messagesList.messages, func(m discord.Message) bool {
return m.ID == message.ID
})
if deletedIndex < 0 {
return
}
v.messagesList.reset()
v.messagesList.drawMessages(messages)
v.app.Draw()
v.app.QueueUpdateDraw(func() {
v.messagesList.deleteMessage(deletedIndex)
})
// Keep cursor stable when possible after removal.
newCursor := prevCursor
if prevCursor == deletedIndex {
// Prefer previous item; fall forward if we deleted the first.
newCursor = deletedIndex - 1
if newCursor < 0 {
if deletedIndex < len(v.messagesList.messages) {
newCursor = deletedIndex
} else {
newCursor = -1
}
}
} else if prevCursor > deletedIndex {
// Shift back since the list shrank before the cursor.
newCursor = prevCursor - 1
}
if newCursor != prevCursor {
// Avoid redundant cursor updates if nothing changed.
v.app.QueueUpdateDraw(func() {
v.messagesList.SetCursor(newCursor)
})
}
}
}