diff --git a/internal/ui/chat/messageslist/embed.go b/internal/ui/chat/messageslist/embed.go index 2a50cda..4b54be2 100644 --- a/internal/ui/chat/messageslist/embed.go +++ b/internal/ui/chat/messageslist/embed.go @@ -8,22 +8,28 @@ import ( "github.com/ayn2op/discordo/internal/config" "github.com/ayn2op/discordo/internal/markdown" "github.com/ayn2op/tview" + "github.com/ayn2op/tview/box" + "github.com/ayn2op/tview/layout" "github.com/ayn2op/tview/text" "github.com/gdamore/tcell/v3" - "github.com/rockorager/go-uucode" "github.com/yuin/goldmark/v2/ast" ) -// embedPrefix is drawn before each line of an embed, in the embed's color. -const embedPrefix = " ▎ " - -// embedPrefixWidth is how many cells embedPrefix takes. -var embedPrefixWidth = uucode.StringWidth(embedPrefix) +const ( + // embedBar is drawn down the left of an embed, in the embed's color. + embedBar = "▎" + // embedIndent is the cells before the bar, and embedBarGap those between the bar and the text. + embedIndent, embedBarGap = 2, 1 +) +// embedViews returns each embed of message as its text with a bar down its left, or nil if the message does not show its embeds: one without any, by a blocked user, or that is not drawn with its own content, as a forward. +// The views capture the model and the message, so they are made once with the text of the message rather than in buildItem. // root is the parsed content of message, or nil to parse it here. -func (m Model) drawEmbeds(builder *markdown.Builder, message discord.Message, baseStyle tview.Style, width int, root ast.Node) { - if len(message.Embeds) == 0 { - return +func (m Model) embedViews(message discord.Message, root ast.Node) []tview.Widget { + forward := message.Reference != nil && message.Reference.Type == discord.MessageReferenceTypeForward + ownContent := message.Type == discord.DefaultMessage && !forward || message.Type == discord.InlinedReplyMessage + if len(message.Embeds) == 0 || m.blocked(message.Author) || !ownContent { + return nil } // Embed URLs are deduplicated against links already shown in the message body. @@ -33,47 +39,52 @@ func (m Model) drawEmbeds(builder *markdown.Builder, message discord.Message, ba seenURLs[u] = struct{}{} } + baseStyle := m.cfg.Theme.MessagesList.MessageStyle.Style lineStyles := embedLineStyles(baseStyle, m.cfg.Theme.MessagesList.Embeds) - defaultPrefixStyle := baseStyle.Dim(true) - // Wrap against the list's width, so the message is rendered again when it changes. - wrapWidth := max(width-embedPrefixWidth, 1) - + var views []tview.Widget for _, embed := range message.Embeds { lines := embedLines(embed, seenURLs) if len(lines) == 0 { continue } - prefixStyle := defaultPrefixStyle + barStyle := baseStyle.Dim(true) if embed.Color != discord.NullColor && embed.Color != 0 { - prefixStyle = prefixStyle.Foreground(tcell.NewHexColor(int32(embed.Color))) + barStyle = barStyle.Foreground(tcell.NewHexColor(int32(embed.Color))) } - prefix := text.Span{Text: embedPrefix, Style: prefixStyle} - builder.NewLine() - for _, line := range lines { - if strings.TrimSpace(line.text) == "" { - continue - } - msg := message - msg.Content = line.text - lineStyle := lineStyles[line.kind] - // Embed descriptions are always markdown-rendered to match Discord's rich embed semantics, even when message markdown is globally disabled. - rendered := m.renderContent(msg, nil, lineStyle, line.kind == embedLineDescription) - if line.url != "" { - rendered = spansWithURL(rendered, line.url) - } - // The prefix is added after wrapping, so every visual line has it, not only the first. - for _, wrapped := range text.Lines(rendered, wrapWidth, text.WrappingGlyph) { - if !builder.LineEmpty() { - builder.NewLine() - } - - builder.WriteSpans(prefix) - builder.WriteSpans(wrapped...) - } - } + content := &messageView{render: func(int) []text.Span { return m.renderEmbed(message, lines, lineStyles) }} + bar := box.New(content). + Borders(tview.BordersLeft). + BorderSet(tview.BorderSet{Left: embedBar}). + BorderStyle(barStyle). + Padding(0, 0, embedBarGap, 0). + Height(layout.Shrink) + views = append(views, box.New(bar).Padding(0, 0, embedIndent, 0).Height(layout.Shrink)) } + + return views +} + +// renderEmbed returns lines of an embed of message, each on its own line in the style of its kind. +func (m Model) renderEmbed(message discord.Message, lines []embedLine, lineStyles [embedLineKinds]tview.Style) []text.Span { + builder := new(markdown.Builder) + for _, line := range lines { + if !builder.LineEmpty() { + builder.NewLine() + } + + msg := message + msg.Content = line.text + // Embed descriptions are always markdown-rendered to match Discord's rich embed semantics, even when message markdown is globally disabled. + rendered := m.renderContent(msg, nil, lineStyles[line.kind], line.kind == embedLineDescription) + if line.url != "" { + rendered = spansWithURL(rendered, line.url) + } + builder.WriteSpans(rendered...) + } + + return builder.Finish() } func spansWithURL(spans []text.Span, rawURL string) []text.Span { diff --git a/internal/ui/chat/messageslist/message.go b/internal/ui/chat/messageslist/message.go index 1804f5f..d4188ea 100644 --- a/internal/ui/chat/messageslist/message.go +++ b/internal/ui/chat/messageslist/message.go @@ -5,7 +5,6 @@ import ( "strconv" "strings" "time" - "unicode/utf8" "github.com/ayn2op/arikawa/v3/discord" md "github.com/ayn2op/arikawa/v3/markdown" @@ -25,7 +24,7 @@ import ( // root is the parsed content of message, or nil to parse it when it is rendered. func (m Model) messageViews(message discord.Message, root ast.Node, grouped bool) (content, reply *messageView) { style := m.cfg.Theme.MessagesList.MessageStyle.Style - content = &messageView{render: func(width int) []text.Span { return m.renderMessage(message, root, style, width, grouped) }} + content = &messageView{render: func(int) []text.Span { return m.renderMessage(message, root, style, grouped) }} reply = &messageView{render: func(int) []text.Span { builder := new(markdown.Builder) m.drawReply(builder, message, style) @@ -70,35 +69,9 @@ func (v *messageView) Draw(screen tview.Screen, area tview.Rectangle) { func (v *messageView) Handle(msg tview.Msg, _ tview.Rectangle) tview.Msg { return msg } -// dateSeparator is a line with the date in the middle, filled to the width it is drawn at. -type dateSeparator struct { - date, fill string - style tview.Style -} - -// Size returns Fill and a height of one row. -func (dateSeparator) Size() (width, height layout.Length) { return layout.Fill, layout.Fixed(1) } - -// Layout returns a height of one row. -func (dateSeparator) Layout(limits layout.Limits) layout.Size { - return layout.Atomic(limits, layout.Fill, layout.Fixed(1)) -} - -func (d dateSeparator) Draw(screen tview.Screen, area tview.Rectangle) { - line := d.date - label := " " + d.date + " " - if labelWidth := utf8.RuneCountInString(label); area.Width > labelWidth { - fill := area.Width - labelWidth - line = strings.Repeat(d.fill, fill/2) + label + strings.Repeat(d.fill, fill-fill/2) - } - tview.Print(screen, line, area.X, area.Y, area.Width, tview.HorizontalAlignmentLeft, d.style) -} - -func (dateSeparator) Handle(msg tview.Msg, area tview.Rectangle) tview.Msg { return msg } - -func (m Model) renderMessage(message discord.Message, root ast.Node, baseStyle tview.Style, width int, grouped bool) []text.Span { +func (m Model) renderMessage(message discord.Message, root ast.Node, baseStyle tview.Style, grouped bool) []text.Span { builder := new(markdown.Builder) - m.writeMessage(builder, message, root, baseStyle, width, grouped) + m.writeMessage(builder, message, root, baseStyle, grouped) // Without a header or content, the message starts with the line break that attachments and embeds start below. return trimLeadingLineBreaks(builder.Finish()) } @@ -114,7 +87,7 @@ func trimLeadingLineBreaks(spans []text.Span) []text.Span { return spans } -func (m Model) writeMessage(builder *markdown.Builder, message discord.Message, root ast.Node, baseStyle tview.Style, width int, grouped bool) { +func (m Model) writeMessage(builder *markdown.Builder, message discord.Message, root ast.Node, baseStyle tview.Style, grouped bool) { if m.blocked(message.Author) { builder.Write("Blocked message", baseStyle.Foreground(color.Red).Bold(true)) return @@ -125,12 +98,12 @@ func (m Model) writeMessage(builder *markdown.Builder, message discord.Message, if message.Reference != nil && message.Reference.Type == discord.MessageReferenceTypeForward { m.drawForwardedMessage(builder, message, baseStyle, grouped) } else { - m.drawDefaultMessage(builder, message, root, baseStyle, width, grouped) + m.drawDefaultMessage(builder, message, root, baseStyle, grouped) } case discord.GuildMemberJoinMessage: m.drawLayout(builder, message, baseStyle, false, func() { builder.Write("joined the server.", baseStyle) }) case discord.InlinedReplyMessage: - m.drawDefaultMessage(builder, message, root, baseStyle, width, false) + m.drawDefaultMessage(builder, message, root, baseStyle, false) case discord.ChannelPinnedMessage: drawPinnedMessage(builder, message, baseStyle) default: @@ -338,14 +311,13 @@ func (m Model) drawSnapshotContent(builder *markdown.Builder, parent discord.Mes m.drawContent(builder, message, nil, baseStyle) } -func (m Model) drawDefaultMessage(builder *markdown.Builder, message discord.Message, root ast.Node, baseStyle tview.Style, width int, grouped bool) { +func (m Model) drawDefaultMessage(builder *markdown.Builder, message discord.Message, root ast.Node, baseStyle tview.Style, grouped bool) { content := func() { m.drawContent(builder, message, root, baseStyle) } if message.Content == "" { content = nil } m.drawLayout(builder, message, baseStyle, grouped, content) - m.drawEmbeds(builder, message, baseStyle, width, root) attachmentStyle := tview.MergeStyle(baseStyle, m.cfg.Theme.MessagesList.AttachmentStyle.Style) // A voice message is shown as its player. diff --git a/internal/ui/chat/messageslist/model.go b/internal/ui/chat/messageslist/model.go index a4724df..4651356 100644 --- a/internal/ui/chat/messageslist/model.go +++ b/internal/ui/chat/messageslist/model.go @@ -11,6 +11,7 @@ import ( "github.com/ayn2op/discordo/internal/voice" "github.com/ayn2op/ningen/v3" "github.com/ayn2op/tview" + "github.com/ayn2op/tview/box" "github.com/ayn2op/tview/column" tviewimage "github.com/ayn2op/tview/image" "github.com/ayn2op/tview/keybind" @@ -19,6 +20,7 @@ import ( "github.com/ayn2op/tview/row" "github.com/ayn2op/tview/scrollbar" "github.com/ayn2op/tview/spacer" + "github.com/ayn2op/tview/text" "github.com/ayn2op/tview/viewport" "github.com/yuin/goldmark/v2/ast" ) @@ -27,8 +29,10 @@ type messageItem struct { message discord.Message // text and reply are the message and what it replies to as wrapped, or nil until they are shown. text, reply *messageView - // A separator shows the date of the message after it. - separator bool + // embeds are the embeds of the message, made with text. + embeds []tview.Widget + // separator is the line with the date of the message after it, or nil for a message. + separator tview.Widget // grouped reports whether the message is drawn without a header, under that of the message before it. grouped bool @@ -181,7 +185,7 @@ func (m Model) indexOf(channelID discord.ChannelID, id discord.MessageID) int { if m.channel == nil || m.channel.ID != channelID { return -1 } - return slices.IndexFunc(m.items, func(item messageItem) bool { return !item.separator && item.message.ID == id }) + return slices.IndexFunc(m.items, func(item messageItem) bool { return item.separator == nil && item.message.ID == id }) } func (m *Model) reset() { @@ -209,7 +213,7 @@ func (m *Model) AddMessage(message discord.Message) tview.Cmd { separator := m.shouldSeparate(previous, message) if separator { - m.items = append(m.items, messageItem{separator: true}) + m.items = append(m.items, messageItem{separator: m.separatorView(message)}) } grouped := !separator && m.shouldGroup(previous, message) @@ -218,7 +222,7 @@ func (m *Model) AddMessage(message discord.Message) tview.Cmd { } func (m *Model) setMessage(index int, message discord.Message) { - if index < 0 || index >= len(m.items) || m.items[index].separator { + if index < 0 || index >= len(m.items) || m.items[index].separator != nil { return } @@ -227,7 +231,7 @@ func (m *Model) setMessage(index int, message discord.Message) { } func (m *Model) deleteMessage(index int) { - if index < 0 || index >= len(m.items) || m.items[index].separator { + if index < 0 || index >= len(m.items) || m.items[index].separator != nil { return } @@ -255,17 +259,22 @@ func (m Model) buildItem(index int) tview.Widget { return nil } item := &m.items[index] - if item.separator { - date := m.items[index+1].message.Timestamp.Time().In(time.Local).Format(m.cfg.DateSeparator.Format) - return dateSeparator{date: date, fill: m.cfg.DateSeparator.Character, style: m.cfg.Theme.MessagesList.MessageStyle.Dim(true)} + if item.separator != nil { + return item.separator } message := &item.message if item.text == nil { - item.text, item.reply = m.messageViews(*message, m.contentRoot(item), item.grouped) + root := m.contentRoot(item) + item.text, item.reply = m.messageViews(*message, root, item.grouped) + item.embeds = m.embedViews(*message, root) } + // Attachments are named in the text, before the embeds. body := column.New(item.text).Height(layout.Shrink) + for _, embed := range item.embeds { + body = body.Push(embed) + } if isVoiceMessage(*message) { body = body.Push(m.voiceMessageView(message.Attachments[0])) } @@ -310,19 +319,35 @@ func (m Model) shouldGroup(previous *discord.Message, message discord.Message) b message.Timestamp.Time().Sub(previous.Timestamp.Time()) <= cfg.Interval } +// separatorDatePadding is on either side of the date on its separator line. +const separatorDatePadding = " " + +// separatorView returns the line with the date of message in the middle of it. It is made once, when the separator is added, as the list builds every item each time it is laid out. +func (m Model) separatorView(message discord.Message) tview.Widget { + date := message.Timestamp.Time().In(time.Local).Format(m.cfg.DateSeparator.Format) + style := m.cfg.Theme.MessagesList.MessageStyle.Dim(true) + return box.New(spacer.New()). + Borders(tview.BordersTop). + BorderSet(tview.BorderSet{Top: m.cfg.DateSeparator.Character}). + BorderStyle(style). + // The separator is kept, so its date is laid out once. + Title(text.New(separatorDatePadding + date + separatorDatePadding).Style(style).Wrapping(text.WrappingNone).Paragraph(new(text.Paragraph))). + Height(layout.Fixed(1)) +} + // 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.Message for i, item := range m.items { - if item.separator { + if item.separator != nil { continue } separator := m.shouldSeparate(previous, item.message) if separator { - items = append(items, messageItem{separator: true}) + items = append(items, messageItem{separator: m.separatorView(item.message)}) } // A message that joins or leaves a group is drawn again, with or without its header. if grouped := !separator && m.shouldGroup(previous, item.message); grouped != item.grouped { @@ -356,7 +381,7 @@ func sameLocalDate(a discord.Timestamp, b discord.Timestamp) bool { // messageIndex finds the next message in direction (-1 or 1), excluding start. func (m Model) messageIndex(start, direction int) int { for i := start + direction; i >= 0 && i < len(m.items); i += direction { - if !m.items[i].separator { + if m.items[i].separator == nil { return i } } @@ -364,7 +389,7 @@ func (m Model) messageIndex(start, direction int) int { } func (m *Model) onRowCursorChanged(index int) { - if index < 0 || index >= len(m.items) || !m.items[index].separator { + if index < 0 || index >= len(m.items) || m.items[index].separator == nil { return } target := m.messageIndex(index, -1) @@ -376,7 +401,7 @@ func (m *Model) onRowCursorChanged(index int) { func (m Model) selectedMessage() (*discord.Message, bool) { cursor := m.cursor - if cursor < 0 || cursor >= len(m.items) || m.items[cursor].separator { + if cursor < 0 || cursor >= len(m.items) || m.items[cursor].separator != nil { return nil, false } @@ -479,7 +504,7 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) { return m, cmd } - index := slices.IndexFunc(m.items, func(item messageItem) bool { return !item.separator && item.message.ID == msg.messageID }) + index := slices.IndexFunc(m.items, func(item messageItem) bool { return item.separator == nil && item.message.ID == msg.messageID }) if index < 0 || m.items[index].previews == nil { return m, nil } @@ -584,7 +609,7 @@ func (m *Model) selectReply() { if ref := messages[cursor].message.ReferencedMessage; ref != nil { refIdx := slices.IndexFunc(messages, func(m messageItem) bool { - return !m.separator && m.message.ID == ref.ID + return m.separator == nil && m.message.ID == ref.ID }) if refIdx != -1 { m.setCursor(refIdx) @@ -600,7 +625,7 @@ func (m *Model) SelectLastOwn() (discord.Message, bool) { } for i, item := range slices.Backward(m.items) { message := item.message - if item.separator || message.ChannelID != channel.ID || !ui.IsMe(m.state, message.Author.ID) || + if item.separator != nil || message.ChannelID != channel.ID || !ui.IsMe(m.state, message.Author.ID) || !message.ID.IsValid() || message.Content == "" || len(message.MessageSnapshots) > 0 || (message.Type != discord.DefaultMessage && message.Type != discord.InlinedReplyMessage) { continue diff --git a/internal/ui/chat/messageslist/model_test.go b/internal/ui/chat/messageslist/model_test.go index e84a154..c1ed2c0 100644 --- a/internal/ui/chat/messageslist/model_test.go +++ b/internal/ui/chat/messageslist/model_test.go @@ -113,8 +113,8 @@ func TestModelRebuildItems(t *testing.T) { t.Helper() var got []discord.MessageID for i, item := range ml.items { - if item.separator { - if !tt.separators || i+1 == len(ml.items) || ml.items[i+1].separator { + if item.separator != nil { + if !tt.separators || i+1 == len(ml.items) || ml.items[i+1].separator != nil { t.Fatalf("item %d: orphaned separator", i) } } else { @@ -223,7 +223,7 @@ func TestModelDrawLayout(t *testing.T) { cfg.UI.MessagesList.Compact = compact var got strings.Builder - for _, span := range ml.renderMessage(message, nil, tview.Style{}, 80, false) { + for _, span := range ml.renderMessage(message, nil, tview.Style{}, false) { got.WriteString(span.Text) } @@ -272,7 +272,7 @@ func TestModelShouldGroup(t *testing.T) { 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, nil, tview.Style{}, 80, true); len(got) != 1 || got[0].Text != "hi" { + if got := ml.renderMessage(ml.items[newest].message, nil, tview.Style{}, true); len(got) != 1 || got[0].Text != "hi" { t.Fatalf("grouped message = %v, want only its content", got) } second := ml.messageIndex(newest, -1) diff --git a/internal/ui/chat/messageslist/preview.go b/internal/ui/chat/messageslist/preview.go index 9b3ec49..a2d25b6 100644 --- a/internal/ui/chat/messageslist/preview.go +++ b/internal/ui/chat/messageslist/preview.go @@ -85,7 +85,7 @@ func (m Model) loadPreviews() tview.Cmd { var cmds []tview.Cmd for i := range m.items { item := &m.items[i] - if item.separator { + if item.separator != nil { continue }