Forráskód Böngészése

fix(ui/chat): emoji picker browse-by-default, favorites, picker i-key fix

- Emoji picker opens in browse/scroll mode (j/k/g/G) instead of text input
- f toggles favorite emoji (★ prefix, up to 10, persisted to cache dir)
- Changed emoji keybind from E to e
- Fixed: pressing i in any picker's browse mode now returns to picker input
  instead of focusing message input (global single-char keybinds suppressed
  while picker overlays are open)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
claude 1 hónapja
szülő
commit
19033eaa62

+ 3 - 3
CLAUDE.md

@@ -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`)
+- **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`)
 - **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`)
@@ -64,7 +64,7 @@ This is the persistent context file for Claude Code. Keep it concise and useful.
 - **Reply quote italic**: dim + italic style for reply lines; `Z` toggles reply collapse (shows `> ` marker only)
 - **Timestamp toggle**: `t` toggles timestamps on/off at runtime
 - **Wrap indentation**: continuation lines get 2-space indent for visual clarity
-- **Picker browse mode**: ESC in overlay pickers enters browse mode (j/k/g/G/i) via `pickerBrowseHandleKey`
+- **Picker browse mode**: ESC in overlay pickers enters browse mode (j/k/g/G/i) via `pickerBrowseHandleKey`; global single-char keybinds suppressed while picker is open (fixes `i` going to message input)
 - **Link display compression**: `ui.LinkDisplayText()` shows human-friendly labels instead of raw URLs in chat and embeds; `ui.CdnDisplayName()` cleans attachment filenames (encoded URLs → `image.ext`, UUIDs → `image.ext`); link preview embeds suppressed when URL already in message content (`isLinkPreviewEmbed`); removed `show_attachment_links` config (attachments always show as OSC 8 clickable filenames)
 
 ## Adding Link Display Rules
@@ -99,7 +99,7 @@ To add a new site-specific URL label, edit `ui.LinkDisplayText()` in `internal/u
 - `keybinds.guilds_tree.yank_id` / `keybinds.messages_list.yank_id` — copy ID (default: `C`, was `i`)
 - `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.add_reaction` — open emoji picker (default: `e`)
 
 ## Build & Run
 - Build: `go build -o discordo-plus .`

+ 1 - 1
internal/config/config.toml

@@ -177,7 +177,7 @@ user_info = "w"
 open_thread = "T"
 toggle_timestamps = "t"
 toggle_replies = "Z"
-add_reaction = "E"
+add_reaction = "e"
 search = "/"
 arrow_up = "up"
 arrow_down = "down"

+ 1 - 1
internal/config/keybinds.go

@@ -221,7 +221,7 @@ func defaultMessagesListKeybinds() MessagesListKeybinds {
 		OpenThread:       newKeybind("T", "thread"),
 		ToggleTimestamps: newKeybind("t", "timestamps"),
 		ToggleReplies:    newKeybind("Z", "collapse replies"),
-		AddReaction:      newKeybind("E", "react"),
+		AddReaction:      newKeybind("e", "react"),
 		ArrowUp:     newKeybind("up", "up"),
 		ArrowDown:   newKeybind("down", "down"),
 		ArrowLeft:   newKeybind("left", "left"),

+ 89 - 0
internal/ui/chat/emoji_favorites.go

@@ -0,0 +1,89 @@
+package chat
+
+import (
+	"encoding/json"
+	"log/slog"
+	"os"
+	"path/filepath"
+	"sync"
+
+	"github.com/ayn2op/discordo/internal/consts"
+)
+
+const maxFavoriteEmoji = 10
+
+type emojiFavorites struct {
+	Favorites []string `json:"favorites"`
+	mu        sync.RWMutex
+}
+
+var emojiFavoritesPath = filepath.Join(consts.CacheDir(), "emoji_favorites.json")
+
+func loadEmojiFavorites() *emojiFavorites {
+	ef := &emojiFavorites{}
+	data, err := os.ReadFile(emojiFavoritesPath)
+	if err != nil {
+		return ef
+	}
+	if err := json.Unmarshal(data, ef); err != nil {
+		slog.Warn("failed to parse emoji favorites", "err", err)
+		return &emojiFavorites{}
+	}
+	return ef
+}
+
+func (ef *emojiFavorites) save() {
+	ef.mu.RLock()
+	data, err := json.Marshal(ef)
+	ef.mu.RUnlock()
+	if err != nil {
+		slog.Error("failed to marshal emoji favorites", "err", err)
+		return
+	}
+	tmpPath := emojiFavoritesPath + ".tmp"
+	if err := os.WriteFile(tmpPath, data, 0600); err != nil {
+		slog.Error("failed to write emoji favorites", "err", err)
+		return
+	}
+	if err := os.Rename(tmpPath, emojiFavoritesPath); err != nil {
+		slog.Error("failed to rename emoji favorites file", "err", err)
+	}
+}
+
+func (ef *emojiFavorites) isFavorite(emoji string) bool {
+	ef.mu.RLock()
+	defer ef.mu.RUnlock()
+	for _, e := range ef.Favorites {
+		if e == emoji {
+			return true
+		}
+	}
+	return false
+}
+
+func (ef *emojiFavorites) toggle(emoji string) {
+	ef.mu.Lock()
+	for i, e := range ef.Favorites {
+		if e == emoji {
+			ef.Favorites = append(ef.Favorites[:i], ef.Favorites[i+1:]...)
+			ef.mu.Unlock()
+			ef.save()
+			return
+		}
+	}
+	if len(ef.Favorites) >= maxFavoriteEmoji {
+		ef.mu.Unlock()
+		return
+	}
+	ef.Favorites = append(ef.Favorites, emoji)
+	ef.mu.Unlock()
+	ef.save()
+}
+
+func (ef *emojiFavorites) list() []string {
+	ef.mu.RLock()
+	defer ef.mu.RUnlock()
+	out := make([]string, len(ef.Favorites))
+	copy(out, ef.Favorites)
+	return out
+}

+ 100 - 10
internal/ui/chat/emoji_picker.go

@@ -10,6 +10,7 @@ import (
 	"github.com/ayn2op/tview/keybind"
 	"github.com/ayn2op/tview/picker"
 	"github.com/diamondburned/arikawa/v3/discord"
+	"github.com/gdamore/tcell/v3"
 )
 
 const emojiPickerLayerName = "emojiPicker"
@@ -18,22 +19,33 @@ type emojiPicker struct {
 	*picker.Model
 	chatView   *Model
 	browseMode bool
+	favorites  *emojiFavorites
+
+	// items mirrors what was passed to SetItems, so we can look up by cursor.
+	items []picker.Item
 
 	targetMessageID discord.MessageID
 	targetChannelID discord.ChannelID
+
+	// cursor tracks the browse-mode position (0-based into items).
+	cursor int
 }
 
 var _ help.KeyMap = (*emojiPicker)(nil)
 
 func newEmojiPicker(cfg *config.Config, chatView *Model) *emojiPicker {
-	ep := &emojiPicker{Model: picker.NewModel(), chatView: chatView}
+	ep := &emojiPicker{
+		Model:     picker.NewModel(),
+		chatView:  chatView,
+		favorites: loadEmojiFavorites(),
+	}
 	ConfigurePicker(ep.Model, cfg, "Emoji")
 	return ep
 }
 
-func (ep *emojiPicker) resetBrowse() { ep.browseMode = false }
+// resetBrowse starts the emoji picker in browse (scroll) mode by default.
+func (ep *emojiPicker) resetBrowse() { ep.browseMode = true; ep.cursor = 0 }
 
-// commonEmoji returns a list of frequently used unicode emoji for the picker.
 var commonEmoji = []struct {
 	emoji string
 	names string
@@ -81,7 +93,6 @@ var commonEmoji = []struct {
 	{"😮", "open_mouth surprised"},
 	{"😱", "scream shocked"},
 	{"😈", "smiling_imp devil"},
-	{"💀", "skull"},
 	{"🤡", "clown clown_face"},
 	{"🫡", "salute saluting_face"},
 	{"🫠", "melting_face"},
@@ -91,17 +102,18 @@ var commonEmoji = []struct {
 }
 
 func (ep *emojiPicker) update() {
-	items := make(picker.Items, 0, len(commonEmoji)+50)
+	items := make(picker.Items, 0, maxFavoriteEmoji+len(commonEmoji)+50)
 
+	allItems := make(picker.Items, 0, len(commonEmoji)+50)
 	for _, e := range commonEmoji {
-		items = append(items, picker.Item{
+		allItems = append(allItems, picker.Item{
 			Text:       e.emoji + " " + e.names[:min(len(e.names), indexOf(e.names, ' '))],
 			FilterText: e.names,
 			Reference:  discord.APIEmoji(e.emoji),
 		})
 	}
 
-	// Add guild custom emoji if available
+	// Guild custom emoji
 	selectedChannel := ep.chatView.SelectedChannel()
 	if selectedChannel != nil && selectedChannel.GuildID.IsValid() {
 		emojis, err := ep.chatView.state.Cabinet.Emojis(selectedChannel.GuildID)
@@ -110,7 +122,7 @@ func (ep *emojiPicker) update() {
 				if !emoji.Available {
 					continue
 				}
-				items = append(items, picker.Item{
+				allItems = append(allItems, picker.Item{
 					Text:       ":" + emoji.Name + ":",
 					FilterText: emoji.Name,
 					Reference:  emoji.APIString(),
@@ -119,7 +131,45 @@ func (ep *emojiPicker) update() {
 		}
 	}
 
+	// Index for favorite lookup.
+	byKey := make(map[string]picker.Item, len(allItems))
+	for _, item := range allItems {
+		byKey[emojiItemKey(item)] = item
+	}
+
+	// Prepend favorites.
+	favSet := make(map[string]struct{})
+	for _, key := range ep.favorites.list() {
+		if item, ok := byKey[key]; ok {
+			items = append(items, picker.Item{
+				Text:       "★ " + item.Text,
+				FilterText: "favorite " + item.FilterText,
+				Reference:  item.Reference,
+			})
+			favSet[key] = struct{}{}
+		}
+	}
+
+	for _, item := range allItems {
+		if _, isFav := favSet[emojiItemKey(item)]; !isFav {
+			items = append(items, item)
+		}
+	}
+
+	ep.items = items
 	ep.Model.SetItems(items)
+
+	// Clamp cursor.
+	if ep.cursor >= len(items) {
+		ep.cursor = max(len(items)-1, 0)
+	}
+}
+
+func emojiItemKey(item picker.Item) string {
+	if ref, ok := item.Reference.(discord.APIEmoji); ok {
+		return string(ref)
+	}
+	return ""
 }
 
 func indexOf(s string, c byte) int {
@@ -134,8 +184,12 @@ func indexOf(s string, c byte) int {
 func (ep *emojiPicker) HandleEvent(event tview.Event) tview.Command {
 	switch event := event.(type) {
 	case *tview.KeyEvent:
-		if cmd, handled := pickerBrowseHandleKey(event, &ep.browseMode, ep.Model, func() { ep.chatView.closeEmojiPicker() }); handled {
-			return cmd
+		if ep.browseMode {
+			return ep.handleBrowseKey(event)
+		}
+		if event.Key() == tcell.KeyEsc {
+			ep.browseMode = true
+			return nil
 		}
 	case *picker.SelectedEvent:
 		apiEmoji, ok := event.Reference.(discord.APIEmoji)
@@ -160,6 +214,42 @@ func (ep *emojiPicker) HandleEvent(event tview.Event) tview.Command {
 	return ep.Model.HandleEvent(event)
 }
 
+func (ep *emojiPicker) handleBrowseKey(event *tview.KeyEvent) tview.Command {
+	switch {
+	case event.Key() == tcell.KeyEsc:
+		ep.browseMode = false
+		ep.chatView.closeEmojiPicker()
+		return nil
+	case event.Key() == tcell.KeyRune && event.Str() == "i":
+		ep.browseMode = false
+		return nil
+	case event.Key() == tcell.KeyRune && event.Str() == "j":
+		ep.cursor = min(ep.cursor+1, max(len(ep.items)-1, 0))
+		return ep.Model.HandleEvent(tcell.NewEventKey(tcell.KeyCtrlN, "", tcell.ModCtrl))
+	case event.Key() == tcell.KeyRune && event.Str() == "k":
+		ep.cursor = max(ep.cursor-1, 0)
+		return ep.Model.HandleEvent(tcell.NewEventKey(tcell.KeyCtrlP, "", tcell.ModCtrl))
+	case event.Key() == tcell.KeyRune && event.Str() == "g":
+		ep.cursor = 0
+		return ep.Model.HandleEvent(tcell.NewEventKey(tcell.KeyHome, "", tcell.ModNone))
+	case event.Key() == tcell.KeyRune && event.Str() == "G":
+		ep.cursor = max(len(ep.items)-1, 0)
+		return ep.Model.HandleEvent(tcell.NewEventKey(tcell.KeyEnd, "", tcell.ModNone))
+	case event.Key() == tcell.KeyRune && event.Str() == "f":
+		if ep.cursor >= 0 && ep.cursor < len(ep.items) {
+			key := emojiItemKey(ep.items[ep.cursor])
+			if key != "" {
+				ep.favorites.toggle(key)
+				ep.update()
+			}
+		}
+		return nil
+	case event.Key() == tcell.KeyEnter:
+		return ep.Model.HandleEvent(event)
+	}
+	return nil
+}
+
 func (ep *emojiPicker) ShortHelp() []keybind.Keybind {
 	cfg := ep.chatView.cfg.Keybinds.Picker
 	return []keybind.Keybind{cfg.Up.Keybind, cfg.Down.Keybind, cfg.Select.Keybind, cfg.Cancel.Keybind}

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

@@ -297,6 +297,13 @@ func (m *Model) InputActive() bool {
 	return m.app != nil && m.app.Focused() == m.messageInput
 }
 
+// pickerOverlayOpen returns true when any picker overlay is visible.
+func (m *Model) pickerOverlayOpen() bool {
+	return m.HasLayer(channelsPickerLayerName) ||
+		m.HasLayer(searchPickerLayerName) ||
+		m.HasLayer(emojiPickerLayerName)
+}
+
 func (m *Model) HandleEvent(event tview.Event) tview.Command {
 	switch event := event.(type) {
 	case *tview.InitEvent:
@@ -395,6 +402,13 @@ func (m *Model) HandleEvent(event tview.Event) tview.Command {
 			return m.Layers.HandleEvent(event)
 		}
 
+		// When a picker overlay is open, skip global single-char keybinds
+		// so keys like 'i' go to the picker (browse mode) instead of
+		// triggering focus_message_input.
+		if m.pickerOverlayOpen() && event.Key() == tcell.KeyRune && event.Modifiers() == 0 {
+			return m.Layers.HandleEvent(event)
+		}
+
 		switch {
 		case keybind.Matches(event, m.cfg.Keybinds.FocusGuildsTree.Keybind):
 			m.messageInput.removeMentionsList()