Add saved GIF picker and docs
All checks were successful
Release App / release-app (push) Successful in 46s
All checks were successful
Release App / release-app (push) Successful in 46s
This commit is contained in:
40
README.md
40
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 <msg> [text]`: prepare a reply to a message reference
|
||||
- `>e <msg> <text>`: edit one of your messages
|
||||
- `>f <msg...>`: forward one or more messages
|
||||
- `>d <msg...>`: 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`.
|
||||
|
||||
13
src/app.h
13
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<std::int64_t> reply_to_message_id = std::nullopt);
|
||||
void send_saved_animation_message(std::int64_t chat_id, const SavedAnimationInfo &animation,
|
||||
std::optional<std::int64_t> reply_to_message_id =
|
||||
std::nullopt);
|
||||
bool preview_clipboard_photo_message(
|
||||
std::int64_t chat_id, const std::string &caption,
|
||||
std::optional<std::int64_t> 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<std::int64_t> highlighted_chat_id() const;
|
||||
[[nodiscard]] std::optional<std::int64_t> 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<std::string> attachment_viewer_lines_;
|
||||
std::vector<std::string> attachment_viewer_animation_frames_;
|
||||
std::vector<SavedAnimationInfo> saved_animations_;
|
||||
json authorization_state_ = json::object();
|
||||
std::optional<AttachmentInfo> pending_attachment_open_;
|
||||
std::optional<AttachmentInfo> pending_attachment_download_;
|
||||
|
||||
@@ -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 {};
|
||||
}
|
||||
|
||||
|
||||
192
src/app_auth.cpp
192
src/app_auth.cpp
@@ -2,6 +2,7 @@
|
||||
|
||||
#include <array>
|
||||
#include <set>
|
||||
#include <cctype>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
@@ -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<char>(std::tolower(static_cast<unsigned char>(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<std::string> 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<unsigned char>(hi)) &&
|
||||
std::isxdigit(static_cast<unsigned char>(lo))) {
|
||||
const std::string hex = value.substr(index + 1, 2);
|
||||
decoded.push_back(
|
||||
static_cast<char>(std::strtoul(hex.c_str(), nullptr, 16)));
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
decoded.push_back(value[index] == '+' ? ' ' : value[index]);
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
std::optional<std::filesystem::path> 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<std::int64_t> 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<std::int64_t> 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;
|
||||
}
|
||||
|
||||
@@ -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 <msg> [text]", "Prepare a reply"),
|
||||
help_item(">paste [caption]", "Send clipboard image"),
|
||||
|
||||
@@ -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<int>(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<int>(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<int>(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<std::size_t>(saved_animation_selection_index_)]);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<std::int64_t> App::forward_target_chat_ids() const {
|
||||
std::vector<std::int64_t> target_chat_ids;
|
||||
target_chat_ids.reserve(sorted_chat_ids_.size());
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <clocale>
|
||||
#include <sstream>
|
||||
|
||||
#include <curses.h>
|
||||
|
||||
@@ -28,6 +29,22 @@ std::string truncate_to_width(std::string text, int max_width) {
|
||||
return text;
|
||||
}
|
||||
|
||||
std::vector<std::string> split_preview_lines(const std::string &text) {
|
||||
std::vector<std::string> 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<int>(saved_animations_.size())) {
|
||||
saved_animation_selection_index_ =
|
||||
std::max(0, static_cast<int>(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<int>(saved_animations_.size())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const SavedAnimationInfo &animation =
|
||||
saved_animations_[static_cast<std::size_t>(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<std::size_t>(
|
||||
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<std::string> preview_lines = split_preview_lines(preview);
|
||||
for (std::size_t i = 0; i < preview_lines.size() &&
|
||||
static_cast<int>(i) < list_height - 3;
|
||||
++i) {
|
||||
mvwaddnstr(window, 6 + static_cast<int>(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") {
|
||||
|
||||
@@ -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<int>(saved_animations_.size())) {
|
||||
saved_animation_selection_index_ =
|
||||
std::max(0, static_cast<int>(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<int>(saved_animations_.size())) {
|
||||
return;
|
||||
}
|
||||
|
||||
SavedAnimationInfo &animation =
|
||||
saved_animations_[static_cast<std::size_t>(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;
|
||||
|
||||
16
src/models.h
16
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;
|
||||
|
||||
Reference in New Issue
Block a user