feat(messageslist): preview images

This commit is contained in:
ayn2op 2026-09-29 16:59:34 -04:00
parent 48a12551aa
commit 1e675f10cf
9 changed files with 229 additions and 27 deletions

4
go.mod
View file

@ -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

2
go.sum
View file

@ -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=

View file

@ -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"`

View file

@ -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.

View file

@ -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)
}

View file

@ -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"},

View file

@ -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()))
}

View file

@ -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)
}
})
}
}

View file

@ -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: