|
|
@@ -3,25 +3,15 @@ package chat
|
|
|
import (
|
|
|
"context"
|
|
|
"errors"
|
|
|
- "fmt"
|
|
|
- "io"
|
|
|
"log/slog"
|
|
|
- "net/http"
|
|
|
- "net/url"
|
|
|
- "os"
|
|
|
- "os/exec"
|
|
|
- "path/filepath"
|
|
|
"slices"
|
|
|
"strings"
|
|
|
"sync"
|
|
|
"time"
|
|
|
"unicode/utf8"
|
|
|
|
|
|
- "github.com/ayn2op/tview/layers"
|
|
|
-
|
|
|
"github.com/ayn2op/discordo/internal/clipboard"
|
|
|
"github.com/ayn2op/discordo/internal/config"
|
|
|
- "github.com/ayn2op/discordo/internal/consts"
|
|
|
"github.com/ayn2op/discordo/internal/markdown"
|
|
|
"github.com/ayn2op/discordo/internal/ui"
|
|
|
"github.com/ayn2op/tview"
|
|
|
@@ -36,11 +26,7 @@ import (
|
|
|
"github.com/diamondburned/ningen/v3/discordmd"
|
|
|
"github.com/gdamore/tcell/v3"
|
|
|
"github.com/gdamore/tcell/v3/color"
|
|
|
- "github.com/rivo/uniseg"
|
|
|
- "github.com/skratchdot/open-golang/open"
|
|
|
"github.com/yuin/goldmark/ast"
|
|
|
- "github.com/yuin/goldmark/parser"
|
|
|
- "github.com/yuin/goldmark/text"
|
|
|
)
|
|
|
|
|
|
type messagesList struct {
|
|
|
@@ -569,241 +555,6 @@ func (ml *messagesList) drawEmbeds(builder *tview.LineBuilder, message discord.M
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-func wrapStyledLine(line tview.Line, width int) []tview.Line {
|
|
|
- if width <= 0 {
|
|
|
- return []tview.Line{line}
|
|
|
- }
|
|
|
- if len(line) == 0 {
|
|
|
- return []tview.Line{line}
|
|
|
- }
|
|
|
-
|
|
|
- lines := make([]tview.Line, 0, 2)
|
|
|
- current := make(tview.Line, 0, len(line))
|
|
|
- currentWidth := 0
|
|
|
-
|
|
|
- pushSegment := func(text string, style tcell.Style) {
|
|
|
- if text == "" {
|
|
|
- return
|
|
|
- }
|
|
|
- if n := len(current); n > 0 && current[n-1].Style == style {
|
|
|
- current[n-1].Text += text
|
|
|
- return
|
|
|
- }
|
|
|
- current = append(current, tview.Segment{Text: text, Style: style})
|
|
|
- }
|
|
|
-
|
|
|
- flush := func() {
|
|
|
- lineCopy := make(tview.Line, len(current))
|
|
|
- copy(lineCopy, current)
|
|
|
- lines = append(lines, lineCopy)
|
|
|
- current = current[:0]
|
|
|
- currentWidth = 0
|
|
|
- }
|
|
|
-
|
|
|
- for _, segment := range line {
|
|
|
- state := -1
|
|
|
- rest := segment.Text
|
|
|
- for len(rest) > 0 {
|
|
|
- cluster, nextRest, boundaries, nextState := uniseg.StepString(rest, state)
|
|
|
- state = nextState
|
|
|
- rest = nextRest
|
|
|
- if cluster == "" {
|
|
|
- continue
|
|
|
- }
|
|
|
-
|
|
|
- // Use grapheme width (not rune count) so wrapping stays correct with wide glyphs, emoji, and combining characters.
|
|
|
- clusterWidth := graphemeClusterWidth(boundaries)
|
|
|
- if currentWidth > 0 && currentWidth+clusterWidth > width {
|
|
|
- flush()
|
|
|
- }
|
|
|
- pushSegment(cluster, segment.Style)
|
|
|
- currentWidth += clusterWidth
|
|
|
-
|
|
|
- if currentWidth >= width {
|
|
|
- flush()
|
|
|
- }
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- if len(current) > 0 {
|
|
|
- flush()
|
|
|
- }
|
|
|
- if len(lines) == 0 {
|
|
|
- return []tview.Line{{}}
|
|
|
- }
|
|
|
- return lines
|
|
|
-}
|
|
|
-
|
|
|
-func graphemeClusterWidth(boundaries int) int {
|
|
|
- return boundaries >> uniseg.ShiftWidth
|
|
|
-}
|
|
|
-
|
|
|
-func lineWithURL(line tview.Line, rawURL string) tview.Line {
|
|
|
- out := make(tview.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
|
|
|
- embedLineKindCount
|
|
|
-)
|
|
|
-
|
|
|
-func embedLineStyles(baseStyle tcell.Style, theme config.MessagesListEmbedsTheme) [embedLineKindCount]tcell.Style {
|
|
|
- styles := [embedLineKindCount]tcell.Style{}
|
|
|
- styles[embedLineProvider] = ui.MergeStyle(baseStyle, theme.ProviderStyle.Style)
|
|
|
- styles[embedLineAuthor] = ui.MergeStyle(baseStyle, theme.AuthorStyle.Style)
|
|
|
- styles[embedLineTitle] = ui.MergeStyle(baseStyle, theme.TitleStyle.Style)
|
|
|
- styles[embedLineDescription] = ui.MergeStyle(baseStyle, theme.DescriptionStyle.Style)
|
|
|
- styles[embedLineFieldName] = ui.MergeStyle(baseStyle, theme.FieldNameStyle.Style)
|
|
|
- styles[embedLineFieldValue] = ui.MergeStyle(baseStyle, theme.FieldValueStyle.Style)
|
|
|
- styles[embedLineFooter] = ui.MergeStyle(baseStyle, theme.FooterStyle.Style)
|
|
|
- styles[embedLineURL] = ui.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(string(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, string(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 {
|
|
|
- switch {
|
|
|
- case field.Name != "" && field.Value != "":
|
|
|
- appendUnique(field.Name, embedLineFieldName, "")
|
|
|
- appendUnique(field.Value, embedLineFieldValue, "")
|
|
|
- case field.Name != "":
|
|
|
- appendUnique(field.Name, embedLineFieldName, "")
|
|
|
- default:
|
|
|
- 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 (ml *messagesList) drawForwardedMessage(builder *tview.LineBuilder, message discord.Message, baseStyle tcell.Style) {
|
|
|
dimStyle := baseStyle.Dim(true)
|
|
|
ml.drawTimestamps(builder, message.Timestamp, baseStyle)
|
|
|
@@ -1092,307 +843,6 @@ func (ml *messagesList) open() {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
-func extractURLs(content string) []string {
|
|
|
- src := []byte(content)
|
|
|
- node := parser.NewParser(
|
|
|
- parser.WithBlockParsers(discordmd.BlockParsers()...),
|
|
|
- parser.WithInlineParsers(discordmd.InlineParserWithLink()...),
|
|
|
- ).Parse(text.NewReader(src))
|
|
|
-
|
|
|
- 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.URL(src)))
|
|
|
- case *ast.Link:
|
|
|
- urls = append(urls, string(n.Destination))
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- 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, string(embed.URL))
|
|
|
- }
|
|
|
- if embed.Image != nil && embed.Image.URL != "" {
|
|
|
- urls = append(urls, string(embed.Image.URL))
|
|
|
- }
|
|
|
- if embed.Video != nil && embed.Video.URL != "" {
|
|
|
- urls = append(urls, string(embed.Video.URL))
|
|
|
- }
|
|
|
- }
|
|
|
- return urls
|
|
|
-}
|
|
|
-
|
|
|
-func messageURLs(msg discord.Message) []string {
|
|
|
- combined := extractURLs(msg.Content)
|
|
|
- combined = append(combined, extractEmbedURLs(msg.Embeds)...)
|
|
|
-
|
|
|
- urls := make([]string, 0, len(combined))
|
|
|
- seen := make(map[string]struct{}, len(combined))
|
|
|
- for _, u := range combined {
|
|
|
- u = strings.TrimSpace(u)
|
|
|
- if u == "" {
|
|
|
- continue
|
|
|
- }
|
|
|
- if _, ok := seen[u]; ok {
|
|
|
- continue
|
|
|
- }
|
|
|
- seen[u] = struct{}{}
|
|
|
- urls = append(urls, u)
|
|
|
- }
|
|
|
- return urls
|
|
|
-}
|
|
|
-
|
|
|
-func (ml *messagesList) showAttachmentsList(urls []string, attachments []discord.Attachment) {
|
|
|
- var items []attachmentItem
|
|
|
- for _, a := range attachments {
|
|
|
- attachment := a
|
|
|
- action := func() {
|
|
|
- if strings.HasPrefix(attachment.ContentType, "image/") {
|
|
|
- go ml.openAttachment(attachment)
|
|
|
- } else {
|
|
|
- go ml.openURL(attachment.URL)
|
|
|
- }
|
|
|
- }
|
|
|
- items = append(items, attachmentItem{
|
|
|
- label: attachment.Filename,
|
|
|
- open: action,
|
|
|
- })
|
|
|
- }
|
|
|
- for _, u := range urls {
|
|
|
- url := u
|
|
|
- items = append(items, attachmentItem{
|
|
|
- label: url,
|
|
|
- open: func() { go ml.openURL(url) },
|
|
|
- })
|
|
|
- }
|
|
|
- ml.attachmentsPicker.SetItems(items)
|
|
|
- ml.showAttachmentsOverlay()
|
|
|
-}
|
|
|
-
|
|
|
-func (ml *messagesList) showAttachmentsOverlay() {
|
|
|
- ml.chatView.
|
|
|
- AddLayer(
|
|
|
- ui.Centered(ml.attachmentsPicker, ml.cfg.Picker.Width, ml.cfg.Picker.Height),
|
|
|
- layers.WithName(attachmentsListLayerName),
|
|
|
- layers.WithResize(true),
|
|
|
- layers.WithVisible(true),
|
|
|
- layers.WithOverlay(),
|
|
|
- ).
|
|
|
- SendToFront(attachmentsListLayerName)
|
|
|
- ml.chatView.app.SetFocus(ml.attachmentsPicker)
|
|
|
-}
|
|
|
-
|
|
|
-const maxAttachmentSize = 100 * 1024 * 1024 // 100 MB
|
|
|
-
|
|
|
-func (ml *messagesList) downloadToCache(attachment discord.Attachment) (string, error) {
|
|
|
- parsed, err := url.Parse(attachment.URL)
|
|
|
- if err != nil {
|
|
|
- return "", fmt.Errorf("invalid attachment URL: %w", err)
|
|
|
- }
|
|
|
- if parsed.Scheme != "https" {
|
|
|
- return "", fmt.Errorf("refusing non-HTTPS attachment URL: %s", parsed.Scheme)
|
|
|
- }
|
|
|
-
|
|
|
- resp, err := http.Get(attachment.URL)
|
|
|
- if err != nil {
|
|
|
- return "", fmt.Errorf("failed to fetch attachment: %w", err)
|
|
|
- }
|
|
|
- defer resp.Body.Close()
|
|
|
-
|
|
|
- if resp.StatusCode != http.StatusOK {
|
|
|
- return "", fmt.Errorf("unexpected status %d fetching attachment", resp.StatusCode)
|
|
|
- }
|
|
|
-
|
|
|
- dir := filepath.Join(consts.CacheDir(), "attachments")
|
|
|
- if err := os.MkdirAll(dir, 0700); err != nil {
|
|
|
- return "", fmt.Errorf("failed to create attachments dir: %w", err)
|
|
|
- }
|
|
|
-
|
|
|
- safeName := filepath.Base(attachment.Filename)
|
|
|
- if safeName == "." || safeName == ".." {
|
|
|
- safeName = "attachment"
|
|
|
- }
|
|
|
- path := filepath.Join(dir, safeName)
|
|
|
- file, err := os.Create(path)
|
|
|
- if err != nil {
|
|
|
- return "", fmt.Errorf("failed to create attachment file: %w", err)
|
|
|
- }
|
|
|
- defer file.Close()
|
|
|
-
|
|
|
- if _, err := io.Copy(file, io.LimitReader(resp.Body, maxAttachmentSize)); err != nil {
|
|
|
- return "", fmt.Errorf("failed to write attachment file: %w", err)
|
|
|
- }
|
|
|
-
|
|
|
- return path, nil
|
|
|
-}
|
|
|
-
|
|
|
-var supportedImageTypes = map[string]bool{
|
|
|
- "image/jpeg": true,
|
|
|
- "image/png": true,
|
|
|
- "image/webp": true,
|
|
|
- "image/gif": true,
|
|
|
-}
|
|
|
-
|
|
|
-func terminalGeometry() string {
|
|
|
- out, err := exec.Command("xdotool", "getactivewindow", "getwindowgeometry", "--shell").Output()
|
|
|
- if err != nil {
|
|
|
- return ""
|
|
|
- }
|
|
|
- var x, y, w, h string
|
|
|
- for _, line := range strings.Split(string(out), "\n") {
|
|
|
- switch {
|
|
|
- case strings.HasPrefix(line, "X="):
|
|
|
- x = strings.TrimPrefix(line, "X=")
|
|
|
- case strings.HasPrefix(line, "Y="):
|
|
|
- y = strings.TrimPrefix(line, "Y=")
|
|
|
- case strings.HasPrefix(line, "WIDTH="):
|
|
|
- w = strings.TrimPrefix(line, "WIDTH=")
|
|
|
- case strings.HasPrefix(line, "HEIGHT="):
|
|
|
- h = strings.TrimPrefix(line, "HEIGHT=")
|
|
|
- }
|
|
|
- }
|
|
|
- if w != "" && h != "" && x != "" && y != "" {
|
|
|
- return w + "x" + h + "+" + x + "+" + y
|
|
|
- }
|
|
|
- return ""
|
|
|
-}
|
|
|
-
|
|
|
-func viewerArgs(viewer, path string) []string {
|
|
|
- if strings.HasSuffix(viewer, "mpv") || strings.Contains(viewer, "mpv ") {
|
|
|
- args := []string{viewer, "--force-window", "--loop-file=inf"}
|
|
|
- if geom := terminalGeometry(); geom != "" {
|
|
|
- args = append(args, "--geometry="+geom)
|
|
|
- }
|
|
|
- args = append(args, path)
|
|
|
- return args
|
|
|
- }
|
|
|
- return []string{viewer, path}
|
|
|
-}
|
|
|
-
|
|
|
-func (ml *messagesList) openAttachment(attachment discord.Attachment) {
|
|
|
- path, err := ml.downloadToCache(attachment)
|
|
|
- if err != nil {
|
|
|
- slog.Error("failed to download attachment", "err", err, "url", attachment.URL)
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- viewer := ml.cfg.ImageViewer
|
|
|
- if viewer != "" && supportedImageTypes[attachment.ContentType] {
|
|
|
- if _, err := exec.LookPath(viewer); err != nil {
|
|
|
- slog.Error("image viewer not found in PATH", "viewer", viewer, "err", err)
|
|
|
- return
|
|
|
- }
|
|
|
- args := viewerArgs(viewer, path)
|
|
|
- cmd := exec.Command(args[0], args[1:]...)
|
|
|
- cmd.Stdin = os.Stdin
|
|
|
- cmd.Stdout = os.Stdout
|
|
|
- cmd.Stderr = os.Stderr
|
|
|
- ml.chatView.app.Suspend(func() {
|
|
|
- if err := cmd.Run(); err != nil {
|
|
|
- slog.Error("failed to run image viewer", "viewer", viewer, "err", err)
|
|
|
- }
|
|
|
- })
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- if err := open.Start(path); err != nil {
|
|
|
- slog.Error("failed to open attachment file", "err", err, "path", path)
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
-func (ml *messagesList) saveImage() {
|
|
|
- msg, err := ml.selectedMessage()
|
|
|
- if err != nil {
|
|
|
- slog.Error("failed to get selected message", "err", err)
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- var images []discord.Attachment
|
|
|
- for _, a := range msg.Attachments {
|
|
|
- if supportedImageTypes[a.ContentType] {
|
|
|
- images = append(images, a)
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
- if len(images) == 0 {
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- if len(images) == 1 {
|
|
|
- go ml.saveAttachmentImage(images[0])
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- var items []attachmentItem
|
|
|
- for _, a := range images {
|
|
|
- attachment := a
|
|
|
- items = append(items, attachmentItem{
|
|
|
- label: attachment.Filename,
|
|
|
- open: func() { go ml.saveAttachmentImage(attachment) },
|
|
|
- })
|
|
|
- }
|
|
|
- ml.attachmentsPicker.SetItems(items)
|
|
|
- ml.showAttachmentsOverlay()
|
|
|
-}
|
|
|
-
|
|
|
-func (ml *messagesList) saveAttachmentImage(attachment discord.Attachment) {
|
|
|
- path, err := ml.downloadToCache(attachment)
|
|
|
- if err != nil {
|
|
|
- slog.Error("failed to download attachment", "err", err, "url", attachment.URL)
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- saveDir := ml.cfg.ImageSaveDir
|
|
|
- if saveDir == "" {
|
|
|
- saveDir = "."
|
|
|
- }
|
|
|
- if err := os.MkdirAll(saveDir, 0700); err != nil {
|
|
|
- slog.Error("failed to create save directory", "err", err, "path", saveDir)
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- safeName := filepath.Base(attachment.Filename)
|
|
|
- if safeName == "." || safeName == ".." {
|
|
|
- safeName = "attachment"
|
|
|
- }
|
|
|
- destPath := filepath.Join(saveDir, safeName)
|
|
|
- src, err := os.Open(path)
|
|
|
- if err != nil {
|
|
|
- slog.Error("failed to open cached file", "err", err, "path", path)
|
|
|
- return
|
|
|
- }
|
|
|
- defer src.Close()
|
|
|
-
|
|
|
- dst, err := os.Create(destPath)
|
|
|
- if err != nil {
|
|
|
- slog.Error("failed to create save file", "err", err, "path", destPath)
|
|
|
- return
|
|
|
- }
|
|
|
- defer dst.Close()
|
|
|
-
|
|
|
- if _, err := io.Copy(dst, src); err != nil {
|
|
|
- slog.Error("failed to save image", "err", err)
|
|
|
- return
|
|
|
- }
|
|
|
-
|
|
|
- slog.Info("image saved", "path", destPath)
|
|
|
-}
|
|
|
-
|
|
|
-func (ml *messagesList) openURL(url string) {
|
|
|
- if err := open.Start(url); err != nil {
|
|
|
- slog.Error("failed to open URL", "err", err, "url", url)
|
|
|
- }
|
|
|
-}
|
|
|
-
|
|
|
func (ml *messagesList) reply(mention bool) {
|
|
|
message, err := ml.selectedMessage()
|
|
|
if err != nil {
|