diff --git a/PROTOCOL.md b/PROTOCOL.md index 2953e6a..8a3b564 100644 --- a/PROTOCOL.md +++ b/PROTOCOL.md @@ -58,6 +58,7 @@ type Content = | { type: "attachment"; name: string; mimeType: string; size?: number; bytes?: string /* base64 */; path?: string } | { type: "voice"; name?: string; mimeType: string; size?: number; bytes?: string; path?: string } | { type: "contact"; name?: { formatted?: string; first?: string; last?: string; middle?: string; prefix?: string; suffix?: string }; vcard?: string } + | { type: "richlink"; url: string; title?: string; summary?: string; cover?: { mimeType?: string; bytes?: string /* base64 */ } } | { type: "custom"; raw: unknown }; ``` diff --git a/internal/plain/plain.go b/internal/plain/plain.go index 72aac52..f541a76 100644 --- a/internal/plain/plain.go +++ b/internal/plain/plain.go @@ -156,6 +156,15 @@ func plainFormat(c protocol.Content) string { return "[custom] " + string(raw) } return "[custom]" + case "richlink": + label := c.Title + if label == "" { + label = c.Url + } + if c.Summary != "" { + return fmt.Sprintf("[link] %s — %s", label, c.Summary) + } + return fmt.Sprintf("[link] %s", label) } return "" } diff --git a/internal/plain/plain_test.go b/internal/plain/plain_test.go new file mode 100644 index 0000000..c6aeed1 --- /dev/null +++ b/internal/plain/plain_test.go @@ -0,0 +1,48 @@ +package plain + +import ( + "testing" + + "github.com/photon-hq/tuichat/internal/protocol" +) + +func TestPlainFormatRichlink(t *testing.T) { + tests := []struct { + name string + content protocol.Content + want string + }{ + { + name: "url only", + content: protocol.Content{Type: "richlink", Url: "https://example.com/x"}, + want: "[link] https://example.com/x", + }, + { + name: "title without summary", + content: protocol.Content{Type: "richlink", Url: "https://example.com/x", Title: "Hello"}, + want: "[link] Hello", + }, + { + name: "title with summary", + content: protocol.Content{Type: "richlink", Url: "https://example.com/x", Title: "Hello", Summary: "world"}, + want: "[link] Hello — world", + }, + { + name: "summary without title falls back to url label", + content: protocol.Content{Type: "richlink", Url: "https://example.com/x", Summary: "world"}, + want: "[link] https://example.com/x — world", + }, + { + name: "missing url and title renders bracket only", + content: protocol.Content{Type: "richlink"}, + want: "[link] ", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := plainFormat(tt.content); got != tt.want { + t.Errorf("plainFormat() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/internal/protocol/types.go b/internal/protocol/types.go index 74730c6..93b0aea 100644 --- a/internal/protocol/types.go +++ b/internal/protocol/types.go @@ -20,6 +20,15 @@ type Content struct { // Contact fields deliberately passed through via Raw on the client side // when needed. We don't model contact/voice extensively in Go today — // they fall through as custom-like payloads. + Url string `json:"url,omitempty"` + Title string `json:"title,omitempty"` + Summary string `json:"summary,omitempty"` + Cover *Cover `json:"cover,omitempty"` +} + +type Cover struct { + MimeType string `json:"mimeType,omitempty"` + Bytes string `json:"bytes,omitempty"` // base64-encoded } type CommandDef struct { diff --git a/internal/protocol/types_test.go b/internal/protocol/types_test.go new file mode 100644 index 0000000..ac76e88 --- /dev/null +++ b/internal/protocol/types_test.go @@ -0,0 +1,31 @@ +package protocol + +import ( + "encoding/json" + "testing" +) + +func TestContentUnmarshalRichlinkCover(t *testing.T) { + var content Content + err := json.Unmarshal([]byte(`{ + "type": "richlink", + "url": "https://example.com/article", + "title": "Example", + "cover": { + "mimeType": "image/png", + "bytes": "aGVsbG8=" + } + }`), &content) + if err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if content.Cover == nil { + t.Fatal("Cover = nil, want value") + } + if content.Cover.MimeType != "image/png" { + t.Fatalf("Cover.MimeType = %q, want %q", content.Cover.MimeType, "image/png") + } + if content.Cover.Bytes != "aGVsbG8=" { + t.Fatalf("Cover.Bytes = %q, want %q", content.Cover.Bytes, "aGVsbG8=") + } +} diff --git a/internal/ui/entry_preview.go b/internal/ui/entry_preview.go new file mode 100644 index 0000000..e9abb4f --- /dev/null +++ b/internal/ui/entry_preview.go @@ -0,0 +1,77 @@ +package ui + +import ( + "encoding/base64" + + "github.com/photon-hq/tuichat/internal/kitty" + "github.com/photon-hq/tuichat/internal/protocol" + "github.com/photon-hq/tuichat/internal/store" +) + +func richlinkLabel(c protocol.Content) string { + if c.Title != "" { + return c.Title + } + if c.Url != "" { + return c.Url + } + return "" +} + +func entrySupportsPreview(e store.LogEntry) bool { + switch e.Content.Type { + case "attachment": + return kitty.SupportedMimeType(e.Content.MimeType) + case "richlink": + if e.Content.Cover == nil || !kitty.SupportedMimeType(e.Content.Cover.MimeType) { + return false + } + raw, err := base64.StdEncoding.DecodeString(e.Content.Cover.Bytes) + return err == nil && len(raw) > 0 + default: + return false + } +} + +func previewForEntry(e store.LogEntry) (*store.HoveredPreview, bool) { + switch e.Content.Type { + case "attachment": + if !entrySupportsPreview(e) { + return nil, false + } + return &store.HoveredPreview{ + CacheKey: attachmentPreviewCacheKey(e), + Name: e.Content.Name, + Path: e.AttachmentPath, + }, true + case "richlink": + if !entrySupportsPreview(e) { + return nil, false + } + raw, err := base64.StdEncoding.DecodeString(e.Content.Cover.Bytes) + if err != nil || len(raw) == 0 { + return nil, false + } + name := richlinkLabel(e.Content) + if name == "" { + name = "link preview" + } + return &store.HoveredPreview{ + CacheKey: e.ID + ":richlink-cover", + Name: name, + Bytes: raw, + }, true + default: + return nil, false + } +} + +func attachmentPreviewCacheKey(e store.LogEntry) string { + if e.AttachmentPath != "" { + return e.AttachmentPath + ":" + e.Content.Name + } + if e.ID != "" { + return e.ID + ":attachment" + } + return e.Content.Name +} diff --git a/internal/ui/entry_preview_test.go b/internal/ui/entry_preview_test.go new file mode 100644 index 0000000..65f8851 --- /dev/null +++ b/internal/ui/entry_preview_test.go @@ -0,0 +1,114 @@ +package ui + +import ( + "bytes" + "encoding/base64" + "testing" + + "github.com/photon-hq/tuichat/internal/protocol" + "github.com/photon-hq/tuichat/internal/store" +) + +func TestPreviewForEntryRichlinkCover(t *testing.T) { + coverBytes := []byte{0x89, 0x50, 0x4e, 0x47} + entry := store.LogEntry{ + ID: "msg-1", + Content: protocol.Content{ + Type: "richlink", + Url: "https://example.com/article", + Title: "Example article", + Cover: &protocol.Cover{ + MimeType: "image/png", + Bytes: base64.StdEncoding.EncodeToString(coverBytes), + }, + }, + } + + preview, ok := previewForEntry(entry) + if !ok { + t.Fatal("previewForEntry() ok = false, want true") + } + if preview.CacheKey != "msg-1:richlink-cover" { + t.Fatalf("CacheKey = %q, want %q", preview.CacheKey, "msg-1:richlink-cover") + } + if preview.Name != "Example article" { + t.Fatalf("Name = %q, want %q", preview.Name, "Example article") + } + if !bytes.Equal(preview.Bytes, coverBytes) { + t.Fatalf("Bytes = %v, want %v", preview.Bytes, coverBytes) + } +} + +func TestPreviewForEntryAttachmentCacheKeyUsesPath(t *testing.T) { + entry := store.LogEntry{ + ID: "msg-1", + AttachmentPath: "/tmp/tuichat-a/image.png", + Content: protocol.Content{ + Type: "attachment", + Name: "image.png", + MimeType: "image/png", + }, + } + + preview, ok := previewForEntry(entry) + if !ok { + t.Fatal("previewForEntry() ok = false, want true") + } + if preview.CacheKey != "/tmp/tuichat-a/image.png:image.png" { + t.Fatalf("CacheKey = %q, want %q", preview.CacheKey, "/tmp/tuichat-a/image.png:image.png") + } + if preview.Name != "image.png" { + t.Fatalf("Name = %q, want %q", preview.Name, "image.png") + } + if preview.Path != "/tmp/tuichat-a/image.png" { + t.Fatalf("Path = %q, want %q", preview.Path, "/tmp/tuichat-a/image.png") + } +} + +func TestPreviewForEntryRichlinkCoverRejectsInvalidPreview(t *testing.T) { + tests := []struct { + name string + content protocol.Content + }{ + { + name: "missing cover", + content: protocol.Content{ + Type: "richlink", + Url: "https://example.com/article", + }, + }, + { + name: "unsupported mime", + content: protocol.Content{ + Type: "richlink", + Url: "https://example.com/article", + Cover: &protocol.Cover{ + MimeType: "text/plain", + Bytes: base64.StdEncoding.EncodeToString([]byte("hello")), + }, + }, + }, + { + name: "invalid base64", + content: protocol.Content{ + Type: "richlink", + Url: "https://example.com/article", + Cover: &protocol.Cover{ + MimeType: "image/png", + Bytes: "not-base64", + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if entrySupportsPreview(store.LogEntry{ID: "msg-1", Content: tt.content}) { + t.Fatal("entrySupportsPreview() = true, want false") + } + if _, ok := previewForEntry(store.LogEntry{ID: "msg-1", Content: tt.content}); ok { + t.Fatal("previewForEntry() ok = true, want false") + } + }) + } +} diff --git a/internal/ui/messagelog.go b/internal/ui/messagelog.go index 5b2c081..c674639 100644 --- a/internal/ui/messagelog.go +++ b/internal/ui/messagelog.go @@ -5,7 +5,6 @@ import ( "strings" "github.com/charmbracelet/lipgloss" - "github.com/photon-hq/tuichat/internal/kitty" "github.com/photon-hq/tuichat/internal/store" ) @@ -53,7 +52,7 @@ func renderEntry(theme Theme, e store.LogEntry, allEntries []store.LogEntry, wid body = LinkifyText(e.Content.Text, textStyle, linkStyle) case "attachment": body = renderAttachmentLabel(theme, e, "attachment") - if kitty.SupportedMimeType(e.Content.MimeType) { + if entrySupportsPreview(e) { hint := lipgloss.NewStyle().Foreground(theme.SystemColor).Render( " (click to preview)", ) @@ -67,6 +66,20 @@ func renderEntry(theme Theme, e store.LogEntry, allEntries []store.LogEntry, wid attachStyle := lipgloss.NewStyle().Foreground(theme.CustomColor) body = attachStyle.Render("[custom] ") + lipgloss.NewStyle().Foreground(theme.SystemColor).Render(safeStringify(e.Content.Raw)) + case "richlink": + linkStyle := lipgloss.NewStyle().Foreground(theme.PromptColor).Underline(true) + label := richlinkLabel(e.Content) + body = lipgloss.NewStyle().Foreground(theme.AttachmentColor).Render("[link] ") + linkStyle.Render(label) + if entrySupportsPreview(e) { + hint := lipgloss.NewStyle().Foreground(theme.SystemColor).Render( + " (click to preview)", + ) + body += hint + } + if e.Content.Summary != "" { + summaryStyle := lipgloss.NewStyle().Foreground(theme.SystemColor) + body += "\n " + summaryStyle.Render(e.Content.Summary) + } default: return "" } @@ -204,6 +217,8 @@ func quoteBody(e store.LogEntry) string { return "[contact]" case "custom": return "[custom]" + case "richlink": + return truncateRunes("[link] "+richlinkLabel(c), 60) } return "" } diff --git a/internal/ui/mouse.go b/internal/ui/mouse.go index 9872353..8cf7168 100644 --- a/internal/ui/mouse.go +++ b/internal/ui/mouse.go @@ -5,7 +5,6 @@ import ( tea "github.com/charmbracelet/bubbletea" zone "github.com/lrstanley/bubblezone" - "github.com/photon-hq/tuichat/internal/kitty" "github.com/photon-hq/tuichat/internal/store" ) @@ -186,25 +185,22 @@ func (m *Model) dispatchClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) { } } - // Attachment chip clicks → toggle preview. + // Image preview chip clicks → toggle the floating preview panel. if active, ok := m.Store.ActiveChat(); ok { for i := range active.Entries { e := active.Entries[i] - if e.Content.Type != "attachment" || !kitty.SupportedMimeType(e.Content.MimeType) { + preview, ok := previewForEntry(e) + if !ok { continue } zoneID := ZoneAttachmentPrefix + e.ID if !zone.Get(zoneID).InBounds(msg) { continue } - if hovered := m.Store.HoveredPreview(); hovered != nil && hovered.CacheKey == e.Content.Name { + if hovered := m.Store.HoveredPreview(); hovered != nil && hovered.CacheKey == preview.CacheKey { m.Store.SetHoveredPreview(nil) } else { - m.Store.SetHoveredPreview(&store.HoveredPreview{ - CacheKey: e.Content.Name, - Name: e.Content.Name, - Path: e.AttachmentPath, - }) + m.Store.SetHoveredPreview(preview) } return m, nil } diff --git a/internal/ui/select.go b/internal/ui/select.go index d508d51..83842c7 100644 --- a/internal/ui/select.go +++ b/internal/ui/select.go @@ -92,6 +92,12 @@ func (m *Model) renderReplyBanner(chat store.ChatState, width int) string { quote = "[contact]" case "custom": quote = "[custom]" + case "richlink": + label := e.Content.Title + if label == "" { + label = e.Content.Url + } + quote = "[link] " + label } break } diff --git a/internal/ui/view.go b/internal/ui/view.go index 9762465..f4bbd2d 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -9,7 +9,6 @@ import ( zone "github.com/lrstanley/bubblezone" "github.com/mattn/go-runewidth" - "github.com/photon-hq/tuichat/internal/kitty" "github.com/photon-hq/tuichat/internal/store" ) @@ -311,7 +310,7 @@ func (m *Model) zoneMarkEntries(theme Theme, chat store.ChatState, width int) st rendered = strings.ReplaceAll(rendered, "\x1b[0m", "\x1b[0m"+bgOpen) rendered = selectedBG.Render(rendered) } - if e.Content.Type == "attachment" && kitty.SupportedMimeType(e.Content.MimeType) { + if entrySupportsPreview(e) { rendered = zone.Mark(ZoneAttachmentPrefix+e.ID, rendered) } // Every entry gets a click-zone so mouse-select works.