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
|
- interactive login flow inside the TUI
|
||||||
- chat list in the left pane
|
- chat list in the left pane
|
||||||
- message view in the right 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
|
- scroll chats and message history with the keyboard
|
||||||
|
|
||||||
## Requirements
|
## 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
|
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.
|
`~/.local/share/telegram-tui/config.json` and reuses them on later launches.
|
||||||
|
|
||||||
Clipboard image sending via `>paste` or `>clip` requires an external clipboard tool:
|
Clipboard image sending via `>paste` or `>clip` supports:
|
||||||
- `wl-clipboard` on Wayland or KDE Plasma Wayland
|
- raw clipboard images via `wl-clipboard` on Wayland or KDE Plasma Wayland
|
||||||
- `xclip` on X11
|
- 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:
|
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
|
- `Tab`: switch focus between chats and messages
|
||||||
- `Enter`: open the selected chat
|
- `Enter`: open the selected chat
|
||||||
- `i`: start composing a message
|
- `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
|
- `PgUp` / `PgDn`: scroll the current message view
|
||||||
- `r`: reload chats or history
|
- `r`: reload chats or history
|
||||||
- `Esc`: cancel current input
|
- `Esc`: cancel current input
|
||||||
- `q`: quit
|
- `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,
|
void send_photo_message(std::int64_t chat_id, const std::string &photo_path,
|
||||||
const std::string &caption,
|
const std::string &caption,
|
||||||
std::optional<std::int64_t> reply_to_message_id = std::nullopt);
|
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(
|
bool preview_clipboard_photo_message(
|
||||||
std::int64_t chat_id, const std::string &caption,
|
std::int64_t chat_id, const std::string &caption,
|
||||||
std::optional<std::int64_t> reply_to_message_id = std::nullopt);
|
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();
|
void request_more_chats();
|
||||||
bool request_chat_history(std::int64_t chat_id, bool force);
|
bool request_chat_history(std::int64_t chat_id, bool force);
|
||||||
bool request_open_chat_history(bool force);
|
bool request_open_chat_history(bool force);
|
||||||
@@ -141,6 +147,7 @@ class App {
|
|||||||
void handle_help_menu_key(int ch);
|
void handle_help_menu_key(int ch);
|
||||||
void handle_attachments_menu_key(int ch);
|
void handle_attachments_menu_key(int ch);
|
||||||
void handle_attachment_action_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 handle_attachment_viewer_key(int ch);
|
||||||
void draw();
|
void draw();
|
||||||
void init_colors();
|
void init_colors();
|
||||||
@@ -150,6 +157,7 @@ class App {
|
|||||||
void draw_forward_target_menu(int height, int width);
|
void draw_forward_target_menu(int height, int width);
|
||||||
void draw_attachments_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_action_menu(int height, int width);
|
||||||
|
void draw_saved_animation_menu(int height, int width);
|
||||||
void draw_attachment_viewer(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> highlighted_chat_id() const;
|
||||||
[[nodiscard]] std::optional<std::int64_t> open_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 attachments_menu_open_ = false;
|
||||||
bool attachment_action_menu_open_ = false;
|
bool attachment_action_menu_open_ = false;
|
||||||
bool attachment_viewer_open_ = false;
|
bool attachment_viewer_open_ = false;
|
||||||
|
bool saved_animation_menu_open_ = false;
|
||||||
bool forward_target_menu_open_ = false;
|
bool forward_target_menu_open_ = false;
|
||||||
bool help_menu_open_ = false;
|
bool help_menu_open_ = false;
|
||||||
bool input_hidden_ = false;
|
bool input_hidden_ = false;
|
||||||
bool auto_reload_chat_history_ = false;
|
bool auto_reload_chat_history_ = false;
|
||||||
bool use_test_dc_ = false;
|
bool use_test_dc_ = false;
|
||||||
|
bool saved_animations_loading_ = false;
|
||||||
|
bool saved_animations_loaded_ = false;
|
||||||
|
|
||||||
FocusPane focus_ = FocusPane::Chats;
|
FocusPane focus_ = FocusPane::Chats;
|
||||||
InputMode input_mode_ = InputMode::None;
|
InputMode input_mode_ = InputMode::None;
|
||||||
@@ -183,6 +194,7 @@ class App {
|
|||||||
int attachment_selection_index_ = 0;
|
int attachment_selection_index_ = 0;
|
||||||
int attachment_action_index_ = 0;
|
int attachment_action_index_ = 0;
|
||||||
int attachment_viewer_scroll_ = 0;
|
int attachment_viewer_scroll_ = 0;
|
||||||
|
int saved_animation_selection_index_ = 0;
|
||||||
int forward_target_index_ = 0;
|
int forward_target_index_ = 0;
|
||||||
int help_menu_page_ = 0;
|
int help_menu_page_ = 0;
|
||||||
std::int64_t open_chat_id_ = 0;
|
std::int64_t open_chat_id_ = 0;
|
||||||
@@ -206,6 +218,7 @@ class App {
|
|||||||
std::string attachment_preview_signature_;
|
std::string attachment_preview_signature_;
|
||||||
std::vector<std::string> attachment_viewer_lines_;
|
std::vector<std::string> attachment_viewer_lines_;
|
||||||
std::vector<std::string> attachment_viewer_animation_frames_;
|
std::vector<std::string> attachment_viewer_animation_frames_;
|
||||||
|
std::vector<SavedAnimationInfo> saved_animations_;
|
||||||
json authorization_state_ = json::object();
|
json authorization_state_ = json::object();
|
||||||
std::optional<AttachmentInfo> pending_attachment_open_;
|
std::optional<AttachmentInfo> pending_attachment_open_;
|
||||||
std::optional<AttachmentInfo> pending_attachment_download_;
|
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 "
|
return "No supported preview backend found. Install `kitten`, `img2sixel`, or "
|
||||||
"`chafa`.";
|
"`chafa`.";
|
||||||
case AttachmentType::Video:
|
case AttachmentType::Video:
|
||||||
|
case AttachmentType::Animation:
|
||||||
if (!attachment.is_downloaded || attachment.local_path.empty()) {
|
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)) {
|
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`, "
|
return attachment.type == AttachmentType::Animation
|
||||||
"`img2sixel`, or `chafa`.";
|
? "GIF preview requires `ffmpegthumbnailer` or `ffmpeg`, plus "
|
||||||
|
"`kitten`, `img2sixel`, or `chafa`."
|
||||||
|
: "Video preview requires `ffmpegthumbnailer` or `ffmpeg`, plus "
|
||||||
|
"`kitten`, `img2sixel`, or `chafa`.";
|
||||||
default:
|
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) {
|
if (attachment.type == AttachmentType::Photo) {
|
||||||
return attachment.local_path;
|
return attachment.local_path;
|
||||||
}
|
}
|
||||||
if (attachment.type != AttachmentType::Video) {
|
if (attachment.type != AttachmentType::Video && attachment.type != AttachmentType::Animation) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
130
src/app_auth.cpp
130
src/app_auth.cpp
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
#include <array>
|
#include <array>
|
||||||
#include <set>
|
#include <set>
|
||||||
|
#include <cctype>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
@@ -78,10 +79,95 @@ bool is_kde_session() {
|
|||||||
session_desktop.find("plasma") != std::string::npos;
|
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() {
|
std::string missing_clipboard_tool_hint() {
|
||||||
if (command_exists("wl-paste") || command_exists("xclip") || command_exists("pngpaste")) {
|
if (command_exists("wl-paste") || command_exists("xclip") || command_exists("pngpaste")) {
|
||||||
return {};
|
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_wayland_session()) {
|
||||||
if (is_kde_session()) {
|
if (is_kde_session()) {
|
||||||
@@ -606,15 +692,53 @@ void App::send_photo_message(std::int64_t chat_id, const std::string &photo_path
|
|||||||
status_line_ = "Photo queued.";
|
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,
|
bool App::preview_clipboard_photo_message(std::int64_t chat_id, const std::string &caption,
|
||||||
std::optional<std::int64_t> reply_to_message_id) {
|
std::optional<std::int64_t> reply_to_message_id) {
|
||||||
const auto clipboard_type = detect_clipboard_image_type();
|
const auto clipboard_type = detect_clipboard_image_type();
|
||||||
|
std::filesystem::path image_path;
|
||||||
if (!clipboard_type.has_value()) {
|
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." +
|
status_line_ = "Clipboard doesn't contain an image, or no clipboard tool is available." +
|
||||||
missing_clipboard_tool_hint();
|
missing_clipboard_tool_hint();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
image_path = *clipboard_image_path;
|
||||||
|
} else {
|
||||||
const std::filesystem::path clipboard_dir = files_dir_ / "clipboard";
|
const std::filesystem::path clipboard_dir = files_dir_ / "clipboard";
|
||||||
try {
|
try {
|
||||||
std::filesystem::create_directories(clipboard_dir);
|
std::filesystem::create_directories(clipboard_dir);
|
||||||
@@ -623,7 +747,6 @@ bool App::preview_clipboard_photo_message(std::int64_t chat_id, const std::strin
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::filesystem::path image_path;
|
|
||||||
for (std::uint64_t index = 0; index < 1024; ++index) {
|
for (std::uint64_t index = 0; index < 1024; ++index) {
|
||||||
const std::filesystem::path candidate =
|
const std::filesystem::path candidate =
|
||||||
clipboard_dir / ("clipboard-" + std::to_string(std::time(nullptr)) + "-" +
|
clipboard_dir / ("clipboard-" + std::to_string(std::time(nullptr)) + "-" +
|
||||||
@@ -645,10 +768,13 @@ bool App::preview_clipboard_photo_message(std::int64_t chat_id, const std::strin
|
|||||||
status_line_ = "Failed to read image data from the clipboard.";
|
status_line_ = "Failed to read image data from the clipboard.";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (std::filesystem::file_size(image_path) == 0) {
|
if (std::filesystem::file_size(image_path) == 0) {
|
||||||
|
if (clipboard_type.has_value()) {
|
||||||
std::filesystem::remove(image_path);
|
std::filesystem::remove(image_path);
|
||||||
|
}
|
||||||
status_line_ = "Clipboard image is empty.";
|
status_line_ = "Clipboard image is empty.";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ void App::draw_help_menu(int height, int width) {
|
|||||||
"",
|
"",
|
||||||
"Compose",
|
"Compose",
|
||||||
help_item("i", "Compose a message"),
|
help_item("i", "Compose a message"),
|
||||||
|
help_item("g", "Open saved GIF picker"),
|
||||||
help_item("a", "Prepare reply to latest"),
|
help_item("a", "Prepare reply to latest"),
|
||||||
help_item(">r <msg> [text]", "Prepare a reply"),
|
help_item(">r <msg> [text]", "Prepare a reply"),
|
||||||
help_item(">paste [caption]", "Send clipboard image"),
|
help_item(">paste [caption]", "Send clipboard image"),
|
||||||
|
|||||||
@@ -108,6 +108,10 @@ void App::handle_key(int ch) {
|
|||||||
handle_attachment_action_menu_key(ch);
|
handle_attachment_action_menu_key(ch);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (saved_animation_menu_open_) {
|
||||||
|
handle_saved_animation_menu_key(ch);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (attachments_menu_open_) {
|
if (attachments_menu_open_) {
|
||||||
handle_attachments_menu_key(ch);
|
handle_attachments_menu_key(ch);
|
||||||
return;
|
return;
|
||||||
@@ -138,6 +142,21 @@ void App::handle_key(int ch) {
|
|||||||
attachment_selection_index_ = 0;
|
attachment_selection_index_ = 0;
|
||||||
status_line_ = "Attachments.";
|
status_line_ = "Attachments.";
|
||||||
return;
|
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':
|
case '\t':
|
||||||
focus_ = focus_ == FocusPane::Chats ? FocusPane::Messages : FocusPane::Chats;
|
focus_ = focus_ == FocusPane::Chats ? FocusPane::Messages : FocusPane::Chats;
|
||||||
return;
|
return;
|
||||||
@@ -270,6 +289,9 @@ void App::handle_wide_char(wint_t ch) {
|
|||||||
if (attachments_menu_open_) {
|
if (attachments_menu_open_) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (saved_animation_menu_open_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (forward_target_menu_open_) {
|
if (forward_target_menu_open_) {
|
||||||
return;
|
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> App::forward_target_chat_ids() const {
|
||||||
std::vector<std::int64_t> target_chat_ids;
|
std::vector<std::int64_t> target_chat_ids;
|
||||||
target_chat_ids.reserve(sorted_chat_ids_.size());
|
target_chat_ids.reserve(sorted_chat_ids_.size());
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <clocale>
|
#include <clocale>
|
||||||
|
#include <sstream>
|
||||||
|
|
||||||
#include <curses.h>
|
#include <curses.h>
|
||||||
|
|
||||||
@@ -28,6 +29,22 @@ std::string truncate_to_width(std::string text, int max_width) {
|
|||||||
return text;
|
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
|
} // namespace
|
||||||
|
|
||||||
void App::init_curses() {
|
void App::init_curses() {
|
||||||
@@ -150,6 +167,9 @@ void App::draw() {
|
|||||||
draw_attachment_viewer(height, width);
|
draw_attachment_viewer(height, width);
|
||||||
} else if (attachment_action_menu_open_) {
|
} else if (attachment_action_menu_open_) {
|
||||||
draw_attachment_action_menu(height, width);
|
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_) {
|
} else if (attachments_menu_open_) {
|
||||||
draw_attachments_menu(height, width);
|
draw_attachments_menu(height, width);
|
||||||
} else if (forward_target_menu_open_) {
|
} else if (forward_target_menu_open_) {
|
||||||
@@ -249,6 +269,116 @@ void App::draw_forward_target_menu(int height, int width) {
|
|||||||
delwin(window);
|
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 {
|
std::string App::current_auth_label() const {
|
||||||
const std::string type = safe_string(authorization_state_, "@type");
|
const std::string type = safe_string(authorization_state_, "@type");
|
||||||
if (type == "authorizationStateWaitTdlibParameters") {
|
if (type == "authorizationStateWaitTdlibParameters") {
|
||||||
|
|||||||
@@ -37,6 +37,97 @@ bool App::process_updates() {
|
|||||||
return changed;
|
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) {
|
void App::handle_td_object(const json &object) {
|
||||||
const std::string type = safe_string(object, "@type");
|
const std::string type = safe_string(object, "@type");
|
||||||
if (type == "updateAuthorizationState") {
|
if (type == "updateAuthorizationState") {
|
||||||
@@ -184,6 +275,10 @@ void App::handle_td_object(const json &object) {
|
|||||||
update_attachment_file(object.value("file", json::object()));
|
update_attachment_file(object.value("file", json::object()));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (type == "updateSavedAnimations") {
|
||||||
|
request_saved_animations(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (type == "updateOption" || type == "ok" || type == "userFullInfo" ||
|
if (type == "updateOption" || type == "ok" || type == "userFullInfo" ||
|
||||||
type == "updateHavePendingNotifications" || type == "updateUnreadMessageCount" ||
|
type == "updateHavePendingNotifications" || type == "updateUnreadMessageCount" ||
|
||||||
type == "updateUnreadChatCount") {
|
type == "updateUnreadChatCount") {
|
||||||
@@ -224,6 +319,16 @@ void App::handle_td_object(const json &object) {
|
|||||||
}
|
}
|
||||||
return;
|
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") {
|
if (type == "updateMessageSendSucceeded") {
|
||||||
const json message = object.value("message", json::object());
|
const json message = object.value("message", json::object());
|
||||||
const std::int64_t chat_id = safe_i64(message, "chat_id");
|
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) {
|
if (pending_attachment_open_.has_value() && pending_attachment_open_->file_id == file_id) {
|
||||||
pending_attachment_open_->size_bytes =
|
pending_attachment_open_->size_bytes =
|
||||||
size_bytes > 0 ? size_bytes : 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;
|
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 {
|
struct ChatInfo {
|
||||||
std::int64_t id = 0;
|
std::int64_t id = 0;
|
||||||
std::int64_t private_user_id = 0;
|
std::int64_t private_user_id = 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user