Improve message pane interactions and editing

This commit is contained in:
2026-04-24 04:53:12 +03:00
parent ac28065d2a
commit 0de7073f00
6 changed files with 307 additions and 24 deletions

View File

@@ -6,6 +6,7 @@
#include <climits>
#include <cwchar>
#include <cwctype>
#include <set>
#include <sstream>
#include <curses.h>
@@ -58,6 +59,8 @@ std::optional<int> 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<std::string>{}(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<std::int64_t>{}(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<int>(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<int>(count));
attroff(attrs);
x += count;
x += used_width;
}
std::string entity_prefix(const json& type) {
@@ -408,6 +438,18 @@ std::optional<std::int64_t> 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<std::int64_t>(chat.messages.size())) {
return chat.messages[static_cast<std::size_t>(ref - 1)].id;
}
for (const auto& message : chat.messages) {
if (message.id == ref) {
return message.id;
}
}
return 0;
}
std::tuple<bool, std::int64_t, std::string> 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<bool, std::int64_t, std::string> 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<std::int64_t>(chat.messages.size())) {
message_id = chat.messages[static_cast<std::size_t>(parsed_value - 1)].id;
return {true, resolve_message_ref(chat, parsed_value), value.substr(id_end)};
}
std::tuple<bool, std::int64_t, std::string> 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<unsigned char>(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<std::size_t> 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<int>(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<RenderLine> lines;
std::string current_day;
std::set<std::int64_t> 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<int>(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<std::string> wrapped =
wrap_text(message.text, std::max(10, wrap_width - static_cast<int>(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<int>(lines.size()) - available_rows);
int selected_attachment_line_index = -1;
for (int i = 0; i < static_cast<int>(lines.size()); ++i) {
if (lines[static_cast<std::size_t>(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<int>(lines.size()) - available_rows - message_scroll_);
int first_line = std::max(0, static_cast<int>(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<int>(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<int>(lines.size()) - selected_attachment_line_index - 1);
}
if (message_scroll_ > max_scroll) {
message_scroll_ = max_scroll;
}
first_line = std::max(0, static_cast<int>(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<int>(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<int>(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);
}
}

View File

@@ -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<std::int64_t> 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<std::int64_t>& 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<std::int64_t> forward_target_chat_ids() const;
void sync_message_attachment_selection();
bool move_message_attachment_selection(int delta);
[[nodiscard]] std::optional<AttachmentInfo> selected_message_attachment() const;
[[nodiscard]] std::optional<AttachmentInfo> 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<bool, std::int64_t, std::string> parse_compose_command(const std::string& value) const;
[[nodiscard]] std::tuple<bool, std::int64_t, std::string> parse_edit_command(const std::string& value) const;
[[nodiscard]] std::optional<std::vector<std::int64_t>> 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<std::size_t> 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;

View File

@@ -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<std::int64_t> 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<std::ptrdiff_t>(attachment_message_ids.size())) {
return false;
}
message_attachment_message_id_ = attachment_message_ids[static_cast<std::size_t>(next_index)];
return true;
}
std::optional<AttachmentInfo> 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<AttachmentInfo> App::selected_attachment() const {
const auto chat_id = open_chat_id();
if (!chat_id.has_value()) {

View File

@@ -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<std::int64_t>& message_ids,

View File

@@ -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({

View File

@@ -267,7 +267,7 @@ std::vector<std::string> wrap_text(const std::string& text, int width) {
continue;
}
if (static_cast<int>(current.size() + 1 + word.size()) > width) {
if (utf8_display_width(current) + 1 + utf8_display_width(word) > width) {
lines.push_back(current);
current = word;
} else {