9 Commits

Author SHA1 Message Date
395d45a4e8 Fix redraw after closing GIF preview
All checks were successful
Release App / release-app (push) Successful in 1m0s
2026-04-26 13:00:57 +03:00
c2c9560e07 Fix saved GIF preview rendering
All checks were successful
Release App / release-app (push) Successful in 48s
2026-04-26 12:59:07 +03:00
063e12a996 Bump version to 0.1.2
All checks were successful
Release App / release-app (push) Successful in 48s
2026-04-26 12:56:24 +03:00
fcb92e584c Fix Cyrillic hotkeys for picker actions
All checks were successful
Release App / release-app (push) Successful in 49s
2026-04-26 12:52:14 +03:00
c6e2a43e4b Add saved GIF picker and docs
All checks were successful
Release App / release-app (push) Successful in 46s
2026-04-26 12:50:18 +03:00
1d74c41657 Show release version in app header
All checks were successful
Release App / release-app (push) Successful in 55s
2026-04-24 15:25:44 +03:00
522704ff3d Bump version to 0.1.1
All checks were successful
Release App / release-app (push) Successful in 58s
2026-04-24 15:18:10 +03:00
53094ec472 Improve clipboard guidance for KDE users
Some checks failed
Release App / release-app (push) Has been cancelled
2026-04-24 15:17:38 +03:00
9aa45d2b09 Revert "Restore TDLib workflow assets"
Some checks failed
Release App / release-app (push) Has been cancelled
This reverts commit 1a5e32a580.
2026-04-24 15:08:36 +03:00
13 changed files with 661 additions and 265 deletions

View File

@@ -1,180 +0,0 @@
name: Build TDLib
on:
workflow_dispatch:
inputs:
tdlib_ref:
description: Optional TDLib tag or commit to build
required: false
type: string
schedule:
- cron: "0 3 * * 1"
permissions:
contents: read
packages: write
jobs:
build-tdlib:
runs-on: ubuntu-latest
env:
PACKAGE_NAME: tdlib
TDLIB_REPO: https://github.com/tdlib/td.git
steps:
- name: Check out repository
uses: https://github.com/actions/checkout@v4
- name: Install build dependencies
run: |
if command -v sudo >/dev/null 2>&1; then
SUDO=sudo
else
SUDO=
fi
$SUDO apt-get update
$SUDO apt-get install -y \
build-essential \
ca-certificates \
cmake \
curl \
git \
gperf \
libssl-dev \
pkg-config \
zlib1g-dev
- name: Resolve TDLib ref
id: tdlib
run: |
set -euo pipefail
ref="${{ inputs.tdlib_ref }}"
if [ -z "$ref" ]; then
ref="$(git ls-remote --tags --refs --sort='version:refname' "$TDLIB_REPO" 'v*' | tail -n1 | awk -F/ '{print $3}')"
fi
if [ -z "$ref" ]; then
echo "Failed to resolve TDLib ref" >&2
exit 1
fi
version="$ref"
version="${version#v}"
version="${version//\//-}"
archive="tdlib-${version}-linux-x86_64.tar.gz"
{
echo "ref=$ref"
echo "version=$version"
echo "archive=$archive"
} >> "$GITHUB_OUTPUT"
- name: Build TDLib bundle
run: |
set -euo pipefail
ref="${{ steps.tdlib.outputs.ref }}"
version="${{ steps.tdlib.outputs.version }}"
archive="${{ steps.tdlib.outputs.archive }}"
rm -rf tdlib-src tdlib-build dist
git init tdlib-src
cd tdlib-src
git remote add origin "$TDLIB_REPO"
git fetch --depth 1 origin \
"refs/tags/${ref}:refs/tags/${ref}" \
"refs/heads/${ref}:refs/remotes/origin/${ref}" || true
if git rev-parse --verify --quiet "refs/tags/${ref}" >/dev/null; then
git checkout --detach "refs/tags/${ref}"
elif git rev-parse --verify --quiet "refs/remotes/origin/${ref}" >/dev/null; then
git checkout --detach "refs/remotes/origin/${ref}"
else
git fetch --depth 1 origin "$ref"
git checkout --detach FETCH_HEAD
fi
cd ..
cmake -S tdlib-src -B tdlib-build \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX="$PWD/dist/tdlib" \
-DTD_ENABLE_INSTALL=ON \
-DTD_ENABLE_JNI=OFF \
-DTD_ENABLE_DOTNET=OFF \
-DTD_ENABLE_TESTS=OFF \
-DTD_ENABLE_BENCHMARKS=OFF
cmake --build tdlib-build -j"$(nproc)"
cmake --install tdlib-build
tar -C dist -czf "$archive" tdlib
sha256sum "$archive" > "${archive}.sha256"
- name: Publish TDLib package
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -euo pipefail
version="${{ steps.tdlib.outputs.version }}"
archive="${{ steps.tdlib.outputs.archive }}"
base="${{ gitea.server_url }}/api/packages/${{ gitea.repository_owner }}/generic/${PACKAGE_NAME}"
status="$(curl --silent --show-error \
--output /dev/null \
--write-out "%{http_code}" \
--user "${{ gitea.actor }}:${GITEA_TOKEN}" \
-X DELETE \
"$base/$version")"
case "$status" in
204|404) ;;
*)
echo "Unexpected response deleting existing package version: $status" >&2
exit 1
;;
esac
curl --fail-with-body \
--user "${{ gitea.actor }}:${GITEA_TOKEN}" \
--upload-file "$archive" \
"$base/$version/$archive"
curl --fail-with-body \
--user "${{ gitea.actor }}:${GITEA_TOKEN}" \
--upload-file "${archive}.sha256" \
"$base/$version/${archive}.sha256"
- name: Publish latest metadata
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -euo pipefail
version="${{ steps.tdlib.outputs.version }}"
archive="${{ steps.tdlib.outputs.archive }}"
base="${{ gitea.server_url }}/api/packages/${{ gitea.repository_owner }}/generic/${PACKAGE_NAME}"
latest_manifest="tdlib-latest.json"
cat > "$latest_manifest" <<EOF
{
"ref": "${{ steps.tdlib.outputs.ref }}",
"version": "$version",
"archive": "$archive",
"url": "$base/$version/$archive",
"sha256_url": "$base/$version/${archive}.sha256"
}
EOF
status="$(curl --silent --show-error \
--output /dev/null \
--write-out "%{http_code}" \
--user "${{ gitea.actor }}:${GITEA_TOKEN}" \
-X DELETE \
"$base/latest")"
case "$status" in
204|404) ;;
*)
echo "Unexpected response deleting latest package version: $status" >&2
exit 1
;;
esac
curl --fail-with-body \
--user "${{ gitea.actor }}:${GITEA_TOKEN}" \
--upload-file "$latest_manifest" \
"$base/latest/$latest_manifest"

View File

@@ -1,6 +1,6 @@
cmake_minimum_required(VERSION 3.21) cmake_minimum_required(VERSION 3.21)
project(shinoa VERSION 0.1.0 LANGUAGES CXX) project(shinoa VERSION 0.1.2 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_STANDARD_REQUIRED ON)

View File

@@ -1,11 +1,13 @@
pkgname=shinoa-bin pkgname=shinoa-bin
pkgver=0.1.0 pkgver=0.1.2
pkgrel=1 pkgrel=1
pkgdesc='Minimal Telegram terminal client built with ncurses and bundled TDLib' pkgdesc='Minimal Telegram terminal client built with ncurses and bundled TDLib'
arch=('x86_64') arch=('x86_64')
url='https://git.mshq.dev/AxiFisk/shinoa' url='https://git.mshq.dev/AxiFisk/shinoa'
license=('custom:unknown') license=('custom:unknown')
depends=('gcc-libs' 'glibc' 'ncurses' 'openssl' 'zlib') depends=('gcc-libs' 'glibc' 'ncurses' 'openssl' 'zlib')
optdepends=('wl-clipboard: clipboard image paste on Wayland/Plasma'
'xclip: clipboard image paste on X11')
options=(!debug) options=(!debug)
provides=('shinoa') provides=('shinoa')
conflicts=('shinoa') conflicts=('shinoa')

View File

@@ -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,6 +53,13 @@ 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` 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 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:
```bash ```bash
@@ -69,7 +79,7 @@ It also refreshes a `latest/tdlib-latest.json` manifest with the newest publishe
The repository also includes [`.gitea/workflows/release-app.yaml`](.gitea/workflows/release-app.yaml), The repository also includes [`.gitea/workflows/release-app.yaml`](.gitea/workflows/release-app.yaml),
which downloads a prebuilt TDLib bundle from the `shinoa-tdlib` repository using a pinned which downloads a prebuilt TDLib bundle from the `shinoa-tdlib` repository using a pinned
version tag such as `v1.8.63`, builds a version tag such as `v1.8.63`, builds a
versioned app release tag such as `v0.1.0`, and publishes an archive containing `usr/bin/shinoa` plus the versioned app release tag such as `v0.1.2`, and publishes an archive containing `usr/bin/shinoa` plus the
bundled `usr/lib/libtdjson.so*`. The root `PKGBUILD` installs that prebuilt release as bundled `usr/lib/libtdjson.so*`. The root `PKGBUILD` installs that prebuilt release as
`shinoa-bin`. The package disables debug splitting with `options=(!debug)`. That workflow expects Gitea secrets named `TELEGRAM_API_ID` and `shinoa-bin`. The package disables debug splitting with `options=(!debug)`. That workflow expects Gitea secrets named `TELEGRAM_API_ID` and
`TELEGRAM_API_HASH`. Release builds are configured to fail if those secrets are missing. `TELEGRAM_API_HASH`. Release builds are configured to fail if those secrets are missing.
@@ -94,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`.

View File

@@ -1,37 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
if [ "$#" -lt 1 ] || [ "$#" -gt 2 ]; then
echo "usage: $0 <tdlib-root> [output-tar.gz]" >&2
exit 1
fi
tdlib_root="$(realpath "$1")"
output_path="${2:-tdlib-linux-x86_64.tar.gz}"
output_path="$(realpath -m "$output_path")"
include_dir="$tdlib_root/include"
lib_dir="$tdlib_root/lib"
if [ ! -f "$include_dir/td/telegram/td_json_client.h" ]; then
echo "missing TDLib header: $include_dir/td/telegram/td_json_client.h" >&2
exit 1
fi
if ! compgen -G "$lib_dir/libtdjson.so*" >/dev/null; then
echo "missing TDLib shared library under: $lib_dir" >&2
exit 1
fi
workdir="$(mktemp -d)"
trap 'rm -rf "$workdir"' EXIT
mkdir -p "$workdir/tdlib"
cp -a "$include_dir" "$workdir/tdlib/include"
cp -a "$lib_dir" "$workdir/tdlib/lib"
tar -C "$workdir" -czf "$output_path" tdlib
sha256sum "$output_path" > "${output_path}.sha256"
echo "wrote $output_path"
echo "wrote ${output_path}.sha256"

View File

@@ -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);
@@ -102,6 +108,8 @@ class App {
[[nodiscard]] bool has_inline_attachment_preview(const AttachmentInfo &attachment, [[nodiscard]] bool has_inline_attachment_preview(const AttachmentInfo &attachment,
int width, int height) const; int width, int height) const;
void clear_attachment_preview_graphics(); void clear_attachment_preview_graphics();
void render_attachment_preview_graphics(const AttachmentInfo &attachment, bool animated,
int top, int left, int width, int height);
void render_attachment_preview_graphics(int top, int left, int width, int height); void render_attachment_preview_graphics(int top, int left, int width, int height);
void reset_attachment_viewer_send_preview(); void reset_attachment_viewer_send_preview();
[[nodiscard]] std::vector<std::int64_t> forward_target_chat_ids() const; [[nodiscard]] std::vector<std::int64_t> forward_target_chat_ids() const;
@@ -141,6 +149,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 +159,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 +178,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 +196,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 +220,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_;

View File

@@ -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 {};
} }
@@ -979,12 +987,19 @@ void App::render_attachment_preview_graphics(int top, int left, int width, int h
return; return;
} }
const AttachmentInfo &attachment = *attachment_viewer_attachment_; render_attachment_preview_graphics(*attachment_viewer_attachment_,
attachment_viewer_is_animated_, top, left, width,
height);
}
void App::render_attachment_preview_graphics(const AttachmentInfo &attachment, bool animated,
int top, int left, int width, int height) {
const std::string preview_path = attachment_preview_path(attachment); const std::string preview_path = attachment_preview_path(attachment);
if (preview_path.empty() || attachment_viewer_is_animated_) { if (preview_path.empty() || animated) {
clear_attachment_preview_graphics(); clear_attachment_preview_graphics();
return; return;
} }
const std::string forced_protocol = configured_image_protocol(); const std::string forced_protocol = configured_image_protocol();
const bool want_kitty = forced_protocol == "kitty" || const bool want_kitty = forced_protocol == "kitty" ||
(forced_protocol.empty() && terminal_supports_kitty_graphics()); (forced_protocol.empty() && terminal_supports_kitty_graphics());

View File

@@ -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>
@@ -65,6 +66,121 @@ bool command_exists(const char *command) {
return !trim_copy(resolved).empty(); return !trim_copy(resolved).empty();
} }
bool is_wayland_session() {
return get_env("XDG_SESSION_TYPE") == "wayland";
}
bool is_kde_session() {
const std::string current_desktop = get_env("XDG_CURRENT_DESKTOP");
const std::string session_desktop = get_env("XDG_SESSION_DESKTOP");
return current_desktop.find("KDE") != std::string::npos ||
current_desktop.find("PLASMA") != std::string::npos ||
session_desktop.find("kde") != 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() {
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()) {
return " Install wl-clipboard. Klipper alone is not enough.";
}
return " Install wl-clipboard.";
}
if (is_kde_session()) {
return " Install xclip or wl-clipboard. Klipper alone is not enough.";
}
return " Install wl-clipboard or xclip.";
}
std::string clipboard_capture_command(const std::string &mime_type, std::string clipboard_capture_command(const std::string &mime_type,
const std::string &destination_path) { const std::string &destination_path) {
if (command_exists("wl-paste")) { if (command_exists("wl-paste")) {
@@ -576,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()) {
status_line_ = const auto clipboard_image_path = clipboard_image_file_path();
"Clipboard doesn't contain an image, or no clipboard tool is available."; 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; 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);
@@ -593,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)) + "-" +
@@ -615,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;
} }

View File

@@ -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"),

View File

@@ -17,10 +17,16 @@ std::optional<int> mapped_layout_hotkey(wchar_t ch) {
switch (std::towlower(ch)) { switch (std::towlower(ch)) {
case L'й': case L'й':
return 'q'; return 'q';
case L'ф':
return 'a';
case L'п':
return 'g';
case L'р': case L'р':
return 'h'; return 'h';
case L'ш': case L'ш':
return 'i'; return 'i';
case L'ь':
return 'm';
case L'к': case L'к':
return 'r'; return 'r';
case L'щ': case L'щ':
@@ -108,6 +114,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 +148,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 +295,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 +408,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());

View File

@@ -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() {
@@ -68,6 +85,10 @@ void App::shutdown_curses() {
} }
void App::draw() { void App::draw() {
if (!attachment_viewer_open_ && !saved_animation_menu_open_) {
clear_attachment_preview_graphics();
}
erase(); erase();
int height = 0; int height = 0;
@@ -89,7 +110,7 @@ void App::draw() {
attron(A_REVERSE); attron(A_REVERSE);
mvhline(header_y, 0, ' ', width); mvhline(header_y, 0, ' ', width);
std::string header_label = std::string("shinoa ") + TELEGRAM_TUI_BUILD_VERSION; std::string header_label = std::string("shinoa ") + TELEGRAM_TUI_PROJECT_VERSION;
if (use_test_dc_) { if (use_test_dc_) {
header_label += " [TEST DC]"; header_label += " [TEST DC]";
} }
@@ -150,14 +171,14 @@ 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_) {
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_) {
draw_forward_target_menu(height, width); draw_forward_target_menu(height, width);
} else if (help_menu_open_) { } else if (help_menu_open_) {
draw_help_menu(height, width); draw_help_menu(height, width);
} else {
clear_attachment_preview_graphics();
} }
} }
@@ -249,6 +270,150 @@ 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()) {
clear_attachment_preview_graphics();
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 int preview_top = 6;
const int preview_height = std::max(4, list_height - 4);
if (has_inline_attachment_preview(preview_attachment, preview_width, preview_height)) {
for (int row = 0; row < preview_height; ++row) {
mvwhline(window, preview_top + row, preview_left, ' ', preview_width);
}
} else {
clear_attachment_preview_graphics();
const std::string preview =
render_attachment_preview(preview_attachment, preview_width, preview_height);
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, preview_top + 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);
if (!saved_animations_.empty()) {
const SavedAnimationInfo &animation = saved_animations_[static_cast<std::size_t>(
saved_animation_selection_index_)];
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;
if (has_inline_attachment_preview(preview_attachment, preview_width,
std::max(4, list_height - 4))) {
render_attachment_preview_graphics(preview_attachment, false, top + 6,
left + preview_left, preview_width,
std::max(4, list_height - 4));
} else {
clear_attachment_preview_graphics();
}
}
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") {

View File

@@ -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;

View File

@@ -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;