feat(messageslist): show custom emoji images in reactions, wrapped with a separator

This commit is contained in:
ayn2op 2026-10-17 13:50:18 -04:00
parent 805621bc2e
commit 997a77c033
16 changed files with 208 additions and 54 deletions

View file

@ -424,6 +424,20 @@ The longest time between two messages by the same author for them to share a hea
Default: `"10m0s"`
### ui.messages_list.reactions
#### ui.messages_list.reactions.separator
The text between reactions.
Default: `" • "`
#### ui.messages_list.reactions.colons_around_name
Whether the names of custom emoji are shown between colons, as in `:name:`.
Default: `false`
## ui.mentions_list
The list of mention suggestions above the composer. Its width and height are capped to the available space.

2
go.mod
View file

@ -16,7 +16,7 @@ require (
github.com/alecthomas/chroma/v2 v2.27.0
github.com/ayn2op/arikawa/v3 v3.0.0-20261016043052-54a68a54e42e
github.com/ayn2op/ningen/v3 v3.0.1-0.20261014035153-5e43590a1426
github.com/ayn2op/tview v0.0.0-20261017014958-0756c1611d0f
github.com/ayn2op/tview v0.0.0-20261017195448-33f4b52a0ef5
github.com/bogdanfinn/fhttp v0.6.9
github.com/bogdanfinn/tls-client v1.16.0
github.com/gdamore/tcell/v3 v3.6.0

4
go.sum
View file

@ -16,8 +16,8 @@ github.com/ayn2op/fhttp v0.6.10-0.20261016015330-2d5e34c6b935 h1:oVxkY1/nVsVQ8Cn
github.com/ayn2op/fhttp v0.6.10-0.20261016015330-2d5e34c6b935/go.mod h1:7phoj3f/q54WuiTnCTlAhEoaVcdlruKkSzCSD47LzHk=
github.com/ayn2op/ningen/v3 v3.0.1-0.20261014035153-5e43590a1426 h1:0ndRWpAiSSgR0z9b7K+pwMlCHEk3VzQeS5ls+zWtbr0=
github.com/ayn2op/ningen/v3 v3.0.1-0.20261014035153-5e43590a1426/go.mod h1:4S2B9frwrjELeAldoqPgasA/VfnOySgRlektwYLuVks=
github.com/ayn2op/tview v0.0.0-20261017014958-0756c1611d0f h1:f3Kiqss18xrNTzyB5MfzVTGfrX1tUwMPENmku8BdNGg=
github.com/ayn2op/tview v0.0.0-20261017014958-0756c1611d0f/go.mod h1:kn8Up2Tk2vhDUtW6H9nqAvRk9hDVyCzk18CCAo2qkjA=
github.com/ayn2op/tview v0.0.0-20261017195448-33f4b52a0ef5 h1:IbG2cMS8rH6fEtktBc/k+HDyIt0Dosx+pB4sxtXXpRM=
github.com/ayn2op/tview v0.0.0-20261017195448-33f4b52a0ef5/go.mod h1:kn8Up2Tk2vhDUtW6H9nqAvRk9hDVyCzk18CCAo2qkjA=
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

@ -37,10 +37,10 @@ func (e enum[T]) text(value T) ([]byte, error) {
var (
boxDrawing = scrollbar.SymbolSet{Track: "│", Thumb: "█", Begin: "▲", End: "▼"}
alignments = enum[tview.Alignment]{
{"left", tview.AlignmentLeft},
{"center", tview.AlignmentCenter},
{"right", tview.AlignmentRight},
alignments = enum[tview.HorizontalAlignment]{
{"left", tview.HorizontalAlignmentLeft},
{"center", tview.HorizontalAlignmentCenter},
{"right", tview.HorizontalAlignmentRight},
}
borderSets = enum[tview.BorderSet]{
{"hidden", tview.BorderSetHidden()},
@ -67,14 +67,16 @@ var (
}
)
type AlignmentWrapper struct{ tview.Alignment }
type AlignmentWrapper struct{ tview.HorizontalAlignment }
func (aw *AlignmentWrapper) UnmarshalText(text []byte) (err error) {
aw.Alignment, err = alignments.parse(string(text))
aw.HorizontalAlignment, err = alignments.parse(string(text))
return err
}
func (aw AlignmentWrapper) MarshalText() ([]byte, error) { return alignments.text(aw.Alignment) }
func (aw AlignmentWrapper) MarshalText() ([]byte, error) {
return alignments.text(aw.HorizontalAlignment)
}
type BorderSetWrapper struct{ tview.BorderSet }
@ -181,6 +183,14 @@ type (
Attachments AttachmentsUIConfig `toml:"attachments"`
Avatars AvatarsUIConfig `toml:"avatars"`
Grouping GroupingUIConfig `toml:"grouping"`
Reactions ReactionsUIConfig `toml:"reactions"`
}
ReactionsUIConfig struct {
// The text between reactions.
Separator string `toml:"separator"`
// Whether the names of custom emoji are shown between colons, as in `:name:`.
ColonsAroundName bool `toml:"colons_around_name"`
}
// GroupingUIConfig shows consecutive messages by the same author under one header.
@ -214,7 +224,7 @@ type (
)
func defaultUI() UIConfig {
left := TitleUIConfig{AlignmentWrapper{tview.AlignmentLeft}}
left := TitleUIConfig{AlignmentWrapper{tview.HorizontalAlignmentLeft}}
round := BorderSetWrapper{tview.BorderSetRound()}
attachments := AttachmentsUIConfig{
ShowLinks: true,
@ -223,7 +233,7 @@ func defaultUI() UIConfig {
}
return UIConfig{
Tabs: TabsUIConfig{
Alignment: AlignmentWrapper{tview.AlignmentCenter},
Alignment: AlignmentWrapper{tview.HorizontalAlignmentCenter},
Separator: "│",
Padding: [2]string{" ", " "},
Arrows: [2]string{"◀", "▶"},
@ -246,6 +256,7 @@ func defaultUI() UIConfig {
Attachments: attachments,
Avatars: AvatarsUIConfig{Preview: true, Height: 2},
Grouping: GroupingUIConfig{Enabled: true, Interval: 10 * time.Minute},
Reactions: ReactionsUIConfig{Separator: " • "},
},
MentionsList: MentionsListUIConfig{MinWidth: 20},
}

View file

@ -121,7 +121,7 @@ func (r row) Draw(screen tview.Screen, area tview.Rectangle) {
for x := area.X; x < area.X+area.Width; x++ {
screen.Put(x, area.Y, " ", r.style)
}
tview.Print(screen, r.text, area.X, area.Y, area.Width, tview.AlignmentLeft, r.style)
tview.Print(screen, r.text, area.X, area.Y, area.Width, tview.HorizontalAlignmentLeft, r.style)
}
func (row) Handle(msg tview.Msg, area tview.Rectangle) tview.Msg { return msg }

View file

@ -13,6 +13,7 @@ import (
"github.com/ayn2op/discordo/internal/markdown"
"github.com/ayn2op/tview"
"github.com/ayn2op/tview/layout"
"github.com/ayn2op/tview/row"
"github.com/ayn2op/tview/text"
"github.com/gdamore/tcell/v3"
"github.com/gdamore/tcell/v3/color"
@ -89,7 +90,7 @@ func (d dateSeparator) Draw(screen tview.Screen, area tview.Rectangle) {
fill := area.Width - labelWidth
line = strings.Repeat(d.fill, fill/2) + label + strings.Repeat(d.fill, fill-fill/2)
}
tview.Print(screen, line, area.X, area.Y, area.Width, tview.AlignmentLeft, d.style)
tview.Print(screen, line, area.X, area.Y, area.Width, tview.HorizontalAlignmentLeft, d.style)
}
func (dateSeparator) Handle(msg tview.Msg, area tview.Rectangle) tview.Msg { return msg }
@ -134,30 +135,42 @@ func (m Model) writeMessage(builder *markdown.Builder, message discord.Message,
default:
m.drawLayout(builder, message, baseStyle, false, nil)
}
m.drawReactions(builder, message.Reactions, baseStyle)
}
func (m Model) drawReactions(builder *markdown.Builder, reactions []discord.Reaction, baseStyle tview.Style) {
// reactionsView returns the reactions wrapped to the width of the message, with the image of each custom emoji that has one before its name, or nil if there are none.
func (m Model) reactionsView(reactions []discord.Reaction) tview.Widget {
if len(reactions) == 0 {
return
return nil
}
builder.NewLine()
theme, cfg := m.cfg.Theme.MessagesList, m.cfg.UI.MessagesList.Reactions
separatorStyle := tview.MergeStyle(theme.MessageStyle.Style, theme.ReactionStyle.Style)
chips := make([]tview.Widget, len(reactions))
for i, reaction := range reactions {
if i > 0 {
builder.Write(" ", baseStyle)
style := theme.ReactionStyle.Style
if reaction.Me {
style = theme.OwnReactionStyle.Style
}
style = tview.MergeStyle(theme.MessageStyle.Style, style)
name := reaction.Emoji.Name
if reaction.Emoji.IsCustom() {
if reaction.Emoji.IsCustom() && cfg.ColonsAroundName {
name = ":" + name + ":"
}
style := m.cfg.Theme.MessagesList.ReactionStyle.Style
if reaction.Me {
style = m.cfg.Theme.MessagesList.OwnReactionStyle.Style
spans := []text.Span{{Text: name + " " + strconv.Itoa(reaction.Count), Style: style}}
// Each reaction but the last ends with the separator, so a line never starts with one.
if i < len(reactions)-1 {
spans = append(spans, text.Span{Text: cfg.Separator, Style: separatorStyle})
}
builder.Write(name+" "+strconv.Itoa(reaction.Count), tview.MergeStyle(baseStyle, style))
var chip tview.Widget = text.Rich(spans...)
if emoji, ok := m.images[emojiURL(reaction.Emoji)]; ok {
chip = row.New(emoji, chip).Spacing(1).Width(layout.Shrink).Height(layout.Shrink)
}
chips[i] = chip
}
return row.New(chips...).Height(layout.Shrink).Wrap()
}
func (m Model) formatTimestamp(ts discord.Timestamp) string {

View file

@ -47,7 +47,8 @@ type Model struct {
items []messageItem
renderer *markdown.Renderer
avatars map[discord.URL]tviewimage.Widget
// images are the avatars and emoji, shared by every message that shows them.
images 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
@ -69,7 +70,7 @@ func NewModel(cfg *config.Config, state *ningen.State) Model {
cfg: cfg,
state: state,
renderer: markdown.NewRenderer(cfg),
avatars: map[discord.URL]tviewimage.Widget{},
images: map[discord.URL]tviewimage.Widget{},
voicePlayer: voice.NewPlayer(),
}
@ -155,10 +156,14 @@ func (m *Model) UpdateMessage(message discord.Message) tview.Cmd {
}
// RefreshMessage shows the cached state of a message, such as after a reaction.
func (m *Model) RefreshMessage(channelID discord.ChannelID, id discord.MessageID) {
if message, err := m.state.Cabinet.Message(channelID, id); err == nil {
m.setMessage(m.indexOf(channelID, id), *message)
func (m *Model) RefreshMessage(channelID discord.ChannelID, id discord.MessageID) tview.Cmd {
message, err := m.state.Cabinet.Message(channelID, id)
if err != nil {
return nil
}
m.setMessage(m.indexOf(channelID, id), *message)
return m.loadPreviews()
}
// DeleteMessage removes the shown message with id.
@ -176,7 +181,7 @@ func (m Model) indexOf(channelID discord.ChannelID, id discord.MessageID) int {
func (m *Model) reset() {
m.items = nil
clear(m.avatars)
clear(m.images)
m.cursor, m.target = -1, -1
m.scrollState = viewport.ScrollState{}
m.scrollState.ScrollToEnd()
@ -266,21 +271,26 @@ func (m Model) buildItem(index int) tview.Widget {
}
}
if avatar, ok := m.avatars[avatarURL(message.Author)]; ok {
if !m.blocked(message.Author) {
body = body.Push(m.reactionsView(message.Reactions))
}
var view tview.Widget = body
if avatar, ok := m.images[avatarURL(message.Author)]; ok {
var gutter tview.Widget = avatar
// A grouped message leaves the avatar's column empty, under the avatar of the group.
if item.grouped {
width, _ := avatar.Size()
gutter = spacer.New().Width(width)
}
body = row.New(gutter, body).Spacing(1).Height(layout.Shrink)
view = row.New(gutter, body).Spacing(1).Height(layout.Shrink)
}
if message.Type == discord.InlinedReplyMessage && !m.blocked(message.Author) {
body = column.New(item.reply, body).Height(layout.Shrink)
view = column.New(item.reply, view).Height(layout.Shrink)
}
return body
return view
}
// shouldGroup reports whether message is drawn under the header of previous, the message before it, which is nil for the first.
@ -447,8 +457,8 @@ func (m Model) Update(msg tview.Msg) (Model, tview.Cmd) {
cmd = preview.Transmit()
}
if _, ok := m.avatars[msg.proxy]; ok {
m.avatars[msg.proxy] = preview
if _, ok := m.images[msg.proxy]; ok {
m.images[msg.proxy] = preview
return m, cmd
}

View file

@ -2,6 +2,7 @@ package messageslist
import (
"encoding/json"
"image"
"path/filepath"
"slices"
"strings"
@ -13,8 +14,10 @@ import (
"github.com/ayn2op/discordo/internal/config"
"github.com/ayn2op/ningen/v3"
"github.com/ayn2op/tview"
tviewimage "github.com/ayn2op/tview/image"
"github.com/ayn2op/tview/layout"
"github.com/gdamore/tcell/v3"
"github.com/gdamore/tcell/v3/vt"
)
func testConfig(t *testing.T) *config.Config {
@ -353,3 +356,46 @@ func TestModelScrollToTop(t *testing.T) {
t.Fatalf("scrolling to the new top fetched %d times, want once", fetches)
}
}
func TestModelReactionsView(t *testing.T) {
ml := newTestModel(t, testConfig(t))
custom := discord.Emoji{ID: 5, Name: "cat"}
reactions := []discord.Reaction{{Count: 2, Emoji: discord.Emoji{Name: "ok"}}, {Count: 1, Emoji: custom}}
draw := func() string {
screen, err := tcell.NewTerminfoScreenFromTty(vt.NewMockTerm(vt.MockOptSize{X: 20, Y: 1}))
if err != nil {
t.Fatal(err)
}
if err := screen.Init(); err != nil {
t.Fatal(err)
}
defer screen.Fini()
ml.reactionsView(reactions).Draw(screen, tview.Rectangle{Width: 20, Height: 1})
var row strings.Builder
for x := range 20 {
str, _, _ := screen.Get(x, 0)
row.WriteString(str)
}
return strings.TrimRight(row.String(), " ")
}
t.Run("names", func(t *testing.T) {
if got := draw(); got != "ok 2 • cat 1" {
t.Fatalf("reactions = %q", got)
}
})
t.Run("emoji image", func(t *testing.T) {
ml.images[emojiURL(custom)] = tviewimage.New(image.NewAlpha(image.Rect(0, 0, 2, 2))).Width(2)
// The blank image takes two cells before the name.
if got := draw(); got != "ok 2 • cat 1" {
t.Fatalf("reactions = %q", got)
}
})
t.Run("with colons", func(t *testing.T) {
ml.cfg.UI.MessagesList.Reactions.ColonsAroundName = true
if got := draw(); got != "ok 2 • :cat: 1" {
t.Fatalf("reactions = %q", got)
}
})
}

View file

@ -2,6 +2,7 @@ package messageslist
import (
"image"
"image/draw"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
@ -86,6 +87,10 @@ func (m Model) loadPreviews() tview.Cmd {
}
cmds = append(cmds, m.loadAvatar(item.message.Author))
for _, reaction := range item.message.Reactions {
cmds = append(cmds, m.loadEmoji(reaction.Emoji))
}
if !m.cfg.UI.MessagesList.Attachments.Preview {
continue
}
@ -116,21 +121,55 @@ func avatarURL(user discord.User) discord.URL {
}
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) {
cfg := m.cfg.UI.MessagesList.Avatars
if !cfg.Preview || cfg.Height == 0 || m.blocked(user) {
return nil
}
return m.loadImage(avatarURL(user), uint(cfg.Height))
}
func emojiURL(emoji discord.Emoji) discord.URL {
return emoji.EmojiURLWithType(discord.PNGImage)
}
// loadEmoji loads the image of a custom emoji, a row tall.
func (m Model) loadEmoji(emoji discord.Emoji) tview.Cmd {
if !emoji.IsCustom() {
return nil
}
return m.loadImage(emojiURL(emoji), 1)
}
// loadImage loads the image at url in a square rows tall, unless it is loaded already.
func (m Model) loadImage(url discord.URL, rows uint) tview.Cmd {
if _, ok := m.images[url]; ok {
return nil
}
cellWidth, cellHeight := m.previewCell()
cols := uint(cfg.Height) * cellHeight / cellWidth
cols := rows * 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))
return tviewimage.New(square(img)).Width(int(cols)).CellSize(int(cellWidth), int(cellHeight))
}
m.avatars[url] = widget(image.NewAlpha(bounds))
m.images[url] = widget(image.NewAlpha(bounds))
return m.loadPreview(0, url, bounds, widget)
}
// square returns img centered on a transparent square, so an image of any shape takes the square's cells.
func square(img image.Image) image.Image {
b := img.Bounds()
if b.Dx() == b.Dy() {
return img
}
side := max(b.Dx(), b.Dy())
dst := image.NewNRGBA(image.Rect(0, 0, side, side))
at := image.Pt((side-b.Dx())/2, (side-b.Dy())/2)
draw.Draw(dst, b.Sub(b.Min).Add(at), img, b.Min, draw.Src)
return dst
}
// 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

@ -1,6 +1,7 @@
package messageslist
import (
"image"
"slices"
"testing"
@ -56,3 +57,23 @@ func TestPreviewSourceCols(t *testing.T) {
})
}
}
func TestSquare(t *testing.T) {
t.Run("square", func(t *testing.T) {
img := image.NewAlpha(image.Rect(0, 0, 20, 20))
if got := square(img); got != image.Image(img) {
t.Fatal("square image was copied")
}
})
t.Run("tall", func(t *testing.T) {
img := image.NewAlpha(image.Rect(0, 0, 10, 30))
img.Pix[0] = 0xff
got := square(img)
if got.Bounds() != image.Rect(0, 0, 30, 30) {
t.Fatalf("bounds = %v, want 30 by 30", got.Bounds())
}
if _, _, _, a := got.At(10, 0).RGBA(); a == 0 {
t.Fatal("image is not centered")
}
})
}

View file

@ -116,15 +116,15 @@ func (m *Model) applyEvent(event gateway.Event) tview.Cmd {
case *gateway.MessageDeleteEvent:
m.messagesList.DeleteMessage(event.ChannelID, event.ID)
case *gateway.MessageReactionAddEvent:
m.messagesList.RefreshMessage(event.ChannelID, event.MessageID)
return m.messagesList.RefreshMessage(event.ChannelID, event.MessageID)
case *gateway.MessageReactionAddManyEvent:
m.messagesList.RefreshMessage(event.ChannelID, event.MessageID)
return m.messagesList.RefreshMessage(event.ChannelID, event.MessageID)
case *gateway.MessageReactionRemoveEvent:
m.messagesList.RefreshMessage(event.ChannelID, event.MessageID)
return m.messagesList.RefreshMessage(event.ChannelID, event.MessageID)
case *gateway.MessageReactionRemoveAllEvent:
m.messagesList.RefreshMessage(event.ChannelID, event.MessageID)
return m.messagesList.RefreshMessage(event.ChannelID, event.MessageID)
case *gateway.MessageReactionRemoveEmojiEvent:
m.messagesList.RefreshMessage(event.ChannelID, event.MessageID)
return m.messagesList.RefreshMessage(event.ChannelID, event.MessageID)
case *gateway.GuildMembersChunkEvent:
return m.refreshMemberNames(event)

View file

@ -72,7 +72,7 @@ func (m Model) View() tview.Widget {
func (m Model) tabs() tabs.Widget {
cfg := m.cfg.UI.Tabs
return tabs.New(m.password.Label(), m.qr.Label(), m.token.Label()).
Alignment(cfg.Alignment.Alignment).
Alignment(cfg.Alignment.HorizontalAlignment).
Wrap(cfg.Wrap).
Separator(cfg.Separator).
Padding(cfg.Padding[0], cfg.Padding[1]).

View file

@ -82,5 +82,5 @@ func (m Model) View() tview.Widget {
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).Align(tview.AlignmentCenter)
).Spacing(1).AlignX(tview.HorizontalAlignmentCenter)
}

View file

@ -2,12 +2,12 @@ package qr
import (
"crypto/rsa"
"github.com/ayn2op/tview/layout"
"strings"
"time"
"github.com/ayn2op/tview"
"github.com/ayn2op/tview/center"
"github.com/ayn2op/tview/layout"
"github.com/ayn2op/tview/text"
"github.com/ayn2op/tview/viewport"
"github.com/gdamore/tcell/v3"
@ -131,7 +131,7 @@ func halfBlock(top, bottom bool) rune {
func (m Model) View() tview.Widget {
code := m.code()
return center.New(
viewport.New(text.New(code).Wrapping(text.WrappingNone).AlignX(tview.AlignmentCenter), m.scrollState).
viewport.New(text.New(code).Wrapping(text.WrappingNone).AlignX(tview.HorizontalAlignmentCenter), m.scrollState).
Height(layout.Shrink).
Focused(true).
OnChange(func(s *viewport.ScrollState) tview.Msg { return scrollMsg(*s) }),

View file

@ -62,5 +62,5 @@ func (m Model) View() tview.Widget {
return column.New(
row.New(column.New(text.New("Token")).Width(layout.Fixed(len("Token")+1)), input).Height(layout.Shrink),
submit,
).Spacing(1).Align(tview.AlignmentCenter)
).Spacing(1).AlignX(tview.HorizontalAlignmentCenter)
}

View file

@ -18,8 +18,8 @@ func Box(child tview.Widget, cfg *config.Config, focused bool) box.Widget {
padding := ui.Border.Padding
b := box.New(child).
Padding(padding[0], padding[1], padding[2], padding[3]).
TitleAlignment(ui.Title.Alignment.Alignment).
FooterAlignment(ui.Footer.Alignment.Alignment)
TitleAlignment(ui.Title.Alignment.HorizontalAlignment).
FooterAlignment(ui.Footer.Alignment.HorizontalAlignment)
if ui.Border.Enabled {
b = b.Borders(tview.BordersAll)
}