diff --git a/README.md b/README.md index a7dc2c9..91ee2b6 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,10 @@ A minimal Telegram terminal client built with `ncurses` and TDLib. - interactive login flow inside the TUI - chat list in the left pane - message view in the right pane -- send plain text messages +- keyboard-first text compose, reply, edit, forward, and delete flows +- attachment browser and inline media preview +- clipboard image sending with `>paste` / `>clip` +- saved GIF picker backed by Telegram saved animations - scroll chats and message history with the keyboard ## Requirements @@ -50,11 +53,12 @@ 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. -Clipboard image sending via `>paste` or `>clip` requires an external clipboard tool: -- `wl-clipboard` on Wayland or KDE Plasma Wayland -- `xclip` on X11 +Clipboard image sending via `>paste` or `>clip` supports: +- raw clipboard images via `wl-clipboard` on Wayland or KDE Plasma Wayland +- raw clipboard images via `xclip` on X11 +- KDE Klipper clipboard entries that point to a local image file via `qdbus`/`qdbus6` -Klipper by itself is not a supported image backend for this feature. +Klipper by itself is still not a raw image backend for this feature. To use Telegram test servers instead of production: @@ -100,7 +104,33 @@ as `v1.8.63`. Then update `TDLIB_RELEASE_TAG` in - `Tab`: switch focus between chats and messages - `Enter`: open the selected chat - `i`: start composing a message +- `a`: prepare a reply to the latest message +- `g`: open the saved GIF picker for the current account +- `m`: open the attachments browser for the current chat +- `o`: open the selected attachment from the message pane - `PgUp` / `PgDn`: scroll the current message view - `r`: reload chats or history - `Esc`: cancel current input - `q`: quit + +## Compose Commands + +While composing, these commands are available: + +- `>r [text]`: prepare a reply to a message reference +- `>e `: edit one of your messages +- `>f `: forward one or more messages +- `>d `: delete one or more of your messages +- `>paste [caption]` or `>clip [caption]`: send an image from the clipboard + +Message references can be a visible message number such as `12`, a raw Telegram message id, +or a range/list such as `3,5,8-10` where supported. + +## Saved GIF Picker + +Press `g` in an open chat to fetch saved animations from the account and open the picker. +Use `Up` / `Down` to move, `r` to refresh, and `Enter` to send the selected GIF. + +If the GIF file is already cached locally, the picker renders a static preview frame using the +same preview backend as other media. For image previews, install one of `chafa`, `kitten`, or +`img2sixel`; for video/GIF thumbnail extraction, install `ffmpegthumbnailer` or `ffmpeg`. diff --git a/src/app.h b/src/app.h index b00f2c9..7ae1a6f 100644 --- a/src/app.h +++ b/src/app.h @@ -66,9 +66,15 @@ class App { void send_photo_message(std::int64_t chat_id, const std::string &photo_path, const std::string &caption, std::optional reply_to_message_id = std::nullopt); + void send_saved_animation_message(std::int64_t chat_id, const SavedAnimationInfo &animation, + 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_saved_animations(bool force); + void sync_saved_animations(const json &animations); + void ensure_saved_animation_preview(); void request_more_chats(); bool request_chat_history(std::int64_t chat_id, bool force); bool request_open_chat_history(bool force); @@ -141,6 +147,7 @@ class App { void handle_help_menu_key(int ch); void handle_attachments_menu_key(int ch); void handle_attachment_action_menu_key(int ch); + void handle_saved_animation_menu_key(int ch); void handle_attachment_viewer_key(int ch); void draw(); void init_colors(); @@ -150,6 +157,7 @@ class App { 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_saved_animation_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; @@ -168,11 +176,14 @@ class App { bool attachments_menu_open_ = false; bool attachment_action_menu_open_ = false; bool attachment_viewer_open_ = false; + bool saved_animation_menu_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; + bool saved_animations_loading_ = false; + bool saved_animations_loaded_ = false; FocusPane focus_ = FocusPane::Chats; InputMode input_mode_ = InputMode::None; @@ -183,6 +194,7 @@ class App { int attachment_selection_index_ = 0; int attachment_action_index_ = 0; int attachment_viewer_scroll_ = 0; + int saved_animation_selection_index_ = 0; int forward_target_index_ = 0; int help_menu_page_ = 0; std::int64_t open_chat_id_ = 0; @@ -206,6 +218,7 @@ class App { std::string attachment_preview_signature_; std::vector attachment_viewer_lines_; std::vector attachment_viewer_animation_frames_; + std::vector saved_animations_; json authorization_state_ = json::object(); std::optional pending_attachment_open_; std::optional pending_attachment_download_; diff --git a/src/app_attachments.cpp b/src/app_attachments.cpp index 78ad9ee..2c0c698 100644 --- a/src/app_attachments.cpp +++ b/src/app_attachments.cpp @@ -109,16 +109,24 @@ std::string inline_preview_unavailable_message(const AttachmentInfo &attachment) return "No supported preview backend found. Install `kitten`, `img2sixel`, or " "`chafa`."; case AttachmentType::Video: + case AttachmentType::Animation: if (!attachment.is_downloaded || attachment.local_path.empty()) { - return "Video is not downloaded yet."; + return attachment.type == AttachmentType::Animation + ? "GIF is not downloaded yet." + : "Video is not downloaded yet."; } if (!std::filesystem::exists(attachment.local_path)) { - return "Downloaded video file is missing on disk."; + return attachment.type == AttachmentType::Animation + ? "Downloaded GIF file is missing on disk." + : "Downloaded video file is missing on disk."; } - return "Video preview requires `ffmpegthumbnailer` or `ffmpeg`, plus `kitten`, " - "`img2sixel`, or `chafa`."; + return attachment.type == AttachmentType::Animation + ? "GIF preview requires `ffmpegthumbnailer` or `ffmpeg`, plus " + "`kitten`, `img2sixel`, or `chafa`." + : "Video preview requires `ffmpegthumbnailer` or `ffmpeg`, plus " + "`kitten`, `img2sixel`, or `chafa`."; default: - return "Inline preview is only supported for photos and videos."; + return "Inline preview is only supported for photos, videos, and GIFs."; } } @@ -907,7 +915,7 @@ std::string App::attachment_preview_path(const AttachmentInfo &attachment) const if (attachment.type == AttachmentType::Photo) { return attachment.local_path; } - if (attachment.type != AttachmentType::Video) { + if (attachment.type != AttachmentType::Video && attachment.type != AttachmentType::Animation) { return {}; } diff --git a/src/app_auth.cpp b/src/app_auth.cpp index e5cbd42..7263e34 100644 --- a/src/app_auth.cpp +++ b/src/app_auth.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -78,10 +79,95 @@ bool is_kde_session() { session_desktop.find("plasma") != std::string::npos; } +std::string lower_copy(std::string value) { + for (char &ch : value) { + ch = static_cast(std::tolower(static_cast(ch))); + } + return value; +} + +std::string clipboard_text_capture_command() { + if (command_exists("qdbus6")) { + return "qdbus6 org.kde.klipper /klipper org.kde.klipper.klipper.getClipboardContents " + "2>/dev/null"; + } + if (command_exists("qdbus")) { + return "qdbus org.kde.klipper /klipper org.kde.klipper.klipper.getClipboardContents " + "2>/dev/null"; + } + return {}; +} + +bool is_supported_image_path(const std::filesystem::path &path) { + static const std::set kExtensions = { + ".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tif", ".tiff", ".gif", + }; + const std::string extension = lower_copy(path.extension().string()); + return kExtensions.count(extension) != 0 && std::filesystem::is_regular_file(path); +} + +std::string percent_decode(std::string value) { + std::string decoded; + decoded.reserve(value.size()); + for (std::size_t index = 0; index < value.size(); ++index) { + if (value[index] == '%' && index + 2 < value.size()) { + const char hi = value[index + 1]; + const char lo = value[index + 2]; + if (std::isxdigit(static_cast(hi)) && + std::isxdigit(static_cast(lo))) { + const std::string hex = value.substr(index + 1, 2); + decoded.push_back( + static_cast(std::strtoul(hex.c_str(), nullptr, 16))); + index += 2; + continue; + } + } + decoded.push_back(value[index] == '+' ? ' ' : value[index]); + } + return decoded; +} + +std::optional clipboard_image_file_path() { + const std::string command = clipboard_text_capture_command(); + if (command.empty()) { + return std::nullopt; + } + + const std::string clipboard_text = trim_copy(run_command_capture(command)); + if (clipboard_text.empty()) { + return std::nullopt; + } + + std::istringstream lines(clipboard_text); + for (std::string line; std::getline(lines, line);) { + line = trim_copy(std::move(line)); + if (line.empty()) { + continue; + } + + std::filesystem::path candidate; + if (line.rfind("file://", 0) == 0) { + candidate = percent_decode(line.substr(7)); + } else if (!line.empty() && line.front() == '/') { + candidate = line; + } else { + continue; + } + + if (is_supported_image_path(candidate)) { + return candidate; + } + } + return std::nullopt; +} + std::string missing_clipboard_tool_hint() { if (command_exists("wl-paste") || command_exists("xclip") || command_exists("pngpaste")) { return {}; } + if (!clipboard_text_capture_command().empty()) { + return " KDE clipboard file references are supported, but raw image clipboard access still needs wl-clipboard or xclip."; + } if (is_wayland_session()) { if (is_kde_session()) { @@ -606,49 +692,89 @@ void App::send_photo_message(std::int64_t chat_id, const std::string &photo_path status_line_ = "Photo queued."; } +void App::send_saved_animation_message(std::int64_t chat_id, const SavedAnimationInfo &animation, + std::optional reply_to_message_id) { + if (animation.file_id == 0) { + status_line_ = "Saved GIF is unavailable."; + return; + } + + json request = { + {"@type", "sendMessage"}, + {"chat_id", chat_id}, + {"input_message_content", + { + {"@type", "inputMessageAnimation"}, + {"animation", {{"@type", "inputFileId"}, {"id", animation.file_id}}}, + {"thumbnail", nullptr}, + {"added_sticker_file_ids", json::array()}, + {"duration", animation.duration}, + {"width", animation.width}, + {"height", animation.height}, + {"caption", nullptr}, + {"show_caption_above_media", false}, + {"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_ = "GIF 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." + - missing_clipboard_tool_hint(); - 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 (!clipboard_type.has_value()) { + const auto clipboard_image_path = clipboard_image_file_path(); + if (!clipboard_image_path.has_value()) { + status_line_ = "Clipboard doesn't contain an image, or no clipboard tool is available." + + missing_clipboard_tool_hint(); + return false; + } + image_path = *clipboard_image_path; + } else { + 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; } - } - 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; + 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); + if (clipboard_type.has_value()) { + std::filesystem::remove(image_path); + } status_line_ = "Clipboard image is empty."; return false; } diff --git a/src/app_help.cpp b/src/app_help.cpp index ef318fa..8d501ba 100644 --- a/src/app_help.cpp +++ b/src/app_help.cpp @@ -102,6 +102,7 @@ void App::draw_help_menu(int height, int width) { "", "Compose", help_item("i", "Compose a message"), + help_item("g", "Open saved GIF picker"), help_item("a", "Prepare reply to latest"), help_item(">r [text]", "Prepare a reply"), help_item(">paste [caption]", "Send clipboard image"), diff --git a/src/app_input.cpp b/src/app_input.cpp index 0d80734..2c55a49 100644 --- a/src/app_input.cpp +++ b/src/app_input.cpp @@ -108,6 +108,10 @@ void App::handle_key(int ch) { handle_attachment_action_menu_key(ch); return; } + if (saved_animation_menu_open_) { + handle_saved_animation_menu_key(ch); + return; + } if (attachments_menu_open_) { handle_attachments_menu_key(ch); return; @@ -138,6 +142,21 @@ void App::handle_key(int ch) { attachment_selection_index_ = 0; status_line_ = "Attachments."; return; + case 'g': + if (!authorized_) { + status_line_ = "Finish login first."; + return; + } + if (!open_chat_id().has_value()) { + status_line_ = "Open chat first."; + return; + } + saved_animation_menu_open_ = true; + saved_animation_selection_index_ = 0; + request_saved_animations(false); + ensure_saved_animation_preview(); + status_line_ = "Saved GIFs."; + return; case '\t': focus_ = focus_ == FocusPane::Chats ? FocusPane::Messages : FocusPane::Chats; return; @@ -270,6 +289,9 @@ void App::handle_wide_char(wint_t ch) { if (attachments_menu_open_) { return; } + if (saved_animation_menu_open_) { + return; + } if (forward_target_menu_open_) { return; } @@ -380,6 +402,65 @@ void App::handle_forward_target_menu_key(int ch) { } } +void App::handle_saved_animation_menu_key(int ch) { + switch (ch) { + case 27: + case 'q': + case 'g': + saved_animation_menu_open_ = false; + status_line_ = "Closed saved GIFs."; + return; + case 'r': + request_saved_animations(true); + status_line_ = "Refreshing saved GIFs..."; + return; + case KEY_UP: + if (saved_animation_selection_index_ > 0) { + --saved_animation_selection_index_; + ensure_saved_animation_preview(); + } + return; + case KEY_DOWN: + if (saved_animation_selection_index_ + 1 < static_cast(saved_animations_.size())) { + ++saved_animation_selection_index_; + ensure_saved_animation_preview(); + } + return; + case KEY_PPAGE: + saved_animation_selection_index_ = + std::max(0, saved_animation_selection_index_ - 10); + ensure_saved_animation_preview(); + return; + case KEY_NPAGE: + saved_animation_selection_index_ = + std::min(std::max(0, static_cast(saved_animations_.size()) - 1), + saved_animation_selection_index_ + 10); + ensure_saved_animation_preview(); + return; + case '\n': + case KEY_ENTER: + case ' ': { + const auto chat_id = open_chat_id(); + if (!chat_id.has_value()) { + saved_animation_menu_open_ = false; + status_line_ = "Open chat first."; + return; + } + if (saved_animation_selection_index_ < 0 || + saved_animation_selection_index_ >= static_cast(saved_animations_.size())) { + status_line_ = "No saved GIF selected."; + return; + } + saved_animation_menu_open_ = false; + send_saved_animation_message( + *chat_id, saved_animations_[static_cast(saved_animation_selection_index_)]); + return; + } + default: + return; + } +} + std::vector App::forward_target_chat_ids() const { std::vector target_chat_ids; target_chat_ids.reserve(sorted_chat_ids_.size()); diff --git a/src/app_shell.cpp b/src/app_shell.cpp index a12133a..d38fbbf 100644 --- a/src/app_shell.cpp +++ b/src/app_shell.cpp @@ -2,6 +2,7 @@ #include #include +#include #include @@ -28,6 +29,22 @@ std::string truncate_to_width(std::string text, int max_width) { return text; } +std::vector split_preview_lines(const std::string &text) { + 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 (lines.empty()) { + lines.push_back(text); + } + return lines; +} + } // namespace void App::init_curses() { @@ -150,6 +167,9 @@ void App::draw() { draw_attachment_viewer(height, width); } else if (attachment_action_menu_open_) { draw_attachment_action_menu(height, width); + } else if (saved_animation_menu_open_) { + clear_attachment_preview_graphics(); + draw_saved_animation_menu(height, width); } else if (attachments_menu_open_) { draw_attachments_menu(height, width); } else if (forward_target_menu_open_) { @@ -249,6 +269,116 @@ void App::draw_forward_target_menu(int height, int width) { delwin(window); } +void App::draw_saved_animation_menu(int height, int width) { + const int menu_width = std::min(width - 4, 110); + const int menu_height = std::min(height - 4, 28); + const int top = std::max(1, (height - menu_height) / 2); + const int left = std::max(1, (width - menu_width) / 2); + + WINDOW *window = newwin(menu_height, menu_width, top, left); + if (window == nullptr) { + return; + } + + if (saved_animation_selection_index_ < 0) { + saved_animation_selection_index_ = 0; + } + if (saved_animation_selection_index_ >= static_cast(saved_animations_.size())) { + saved_animation_selection_index_ = + std::max(0, static_cast(saved_animations_.size()) - 1); + } + + box(window, 0, 0); + mvwprintw(window, 0, 2, " Saved GIFs "); + std::string subtitle = saved_animations_loading_ + ? "Loading..." + : std::to_string(saved_animations_.size()) + " saved"; + mvwaddnstr(window, 1, 2, subtitle.c_str(), menu_width - 4); + mvwhline(window, 2, 1, ACS_HLINE, menu_width - 2); + + const int list_width = std::max(28, menu_width / 2 - 1); + const int preview_left = list_width + 2; + const int preview_width = std::max(10, menu_width - preview_left - 2); + const int list_top = 3; + const int list_height = std::max(1, menu_height - 6); + int first_index = 0; + if (saved_animation_selection_index_ >= list_height) { + first_index = saved_animation_selection_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, ' ', list_width); + if (item_index >= static_cast(saved_animations_.size())) { + continue; + } + + const SavedAnimationInfo &animation = + saved_animations_[static_cast(item_index)]; + std::string label = animation.name; + if (animation.is_downloading_active && !animation.is_downloaded) { + label += " [dl]"; + } else if (animation.is_downloaded) { + label += " [ready]"; + } + if (item_index == saved_animation_selection_index_) { + wattron(window, A_REVERSE | A_BOLD); + } + mvwaddnstr(window, y, 2, truncate_to_width(label, list_width - 2).c_str(), + list_width - 2); + if (item_index == saved_animation_selection_index_) { + wattroff(window, A_REVERSE | A_BOLD); + } + } + + mvwvline(window, 3, preview_left - 1, ACS_VLINE, menu_height - 4); + + if (saved_animations_.empty()) { + mvwaddnstr(window, 4, preview_left, "No saved GIFs on this account.", preview_width); + } else { + const SavedAnimationInfo &animation = saved_animations_[static_cast( + saved_animation_selection_index_)]; + const std::string title = truncate_to_width(animation.name, preview_width); + mvwaddnstr(window, 3, preview_left, title.c_str(), preview_width); + const std::string meta = + truncate_to_width(format_file_size(animation.size_bytes) + " " + + std::to_string(std::max(0, animation.width)) + "x" + + std::to_string(std::max(0, animation.height)) + " " + + std::to_string(std::max(0, animation.duration)) + "s", + preview_width); + mvwaddnstr(window, 4, preview_left, meta.c_str(), preview_width); + mvwhline(window, 5, preview_left, ACS_HLINE, preview_width); + + AttachmentInfo preview_attachment; + preview_attachment.type = AttachmentType::Animation; + preview_attachment.name = animation.name; + preview_attachment.size_bytes = animation.size_bytes; + preview_attachment.downloaded_size = animation.downloaded_size; + preview_attachment.file_id = animation.file_id; + preview_attachment.local_path = animation.local_path; + preview_attachment.is_downloading_active = animation.is_downloading_active; + preview_attachment.can_be_downloaded = animation.can_be_downloaded; + preview_attachment.can_be_deleted = animation.can_be_deleted; + preview_attachment.is_downloaded = animation.is_downloaded; + + const std::string preview = render_attachment_preview( + preview_attachment, preview_width, std::max(4, list_height - 4)); + const std::vector preview_lines = split_preview_lines(preview); + for (std::size_t i = 0; i < preview_lines.size() && + static_cast(i) < list_height - 3; + ++i) { + mvwaddnstr(window, 6 + static_cast(i), preview_left, + truncate_to_width(preview_lines[i], preview_width).c_str(), preview_width); + } + } + + mvwaddnstr(window, menu_height - 2, 2, + "Enter send r refresh Up/Down move Esc close", menu_width - 4); + wrefresh(window); + delwin(window); +} + std::string App::current_auth_label() const { const std::string type = safe_string(authorization_state_, "@type"); if (type == "authorizationStateWaitTdlibParameters") { diff --git a/src/app_state.cpp b/src/app_state.cpp index b93c7d4..449ad44 100644 --- a/src/app_state.cpp +++ b/src/app_state.cpp @@ -37,6 +37,97 @@ bool App::process_updates() { return changed; } +void App::request_saved_animations(bool force) { + if (saved_animations_loading_ && !force) { + return; + } + saved_animations_loading_ = true; + td_.send({ + {"@type", "getSavedAnimations"}, + {"@extra", "saved_animations"}, + }); + if (!saved_animation_menu_open_) { + status_line_ = "Loading saved GIFs..."; + } +} + +void App::sync_saved_animations(const json &animations) { + saved_animations_.clear(); + if (!animations.is_array()) { + saved_animations_loading_ = false; + saved_animations_loaded_ = true; + return; + } + + for (const auto &item : animations) { + if (!item.is_object()) { + continue; + } + const json file = item.value("animation", json::object()); + const std::int32_t file_id = safe_i32(file, "id"); + if (file_id == 0) { + continue; + } + + SavedAnimationInfo animation; + animation.file_id = file_id; + animation.name = safe_string(item, "file_name"); + if (animation.name.empty()) { + animation.name = "animation"; + } + animation.mime_type = safe_string(item, "mime_type"); + animation.size_bytes = std::max(safe_i64(file, "size"), safe_i64(file, "expected_size")); + animation.downloaded_size = safe_i64(file.value("local", json::object()), "downloaded_size"); + animation.duration = safe_i32(item, "duration"); + animation.width = safe_i32(item, "width"); + animation.height = safe_i32(item, "height"); + animation.local_path = safe_string(file.value("local", json::object()), "path"); + animation.is_downloading_active = + file.value("local", json::object()).value("is_downloading_active", false); + animation.can_be_downloaded = + file.value("local", json::object()).value("can_be_downloaded", false); + animation.can_be_deleted = + file.value("local", json::object()).value("can_be_deleted", false); + animation.is_downloaded = + file.value("local", json::object()).value("is_downloading_completed", false); + saved_animations_.push_back(std::move(animation)); + } + + if (saved_animation_selection_index_ < 0) { + saved_animation_selection_index_ = 0; + } + if (saved_animation_selection_index_ >= static_cast(saved_animations_.size())) { + saved_animation_selection_index_ = + std::max(0, static_cast(saved_animations_.size()) - 1); + } + saved_animations_loading_ = false; + saved_animations_loaded_ = true; + ensure_saved_animation_preview(); +} + +void App::ensure_saved_animation_preview() { + if (saved_animation_selection_index_ < 0 || + saved_animation_selection_index_ >= static_cast(saved_animations_.size())) { + return; + } + + SavedAnimationInfo &animation = + saved_animations_[static_cast(saved_animation_selection_index_)]; + if (animation.file_id == 0 || animation.is_downloaded || animation.is_downloading_active || + !animation.can_be_downloaded) { + return; + } + + td_.send({ + {"@type", "downloadFile"}, + {"file_id", animation.file_id}, + {"priority", 1}, + {"offset", 0}, + {"limit", 0}, + {"synchronous", false}, + }); +} + void App::handle_td_object(const json &object) { const std::string type = safe_string(object, "@type"); if (type == "updateAuthorizationState") { @@ -184,6 +275,10 @@ void App::handle_td_object(const json &object) { update_attachment_file(object.value("file", json::object())); return; } + if (type == "updateSavedAnimations") { + request_saved_animations(true); + return; + } if (type == "updateOption" || type == "ok" || type == "userFullInfo" || type == "updateHavePendingNotifications" || type == "updateUnreadMessageCount" || type == "updateUnreadChatCount") { @@ -224,6 +319,16 @@ void App::handle_td_object(const json &object) { } return; } + if (type == "animations") { + const std::string extra = safe_string(object, "@extra"); + if (extra == "saved_animations") { + sync_saved_animations(object.value("animations", json::array())); + if (saved_animation_menu_open_) { + status_line_ = saved_animations_.empty() ? "No saved GIFs." : "Saved GIFs."; + } + } + return; + } if (type == "updateMessageSendSucceeded") { const json message = object.value("message", json::object()); const std::int64_t chat_id = safe_i64(message, "chat_id"); @@ -509,6 +614,21 @@ void App::update_attachment_file(const json &file) { } } + for (auto &animation : saved_animations_) { + if (animation.file_id != file_id) { + continue; + } + if (size_bytes > 0) { + animation.size_bytes = size_bytes; + } + animation.downloaded_size = downloaded_size; + animation.local_path = local_path; + animation.is_downloading_active = is_downloading_active; + animation.is_downloaded = is_downloaded; + animation.can_be_downloaded = can_be_downloaded; + animation.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; diff --git a/src/models.h b/src/models.h index 73f5b83..f827d10 100644 --- a/src/models.h +++ b/src/models.h @@ -57,6 +57,22 @@ struct MessageInfo { std::string via_bot; }; +struct SavedAnimationInfo { + std::int32_t file_id = 0; + std::string name; + std::string mime_type; + std::int64_t size_bytes = 0; + std::int64_t downloaded_size = 0; + std::int32_t duration = 0; + std::int32_t width = 0; + std::int32_t height = 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 ChatInfo { std::int64_t id = 0; std::int64_t private_user_id = 0;