Explorar o código

feat(ui/chat): add reactions, search, threads, user info, command mode, and cleanup

- Render message reactions with bold highlight for own reactions,
  real-time updates via MessageReactionAdd/Remove gateway events
- Add `/` fuzzy search picker over current channel messages
- Show thread indicator on messages, `t` keybind navigates to thread
- Add `w` keybind for user info popup (username, join date, roles)
- Add `:` vim-style command mode with :q, :quit, :logout
- Cap itemByID cache at 500 entries with stale eviction (COMP #17)
- Replace skratchdot/open-golang with stdlib os/exec + runtime.GOOS (TECH #1)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
claude hai 1 mes
pai
achega
24b7de1700

+ 14 - 3
CLAUDE.md

@@ -40,7 +40,7 @@ This is the persistent context file for Claude Code. Keep it concise and useful.
 ## Key Patterns
 - Keybinds: defined in `config/keybinds.go` structs, matched in `HandleEvent()` via `keybind.Matches()`
 - Help tooltips: `ShortHelp()` (bottom bar, contextual) and `FullHelp()` (full overlay via `?`)
-- Attachments: downloaded to `~/.cache/discordo/attachments/`, opened via configured viewer or `open.Start()`
+- Attachments: downloaded to `~/.cache/discordo/attachments/`, opened via configured viewer or `openDefault()`
 - Embeds: rendered with `▎` bar prefix, wrapped to viewport width, markdown in descriptions
 - Config defaults embedded via `//go:embed config.toml`
 
@@ -62,6 +62,13 @@ This is the persistent context file for Claude Code. Keep it concise and useful.
 - **Image viewer args**: `image_viewer_args` config field — explicit viewer args, bypasses xdotool auto-detection (Wayland-friendly)
 - **Brotli body leak fix**: transport.go properly closes underlying HTTP body
 - **Cache panic fix**: `cache.Get()` uses comma-ok assertion instead of bare type assert
+- **Reactions display**: renders message reactions below content (`drawReactions`), bold for own reactions, real-time updates via gateway events
+- **Search picker**: `/` keybind opens fuzzy search over current channel messages (`search_picker.go`)
+- **Thread indicators**: shows "Thread: name" on messages with threads, `t` keybind navigates to thread
+- **User info popup**: `w` keybind shows author info (username, join date, roles, color) in overlay (`user_info.go`)
+- **Command mode**: `:` keybind opens vim-style command input, supports `:q`/`:quit`/`:logout` (`command_input.go`)
+- **LRU cache cap**: `itemByID` map evicts stale entries when exceeding 500 items (COMP #17)
+- **Replace open-golang**: replaced `skratchdot/open-golang` with stdlib `os/exec` + `runtime.GOOS` (TECH #1)
 
 ## Config Fields We Added
 - `image_viewer` — external image viewer command (default: `"mpv"`, `"default"` = system opener)
@@ -70,13 +77,17 @@ This is the persistent context file for Claude Code. Keep it concise and useful.
 - `keybinds.messages_list.save_image` — save image keybind (default: `S`)
 - `keybinds.edit_config` — open config in editor from help overlay (default: `E`)
 - `keybinds.toggle_help` — changed default from `ctrl+.` to `?`
+- `keybinds.messages_list.search` — fuzzy search messages (default: `/`)
+- `keybinds.messages_list.open_thread` — navigate to message's thread (default: `t`)
+- `keybinds.messages_list.user_info` — show author info popup (default: `w`)
+- `keybinds.command_mode` — open vim-style command input (default: `:`)
 
 ## Build & Run
 - Build: `go build -o discordo-plus .`
 - Install: `sudo mv discordo-plus /usr/local/bin/`
 - Run: `discordo-plus`
 - Test: `go test ./...` (only config + keyring packages have tests)
-- Dependencies: `mpv` (or configured image viewer), `xdotool` (optional, X11 geometry detection)
+- Dependencies: `mpv` (or configured image viewer), `xdotool` (optional, X11 geometry detection), `xdg-open` (Linux, for default opener)
 
 ## Git
 - Identity: `claude <claude@altsol.dev>`
@@ -86,7 +97,7 @@ This is the persistent context file for Claude Code. Keep it concise and useful.
 ## Audit Status
 - Research audits in `./research/`: SECFILE.md, COMPLIANCE.md, TECHFILE.md
 - Resolved all low-risk findings (marked ✅ FIXED in research files)
-- Remaining unfixed: SEC #7 (raw events in debug), COMP #17/19-20 (perf), COMP #22/24 (linter/tests), TECH #1-3 (unmaintained deps)
+- Remaining unfixed: SEC #7 (raw events in debug), COMP #19-20 (perf), COMP #22/24 (linter/tests), TECH #2-3 (unmaintained deps)
 
 ## Known Issues
 - Discord ToS discourages third-party clients — use at own risk

+ 0 - 1
go.mod

@@ -25,7 +25,6 @@ require (
 	github.com/rivo/uniseg v0.4.7
 	github.com/sahilm/fuzzy v0.1.1
 	github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
-	github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
 	github.com/yuin/goldmark v1.7.17
 	github.com/zalando/go-keyring v0.2.6
 )

+ 0 - 2
go.sum

@@ -88,8 +88,6 @@ github.com/sergeymakinen/go-ico v1.0.0 h1:uL3khgvKkY6WfAetA+RqsguClBuu7HpvBB/nq/
 github.com/sergeymakinen/go-ico v1.0.0/go.mod h1:wQ47mTczswBO5F0NoDt7O0IXgnV4Xy3ojrroMQzyhUk=
 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
-github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA=
-github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
 github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=

+ 4 - 0
internal/config/config.toml

@@ -117,6 +117,7 @@ toggle_guilds_tree = "ctrl+b"
 logout = "ctrl+d"
 suspend = "ctrl+z"
 quit = "ctrl+c"
+command_mode = ":"
 
 [keybinds.picker]
 toggle = "ctrl+k"
@@ -165,6 +166,9 @@ delete_confirm = "d"
 open = "o"
 # Save the selected message's image attachment to image_save_dir.
 save_image = "S"
+user_info = "w"
+open_thread = "t"
+search = "/"
 # Yank (copy) the selected message's content/url/id.
 yank_content = "y"
 yank_url = "u"

+ 11 - 3
internal/config/keybinds.go

@@ -90,7 +90,10 @@ type MessagesListKeybinds struct {
 	DeleteConfirm Keybind `toml:"delete_confirm"`
 	Open          Keybind `toml:"open"`
 
-	SaveImage Keybind `toml:"save_image"`
+	SaveImage  Keybind `toml:"save_image"`
+	UserInfo   Keybind `toml:"user_info"`
+	Search     Keybind `toml:"search"`
+	OpenThread Keybind `toml:"open_thread"`
 
 	YankContent Keybind `toml:"yank_content"`
 	YankURL     Keybind `toml:"yank_url"`
@@ -132,7 +135,8 @@ type Keybinds struct {
 	MessageInput MessageInputKeybinds `toml:"message_input"`
 	MentionsList MentionsListKeybinds `toml:"mentions_list"`
 
-	Logout Keybind `toml:"logout"`
+	CommandMode Keybind `toml:"command_mode"`
+	Logout      Keybind `toml:"logout"`
 	Quit   Keybind `toml:"quit"`
 }
 
@@ -194,6 +198,9 @@ func defaultMessagesListKeybinds() MessagesListKeybinds {
 		),
 		Open:        newKeybind("o", "open"),
 		SaveImage:   newKeybind("S", "save image"),
+		UserInfo:    newKeybind("w", "who"),
+		Search:      newKeybind("/", "search"),
+		OpenThread:  newKeybind("t", "thread"),
 		YankContent: newKeybind("y", "copy text"),
 		YankURL:     newKeybind("u", "copy url"),
 		YankID:      newKeybind("i", "copy id"),
@@ -238,7 +245,8 @@ func defaultKeybinds() Keybinds {
 		FocusPrevious: newKeybind("ctrl+h", "focus prev"),
 		FocusNext:     newKeybind("ctrl+l", "focus next"),
 
-		Logout: newKeybind("ctrl+d", "logout"),
+		CommandMode: newKeybind(":", "command"),
+		Logout:      newKeybind("ctrl+d", "logout"),
 		Quit:   newKeybind("ctrl+c", "quit"),
 
 		Picker:       defaultPickerKeybinds(),

+ 16 - 3
internal/ui/chat/attachment_handler.go

@@ -9,17 +9,30 @@ import (
 	"os"
 	"os/exec"
 	"path/filepath"
+	"runtime"
 	"strings"
 
 	"github.com/ayn2op/discordo/internal/consts"
 	"github.com/ayn2op/discordo/internal/ui"
 	"github.com/ayn2op/tview/layers"
 	"github.com/diamondburned/arikawa/v3/discord"
-	"github.com/skratchdot/open-golang/open"
 )
 
 const maxAttachmentSize = 100 * 1024 * 1024 // 100 MB
 
+func openDefault(target string) error {
+	var cmd *exec.Cmd
+	switch runtime.GOOS {
+	case "darwin":
+		cmd = exec.Command("open", target)
+	case "windows":
+		cmd = exec.Command("cmd", "/c", "start", "", target)
+	default: // linux, freebsd, etc.
+		cmd = exec.Command("xdg-open", target)
+	}
+	return cmd.Start()
+}
+
 var supportedImageTypes = map[string]bool{
 	"image/jpeg": true,
 	"image/png":  true,
@@ -138,7 +151,7 @@ func (ml *messagesList) openAttachment(attachment discord.Attachment) {
 		return
 	}
 
-	if err := open.Start(path); err != nil {
+	if err := openDefault(path); err != nil {
 		slog.Error("failed to open attachment file", "err", err, "path", path)
 	}
 }
@@ -222,7 +235,7 @@ func (ml *messagesList) saveImage() {
 }
 
 func (ml *messagesList) openURL(url string) {
-	if err := open.Start(url); err != nil {
+	if err := openDefault(url); err != nil {
 		slog.Error("failed to open URL", "err", err, "url", url)
 	}
 }

+ 70 - 0
internal/ui/chat/command_input.go

@@ -0,0 +1,70 @@
+package chat
+
+import (
+	"github.com/ayn2op/tview"
+	"github.com/ayn2op/tview/keybind"
+	"github.com/ayn2op/tview/layers"
+)
+
+const commandInputLayerName = "commandInput"
+
+type commandInput struct {
+	*tview.InputField
+	chatView *Model
+}
+
+func newCommandInput(chatView *Model) *commandInput {
+	ci := &commandInput{
+		InputField: tview.NewInputField(),
+		chatView:   chatView,
+	}
+	ci.SetLabel(":")
+	ci.SetFieldWidth(0)
+	return ci
+}
+
+func (ci *commandInput) HandleEvent(event tview.Event) tview.Command {
+	switch event := event.(type) {
+	case *tview.KeyEvent:
+		switch {
+		case keybind.Matches(event, keybind.NewKeybind(keybind.WithKeys("enter"))):
+			text := ci.GetText()
+			ci.SetText("")
+			ci.chatView.closeCommandInput()
+			return ci.execute(text)
+		case keybind.Matches(event, keybind.NewKeybind(keybind.WithKeys("esc"))):
+			ci.SetText("")
+			ci.chatView.closeCommandInput()
+			return nil
+		}
+	}
+	return ci.InputField.HandleEvent(event)
+}
+
+func (ci *commandInput) execute(cmd string) tview.Command {
+	switch cmd {
+	case "q", "quit":
+		return func() tview.Event {
+			return &QuitEvent{}
+		}
+	case "logout":
+		return tview.Batch(ci.chatView.closeState(), ci.chatView.logout())
+	default:
+		return nil
+	}
+}
+
+func (m *Model) openCommandInput() {
+	m.AddLayer(
+		m.commandInput,
+		layers.WithName(commandInputLayerName),
+		layers.WithResize(true),
+		layers.WithVisible(true),
+		layers.WithOverlay(),
+	).SendToFront(commandInputLayerName)
+	m.app.SetFocus(m.commandInput)
+}
+
+func (m *Model) closeCommandInput() {
+	m.RemoveLayer(commandInputLayerName)
+}

+ 96 - 1
internal/ui/chat/messages_list.go

@@ -3,6 +3,7 @@ package chat
 import (
 	"context"
 	"errors"
+	"fmt"
 	"log/slog"
 	"slices"
 	"strings"
@@ -184,10 +185,26 @@ func (ml *messagesList) buildItem(index int, cursor int) list.Item {
 			SetWordWrap(true).
 			SetLines(ml.renderMessage(message, ml.cfg.Theme.MessagesList.MessageStyle.Style))
 		ml.itemByID[message.ID] = item
+		// Evict stale cache entries when the map grows too large.
+		if len(ml.itemByID) > 500 {
+			ml.evictStaleCache()
+		}
 	}
 	return item
 }
 
+func (ml *messagesList) evictStaleCache() {
+	active := make(map[discord.MessageID]struct{}, len(ml.messages))
+	for _, msg := range ml.messages {
+		active[msg.ID] = struct{}{}
+	}
+	for id := range ml.itemByID {
+		if _, ok := active[id]; !ok {
+			delete(ml.itemByID, id)
+		}
+	}
+}
+
 func (ml *messagesList) renderMessage(message discord.Message, baseStyle tcell.Style) []tview.Line {
 	builder := tview.NewLineBuilder()
 	ml.writeMessage(builder, message, baseStyle)
@@ -492,6 +509,43 @@ func (ml *messagesList) drawDefaultMessage(builder *tview.LineBuilder, message d
 			builder.Write(a.Filename, attachmentStyle)
 		}
 	}
+
+	// Thread indicator
+	if message.Thread != nil {
+		thread := *message.Thread
+		builder.NewLine()
+		dimStyle := baseStyle.Dim(true)
+		threadLabel := "Thread: " + thread.Name
+		builder.Write(threadLabel, dimStyle)
+	}
+
+	ml.drawReactions(builder, message, baseStyle)
+}
+
+func (ml *messagesList) drawReactions(builder *tview.LineBuilder, message discord.Message, baseStyle tcell.Style) {
+	if len(message.Reactions) == 0 {
+		return
+	}
+
+	builder.NewLine()
+	dimStyle := baseStyle.Dim(true)
+	boldStyle := baseStyle.Bold(true)
+	for i, r := range message.Reactions {
+		if i > 0 {
+			builder.Write("  ", dimStyle)
+		}
+		name := r.Emoji.Name
+		if !r.Emoji.ID.IsValid() {
+			// Unicode emoji — use name directly
+		} else if name != "" {
+			name = ":" + name + ":"
+		}
+		style := dimStyle
+		if r.Me {
+			style = boldStyle
+		}
+		builder.Write(fmt.Sprintf("%s %d", name, r.Count), style)
+	}
 }
 
 func (ml *messagesList) drawEmbeds(builder *tview.LineBuilder, message discord.Message, baseStyle tcell.Style) {
@@ -647,6 +701,15 @@ func (ml *messagesList) HandleEvent(event tview.Event) tview.Command {
 		case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.DeleteConfirm.Keybind):
 			ml.confirmDelete()
 			return nil
+		case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.OpenThread.Keybind):
+			ml.openThread()
+			return nil
+		case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.Search.Keybind):
+			ml.chatView.openSearch()
+			return nil
+		case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.UserInfo.Keybind):
+			ml.showUserInfo()
+			return nil
 		}
 		return ml.Model.HandleEvent(event)
 
@@ -843,6 +906,28 @@ func (ml *messagesList) open() {
 	}
 }
 
+func (ml *messagesList) openThread() {
+	msg, err := ml.selectedMessage()
+	if err != nil {
+		slog.Error("failed to get selected message", "err", err)
+		return
+	}
+
+	if msg.Thread == nil {
+		return
+	}
+
+	thread := *msg.Thread
+	node := ml.chatView.guildsTree.findNodeByChannelID(thread.ID)
+	if node == nil {
+		return
+	}
+
+	ml.chatView.guildsTree.expandPathToNode(node)
+	ml.chatView.guildsTree.SetCurrentNode(node)
+	ml.chatView.guildsTree.onSelected(node)
+}
+
 func (ml *messagesList) reply(mention bool) {
 	message, err := ml.selectedMessage()
 	if err != nil {
@@ -1012,6 +1097,7 @@ func (ml *messagesList) ShortHelp() []keybind.Keybind {
 		cfg.SelectUp.Keybind,
 		cfg.SelectDown.Keybind,
 		cfg.Cancel.Keybind,
+		cfg.Search.Keybind,
 	}
 
 	if msg, err := ml.selectedMessage(); err == nil {
@@ -1023,6 +1109,9 @@ func (ml *messagesList) ShortHelp() []keybind.Keybind {
 		if len(messageURLs(*msg)) != 0 || len(msg.Attachments) != 0 {
 			help = append(help, cfg.Open.Keybind)
 		}
+		if msg.Thread != nil {
+			help = append(help, cfg.OpenThread.Keybind)
+		}
 	}
 
 	return help
@@ -1036,9 +1125,11 @@ func (ml *messagesList) FullHelp() [][]keybind.Keybind {
 	canEdit := false
 	canDelete := false
 	canOpen := false
+	canOpenThread := false
 	if msg, err := ml.selectedMessage(); err == nil {
 		canSelectReply = msg.ReferencedMessage != nil
 		canOpen = len(messageURLs(*msg)) != 0 || len(msg.Attachments) != 0
+		canOpenThread = msg.Thread != nil
 
 		if me, err := ml.chatView.state.Cabinet.Me(); err == nil {
 			canReply = msg.Author.ID != me.ID
@@ -1070,12 +1161,16 @@ func (ml *messagesList) FullHelp() [][]keybind.Keybind {
 	if canOpen {
 		manage = append(manage, cfg.Open.Keybind, cfg.SaveImage.Keybind)
 	}
+	if canOpenThread {
+		manage = append(manage, cfg.OpenThread.Keybind)
+	}
+	manage = append(manage, cfg.UserInfo.Keybind)
 
 	return [][]keybind.Keybind{
 		{cfg.SelectUp.Keybind, cfg.SelectDown.Keybind, cfg.SelectTop.Keybind, cfg.SelectBottom.Keybind},
 		{cfg.ScrollUp.Keybind, cfg.ScrollDown.Keybind, cfg.ScrollTop.Keybind, cfg.ScrollBottom.Keybind},
 		actions,
 		manage,
-		{cfg.YankContent.Keybind, cfg.YankURL.Keybind, cfg.YankID.Keybind},
+		{cfg.YankContent.Keybind, cfg.YankURL.Keybind, cfg.YankID.Keybind, cfg.Search.Keybind},
 	}
 }

+ 44 - 0
internal/ui/chat/model.go

@@ -52,6 +52,8 @@ type Model struct {
 	messagesList   *messagesList
 	messageInput   *messageInput
 	channelsPicker *channelsPicker
+	searchPicker   *searchPicker
+	commandInput   *commandInput
 
 	selectedChannel   *discord.Channel
 	selectedChannelMu sync.RWMutex
@@ -86,6 +88,8 @@ func NewModel(app *tview.Application, cfg *config.Config, token string) *Model {
 	m.messagesList = newMessagesList(cfg, m)
 	m.messageInput = newMessageInput(cfg, m)
 	m.channelsPicker = newChannelsPicker(cfg, m)
+	m.searchPicker = newSearchPicker(cfg, m)
+	m.commandInput = newCommandInput(m)
 
 	identifyProps := http.IdentifyProperties()
 	gateway.DefaultIdentity = identifyProps
@@ -172,6 +176,22 @@ func (m *Model) closePicker() {
 	m.channelsPicker.Update()
 }
 
+func (m *Model) openSearch() {
+	m.AddLayer(
+		ui.Centered(m.searchPicker, m.cfg.Picker.Width, m.cfg.Picker.Height),
+		layers.WithName(searchPickerLayerName),
+		layers.WithResize(true),
+		layers.WithVisible(true),
+		layers.WithOverlay(),
+	).SendToFront(searchPickerLayerName)
+	m.searchPicker.update()
+}
+
+func (m *Model) closeSearch() {
+	m.RemoveLayer(searchPickerLayerName)
+	m.searchPicker.Update()
+}
+
 func (m *Model) toggleGuildsTree() tview.Command {
 	// The guilds tree is visible if the number of items is two.
 	if m.mainFlex.GetItemCount() == 2 {
@@ -266,6 +286,10 @@ func (m *Model) HandleEvent(event tview.Event) tview.Command {
 			m.onMessageUpdate(event)
 		case *gateway.MessageDeleteEvent:
 			m.onMessageDelete(event)
+		case *gateway.MessageReactionAddEvent:
+			m.onMessageReactionAdd(event)
+		case *gateway.MessageReactionRemoveEvent:
+			m.onMessageReactionRemove(event)
 
 		case *gateway.GuildMembersChunkEvent:
 			m.onGuildMembersChunk(event)
@@ -333,6 +357,12 @@ func (m *Model) HandleEvent(event tview.Event) tview.Command {
 			return focusCmd
 		}
 	case *tview.KeyEvent:
+		// Close user info overlay on any key
+		if m.HasLayer(userInfoLayerName) {
+			m.RemoveLayer(userInfoLayerName)
+			return nil
+		}
+
 		switch {
 		case keybind.Matches(event, m.cfg.Keybinds.FocusGuildsTree.Keybind):
 			m.messageInput.removeMentionsList()
@@ -354,6 +384,10 @@ func (m *Model) HandleEvent(event tview.Event) tview.Command {
 			m.togglePicker()
 			return nil
 
+		case keybind.Matches(event, m.cfg.Keybinds.CommandMode.Keybind):
+			m.openCommandInput()
+			return nil
+
 		case keybind.Matches(event, m.cfg.Keybinds.Logout.Keybind):
 			return tview.Batch(m.closeState(), m.logout())
 		}
@@ -366,6 +400,16 @@ func (m *Model) HandleEvent(event tview.Event) tview.Command {
 	return m.Layers.HandleEvent(event)
 }
 
+func (m *Model) showUserInfoOverlay(tv *tview.TextView) {
+	m.AddLayer(
+		ui.Centered(tv, 60, 15),
+		layers.WithName(userInfoLayerName),
+		layers.WithResize(true),
+		layers.WithVisible(true),
+		layers.WithOverlay(),
+	).SendToFront(userInfoLayerName)
+}
+
 func (m *Model) showConfirmModal(prompt string, buttons []string, onDone func(label string)) {
 	m.confirmModalPreviousFocus = m.app.Focused()
 	m.confirmModalDone = onDone

+ 74 - 0
internal/ui/chat/search_picker.go

@@ -0,0 +1,74 @@
+package chat
+
+import (
+	"fmt"
+
+	"github.com/ayn2op/discordo/internal/config"
+	"github.com/ayn2op/tview"
+	"github.com/ayn2op/tview/help"
+	"github.com/ayn2op/tview/keybind"
+	"github.com/ayn2op/tview/picker"
+)
+
+const searchPickerLayerName = "searchPicker"
+
+type searchPicker struct {
+	*picker.Model
+	chatView *Model
+}
+
+var _ help.KeyMap = (*searchPicker)(nil)
+
+func newSearchPicker(cfg *config.Config, chatView *Model) *searchPicker {
+	sp := &searchPicker{picker.NewModel(), chatView}
+	ConfigurePicker(sp.Model, cfg, "Search")
+	return sp
+}
+
+func (sp *searchPicker) HandleEvent(event tview.Event) tview.Command {
+	switch event := event.(type) {
+	case *picker.SelectedEvent:
+		messageIndex, ok := event.Reference.(int)
+		if !ok {
+			return nil
+		}
+
+		sp.chatView.closeSearch()
+		sp.chatView.messagesList.SetCursor(messageIndex)
+		return tview.SetFocus(sp.chatView.messagesList)
+	case *picker.CancelEvent:
+		sp.chatView.closeSearch()
+		return nil
+	}
+	return sp.Model.HandleEvent(event)
+}
+
+func (sp *searchPicker) update() {
+	messages := sp.chatView.messagesList.messages
+	items := make(picker.Items, 0, len(messages))
+	for i, msg := range messages {
+		text := msg.Author.DisplayOrUsername() + ": " + msg.Content
+		if len(text) > 120 {
+			text = text[:120] + "..."
+		}
+		items = append(items, picker.Item{
+			Text:       text,
+			FilterText: fmt.Sprintf("%s %s", msg.Author.DisplayOrUsername(), msg.Content),
+			Reference:  i,
+		})
+	}
+	sp.Model.SetItems(items)
+}
+
+func (sp *searchPicker) ShortHelp() []keybind.Keybind {
+	cfg := sp.chatView.cfg.Keybinds.Picker
+	return []keybind.Keybind{cfg.Up.Keybind, cfg.Down.Keybind, cfg.Select.Keybind, cfg.Cancel.Keybind}
+}
+
+func (sp *searchPicker) FullHelp() [][]keybind.Keybind {
+	cfg := sp.chatView.cfg.Keybinds.Picker
+	return [][]keybind.Keybind{
+		{cfg.Up.Keybind, cfg.Down.Keybind, cfg.Top.Keybind, cfg.Bottom.Keybind},
+		{cfg.Select.Keybind, cfg.Cancel.Keybind},
+	}
+}

+ 70 - 0
internal/ui/chat/state.go

@@ -200,6 +200,76 @@ func (m *Model) onTypingStart(event *gateway.TypingStartEvent) {
 	m.addTyper(event.UserID)
 }
 
+func (m *Model) onMessageReactionAdd(event *gateway.MessageReactionAddEvent) {
+	selected := m.SelectedChannel()
+	if selected == nil || selected.ID != event.ChannelID {
+		return
+	}
+
+	index := slices.IndexFunc(m.messagesList.messages, func(msg discord.Message) bool {
+		return msg.ID == event.MessageID
+	})
+	if index < 0 {
+		return
+	}
+
+	msg := &m.messagesList.messages[index]
+	found := false
+	for i, r := range msg.Reactions {
+		if r.Emoji.Name == event.Emoji.Name && r.Emoji.ID == event.Emoji.ID {
+			msg.Reactions[i].Count++
+			me, _ := m.state.Cabinet.Me()
+			if me != nil && event.UserID == me.ID {
+				msg.Reactions[i].Me = true
+			}
+			found = true
+			break
+		}
+	}
+	if !found {
+		me, _ := m.state.Cabinet.Me()
+		isMe := me != nil && event.UserID == me.ID
+		msg.Reactions = append(msg.Reactions, discord.Reaction{
+			Count: 1,
+			Me:    isMe,
+			Emoji: event.Emoji,
+		})
+	}
+	delete(m.messagesList.itemByID, event.MessageID)
+	m.messagesList.invalidateRows()
+}
+
+func (m *Model) onMessageReactionRemove(event *gateway.MessageReactionRemoveEvent) {
+	selected := m.SelectedChannel()
+	if selected == nil || selected.ID != event.ChannelID {
+		return
+	}
+
+	index := slices.IndexFunc(m.messagesList.messages, func(msg discord.Message) bool {
+		return msg.ID == event.MessageID
+	})
+	if index < 0 {
+		return
+	}
+
+	msg := &m.messagesList.messages[index]
+	for i, r := range msg.Reactions {
+		if r.Emoji.Name == event.Emoji.Name && r.Emoji.ID == event.Emoji.ID {
+			msg.Reactions[i].Count--
+			me, _ := m.state.Cabinet.Me()
+			if me != nil && event.UserID == me.ID {
+				msg.Reactions[i].Me = false
+			}
+			if msg.Reactions[i].Count <= 0 {
+				msg.Reactions = append(msg.Reactions[:i], msg.Reactions[i+1:]...)
+			}
+			break
+		}
+	}
+	delete(m.messagesList.itemByID, event.MessageID)
+	m.messagesList.invalidateRows()
+}
+
 func (m *Model) onReadUpdate(event *read.UpdateEvent) {
 	// Use indexed node lookup to avoid walking the whole tree on every read
 	// event. This runs frequently while reading/typing across channels.

+ 113 - 0
internal/ui/chat/user_info.go

@@ -0,0 +1,113 @@
+package chat
+
+import (
+	"fmt"
+	"strings"
+	"time"
+
+	"github.com/ayn2op/tview"
+	"github.com/diamondburned/arikawa/v3/discord"
+	"github.com/diamondburned/arikawa/v3/state"
+	"github.com/gdamore/tcell/v3"
+)
+
+const userInfoLayerName = "userInfo"
+
+func (ml *messagesList) showUserInfo() {
+	msg, err := ml.selectedMessage()
+	if err != nil {
+		return
+	}
+
+	var lines []tview.Line
+	b := tview.NewLineBuilder()
+	boldStyle := tcell.StyleDefault.Bold(true)
+	dimStyle := tcell.StyleDefault.Dim(true)
+	normalStyle := tcell.StyleDefault
+
+	// Username
+	b.Write("Username: ", dimStyle)
+	b.Write(msg.Author.DisplayOrUsername(), boldStyle)
+	lines = append(lines, b.Finish()...)
+
+	if msg.Author.DisplayName != "" && msg.Author.DisplayName != msg.Author.Username {
+		b = tview.NewLineBuilder()
+		b.Write("Display:  ", dimStyle)
+		b.Write(msg.Author.DisplayName, normalStyle)
+		lines = append(lines, b.Finish()...)
+	}
+
+	// Account created (derived from snowflake ID)
+	b = tview.NewLineBuilder()
+	created := discord.Snowflake(msg.Author.ID).Time()
+	b.Write("Created:  ", dimStyle)
+	b.Write(created.Format("Jan 2, 2006"), normalStyle)
+	lines = append(lines, b.Finish()...)
+
+	// Guild-specific info
+	if msg.GuildID.IsValid() {
+		member, err := ml.chatView.state.Cabinet.Member(msg.GuildID, msg.Author.ID)
+		if err == nil && member != nil {
+			// Join date
+			if member.Joined.IsValid() {
+				b = tview.NewLineBuilder()
+				b.Write("Joined:   ", dimStyle)
+				b.Write(member.Joined.Time().In(time.Local).Format("Jan 2, 2006"), normalStyle)
+				lines = append(lines, b.Finish()...)
+			}
+
+			// Nickname
+			if member.Nick != "" {
+				b = tview.NewLineBuilder()
+				b.Write("Nickname: ", dimStyle)
+				b.Write(member.Nick, normalStyle)
+				lines = append(lines, b.Finish()...)
+			}
+
+			// Roles
+			if len(member.RoleIDs) > 0 {
+				b = tview.NewLineBuilder()
+				b.Write("Roles:    ", dimStyle)
+				var roleNames []string
+				for _, roleID := range member.RoleIDs {
+					role, err := ml.chatView.state.Cabinet.Role(msg.GuildID, roleID)
+					if err != nil {
+						continue
+					}
+					roleNames = append(roleNames, role.Name)
+				}
+				if len(roleNames) > 0 {
+					b.Write(strings.Join(roleNames, ", "), normalStyle)
+					lines = append(lines, b.Finish()...)
+				}
+			}
+
+			// Top role color indicator
+			roleColor, ok := state.MemberColor(member, func(id discord.RoleID) *discord.Role {
+				r, _ := ml.chatView.state.Cabinet.Role(msg.GuildID, id)
+				return r
+			})
+			if ok {
+				b = tview.NewLineBuilder()
+				b.Write("Color:    ", dimStyle)
+				colorHex := fmt.Sprintf("#%06X", uint32(roleColor))
+				b.Write(colorHex, normalStyle.Foreground(tcell.NewHexColor(int32(roleColor))))
+				lines = append(lines, b.Finish()...)
+			}
+		}
+	}
+
+	// User ID
+	b = tview.NewLineBuilder()
+	b.Write("ID:       ", dimStyle)
+	b.Write(msg.Author.ID.String(), normalStyle)
+	lines = append(lines, b.Finish()...)
+
+	tv := tview.NewTextView().
+		SetWrap(true).
+		SetWordWrap(true).
+		SetLines(lines)
+	tv.SetTitle(msg.Author.DisplayOrUsername())
+
+	ml.chatView.showUserInfoOverlay(tv)
+}