refactor(messageslist): split model into files by concern

This commit is contained in:
ayn2op 2026-10-16 18:52:27 -04:00
parent 25957fd3fd
commit e38b7490ad
6 changed files with 832 additions and 783 deletions

View file

@ -0,0 +1,55 @@
package messageslist
import (
"errors"
"fmt"
"io"
"net/http"
"os"
"github.com/ayn2op/arikawa/v3/api"
"github.com/ayn2op/arikawa/v3/discord"
"github.com/ayn2op/arikawa/v3/utils/httputil"
)
// get requests the file at url, by a new link to it if that one expired.
func (m Model) get(url string) (*http.Response, error) {
resp, err := http.Get(url)
if err != nil || resp.StatusCode == http.StatusOK {
return resp, err
}
resp.Body.Close() //nolint:errcheck
var links struct {
URLs []struct {
Refreshed string `json:"refreshed"`
} `json:"refreshed_urls"`
}
body := map[string][]string{"attachment_urls": {url}}
if err := m.state.RequestJSONInto(&links, http.MethodPost, api.Endpoint+"attachments/refresh-urls", httputil.WithJSONBody(body)); err != nil {
return nil, err
}
if len(links.URLs) != 0 {
if resp, err = http.Get(links.URLs[0].Refreshed); err != nil || resp.StatusCode == http.StatusOK {
return resp, err
}
resp.Body.Close() //nolint:errcheck
}
return nil, fmt.Errorf("unexpected HTTP status: %s", resp.Status)
}
func (m Model) downloadAttachment(attachment discord.Attachment, destination string) error {
resp, err := m.get(attachment.URL)
if err != nil {
return err
}
defer resp.Body.Close() //nolint:errcheck
file, err := os.Create(destination)
if err != nil {
return err
}
_, err = io.Copy(file, resp.Body)
return errors.Join(err, file.Close())
}

View file

@ -0,0 +1,236 @@
package messageslist
import (
"net/url"
"strings"
"github.com/ayn2op/arikawa/v3/discord"
"github.com/ayn2op/discordo/internal/config"
"github.com/ayn2op/tview"
"github.com/ayn2op/tview/richtext"
"github.com/gdamore/tcell/v3"
"github.com/rivo/uniseg"
"github.com/yuin/goldmark/v2/ast"
)
func (m Model) drawEmbeds(builder *richtext.Builder, message discord.Message, baseStyle tview.Style, width int, contentRoot ast.Node, contentSource []byte) {
if len(message.Embeds) == 0 {
return
}
// Embed URLs are deduplicated against links already shown in the message body.
// Reuse the body's parsed AST when markdown is enabled; only the markdown-disabled path (no AST) has to parse the content here.
var contentListURLs []string
if contentRoot != nil {
contentListURLs = urlsFromAST(contentRoot, contentSource)
} else {
contentListURLs = extractURLs(message.Content)
}
contentURLs := make(map[string]struct{}, len(contentListURLs))
for _, u := range contentListURLs {
contentURLs[u] = struct{}{}
}
lineStyles := embedLineStyles(baseStyle, m.cfg.Theme.MessagesList.Embeds)
defaultBarStyle := baseStyle.Dim(true)
prefixText := " ▎ "
prefixWidth := uniseg.StringWidth(prefixText)
// Wrap against the list's width, so the message is rendered again when it changes.
wrapWidth := max(width-prefixWidth, 1)
for _, embed := range message.Embeds {
lines := embedLines(embed, contentURLs)
if len(lines) == 0 {
continue
}
embedText := make(richtext.Text, 0, len(lines)*2)
barStyle := defaultBarStyle
if embed.Color != discord.NullColor && embed.Color != 0 {
barStyle = barStyle.Foreground(tcell.NewHexColor(int32(embed.Color)))
}
prefix := richtext.NewSegment(prefixText, barStyle)
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, lineStyle, line.Kind == embedLineDescription)
for _, renderedLine := range rendered {
if line.URL != "" {
renderedLine = lineWithURL(renderedLine, line.URL)
}
// Prefix must be applied after wrapping so every visual line keeps the embed bar marker ("▎"), not only the first logical line.
for _, wrapped := range richtext.Wrap(renderedLine, wrapWidth) {
embedText = append(embedText, append(richtext.Line{prefix}, wrapped...))
}
}
}
if len(embedText) > 0 {
builder.WriteText(embedText)
}
}
}
func lineWithURL(line richtext.Line, rawURL string) richtext.Line {
out := make(richtext.Line, len(line))
for i, segment := range line {
out[i] = segment
out[i].Style = out[i].Style.Url(rawURL)
}
return out
}
type embedLine struct {
Text string
Kind embedLineKind
URL string
}
type embedLineKind uint8
const (
// Keep this ordering stable: drawEmbeds indexes precomputed style slots by this enum.
embedLineProvider embedLineKind = iota
embedLineAuthor
embedLineTitle
embedLineDescription
embedLineFieldName
embedLineFieldValue
embedLineFooter
embedLineURL
)
func embedLineStyles(baseStyle tview.Style, theme config.MessagesListEmbedsThemeConfig) [8]tview.Style {
styles := [8]tview.Style{}
styles[embedLineProvider] = tview.MergeStyle(baseStyle, theme.ProviderStyle.Style)
styles[embedLineAuthor] = tview.MergeStyle(baseStyle, theme.AuthorStyle.Style)
styles[embedLineTitle] = tview.MergeStyle(baseStyle, theme.TitleStyle.Style)
styles[embedLineDescription] = tview.MergeStyle(baseStyle, theme.DescriptionStyle.Style)
styles[embedLineFieldName] = tview.MergeStyle(baseStyle, theme.FieldNameStyle.Style)
styles[embedLineFieldValue] = tview.MergeStyle(baseStyle, theme.FieldValueStyle.Style)
styles[embedLineFooter] = tview.MergeStyle(baseStyle, theme.FooterStyle.Style)
styles[embedLineURL] = tview.MergeStyle(baseStyle, theme.URLStyle.Style)
return styles
}
type embedLineDedupKey struct {
kind embedLineKind
text string
}
func embedLines(embed discord.Embed, contentURLs map[string]struct{}) []embedLine {
lines := make([]embedLine, 0, 8)
seen := make(map[embedLineDedupKey]struct{}, 8)
appendUnique := func(s string, kind embedLineKind, rawURL string) {
s = strings.TrimSpace(s)
if s == "" {
return
}
// Deduplicate by kind+text so the same value can intentionally appear in multiple semantic slots with different styles (e.g. title vs. field).
key := embedLineDedupKey{kind: kind, text: s}
if _, ok := seen[key]; ok {
return
}
seen[key] = struct{}{}
lines = append(lines, embedLine{
Text: s,
Kind: kind,
URL: rawURL,
})
}
appendURL := func(url discord.URL) {
u := strings.TrimSpace(url)
if u == "" {
return
}
// Avoid duplicating links that already appear in message body content.
if _, ok := contentURLs[u]; ok {
return
}
appendUnique(linkDisplayText(u), embedLineURL, u)
}
if embed.Provider != nil {
appendUnique(embed.Provider.Name, embedLineProvider, "")
}
if embed.Author != nil {
appendUnique(embed.Author.Name, embedLineAuthor, "")
}
appendUnique(embed.Title, embedLineTitle, embed.URL)
// Some Discord embeds include markdown-escaped punctuation in raw payload text (e.g. "\."), so normalize for display.
appendUnique(unescapeMarkdownEscapes(embed.Description), embedLineDescription, "")
for _, field := range embed.Fields {
appendUnique(field.Name, embedLineFieldName, "")
appendUnique(field.Value, embedLineFieldValue, "")
}
if embed.Footer != nil {
appendUnique(embed.Footer.Text, embedLineFooter, "")
}
// Prefer media URLs after textual fields so previews read top-to-bottom before jumping to link targets.
// When a title exists, embed.URL is represented by title Style.Url metadata instead of a separate URL row.
if embed.Title == "" {
appendURL(embed.URL)
}
if embed.Image != nil {
appendURL(embed.Image.URL)
}
if embed.Video != nil {
appendURL(embed.Video.URL)
}
return lines
}
func linkDisplayText(raw string) string {
parsed, err := url.Parse(raw)
if err != nil || parsed.Host == "" {
return raw
}
path := strings.TrimSpace(parsed.EscapedPath())
switch {
case path == "", path == "/":
return parsed.Host
case len(path) > 48:
return parsed.Host + path[:45] + "..."
default:
return parsed.Host + path
}
}
func unescapeMarkdownEscapes(s string) string {
if !strings.ContainsRune(s, '\\') {
return s
}
var b strings.Builder
b.Grow(len(s))
for i := range len(s) {
if s[i] == '\\' && i+1 < len(s) && isMarkdownEscapable(s[i+1]) {
continue
}
b.WriteByte(s[i])
}
return b.String()
}
func isMarkdownEscapable(c byte) bool {
switch c {
case '\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '#', '+', '-', '.', '!', '|', '>', '~':
return true
default:
return false
}
}

View file

@ -0,0 +1,78 @@
package messageslist
import (
"net/url"
"slices"
"strings"
"github.com/ayn2op/arikawa/v3/discord"
md "github.com/ayn2op/arikawa/v3/markdown"
"github.com/yuin/goldmark/v2/ast"
)
func extractURLs(content string) []string {
src := []byte(content)
node := md.Parse(src)
return urlsFromAST(node, src)
}
// urlsFromAST collects link destinations from an already-parsed markdown AST.
// src must be the byte slice the node was parsed from (AutoLink resolves its URL against it).
func urlsFromAST(node ast.Node, src []byte) []string {
var urls []string
_ = ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
switch n := n.(type) {
case *ast.AutoLink:
urls = append(urls, string(n.Destination.Bytes(src)))
case *ast.Link:
urls = append(urls, string(n.Destination.Bytes(src)))
}
}
return ast.WalkContinue, nil
})
return urls
}
func extractEmbedURLs(embeds []discord.Embed) []string {
urls := make([]string, 0, len(embeds)*3)
for _, embed := range embeds {
if embed.URL != "" {
urls = append(urls, embed.URL)
}
if embed.Image != nil && embed.Image.URL != "" {
urls = append(urls, embed.Image.URL)
}
if embed.Video != nil && embed.Video.URL != "" {
urls = append(urls, embed.Video.URL)
}
}
return urls
}
// The schemes of the links that are opened.
const (
schemeHTTP = "http"
schemeHTTPS = "https"
)
func messageURLs(msg discord.Message) []string {
combined := slices.Concat(extractURLs(msg.Content), extractEmbedURLs(msg.Embeds))
urls := make([]string, 0, len(combined))
seen := make(map[string]struct{}, len(combined))
for _, u := range combined {
u = strings.TrimSpace(u)
// Only web links are opened, as one of another scheme could run a program.
if parsed, err := url.Parse(u); err != nil || parsed.Scheme != schemeHTTP && parsed.Scheme != schemeHTTPS {
continue
}
if _, ok := seen[u]; ok {
continue
}
seen[u] = struct{}{}
urls = append(urls, u)
}
return urls
}

View file

@ -0,0 +1,375 @@
package messageslist
import (
"log/slog"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/ayn2op/arikawa/v3/discord"
md "github.com/ayn2op/arikawa/v3/markdown"
"github.com/ayn2op/arikawa/v3/state"
"github.com/ayn2op/tview"
"github.com/ayn2op/tview/layout"
"github.com/ayn2op/tview/richtext"
"github.com/ayn2op/tview/textview"
"github.com/gdamore/tcell/v3"
"github.com/gdamore/tcell/v3/color"
"github.com/yuin/goldmark/v2/ast"
)
// messageViews returns the views of the text of message and of what it replies to.
// They capture the model and the message, so they are made here: capturing them in buildItem would copy both to the heap on every call, as the list builds every item each time it is laid out.
func (m Model) messageViews(message discord.Message, grouped bool) (text, reply *messageView) {
style := m.cfg.Theme.MessagesList.MessageStyle.Style
text = &messageView{render: func(width int) richtext.Text { return m.renderMessage(message, style, width, grouped) }}
reply = &messageView{render: func(int) richtext.Text {
builder := new(richtext.Builder)
m.drawReply(builder, message, style)
return builder.Finish()
}}
return text, reply
}
func (m Model) blocked(user discord.User) bool {
return m.cfg.HideBlockedUsers && m.state.UserIsBlocked(user.ID)
}
// messageView is the text of a message. It keeps the wrapped lines until the width changes, as the list measures every message when it draws.
type messageView struct {
render func(width int) richtext.Text
width int
rendered bool
lines richtext.Text
}
func (v *messageView) at(width int) textview.Widget {
if !v.rendered || v.width != width {
v.lines, v.width, v.rendered = nil, width, true
for _, line := range v.render(width) {
v.lines = append(v.lines, richtext.WrapWords(line, width)...)
}
}
return textview.New(v.lines).Wrap(false)
}
func (v *messageView) Size() (width, height layout.Length) { return layout.Fill, layout.Shrink }
func (v *messageView) Layout(limits layout.Limits) layout.Size {
return layout.Sized(limits, layout.Fill, layout.Shrink, func(limits layout.Limits) layout.Size {
v.at(limits.Max.Width)
return layout.Size{Height: len(v.lines)}
})
}
func (v *messageView) Draw(screen tview.Screen, area tview.Rectangle) {
v.at(area.Width).Draw(screen, area)
}
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.AlignmentLeft, d.style)
}
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, grouped bool) richtext.Text {
builder := new(richtext.Builder)
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
}
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
}
switch message.Type {
case discord.DefaultMessage:
if message.Reference != nil && message.Reference.Type == discord.MessageReferenceTypeForward {
m.drawForwardedMessage(builder, message, baseStyle, grouped)
} else {
m.drawDefaultMessage(builder, message, baseStyle, width, grouped)
}
case discord.GuildMemberJoinMessage:
m.drawLayout(builder, message, baseStyle, false, func() { builder.Write("joined the server.", baseStyle) })
case discord.InlinedReplyMessage:
m.drawDefaultMessage(builder, message, baseStyle, width, false)
case discord.ChannelPinnedMessage:
m.drawPinnedMessage(builder, message, baseStyle)
default:
m.drawLayout(builder, message, baseStyle, false, nil)
}
m.drawReactions(builder, message.Reactions, baseStyle)
}
func (m Model) drawReactions(builder *richtext.Builder, reactions []discord.Reaction, baseStyle tview.Style) {
if len(reactions) == 0 {
return
}
builder.NewLine()
for i, reaction := range reactions {
if i > 0 {
builder.Write(" ", baseStyle)
}
name := reaction.Emoji.Name
if reaction.Emoji.IsCustom() {
name = ":" + name + ":"
}
style := m.cfg.Theme.MessagesList.ReactionStyle.Style
if reaction.Me {
style = m.cfg.Theme.MessagesList.OwnReactionStyle.Style
}
builder.Write(name+" "+strconv.Itoa(reaction.Count), tview.MergeStyle(baseStyle, style))
}
}
func (m Model) formatTimestamp(ts discord.Timestamp) string {
return ts.Time().In(time.Local).Format(m.cfg.Timestamps.Format)
}
const editedIndicator = " (edited)"
// 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, grouped, content)
return
}
m.drawCozy(builder, message, baseStyle, grouped, content)
}
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)
}
if !grouped {
m.drawAuthor(builder, message, baseStyle)
if content != nil {
builder.Write(" ", baseStyle)
}
}
if content != nil {
content()
}
if message.EditedTimestamp.IsValid() {
builder.Write(editedIndicator, dimStyle)
}
}
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)
}
if message.EditedTimestamp.IsValid() {
builder.Write(editedIndicator, dimStyle)
}
if content != nil {
builder.NewLine()
content()
}
}
func (m Model) drawAuthor(builder *richtext.Builder, message discord.Message, baseStyle tview.Style) {
name := message.Author.DisplayOrUsername()
foreground := tcell.ColorDefault
if member := m.memberForMessage(message); member != nil {
if member.Nick != "" {
name = member.Nick
}
color, ok := state.MemberColor(member, func(id discord.RoleID) *discord.Role {
r, _ := m.state.Cabinet.Role(message.GuildID, id)
return r
})
if ok {
foreground = tcell.NewHexColor(int32(color))
}
}
style := baseStyle.Foreground(foreground).Bold(true)
builder.Write(name, style)
}
func (m Model) memberForMessage(message discord.Message) *discord.Member {
// Webhooks do not have nicknames or roles.
if !message.GuildID.IsValid() || message.WebhookID.IsValid() {
return nil
}
member, err := m.state.Cabinet.Member(message.GuildID, message.Author.ID)
if err != nil {
slog.Error("failed to get member from state", "guild_id", message.GuildID, "member_id", message.Author.ID, "err", err)
return nil
}
return member
}
// drawContent renders the message body and returns the parsed markdown AST together with the source bytes it indexes into, so callers can reuse them instead of re-parsing the same content (see drawEmbeds).
// root is nil when markdown rendering is disabled.
func (m Model) drawContent(builder *richtext.Builder, message discord.Message, baseStyle tview.Style) (ast.Node, []byte) {
content, root, source := m.renderContent(message, baseStyle, false)
if m.cfg.Markdown.Enabled {
startsWithCodeBlock := false
if root != nil {
if first := root.FirstChild(); first != nil {
_, startsWithCodeBlock = first.(*ast.CodeBlock)
}
}
if startsWithCodeBlock {
// Keep code blocks visually separate from "timestamp + author".
if !builder.LineEmpty() {
builder.NewLine()
}
for len(content) > 0 && len(content[0]) == 0 {
content = content[1:]
}
} else {
for len(content) > 1 && len(content[0]) == 0 {
content = content[1:]
}
}
}
builder.WriteText(content)
return root, source
}
func (m Model) renderContent(message discord.Message, baseStyle tview.Style, forceMarkdown bool) (richtext.Text, ast.Node, []byte) {
// Keep one rendering path for both normal messages and embed fragments so we preserve mention/link parsing behavior consistently across both.
if forceMarkdown || m.cfg.Markdown.Enabled {
c := []byte(message.Content)
root := md.ParseWithMessage(c, *m.state.Cabinet, &message)
return m.renderer.RenderText(c, root, baseStyle), root, c
}
b := new(richtext.Builder)
b.Write(message.Content, baseStyle)
return b.Finish(), nil, nil
}
func (m Model) drawSnapshotContent(builder *richtext.Builder, parent discord.Message, snapshot discord.MessageSnapshotMessage, baseStyle tview.Style) {
// Convert discord.MessageSnapshotMessage to discord.Message with common fields.
message := discord.Message{
Type: snapshot.Type,
Content: snapshot.Content,
Embeds: snapshot.Embeds,
Attachments: snapshot.Attachments,
Timestamp: snapshot.Timestamp,
EditedTimestamp: snapshot.EditedTimestamp,
Flags: snapshot.Flags,
Mentions: snapshot.Mentions,
MentionRoleIDs: snapshot.MentionRoleIDs,
Stickers: snapshot.Stickers,
Components: snapshot.Components,
ChannelID: parent.ChannelID,
GuildID: parent.GuildID,
}
m.drawContent(builder, message, baseStyle)
}
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) }
if message.Content == "" {
content = nil
}
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)
// A voice message is shown as its player.
if isVoiceMessage(message) {
return
}
for _, a := range message.Attachments {
builder.NewLine()
if m.cfg.UI.MessagesList.Attachments.ShowLinks {
builder.Write(a.Filename+":", attachmentStyle)
builder.NewLine()
builder.Write(a.URL, attachmentStyle.Url(a.URL))
} else {
builder.Write(a.Filename, attachmentStyle)
}
}
}
func (m Model) drawForwardedMessage(builder *richtext.Builder, message discord.Message, baseStyle tview.Style, grouped bool) {
dimStyle := baseStyle.Dim(true)
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)
})
}
func (m Model) drawReply(builder *richtext.Builder, message discord.Message, baseStyle tview.Style) {
dimStyle := baseStyle.Dim(true)
builder.Write(m.cfg.UI.MessagesList.ReplyIndicator+" ", dimStyle)
if reference := message.ReferencedMessage; reference != nil {
reference.GuildID = message.GuildID
m.drawAuthor(builder, *reference, dimStyle)
builder.Write(" ", dimStyle)
m.drawContent(builder, *reference, dimStyle)
} else {
builder.Write("Original message was deleted", dimStyle)
}
}
func (m Model) drawPinnedMessage(builder *richtext.Builder, message discord.Message, baseStyle tview.Style) {
builder.Write(message.Author.DisplayOrUsername()+" pinned a message.", baseStyle)
}

View file

@ -1,49 +1,25 @@
package messageslist
import (
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"os"
"slices"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/ayn2op/tview/layout"
"github.com/ayn2op/arikawa/v3/api"
"github.com/ayn2op/arikawa/v3/discord"
md "github.com/ayn2op/arikawa/v3/markdown"
"github.com/ayn2op/arikawa/v3/state"
"github.com/ayn2op/arikawa/v3/utils/httputil"
"github.com/ayn2op/discordo/internal/config"
"github.com/ayn2op/discordo/internal/markdown"
"github.com/ayn2op/discordo/internal/ui"
"github.com/ayn2op/discordo/internal/voice"
"github.com/ayn2op/ningen/v3"
"github.com/ayn2op/tview"
"github.com/ayn2op/tview/button"
"github.com/ayn2op/tview/column"
tviewimage "github.com/ayn2op/tview/image"
"github.com/ayn2op/tview/keybind"
"github.com/ayn2op/tview/layout"
"github.com/ayn2op/tview/list"
"github.com/ayn2op/tview/richtext"
"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"
"github.com/gdamore/tcell/v3"
"github.com/gdamore/tcell/v3/color"
"github.com/rivo/uniseg"
"github.com/yuin/goldmark/v2/ast"
)
type messageItem struct {
@ -307,93 +283,6 @@ func (m Model) buildItem(index int) tview.Widget {
return body
}
// messageViews returns the views of the text of message and of what it replies to.
// They capture the model and the message, so they are made here: capturing them in buildItem would copy both to the heap on every call, as the list builds every item each time it is laid out.
func (m Model) messageViews(message discord.Message, grouped bool) (text, reply *messageView) {
style := m.cfg.Theme.MessagesList.MessageStyle.Style
text = &messageView{render: func(width int) richtext.Text { return m.renderMessage(message, style, width, grouped) }}
reply = &messageView{render: func(int) richtext.Text {
builder := new(richtext.Builder)
m.drawReply(builder, message, style)
return builder.Finish()
}}
return text, reply
}
func (m Model) blocked(user discord.User) bool {
return m.cfg.HideBlockedUsers && m.state.UserIsBlocked(user.ID)
}
// messageView is the text of a message. It keeps the wrapped lines until the width changes, as the list measures every message when it draws.
type messageView struct {
render func(width int) richtext.Text
width int
rendered bool
lines richtext.Text
}
func (v *messageView) at(width int) textview.Widget {
if !v.rendered || v.width != width {
v.lines, v.width, v.rendered = nil, width, true
for _, line := range v.render(width) {
v.lines = append(v.lines, richtext.WrapWords(line, width)...)
}
}
return textview.New(v.lines).Wrap(false)
}
func (v *messageView) Size() (width, height layout.Length) { return layout.Fill, layout.Shrink }
func (v *messageView) Layout(limits layout.Limits) layout.Size {
return layout.Sized(limits, layout.Fill, layout.Shrink, func(limits layout.Limits) layout.Size {
v.at(limits.Max.Width)
return layout.Size{Height: len(v.lines)}
})
}
func (v *messageView) Draw(screen tview.Screen, area tview.Rectangle) {
v.at(area.Width).Draw(screen, area)
}
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.AlignmentLeft, d.style)
}
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, grouped bool) richtext.Text {
builder := new(richtext.Builder)
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
}
// shouldGroup reports whether message is drawn under the header of previous, the message before it, which is nil for the first.
func (m Model) shouldGroup(previous *discord.Message, message discord.Message) bool {
cfg := m.cfg.UI.MessagesList.Grouping
@ -470,568 +359,6 @@ func (m *Model) onRowCursorChanged(index int) {
m.setCursor(target)
}
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
}
switch message.Type {
case discord.DefaultMessage:
if message.Reference != nil && message.Reference.Type == discord.MessageReferenceTypeForward {
m.drawForwardedMessage(builder, message, baseStyle, grouped)
} else {
m.drawDefaultMessage(builder, message, baseStyle, width, grouped)
}
case discord.GuildMemberJoinMessage:
m.drawLayout(builder, message, baseStyle, false, func() { builder.Write("joined the server.", baseStyle) })
case discord.InlinedReplyMessage:
m.drawDefaultMessage(builder, message, baseStyle, width, false)
case discord.ChannelPinnedMessage:
m.drawPinnedMessage(builder, message, baseStyle)
default:
m.drawLayout(builder, message, baseStyle, false, nil)
}
m.drawReactions(builder, message.Reactions, baseStyle)
}
func (m Model) drawReactions(builder *richtext.Builder, reactions []discord.Reaction, baseStyle tview.Style) {
if len(reactions) == 0 {
return
}
builder.NewLine()
for i, reaction := range reactions {
if i > 0 {
builder.Write(" ", baseStyle)
}
name := reaction.Emoji.Name
if reaction.Emoji.IsCustom() {
name = ":" + name + ":"
}
style := m.cfg.Theme.MessagesList.ReactionStyle.Style
if reaction.Me {
style = m.cfg.Theme.MessagesList.OwnReactionStyle.Style
}
builder.Write(name+" "+strconv.Itoa(reaction.Count), tview.MergeStyle(baseStyle, style))
}
}
func (m Model) formatTimestamp(ts discord.Timestamp) string {
return ts.Time().In(time.Local).Format(m.cfg.Timestamps.Format)
}
const editedIndicator = " (edited)"
// 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, grouped, content)
return
}
m.drawCozy(builder, message, baseStyle, grouped, content)
}
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)
}
if !grouped {
m.drawAuthor(builder, message, baseStyle)
if content != nil {
builder.Write(" ", baseStyle)
}
}
if content != nil {
content()
}
if message.EditedTimestamp.IsValid() {
builder.Write(editedIndicator, dimStyle)
}
}
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)
}
if message.EditedTimestamp.IsValid() {
builder.Write(editedIndicator, dimStyle)
}
if content != nil {
builder.NewLine()
content()
}
}
func (m Model) drawAuthor(builder *richtext.Builder, message discord.Message, baseStyle tview.Style) {
name := message.Author.DisplayOrUsername()
foreground := tcell.ColorDefault
if member := m.memberForMessage(message); member != nil {
if member.Nick != "" {
name = member.Nick
}
color, ok := state.MemberColor(member, func(id discord.RoleID) *discord.Role {
r, _ := m.state.Cabinet.Role(message.GuildID, id)
return r
})
if ok {
foreground = tcell.NewHexColor(int32(color))
}
}
style := baseStyle.Foreground(foreground).Bold(true)
builder.Write(name, style)
}
func (m Model) memberForMessage(message discord.Message) *discord.Member {
// Webhooks do not have nicknames or roles.
if !message.GuildID.IsValid() || message.WebhookID.IsValid() {
return nil
}
member, err := m.state.Cabinet.Member(message.GuildID, message.Author.ID)
if err != nil {
slog.Error("failed to get member from state", "guild_id", message.GuildID, "member_id", message.Author.ID, "err", err)
return nil
}
return member
}
// drawContent renders the message body and returns the parsed markdown AST together with the source bytes it indexes into, so callers can reuse them instead of re-parsing the same content (see drawEmbeds).
// root is nil when markdown rendering is disabled.
func (m Model) drawContent(builder *richtext.Builder, message discord.Message, baseStyle tview.Style) (ast.Node, []byte) {
content, root, source := m.renderContent(message, baseStyle, false)
if m.cfg.Markdown.Enabled {
startsWithCodeBlock := false
if root != nil {
if first := root.FirstChild(); first != nil {
_, startsWithCodeBlock = first.(*ast.CodeBlock)
}
}
if startsWithCodeBlock {
// Keep code blocks visually separate from "timestamp + author".
if !builder.LineEmpty() {
builder.NewLine()
}
for len(content) > 0 && len(content[0]) == 0 {
content = content[1:]
}
} else {
for len(content) > 1 && len(content[0]) == 0 {
content = content[1:]
}
}
}
builder.WriteText(content)
return root, source
}
func (m Model) renderContent(message discord.Message, baseStyle tview.Style, forceMarkdown bool) (richtext.Text, ast.Node, []byte) {
// Keep one rendering path for both normal messages and embed fragments so we preserve mention/link parsing behavior consistently across both.
if forceMarkdown || m.cfg.Markdown.Enabled {
c := []byte(message.Content)
root := md.ParseWithMessage(c, *m.state.Cabinet, &message)
return m.renderer.RenderText(c, root, baseStyle), root, c
}
b := new(richtext.Builder)
b.Write(message.Content, baseStyle)
return b.Finish(), nil, nil
}
func (m Model) drawSnapshotContent(builder *richtext.Builder, parent discord.Message, snapshot discord.MessageSnapshotMessage, baseStyle tview.Style) {
// Convert discord.MessageSnapshotMessage to discord.Message with common fields.
message := discord.Message{
Type: snapshot.Type,
Content: snapshot.Content,
Embeds: snapshot.Embeds,
Attachments: snapshot.Attachments,
Timestamp: snapshot.Timestamp,
EditedTimestamp: snapshot.EditedTimestamp,
Flags: snapshot.Flags,
Mentions: snapshot.Mentions,
MentionRoleIDs: snapshot.MentionRoleIDs,
Stickers: snapshot.Stickers,
Components: snapshot.Components,
ChannelID: parent.ChannelID,
GuildID: parent.GuildID,
}
m.drawContent(builder, message, baseStyle)
}
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) }
if message.Content == "" {
content = nil
}
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)
// A voice message is shown as its player.
if isVoiceMessage(message) {
return
}
for _, a := range message.Attachments {
builder.NewLine()
if m.cfg.UI.MessagesList.Attachments.ShowLinks {
builder.Write(a.Filename+":", attachmentStyle)
builder.NewLine()
builder.Write(a.URL, attachmentStyle.Url(a.URL))
} else {
builder.Write(a.Filename, attachmentStyle)
}
}
}
const (
symbolPlay = "▶"
symbolPause = "⏸"
// positionSeparator is between how much of a voice message was played and how long it is.
positionSeparator = " / "
// waveformBars is what the waveform of a voice message is drawn with, from quiet to loud.
waveformBars = "▁▂▃▄▅▆▇█"
// waveformWidth is how many bars a waveform is drawn with at most.
waveformWidth = 32
// seekStep is how far a key seeks a voice message.
seekStep = time.Second
)
// waveformRail returns the bars the waveform of a voice message is drawn with.
func waveformRail(waveform []byte) []string {
bars := []rune(waveformBars)
var rail []string
for chunk := range slices.Chunk(waveform, len(waveform)/waveformWidth+1) {
rail = append(rail, string(bars[int(slices.Max(chunk))*len(bars)/256]))
}
return rail
}
func formatDuration(d time.Duration) string {
seconds := int(d.Round(time.Second).Seconds())
return fmt.Sprintf("%d:%02d", seconds/60, seconds%60)
}
// voiceMessageView returns the player of the voice message a: whether it is playing, its waveform with what was played of it, and how long it is.
func (m Model) voiceMessageView(a discord.Attachment) tview.Widget {
duration := time.Duration(a.DurationSecs * float64(time.Second))
symbol, label := symbolPlay, formatDuration(duration)
status := m.voiceStatus
loaded := status.ID == a.ID
if loaded {
label = formatDuration(status.Position) + positionSeparator + label
}
if loaded && status.Playing {
symbol = symbolPause
}
rail := waveformRail(a.Waveform)
style := m.cfg.Theme.MessagesList.AttachmentStyle.Style
// Only the voice message that is loaded can be sought.
var onSeek func(time.Duration) tview.Msg
if loaded {
onSeek = func(position time.Duration) tview.Msg { return seekMsg(position) }
}
waveform := slider.New(0, duration, status.Position, onSeek).
OnPress(func(position time.Duration) tview.Msg { return seekPressMsg(position) }).
OnRelease(seekReleaseMsg{}).
Dragging(loaded && m.seeking).
Width(layout.Fixed(len(rail))).
Rail(func(cell, handle int) (string, tview.Style) {
if loaded && cell <= handle {
return rail[cell], style
}
return rail[cell], style.Dim(true)
})
width, _ := text.New(symbol).Size()
play := button.New().Label(symbol).Width(width).OnClick(playMsg(a))
return row.New(play, waveform, text.New(label)).Spacing(1).Height(layout.Fixed(1))
}
func (m Model) drawEmbeds(builder *richtext.Builder, message discord.Message, baseStyle tview.Style, width int, contentRoot ast.Node, contentSource []byte) {
if len(message.Embeds) == 0 {
return
}
// Embed URLs are deduplicated against links already shown in the message body.
// Reuse the body's parsed AST when markdown is enabled; only the markdown-disabled path (no AST) has to parse the content here.
var contentListURLs []string
if contentRoot != nil {
contentListURLs = urlsFromAST(contentRoot, contentSource)
} else {
contentListURLs = extractURLs(message.Content)
}
contentURLs := make(map[string]struct{}, len(contentListURLs))
for _, u := range contentListURLs {
contentURLs[u] = struct{}{}
}
lineStyles := embedLineStyles(baseStyle, m.cfg.Theme.MessagesList.Embeds)
defaultBarStyle := baseStyle.Dim(true)
prefixText := " ▎ "
prefixWidth := uniseg.StringWidth(prefixText)
// Wrap against the list's width, so the message is rendered again when it changes.
wrapWidth := max(width-prefixWidth, 1)
for _, embed := range message.Embeds {
lines := embedLines(embed, contentURLs)
if len(lines) == 0 {
continue
}
embedText := make(richtext.Text, 0, len(lines)*2)
barStyle := defaultBarStyle
if embed.Color != discord.NullColor && embed.Color != 0 {
barStyle = barStyle.Foreground(tcell.NewHexColor(int32(embed.Color)))
}
prefix := richtext.NewSegment(prefixText, barStyle)
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, lineStyle, line.Kind == embedLineDescription)
for _, renderedLine := range rendered {
if line.URL != "" {
renderedLine = lineWithURL(renderedLine, line.URL)
}
// Prefix must be applied after wrapping so every visual line keeps the embed bar marker ("▎"), not only the first logical line.
for _, wrapped := range richtext.Wrap(renderedLine, wrapWidth) {
embedText = append(embedText, append(richtext.Line{prefix}, wrapped...))
}
}
}
if len(embedText) > 0 {
builder.WriteText(embedText)
}
}
}
func lineWithURL(line richtext.Line, rawURL string) richtext.Line {
out := make(richtext.Line, len(line))
for i, segment := range line {
out[i] = segment
out[i].Style = out[i].Style.Url(rawURL)
}
return out
}
type embedLine struct {
Text string
Kind embedLineKind
URL string
}
type embedLineKind uint8
const (
// Keep this ordering stable: drawEmbeds indexes precomputed style slots by this enum.
embedLineProvider embedLineKind = iota
embedLineAuthor
embedLineTitle
embedLineDescription
embedLineFieldName
embedLineFieldValue
embedLineFooter
embedLineURL
)
func embedLineStyles(baseStyle tview.Style, theme config.MessagesListEmbedsThemeConfig) [8]tview.Style {
styles := [8]tview.Style{}
styles[embedLineProvider] = tview.MergeStyle(baseStyle, theme.ProviderStyle.Style)
styles[embedLineAuthor] = tview.MergeStyle(baseStyle, theme.AuthorStyle.Style)
styles[embedLineTitle] = tview.MergeStyle(baseStyle, theme.TitleStyle.Style)
styles[embedLineDescription] = tview.MergeStyle(baseStyle, theme.DescriptionStyle.Style)
styles[embedLineFieldName] = tview.MergeStyle(baseStyle, theme.FieldNameStyle.Style)
styles[embedLineFieldValue] = tview.MergeStyle(baseStyle, theme.FieldValueStyle.Style)
styles[embedLineFooter] = tview.MergeStyle(baseStyle, theme.FooterStyle.Style)
styles[embedLineURL] = tview.MergeStyle(baseStyle, theme.URLStyle.Style)
return styles
}
type embedLineDedupKey struct {
kind embedLineKind
text string
}
func embedLines(embed discord.Embed, contentURLs map[string]struct{}) []embedLine {
lines := make([]embedLine, 0, 8)
seen := make(map[embedLineDedupKey]struct{}, 8)
appendUnique := func(s string, kind embedLineKind, rawURL string) {
s = strings.TrimSpace(s)
if s == "" {
return
}
// Deduplicate by kind+text so the same value can intentionally appear in multiple semantic slots with different styles (e.g. title vs. field).
key := embedLineDedupKey{kind: kind, text: s}
if _, ok := seen[key]; ok {
return
}
seen[key] = struct{}{}
lines = append(lines, embedLine{
Text: s,
Kind: kind,
URL: rawURL,
})
}
appendURL := func(url discord.URL) {
u := strings.TrimSpace(url)
if u == "" {
return
}
// Avoid duplicating links that already appear in message body content.
if _, ok := contentURLs[u]; ok {
return
}
appendUnique(linkDisplayText(u), embedLineURL, u)
}
if embed.Provider != nil {
appendUnique(embed.Provider.Name, embedLineProvider, "")
}
if embed.Author != nil {
appendUnique(embed.Author.Name, embedLineAuthor, "")
}
appendUnique(embed.Title, embedLineTitle, embed.URL)
// Some Discord embeds include markdown-escaped punctuation in raw payload text (e.g. "\."), so normalize for display.
appendUnique(unescapeMarkdownEscapes(embed.Description), embedLineDescription, "")
for _, field := range embed.Fields {
appendUnique(field.Name, embedLineFieldName, "")
appendUnique(field.Value, embedLineFieldValue, "")
}
if embed.Footer != nil {
appendUnique(embed.Footer.Text, embedLineFooter, "")
}
// Prefer media URLs after textual fields so previews read top-to-bottom before jumping to link targets.
// When a title exists, embed.URL is represented by title Style.Url metadata instead of a separate URL row.
if embed.Title == "" {
appendURL(embed.URL)
}
if embed.Image != nil {
appendURL(embed.Image.URL)
}
if embed.Video != nil {
appendURL(embed.Video.URL)
}
return lines
}
func linkDisplayText(raw string) string {
parsed, err := url.Parse(raw)
if err != nil || parsed.Host == "" {
return raw
}
path := strings.TrimSpace(parsed.EscapedPath())
switch {
case path == "", path == "/":
return parsed.Host
case len(path) > 48:
return parsed.Host + path[:45] + "..."
default:
return parsed.Host + path
}
}
func unescapeMarkdownEscapes(s string) string {
if !strings.ContainsRune(s, '\\') {
return s
}
var b strings.Builder
b.Grow(len(s))
for i := range len(s) {
if s[i] == '\\' && i+1 < len(s) && isMarkdownEscapable(s[i+1]) {
continue
}
b.WriteByte(s[i])
}
return b.String()
}
func isMarkdownEscapable(c byte) bool {
switch c {
case '\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '#', '+', '-', '.', '!', '|', '>', '~':
return true
default:
return false
}
}
func (m Model) drawForwardedMessage(builder *richtext.Builder, message discord.Message, baseStyle tview.Style, grouped bool) {
dimStyle := baseStyle.Dim(true)
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)
})
}
func (m Model) drawReply(builder *richtext.Builder, message discord.Message, baseStyle tview.Style) {
dimStyle := baseStyle.Dim(true)
builder.Write(m.cfg.UI.MessagesList.ReplyIndicator+" ", dimStyle)
if reference := message.ReferencedMessage; reference != nil {
reference.GuildID = message.GuildID
m.drawAuthor(builder, *reference, dimStyle)
builder.Write(" ", dimStyle)
m.drawContent(builder, *reference, dimStyle)
} else {
builder.Write("Original message was deleted", dimStyle)
}
}
func (m Model) drawPinnedMessage(builder *richtext.Builder, message discord.Message, baseStyle tview.Style) {
builder.Write(message.Author.DisplayOrUsername()+" pinned a message.", baseStyle)
}
func (m Model) selectedMessage() (*discord.Message, bool) {
cursor := m.cursor
if cursor < 0 || cursor >= len(m.items) || m.items[cursor].separator {
@ -1238,115 +565,6 @@ func (m *Model) selectReply() {
}
}
func extractURLs(content string) []string {
src := []byte(content)
node := md.Parse(src)
return urlsFromAST(node, src)
}
// urlsFromAST collects link destinations from an already-parsed markdown AST.
// src must be the byte slice the node was parsed from (AutoLink resolves its URL against it).
func urlsFromAST(node ast.Node, src []byte) []string {
var urls []string
_ = ast.Walk(node, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
switch n := n.(type) {
case *ast.AutoLink:
urls = append(urls, string(n.Destination.Bytes(src)))
case *ast.Link:
urls = append(urls, string(n.Destination.Bytes(src)))
}
}
return ast.WalkContinue, nil
})
return urls
}
func extractEmbedURLs(embeds []discord.Embed) []string {
urls := make([]string, 0, len(embeds)*3)
for _, embed := range embeds {
if embed.URL != "" {
urls = append(urls, embed.URL)
}
if embed.Image != nil && embed.Image.URL != "" {
urls = append(urls, embed.Image.URL)
}
if embed.Video != nil && embed.Video.URL != "" {
urls = append(urls, embed.Video.URL)
}
}
return urls
}
// The schemes of the links that are opened.
const (
schemeHTTP = "http"
schemeHTTPS = "https"
)
func messageURLs(msg discord.Message) []string {
combined := slices.Concat(extractURLs(msg.Content), extractEmbedURLs(msg.Embeds))
urls := make([]string, 0, len(combined))
seen := make(map[string]struct{}, len(combined))
for _, u := range combined {
u = strings.TrimSpace(u)
// Only web links are opened, as one of another scheme could run a program.
if parsed, err := url.Parse(u); err != nil || parsed.Scheme != schemeHTTP && parsed.Scheme != schemeHTTPS {
continue
}
if _, ok := seen[u]; ok {
continue
}
seen[u] = struct{}{}
urls = append(urls, u)
}
return urls
}
// get requests the file at url, by a new link to it if that one expired.
func (m Model) get(url string) (*http.Response, error) {
resp, err := http.Get(url)
if err != nil || resp.StatusCode == http.StatusOK {
return resp, err
}
resp.Body.Close() //nolint:errcheck
var links struct {
URLs []struct {
Refreshed string `json:"refreshed"`
} `json:"refreshed_urls"`
}
body := map[string][]string{"attachment_urls": {url}}
if err := m.state.RequestJSONInto(&links, http.MethodPost, api.Endpoint+"attachments/refresh-urls", httputil.WithJSONBody(body)); err != nil {
return nil, err
}
if len(links.URLs) != 0 {
if resp, err = http.Get(links.URLs[0].Refreshed); err != nil || resp.StatusCode == http.StatusOK {
return resp, err
}
resp.Body.Close() //nolint:errcheck
}
return nil, fmt.Errorf("unexpected HTTP status: %s", resp.Status)
}
func (m Model) downloadAttachment(attachment discord.Attachment, destination string) error {
resp, err := m.get(attachment.URL)
if err != nil {
return err
}
defer resp.Body.Close() //nolint:errcheck
file, err := os.Create(destination)
if err != nil {
return err
}
_, err = io.Copy(file, resp.Body)
return errors.Join(err, file.Close())
}
// SelectLastOwn selects the last message in the selected channel that the user can edit, and returns it.
func (m *Model) SelectLastOwn() (discord.Message, bool) {
channel := m.channel

View file

@ -0,0 +1,87 @@
package messageslist
import (
"fmt"
"slices"
"time"
"github.com/ayn2op/arikawa/v3/discord"
"github.com/ayn2op/tview"
"github.com/ayn2op/tview/button"
"github.com/ayn2op/tview/layout"
"github.com/ayn2op/tview/row"
"github.com/ayn2op/tview/slider"
"github.com/ayn2op/tview/text"
)
const (
symbolPlay = "▶"
symbolPause = "⏸"
// positionSeparator is between how much of a voice message was played and how long it is.
positionSeparator = " / "
// waveformBars is what the waveform of a voice message is drawn with, from quiet to loud.
waveformBars = "▁▂▃▄▅▆▇█"
// waveformWidth is how many bars a waveform is drawn with at most.
waveformWidth = 32
// seekStep is how far a key seeks a voice message.
seekStep = time.Second
)
// waveformRail returns the bars the waveform of a voice message is drawn with.
func waveformRail(waveform []byte) []string {
bars := []rune(waveformBars)
var rail []string
for chunk := range slices.Chunk(waveform, len(waveform)/waveformWidth+1) {
rail = append(rail, string(bars[int(slices.Max(chunk))*len(bars)/256]))
}
return rail
}
func formatDuration(d time.Duration) string {
seconds := int(d.Round(time.Second).Seconds())
return fmt.Sprintf("%d:%02d", seconds/60, seconds%60)
}
// voiceMessageView returns the player of the voice message a: whether it is playing, its waveform with what was played of it, and how long it is.
func (m Model) voiceMessageView(a discord.Attachment) tview.Widget {
duration := time.Duration(a.DurationSecs * float64(time.Second))
symbol, label := symbolPlay, formatDuration(duration)
status := m.voiceStatus
loaded := status.ID == a.ID
if loaded {
label = formatDuration(status.Position) + positionSeparator + label
}
if loaded && status.Playing {
symbol = symbolPause
}
rail := waveformRail(a.Waveform)
style := m.cfg.Theme.MessagesList.AttachmentStyle.Style
// Only the voice message that is loaded can be sought.
var onSeek func(time.Duration) tview.Msg
if loaded {
onSeek = func(position time.Duration) tview.Msg { return seekMsg(position) }
}
waveform := slider.New(0, duration, status.Position, onSeek).
OnPress(func(position time.Duration) tview.Msg { return seekPressMsg(position) }).
OnRelease(seekReleaseMsg{}).
Dragging(loaded && m.seeking).
Width(layout.Fixed(len(rail))).
Rail(func(cell, handle int) (string, tview.Style) {
if loaded && cell <= handle {
return rail[cell], style
}
return rail[cell], style.Dim(true)
})
width, _ := text.New(symbol).Size()
play := button.New().Label(symbol).Width(width).OnClick(playMsg(a))
return row.New(play, waveform, text.New(label)).Spacing(1).Height(layout.Fixed(1))
}