feat(messageslist)!: group consecutive messages by the same author
This commit is contained in:
parent
3562b4d546
commit
60aa5f52f1
6 changed files with 168 additions and 38 deletions
|
|
@ -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
2
go.mod
|
|
@ -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
4
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/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=
|
||||||
|
|
|
||||||
|
|
@ -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},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue