feat(messageslist): show avatar preview

This commit is contained in:
ayn2op 2026-10-12 20:07:54 -04:00
parent 5d518d7521
commit bba502cba8
20 changed files with 227 additions and 213 deletions

View file

@ -41,31 +41,6 @@ The number of messages to fetch when a text-based channel is selected, from 1 to
Default: `50`
# attachments
## attachments.show_links
Default: `true`
## attachments.allowed_mime_types
Attachment MIME types that can be opened or downloaded without confirmation.
Exact types and wildcards such as "image/*" are supported.
Default: `["image/*", "video/*"]`
## attachments.preview
Whether to draw image attachments and embedded images below their messages.
Default: `true`
## attachments.protocol
How previews are drawn: "auto", "kitty" or "halfblocks".
Default: `"auto"`
# markdown
## markdown.enabled
@ -388,7 +363,7 @@ Default: `false`
### ui.messages_list.gap
Empty rows between messages.
The number of empty rows between messages.
Default: `1`
@ -400,6 +375,43 @@ Default: `">"`
Default: `"<"`
### ui.messages_list.attachments
#### ui.messages_list.attachments.show_links
Default: `true`
#### ui.messages_list.attachments.allowed_mime_types
Attachment MIME types that can be opened or downloaded without confirmation.
Exact types and wildcards such as "image/*" are supported.
Default: `["image/*", "video/*"]`
#### ui.messages_list.attachments.preview
Whether to draw image attachments and embedded images below their messages.
Default: `true`
#### ui.messages_list.attachments.protocol
How previews are drawn: "auto", "kitty" or "halfblocks".
Default: `"auto"`
### ui.messages_list.avatars
#### ui.messages_list.avatars.preview
Default: `true`
#### ui.messages_list.avatars.height
The number of rows an avatar takes. It is recommended to use `1` with compact.
Default: `2`
## ui.mentions_list
The list of mention suggestions above the composer. Its width and height are capped to the available space.

4
go.mod
View file

@ -15,13 +15,13 @@ require (
github.com/andybalholm/brotli v1.2.6
github.com/ayn2op/arikawa/v3 v3.0.0-20261012034659-5b006907b2de
github.com/ayn2op/ningen/v3 v3.0.1-0.20260929010920-29794d5fa70e
github.com/ayn2op/tview v0.0.0-20261012034741-d8b2704e2b8e
github.com/ayn2op/tview v0.0.0-20261013011207-c8c627013874
github.com/bogdanfinn/fhttp v0.6.9
github.com/bogdanfinn/tls-client v1.16.0
github.com/gdamore/tcell/v3 v3.6.0
github.com/gen2brain/malgo v0.11.26
github.com/gorilla/websocket v1.5.3
github.com/klauspost/compress v1.20.1
github.com/klauspost/compress v1.20.3
github.com/mattn/go-shellwords v1.0.16
github.com/ncruces/zenity v0.10.15
github.com/rivo/uniseg v0.4.7

8
go.sum
View file

@ -14,8 +14,8 @@ github.com/ayn2op/arikawa/v3 v3.0.0-20261012034659-5b006907b2de h1:bO825TKutshlG
github.com/ayn2op/arikawa/v3 v3.0.0-20261012034659-5b006907b2de/go.mod h1:YARcqZmk7hDjXB+lexlXX4wDFHULafbGnF0gsD0tuNw=
github.com/ayn2op/ningen/v3 v3.0.1-0.20260929010920-29794d5fa70e h1:QAei/IVEyZV7GcpkpcFUh37+QRqwrt57z7OT3ciHrhI=
github.com/ayn2op/ningen/v3 v3.0.1-0.20260929010920-29794d5fa70e/go.mod h1:RsvYiMzi4Si1F7vmml5NCL2DaKIZXkNUQC7QlGTMtCw=
github.com/ayn2op/tview v0.0.0-20261012034741-d8b2704e2b8e h1:uCt61XkJ0DqRiBd5ozXZVd2xWlNFcA0rR2hew9lAZx0=
github.com/ayn2op/tview v0.0.0-20261012034741-d8b2704e2b8e/go.mod h1:3aWH6S5uOqOkZv7JFePEVHukTj9hPb6Lbynm59Sks2E=
github.com/ayn2op/tview v0.0.0-20261013011207-c8c627013874 h1:zXM8mKeDfXx+dzGEVTTZ0Xfo9vCXNBiX2qEWz/6KMV0=
github.com/ayn2op/tview v0.0.0-20261013011207-c8c627013874/go.mod h1:3aWH6S5uOqOkZv7JFePEVHukTj9hPb6Lbynm59Sks2E=
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,8 +62,8 @@ github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUq
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/josephspurrier/goversioninfo v1.7.0 h1:LQzXOlVm/CtbwJ9/UHl5a2HT0BjcLAwid5gqGd7ZUJ8=
github.com/josephspurrier/goversioninfo v1.7.0/go.mod h1:z9y0r2G6g5jwSJaFE0cxW9to0aeIibK7UYeLx53aQRU=
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/klauspost/compress v1.20.3 h1:zWNoFqq/lSuYQRim3dUXlXeA0IqwuZGicdG5ZTfY5j4=
github.com/klauspost/compress v1.20.3/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=

View file

@ -62,17 +62,6 @@ type (
Format string `toml:"format"`
}
AttachmentsConfig struct {
ShowLinks bool `toml:"show_links"`
// Attachment MIME types that can be opened or downloaded without confirmation.
// Exact types and wildcards such as "image/*" are supported.
AllowedMIMETypes MIMETypes `toml:"allowed_mime_types"`
// Whether to draw image attachments and embedded images below their messages.
Preview bool `toml:"preview"`
// How previews are drawn: "auto", "kitty" or "halfblocks".
Protocol string `toml:"protocol"`
}
DateSeparator struct {
Enabled bool `toml:"enabled"`
// https://pkg.go.dev/time#Layout
@ -198,8 +187,6 @@ type (
// The number of messages to fetch when a text-based channel is selected, from 1 to 100.
MessagesLimit uint8 `toml:"messages_limit"`
Attachments AttachmentsConfig `toml:"attachments"`
Markdown MarkdownConfig `toml:"markdown"`
Help HelpConfig `toml:"help"`
Picker PickerConfig `toml:"picker"`
@ -242,13 +229,6 @@ func Default() Config {
AutocompleteLimit: 20,
MessagesLimit: 50,
Attachments: AttachmentsConfig{
ShowLinks: true,
AllowedMIMETypes: MIMETypes{"image/*", "video/*"},
Preview: true,
Protocol: "auto",
},
Markdown: MarkdownConfig{Enabled: true, Theme: "monokai"},
Help: HelpConfig{Enabled: true, CompactModifiers: true, Padding: [2]int{1, 1}, Separator: " • "},
Picker: PickerConfig{Width: 80, Height: 25},

View file

@ -60,7 +60,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("[attachments]\nallowed_mime_types = [\"image/\"]"), os.ModePerm); err != nil {
if err := os.WriteFile(path, []byte("[ui.messages_list.attachments]\nallowed_mime_types = [\"image/\"]"), os.ModePerm); err != nil {
t.Fatal(err)
}

View file

@ -173,6 +173,26 @@ type (
ReplyIndicator string `toml:"reply_indicator"`
ForwardedIndicator string `toml:"forwarded_indicator"`
Attachments AttachmentsUIConfig `toml:"attachments"`
Avatars AvatarsUIConfig `toml:"avatars"`
}
AvatarsUIConfig struct {
Preview bool `toml:"preview"`
// The number of rows an avatar takes. It is recommended to use `1` with compact.
Height uint8 `toml:"height"`
}
AttachmentsUIConfig struct {
ShowLinks bool `toml:"show_links"`
// Attachment MIME types that can be opened or downloaded without confirmation.
// Exact types and wildcards such as "image/*" are supported.
AllowedMIMETypes MIMETypes `toml:"allowed_mime_types"`
// Whether to draw image attachments and embedded images below their messages.
Preview bool `toml:"preview"`
// How previews are drawn: "auto", "kitty" or "halfblocks".
Protocol string `toml:"protocol"`
}
MentionsListUIConfig struct {
@ -186,6 +206,7 @@ type (
func defaultUI() UIConfig {
left := TitleUIConfig{AlignmentWrapper{tview.AlignmentLeft}}
round := BorderSetWrapper{tview.BorderSetRound()}
attachments := AttachmentsUIConfig{ShowLinks: true, AllowedMIMETypes: MIMETypes{"image/*", "video/*"}, Preview: true, Protocol: "auto"}
return UIConfig{
Tabs: TabsUIConfig{
Alignment: AlignmentWrapper{tview.AlignmentCenter},
@ -200,7 +221,7 @@ func defaultUI() UIConfig {
GuildsTree: GuildsTreeUIConfig{TreeUIConfig{Graphics: true}, true},
MembersTree: TreeUIConfig{Graphics: true},
ScrollBar: ScrollBarUIConfig{SymbolSet: SymbolSetWrapper{boxDrawing}, Visibility: ScrollBarVisibilityWrapper{viewport.ScrollBarVisibilityAutomatic}},
MessagesList: MessagesListUIConfig{Gap: 1, ReplyIndicator: ">", ForwardedIndicator: "<"},
MessagesList: MessagesListUIConfig{Gap: 1, ReplyIndicator: ">", ForwardedIndicator: "<", Attachments: attachments, Avatars: AvatarsUIConfig{Preview: true, Height: 2}},
MentionsList: MentionsListUIConfig{MinWidth: 20},
}
}

View file

@ -93,12 +93,6 @@ func (m *Model) SetChannel(channel *discord.Channel) {
m.disabled = !isDM && !m.state.HasPermissions(channel.ID, discord.PermissionSendMessages)
}
// Height returns the number of rows the composer takes, including its border.
func (m *Model) Height() int {
_, frame := m.frame()
return min(strings.Count(m.editState.Value(), "\n")+1, max(m.cfg.Composer.MaxHeight, 1)) + frame
}
func (m Model) Disabled() bool {
return m.disabled
}
@ -148,8 +142,9 @@ func (m *Model) box(focused bool) box.Widget {
Placeholder(m.placeholder()).
Keybind(m.editAction).
Focused(focused && !m.disabled).
OnChange(func(a textarea.Change) tview.Msg { return editMsg(a) })
return ui.Box(text, m.cfg, focused).Title(m.title()).Footer(m.footer())
OnChange(func(a textarea.Change) tview.Msg { return editMsg(a) }).
Height(layout.Shrink.Max(m.cfg.Composer.MaxHeight))
return ui.Box(text, m.cfg, focused).Title(m.title()).Footer(m.footer()).Height(layout.Shrink)
}
// title returns what the composer is doing: editing, replying, or nothing.
@ -202,13 +197,6 @@ func (m *Model) pasteText() {
m.insert(string(data))
}
// frame returns the cells the composer's border and padding take on each side.
func (m *Model) frame() (left, vertical int) {
const size = 100
inner := m.box(false).InnerArea(tview.Rectangle{Width: size, Height: size})
return inner.X, size - inner.Height
}
func (m *Model) reset() {
m.editing = nil
m.sendMessageData = &api.SendMessageData{}
@ -487,7 +475,7 @@ func (m Model) OnGuildMemberRemove(event *gateway.GuildMemberRemoveEvent) {
}
}
// MentionsListView places the mentions list just above the composer, near the cursor, or returns nil if it is empty or the composer is not focused.
// MentionsListView places the mentions list at the bottom of the area above the composer, near the cursor, or returns nil if it is empty or the composer is not focused.
func (m Model) MentionsListView(focused bool) tview.Widget {
if !focused || !m.mentionsListVisible() {
return nil
@ -495,7 +483,7 @@ func (m Model) MentionsListView(focused bool) tview.Widget {
return mentionsPopup{m}
}
// mentionsPopup lays out the mentions list within the area above the composer, where it is drawn over the messages.
// mentionsPopup places the mentions list within the area of the messages, which it is drawn over.
type mentionsPopup struct {
c Model
}
@ -516,29 +504,14 @@ func (p mentionsPopup) Handle(msg tview.Msg, area tview.Rectangle) tview.Msg {
return p.c.mentionsList.View().Handle(msg, p.area(area))
}
// area returns where the list goes in area, the messages and composer together.
// area returns where the list goes in area: at its bottom, at the column of the cursor where it fits.
func (p mentionsPopup) area(area tview.Rectangle) tview.Rectangle {
c := p.c
borders := 0
if c.cfg.UI.Border.Enabled {
borders = 1
}
x, _ := c.frame()
maxW, maxH := area.Width, area.Height-c.Height()
if t := int(c.cfg.UI.MentionsList.MaxHeight); t != 0 {
maxH = min(maxH, t)
}
h := min(c.mentionsList.ItemCount()+borders, maxH) + borders + c.cfg.UI.Border.Padding[1]
w := int(c.cfg.UI.MentionsList.MinWidth)
if w == 0 {
w = maxW
} else {
w = min(max(w, c.mentionsList.MaxDisplayWidth())+borders*2, maxW)
value, cursor := c.editState.Value(), c.editState.Cursor()
col := uniseg.StringWidth(value[strings.LastIndex(value[:cursor], "\n")+1 : cursor])
x += min(col, maxW-w)
}
return tview.Rectangle{X: area.X + x, Y: area.Y + area.Height - c.Height() - h, Width: w, Height: h}
size := c.mentionsList.View().Layout(layout.Limits{Max: layout.Size{Width: area.Width, Height: area.Height}})
value, cursor := c.editState.Value(), c.editState.Cursor()
col := uniseg.StringWidth(value[strings.LastIndex(value[:cursor], "\n")+1 : cursor])
x := min(c.box(false).InnerArea(tview.Rectangle{}).X+col, area.Width-size.Width)
return tview.Rectangle{X: area.X + x, Y: area.Y + area.Height - size.Height, Width: size.Width, Height: size.Height}
}
func (m *Model) addMentionMember(gID discord.GuildID, member *discord.Member) bool {

View file

@ -27,7 +27,13 @@ func NewModel(cfg *config.Config) Model {
func (m Model) View() tview.Widget {
set := m.cfg.UI.Border.NormalSet.BorderSet
set.BottomLeft, set.BottomRight = set.BottomT, set.BottomT
return ui.Box(m.listView(), m.cfg, false).Title("Mentions").BorderSet(set)
cfg := m.cfg.UI.MentionsList
list, width := m.listView().Height(layout.Shrink.Max(int(cfg.MaxHeight))), layout.Fill
if cfg.MinWidth != 0 {
list, width = list.Width(layout.Fixed(max(int(cfg.MinWidth), m.maxDisplayWidth()))), layout.Shrink
}
return ui.Box(list, m.cfg, false).Title("Mentions").BorderSet(set).Width(width).Height(layout.Shrink)
}
func (Model) Init() tview.Cmd { return nil }
@ -80,7 +86,7 @@ func (m Model) SelectedInsertText() (string, bool) {
return m.items[index].InsertText, true
}
func (m Model) MaxDisplayWidth() int {
func (m Model) maxDisplayWidth() int {
width := 0
for _, item := range m.items {
width = max(width, uniseg.StringWidth(item.DisplayText))

View file

@ -26,6 +26,7 @@ import (
"github.com/ayn2op/discordo/internal/voice"
"github.com/ayn2op/ningen/v3"
"github.com/ayn2op/tview"
"github.com/ayn2op/tview/column"
tviewimage "github.com/ayn2op/tview/image"
"github.com/ayn2op/tview/keybind"
"github.com/ayn2op/tview/list"
@ -44,7 +45,8 @@ import (
type messageItem struct {
message discord.Message
view tview.Widget
// text and reply are the message and what it replies to as wrapped, or nil until they are shown.
text, reply *messageView
// A separator shows the date of the message after it.
separator bool
@ -63,6 +65,7 @@ type Model struct {
items []messageItem
renderer *markdown.Renderer
avatars map[discord.URL]tviewimage.Widget
// voicePlayer plays the voice message played last, and voiceStatus is what it last said of it.
voicePlayer *voice.Player
voiceStatus voice.PlayerStatus
@ -81,6 +84,7 @@ func NewModel(cfg *config.Config, state *ningen.State) Model {
cfg: cfg,
state: state,
renderer: markdown.NewRenderer(cfg),
avatars: map[discord.URL]tviewimage.Widget{},
voicePlayer: voice.NewPlayer(),
}
@ -182,6 +186,7 @@ func (m Model) indexOf(channelID discord.ChannelID, id discord.MessageID) int {
func (m *Model) reset() {
m.items = nil
clear(m.avatars)
m.selectionState = list.NewSelectionState()
m.scrollState = viewport.ScrollState{}
m.scrollState.ScrollToEnd()
@ -247,31 +252,45 @@ func (m Model) buildItem(index int) tview.Widget {
return dateSeparator{date: date, fill: m.cfg.DateSeparator.Character, style: m.cfg.Theme.MessagesList.MessageStyle.Style.Dim(true)}
}
if item.view == nil {
message := item.message
view := &messageView{render: func(width int) richtext.Text {
return m.renderMessage(message, m.cfg.Theme.MessagesList.MessageStyle.Style, width)
message, style := item.message, m.cfg.Theme.MessagesList.MessageStyle.Style
if item.text == nil {
item.text = &messageView{render: func(width int) richtext.Text { return m.renderMessage(message, style, width) }}
item.reply = &messageView{render: func(int) richtext.Text {
builder := new(richtext.Builder)
m.drawReply(builder, message, style)
return builder.Finish()
}}
if isVoiceMessage(message) {
view.player = m.voiceMessageView(message.Attachments[0])
}
for _, s := range previewSources(message) {
if preview, ok := item.previews[s.proxy]; ok {
view.images = append(view.images, preview)
}
}
item.view = view
}
return item.view
body := column.New(item.text).Height(layout.Shrink)
if isVoiceMessage(message) {
body = body.Push(m.voiceMessageView(message.Attachments[0]))
}
for _, s := range previewSources(message) {
if preview, ok := item.previews[s.proxy]; ok {
body = body.Push(preview)
}
}
if avatar, ok := m.avatars[avatarURL(message.Author)]; ok {
body = row.New(avatar, body).Spacing(1).Height(layout.Shrink)
}
if message.Type == discord.InlinedReplyMessage && !m.blocked(message.Author) {
body = column.New(item.reply, body).Height(layout.Shrink)
}
return body
}
// messageView renders a message followed by its voice message player, if it is one, and its image previews, caching the wrapped lines until the width changes.
func (m Model) blocked(user discord.User) bool {
return m.cfg.HideBlockedUsers && m.state.UserIsBlocked(user.ID)
}
// messageView is the text of a message. It keeps the wrapped lines until the width changes, as the list measures every message when it draws.
type messageView struct {
render func(width int) richtext.Text
// player is a row high, or nil.
player tview.Widget
images []tviewimage.Widget
render func(width int) richtext.Text
width int
rendered bool
lines richtext.Text
@ -287,49 +306,20 @@ func (v *messageView) at(width int) textview.Widget {
return textview.New(v.lines).Wrap(false)
}
// Size returns Fill and Shrink, as the message is as tall as its content.
func (v *messageView) Size() (width, height layout.Length) { return layout.Fill, layout.Shrink }
// Layout returns the size of the message within limits, as tall as the rows it takes at the width of limits.
func (v *messageView) Layout(limits layout.Limits) layout.Size {
return layout.Sized(limits, layout.Fill, layout.Shrink, func(limits layout.Limits) layout.Size {
v.at(limits.Max.Width)
rows := len(v.lines)
if v.player != nil {
rows++
}
for _, preview := range v.images {
_, height := preview.Size()
rows += height.Cells()
}
return layout.Size{Height: rows}
return layout.Size{Height: len(v.lines)}
})
}
func (v *messageView) Draw(screen tview.Screen, area tview.Rectangle) {
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)
if v.player != nil {
v.player.Draw(screen, tview.Rectangle{X: area.X, Y: y, Width: area.Width, Height: 1})
y++
}
for _, preview := range v.images {
_, height := preview.Size()
preview.Draw(screen, tview.Rectangle{X: area.X, Y: y, Width: area.Width, Height: height.Cells()})
y += height.Cells()
}
v.at(area.Width).Draw(screen, area)
}
// Handle passes msg to the player, for its waveform to be clicked.
func (v *messageView) Handle(msg tview.Msg, area tview.Rectangle) tview.Msg {
if v.player == nil {
return msg
}
return v.player.Handle(msg, tview.Rectangle{X: area.X, Y: area.Y + len(v.lines), Width: area.Width, Height: 1})
}
func (v *messageView) Handle(msg tview.Msg, _ tview.Rectangle) tview.Msg { return msg }
// dateSeparator is a line with the date in the middle, filled to the width it is drawn at.
type dateSeparator struct {
@ -413,12 +403,9 @@ func (m *Model) onRowCursorChanged(index int) {
}
func (m Model) writeMessage(builder *richtext.Builder, message discord.Message, baseStyle tview.Style, width int) {
if m.cfg.HideBlockedUsers {
isBlocked := m.state.UserIsBlocked(message.Author.ID)
if isBlocked {
builder.Write("Blocked message", baseStyle.Foreground(color.Red).Bold(true))
return
}
if m.blocked(message.Author) {
builder.Write("Blocked message", baseStyle.Foreground(color.Red).Bold(true))
return
}
switch message.Type {
@ -431,7 +418,7 @@ func (m Model) writeMessage(builder *richtext.Builder, message discord.Message,
case discord.GuildMemberJoinMessage:
m.drawLayout(builder, message, baseStyle, func() { builder.Write("joined the server.", baseStyle) })
case discord.InlinedReplyMessage:
m.drawReplyMessage(builder, message, baseStyle, width)
m.drawDefaultMessage(builder, message, baseStyle, width)
case discord.ChannelPinnedMessage:
m.drawPinnedMessage(builder, message, baseStyle)
default:
@ -631,7 +618,7 @@ func (m Model) drawDefaultMessage(builder *richtext.Builder, message discord.Mes
for _, a := range message.Attachments {
builder.NewLine()
if m.cfg.Attachments.ShowLinks {
if m.cfg.UI.MessagesList.Attachments.ShowLinks {
builder.Write(a.Filename+":", attachmentStyle)
builder.NewLine()
builder.Write(a.URL, attachmentStyle.Url(a.URL))
@ -704,7 +691,7 @@ func (m Model) voiceMessageView(a discord.Attachment) tview.Widget {
return rail[cell], style.Dim(true)
})
return row.New(text.New(symbol), waveform, text.New(label)).Spacing(1)
return row.New(text.New(symbol), waveform, text.New(label)).Spacing(1).Height(layout.Fixed(1))
}
func (m Model) drawEmbeds(builder *richtext.Builder, message discord.Message, baseStyle tview.Style, width int, contentRoot ast.Node, contentSource []byte) {
@ -938,7 +925,7 @@ func (m Model) drawForwardedMessage(builder *richtext.Builder, message discord.M
})
}
func (m Model) drawReplyMessage(builder *richtext.Builder, message discord.Message, baseStyle tview.Style, width int) {
func (m Model) drawReply(builder *richtext.Builder, message discord.Message, baseStyle tview.Style) {
dimStyle := baseStyle.Dim(true)
builder.Write(m.cfg.UI.MessagesList.ReplyIndicator+" ", dimStyle)
@ -950,9 +937,6 @@ func (m Model) drawReplyMessage(builder *richtext.Builder, message discord.Messa
} else {
builder.Write("Original message was deleted", dimStyle)
}
builder.NewLine()
m.drawDefaultMessage(builder, message, baseStyle, width)
}
func (m Model) drawPinnedMessage(builder *richtext.Builder, message discord.Message, baseStyle tview.Style) {
@ -1035,30 +1019,29 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
}
return m, m.loadPreviews()
case previewLoadedMsg:
index := slices.IndexFunc(m.items, func(item messageItem) bool { return !item.separator && item.message.ID == msg.messageID })
if index < 0 || m.items[index].previews == nil {
return m, nil
}
preview, cmd := msg.preview, tview.Cmd(nil)
if m.kitty {
m.kittyImages++
preview = preview.Kitty(m.kittyImages%63488 + 1)
cmd = preview.Transmit()
}
if _, ok := m.avatars[msg.proxy]; ok {
m.avatars[msg.proxy] = preview
return m, cmd
}
index := slices.IndexFunc(m.items, func(item messageItem) bool { return !item.separator && item.message.ID == msg.messageID })
if index < 0 || m.items[index].previews == nil {
return m, nil
}
m.items[index].previews[msg.proxy] = preview
// Invalidate the view so it is laid out with the preview.
m.items[index].view = nil
return m, cmd
case seekMsg:
return m, m.seek(time.Duration(msg))
case voiceMsg:
m.voiceStatus = m.voicePlayer.Status()
for i := range m.items {
if isVoiceMessage(m.items[i].message) {
m.items[i].view = nil
}
}
return m, m.Init()
case tview.ResizeMsg:
cols, rows := msg.Size()
@ -1067,7 +1050,7 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
m.cellWidth, m.cellHeight = uint(width/cols), uint(height/rows)
}
case tview.TerminalInfoMsg:
switch m.cfg.Attachments.Protocol {
switch m.cfg.UI.MessagesList.Attachments.Protocol {
case "kitty":
m.kitty = true
case "halfblocks":
@ -1267,6 +1250,6 @@ func (m Model) canDeleteMessage(message discord.Message) bool {
func (m Model) InvalidateRendered() {
for i := range m.items {
m.items[i].view = nil
m.items[i].text = nil
}
}

View file

@ -12,6 +12,7 @@ import (
"github.com/ayn2op/discordo/internal/config"
"github.com/ayn2op/ningen/v3"
"github.com/ayn2op/tview"
"github.com/ayn2op/tview/layout"
)
func testConfig(t *testing.T) *config.Config {
@ -40,9 +41,15 @@ func TestModelBuildItem(t *testing.T) {
if ml.items[ml.messageIndex(-1, 1)].message.ID != 2 {
t.Fatal("messages are not oldest first")
}
view := ml.buildItem(ml.messageIndex(len(ml.items), -1))
if ml.buildItem(ml.messageIndex(len(ml.items), -1)) != view {
t.Fatal("message view was not reused")
// text returns the wrapped text kept for the newest message once it is shown.
text := func() *messageView {
index := ml.messageIndex(len(ml.items), -1)
ml.buildItem(index)
return ml.items[index].text
}
view := text()
if text() != view {
t.Fatal("message text was not reused")
}
if ml.selectTop() == nil || ml.selectTop() != nil || ml.selectUp() != nil {
t.Fatal("older messages were not fetched once on reaching the top")
@ -54,21 +61,21 @@ func TestModelBuildItem(t *testing.T) {
ml.selectBottom()
*ml, _ = ml.Update(olderMessagesLoadedMsg{ChannelID: 1, Older: []discord.Message{{ID: 1}}})
selected, ok := ml.selectedMessage()
if !ok || selected.ID != 3 || ml.buildItem(ml.messageIndex(len(ml.items), -1)) != view {
t.Fatal("prepend lost selection or cached view")
if !ok || selected.ID != 3 || text() != view {
t.Fatal("prepend lost selection or cached text")
}
ml.deleteMessage(ml.messageIndex(-1, 1))
if ml.buildItem(ml.messageIndex(len(ml.items), -1)) != view {
t.Fatal("deletion discarded another message's view")
if text() != view {
t.Fatal("deletion discarded another message's text")
}
ml.setMessage(ml.messageIndex(len(ml.items), -1), discord.Message{ID: 3, Content: "edited"})
edited := ml.buildItem(ml.messageIndex(len(ml.items), -1))
edited := text()
if edited == view {
t.Fatal("edit reused a stale view")
t.Fatal("edit reused stale text")
}
ml.InvalidateRendered()
if ml.buildItem(ml.messageIndex(len(ml.items), -1)) == edited {
t.Fatal("global invalidation reused a stale view")
if text() == edited {
t.Fatal("global invalidation reused stale text")
}
ml.reset()
if len(ml.items) != 0 {
@ -224,3 +231,18 @@ func TestModelDrawLayout(t *testing.T) {
}
}
}
func TestMessageViewLayout(t *testing.T) {
cfg := testConfig(t)
cfg.UI.MessagesList.Compact = true
ml := newTestModel(t, cfg)
limits := layout.Limits{Max: layout.Size{Width: 80}, Infinite: layout.Axes{Height: true}}
// The avatar is 2 rows, below the row of a reply.
for want, messageType := range map[int]discord.MessageType{2: discord.DefaultMessage, 3: discord.InlinedReplyMessage} {
ml.SetChannel(ml.channel, []discord.Message{{ID: 1, Type: messageType, Content: "hi"}})
if got := ml.buildItem(ml.messageIndex(-1, 1)).Layout(limits).Height; got != want {
t.Fatalf("height = %d, want %d", got, want)
}
}
}

View file

@ -272,7 +272,7 @@ func (m Model) openAttachment(attachment discord.Attachment) tview.Cmd {
}
func (m Model) confirmAttachment(attachment discord.Attachment, action tview.Cmd) tview.Cmd {
if !m.cfg.Attachments.AllowedMIMETypes.Has(attachment.ContentType) {
if !m.cfg.UI.MessagesList.Attachments.AllowedMIMETypes.Has(attachment.ContentType) {
return ui.ShowDialog(
"This attachment type is not allowed and may be unsafe. Continue anyway?",
ui.DialogButton{Label: "No"},

View file

@ -73,13 +73,19 @@ func (m Model) previewCell() (width, height uint) {
}
func (m Model) loadPreviews() tview.Cmd {
if !m.cfg.Attachments.Preview {
return nil
}
cellWidth, cellHeight := m.previewCell()
var cmds []tview.Cmd
for i := range m.items {
item := &m.items[i]
if item.separator {
continue
}
cmds = append(cmds, m.loadAvatar(item.message.Author))
if !m.cfg.UI.MessagesList.Attachments.Preview {
continue
}
for _, s := range previewSources(item.message) {
if _, ok := item.previews[s.proxy]; ok {
continue
@ -101,6 +107,26 @@ func (m Model) loadPreviews() tview.Cmd {
return tview.Batch(cmds...)
}
func avatarURL(user discord.User) discord.URL {
return user.AvatarURLWithType(discord.PNGImage) + "?size=128"
}
func (m Model) loadAvatar(user discord.User) tview.Cmd {
url, cfg := avatarURL(user), m.cfg.UI.MessagesList.Avatars
if _, ok := m.avatars[url]; ok || !cfg.Preview || cfg.Height == 0 || m.blocked(user) {
return nil
}
cellWidth, cellHeight := m.previewCell()
cols := uint(cfg.Height) * cellHeight / cellWidth
bounds := image.Rect(0, 0, int(cols*cellWidth), int(cols*cellWidth))
widget := func(img image.Image) tviewimage.Widget {
return tviewimage.New(img).Width(int(cols)).CellSize(int(cellWidth), int(cellHeight))
}
m.avatars[url] = widget(image.NewAlpha(bounds))
return m.loadPreview(0, url, bounds, widget)
}
// loadPreview yields an empty preview if loading fails.
func (m Model) loadPreview(messageID discord.MessageID, proxy discord.URL, bounds image.Rectangle, widget func(image.Image) tviewimage.Widget) tview.Cmd {
return func() tview.Msg {

View file

@ -427,14 +427,13 @@ func (m Model) canFocus(p pane) bool {
func (m Model) View() tview.Widget {
picker := m.pickerView()
var right tview.Widget = column.New(
m.paneView(paneMessagesList),
column.New(m.paneView(paneComposer)).Height(layout.Fixed(m.composer.Height())),
)
messages := m.paneView(paneMessagesList)
if mentions := m.composer.MentionsListView(m.focused == paneComposer); mentions != nil {
right = stack.New(right, mentions)
messages = stack.New(messages, mentions)
}
var right tview.Widget = column.New(messages, m.paneView(paneComposer))
main := right
if m.membersTreeShown() {
width := m.cfg.MembersTree.WidthPercent

View file

@ -70,6 +70,8 @@ func TestModelAddMessageOrNotify(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
m := newTestModel(t)
m.cfg.Notifications.WhenUnfocused = tt.whenUnfocused
// Loading an avatar is a command too.
m.cfg.UI.MessagesList.Avatars.Preview = false
m.setSelectedChannel(&discord.Channel{ID: 1})
*m, _ = m.Update(tcell.NewEventFocus(tt.focused))
cmd := m.addMessageOrNotify(&gateway.MessageCreateEvent{Message: discord.Message{ID: 1, ChannelID: 1}})

View file

@ -9,6 +9,7 @@ import (
"github.com/ayn2op/discordo/internal/ui/login/qr"
"github.com/ayn2op/discordo/internal/ui/login/token"
"github.com/ayn2op/tview"
"github.com/ayn2op/tview/column"
)
// tab is a login method shown as a tab.
@ -62,10 +63,9 @@ func (m Model) View() tview.Widget {
}
t := m.tabs().
Active(int(m.active)).
Content(content).
Keybind(tabAction).
OnSelect(func(i int) tview.Msg { return selectTabMsg(i) })
return ui.Box(t, m.cfg, false)
return ui.Box(column.New(t, content), m.cfg, false)
}
// tabs returns the login methods as tabs set up by the tabs config.

View file

@ -78,8 +78,8 @@ func (m Model) View() tview.Widget {
Focused(m.focus == focusButton).
OnClick(submitMsg{})
return column.New(
row.New(column.New(text.New("Login")).Width(labelWidth), login).Height(layout.Fixed(1)),
row.New(column.New(text.New("Password")).Width(labelWidth), password).Height(layout.Fixed(1)),
row.New(column.New(text.New("Login")).Width(labelWidth), login).Height(layout.Shrink),
row.New(column.New(text.New("Password")).Width(labelWidth), password).Height(layout.Shrink),
submit,
).Spacing(1)
).Spacing(1).Align(tview.AlignmentCenter)
}

View file

@ -133,7 +133,7 @@ func (m Model) View() tview.Widget {
code := m.code()
return center.New(
viewport.New(textview.New(code).Wrap(false).Alignment(tview.AlignmentCenter), m.scrollState).
Height(layout.Fixed(len(code))).
Height(layout.Shrink).
Focused(true).
OnChange(func(a viewport.Change) tview.Msg { return scrollMsg(a) }),
)

View file

@ -59,7 +59,7 @@ func (m Model) View() tview.Widget {
Focused(m.buttonFocused).
OnClick(submitMsg{})
return column.New(
row.New(column.New(text.New("Token")).Width(layout.Fixed(len("Token")+1)), input).Height(layout.Fixed(1)),
row.New(column.New(text.New("Token")).Width(layout.Fixed(len("Token")+1)), input).Height(layout.Shrink),
submit,
).Spacing(1)
).Spacing(1).Align(tview.AlignmentCenter)
}

View file

@ -144,11 +144,6 @@ func (m *Model) updateScreen(msg tview.Msg) tview.Cmd {
return cmd
}
// helpHeight returns the rows the help takes at an unlimited width, as the width of the screen is not known in View.
func (m Model) helpHeight() int {
return m.helpView().Layout(layout.Limits{Infinite: layout.Axes{Height: true}}).Height
}
// View shows the inner model above help, with the dialog on top of both when one is open.
func (m Model) View() tview.Widget {
var innerView, helpView tview.Widget
@ -159,8 +154,7 @@ func (m Model) View() tview.Widget {
innerView = m.chat.View()
}
if m.helpVisible {
padded := box.New(m.helpView()).Padding(0, 0, m.cfg.Help.Padding[0], m.cfg.Help.Padding[1])
helpView = column.New(padded).Height(layout.Fixed(max(m.helpHeight(), 1)))
helpView = box.New(m.helpView()).Padding(0, 0, m.cfg.Help.Padding[0], m.cfg.Help.Padding[1]).Height(layout.Shrink)
}
content := column.New(innerView, helpView)
if m.dialog == nil {

View file

@ -21,10 +21,6 @@ func TestNewModel(t *testing.T) {
if !m.helpVisible {
t.Fatal("help is hidden")
}
if height := m.helpHeight(); height != 1 {
t.Fatalf("height = %d, want 1", height)
}
m, _ = m.Update(tcell.NewEventKey(tcell.KeyRune, ".", tcell.ModCtrl))
if !m.helpShowAll {
t.Fatal("toggle_full_help did not enable full help")