refactor(messageslist): split model into files by concern
This commit is contained in:
parent
25957fd3fd
commit
e38b7490ad
6 changed files with 832 additions and 783 deletions
55
internal/ui/chat/messageslist/download.go
Normal file
55
internal/ui/chat/messageslist/download.go
Normal 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())
|
||||
}
|
||||
236
internal/ui/chat/messageslist/embed.go
Normal file
236
internal/ui/chat/messageslist/embed.go
Normal 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
|
||||
}
|
||||
}
|
||||
78
internal/ui/chat/messageslist/links.go
Normal file
78
internal/ui/chat/messageslist/links.go
Normal 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
|
||||
}
|
||||
375
internal/ui/chat/messageslist/message.go
Normal file
375
internal/ui/chat/messageslist/message.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
87
internal/ui/chat/messageslist/voice.go
Normal file
87
internal/ui/chat/messageslist/voice.go
Normal 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))
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue