Explorar el Código

feat(ui/chat): add emoji reactions, reply collapse, timestamp toggle, focus fix, wrap indent

- E opens emoji picker (common unicode + guild custom emoji) to react to messages
- Z toggles reply quote collapse (shows compact "> " marker)
- t toggles timestamps on/off at runtime (open_thread moved to T)
- ESC from messages respects hidden guilds panel, falls back to input
- Channel enter always selects latest message and focuses messages list
- Long messages get 2-space indent on continuation lines
- Dynamic input height, config auto-create, discordo-plus rename

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
claude hace 1 mes
padre
commit
60f8ae16c8

+ 19 - 5
CLAUDE.md

@@ -49,17 +49,21 @@ This is the persistent context file for Claude Code. Keep it concise and useful.
 - **Attachments**: URL fix (proper `NewLine()` + `.Url()` style), `o` in ShortHelp when attachments/URLs present
 - **Help/Config**: `?` keybind (was `ctrl+.`), `E` edits config in `$EDITOR`/vim from help overlay
 - **State persistence**: guild + channel expand/collapse state in `~/.cache/discordo/state.json` (`guildstate.go`)
-- **Focus/Navigation**: AutoFocus targets messages list on channel select; ESC cycles input→messages→guilds→input; arrow keys: up/down same as k/j, left/right cycle between panels (configurable keybinds)
+- **Focus/Navigation**: Always focus messages + select latest on channel enter; ESC respects hidden guilds panel (falls back to input); arrow keys: up/down same as k/j, left/right cycle between panels (configurable keybinds); vim-style `h`/`l` panel nav, `i` focus input, `H` toggle guilds, `c` channels picker, `I` attach file; input isolation (single-char keybinds blocked while typing)
+- **Config auto-create**: config path `~/.config/discordo-plus/`, auto-creates dir + default config on first run
+- **Dynamic input height**: message input grows from 3 to 8 lines based on content
 - **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
+- **Reactions display**: renders below content (`drawReactions`), bold for own, real-time gateway updates; `E` opens emoji picker to add reactions (`emoji_picker.go`)
 - **Search picker**: `/` opens fuzzy search over current channel messages (`search_picker.go`)
-- **Thread indicators**: "Thread: name" display, `t` navigates to thread
+- **Thread indicators**: "Thread: name" display, `T` navigates to thread (was `t`)
 - **User info popup**: `w` shows author info overlay (`user_info.go`)
 - **Command mode**: `:` opens vim-style input, supports `:q`/`:quit`/`:logout` (`command_input.go`)
 - **LRU cache cap**: `itemByID` evicts stale entries past 500 items
-- **Reply quote italic**: dim + italic style for reply lines
+- **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`
 - **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)
 
@@ -82,10 +86,20 @@ To add a new site-specific URL label, edit `ui.LinkDisplayText()` in `internal/u
 - `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.open_thread` — navigate to message's thread (default: `T`, was `t`)
 - `keybinds.messages_list.user_info` — show author info popup (default: `w`)
 - `keybinds.command_mode` — open vim-style command input (default: `:`)
 - `keybinds.guilds_tree.arrow_*` / `keybinds.messages_list.arrow_*` — arrow key navigation (default: `up`/`down`/`left`/`right`)
+- `keybinds.focus_previous` / `keybinds.focus_next` — vim-style panel nav (default: `h`/`l`)
+- `keybinds.focus_message_input` — focus input (default: `i`)
+- `keybinds.toggle_guilds_tree` — toggle sidebar (default: `H`)
+- `keybinds.toggle_channels_picker` — channels picker (default: `c`)
+- `keybinds.attach_file` — global attach file keybind (default: `I`)
+- `keybinds.messages_list.reply` / `reply_mention` — swapped: `r` = reply (no mention), `R` = @reply
+- `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`)
 
 ## Build & Run
 - Build: `go build -o discordo-plus .`

+ 8 - 4
README.md

@@ -11,10 +11,14 @@ A fork of [discordo](https://github.com/ayn2op/discordo) — a lightweight Disco
 
 ### Navigation & UI
 - **Arrow key navigation**: Up/down arrows move within a panel (same as k/j), left/right arrows cycle between guilds and messages panels. All four are separate configurable keybinds.
-- **ESC focus cycling**: ESC cycles focus between input → messages → guilds → input
+- **ESC focus cycling**: ESC cycles focus between input → messages → guilds → input (respects hidden guilds panel)
+- **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
+- **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).
+- **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
 - **User info**: `w` shows author info popup (username, join date, roles)
 - **Command mode**: `:` opens vim-style command input (`:q`, `:quit`, `:logout`)
 - **Help**: `?` toggles full help overlay, `E` edits config from help
@@ -22,7 +26,7 @@ A fork of [discordo](https://github.com/ayn2op/discordo) — a lightweight Disco
 
 ### State & Persistence
 - **Guild/channel state**: Expanded/collapsed state for guilds, categories, and forums persists between sessions
-- **Focus on channel select**: Selecting a channel focuses the messages list instead of input
+- **Focus on channel select**: Selecting a channel always focuses messages list and selects the latest message
 
 ### Security
 - Path traversal prevention, HTTPS-only downloads with 100MB size limit, restrictive file permissions (0700/0600), image viewer validation via `exec.LookPath`, atomic state writes, direct `exec.Command` (no shell), environment token warning

+ 6 - 3
internal/config/config.go

@@ -147,12 +147,15 @@ func Load(path string) (*Config, error) {
 	file, err := os.Open(path)
 	if os.IsNotExist(err) {
 		slog.Info(
-			"config file does not exist, falling back to the default config",
+			"config file does not exist, creating default config",
 			"path",
 			path,
-			"err",
-			err,
 		)
+		if mkErr := os.MkdirAll(filepath.Dir(path), 0700); mkErr != nil {
+			slog.Error("failed to create config dir", "err", mkErr)
+		} else if wErr := os.WriteFile(path, defaultCfg, 0600); wErr != nil {
+			slog.Error("failed to write default config", "err", wErr)
+		}
 	} else {
 		if err != nil {
 			return nil, fmt.Errorf("failed to open config file: %w", err)

+ 16 - 9
internal/config/config.toml

@@ -105,12 +105,16 @@ toggle_help = "?"
 edit_config = "E"
 focus_guilds_tree = "ctrl+g"
 focus_messages_list = "ctrl+t"
-focus_message_input = "ctrl+i"
+focus_message_input = "i"
 # Cycle focus between the widgets.
-focus_previous = "ctrl+h"
-focus_next = "ctrl+l"
+focus_previous = "h"
+focus_next = "l"
 # Hide/show the guilds tree.
-toggle_guilds_tree = "ctrl+b"
+toggle_guilds_tree = "H"
+# Open file picker to attach files.
+attach_file = "I"
+# Open channels picker.
+toggle_channels_picker = "c"
 # Log out and remove the authentication token from keyring.
 # Requires re-login upon restart.
 logout = "ctrl+d"
@@ -135,7 +139,7 @@ top = "g"
 bottom = "G"
 # Select the currently highlighted text-based channel or expand a guild or channel.
 select_current = "enter"
-yank_id = "i"
+yank_id = "C"
 collapse_parent_node = "-"
 move_to_parent_node = "p"
 arrow_up = "up"
@@ -157,9 +161,9 @@ scroll_bottom = "end"
 # Select the message reference (reply) of the selected channel.
 select_reply = "s"
 # Reply to the selected message.
-reply = "R"
+reply = "r"
 # Reply (with mention) to the selected message.
-reply_mention = "r"
+reply_mention = "R"
 cancel = "esc"
 edit = "e"
 delete = "D"
@@ -170,7 +174,10 @@ open = "o"
 # Save the selected message's image attachment to image_save_dir.
 save_image = "S"
 user_info = "w"
-open_thread = "t"
+open_thread = "T"
+toggle_timestamps = "t"
+toggle_replies = "Z"
+add_reaction = "E"
 search = "/"
 arrow_up = "up"
 arrow_down = "down"
@@ -179,7 +186,7 @@ arrow_right = "right"
 # Yank (copy) the selected message's content/url/id.
 yank_content = "y"
 yank_url = "u"
-yank_id = "i"
+yank_id = "C"
 
 # Only while typing a message
 # Alt+Enter: Insert a new line to the current text.

+ 22 - 14
internal/config/keybinds.go

@@ -95,10 +95,13 @@ type MessagesListKeybinds struct {
 	DeleteConfirm Keybind `toml:"delete_confirm"`
 	Open          Keybind `toml:"open"`
 
-	SaveImage  Keybind `toml:"save_image"`
-	UserInfo   Keybind `toml:"user_info"`
-	Search     Keybind `toml:"search"`
-	OpenThread Keybind `toml:"open_thread"`
+	SaveImage        Keybind `toml:"save_image"`
+	UserInfo         Keybind `toml:"user_info"`
+	Search           Keybind `toml:"search"`
+	OpenThread       Keybind `toml:"open_thread"`
+	ToggleTimestamps Keybind `toml:"toggle_timestamps"`
+	ToggleReplies    Keybind `toml:"toggle_replies"`
+	AddReaction      Keybind `toml:"add_reaction"`
 
 	ArrowUp    Keybind `toml:"arrow_up"`
 	ArrowDown  Keybind `toml:"arrow_down"`
@@ -131,6 +134,7 @@ type Keybinds struct {
 	ToggleHelp           Keybind `toml:"toggle_help"`
 	EditConfig           Keybind `toml:"edit_config"`
 	Suspend              Keybind `toml:"suspend"`
+	AttachFile           Keybind `toml:"attach_file"`
 
 	FocusGuildsTree   Keybind `toml:"focus_guilds_tree"`
 	FocusMessagesList Keybind `toml:"focus_messages_list"`
@@ -176,7 +180,7 @@ func defaultGuildsTreeKeybinds() GuildsTreeKeybinds {
 	return GuildsTreeKeybinds{
 		NavigationKeybinds: defaultNavigationKeybinds(),
 		SelectCurrent:      newKeybind("enter", "sel"),
-		YankID:             newKeybind("i", "copy id"),
+		YankID:             newKeybind("C", "copy id"),
 		CollapseParentNode: newKeybind("-", "collapse"),
 		MoveToParentNode:   newKeybind("p", "parent"),
 		ArrowUp:            newKeybind("up", "up"),
@@ -201,8 +205,8 @@ func defaultMessagesListKeybinds() MessagesListKeybinds {
 			ScrollBottom: newKeybind("end", "scroll bottom"),
 		},
 		SelectReply:  newKeybind("s", "sel reply"),
-		Reply:        newKeybind("R", "reply"),
-		ReplyMention: newKeybind("r", "@reply"),
+		Reply:        newKeybind("r", "reply"),
+		ReplyMention: newKeybind("R", "@reply"),
 		Cancel:       newKeybind("esc", "cancel"),
 		Edit:         newKeybind("e", "edit"),
 		Delete:       newKeybind("D", "force delete"),
@@ -214,14 +218,17 @@ func defaultMessagesListKeybinds() MessagesListKeybinds {
 		SaveImage:   newKeybind("S", "save image"),
 		UserInfo:    newKeybind("w", "who"),
 		Search:      newKeybind("/", "search"),
-		OpenThread:  newKeybind("t", "thread"),
+		OpenThread:       newKeybind("T", "thread"),
+		ToggleTimestamps: newKeybind("t", "timestamps"),
+		ToggleReplies:    newKeybind("Z", "collapse replies"),
+		AddReaction:      newKeybind("E", "react"),
 		ArrowUp:     newKeybind("up", "up"),
 		ArrowDown:   newKeybind("down", "down"),
 		ArrowLeft:   newKeybind("left", "left"),
 		ArrowRight:  newKeybind("right", "right"),
 		YankContent: newKeybind("y", "copy text"),
 		YankURL:     newKeybind("u", "copy url"),
-		YankID:      newKeybind("i", "copy id"),
+		YankID:      newKeybind("C", "copy id"),
 	}
 }
 
@@ -250,18 +257,19 @@ func defaultMentionsListKeybinds() MentionsListKeybinds {
 
 func defaultKeybinds() Keybinds {
 	return Keybinds{
-		ToggleGuildsTree:     newKeybind("ctrl+b", "toggle guilds"),
-		ToggleChannelsPicker: newKeybind("ctrl+k", "channels picker"),
+		ToggleGuildsTree:     newKeybind("H", "toggle guilds"),
+		ToggleChannelsPicker: newKeybind("c", "channels picker"),
 		ToggleHelp:           newKeybind("?", "help"),
 		EditConfig:           newKeybind("E", "edit config"),
 		Suspend:              newKeybind("ctrl+z", "suspend"),
+		AttachFile:           newKeybind("I", "attach file"),
 
 		FocusGuildsTree:   newKeybind("ctrl+g", "guilds"),
 		FocusMessagesList: newKeybind("ctrl+t", "messages"),
-		FocusMessageInput: newKeybind("ctrl+i", "input"),
+		FocusMessageInput: newKeybind("i", "input"),
 
-		FocusPrevious: newKeybind("ctrl+h", "focus prev"),
-		FocusNext:     newKeybind("ctrl+l", "focus next"),
+		FocusPrevious: newKeybind("h", "focus prev"),
+		FocusNext:     newKeybind("l", "focus next"),
 
 		CommandMode: newKeybind(":", "command"),
 		Logout:      newKeybind("ctrl+d", "logout"),

+ 1 - 1
internal/consts/consts.go

@@ -6,7 +6,7 @@ import (
 	"path/filepath"
 )
 
-const Name = "discordo"
+const Name = "discordo-plus"
 
 var cacheDir string
 

+ 174 - 0
internal/ui/chat/emoji_picker.go

@@ -0,0 +1,174 @@
+package chat
+
+import (
+	"fmt"
+	"log/slog"
+
+	"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"
+	"github.com/diamondburned/arikawa/v3/discord"
+)
+
+const emojiPickerLayerName = "emojiPicker"
+
+type emojiPicker struct {
+	*picker.Model
+	chatView   *Model
+	browseMode bool
+
+	targetMessageID discord.MessageID
+	targetChannelID discord.ChannelID
+}
+
+var _ help.KeyMap = (*emojiPicker)(nil)
+
+func newEmojiPicker(cfg *config.Config, chatView *Model) *emojiPicker {
+	ep := &emojiPicker{Model: picker.NewModel(), chatView: chatView}
+	ConfigurePicker(ep.Model, cfg, "Emoji")
+	return ep
+}
+
+func (ep *emojiPicker) resetBrowse() { ep.browseMode = false }
+
+// commonEmoji returns a list of frequently used unicode emoji for the picker.
+var commonEmoji = []struct {
+	emoji string
+	names string
+}{
+	{"👍", "thumbsup thumbs_up +1 like"},
+	{"👎", "thumbsdown thumbs_down -1 dislike"},
+	{"❤️", "heart love red_heart"},
+	{"😂", "joy laughing tears"},
+	{"😭", "sob crying"},
+	{"😊", "blush smile happy"},
+	{"😍", "heart_eyes love"},
+	{"🤣", "rofl rolling"},
+	{"🙂", "slightly_smiling_face"},
+	{"😔", "pensive sad"},
+	{"🔥", "fire lit hot"},
+	{"✅", "white_check_mark check yes"},
+	{"❌", "x cross no"},
+	{"👀", "eyes look"},
+	{"🎉", "tada party celebration"},
+	{"💀", "skull dead"},
+	{"🤔", "thinking hmm"},
+	{"😐", "neutral_face"},
+	{"🤷", "shrug"},
+	{"👋", "wave hello hi"},
+	{"🙏", "pray please thanks"},
+	{"💯", "100 hundred perfect"},
+	{"😎", "sunglasses cool"},
+	{"🥳", "partying_face party"},
+	{"😢", "cry sad"},
+	{"😅", "sweat_smile nervous"},
+	{"🤗", "hugs hugging"},
+	{"😤", "triumph angry"},
+	{"🥺", "pleading_face"},
+	{"💪", "muscle strong flex"},
+	{"✨", "sparkles stars"},
+	{"💜", "purple_heart"},
+	{"💙", "blue_heart"},
+	{"💚", "green_heart"},
+	{"🧡", "orange_heart"},
+	{"💛", "yellow_heart"},
+	{"🖤", "black_heart"},
+	{"🤍", "white_heart"},
+	{"⭐", "star"},
+	{"🌟", "star2 glowing_star"},
+	{"😮", "open_mouth surprised"},
+	{"😱", "scream shocked"},
+	{"😈", "smiling_imp devil"},
+	{"💀", "skull"},
+	{"🤡", "clown clown_face"},
+	{"🫡", "salute saluting_face"},
+	{"🫠", "melting_face"},
+	{"💤", "zzz sleep"},
+	{"🤝", "handshake"},
+	{"🏳️", "white_flag surrender"},
+}
+
+func (ep *emojiPicker) update() {
+	items := make(picker.Items, 0, len(commonEmoji)+50)
+
+	for _, e := range commonEmoji {
+		items = append(items, 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
+	selectedChannel := ep.chatView.SelectedChannel()
+	if selectedChannel != nil && selectedChannel.GuildID.IsValid() {
+		emojis, err := ep.chatView.state.Cabinet.Emojis(selectedChannel.GuildID)
+		if err == nil {
+			for _, emoji := range emojis {
+				if !emoji.Available {
+					continue
+				}
+				items = append(items, picker.Item{
+					Text:       ":" + emoji.Name + ":",
+					FilterText: emoji.Name,
+					Reference:  emoji.APIString(),
+				})
+			}
+		}
+	}
+
+	ep.Model.SetItems(items)
+}
+
+func indexOf(s string, c byte) int {
+	for i := range len(s) {
+		if s[i] == c {
+			return i
+		}
+	}
+	return len(s)
+}
+
+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
+		}
+	case *picker.SelectedEvent:
+		apiEmoji, ok := event.Reference.(discord.APIEmoji)
+		if !ok {
+			return nil
+		}
+
+		channelID := ep.targetChannelID
+		messageID := ep.targetMessageID
+		ep.chatView.closeEmojiPicker()
+
+		return func() tview.Event {
+			if err := ep.chatView.state.React(channelID, messageID, apiEmoji); err != nil {
+				slog.Error("failed to add reaction", "err", err, "emoji", fmt.Sprint(apiEmoji))
+			}
+			return nil
+		}
+	case *picker.CancelEvent:
+		ep.chatView.closeEmojiPicker()
+		return nil
+	}
+	return ep.Model.HandleEvent(event)
+}
+
+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}
+}
+
+func (ep *emojiPicker) FullHelp() [][]keybind.Keybind {
+	cfg := ep.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},
+	}
+}

+ 1 - 1
internal/ui/chat/keybinds.go

@@ -65,7 +65,7 @@ func (m *Model) baseFullHelp() [][]keybind.Keybind {
 	return [][]keybind.Keybind{
 		focus,
 		{cfg.FocusPrevious.Keybind, cfg.FocusNext.Keybind},
-		{cfg.ToggleGuildsTree.Keybind, cfg.ToggleChannelsPicker.Keybind},
+		{cfg.ToggleGuildsTree.Keybind, cfg.ToggleChannelsPicker.Keybind, cfg.AttachFile.Keybind},
 		{cfg.Logout.Keybind},
 	}
 }

+ 21 - 1
internal/ui/chat/message_input.go

@@ -49,6 +49,7 @@ type messageInput struct {
 	cache           *cache.Cache
 	mentionsList    *mentionsList
 	lastSearch      time.Time
+	lastHeight      int
 
 	typingTimerMu sync.Mutex
 	typingTimer   *time.Timer
@@ -94,6 +95,8 @@ func (mi *messageInput) reset() {
 	mi.SetTitle("")
 	mi.SetFooter("")
 	mi.SetText("", true)
+	mi.lastHeight = minInputHeight
+	mi.chat.rightFlex.ResizeItem(mi, minInputHeight, 1)
 }
 
 func (mi *messageInput) stopTypingTimer() {
@@ -176,7 +179,24 @@ func (mi *messageInput) HandleEvent(event tview.Event) tview.Command {
 			go mi.chat.app.QueueUpdateDraw(func() { mi.tabSuggestion() })
 		}
 	}
-	return handler(event)
+	cmd := handler(event)
+	mi.updateHeight()
+	return cmd
+}
+
+const (
+	minInputHeight = 3
+	maxInputHeight = 8
+)
+
+func (mi *messageInput) updateHeight() {
+	text := mi.GetText()
+	lines := strings.Count(text, "\n") + 1
+	height := max(min(lines, maxInputHeight), minInputHeight)
+	if height != mi.lastHeight {
+		mi.lastHeight = height
+		mi.chat.rightFlex.ResizeItem(mi, height, 1)
+	}
 }
 
 func (mi *messageInput) paste() {

+ 111 - 13
internal/ui/chat/messages_list.go

@@ -46,6 +46,12 @@ type messagesList struct {
 
 	attachmentsPicker *attachmentsPicker
 
+	timestampsHidden bool
+	repliesCollapsed bool
+	expandedReplies  map[discord.MessageID]struct{}
+
+	lastWidth int
+
 	fetchingMembers struct {
 		mu    sync.Mutex
 		value bool
@@ -71,11 +77,12 @@ type messagesListRow struct {
 
 func newMessagesList(cfg *config.Config, chatView *Model) *messagesList {
 	ml := &messagesList{
-		Model:    list.NewModel(),
-		cfg:      cfg,
-		chatView: chatView,
-		renderer: markdown.NewRenderer(cfg),
-		itemByID: make(map[discord.MessageID]*tview.TextView),
+		Model:           list.NewModel(),
+		cfg:             cfg,
+		chatView:        chatView,
+		renderer:        markdown.NewRenderer(cfg),
+		itemByID:        make(map[discord.MessageID]*tview.TextView),
+		expandedReplies: make(map[discord.MessageID]struct{}),
 	}
 	ml.attachmentsPicker = newAttachmentsPicker(cfg, chatView)
 
@@ -103,6 +110,7 @@ func (ml *messagesList) reset() {
 	ml.rows = nil
 	ml.rowsDirty = false
 	clear(ml.itemByID)
+	clear(ml.expandedReplies)
 	ml.
 		Clear().
 		SetBuilder(ml.buildItem).
@@ -158,9 +166,18 @@ func (ml *messagesList) clearSelection() {
 	ml.SetCursor(-1)
 }
 
+const wrapIndent = 2
+
 func (ml *messagesList) buildItem(index int, cursor int) list.Item {
 	ml.ensureRows()
 
+	// Invalidate cache when viewport width changes.
+	_, _, innerWidth, _ := ml.InnerRect()
+	if ml.lastWidth != 0 && ml.lastWidth != innerWidth {
+		clear(ml.itemByID)
+	}
+	ml.lastWidth = innerWidth
+
 	if index < 0 || index >= len(ml.rows) {
 		return nil
 	}
@@ -172,18 +189,18 @@ func (ml *messagesList) buildItem(index int, cursor int) list.Item {
 
 	message := ml.messages[row.messageIndex]
 	if index == cursor {
+		lines := ml.renderMessage(message, ml.cfg.Theme.MessagesList.SelectedMessageStyle.Style)
 		return tview.NewTextView().
-			SetWrap(true).
-			SetWordWrap(true).
-			SetLines(ml.renderMessage(message, ml.cfg.Theme.MessagesList.SelectedMessageStyle.Style))
+			SetWrap(false).
+			SetLines(ml.indentWrappedLines(lines, innerWidth))
 	}
 
 	item, ok := ml.itemByID[message.ID]
 	if !ok {
+		lines := ml.renderMessage(message, ml.cfg.Theme.MessagesList.MessageStyle.Style)
 		item = tview.NewTextView().
-			SetWrap(true).
-			SetWordWrap(true).
-			SetLines(ml.renderMessage(message, ml.cfg.Theme.MessagesList.MessageStyle.Style))
+			SetWrap(false).
+			SetLines(ml.indentWrappedLines(lines, innerWidth))
 		ml.itemByID[message.ID] = item
 		// Evict stale cache entries when the map grows too large.
 		if len(ml.itemByID) > 500 {
@@ -193,6 +210,40 @@ func (ml *messagesList) buildItem(index int, cursor int) list.Item {
 	return item
 }
 
+func (ml *messagesList) indentWrappedLines(lines []tview.Line, viewportWidth int) []tview.Line {
+	if viewportWidth <= wrapIndent {
+		return lines
+	}
+
+	wrapWidth := viewportWidth - wrapIndent
+	indentStr := strings.Repeat(" ", wrapIndent)
+	result := make([]tview.Line, 0, len(lines))
+
+	for _, line := range lines {
+		wrapped := wrapStyledLine(line, viewportWidth)
+		if len(wrapped) <= 1 {
+			result = append(result, wrapped...)
+			continue
+		}
+		// First sub-line: no indent
+		result = append(result, wrapped[0])
+		// Re-wrap with reduced width for continuation lines
+		remaining := make(tview.Line, 0)
+		for _, w := range wrapped[1:] {
+			remaining = append(remaining, w...)
+		}
+		rewrapped := wrapStyledLine(remaining, wrapWidth)
+		for _, sub := range rewrapped {
+			indented := make(tview.Line, 0, len(sub)+1)
+			indented = append(indented, tview.NewSegment(indentStr, tcell.StyleDefault))
+			indented = append(indented, sub...)
+			result = append(result, indented)
+		}
+	}
+
+	return result
+}
+
 func (ml *messagesList) evictStaleCache() {
 	active := make(map[discord.MessageID]struct{}, len(ml.messages))
 	for _, msg := range ml.messages {
@@ -381,6 +432,9 @@ func (ml *messagesList) formatTimestamp(ts discord.Timestamp) string {
 }
 
 func (ml *messagesList) drawTimestamps(builder *tview.LineBuilder, ts discord.Timestamp, baseStyle tcell.Style) {
+	if ml.timestampsHidden {
+		return
+	}
 	dimStyle := baseStyle.Dim(true)
 	builder.Write(ml.formatTimestamp(ts)+" ", dimStyle)
 }
@@ -484,7 +538,7 @@ func (ml *messagesList) drawSnapshotContent(builder *tview.LineBuilder, parent d
 }
 
 func (ml *messagesList) drawDefaultMessage(builder *tview.LineBuilder, message discord.Message, baseStyle tcell.Style) {
-	if ml.cfg.Timestamps.Enabled {
+	if ml.cfg.Timestamps.Enabled && !ml.timestampsHidden {
 		ml.drawTimestamps(builder, message.Timestamp, baseStyle)
 	}
 
@@ -620,6 +674,15 @@ func (ml *messagesList) drawForwardedMessage(builder *tview.LineBuilder, message
 }
 
 func (ml *messagesList) drawReplyMessage(builder *tview.LineBuilder, message discord.Message, baseStyle tcell.Style) {
+	_, expanded := ml.expandedReplies[message.ID]
+	if ml.repliesCollapsed && !expanded {
+		// Collapsed: show indicator only, then main message on same line
+		replyStyle := baseStyle.Dim(true).Italic(true)
+		builder.Write(ml.cfg.Theme.MessagesList.ReplyIndicator+" ", replyStyle)
+		ml.drawDefaultMessage(builder, message, baseStyle)
+		return
+	}
+
 	replyStyle := baseStyle.Dim(true).Italic(true)
 	// indicator
 	builder.Write(ml.cfg.Theme.MessagesList.ReplyIndicator+" ", replyStyle)
@@ -661,7 +724,10 @@ func (ml *messagesList) HandleEvent(event tview.Event) tview.Command {
 		switch {
 		case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.Cancel.Keybind):
 			ml.clearSelection()
-			return tview.SetFocus(ml.chatView.guildsTree)
+			if cmd := ml.chatView.focusGuildsTree(); cmd != nil {
+				return cmd
+			}
+			return ml.chatView.focusMessageInput()
 		case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.SelectUp.Keybind),
 			keybind.Matches(event, ml.cfg.Keybinds.MessagesList.ArrowUp.Keybind):
 			return ml.selectUp()
@@ -719,6 +785,22 @@ func (ml *messagesList) HandleEvent(event tview.Event) tview.Command {
 		case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.UserInfo.Keybind):
 			ml.showUserInfo()
 			return nil
+		case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.ToggleTimestamps.Keybind):
+			ml.timestampsHidden = !ml.timestampsHidden
+			clear(ml.itemByID)
+			ml.invalidateRows()
+			return nil
+		case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.ToggleReplies.Keybind):
+			ml.repliesCollapsed = !ml.repliesCollapsed
+			if !ml.repliesCollapsed {
+				clear(ml.expandedReplies)
+			}
+			clear(ml.itemByID)
+			ml.invalidateRows()
+			return nil
+		case keybind.Matches(event, ml.cfg.Keybinds.MessagesList.AddReaction.Keybind):
+			ml.addReaction()
+			return nil
 		}
 		return ml.Model.HandleEvent(event)
 
@@ -1036,6 +1118,21 @@ func (ml *messagesList) deleteSelectedMessage() tview.Command {
 	}
 }
 
+func (ml *messagesList) addReaction() {
+	msg, err := ml.selectedMessage()
+	if err != nil {
+		slog.Error("failed to get selected message", "err", err)
+		return
+	}
+
+	selectedChannel := ml.chatView.SelectedChannel()
+	if selectedChannel == nil {
+		return
+	}
+
+	ml.chatView.openEmojiPicker(msg.ID, selectedChannel.ID)
+}
+
 func (ml *messagesList) requestGuildMembers(guildID discord.GuildID, messages []discord.Message) {
 	usersToFetch := make([]discord.UserID, 0, len(messages))
 	seen := make(map[discord.UserID]struct{}, len(messages))
@@ -1181,5 +1278,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},
 	}
 }

+ 39 - 5
internal/ui/chat/model.go

@@ -53,6 +53,7 @@ type Model struct {
 	messageInput   *messageInput
 	channelsPicker *channelsPicker
 	searchPicker   *searchPicker
+	emojiPicker    *emojiPicker
 	commandInput   *commandInput
 
 	selectedChannel   *discord.Channel
@@ -89,6 +90,7 @@ func NewModel(app *tview.Application, cfg *config.Config, token string) *Model {
 	m.messageInput = newMessageInput(cfg, m)
 	m.channelsPicker = newChannelsPicker(cfg, m)
 	m.searchPicker = newSearchPicker(cfg, m)
+	m.emojiPicker = newEmojiPicker(cfg, m)
 	m.commandInput = newCommandInput(m)
 
 	identifyProps := http.IdentifyProperties()
@@ -194,6 +196,25 @@ func (m *Model) closeSearch() {
 	m.searchPicker.Update()
 }
 
+func (m *Model) openEmojiPicker(messageID discord.MessageID, channelID discord.ChannelID) {
+	m.emojiPicker.targetMessageID = messageID
+	m.emojiPicker.targetChannelID = channelID
+	m.AddLayer(
+		ui.Centered(m.emojiPicker, m.cfg.Picker.Width, m.cfg.Picker.Height),
+		layers.WithName(emojiPickerLayerName),
+		layers.WithResize(true),
+		layers.WithVisible(true),
+		layers.WithOverlay(),
+	).SendToFront(emojiPickerLayerName)
+	m.emojiPicker.resetBrowse()
+	m.emojiPicker.update()
+}
+
+func (m *Model) closeEmojiPicker() {
+	m.RemoveLayer(emojiPickerLayerName)
+	m.emojiPicker.Update()
+}
+
 func (m *Model) toggleGuildsTree() tview.Command {
 	// The guilds tree is visible if the number of items is two.
 	if m.mainFlex.GetItemCount() == 2 {
@@ -270,6 +291,12 @@ func (m *Model) focusNext() tview.Command {
 	return nil
 }
 
+// InputActive returns true when the message input is focused, signaling that
+// single-char keybinds should not fire (they should be typed into the input).
+func (m *Model) InputActive() bool {
+	return m.app != nil && m.app.Focused() == m.messageInput
+}
+
 func (m *Model) HandleEvent(event tview.Event) tview.Command {
 	switch event := event.(type) {
 	case *tview.InitEvent:
@@ -325,19 +352,16 @@ func (m *Model) HandleEvent(event tview.Event) tview.Command {
 		m.messagesList.setTitle(event.Channel)
 		m.messagesList.setMessages(event.Messages)
 		m.messagesList.ScrollBottom()
+		m.messagesList.selectBottom()
 
 		hasNoPerm := event.Channel.Type != discord.DirectMessage && event.Channel.Type != discord.GroupDM && !m.state.HasPermissions(event.Channel.ID, discord.PermissionSendMessages)
 		m.messageInput.SetDisabled(hasNoPerm)
 		text := "Message..."
-
-		var focusCommand tview.Command
 		if hasNoPerm {
 			text = "You do not have permission to send messages in this channel."
-		} else if m.cfg.AutoFocus {
-			focusCommand = m.focusMessagesList()
 		}
 		m.messageInput.SetPlaceholder(tview.NewLine(tview.NewSegment(text, tcell.StyleDefault.Dim(true))))
-		return focusCommand
+		return m.focusMessagesList()
 	case *QuitEvent:
 		return tview.Batch(
 			m.closeState(),
@@ -365,6 +389,12 @@ func (m *Model) HandleEvent(event tview.Event) tview.Command {
 			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 {
+			return m.Layers.HandleEvent(event)
+		}
+
 		switch {
 		case keybind.Matches(event, m.cfg.Keybinds.FocusGuildsTree.Keybind):
 			m.messageInput.removeMentionsList()
@@ -386,6 +416,10 @@ func (m *Model) HandleEvent(event tview.Event) tview.Command {
 			m.togglePicker()
 			return nil
 
+		case keybind.Matches(event, m.cfg.Keybinds.AttachFile.Keybind):
+			m.messageInput.openFilePicker()
+			return nil
+
 		case keybind.Matches(event, m.cfg.Keybinds.CommandMode.Keybind):
 			m.openCommandInput()
 			return nil

+ 11 - 2
internal/ui/root/model.go

@@ -114,12 +114,21 @@ func (m *Model) HandleEvent(event tview.Event) tview.Command {
 		)
 
 	case *tview.KeyEvent:
+		// Skip single-char keybinds when the chat's message input is focused.
+		type inputChecker interface {
+			InputActive() bool
+		}
+		inputActive := false
+		if ic, ok := m.inner.(inputChecker); ok {
+			inputActive = ic.InputActive()
+		}
+
 		switch {
-		case keybind.Matches(event, m.cfg.Keybinds.ToggleHelp.Keybind):
+		case !inputActive && keybind.Matches(event, m.cfg.Keybinds.ToggleHelp.Keybind):
 			m.help.SetShowAll(!m.help.ShowAll())
 			m.updateHelpHeight()
 			return nil
-		case m.help.ShowAll() && keybind.Matches(event, m.cfg.Keybinds.EditConfig.Keybind):
+		case !inputActive && m.help.ShowAll() && keybind.Matches(event, m.cfg.Keybinds.EditConfig.Keybind):
 			m.editConfig()
 			return nil
 		case keybind.Matches(event, m.cfg.Keybinds.Suspend.Keybind):