Bladeren bron

feat(ui): add link display compression for chat URLs

Replace raw URLs with human-friendly labels in chat messages and embeds.
Discord CDN shows filename, known sites get short labels (Tenor GIF,
YouTube, GitHub - owner/repo, etc.), fallback shows host + truncated path.
Full URL preserved as OSC 8 hyperlink. Shared via ui.LinkDisplayText().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
claude 1 maand geleden
bovenliggende
commit
2820e38883
5 gewijzigde bestanden met toevoegingen van 124 en 58 verwijderingen
  1. 26 29
      CLAUDE.md
  2. 22 9
      README.md
  3. 1 1
      internal/markdown/renderer.go
  4. 1 19
      internal/ui/chat/embed_renderer.go
  5. 74 0
      internal/ui/util.go

+ 26 - 29
CLAUDE.md

@@ -45,35 +45,32 @@ This is the persistent context file for Claude Code. Keep it concise and useful.
 - Config defaults embedded via `//go:embed config.toml`
 
 ## Our Changes (vs upstream)
-- **Image viewer**: `image_viewer` config (default: `mpv`) — opens image attachments in configured viewer with TUI suspend/resume instead of browser
-- **mpv geometry**: `viewerArgs()` auto-detects terminal geometry via `xdotool` on X11, passes `--geometry`, `--force-window`, `--loop-file=inf` to mpv
-- **Save image**: `save_image` keybind (`S`) — saves image attachment to `image_save_dir`
-- **Attachment URL fix**: split `builder.Write(filename+"\n"+url)` into proper `NewLine()` + `.Url()` style
-- **Supported image types**: jpeg, png, webp, gif (via `supportedImageTypes` map)
-- **ShortHelp**: `o` (open) now shows in bottom tooltip bar when message has attachments/URLs
-- **Help keybind**: changed from `ctrl+.` to `?` — `ctrl+.` not reliably handled by terminals
-- **Edit config**: `E` keybind (only active in help overlay) opens config in `$EDITOR`/vim via `app.Suspend()`
-- **Editor default**: falls back to `vim` when `editor = "default"` and `$EDITOR` is unset
-- **Guild state persistence**: expanded/collapsed guild state saved to `~/.cache/discordo/state.json`, restored on launch via `guildstate.go`
-- **Focus on channel select**: AutoFocus now targets messages list instead of message input when selecting a channel
-- **Security hardening**: path traversal prevention (`filepath.Base`), HTTPS-only downloads, bounded downloads (100MB), `exec.LookPath` viewer validation, atomic guild state writes, restrictive file permissions (0700/0600), env token warning
-- **Editor security**: replaced `sh -c` with direct `exec.Command` + `strings.Fields` (merged editor files into single `editor.go`, no build tags)
-- **God file split**: extracted `url_extractor.go`, `embed_renderer.go`, `attachment_handler.go` from `messages_list.go`
-- **Image viewer args**: `image_viewer_args` config field — explicit viewer args, bypasses xdotool auto-detection (Wayland-friendly)
-- **Brotli body leak fix**: transport.go properly closes underlying HTTP body
-- **Cache panic fix**: `cache.Get()` uses comma-ok assertion instead of bare type assert
-- **Reactions display**: renders message reactions below content (`drawReactions`), bold for own reactions, real-time updates via gateway events
-- **Search picker**: `/` keybind opens fuzzy search over current channel messages (`search_picker.go`)
-- **Thread indicators**: shows "Thread: name" on messages with threads, `t` keybind navigates to thread
-- **User info popup**: `w` keybind shows author info (username, join date, roles, color) in overlay (`user_info.go`)
-- **Command mode**: `:` keybind opens vim-style command input, supports `:q`/`:quit`/`:logout` (`command_input.go`)
-- **LRU cache cap**: `itemByID` map evicts stale entries when exceeding 500 items (COMP #17)
-- **Replace open-golang**: replaced `skratchdot/open-golang` with stdlib `os/exec` + `runtime.GOOS` (TECH #1)
-- **ESC focus cycling**: ESC cycles focus: input → messages → guilds → input (full loop); left/right arrows navigate between guilds and messages
-- **Channel state persistence**: category/forum expand/collapse state saved to `state.json` alongside guild state (`ExpandedChannels` map)
-- **MarkRead fix**: uses newest fetched message ID instead of potentially stale `channel.LastMessageID`
-- **Reply quote italic**: reply lines rendered with dim + italic style
-- **Picker browse mode**: ESC in overlay pickers (channels, search, attachments) enters browse mode (j/k/g/G navigate, `i` returns to input, second ESC closes) via shared `pickerBrowseHandleKey` helper
+- **Image viewer**: `image_viewer` / `image_viewer_args` config, `save_image` keybind (`S`), mpv geometry auto-detection via `xdotool`, supported types: jpeg/png/webp/gif
+- **Attachments**: URL fix (proper `NewLine()` + `.Url()` style), `o` in ShortHelp when attachments/URLs present
+- **Help/Config**: `?` keybind (was `ctrl+.`), `E` edits config in `$EDITOR`/vim from help overlay
+- **State persistence**: guild + channel expand/collapse state in `~/.cache/discordo/state.json` (`guildstate.go`)
+- **Focus/Navigation**: AutoFocus targets messages list on channel select; ESC cycles input→messages→guilds→input; left/right arrows between panels
+- **Security hardening**: path traversal prevention, HTTPS-only downloads, bounded downloads (100MB), `exec.LookPath` validation, atomic writes, restrictive perms, direct `exec.Command` (no `sh -c`)
+- **Bug fixes**: Brotli body leak, cache type assertion panic, MarkRead uses newest fetched message ID
+- **Code structure**: extracted `url_extractor.go`, `embed_renderer.go`, `attachment_handler.go` from `messages_list.go`; replaced `open-golang` with stdlib
+- **Reactions display**: renders below content (`drawReactions`), bold for own, real-time gateway updates
+- **Search picker**: `/` opens fuzzy search over current channel messages (`search_picker.go`)
+- **Thread indicators**: "Thread: name" display, `t` navigates to thread
+- **User info popup**: `w` shows author info overlay (`user_info.go`)
+- **Command mode**: `:` opens vim-style input, supports `:q`/`:quit`/`:logout` (`command_input.go`)
+- **LRU cache cap**: `itemByID` evicts stale entries past 500 items
+- **Reply quote italic**: dim + italic style for reply lines
+- **Picker browse mode**: ESC in overlay pickers enters browse mode (j/k/g/G/i) via `pickerBrowseHandleKey`
+- **Link display compression**: `ui.LinkDisplayText()` shows human-friendly labels instead of raw URLs in chat and embeds (e.g., CDN filename, "Tenor GIF", "Substack - author")
+
+## Adding Link Display Rules
+To add a new site-specific URL label, edit `ui.LinkDisplayText()` in `internal/ui/util.go`:
+1. Add a host match (`host == "example.com"` or `strings.HasSuffix(host, ".example.com")`) after the existing site blocks
+2. Use `segments` (pre-split path segments) to extract meaningful parts (e.g., `segments[1]` for subreddit in `/r/{sub}`)
+3. Return a short label string (e.g., `"SiteName - " + segments[1]`)
+4. The raw URL is preserved as OSC 8 hyperlink metadata — only the visible text changes
+- Currently handled: Discord CDN (filename), Tenor (GIF), Substack (author), YouTube, X/Twitter, Reddit (subreddit), GitHub (owner/repo)
+- Fallback: `host + truncated path` (max 48 chars)
 
 ## Config Fields We Added
 - `image_viewer` — external image viewer command (default: `"mpv"`, `"default"` = system opener)

+ 22 - 9
README.md

@@ -1,17 +1,30 @@
 # Discordo Plus
 
-A fork of [discordo](https://github.com/ayn2op/discordo) — a lightweight Discord terminal client — with image viewing, save support, and security hardening.
+A fork of [discordo](https://github.com/ayn2op/discordo) — a lightweight Discord terminal client — with enhanced navigation, media support, and security hardening.
 
 ## Changes from upstream
 
-- **Image viewer**: Opens image attachments (jpeg, png, webp, gif) in a configurable viewer (`mpv` by default) instead of the browser. On X11, auto-detects terminal window geometry so the viewer opens at the same position and size
-- **Save image**: `S` keybind saves the selected message's image attachment to a configurable directory
-- **Attachment URL fix**: Links no longer break due to bad newline handling
-- **Tooltip**: `o open` appears in the bottom help bar when a message has attachments or URLs
-- **Guild state persistence**: Remembers which guilds are expanded/collapsed between sessions (saved to `~/.cache/discordo/state.json`)
-- **Focus on channel select**: When AutoFocus is enabled, selecting a channel focuses the messages list instead of the message input
-- **ESC focus navigation**: Pressing Escape in the message input switches focus to the messages list; pressing Escape again switches focus to the guilds tree
-- **Security hardening**: Path traversal prevention on attachment filenames, HTTPS-only downloads with size limits, restrictive file permissions (0700/0600), image viewer validation, atomic state file writes, environment token warning
+### Media & Attachments
+- **Image viewer**: Opens image attachments (jpeg, png, webp, gif) in a configurable viewer (`mpv` by default) with X11 geometry auto-detection
+- **Save image**: `S` saves the selected message's image attachment to a configurable directory
+- **Link display compression**: Long URLs are shown as human-friendly labels — Discord CDN links show the filename, known sites show short labels (e.g., "Tenor GIF", "YouTube", "GitHub - owner/repo"), others show host + truncated path. Full URL remains clickable via OSC 8 hyperlinks.
+
+### Navigation & UI
+- **ESC focus cycling**: ESC cycles focus between input → messages → guilds → input; arrow keys navigate between panels
+- **Search**: `/` opens fuzzy search over current channel messages
+- **Threads**: Thread indicator on messages, `t` navigates into thread
+- **Reactions**: Displayed below messages, bold for own reactions, real-time gateway updates
+- **User info**: `w` shows author info popup (username, join date, roles)
+- **Command mode**: `:` opens vim-style command input (`:q`, `:quit`, `:logout`)
+- **Help**: `?` toggles full help overlay, `E` edits config from help
+- **Picker browse mode**: ESC in overlay pickers enters browse mode (j/k/g/G navigate, `i` returns to input)
+
+### State & Persistence
+- **Guild/channel state**: Expanded/collapsed state for guilds, categories, and forums persists between sessions
+- **Focus on channel select**: Selecting a channel focuses the messages list instead of input
+
+### Security
+- Path traversal prevention, HTTPS-only downloads with 100MB size limit, restrictive file permissions (0700/0600), image viewer validation via `exec.LookPath`, atomic state writes, direct `exec.Command` (no shell), environment token warning
 
 ## Building on Arch Linux
 

+ 1 - 1
internal/markdown/renderer.go

@@ -84,7 +84,7 @@ func (r *Renderer) RenderLines(source []byte, node ast.Node, base tcell.Style) [
 			if entering {
 				url := string(node.URL(source))
 				style := ui.MergeStyle(currentStyle(), theme.URLStyle.Style).Url(url)
-				builder.Write(url, style)
+				builder.Write(ui.LinkDisplayText(url), style)
 			}
 		case *ast.Link:
 			if entering {

+ 1 - 19
internal/ui/chat/embed_renderer.go

@@ -1,7 +1,6 @@
 package chat
 
 import (
-	"net/url"
 	"strings"
 
 	"github.com/ayn2op/discordo/internal/config"
@@ -82,7 +81,7 @@ func embedLines(embed discord.Embed, contentURLs map[string]struct{}) []embedLin
 		if _, ok := contentURLs[u]; ok {
 			return
 		}
-		appendUnique(linkDisplayText(u), embedLineURL, u)
+		appendUnique(ui.LinkDisplayText(u), embedLineURL, u)
 	}
 
 	if embed.Provider != nil {
@@ -126,23 +125,6 @@ func embedLines(embed discord.Embed, contentURLs map[string]struct{}) []embedLin
 	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

+ 74 - 0
internal/ui/util.go

@@ -2,6 +2,8 @@ package ui
 
 import (
 	"cmp"
+	"net/url"
+	"path"
 	"slices"
 	"strings"
 
@@ -132,6 +134,78 @@ func getMessageIDFromChannel(channel discord.Channel) discord.MessageID {
 	return discord.MessageID(channel.ID)
 }
 
+// LinkDisplayText returns a short, human-friendly label for a URL.
+// Known hosts get special treatment; everything else shows host + truncated path.
+func LinkDisplayText(raw string) string {
+	parsed, err := url.Parse(raw)
+	if err != nil || parsed.Host == "" {
+		return raw
+	}
+
+	host := strings.ToLower(parsed.Host)
+	p := strings.TrimRight(parsed.EscapedPath(), "/")
+	segments := strings.Split(strings.TrimLeft(p, "/"), "/")
+
+	// Discord CDN / media — show filename
+	if host == "cdn.discordapp.com" || host == "media.discordapp.net" {
+		if base := path.Base(p); base != "" && base != "." && base != "/" {
+			return base
+		}
+	}
+
+	// Tenor GIFs
+	if host == "tenor.com" || strings.HasSuffix(host, ".tenor.com") {
+		return "Tenor GIF"
+	}
+
+	// Substack: open.substack.com/pub/{author} or {author}.substack.com
+	switch {
+	case host == "open.substack.com" || host == "substack.com":
+		if len(segments) >= 2 && segments[0] == "pub" && segments[1] != "" {
+			return "Substack - " + segments[1]
+		}
+		return "Substack"
+	case strings.HasSuffix(host, ".substack.com"):
+		return "Substack - " + strings.TrimSuffix(host, ".substack.com")
+	}
+
+	// YouTube
+	if host == "youtube.com" || host == "www.youtube.com" || host == "m.youtube.com" || host == "youtu.be" {
+		return "YouTube"
+	}
+
+	// Twitter / X
+	if host == "twitter.com" || host == "www.twitter.com" || host == "x.com" || host == "www.x.com" {
+		return "X (Twitter)"
+	}
+
+	// Reddit
+	if host == "reddit.com" || host == "www.reddit.com" || host == "old.reddit.com" {
+		if len(segments) >= 2 && segments[0] == "r" {
+			return "Reddit - r/" + segments[1]
+		}
+		return "Reddit"
+	}
+
+	// GitHub
+	if host == "github.com" || host == "www.github.com" {
+		if len(segments) >= 2 && segments[0] != "" && segments[1] != "" {
+			return "GitHub - " + segments[0] + "/" + segments[1]
+		}
+		return "GitHub"
+	}
+
+	// Generic fallback: host + truncated path
+	switch {
+	case p == "", p == "/":
+		return parsed.Host
+	case len(p) > 48:
+		return parsed.Host + p[:45] + "..."
+	default:
+		return parsed.Host + p
+	}
+}
+
 func MergeStyle(base, overlay tcell.Style) tcell.Style {
 	fg := overlay.GetForeground()
 	if fg == tcell.ColorDefault {