13 Commits

Author SHA1 Message Date
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
1a5e32a580 Restore TDLib workflow assets
All checks were successful
Release App / release-app (push) Successful in 1m42s
2026-04-24 15:06:21 +03:00
2e79f2b00b Fix update notice for versioned releases
Some checks failed
Release App / release-app (push) Has been cancelled
2026-04-24 15:06:07 +03:00
891fc73e25 Use versioned app releases for binary package
All checks were successful
Release App / release-app (push) Successful in 1m31s
2026-04-24 15:00:42 +03:00
5a5677a994 Add update notice and require release build secrets
All checks were successful
Release App / release-app (push) Successful in 58s
2026-04-24 14:55:36 +03:00
94fc240086 Show build version in app header
All checks were successful
Release App / release-app (push) Successful in 1m12s
2026-04-24 14:47:01 +03:00
c39071b61a Use versioned TDLib releases from shinoa-tdlib
All checks were successful
Release App / release-app (push) Successful in 1m1s
2026-04-24 14:42:41 +03:00
7b35201799 Fetch nlohmann_json for prebuilt TDLib builds
All checks were successful
Release App / release-app (push) Successful in 56s
2026-04-24 14:33:11 +03:00
16 changed files with 856 additions and 288 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

@@ -13,10 +13,10 @@ jobs:
release-app: release-app:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
RELEASE_TAG: latest
ARCHIVE_NAME: shinoa-linux-x86_64.tar.gz ARCHIVE_NAME: shinoa-linux-x86_64.tar.gz
CHECKSUM_NAME: shinoa-linux-x86_64.tar.gz.sha256 CHECKSUM_NAME: shinoa-linux-x86_64.tar.gz.sha256
TDLIB_ARCHIVE_URL: ${{ gitea.server_url }}/${{ gitea.repository }}/releases/download/tdlib/tdlib-linux-x86_64.tar.gz TDLIB_RELEASE_TAG: v1.8.63
TDLIB_ARCHIVE_URL: https://git.mshq.dev/AxiFisk/shinoa-tdlib/releases/download/${{ env.TDLIB_RELEASE_TAG }}/tdlib-linux-x86_64.tar.gz
steps: steps:
- name: Check out repository - name: Check out repository
uses: https://github.com/actions/checkout@v4 uses: https://github.com/actions/checkout@v4
@@ -45,6 +45,20 @@ jobs:
pkg-config \ pkg-config \
zlib1g-dev 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 - name: Download prebuilt TDLib
run: | run: |
set -euo pipefail set -euo pipefail
@@ -54,12 +68,19 @@ jobs:
test -f tdlib/lib/libtdjson.so test -f tdlib/lib/libtdjson.so
- name: Build release bundle - name: Build release bundle
env:
TELEGRAM_TUI_BUILD_API_ID: ${{ secrets.TELEGRAM_API_ID }}
TELEGRAM_TUI_BUILD_API_HASH: ${{ secrets.TELEGRAM_API_HASH }}
run: | run: |
set -euo pipefail set -euo pipefail
rm -rf build dist rm -rf build dist
test -n "${TELEGRAM_TUI_BUILD_API_ID}"
test -n "${TELEGRAM_TUI_BUILD_API_HASH}"
cmake -S . -B build \ cmake -S . -B build \
-DCMAKE_BUILD_TYPE=Release \ -DCMAKE_BUILD_TYPE=Release \
-DTELEGRAM_TUI_REQUIRE_BUILD_CREDENTIALS=ON \
-DTELEGRAM_TUI_TDLIB_ROOT="$PWD/tdlib" -DTELEGRAM_TUI_TDLIB_ROOT="$PWD/tdlib"
cmake --build build -j"$(nproc)" cmake --build build -j"$(nproc)"
@@ -80,18 +101,20 @@ jobs:
set -euo pipefail set -euo pipefail
api="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}" api="${{ gitea.server_url }}/api/v1/repos/${{ gitea.repository }}"
commit_sha="${{ github.sha }}" commit_sha="${{ github.sha }}"
release_tag="${{ steps.app_version.outputs.release_tag }}"
version="${{ steps.app_version.outputs.version }}"
release_json="$(mktemp)" release_json="$(mktemp)"
body_file="$(mktemp)" body_file="$(mktemp)"
cat > "$body_file" <<EOF cat > "$body_file" <<EOF
Automated rolling release for commit \`${commit_sha}\`. Release ${version} for commit \`${commit_sha}\`.
EOF EOF
status="$(curl --silent --show-error \ status="$(curl --silent --show-error \
--output "$release_json" \ --output "$release_json" \
--write-out "%{http_code}" \ --write-out "%{http_code}" \
-H "Authorization: token ${GITEA_TOKEN}" \ -H "Authorization: token ${GITEA_TOKEN}" \
"$api/releases/tags/$RELEASE_TAG")" "$api/releases/tags/$release_tag")"
if [ "$status" = "200" ]; then if [ "$status" = "200" ]; then
release_id="$(jq -r '.id' "$release_json")" release_id="$(jq -r '.id' "$release_json")"
@@ -105,7 +128,7 @@ jobs:
curl --fail-with-body \ curl --fail-with-body \
-H "Authorization: token ${GITEA_TOKEN}" \ -H "Authorization: token ${GITEA_TOKEN}" \
-X DELETE \ -X DELETE \
"$api/releases/tags/$RELEASE_TAG" "$api/releases/tags/$release_tag"
elif [ "$status" != "404" ]; then elif [ "$status" != "404" ]; then
echo "Failed to query release, HTTP $status" >&2 echo "Failed to query release, HTTP $status" >&2
cat "$release_json" >&2 cat "$release_json" >&2
@@ -117,11 +140,11 @@ jobs:
--write-out "%{http_code}" \ --write-out "%{http_code}" \
-H "Authorization: token ${GITEA_TOKEN}" \ -H "Authorization: token ${GITEA_TOKEN}" \
-X DELETE \ -X DELETE \
"$api/tags/$RELEASE_TAG")" "$api/tags/$release_tag")"
case "$status" in case "$status" in
204|404) ;; 204|404) ;;
*) *)
echo "Failed to delete tag $RELEASE_TAG, HTTP $status" >&2 echo "Failed to delete tag $release_tag, HTTP $status" >&2
exit 1 exit 1
;; ;;
esac esac
@@ -135,9 +158,9 @@ jobs:
{ {
"body": $(jq -Rs . < "$body_file"), "body": $(jq -Rs . < "$body_file"),
"draft": false, "draft": false,
"name": "latest", "name": "v${version}",
"prerelease": true, "prerelease": false,
"tag_name": "$RELEASE_TAG", "tag_name": "$release_tag",
"target_commitish": "$commit_sha" "target_commitish": "$commit_sha"
} }
EOF EOF

View File

@@ -1,21 +1,36 @@
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.1 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF) set(CMAKE_CXX_EXTENSIONS OFF)
find_program(CLANG_FORMAT_BIN clang-format) find_program(CLANG_FORMAT_BIN clang-format)
find_package(Git QUIET)
include(FetchContent) include(FetchContent)
option(TELEGRAM_TUI_USE_SYSTEM_TDLIB "Use an installed TDLib package instead of fetching it." OFF) option(TELEGRAM_TUI_USE_SYSTEM_TDLIB "Use an installed TDLib package instead of fetching it." OFF)
option(TELEGRAM_TUI_REQUIRE_BUILD_CREDENTIALS
"Fail configure if build credentials are not provided." OFF)
set(TELEGRAM_TUI_TDLIB_ROOT "" set(TELEGRAM_TUI_TDLIB_ROOT ""
CACHE PATH "Path to a prebuilt TDLib root containing include/ and lib/ directories.") CACHE PATH "Path to a prebuilt TDLib root containing include/ and lib/ directories.")
set(CURSES_NEED_WIDE TRUE) set(CURSES_NEED_WIDE TRUE)
find_package(Curses REQUIRED) find_package(Curses REQUIRED)
find_package(Threads REQUIRED)
find_package(nlohmann_json QUIET)
if(NOT nlohmann_json_FOUND)
FetchContent_Declare(
nlohmann_json
GIT_REPOSITORY https://github.com/nlohmann/json.git
GIT_TAG v3.11.3
GIT_SHALLOW TRUE
)
FetchContent_MakeAvailable(nlohmann_json)
endif()
set(TELEGRAM_TUI_APP_CONFIG_PATH "") set(TELEGRAM_TUI_APP_CONFIG_PATH "")
if(DEFINED ENV{XDG_DATA_HOME} AND NOT "$ENV{XDG_DATA_HOME}" STREQUAL "") if(DEFINED ENV{XDG_DATA_HOME} AND NOT "$ENV{XDG_DATA_HOME}" STREQUAL "")
@@ -26,22 +41,67 @@ endif()
set(TELEGRAM_TUI_BUILD_API_ID "") set(TELEGRAM_TUI_BUILD_API_ID "")
set(TELEGRAM_TUI_BUILD_API_HASH "") set(TELEGRAM_TUI_BUILD_API_HASH "")
if(TELEGRAM_TUI_APP_CONFIG_PATH AND EXISTS "${TELEGRAM_TUI_APP_CONFIG_PATH}") set(TELEGRAM_TUI_BUILD_VERSION "${PROJECT_VERSION}")
set(TELEGRAM_TUI_BUILD_COMMIT "")
if(GIT_FOUND AND EXISTS "${CMAKE_SOURCE_DIR}/.git")
execute_process(
COMMAND "${GIT_EXECUTABLE}" rev-parse --short HEAD
WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}"
OUTPUT_VARIABLE TELEGRAM_TUI_BUILD_COMMIT
OUTPUT_STRIP_TRAILING_WHITESPACE
ERROR_QUIET
)
execute_process(
COMMAND "${GIT_EXECUTABLE}" describe --tags --always --dirty
WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}"
OUTPUT_VARIABLE TELEGRAM_TUI_GIT_DESCRIBE
OUTPUT_STRIP_TRAILING_WHITESPACE
ERROR_QUIET
)
if(TELEGRAM_TUI_GIT_DESCRIBE)
set(TELEGRAM_TUI_BUILD_VERSION "${PROJECT_VERSION}+${TELEGRAM_TUI_GIT_DESCRIBE}")
endif()
endif()
if(DEFINED ENV{TELEGRAM_TUI_BUILD_API_ID} AND NOT "$ENV{TELEGRAM_TUI_BUILD_API_ID}" STREQUAL "")
set(TELEGRAM_TUI_BUILD_API_ID "$ENV{TELEGRAM_TUI_BUILD_API_ID}")
elseif(DEFINED ENV{TELEGRAM_API_ID} AND NOT "$ENV{TELEGRAM_API_ID}" STREQUAL "")
set(TELEGRAM_TUI_BUILD_API_ID "$ENV{TELEGRAM_API_ID}")
endif()
if(DEFINED ENV{TELEGRAM_TUI_BUILD_API_HASH} AND NOT "$ENV{TELEGRAM_TUI_BUILD_API_HASH}" STREQUAL "")
set(TELEGRAM_TUI_BUILD_API_HASH "$ENV{TELEGRAM_TUI_BUILD_API_HASH}")
elseif(DEFINED ENV{TELEGRAM_API_HASH} AND NOT "$ENV{TELEGRAM_API_HASH}" STREQUAL "")
set(TELEGRAM_TUI_BUILD_API_HASH "$ENV{TELEGRAM_API_HASH}")
endif()
if((TELEGRAM_TUI_BUILD_API_ID STREQUAL "" OR TELEGRAM_TUI_BUILD_API_HASH STREQUAL "") AND
TELEGRAM_TUI_APP_CONFIG_PATH AND EXISTS "${TELEGRAM_TUI_APP_CONFIG_PATH}")
file(READ "${TELEGRAM_TUI_APP_CONFIG_PATH}" TELEGRAM_TUI_APP_CONFIG_JSON) file(READ "${TELEGRAM_TUI_APP_CONFIG_PATH}" TELEGRAM_TUI_APP_CONFIG_JSON)
string(REGEX MATCH "\"api_id\"[ \t\r\n]*:[ \t\r\n]*\"?([0-9]+)\"?" if(TELEGRAM_TUI_BUILD_API_ID STREQUAL "")
TELEGRAM_TUI_APP_CONFIG_API_ID_MATCH "${TELEGRAM_TUI_APP_CONFIG_JSON}") string(REGEX MATCH "\"api_id\"[ \t\r\n]*:[ \t\r\n]*\"?([0-9]+)\"?"
if(CMAKE_MATCH_1) TELEGRAM_TUI_APP_CONFIG_API_ID_MATCH "${TELEGRAM_TUI_APP_CONFIG_JSON}")
set(TELEGRAM_TUI_BUILD_API_ID "${CMAKE_MATCH_1}") if(CMAKE_MATCH_1)
set(TELEGRAM_TUI_BUILD_API_ID "${CMAKE_MATCH_1}")
endif()
endif() endif()
string(REGEX MATCH "\"api_hash\"[ \t\r\n]*:[ \t\r\n]*\"([^\"]+)\"" if(TELEGRAM_TUI_BUILD_API_HASH STREQUAL "")
TELEGRAM_TUI_APP_CONFIG_API_HASH_MATCH "${TELEGRAM_TUI_APP_CONFIG_JSON}") string(REGEX MATCH "\"api_hash\"[ \t\r\n]*:[ \t\r\n]*\"([^\"]+)\""
if(CMAKE_MATCH_1) TELEGRAM_TUI_APP_CONFIG_API_HASH_MATCH "${TELEGRAM_TUI_APP_CONFIG_JSON}")
set(TELEGRAM_TUI_BUILD_API_HASH "${CMAKE_MATCH_1}") if(CMAKE_MATCH_1)
set(TELEGRAM_TUI_BUILD_API_HASH "${CMAKE_MATCH_1}")
endif()
endif() endif()
endif() endif()
if(TELEGRAM_TUI_REQUIRE_BUILD_CREDENTIALS AND
(TELEGRAM_TUI_BUILD_API_ID STREQUAL "" OR TELEGRAM_TUI_BUILD_API_HASH STREQUAL ""))
message(FATAL_ERROR
"Build credentials are required. Set TELEGRAM_TUI_BUILD_API_ID and "
"TELEGRAM_TUI_BUILD_API_HASH (or TELEGRAM_API_ID/TELEGRAM_API_HASH).")
endif()
configure_file( configure_file(
${CMAKE_SOURCE_DIR}/src/build_config.h.in ${CMAKE_SOURCE_DIR}/src/build_config.h.in
${CMAKE_CURRENT_BINARY_DIR}/build_config.h ${CMAKE_CURRENT_BINARY_DIR}/build_config.h
@@ -119,7 +179,8 @@ if(CLANG_FORMAT_BIN)
endif() endif()
target_include_directories(shinoa PRIVATE ${CURSES_INCLUDE_DIRS} ${CMAKE_CURRENT_BINARY_DIR}) target_include_directories(shinoa PRIVATE ${CURSES_INCLUDE_DIRS} ${CMAKE_CURRENT_BINARY_DIR})
target_link_libraries(shinoa PRIVATE ${CURSES_LIBRARIES}) target_link_libraries(shinoa PRIVATE ${CURSES_LIBRARIES} Threads::Threads
nlohmann_json::nlohmann_json)
if(TELEGRAM_TUI_USE_SYSTEM_TDLIB) if(TELEGRAM_TUI_USE_SYSTEM_TDLIB)
target_link_libraries(shinoa PRIVATE Td::TdJson) target_link_libraries(shinoa PRIVATE Td::TdJson)

View File

@@ -1,15 +1,18 @@
pkgname=shinoa-bin pkgname=shinoa-bin
pkgver=latest pkgver=0.1.1
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)
provides=('shinoa') provides=('shinoa')
conflicts=('shinoa') conflicts=('shinoa')
source=( 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') sha256sums=('SKIP')

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
@@ -31,6 +34,9 @@ cmake --build build -j
During configure, CMake also checks the app config at During configure, CMake also checks the app config at
`$XDG_DATA_HOME/telegram-tui/config.json` or `~/.local/share/telegram-tui/config.json`. `$XDG_DATA_HOME/telegram-tui/config.json` or `~/.local/share/telegram-tui/config.json`.
If that file contains `api_id` and `api_hash`, they are embedded into the build. If that file contains `api_id` and `api_hash`, they are embedded into the build.
For CI or release builds, prefer setting `TELEGRAM_TUI_BUILD_API_ID` and
`TELEGRAM_TUI_BUILD_API_HASH` in the environment so credentials come from secrets instead of
local config.
## Run ## Run
@@ -47,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
@@ -64,10 +77,12 @@ archive plus checksum to the Gitea Generic Package Registry under the `tdlib` pa
It also refreshes a `latest/tdlib-latest.json` manifest with the newest published version. It also refreshes a `latest/tdlib-latest.json` manifest with the newest published version.
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 this repository's `tdlib` release tag, builds a which downloads a prebuilt TDLib bundle from the `shinoa-tdlib` repository using a pinned
rolling `latest` app release, and publishes an archive containing `usr/bin/shinoa` plus the version tag such as `v1.8.63`, builds a
versioned app release tag such as `v0.1.1`, 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`. `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: To prepare the TDLib bundle on your own machine:
@@ -78,8 +93,10 @@ cmake --install td-build
./scripts/package-tdlib.sh td-install tdlib-linux-x86_64.tar.gz ./scripts/package-tdlib.sh td-install tdlib-linux-x86_64.tar.gz
``` ```
Upload `tdlib-linux-x86_64.tar.gz` to a release tagged `tdlib` in this repository. After that, Publish `tdlib-linux-x86_64.tar.gz` to the `shinoa-tdlib` repository under a versioned tag such
the `Release App` workflow can consume it and publish the app bundle. as `v1.8.63`. Then update `TDLIB_RELEASE_TAG` in
[`.gitea/workflows/release-app.yaml`](.gitea/workflows/release-app.yaml) and run the
`Release App` workflow.
## Keys ## Keys
@@ -87,7 +104,33 @@ the `Release App` workflow can consume it and publish the app bundle.
- `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

@@ -1,5 +1,9 @@
#include "app.h" #include "app.h"
#include <array>
#include <cstdio>
#include <future>
#include <curses.h> #include <curses.h>
#include "build_config.h" #include "build_config.h"
@@ -9,11 +13,83 @@ namespace telegram_tui {
namespace { 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) { bool is_truthy_env_value(const std::string &value) {
return value == "1" || value == "true" || value == "TRUE" || value == "yes" || return value == "1" || value == "true" || value == "TRUE" || value == "yes" ||
value == "YES" || value == "on" || value == "ON"; value == "YES" || value == "on" || value == "ON";
} }
std::string shell_quote(const std::string &value) {
std::string quoted = "'";
for (char ch : value) {
if (ch == '\'') {
quoted += "'\\''";
} else {
quoted.push_back(ch);
}
}
quoted.push_back('\'');
return quoted;
}
std::string run_command_capture(const std::string &command) {
FILE *pipe = popen(command.c_str(), "r");
if (pipe == nullptr) {
return {};
}
std::string output;
std::array<char, 4096> buffer{};
while (std::fgets(buffer.data(), static_cast<int>(buffer.size()), pipe) != nullptr) {
output += buffer.data();
}
if (pclose(pipe) != 0) {
return {};
}
return output;
}
std::optional<std::string> fetch_update_notice() {
static constexpr const char *kLatestReleaseApiUrl =
"https://git.mshq.dev/api/v1/repos/AxiFisk/shinoa/releases/latest";
if (std::string(TELEGRAM_TUI_BUILD_COMMIT).empty()) {
return std::nullopt;
}
const std::string response =
run_command_capture("curl -fsSL " + shell_quote(kLatestReleaseApiUrl) + " 2>/dev/null");
if (response.empty()) {
return std::nullopt;
}
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;
}
const std::string current_commit = TELEGRAM_TUI_BUILD_COMMIT;
if (target_commit == current_commit || target_commit.rfind(current_commit, 0) == 0) {
return std::nullopt;
}
return std::string("Update available");
} catch (const json::exception &) {
return std::nullopt;
}
}
} // namespace } // namespace
App::App() { App::App() {
@@ -49,6 +125,8 @@ App::App() {
if (use_test_dc_) { if (use_test_dc_) {
status_line_ = "Starting TDLib in test DC mode..."; status_line_ = "Starting TDLib in test DC mode...";
} }
start_update_check();
} }
int App::run() { int App::run() {
@@ -80,6 +158,27 @@ int App::run() {
return 0; return 0;
} }
void App::start_update_check() {
update_check_future_ =
std::async(std::launch::async, []() { return fetch_update_notice(); });
}
bool App::refresh_update_check_result() {
if (!update_check_future_.valid()) {
return false;
}
if (update_check_future_.wait_for(std::chrono::seconds(0)) != std::future_status::ready) {
return false;
}
const auto notice = update_check_future_.get();
if (notice == update_notice_) {
return false;
}
update_notice_ = notice.value_or(std::string());
return true;
}
std::optional<std::int64_t> App::highlighted_chat_id() const { std::optional<std::int64_t> App::highlighted_chat_id() const {
if (sorted_chat_ids_.empty()) { if (sorted_chat_ids_.empty()) {
return std::nullopt; return std::nullopt;

View File

@@ -3,6 +3,7 @@
#include <cstdint> #include <cstdint>
#include <chrono> #include <chrono>
#include <filesystem> #include <filesystem>
#include <future>
#include <map> #include <map>
#include <optional> #include <optional>
#include <tuple> #include <tuple>
@@ -38,6 +39,8 @@ class App {
void init_curses(); void init_curses();
void shutdown_curses(); void shutdown_curses();
void start_update_check();
bool refresh_update_check_result();
bool process_updates(); bool process_updates();
void handle_td_object(const json &object); void handle_td_object(const json &object);
void handle_authorization_state(); void handle_authorization_state();
@@ -63,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);
@@ -138,6 +147,7 @@ class App {
void handle_help_menu_key(int ch); void handle_help_menu_key(int ch);
void handle_attachments_menu_key(int ch); void handle_attachments_menu_key(int ch);
void handle_attachment_action_menu_key(int ch); void handle_attachment_action_menu_key(int ch);
void handle_saved_animation_menu_key(int ch);
void handle_attachment_viewer_key(int ch); void handle_attachment_viewer_key(int ch);
void draw(); void draw();
void init_colors(); void init_colors();
@@ -147,6 +157,7 @@ class App {
void draw_forward_target_menu(int height, int width); void draw_forward_target_menu(int height, int width);
void draw_attachments_menu(int height, int width); void draw_attachments_menu(int height, int width);
void draw_attachment_action_menu(int height, int width); void draw_attachment_action_menu(int height, int width);
void draw_saved_animation_menu(int height, int width);
void draw_attachment_viewer(int height, int width); void draw_attachment_viewer(int height, int width);
[[nodiscard]] std::optional<std::int64_t> highlighted_chat_id() const; [[nodiscard]] std::optional<std::int64_t> highlighted_chat_id() const;
[[nodiscard]] std::optional<std::int64_t> open_chat_id() const; [[nodiscard]] std::optional<std::int64_t> open_chat_id() const;
@@ -165,11 +176,14 @@ class App {
bool attachments_menu_open_ = false; bool attachments_menu_open_ = false;
bool attachment_action_menu_open_ = false; bool attachment_action_menu_open_ = false;
bool attachment_viewer_open_ = false; bool attachment_viewer_open_ = false;
bool saved_animation_menu_open_ = false;
bool forward_target_menu_open_ = false; bool forward_target_menu_open_ = false;
bool help_menu_open_ = false; bool help_menu_open_ = false;
bool input_hidden_ = false; bool input_hidden_ = false;
bool auto_reload_chat_history_ = false; bool auto_reload_chat_history_ = false;
bool use_test_dc_ = false; bool use_test_dc_ = false;
bool saved_animations_loading_ = false;
bool saved_animations_loaded_ = false;
FocusPane focus_ = FocusPane::Chats; FocusPane focus_ = FocusPane::Chats;
InputMode input_mode_ = InputMode::None; InputMode input_mode_ = InputMode::None;
@@ -180,6 +194,7 @@ class App {
int attachment_selection_index_ = 0; int attachment_selection_index_ = 0;
int attachment_action_index_ = 0; int attachment_action_index_ = 0;
int attachment_viewer_scroll_ = 0; int attachment_viewer_scroll_ = 0;
int saved_animation_selection_index_ = 0;
int forward_target_index_ = 0; int forward_target_index_ = 0;
int help_menu_page_ = 0; int help_menu_page_ = 0;
std::int64_t open_chat_id_ = 0; std::int64_t open_chat_id_ = 0;
@@ -197,11 +212,13 @@ class App {
std::string input_prompt_; std::string input_prompt_;
std::string input_buffer_; std::string input_buffer_;
std::string status_line_ = "Starting TDLib..."; std::string status_line_ = "Starting TDLib...";
std::string update_notice_;
std::string attachment_preview_graphics_data_; std::string attachment_preview_graphics_data_;
std::string attachment_viewer_title_; std::string attachment_viewer_title_;
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_;
@@ -217,6 +234,7 @@ class App {
std::size_t attachment_viewer_frame_index_ = 0; std::size_t attachment_viewer_frame_index_ = 0;
std::string attachment_viewer_send_caption_; std::string attachment_viewer_send_caption_;
std::vector<std::int64_t> forward_message_ids_; std::vector<std::int64_t> forward_message_ids_;
std::future<std::optional<std::string>> update_check_future_;
}; };
} // namespace telegram_tui } // namespace telegram_tui

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 {};
} }

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>
@@ -9,6 +10,7 @@
#include <curses.h> #include <curses.h>
#include "build_config.h"
#include "util.h" #include "util.h"
namespace telegram_tui { namespace telegram_tui {
@@ -64,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")) {
@@ -242,7 +359,7 @@ void App::send_tdlib_parameters() {
{"system_language_code", "en"}, {"system_language_code", "en"},
{"device_model", "shinoa"}, {"device_model", "shinoa"},
{"system_version", "Linux"}, {"system_version", "Linux"},
{"application_version", "0.1.0"}, {"application_version", TELEGRAM_TUI_BUILD_VERSION},
{"enable_storage_optimizer", true}, {"enable_storage_optimizer", true},
{"ignore_file_names", false}, {"ignore_file_names", false},
}); });
@@ -575,49 +692,89 @@ 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();
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; std::filesystem::path image_path;
for (std::uint64_t index = 0; index < 1024; ++index) { if (!clipboard_type.has_value()) {
const std::filesystem::path candidate = const auto clipboard_image_path = clipboard_image_file_path();
clipboard_dir / ("clipboard-" + std::to_string(std::time(nullptr)) + "-" + if (!clipboard_image_path.has_value()) {
std::to_string(index) + clipboard_type->extension); status_line_ = "Clipboard doesn't contain an image, or no clipboard tool is available." +
if (!std::filesystem::exists(candidate)) { missing_clipboard_tool_hint();
image_path = candidate; return false;
break; }
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 = for (std::uint64_t index = 0; index < 1024; ++index) {
clipboard_capture_command(clipboard_type->mime, image_path.string()); const std::filesystem::path candidate =
if (command.empty() || std::system(command.c_str()) != 0 || clipboard_dir / ("clipboard-" + std::to_string(std::time(nullptr)) + "-" +
!std::filesystem::exists(image_path)) { std::to_string(index) + clipboard_type->extension);
status_line_ = "Failed to read image data from the clipboard."; if (!std::filesystem::exists(candidate)) {
return false; 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 { try {
if (std::filesystem::file_size(image_path) == 0) { 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."; 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,10 +2,12 @@
#include <algorithm> #include <algorithm>
#include <clocale> #include <clocale>
#include <sstream>
#include <curses.h> #include <curses.h>
#include "app_ui.h" #include "app_ui.h"
#include "build_config.h"
#include "util.h" #include "util.h"
namespace telegram_tui { namespace telegram_tui {
@@ -27,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() {
@@ -88,10 +106,25 @@ void App::draw() {
attron(A_REVERSE); attron(A_REVERSE);
mvhline(header_y, 0, ' ', width); mvhline(header_y, 0, ' ', width);
const std::string header_label = use_test_dc_ ? "shinoa [TEST DC]" : "shinoa"; std::string header_label = std::string("shinoa ") + TELEGRAM_TUI_PROJECT_VERSION;
if (use_test_dc_) {
header_label += " [TEST DC]";
}
mvprintw(header_y, 1, "%s", header_label.c_str()); mvprintw(header_y, 1, "%s", header_label.c_str());
const std::string auth_label = authorized_ ? "ready" : current_auth_label(); const std::string auth_label = authorized_ ? "ready" : current_auth_label();
const int auth_x = std::max(1, width - static_cast<int>(auth_label.size()) - 2); const int auth_x = std::max(1, width - static_cast<int>(auth_label.size()) - 2);
if (!update_notice_.empty()) {
const std::string centered_notice =
truncate_to_width(update_notice_, std::max(1, width - 4));
const int notice_x =
std::max(1, (width - static_cast<int>(centered_notice.size())) / 2);
if (notice_x > 1 + static_cast<int>(header_label.size()) &&
notice_x + static_cast<int>(centered_notice.size()) < auth_x - 1) {
attron(A_BOLD);
mvprintw(header_y, notice_x, "%s", centered_notice.c_str());
attroff(A_BOLD);
}
}
mvprintw(header_y, auth_x, "%s", auth_label.c_str()); mvprintw(header_y, auth_x, "%s", auth_label.c_str());
attroff(A_REVERSE); attroff(A_REVERSE);
@@ -134,6 +167,9 @@ void App::draw() {
draw_attachment_viewer(height, width); draw_attachment_viewer(height, width);
} else if (attachment_action_menu_open_) { } else if (attachment_action_menu_open_) {
draw_attachment_action_menu(height, width); draw_attachment_action_menu(height, width);
} else if (saved_animation_menu_open_) {
clear_attachment_preview_graphics();
draw_saved_animation_menu(height, width);
} else if (attachments_menu_open_) { } else if (attachments_menu_open_) {
draw_attachments_menu(height, width); draw_attachments_menu(height, width);
} else if (forward_target_menu_open_) { } else if (forward_target_menu_open_) {
@@ -233,6 +269,116 @@ void App::draw_forward_target_menu(int height, int width) {
delwin(window); delwin(window);
} }
void App::draw_saved_animation_menu(int height, int width) {
const int menu_width = std::min(width - 4, 110);
const int menu_height = std::min(height - 4, 28);
const int top = std::max(1, (height - menu_height) / 2);
const int left = std::max(1, (width - menu_width) / 2);
WINDOW *window = newwin(menu_height, menu_width, top, left);
if (window == nullptr) {
return;
}
if (saved_animation_selection_index_ < 0) {
saved_animation_selection_index_ = 0;
}
if (saved_animation_selection_index_ >= static_cast<int>(saved_animations_.size())) {
saved_animation_selection_index_ =
std::max(0, static_cast<int>(saved_animations_.size()) - 1);
}
box(window, 0, 0);
mvwprintw(window, 0, 2, " Saved GIFs ");
std::string subtitle = saved_animations_loading_
? "Loading..."
: std::to_string(saved_animations_.size()) + " saved";
mvwaddnstr(window, 1, 2, subtitle.c_str(), menu_width - 4);
mvwhline(window, 2, 1, ACS_HLINE, menu_width - 2);
const int list_width = std::max(28, menu_width / 2 - 1);
const int preview_left = list_width + 2;
const int preview_width = std::max(10, menu_width - preview_left - 2);
const int list_top = 3;
const int list_height = std::max(1, menu_height - 6);
int first_index = 0;
if (saved_animation_selection_index_ >= list_height) {
first_index = saved_animation_selection_index_ - list_height + 1;
}
for (int row = 0; row < list_height; ++row) {
const int item_index = first_index + row;
const int y = list_top + row;
mvwhline(window, y, 1, ' ', list_width);
if (item_index >= static_cast<int>(saved_animations_.size())) {
continue;
}
const SavedAnimationInfo &animation =
saved_animations_[static_cast<std::size_t>(item_index)];
std::string label = animation.name;
if (animation.is_downloading_active && !animation.is_downloaded) {
label += " [dl]";
} else if (animation.is_downloaded) {
label += " [ready]";
}
if (item_index == saved_animation_selection_index_) {
wattron(window, A_REVERSE | A_BOLD);
}
mvwaddnstr(window, y, 2, truncate_to_width(label, list_width - 2).c_str(),
list_width - 2);
if (item_index == saved_animation_selection_index_) {
wattroff(window, A_REVERSE | A_BOLD);
}
}
mvwvline(window, 3, preview_left - 1, ACS_VLINE, menu_height - 4);
if (saved_animations_.empty()) {
mvwaddnstr(window, 4, preview_left, "No saved GIFs on this account.", preview_width);
} else {
const SavedAnimationInfo &animation = saved_animations_[static_cast<std::size_t>(
saved_animation_selection_index_)];
const std::string title = truncate_to_width(animation.name, preview_width);
mvwaddnstr(window, 3, preview_left, title.c_str(), preview_width);
const std::string meta =
truncate_to_width(format_file_size(animation.size_bytes) + " " +
std::to_string(std::max(0, animation.width)) + "x" +
std::to_string(std::max(0, animation.height)) + " " +
std::to_string(std::max(0, animation.duration)) + "s",
preview_width);
mvwaddnstr(window, 4, preview_left, meta.c_str(), preview_width);
mvwhline(window, 5, preview_left, ACS_HLINE, preview_width);
AttachmentInfo preview_attachment;
preview_attachment.type = AttachmentType::Animation;
preview_attachment.name = animation.name;
preview_attachment.size_bytes = animation.size_bytes;
preview_attachment.downloaded_size = animation.downloaded_size;
preview_attachment.file_id = animation.file_id;
preview_attachment.local_path = animation.local_path;
preview_attachment.is_downloading_active = animation.is_downloading_active;
preview_attachment.can_be_downloaded = animation.can_be_downloaded;
preview_attachment.can_be_deleted = animation.can_be_deleted;
preview_attachment.is_downloaded = animation.is_downloaded;
const std::string preview = render_attachment_preview(
preview_attachment, preview_width, std::max(4, list_height - 4));
const std::vector<std::string> preview_lines = split_preview_lines(preview);
for (std::size_t i = 0; i < preview_lines.size() &&
static_cast<int>(i) < list_height - 3;
++i) {
mvwaddnstr(window, 6 + static_cast<int>(i), preview_left,
truncate_to_width(preview_lines[i], preview_width).c_str(), preview_width);
}
}
mvwaddnstr(window, menu_height - 2, 2,
"Enter send r refresh Up/Down move Esc close", menu_width - 4);
wrefresh(window);
delwin(window);
}
std::string App::current_auth_label() const { std::string App::current_auth_label() const {
const std::string type = safe_string(authorization_state_, "@type"); const std::string type = safe_string(authorization_state_, "@type");
if (type == "authorizationStateWaitTdlibParameters") { if (type == "authorizationStateWaitTdlibParameters") {

View File

@@ -25,7 +25,7 @@ std::string format_download_progress(std::int64_t downloaded_size, std::int64_t
} // namespace } // namespace
bool App::process_updates() { bool App::process_updates() {
bool changed = false; bool changed = refresh_update_check_result();
while (true) { while (true) {
auto update = td_.receive(0.0); auto update = td_.receive(0.0);
if (!update.has_value()) { if (!update.has_value()) {
@@ -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

@@ -1,4 +1,7 @@
#pragma once #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@" #define TELEGRAM_TUI_BUILD_API_ID "@TELEGRAM_TUI_BUILD_API_ID@"
#define TELEGRAM_TUI_BUILD_API_HASH "@TELEGRAM_TUI_BUILD_API_HASH@" #define TELEGRAM_TUI_BUILD_API_HASH "@TELEGRAM_TUI_BUILD_API_HASH@"

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;