浏览代码

feat(ui/chat): add reaction viewer popup (v keybind)

Press v on a message to see who reacted with each emoji. Fetches up to
100 users per emoji via the Discord API and displays them in an overlay.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
claude 3 周之前
父节点
当前提交
6e45ea8f3a

+ 3 - 2
CLAUDE.md

@@ -28,7 +28,7 @@ This is the persistent context file for Claude Code. Keep it concise and useful.
 
 ## Architecture
 - `main.go` → `cmd/root.go` (app init) → `internal/ui/` (TUI layers)
-- `internal/ui/chat/` — core chat UI: `messages_list.go` (rendering/keybinds), `attachment_handler.go`, `embed_renderer.go`, `url_extractor.go`, `message_input.go`, `guilds_tree.go`, `attachments_picker.go`
+- `internal/ui/chat/` — core chat UI: `messages_list.go` (rendering/keybinds), `attachment_handler.go`, `embed_renderer.go`, `url_extractor.go`, `message_input.go`, `guilds_tree.go`, `attachments_picker.go`, `reaction_viewer.go`
 - `internal/markdown/renderer.go` — AST→styled lines, handles Discord markdown flavors
 - `internal/config/` — `config.go` (struct + loader), `config.toml` (defaults, embedded), `keybinds.go`, `theme.go`, `editor.go`
 - `internal/consts/` — app name, cache dir
@@ -55,7 +55,7 @@ This is the persistent context file for Claude Code. Keep it concise and useful.
 - **Security hardening**: path traversal prevention, HTTPS-only downloads, bounded downloads (100MB), `exec.LookPath` validation, atomic writes, restrictive perms, direct `exec.Command` (no `sh -c`)
 - **Bug fixes**: Brotli body leak, cache type assertion panic, MarkRead uses newest fetched message ID
 - **Code structure**: extracted `url_extractor.go`, `embed_renderer.go`, `attachment_handler.go` from `messages_list.go`; replaced `open-golang` with stdlib
-- **Reactions display**: renders below content (`drawReactions`), bold for own, real-time gateway updates; `e` opens emoji picker to add reactions (`emoji_picker.go`); picker defaults to browse/scroll mode; `f` toggles favorites (★, up to 10, persisted to `~/.cache/discordo/emoji_favorites.json`)
+- **Reactions display**: renders below content (`drawReactions`), bold for own, real-time gateway updates; `e` opens emoji picker to add reactions (`emoji_picker.go`); picker defaults to browse/scroll mode; `f` toggles favorites (★, up to 10, persisted to `~/.cache/discordo/emoji_favorites.json`); `v` opens reaction viewer popup showing who reacted with each emoji (`reaction_viewer.go`)
 - **Search picker**: `/` opens fuzzy search over current channel messages (`search_picker.go`)
 - **Thread indicators**: "Thread: name" display, `T` navigates to thread (was `t`)
 - **User info popup**: `w` shows author info overlay (`user_info.go`)
@@ -100,6 +100,7 @@ To add a new site-specific URL label, edit `ui.LinkDisplayText()` in `internal/u
 - `keybinds.messages_list.toggle_timestamps` — runtime timestamp toggle (default: `t`)
 - `keybinds.messages_list.toggle_replies` — collapse/expand reply quotes (default: `Z`)
 - `keybinds.messages_list.add_reaction` — open emoji picker (default: `e`)
+- `keybinds.messages_list.view_reactions` — view who reacted popup (default: `v`)
 
 ## Build & Run
 - Build: `go build -o discordo-plus .`

+ 1 - 1
README.md

@@ -15,7 +15,7 @@ A fork of [discordo](https://github.com/ayn2op/discordo) — a lightweight Disco
 - **Auto-select**: Entering a channel always selects the latest message and focuses the messages list
 - **Search**: `/` opens fuzzy search over current channel messages
 - **Threads**: Thread indicator on messages, `T` navigates into thread
-- **Reactions**: Displayed below messages, bold for own reactions, real-time gateway updates. `E` opens emoji picker to add reactions (common unicode + guild custom emoji).
+- **Reactions**: Displayed below messages, bold for own reactions, real-time gateway updates. `e` opens emoji picker to add reactions (common unicode + guild custom emoji). `v` shows who reacted with each emoji in a popup.
 - **Reply collapse**: `Z` toggles collapsing reply quotes to a compact `> ` indicator
 - **Timestamp toggle**: `t` toggles timestamps on/off at runtime
 - **Wrap indentation**: Continuation lines of long messages get a 2-space indent for visual clarity

+ 1 - 0
internal/config/config.toml

@@ -180,6 +180,7 @@ open_thread = "T"
 toggle_timestamps = "t"
 toggle_replies = "Z"
 add_reaction = "e"
+view_reactions = "v"
 search = "/"
 arrow_up = "up"
 arrow_down = "down"

+ 2 - 0
internal/config/keybinds.go

@@ -102,6 +102,7 @@ type MessagesListKeybinds struct {
 	ToggleTimestamps Keybind `toml:"toggle_timestamps"`
 	ToggleReplies    Keybind `toml:"toggle_replies"`
 	AddReaction      Keybind `toml:"add_reaction"`
+	ViewReactions    Keybind `toml:"view_reactions"`
 
 	ArrowUp    Keybind `toml:"arrow_up"`
 	ArrowDown  Keybind `toml:"arrow_down"`
@@ -224,6 +225,7 @@ func defaultMessagesListKeybinds() MessagesListKeybinds {
 		ToggleTimestamps: newKeybind("t", "timestamps"),
 		ToggleReplies:    newKeybind("Z", "collapse replies"),
 		AddReaction:      newKeybind("e", "react"),
+		ViewReactions:    newKeybind("v", "reactions"),
 		ArrowUp:     newKeybind("up", "up"),
 		ArrowDown:   newKeybind("down", "down"),
 		ArrowLeft:   newKeybind("left", "left"),

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

@@ -810,6 +810,9 @@ func (ml *messagesList) HandleEvent(event tview.Event) tview.Command {
 		case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.AddReaction.Keybind):
 			ml.addReaction()
 			return nil
+		case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.ViewReactions.Keybind):
+			ml.showReactionViewer()
+			return nil
 		}
 		return ml.Model.HandleEvent(event)
 
@@ -1310,6 +1313,6 @@ func (ml *messagesList) FullHelp() [][]keybind.Keybind {
 		actions,
 		manage,
 		{cfg.YankContent.Keybind, cfg.YankURL.Keybind, cfg.YankID.Keybind, cfg.Search.Keybind},
-		{cfg.ToggleTimestamps.Keybind, cfg.ToggleReplies.Keybind, cfg.AddReaction.Keybind},
+		{cfg.ToggleTimestamps.Keybind, cfg.ToggleReplies.Keybind, cfg.AddReaction.Keybind, cfg.ViewReactions.Keybind},
 	}
 }

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

@@ -397,6 +397,12 @@ func (m *Model) HandleEvent(event tview.Event) tview.Command {
 			return nil
 		}
 
+		// Close reaction viewer overlay on any key
+		if m.HasLayer(reactionViewerLayerName) {
+			m.RemoveLayer(reactionViewerLayerName)
+			return nil
+		}
+
 		// When the message input is focused, only pass through ctrl/esc keybinds
 		// at this level. All other keys go directly to the input widget.
 		if m.InputActive() && event.Key() != tcell.KeyEscape && event.Modifiers()&tcell.ModCtrl == 0 {
@@ -461,6 +467,16 @@ func (m *Model) showUserInfoOverlay(tv *tview.TextView) {
 	).SendToFront(userInfoLayerName)
 }
 
+func (m *Model) showReactionViewerOverlay(tv *tview.TextView) {
+	m.AddLayer(
+		ui.Centered(tv, 50, 20),
+		layers.WithName(reactionViewerLayerName),
+		layers.WithResize(true),
+		layers.WithVisible(true),
+		layers.WithOverlay(),
+	).SendToFront(reactionViewerLayerName)
+}
+
 func (m *Model) showConfirmModal(prompt string, buttons []string, onDone func(label string)) {
 	m.confirmModalPreviousFocus = m.app.Focused()
 	m.confirmModalDone = onDone

+ 71 - 0
internal/ui/chat/reaction_viewer.go

@@ -0,0 +1,71 @@
+// reaction_viewer.go implements the reaction details popup overlay.
+// Opened with the view_reactions keybind (v), it fetches and displays
+// which users reacted with each emoji on the selected message.
+package chat
+
+import (
+	"fmt"
+
+	"github.com/ayn2op/tview"
+	"github.com/diamondburned/arikawa/v3/discord"
+	"github.com/gdamore/tcell/v3"
+)
+
+const reactionViewerLayerName = "reactionViewer"
+
+func (ml *messagesList) showReactionViewer() {
+	msg, err := ml.selectedMessage()
+	if err != nil || len(msg.Reactions) == 0 {
+		return
+	}
+
+	channelID := msg.ChannelID
+
+	var lines []tview.Line
+	boldStyle := tcell.StyleDefault.Bold(true)
+	dimStyle := tcell.StyleDefault.Dim(true)
+	normalStyle := tcell.StyleDefault
+
+	for i, r := range msg.Reactions {
+		if i > 0 {
+			// Blank separator line
+			b := tview.NewLineBuilder()
+			b.Write("", normalStyle)
+			lines = append(lines, b.Finish()...)
+		}
+
+		// Emoji header: "👍 (3)"
+		name := r.Emoji.Name
+		if r.Emoji.ID.IsValid() && name != "" {
+			name = ":" + name + ":"
+		}
+
+		b := tview.NewLineBuilder()
+		b.Write(fmt.Sprintf("%s (%d)", name, r.Count), boldStyle)
+		lines = append(lines, b.Finish()...)
+
+		// Fetch users who reacted
+		apiEmoji := discord.NewAPIEmoji(r.Emoji.ID, r.Emoji.Name)
+		users, err := ml.chatView.state.Reactions(channelID, msg.ID, apiEmoji, 100)
+		if err != nil {
+			b = tview.NewLineBuilder()
+			b.Write("  (could not load)", dimStyle)
+			lines = append(lines, b.Finish()...)
+			continue
+		}
+
+		for _, u := range users {
+			b = tview.NewLineBuilder()
+			b.Write("  "+u.DisplayOrUsername(), normalStyle)
+			lines = append(lines, b.Finish()...)
+		}
+	}
+
+	tv := tview.NewTextView().
+		SetWrap(true).
+		SetWordWrap(true).
+		SetLines(lines)
+	tv.SetTitle("Reactions")
+
+	ml.chatView.showReactionViewerOverlay(tv)
+}