diff --git a/go.mod b/go.mod index 2265041..f18aaf7 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,7 @@ require ( github.com/andybalholm/brotli v1.2.5 github.com/ayn2op/arikawa/v3 v3.0.0-20260929021956-7d0802bf4d7c github.com/ayn2op/ningen/v3 v3.0.1-0.20260929010920-29794d5fa70e - github.com/ayn2op/tview v0.0.0-20260928205525-fa36e0ece3f0 + github.com/ayn2op/tview v0.0.0-20260929205507-e9707583f12e github.com/bogdanfinn/fhttp v0.6.9 github.com/bogdanfinn/tls-client v1.16.0 github.com/gdamore/tcell/v3 v3.5.0 @@ -30,6 +30,7 @@ require ( github.com/yuin/goldmark v1.8.6 github.com/zalando/go-keyring v0.2.8 golang.design/x/clipboard v0.11.0 + golang.org/x/image v0.46.0 ) require ( @@ -59,7 +60,6 @@ require ( golang.design/x/x11 v0.2.0 // indirect golang.org/x/crypto v0.57.0 // indirect golang.org/x/exp/shiny v0.0.0-20260908205506-85c1c2202aba // indirect - golang.org/x/image v0.46.0 // indirect golang.org/x/mobile v0.0.0-20260908204917-8b95e45f8d3e // indirect golang.org/x/net v0.59.0 // indirect golang.org/x/sys v0.48.0 // indirect diff --git a/go.sum b/go.sum index 25f5375..2768127 100644 --- a/go.sum +++ b/go.sum @@ -16,6 +16,8 @@ github.com/ayn2op/ningen/v3 v3.0.1-0.20260929010920-29794d5fa70e h1:QAei/IVEyZV7 github.com/ayn2op/ningen/v3 v3.0.1-0.20260929010920-29794d5fa70e/go.mod h1:RsvYiMzi4Si1F7vmml5NCL2DaKIZXkNUQC7QlGTMtCw= github.com/ayn2op/tview v0.0.0-20260928205525-fa36e0ece3f0 h1:mh5nA4XFDLYLmLs++EhN2X+6LODI0tTbRRQFp4Q4F/A= github.com/ayn2op/tview v0.0.0-20260928205525-fa36e0ece3f0/go.mod h1:oRPTSZgaEJPhv3dmmNvo6grBq/BemrLG3wFQLMTZJkY= +github.com/ayn2op/tview v0.0.0-20260929205507-e9707583f12e h1:I5YDH5x+TzBinzFnupxdDUcHxQRjJoenCZffXDamn6g= +github.com/ayn2op/tview v0.0.0-20260929205507-e9707583f12e/go.mod h1:oRPTSZgaEJPhv3dmmNvo6grBq/BemrLG3wFQLMTZJkY= github.com/bdandy/go-errors v1.2.2 h1:WdFv/oukjTJCLa79UfkGmwX7ZxONAihKu4V0mLIs11Q= github.com/bdandy/go-errors v1.2.2/go.mod h1:NkYHl4Fey9oRRdbB1CoC6e84tuqQHiqrOcZpqFEkBxM= github.com/bdandy/go-socks4 v1.2.3 h1:Q6Y2heY1GRjCtHbmlKfnwrKVU/k81LS8mRGLRlmDlic= diff --git a/internal/config/config.go b/internal/config/config.go index 66f7312..3e930b1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -62,6 +62,12 @@ type ( Format string `toml:"format"` } + AttachmentsConfig struct { + ShowLinks bool `toml:"show_links"` + AllowedMIMETypes MIMETypes `toml:"allowed_mime_types"` + Preview bool `toml:"preview"` + } + DateSeparator struct { Enabled bool `toml:"enabled"` Format string `toml:"format"` @@ -151,15 +157,15 @@ type ( Mouse bool `toml:"mouse"` Editor string `toml:"editor"` - Status discord.Status `toml:"status"` - HideBlockedUsers bool `toml:"hide_blocked_users"` - ShowAttachmentLinks bool `toml:"show_attachment_links"` - AllowedMIMETypes MIMETypes `toml:"allowed_mime_types"` + Status discord.Status `toml:"status"` + HideBlockedUsers bool `toml:"hide_blocked_users"` // Use 0 to disable AutocompleteLimit uint8 `toml:"autocomplete_limit"` MessagesLimit uint8 `toml:"messages_limit"` + Attachments AttachmentsConfig `toml:"attachments"` + Markdown MarkdownConfig `toml:"markdown"` Help HelpConfig `toml:"help"` Picker PickerConfig `toml:"picker"` diff --git a/internal/config/config.toml b/internal/config/config.toml index 0a81d18..10d40d2 100644 --- a/internal/config/config.toml +++ b/internal/config/config.toml @@ -11,10 +11,6 @@ editor = "default" status = "default" hide_blocked_users = true -show_attachment_links = true -# Attachment MIME types that can be opened or downloaded without confirmation. -# Exact types and wildcards such as "image/*" are supported. -allowed_mime_types = ["image/*", "video/*"] # Max members to be in the mention autocomplete suggestions list # Note: Use autocomplete_limit = 0 to disable. @@ -23,6 +19,14 @@ autocomplete_limit = 20 # The number of messages to fetch when a text-based channel is selected from guilds tree. The minimum and maximum value is 1 and 100, respectively. messages_limit = 50 +[attachments] +show_links = true +# Attachment MIME types that can be opened or downloaded without confirmation. +# Exact types and wildcards such as "image/*" are supported. +allowed_mime_types = ["image/*", "video/*"] +# Whether to draw image attachments and embedded images below their messages. +preview = true + [composer] # Maximum height (in newline-separated rows) the composer grows to before it starts scrolling internally. # Set to 1 for a fixed single-line input. diff --git a/internal/config/config_test.go b/internal/config/config_test.go index caf8039..b480021 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -68,7 +68,7 @@ func TestLoad(t *testing.T) { t.Run("invalid allowed MIME type returns error", func(t *testing.T) { path := filepath.Join(t.TempDir(), "bad-mime.toml") - if err := os.WriteFile(path, []byte(`allowed_mime_types = ["image/"]`), os.ModePerm); err != nil { + if err := os.WriteFile(path, []byte("[attachments]\nallowed_mime_types = [\"image/\"]"), os.ModePerm); err != nil { t.Fatal(err) } diff --git a/internal/ui/chat/messageslist/model.go b/internal/ui/chat/messageslist/model.go index 0e4892a..f9fc685 100644 --- a/internal/ui/chat/messageslist/model.go +++ b/internal/ui/chat/messageslist/model.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "image" "io" "log/slog" "mime" @@ -48,6 +49,9 @@ type messageItem struct { view list.Item separator bool timestamp discord.Timestamp + + // previews holds the message's images by proxy URL, blank while loading and nil if they failed to. + previews map[discord.URL]image.Image } type Model struct { @@ -140,9 +144,9 @@ func (ml *Model) SetChannel(channel *discord.Channel, messages []discord.Message ml.setTitle(*channel) ml.setMessages(messages) if channel.GuildID.IsValid() { - return ml.requestGuildMembers(channel.GuildID, messages) + return tview.Batch(ml.requestGuildMembers(channel.GuildID, messages), ml.loadPreviews()) } - return nil + return ml.loadPreviews() } // SetFooter sets the text below the messages, such as who is typing. @@ -157,14 +161,15 @@ func (ml *Model) ShowNewest() { } // UpdateMessage replaces the shown message with the same ID. -func (ml *Model) UpdateMessage(message discord.Message) { +func (ml *Model) UpdateMessage(message discord.Message) tview.Cmd { ml.setMessage(ml.indexOf(message.ChannelID, message.ID), message) + return ml.loadPreviews() } // RefreshMessage shows the cached state of a message, such as after a reaction. func (ml *Model) RefreshMessage(channelID discord.ChannelID, id discord.MessageID) { if message, err := ml.state.Cabinet.Message(channelID, id); err == nil { - ml.UpdateMessage(*message) + ml.setMessage(ml.indexOf(channelID, id), *message) } } @@ -206,9 +211,10 @@ func (ml *Model) setMessages(messages []discord.Message) { ml.rebuildItems() } -func (ml *Model) AddMessage(message discord.Message) { +func (ml *Model) AddMessage(message discord.Message) tview.Cmd { ml.items = append(ml.items, messageItem{message: message}) ml.rebuildItems() + return ml.loadPreviews() } func (ml *Model) setMessage(index int, message discord.Message) { @@ -216,7 +222,7 @@ func (ml *Model) setMessage(index int, message discord.Message) { return } - ml.items[index] = messageItem{message: message} + ml.items[index] = messageItem{message: message, previews: ml.items[index].previews} ml.rebuildItems() } @@ -256,17 +262,24 @@ func (ml *Model) buildItem(index int) list.Item { if item.view == nil { message := item.message - item.view = &messageView{render: func(width int) richtext.Text { + view := &messageView{render: func(width int) richtext.Text { ml.renderWidth = width return ml.renderMessage(message, ml.cfg.Theme.MessagesList.MessageStyle.Style) }} + for _, s := range previewSources(message) { + if img := item.previews[s.proxy]; img != nil { + view.images = append(view.images, img) + } + } + item.view = view } return item.view } -// messageView renders a message for the width it is laid out at, keeping the wrapped lines until the width changes. +// messageView renders a message for the width it is laid out at, keeping the wrapped lines until the width changes, followed by its image previews. type messageView struct { render func(width int) richtext.Text + images []image.Image width int rendered bool lines richtext.Text @@ -284,11 +297,23 @@ func (v *messageView) at(width int) textview.Widget { func (v *messageView) Rows(width int) int { v.at(width) - return len(v.lines) + rows := len(v.lines) + for _, img := range v.images { + _, height := preview(img, width).Size() + rows += height.Cells() + } + return rows } func (v *messageView) Draw(screen tview.Screen, area tview.Rectangle) { - v.at(area.Width).Draw(screen, area) + v.at(area.Width).Draw(screen, tview.Rectangle{X: area.X, Y: area.Y, Width: area.Width, Height: len(v.lines)}) + y := area.Y + len(v.lines) + for _, img := range v.images { + p := preview(img, area.Width) + _, height := p.Size() + p.Draw(screen, tview.Rectangle{X: area.X, Y: y, Width: area.Width, Height: height.Cells()}) + y += height.Cells() + } } func (v *messageView) Handle(msg tview.Msg, area tview.Rectangle) tview.Msg { return msg } @@ -546,7 +571,7 @@ func (ml *Model) drawDefaultMessage(builder *richtext.Builder, message discord.M attachmentStyle := tview.MergeStyle(baseStyle, ml.cfg.Theme.MessagesList.AttachmentStyle.Style) for _, a := range message.Attachments { builder.NewLine() - if ml.cfg.ShowAttachmentLinks { + if ml.cfg.Attachments.ShowLinks { builder.Write(a.Filename+":", attachmentStyle) builder.NewLine() builder.Write(a.URL, attachmentStyle.Url(a.URL)) @@ -905,13 +930,21 @@ func (ml *Model) update(msg tview.Msg) tview.Cmd { } ml.rebuildItems() if selectedChannel.GuildID.IsValid() { - return ml.requestGuildMembers(selectedChannel.GuildID, msg.Older) + return tview.Batch(ml.requestGuildMembers(selectedChannel.GuildID, msg.Older), ml.loadPreviews()) } - return nil + return ml.loadPreviews() case deleteMessageMsg: return ml.requestDelete(discord.Message(msg)) case attachmentActionMsg: return msg.Action + case previewLoadedMsg: + index := slices.IndexFunc(ml.items, func(item messageItem) bool { return !item.separator && item.message.ID == msg.messageID }) + if index < 0 || ml.items[index].previews == nil { + return nil + } + ml.items[index].previews[msg.proxy] = msg.image + // Drop the view, so it is laid out again with the preview. + ml.items[index].view = nil case listMsg: ml.selectionState.Apply(list.Change(msg)) ml.onRowCursorChanged(ml.cursor()) @@ -1163,7 +1196,7 @@ func (ml *Model) openAttachment(attachment discord.Attachment) tview.Cmd { } func (ml *Model) confirmAttachment(attachment discord.Attachment, action tview.Cmd) tview.Cmd { - if !ml.cfg.AllowedMIMETypes.Has(attachment.ContentType) { + if !ml.cfg.Attachments.AllowedMIMETypes.Has(attachment.ContentType) { return ui.ShowModal( "This attachment type is not allowed and may be unsafe. Continue anyway?", ui.ModalButton{Label: "No"}, diff --git a/internal/ui/chat/messageslist/preview.go b/internal/ui/chat/messageslist/preview.go new file mode 100644 index 0000000..41e1be8 --- /dev/null +++ b/internal/ui/chat/messageslist/preview.go @@ -0,0 +1,120 @@ +package messageslist + +import ( + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "log/slog" + "net/http" + "net/url" + "slices" + "strconv" + + "github.com/ayn2op/arikawa/v3/discord" + "github.com/ayn2op/tview" + tviewimage "github.com/ayn2op/tview/image" + _ "golang.org/x/image/webp" +) + +// previewWidth is the widest a preview is drawn, in cells. +const previewWidth = 40 + +type previewLoadedMsg struct { + messageID discord.MessageID + proxy discord.URL + image image.Image +} + +func (previewLoadedMsg) messagesList() {} + +// previewSource is an image to preview. +type previewSource struct { + proxy discord.URL + width, height uint +} + +// previewSources returns the images of message's attachments and embeds. +func previewSources(message discord.Message) []previewSource { + var sources []previewSource + for _, a := range message.Attachments { + sources = append(sources, previewSource{a.Proxy, a.Width, a.Height}) + } + for _, e := range message.Embeds { + switch { + case e.Image != nil: + sources = append(sources, previewSource{e.Image.Proxy, e.Image.Width, e.Image.Height}) + // Image links and GIFs have their media as the thumbnail. + case e.Thumbnail != nil && (e.Type == discord.ImageEmbed || e.Type == discord.GIFVEmbed): + sources = append(sources, previewSource{e.Thumbnail.Proxy, e.Thumbnail.Width, e.Thumbnail.Height}) + } + } + // Files that are not images have no size. + return slices.DeleteFunc(sources, func(s previewSource) bool { return s.width == 0 || s.height == 0 }) +} + +// bounds returns the size s is loaded at, a pixel per cell of its preview. +func (s previewSource) bounds() image.Rectangle { + width := min(s.width, previewWidth) + return image.Rect(0, 0, int(width), int(s.height*width/s.width)) +} + +// loadPreviews loads the previews of the shown messages that are not loaded yet. +func (ml *Model) loadPreviews() tview.Cmd { + if !ml.cfg.Attachments.Preview { + return nil + } + var cmds []tview.Cmd + for i := range ml.items { + item := &ml.items[i] + for _, s := range previewSources(item.message) { + if _, ok := item.previews[s.proxy]; ok { + continue + } + if item.previews == nil { + item.previews = make(map[discord.URL]image.Image) + } + // A transparent image of the preview's size reserves its rows while it loads, so the messages below do not jump. + item.previews[s.proxy] = image.NewAlpha(s.bounds()) + cmds = append(cmds, loadPreview(item.message.ID, s)) + } + } + return tview.Batch(cmds...) +} + +// loadPreview loads the preview of s, with a nil image if it fails to. +func loadPreview(messageID discord.MessageID, s previewSource) tview.Cmd { + return func() tview.Msg { + img, err := downloadPreview(s) + if err != nil { + slog.Error("failed to load preview", "err", err) + } + return previewLoadedMsg{messageID: messageID, proxy: s.proxy, image: img} + } +} + +// downloadPreview downloads s from the media proxy, scaled down to its bounds. +func downloadPreview(s previewSource) (image.Image, error) { + u, err := url.Parse(s.proxy) + if err != nil { + return nil, err + } + bounds := s.bounds() + query := u.Query() + query.Set("width", strconv.Itoa(bounds.Dx())) + query.Set("height", strconv.Itoa(bounds.Dy())) + u.RawQuery = query.Encode() + + resp, err := http.Get(u.String()) + if err != nil { + return nil, err + } + defer resp.Body.Close() + img, _, err := image.Decode(resp.Body) + return img, err +} + +// preview returns the widget that draws img within width cells. +func preview(img image.Image, width int) tviewimage.Widget { + return tviewimage.New(img).Width(min(width, previewWidth, img.Bounds().Dx())) +} diff --git a/internal/ui/chat/messageslist/preview_test.go b/internal/ui/chat/messageslist/preview_test.go new file mode 100644 index 0000000..6290903 --- /dev/null +++ b/internal/ui/chat/messageslist/preview_test.go @@ -0,0 +1,37 @@ +package messageslist + +import ( + "slices" + "testing" + + "github.com/ayn2op/arikawa/v3/discord" +) + +func TestPreviewSources(t *testing.T) { + for _, tt := range []struct { + name string + message discord.Message + want []previewSource + }{ + {"file attachment", discord.Message{Attachments: []discord.Attachment{{Proxy: "a"}}}, nil}, + {"embed image over thumbnail", discord.Message{Embeds: []discord.Embed{{ + Type: discord.ImageEmbed, + Image: &discord.EmbedImage{Proxy: "i", Width: 2, Height: 1}, + Thumbnail: &discord.EmbedThumbnail{Proxy: "t", Width: 2, Height: 1}, + }}}, []previewSource{{"i", 2, 1}}}, + {"gif thumbnail", discord.Message{Embeds: []discord.Embed{{ + Type: discord.GIFVEmbed, + Thumbnail: &discord.EmbedThumbnail{Proxy: "t", Width: 2, Height: 1}, + }}}, []previewSource{{"t", 2, 1}}}, + {"article thumbnail", discord.Message{Embeds: []discord.Embed{{ + Type: discord.ArticleEmbed, + Thumbnail: &discord.EmbedThumbnail{Proxy: "t", Width: 2, Height: 1}, + }}}, nil}, + } { + t.Run(tt.name, func(t *testing.T) { + if got := previewSources(tt.message); !slices.Equal(got, tt.want) { + t.Fatalf("previewSources() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/ui/chat/state.go b/internal/ui/chat/state.go index 97d115e..ef6b303 100644 --- a/internal/ui/chat/state.go +++ b/internal/ui/chat/state.go @@ -39,7 +39,7 @@ func (m *Model) addMessageOrNotify(message *gateway.MessageCreateEvent) tview.Cm if channel := m.selectedChannel; channel != nil && channel.ID == message.ChannelID { m.removeTyper(message.Author.ID) - m.messagesList.AddMessage(message.Message) + cmd = tview.Batch(cmd, m.messagesList.AddMessage(message.Message)) if !m.windowUnfocused || !m.cfg.Notifications.WhenUnfocused { return cmd } @@ -117,7 +117,7 @@ func (m *Model) applyEvent(event gateway.Event) tview.Cmd { case *gateway.MessageCreateEvent: return m.addMessageOrNotify(event) case *gateway.MessageUpdateEvent: - m.messagesList.UpdateMessage(event.Message) + return m.messagesList.UpdateMessage(event.Message) case *gateway.PresenceUpdateEvent: return tview.Batch(m.updatePane(guildsTreePane, event), m.updatePane(membersTreePane, event)) case *gateway.GuildMemberListUpdateEvent: