From 70c2e779579c3643947e4c689abb5261c84ac3e2 Mon Sep 17 00:00:00 2001 From: Dmitry Date: Fri, 24 Apr 2026 06:34:30 +0300 Subject: [PATCH] Refactor app modules and add clang-format --- .clang-format | 14 + CMakeLists.txt | 20 + src/app.cpp | 1667 +----------------------------------------- src/app.h | 135 ++-- src/app_auth.cpp | 477 ++++++------ src/app_chats.cpp | 155 +++- src/app_commands.cpp | 180 +++++ src/app_help.cpp | 174 +++++ src/app_input.cpp | 396 ++++++++++ src/app_messages.cpp | 869 ++++++++++++++++++++++ src/app_shell.cpp | 262 +++++++ src/app_ui.h | 16 + 12 files changed, 2354 insertions(+), 2011 deletions(-) create mode 100644 .clang-format create mode 100644 src/app_commands.cpp create mode 100644 src/app_help.cpp create mode 100644 src/app_input.cpp create mode 100644 src/app_messages.cpp create mode 100644 src/app_shell.cpp create mode 100644 src/app_ui.h diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..6a0ab5f --- /dev/null +++ b/.clang-format @@ -0,0 +1,14 @@ +BasedOnStyle: LLVM +UseTab: ForIndentation +IndentWidth: 8 +TabWidth: 8 +ContinuationIndentWidth: 8 +ColumnLimit: 100 +SortIncludes: false +AlignConsecutiveAssignments: false +AlignConsecutiveDeclarations: false +AlignOperands: Align +AllowShortFunctionsOnASingleLine: Empty +BreakBeforeBraces: Attach +IndentCaseLabels: false +PointerAlignment: Right diff --git a/CMakeLists.txt b/CMakeLists.txt index ad9c456..569fdfb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -6,6 +6,8 @@ set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) +find_program(CLANG_FORMAT_BIN clang-format) + include(FetchContent) option(TELEGRAM_TUI_USE_SYSTEM_TDLIB "Use an installed TDLib package instead of fetching it." OFF) @@ -36,8 +38,13 @@ add_executable( shinoa src/app_attachments.cpp src/app_chats.cpp + src/app_commands.cpp src/app.cpp src/app_auth.cpp + src/app_help.cpp + src/app_input.cpp + src/app_messages.cpp + src/app_shell.cpp src/app_state.cpp src/main.cpp src/models.cpp @@ -45,6 +52,19 @@ add_executable( src/util.cpp ) +if(CLANG_FORMAT_BIN) + file(GLOB_RECURSE SHINOA_FORMAT_SOURCES CONFIGURE_DEPENDS + src/*.cpp + src/*.h + ) + add_custom_target( + format + COMMAND ${CLANG_FORMAT_BIN} -i ${SHINOA_FORMAT_SOURCES} + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} + COMMENT "Formatting C++ sources with clang-format" + ) +endif() + target_include_directories(shinoa PRIVATE ${CURSES_INCLUDE_DIRS}) target_link_libraries(shinoa PRIVATE ${CURSES_LIBRARIES}) diff --git a/src/app.cpp b/src/app.cpp index 9a38652..bae1992 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -1,14 +1,5 @@ #include "app.h" -#include -#include -#include -#include -#include -#include -#include -#include - #include #include "util.h" @@ -17,313 +8,12 @@ namespace telegram_tui { namespace { -struct TextInsertion { - std::size_t offset = 0; - int order = 0; - std::size_t span_length = 0; - std::string text; -}; - -constexpr short kColorPairSenderBlue = 1; -constexpr short kColorPairSenderCyan = 2; -constexpr short kColorPairSenderGreen = 3; -constexpr short kColorPairSenderYellow = 4; -constexpr short kColorPairSenderMagenta = 5; -constexpr short kColorPairSenderRed = 6; -constexpr short kColorPairLink = 7; -constexpr short kColorPairMarkdown = 8; -constexpr short kColorPairTimestamp = 9; -constexpr short kColorPairOpenChat = 10; - -bool is_truthy_env_value(const std::string& value) { - return value == "1" || value == "true" || value == "TRUE" || - value == "yes" || value == "YES" || value == "on" || value == "ON"; +bool is_truthy_env_value(const std::string &value) { + return value == "1" || value == "true" || value == "TRUE" || value == "yes" || + value == "YES" || value == "on" || value == "ON"; } -bool is_word_char(unsigned char ch) { - return std::isalnum(ch) || ch == '_'; -} - -bool starts_with_at(const std::string& text, std::size_t index, const char* prefix) { - const std::size_t prefix_size = std::char_traits::length(prefix); - return text.compare(index, prefix_size, prefix) == 0; -} - -std::optional mapped_layout_hotkey(wchar_t ch) { - switch (std::towlower(ch)) { - case L'й': - return 'q'; - case L'р': - return 'h'; - case L'ш': - return 'i'; - case L'к': - return 'r'; - case L'щ': - return 'o'; - default: - return std::nullopt; - } -} - -short sender_color_pair(const std::string& sender) { - static constexpr short sender_pairs[] = { - kColorPairSenderBlue, - kColorPairSenderCyan, - kColorPairSenderGreen, - kColorPairSenderYellow, - kColorPairSenderMagenta, - kColorPairSenderRed, - }; - - 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{}; - const std::size_t size = std::wcrtomb(buffer, ch, &state); - if (size == static_cast(-1)) { - return {}; - } - return std::string(buffer, size); -} - -std::string join_with_separator(const std::vector& parts, const char* separator) { - std::string joined; - for (const auto& part : parts) { - if (part.empty()) { - continue; - } - if (!joined.empty()) { - joined += separator; - } - joined += part; - } - return joined; -} - -std::int64_t file_size_from_object(const json& file) { - return std::max(safe_i64(file, "size"), safe_i64(file, "expected_size")); -} - -std::int32_t file_id_from_object(const json& file) { - return safe_i32(file, "id"); -} - -std::string local_path_from_file(const json& file) { - return safe_string(file.value("local", json::object()), "path"); -} - -bool file_can_be_downloaded(const json& file) { - return file.value("local", json::object()).value("can_be_downloaded", false); -} - -bool file_can_be_deleted(const json& file) { - return file.value("local", json::object()).value("can_be_deleted", false); -} - -std::int64_t file_downloaded_size(const json& file) { - return safe_i64(file.value("local", json::object()), "downloaded_size"); -} - -bool file_is_downloading_active(const json& file) { - return file.value("local", json::object()).value("is_downloading_active", false); -} - -bool file_is_downloaded(const json& file) { - return file.value("local", json::object()).value("is_downloading_completed", false); -} - -AttachmentInfo attachment_from_file(AttachmentType type, std::string name, const json& file, std::int64_t size_bytes) { - AttachmentInfo attachment; - attachment.type = type; - attachment.name = std::move(name); - attachment.size_bytes = size_bytes; - attachment.downloaded_size = file_downloaded_size(file); - attachment.file_id = file_id_from_object(file); - attachment.local_path = local_path_from_file(file); - attachment.is_downloading_active = file_is_downloading_active(file); - attachment.can_be_downloaded = file_can_be_downloaded(file); - attachment.can_be_deleted = file_can_be_deleted(file); - attachment.is_downloaded = file_is_downloaded(file); - return attachment; -} - -json largest_photo_file(const json& photo) { - json best_file = json::object(); - std::int64_t best_size = -1; - const auto& sizes = photo.value("sizes", json::array()); - if (!sizes.is_array()) { - return best_file; - } - for (const auto& item : sizes) { - const json file = item.value("photo", json::object()); - const std::int64_t size = file_size_from_object(file); - if (size > best_size) { - best_size = size; - best_file = file; - } - } - return best_file; -} - -std::int64_t photo_size_from_object(const json& photo) { - std::int64_t size = 0; - const auto& sizes = photo.value("sizes", json::array()); - if (!sizes.is_array()) { - return 0; - } - for (const auto& item : sizes) { - size = std::max(size, file_size_from_object(item.value("photo", json::object()))); - } - return size; -} - -void draw_colored_span(int y, int& x, int max_x, const std::string& text, chtype attrs) { - if (x >= max_x || text.empty()) { - return; - } - - const int remaining = max_x - x; - 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(), static_cast(count)); - attroff(attrs); - x += used_width; -} - -std::string entity_prefix(const json& type) { - const std::string type_name = safe_string(type, "@type"); - if (type_name == "textEntityTypeBold") { - return "*"; - } - if (type_name == "textEntityTypeItalic") { - return "_"; - } - if (type_name == "textEntityTypeCode" || type_name == "textEntityTypePre" || - type_name == "textEntityTypePreCode") { - return "`"; - } - if (type_name == "textEntityTypeStrikethrough") { - return "~"; - } - if (type_name == "textEntityTypeUnderline") { - return "__"; - } - if (type_name == "textEntityTypeSpoiler") { - return "||"; - } - if (type_name == "textEntityTypeTextUrl") { - return "["; - } - return {}; -} - -std::string entity_suffix(const json& type) { - const std::string type_name = safe_string(type, "@type"); - if (type_name == "textEntityTypeBold") { - return "*"; - } - if (type_name == "textEntityTypeItalic") { - return "_"; - } - if (type_name == "textEntityTypeCode" || type_name == "textEntityTypePre" || - type_name == "textEntityTypePreCode") { - return "`"; - } - if (type_name == "textEntityTypeStrikethrough") { - return "~"; - } - if (type_name == "textEntityTypeUnderline") { - return "__"; - } - if (type_name == "textEntityTypeSpoiler") { - return "||"; - } - if (type_name == "textEntityTypeTextUrl") { - const std::string url = safe_string(type, "url"); - return url.empty() ? "]" : "](" + url + ")"; - } - return {}; -} - -void draw_message_body(int y, int& x, int max_x, const std::string& text) { - std::size_t i = 0; - while (i < text.size() && x < max_x) { - if (starts_with_at(text, i, "http://") || starts_with_at(text, i, "https://") || - starts_with_at(text, i, "t.me/")) { - std::size_t j = i; - while (j < text.size() && !std::isspace(static_cast(text[j]))) { - ++j; - } - draw_colored_span(y, x, max_x, text.substr(i, j - i), COLOR_PAIR(kColorPairLink) | A_UNDERLINE); - i = j; - continue; - } - - if (text[i] == '@') { - std::size_t j = i + 1; - while (j < text.size() && is_word_char(static_cast(text[j]))) { - ++j; - } - if (j > i + 1) { - draw_colored_span(y, x, max_x, text.substr(i, j - i), COLOR_PAIR(kColorPairLink) | A_BOLD); - i = j; - continue; - } - } - - if (text[i] == '*' || text[i] == '_' || text[i] == '`' || text[i] == '~' || - text[i] == '[' || text[i] == ']' || text[i] == '(' || text[i] == ')') { - draw_colored_span(y, x, max_x, text.substr(i, 1), COLOR_PAIR(kColorPairMarkdown) | A_BOLD); - ++i; - continue; - } - - std::size_t j = i; - while (j < text.size()) { - if (starts_with_at(text, j, "http://") || starts_with_at(text, j, "https://") || - starts_with_at(text, j, "t.me/") || text[j] == '@' || text[j] == '*' || - text[j] == '_' || text[j] == '`' || text[j] == '~' || text[j] == '[' || - text[j] == ']' || text[j] == '(' || text[j] == ')') { - break; - } - ++j; - } - draw_colored_span(y, x, max_x, text.substr(i, j - i), A_NORMAL); - i = j; - } -} - -} // namespace +} // namespace App::App() { const StoredConfig config = load_app_config(); @@ -383,49 +73,12 @@ int App::run() { return 0; } -void App::init_curses() { - std::setlocale(LC_ALL, ""); - initscr(); - init_colors(); - cbreak(); - noecho(); - keypad(stdscr, TRUE); -#ifdef NCURSES_VERSION - set_escdelay(25); -#endif - timeout(kPollTimeoutMs); - curs_set(0); -} - -void App::init_colors() { - if (!has_colors()) { - return; - } - - start_color(); - use_default_colors(); - init_pair(kColorPairSenderBlue, COLOR_BLUE, -1); - init_pair(kColorPairSenderCyan, COLOR_CYAN, -1); - init_pair(kColorPairSenderGreen, COLOR_GREEN, -1); - init_pair(kColorPairSenderYellow, COLOR_YELLOW, -1); - init_pair(kColorPairSenderMagenta, COLOR_MAGENTA, -1); - init_pair(kColorPairSenderRed, COLOR_RED, -1); - init_pair(kColorPairLink, COLOR_CYAN, -1); - init_pair(kColorPairMarkdown, COLOR_MAGENTA, -1); - init_pair(kColorPairTimestamp, COLOR_YELLOW, -1); - init_pair(kColorPairOpenChat, COLOR_GREEN, -1); -} - -void App::shutdown_curses() { - endwin(); -} - std::optional App::highlighted_chat_id() const { if (sorted_chat_ids_.empty()) { return std::nullopt; } if (selected_chat_index_ < 0 || - selected_chat_index_ >= static_cast(sorted_chat_ids_.size())) { + selected_chat_index_ >= static_cast(sorted_chat_ids_.size())) { return std::nullopt; } return sorted_chat_ids_[selected_chat_index_]; @@ -438,100 +91,8 @@ 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}; - } - - 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 {false, 0, value}; - } - - std::int64_t parsed_value = 0; - try { - parsed_value = std::stoll(value.substr(id_start, id_end - id_start)); - } catch (...) { - return {false, 0, value}; - } - - while (id_end < value.size() && value[id_end] == ' ') { - ++id_end; - } - - const auto chat_id = open_chat_id(); - if (!chat_id.has_value()) { - return {false, 0, value}; - } - const auto chat_it = chats_.find(*chat_id); - if (chat_it == chats_.end()) { - return {false, 0, value}; - } - - const ChatInfo& chat = chat_it->second; - 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}; - } - - 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 { +std::optional App::find_message_index(const ChatInfo &chat, + std::int64_t message_id) const { for (std::size_t i = 0; i < chat.messages.size(); ++i) { if (chat.messages[i].id == message_id) { return i; @@ -540,7 +101,7 @@ std::optional App::find_message_index(const ChatInfo& chat, std::in return std::nullopt; } -std::string App::format_message_ref(const ChatInfo& chat, std::int64_t message_id) const { +std::string App::format_message_ref(const ChatInfo &chat, std::int64_t message_id) const { const auto index = find_message_index(chat, message_id); if (index.has_value()) { return "[" + std::to_string(*index + 1) + "]"; @@ -548,1214 +109,4 @@ std::string App::format_message_ref(const ChatInfo& chat, std::int64_t message_i return "#" + std::to_string(message_id); } -void App::start_reply_to_latest_message() { - const auto chat_id = open_chat_id(); - if (!chat_id.has_value()) { - status_line_ = "Open chat first."; - return; - } - - const auto chat_it = chats_.find(*chat_id); - if (chat_it == chats_.end() || chat_it->second.messages.empty()) { - status_line_ = "No message available to reply to."; - return; - } - - input_buffer_ = ">r " + std::to_string(chat_it->second.messages.size()) + " "; - start_input(InputMode::Compose, "Reply", false); - status_line_ = "Replying to latest message."; -} - -void App::open_forward_target_menu(std::int64_t source_chat_id, std::vector message_ids) { - if (source_chat_id == 0 || message_ids.empty()) { - status_line_ = "Forward command needs one or more valid message refs."; - return; - } - const std::vector target_chat_ids = forward_target_chat_ids(); - if (target_chat_ids.empty()) { - status_line_ = "No non-channel chats available to forward to."; - return; - } - - forward_source_chat_id_ = source_chat_id; - forward_message_ids_ = std::move(message_ids); - forward_target_index_ = selected_chat_index_; - if (forward_target_index_ < 0 || forward_target_index_ >= static_cast(target_chat_ids.size())) { - forward_target_index_ = 0; - } - if (target_chat_ids.size() > 1 && - target_chat_ids[static_cast(forward_target_index_)] == source_chat_id) { - if (forward_target_index_ + 1 < static_cast(target_chat_ids.size())) { - ++forward_target_index_; - } else { - --forward_target_index_; - } - } - forward_target_menu_open_ = true; - status_line_ = forward_message_ids_.size() == 1 - ? "Select a chat to forward the message to." - : "Select a chat to forward the messages to."; -} - -std::string App::sender_label(const json& sender) const { - const std::string type = safe_string(sender, "@type"); - if (type == "messageSenderUser") { - const auto user_id = safe_i64(sender, "user_id"); - auto it = users_.find(user_id); - if (it != users_.end()) { - return it->second.display_name(); - } - if (user_id == my_user_id_) { - return "You"; - } - return "User " + std::to_string(user_id); - } - if (type == "messageSenderChat") { - const auto chat_id = safe_i64(sender, "chat_id"); - auto it = chats_.find(chat_id); - if (it != chats_.end() && !it->second.title.empty()) { - return it->second.title; - } - return "Chat " + std::to_string(chat_id); - } - return "Unknown"; -} - -std::string App::user_status_label(std::int64_t user_id) const { - const auto user_it = users_.find(user_id); - if (user_it == users_.end()) { - return {}; - } - return user_it->second.status; -} - -std::string App::forward_origin_label(const json& forward_info) const { - if (!forward_info.is_object()) { - return {}; - } - - const json origin = forward_info.value("origin", json::object()); - const std::string origin_type = safe_string(origin, "@type"); - if (origin_type.empty()) { - return {}; - } - if (origin_type == "messageOriginUser") { - const auto user_id = safe_i64(origin, "sender_user_id"); - const auto user_it = users_.find(user_id); - if (user_it != users_.end()) { - return "fwd from " + user_it->second.display_name(); - } - return "fwd from User " + std::to_string(user_id); - } - if (origin_type == "messageOriginHiddenUser") { - const std::string sender_name = safe_string(origin, "sender_name"); - return sender_name.empty() ? "fwd" : "fwd from " + sender_name; - } - if (origin_type == "messageOriginChat") { - const auto sender_chat_id = safe_i64(origin, "sender_chat_id"); - std::string label; - const auto chat_it = chats_.find(sender_chat_id); - if (chat_it != chats_.end() && !chat_it->second.title.empty()) { - label = chat_it->second.title; - } else { - label = "Chat " + std::to_string(sender_chat_id); - } - const std::string signature = safe_string(origin, "author_signature"); - if (!signature.empty()) { - label += " (" + signature + ")"; - } - return "fwd from " + label; - } - if (origin_type == "messageOriginChannel") { - const auto channel_chat_id = safe_i64(origin, "chat_id"); - std::string label; - const auto chat_it = chats_.find(channel_chat_id); - if (chat_it != chats_.end() && !chat_it->second.title.empty()) { - label = chat_it->second.title; - } else { - label = "Channel " + std::to_string(channel_chat_id); - } - const std::string signature = safe_string(origin, "author_signature"); - if (!signature.empty()) { - label += " (" + signature + ")"; - } - return "fwd from " + label; - } - - return "fwd"; -} - -std::string App::via_bot_label(std::int64_t via_bot_user_id) const { - if (via_bot_user_id == 0) { - return {}; - } - - const auto user_it = users_.find(via_bot_user_id); - if (user_it != users_.end()) { - if (!user_it->second.username.empty()) { - return "via @" + user_it->second.username; - } - return "via " + user_it->second.display_name(); - } - return "via bot " + std::to_string(via_bot_user_id); -} - -std::string App::preview_message(const json& message) const { - if (!message.is_object()) { - return {}; - } - return single_line(content_to_text(message.value("content", json::object()), false)); -} - -std::optional App::parse_attachment(const json& content) const { - const std::string type = safe_string(content, "@type"); - if (type == "messagePhoto") { - const json photo_file = largest_photo_file(content.value("photo", json::object())); - return attachment_from_file( - AttachmentType::Photo, - "photo", - photo_file, - photo_size_from_object(content.value("photo", json::object()))); - } - if (type == "messageVideo") { - const json video = content.value("video", json::object()); - std::string name = safe_string(video, "file_name"); - if (name.empty()) { - name = "video"; - } - const json file = video.value("video", json::object()); - return attachment_from_file(AttachmentType::Video, name, file, file_size_from_object(file)); - } - if (type == "messageVideoNote") { - const json file = content.value("video_note", json::object()).value("video", json::object()); - return attachment_from_file(AttachmentType::Video, "video note", file, file_size_from_object(file)); - } - if (type == "messageDocument") { - const json document = content.value("document", json::object()); - std::string name = safe_string(document, "file_name"); - if (name.empty()) { - name = "document"; - } - const json file = document.value("document", json::object()); - return attachment_from_file(AttachmentType::Document, name, file, file_size_from_object(file)); - } - if (type == "messageAudio") { - const json audio = content.value("audio", json::object()); - std::string name = safe_string(audio, "title"); - if (name.empty()) { - name = safe_string(audio, "file_name"); - } - if (name.empty()) { - name = "audio"; - } - const json file = audio.value("audio", json::object()); - return attachment_from_file(AttachmentType::Audio, name, file, file_size_from_object(file)); - } - if (type == "messageVoiceNote") { - const json file = content.value("voice_note", json::object()).value("voice", json::object()); - return attachment_from_file(AttachmentType::Voice, "voice note", file, file_size_from_object(file)); - } - if (type == "messageAnimation") { - const json animation = content.value("animation", json::object()); - std::string name = safe_string(animation, "file_name"); - if (name.empty()) { - name = "animation"; - } - const json file = animation.value("animation", json::object()); - return attachment_from_file(AttachmentType::Animation, name, file, file_size_from_object(file)); - } - if (type == "messageSticker") { - const json sticker = content.value("sticker", json::object()); - std::string name = safe_string(sticker, "emoji"); - if (name.empty()) { - name = "sticker"; - } - const json file = sticker.value("sticker", json::object()); - return attachment_from_file(AttachmentType::Sticker, name, file, file_size_from_object(file)); - } - return std::nullopt; -} - -std::string App::content_to_text(const json& content, bool decorate) const { - auto format_text = [&](const json& formatted) { - const std::string base = safe_string(formatted, "text"); - if (!decorate || !formatted.contains("entities") || !formatted.at("entities").is_array()) { - return base; - } - - std::vector insertions; - for (const auto& entity : formatted.at("entities")) { - if (!entity.is_object()) { - continue; - } - - const std::size_t utf16_offset = static_cast(safe_i32(entity, "offset")); - const std::size_t utf16_length = static_cast(safe_i32(entity, "length")); - if (utf16_length == 0) { - continue; - } - - const json type = entity.value("type", json::object()); - const std::string prefix = entity_prefix(type); - const std::string suffix = entity_suffix(type); - if (prefix.empty() && suffix.empty()) { - continue; - } - - const std::size_t start = utf8_byte_index_from_utf16_offset(base, utf16_offset); - const std::size_t end = utf8_byte_index_from_utf16_offset(base, utf16_offset + utf16_length); - if (start > end || end > base.size()) { - continue; - } - - if (!suffix.empty()) { - insertions.push_back({end, 0, utf16_length, suffix}); - } - if (!prefix.empty()) { - insertions.push_back({start, 1, utf16_length, prefix}); - } - } - - std::sort(insertions.begin(), insertions.end(), - [](const TextInsertion& lhs, const TextInsertion& rhs) { - if (lhs.offset != rhs.offset) { - return lhs.offset > rhs.offset; - } - if (lhs.order != rhs.order) { - return lhs.order < rhs.order; - } - return lhs.span_length < rhs.span_length; - }); - - std::string decorated = base; - for (const auto& insertion : insertions) { - decorated.insert(insertion.offset, insertion.text); - } - return decorated; - }; - - const std::string type = safe_string(content, "@type"); - if (type == "messageText") { - return format_text(content.value("text", json::object())); - } - if (type == "messagePhoto") { - const std::string caption = format_text(content.value("caption", json::object())); - return caption.empty() ? "[photo]" : "[photo] " + caption; - } - if (type == "messageVideo") { - const std::string caption = format_text(content.value("caption", json::object())); - return caption.empty() ? "[video]" : "[video] " + caption; - } - if (type == "messageDocument") { - const std::string file_name = safe_string(content.value("document", json::object()), "file_name"); - const std::string caption = format_text(content.value("caption", json::object())); - if (!caption.empty()) { - return file_name.empty() ? "[document] " + caption : "[document] " + file_name + " " + caption; - } - return file_name.empty() ? "[document]" : "[document] " + file_name; - } - if (type == "messageSticker") { - const std::string emoji = safe_string(content.value("sticker", json::object()), "emoji"); - return emoji.empty() ? "[sticker]" : "[sticker] " + emoji; - } - if (type == "messageAnimation") { - const std::string caption = format_text(content.value("caption", json::object())); - return caption.empty() ? "[animation]" : "[animation] " + caption; - } - if (type == "messageVoiceNote") { - return "[voice message]"; - } - if (type == "messageAudio") { - const std::string caption = format_text(content.value("caption", json::object())); - return caption.empty() ? "[audio]" : "[audio] " + caption; - } - if (type == "messageCall") { - return "[call]"; - } - if (type == "messagePoll") { - return "[poll] " + safe_string(content.value("poll", json::object()), "question"); - } - if (type == "messageUnsupported") { - return "[unsupported message]"; - } - return "[" + type + "]"; -} - -MessageInfo App::parse_message(const json& message) const { - MessageInfo parsed; - parsed.id = safe_i64(message, "id"); - parsed.date = safe_i32(message, "date"); - parsed.is_outgoing = message.value("is_outgoing", false); - const json reply_to = message.value("reply_to", json::object()); - if (safe_string(reply_to, "@type") == "messageReplyToMessage") { - parsed.reply_to_message_id = safe_i64(reply_to, "message_id"); - } - parsed.forward_info = forward_origin_label(message.value("forward_info", json::object())); - parsed.sender = parsed.is_outgoing ? "You" : sender_label(message.value("sender_id", json::object())); - const json content = message.value("content", json::object()); - if (const auto attachment = parse_attachment(content); attachment.has_value()) { - parsed.has_attachment = true; - parsed.attachment = *attachment; - } - parsed.text = content_to_text(content, true); - parsed.via_bot = via_bot_label(safe_i64(message, "via_bot_user_id")); - return parsed; -} - -void App::handle_key(int ch) { - if (input_mode_ != InputMode::None) { - handle_input_key(ch); - return; - } - if (forward_target_menu_open_) { - handle_forward_target_menu_key(ch); - return; - } - if (attachment_viewer_open_) { - handle_attachment_viewer_key(ch); - return; - } - if (attachment_action_menu_open_) { - handle_attachment_action_menu_key(ch); - return; - } - if (attachments_menu_open_) { - handle_attachments_menu_key(ch); - return; - } - if (help_menu_open_) { - handle_help_menu_key(ch); - return; - } - - switch (ch) { - case 'q': - running_ = false; - return; - case 'h': - case '?': - case KEY_F(1): - help_menu_open_ = true; - status_line_ = "Help and settings."; - return; - case 'm': - if (!open_chat_id().has_value()) { - status_line_ = "Open chat first."; - return; - } - attachments_menu_open_ = true; - attachment_category_index_ = 0; - attachment_selection_index_ = 0; - status_line_ = "Attachments."; - return; - case '\t': - focus_ = focus_ == FocusPane::Chats ? FocusPane::Messages : FocusPane::Chats; - return; - case 'r': - if (focus_ == FocusPane::Chats) { - request_more_chats(); - } else { - request_open_chat_history(true); - } - return; - case 'a': - if (focus_ == FocusPane::Messages) { - 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."; - return; - } - if (!open_chat_id().has_value()) { - status_line_ = "Open chat first."; - return; - } - start_input(InputMode::Compose, "Message", false); - status_line_ = "Compose mode."; - return; - case KEY_UP: - if (focus_ == FocusPane::Chats && selected_chat_index_ > 0) { - --selected_chat_index_; - } else if (focus_ == FocusPane::Messages) { - 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) { - if (!move_message_attachment_selection(1) && message_scroll_ > 0) { - --message_scroll_; - } - } - return; - case KEY_PPAGE: - message_scroll_ += 10; - return; - case KEY_NPAGE: - message_scroll_ = std::max(0, message_scroll_ - 10); - return; - case '\n': - case KEY_ENTER: - if (focus_ == FocusPane::Chats) { - const auto chat_id = highlighted_chat_id(); - if (chat_id.has_value()) { - const bool is_same_chat = open_chat_id_ == *chat_id; - set_open_chat(*chat_id); - if (is_same_chat) { - const auto chat_it = chats_.find(*chat_id); - const bool should_request_history = - auto_reload_chat_history_ || - (chat_it != chats_.end() && (!chat_it->second.history_loaded || chat_it->second.messages.empty())); - const bool requested = should_request_history && request_open_chat_history(true); - if (!requested) { - status_line_ = "Opened chat."; - } - } - } - } else if (auto_reload_chat_history_) { - const bool requested = request_open_chat_history(true); - if (!requested) { - status_line_ = "Opened chat."; - } - } else { - status_line_ = "History reload is manual. Press r to reload."; - } - return; - case KEY_RESIZE: - default: - if (focus_ == FocusPane::Messages && authorized_ && open_chat_id().has_value() && - (ch >= 32 && ch <= 126)) { - input_buffer_.clear(); - input_buffer_.push_back(static_cast(ch)); - input_cursor_ = input_buffer_.size(); - start_input(InputMode::Compose, "Message", false); - status_line_ = "Compose mode."; - return; - } - return; - } -} - -void App::handle_wide_char(wint_t ch) { - const std::string encoded = utf8_from_wchar(static_cast(ch)); - if (encoded.empty()) { - return; - } - - if (input_mode_ != InputMode::None) { - input_buffer_.insert(input_cursor_, encoded); - input_cursor_ += encoded.size(); - return; - } - - if (attachment_viewer_open_ || attachment_action_menu_open_) { - return; - } - if (const auto hotkey = mapped_layout_hotkey(static_cast(ch)); hotkey.has_value()) { - handle_key(*hotkey); - return; - } - - if (attachments_menu_open_) { - return; - } - if (forward_target_menu_open_) { - return; - } - if (help_menu_open_) { - return; - } - - if (focus_ == FocusPane::Messages && authorized_ && open_chat_id().has_value()) { - input_buffer_ = encoded; - input_cursor_ = input_buffer_.size(); - start_input(InputMode::Compose, "Message", false); - status_line_ = "Compose mode."; - } -} - -void App::handle_input_key(int ch) { - switch (ch) { - case 27: - if (input_mode_ == InputMode::Compose) { - clear_input(); - status_line_ = "Compose cancelled."; - } - return; - case '\n': - case KEY_ENTER: - submit_input(); - return; - case KEY_BACKSPACE: - case 127: - case 8: - if (input_cursor_ > 0) { - const std::size_t previous = utf8_prev_index(input_buffer_, input_cursor_); - input_buffer_.erase(previous, input_cursor_ - previous); - input_cursor_ = previous; - } - return; - case KEY_DC: - if (input_cursor_ < input_buffer_.size()) { - const std::size_t next = utf8_next_index(input_buffer_, input_cursor_); - input_buffer_.erase(input_cursor_, next - input_cursor_); - } - return; - case KEY_LEFT: - input_cursor_ = utf8_prev_index(input_buffer_, input_cursor_); - return; - case KEY_RIGHT: - input_cursor_ = utf8_next_index(input_buffer_, input_cursor_); - return; - case KEY_HOME: - input_cursor_ = 0; - return; - case KEY_END: - input_cursor_ = input_buffer_.size(); - return; - default: - if (ch >= 32 && ch <= 126) { - input_buffer_.insert(input_cursor_, 1, static_cast(ch)); - ++input_cursor_; - } - return; - } -} - -void App::handle_help_menu_key(int ch) { - switch (ch) { - case 27: - case 'h': - case '?': - case KEY_F(1): - help_menu_open_ = false; - status_line_ = "Closed help and settings."; - return; - case 't': - auto_reload_chat_history_ = !auto_reload_chat_history_; - persist_config(); - status_line_ = auto_reload_chat_history_ - ? "Auto-reload history enabled." - : "Auto-reload history disabled."; - return; - default: - return; - } -} - -void App::handle_forward_target_menu_key(int ch) { - const std::vector target_chat_ids = forward_target_chat_ids(); - if (target_chat_ids.empty()) { - forward_target_menu_open_ = false; - status_line_ = "No non-channel chats available to forward to."; - return; - } - - switch (ch) { - case 27: - case 'q': - forward_target_menu_open_ = false; - forward_message_ids_.clear(); - status_line_ = "Cancelled forwarding."; - return; - case KEY_UP: - if (forward_target_index_ > 0) { - --forward_target_index_; - } - return; - case KEY_DOWN: - if (forward_target_index_ + 1 < static_cast(target_chat_ids.size())) { - ++forward_target_index_; - } - return; - case KEY_PPAGE: - forward_target_index_ = std::max(0, forward_target_index_ - 10); - return; - case KEY_NPAGE: - forward_target_index_ = std::min( - static_cast(target_chat_ids.size()) - 1, - forward_target_index_ + 10); - return; - case '\n': - case KEY_ENTER: - case ' ': - { - const std::int64_t target_chat_id = - target_chat_ids[static_cast(forward_target_index_)]; - forward_target_menu_open_ = false; - forward_message(forward_source_chat_id_, forward_message_ids_, target_chat_id); - forward_message_ids_.clear(); - } - return; - default: - return; - } -} - -std::vector App::forward_target_chat_ids() const { - std::vector target_chat_ids; - target_chat_ids.reserve(sorted_chat_ids_.size()); - for (const auto chat_id : sorted_chat_ids_) { - const auto chat_it = chats_.find(chat_id); - if (chat_it != chats_.end() && chat_it->second.is_channel) { - continue; - } - target_chat_ids.push_back(chat_id); - } - return target_chat_ids; -} - -void App::draw() { - erase(); - - int height = 0; - int width = 0; - getmaxyx(stdscr, height, width); - if (height < 8 || width < 40) { - mvprintw(0, 0, "Terminal too small."); - refresh(); - return; - } - - const int header_y = 0; - const int content_top = 1; - const int footer_y = height - 2; - const int input_y = height - 1; - const int content_height = footer_y - content_top; - const int chat_width = std::max(24, width / 3); - const int message_width = width - chat_width - 1; - - attron(A_REVERSE); - mvhline(header_y, 0, ' ', width); - const std::string header_label = use_test_dc_ ? "shinoa [TEST DC]" : "shinoa"; - mvprintw(header_y, 1, "%s", header_label.c_str()); - const std::string auth_label = authorized_ ? "ready" : current_auth_label(); - const int auth_x = std::max(1, width - static_cast(auth_label.size()) - 2); - mvprintw(header_y, auth_x, "%s", auth_label.c_str()); - attroff(A_REVERSE); - - mvvline(content_top, chat_width, ACS_VLINE, content_height); - draw_chat_pane(content_top, content_height, chat_width); - draw_message_pane(content_top, content_height, chat_width + 1, message_width); - - attron(A_REVERSE); - mvhline(footer_y, 0, ' ', width); - const std::string footer_status = use_test_dc_ ? "[TEST DC] " + status_line_ : status_line_; - mvprintw(footer_y, 1, "%s", footer_status.c_str()); - attroff(A_REVERSE); - - const std::string help = input_mode_ == InputMode::None - ? "h help" - : input_prompt_ + ": " + - (input_hidden_ ? std::string(input_buffer_.size(), '*') : input_buffer_); - mvhline(input_y, 0, ' ', width); - mvprintw(input_y, 0, "%s", help.c_str()); - if (input_mode_ != InputMode::None) { - const std::string prefix = input_prompt_ + ": "; - const int cursor_x = std::min( - width - 1, - utf8_display_width(prefix) + utf8_display_width(input_buffer_, input_cursor_)); - move(input_y, std::max(0, cursor_x)); - } - - refresh(); - - if (attachment_viewer_open_) { - draw_attachment_viewer(height, width); - } else if (attachment_action_menu_open_) { - draw_attachment_action_menu(height, width); - } else if (attachments_menu_open_) { - draw_attachments_menu(height, width); - } else if (forward_target_menu_open_) { - draw_forward_target_menu(height, width); - } else if (help_menu_open_) { - draw_help_menu(height, width); - } else { - clear_attachment_preview_graphics(); - } -} - -void App::draw_help_menu(int height, int width) { - const int menu_width = std::min(width - 4, 64); - const int menu_height = std::min(height - 4, 15); - const int top = std::max(1, (height - menu_height) / 2); - const int left = std::max(1, (width - menu_width) / 2); - - WINDOW* window = newwin(menu_height, menu_width, top, left); - if (window == nullptr) { - return; - } - - box(window, 0, 0); - mvwprintw(window, 0, 2, " Help / Settings "); - - const std::vector lines = { - "Settings", - std::string(" [") + (auto_reload_chat_history_ ? 'x' : ' ') + - "] Auto-reload open chat history (press t)", - "", - "Hints", - " h Open or close this menu", - " m Open attachments menu", - " >paste [caption] Send image from clipboard in compose mode", - " >f Forward one or more messages", - " 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", - " q Quit", - }; - - for (std::size_t i = 0; i < lines.size() && static_cast(i) < menu_height - 2; ++i) { - mvwaddnstr(window, static_cast(i) + 1, 2, lines[i].c_str(), menu_width - 4); - } - - wrefresh(window); - delwin(window); -} - -void App::draw_forward_target_menu(int height, int width) { - const std::vector target_chat_ids = forward_target_chat_ids(); - if (target_chat_ids.empty()) { - forward_target_menu_open_ = false; - status_line_ = "No non-channel chats available to forward to."; - return; - } - - const int menu_width = std::min(width - 4, 72); - const int menu_height = std::min(height - 4, 20); - const int top = std::max(1, (height - menu_height) / 2); - const int left = std::max(1, (width - menu_width) / 2); - - WINDOW* window = newwin(menu_height, menu_width, top, left); - if (window == nullptr) { - return; - } - - auto truncate = [](std::string text, int max_width) { - if (max_width <= 0) { - return std::string(); - } - if (static_cast(text.size()) <= max_width) { - return text; - } - if (max_width <= 3) { - return text.substr(0, static_cast(max_width)); - } - text.resize(static_cast(max_width - 3)); - text += "..."; - return text; - }; - - if (forward_target_index_ < 0) { - forward_target_index_ = 0; - } - if (forward_target_index_ >= static_cast(target_chat_ids.size())) { - forward_target_index_ = static_cast(target_chat_ids.size()) - 1; - } - - box(window, 0, 0); - mvwprintw(window, 0, 2, " Forward To "); - - std::string source_label = forward_message_ids_.size() == 1 - ? "1 message" - : (std::to_string(forward_message_ids_.size()) + " messages"); - const auto source_chat_it = chats_.find(forward_source_chat_id_); - if (source_chat_it != chats_.end()) { - if (forward_message_ids_.size() == 1) { - const auto message_index = find_message_index(source_chat_it->second, forward_message_ids_.front()); - if (message_index.has_value()) { - source_label = "Message [" + std::to_string(*message_index + 1) + "]"; - } - } - if (!source_chat_it->second.title.empty()) { - source_label += " from " + source_chat_it->second.title; - } - } - mvwaddnstr(window, 1, 2, truncate(source_label, menu_width - 4).c_str(), menu_width - 4); - mvwhline(window, 2, 1, ACS_HLINE, menu_width - 2); - - const int list_top = 3; - const int list_height = std::max(1, menu_height - 5); - int first_index = 0; - if (forward_target_index_ >= list_height) { - first_index = forward_target_index_ - list_height + 1; - } - - for (int row = 0; row < list_height; ++row) { - const int item_index = first_index + row; - const int y = list_top + row; - mvwhline(window, y, 1, ' ', menu_width - 2); - if (item_index >= static_cast(target_chat_ids.size())) { - continue; - } - - const std::int64_t chat_id = target_chat_ids[static_cast(item_index)]; - const auto chat_it = chats_.find(chat_id); - std::string label = chat_it != chats_.end() - ? chat_it->second.title - : ("Chat " + std::to_string(chat_id)); - if (chat_id == forward_source_chat_id_) { - label += " (current)"; - } - if (item_index == forward_target_index_) { - wattron(window, A_REVERSE | A_BOLD); - } - mvwaddnstr(window, y, 2, truncate(label, menu_width - 4).c_str(), menu_width - 4); - if (item_index == forward_target_index_) { - wattroff(window, A_REVERSE | A_BOLD); - } - } - - mvwaddnstr(window, menu_height - 1, 2, "Enter forward Esc cancel Up/Down move", menu_width - 4); - wrefresh(window); - delwin(window); -} - -void App::draw_chat_pane(int top, int height, int width) { - const int visible_rows = std::max(1, height); - int first_index = 0; - if (selected_chat_index_ >= visible_rows) { - first_index = selected_chat_index_ - visible_rows + 1; - } - - for (int row = 0; row < visible_rows; ++row) { - const int index = first_index + row; - const int y = top + row; - mvhline(y, 0, ' ', width); - if (index >= static_cast(sorted_chat_ids_.size())) { - continue; - } - - const auto chat_id = sorted_chat_ids_[index]; - const auto chat_it = chats_.find(chat_id); - const bool selected = index == selected_chat_index_; - const bool is_open = open_chat_id_ == chat_id; - if (selected) { - attron(A_BOLD | (focus_ == FocusPane::Chats ? A_REVERSE : A_NORMAL)); - } - if (is_open) { - attron(COLOR_PAIR(kColorPairOpenChat)); - } - - const bool has_chat = chat_it != chats_.end(); - const ChatInfo* chat = has_chat ? &chat_it->second : nullptr; - std::string line = (chat != nullptr && chat->unread_count > 0) ? "[U] " : "[ ] "; - line += is_open ? ">" : " "; - if (chat != nullptr) { - line += chat->title; - } else { - line += "Chat " + std::to_string(chat_id); - } - if (chat != nullptr && chat->unread_count > 0) { - line += " [" + std::to_string(chat->unread_count) + "]"; - } - if (chat != nullptr && !chat->last_message_preview.empty()) { - line += " - " + chat->last_message_preview; - } - if (static_cast(line.size()) > width - 2) { - line.resize(static_cast(width - 5)); - line += "..."; - } - mvprintw(y, 1, "%s", line.c_str()); - - if (is_open) { - attroff(COLOR_PAIR(kColorPairOpenChat)); - } - if (selected) { - attroff(A_BOLD | (focus_ == FocusPane::Chats ? A_REVERSE : A_NORMAL)); - } - } -} - -void App::draw_message_pane(int top, int height, int left, int width) { - for (int row = 0; row < height; ++row) { - mvhline(top + row, left, ' ', width); - } - - const auto chat_id = open_chat_id(); - if (!chat_id.has_value()) { - mvprintw(top, left + 1, "%s", authorized_ ? "Open a chat with Enter." : "Waiting for authorization."); - return; - } - - const auto chat_it = chats_.find(*chat_id); - if (chat_it == chats_.end()) { - mvprintw(top, left + 1, "Chat %lld unavailable.", static_cast(*chat_id)); - return; - } - const ChatInfo& chat = chat_it->second; - const auto truncate = [](std::string text, int max_width) { - if (max_width <= 0) { - return std::string(); - } - if (static_cast(text.size()) <= max_width) { - return text; - } - if (max_width <= 3) { - return text.substr(0, static_cast(max_width)); - } - text.resize(static_cast(max_width - 3)); - text += "..."; - return text; - }; - const std::string header = truncate(format_open_chat_header(chat), std::max(1, width - 2)); - attron(COLOR_PAIR(kColorPairOpenChat) | A_BOLD); - mvprintw(top, left + 1, "%s", header.c_str()); - attroff(COLOR_PAIR(kColorPairOpenChat) | A_BOLD); - - 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_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) { - current_day = message_day; - RenderLine separator; - separator.is_day_separator = true; - separator.body = message_day; - lines.push_back(separator); - } - - std::string state = "[in]"; - if (message.is_outgoing) { - state = - (chat.last_read_outbox_message_id != 0 && message.id <= chat.last_read_outbox_message_id) - ? "[read]" - : "[sent]"; - } else if (chat.last_read_inbox_message_id != 0 && message.id > chat.last_read_inbox_message_id) { - state = "[new]"; - } - - 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_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_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 - 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_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_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); - - for (std::size_t i = 1; i < wrapped.size(); ++i) { - RenderLine continuation; - continuation.body = wrapped[i]; - continuation.is_continuation = true; - lines.push_back(continuation); - } - } - - if (lines.empty()) { - mvprintw(top + 1, left + 1, "%s", "No messages loaded."); - return; - } - - 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; - } - 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())) { - break; - } - - const auto& line = lines[line_index]; - const int y = top + 1 + row; - if (line.is_day_separator) { - const int separator_width = std::max(0, width - 2); - std::string label = " " + line.body + " "; - if (static_cast(label.size()) > separator_width) { - label.resize(static_cast(separator_width)); - } - attron(COLOR_PAIR(kColorPairTimestamp) | A_DIM); - mvhline(y, left + 1, ACS_HLINE, separator_width); - mvaddnstr(y, left + 1 + std::max(0, (separator_width - static_cast(label.size())) / 2), - label.c_str(), separator_width); - attroff(COLOR_PAIR(kColorPairTimestamp) | A_DIM); - continue; - } - - int x = left + 1; - const int max_x = left + width; - if (!line.is_continuation) { - chtype state_attrs = A_DIM; - if (line.state == "[new]") { - state_attrs = COLOR_PAIR(kColorPairMarkdown) | A_BOLD; - } else if (line.state == "[read]") { - state_attrs = COLOR_PAIR(kColorPairOpenChat) | A_BOLD; - } else if (line.state == "[sent]") { - state_attrs = COLOR_PAIR(kColorPairTimestamp) | A_DIM; - } - 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, 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); - if (!line.meta.empty()) { - 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_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_message_body(y, x, max_x, line.body); - } - } -} - -std::string App::current_auth_label() const { - const std::string type = safe_string(authorization_state_, "@type"); - if (type == "authorizationStateWaitTdlibParameters") { - return "need API"; - } - if (type == "authorizationStateWaitPhoneNumber") { - return "need phone"; - } - if (type == "authorizationStateWaitCode") { - return "need code"; - } - if (type == "authorizationStateWaitEncryptionKey") { - return "unlock db"; - } - if (type == "authorizationStateWaitPassword") { - return "need password"; - } - if (type == "authorizationStateReady") { - return "ready"; - } - if (type.empty()) { - return "starting"; - } - return type; -} - -} // namespace telegram_tui +} // namespace telegram_tui diff --git a/src/app.h b/src/app.h index 7410d0b..b650366 100644 --- a/src/app.h +++ b/src/app.h @@ -16,69 +16,88 @@ namespace telegram_tui { class App { - public: + public: App(); int run(); - private: + private: + enum class ComposeCommandKind { + None, + Reply, + Edit, + Delete, + Forward, + }; + + struct ComposeCommand { + ComposeCommandKind kind = ComposeCommandKind::None; + std::int64_t message_id = 0; + std::vector message_ids; + std::string text; + }; + void init_curses(); void shutdown_curses(); bool process_updates(); - void handle_td_object(const json& object); + void handle_td_object(const json &object); void handle_authorization_state(); void send_tdlib_parameters(); void send_check_phone_number(); void persist_config(); void request_chat_details(std::int64_t chat_id); void set_open_chat(std::int64_t chat_id); - void open_forward_target_menu(std::int64_t source_chat_id, std::vector message_ids); + void open_forward_target_menu(std::int64_t source_chat_id, + std::vector message_ids); void start_input(InputMode mode, std::string prompt, bool hidden); 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, - std::int64_t target_chat_id); - void send_photo_message( - std::int64_t chat_id, - const std::string& photo_path, - const std::string& caption, - std::optional reply_to_message_id = std::nullopt); + void prepare_reply_input(std::int64_t chat_id, std::int64_t message_id, + std::string initial_text = {}); + 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 delete_message(std::int64_t chat_id, const std::vector &message_ids); + void forward_message(std::int64_t source_chat_id, + const std::vector &message_ids, + std::int64_t target_chat_id); + void send_photo_message(std::int64_t chat_id, const std::string &photo_path, + const std::string &caption, + std::optional reply_to_message_id = std::nullopt); bool preview_clipboard_photo_message( - std::int64_t chat_id, - const std::string& caption, - std::optional reply_to_message_id = std::nullopt); + std::int64_t chat_id, const std::string &caption, + std::optional reply_to_message_id = std::nullopt); void request_more_chats(); bool request_chat_history(std::int64_t chat_id, bool force); bool request_open_chat_history(bool force); - void sync_chat_ids_from_response(const json& response); + void sync_chat_ids_from_response(const json &response); void mark_chat_messages_as_read(std::int64_t chat_id); void mark_message_as_read(std::int64_t chat_id, std::int64_t message_id); - void update_user_status(std::int64_t user_id, const json& status); - void upsert_user(const json& user); - void upsert_basic_group(const json& basic_group); - void upsert_supergroup(const json& supergroup); - void upsert_chat(const json& chat_object); - void apply_chat_position(ChatInfo& chat, const json& position); + void update_user_status(std::int64_t user_id, const json &status); + void upsert_user(const json &user); + void upsert_basic_group(const json &basic_group); + void upsert_supergroup(const json &supergroup); + void upsert_chat(const json &chat_object); + void apply_chat_position(ChatInfo &chat, const json &position); void resort_chats(); void append_message(std::int64_t chat_id, MessageInfo message); void remove_message(std::int64_t chat_id, std::int64_t message_id); - void merge_history(std::int64_t chat_id, const json& messages); - void update_attachment_file(const json& file); + void merge_history(std::int64_t chat_id, const json &messages); + void update_attachment_file(const json &file); void start_reply_to_latest_message(); - void request_attachment_download(const AttachmentInfo& attachment, bool open_after_download); - void open_attachment(const AttachmentInfo& attachment); - void download_attachment(const AttachmentInfo& attachment); - void delete_attachment(const AttachmentInfo& attachment); - bool export_attachment_to_downloads(const AttachmentInfo& attachment); - bool play_video_attachment(const AttachmentInfo& attachment); - void refresh_attachment_viewer_content(const AttachmentInfo& attachment); - [[nodiscard]] std::vector attachment_animation_frames(const AttachmentInfo& attachment) const; + void request_attachment_download(const AttachmentInfo &attachment, + bool open_after_download); + void open_attachment(const AttachmentInfo &attachment); + void download_attachment(const AttachmentInfo &attachment); + void delete_attachment(const AttachmentInfo &attachment); + bool export_attachment_to_downloads(const AttachmentInfo &attachment); + bool play_video_attachment(const AttachmentInfo &attachment); + void refresh_attachment_viewer_content(const AttachmentInfo &attachment); + [[nodiscard]] std::vector + attachment_animation_frames(const AttachmentInfo &attachment) const; bool advance_attachment_animation(); - [[nodiscard]] std::string attachment_preview_path(const AttachmentInfo& attachment) const; - [[nodiscard]] bool has_inline_attachment_preview(const AttachmentInfo& attachment, int width, int height) const; + [[nodiscard]] std::string attachment_preview_path(const AttachmentInfo &attachment) const; + [[nodiscard]] bool has_inline_attachment_preview(const AttachmentInfo &attachment, + int width, int height) const; void clear_attachment_preview_graphics(); void render_attachment_preview_graphics(int top, int left, int width, int height); void reset_attachment_viewer_send_preview(); @@ -87,23 +106,31 @@ class App { 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; + [[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]] ComposeCommand parse_compose_command(const std::string &value) const; + [[nodiscard]] std::tuple + parse_single_message_command(const std::string &value, const char *prefix) const; + [[nodiscard]] std::optional> + parse_message_list_command(const std::string &value, const char *prefix) 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; [[nodiscard]] std::string user_status_label(std::int64_t user_id) const; - [[nodiscard]] std::string forward_origin_label(const json& forward_info) const; + [[nodiscard]] std::string forward_origin_label(const json &forward_info) const; [[nodiscard]] std::string via_bot_label(std::int64_t via_bot_user_id) const; - [[nodiscard]] std::string preview_message(const json& message) const; - [[nodiscard]] std::optional parse_attachment(const json& content) const; - [[nodiscard]] std::string content_to_text(const json& content, bool decorate) const; - [[nodiscard]] MessageInfo parse_message(const json& message) const; - [[nodiscard]] std::string format_open_chat_header(const ChatInfo& chat) const; + [[nodiscard]] std::string preview_message(const json &message) const; + [[nodiscard]] std::optional parse_attachment(const json &content) const; + [[nodiscard]] std::string content_to_text(const json &content, bool decorate) const; + [[nodiscard]] MessageInfo parse_message(const json &message) const; + [[nodiscard]] std::string format_open_chat_header(const ChatInfo &chat) const; void handle_key(int ch); void handle_wide_char(wint_t ch); void handle_input_key(int ch); @@ -154,6 +181,7 @@ class App { int attachment_action_index_ = 0; int attachment_viewer_scroll_ = 0; int forward_target_index_ = 0; + int help_menu_page_ = 0; std::int64_t open_chat_id_ = 0; std::int64_t tdlib_open_chat_id_ = 0; std::int64_t message_attachment_message_id_ = 0; @@ -178,6 +206,7 @@ class App { std::optional pending_attachment_open_; std::optional pending_attachment_download_; std::optional attachment_viewer_attachment_; + std::optional compose_reply_to_message_id_; std::optional attachment_viewer_send_reply_to_message_id_; std::chrono::steady_clock::time_point attachment_viewer_next_frame_at_{}; std::int64_t attachment_viewer_send_chat_id_ = 0; @@ -190,4 +219,4 @@ class App { std::vector forward_message_ids_; }; -} // namespace telegram_tui +} // namespace telegram_tui diff --git a/src/app_auth.cpp b/src/app_auth.cpp index 355d2b7..9cf7fa7 100644 --- a/src/app_auth.cpp +++ b/src/app_auth.cpp @@ -16,19 +16,19 @@ namespace telegram_tui { namespace { struct ClipboardImageType { - const char* mime = ""; - const char* extension = ""; + const char *mime = ""; + const char *extension = ""; }; constexpr std::array kClipboardImageTypes = {{ - {"image/png", ".png"}, - {"image/jpeg", ".jpg"}, - {"image/webp", ".webp"}, - {"image/bmp", ".bmp"}, - {"image/tiff", ".tiff"}, + {"image/png", ".png"}, + {"image/jpeg", ".jpg"}, + {"image/webp", ".webp"}, + {"image/bmp", ".bmp"}, + {"image/tiff", ".tiff"}, }}; -std::string shell_quote(const std::string& value) { +std::string shell_quote(const std::string &value) { std::string quoted = "'"; for (char ch : value) { if (ch == '\'') { @@ -41,8 +41,8 @@ std::string shell_quote(const std::string& value) { return quoted; } -std::string run_command_capture(const std::string& command) { - FILE* pipe = popen(command.c_str(), "r"); +std::string run_command_capture(const std::string &command) { + FILE *pipe = popen(command.c_str(), "r"); if (pipe == nullptr) { return {}; } @@ -58,19 +58,21 @@ std::string run_command_capture(const std::string& command) { return output; } -bool command_exists(const char* command) { - const std::string resolved = run_command_capture("command -v " + std::string(command) + " 2>/dev/null"); +bool command_exists(const char *command) { + const std::string resolved = + run_command_capture("command -v " + std::string(command) + " 2>/dev/null"); return !trim_copy(resolved).empty(); } -std::string clipboard_capture_command(const std::string& mime_type, const std::string& destination_path) { +std::string clipboard_capture_command(const std::string &mime_type, + const std::string &destination_path) { if (command_exists("wl-paste")) { return "wl-paste --no-newline --type " + shell_quote(mime_type) + " > " + - shell_quote(destination_path) + " 2>/dev/null"; + shell_quote(destination_path) + " 2>/dev/null"; } if (command_exists("xclip")) { return "xclip -selection clipboard -t " + shell_quote(mime_type) + " -o > " + - shell_quote(destination_path) + " 2>/dev/null"; + shell_quote(destination_path) + " 2>/dev/null"; } if (command_exists("pngpaste") && mime_type == "image/png") { return "pngpaste " + shell_quote(destination_path) + " >/dev/null 2>&1"; @@ -80,8 +82,9 @@ std::string clipboard_capture_command(const std::string& mime_type, const std::s std::optional detect_clipboard_image_type() { if (command_exists("wl-paste")) { - const std::string types_output = run_command_capture("wl-paste --list-types 2>/dev/null"); - for (const auto& type : kClipboardImageTypes) { + const std::string types_output = + run_command_capture("wl-paste --list-types 2>/dev/null"); + for (const auto &type : kClipboardImageTypes) { if (types_output.find(type.mime) != std::string::npos) { return type; } @@ -89,8 +92,8 @@ std::optional detect_clipboard_image_type() { } if (command_exists("xclip")) { const std::string targets_output = - run_command_capture("xclip -selection clipboard -t TARGETS -o 2>/dev/null"); - for (const auto& type : kClipboardImageTypes) { + run_command_capture("xclip -selection clipboard -t TARGETS -o 2>/dev/null"); + for (const auto &type : kClipboardImageTypes) { if (targets_output.find(type.mime) != std::string::npos) { return type; } @@ -102,9 +105,9 @@ std::optional detect_clipboard_image_type() { return std::nullopt; } -std::optional parse_clipboard_compose_command(const std::string& value) { - static constexpr std::array kPrefixes = {">paste", ">clip", ">screenshot"}; - for (const char* prefix : kPrefixes) { +std::optional parse_clipboard_compose_command(const std::string &value) { + static constexpr std::array kPrefixes = {">paste", ">clip", ">screenshot"}; + for (const char *prefix : kPrefixes) { if (value == prefix) { return std::string(); } @@ -116,37 +119,23 @@ std::optional parse_clipboard_compose_command(const std::string& va return std::nullopt; } -std::optional strip_forward_prefix(const std::string& value) { - static constexpr std::array kPrefixes = {">f", ">fw", ">forward"}; - for (const char* prefix : kPrefixes) { - if (value == prefix) { - return std::string(); - } - const std::string with_space = std::string(prefix) + " "; - if (value.rfind(with_space, 0) == 0) { - return trim_copy(value.substr(with_space.size())); - } - } - return std::nullopt; -} - -json markdown_formatted_text(TdClient& td, const std::string& text) { +json markdown_formatted_text(TdClient &td, const std::string &text) { json formatted_text = { - {"@type", "formattedText"}, - {"text", text}, - {"entities", json::array()}, + {"@type", "formattedText"}, + {"text", text}, + {"entities", json::array()}, }; if (const auto parsed = td.execute({ - {"@type", "parseMarkdown"}, - {"text", formatted_text}, - }); - parsed.has_value() && safe_string(*parsed, "@type") == "formattedText") { + {"@type", "parseMarkdown"}, + {"text", formatted_text}, + }); + parsed.has_value() && safe_string(*parsed, "@type") == "formattedText") { formatted_text = *parsed; } return formatted_text; } -} // namespace +} // namespace void App::handle_authorization_state() { const std::string type = safe_string(authorization_state_, "@type"); @@ -175,8 +164,8 @@ void App::handle_authorization_state() { } if (type == "authorizationStateWaitEncryptionKey") { td_.send({ - {"@type", "checkDatabaseEncryptionKey"}, - {"encryption_key", ""}, + {"@type", "checkDatabaseEncryptionKey"}, + {"encryption_key", ""}, }); status_line_ = "Unlocking local database..."; return; @@ -206,8 +195,8 @@ void App::handle_authorization_state() { input_mode_ = InputMode::None; status_line_ = "Authorized. Loading chats."; td_.send({ - {"@type", "getMe"}, - {"@extra", "getMe"}, + {"@type", "getMe"}, + {"@extra", "getMe"}, }); request_more_chats(); if (!open_chat_id().has_value()) { @@ -239,32 +228,32 @@ void App::send_tdlib_parameters() { status_line_ = "Sending TDLib parameters..."; td_.send({ - {"@type", "setTdlibParameters"}, - {"use_test_dc", use_test_dc_}, - {"database_directory", database_dir_.string()}, - {"files_directory", files_dir_.string()}, - {"database_encryption_key", ""}, - {"use_file_database", true}, - {"use_chat_info_database", true}, - {"use_message_database", true}, - {"use_secret_chats", true}, - {"api_id", std::stoi(api_id_)}, - {"api_hash", api_hash_}, - {"system_language_code", "en"}, - {"device_model", "shinoa"}, - {"system_version", "Linux"}, - {"application_version", "0.1.0"}, - {"enable_storage_optimizer", true}, - {"ignore_file_names", false}, + {"@type", "setTdlibParameters"}, + {"use_test_dc", use_test_dc_}, + {"database_directory", database_dir_.string()}, + {"files_directory", files_dir_.string()}, + {"database_encryption_key", ""}, + {"use_file_database", true}, + {"use_chat_info_database", true}, + {"use_message_database", true}, + {"use_secret_chats", true}, + {"api_id", std::stoi(api_id_)}, + {"api_hash", api_hash_}, + {"system_language_code", "en"}, + {"device_model", "shinoa"}, + {"system_version", "Linux"}, + {"application_version", "0.1.0"}, + {"enable_storage_optimizer", true}, + {"ignore_file_names", false}, }); } void App::send_check_phone_number() { status_line_ = "Requesting login code..."; td_.send({ - {"@type", "setAuthenticationPhoneNumber"}, - {"phone_number", phone_number_}, - {"settings", {{"@type", "phoneNumberAuthenticationSettings"}}}, + {"@type", "setAuthenticationPhoneNumber"}, + {"phone_number", phone_number_}, + {"settings", {{"@type", "phoneNumberAuthenticationSettings"}}}, }); } @@ -280,15 +269,15 @@ void App::request_chat_details(std::int64_t chat_id) { return; } - ChatInfo& chat = chat_it->second; + ChatInfo &chat = chat_it->second; if (chat.details_requested) { return; } if (chat.private_user_id != 0) { td_.send({ - {"@type", "getUser"}, - {"user_id", chat.private_user_id}, + {"@type", "getUser"}, + {"user_id", chat.private_user_id}, }); chat.details_requested = true; return; @@ -296,8 +285,8 @@ void App::request_chat_details(std::int64_t chat_id) { if (chat.basic_group_id != 0) { td_.send({ - {"@type", "getBasicGroup"}, - {"basic_group_id", chat.basic_group_id}, + {"@type", "getBasicGroup"}, + {"basic_group_id", chat.basic_group_id}, }); chat.details_requested = true; return; @@ -305,8 +294,8 @@ void App::request_chat_details(std::int64_t chat_id) { if (chat.supergroup_id != 0) { td_.send({ - {"@type", "getSupergroup"}, - {"supergroup_id", chat.supergroup_id}, + {"@type", "getSupergroup"}, + {"supergroup_id", chat.supergroup_id}, }); chat.details_requested = true; } @@ -329,6 +318,7 @@ void App::clear_input() { input_buffer_.clear(); input_cursor_ = 0; input_hidden_ = false; + compose_reply_to_message_id_.reset(); curs_set(0); } @@ -344,6 +334,7 @@ void App::submit_input() { status_line_ = "Telegram API ID must be numeric."; return; } + const auto pending_reply_to_message_id = compose_reply_to_message_id_; clear_input(); if (mode == InputMode::Compose) { @@ -352,36 +343,72 @@ void App::submit_input() { status_line_ = "Open chat first."; return; } - if (const auto message_ids = parse_forward_command(value); message_ids.has_value()) { - open_forward_target_menu(*chat_id, *message_ids); + const ComposeCommand command = parse_compose_command(value); + if (command.kind == ComposeCommandKind::Forward) { + if (command.message_ids.empty()) { + status_line_ = + "Forward command needs one or more valid message refs."; + return; + } + open_forward_target_menu(*chat_id, command.message_ids); return; } - const auto [is_edit, edit_message_id, edit_text] = parse_edit_command(value); - if (is_edit) { - if (edit_message_id == 0) { + if (command.kind == ComposeCommandKind::Delete) { + if (command.message_ids.empty()) { + status_line_ = "Delete command needs a valid message ref."; + return; + } + const auto chat_it = chats_.find(*chat_id); + if (chat_it != chats_.end()) { + for (const std::int64_t delete_message_id : command.message_ids) { + const auto message_it = std::find_if( + chat_it->second.messages.begin(), + chat_it->second.messages.end(), + [&](const MessageInfo &message) { + return message.id == delete_message_id; + }); + if (message_it != chat_it->second.messages.end() && + !message_it->is_outgoing) { + status_line_ = "Can delete only your own messages."; + return; + } + } + } + delete_message(*chat_id, command.message_ids); + return; + } + if (command.kind == ComposeCommandKind::Edit) { + if (command.message_id == 0) { status_line_ = "Edit command needs a valid message ref."; return; } - if (edit_text.empty()) { + if (command.text.empty()) { status_line_ = "Edit text cannot be empty."; return; } - edit_message(*chat_id, edit_message_id, edit_text); + edit_message(*chat_id, command.message_id, command.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( - *chat_id, - *clipboard_caption, - is_reply ? std::optional(reply_to_message_id) : std::nullopt); + std::string text = value; + if (command.kind == ComposeCommandKind::Reply) { + if (command.message_id == 0) { + status_line_ = "Reply command needs a valid message ref."; + return; + } + prepare_reply_input(*chat_id, command.message_id, command.text); + return; + } + if (const auto clipboard_caption = parse_clipboard_compose_command(text); + clipboard_caption.has_value()) { + preview_clipboard_photo_message(*chat_id, *clipboard_caption, + pending_reply_to_message_id); return; } if (text.empty()) { - status_line_ = "Reply text cannot be empty."; + status_line_ = "Message text cannot be empty."; return; } - send_message(*chat_id, text, is_reply ? std::optional(reply_to_message_id) : std::nullopt); + send_message(*chat_id, text, pending_reply_to_message_id); return; } if (mode == InputMode::ApiId) { @@ -407,16 +434,16 @@ void App::submit_input() { } if (mode == InputMode::AuthCode) { td_.send({ - {"@type", "checkAuthenticationCode"}, - {"code", value}, + {"@type", "checkAuthenticationCode"}, + {"code", value}, }); status_line_ = "Submitting code..."; return; } if (mode == InputMode::Password) { td_.send({ - {"@type", "checkAuthenticationPassword"}, - {"password", value}, + {"@type", "checkAuthenticationPassword"}, + {"password", value}, }); status_line_ = "Submitting password..."; return; @@ -430,119 +457,137 @@ void App::submit_input() { if (mode == InputMode::LastName) { const std::string last_name = value == "-" ? "" : value; td_.send({ - {"@type", "registerUser"}, - {"first_name", pending_first_name_}, - {"last_name", last_name}, + {"@type", "registerUser"}, + {"first_name", pending_first_name_}, + {"last_name", last_name}, }); pending_first_name_.clear(); status_line_ = "Registering account..."; } } -void App::send_message( - std::int64_t chat_id, - const std::string& text, - std::optional reply_to_message_id) { +void App::prepare_reply_input(std::int64_t chat_id, std::int64_t message_id, + std::string initial_text) { + const auto chat_it = chats_.find(chat_id); + if (chat_it == chats_.end()) { + status_line_ = "Chat unavailable."; + return; + } + + compose_reply_to_message_id_ = message_id; + input_buffer_ = std::move(initial_text); + start_input(InputMode::Compose, "Reply " + format_message_ref(chat_it->second, message_id), + false); + status_line_ = "Reply prepared."; +} + +void App::send_message(std::int64_t chat_id, const std::string &text, + std::optional reply_to_message_id) { json formatted_text = markdown_formatted_text(td_, text); json request = { - {"@type", "sendMessage"}, - {"chat_id", chat_id}, - {"input_message_content", - { - {"@type", "inputMessageText"}, - {"text", formatted_text}, - {"clear_draft", true}, - }}, + {"@type", "sendMessage"}, + {"chat_id", chat_id}, + {"input_message_content", + { + {"@type", "inputMessageText"}, + {"text", formatted_text}, + {"clear_draft", true}, + }}, }; if (reply_to_message_id.has_value()) { request["reply_to"] = { - {"@type", "inputMessageReplyToMessage"}, - {"message_id", *reply_to_message_id}, + {"@type", "inputMessageReplyToMessage"}, + {"message_id", *reply_to_message_id}, }; } td_.send(request); status_line_ = "Message queued."; } -void App::edit_message(std::int64_t chat_id, std::int64_t message_id, const std::string& text) { +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}, - }}, + {"@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, - std::int64_t target_chat_id) { +void App::delete_message(std::int64_t chat_id, const std::vector &message_ids) { td_.send({ - {"@type", "forwardMessages"}, - {"chat_id", target_chat_id}, - {"from_chat_id", source_chat_id}, - {"message_ids", message_ids}, - {"send_copy", false}, - {"remove_caption", false}, + {"@type", "deleteMessages"}, + {"chat_id", chat_id}, + {"message_ids", message_ids}, + {"revoke", true}, + }); + status_line_ = message_ids.size() == 1 ? "Deleting message..." : "Deleting messages..."; +} + +void App::forward_message(std::int64_t source_chat_id, const std::vector &message_ids, + std::int64_t target_chat_id) { + td_.send({ + {"@type", "forwardMessages"}, + {"chat_id", target_chat_id}, + {"from_chat_id", source_chat_id}, + {"message_ids", message_ids}, + {"send_copy", false}, + {"remove_caption", false}, }); status_line_ = message_ids.size() == 1 ? "Forwarding message..." : "Forwarding messages..."; } -void App::send_photo_message( - std::int64_t chat_id, - const std::string& photo_path, - const std::string& caption, - std::optional reply_to_message_id) { +void App::send_photo_message(std::int64_t chat_id, const std::string &photo_path, + const std::string &caption, + std::optional reply_to_message_id) { json request = { - {"@type", "sendMessage"}, - {"chat_id", chat_id}, - {"input_message_content", - { - {"@type", "inputMessagePhoto"}, - {"photo", {{"@type", "inputFileLocal"}, {"path", photo_path}}}, - {"thumbnail", nullptr}, - {"video", nullptr}, - {"added_sticker_file_ids", json::array()}, - {"width", 0}, - {"height", 0}, - {"caption", markdown_formatted_text(td_, caption)}, - {"show_caption_above_media", false}, - {"self_destruct_type", nullptr}, - {"has_spoiler", false}, - }}, + {"@type", "sendMessage"}, + {"chat_id", chat_id}, + {"input_message_content", + { + {"@type", "inputMessagePhoto"}, + {"photo", {{"@type", "inputFileLocal"}, {"path", photo_path}}}, + {"thumbnail", nullptr}, + {"video", nullptr}, + {"added_sticker_file_ids", json::array()}, + {"width", 0}, + {"height", 0}, + {"caption", markdown_formatted_text(td_, caption)}, + {"show_caption_above_media", false}, + {"self_destruct_type", nullptr}, + {"has_spoiler", false}, + }}, }; if (reply_to_message_id.has_value()) { request["reply_to"] = { - {"@type", "inputMessageReplyToMessage"}, - {"message_id", *reply_to_message_id}, + {"@type", "inputMessageReplyToMessage"}, + {"message_id", *reply_to_message_id}, }; } td_.send(request); status_line_ = "Photo queued."; } -bool App::preview_clipboard_photo_message( - std::int64_t chat_id, - const std::string& caption, - std::optional reply_to_message_id) { +bool App::preview_clipboard_photo_message(std::int64_t chat_id, const std::string &caption, + std::optional reply_to_message_id) { const auto clipboard_type = detect_clipboard_image_type(); if (!clipboard_type.has_value()) { - status_line_ = "Clipboard doesn't contain an image, or no clipboard tool is available."; + status_line_ = + "Clipboard doesn't contain an image, or no clipboard tool is available."; return false; } const std::filesystem::path clipboard_dir = files_dir_ / "clipboard"; try { std::filesystem::create_directories(clipboard_dir); - } catch (const std::exception& error) { + } catch (const std::exception &error) { status_line_ = std::string("Failed to prepare clipboard cache: ") + error.what(); return false; } @@ -550,8 +595,8 @@ bool App::preview_clipboard_photo_message( std::filesystem::path image_path; for (std::uint64_t index = 0; index < 1024; ++index) { const std::filesystem::path candidate = - clipboard_dir / ("clipboard-" + std::to_string(std::time(nullptr)) + "-" + std::to_string(index) + - clipboard_type->extension); + clipboard_dir / ("clipboard-" + std::to_string(std::time(nullptr)) + "-" + + std::to_string(index) + clipboard_type->extension); if (!std::filesystem::exists(candidate)) { image_path = candidate; break; @@ -562,8 +607,10 @@ bool App::preview_clipboard_photo_message( return false; } - const std::string command = clipboard_capture_command(clipboard_type->mime, image_path.string()); - if (command.empty() || std::system(command.c_str()) != 0 || !std::filesystem::exists(image_path)) { + const std::string command = + clipboard_capture_command(clipboard_type->mime, image_path.string()); + if (command.empty() || std::system(command.c_str()) != 0 || + !std::filesystem::exists(image_path)) { status_line_ = "Failed to read image data from the clipboard."; return false; } @@ -574,7 +621,7 @@ bool App::preview_clipboard_photo_message( status_line_ = "Clipboard image is empty."; return false; } - } catch (const std::exception&) { + } catch (const std::exception &) { status_line_ = "Clipboard image couldn't be validated."; return false; } @@ -600,99 +647,9 @@ bool App::preview_clipboard_photo_message( refresh_attachment_viewer_content(attachment); attachment_viewer_open_ = true; status_line_ = caption.empty() - ? "Clipboard image preview. Press Enter to send." - : "Clipboard image preview with caption. Press Enter to send."; + ? "Clipboard image preview. Press Enter to send." + : "Clipboard image preview with caption. Press Enter to send."; return true; } -std::optional> App::parse_forward_command(const std::string& value) const { - const auto remainder = strip_forward_prefix(value); - if (!remainder.has_value()) { - return std::nullopt; - } - - const auto chat_id = open_chat_id(); - if (!chat_id.has_value()) { - return std::vector{}; - } - const auto chat_it = chats_.find(*chat_id); - if (chat_it == chats_.end()) { - return std::vector{}; - } - - const ChatInfo& chat = chat_it->second; - if (remainder->empty()) { - return std::vector{}; - } - - auto resolve_message_ref = [&](std::int64_t ref) -> std::int64_t { - 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::string normalized = *remainder; - for (char& ch : normalized) { - if (ch == ',') { - ch = ' '; - } - } - - std::set unique_message_ids; - std::stringstream stream(normalized); - std::string token; - while (stream >> token) { - const auto dash = token.find('-'); - if (dash != std::string::npos) { - const std::string start_text = token.substr(0, dash); - const std::string end_text = token.substr(dash + 1); - if (!is_decimal_number(start_text) || !is_decimal_number(end_text)) { - return std::vector{}; - } - std::int64_t start_value = 0; - std::int64_t end_value = 0; - try { - start_value = std::stoll(start_text); - end_value = std::stoll(end_text); - } catch (...) { - return std::vector{}; - } - if (start_value > end_value) { - std::swap(start_value, end_value); - } - for (std::int64_t ref = start_value; ref <= end_value; ++ref) { - const std::int64_t message_id = resolve_message_ref(ref); - if (message_id == 0) { - return std::vector{}; - } - unique_message_ids.insert(message_id); - } - continue; - } - - if (!is_decimal_number(token)) { - return std::vector{}; - } - std::int64_t parsed_value = 0; - try { - parsed_value = std::stoll(token); - } catch (...) { - return std::vector{}; - } - const std::int64_t message_id = resolve_message_ref(parsed_value); - if (message_id == 0) { - return std::vector{}; - } - unique_message_ids.insert(message_id); - } - - return std::vector(unique_message_ids.begin(), unique_message_ids.end()); -} - -} // namespace telegram_tui +} // namespace telegram_tui diff --git a/src/app_chats.cpp b/src/app_chats.cpp index 05ed4bc..86cc297 100644 --- a/src/app_chats.cpp +++ b/src/app_chats.cpp @@ -2,15 +2,19 @@ #include +#include + +#include "app_ui.h" #include "util.h" namespace telegram_tui { namespace { -std::string join_with_separator_local(const std::vector& parts, const char* separator) { +std::string join_with_separator_local(const std::vector &parts, + const char *separator) { std::string joined; - for (const auto& part : parts) { + for (const auto &part : parts) { if (part.empty()) { continue; } @@ -22,7 +26,7 @@ std::string join_with_separator_local(const std::vector& parts, con return joined; } -std::string format_user_status(const json& status) { +std::string format_user_status(const json &status) { const std::string type = safe_string(status, "@type"); if (type == "userStatusOnline") { return "online"; @@ -43,11 +47,11 @@ std::string format_user_status(const json& status) { return {}; } -std::string primary_username(const json& object) { +std::string primary_username(const json &object) { const json usernames = object.value("usernames", json::object()); const json active_usernames = usernames.value("active_usernames", json::array()); if (active_usernames.is_array()) { - for (const auto& username : active_usernames) { + for (const auto &username : active_usernames) { if (username.is_string()) { const std::string value = username.get(); if (!value.empty()) { @@ -59,19 +63,19 @@ std::string primary_username(const json& object) { return safe_string(object, "username"); } -} // namespace +} // namespace -void App::update_user_status(std::int64_t user_id, const json& status) { - UserInfo& info = users_[user_id]; +void App::update_user_status(std::int64_t user_id, const json &status) { + UserInfo &info = users_[user_id]; info.id = user_id; info.status = format_user_status(status); } -void App::upsert_user(const json& user) { +void App::upsert_user(const json &user) { if (user.is_null()) { return; } - UserInfo& info = users_[safe_i64(user, "id")]; + UserInfo &info = users_[safe_i64(user, "id")]; info.id = safe_i64(user, "id"); info.first_name = safe_string(user, "first_name"); info.last_name = safe_string(user, "last_name"); @@ -79,13 +83,13 @@ void App::upsert_user(const json& user) { info.status = format_user_status(user.value("status", json::object())); } -void App::upsert_basic_group(const json& basic_group) { +void App::upsert_basic_group(const json &basic_group) { if (basic_group.is_null()) { return; } const std::int64_t basic_group_id = safe_i64(basic_group, "id"); const std::int32_t member_count = safe_i32(basic_group, "member_count"); - for (auto& [chat_id, chat] : chats_) { + for (auto &[chat_id, chat] : chats_) { if (chat.basic_group_id != basic_group_id) { continue; } @@ -96,7 +100,7 @@ void App::upsert_basic_group(const json& basic_group) { } } -void App::upsert_supergroup(const json& supergroup) { +void App::upsert_supergroup(const json &supergroup) { if (supergroup.is_null()) { return; } @@ -104,7 +108,7 @@ void App::upsert_supergroup(const json& supergroup) { const std::int32_t member_count = safe_i32(supergroup, "member_count"); const bool is_channel = supergroup.value("is_channel", false); const std::string username = primary_username(supergroup); - for (auto& [chat_id, chat] : chats_) { + for (auto &[chat_id, chat] : chats_) { if (chat.supergroup_id != supergroup_id) { continue; } @@ -116,13 +120,13 @@ void App::upsert_supergroup(const json& supergroup) { } } -void App::upsert_chat(const json& chat_object) { +void App::upsert_chat(const json &chat_object) { if (chat_object.is_null()) { return; } const std::int64_t chat_id = safe_i64(chat_object, "id"); - ChatInfo& chat = chats_[chat_id]; + ChatInfo &chat = chats_[chat_id]; chat.id = chat_id; if (chat_object.contains("title")) { chat.title = safe_string(chat_object, "title"); @@ -156,8 +160,8 @@ void App::upsert_chat(const json& chat_object) { chat.username.clear(); } if (chat.private_user_id != previous_private_user_id || - chat.basic_group_id != previous_basic_group_id || - chat.supergroup_id != previous_supergroup_id) { + chat.basic_group_id != previous_basic_group_id || + chat.supergroup_id != previous_supergroup_id) { chat.details_requested = false; } chat.unread_count = safe_i32(chat_object, "unread_count"); @@ -169,7 +173,7 @@ void App::upsert_chat(const json& chat_object) { if (chat_object.contains("positions") && chat_object.at("positions").is_array()) { chat.in_main_list = false; chat.main_order = 0; - for (const auto& position : chat_object.at("positions")) { + for (const auto &position : chat_object.at("positions")) { apply_chat_position(chat, position); } } @@ -179,7 +183,7 @@ void App::upsert_chat(const json& chat_object) { resort_chats(); } -void App::apply_chat_position(ChatInfo& chat, const json& position) { +void App::apply_chat_position(ChatInfo &chat, const json &position) { if (!position.is_object()) { return; } @@ -194,43 +198,58 @@ void App::apply_chat_position(ChatInfo& chat, const json& position) { void App::resort_chats() { if (sorted_chat_ids_.empty()) { - for (const auto& [chat_id, chat] : chats_) { + for (const auto &[chat_id, chat] : chats_) { if (chat.in_main_list) { sorted_chat_ids_.push_back(chat_id); } } } else { - for (const auto& [chat_id, chat] : chats_) { + for (const auto &[chat_id, chat] : chats_) { if (!chat.in_main_list) { continue; } - if (std::find(sorted_chat_ids_.begin(), sorted_chat_ids_.end(), chat_id) == sorted_chat_ids_.end()) { + if (std::find(sorted_chat_ids_.begin(), sorted_chat_ids_.end(), chat_id) == + sorted_chat_ids_.end()) { sorted_chat_ids_.push_back(chat_id); } } } std::stable_sort(sorted_chat_ids_.begin(), sorted_chat_ids_.end(), - [&](std::int64_t lhs, std::int64_t rhs) { - const auto left_it = chats_.find(lhs); - const auto right_it = chats_.find(rhs); - const bool left_has_order = left_it != chats_.end() && left_it->second.in_main_list && left_it->second.main_order > 0; - const bool right_has_order = right_it != chats_.end() && right_it->second.in_main_list && right_it->second.main_order > 0; - if (left_has_order != right_has_order) { - return left_has_order; - } - if (left_has_order && right_has_order && left_it->second.main_order != right_it->second.main_order) { - return left_it->second.main_order > right_it->second.main_order; - } - return false; - }); + [&](std::int64_t lhs, std::int64_t rhs) { + const auto left_it = chats_.find(lhs); + const auto right_it = chats_.find(rhs); + const bool left_has_order = left_it != chats_.end() && + left_it->second.in_main_list && + left_it->second.main_order > 0; + const bool right_has_order = right_it != chats_.end() && + right_it->second.in_main_list && + right_it->second.main_order > 0; + if (left_has_order != right_has_order) { + return left_has_order; + } + if (left_has_order && right_has_order && + left_it->second.main_order != right_it->second.main_order) { + return left_it->second.main_order > + right_it->second.main_order; + } + return false; + }); if (selected_chat_index_ >= static_cast(sorted_chat_ids_.size())) { selected_chat_index_ = std::max(0, static_cast(sorted_chat_ids_.size()) - 1); } } -std::string App::format_open_chat_header(const ChatInfo& chat) const { +std::string App::user_status_label(std::int64_t user_id) const { + const auto user_it = users_.find(user_id); + if (user_it == users_.end()) { + return {}; + } + return user_it->second.status; +} + +std::string App::format_open_chat_header(const ChatInfo &chat) const { std::vector parts; if (!chat.title.empty()) { parts.push_back(chat.title); @@ -256,8 +275,8 @@ std::string App::format_open_chat_header(const ChatInfo& chat) const { parts.push_back("@" + chat.username); } if (chat.has_member_count) { - parts.push_back( - std::to_string(chat.member_count) + " " + (chat.is_channel ? "subscribers" : "members")); + parts.push_back(std::to_string(chat.member_count) + " " + + (chat.is_channel ? "subscribers" : "members")); } if (!chat.is_channel && chat.has_online_member_count) { parts.push_back(std::to_string(chat.online_member_count) + " online"); @@ -266,4 +285,60 @@ std::string App::format_open_chat_header(const ChatInfo& chat) const { return join_with_separator_local(parts, " | "); } -} // namespace telegram_tui +void App::draw_chat_pane(int top, int height, int width) { + const int visible_rows = std::max(1, height); + int first_index = 0; + if (selected_chat_index_ >= visible_rows) { + first_index = selected_chat_index_ - visible_rows + 1; + } + + for (int row = 0; row < visible_rows; ++row) { + const int index = first_index + row; + const int y = top + row; + mvhline(y, 0, ' ', width); + if (index >= static_cast(sorted_chat_ids_.size())) { + continue; + } + + const auto chat_id = sorted_chat_ids_[index]; + const auto chat_it = chats_.find(chat_id); + const bool selected = index == selected_chat_index_; + const bool is_open = open_chat_id_ == chat_id; + if (selected) { + attron(A_BOLD | (focus_ == FocusPane::Chats ? A_REVERSE : A_NORMAL)); + } + if (is_open) { + attron(COLOR_PAIR(kColorPairOpenChat)); + } + + const bool has_chat = chat_it != chats_.end(); + const ChatInfo *chat = has_chat ? &chat_it->second : nullptr; + std::string line = (chat != nullptr && chat->unread_count > 0) ? "[U] " : "[ ] "; + line += is_open ? ">" : " "; + if (chat != nullptr) { + line += chat->title; + } else { + line += "Chat " + std::to_string(chat_id); + } + if (chat != nullptr && chat->unread_count > 0) { + line += " [" + std::to_string(chat->unread_count) + "]"; + } + if (chat != nullptr && !chat->last_message_preview.empty()) { + line += " - " + chat->last_message_preview; + } + if (static_cast(line.size()) > width - 2) { + line.resize(static_cast(width - 5)); + line += "..."; + } + mvprintw(y, 1, "%s", line.c_str()); + + if (is_open) { + attroff(COLOR_PAIR(kColorPairOpenChat)); + } + if (selected) { + attroff(A_BOLD | (focus_ == FocusPane::Chats ? A_REVERSE : A_NORMAL)); + } + } +} + +} // namespace telegram_tui diff --git a/src/app_commands.cpp b/src/app_commands.cpp new file mode 100644 index 0000000..d3926e7 --- /dev/null +++ b/src/app_commands.cpp @@ -0,0 +1,180 @@ +#include "app.h" + +#include +#include +#include +#include + +#include "util.h" + +namespace telegram_tui { + +namespace { + +bool starts_with_at(const std::string &text, std::size_t index, const char *prefix) { + const std::size_t prefix_size = std::char_traits::length(prefix); + return text.compare(index, prefix_size, prefix) == 0; +} + +} // namespace + +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_single_message_command(const std::string &value, const char *prefix) const { + if (!starts_with_at(value, 0, prefix)) { + return {false, 0, value}; + } + + const std::size_t prefix_size = std::char_traits::length(prefix); + std::size_t id_start = prefix_size; + 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)}; + } + + const ChatInfo &chat = chat_it->second; + return {true, resolve_message_ref(chat, parsed_value), value.substr(id_end)}; +} + +std::optional> App::parse_message_list_command(const std::string &value, + const char *prefix) const { + if (!starts_with_at(value, 0, prefix)) { + return std::nullopt; + } + + const std::size_t prefix_size = std::char_traits::length(prefix); + std::string remainder = value.substr(prefix_size); + remainder = trim_copy(std::move(remainder)); + if (remainder.empty()) { + return std::vector{}; + } + + const auto chat_id = open_chat_id(); + if (!chat_id.has_value()) { + return std::vector{}; + } + const auto chat_it = chats_.find(*chat_id); + if (chat_it == chats_.end()) { + return std::vector{}; + } + + const ChatInfo &chat = chat_it->second; + for (char &ch : remainder) { + if (ch == ',') { + ch = ' '; + } + } + + std::set unique_message_ids; + std::stringstream stream(remainder); + std::string token; + while (stream >> token) { + const auto dash = token.find('-'); + if (dash != std::string::npos) { + const std::string start_text = token.substr(0, dash); + const std::string end_text = token.substr(dash + 1); + if (!is_decimal_number(start_text) || !is_decimal_number(end_text)) { + return std::vector{}; + } + std::int64_t start_value = 0; + std::int64_t end_value = 0; + try { + start_value = std::stoll(start_text); + end_value = std::stoll(end_text); + } catch (...) { + return std::vector{}; + } + if (start_value > end_value) { + std::swap(start_value, end_value); + } + for (std::int64_t ref = start_value; ref <= end_value; ++ref) { + const std::int64_t message_id = resolve_message_ref(chat, ref); + if (message_id == 0) { + return std::vector{}; + } + unique_message_ids.insert(message_id); + } + continue; + } + + if (!is_decimal_number(token)) { + return std::vector{}; + } + std::int64_t parsed_value = 0; + try { + parsed_value = std::stoll(token); + } catch (...) { + return std::vector{}; + } + const std::int64_t message_id = resolve_message_ref(chat, parsed_value); + if (message_id == 0) { + return std::vector{}; + } + unique_message_ids.insert(message_id); + } + + return std::vector(unique_message_ids.begin(), unique_message_ids.end()); +} + +App::ComposeCommand App::parse_compose_command(const std::string &value) const { + if (const auto message_ids = parse_message_list_command(value, ">f "); + message_ids.has_value()) { + return ComposeCommand{ComposeCommandKind::Forward, 0, *message_ids, {}}; + } + + if (const auto message_ids = parse_message_list_command(value, ">d "); + message_ids.has_value()) { + return ComposeCommand{ComposeCommandKind::Delete, 0, *message_ids, {}}; + } + + if (const auto [matched, message_id, text] = parse_single_message_command(value, ">e "); + matched) { + return ComposeCommand{ComposeCommandKind::Edit, message_id, {}, text}; + } + + if (const auto [matched, message_id, text] = parse_single_message_command(value, ">r "); + matched) { + return ComposeCommand{ComposeCommandKind::Reply, message_id, {}, text}; + } + + return ComposeCommand{}; +} + +} // namespace telegram_tui diff --git a/src/app_help.cpp b/src/app_help.cpp new file mode 100644 index 0000000..ef318fa --- /dev/null +++ b/src/app_help.cpp @@ -0,0 +1,174 @@ +#include "app.h" + +#include +#include + +#include + +#include "util.h" + +namespace telegram_tui { + +namespace { + +const std::vector kShinoaBanner = { + " ____ _ _ ", " / ___|| |__ (_)_ __ ___ __ _ ", + " \\___ \\| '_ \\| | '_ \\ / _ \\ / _` |", " ___) | | | | | | | | (_) | (_| |", + " |____/|_| |_|_|_| |_|\\___/ \\__,_|", +}; + +} // namespace + +void App::handle_help_menu_key(int ch) { + switch (ch) { + case 27: + case 'h': + case '?': + case KEY_F(1): + help_menu_open_ = false; + status_line_ = "Closed help."; + return; + case '1': + help_menu_page_ = 0; + status_line_ = "Help."; + return; + case '2': + help_menu_page_ = 1; + status_line_ = "Settings."; + return; + case '3': + help_menu_page_ = 2; + status_line_ = "About."; + return; + case KEY_LEFT: + help_menu_page_ = (help_menu_page_ + 2) % 3; + return; + case KEY_RIGHT: + help_menu_page_ = (help_menu_page_ + 1) % 3; + return; + case 't': + if (help_menu_page_ != 1) { + return; + } + auto_reload_chat_history_ = !auto_reload_chat_history_; + persist_config(); + status_line_ = auto_reload_chat_history_ ? "Auto-reload history enabled." + : "Auto-reload history disabled."; + return; + default: + return; + } +} + +void App::draw_help_menu(int height, int width) { + const int menu_width = std::min(width - 4, 92); + const int menu_height = std::min(height - 4, 24); + const int top = std::max(1, (height - menu_height) / 2); + const int left = std::max(1, (width - menu_width) / 2); + + WINDOW *window = newwin(menu_height, menu_width, top, left); + if (window == nullptr) { + return; + } + + box(window, 0, 0); + const char *title = + help_menu_page_ == 0 ? " Help " : (help_menu_page_ == 1 ? " Settings " : " About "); + mvwprintw(window, 0, 2, "%s", title); + const std::string page_tabs = + help_menu_page_ == 0 ? "[1] Help 2 Settings 3 About" + : (help_menu_page_ == 1 ? "1 Help [2] Settings 3 About" + : "1 Help 2 Settings [3] About"); + mvwaddnstr(window, 1, 2, page_tabs.c_str(), menu_width - 4); + mvwhline(window, 2, 1, ACS_HLINE, menu_width - 2); + const auto help_item = [](const std::string &key, const std::string &description) { + std::string line = " " + key; + const int key_width = std::max(16, utf8_display_width(key)); + const int padding = std::max(2, key_width - utf8_display_width(key) + 2); + line.append(static_cast(padding), ' '); + line += description; + return line; + }; + + std::vector lines; + if (help_menu_page_ == 0) { + lines = { + "Navigation", + help_item("? / h", "Open or close help"), + help_item("Tab", "Switch focus"), + help_item("Enter", "Open selected chat"), + help_item("Up / Down", "Move chats or attachment selection"), + help_item("PgUp / PgDn", "Scroll messages"), + "", + "Compose", + help_item("i", "Compose a message"), + help_item("a", "Prepare reply to latest"), + help_item(">r [text]", "Prepare a reply"), + help_item(">paste [caption]", "Send clipboard image"), + "", + "Message Actions", + help_item(">f ", "Forward messages"), + help_item(">e ", "Edit a message"), + help_item(">d ", "Delete your messages"), + help_item("m / o", "Attachments menu / open selected"), + }; + } else if (help_menu_page_ == 1) { + lines = { + "Settings", + std::string(" [") + (auto_reload_chat_history_ ? 'x' : ' ') + + "] Auto-reload open chat history [t]", + "", + "Behavior", + " On: Enter in the message pane reloads history.", + " Off: Press r to reload the current chat manually.", + "", + "Menu", + help_item("1 / 2 / 3", "Switch help pages"), + help_item("Left / Right", "Cycle pages"), + help_item("Esc", "Close the help overlay"), + }; + } else { + lines = { + "", + "", + }; + } + + const int content_top = 3; + const int content_height = menu_height - 6; + if (help_menu_page_ == 2) { + int row = content_top; + for (const auto &line : kShinoaBanner) { + if (row >= content_top + content_height) { + break; + } + const int x = std::max(2, (menu_width - static_cast(line.size())) / 2); + mvwaddnstr(window, row++, x, line.c_str(), menu_width - x - 1); + } + const std::vector about_lines = { + "", + "Telegram TUI with message refs, reply prep, attachment navigation,", + "and keyboard-first chat workflows.", + }; + for (const auto &line : about_lines) { + if (row >= content_top + content_height) { + break; + } + const int x = std::max(2, (menu_width - static_cast(line.size())) / 2); + mvwaddnstr(window, row++, x, line.c_str(), menu_width - x - 1); + } + } else { + for (std::size_t i = 0; i < lines.size() && static_cast(i) < content_height; + ++i) { + mvwaddnstr(window, static_cast(i) + content_top, 2, lines[i].c_str(), + menu_width - 4); + } + } + + mvwhline(window, menu_height - 3, 1, ACS_HLINE, menu_width - 2); + mvwaddnstr(window, menu_height - 2, 2, "Esc close", menu_width - 4); + wrefresh(window); + delwin(window); +} + +} // namespace telegram_tui diff --git a/src/app_input.cpp b/src/app_input.cpp new file mode 100644 index 0000000..0d80734 --- /dev/null +++ b/src/app_input.cpp @@ -0,0 +1,396 @@ +#include "app.h" + +#include +#include +#include +#include + +#include + +#include "util.h" + +namespace telegram_tui { + +namespace { + +std::optional mapped_layout_hotkey(wchar_t ch) { + switch (std::towlower(ch)) { + case L'й': + return 'q'; + case L'р': + return 'h'; + case L'ш': + return 'i'; + case L'к': + return 'r'; + case L'щ': + return 'o'; + default: + return std::nullopt; + } +} + +std::string utf8_from_wchar(wchar_t ch) { + char buffer[MB_LEN_MAX] = {}; + std::mbstate_t state = std::mbstate_t{}; + const std::size_t size = std::wcrtomb(buffer, ch, &state); + if (size == static_cast(-1)) { + return {}; + } + return std::string(buffer, size); +} + +} // namespace + +void App::start_reply_to_latest_message() { + const auto chat_id = open_chat_id(); + if (!chat_id.has_value()) { + status_line_ = "Open chat first."; + return; + } + + const auto chat_it = chats_.find(*chat_id); + if (chat_it == chats_.end() || chat_it->second.messages.empty()) { + status_line_ = "No message available to reply to."; + return; + } + + prepare_reply_input(*chat_id, chat_it->second.messages.back().id); +} + +void App::open_forward_target_menu(std::int64_t source_chat_id, + std::vector message_ids) { + if (source_chat_id == 0 || message_ids.empty()) { + status_line_ = "Forward command needs one or more valid message refs."; + return; + } + const std::vector target_chat_ids = forward_target_chat_ids(); + if (target_chat_ids.empty()) { + status_line_ = "No non-channel chats available to forward to."; + return; + } + + forward_source_chat_id_ = source_chat_id; + forward_message_ids_ = std::move(message_ids); + forward_target_index_ = selected_chat_index_; + if (forward_target_index_ < 0 || + forward_target_index_ >= static_cast(target_chat_ids.size())) { + forward_target_index_ = 0; + } + if (target_chat_ids.size() > 1 && + target_chat_ids[static_cast(forward_target_index_)] == source_chat_id) { + if (forward_target_index_ + 1 < static_cast(target_chat_ids.size())) { + ++forward_target_index_; + } else { + --forward_target_index_; + } + } + forward_target_menu_open_ = true; + status_line_ = forward_message_ids_.size() == 1 + ? "Select a chat to forward the message to." + : "Select a chat to forward the messages to."; +} + +void App::handle_key(int ch) { + if (input_mode_ != InputMode::None) { + handle_input_key(ch); + return; + } + if (forward_target_menu_open_) { + handle_forward_target_menu_key(ch); + return; + } + if (attachment_viewer_open_) { + handle_attachment_viewer_key(ch); + return; + } + if (attachment_action_menu_open_) { + handle_attachment_action_menu_key(ch); + return; + } + if (attachments_menu_open_) { + handle_attachments_menu_key(ch); + return; + } + if (help_menu_open_) { + handle_help_menu_key(ch); + return; + } + + switch (ch) { + case 'q': + running_ = false; + return; + case 'h': + case '?': + case KEY_F(1): + help_menu_open_ = true; + help_menu_page_ = 0; + status_line_ = "Help."; + return; + case 'm': + if (!open_chat_id().has_value()) { + status_line_ = "Open chat first."; + return; + } + attachments_menu_open_ = true; + attachment_category_index_ = 0; + attachment_selection_index_ = 0; + status_line_ = "Attachments."; + return; + case '\t': + focus_ = focus_ == FocusPane::Chats ? FocusPane::Messages : FocusPane::Chats; + return; + case 'r': + if (focus_ == FocusPane::Chats) { + request_more_chats(); + } else { + request_open_chat_history(true); + } + return; + case 'a': + if (focus_ == FocusPane::Messages) { + 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."; + return; + } + if (!open_chat_id().has_value()) { + status_line_ = "Open chat first."; + return; + } + start_input(InputMode::Compose, "Message", false); + status_line_ = "Compose mode."; + return; + case KEY_UP: + if (focus_ == FocusPane::Chats && selected_chat_index_ > 0) { + --selected_chat_index_; + } else if (focus_ == FocusPane::Messages) { + 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) { + if (!move_message_attachment_selection(1) && message_scroll_ > 0) { + --message_scroll_; + } + } + return; + case KEY_PPAGE: + message_scroll_ += 10; + return; + case KEY_NPAGE: + message_scroll_ = std::max(0, message_scroll_ - 10); + return; + case '\n': + case KEY_ENTER: + if (focus_ == FocusPane::Chats) { + const auto chat_id = highlighted_chat_id(); + if (chat_id.has_value()) { + const bool is_same_chat = open_chat_id_ == *chat_id; + set_open_chat(*chat_id); + if (is_same_chat) { + const auto chat_it = chats_.find(*chat_id); + const bool should_request_history = + auto_reload_chat_history_ || + (chat_it != chats_.end() && + (!chat_it->second.history_loaded || + chat_it->second.messages.empty())); + const bool requested = should_request_history && + request_open_chat_history(true); + if (!requested) { + status_line_ = "Opened chat."; + } + } + } + } else if (auto_reload_chat_history_) { + const bool requested = request_open_chat_history(true); + if (!requested) { + status_line_ = "Opened chat."; + } + } else { + status_line_ = "History reload is manual. Press r to reload."; + } + return; + case KEY_RESIZE: + default: + if (focus_ == FocusPane::Messages && authorized_ && open_chat_id().has_value() && + (ch >= 32 && ch <= 126)) { + input_buffer_.clear(); + input_buffer_.push_back(static_cast(ch)); + input_cursor_ = input_buffer_.size(); + start_input(InputMode::Compose, "Message", false); + status_line_ = "Compose mode."; + return; + } + return; + } +} + +void App::handle_wide_char(wint_t ch) { + const std::string encoded = utf8_from_wchar(static_cast(ch)); + if (encoded.empty()) { + return; + } + + if (input_mode_ != InputMode::None) { + input_buffer_.insert(input_cursor_, encoded); + input_cursor_ += encoded.size(); + return; + } + + if (attachment_viewer_open_ || attachment_action_menu_open_) { + return; + } + if (const auto hotkey = mapped_layout_hotkey(static_cast(ch)); + hotkey.has_value()) { + handle_key(*hotkey); + return; + } + + if (attachments_menu_open_) { + return; + } + if (forward_target_menu_open_) { + return; + } + if (help_menu_open_) { + return; + } + + if (focus_ == FocusPane::Messages && authorized_ && open_chat_id().has_value()) { + input_buffer_ = encoded; + input_cursor_ = input_buffer_.size(); + start_input(InputMode::Compose, "Message", false); + status_line_ = "Compose mode."; + } +} + +void App::handle_input_key(int ch) { + switch (ch) { + case 27: + if (input_mode_ == InputMode::Compose) { + clear_input(); + status_line_ = "Compose cancelled."; + } + return; + case '\n': + case KEY_ENTER: + submit_input(); + return; + case KEY_BACKSPACE: + case 127: + case 8: + if (input_cursor_ > 0) { + const std::size_t previous = utf8_prev_index(input_buffer_, input_cursor_); + input_buffer_.erase(previous, input_cursor_ - previous); + input_cursor_ = previous; + } + return; + case KEY_DC: + if (input_cursor_ < input_buffer_.size()) { + const std::size_t next = utf8_next_index(input_buffer_, input_cursor_); + input_buffer_.erase(input_cursor_, next - input_cursor_); + } + return; + case KEY_LEFT: + input_cursor_ = utf8_prev_index(input_buffer_, input_cursor_); + return; + case KEY_RIGHT: + input_cursor_ = utf8_next_index(input_buffer_, input_cursor_); + return; + case KEY_HOME: + input_cursor_ = 0; + return; + case KEY_END: + input_cursor_ = input_buffer_.size(); + return; + default: + if (ch >= 32 && ch <= 126) { + input_buffer_.insert(input_cursor_, 1, static_cast(ch)); + ++input_cursor_; + } + return; + } +} + +void App::handle_forward_target_menu_key(int ch) { + const std::vector target_chat_ids = forward_target_chat_ids(); + if (target_chat_ids.empty()) { + forward_target_menu_open_ = false; + status_line_ = "No non-channel chats available to forward to."; + return; + } + + switch (ch) { + case 27: + case 'q': + forward_target_menu_open_ = false; + forward_message_ids_.clear(); + status_line_ = "Cancelled forwarding."; + return; + case KEY_UP: + if (forward_target_index_ > 0) { + --forward_target_index_; + } + return; + case KEY_DOWN: + if (forward_target_index_ + 1 < static_cast(target_chat_ids.size())) { + ++forward_target_index_; + } + return; + case KEY_PPAGE: + forward_target_index_ = std::max(0, forward_target_index_ - 10); + return; + case KEY_NPAGE: + forward_target_index_ = std::min(static_cast(target_chat_ids.size()) - 1, + forward_target_index_ + 10); + return; + case '\n': + case KEY_ENTER: + case ' ': { + const std::int64_t target_chat_id = + target_chat_ids[static_cast(forward_target_index_)]; + forward_target_menu_open_ = false; + forward_message(forward_source_chat_id_, forward_message_ids_, target_chat_id); + forward_message_ids_.clear(); + } + return; + default: + return; + } +} + +std::vector App::forward_target_chat_ids() const { + std::vector target_chat_ids; + target_chat_ids.reserve(sorted_chat_ids_.size()); + for (const auto chat_id : sorted_chat_ids_) { + const auto chat_it = chats_.find(chat_id); + if (chat_it != chats_.end() && chat_it->second.is_channel) { + continue; + } + target_chat_ids.push_back(chat_id); + } + return target_chat_ids; +} + +} // namespace telegram_tui diff --git a/src/app_messages.cpp b/src/app_messages.cpp new file mode 100644 index 0000000..8f19520 --- /dev/null +++ b/src/app_messages.cpp @@ -0,0 +1,869 @@ +#include "app.h" + +#include +#include +#include +#include + +#include + +#include "app_ui.h" +#include "util.h" + +namespace telegram_tui { + +namespace { + +struct TextInsertion { + std::size_t offset = 0; + int order = 0; + std::size_t span_length = 0; + std::string text; +}; + +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; + int body_indent = 0; + std::string timestamp; + std::string message_id; + std::string sender; + std::string forward_info; + std::string via_bot; + std::string reply_prefix; + std::string reply_ref; + std::string body; + std::string attachment_hint; + std::string state; + bool is_continuation = false; +}; + +bool is_word_char(unsigned char ch) { + return std::isalnum(ch) || ch == '_'; +} + +bool starts_with_at(const std::string &text, std::size_t index, const char *prefix) { + const std::size_t prefix_size = std::char_traits::length(prefix); + return text.compare(index, prefix_size, prefix) == 0; +} + +short sender_color_pair(const std::string &sender) { + static constexpr short sender_pairs[] = { + kColorPairSenderBlue, kColorPairSenderCyan, kColorPairSenderGreen, + kColorPairSenderYellow, kColorPairSenderMagenta, kColorPairSenderRed, + }; + + 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::int64_t file_size_from_object(const json &file) { + return std::max(safe_i64(file, "size"), safe_i64(file, "expected_size")); +} + +std::int32_t file_id_from_object(const json &file) { + return safe_i32(file, "id"); +} + +std::string local_path_from_file(const json &file) { + return safe_string(file.value("local", json::object()), "path"); +} + +bool file_can_be_downloaded(const json &file) { + return file.value("local", json::object()).value("can_be_downloaded", false); +} + +bool file_can_be_deleted(const json &file) { + return file.value("local", json::object()).value("can_be_deleted", false); +} + +std::int64_t file_downloaded_size(const json &file) { + return safe_i64(file.value("local", json::object()), "downloaded_size"); +} + +bool file_is_downloading_active(const json &file) { + return file.value("local", json::object()).value("is_downloading_active", false); +} + +bool file_is_downloaded(const json &file) { + return file.value("local", json::object()).value("is_downloading_completed", false); +} + +AttachmentInfo attachment_from_file(AttachmentType type, std::string name, const json &file, + std::int64_t size_bytes) { + AttachmentInfo attachment; + attachment.type = type; + attachment.name = std::move(name); + attachment.size_bytes = size_bytes; + attachment.downloaded_size = file_downloaded_size(file); + attachment.file_id = file_id_from_object(file); + attachment.local_path = local_path_from_file(file); + attachment.is_downloading_active = file_is_downloading_active(file); + attachment.can_be_downloaded = file_can_be_downloaded(file); + attachment.can_be_deleted = file_can_be_deleted(file); + attachment.is_downloaded = file_is_downloaded(file); + return attachment; +} + +json largest_photo_file(const json &photo) { + json best_file = json::object(); + std::int64_t best_size = -1; + const auto &sizes = photo.value("sizes", json::array()); + if (!sizes.is_array()) { + return best_file; + } + for (const auto &item : sizes) { + const json file = item.value("photo", json::object()); + const std::int64_t size = file_size_from_object(file); + if (size > best_size) { + best_size = size; + best_file = file; + } + } + return best_file; +} + +std::int64_t photo_size_from_object(const json &photo) { + std::int64_t size = 0; + const auto &sizes = photo.value("sizes", json::array()); + if (!sizes.is_array()) { + return 0; + } + for (const auto &item : sizes) { + size = std::max(size, file_size_from_object(item.value("photo", json::object()))); + } + return size; +} + +void draw_colored_span(int y, int &x, int max_x, const std::string &text, chtype attrs) { + if (x >= max_x || text.empty()) { + return; + } + + const int remaining = max_x - x; + 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(), static_cast(count)); + attroff(attrs); + x += used_width; +} + +std::string entity_prefix(const json &type) { + const std::string type_name = safe_string(type, "@type"); + if (type_name == "textEntityTypeBold") { + return "*"; + } + if (type_name == "textEntityTypeItalic") { + return "_"; + } + if (type_name == "textEntityTypeCode" || type_name == "textEntityTypePre" || + type_name == "textEntityTypePreCode") { + return "`"; + } + if (type_name == "textEntityTypeStrikethrough") { + return "~"; + } + if (type_name == "textEntityTypeUnderline") { + return "__"; + } + if (type_name == "textEntityTypeSpoiler") { + return "||"; + } + if (type_name == "textEntityTypeTextUrl") { + return "["; + } + return {}; +} + +std::string entity_suffix(const json &type) { + const std::string type_name = safe_string(type, "@type"); + if (type_name == "textEntityTypeBold") { + return "*"; + } + if (type_name == "textEntityTypeItalic") { + return "_"; + } + if (type_name == "textEntityTypeCode" || type_name == "textEntityTypePre" || + type_name == "textEntityTypePreCode") { + return "`"; + } + if (type_name == "textEntityTypeStrikethrough") { + return "~"; + } + if (type_name == "textEntityTypeUnderline") { + return "__"; + } + if (type_name == "textEntityTypeSpoiler") { + return "||"; + } + if (type_name == "textEntityTypeTextUrl") { + const std::string url = safe_string(type, "url"); + return url.empty() ? "]" : "](" + url + ")"; + } + return {}; +} + +void draw_message_body(int y, int &x, int max_x, const std::string &text) { + std::size_t i = 0; + while (i < text.size() && x < max_x) { + if (starts_with_at(text, i, "http://") || starts_with_at(text, i, "https://") || + starts_with_at(text, i, "t.me/")) { + std::size_t j = i; + while (j < text.size() && + !std::isspace(static_cast(text[j]))) { + ++j; + } + draw_colored_span(y, x, max_x, text.substr(i, j - i), + COLOR_PAIR(kColorPairLink) | A_UNDERLINE); + i = j; + continue; + } + + if (text[i] == '@') { + std::size_t j = i + 1; + while (j < text.size() && + is_word_char(static_cast(text[j]))) { + ++j; + } + if (j > i + 1) { + draw_colored_span(y, x, max_x, text.substr(i, j - i), + COLOR_PAIR(kColorPairLink) | A_BOLD); + i = j; + continue; + } + } + + if (text[i] == '*' || text[i] == '_' || text[i] == '`' || text[i] == '~' || + text[i] == '[' || text[i] == ']' || text[i] == '(' || text[i] == ')') { + draw_colored_span(y, x, max_x, text.substr(i, 1), + COLOR_PAIR(kColorPairMarkdown) | A_BOLD); + ++i; + continue; + } + + std::size_t j = i; + while (j < text.size()) { + if (starts_with_at(text, j, "http://") || + starts_with_at(text, j, "https://") || + starts_with_at(text, j, "t.me/") || text[j] == '@' || text[j] == '*' || + text[j] == '_' || text[j] == '`' || text[j] == '~' || text[j] == '[' || + text[j] == ']' || text[j] == '(' || text[j] == ')') { + break; + } + ++j; + } + draw_colored_span(y, x, max_x, text.substr(i, j - i), A_NORMAL); + i = j; + } +} + +std::string truncate_to_width(std::string text, int max_width) { + if (max_width <= 0) { + return {}; + } + if (static_cast(text.size()) <= max_width) { + return text; + } + if (max_width <= 3) { + return text.substr(0, static_cast(max_width)); + } + text.resize(static_cast(max_width - 3)); + text += "..."; + return text; +} + +} // namespace + +std::string App::sender_label(const json &sender) const { + const std::string type = safe_string(sender, "@type"); + if (type == "messageSenderUser") { + const auto user_id = safe_i64(sender, "user_id"); + auto it = users_.find(user_id); + if (it != users_.end()) { + return it->second.display_name(); + } + if (user_id == my_user_id_) { + return "You"; + } + return "User " + std::to_string(user_id); + } + if (type == "messageSenderChat") { + const auto chat_id = safe_i64(sender, "chat_id"); + auto it = chats_.find(chat_id); + if (it != chats_.end() && !it->second.title.empty()) { + return it->second.title; + } + return "Chat " + std::to_string(chat_id); + } + return "Unknown"; +} + +std::string App::forward_origin_label(const json &forward_info) const { + if (!forward_info.is_object()) { + return {}; + } + + const json origin = forward_info.value("origin", json::object()); + const std::string origin_type = safe_string(origin, "@type"); + if (origin_type.empty()) { + return {}; + } + if (origin_type == "messageOriginUser") { + const auto user_id = safe_i64(origin, "sender_user_id"); + const auto user_it = users_.find(user_id); + if (user_it != users_.end()) { + return "fwd " + user_it->second.display_name(); + } + return "fwd User " + std::to_string(user_id); + } + if (origin_type == "messageOriginHiddenUser") { + const std::string sender_name = safe_string(origin, "sender_name"); + return sender_name.empty() ? "fwd" : "fwd " + sender_name; + } + if (origin_type == "messageOriginChat") { + const auto sender_chat_id = safe_i64(origin, "sender_chat_id"); + std::string label; + const auto chat_it = chats_.find(sender_chat_id); + if (chat_it != chats_.end() && !chat_it->second.title.empty()) { + label = chat_it->second.title; + } else { + label = "Chat " + std::to_string(sender_chat_id); + } + const std::string signature = safe_string(origin, "author_signature"); + if (!signature.empty()) { + label += " (" + signature + ")"; + } + return "fwd " + label; + } + if (origin_type == "messageOriginChannel") { + const auto channel_chat_id = safe_i64(origin, "chat_id"); + std::string label; + const auto chat_it = chats_.find(channel_chat_id); + if (chat_it != chats_.end() && !chat_it->second.title.empty()) { + label = chat_it->second.title; + } else { + label = "Channel " + std::to_string(channel_chat_id); + } + const std::string signature = safe_string(origin, "author_signature"); + if (!signature.empty()) { + label += " (" + signature + ")"; + } + return "fwd " + label; + } + + return "fwd"; +} + +std::string App::via_bot_label(std::int64_t via_bot_user_id) const { + if (via_bot_user_id == 0) { + return {}; + } + + const auto user_it = users_.find(via_bot_user_id); + if (user_it != users_.end()) { + if (!user_it->second.username.empty()) { + return "@" + user_it->second.username; + } + return user_it->second.display_name(); + } + return "via"; +} + +std::string App::preview_message(const json &message) const { + if (!message.is_object()) { + return {}; + } + return single_line(content_to_text(message.value("content", json::object()), false)); +} + +std::optional App::parse_attachment(const json &content) const { + const std::string type = safe_string(content, "@type"); + if (type == "messagePhoto") { + const json photo_file = largest_photo_file(content.value("photo", json::object())); + return attachment_from_file( + AttachmentType::Photo, "photo", photo_file, + photo_size_from_object(content.value("photo", json::object()))); + } + if (type == "messageVideo") { + const json video = content.value("video", json::object()); + std::string name = safe_string(video, "file_name"); + if (name.empty()) { + name = "video"; + } + const json file = video.value("video", json::object()); + return attachment_from_file(AttachmentType::Video, name, file, + file_size_from_object(file)); + } + if (type == "messageVideoNote") { + const json file = + content.value("video_note", json::object()).value("video", json::object()); + return attachment_from_file(AttachmentType::Video, "video note", file, + file_size_from_object(file)); + } + if (type == "messageDocument") { + const json document = content.value("document", json::object()); + std::string name = safe_string(document, "file_name"); + if (name.empty()) { + name = "document"; + } + const json file = document.value("document", json::object()); + return attachment_from_file(AttachmentType::Document, name, file, + file_size_from_object(file)); + } + if (type == "messageAudio") { + const json audio = content.value("audio", json::object()); + std::string name = safe_string(audio, "title"); + if (name.empty()) { + name = safe_string(audio, "file_name"); + } + if (name.empty()) { + name = "audio"; + } + const json file = audio.value("audio", json::object()); + return attachment_from_file(AttachmentType::Audio, name, file, + file_size_from_object(file)); + } + if (type == "messageVoiceNote") { + const json file = + content.value("voice_note", json::object()).value("voice", json::object()); + return attachment_from_file(AttachmentType::Voice, "voice note", file, + file_size_from_object(file)); + } + if (type == "messageAnimation") { + const json animation = content.value("animation", json::object()); + std::string name = safe_string(animation, "file_name"); + if (name.empty()) { + name = "animation"; + } + const json file = animation.value("animation", json::object()); + return attachment_from_file(AttachmentType::Animation, name, file, + file_size_from_object(file)); + } + if (type == "messageSticker") { + const json sticker = content.value("sticker", json::object()); + std::string name = safe_string(sticker, "emoji"); + if (name.empty()) { + name = "sticker"; + } + const json file = sticker.value("sticker", json::object()); + return attachment_from_file(AttachmentType::Sticker, name, file, + file_size_from_object(file)); + } + return std::nullopt; +} + +std::string App::content_to_text(const json &content, bool decorate) const { + auto format_text = [&](const json &formatted) { + const std::string base = safe_string(formatted, "text"); + if (!decorate || !formatted.contains("entities") || + !formatted.at("entities").is_array()) { + return base; + } + + std::vector insertions; + for (const auto &entity : formatted.at("entities")) { + if (!entity.is_object()) { + continue; + } + + const std::size_t utf16_offset = + static_cast(safe_i32(entity, "offset")); + const std::size_t utf16_length = + static_cast(safe_i32(entity, "length")); + if (utf16_length == 0) { + continue; + } + + const json type = entity.value("type", json::object()); + const std::string prefix = entity_prefix(type); + const std::string suffix = entity_suffix(type); + if (prefix.empty() && suffix.empty()) { + continue; + } + + const std::size_t start = + utf8_byte_index_from_utf16_offset(base, utf16_offset); + const std::size_t end = utf8_byte_index_from_utf16_offset( + base, utf16_offset + utf16_length); + if (start > end || end > base.size()) { + continue; + } + + if (!suffix.empty()) { + insertions.push_back({end, 0, utf16_length, suffix}); + } + if (!prefix.empty()) { + insertions.push_back({start, 1, utf16_length, prefix}); + } + } + + std::sort(insertions.begin(), insertions.end(), + [](const TextInsertion &lhs, const TextInsertion &rhs) { + if (lhs.offset != rhs.offset) { + return lhs.offset > rhs.offset; + } + if (lhs.order != rhs.order) { + return lhs.order < rhs.order; + } + return lhs.span_length < rhs.span_length; + }); + + std::string decorated = base; + for (const auto &insertion : insertions) { + decorated.insert(insertion.offset, insertion.text); + } + return decorated; + }; + + const std::string type = safe_string(content, "@type"); + if (type == "messageText") { + return format_text(content.value("text", json::object())); + } + if (type == "messagePhoto") { + const std::string caption = format_text(content.value("caption", json::object())); + return caption.empty() ? "[photo]" : "[photo] " + caption; + } + if (type == "messageVideo") { + const std::string caption = format_text(content.value("caption", json::object())); + return caption.empty() ? "[video]" : "[video] " + caption; + } + if (type == "messageDocument") { + const std::string file_name = + safe_string(content.value("document", json::object()), "file_name"); + const std::string caption = format_text(content.value("caption", json::object())); + if (!caption.empty()) { + return file_name.empty() ? "[document] " + caption + : "[document] " + file_name + " " + caption; + } + return file_name.empty() ? "[document]" : "[document] " + file_name; + } + if (type == "messageSticker") { + const std::string emoji = + safe_string(content.value("sticker", json::object()), "emoji"); + return emoji.empty() ? "[sticker]" : "[sticker] " + emoji; + } + if (type == "messageAnimation") { + const std::string caption = format_text(content.value("caption", json::object())); + return caption.empty() ? "[animation]" : "[animation] " + caption; + } + if (type == "messageVoiceNote") { + return "[voice message]"; + } + if (type == "messageAudio") { + const std::string caption = format_text(content.value("caption", json::object())); + return caption.empty() ? "[audio]" : "[audio] " + caption; + } + if (type == "messageCall") { + return "[call]"; + } + if (type == "messagePoll") { + return "[poll] " + safe_string(content.value("poll", json::object()), "question"); + } + if (type == "messageUnsupported") { + return "[unsupported message]"; + } + return "[" + type + "]"; +} + +MessageInfo App::parse_message(const json &message) const { + MessageInfo parsed; + parsed.id = safe_i64(message, "id"); + parsed.date = safe_i32(message, "date"); + parsed.is_outgoing = message.value("is_outgoing", false); + const json reply_to = message.value("reply_to", json::object()); + if (safe_string(reply_to, "@type") == "messageReplyToMessage") { + parsed.reply_to_message_id = safe_i64(reply_to, "message_id"); + } + parsed.forward_info = forward_origin_label(message.value("forward_info", json::object())); + parsed.sender = parsed.is_outgoing + ? "You" + : sender_label(message.value("sender_id", json::object())); + const json content = message.value("content", json::object()); + if (const auto attachment = parse_attachment(content); attachment.has_value()) { + parsed.has_attachment = true; + parsed.attachment = *attachment; + } + parsed.text = content_to_text(content, true); + parsed.via_bot = via_bot_label(safe_i64(message, "via_bot_user_id")); + return parsed; +} + +void App::draw_message_pane(int top, int height, int left, int width) { + for (int row = 0; row < height; ++row) { + mvhline(top + row, left, ' ', width); + } + + const auto chat_id = open_chat_id(); + if (!chat_id.has_value()) { + mvprintw(top, left + 1, "%s", + authorized_ ? "Open a chat with Enter." : "Waiting for authorization."); + return; + } + + const auto chat_it = chats_.find(*chat_id); + if (chat_it == chats_.end()) { + mvprintw(top, left + 1, "Chat %lld unavailable.", static_cast(*chat_id)); + return; + } + const ChatInfo &chat = chat_it->second; + const std::string header = + truncate_to_width(format_open_chat_header(chat), std::max(1, width - 2)); + attron(COLOR_PAIR(kColorPairOpenChat) | A_BOLD); + mvprintw(top, left + 1, "%s", header.c_str()); + attroff(COLOR_PAIR(kColorPairOpenChat) | A_BOLD); + + 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) { + current_day = message_day; + RenderLine separator; + separator.is_day_separator = true; + separator.body = message_day; + lines.push_back(separator); + } + + std::string state = "[in]"; + if (message.is_outgoing) { + state = (chat.last_read_outbox_message_id != 0 && + message.id <= chat.last_read_outbox_message_id) + ? "[read]" + : "[sent]"; + } else if (chat.last_read_inbox_message_id != 0 && + message.id > chat.last_read_inbox_message_id) { + state = "[new]"; + } + + 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_prefix = message.reply_to_message_id != 0 ? "re " : ""; + const std::string reply_ref = + message.reply_to_message_id != 0 + ? format_message_ref(chat, message.reply_to_message_id) + : ""; + const std::string forward_info = message.forward_info; + const std::string via_bot = 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 (!forward_info.empty()) { + prefix += forward_info + " "; + } + if (!via_bot.empty()) { + prefix += via_bot + " "; + } + if (!reply_ref.empty()) { + prefix += reply_prefix + reply_ref + " "; + } + const int wrap_width = std::max(10, width - 3 - reserved_state_width); + const int prefix_width = utf8_display_width(prefix); + const int body_indent = + prefix_width < std::max(8, wrap_width - 12) ? prefix_width : 0; + std::vector wrapped = wrap_text( + message.text, std::max(10, wrap_width - prefix_width - + 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.body_indent = body_indent; + line.timestamp = timestamp; + line.message_id = message_id; + line.sender = message.sender; + line.forward_info = forward_info; + line.via_bot = via_bot; + 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.body_indent = body_indent; + first_line.timestamp = timestamp; + first_line.message_id = message_id; + first_line.sender = message.sender; + first_line.forward_info = forward_info; + first_line.via_bot = via_bot; + 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); + + for (std::size_t i = 1; i < wrapped.size(); ++i) { + RenderLine continuation; + continuation.body_indent = body_indent; + continuation.body = wrapped[i]; + continuation.is_continuation = true; + lines.push_back(continuation); + } + } + + if (lines.empty()) { + mvprintw(top + 1, left + 1, "%s", "No messages loaded."); + return; + } + + 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; + } + 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())) { + break; + } + + const auto &line = lines[line_index]; + const int y = top + 1 + row; + if (line.is_day_separator) { + const int separator_width = std::max(0, width - 2); + std::string label = " " + line.body + " "; + if (static_cast(label.size()) > separator_width) { + label.resize(static_cast(separator_width)); + } + attron(COLOR_PAIR(kColorPairTimestamp) | A_DIM); + mvhline(y, left + 1, ACS_HLINE, separator_width); + mvaddnstr(y, + left + 1 + + std::max(0, (separator_width - + static_cast(label.size())) / + 2), + label.c_str(), separator_width); + attroff(COLOR_PAIR(kColorPairTimestamp) | A_DIM); + continue; + } + + int x = left + 1; + const int max_x = left + width; + if (!line.is_continuation) { + chtype state_attrs = A_DIM; + if (line.state == "[new]") { + state_attrs = COLOR_PAIR(kColorPairMarkdown) | A_BOLD; + } else if (line.state == "[read]") { + state_attrs = COLOR_PAIR(kColorPairOpenChat) | A_BOLD; + } else if (line.state == "[sent]") { + state_attrs = COLOR_PAIR(kColorPairTimestamp) | A_DIM; + } + 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, 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); + if (!line.forward_info.empty()) { + draw_colored_span(y, x, content_max_x, line.forward_info, + COLOR_PAIR(kColorPairMarkdown) | A_DIM); + draw_colored_span(y, x, content_max_x, " ", A_NORMAL); + } + if (!line.via_bot.empty()) { + draw_colored_span(y, x, content_max_x, line.via_bot, + COLOR_PAIR(kColorPairMarkdown) | A_DIM); + draw_colored_span(y, x, content_max_x, " ", A_NORMAL); + } + 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 { + x = std::min(max_x, left + 1 + line.body_indent); + draw_message_body(y, x, max_x, line.body); + } + } +} + +} // namespace telegram_tui diff --git a/src/app_shell.cpp b/src/app_shell.cpp new file mode 100644 index 0000000..2a3797a --- /dev/null +++ b/src/app_shell.cpp @@ -0,0 +1,262 @@ +#include "app.h" + +#include +#include + +#include + +#include "app_ui.h" +#include "util.h" + +namespace telegram_tui { + +namespace { + +std::string truncate_to_width(std::string text, int max_width) { + if (max_width <= 0) { + return {}; + } + if (static_cast(text.size()) <= max_width) { + return text; + } + if (max_width <= 3) { + return text.substr(0, static_cast(max_width)); + } + text.resize(static_cast(max_width - 3)); + text += "..."; + return text; +} + +} // namespace + +void App::init_curses() { + std::setlocale(LC_ALL, ""); + initscr(); + init_colors(); + cbreak(); + noecho(); + keypad(stdscr, TRUE); +#ifdef NCURSES_VERSION + set_escdelay(25); +#endif + timeout(kPollTimeoutMs); + curs_set(0); +} + +void App::init_colors() { + if (!has_colors()) { + return; + } + + start_color(); + use_default_colors(); + init_pair(kColorPairSenderBlue, COLOR_BLUE, -1); + init_pair(kColorPairSenderCyan, COLOR_CYAN, -1); + init_pair(kColorPairSenderGreen, COLOR_GREEN, -1); + init_pair(kColorPairSenderYellow, COLOR_YELLOW, -1); + init_pair(kColorPairSenderMagenta, COLOR_MAGENTA, -1); + init_pair(kColorPairSenderRed, COLOR_RED, -1); + init_pair(kColorPairLink, COLOR_CYAN, -1); + init_pair(kColorPairMarkdown, COLOR_MAGENTA, -1); + init_pair(kColorPairTimestamp, COLOR_YELLOW, -1); + init_pair(kColorPairOpenChat, COLOR_GREEN, -1); +} + +void App::shutdown_curses() { + endwin(); +} + +void App::draw() { + erase(); + + int height = 0; + int width = 0; + getmaxyx(stdscr, height, width); + if (height < 8 || width < 40) { + mvprintw(0, 0, "Terminal too small."); + refresh(); + return; + } + + const int header_y = 0; + const int content_top = 1; + const int footer_y = height - 2; + const int input_y = height - 1; + const int content_height = footer_y - content_top; + const int chat_width = std::max(24, width / 3); + const int message_width = width - chat_width - 1; + + attron(A_REVERSE); + mvhline(header_y, 0, ' ', width); + const std::string header_label = use_test_dc_ ? "shinoa [TEST DC]" : "shinoa"; + mvprintw(header_y, 1, "%s", header_label.c_str()); + const std::string auth_label = authorized_ ? "ready" : current_auth_label(); + const int auth_x = std::max(1, width - static_cast(auth_label.size()) - 2); + mvprintw(header_y, auth_x, "%s", auth_label.c_str()); + attroff(A_REVERSE); + + mvvline(content_top, chat_width, ACS_VLINE, content_height); + draw_chat_pane(content_top, content_height, chat_width); + draw_message_pane(content_top, content_height, chat_width + 1, message_width); + + attron(A_REVERSE); + mvhline(footer_y, 0, ' ', width); + const std::string footer_status = use_test_dc_ ? "[TEST DC] " + status_line_ : status_line_; + mvprintw(footer_y, 1, "%s", footer_status.c_str()); + const std::string footer_hint = "? for help"; + const int footer_hint_x = std::max(1, width - static_cast(footer_hint.size()) - 2); + if (footer_hint_x > 1 + static_cast(footer_status.size())) { + attron(A_DIM); + mvprintw(footer_y, footer_hint_x, "%s", footer_hint.c_str()); + attroff(A_DIM); + } + attroff(A_REVERSE); + + const std::string help = + input_mode_ == InputMode::None + ? "? for help" + : input_prompt_ + ": " + + (input_hidden_ ? std::string(input_buffer_.size(), '*') + : input_buffer_); + mvhline(input_y, 0, ' ', width); + mvprintw(input_y, 0, "%s", help.c_str()); + if (input_mode_ != InputMode::None) { + const std::string prefix = input_prompt_ + ": "; + const int cursor_x = std::min( + width - 1, utf8_display_width(prefix) + + utf8_display_width(input_buffer_, input_cursor_)); + move(input_y, std::max(0, cursor_x)); + } + + refresh(); + + if (attachment_viewer_open_) { + draw_attachment_viewer(height, width); + } else if (attachment_action_menu_open_) { + draw_attachment_action_menu(height, width); + } else if (attachments_menu_open_) { + draw_attachments_menu(height, width); + } else if (forward_target_menu_open_) { + draw_forward_target_menu(height, width); + } else if (help_menu_open_) { + draw_help_menu(height, width); + } else { + clear_attachment_preview_graphics(); + } +} + +void App::draw_forward_target_menu(int height, int width) { + const std::vector target_chat_ids = forward_target_chat_ids(); + if (target_chat_ids.empty()) { + forward_target_menu_open_ = false; + status_line_ = "No non-channel chats available to forward to."; + return; + } + + const int menu_width = std::min(width - 4, 72); + const int menu_height = std::min(height - 4, 20); + const int top = std::max(1, (height - menu_height) / 2); + const int left = std::max(1, (width - menu_width) / 2); + + WINDOW *window = newwin(menu_height, menu_width, top, left); + if (window == nullptr) { + return; + } + + if (forward_target_index_ < 0) { + forward_target_index_ = 0; + } + if (forward_target_index_ >= static_cast(target_chat_ids.size())) { + forward_target_index_ = static_cast(target_chat_ids.size()) - 1; + } + + box(window, 0, 0); + mvwprintw(window, 0, 2, " Forward To "); + + std::string source_label = + forward_message_ids_.size() == 1 + ? "1 message" + : (std::to_string(forward_message_ids_.size()) + " messages"); + const auto source_chat_it = chats_.find(forward_source_chat_id_); + if (source_chat_it != chats_.end()) { + if (forward_message_ids_.size() == 1) { + const auto message_index = find_message_index(source_chat_it->second, + forward_message_ids_.front()); + if (message_index.has_value()) { + source_label = + "Message [" + std::to_string(*message_index + 1) + "]"; + } + } + if (!source_chat_it->second.title.empty()) { + source_label += " from " + source_chat_it->second.title; + } + } + mvwaddnstr(window, 1, 2, truncate_to_width(source_label, menu_width - 4).c_str(), + menu_width - 4); + mvwhline(window, 2, 1, ACS_HLINE, menu_width - 2); + + const int list_top = 3; + const int list_height = std::max(1, menu_height - 5); + int first_index = 0; + if (forward_target_index_ >= list_height) { + first_index = forward_target_index_ - list_height + 1; + } + + for (int row = 0; row < list_height; ++row) { + const int item_index = first_index + row; + const int y = list_top + row; + mvwhline(window, y, 1, ' ', menu_width - 2); + if (item_index >= static_cast(target_chat_ids.size())) { + continue; + } + + const std::int64_t chat_id = target_chat_ids[static_cast(item_index)]; + const auto chat_it = chats_.find(chat_id); + std::string label = chat_it != chats_.end() ? chat_it->second.title + : ("Chat " + std::to_string(chat_id)); + if (chat_id == forward_source_chat_id_) { + label += " (current)"; + } + if (item_index == forward_target_index_) { + wattron(window, A_REVERSE | A_BOLD); + } + mvwaddnstr(window, y, 2, truncate_to_width(label, menu_width - 4).c_str(), + menu_width - 4); + if (item_index == forward_target_index_) { + wattroff(window, A_REVERSE | A_BOLD); + } + } + + mvwaddnstr(window, menu_height - 1, 2, "Enter forward Esc cancel Up/Down move", + menu_width - 4); + wrefresh(window); + delwin(window); +} + +std::string App::current_auth_label() const { + const std::string type = safe_string(authorization_state_, "@type"); + if (type == "authorizationStateWaitTdlibParameters") { + return "need API"; + } + if (type == "authorizationStateWaitPhoneNumber") { + return "need phone"; + } + if (type == "authorizationStateWaitCode") { + return "need code"; + } + if (type == "authorizationStateWaitEncryptionKey") { + return "unlock db"; + } + if (type == "authorizationStateWaitPassword") { + return "need password"; + } + if (type == "authorizationStateReady") { + return "ready"; + } + if (type.empty()) { + return "starting"; + } + return type; +} + +} // namespace telegram_tui diff --git a/src/app_ui.h b/src/app_ui.h new file mode 100644 index 0000000..cc3430e --- /dev/null +++ b/src/app_ui.h @@ -0,0 +1,16 @@ +#pragma once + +namespace telegram_tui { + +constexpr short kColorPairSenderBlue = 1; +constexpr short kColorPairSenderCyan = 2; +constexpr short kColorPairSenderGreen = 3; +constexpr short kColorPairSenderYellow = 4; +constexpr short kColorPairSenderMagenta = 5; +constexpr short kColorPairSenderRed = 6; +constexpr short kColorPairLink = 7; +constexpr short kColorPairMarkdown = 8; +constexpr short kColorPairTimestamp = 9; +constexpr short kColorPairOpenChat = 10; + +} // namespace telegram_tui