diff --git a/internal/ui/chat/messageslist/download.go b/internal/ui/chat/messageslist/download.go new file mode 100644 index 0000000..0e48e16 --- /dev/null +++ b/internal/ui/chat/messageslist/download.go @@ -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()) +} diff --git a/internal/ui/chat/messageslist/embed.go b/internal/ui/chat/messageslist/embed.go new file mode 100644 index 0000000..6e1a7aa --- /dev/null +++ b/internal/ui/chat/messageslist/embed.go @@ -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 + } +} diff --git a/internal/ui/chat/messageslist/links.go b/internal/ui/chat/messageslist/links.go new file mode 100644 index 0000000..5e11a2b --- /dev/null +++ b/internal/ui/chat/messageslist/links.go @@ -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 +} diff --git a/internal/ui/chat/messageslist/message.go b/internal/ui/chat/messageslist/message.go new file mode 100644 index 0000000..8a19074 --- /dev/null +++ b/internal/ui/chat/messageslist/message.go @@ -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) +} diff --git a/internal/ui/chat/messageslist/model.go b/internal/ui/chat/messageslist/model.go index 9bb905a..65b0d9c 100644 --- a/internal/ui/chat/messageslist/model.go +++ b/internal/ui/chat/messageslist/model.go @@ -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 diff --git a/internal/ui/chat/messageslist/voice.go b/internal/ui/chat/messageslist/voice.go new file mode 100644 index 0000000..d15da3b --- /dev/null +++ b/internal/ui/chat/messageslist/voice.go @@ -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)) +}