diff --git a/docs/tui.md b/docs/tui.md
index 41c32b70..cec763c0 100644
--- a/docs/tui.md
+++ b/docs/tui.md
@@ -172,7 +172,7 @@ the iOS and Android apps each keep their own local choice too, and this is the s
## Attachments
-Thread attachments always appear with their filename, media type, and size. Use `[` and `]` to select an attachment, `s` to save it without replacing an existing file, and `o` to download and open it in an external application. Attachments never open automatically. Kitty and Ghostty can show inline images. Foot and other terminals use visible text markers.
+Thread attachments always appear with their filename, media type, and size. Use `[` and `]` to select an attachment, `s` to save it without replacing an existing file, and `o` to download and open it in an external application. Attachments never open automatically. Kitty and Ghostty can show inline images. In the message itself, a file is marked by its kind: š· an image, š¬ a video, šµ audio, š a PDF or document, š anything else. An image shows by name rather than by its URL, unless its name looks like a URL, when the real destination is shown beside it; an image on the web is a link you can select with Tab, and the row above the shortcut bar shows its whole destination.
## Contacts
diff --git a/internal/htmlutil/markdown.go b/internal/htmlutil/markdown.go
index d1d7098f..73e0db3b 100644
--- a/internal/htmlutil/markdown.go
+++ b/internal/htmlutil/markdown.go
@@ -673,7 +673,7 @@ func (m *markdownizer) image(n *html.Node) {
src, linkable := destination(getAttr(n, "src"))
switch {
case linkable:
- m.write("")
+ m.write(imageMarkdown(alt, escapeText(alt, "alt"), src))
case alt != "":
m.write(escapeText(alt, m.line.String()))
}
@@ -724,20 +724,57 @@ func (m *markdownizer) actionTextAttachment(n *html.Node) {
}
func (m *markdownizer) attachment(filename, url, contentType string) {
- filename = escapeText(strings.Join(strings.Fields(filename), " "), "š ")
+ icon := attachmentIcon(contentType)
+ name := strings.Join(strings.Fields(filename), " ")
+ filename = escapeText(name, icon)
if filename == "" {
filename = "attachment"
}
dest, linkable := destination(url)
m.block(func() {
if isImageContentType(contentType) && linkable {
- m.write("")
+ m.write(imageMarkdown(name, filename, dest))
} else {
- m.write("š " + filename)
+ m.write(icon + filename)
}
})
}
+// imageMarkdown writes an image. A terminal shows an image by its label alone, so a
+// label a reader could take for a URL gets the destination it links to written out
+// beside it, as a link's label does: "https://bank.example/login" pointed at
+// https://evil.example shows both. A label that is just the file the destination
+// names is not mistaken for anywhere else, and a relative destination is never a link.
+func imageMarkdown(label, escaped, dest string) string {
+ md := ""
+ if absolute(dest) && !strings.ContainsFunc(label, unicode.IsSpace) &&
+ strings.ContainsAny(label, ".:/@") && label != destinationFilename(dest) {
+ md += " <" + dest + ">"
+ }
+ return md
+}
+
+// attachmentIcon says what kind of file an attachment is. Each of these emoji is
+// drawn as an emoji without a variation selector, so every terminal gives it the
+// same two cells; š¼ļø and šļø need U+FE0F for that, and terminals disagree on how
+// wide it makes them.
+func attachmentIcon(contentType string) string {
+ contentType = strings.ToLower(strings.TrimSpace(contentType))
+ switch {
+ case isImageContentType(contentType):
+ return "š· "
+ case strings.HasPrefix(contentType, "video/"):
+ return "š¬ "
+ case strings.HasPrefix(contentType, "audio/"):
+ return "šµ "
+ case contentType == "application/pdf", strings.HasPrefix(contentType, "text/"),
+ strings.Contains(contentType, "msword"), strings.Contains(contentType, "document"):
+ return "š "
+ default:
+ return "š "
+ }
+}
+
func (m *markdownizer) children(n *html.Node) {
for child := n.FirstChild; child != nil; child = child.NextSibling {
m.walk(child)
diff --git a/internal/htmlutil/markdown_safety_test.go b/internal/htmlutil/markdown_safety_test.go
index c445c880..5803df84 100644
--- a/internal/htmlutil/markdown_safety_test.go
+++ b/internal/htmlutil/markdown_safety_test.go
@@ -448,7 +448,7 @@ func TestToMarkdownUnlinkableImageAltIsProse(t *testing.T) {
func TestToMarkdownAttachmentNamesAreSerialized(t *testing.T) {
got := toMarkdown(`` +
``)
- want := "š \\*report\\*.png\n\nš \\[q3\\].pdf"
+ want := "š· \\*report\\*.png\n\nš \\[q3\\].pdf"
if got != want {
t.Errorf("ToMarkdown = %q, want %q", got, want)
}
@@ -630,3 +630,18 @@ func FuzzToMarkdownTerminalSafety(f *testing.F) {
assertSafeTree(t, md)
})
}
+
+func TestToMarkdownImageLabelThatLooksLikeAURLShowsItsDestination(t *testing.T) {
+ for input, want := range map[string]string{
+ ``: " ",
+ ``: " ",
+ ``: "",
+ ``: "",
+ ``: "",
+ ``: " ",
+ } {
+ if got := toMarkdown(input); got != want {
+ t.Errorf("ToMarkdown(%s) = %q, want %q", input, got, want)
+ }
+ }
+}
diff --git a/internal/htmlutil/markdown_test.go b/internal/htmlutil/markdown_test.go
index fafab9f3..2e1bc2e6 100644
--- a/internal/htmlutil/markdown_test.go
+++ b/internal/htmlutil/markdown_test.go
@@ -278,7 +278,35 @@ func TestMarkdownRoundTripPreservesAuthoredContent(t *testing.T) {
func TestToMarkdownTrixFileAttachment(t *testing.T) {
got := toMarkdown(``)
- want := "š q3-report.pdf"
+ want := "š q3-report.pdf"
+ if got != want {
+ t.Errorf("ToMarkdown = %q, want %q", got, want)
+ }
+}
+
+func TestAttachmentIconSaysWhatKindOfFileItIs(t *testing.T) {
+ for contentType, want := range map[string]string{
+ "image/jpeg": "š· ",
+ "video/mp4": "š¬ ",
+ "audio/mpeg": "šµ ",
+ "application/pdf": "š ",
+ "text/plain": "š ",
+ "application/msword": "š ",
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "š ",
+ "application/zip": "š ",
+ "": "š ",
+ } {
+ if got := attachmentIcon(contentType); got != want {
+ t.Errorf("attachmentIcon(%q) = %q, want %q", contentType, got, want)
+ }
+ }
+}
+
+func TestToMarkdownAttachmentsCarryTheirKind(t *testing.T) {
+ got := toMarkdown(`` +
+ `` +
+ ``)
+ want := "š¬ walkthrough.mp4\n\nšµ voicemail.m4a\n\nš receipts.zip"
if got != want {
t.Errorf("ToMarkdown = %q, want %q", got, want)
}
@@ -286,7 +314,7 @@ func TestToMarkdownTrixFileAttachment(t *testing.T) {
func TestToMarkdownActionTextAttachment(t *testing.T) {
got := toMarkdown(`
Attached:
`)
- want := "Attached:\n\nš q3-report.pdf"
+ want := "Attached:\n\nš q3-report.pdf"
if got != want {
t.Errorf("ToMarkdown = %q, want %q", got, want)
}
diff --git a/internal/markdown/render_test.go b/internal/markdown/render_test.go
index e6c18e10..5d56748e 100644
--- a/internal/markdown/render_test.go
+++ b/internal/markdown/render_test.go
@@ -4,6 +4,8 @@ import (
"strings"
"testing"
+ "github.com/charmbracelet/x/ansi"
+
"github.com/basecamp/hey-cli/internal/htmlutil"
)
@@ -230,3 +232,25 @@ func TestRenderLinkedRejectsMalformedDestinationAndHandlesNoLinks(t *testing.T)
}
}
}
+
+func TestRenderShowsAnImageByNameAndLinksTheName(t *testing.T) {
+ attached := Render(htmlutil.ToMarkdown(``), 80)
+ if text := ansi.Strip(attached); text != "š· 1_an_Gustav.jpg" {
+ t.Errorf("attached image = %q, want its name behind a camera and no URL", text)
+ }
+
+ linked := RenderLinked(htmlutil.ToMarkdown(`
`), 80, -1)
+ if text := ansi.Strip(linked.Text); text != "š· Lantern walk" {
+ t.Errorf("web image = %q, want its name behind a camera and no URL", text)
+ }
+ if len(linked.Links) != 1 || linked.Links[0].Destination != "https://images.example.com/lanterns.jpg" {
+ t.Errorf("web image links = %#v, want its name selectable with the whole destination", linked.Links)
+ }
+}
+
+func TestRenderNeverHidesWhereAURLShapedImageLabelGoes(t *testing.T) {
+ out := ansi.Strip(Render(htmlutil.ToMarkdown(`
`), 80))
+ if !strings.Contains(out, "š· https://bank.example/login") || !strings.Contains(out, "https://evil.example/login") {
+ t.Errorf("rendered = %q, want the label and the destination it really links to", out)
+ }
+}
diff --git a/internal/markdown/style.go b/internal/markdown/style.go
index 7d7b0fba..700e9994 100644
--- a/internal/markdown/style.go
+++ b/internal/markdown/style.go
@@ -67,13 +67,16 @@ var terminalStyle = ansi.StyleConfig{
LinkText: ansi.StylePrimitive{
Color: stringPointer("12"),
},
+ // An image is its name behind a camera, not its URL: the name carries the link, and
+ // the TUI's status row shows the whole destination when the name is selected.
+ // Glamour writes the URL as a separate element, so its Format drops it.
Image: ansi.StylePrimitive{
- Color: stringPointer("14"),
- Underline: boolPointer(true),
+ Format: "{{/* shown in the status row */}}",
},
ImageText: ansi.StylePrimitive{
- Faint: boolPointer(true),
- Format: "Image: {{.text}} ā",
+ BlockPrefix: "š· ",
+ Color: stringPointer("14"),
+ Underline: boolPointer(true),
},
Code: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
diff --git a/internal/tui/mail_test.go b/internal/tui/mail_test.go
index 0cf72ef1..5ad8a55a 100644
--- a/internal/tui/mail_test.go
+++ b/internal/tui/mail_test.go
@@ -3111,8 +3111,8 @@ func TestMailViewAlwaysRendersAttachmentPanelAndTextMarker(t *testing.T) {
})
view := v.View()
- for _, want := range []string{"Image: ", "Attachments", "chart.png", "image/png", "1.5 KB"} {
- if !strings.Contains(view, want) {
+ for _, want := range []string{"š· chart.png", "Attachments", "chart.png", "image/png", "1.5 KB"} {
+ if !strings.Contains(stripANSI(view), want) {
t.Errorf("thread view does not contain %q: %q", want, view)
}
}