feat(messageslist)!: group consecutive messages by the same author

This commit is contained in:
ayn2op 2026-10-15 17:59:24 -04:00
parent 3562b4d546
commit 60aa5f52f1
6 changed files with 168 additions and 38 deletions

View file

@ -361,9 +361,9 @@ Default: `"box_drawing"`
Default: `false`
### ui.messages_list.gap
### ui.messages_list.spacing
The number of empty rows between messages.
The number of empty rows between messages, except those of a group.
Default: `1`
@ -412,6 +412,18 @@ The number of rows an avatar takes. It is recommended to use `1` with compact.
Default: `2`
### ui.messages_list.grouping
#### ui.messages_list.grouping.enabled
Default: `true`
#### ui.messages_list.grouping.interval
The longest time between two messages by the same author for them to share a header.
Default: `"10m0s"`
## ui.mentions_list
The list of mention suggestions above the composer. Its width and height are capped to the available space.

2
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-20261015202917-18bf0788a772
github.com/ayn2op/tview v0.0.0-20261015215920-c33abb9e4eb2
github.com/bogdanfinn/fhttp v0.6.9
github.com/bogdanfinn/tls-client v1.16.0
github.com/gdamore/tcell/v3 v3.6.0

4
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-20261015202917-18bf0788a772 h1:XXNOUzYpwH2S/Q5XA/kHJCkbz4HwXtM/Unh3HbhqRIw=
github.com/ayn2op/tview v0.0.0-20261015202917-18bf0788a772/go.mod h1:3aWH6S5uOqOkZv7JFePEVHukTj9hPb6Lbynm59Sks2E=
github.com/ayn2op/tview v0.0.0-20261015215920-c33abb9e4eb2 h1:uGLp4qH9uegcYax3fruZ+FyadoXZpEIyYova0gK8nkM=
github.com/ayn2op/tview v0.0.0-20261015215920-c33abb9e4eb2/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=

View file

@ -2,6 +2,7 @@ package config
import (
"fmt"
"time"
"github.com/ayn2op/tview"
"github.com/ayn2op/tview/scrollbar"
@ -168,14 +169,22 @@ type (
MessagesListUIConfig struct {
Compact bool `toml:"compact"`
// The number of empty rows between messages.
Gap uint8 `toml:"gap"`
// The number of empty rows between messages, except those of a group.
Spacing uint8 `toml:"spacing"`
ReplyIndicator string `toml:"reply_indicator"`
ForwardedIndicator string `toml:"forwarded_indicator"`
Attachments AttachmentsUIConfig `toml:"attachments"`
Avatars AvatarsUIConfig `toml:"avatars"`
Grouping GroupingUIConfig `toml:"grouping"`
}
// GroupingUIConfig shows consecutive messages by the same author under one header.
GroupingUIConfig struct {
Enabled bool `toml:"enabled"`
// The longest time between two messages by the same author for them to share a header.
Interval time.Duration `toml:"interval"`
}
AvatarsUIConfig struct {
@ -221,7 +230,7 @@ func defaultUI() UIConfig {
GuildsTree: GuildsTreeUIConfig{TreeUIConfig{Graphics: true}, true},
MembersTree: TreeUIConfig{Graphics: true},
ScrollBar: ScrollBarUIConfig{SymbolSet: SymbolSetWrapper{boxDrawing}, Visibility: ScrollBarVisibilityWrapper{viewport.ScrollBarVisibilityAutomatic}},
MessagesList: MessagesListUIConfig{Gap: 1, ReplyIndicator: ">", ForwardedIndicator: "<", Attachments: attachments, Avatars: AvatarsUIConfig{Preview: true, Height: 2}},
MessagesList: MessagesListUIConfig{Spacing: 1, ReplyIndicator: ">", ForwardedIndicator: "<", Attachments: attachments, Avatars: AvatarsUIConfig{Preview: true, Height: 2}, Grouping: GroupingUIConfig{Enabled: true, Interval: 10 * time.Minute}},
MentionsList: MentionsListUIConfig{MinWidth: 20},
}
}

View file

@ -35,6 +35,7 @@ import (
"github.com/ayn2op/tview/row"
"github.com/ayn2op/tview/scrollbar"
"github.com/ayn2op/tview/slider"
"github.com/ayn2op/tview/spacer"
"github.com/ayn2op/tview/text"
"github.com/ayn2op/tview/textview"
"github.com/ayn2op/tview/viewport"
@ -50,6 +51,8 @@ type messageItem struct {
text, reply *messageView
// A separator shows the date of the message after it.
separator bool
// grouped reports whether the message is drawn without a header, under that of the message before it.
grouped bool
// A preview is blank while loading and empty if loading failed.
previews map[discord.URL]tviewimage.Widget
@ -122,7 +125,13 @@ func (m Model) listView(focused bool) viewport.Widget {
ThumbStyle(theme.ThumbStyle.Style)
// The model moves the selection itself, so the list is not focused and only takes clicks.
l := list.New(m.cursor, len(m.items), m.buildItem).
Gap(int(m.cfg.UI.MessagesList.Gap)).
// A group's messages have no spacing between them.
Spacing(func(index int) int {
if m.items[index].grouped {
return 0
}
return int(m.cfg.UI.MessagesList.Spacing)
}).
SelectedStyle(m.cfg.Theme.MessagesList.SelectedMessageStyle.Style).
OnChange(func(cursor int) tview.Msg { return listMsg(cursor) })
return viewport.New(l, m.scrollState).
@ -206,10 +215,12 @@ func (m *Model) setMessages(messages []discord.Message) {
func (m *Model) AddMessage(message discord.Message) tview.Cmd {
// Appending keeps the items before it as they are, so only the separator of a new day is added rather than all rebuilt.
if m.cfg.DateSeparator.Enabled && (len(m.items) == 0 || !sameLocalDate(m.items[len(m.items)-1].message.Timestamp, message.Timestamp)) {
last := len(m.items) - 1
separator := m.cfg.DateSeparator.Enabled && (last < 0 || !sameLocalDate(m.items[last].message.Timestamp, message.Timestamp))
if separator {
m.items = append(m.items, messageItem{separator: true})
}
m.items = append(m.items, messageItem{message: message})
m.items = append(m.items, messageItem{message: message, grouped: last >= 0 && !separator && m.grouped(m.items[last].message, message)})
return m.loadPreviews()
}
@ -258,7 +269,8 @@ func (m Model) buildItem(index int) tview.Widget {
message, style := item.message, m.cfg.Theme.MessagesList.MessageStyle.Style
if item.text == nil {
item.text = &messageView{render: func(width int) richtext.Text { return m.renderMessage(message, style, width) }}
grouped := item.grouped
item.text = &messageView{render: func(width int) richtext.Text { return m.renderMessage(message, style, width, grouped) }}
item.reply = &messageView{render: func(int) richtext.Text {
builder := new(richtext.Builder)
m.drawReply(builder, message, style)
@ -278,7 +290,13 @@ func (m Model) buildItem(index int) tview.Widget {
}
if avatar, ok := m.avatars[avatarURL(message.Author)]; ok {
body = row.New(avatar, body).Spacing(1).Height(layout.Shrink)
var gutter tview.Widget = avatar
// A grouped message leaves the avatar's column empty, under the avatar of the group.
if item.grouped {
width, _ := avatar.Size()
gutter = spacer.New().Width(width)
}
body = row.New(gutter, body).Spacing(1).Height(layout.Shrink)
}
if message.Type == discord.InlinedReplyMessage && !m.blocked(message.Author) {
@ -351,24 +369,46 @@ func (d dateSeparator) Draw(screen tview.Screen, area tview.Rectangle) {
func (dateSeparator) Handle(msg tview.Msg, area tview.Rectangle) tview.Msg { return msg }
func (m Model) renderMessage(message discord.Message, baseStyle tview.Style, width int) richtext.Text {
func (m Model) renderMessage(message discord.Message, baseStyle tview.Style, width int, grouped bool) richtext.Text {
builder := new(richtext.Builder)
m.writeMessage(builder, message, baseStyle, width)
return builder.Finish()
m.writeMessage(builder, message, baseStyle, width, grouped)
text := builder.Finish()
// Without a header or content, the first line is the empty one that attachments and embeds start below.
if len(text) > 1 && len(text[0]) == 0 {
text = text[1:]
}
return text
}
// rebuildItems replaces date separators while retaining message views and selection.
// grouped reports whether message is drawn under the header of previous, the message before it.
func (m Model) grouped(previous, message discord.Message) bool {
cfg := m.cfg.UI.MessagesList.Grouping
return cfg.Enabled &&
// Replies and system messages start a group, as their header says more than who wrote them.
message.Type == discord.DefaultMessage &&
(previous.Type == discord.DefaultMessage || previous.Type == discord.InlinedReplyMessage) &&
// Webhooks post under one ID with a name of each message's own.
message.Author.ID == previous.Author.ID && message.Author.Username == previous.Author.Username &&
message.Timestamp.Time().Sub(previous.Timestamp.Time()) <= cfg.Interval
}
// rebuildItems replaces date separators and groups while retaining message views and selection.
func (m *Model) rebuildItems() {
items := make([]messageItem, 0, len(m.items))
cursor, target := -1, -1
var previous discord.Timestamp
var previous discord.Message
for i, item := range m.items {
if item.separator {
continue
}
if m.cfg.DateSeparator.Enabled && (len(items) == 0 || !sameLocalDate(previous, item.message.Timestamp)) {
separator := m.cfg.DateSeparator.Enabled && (len(items) == 0 || !sameLocalDate(previous.Timestamp, item.message.Timestamp))
if separator {
items = append(items, messageItem{separator: true})
}
// A message that joins or leaves a group is drawn again, with or without its header.
if grouped := len(items) > 0 && !separator && m.grouped(previous, item.message); grouped != item.grouped {
item.grouped, item.text = grouped, nil
}
if i == m.cursor {
cursor = len(items)
}
@ -376,7 +416,7 @@ func (m *Model) rebuildItems() {
target = len(items)
}
items = append(items, item)
previous = item.message.Timestamp
previous = item.message
}
m.items = items
// The selected and target messages only move with the separators, so they are not scrolled to.
@ -410,7 +450,7 @@ func (m *Model) onRowCursorChanged(index int) {
m.setCursor(target)
}
func (m Model) writeMessage(builder *richtext.Builder, message discord.Message, baseStyle tview.Style, width int) {
func (m Model) writeMessage(builder *richtext.Builder, message discord.Message, baseStyle tview.Style, width int, grouped bool) {
if m.blocked(message.Author) {
builder.Write("Blocked message", baseStyle.Foreground(color.Red).Bold(true))
return
@ -419,18 +459,18 @@ func (m Model) writeMessage(builder *richtext.Builder, message discord.Message,
switch message.Type {
case discord.DefaultMessage:
if message.Reference != nil && message.Reference.Type == discord.MessageReferenceTypeForward {
m.drawForwardedMessage(builder, message, baseStyle)
m.drawForwardedMessage(builder, message, baseStyle, grouped)
} else {
m.drawDefaultMessage(builder, message, baseStyle, width)
m.drawDefaultMessage(builder, message, baseStyle, width, grouped)
}
case discord.GuildMemberJoinMessage:
m.drawLayout(builder, message, baseStyle, func() { builder.Write("joined the server.", baseStyle) })
m.drawLayout(builder, message, baseStyle, false, func() { builder.Write("joined the server.", baseStyle) })
case discord.InlinedReplyMessage:
m.drawDefaultMessage(builder, message, baseStyle, width)
m.drawDefaultMessage(builder, message, baseStyle, width, false)
case discord.ChannelPinnedMessage:
m.drawPinnedMessage(builder, message, baseStyle)
default:
m.drawLayout(builder, message, baseStyle, nil)
m.drawLayout(builder, message, baseStyle, false, nil)
}
m.drawReactions(builder, message.Reactions, baseStyle)
}
@ -464,24 +504,29 @@ func (m Model) formatTimestamp(ts discord.Timestamp) string {
const editedIndicator = " (edited)"
func (m Model) drawLayout(builder *richtext.Builder, message discord.Message, baseStyle tview.Style, content func()) {
// drawLayout draws the header of message, unless it is grouped, and its content.
func (m Model) drawLayout(builder *richtext.Builder, message discord.Message, baseStyle tview.Style, grouped bool, content func()) {
if m.cfg.UI.MessagesList.Compact {
m.drawCompact(builder, message, baseStyle, content)
m.drawCompact(builder, message, baseStyle, grouped, content)
return
}
m.drawCozy(builder, message, baseStyle, content)
m.drawCozy(builder, message, baseStyle, grouped, content)
}
func (m Model) drawCompact(builder *richtext.Builder, message discord.Message, baseStyle tview.Style, content func()) {
func (m Model) drawCompact(builder *richtext.Builder, message discord.Message, baseStyle tview.Style, grouped bool, content func()) {
dimStyle := baseStyle.Dim(true)
if m.cfg.Timestamps.Enabled {
builder.Write(m.formatTimestamp(message.Timestamp)+" ", dimStyle)
}
m.drawAuthor(builder, message, baseStyle)
if !grouped {
m.drawAuthor(builder, message, baseStyle)
if content != nil {
builder.Write(" ", baseStyle)
}
}
if content != nil {
builder.Write(" ", baseStyle)
content()
}
@ -490,8 +535,18 @@ func (m Model) drawCompact(builder *richtext.Builder, message discord.Message, b
}
}
func (m Model) drawCozy(builder *richtext.Builder, message discord.Message, baseStyle tview.Style, content func()) {
func (m Model) drawCozy(builder *richtext.Builder, message discord.Message, baseStyle tview.Style, grouped bool, content func()) {
dimStyle := baseStyle.Dim(true)
if grouped {
if content != nil {
content()
}
if message.EditedTimestamp.IsValid() {
builder.Write(editedIndicator, dimStyle)
}
return
}
m.drawAuthor(builder, message, baseStyle)
if m.cfg.Timestamps.Enabled {
builder.Write(" "+m.formatTimestamp(message.Timestamp), dimStyle)
@ -607,7 +662,7 @@ func (m Model) drawSnapshotContent(builder *richtext.Builder, parent discord.Mes
m.drawContent(builder, message, baseStyle)
}
func (m Model) drawDefaultMessage(builder *richtext.Builder, message discord.Message, baseStyle tview.Style, width int) {
func (m Model) drawDefaultMessage(builder *richtext.Builder, message discord.Message, baseStyle tview.Style, width int, grouped bool) {
var contentRoot ast.Node
var contentSource []byte
content := func() { contentRoot, contentSource = m.drawContent(builder, message, baseStyle) }
@ -615,7 +670,7 @@ func (m Model) drawDefaultMessage(builder *richtext.Builder, message discord.Mes
content = nil
}
m.drawLayout(builder, message, baseStyle, content)
m.drawLayout(builder, message, baseStyle, grouped, content)
m.drawEmbeds(builder, message, baseStyle, width, contentRoot, contentSource)
attachmentStyle := tview.MergeStyle(baseStyle, m.cfg.Theme.MessagesList.AttachmentStyle.Style)
@ -930,9 +985,9 @@ func isMarkdownEscapable(c byte) bool {
}
}
func (m Model) drawForwardedMessage(builder *richtext.Builder, message discord.Message, baseStyle tview.Style) {
func (m Model) drawForwardedMessage(builder *richtext.Builder, message discord.Message, baseStyle tview.Style, grouped bool) {
dimStyle := baseStyle.Dim(true)
m.drawLayout(builder, message, baseStyle, func() {
m.drawLayout(builder, message, baseStyle, grouped, func() {
builder.Write(m.cfg.UI.MessagesList.ForwardedIndicator+" ", dimStyle)
m.drawSnapshotContent(builder, message, message.MessageSnapshots[0].Message, baseStyle)
builder.Write(" ("+m.formatTimestamp(message.MessageSnapshots[0].Message.Timestamp)+") ", dimStyle)

View file

@ -6,6 +6,7 @@ import (
"slices"
"strings"
"testing"
"time"
"github.com/ayn2op/arikawa/v3/discord"
"github.com/ayn2op/arikawa/v3/state"
@ -219,7 +220,7 @@ func TestModelDrawLayout(t *testing.T) {
cfg.UI.MessagesList.Compact = compact
var got strings.Builder
for _, line := range ml.renderMessage(message, tview.Style{}, 80) {
for _, line := range ml.renderMessage(message, tview.Style{}, 80, false) {
for _, segment := range line {
got.WriteString(segment.Text)
}
@ -233,6 +234,59 @@ func TestModelDrawLayout(t *testing.T) {
}
}
func TestModelGrouped(t *testing.T) {
start := time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC)
message := func(author string, after time.Duration, typ discord.MessageType) discord.Message {
return discord.Message{Type: typ, Author: discord.User{ID: 2, Username: author}, Timestamp: discord.NewTimestamp(start.Add(after)), Content: "hi"}
}
previous := message("a", 0, discord.DefaultMessage)
for _, tt := range []struct {
name string
previous discord.Message
message discord.Message
want bool
}{
{"same author", previous, message("a", 10*time.Minute, discord.DefaultMessage), true},
{"after a reply", message("a", 0, discord.InlinedReplyMessage), message("a", time.Minute, discord.DefaultMessage), true},
{"over the interval", previous, message("a", 10*time.Minute+time.Second, discord.DefaultMessage), false},
{"renamed webhook", previous, message("b", time.Minute, discord.DefaultMessage), false},
{"reply", previous, message("a", time.Minute, discord.InlinedReplyMessage), false},
{"after a system message", message("a", 0, discord.ChannelPinnedMessage), message("a", time.Minute, discord.DefaultMessage), false},
} {
t.Run(tt.name, func(t *testing.T) {
if got := newTestModel(t, testConfig(t)).grouped(tt.previous, tt.message); got != tt.want {
t.Fatalf("grouped = %v, want %v", got, tt.want)
}
})
}
cfg := testConfig(t)
cfg.Timestamps.Enabled = false
ml := newTestModel(t, cfg)
if cfg.UI.MessagesList.Grouping.Enabled = false; ml.grouped(previous, previous) {
t.Fatal("grouped while disabled")
}
cfg.UI.MessagesList.Grouping.Enabled = true
// Newest first, as Discord sends them.
ml.setMessages([]discord.Message{message("a", 2*time.Minute, discord.DefaultMessage), message("a", time.Minute, discord.DefaultMessage), previous})
newest := ml.messageIndex(len(ml.items), -1)
if !ml.items[newest].grouped || ml.items[ml.messageIndex(-1, 1)].grouped {
t.Fatal("only the first message has a header")
}
if got := ml.renderMessage(ml.items[newest].message, tview.Style{}, 80, true); len(got) != 1 || got[0][0].Text != "hi" {
t.Fatalf("grouped message = %v, want only its content", got)
}
second := ml.messageIndex(newest, -1)
ml.buildItem(second)
ml.deleteMessage(ml.messageIndex(-1, 1))
if first := ml.messageIndex(-1, 1); ml.items[first].grouped || ml.items[first].text != nil {
t.Fatal("deleting the first message did not give the next its header")
}
if ml.AddMessage(message("a", 3*time.Minute, discord.DefaultMessage)); !ml.items[len(ml.items)-1].grouped {
t.Fatal("added message was not grouped")
}
}
func TestMessageViewLayout(t *testing.T) {
cfg := testConfig(t)
cfg.UI.MessagesList.Compact = true