Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 395d45a4e8 | |||
| c2c9560e07 | |||
| 063e12a996 | |||
| fcb92e584c | |||
| c6e2a43e4b | |||
| 1d74c41657 | |||
| 522704ff3d | |||
| 53094ec472 | |||
| 9aa45d2b09 | |||
| 1a5e32a580 | |||
| 2e79f2b00b | |||
| 891fc73e25 |
@@ -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"
|
||||
@@ -13,7 +13,6 @@ jobs:
|
||||
release-app:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
RELEASE_TAG: latest
|
||||
ARCHIVE_NAME: shinoa-linux-x86_64.tar.gz
|
||||
CHECKSUM_NAME: shinoa-linux-x86_64.tar.gz.sha256
|
||||
TDLIB_RELEASE_TAG: v1.8.63
|
||||
@@ -46,6 +45,20 @@ jobs:
|
||||
pkg-config \
|
||||
zlib1g-dev
|
||||
|
||||
- name: Resolve app version
|
||||
id: app_version
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="$(sed -n 's/^project(shinoa VERSION \([^ ]*\) LANGUAGES CXX)$/\1/p' CMakeLists.txt)"
|
||||
if [ -z "$version" ]; then
|
||||
echo "Failed to resolve app version from CMakeLists.txt" >&2
|
||||
exit 1
|
||||
fi
|
||||
{
|
||||
echo "version=$version"
|
||||
echo "release_tag=v$version"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Download prebuilt TDLib
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -88,18 +101,20 @@ jobs:
|
||||
set -euo pipefail
|
||||
api="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
|
||||
commit_sha="${{ github.sha }}"
|
||||
release_tag="${{ steps.app_version.outputs.release_tag }}"
|
||||
version="${{ steps.app_version.outputs.version }}"
|
||||
release_json="$(mktemp)"
|
||||
body_file="$(mktemp)"
|
||||
|
||||
cat > "$body_file" <<EOF
|
||||
Automated rolling release for commit \`${commit_sha}\`.
|
||||
Release ${version} for commit \`${commit_sha}\`.
|
||||
EOF
|
||||
|
||||
status="$(curl --silent --show-error \
|
||||
--output "$release_json" \
|
||||
--write-out "%{http_code}" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"$api/releases/tags/$RELEASE_TAG")"
|
||||
"$api/releases/tags/$release_tag")"
|
||||
|
||||
if [ "$status" = "200" ]; then
|
||||
release_id="$(jq -r '.id' "$release_json")"
|
||||
@@ -113,7 +128,7 @@ jobs:
|
||||
curl --fail-with-body \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-X DELETE \
|
||||
"$api/releases/tags/$RELEASE_TAG"
|
||||
"$api/releases/tags/$release_tag"
|
||||
elif [ "$status" != "404" ]; then
|
||||
echo "Failed to query release, HTTP $status" >&2
|
||||
cat "$release_json" >&2
|
||||
@@ -125,11 +140,11 @@ jobs:
|
||||
--write-out "%{http_code}" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-X DELETE \
|
||||
"$api/tags/$RELEASE_TAG")"
|
||||
"$api/tags/$release_tag")"
|
||||
case "$status" in
|
||||
204|404) ;;
|
||||
*)
|
||||
echo "Failed to delete tag $RELEASE_TAG, HTTP $status" >&2
|
||||
echo "Failed to delete tag $release_tag, HTTP $status" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -143,9 +158,9 @@ jobs:
|
||||
{
|
||||
"body": $(jq -Rs . < "$body_file"),
|
||||
"draft": false,
|
||||
"name": "latest",
|
||||
"prerelease": true,
|
||||
"tag_name": "$RELEASE_TAG",
|
||||
"name": "v${version}",
|
||||
"prerelease": false,
|
||||
"tag_name": "$release_tag",
|
||||
"target_commitish": "$commit_sha"
|
||||
}
|
||||
EOF
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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_REQUIRED ON)
|
||||
|
||||
7
PKGBUILD
7
PKGBUILD
@@ -1,15 +1,18 @@
|
||||
pkgname=shinoa-bin
|
||||
pkgver=latest
|
||||
pkgver=0.1.2
|
||||
pkgrel=1
|
||||
pkgdesc='Minimal Telegram terminal client built with ncurses and bundled TDLib'
|
||||
arch=('x86_64')
|
||||
url='https://git.mshq.dev/AxiFisk/shinoa'
|
||||
license=('custom:unknown')
|
||||
depends=('gcc-libs' 'glibc' 'ncurses' 'openssl' 'zlib')
|
||||
optdepends=('wl-clipboard: clipboard image paste on Wayland/Plasma'
|
||||
'xclip: clipboard image paste on X11')
|
||||
options=(!debug)
|
||||
provides=('shinoa')
|
||||
conflicts=('shinoa')
|
||||
source=(
|
||||
"shinoa-linux-x86_64.tar.gz::${url}/releases/download/latest/shinoa-linux-x86_64.tar.gz"
|
||||
"shinoa-linux-x86_64.tar.gz::${url}/releases/download/v${pkgver}/shinoa-linux-x86_64.tar.gz"
|
||||
)
|
||||
sha256sums=('SKIP')
|
||||
|
||||
|
||||
42
README.md
42
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,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
|
||||
`~/.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:
|
||||
|
||||
```bash
|
||||
@@ -69,9 +79,9 @@ 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),
|
||||
which downloads a prebuilt TDLib bundle from the `shinoa-tdlib` repository using a pinned
|
||||
version tag such as `v1.8.63`, builds a
|
||||
rolling `latest` app release, 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
|
||||
`shinoa-bin`. 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.
|
||||
|
||||
To prepare the TDLib bundle on your own machine:
|
||||
@@ -94,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`.
|
||||
|
||||
@@ -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"
|
||||
14
src/app.cpp
14
src/app.cpp
@@ -13,6 +13,13 @@ namespace telegram_tui {
|
||||
|
||||
namespace {
|
||||
|
||||
std::string trim_release_tag_prefix(const std::string &value) {
|
||||
if (!value.empty() && (value[0] == 'v' || value[0] == 'V')) {
|
||||
return value.substr(1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
bool is_truthy_env_value(const std::string &value) {
|
||||
return value == "1" || value == "true" || value == "TRUE" || value == "yes" ||
|
||||
value == "YES" || value == "on" || value == "ON";
|
||||
@@ -50,7 +57,7 @@ std::string run_command_capture(const std::string &command) {
|
||||
|
||||
std::optional<std::string> fetch_update_notice() {
|
||||
static constexpr const char *kLatestReleaseApiUrl =
|
||||
"https://git.mshq.dev/api/v1/repos/AxiFisk/shinoa/releases/tags/latest";
|
||||
"https://git.mshq.dev/api/v1/repos/AxiFisk/shinoa/releases/latest";
|
||||
|
||||
if (std::string(TELEGRAM_TUI_BUILD_COMMIT).empty()) {
|
||||
return std::nullopt;
|
||||
@@ -64,6 +71,11 @@ std::optional<std::string> fetch_update_notice() {
|
||||
|
||||
try {
|
||||
const json release = json::parse(response, nullptr, true, true);
|
||||
const std::string latest_tag = trim_release_tag_prefix(safe_string(release, "tag_name"));
|
||||
if (!latest_tag.empty() && latest_tag == TELEGRAM_TUI_PROJECT_VERSION) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
const std::string target_commit = safe_string(release, "target_commitish");
|
||||
if (target_commit.empty()) {
|
||||
return std::nullopt;
|
||||
|
||||
15
src/app.h
15
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);
|
||||
@@ -102,6 +108,8 @@ class App {
|
||||
[[nodiscard]] bool has_inline_attachment_preview(const AttachmentInfo &attachment,
|
||||
int width, int height) const;
|
||||
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 reset_attachment_viewer_send_preview();
|
||||
[[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_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 +159,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 +178,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 +196,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 +220,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 {};
|
||||
}
|
||||
|
||||
@@ -979,12 +987,19 @@ void App::render_attachment_preview_graphics(int top, int left, int width, int h
|
||||
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);
|
||||
if (preview_path.empty() || attachment_viewer_is_animated_) {
|
||||
if (preview_path.empty() || animated) {
|
||||
clear_attachment_preview_graphics();
|
||||
return;
|
||||
}
|
||||
|
||||
const std::string forced_protocol = configured_image_protocol();
|
||||
const bool want_kitty = forced_protocol == "kitty" ||
|
||||
(forced_protocol.empty() && terminal_supports_kitty_graphics());
|
||||
|
||||
222
src/app_auth.cpp
222
src/app_auth.cpp
@@ -2,6 +2,7 @@
|
||||
|
||||
#include <array>
|
||||
#include <set>
|
||||
#include <cctype>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
@@ -65,6 +66,121 @@ bool command_exists(const char *command) {
|
||||
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,
|
||||
const std::string &destination_path) {
|
||||
if (command_exists("wl-paste")) {
|
||||
@@ -576,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.";
|
||||
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"),
|
||||
|
||||
@@ -17,10 +17,16 @@ std::optional<int> mapped_layout_hotkey(wchar_t ch) {
|
||||
switch (std::towlower(ch)) {
|
||||
case L'й':
|
||||
return 'q';
|
||||
case L'ф':
|
||||
return 'a';
|
||||
case L'п':
|
||||
return 'g';
|
||||
case L'р':
|
||||
return 'h';
|
||||
case L'ш':
|
||||
return 'i';
|
||||
case L'ь':
|
||||
return 'm';
|
||||
case L'к':
|
||||
return 'r';
|
||||
case L'щ':
|
||||
@@ -108,6 +114,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 +148,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 +295,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 +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> 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() {
|
||||
@@ -68,6 +85,10 @@ void App::shutdown_curses() {
|
||||
}
|
||||
|
||||
void App::draw() {
|
||||
if (!attachment_viewer_open_ && !saved_animation_menu_open_) {
|
||||
clear_attachment_preview_graphics();
|
||||
}
|
||||
|
||||
erase();
|
||||
|
||||
int height = 0;
|
||||
@@ -89,7 +110,7 @@ void App::draw() {
|
||||
|
||||
attron(A_REVERSE);
|
||||
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_) {
|
||||
header_label += " [TEST DC]";
|
||||
}
|
||||
@@ -150,14 +171,14 @@ 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_) {
|
||||
draw_saved_animation_menu(height, width);
|
||||
} else if (attachments_menu_open_) {
|
||||
draw_attachments_menu(height, width);
|
||||
} else if (forward_target_menu_open_) {
|
||||
draw_forward_target_menu(height, width);
|
||||
} else if (help_menu_open_) {
|
||||
draw_help_menu(height, width);
|
||||
} else {
|
||||
clear_attachment_preview_graphics();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,6 +270,150 @@ 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()) {
|
||||
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 {
|
||||
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;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#define TELEGRAM_TUI_PROJECT_VERSION "@PROJECT_VERSION@"
|
||||
#define TELEGRAM_TUI_BUILD_COMMIT "@TELEGRAM_TUI_BUILD_COMMIT@"
|
||||
#define TELEGRAM_TUI_BUILD_VERSION "@TELEGRAM_TUI_BUILD_VERSION@"
|
||||
#define TELEGRAM_TUI_BUILD_API_ID "@TELEGRAM_TUI_BUILD_API_ID@"
|
||||
|
||||
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