feat(messageslist): preview images
This commit is contained in:
parent
48a12551aa
commit
1e675f10cf
9 changed files with 229 additions and 27 deletions
4
go.mod
4
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
|
||||
|
|
|
|||
2
go.sum
2
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=
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
120
internal/ui/chat/messageslist/preview.go
Normal file
120
internal/ui/chat/messageslist/preview.go
Normal 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()))
|
||||
}
|
||||
37
internal/ui/chat/messageslist/preview_test.go
Normal file
37
internal/ui/chat/messageslist/preview_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue