images.plan 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191
  1. # Image Viewing & Save Feature — Implementation Plan
  2. ## Overview
  3. Add lightweight image viewing and saving for image attachments (jpg, jpeg, png, webp, gif)
  4. in discordo. Uses an external viewer (`imv` by default, configurable) that can be dismissed
  5. with `q`. Adds a new keybind to save the highlighted message's image attachment to a
  6. user-configured directory.
  7. ---
  8. ## Bug Fix (prerequisite)
  9. ### 1. Fix broken attachment link rendering
  10. **File:** `internal/ui/chat/messages_list.go` ~line 500
  11. **Problem:** Attachment URLs are rendered with a literal `\n` inside a single
  12. `builder.Write()` call:
  13. ```go
  14. builder.Write(a.Filename+":\n"+a.URL, attachmentStyle)
  15. ```
  16. The `\n` is embedded in the segment text but `tview.LineBuilder` does not split
  17. on `\n` within a `Write()` call — the newline character ends up treated as inline
  18. content, corrupting the URL display and breaking the link.
  19. **Fix:** Split into two writes separated by `builder.NewLine()`:
  20. ```go
  21. builder.Write(a.Filename+":", attachmentStyle)
  22. builder.NewLine()
  23. builder.Write(a.URL, attachmentStyle.Url(a.URL))
  24. ```
  25. This also applies the `.Url()` style metadata so the link is properly clickable
  26. in terminals that support OSC 8 hyperlinks.
  27. ---
  28. ## Feature Changes
  29. ### 2. Add config fields for image viewer and save directory
  30. **File:** `internal/config/config.go`
  31. Add two new fields to the `Config` struct:
  32. ```go
  33. ImageViewer string `toml:"image_viewer"` // e.g. "imv", "feh", "sxiv"
  34. ImageSaveDir string `toml:"image_save_dir"` // e.g. "~/Pictures/discordo"
  35. ```
  36. **File:** `internal/config/config.toml`
  37. Add defaults:
  38. ```toml
  39. # Program used to view image attachments. Must accept a file path as its
  40. # first argument. Set to "default" to use xdg-open / open.
  41. image_viewer = "imv"
  42. # Directory where images are saved with the save_image keybind.
  43. # Supports ~ for home directory. Leave empty to use the current directory.
  44. image_save_dir = ""
  45. ```
  46. **File:** `internal/config/config.go` — `applyDefaults()`
  47. - Expand `~` in `ImageSaveDir` to the user's home directory.
  48. - If `ImageViewer` is `"default"` or empty, fall back to `open.Start()` behavior
  49. (system default).
  50. ### 3. Add `save_image` keybind
  51. **File:** `internal/config/keybinds.go`
  52. Add to `MessagesListKeybinds`:
  53. ```go
  54. SaveImage Keybind `toml:"save_image"`
  55. ```
  56. Default key: `S` (capital S — currently unused in messages list).
  57. **File:** `internal/config/config.toml`
  58. ```toml
  59. # Save the selected message's image attachment to image_save_dir.
  60. save_image = "S"
  61. ```
  62. ### 4. Change `openAttachment()` to use configured image viewer
  63. **File:** `internal/ui/chat/messages_list.go`
  64. Modify `openAttachment()` (line 1183):
  65. - After downloading the attachment to cache (existing logic), instead of calling
  66. `open.Start(path)`, check `ml.cfg.ImageViewer`:
  67. - If set and not `"default"`: exec the viewer with the cached file path as arg,
  68. e.g. `exec.Command(ml.cfg.ImageViewer, path).Run()`.
  69. - If `"default"` or empty: fall back to `open.Start(path)` (current behavior).
  70. - The viewer command runs in a goroutine. While the viewer is open, discordo's
  71. TUI is suspended (same pattern as the external editor: `ml.chatView.app.Suspend()`
  72. + resume after the command exits). This lets terminal-based viewers like `imv`
  73. take over the terminal cleanly.
  74. Affected types: jpg, jpeg, png, webp, gif — filter on `attachment.ContentType`
  75. matching `image/jpeg`, `image/png`, `image/webp`, `image/gif`. For non-image
  76. attachments, keep existing `open.Start(url)` behavior.
  77. ### 5. Implement `saveImage()` function and wire up keybind
  78. **File:** `internal/ui/chat/messages_list.go`
  79. New function `saveImage()`:
  80. ```
  81. func (ml *messagesList) saveImage()
  82. ```
  83. Logic:
  84. 1. Get the selected message via `ml.selectedMessage()`.
  85. 2. Filter `msg.Attachments` to only image types (jpg, jpeg, png, webp, gif) by
  86. checking `attachment.ContentType`.
  87. 3. If no image attachments, return early (no-op).
  88. 4. If one image: download and save directly.
  89. 5. If multiple images: show the attachments picker (reuse existing
  90. `showAttachmentsList` pattern but with save actions instead of open actions).
  91. 6. Download the image via HTTP (reuse the download logic from `openAttachment`
  92. — extract into a shared helper `downloadAttachment()` that returns the local
  93. file path).
  94. 7. Copy the file from cache to `ml.cfg.ImageSaveDir/filename`. If
  95. `ImageSaveDir` is empty, save to the current working directory.
  96. 8. Log success/failure via `slog`.
  97. **File:** `internal/ui/chat/messages_list.go` — keybind registration
  98. In the function that registers messages list keybinds (look for where `Open` keybind
  99. is registered), add the `SaveImage` keybind pointing to `ml.saveImage`.
  100. ### 6. Extract shared download helper
  101. **File:** `internal/ui/chat/messages_list.go`
  102. Refactor the HTTP download + cache write logic currently in `openAttachment()` into:
  103. ```go
  104. func (ml *messagesList) downloadToCache(attachment discord.Attachment) (string, error)
  105. ```
  106. Returns the local cached file path. Used by both `openAttachment()` and `saveImage()`.
  107. ---
  108. ## File Change Summary
  109. | File | Changes |
  110. |------|---------|
  111. | `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 |
  112. | `internal/config/config.go` | Add `ImageViewer` and `ImageSaveDir` fields; expand `~` in defaults |
  113. | `internal/config/config.toml` | Add `image_viewer`, `image_save_dir`, and `save_image` keybind defaults |
  114. | `internal/config/keybinds.go` | Add `SaveImage` keybind to `MessagesListKeybinds` and default |
  115. ---
  116. ## Implementation Order
  117. 1. Fix the `\n` attachment link bug (quick, independent)
  118. 2. Add config fields + defaults (`config.go`, `config.toml`)
  119. 3. Add `SaveImage` keybind (`keybinds.go`, `config.toml`)
  120. 4. Extract `downloadToCache()` helper from `openAttachment()`
  121. 5. Modify `openAttachment()` to use configured viewer + suspend/resume
  122. 6. Implement `saveImage()` and wire up keybind
  123. ---
  124. ## Notes
  125. - `imv` is chosen as the default viewer because it's lightweight, supports
  126. Wayland and X11, handles all target formats (jpg, png, webp, gif) out of
  127. the box via FreeImage/libnsgif, and exits on `q` by default.
  128. - Users who prefer X11-only can set `image_viewer = "feh"` (note: `feh` lacks
  129. WebP support on many distros).
  130. - Users on macOS can set `image_viewer = "open"` or `image_viewer = "default"`.
  131. - The suspend/resume pattern for the viewer follows the same approach already
  132. used for the external editor (see `message_input.go` `openEditor` flow).
  133. - No new Go dependencies are needed — `os/exec` is in the stdlib and HTTP
  134. download logic already exists.