Эх сурвалжийг харах

feat(ui/chat): add image viewing with configurable viewer and save keybind

Fix broken attachment URL rendering caused by embedded \n in a single
Write() call. Add configurable image_viewer (default: imv) that suspends
the TUI to display image attachments inline. Add save_image keybind (S)
to save image attachments to a configurable directory. Supports jpeg,
png, webp, and gif.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
claude 1 сар өмнө
parent
commit
9ab7409dde

+ 191 - 0
images.plan

@@ -0,0 +1,191 @@
+# Image Viewing & Save Feature — Implementation Plan
+
+## Overview
+
+Add lightweight image viewing and saving for image attachments (jpg, jpeg, png, webp, gif)
+in discordo. Uses an external viewer (`imv` by default, configurable) that can be dismissed
+with `q`. Adds a new keybind to save the highlighted message's image attachment to a
+user-configured directory.
+
+---
+
+## Bug Fix (prerequisite)
+
+### 1. Fix broken attachment link rendering
+
+**File:** `internal/ui/chat/messages_list.go` ~line 500
+
+**Problem:** Attachment URLs are rendered with a literal `\n` inside a single
+`builder.Write()` call:
+
+```go
+builder.Write(a.Filename+":\n"+a.URL, attachmentStyle)
+```
+
+The `\n` is embedded in the segment text but `tview.LineBuilder` does not split
+on `\n` within a `Write()` call — the newline character ends up treated as inline
+content, corrupting the URL display and breaking the link.
+
+**Fix:** Split into two writes separated by `builder.NewLine()`:
+
+```go
+builder.Write(a.Filename+":", attachmentStyle)
+builder.NewLine()
+builder.Write(a.URL, attachmentStyle.Url(a.URL))
+```
+
+This also applies the `.Url()` style metadata so the link is properly clickable
+in terminals that support OSC 8 hyperlinks.
+
+---
+
+## Feature Changes
+
+### 2. Add config fields for image viewer and save directory
+
+**File:** `internal/config/config.go`
+
+Add two new fields to the `Config` struct:
+
+```go
+ImageViewer  string `toml:"image_viewer"`   // e.g. "imv", "feh", "sxiv"
+ImageSaveDir string `toml:"image_save_dir"` // e.g. "~/Pictures/discordo"
+```
+
+**File:** `internal/config/config.toml`
+
+Add defaults:
+
+```toml
+# Program used to view image attachments. Must accept a file path as its
+# first argument. Set to "default" to use xdg-open / open.
+image_viewer = "imv"
+
+# Directory where images are saved with the save_image keybind.
+# Supports ~ for home directory. Leave empty to use the current directory.
+image_save_dir = ""
+```
+
+**File:** `internal/config/config.go` — `applyDefaults()`
+
+- Expand `~` in `ImageSaveDir` to the user's home directory.
+- If `ImageViewer` is `"default"` or empty, fall back to `open.Start()` behavior
+  (system default).
+
+### 3. Add `save_image` keybind
+
+**File:** `internal/config/keybinds.go`
+
+Add to `MessagesListKeybinds`:
+
+```go
+SaveImage Keybind `toml:"save_image"`
+```
+
+Default key: `S` (capital S — currently unused in messages list).
+
+**File:** `internal/config/config.toml`
+
+```toml
+# Save the selected message's image attachment to image_save_dir.
+save_image = "S"
+```
+
+### 4. Change `openAttachment()` to use configured image viewer
+
+**File:** `internal/ui/chat/messages_list.go`
+
+Modify `openAttachment()` (line 1183):
+
+- After downloading the attachment to cache (existing logic), instead of calling
+  `open.Start(path)`, check `ml.cfg.ImageViewer`:
+  - If set and not `"default"`: exec the viewer with the cached file path as arg,
+    e.g. `exec.Command(ml.cfg.ImageViewer, path).Run()`.
+  - If `"default"` or empty: fall back to `open.Start(path)` (current behavior).
+- The viewer command runs in a goroutine. While the viewer is open, discordo's
+  TUI is suspended (same pattern as the external editor: `ml.chatView.app.Suspend()`
+  + resume after the command exits). This lets terminal-based viewers like `imv`
+  take over the terminal cleanly.
+
+Affected types: jpg, jpeg, png, webp, gif — filter on `attachment.ContentType`
+matching `image/jpeg`, `image/png`, `image/webp`, `image/gif`. For non-image
+attachments, keep existing `open.Start(url)` behavior.
+
+### 5. Implement `saveImage()` function and wire up keybind
+
+**File:** `internal/ui/chat/messages_list.go`
+
+New function `saveImage()`:
+
+```
+func (ml *messagesList) saveImage()
+```
+
+Logic:
+1. Get the selected message via `ml.selectedMessage()`.
+2. Filter `msg.Attachments` to only image types (jpg, jpeg, png, webp, gif) by
+   checking `attachment.ContentType`.
+3. If no image attachments, return early (no-op).
+4. If one image: download and save directly.
+5. If multiple images: show the attachments picker (reuse existing
+   `showAttachmentsList` pattern but with save actions instead of open actions).
+6. Download the image via HTTP (reuse the download logic from `openAttachment`
+   — extract into a shared helper `downloadAttachment()` that returns the local
+   file path).
+7. Copy the file from cache to `ml.cfg.ImageSaveDir/filename`. If
+   `ImageSaveDir` is empty, save to the current working directory.
+8. Log success/failure via `slog`.
+
+**File:** `internal/ui/chat/messages_list.go` — keybind registration
+
+In the function that registers messages list keybinds (look for where `Open` keybind
+is registered), add the `SaveImage` keybind pointing to `ml.saveImage`.
+
+### 6. Extract shared download helper
+
+**File:** `internal/ui/chat/messages_list.go`
+
+Refactor the HTTP download + cache write logic currently in `openAttachment()` into:
+
+```go
+func (ml *messagesList) downloadToCache(attachment discord.Attachment) (string, error)
+```
+
+Returns the local cached file path. Used by both `openAttachment()` and `saveImage()`.
+
+---
+
+## File Change Summary
+
+| File | Changes |
+|------|---------|
+| `internal/ui/chat/messages_list.go` | Fix attachment link rendering; refactor download logic; modify `openAttachment()` to use configured viewer with app suspend/resume; add `saveImage()`; register new keybind |
+| `internal/config/config.go` | Add `ImageViewer` and `ImageSaveDir` fields; expand `~` in defaults |
+| `internal/config/config.toml` | Add `image_viewer`, `image_save_dir`, and `save_image` keybind defaults |
+| `internal/config/keybinds.go` | Add `SaveImage` keybind to `MessagesListKeybinds` and default |
+
+---
+
+## Implementation Order
+
+1. Fix the `\n` attachment link bug (quick, independent)
+2. Add config fields + defaults (`config.go`, `config.toml`)
+3. Add `SaveImage` keybind (`keybinds.go`, `config.toml`)
+4. Extract `downloadToCache()` helper from `openAttachment()`
+5. Modify `openAttachment()` to use configured viewer + suspend/resume
+6. Implement `saveImage()` and wire up keybind
+
+---
+
+## Notes
+
+- `imv` is chosen as the default viewer because it's lightweight, supports
+  Wayland and X11, handles all target formats (jpg, png, webp, gif) out of
+  the box via FreeImage/libnsgif, and exits on `q` by default.
+- Users who prefer X11-only can set `image_viewer = "feh"` (note: `feh` lacks
+  WebP support on many distros).
+- Users on macOS can set `image_viewer = "open"` or `image_viewer = "default"`.
+- The suspend/resume pattern for the viewer follows the same approach already
+  used for the external editor (see `message_input.go` `openEditor` flow).
+- No new Go dependencies are needed — `os/exec` is in the stdlib and HTTP
+  download logic already exists.

+ 12 - 0
internal/config/config.go

@@ -6,6 +6,7 @@ import (
 	"log/slog"
 	"os"
 	"path/filepath"
+	"strings"
 	"unicode/utf8"
 
 	"github.com/BurntSushi/toml"
@@ -92,6 +93,8 @@ type (
 		Status              discord.Status `toml:"status"`
 		HideBlockedUsers    bool           `toml:"hide_blocked_users"`
 		ShowAttachmentLinks bool           `toml:"show_attachment_links"`
+		ImageViewer         string         `toml:"image_viewer"`
+		ImageSaveDir        string         `toml:"image_save_dir"`
 
 		// Use 0 to disable
 		AutocompleteLimit uint8 `toml:"autocomplete_limit"`
@@ -171,6 +174,15 @@ func applyDefaults(cfg *Config) {
 		cfg.Status = discord.UnknownStatus
 	}
 
+	if cfg.ImageViewer == "default" {
+		cfg.ImageViewer = ""
+	}
+	if strings.HasPrefix(cfg.ImageSaveDir, "~/") {
+		if home, err := os.UserHomeDir(); err == nil {
+			cfg.ImageSaveDir = filepath.Join(home, cfg.ImageSaveDir[2:])
+		}
+	}
+
 	if cfg.DateSeparator.Format == "" {
 		cfg.DateSeparator.Format = "January 2, 2006"
 	}

+ 10 - 0
internal/config/config.toml

@@ -13,6 +13,14 @@ status = "default"
 hide_blocked_users = true
 show_attachment_links = true
 
+# Program used to view image attachments. Must accept a file path as its
+# first argument. Set to "default" to use xdg-open / open.
+image_viewer = "imv"
+
+# Directory where images are saved with the save_image keybind.
+# Supports ~ for home directory. Leave empty to use the current directory.
+image_save_dir = ""
+
 # Max members to be in the mention autocomplete suggestions list
 # Note: Use autocomplete_limit = 0 to disable.
 autocomplete_limit = 20
@@ -143,6 +151,8 @@ delete_confirm = "d"
 # Open the selected message's attachments or hyperlinks in the message
 # using the default browser application.
 open = "o"
+# Save the selected message's image attachment to image_save_dir.
+save_image = "S"
 # Yank (copy) the selected message's content/url/id.
 yank_content = "y"
 yank_url = "u"

+ 3 - 0
internal/config/keybinds.go

@@ -90,6 +90,8 @@ type MessagesListKeybinds struct {
 	DeleteConfirm Keybind `toml:"delete_confirm"`
 	Open          Keybind `toml:"open"`
 
+	SaveImage Keybind `toml:"save_image"`
+
 	YankContent Keybind `toml:"yank_content"`
 	YankURL     Keybind `toml:"yank_url"`
 	YankID      Keybind `toml:"yank_id"`
@@ -190,6 +192,7 @@ func defaultMessagesListKeybinds() MessagesListKeybinds {
 			"delete",
 		),
 		Open:        newKeybind("o", "open"),
+		SaveImage:   newKeybind("S", "save image"),
 		YankContent: newKeybind("y", "copy text"),
 		YankURL:     newKeybind("u", "copy url"),
 		YankID:      newKeybind("i", "copy id"),

+ 129 - 13
internal/ui/chat/messages_list.go

@@ -3,11 +3,13 @@ package chat
 import (
 	"context"
 	"errors"
+	"fmt"
 	"io"
 	"log/slog"
 	"net/http"
 	"net/url"
 	"os"
+	"os/exec"
 	"path/filepath"
 	"slices"
 	"strings"
@@ -497,7 +499,9 @@ func (ml *messagesList) drawDefaultMessage(builder *tview.LineBuilder, message d
 	for _, a := range message.Attachments {
 		builder.NewLine()
 		if ml.cfg.ShowAttachmentLinks {
-			builder.Write(a.Filename+":\n"+a.URL, attachmentStyle)
+			builder.Write(a.Filename+":", attachmentStyle)
+			builder.NewLine()
+			builder.Write(a.URL, attachmentStyle.Url(a.URL))
 		} else {
 			builder.Write(a.Filename, attachmentStyle)
 		}
@@ -874,6 +878,9 @@ func (ml *messagesList) HandleEvent(event tview.Event) tview.Command {
 		case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.Open.Keybind):
 			ml.open()
 			return nil
+		case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.SaveImage.Keybind):
+			ml.saveImage()
+			return nil
 		case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.Reply.Keybind):
 			ml.reply(false)
 			return nil
@@ -1180,37 +1187,146 @@ func (ml *messagesList) showAttachmentsList(urls []string, attachments []discord
 	ml.chatView.app.SetFocus(ml.attachmentsPicker)
 }
 
-func (ml *messagesList) openAttachment(attachment discord.Attachment) {
+func (ml *messagesList) downloadToCache(attachment discord.Attachment) (string, error) {
 	resp, err := http.Get(attachment.URL)
 	if err != nil {
-		slog.Error("failed to fetch the attachment", "err", err, "url", attachment.URL)
-		return
+		return "", fmt.Errorf("failed to fetch attachment: %w", err)
 	}
 	defer resp.Body.Close()
 
-	path := filepath.Join(consts.CacheDir(), "attachments")
-	if err := os.MkdirAll(path, os.ModePerm); err != nil {
-		slog.Error("failed to create attachments dir", "err", err, "path", path)
-		return
+	dir := filepath.Join(consts.CacheDir(), "attachments")
+	if err := os.MkdirAll(dir, os.ModePerm); err != nil {
+		return "", fmt.Errorf("failed to create attachments dir: %w", err)
 	}
 
-	path = filepath.Join(path, attachment.Filename)
+	path := filepath.Join(dir, attachment.Filename)
 	file, err := os.Create(path)
 	if err != nil {
-		slog.Error("failed to create attachment file", "err", err, "path", path)
-		return
+		return "", fmt.Errorf("failed to create attachment file: %w", err)
 	}
 	defer file.Close()
 
 	if _, err := io.Copy(file, resp.Body); err != nil {
-		slog.Error("failed to copy attachment to file", "err", err)
+		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 (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] {
+		cmd := exec.Command(viewer, path)
+		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.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)
+}
+
+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, os.ModePerm); err != nil {
+		slog.Error("failed to create save directory", "err", err, "path", saveDir)
+		return
+	}
+
+	destPath := filepath.Join(saveDir, attachment.Filename)
+	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) {
@@ -1431,7 +1547,7 @@ func (ml *messagesList) FullHelp() [][]keybind.Keybind {
 		manage = append(manage, cfg.DeleteConfirm.Keybind, cfg.Delete.Keybind)
 	}
 	if canOpen {
-		manage = append(manage, cfg.Open.Keybind)
+		manage = append(manage, cfg.Open.Keybind, cfg.SaveImage.Keybind)
 	}
 
 	return [][]keybind.Keybind{