From 60aa5f52f14cac1537eb5f9a5d3ae93430ba2084 Mon Sep 17 00:00:00 2001 From: ayn2op Date: Thu, 15 Oct 2026 17:59:24 -0400 Subject: [PATCH] feat(messageslist)!: group consecutive messages by the same author --- docs/config.md | 16 ++- go.mod | 2 +- go.sum | 4 +- internal/config/ui.go | 15 ++- internal/ui/chat/messageslist/model.go | 113 +++++++++++++++----- internal/ui/chat/messageslist/model_test.go | 56 +++++++++- 6 files changed, 168 insertions(+), 38 deletions(-) diff --git a/docs/config.md b/docs/config.md index b89d7e3..5d35d66 100644 --- a/docs/config.md +++ b/docs/config.md @@ -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. diff --git a/go.mod b/go.mod index 4cc0c82..344d017 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-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 diff --git a/go.sum b/go.sum index 3397fb7..d24c256 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-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= diff --git a/internal/config/ui.go b/internal/config/ui.go index 47d4ffd..e772864 100644 --- a/internal/config/ui.go +++ b/internal/config/ui.go @@ -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}, } } diff --git a/internal/ui/chat/messageslist/model.go b/internal/ui/chat/messageslist/model.go index a471154..5bee0dd 100644 --- a/internal/ui/chat/messageslist/model.go +++ b/internal/ui/chat/messageslist/model.go @@ -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) diff --git a/internal/ui/chat/messageslist/model_test.go b/internal/ui/chat/messageslist/model_test.go index acdcf16..7edd1b7 100644 --- a/internal/ui/chat/messageslist/model_test.go +++ b/internal/ui/chat/messageslist/model_test.go @@ -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