commit ac28065d2adb13dc84131183e6ce0486e677877e Author: Dmitry Date: Thu Apr 23 17:00:41 2026 +0300 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a4fb4fb --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +build/ +.cache/ diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..ad9c456 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,55 @@ +cmake_minimum_required(VERSION 3.21) + +project(shinoa VERSION 0.1.0 LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +include(FetchContent) + +option(TELEGRAM_TUI_USE_SYSTEM_TDLIB "Use an installed TDLib package instead of fetching it." OFF) + +set(CURSES_NEED_WIDE TRUE) +find_package(Curses REQUIRED) + +if(TELEGRAM_TUI_USE_SYSTEM_TDLIB) + find_package(Td REQUIRED) +else() + set(TD_ENABLE_JNI OFF CACHE BOOL "" FORCE) + set(TD_ENABLE_DOTNET OFF CACHE BOOL "" FORCE) + set(TD_ENABLE_TESTS OFF CACHE BOOL "" FORCE) + set(TD_ENABLE_BENCHMARKS OFF CACHE BOOL "" FORCE) + set(TD_ENABLE_INSTALL OFF CACHE BOOL "" FORCE) + set(BUILD_SHARED_LIBS ON CACHE BOOL "" FORCE) + + FetchContent_Declare( + tdlib + GIT_REPOSITORY https://github.com/tdlib/td.git + GIT_TAG 8921c22f0f85b3cb0b56303f9cba81ba8549f4e8 + GIT_SHALLOW TRUE + ) + FetchContent_MakeAvailable(tdlib) +endif() + +add_executable( + shinoa + src/app_attachments.cpp + src/app_chats.cpp + src/app.cpp + src/app_auth.cpp + src/app_state.cpp + src/main.cpp + src/models.cpp + src/td_client.cpp + src/util.cpp +) + +target_include_directories(shinoa PRIVATE ${CURSES_INCLUDE_DIRS}) +target_link_libraries(shinoa PRIVATE ${CURSES_LIBRARIES}) + +if(TELEGRAM_TUI_USE_SYSTEM_TDLIB) + target_link_libraries(shinoa PRIVATE Td::TdJson) +else() + target_link_libraries(shinoa PRIVATE Td::TdJson) +endif() diff --git a/README.md b/README.md new file mode 100644 index 0000000..d8b23c8 --- /dev/null +++ b/README.md @@ -0,0 +1,61 @@ +# telegram-tui + +A minimal Telegram terminal client built with `ncurses` and TDLib. + +## Features + +- interactive login flow inside the TUI +- chat list in the left pane +- message view in the right pane +- send plain text messages +- scroll chats and message history with the keyboard + +## Requirements + +- CMake 3.21+ +- a C++17 compiler +- `ncurses` +- TDLib build dependencies (`gperf`, `openssl`, `zlib`, `git`) + +The project vendors TDLib automatically by default. If you already have TDLib installed with CMake package metadata, configure with `-DTELEGRAM_TUI_USE_SYSTEM_TDLIB=ON`. + +## Build + +```bash +cmake -S . -B build +cmake --build build -j +``` + +## Run + +Create a Telegram application at , then either export credentials: + +```bash +export TELEGRAM_API_ID=123456 +export TELEGRAM_API_HASH=0123456789abcdef0123456789abcdef +./build/telegram-tui +``` + +Or start the app without env vars and enter them interactively when prompted. +When entered in the TUI, the app now stores `api_id` and `api_hash` in +`~/.local/share/telegram-tui/config.json` and reuses them on later launches. + +To use Telegram test servers instead of production: + +```bash +export TELEGRAM_USE_TEST_DC=1 +./build/telegram-tui +``` + +The client stores TDLib state in `~/.local/share/telegram-tui/tdlib` for production and `~/.local/share/telegram-tui/test/tdlib` for test mode. + +## Keys + +- `Up` / `Down`: move selection +- `Tab`: switch focus between chats and messages +- `Enter`: open the selected chat +- `i`: start composing a message +- `PgUp` / `PgDn`: scroll the current message view +- `r`: reload chats or history +- `Esc`: cancel current input +- `q`: quit diff --git a/src/app.cpp b/src/app.cpp new file mode 100644 index 0000000..beba383 --- /dev/null +++ b/src/app.cpp @@ -0,0 +1,1610 @@ +#include "app.h" + +#include +#include +#include +#include +#include +#include +#include + +#include + +#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; +}; + +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_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'; + 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)]; +} + +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; + const int count = std::min(remaining, static_cast(text.size())); + attron(attrs); + mvaddnstr(y, x, text.c_str(), count); + attroff(attrs); + x += count; +} + +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 + +App::App() { + const StoredConfig config = load_app_config(); + api_id_ = get_env("TELEGRAM_API_ID"); + api_hash_ = get_env("TELEGRAM_API_HASH"); + if (api_id_.empty()) { + api_id_ = config.api_id; + } + if (api_hash_.empty()) { + api_hash_ = config.api_hash; + } + auto_reload_chat_history_ = config.auto_reload_chat_history; + phone_number_ = get_env("TELEGRAM_PHONE"); + use_test_dc_ = is_truthy_env_value(get_env("TELEGRAM_USE_TEST_DC")); + + const auto root = data_root(); + const auto state_root = use_test_dc_ ? (root / "test") : root; + database_dir_ = state_root / "tdlib"; + files_dir_ = state_root / "files"; + std::filesystem::create_directories(database_dir_); + std::filesystem::create_directories(files_dir_); + if (!api_id_.empty() || !api_hash_.empty()) { + persist_config(); + } + + if (use_test_dc_) { + status_line_ = "Starting TDLib in test DC mode..."; + } +} + +int App::run() { + init_curses(); + draw(); + while (running_) { + bool should_draw = process_updates(); + if (advance_attachment_animation()) { + should_draw = true; + } + wint_t ch = 0; + const int result = get_wch(&ch); + if (result == KEY_CODE_YES) { + handle_key(static_cast(ch)); + should_draw = true; + } else if (result == OK) { + if (ch < 128) { + handle_key(static_cast(ch)); + } else { + handle_wide_char(ch); + } + should_draw = true; + } + if (should_draw) { + draw(); + } + } + shutdown_curses(); + 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())) { + return std::nullopt; + } + return sorted_chat_ids_[selected_chat_index_]; +} + +std::optional App::open_chat_id() const { + if (open_chat_id_ == 0) { + return std::nullopt; + } + return open_chat_id_; +} + +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; + std::int64_t message_id = parsed_value; + if (parsed_value > 0 && parsed_value <= static_cast(chat.messages.size())) { + message_id = chat.messages[static_cast(parsed_value - 1)].id; + } + + return {true, message_id, value.substr(id_end)}; +} + +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; + } + } + return std::nullopt; +} + +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) + "]"; + } + 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 '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) { + ++message_scroll_; + } + return; + case KEY_DOWN: + if (focus_ == FocusPane::Chats && + selected_chat_index_ + 1 < static_cast(sorted_chat_ids_.size())) { + ++selected_chat_index_; + } else if (focus_ == FocusPane::Messages && message_scroll_ > 0) { + --message_scroll_; + } + 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", + " 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; + std::string timestamp; + std::string message_id; + std::string sender; + std::string meta; + std::string reply_to; + std::string body; + std::string state; + bool is_continuation = false; + }; + + std::vector lines; + std::string current_day; + 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_to = + message.reply_to_message_id != 0 ? ("reply " + format_message_ref(chat, message.reply_to_message_id)) : ""; + const std::string meta = join_with_separator({message.forward_info, message.via_bot}, " "); + const int reserved_state_width = static_cast(state.size()) + 1; + std::string prefix = timestamp + " " + message_id + " " + message.sender + ": "; + if (!meta.empty()) { + prefix += meta + " "; + } + if (!reply_to.empty()) { + prefix += reply_to + " "; + } + const int wrap_width = std::max(10, width - 3 - reserved_state_width); + std::vector wrapped = + wrap_text(message.text, std::max(10, wrap_width - static_cast(prefix.size()))); + if (wrapped.empty()) { + RenderLine line; + line.timestamp = timestamp; + line.message_id = message_id; + line.sender = message.sender; + line.meta = meta; + line.reply_to = reply_to; + line.state = state; + lines.push_back(line); + continue; + } + + RenderLine first_line; + first_line.timestamp = timestamp; + first_line.message_id = message_id; + first_line.sender = message.sender; + first_line.meta = meta; + first_line.reply_to = reply_to; + first_line.body = wrapped.front(); + 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); + if (message_scroll_ > max_scroll) { + message_scroll_ = max_scroll; + } + const int 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); + draw_colored_span(y, x, content_max_x, line.timestamp, COLOR_PAIR(kColorPairTimestamp)); + draw_colored_span(y, x, content_max_x, " ", A_NORMAL); + draw_colored_span(y, x, content_max_x, line.message_id, COLOR_PAIR(kColorPairMarkdown) | A_BOLD); + draw_colored_span(y, x, content_max_x, " ", 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_to.empty()) { + draw_colored_span(y, x, content_max_x, line.reply_to, COLOR_PAIR(kColorPairMarkdown) | A_DIM); + 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); + } else { + draw_colored_span(y, x, max_x, " ", A_NORMAL); + 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 diff --git a/src/app.h b/src/app.h new file mode 100644 index 0000000..47c71e9 --- /dev/null +++ b/src/app.h @@ -0,0 +1,186 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "json.h" +#include "models.h" +#include "td_client.h" + +namespace telegram_tui { + +class App { + public: + App(); + int run(); + + private: + void init_curses(); + void shutdown_curses(); + bool process_updates(); + 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 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 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); + 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 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 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 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; + 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; + void clear_attachment_preview_graphics(); + void render_attachment_preview_graphics(int top, int left, int width, int height); + void reset_attachment_viewer_send_preview(); + [[nodiscard]] std::vector forward_target_chat_ids() const; + [[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::optional> parse_forward_command(const std::string& value) 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 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; + void handle_key(int ch); + void handle_wide_char(wint_t ch); + void handle_input_key(int ch); + void handle_forward_target_menu_key(int ch); + void handle_help_menu_key(int ch); + void handle_attachments_menu_key(int ch); + void handle_attachment_action_menu_key(int ch); + void handle_attachment_viewer_key(int ch); + void draw(); + void init_colors(); + void draw_chat_pane(int top, int height, int width); + void draw_message_pane(int top, int height, int left, int width); + void draw_help_menu(int height, int width); + void draw_forward_target_menu(int height, int width); + void draw_attachments_menu(int height, int width); + void draw_attachment_action_menu(int height, int width); + void draw_attachment_viewer(int height, int width); + [[nodiscard]] std::optional highlighted_chat_id() const; + [[nodiscard]] std::optional open_chat_id() const; + [[nodiscard]] std::string current_auth_label() const; + + TdClient td_; + std::map users_; + std::map chats_; + std::vector sorted_chat_ids_; + + std::filesystem::path database_dir_; + std::filesystem::path files_dir_; + + bool running_ = true; + bool authorized_ = false; + bool attachments_menu_open_ = false; + bool attachment_action_menu_open_ = false; + bool attachment_viewer_open_ = false; + bool forward_target_menu_open_ = false; + bool help_menu_open_ = false; + bool input_hidden_ = false; + bool auto_reload_chat_history_ = false; + bool use_test_dc_ = false; + + FocusPane focus_ = FocusPane::Chats; + InputMode input_mode_ = InputMode::None; + + int selected_chat_index_ = 0; + int message_scroll_ = 0; + int attachment_category_index_ = 0; + int attachment_selection_index_ = 0; + int attachment_action_index_ = 0; + int attachment_viewer_scroll_ = 0; + int forward_target_index_ = 0; + std::int64_t open_chat_id_ = 0; + std::int64_t tdlib_open_chat_id_ = 0; + std::int64_t my_user_id_ = 0; + std::int64_t forward_source_chat_id_ = 0; + std::size_t input_cursor_ = 0; + + std::string api_id_; + std::string api_hash_; + std::string phone_number_; + std::string pending_first_name_; + + std::string input_prompt_; + std::string input_buffer_; + std::string status_line_ = "Starting TDLib..."; + std::string attachment_preview_graphics_data_; + std::string attachment_viewer_title_; + std::string attachment_preview_signature_; + std::vector attachment_viewer_lines_; + std::vector attachment_viewer_animation_frames_; + json authorization_state_ = json::object(); + std::optional pending_attachment_open_; + std::optional pending_attachment_download_; + std::optional attachment_viewer_attachment_; + 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; + bool attachment_preview_graphics_visible_ = false; + bool attachment_preview_graphics_is_sixel_ = false; + bool attachment_viewer_send_on_enter_ = false; + bool attachment_viewer_is_animated_ = false; + std::size_t attachment_viewer_frame_index_ = 0; + std::string attachment_viewer_send_caption_; + std::vector forward_message_ids_; +}; + +} // namespace telegram_tui diff --git a/src/app_attachments.cpp b/src/app_attachments.cpp new file mode 100644 index 0000000..cb23ffd --- /dev/null +++ b/src/app_attachments.cpp @@ -0,0 +1,1356 @@ +#include "app.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "util.h" + +namespace telegram_tui { + +namespace { + +constexpr std::array kAttachmentTypes = { + AttachmentType::Photo, + AttachmentType::Video, + AttachmentType::Document, + AttachmentType::Audio, + AttachmentType::Voice, + AttachmentType::Animation, + AttachmentType::Sticker, +}; + +std::string attachment_type_label(AttachmentType type) { + switch (type) { + case AttachmentType::Photo: + return "Photos"; + case AttachmentType::Video: + return "Videos"; + case AttachmentType::Document: + return "Documents"; + case AttachmentType::Audio: + return "Audio"; + case AttachmentType::Voice: + return "Voice"; + case AttachmentType::Animation: + return "GIFs"; + case AttachmentType::Sticker: + return "Stickers"; + } + return "Media"; +} + +std::filesystem::path downloads_directory() { + const std::string home = get_env("HOME"); + if (!home.empty()) { + return std::filesystem::path(home) / "Downloads"; + } + return std::filesystem::current_path() / "Downloads"; +} + +std::string sanitize_filename(std::string value) { + for (char& ch : value) { + if (ch == '/' || ch == '\\' || ch == ':' || ch == '*' || ch == '?' || ch == '"' || ch == '<' || + ch == '>' || ch == '|') { + ch = '_'; + } + } + value = trim_copy(std::move(value)); + while (!value.empty() && (value.back() == '.' || value.back() == ' ')) { + value.pop_back(); + } + return value; +} + +std::filesystem::path preferred_attachment_filename(const AttachmentInfo& attachment) { + std::string filename = sanitize_filename(attachment.name); + if (filename.empty() || filename == "." || filename == "..") { + filename = "attachment"; + } + + std::filesystem::path candidate(filename); + if (!candidate.has_extension() && !attachment.local_path.empty()) { + const std::string extension = std::filesystem::path(attachment.local_path).extension().string(); + if (!extension.empty()) { + candidate += extension; + } + } + return candidate; +} + +std::filesystem::path unique_destination_path( + const std::filesystem::path& directory, const std::filesystem::path& filename) { + const std::filesystem::path stem = filename.stem(); + const std::filesystem::path extension = filename.extension(); + std::filesystem::path candidate = directory / filename; + for (int index = 1; std::filesystem::exists(candidate); ++index) { + candidate = directory / (stem.string() + " (" + std::to_string(index) + ")" + extension.string()); + } + return candidate; +} + +std::string inline_preview_unavailable_message(const AttachmentInfo& attachment) { + switch (attachment.type) { + case AttachmentType::Photo: + if (!attachment.is_downloaded || attachment.local_path.empty()) { + return "Photo is not downloaded yet."; + } + if (!std::filesystem::exists(attachment.local_path)) { + return "Downloaded photo file is missing on disk."; + } + return "No supported preview backend found. Install `kitten`, `img2sixel`, or `chafa`."; + case AttachmentType::Video: + if (!attachment.is_downloaded || attachment.local_path.empty()) { + return "Video is not downloaded yet."; + } + if (!std::filesystem::exists(attachment.local_path)) { + return "Downloaded video file is missing on disk."; + } + return "Video preview requires `ffmpegthumbnailer` or `ffmpeg`, plus `kitten`, `img2sixel`, or `chafa`."; + default: + return "Inline preview is only supported for photos and videos."; + } +} + +int attachment_progress_percent(const AttachmentInfo& attachment) { + if (attachment.is_downloaded) { + return 100; + } + if (attachment.size_bytes <= 0 || attachment.downloaded_size <= 0) { + return 0; + } + const auto downloaded = std::min(attachment.downloaded_size, attachment.size_bytes); + return static_cast((downloaded * 100) / attachment.size_bytes); +} + +std::string attachment_progress_bar(const AttachmentInfo& attachment, int width) { + if (width <= 2) { + return {}; + } + const int inner_width = width - 2; + int filled = 0; + if (attachment.is_downloaded) { + filled = inner_width; + } else if (attachment.size_bytes > 0 && attachment.downloaded_size > 0) { + filled = std::clamp((inner_width * attachment_progress_percent(attachment)) / 100, 0, inner_width); + } + return "[" + std::string(filled, '#') + std::string(inner_width - filled, '.') + "]"; +} + +std::string shell_quote(const std::string& value) { + std::string quoted = "'"; + for (char ch : value) { + if (ch == '\'') { + quoted += "'\\''"; + } else { + quoted.push_back(ch); + } + } + quoted.push_back('\''); + return quoted; +} + +std::string run_command_capture(const std::string& command) { + FILE* pipe = popen(command.c_str(), "r"); + if (pipe == nullptr) { + return {}; + } + + std::string output; + char buffer[4096]; + while (std::fgets(buffer, sizeof(buffer), pipe) != nullptr) { + output += buffer; + } + const int status = pclose(pipe); + if (status != 0) { + return {}; + } + return output; +} + +std::vector split_preserved_lines(const std::string& text, int fallback_wrap_width) { + std::vector lines; + std::stringstream stream(text); + std::string line; + while (std::getline(stream, line)) { + if (!line.empty() && line.back() == '\r') { + line.pop_back(); + } + lines.push_back(line); + } + + if (!text.empty() && text.back() == '\n') { + lines.emplace_back(); + } + + if (lines.empty()) { + return wrap_text(text, fallback_wrap_width); + } + if (lines.size() == 1 && text.find('\n') == std::string::npos) { + return wrap_text(text, fallback_wrap_width); + } + return lines; +} + +bool command_exists(const char* command) { + static std::map cache; + const auto cached = cache.find(command); + if (cached != cache.end()) { + return cached->second; + } + + const std::string resolved = run_command_capture("command -v " + std::string(command) + " 2>/dev/null"); + const bool exists = !trim_copy(resolved).empty(); + cache[command] = exists; + return exists; +} + +void write_terminal_output(const std::string& data) { + if (data.empty()) { + return; + } + std::fwrite(data.data(), 1, data.size(), stdout); + std::fflush(stdout); +} + +int run_command_to_terminal(const std::string& command) { + return std::system(command.c_str()); +} + +bool spawn_detached_process(const std::vector& args) { + if (args.empty()) { + return false; + } + + const pid_t child = fork(); + if (child < 0) { + return false; + } + if (child == 0) { + const pid_t grandchild = fork(); + if (grandchild < 0) { + _exit(127); + } + if (grandchild > 0) { + _exit(0); + } + + setsid(); + const int devnull = open("/dev/null", O_RDWR); + if (devnull >= 0) { + dup2(devnull, STDIN_FILENO); + dup2(devnull, STDOUT_FILENO); + dup2(devnull, STDERR_FILENO); + if (devnull > STDERR_FILENO) { + close(devnull); + } + } + + std::vector argv; + argv.reserve(args.size() + 1); + for (const std::string& arg : args) { + argv.push_back(const_cast(arg.c_str())); + } + argv.push_back(nullptr); + execvp(argv.front(), argv.data()); + _exit(127); + } + + int status = 0; + while (waitpid(child, &status, 0) < 0) { + if (errno != EINTR) { + return false; + } + } + return WIFEXITED(status) && WEXITSTATUS(status) == 0; +} + +bool open_with_system_app(const std::string& path) { + if (path.empty()) { + return false; + } + + if (command_exists("xdg-open")) { + return spawn_detached_process({"xdg-open", path}); + } + if (command_exists("gio")) { + return spawn_detached_process({"gio", "open", path}); + } + if (command_exists("wslview")) { + return spawn_detached_process({"wslview", path}); + } + if (command_exists("open")) { + return spawn_detached_process({"open", path}); + } + return false; +} + +std::string configured_image_protocol() { + std::string value = trim_copy(get_env("TELEGRAM_TUI_IMAGE_PROTOCOL")); + std::transform(value.begin(), value.end(), value.begin(), + [](unsigned char ch) { return static_cast(std::tolower(ch)); }); + if (value == "kitty" || value == "sixels" || value == "symbols") { + return value; + } + return {}; +} + +std::string lower_copy(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), + [](unsigned char ch) { return static_cast(std::tolower(ch)); }); + return value; +} + +bool terminal_supports_kitty_graphics() { + const std::string term = lower_copy(get_env("TERM")); + return !get_env("KITTY_WINDOW_ID").empty() || term.find("kitty") != std::string::npos; +} + +bool terminal_supports_sixels() { + const std::string term = lower_copy(get_env("TERM")); + const std::string term_program = lower_copy(get_env("TERM_PROGRAM")); + return term == "foot" || term.find("xterm") != std::string::npos || + term.find("mlterm") != std::string::npos || term.find("wezterm") != std::string::npos || + term_program == "wezterm"; +} + +struct TerminalWindowSize { + int cols = 0; + int rows = 0; + int width_pixels = 0; + int height_pixels = 0; +}; + +TerminalWindowSize terminal_window_size() { + TerminalWindowSize size; + struct winsize ws {}; + if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == 0) { + size.cols = ws.ws_col; + size.rows = ws.ws_row; + size.width_pixels = ws.ws_xpixel; + size.height_pixels = ws.ws_ypixel; + } + return size; +} + +int approximate_pixel_width(int cell_width) { + const TerminalWindowSize size = terminal_window_size(); + if (size.cols > 0 && size.width_pixels > 0) { + return std::max(1, cell_width * size.width_pixels / size.cols); + } + return std::max(1, cell_width * 8); +} + +int approximate_pixel_height(int cell_height) { + const TerminalWindowSize size = terminal_window_size(); + if (size.rows > 0 && size.height_pixels > 0) { + return std::max(1, cell_height * size.height_pixels / size.rows); + } + return std::max(1, cell_height * 16); +} + +std::string truncate_text(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::reset_attachment_viewer_send_preview() { + attachment_viewer_send_on_enter_ = false; + attachment_viewer_send_chat_id_ = 0; + attachment_viewer_send_reply_to_message_id_.reset(); + attachment_viewer_send_caption_.clear(); +} + +std::optional App::selected_attachment() const { + const auto chat_id = open_chat_id(); + if (!chat_id.has_value()) { + return std::nullopt; + } + + const auto chat_it = chats_.find(*chat_id); + if (chat_it == chats_.end()) { + return std::nullopt; + } + + const int type_count = static_cast(kAttachmentTypes.size()); + if (type_count == 0) { + return std::nullopt; + } + + int category_index = attachment_category_index_; + if (category_index < 0) { + category_index = 0; + } + if (category_index >= type_count) { + category_index %= type_count; + } + + const AttachmentType selected_type = kAttachmentTypes[category_index]; + std::vector attachments; + for (auto it = chat_it->second.messages.rbegin(); it != chat_it->second.messages.rend(); ++it) { + if (it->has_attachment && it->attachment.type == selected_type) { + attachments.push_back(it->attachment); + } + } + + if (attachments.empty()) { + return std::nullopt; + } + + int selection_index = attachment_selection_index_; + if (selection_index < 0) { + selection_index = 0; + } + if (selection_index >= static_cast(attachments.size())) { + selection_index = static_cast(attachments.size()) - 1; + } + return attachments[static_cast(selection_index)]; +} + +std::string App::render_attachment_preview(const AttachmentInfo& attachment, int width, int height) const { + const std::string preview_path = attachment_preview_path(attachment); + if (preview_path.empty()) { + return inline_preview_unavailable_message(attachment); + } + + const int render_width = std::max(10, width); + const int render_height = std::max(4, height); + const std::string command = + "command -v chafa >/dev/null 2>&1 && chafa --format symbols --symbols all --colors none --size " + + std::to_string(render_width) + "x" + std::to_string(render_height) + " " + + shell_quote(preview_path) + " 2>/dev/null"; + std::string preview = run_command_capture(command); + if (!preview.empty()) { + return preview; + } + return "No supported preview backend found. Install `kitten`, `img2sixel`, or `chafa`."; +} + +std::string App::build_attachment_preview_graphics(const AttachmentInfo& attachment, int width, int height) const { + const std::string preview_path = attachment_preview_path(attachment); + if (preview_path.empty()) { + return {}; + } + + const std::string forced_protocol = configured_image_protocol(); + const bool want_sixels = + forced_protocol == "sixels" || (forced_protocol.empty() && terminal_supports_sixels()); + if (want_sixels) { + if (command_exists("chafa")) { + return "chafa --format sixels --size " + std::to_string(std::max(4, width)) + "x" + + std::to_string(std::max(2, height)) + " " + + shell_quote(preview_path) + " >/dev/tty 2>/dev/null"; + } + if (command_exists("img2sixel")) { + return "img2sixel -w " + std::to_string(approximate_pixel_width(width)) + "px -h " + + std::to_string(approximate_pixel_height(height)) + "px " + + shell_quote(preview_path) + " >/dev/tty 2>/dev/null"; + } + } + + if ((forced_protocol == "kitty" || (forced_protocol.empty() && terminal_supports_kitty_graphics())) && + command_exists("kitten")) { + TerminalWindowSize size = terminal_window_size(); + if (size.cols <= 0) { + size.cols = COLS > 0 ? COLS : width; + } + if (size.rows <= 0) { + size.rows = LINES > 0 ? LINES : height; + } + if (size.width_pixels <= 0) { + size.width_pixels = std::max(1, size.cols * 8); + } + if (size.height_pixels <= 0) { + size.height_pixels = std::max(1, size.rows * 16); + } + return "kitten icat --stdin=no --passthrough=none --transfer-mode=stream --align=left " + "--place " + std::to_string(std::max(1, width)) + "x" + std::to_string(std::max(1, height)) + + "@0x0 --use-window-size " + std::to_string(size.cols) + "," + std::to_string(size.rows) + "," + + std::to_string(size.width_pixels) + "," + std::to_string(size.height_pixels) + " " + + shell_quote(preview_path) + " >/dev/tty 2>/dev/null"; + } + + if (command_exists("chafa")) { + return "chafa --size " + std::to_string(std::max(4, width)) + "x" + + std::to_string(std::max(2, height)) + " " + + shell_quote(preview_path) + " >/dev/tty 2>/dev/null"; + } + + return {}; +} + +void App::request_attachment_download(const AttachmentInfo& attachment, bool open_after_download) { + if (attachment.file_id == 0) { + status_line_ = "Attachment file is unavailable."; + return; + } + + pending_attachment_open_ = open_after_download ? std::optional(attachment) : std::nullopt; + if (open_after_download) { + pending_attachment_download_.reset(); + } + td_.send({ + {"@type", "downloadFile"}, + {"file_id", attachment.file_id}, + {"priority", open_after_download ? 16 : 1}, + {"offset", 0}, + {"limit", 0}, + {"synchronous", false}, + }); + if (open_after_download) { + status_line_ = "Downloading attachment to open..."; + } else if (pending_attachment_download_.has_value() && + pending_attachment_download_->file_id == attachment.file_id) { + status_line_ = "Downloading attachment to ~/Downloads..."; + } else { + status_line_ = "Downloading attachment..."; + } +} + +void App::open_attachment(const AttachmentInfo& attachment) { + reset_attachment_viewer_send_preview(); + if (!attachment.is_downloaded || attachment.local_path.empty()) { + request_attachment_download(attachment, true); + return; + } + if (!std::filesystem::exists(attachment.local_path)) { + status_line_ = "Downloaded attachment is missing on disk."; + return; + } + if (attachment.type == AttachmentType::Video) { + if (play_video_attachment(attachment)) { + return; + } + if (open_with_system_app(attachment.local_path)) { + status_line_ = "Video playback failed. Opened in the system app."; + } else { + status_line_ = "No supported video player found. Install `mpv` or `ffplay`."; + } + return; + } + const int preview_width = COLS > 0 ? COLS - 8 : 72; + const int preview_height = LINES > 0 ? LINES - 8 : 24; + attachment_viewer_attachment_ = attachment; + attachment_viewer_title_ = attachment.name; + attachment_viewer_animation_frames_.clear(); + attachment_viewer_is_animated_ = false; + attachment_viewer_frame_index_ = 0; + if (attachment.type == AttachmentType::Video) { + attachment_viewer_animation_frames_ = attachment_animation_frames(attachment); + attachment_viewer_next_frame_at_ = std::chrono::steady_clock::now() + std::chrono::milliseconds(125); + } + if (!has_inline_attachment_preview(attachment, preview_width, preview_height)) { + attachment_viewer_attachment_.reset(); + attachment_viewer_animation_frames_.clear(); + attachment_viewer_is_animated_ = false; + attachment_viewer_frame_index_ = 0; + if (open_with_system_app(attachment.local_path)) { + status_line_ = "Inline preview unavailable. Opened attachment in the system app."; + } else { + status_line_ = inline_preview_unavailable_message(attachment); + } + return; + } + + refresh_attachment_viewer_content(attachment); + attachment_viewer_scroll_ = 0; + attachment_preview_signature_.clear(); + attachment_viewer_open_ = true; + status_line_ = "Attachment preview."; +} + +void App::download_attachment(const AttachmentInfo& attachment) { + pending_attachment_open_.reset(); + if (!attachment.is_downloaded || attachment.local_path.empty()) { + pending_attachment_download_ = attachment; + request_attachment_download(attachment, false); + return; + } + pending_attachment_download_.reset(); + export_attachment_to_downloads(attachment); +} + +void App::delete_attachment(const AttachmentInfo& attachment) { + if (!attachment.is_downloaded || attachment.local_path.empty()) { + status_line_ = "Attachment is not downloaded."; + return; + } + if (!attachment.can_be_deleted) { + status_line_ = "Attachment can't be deleted from cache."; + return; + } + + if (attachment_viewer_attachment_.has_value() && attachment_viewer_attachment_->file_id == attachment.file_id) { + attachment_viewer_open_ = false; + attachment_viewer_attachment_.reset(); + attachment_viewer_animation_frames_.clear(); + attachment_viewer_is_animated_ = false; + attachment_viewer_frame_index_ = 0; + reset_attachment_viewer_send_preview(); + clear_attachment_preview_graphics(); + } + + td_.send({ + {"@type", "deleteFile"}, + {"file_id", attachment.file_id}, + }); + status_line_ = "Deleting cached attachment..."; +} + +bool App::export_attachment_to_downloads(const AttachmentInfo& attachment) { + if (!attachment.is_downloaded || attachment.local_path.empty()) { + status_line_ = "Attachment is not downloaded yet."; + return false; + } + if (!std::filesystem::exists(attachment.local_path)) { + status_line_ = "Cached attachment is missing on disk."; + return false; + } + + const std::filesystem::path target_dir = downloads_directory(); + const std::filesystem::path target_name = preferred_attachment_filename(attachment); + try { + std::filesystem::create_directories(target_dir); + const std::filesystem::path target_path = unique_destination_path(target_dir, target_name); + std::filesystem::copy_file( + attachment.local_path, + target_path, + std::filesystem::copy_options::none); + status_line_ = "Saved to " + target_path.string(); + return true; + } catch (const std::exception& error) { + status_line_ = std::string("Failed to save attachment: ") + error.what(); + return false; + } +} + +bool App::play_video_attachment(const AttachmentInfo& attachment) { + if (attachment.local_path.empty() || !std::filesystem::exists(attachment.local_path)) { + status_line_ = "Downloaded video file is missing on disk."; + return false; + } + + if (command_exists("mpv")) { + if (spawn_detached_process({ + "mpv", + "--force-window=immediate", + "--keep-open=no", + "--quiet", + "--terminal=no", + "--input-terminal=no", + attachment.local_path, + })) { + status_line_ = "Opened video in mpv."; + return true; + } + } + if (command_exists("ffplay")) { + if (spawn_detached_process({ + "ffplay", + "-autoexit", + "-loglevel", + "error", + "-nostats", + attachment.local_path, + })) { + status_line_ = "Opened video in ffplay."; + return true; + } + } + return false; +} + +void App::refresh_attachment_viewer_content(const AttachmentInfo& attachment) { + const int preview_width = COLS > 0 ? COLS - 8 : 72; + const int preview_height = LINES > 0 ? LINES - 8 : 24; + if (!attachment_viewer_is_animated_ && + !build_attachment_preview_graphics(attachment, preview_width, preview_height).empty()) { + attachment_viewer_lines_.clear(); + return; + } + const std::string preview = render_attachment_preview( + attachment, + preview_width, + preview_height); + attachment_viewer_lines_ = split_preserved_lines( + preview, + std::max(10, (COLS > 0 ? COLS : 80) - 8)); + if (attachment_viewer_lines_.empty()) { + attachment_viewer_lines_.push_back("Preview is empty."); + } +} + +std::vector App::attachment_animation_frames(const AttachmentInfo& attachment) const { + if (attachment.type != AttachmentType::Video || !attachment.is_downloaded || attachment.local_path.empty()) { + return {}; + } + if (!std::filesystem::exists(attachment.local_path) || !command_exists("ffmpeg")) { + return {}; + } + + const std::filesystem::path preview_dir = + files_dir_ / "previews" / (std::string("video-") + std::to_string(attachment.file_id)); + std::vector frames; + try { + std::filesystem::create_directories(preview_dir); + const auto source_time = std::filesystem::last_write_time(attachment.local_path); + bool needs_regeneration = true; + for (const auto& entry : std::filesystem::directory_iterator(preview_dir)) { + if (!entry.is_regular_file() || entry.path().extension() != ".jpg") { + continue; + } + frames.push_back(entry.path().string()); + if (std::filesystem::last_write_time(entry.path()) >= source_time) { + needs_regeneration = false; + } + } + std::sort(frames.begin(), frames.end()); + if (!frames.empty() && !needs_regeneration) { + return frames; + } + + for (const auto& entry : std::filesystem::directory_iterator(preview_dir)) { + if (entry.is_regular_file() && entry.path().extension() == ".jpg") { + std::filesystem::remove(entry.path()); + } + } + frames.clear(); + } catch (const std::exception&) { + return {}; + } + + const std::string pattern = (preview_dir / "frame-%04d.jpg").string(); + const std::string command = + "ffmpeg -y -i " + shell_quote(attachment.local_path) + + " -vf 'fps=6,scale=640:-1:force_original_aspect_ratio=decrease' -frames:v 48 " + + shell_quote(pattern) + " >/dev/null 2>&1"; + if (run_command_to_terminal(command) != 0) { + return {}; + } + + try { + for (const auto& entry : std::filesystem::directory_iterator(preview_dir)) { + if (entry.is_regular_file() && entry.path().extension() == ".jpg") { + frames.push_back(entry.path().string()); + } + } + } catch (const std::exception&) { + return {}; + } + std::sort(frames.begin(), frames.end()); + return frames; +} + +bool App::advance_attachment_animation() { + if (!attachment_viewer_open_ || !attachment_viewer_is_animated_ || attachment_viewer_animation_frames_.empty() || + !attachment_viewer_attachment_.has_value()) { + return false; + } + const auto now = std::chrono::steady_clock::now(); + if (now < attachment_viewer_next_frame_at_) { + return false; + } + + attachment_viewer_frame_index_ = + (attachment_viewer_frame_index_ + 1) % attachment_viewer_animation_frames_.size(); + attachment_viewer_next_frame_at_ = now + std::chrono::milliseconds(125); + attachment_preview_signature_.clear(); + refresh_attachment_viewer_content(*attachment_viewer_attachment_); + return true; +} + +std::string App::attachment_preview_path(const AttachmentInfo& attachment) const { + if (!attachment.is_downloaded || attachment.local_path.empty()) { + return {}; + } + if (!std::filesystem::exists(attachment.local_path)) { + return {}; + } + if (attachment_viewer_attachment_.has_value() && attachment_viewer_attachment_->file_id == attachment.file_id && + !attachment_viewer_animation_frames_.empty() && + attachment_viewer_frame_index_ < attachment_viewer_animation_frames_.size()) { + return attachment_viewer_animation_frames_[attachment_viewer_frame_index_]; + } + if (attachment.type == AttachmentType::Photo) { + return attachment.local_path; + } + if (attachment.type != AttachmentType::Video) { + return {}; + } + + const std::filesystem::path preview_dir = files_dir_ / "previews"; + const std::filesystem::path preview_path = preview_dir / (std::to_string(attachment.file_id) + ".jpg"); + try { + std::filesystem::create_directories(preview_dir); + if (std::filesystem::exists(preview_path)) { + const auto source_time = std::filesystem::last_write_time(attachment.local_path); + const auto preview_time = std::filesystem::last_write_time(preview_path); + if (preview_time >= source_time) { + return preview_path.string(); + } + } + } catch (const std::exception&) { + return {}; + } + + std::string command; + if (command_exists("ffmpegthumbnailer")) { + command = "ffmpegthumbnailer -i " + shell_quote(attachment.local_path) + " -o " + + shell_quote(preview_path.string()) + " -s 0 -q 8 >/dev/null 2>&1"; + } else if (command_exists("ffmpeg")) { + command = "ffmpeg -y -ss 1 -i " + shell_quote(attachment.local_path) + + " -frames:v 1 -vf thumbnail " + shell_quote(preview_path.string()) + " >/dev/null 2>&1"; + } else { + return {}; + } + + if (run_command_to_terminal(command) != 0 || !std::filesystem::exists(preview_path)) { + return {}; + } + return preview_path.string(); +} + +bool App::has_inline_attachment_preview(const AttachmentInfo& attachment, int width, int height) const { + if (attachment_preview_path(attachment).empty()) { + return false; + } + return command_exists("chafa") || !build_attachment_preview_graphics(attachment, width, height).empty(); +} + +void App::clear_attachment_preview_graphics() { + if (!attachment_preview_graphics_visible_) { + attachment_preview_signature_.clear(); + attachment_preview_graphics_is_sixel_ = false; + return; + } + + if (attachment_preview_graphics_is_sixel_) { + write_terminal_output("\033[2J\033[H"); + } else { + write_terminal_output("\033_Ga=d\033\\"); + } + attachment_preview_graphics_visible_ = false; + attachment_preview_signature_.clear(); + attachment_preview_graphics_is_sixel_ = false; + clearok(stdscr, TRUE); +} + +void App::render_attachment_preview_graphics(int top, int left, int width, int height) { + if (!attachment_viewer_attachment_.has_value()) { + clear_attachment_preview_graphics(); + return; + } + + const AttachmentInfo& attachment = *attachment_viewer_attachment_; + const std::string preview_path = attachment_preview_path(attachment); + if (preview_path.empty() || attachment_viewer_is_animated_) { + clear_attachment_preview_graphics(); + return; + } + const std::string forced_protocol = configured_image_protocol(); + const bool want_kitty = + forced_protocol == "kitty" || (forced_protocol.empty() && terminal_supports_kitty_graphics()); + const std::string signature = + preview_path + "|" + std::to_string(width) + "x" + std::to_string(height) + "|" + + std::to_string(top) + ":" + std::to_string(left) + "|" + forced_protocol; + const bool want_sixels = + forced_protocol == "sixels" || (forced_protocol.empty() && terminal_supports_sixels()); + const std::string command = build_attachment_preview_graphics(attachment, width, height); + if (command.empty()) { + clear_attachment_preview_graphics(); + return; + } + + const bool same_signature = attachment_preview_signature_ == signature && attachment_preview_graphics_visible_; + if (!same_signature) { + clear_attachment_preview_graphics(); + } + attachment_preview_graphics_is_sixel_ = want_sixels; + if (want_kitty) { + TerminalWindowSize size = terminal_window_size(); + if (size.cols <= 0) { + size.cols = COLS > 0 ? COLS : left + width + 1; + } + if (size.rows <= 0) { + size.rows = LINES > 0 ? LINES : top + height + 1; + } + if (size.width_pixels <= 0) { + size.width_pixels = std::max(1, size.cols * 8); + } + if (size.height_pixels <= 0) { + size.height_pixels = std::max(1, size.rows * 16); + } + + const std::string kitty_command = + "kitten icat --stdin=no --passthrough=none --transfer-mode=stream --align=left " + "--place " + std::to_string(std::max(1, width)) + "x" + std::to_string(std::max(1, height)) + + "@" + std::to_string(std::max(0, left)) + "x" + std::to_string(std::max(0, top)) + + " --use-window-size " + std::to_string(size.cols) + "," + std::to_string(size.rows) + "," + + std::to_string(size.width_pixels) + "," + std::to_string(size.height_pixels) + + " " + shell_quote(preview_path) + " >/dev/tty 2>/dev/null"; + if (run_command_to_terminal(kitty_command) != 0) { + attachment_preview_graphics_is_sixel_ = false; + return; + } + } else { + write_terminal_output("\0337\033[" + std::to_string(top + 1) + ";" + std::to_string(left + 1) + "H"); + if (run_command_to_terminal(command) != 0) { + write_terminal_output("\0338"); + attachment_preview_graphics_is_sixel_ = false; + return; + } + write_terminal_output("\0338"); + } + + attachment_preview_graphics_visible_ = true; + attachment_preview_signature_ = signature; +} + +void App::handle_attachments_menu_key(int ch) { + switch (ch) { + case 27: + case 'm': + attachment_action_menu_open_ = false; + attachment_viewer_open_ = false; + attachment_viewer_attachment_.reset(); + attachment_viewer_animation_frames_.clear(); + attachment_viewer_is_animated_ = false; + attachment_viewer_frame_index_ = 0; + reset_attachment_viewer_send_preview(); + clear_attachment_preview_graphics(); + attachments_menu_open_ = false; + status_line_ = "Closed attachments."; + return; + case ' ': + if (!selected_attachment().has_value()) { + status_line_ = "No attachment selected."; + return; + } + attachment_action_index_ = 0; + attachment_action_menu_open_ = true; + status_line_ = "Attachment actions."; + return; + case KEY_LEFT: + attachment_category_index_ = + (attachment_category_index_ + static_cast(kAttachmentTypes.size()) - 1) % + static_cast(kAttachmentTypes.size()); + attachment_selection_index_ = 0; + return; + case KEY_RIGHT: + attachment_category_index_ = + (attachment_category_index_ + 1) % static_cast(kAttachmentTypes.size()); + attachment_selection_index_ = 0; + return; + case KEY_UP: + if (attachment_selection_index_ > 0) { + --attachment_selection_index_; + } + return; + case KEY_DOWN: + ++attachment_selection_index_; + return; + default: + return; + } +} + +void App::handle_attachment_action_menu_key(int ch) { + switch (ch) { + case 27: + attachment_action_menu_open_ = false; + status_line_ = "Closed attachment actions."; + return; + case KEY_LEFT: + if (attachment_action_index_ > 0) { + --attachment_action_index_; + } + return; + case KEY_RIGHT: + if (attachment_action_index_ < 3) { + ++attachment_action_index_; + } + return; + case KEY_UP: + if (attachment_action_index_ > 0) { + --attachment_action_index_; + } + return; + case KEY_DOWN: + if (attachment_action_index_ < 3) { + ++attachment_action_index_; + } + return; + case '\n': + case KEY_ENTER: + case ' ': + if (attachment_action_index_ == 3) { + attachment_action_menu_open_ = false; + status_line_ = "Cancelled attachment action."; + return; + } + if (const auto attachment = selected_attachment(); attachment.has_value()) { + attachment_action_menu_open_ = false; + if (attachment_action_index_ == 0) { + open_attachment(*attachment); + } else if (attachment_action_index_ == 1) { + download_attachment(*attachment); + } else { + delete_attachment(*attachment); + } + } else { + attachment_action_menu_open_ = false; + status_line_ = "Attachment is no longer available."; + } + return; + default: + return; + } +} + +void App::handle_attachment_viewer_key(int ch) { + if (attachment_viewer_send_on_enter_) { + switch (ch) { + case '\n': + case KEY_ENTER: + if (!attachment_viewer_attachment_.has_value() || + attachment_viewer_attachment_->local_path.empty() || + attachment_viewer_send_chat_id_ == 0) { + attachment_viewer_open_ = false; + attachment_viewer_attachment_.reset(); + attachment_viewer_animation_frames_.clear(); + attachment_viewer_is_animated_ = false; + attachment_viewer_frame_index_ = 0; + reset_attachment_viewer_send_preview(); + clear_attachment_preview_graphics(); + status_line_ = "Clipboard image preview is no longer available."; + return; + } + { + const std::string local_path = attachment_viewer_attachment_->local_path; + const std::string caption = attachment_viewer_send_caption_; + const auto reply_to_message_id = attachment_viewer_send_reply_to_message_id_; + const std::int64_t chat_id = attachment_viewer_send_chat_id_; + attachment_viewer_open_ = false; + attachment_viewer_attachment_.reset(); + attachment_viewer_animation_frames_.clear(); + attachment_viewer_is_animated_ = false; + attachment_viewer_frame_index_ = 0; + reset_attachment_viewer_send_preview(); + clear_attachment_preview_graphics(); + send_photo_message(chat_id, local_path, caption, reply_to_message_id); + } + return; + default: + break; + } + } + + switch (ch) { + case 27: + case 'q': + case ' ': + { + const bool was_send_preview = attachment_viewer_send_on_enter_; + attachment_viewer_open_ = false; + attachment_viewer_attachment_.reset(); + attachment_viewer_animation_frames_.clear(); + attachment_viewer_is_animated_ = false; + attachment_viewer_frame_index_ = 0; + reset_attachment_viewer_send_preview(); + clear_attachment_preview_graphics(); + status_line_ = was_send_preview + ? "Cancelled clipboard image send." + : "Closed attachment preview."; + return; + } + case KEY_UP: + if (attachment_viewer_scroll_ > 0) { + --attachment_viewer_scroll_; + } + return; + case KEY_DOWN: + ++attachment_viewer_scroll_; + return; + case KEY_PPAGE: + attachment_viewer_scroll_ = std::max(0, attachment_viewer_scroll_ - 10); + return; + case KEY_NPAGE: + attachment_viewer_scroll_ += 10; + return; + default: + return; + } +} + +void App::draw_attachments_menu(int height, int width) { + const auto chat_id = open_chat_id(); + if (!chat_id.has_value()) { + attachments_menu_open_ = false; + status_line_ = "Open chat first."; + return; + } + + const auto chat_it = chats_.find(*chat_id); + if (chat_it == chats_.end()) { + attachments_menu_open_ = false; + status_line_ = "Chat unavailable."; + return; + } + + const int menu_width = std::min(width - 2, 96); + const int menu_height = std::min(height - 2, 21); + 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; + } + + const int type_count = static_cast(kAttachmentTypes.size()); + if (attachment_category_index_ < 0) { + attachment_category_index_ = 0; + } + if (attachment_category_index_ >= type_count) { + attachment_category_index_ %= type_count; + } + + const AttachmentType selected_type = kAttachmentTypes[attachment_category_index_]; + std::vector attachments; + for (auto it = chat_it->second.messages.rbegin(); it != chat_it->second.messages.rend(); ++it) { + if (it->has_attachment && it->attachment.type == selected_type) { + attachments.push_back(&*it); + } + } + + if (attachments.empty()) { + attachment_selection_index_ = 0; + } else if (attachment_selection_index_ < 0) { + attachment_selection_index_ = 0; + } else if (attachment_selection_index_ >= static_cast(attachments.size())) { + attachment_selection_index_ = static_cast(attachments.size()) - 1; + } + + box(window, 0, 0); + mvwprintw(window, 0, 2, " Attachments "); + + int tabs_x = 2; + for (std::size_t i = 0; i < kAttachmentTypes.size() && tabs_x < menu_width - 2; ++i) { + const std::string label = " " + attachment_type_label(kAttachmentTypes[i]) + " "; + if (static_cast(i) == attachment_category_index_) { + wattron(window, A_REVERSE | A_BOLD); + } + mvwaddnstr(window, 1, tabs_x, label.c_str(), menu_width - tabs_x - 2); + if (static_cast(i) == attachment_category_index_) { + wattroff(window, A_REVERSE | A_BOLD); + } + tabs_x += static_cast(label.size()) + 1; + } + + const int inner_width = std::max(1, menu_width - 2); + const int content_width = std::max(1, menu_width - 4); + const int status_width = std::min(26, std::max(12, content_width / 3)); + const int size_width = std::min(12, std::max(8, content_width / 6)); + int date_width = std::min(16, std::max(8, content_width / 3)); + int sender_width = std::max(8, content_width - status_width - size_width - date_width - 3); + if (sender_width + status_width + size_width + date_width + 3 > content_width) { + date_width = std::max(8, content_width - sender_width - status_width - size_width - 3); + } + + mvwaddnstr(window, 3, 2, "Sender", sender_width); + mvwaddnstr(window, 3, 3 + sender_width, "DL", status_width); + mvwaddnstr(window, 3, 4 + sender_width + status_width, "File size", size_width); + mvwaddnstr(window, 3, 5 + sender_width + status_width + size_width, "Sent", date_width); + mvwhline(window, 4, 1, ACS_HLINE, inner_width); + + const int list_top = 5; + const int list_height = std::max(1, menu_height - 9); + int first_index = 0; + if (attachment_selection_index_ >= list_height) { + first_index = attachment_selection_index_ - list_height + 1; + } + + if (attachments.empty()) { + const std::string message = "No " + attachment_type_label(selected_type) + " in this chat."; + mvwaddnstr(window, list_top, 2, message.c_str(), menu_width - 4); + } else { + 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, ' ', inner_width); + if (item_index >= static_cast(attachments.size())) { + continue; + } + + const MessageInfo& message = *attachments[static_cast(item_index)]; + if (item_index == attachment_selection_index_) { + wattron(window, A_REVERSE); + } + const std::string sender = truncate_text(message.sender, sender_width); + const std::string dl_status = truncate_text( + message.attachment.is_downloading_active && !message.attachment.is_downloaded + ? attachment_progress_bar(message.attachment, std::max(10, status_width - 2)) + : (message.attachment.is_downloaded ? "Ready" : ""), + status_width); + const std::string file_size = truncate_text( + format_file_size(message.attachment.size_bytes), + size_width); + const std::string sent = truncate_text(format_datetime(message.date), date_width); + mvwaddnstr(window, y, 2, sender.c_str(), sender_width); + mvwaddnstr(window, y, 3 + sender_width, dl_status.c_str(), status_width); + mvwaddnstr(window, y, 4 + sender_width + status_width, file_size.c_str(), size_width); + mvwaddnstr(window, y, 5 + sender_width + status_width + size_width, sent.c_str(), date_width); + if (item_index == attachment_selection_index_) { + wattroff(window, A_REVERSE); + } + } + } + + mvwhline(window, menu_height - 4, 1, ACS_HLINE, inner_width); + std::string title_line; + std::string actions_line = "Left/Right type Up/Down move Space actions Esc close"; + if (!attachments.empty()) { + const MessageInfo& selected = *attachments[static_cast(attachment_selection_index_)]; + title_line = truncate_text(selected.attachment.name, menu_width - 4); + } else { + title_line = truncate_text("No attachment selected.", menu_width - 4); + } + mvwaddnstr(window, menu_height - 3, 2, title_line.c_str(), menu_width - 4); + mvwaddnstr(window, menu_height - 2, 2, actions_line.c_str(), menu_width - 4); + + wrefresh(window); + delwin(window); +} + +void App::draw_attachment_action_menu(int height, int width) { + const auto attachment = selected_attachment(); + if (!attachment.has_value()) { + attachment_action_menu_open_ = false; + status_line_ = "Attachment is no longer available."; + return; + } + + const int menu_width = std::min(width - 2, 96); + const int menu_height = std::min(height - 2, 21); + 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, " Attachment Action "); + mvwaddnstr(window, 1, 2, truncate_text(attachment->name, menu_width - 4).c_str(), menu_width - 4); + mvwhline(window, 3, 1, ACS_HLINE, menu_width - 2); + + const std::vector prompt_lines = wrap_text( + "What do you want to do with this file?", + std::max(10, menu_width - 8)); + for (std::size_t i = 0; i < prompt_lines.size() && static_cast(i) < menu_height - 9; ++i) { + mvwaddnstr(window, 5 + static_cast(i), 4, prompt_lines[i].c_str(), menu_width - 8); + } + + mvwhline(window, menu_height - 5, 1, ACS_HLINE, menu_width - 2); + const std::array actions = {"Open", "Download", "Delete", "Cancel"}; + std::vector labels; + int total_label_width = 0; + for (const char* action : actions) { + labels.push_back(std::string("[ ") + action + " ]"); + total_label_width += static_cast(labels.back().size()); + } + + const int button_y = menu_height - 3; + const int inner_width = menu_width - 2; + const int gap = std::max(2, (inner_width - total_label_width) / (static_cast(labels.size()) + 1)); + int button_x = 1 + gap; + for (std::size_t i = 0; i < actions.size(); ++i) { + if (static_cast(i) == attachment_action_index_) { + wattron(window, A_REVERSE | A_BOLD); + } + mvwaddnstr(window, button_y, button_x, labels[i].c_str(), menu_width - button_x - 2); + if (static_cast(i) == attachment_action_index_) { + wattroff(window, A_REVERSE | A_BOLD); + } + button_x += static_cast(labels[i].size()) + gap; + } + + mvwaddnstr(window, menu_height - 2, 2, "Left/Right move Enter select Esc close", menu_width - 4); + wrefresh(window); + delwin(window); +} + +void App::draw_attachment_viewer(int height, int width) { + const int menu_width = std::min(width - 2, 120); + const int menu_height = std::min(height - 2, 36); + 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, " Preview "); + mvwaddnstr(window, 1, 2, attachment_viewer_title_.c_str(), menu_width - 4); + mvwhline(window, 2, 1, ACS_HLINE, menu_width - 2); + + const int content_top = 3; + const int content_height = std::max(1, menu_height - 5); + const int max_scroll = std::max(0, static_cast(attachment_viewer_lines_.size()) - content_height); + if (attachment_viewer_scroll_ > max_scroll) { + attachment_viewer_scroll_ = max_scroll; + } + + for (int row = 0; row < content_height; ++row) { + const int line_index = attachment_viewer_scroll_ + row; + mvwhline(window, content_top + row, 1, ' ', menu_width - 2); + if (line_index >= static_cast(attachment_viewer_lines_.size())) { + continue; + } + mvwaddnstr( + window, + content_top + row, + 2, + attachment_viewer_lines_[static_cast(line_index)].c_str(), + menu_width - 4); + } + + const std::string controls = attachment_viewer_send_on_enter_ + ? "Enter send Esc cancel Up/Down scroll" + : "Space/Esc close Up/Down scroll"; + mvwaddnstr(window, menu_height - 2, 2, controls.c_str(), menu_width - 4); + wrefresh(window); + if (attachment_viewer_attachment_.has_value()) { + render_attachment_preview_graphics(top + content_top, left + 2, menu_width - 4, content_height); + } else { + clear_attachment_preview_graphics(); + } + delwin(window); +} + +} // namespace telegram_tui diff --git a/src/app_auth.cpp b/src/app_auth.cpp new file mode 100644 index 0000000..8774fe1 --- /dev/null +++ b/src/app_auth.cpp @@ -0,0 +1,669 @@ +#include "app.h" + +#include +#include +#include +#include +#include +#include + +#include + +#include "util.h" + +namespace telegram_tui { + +namespace { + +struct ClipboardImageType { + 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"}, +}}; + +std::string shell_quote(const std::string& value) { + std::string quoted = "'"; + for (char ch : value) { + if (ch == '\'') { + quoted += "'\\''"; + } else { + quoted.push_back(ch); + } + } + quoted.push_back('\''); + return quoted; +} + +std::string run_command_capture(const std::string& command) { + FILE* pipe = popen(command.c_str(), "r"); + if (pipe == nullptr) { + return {}; + } + + std::string output; + char buffer[4096]; + while (std::fgets(buffer, sizeof(buffer), pipe) != nullptr) { + output += buffer; + } + if (pclose(pipe) != 0) { + return {}; + } + return output; +} + +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) { + if (command_exists("wl-paste")) { + return "wl-paste --no-newline --type " + shell_quote(mime_type) + " > " + + 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"; + } + if (command_exists("pngpaste") && mime_type == "image/png") { + return "pngpaste " + shell_quote(destination_path) + " >/dev/null 2>&1"; + } + return {}; +} + +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) { + if (types_output.find(type.mime) != std::string::npos) { + return 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) { + if (targets_output.find(type.mime) != std::string::npos) { + return type; + } + } + } + if (command_exists("pngpaste")) { + return ClipboardImageType{"image/png", ".png"}; + } + 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) { + 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; +} + +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 formatted_text = { + {"@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") { + formatted_text = *parsed; + } + return formatted_text; +} + +} // namespace + +void App::handle_authorization_state() { + const std::string type = safe_string(authorization_state_, "@type"); + if (type == "authorizationStateWaitTdlibParameters") { + if (api_id_.empty()) { + start_input(InputMode::ApiId, "Telegram API ID", false); + status_line_ = "Enter your Telegram application API ID."; + return; + } + if (api_hash_.empty()) { + start_input(InputMode::ApiHash, "Telegram API hash", false); + status_line_ = "Enter your Telegram application API hash."; + return; + } + send_tdlib_parameters(); + return; + } + if (type == "authorizationStateWaitPhoneNumber") { + if (phone_number_.empty()) { + start_input(InputMode::PhoneNumber, "Phone number", false); + status_line_ = "Enter phone number for Telegram account."; + return; + } + send_check_phone_number(); + return; + } + if (type == "authorizationStateWaitEncryptionKey") { + td_.send({ + {"@type", "checkDatabaseEncryptionKey"}, + {"encryption_key", ""}, + }); + status_line_ = "Unlocking local database..."; + return; + } + if (type == "authorizationStateWaitCode") { + start_input(InputMode::AuthCode, "Login code", false); + status_line_ = "Enter code Telegram sent."; + return; + } + if (type == "authorizationStateWaitPassword") { + start_input(InputMode::Password, "2FA password", true); + status_line_ = "Two-step verification enabled."; + return; + } + if (type == "authorizationStateWaitRegistration") { + if (pending_first_name_.empty()) { + start_input(InputMode::FirstName, "First name", false); + status_line_ = "New account. Enter first name."; + } else { + start_input(InputMode::LastName, "Last name", false); + status_line_ = "Enter optional last name."; + } + return; + } + if (type == "authorizationStateReady") { + authorized_ = true; + input_mode_ = InputMode::None; + status_line_ = "Authorized. Loading chats."; + td_.send({ + {"@type", "getMe"}, + {"@extra", "getMe"}, + }); + request_more_chats(); + if (!open_chat_id().has_value()) { + const auto chat_id = highlighted_chat_id(); + if (chat_id.has_value()) { + set_open_chat(*chat_id); + } + } + request_open_chat_history(false); + return; + } + if (type == "authorizationStateLoggingOut") { + authorized_ = false; + status_line_ = "Logging out..."; + return; + } + if (type == "authorizationStateClosed") { + authorized_ = false; + status_line_ = "TDLib session closed."; + } +} + +void App::send_tdlib_parameters() { + if (!is_decimal_number(api_id_)) { + start_input(InputMode::ApiId, "Telegram API ID", false); + status_line_ = "Telegram API ID must be numeric."; + return; + } + + 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}, + }); +} + +void App::send_check_phone_number() { + status_line_ = "Requesting login code..."; + td_.send({ + {"@type", "setAuthenticationPhoneNumber"}, + {"phone_number", phone_number_}, + {"settings", {{"@type", "phoneNumberAuthenticationSettings"}}}, + }); +} + +void App::persist_config() { + if (!save_app_config(StoredConfig{api_id_, api_hash_, auto_reload_chat_history_})) { + status_line_ = "Failed to save config locally."; + } +} + +void App::request_chat_details(std::int64_t chat_id) { + auto chat_it = chats_.find(chat_id); + if (chat_it == chats_.end()) { + return; + } + + 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}, + }); + chat.details_requested = true; + return; + } + + if (chat.basic_group_id != 0) { + td_.send({ + {"@type", "getBasicGroup"}, + {"basic_group_id", chat.basic_group_id}, + }); + chat.details_requested = true; + return; + } + + if (chat.supergroup_id != 0) { + td_.send({ + {"@type", "getSupergroup"}, + {"supergroup_id", chat.supergroup_id}, + }); + chat.details_requested = true; + } +} + +void App::start_input(InputMode mode, std::string prompt, bool hidden) { + input_mode_ = mode; + input_prompt_ = std::move(prompt); + input_hidden_ = hidden; + if (mode != InputMode::Compose) { + input_buffer_.clear(); + } + input_cursor_ = input_buffer_.size(); + curs_set(1); +} + +void App::clear_input() { + input_mode_ = InputMode::None; + input_prompt_.clear(); + input_buffer_.clear(); + input_cursor_ = 0; + input_hidden_ = false; + curs_set(0); +} + +void App::submit_input() { + const std::string value = trim_copy(input_buffer_); + if (value.empty()) { + status_line_ = "Input cannot be empty."; + return; + } + + const InputMode mode = input_mode_; + if (mode == InputMode::ApiId && !is_decimal_number(value)) { + status_line_ = "Telegram API ID must be numeric."; + return; + } + clear_input(); + + if (mode == InputMode::Compose) { + const auto chat_id = open_chat_id(); + if (!chat_id.has_value()) { + 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); + 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); + return; + } + if (text.empty()) { + status_line_ = "Reply text cannot be empty."; + return; + } + send_message(*chat_id, text, is_reply ? std::optional(reply_to_message_id) : std::nullopt); + return; + } + if (mode == InputMode::ApiId) { + api_id_ = value; + persist_config(); + if (api_hash_.empty()) { + start_input(InputMode::ApiHash, "Telegram API hash", false); + } else { + send_tdlib_parameters(); + } + return; + } + if (mode == InputMode::ApiHash) { + api_hash_ = value; + persist_config(); + send_tdlib_parameters(); + return; + } + if (mode == InputMode::PhoneNumber) { + phone_number_ = value; + send_check_phone_number(); + return; + } + if (mode == InputMode::AuthCode) { + td_.send({ + {"@type", "checkAuthenticationCode"}, + {"code", value}, + }); + status_line_ = "Submitting code..."; + return; + } + if (mode == InputMode::Password) { + td_.send({ + {"@type", "checkAuthenticationPassword"}, + {"password", value}, + }); + status_line_ = "Submitting password..."; + return; + } + if (mode == InputMode::FirstName) { + pending_first_name_ = value; + start_input(InputMode::LastName, "Last name", false); + status_line_ = "Enter last name or - for none."; + return; + } + if (mode == InputMode::LastName) { + const std::string last_name = value == "-" ? "" : value; + td_.send({ + {"@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) { + 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}, + }}, + }; + if (reply_to_message_id.has_value()) { + request["reply_to"] = { + {"@type", "inputMessageReplyToMessage"}, + {"message_id", *reply_to_message_id}, + }; + } + td_.send(request); + status_line_ = "Message queued."; +} + +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) { + 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}, + }}, + }; + if (reply_to_message_id.has_value()) { + request["reply_to"] = { + {"@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) { + 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."; + return false; + } + + const std::filesystem::path clipboard_dir = files_dir_ / "clipboard"; + try { + std::filesystem::create_directories(clipboard_dir); + } catch (const std::exception& error) { + status_line_ = std::string("Failed to prepare clipboard cache: ") + error.what(); + return false; + } + + 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); + if (!std::filesystem::exists(candidate)) { + image_path = candidate; + break; + } + } + if (image_path.empty()) { + status_line_ = "Failed to allocate a clipboard image path."; + 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)) { + status_line_ = "Failed to read image data from the clipboard."; + return false; + } + + try { + if (std::filesystem::file_size(image_path) == 0) { + std::filesystem::remove(image_path); + status_line_ = "Clipboard image is empty."; + return false; + } + } catch (const std::exception&) { + status_line_ = "Clipboard image couldn't be validated."; + return false; + } + + AttachmentInfo attachment; + attachment.type = AttachmentType::Photo; + attachment.name = image_path.filename().string(); + attachment.size_bytes = static_cast(std::filesystem::file_size(image_path)); + attachment.local_path = image_path.string(); + attachment.is_downloaded = true; + + attachment_viewer_attachment_ = attachment; + attachment_viewer_title_ = "Clipboard photo: " + attachment.name; + attachment_viewer_animation_frames_.clear(); + attachment_viewer_is_animated_ = false; + attachment_viewer_frame_index_ = 0; + attachment_viewer_scroll_ = 0; + attachment_preview_signature_.clear(); + attachment_viewer_send_on_enter_ = true; + attachment_viewer_send_chat_id_ = chat_id; + attachment_viewer_send_reply_to_message_id_ = reply_to_message_id; + attachment_viewer_send_caption_ = caption; + 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."; + 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 diff --git a/src/app_chats.cpp b/src/app_chats.cpp new file mode 100644 index 0000000..05ed4bc --- /dev/null +++ b/src/app_chats.cpp @@ -0,0 +1,269 @@ +#include "app.h" + +#include + +#include "util.h" + +namespace telegram_tui { + +namespace { + +std::string join_with_separator_local(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::string format_user_status(const json& status) { + const std::string type = safe_string(status, "@type"); + if (type == "userStatusOnline") { + return "online"; + } + if (type == "userStatusOffline") { + const std::int32_t was_online = safe_i32(status, "was_online"); + return was_online > 0 ? ("last seen " + format_datetime(was_online)) : "offline"; + } + if (type == "userStatusRecently") { + return "last seen recently"; + } + if (type == "userStatusLastWeek") { + return "last seen within a week"; + } + if (type == "userStatusLastMonth") { + return "last seen within a month"; + } + return {}; +} + +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) { + if (username.is_string()) { + const std::string value = username.get(); + if (!value.empty()) { + return value; + } + } + } + } + return safe_string(object, "username"); +} + +} // namespace + +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) { + if (user.is_null()) { + return; + } + 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"); + info.username = primary_username(user); + info.status = format_user_status(user.value("status", json::object())); +} + +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_) { + if (chat.basic_group_id != basic_group_id) { + continue; + } + chat.id = chat_id; + chat.username.clear(); + chat.member_count = member_count; + chat.has_member_count = true; + } +} + +void App::upsert_supergroup(const json& supergroup) { + if (supergroup.is_null()) { + return; + } + const std::int64_t supergroup_id = safe_i64(supergroup, "id"); + 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_) { + if (chat.supergroup_id != supergroup_id) { + continue; + } + chat.id = chat_id; + chat.is_channel = is_channel; + chat.username = username; + chat.member_count = member_count; + chat.has_member_count = true; + } +} + +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]; + chat.id = chat_id; + if (chat_object.contains("title")) { + chat.title = safe_string(chat_object, "title"); + } + const json type = chat_object.value("type", json::object()); + const std::string type_name = safe_string(type, "@type"); + const std::int64_t previous_private_user_id = chat.private_user_id; + const std::int64_t previous_basic_group_id = chat.basic_group_id; + const std::int64_t previous_supergroup_id = chat.supergroup_id; + chat.private_user_id = 0; + chat.basic_group_id = 0; + chat.supergroup_id = 0; + chat.is_channel = false; + if (type_name == "chatTypePrivate" || type_name == "chatTypeSecret") { + chat.private_user_id = safe_i64(type, "user_id"); + chat.username.clear(); + chat.has_member_count = false; + chat.has_online_member_count = false; + chat.member_count = 0; + chat.online_member_count = 0; + } else if (type_name == "chatTypeBasicGroup") { + chat.basic_group_id = safe_i64(type, "basic_group_id"); + chat.username.clear(); + } else if (type_name == "chatTypeSupergroup") { + chat.supergroup_id = safe_i64(type, "supergroup_id"); + chat.is_channel = type.value("is_channel", false); + if (chat.supergroup_id != previous_supergroup_id) { + chat.username.clear(); + } + } else { + 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.details_requested = false; + } + chat.unread_count = safe_i32(chat_object, "unread_count"); + chat.last_read_inbox_message_id = safe_i64(chat_object, "last_read_inbox_message_id"); + chat.last_read_outbox_message_id = safe_i64(chat_object, "last_read_outbox_message_id"); + if (chat_object.contains("last_message")) { + chat.last_message_preview = preview_message(chat_object.at("last_message")); + } + 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")) { + apply_chat_position(chat, position); + } + } + if (open_chat_id_ == chat.id) { + request_chat_details(chat.id); + } + resort_chats(); +} + +void App::apply_chat_position(ChatInfo& chat, const json& position) { + if (!position.is_object()) { + return; + } + + const json chat_list = position.value("list", json::object()); + if (safe_string(chat_list, "@type") != "chatListMain") { + return; + } + chat.in_main_list = true; + chat.main_order = safe_i64(position, "order"); +} + +void App::resort_chats() { + if (sorted_chat_ids_.empty()) { + 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_) { + if (!chat.in_main_list) { + continue; + } + 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; + }); + + 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::vector parts; + if (!chat.title.empty()) { + parts.push_back(chat.title); + } + + if (chat.private_user_id != 0) { + const auto user_it = users_.find(chat.private_user_id); + if (user_it != users_.end()) { + if (!user_it->second.username.empty()) { + parts.push_back("@" + user_it->second.username); + } + const std::string status = user_status_label(chat.private_user_id); + if (!status.empty()) { + parts.push_back(status); + } + parts.push_back("id:" + std::to_string(user_it->second.id)); + } else { + parts.push_back("id:" + std::to_string(chat.private_user_id)); + } + return join_with_separator_local(parts, " | "); + } + if (!chat.username.empty()) { + parts.push_back("@" + chat.username); + } + if (chat.has_member_count) { + 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"); + } + parts.push_back("id:" + std::to_string(chat.id)); + return join_with_separator_local(parts, " | "); +} + +} // namespace telegram_tui diff --git a/src/app_state.cpp b/src/app_state.cpp new file mode 100644 index 0000000..6bcdde5 --- /dev/null +++ b/src/app_state.cpp @@ -0,0 +1,545 @@ +#include "app.h" + +#include +#include + +#include "util.h" + +namespace telegram_tui { + +namespace { + +std::string format_download_progress(std::int64_t downloaded_size, std::int64_t size_bytes, bool is_downloaded) { + if (is_downloaded) { + return "100%"; + } + if (size_bytes > 0) { + const auto downloaded = std::min(downloaded_size, size_bytes); + return std::to_string(static_cast((downloaded * 100) / size_bytes)) + "% " + + format_file_size(downloaded) + "/" + format_file_size(size_bytes); + } + return format_file_size(downloaded_size); +} + +} // namespace + +bool App::process_updates() { + bool changed = false; + while (true) { + auto update = td_.receive(0.0); + if (!update.has_value()) { + break; + } + changed = true; + handle_td_object(*update); + } + return changed; +} + +void App::handle_td_object(const json& object) { + const std::string type = safe_string(object, "@type"); + if (type == "updateAuthorizationState") { + authorization_state_ = object.value("authorization_state", json::object()); + handle_authorization_state(); + return; + } + if (type == "updateConnectionState") { + status_line_ = "Connection: " + safe_string(object.at("state"), "@type"); + return; + } + if (type == "updateChatOnlineMemberCount") { + ChatInfo& chat = chats_[safe_i64(object, "chat_id")]; + chat.id = safe_i64(object, "chat_id"); + chat.online_member_count = safe_i32(object, "online_member_count"); + chat.has_online_member_count = true; + return; + } + if (type == "updateUser") { + upsert_user(object.value("user", json::object())); + return; + } + if (type == "updateUserStatus") { + update_user_status(safe_i64(object, "user_id"), object.value("status", json::object())); + return; + } + if (type == "updateBasicGroup" || type == "basicGroup") { + upsert_basic_group(type == "basicGroup" ? object : object.value("basic_group", json::object())); + return; + } + if (type == "updateSupergroup" || type == "supergroup") { + upsert_supergroup(type == "supergroup" ? object : object.value("supergroup", json::object())); + return; + } + if (type == "updateNewChat") { + upsert_chat(object.value("chat", json::object())); + return; + } + if (type == "updateChatTitle") { + ChatInfo& chat = chats_[safe_i64(object, "chat_id")]; + chat.id = safe_i64(object, "chat_id"); + chat.title = safe_string(object, "title"); + resort_chats(); + return; + } + if (type == "updateChatPosition") { + ChatInfo& chat = chats_[safe_i64(object, "chat_id")]; + chat.id = safe_i64(object, "chat_id"); + apply_chat_position(chat, object.value("position", json::object())); + resort_chats(); + return; + } + if (type == "updateChatLastMessage") { + ChatInfo& chat = chats_[safe_i64(object, "chat_id")]; + chat.id = safe_i64(object, "chat_id"); + chat.last_message_preview = preview_message(object.value("last_message", json::object())); + if (object.contains("positions") && object.at("positions").is_array()) { + chat.in_main_list = false; + chat.main_order = 0; + for (const auto& position : object.at("positions")) { + apply_chat_position(chat, position); + } + } + resort_chats(); + return; + } + if (type == "updateChatReadInbox") { + ChatInfo& chat = chats_[safe_i64(object, "chat_id")]; + chat.id = safe_i64(object, "chat_id"); + chat.unread_count = safe_i32(object, "unread_count"); + chat.last_read_inbox_message_id = safe_i64(object, "last_read_inbox_message_id"); + return; + } + if (type == "updateChatReadOutbox") { + ChatInfo& chat = chats_[safe_i64(object, "chat_id")]; + chat.id = safe_i64(object, "chat_id"); + chat.last_read_outbox_message_id = safe_i64(object, "last_read_outbox_message_id"); + return; + } + if (type == "updateNewMessage") { + const json message = object.value("message", json::object()); + const std::int64_t chat_id = safe_i64(message, "chat_id"); + if (chat_id == open_chat_id_ && message_scroll_ > 0) { + ++message_scroll_; + } + const MessageInfo parsed = parse_message(message); + append_message(chat_id, parsed); + chats_[chat_id].last_message_preview = preview_message(message); + if (chat_id == open_chat_id_ && !parsed.is_outgoing) { + mark_message_as_read(chat_id, parsed.id); + } + return; + } + if (type == "updateMessageContent") { + const std::int64_t chat_id = safe_i64(object, "chat_id"); + const std::int64_t message_id = safe_i64(object, "message_id"); + auto chat_it = chats_.find(chat_id); + if (chat_it == chats_.end()) { + return; + } + for (auto& message : chat_it->second.messages) { + if (message.id == message_id) { + const json content = object.value("new_content", json::object()); + message.text = content_to_text(content, true); + if (const auto attachment = parse_attachment(content); attachment.has_value()) { + message.has_attachment = true; + message.attachment = *attachment; + } else { + message.has_attachment = false; + } + break; + } + } + return; + } + if (type == "updateDeleteMessages") { + const std::int64_t chat_id = safe_i64(object, "chat_id"); + auto chat_it = chats_.find(chat_id); + if (chat_it == chats_.end()) { + return; + } + std::set deleted; + for (const auto& id : object.value("message_ids", json::array())) { + if (id.is_number_integer()) { + deleted.insert(id.get()); + } + } + std::vector kept; + kept.reserve(chat_it->second.messages.size()); + for (const auto& message : chat_it->second.messages) { + if (deleted.find(message.id) == deleted.end()) { + kept.push_back(message); + } + } + chat_it->second.messages = std::move(kept); + return; + } + if (type == "updateFile") { + update_attachment_file(object.value("file", json::object())); + return; + } + if (type == "updateOption" || type == "ok" || type == "userFullInfo" || + type == "updateHavePendingNotifications" || type == "updateUnreadMessageCount" || + type == "updateUnreadChatCount") { + return; + } + if (type == "error") { + const std::string extra = safe_string(object, "@extra"); + const std::string message = safe_string(object, "message"); + if (extra.rfind("history:", 0) == 0) { + const auto chat_id = static_cast(std::stoll(extra.substr(8))); + chats_[chat_id].history_loading = false; + status_line_ = "History unavailable: " + message; + return; + } + status_line_ = "TDLib error: " + message; + return; + } + if (type == "user") { + upsert_user(object); + return; + } + if (type == "chat") { + upsert_chat(object); + return; + } + if (type == "chats") { + const std::string extra = safe_string(object, "@extra"); + if (extra == "main_chats") { + sync_chat_ids_from_response(object); + } + return; + } + if (type == "messages") { + const std::string extra = safe_string(object, "@extra"); + if (extra.rfind("history:", 0) == 0) { + const auto chat_id = static_cast(std::stoll(extra.substr(8))); + merge_history(chat_id, object.value("messages", json::array())); + } + return; + } + if (type == "updateMessageSendSucceeded") { + const json message = object.value("message", json::object()); + const std::int64_t chat_id = safe_i64(message, "chat_id"); + remove_message(chat_id, safe_i64(object, "old_message_id")); + append_message(chat_id, parse_message(message)); + chats_[chat_id].last_message_preview = preview_message(message); + status_line_ = "Message sent."; + return; + } + + const std::string extra = safe_string(object, "@extra"); + if (extra == "getMe" && object.is_object()) { + my_user_id_ = safe_i64(object, "id"); + } +} + +void App::request_more_chats() { + td_.send({ + {"@type", "getChats"}, + {"chat_list", {{"@type", "chatListMain"}}}, + {"limit", kChatPageSize}, + {"@extra", "main_chats"}, + }); + status_line_ = "Loading chats..."; +} + +bool App::request_chat_history(std::int64_t chat_id, bool force) { + ChatInfo& chat = chats_[chat_id]; + chat.id = chat_id; + if (chat.history_loading && !force) { + return false; + } + chat.history_loading = true; + td_.send({ + {"@type", "getChatHistory"}, + {"chat_id", chat_id}, + {"from_message_id", 0}, + {"offset", 0}, + {"limit", kHistoryBatchSize}, + {"only_local", false}, + {"@extra", "history:" + std::to_string(chat_id)}, + }); + return true; +} + +bool App::request_open_chat_history(bool force) { + const auto chat_id = open_chat_id(); + if (!chat_id.has_value()) { + return false; + } + request_chat_details(*chat_id); + const bool requested = request_chat_history(*chat_id, force); + if (requested) { + status_line_ = force ? "Reloading chat history..." : "Loading chat history..."; + } + return requested; +} + +void App::mark_chat_messages_as_read(std::int64_t chat_id) { + auto chat_it = chats_.find(chat_id); + if (chat_it == chats_.end()) { + return; + } + + std::vector unread_message_ids; + unread_message_ids.reserve(chat_it->second.messages.size()); + for (const auto& message : chat_it->second.messages) { + if (message.is_outgoing || message.id == 0) { + continue; + } + if (message.id <= chat_it->second.last_read_inbox_message_id) { + continue; + } + unread_message_ids.push_back(message.id); + } + + if (!unread_message_ids.empty()) { + td_.send({ + {"@type", "viewMessages"}, + {"chat_id", chat_id}, + {"message_ids", unread_message_ids}, + {"source", {{"@type", "messageSourceChatHistory"}}}, + {"force_read", true}, + }); + } + + td_.send({ + {"@type", "toggleChatIsMarkedAsUnread"}, + {"chat_id", chat_id}, + {"is_marked_as_unread", false}, + }); +} + +void App::mark_message_as_read(std::int64_t chat_id, std::int64_t message_id) { + if (chat_id == 0 || message_id == 0) { + return; + } + + td_.send({ + {"@type", "viewMessages"}, + {"chat_id", chat_id}, + {"message_ids", json::array({message_id})}, + {"source", {{"@type", "messageSourceChatHistory"}}}, + {"force_read", true}, + }); +} + +void App::set_open_chat(std::int64_t chat_id) { + if (chat_id == 0) { + return; + } + + const bool changed_chat = open_chat_id_ != chat_id; + ChatInfo& chat = chats_[chat_id]; + chat.id = chat_id; + if (tdlib_open_chat_id_ != 0 && tdlib_open_chat_id_ != chat_id) { + td_.send({ + {"@type", "closeChat"}, + {"chat_id", tdlib_open_chat_id_}, + }); + } + + open_chat_id_ = chat_id; + message_scroll_ = 0; + + if (tdlib_open_chat_id_ != chat_id) { + td_.send({ + {"@type", "openChat"}, + {"chat_id", chat_id}, + }); + tdlib_open_chat_id_ = chat_id; + } + + mark_chat_messages_as_read(chat_id); + + if (changed_chat) { + request_chat_details(chat_id); + const bool should_request_history = + auto_reload_chat_history_ || !chat.history_loaded || chat.messages.empty(); + const bool requested = should_request_history && + request_chat_history(chat_id, auto_reload_chat_history_); + if (requested) { + status_line_ = auto_reload_chat_history_ && chat.history_loaded + ? "Reloading chat history..." + : "Loading chat history..."; + } + } +} + +void App::sync_chat_ids_from_response(const json& response) { + if (!response.contains("chat_ids") || !response.at("chat_ids").is_array()) { + status_line_ = "Chat list response missing chat ids."; + return; + } + + std::vector chat_ids; + for (const auto& entry : response.at("chat_ids")) { + if (!entry.is_number_integer()) { + continue; + } + const auto chat_id = entry.get(); + chat_ids.push_back(chat_id); + if (chats_.find(chat_id) == chats_.end()) { + td_.send({ + {"@type", "getChat"}, + {"chat_id", chat_id}, + }); + } + } + + if (!chat_ids.empty()) { + sorted_chat_ids_ = std::move(chat_ids); + if (selected_chat_index_ >= static_cast(sorted_chat_ids_.size())) { + selected_chat_index_ = std::max(0, static_cast(sorted_chat_ids_.size()) - 1); + } + if (!open_chat_id().has_value()) { + const auto chat_id = highlighted_chat_id(); + if (chat_id.has_value()) { + set_open_chat(*chat_id); + } + } + request_open_chat_history(false); + status_line_ = "Chats loaded."; + return; + } + + status_line_ = "No chats loaded."; +} + +void App::append_message(std::int64_t chat_id, MessageInfo message) { + ChatInfo& chat = chats_[chat_id]; + chat.id = chat_id; + auto existing = std::find_if(chat.messages.begin(), chat.messages.end(), + [&](const MessageInfo& item) { return item.id == message.id; }); + if (existing != chat.messages.end()) { + *existing = std::move(message); + } else { + chat.messages.push_back(std::move(message)); + } + + std::sort(chat.messages.begin(), chat.messages.end(), + [](const MessageInfo& lhs, const MessageInfo& rhs) { + if (lhs.date != rhs.date) { + return lhs.date < rhs.date; + } + return lhs.id < rhs.id; + }); + + if (kMaxMessagesPerChat > 0 && chat.messages.size() > kMaxMessagesPerChat) { + chat.messages.erase( + chat.messages.begin(), + chat.messages.begin() + + static_cast(chat.messages.size() - kMaxMessagesPerChat)); + } +} + +void App::remove_message(std::int64_t chat_id, std::int64_t message_id) { + if (message_id == 0) { + return; + } + + auto chat_it = chats_.find(chat_id); + if (chat_it == chats_.end()) { + return; + } + + auto& messages = chat_it->second.messages; + messages.erase( + std::remove_if( + messages.begin(), + messages.end(), + [&](const MessageInfo& item) { return item.id == message_id; }), + messages.end()); +} + +void App::merge_history(std::int64_t chat_id, const json& messages) { + ChatInfo& chat = chats_[chat_id]; + chat.history_loading = false; + chat.history_loaded = true; + for (const auto& message : messages) { + append_message(chat_id, parse_message(message)); + } + if (chat_id == open_chat_id_) { + mark_chat_messages_as_read(chat_id); + } + status_line_ = "History loaded."; +} + +void App::update_attachment_file(const json& file) { + const std::int32_t file_id = safe_i32(file, "id"); + if (file_id == 0) { + return; + } + + const std::int64_t size_bytes = std::max(safe_i64(file, "size"), safe_i64(file, "expected_size")); + const json local = file.value("local", json::object()); + const std::string local_path = safe_string(local, "path"); + const std::int64_t downloaded_size = safe_i64(local, "downloaded_size"); + const bool is_downloading_active = local.value("is_downloading_active", false); + const bool is_downloaded = local.value("is_downloading_completed", false); + const bool can_be_downloaded = local.value("can_be_downloaded", false); + const bool can_be_deleted = local.value("can_be_deleted", false); + + for (auto& [chat_id, chat] : chats_) { + (void) chat_id; + for (auto& message : chat.messages) { + if (!message.has_attachment || message.attachment.file_id != file_id) { + continue; + } + if (size_bytes > 0) { + message.attachment.size_bytes = size_bytes; + } + message.attachment.downloaded_size = downloaded_size; + message.attachment.local_path = local_path; + message.attachment.is_downloading_active = is_downloading_active; + message.attachment.is_downloaded = is_downloaded; + message.attachment.can_be_downloaded = can_be_downloaded; + message.attachment.can_be_deleted = can_be_deleted; + } + } + + if (pending_attachment_open_.has_value() && pending_attachment_open_->file_id == file_id) { + pending_attachment_open_->size_bytes = size_bytes > 0 ? size_bytes : pending_attachment_open_->size_bytes; + pending_attachment_open_->downloaded_size = downloaded_size; + pending_attachment_open_->local_path = local_path; + pending_attachment_open_->is_downloading_active = is_downloading_active; + pending_attachment_open_->is_downloaded = is_downloaded; + pending_attachment_open_->can_be_downloaded = can_be_downloaded; + pending_attachment_open_->can_be_deleted = can_be_deleted; + if (!is_downloaded && is_downloading_active) { + status_line_ = "Downloading attachment to open " + + format_download_progress( + pending_attachment_open_->downloaded_size, + pending_attachment_open_->size_bytes, + pending_attachment_open_->is_downloaded); + } + if (is_downloaded && !local_path.empty()) { + open_attachment(*pending_attachment_open_); + pending_attachment_open_.reset(); + } + } + + if (pending_attachment_download_.has_value() && pending_attachment_download_->file_id == file_id) { + pending_attachment_download_->size_bytes = + size_bytes > 0 ? size_bytes : pending_attachment_download_->size_bytes; + pending_attachment_download_->downloaded_size = downloaded_size; + pending_attachment_download_->local_path = local_path; + pending_attachment_download_->is_downloading_active = is_downloading_active; + pending_attachment_download_->is_downloaded = is_downloaded; + pending_attachment_download_->can_be_downloaded = can_be_downloaded; + pending_attachment_download_->can_be_deleted = can_be_deleted; + if (!is_downloaded && is_downloading_active) { + status_line_ = "Downloading attachment to ~/Downloads " + + format_download_progress( + pending_attachment_download_->downloaded_size, + pending_attachment_download_->size_bytes, + pending_attachment_download_->is_downloaded); + } + if (is_downloaded && !local_path.empty()) { + export_attachment_to_downloads(*pending_attachment_download_); + pending_attachment_download_.reset(); + } + } +} + +} // namespace telegram_tui diff --git a/src/json.h b/src/json.h new file mode 100644 index 0000000..54a5723 --- /dev/null +++ b/src/json.h @@ -0,0 +1,9 @@ +#pragma once + +#include + +namespace telegram_tui { + +using json = nlohmann::json; + +} // namespace telegram_tui diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..767b04b --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,17 @@ +#include +#include + +#include + +#include "app.h" + +int main() { + try { + telegram_tui::App app; + return app.run(); + } catch (const std::exception& error) { + endwin(); + std::fprintf(stderr, "fatal: %s\n", error.what()); + return 1; + } +} diff --git a/src/models.cpp b/src/models.cpp new file mode 100644 index 0000000..94d34d6 --- /dev/null +++ b/src/models.cpp @@ -0,0 +1,25 @@ +#include "models.h" + +namespace telegram_tui { + +std::string UserInfo::display_name() const { + std::string name; + if (!first_name.empty()) { + name += first_name; + } + if (!last_name.empty()) { + if (!name.empty()) { + name += " "; + } + name += last_name; + } + if (!name.empty()) { + return name; + } + if (!username.empty()) { + return "@" + username; + } + return "Unknown"; +} + +} // namespace telegram_tui diff --git a/src/models.h b/src/models.h new file mode 100644 index 0000000..2b4b64d --- /dev/null +++ b/src/models.h @@ -0,0 +1,101 @@ +#pragma once + +#include +#include +#include + +namespace telegram_tui { + +constexpr std::size_t kMaxMessagesPerChat = 0; +constexpr int kPollTimeoutMs = 50; +constexpr int kChatPageSize = 100; +constexpr int kHistoryBatchSize = 50; + +struct UserInfo { + std::int64_t id = 0; + std::string first_name; + std::string last_name; + std::string username; + std::string status; + + [[nodiscard]] std::string display_name() const; +}; + +enum class AttachmentType { + Photo, + Video, + Document, + Audio, + Voice, + Animation, + Sticker, +}; + +struct AttachmentInfo { + AttachmentType type = AttachmentType::Photo; + std::string name; + std::int64_t size_bytes = 0; + std::int64_t downloaded_size = 0; + std::int32_t file_id = 0; + std::string local_path; + bool is_downloading_active = false; + bool can_be_downloaded = false; + bool can_be_deleted = false; + bool is_downloaded = false; +}; + +struct MessageInfo { + std::int64_t id = 0; + std::int32_t date = 0; + bool is_outgoing = false; + std::int64_t reply_to_message_id = 0; + bool has_attachment = false; + AttachmentInfo attachment; + std::string forward_info; + std::string sender; + std::string text; + std::string via_bot; +}; + +struct ChatInfo { + std::int64_t id = 0; + std::int64_t private_user_id = 0; + std::int64_t basic_group_id = 0; + std::int64_t supergroup_id = 0; + std::int64_t last_read_inbox_message_id = 0; + std::int64_t last_read_outbox_message_id = 0; + std::string title = "Loading..."; + std::string username; + std::string last_message_preview; + std::int32_t unread_count = 0; + std::int32_t member_count = 0; + std::int32_t online_member_count = 0; + std::int64_t main_order = 0; + bool is_channel = false; + bool in_main_list = false; + bool history_loading = false; + bool history_loaded = false; + bool details_requested = false; + bool has_member_count = false; + bool has_online_member_count = false; + std::vector messages; +}; + +enum class FocusPane { + Chats, + Messages, +}; + +enum class InputMode { + None, + Compose, + ApiId, + ApiHash, + PhoneNumber, + AuthCode, + Password, + FirstName, + LastName, +}; + +} // namespace telegram_tui diff --git a/src/td_client.cpp b/src/td_client.cpp new file mode 100644 index 0000000..4a0736e --- /dev/null +++ b/src/td_client.cpp @@ -0,0 +1,66 @@ +#include "td_client.h" + +#include + +#include + +namespace telegram_tui { + +namespace { + +void configure_tdlib_logging() { + const char* stream_request = + R"({"@type":"setLogStream","log_stream":{"@type":"logStreamEmpty"}})"; + const char* verbosity_request = + R"({"@type":"setLogVerbosityLevel","new_verbosity_level":0})"; + + td_json_client_execute(nullptr, stream_request); + td_json_client_execute(nullptr, verbosity_request); +} + +} // namespace + +TdClient::TdClient() { + configure_tdlib_logging(); + client_ = td_json_client_create(); +} + +TdClient::~TdClient() { + if (client_ != nullptr) { + td_json_client_destroy(client_); + } +} + +void TdClient::send(const json& request) { + const std::string payload = request.dump(); + td_json_client_send(client_, payload.c_str()); +} + +std::optional TdClient::execute(const json& request) { + const std::string payload = request.dump(); + const char* raw = td_json_client_execute(client_, payload.c_str()); + if (raw == nullptr) { + return std::nullopt; + } + + try { + return json::parse(raw); + } catch (...) { + return std::nullopt; + } +} + +std::optional TdClient::receive(double timeout_seconds) { + const char* raw = td_json_client_receive(client_, timeout_seconds); + if (raw == nullptr) { + return std::nullopt; + } + + try { + return json::parse(raw); + } catch (...) { + return std::nullopt; + } +} + +} // namespace telegram_tui diff --git a/src/td_client.h b/src/td_client.h new file mode 100644 index 0000000..05e5260 --- /dev/null +++ b/src/td_client.h @@ -0,0 +1,25 @@ +#pragma once + +#include + +#include "json.h" + +namespace telegram_tui { + +class TdClient { + public: + TdClient(); + ~TdClient(); + + TdClient(const TdClient&) = delete; + TdClient& operator=(const TdClient&) = delete; + + void send(const json& request); + [[nodiscard]] std::optional execute(const json& request); + [[nodiscard]] std::optional receive(double timeout_seconds); + + private: + void* client_ = nullptr; +}; + +} // namespace telegram_tui diff --git a/src/util.cpp b/src/util.cpp new file mode 100644 index 0000000..d32990b --- /dev/null +++ b/src/util.cpp @@ -0,0 +1,366 @@ +#include "util.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace telegram_tui { + +namespace { + +std::size_t utf8_sequence_size(unsigned char lead) { + if ((lead & 0x80U) == 0) { + return 1; + } + if ((lead & 0xE0U) == 0xC0U) { + return 2; + } + if ((lead & 0xF0U) == 0xE0U) { + return 3; + } + if ((lead & 0xF8U) == 0xF0U) { + return 4; + } + return 1; +} + +bool is_utf8_continuation(unsigned char ch) { + return (ch & 0xC0U) == 0x80U; +} + +std::uint32_t decode_utf8_codepoint(const std::string& text, std::size_t offset, std::size_t* size_out = nullptr) { + if (offset >= text.size()) { + if (size_out != nullptr) { + *size_out = 0; + } + return 0; + } + + const auto lead = static_cast(text[offset]); + std::size_t size = utf8_sequence_size(lead); + if (offset + size > text.size()) { + size = 1; + } + for (std::size_t i = 1; i < size; ++i) { + if (!is_utf8_continuation(static_cast(text[offset + i]))) { + size = 1; + break; + } + } + + if (size_out != nullptr) { + *size_out = size; + } + + if (size == 1) { + return lead; + } + if (size == 2) { + return ((lead & 0x1FU) << 6) | + (static_cast(text[offset + 1]) & 0x3FU); + } + if (size == 3) { + return ((lead & 0x0FU) << 12) | + ((static_cast(text[offset + 1]) & 0x3FU) << 6) | + (static_cast(text[offset + 2]) & 0x3FU); + } + return ((lead & 0x07U) << 18) | + ((static_cast(text[offset + 1]) & 0x3FU) << 12) | + ((static_cast(text[offset + 2]) & 0x3FU) << 6) | + (static_cast(text[offset + 3]) & 0x3FU); +} + +} // namespace + +std::string get_env(const char* name) { + const char* value = std::getenv(name); + return value == nullptr ? std::string() : std::string(value); +} + +std::string trim_copy(std::string value) { + auto not_space = [](unsigned char c) { return !std::isspace(c); }; + while (!value.empty() && !not_space(static_cast(value.front()))) { + value.erase(value.begin()); + } + while (!value.empty() && !not_space(static_cast(value.back()))) { + value.pop_back(); + } + return value; +} + +std::string single_line(std::string text) { + for (char& c : text) { + if (c == '\n' || c == '\r' || c == '\t') { + c = ' '; + } + } + return trim_copy(std::move(text)); +} + +bool is_decimal_number(const std::string& value) { + return !value.empty() && + std::all_of(value.begin(), value.end(), [](unsigned char ch) { return std::isdigit(ch); }); +} + +std::filesystem::path data_root() { + if (const char* xdg = std::getenv("XDG_DATA_HOME"); xdg != nullptr && *xdg != '\0') { + return std::filesystem::path(xdg) / "telegram-tui"; + } + if (const char* home = std::getenv("HOME"); home != nullptr && *home != '\0') { + return std::filesystem::path(home) / ".local" / "share" / "telegram-tui"; + } + return std::filesystem::current_path() / ".telegram-tui-data"; +} + +StoredConfig load_app_config() { + const std::filesystem::path path = data_root() / "config.json"; + std::ifstream input(path); + if (!input.is_open()) { + return {}; + } + + try { + const json config = json::parse(input, nullptr, true, true); + if (!config.is_object()) { + return {}; + } + return StoredConfig{ + safe_string(config, "api_id"), + safe_string(config, "api_hash"), + config.value("auto_reload_chat_history", false), + }; + } catch (const json::exception&) { + return {}; + } +} + +bool save_app_config(const StoredConfig& config) { + const std::filesystem::path path = data_root() / "config.json"; + try { + if (path.has_parent_path()) { + std::filesystem::create_directories(path.parent_path()); + } + + json document = json::object(); + if (!config.api_id.empty()) { + document["api_id"] = config.api_id; + } + if (!config.api_hash.empty()) { + document["api_hash"] = config.api_hash; + } + document["auto_reload_chat_history"] = config.auto_reload_chat_history; + + std::ofstream output(path, std::ios::trunc); + if (!output.is_open()) { + return false; + } + output << document.dump(2) << '\n'; + return static_cast(output); + } catch (const std::exception&) { + return false; + } +} + +std::string safe_string(const json& object, const char* key) { + if (!object.contains(key) || !object.at(key).is_string()) { + return {}; + } + return object.at(key).get(); +} + +std::int64_t safe_i64(const json& object, const char* key) { + if (!object.contains(key) || !object.at(key).is_number_integer()) { + return 0; + } + return object.at(key).get(); +} + +std::int32_t safe_i32(const json& object, const char* key) { + if (!object.contains(key) || !object.at(key).is_number_integer()) { + return 0; + } + return object.at(key).get(); +} + +std::string format_time(std::int32_t unix_time) { + if (unix_time <= 0) { + return "--:--"; + } + + std::time_t raw = unix_time; + std::tm tm = *std::localtime(&raw); + char buffer[16] = {}; + std::strftime(buffer, sizeof(buffer), "%H:%M", &tm); + return buffer; +} + +std::string format_date(std::int32_t unix_time) { + if (unix_time <= 0) { + return "Unknown day"; + } + + std::time_t raw = unix_time; + std::tm tm = *std::localtime(&raw); + char buffer[32] = {}; + std::strftime(buffer, sizeof(buffer), "%Y-%m-%d", &tm); + return buffer; +} + +std::string format_datetime(std::int32_t unix_time) { + if (unix_time <= 0) { + return "Unknown time"; + } + + std::time_t raw = unix_time; + std::tm tm = *std::localtime(&raw); + char buffer[32] = {}; + std::strftime(buffer, sizeof(buffer), "%Y-%m-%d %H:%M", &tm); + return buffer; +} + +std::string format_file_size(std::int64_t size_bytes) { + if (size_bytes <= 0) { + return "?"; + } + + static constexpr const char* units[] = {"B", "KB", "MB", "GB", "TB"}; + double size = static_cast(size_bytes); + std::size_t unit_index = 0; + while (size >= 1024.0 && unit_index + 1 < std::size(units)) { + size /= 1024.0; + ++unit_index; + } + + std::ostringstream stream; + stream.setf(std::ios::fixed); + stream.precision(unit_index == 0 ? 0 : 1); + stream << size << ' ' << units[unit_index]; + return stream.str(); +} + +std::vector wrap_text(const std::string& text, int width) { + if (width <= 1) { + return {text}; + } + + std::vector lines; + std::stringstream stream(text); + std::string paragraph; + while (std::getline(stream, paragraph, '\n')) { + paragraph = single_line(std::move(paragraph)); + if (paragraph.empty()) { + lines.emplace_back(); + continue; + } + + std::stringstream words(paragraph); + std::string word; + std::string current; + while (words >> word) { + if (current.empty()) { + current = word; + continue; + } + + if (static_cast(current.size() + 1 + word.size()) > width) { + lines.push_back(current); + current = word; + } else { + current += " "; + current += word; + } + } + if (!current.empty()) { + lines.push_back(current); + } + } + + if (lines.empty()) { + lines.emplace_back(); + } + return lines; +} + +std::size_t utf8_byte_index_from_utf16_offset(const std::string& text, std::size_t utf16_offset) { + std::size_t byte_index = 0; + std::size_t utf16_units = 0; + while (byte_index < text.size() && utf16_units < utf16_offset) { + std::size_t size = 0; + const std::uint32_t codepoint = decode_utf8_codepoint(text, byte_index, &size); + if (size == 0) { + break; + } + const std::size_t units = codepoint > 0xFFFFU ? 2 : 1; + if (utf16_units + units > utf16_offset) { + break; + } + utf16_units += units; + byte_index += size; + } + return byte_index; +} + +std::size_t utf8_prev_index(const std::string& text, std::size_t byte_index) { + if (byte_index == 0 || text.empty()) { + return 0; + } + + std::size_t index = std::min(byte_index, text.size()) - 1; + while (index > 0 && is_utf8_continuation(static_cast(text[index]))) { + --index; + } + return index; +} + +std::size_t utf8_next_index(const std::string& text, std::size_t byte_index) { + if (byte_index >= text.size()) { + return text.size(); + } + + std::size_t size = 0; + decode_utf8_codepoint(text, byte_index, &size); + return std::min(text.size(), byte_index + std::max(1, size)); +} + +int utf8_display_width(const std::string& text, std::size_t byte_limit) { + const std::size_t limit = std::min(byte_limit, text.size()); + int width = 0; + std::size_t index = 0; + while (index < limit) { + std::size_t size = 0; + const std::uint32_t codepoint = decode_utf8_codepoint(text, index, &size); + if (size == 0) { + break; + } + + int char_width = 1; + if (codepoint <= static_cast(WCHAR_MAX)) { + const int measured = ::wcwidth(static_cast(codepoint)); + if (measured > 0) { + char_width = measured; + } + } + width += char_width; + index += size; + } + return width; +} + +void pop_utf8_back(std::string& text) { + if (text.empty()) { + return; + } + + std::size_t start = text.size() - 1; + while (start > 0 && is_utf8_continuation(static_cast(text[start]))) { + --start; + } + text.erase(start); +} + +} // namespace telegram_tui diff --git a/src/util.h b/src/util.h new file mode 100644 index 0000000..2c9a806 --- /dev/null +++ b/src/util.h @@ -0,0 +1,39 @@ +#pragma once + +#include +#include +#include +#include + +#include "json.h" + +namespace telegram_tui { + +struct StoredConfig { + std::string api_id; + std::string api_hash; + bool auto_reload_chat_history = false; +}; + +[[nodiscard]] std::string get_env(const char* name); +[[nodiscard]] std::string trim_copy(std::string value); +[[nodiscard]] std::string single_line(std::string text); +[[nodiscard]] bool is_decimal_number(const std::string& value); +[[nodiscard]] std::filesystem::path data_root(); +[[nodiscard]] StoredConfig load_app_config(); +bool save_app_config(const StoredConfig& config); +[[nodiscard]] std::string safe_string(const json& object, const char* key); +[[nodiscard]] std::int64_t safe_i64(const json& object, const char* key); +[[nodiscard]] std::int32_t safe_i32(const json& object, const char* key); +[[nodiscard]] std::string format_time(std::int32_t unix_time); +[[nodiscard]] std::string format_date(std::int32_t unix_time); +[[nodiscard]] std::string format_datetime(std::int32_t unix_time); +[[nodiscard]] std::string format_file_size(std::int64_t size_bytes); +[[nodiscard]] std::vector wrap_text(const std::string& text, int width); +[[nodiscard]] std::size_t utf8_byte_index_from_utf16_offset(const std::string& text, std::size_t utf16_offset); +[[nodiscard]] std::size_t utf8_prev_index(const std::string& text, std::size_t byte_index); +[[nodiscard]] std::size_t utf8_next_index(const std::string& text, std::size_t byte_index); +[[nodiscard]] int utf8_display_width(const std::string& text, std::size_t byte_limit = std::string::npos); +void pop_utf8_back(std::string& text); + +} // namespace telegram_tui