From 0de7073f00b3a7d3e24d643fa0657dbaac00ce23 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Fri, 24 Apr 2026 04:53:12 +0300 Subject: [PATCH] Improve message pane interactions and editing --- src/app.cpp | 197 +++++++++++++++++++++++++++++++++++----- src/app.h | 7 ++ src/app_attachments.cpp | 93 +++++++++++++++++++ src/app_auth.cpp | 29 ++++++ src/app_state.cpp | 3 + src/util.cpp | 2 +- 6 files changed, 307 insertions(+), 24 deletions(-) diff --git a/src/app.cpp b/src/app.cpp index beba383..9a38652 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include @@ -58,6 +59,8 @@ std::optional mapped_layout_hotkey(wchar_t ch) { return 'i'; case L'к': return 'r'; + case L'щ': + return 'o'; default: return std::nullopt; } @@ -76,6 +79,19 @@ short sender_color_pair(const std::string& sender) { return sender_pairs[std::hash{}(sender) % std::size(sender_pairs)]; } +short message_id_color_pair(std::int64_t message_id) { + static constexpr short id_pairs[] = { + kColorPairSenderBlue, + kColorPairSenderCyan, + kColorPairSenderGreen, + kColorPairSenderYellow, + kColorPairSenderMagenta, + kColorPairSenderRed, + }; + + return id_pairs[std::hash{}(message_id) % std::size(id_pairs)]; +} + std::string utf8_from_wchar(wchar_t ch) { char buffer[MB_LEN_MAX] = {}; std::mbstate_t state = std::mbstate_t{}; @@ -183,11 +199,25 @@ void draw_colored_span(int y, int& x, int max_x, const std::string& text, chtype } const int remaining = max_x - x; - const int count = std::min(remaining, static_cast(text.size())); + std::size_t count = 0; + int used_width = 0; + while (count < text.size() && used_width < remaining) { + const std::size_t next = utf8_next_index(text, count); + const int next_width = + utf8_display_width(text, next) - utf8_display_width(text, count); + if (used_width + next_width > remaining) { + break; + } + count = next; + used_width += next_width; + } + if (count == 0) { + return; + } attron(attrs); - mvaddnstr(y, x, text.c_str(), count); + mvaddnstr(y, x, text.c_str(), static_cast(count)); attroff(attrs); - x += count; + x += used_width; } std::string entity_prefix(const json& type) { @@ -408,6 +438,18 @@ std::optional App::open_chat_id() const { return open_chat_id_; } +std::int64_t App::resolve_message_ref(const ChatInfo& chat, std::int64_t ref) const { + if (ref > 0 && ref <= static_cast(chat.messages.size())) { + return chat.messages[static_cast(ref - 1)].id; + } + for (const auto& message : chat.messages) { + if (message.id == ref) { + return message.id; + } + } + return 0; +} + std::tuple App::parse_compose_command(const std::string& value) const { if (!starts_with_at(value, 0, ">r ")) { return {false, 0, value}; @@ -446,12 +488,47 @@ std::tuple App::parse_compose_command(const std } const ChatInfo& chat = chat_it->second; - std::int64_t message_id = parsed_value; - if (parsed_value > 0 && parsed_value <= static_cast(chat.messages.size())) { - message_id = chat.messages[static_cast(parsed_value - 1)].id; + return {true, resolve_message_ref(chat, parsed_value), value.substr(id_end)}; +} + +std::tuple App::parse_edit_command(const std::string& value) const { + if (!starts_with_at(value, 0, ">e ")) { + return {false, 0, value}; } - return {true, message_id, value.substr(id_end)}; + std::size_t id_start = 3; + while (id_start < value.size() && value[id_start] == ' ') { + ++id_start; + } + std::size_t id_end = id_start; + while (id_end < value.size() && std::isdigit(static_cast(value[id_end]))) { + ++id_end; + } + if (id_end == id_start) { + return {true, 0, {}}; + } + + std::int64_t parsed_value = 0; + try { + parsed_value = std::stoll(value.substr(id_start, id_end - id_start)); + } catch (...) { + return {true, 0, {}}; + } + + while (id_end < value.size() && value[id_end] == ' ') { + ++id_end; + } + + const auto chat_id = open_chat_id(); + if (!chat_id.has_value()) { + return {true, 0, value.substr(id_end)}; + } + const auto chat_it = chats_.find(*chat_id); + if (chat_it == chats_.end()) { + return {true, 0, value.substr(id_end)}; + } + + return {true, resolve_message_ref(chat_it->second, parsed_value), value.substr(id_end)}; } std::optional App::find_message_index(const ChatInfo& chat, std::int64_t message_id) const { @@ -886,6 +963,17 @@ void App::handle_key(int ch) { start_reply_to_latest_message(); } return; + case 'o': + if (focus_ != FocusPane::Messages) { + return; + } + sync_message_attachment_selection(); + if (const auto attachment = selected_message_attachment(); attachment.has_value()) { + open_attachment(*attachment); + } else { + status_line_ = "No attachment available to open."; + } + return; case 'i': if (!authorized_) { status_line_ = "Finish login first."; @@ -902,15 +990,19 @@ void App::handle_key(int ch) { if (focus_ == FocusPane::Chats && selected_chat_index_ > 0) { --selected_chat_index_; } else if (focus_ == FocusPane::Messages) { - ++message_scroll_; + if (!move_message_attachment_selection(-1)) { + ++message_scroll_; + } } return; case KEY_DOWN: if (focus_ == FocusPane::Chats && selected_chat_index_ + 1 < static_cast(sorted_chat_ids_.size())) { ++selected_chat_index_; - } else if (focus_ == FocusPane::Messages && message_scroll_ > 0) { - --message_scroll_; + } else if (focus_ == FocusPane::Messages) { + if (!move_message_attachment_selection(1) && message_scroll_ > 0) { + --message_scroll_; + } } return; case KEY_PPAGE: @@ -1227,6 +1319,7 @@ void App::draw_help_menu(int height, int width) { " Enter Open selected chat", " r Reload chats or current chat history", " a Reply to the latest message", + " o Open the latest attachment in the current chat", " i Compose message", " Tab Switch focus", " PgUp/PgDn Scroll messages", @@ -1433,18 +1526,30 @@ void App::draw_message_pane(int top, int height, int left, int width) { struct RenderLine { bool is_day_separator = false; + bool is_selected_attachment = false; + std::int64_t message_numeric_id = 0; + std::int64_t reply_target_message_id = 0; std::string timestamp; std::string message_id; std::string sender; std::string meta; - std::string reply_to; + std::string reply_prefix; + std::string reply_ref; std::string body; + std::string attachment_hint; std::string state; bool is_continuation = false; }; std::vector lines; std::string current_day; + std::set replied_message_ids; + sync_message_attachment_selection(); + for (const auto& message : chat.messages) { + if (message.reply_to_message_id != 0) { + replied_message_ids.insert(message.reply_to_message_id); + } + } for (const auto& message : chat.messages) { const std::string message_day = format_date(message.date); if (message_day != current_day) { @@ -1467,39 +1572,54 @@ void App::draw_message_pane(int top, int height, int left, int width) { const std::string timestamp = "[" + format_time(message.date) + "]"; const std::string message_id = "[" + std::to_string(&message - &chat.messages[0] + 1) + "]"; - const std::string reply_to = - message.reply_to_message_id != 0 ? ("reply " + format_message_ref(chat, message.reply_to_message_id)) : ""; + const std::string reply_prefix = message.reply_to_message_id != 0 ? "reply " : ""; + const std::string reply_ref = + message.reply_to_message_id != 0 ? format_message_ref(chat, message.reply_to_message_id) : ""; const std::string meta = join_with_separator({message.forward_info, message.via_bot}, " "); + const std::string attachment_hint = + message.id == message_attachment_message_id_ ? " o to open" : ""; const int reserved_state_width = static_cast(state.size()) + 1; std::string prefix = timestamp + " " + message_id + " " + message.sender + ": "; if (!meta.empty()) { prefix += meta + " "; } - if (!reply_to.empty()) { - prefix += reply_to + " "; + if (!reply_ref.empty()) { + prefix += reply_prefix + reply_ref + " "; } const int wrap_width = std::max(10, width - 3 - reserved_state_width); std::vector wrapped = - wrap_text(message.text, std::max(10, wrap_width - static_cast(prefix.size()))); + wrap_text( + message.text, + std::max(10, wrap_width - utf8_display_width(prefix) - utf8_display_width(attachment_hint))); if (wrapped.empty()) { RenderLine line; + line.is_selected_attachment = message.id == message_attachment_message_id_; + line.message_numeric_id = message.id; + line.reply_target_message_id = message.reply_to_message_id; line.timestamp = timestamp; line.message_id = message_id; line.sender = message.sender; line.meta = meta; - line.reply_to = reply_to; + line.reply_prefix = reply_prefix; + line.reply_ref = reply_ref; + line.attachment_hint = attachment_hint; line.state = state; lines.push_back(line); continue; } RenderLine first_line; + first_line.is_selected_attachment = message.id == message_attachment_message_id_; + first_line.message_numeric_id = message.id; + first_line.reply_target_message_id = message.reply_to_message_id; first_line.timestamp = timestamp; first_line.message_id = message_id; first_line.sender = message.sender; first_line.meta = meta; - first_line.reply_to = reply_to; + first_line.reply_prefix = reply_prefix; + first_line.reply_ref = reply_ref; first_line.body = wrapped.front(); + first_line.attachment_hint = attachment_hint; first_line.state = state; lines.push_back(first_line); @@ -1518,10 +1638,28 @@ void App::draw_message_pane(int top, int height, int left, int width) { const int available_rows = std::max(1, height - 2); const int max_scroll = std::max(0, static_cast(lines.size()) - available_rows); + int selected_attachment_line_index = -1; + for (int i = 0; i < static_cast(lines.size()); ++i) { + if (lines[static_cast(i)].is_selected_attachment) { + selected_attachment_line_index = i; + break; + } + } if (message_scroll_ > max_scroll) { message_scroll_ = max_scroll; } - const int first_line = std::max(0, static_cast(lines.size()) - available_rows - message_scroll_); + int first_line = std::max(0, static_cast(lines.size()) - available_rows - message_scroll_); + if (selected_attachment_line_index >= 0) { + if (selected_attachment_line_index < first_line) { + message_scroll_ = std::max(0, static_cast(lines.size()) - available_rows - selected_attachment_line_index); + } else if (selected_attachment_line_index >= first_line + available_rows) { + message_scroll_ = std::max(0, static_cast(lines.size()) - selected_attachment_line_index - 1); + } + if (message_scroll_ > max_scroll) { + message_scroll_ = max_scroll; + } + first_line = std::max(0, static_cast(lines.size()) - available_rows - message_scroll_); + } for (int row = 0; row < available_rows; ++row) { const int line_index = first_line + row; if (line_index >= static_cast(lines.size())) { @@ -1557,9 +1695,12 @@ void App::draw_message_pane(int top, int height, int left, int width) { } const int state_x = std::max(left + 1, max_x - static_cast(line.state.size())); const int content_max_x = std::max(left + 1, state_x - 1); + const chtype message_id_attrs = replied_message_ids.find(line.message_numeric_id) != replied_message_ids.end() + ? (COLOR_PAIR(message_id_color_pair(line.message_numeric_id)) | A_BOLD) + : (COLOR_PAIR(kColorPairMarkdown) | A_BOLD); draw_colored_span(y, x, content_max_x, line.timestamp, COLOR_PAIR(kColorPairTimestamp)); draw_colored_span(y, x, content_max_x, " ", A_NORMAL); - draw_colored_span(y, x, content_max_x, line.message_id, COLOR_PAIR(kColorPairMarkdown) | A_BOLD); + draw_colored_span(y, x, content_max_x, line.message_id, message_id_attrs); draw_colored_span(y, x, content_max_x, " ", A_NORMAL); draw_colored_span(y, x, content_max_x, line.sender, COLOR_PAIR(sender_color_pair(line.sender)) | A_BOLD); draw_colored_span(y, x, content_max_x, ": ", A_NORMAL); @@ -1567,15 +1708,25 @@ void App::draw_message_pane(int top, int height, int left, int width) { draw_colored_span(y, x, content_max_x, line.meta, COLOR_PAIR(kColorPairMarkdown) | A_DIM); draw_colored_span(y, x, content_max_x, " ", A_NORMAL); } - if (!line.reply_to.empty()) { - draw_colored_span(y, x, content_max_x, line.reply_to, COLOR_PAIR(kColorPairMarkdown) | A_DIM); + if (!line.reply_ref.empty()) { + draw_colored_span(y, x, content_max_x, line.reply_prefix, COLOR_PAIR(kColorPairMarkdown) | A_DIM); + draw_colored_span( + y, + x, + content_max_x, + line.reply_ref, + replied_message_ids.find(line.reply_target_message_id) != replied_message_ids.end() + ? (COLOR_PAIR(message_id_color_pair(line.reply_target_message_id)) | A_BOLD) + : (COLOR_PAIR(kColorPairMarkdown) | A_BOLD)); draw_colored_span(y, x, content_max_x, " ", A_NORMAL); } int state_draw_x = state_x; draw_colored_span(y, state_draw_x, max_x, line.state, state_attrs); draw_message_body(y, x, content_max_x, line.body); + if (!line.attachment_hint.empty()) { + draw_colored_span(y, x, content_max_x, line.attachment_hint, A_DIM); + } } else { - draw_colored_span(y, x, max_x, " ", A_NORMAL); draw_message_body(y, x, max_x, line.body); } } diff --git a/src/app.h b/src/app.h index 47c71e9..7410d0b 100644 --- a/src/app.h +++ b/src/app.h @@ -36,6 +36,7 @@ class App { void clear_input(); void submit_input(); void send_message(std::int64_t chat_id, const std::string& text, std::optional reply_to_message_id = std::nullopt); + void edit_message(std::int64_t chat_id, std::int64_t message_id, const std::string& text); void forward_message( std::int64_t source_chat_id, const std::vector& message_ids, @@ -82,11 +83,16 @@ class App { void render_attachment_preview_graphics(int top, int left, int width, int height); void reset_attachment_viewer_send_preview(); [[nodiscard]] std::vector forward_target_chat_ids() const; + void sync_message_attachment_selection(); + bool move_message_attachment_selection(int delta); + [[nodiscard]] std::optional selected_message_attachment() const; [[nodiscard]] std::optional selected_attachment() const; [[nodiscard]] std::string render_attachment_preview(const AttachmentInfo& attachment, int width, int height) const; [[nodiscard]] std::string build_attachment_preview_graphics(const AttachmentInfo& attachment, int width, int height) const; [[nodiscard]] std::tuple parse_compose_command(const std::string& value) const; + [[nodiscard]] std::tuple parse_edit_command(const std::string& value) const; [[nodiscard]] std::optional> parse_forward_command(const std::string& value) const; + [[nodiscard]] std::int64_t resolve_message_ref(const ChatInfo& chat, std::int64_t ref) const; [[nodiscard]] std::optional find_message_index(const ChatInfo& chat, std::int64_t message_id) const; [[nodiscard]] std::string format_message_ref(const ChatInfo& chat, std::int64_t message_id) const; [[nodiscard]] std::string sender_label(const json& sender) const; @@ -150,6 +156,7 @@ class App { int forward_target_index_ = 0; std::int64_t open_chat_id_ = 0; std::int64_t tdlib_open_chat_id_ = 0; + std::int64_t message_attachment_message_id_ = 0; std::int64_t my_user_id_ = 0; std::int64_t forward_source_chat_id_ = 0; std::size_t input_cursor_ = 0; diff --git a/src/app_attachments.cpp b/src/app_attachments.cpp index cb23ffd..e95ecce 100644 --- a/src/app_attachments.cpp +++ b/src/app_attachments.cpp @@ -383,6 +383,99 @@ void App::reset_attachment_viewer_send_preview() { attachment_viewer_send_caption_.clear(); } +void App::sync_message_attachment_selection() { + const auto chat_id = open_chat_id(); + if (!chat_id.has_value()) { + message_attachment_message_id_ = 0; + return; + } + + const auto chat_it = chats_.find(*chat_id); + if (chat_it == chats_.end()) { + message_attachment_message_id_ = 0; + return; + } + + for (const auto& message : chat_it->second.messages) { + if (message.id == message_attachment_message_id_ && message.has_attachment) { + return; + } + } + + message_attachment_message_id_ = 0; + for (auto it = chat_it->second.messages.rbegin(); it != chat_it->second.messages.rend(); ++it) { + if (it->has_attachment) { + message_attachment_message_id_ = it->id; + return; + } + } +} + +bool App::move_message_attachment_selection(int delta) { + if (delta == 0) { + return false; + } + + sync_message_attachment_selection(); + const auto chat_id = open_chat_id(); + if (!chat_id.has_value()) { + return false; + } + + const auto chat_it = chats_.find(*chat_id); + if (chat_it == chats_.end()) { + return false; + } + + std::vector attachment_message_ids; + for (const auto& message : chat_it->second.messages) { + if (message.has_attachment) { + attachment_message_ids.push_back(message.id); + } + } + if (attachment_message_ids.empty()) { + message_attachment_message_id_ = 0; + return false; + } + + auto selected = std::find( + attachment_message_ids.begin(), + attachment_message_ids.end(), + message_attachment_message_id_); + if (selected == attachment_message_ids.end()) { + message_attachment_message_id_ = attachment_message_ids.back(); + return true; + } + + const std::ptrdiff_t index = selected - attachment_message_ids.begin(); + const std::ptrdiff_t next_index = index + (delta < 0 ? -1 : 1); + if (next_index < 0 || next_index >= static_cast(attachment_message_ids.size())) { + return false; + } + + message_attachment_message_id_ = attachment_message_ids[static_cast(next_index)]; + return true; +} + +std::optional App::selected_message_attachment() const { + const auto chat_id = open_chat_id(); + if (!chat_id.has_value()) { + return std::nullopt; + } + + const auto chat_it = chats_.find(*chat_id); + if (chat_it == chats_.end()) { + return std::nullopt; + } + + for (const auto& message : chat_it->second.messages) { + if (message.id == message_attachment_message_id_ && message.has_attachment) { + return message.attachment; + } + } + return std::nullopt; +} + std::optional App::selected_attachment() const { const auto chat_id = open_chat_id(); if (!chat_id.has_value()) { diff --git a/src/app_auth.cpp b/src/app_auth.cpp index 8774fe1..355d2b7 100644 --- a/src/app_auth.cpp +++ b/src/app_auth.cpp @@ -356,6 +356,19 @@ void App::submit_input() { open_forward_target_menu(*chat_id, *message_ids); return; } + const auto [is_edit, edit_message_id, edit_text] = parse_edit_command(value); + if (is_edit) { + if (edit_message_id == 0) { + status_line_ = "Edit command needs a valid message ref."; + return; + } + if (edit_text.empty()) { + status_line_ = "Edit text cannot be empty."; + return; + } + edit_message(*chat_id, edit_message_id, edit_text); + return; + } const auto [is_reply, reply_to_message_id, text] = parse_compose_command(value); if (const auto clipboard_caption = parse_clipboard_compose_command(text); clipboard_caption.has_value()) { preview_clipboard_photo_message( @@ -452,6 +465,22 @@ void App::send_message( status_line_ = "Message queued."; } +void App::edit_message(std::int64_t chat_id, std::int64_t message_id, const std::string& text) { + td_.send({ + {"@type", "editMessageText"}, + {"chat_id", chat_id}, + {"message_id", message_id}, + {"reply_markup", nullptr}, + {"input_message_content", + { + {"@type", "inputMessageText"}, + {"text", markdown_formatted_text(td_, text)}, + {"clear_draft", true}, + }}, + }); + status_line_ = "Editing message..."; +} + void App::forward_message( std::int64_t source_chat_id, const std::vector& message_ids, diff --git a/src/app_state.cpp b/src/app_state.cpp index 6bcdde5..4acbea2 100644 --- a/src/app_state.cpp +++ b/src/app_state.cpp @@ -341,6 +341,9 @@ void App::set_open_chat(std::int64_t chat_id) { open_chat_id_ = chat_id; message_scroll_ = 0; + if (changed_chat) { + message_attachment_message_id_ = 0; + } if (tdlib_open_chat_id_ != chat_id) { td_.send({ diff --git a/src/util.cpp b/src/util.cpp index d32990b..4592c0c 100644 --- a/src/util.cpp +++ b/src/util.cpp @@ -267,7 +267,7 @@ std::vector wrap_text(const std::string& text, int width) { continue; } - if (static_cast(current.size() + 1 + word.size()) > width) { + if (utf8_display_width(current) + 1 + utf8_display_width(word) > width) { lines.push_back(current); current = word; } else {