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` 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` Default: `1`
@ -412,6 +412,18 @@ The number of rows an avatar takes. It is recommended to use `1` with compact.
Default: `2` 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 ## ui.mentions_list
The list of mention suggestions above the composer. Its width and height are capped to the available space. 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/andybalholm/brotli v1.2.6
github.com/ayn2op/arikawa/v3 v3.0.0-20261014035141-32b58d8f3755 github.com/ayn2op/arikawa/v3 v3.0.0-20261014035141-32b58d8f3755
github.com/ayn2op/ningen/v3 v3.0.1-0.20261014035153-5e43590a1426 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/fhttp v0.6.9
github.com/bogdanfinn/tls-client v1.16.0 github.com/bogdanfinn/tls-client v1.16.0
github.com/gdamore/tcell/v3 v3.6.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/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 h1:0ndRWpAiSSgR0z9b7K+pwMlCHEk3VzQeS5ls+zWtbr0=
github.com/ayn2op/ningen/v3 v3.0.1-0.20261014035153-5e43590a1426/go.mod h1:4S2B9frwrjELeAldoqPgasA/VfnOySgRlektwYLuVks= 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-20261015215920-c33abb9e4eb2 h1:uGLp4qH9uegcYax3fruZ+FyadoXZpEIyYova0gK8nkM=
github.com/ayn2op/tview v0.0.0-20261015202917-18bf0788a772/go.mod h1:3aWH6S5uOqOkZv7JFePEVHukTj9hPb6Lbynm59Sks2E= 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 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q=
github.com/bdandy/go-errors v1.2.2/go.mod h1:NkYHl4Fey9oRRdbB1CoC6e84tuqQHiqrOcZpqFEkBxM= github.com/bdandy/go-errors v1.2.2/go.mod h1:NkYHl4Fey9oRRdbB1CoC6e84tuqQHiqrOcZpqFEkBxM=
github.com/bdandy/go-socks4 v1.2.3 h1:Q6Y2heY1GRjCtHbmlKfnwrKVU/k81LS8mRGLRlmDlic= github.com/bdandy/go-socks4 v1.2.3 h1:Q6Y2heY1GRjCtHbmlKfnwrKVU/k81LS8mRGLRlmDlic=

View file

@ -2,6 +2,7 @@ package config
import ( import (
"fmt" "fmt"
"time"
"github.com/ayn2op/tview" "github.com/ayn2op/tview"
"github.com/ayn2op/tview/scrollbar" "github.com/ayn2op/tview/scrollbar"
@ -168,14 +169,22 @@ type (
MessagesListUIConfig struct { MessagesListUIConfig struct {
Compact bool `toml:"compact"` Compact bool `toml:"compact"`
// The number of empty rows between messages. // The number of empty rows between messages, except those of a group.
Gap uint8 `toml:"gap"` Spacing uint8 `toml:"spacing"`
ReplyIndicator string `toml:"reply_indicator"` ReplyIndicator string `toml:"reply_indicator"`
ForwardedIndicator string `toml:"forwarded_indicator"` ForwardedIndicator string `toml:"forwarded_indicator"`
Attachments AttachmentsUIConfig `toml:"attachments"` Attachments AttachmentsUIConfig `toml:"attachments"`
Avatars AvatarsUIConfig `toml:"avatars"` 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 { AvatarsUIConfig struct {
@ -221,7 +230,7 @@ func defaultUI() UIConfig {
GuildsTree: GuildsTreeUIConfig{TreeUIConfig{Graphics: true}, true}, GuildsTree: GuildsTreeUIConfig{TreeUIConfig{Graphics: true}, true},
MembersTree: TreeUIConfig{Graphics: true}, MembersTree: TreeUIConfig{Graphics: true},
ScrollBar: ScrollBarUIConfig{SymbolSet: SymbolSetWrapper{boxDrawing}, Visibility: ScrollBarVisibilityWrapper{viewport.ScrollBarVisibilityAutomatic}}, 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}, MentionsList: MentionsListUIConfig{MinWidth: 20},
} }
} }

View file

@ -35,6 +35,7 @@ import (
"github.com/ayn2op/tview/row" "github.com/ayn2op/tview/row"
"github.com/ayn2op/tview/scrollbar" "github.com/ayn2op/tview/scrollbar"
"github.com/ayn2op/tview/slider" "github.com/ayn2op/tview/slider"
"github.com/ayn2op/tview/spacer"
"github.com/ayn2op/tview/text" "github.com/ayn2op/tview/text"
"github.com/ayn2op/tview/textview" "github.com/ayn2op/tview/textview"
"github.com/ayn2op/tview/viewport" "github.com/ayn2op/tview/viewport"
@ -50,6 +51,8 @@ type messageItem struct {
text, reply *messageView text, reply *messageView
// A separator shows the date of the message after it. // A separator shows the date of the message after it.
separator bool 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. // A preview is blank while loading and empty if loading failed.
previews map[discord.URL]tviewimage.Widget previews map[discord.URL]tviewimage.Widget
@ -122,7 +125,13 @@ func (m Model) listView(focused bool) viewport.Widget {
ThumbStyle(theme.ThumbStyle.Style) ThumbStyle(theme.ThumbStyle.Style)
// The model moves the selection itself, so the list is not focused and only takes clicks. // 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). 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). SelectedStyle(m.cfg.Theme.MessagesList.SelectedMessageStyle.Style).
OnChange(func(cursor int) tview.Msg { return listMsg(cursor) }) OnChange(func(cursor int) tview.Msg { return listMsg(cursor) })
return viewport.New(l, m.scrollState). 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 { 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. // 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{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() 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 message, style := item.message, m.cfg.Theme.MessagesList.MessageStyle.Style
if item.text == nil { 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 { item.reply = &messageView{render: func(int) richtext.Text {
builder := new(richtext.Builder) builder := new(richtext.Builder)
m.drawReply(builder, message, style) 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 { 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) { 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 (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) builder := new(richtext.Builder)
m.writeMessage(builder, message, baseStyle, width) m.writeMessage(builder, message, baseStyle, width, grouped)
return builder.Finish() 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() { func (m *Model) rebuildItems() {
items := make([]messageItem, 0, len(m.items)) items := make([]messageItem, 0, len(m.items))
cursor, target := -1, -1 cursor, target := -1, -1
var previous discord.Timestamp var previous discord.Message
for i, item := range m.items { for i, item := range m.items {
if item.separator { if item.separator {
continue 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}) 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 { if i == m.cursor {
cursor = len(items) cursor = len(items)
} }
@ -376,7 +416,7 @@ func (m *Model) rebuildItems() {
target = len(items) target = len(items)
} }
items = append(items, item) items = append(items, item)
previous = item.message.Timestamp previous = item.message
} }
m.items = items m.items = items
// The selected and target messages only move with the separators, so they are not scrolled to. // 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) 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) { if m.blocked(message.Author) {
builder.Write("Blocked message", baseStyle.Foreground(color.Red).Bold(true)) builder.Write("Blocked message", baseStyle.Foreground(color.Red).Bold(true))
return return
@ -419,18 +459,18 @@ func (m Model) writeMessage(builder *richtext.Builder, message discord.Message,
switch message.Type { switch message.Type {
case discord.DefaultMessage: case discord.DefaultMessage:
if message.Reference != nil && message.Reference.Type == discord.MessageReferenceTypeForward { if message.Reference != nil && message.Reference.Type == discord.MessageReferenceTypeForward {
m.drawForwardedMessage(builder, message, baseStyle) m.drawForwardedMessage(builder, message, baseStyle, grouped)
} else { } else {
m.drawDefaultMessage(builder, message, baseStyle, width) m.drawDefaultMessage(builder, message, baseStyle, width, grouped)
} }
case discord.GuildMemberJoinMessage: 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: case discord.InlinedReplyMessage:
m.drawDefaultMessage(builder, message, baseStyle, width) m.drawDefaultMessage(builder, message, baseStyle, width, false)
case discord.ChannelPinnedMessage: case discord.ChannelPinnedMessage:
m.drawPinnedMessage(builder, message, baseStyle) m.drawPinnedMessage(builder, message, baseStyle)
default: default:
m.drawLayout(builder, message, baseStyle, nil) m.drawLayout(builder, message, baseStyle, false, nil)
} }
m.drawReactions(builder, message.Reactions, baseStyle) m.drawReactions(builder, message.Reactions, baseStyle)
} }
@ -464,24 +504,29 @@ func (m Model) formatTimestamp(ts discord.Timestamp) string {
const editedIndicator = " (edited)" 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 { if m.cfg.UI.MessagesList.Compact {
m.drawCompact(builder, message, baseStyle, content) m.drawCompact(builder, message, baseStyle, grouped, content)
return 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) dimStyle := baseStyle.Dim(true)
if m.cfg.Timestamps.Enabled { if m.cfg.Timestamps.Enabled {
builder.Write(m.formatTimestamp(message.Timestamp)+" ", dimStyle) 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 { if content != nil {
builder.Write(" ", baseStyle)
content() 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) dimStyle := baseStyle.Dim(true)
if grouped {
if content != nil {
content()
}
if message.EditedTimestamp.IsValid() {
builder.Write(editedIndicator, dimStyle)
}
return
}
m.drawAuthor(builder, message, baseStyle) m.drawAuthor(builder, message, baseStyle)
if m.cfg.Timestamps.Enabled { if m.cfg.Timestamps.Enabled {
builder.Write(" "+m.formatTimestamp(message.Timestamp), dimStyle) 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) 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 contentRoot ast.Node
var contentSource []byte var contentSource []byte
content := func() { contentRoot, contentSource = m.drawContent(builder, message, baseStyle) } 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 content = nil
} }
m.drawLayout(builder, message, baseStyle, content) m.drawLayout(builder, message, baseStyle, grouped, content)
m.drawEmbeds(builder, message, baseStyle, width, contentRoot, contentSource) m.drawEmbeds(builder, message, baseStyle, width, contentRoot, contentSource)
attachmentStyle := tview.MergeStyle(baseStyle, m.cfg.Theme.MessagesList.AttachmentStyle.Style) 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) 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) builder.Write(m.cfg.UI.MessagesList.ForwardedIndicator+" ", dimStyle)
m.drawSnapshotContent(builder, message, message.MessageSnapshots[0].Message, baseStyle) m.drawSnapshotContent(builder, message, message.MessageSnapshots[0].Message, baseStyle)
builder.Write(" ("+m.formatTimestamp(message.MessageSnapshots[0].Message.Timestamp)+") ", dimStyle) builder.Write(" ("+m.formatTimestamp(message.MessageSnapshots[0].Message.Timestamp)+") ", dimStyle)

View file

@ -6,6 +6,7 @@ import (
"slices" "slices"
"strings" "strings"
"testing" "testing"
"time"
"github.com/ayn2op/arikawa/v3/discord" "github.com/ayn2op/arikawa/v3/discord"
"github.com/ayn2op/arikawa/v3/state" "github.com/ayn2op/arikawa/v3/state"
@ -219,7 +220,7 @@ func TestModelDrawLayout(t *testing.T) {
cfg.UI.MessagesList.Compact = compact cfg.UI.MessagesList.Compact = compact
var got strings.Builder 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 { for _, segment := range line {
got.WriteString(segment.Text) 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) { func TestMessageViewLayout(t *testing.T) {
cfg := testConfig(t) cfg := testConfig(t)
cfg.UI.MessagesList.Compact = true cfg.UI.MessagesList.Compact = true