This commit is contained in:
2026-04-23 17:00:41 +03:00
commit ac28065d2a
17 changed files with 5401 additions and 0 deletions

1610
src/app.cpp Normal file

File diff suppressed because it is too large Load Diff

186
src/app.h Normal file
View File

@@ -0,0 +1,186 @@
#pragma once
#include <cstdint>
#include <chrono>
#include <filesystem>
#include <map>
#include <optional>
#include <tuple>
#include <string>
#include <vector>
#include "json.h"
#include "models.h"
#include "td_client.h"
namespace telegram_tui {
class App {
public:
App();
int run();
private:
void init_curses();
void shutdown_curses();
bool process_updates();
void handle_td_object(const json& object);
void handle_authorization_state();
void send_tdlib_parameters();
void send_check_phone_number();
void persist_config();
void request_chat_details(std::int64_t chat_id);
void set_open_chat(std::int64_t chat_id);
void open_forward_target_menu(std::int64_t source_chat_id, std::vector<std::int64_t> message_ids);
void start_input(InputMode mode, std::string prompt, bool hidden);
void clear_input();
void submit_input();
void send_message(std::int64_t chat_id, const std::string& text, std::optional<std::int64_t> reply_to_message_id = std::nullopt);
void forward_message(
std::int64_t source_chat_id,
const std::vector<std::int64_t>& message_ids,
std::int64_t target_chat_id);
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);
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_more_chats();
bool request_chat_history(std::int64_t chat_id, bool force);
bool request_open_chat_history(bool force);
void sync_chat_ids_from_response(const json& response);
void mark_chat_messages_as_read(std::int64_t chat_id);
void mark_message_as_read(std::int64_t chat_id, std::int64_t message_id);
void update_user_status(std::int64_t user_id, const json& status);
void upsert_user(const json& user);
void upsert_basic_group(const json& basic_group);
void upsert_supergroup(const json& supergroup);
void upsert_chat(const json& chat_object);
void apply_chat_position(ChatInfo& chat, const json& position);
void resort_chats();
void append_message(std::int64_t chat_id, MessageInfo message);
void remove_message(std::int64_t chat_id, std::int64_t message_id);
void merge_history(std::int64_t chat_id, const json& messages);
void update_attachment_file(const json& file);
void start_reply_to_latest_message();
void request_attachment_download(const AttachmentInfo& attachment, bool open_after_download);
void open_attachment(const AttachmentInfo& attachment);
void download_attachment(const AttachmentInfo& attachment);
void delete_attachment(const AttachmentInfo& attachment);
bool export_attachment_to_downloads(const AttachmentInfo& attachment);
bool play_video_attachment(const AttachmentInfo& attachment);
void refresh_attachment_viewer_content(const AttachmentInfo& attachment);
[[nodiscard]] std::vector<std::string> attachment_animation_frames(const AttachmentInfo& attachment) const;
bool advance_attachment_animation();
[[nodiscard]] std::string attachment_preview_path(const AttachmentInfo& attachment) const;
[[nodiscard]] bool has_inline_attachment_preview(const AttachmentInfo& attachment, int width, int height) const;
void clear_attachment_preview_graphics();
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;
[[nodiscard]] std::optional<AttachmentInfo> selected_attachment() const;
[[nodiscard]] std::string render_attachment_preview(const AttachmentInfo& attachment, int width, int height) const;
[[nodiscard]] std::string build_attachment_preview_graphics(const AttachmentInfo& attachment, int width, int height) const;
[[nodiscard]] std::tuple<bool, std::int64_t, std::string> parse_compose_command(const std::string& value) const;
[[nodiscard]] std::optional<std::vector<std::int64_t>> parse_forward_command(const std::string& value) const;
[[nodiscard]] std::optional<std::size_t> find_message_index(const ChatInfo& chat, std::int64_t message_id) const;
[[nodiscard]] std::string format_message_ref(const ChatInfo& chat, std::int64_t message_id) const;
[[nodiscard]] std::string sender_label(const json& sender) const;
[[nodiscard]] std::string user_status_label(std::int64_t user_id) const;
[[nodiscard]] std::string forward_origin_label(const json& forward_info) const;
[[nodiscard]] std::string via_bot_label(std::int64_t via_bot_user_id) const;
[[nodiscard]] std::string preview_message(const json& message) const;
[[nodiscard]] std::optional<AttachmentInfo> parse_attachment(const json& content) const;
[[nodiscard]] std::string content_to_text(const json& content, bool decorate) const;
[[nodiscard]] MessageInfo parse_message(const json& message) const;
[[nodiscard]] std::string format_open_chat_header(const ChatInfo& chat) const;
void handle_key(int ch);
void handle_wide_char(wint_t ch);
void handle_input_key(int ch);
void handle_forward_target_menu_key(int ch);
void handle_help_menu_key(int ch);
void handle_attachments_menu_key(int ch);
void handle_attachment_action_menu_key(int ch);
void handle_attachment_viewer_key(int ch);
void draw();
void init_colors();
void draw_chat_pane(int top, int height, int width);
void draw_message_pane(int top, int height, int left, int width);
void draw_help_menu(int height, int width);
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_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;
[[nodiscard]] std::string current_auth_label() const;
TdClient td_;
std::map<std::int64_t, UserInfo> users_;
std::map<std::int64_t, ChatInfo> chats_;
std::vector<std::int64_t> sorted_chat_ids_;
std::filesystem::path database_dir_;
std::filesystem::path files_dir_;
bool running_ = true;
bool authorized_ = false;
bool attachments_menu_open_ = false;
bool attachment_action_menu_open_ = false;
bool attachment_viewer_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;
FocusPane focus_ = FocusPane::Chats;
InputMode input_mode_ = InputMode::None;
int selected_chat_index_ = 0;
int message_scroll_ = 0;
int attachment_category_index_ = 0;
int attachment_selection_index_ = 0;
int attachment_action_index_ = 0;
int attachment_viewer_scroll_ = 0;
int forward_target_index_ = 0;
std::int64_t open_chat_id_ = 0;
std::int64_t tdlib_open_chat_id_ = 0;
std::int64_t my_user_id_ = 0;
std::int64_t forward_source_chat_id_ = 0;
std::size_t input_cursor_ = 0;
std::string api_id_;
std::string api_hash_;
std::string phone_number_;
std::string pending_first_name_;
std::string input_prompt_;
std::string input_buffer_;
std::string status_line_ = "Starting TDLib...";
std::string attachment_preview_graphics_data_;
std::string attachment_viewer_title_;
std::string attachment_preview_signature_;
std::vector<std::string> attachment_viewer_lines_;
std::vector<std::string> attachment_viewer_animation_frames_;
json authorization_state_ = json::object();
std::optional<AttachmentInfo> pending_attachment_open_;
std::optional<AttachmentInfo> pending_attachment_download_;
std::optional<AttachmentInfo> attachment_viewer_attachment_;
std::optional<std::int64_t> attachment_viewer_send_reply_to_message_id_;
std::chrono::steady_clock::time_point attachment_viewer_next_frame_at_{};
std::int64_t attachment_viewer_send_chat_id_ = 0;
bool attachment_preview_graphics_visible_ = false;
bool attachment_preview_graphics_is_sixel_ = false;
bool attachment_viewer_send_on_enter_ = false;
bool attachment_viewer_is_animated_ = false;
std::size_t attachment_viewer_frame_index_ = 0;
std::string attachment_viewer_send_caption_;
std::vector<std::int64_t> forward_message_ids_;
};
} // namespace telegram_tui

1356
src/app_attachments.cpp Normal file

File diff suppressed because it is too large Load Diff

669
src/app_auth.cpp Normal file
View File

@@ -0,0 +1,669 @@
#include "app.h"
#include <array>
#include <set>
#include <cstdio>
#include <cstdlib>
#include <filesystem>
#include <sstream>
#include <curses.h>
#include "util.h"
namespace telegram_tui {
namespace {
struct ClipboardImageType {
const char* mime = "";
const char* extension = "";
};
constexpr std::array<ClipboardImageType, 5> kClipboardImageTypes = {{
{"image/png", ".png"},
{"image/jpeg", ".jpg"},
{"image/webp", ".webp"},
{"image/bmp", ".bmp"},
{"image/tiff", ".tiff"},
}};
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;
char buffer[4096];
while (std::fgets(buffer, sizeof(buffer), pipe) != nullptr) {
output += buffer;
}
if (pclose(pipe) != 0) {
return {};
}
return output;
}
bool command_exists(const char* command) {
const std::string resolved = run_command_capture("command -v " + std::string(command) + " 2>/dev/null");
return !trim_copy(resolved).empty();
}
std::string clipboard_capture_command(const std::string& mime_type, const std::string& destination_path) {
if (command_exists("wl-paste")) {
return "wl-paste --no-newline --type " + shell_quote(mime_type) + " > " +
shell_quote(destination_path) + " 2>/dev/null";
}
if (command_exists("xclip")) {
return "xclip -selection clipboard -t " + shell_quote(mime_type) + " -o > " +
shell_quote(destination_path) + " 2>/dev/null";
}
if (command_exists("pngpaste") && mime_type == "image/png") {
return "pngpaste " + shell_quote(destination_path) + " >/dev/null 2>&1";
}
return {};
}
std::optional<ClipboardImageType> detect_clipboard_image_type() {
if (command_exists("wl-paste")) {
const std::string types_output = run_command_capture("wl-paste --list-types 2>/dev/null");
for (const auto& type : kClipboardImageTypes) {
if (types_output.find(type.mime) != std::string::npos) {
return type;
}
}
}
if (command_exists("xclip")) {
const std::string targets_output =
run_command_capture("xclip -selection clipboard -t TARGETS -o 2>/dev/null");
for (const auto& type : kClipboardImageTypes) {
if (targets_output.find(type.mime) != std::string::npos) {
return type;
}
}
}
if (command_exists("pngpaste")) {
return ClipboardImageType{"image/png", ".png"};
}
return std::nullopt;
}
std::optional<std::string> parse_clipboard_compose_command(const std::string& value) {
static constexpr std::array<const char*, 3> kPrefixes = {">paste", ">clip", ">screenshot"};
for (const char* prefix : kPrefixes) {
if (value == prefix) {
return std::string();
}
const std::string with_space = std::string(prefix) + " ";
if (value.rfind(with_space, 0) == 0) {
return trim_copy(value.substr(with_space.size()));
}
}
return std::nullopt;
}
std::optional<std::string> strip_forward_prefix(const std::string& value) {
static constexpr std::array<const char*, 3> kPrefixes = {">f", ">fw", ">forward"};
for (const char* prefix : kPrefixes) {
if (value == prefix) {
return std::string();
}
const std::string with_space = std::string(prefix) + " ";
if (value.rfind(with_space, 0) == 0) {
return trim_copy(value.substr(with_space.size()));
}
}
return std::nullopt;
}
json markdown_formatted_text(TdClient& td, const std::string& text) {
json formatted_text = {
{"@type", "formattedText"},
{"text", text},
{"entities", json::array()},
};
if (const auto parsed = td.execute({
{"@type", "parseMarkdown"},
{"text", formatted_text},
});
parsed.has_value() && safe_string(*parsed, "@type") == "formattedText") {
formatted_text = *parsed;
}
return formatted_text;
}
} // namespace
void App::handle_authorization_state() {
const std::string type = safe_string(authorization_state_, "@type");
if (type == "authorizationStateWaitTdlibParameters") {
if (api_id_.empty()) {
start_input(InputMode::ApiId, "Telegram API ID", false);
status_line_ = "Enter your Telegram application API ID.";
return;
}
if (api_hash_.empty()) {
start_input(InputMode::ApiHash, "Telegram API hash", false);
status_line_ = "Enter your Telegram application API hash.";
return;
}
send_tdlib_parameters();
return;
}
if (type == "authorizationStateWaitPhoneNumber") {
if (phone_number_.empty()) {
start_input(InputMode::PhoneNumber, "Phone number", false);
status_line_ = "Enter phone number for Telegram account.";
return;
}
send_check_phone_number();
return;
}
if (type == "authorizationStateWaitEncryptionKey") {
td_.send({
{"@type", "checkDatabaseEncryptionKey"},
{"encryption_key", ""},
});
status_line_ = "Unlocking local database...";
return;
}
if (type == "authorizationStateWaitCode") {
start_input(InputMode::AuthCode, "Login code", false);
status_line_ = "Enter code Telegram sent.";
return;
}
if (type == "authorizationStateWaitPassword") {
start_input(InputMode::Password, "2FA password", true);
status_line_ = "Two-step verification enabled.";
return;
}
if (type == "authorizationStateWaitRegistration") {
if (pending_first_name_.empty()) {
start_input(InputMode::FirstName, "First name", false);
status_line_ = "New account. Enter first name.";
} else {
start_input(InputMode::LastName, "Last name", false);
status_line_ = "Enter optional last name.";
}
return;
}
if (type == "authorizationStateReady") {
authorized_ = true;
input_mode_ = InputMode::None;
status_line_ = "Authorized. Loading chats.";
td_.send({
{"@type", "getMe"},
{"@extra", "getMe"},
});
request_more_chats();
if (!open_chat_id().has_value()) {
const auto chat_id = highlighted_chat_id();
if (chat_id.has_value()) {
set_open_chat(*chat_id);
}
}
request_open_chat_history(false);
return;
}
if (type == "authorizationStateLoggingOut") {
authorized_ = false;
status_line_ = "Logging out...";
return;
}
if (type == "authorizationStateClosed") {
authorized_ = false;
status_line_ = "TDLib session closed.";
}
}
void App::send_tdlib_parameters() {
if (!is_decimal_number(api_id_)) {
start_input(InputMode::ApiId, "Telegram API ID", false);
status_line_ = "Telegram API ID must be numeric.";
return;
}
status_line_ = "Sending TDLib parameters...";
td_.send({
{"@type", "setTdlibParameters"},
{"use_test_dc", use_test_dc_},
{"database_directory", database_dir_.string()},
{"files_directory", files_dir_.string()},
{"database_encryption_key", ""},
{"use_file_database", true},
{"use_chat_info_database", true},
{"use_message_database", true},
{"use_secret_chats", true},
{"api_id", std::stoi(api_id_)},
{"api_hash", api_hash_},
{"system_language_code", "en"},
{"device_model", "shinoa"},
{"system_version", "Linux"},
{"application_version", "0.1.0"},
{"enable_storage_optimizer", true},
{"ignore_file_names", false},
});
}
void App::send_check_phone_number() {
status_line_ = "Requesting login code...";
td_.send({
{"@type", "setAuthenticationPhoneNumber"},
{"phone_number", phone_number_},
{"settings", {{"@type", "phoneNumberAuthenticationSettings"}}},
});
}
void App::persist_config() {
if (!save_app_config(StoredConfig{api_id_, api_hash_, auto_reload_chat_history_})) {
status_line_ = "Failed to save config locally.";
}
}
void App::request_chat_details(std::int64_t chat_id) {
auto chat_it = chats_.find(chat_id);
if (chat_it == chats_.end()) {
return;
}
ChatInfo& chat = chat_it->second;
if (chat.details_requested) {
return;
}
if (chat.private_user_id != 0) {
td_.send({
{"@type", "getUser"},
{"user_id", chat.private_user_id},
});
chat.details_requested = true;
return;
}
if (chat.basic_group_id != 0) {
td_.send({
{"@type", "getBasicGroup"},
{"basic_group_id", chat.basic_group_id},
});
chat.details_requested = true;
return;
}
if (chat.supergroup_id != 0) {
td_.send({
{"@type", "getSupergroup"},
{"supergroup_id", chat.supergroup_id},
});
chat.details_requested = true;
}
}
void App::start_input(InputMode mode, std::string prompt, bool hidden) {
input_mode_ = mode;
input_prompt_ = std::move(prompt);
input_hidden_ = hidden;
if (mode != InputMode::Compose) {
input_buffer_.clear();
}
input_cursor_ = input_buffer_.size();
curs_set(1);
}
void App::clear_input() {
input_mode_ = InputMode::None;
input_prompt_.clear();
input_buffer_.clear();
input_cursor_ = 0;
input_hidden_ = false;
curs_set(0);
}
void App::submit_input() {
const std::string value = trim_copy(input_buffer_);
if (value.empty()) {
status_line_ = "Input cannot be empty.";
return;
}
const InputMode mode = input_mode_;
if (mode == InputMode::ApiId && !is_decimal_number(value)) {
status_line_ = "Telegram API ID must be numeric.";
return;
}
clear_input();
if (mode == InputMode::Compose) {
const auto chat_id = open_chat_id();
if (!chat_id.has_value()) {
status_line_ = "Open chat first.";
return;
}
if (const auto message_ids = parse_forward_command(value); message_ids.has_value()) {
open_forward_target_menu(*chat_id, *message_ids);
return;
}
const auto [is_reply, reply_to_message_id, text] = parse_compose_command(value);
if (const auto clipboard_caption = parse_clipboard_compose_command(text); clipboard_caption.has_value()) {
preview_clipboard_photo_message(
*chat_id,
*clipboard_caption,
is_reply ? std::optional<std::int64_t>(reply_to_message_id) : std::nullopt);
return;
}
if (text.empty()) {
status_line_ = "Reply text cannot be empty.";
return;
}
send_message(*chat_id, text, is_reply ? std::optional<std::int64_t>(reply_to_message_id) : std::nullopt);
return;
}
if (mode == InputMode::ApiId) {
api_id_ = value;
persist_config();
if (api_hash_.empty()) {
start_input(InputMode::ApiHash, "Telegram API hash", false);
} else {
send_tdlib_parameters();
}
return;
}
if (mode == InputMode::ApiHash) {
api_hash_ = value;
persist_config();
send_tdlib_parameters();
return;
}
if (mode == InputMode::PhoneNumber) {
phone_number_ = value;
send_check_phone_number();
return;
}
if (mode == InputMode::AuthCode) {
td_.send({
{"@type", "checkAuthenticationCode"},
{"code", value},
});
status_line_ = "Submitting code...";
return;
}
if (mode == InputMode::Password) {
td_.send({
{"@type", "checkAuthenticationPassword"},
{"password", value},
});
status_line_ = "Submitting password...";
return;
}
if (mode == InputMode::FirstName) {
pending_first_name_ = value;
start_input(InputMode::LastName, "Last name", false);
status_line_ = "Enter last name or - for none.";
return;
}
if (mode == InputMode::LastName) {
const std::string last_name = value == "-" ? "" : value;
td_.send({
{"@type", "registerUser"},
{"first_name", pending_first_name_},
{"last_name", last_name},
});
pending_first_name_.clear();
status_line_ = "Registering account...";
}
}
void App::send_message(
std::int64_t chat_id,
const std::string& text,
std::optional<std::int64_t> reply_to_message_id) {
json formatted_text = markdown_formatted_text(td_, text);
json request = {
{"@type", "sendMessage"},
{"chat_id", chat_id},
{"input_message_content",
{
{"@type", "inputMessageText"},
{"text", formatted_text},
{"clear_draft", true},
}},
};
if (reply_to_message_id.has_value()) {
request["reply_to"] = {
{"@type", "inputMessageReplyToMessage"},
{"message_id", *reply_to_message_id},
};
}
td_.send(request);
status_line_ = "Message queued.";
}
void App::forward_message(
std::int64_t source_chat_id,
const std::vector<std::int64_t>& message_ids,
std::int64_t target_chat_id) {
td_.send({
{"@type", "forwardMessages"},
{"chat_id", target_chat_id},
{"from_chat_id", source_chat_id},
{"message_ids", message_ids},
{"send_copy", false},
{"remove_caption", false},
});
status_line_ = message_ids.size() == 1 ? "Forwarding message..." : "Forwarding messages...";
}
void App::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) {
json request = {
{"@type", "sendMessage"},
{"chat_id", chat_id},
{"input_message_content",
{
{"@type", "inputMessagePhoto"},
{"photo", {{"@type", "inputFileLocal"}, {"path", photo_path}}},
{"thumbnail", nullptr},
{"video", nullptr},
{"added_sticker_file_ids", json::array()},
{"width", 0},
{"height", 0},
{"caption", markdown_formatted_text(td_, caption)},
{"show_caption_above_media", false},
{"self_destruct_type", nullptr},
{"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_ = "Photo 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 (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);
status_line_ = "Clipboard image is empty.";
return false;
}
} catch (const std::exception&) {
status_line_ = "Clipboard image couldn't be validated.";
return false;
}
AttachmentInfo attachment;
attachment.type = AttachmentType::Photo;
attachment.name = image_path.filename().string();
attachment.size_bytes = static_cast<std::int64_t>(std::filesystem::file_size(image_path));
attachment.local_path = image_path.string();
attachment.is_downloaded = true;
attachment_viewer_attachment_ = attachment;
attachment_viewer_title_ = "Clipboard photo: " + attachment.name;
attachment_viewer_animation_frames_.clear();
attachment_viewer_is_animated_ = false;
attachment_viewer_frame_index_ = 0;
attachment_viewer_scroll_ = 0;
attachment_preview_signature_.clear();
attachment_viewer_send_on_enter_ = true;
attachment_viewer_send_chat_id_ = chat_id;
attachment_viewer_send_reply_to_message_id_ = reply_to_message_id;
attachment_viewer_send_caption_ = caption;
refresh_attachment_viewer_content(attachment);
attachment_viewer_open_ = true;
status_line_ = caption.empty()
? "Clipboard image preview. Press Enter to send."
: "Clipboard image preview with caption. Press Enter to send.";
return true;
}
std::optional<std::vector<std::int64_t>> App::parse_forward_command(const std::string& value) const {
const auto remainder = strip_forward_prefix(value);
if (!remainder.has_value()) {
return std::nullopt;
}
const auto chat_id = open_chat_id();
if (!chat_id.has_value()) {
return std::vector<std::int64_t>{};
}
const auto chat_it = chats_.find(*chat_id);
if (chat_it == chats_.end()) {
return std::vector<std::int64_t>{};
}
const ChatInfo& chat = chat_it->second;
if (remainder->empty()) {
return std::vector<std::int64_t>{};
}
auto resolve_message_ref = [&](std::int64_t ref) -> std::int64_t {
if (ref > 0 && ref <= static_cast<std::int64_t>(chat.messages.size())) {
return chat.messages[static_cast<std::size_t>(ref - 1)].id;
}
for (const auto& message : chat.messages) {
if (message.id == ref) {
return message.id;
}
}
return 0;
};
std::string normalized = *remainder;
for (char& ch : normalized) {
if (ch == ',') {
ch = ' ';
}
}
std::set<std::int64_t> unique_message_ids;
std::stringstream stream(normalized);
std::string token;
while (stream >> token) {
const auto dash = token.find('-');
if (dash != std::string::npos) {
const std::string start_text = token.substr(0, dash);
const std::string end_text = token.substr(dash + 1);
if (!is_decimal_number(start_text) || !is_decimal_number(end_text)) {
return std::vector<std::int64_t>{};
}
std::int64_t start_value = 0;
std::int64_t end_value = 0;
try {
start_value = std::stoll(start_text);
end_value = std::stoll(end_text);
} catch (...) {
return std::vector<std::int64_t>{};
}
if (start_value > end_value) {
std::swap(start_value, end_value);
}
for (std::int64_t ref = start_value; ref <= end_value; ++ref) {
const std::int64_t message_id = resolve_message_ref(ref);
if (message_id == 0) {
return std::vector<std::int64_t>{};
}
unique_message_ids.insert(message_id);
}
continue;
}
if (!is_decimal_number(token)) {
return std::vector<std::int64_t>{};
}
std::int64_t parsed_value = 0;
try {
parsed_value = std::stoll(token);
} catch (...) {
return std::vector<std::int64_t>{};
}
const std::int64_t message_id = resolve_message_ref(parsed_value);
if (message_id == 0) {
return std::vector<std::int64_t>{};
}
unique_message_ids.insert(message_id);
}
return std::vector<std::int64_t>(unique_message_ids.begin(), unique_message_ids.end());
}
} // namespace telegram_tui

269
src/app_chats.cpp Normal file
View File

@@ -0,0 +1,269 @@
#include "app.h"
#include <algorithm>
#include "util.h"
namespace telegram_tui {
namespace {
std::string join_with_separator_local(const std::vector<std::string>& parts, const char* separator) {
std::string joined;
for (const auto& part : parts) {
if (part.empty()) {
continue;
}
if (!joined.empty()) {
joined += separator;
}
joined += part;
}
return joined;
}
std::string format_user_status(const json& status) {
const std::string type = safe_string(status, "@type");
if (type == "userStatusOnline") {
return "online";
}
if (type == "userStatusOffline") {
const std::int32_t was_online = safe_i32(status, "was_online");
return was_online > 0 ? ("last seen " + format_datetime(was_online)) : "offline";
}
if (type == "userStatusRecently") {
return "last seen recently";
}
if (type == "userStatusLastWeek") {
return "last seen within a week";
}
if (type == "userStatusLastMonth") {
return "last seen within a month";
}
return {};
}
std::string primary_username(const json& object) {
const json usernames = object.value("usernames", json::object());
const json active_usernames = usernames.value("active_usernames", json::array());
if (active_usernames.is_array()) {
for (const auto& username : active_usernames) {
if (username.is_string()) {
const std::string value = username.get<std::string>();
if (!value.empty()) {
return value;
}
}
}
}
return safe_string(object, "username");
}
} // namespace
void App::update_user_status(std::int64_t user_id, const json& status) {
UserInfo& info = users_[user_id];
info.id = user_id;
info.status = format_user_status(status);
}
void App::upsert_user(const json& user) {
if (user.is_null()) {
return;
}
UserInfo& info = users_[safe_i64(user, "id")];
info.id = safe_i64(user, "id");
info.first_name = safe_string(user, "first_name");
info.last_name = safe_string(user, "last_name");
info.username = primary_username(user);
info.status = format_user_status(user.value("status", json::object()));
}
void App::upsert_basic_group(const json& basic_group) {
if (basic_group.is_null()) {
return;
}
const std::int64_t basic_group_id = safe_i64(basic_group, "id");
const std::int32_t member_count = safe_i32(basic_group, "member_count");
for (auto& [chat_id, chat] : chats_) {
if (chat.basic_group_id != basic_group_id) {
continue;
}
chat.id = chat_id;
chat.username.clear();
chat.member_count = member_count;
chat.has_member_count = true;
}
}
void App::upsert_supergroup(const json& supergroup) {
if (supergroup.is_null()) {
return;
}
const std::int64_t supergroup_id = safe_i64(supergroup, "id");
const std::int32_t member_count = safe_i32(supergroup, "member_count");
const bool is_channel = supergroup.value("is_channel", false);
const std::string username = primary_username(supergroup);
for (auto& [chat_id, chat] : chats_) {
if (chat.supergroup_id != supergroup_id) {
continue;
}
chat.id = chat_id;
chat.is_channel = is_channel;
chat.username = username;
chat.member_count = member_count;
chat.has_member_count = true;
}
}
void App::upsert_chat(const json& chat_object) {
if (chat_object.is_null()) {
return;
}
const std::int64_t chat_id = safe_i64(chat_object, "id");
ChatInfo& chat = chats_[chat_id];
chat.id = chat_id;
if (chat_object.contains("title")) {
chat.title = safe_string(chat_object, "title");
}
const json type = chat_object.value("type", json::object());
const std::string type_name = safe_string(type, "@type");
const std::int64_t previous_private_user_id = chat.private_user_id;
const std::int64_t previous_basic_group_id = chat.basic_group_id;
const std::int64_t previous_supergroup_id = chat.supergroup_id;
chat.private_user_id = 0;
chat.basic_group_id = 0;
chat.supergroup_id = 0;
chat.is_channel = false;
if (type_name == "chatTypePrivate" || type_name == "chatTypeSecret") {
chat.private_user_id = safe_i64(type, "user_id");
chat.username.clear();
chat.has_member_count = false;
chat.has_online_member_count = false;
chat.member_count = 0;
chat.online_member_count = 0;
} else if (type_name == "chatTypeBasicGroup") {
chat.basic_group_id = safe_i64(type, "basic_group_id");
chat.username.clear();
} else if (type_name == "chatTypeSupergroup") {
chat.supergroup_id = safe_i64(type, "supergroup_id");
chat.is_channel = type.value("is_channel", false);
if (chat.supergroup_id != previous_supergroup_id) {
chat.username.clear();
}
} else {
chat.username.clear();
}
if (chat.private_user_id != previous_private_user_id ||
chat.basic_group_id != previous_basic_group_id ||
chat.supergroup_id != previous_supergroup_id) {
chat.details_requested = false;
}
chat.unread_count = safe_i32(chat_object, "unread_count");
chat.last_read_inbox_message_id = safe_i64(chat_object, "last_read_inbox_message_id");
chat.last_read_outbox_message_id = safe_i64(chat_object, "last_read_outbox_message_id");
if (chat_object.contains("last_message")) {
chat.last_message_preview = preview_message(chat_object.at("last_message"));
}
if (chat_object.contains("positions") && chat_object.at("positions").is_array()) {
chat.in_main_list = false;
chat.main_order = 0;
for (const auto& position : chat_object.at("positions")) {
apply_chat_position(chat, position);
}
}
if (open_chat_id_ == chat.id) {
request_chat_details(chat.id);
}
resort_chats();
}
void App::apply_chat_position(ChatInfo& chat, const json& position) {
if (!position.is_object()) {
return;
}
const json chat_list = position.value("list", json::object());
if (safe_string(chat_list, "@type") != "chatListMain") {
return;
}
chat.in_main_list = true;
chat.main_order = safe_i64(position, "order");
}
void App::resort_chats() {
if (sorted_chat_ids_.empty()) {
for (const auto& [chat_id, chat] : chats_) {
if (chat.in_main_list) {
sorted_chat_ids_.push_back(chat_id);
}
}
} else {
for (const auto& [chat_id, chat] : chats_) {
if (!chat.in_main_list) {
continue;
}
if (std::find(sorted_chat_ids_.begin(), sorted_chat_ids_.end(), chat_id) == sorted_chat_ids_.end()) {
sorted_chat_ids_.push_back(chat_id);
}
}
}
std::stable_sort(sorted_chat_ids_.begin(), sorted_chat_ids_.end(),
[&](std::int64_t lhs, std::int64_t rhs) {
const auto left_it = chats_.find(lhs);
const auto right_it = chats_.find(rhs);
const bool left_has_order = left_it != chats_.end() && left_it->second.in_main_list && left_it->second.main_order > 0;
const bool right_has_order = right_it != chats_.end() && right_it->second.in_main_list && right_it->second.main_order > 0;
if (left_has_order != right_has_order) {
return left_has_order;
}
if (left_has_order && right_has_order && left_it->second.main_order != right_it->second.main_order) {
return left_it->second.main_order > right_it->second.main_order;
}
return false;
});
if (selected_chat_index_ >= static_cast<int>(sorted_chat_ids_.size())) {
selected_chat_index_ = std::max(0, static_cast<int>(sorted_chat_ids_.size()) - 1);
}
}
std::string App::format_open_chat_header(const ChatInfo& chat) const {
std::vector<std::string> parts;
if (!chat.title.empty()) {
parts.push_back(chat.title);
}
if (chat.private_user_id != 0) {
const auto user_it = users_.find(chat.private_user_id);
if (user_it != users_.end()) {
if (!user_it->second.username.empty()) {
parts.push_back("@" + user_it->second.username);
}
const std::string status = user_status_label(chat.private_user_id);
if (!status.empty()) {
parts.push_back(status);
}
parts.push_back("id:" + std::to_string(user_it->second.id));
} else {
parts.push_back("id:" + std::to_string(chat.private_user_id));
}
return join_with_separator_local(parts, " | ");
}
if (!chat.username.empty()) {
parts.push_back("@" + chat.username);
}
if (chat.has_member_count) {
parts.push_back(
std::to_string(chat.member_count) + " " + (chat.is_channel ? "subscribers" : "members"));
}
if (!chat.is_channel && chat.has_online_member_count) {
parts.push_back(std::to_string(chat.online_member_count) + " online");
}
parts.push_back("id:" + std::to_string(chat.id));
return join_with_separator_local(parts, " | ");
}
} // namespace telegram_tui

545
src/app_state.cpp Normal file
View File

@@ -0,0 +1,545 @@
#include "app.h"
#include <algorithm>
#include <set>
#include "util.h"
namespace telegram_tui {
namespace {
std::string format_download_progress(std::int64_t downloaded_size, std::int64_t size_bytes, bool is_downloaded) {
if (is_downloaded) {
return "100%";
}
if (size_bytes > 0) {
const auto downloaded = std::min(downloaded_size, size_bytes);
return std::to_string(static_cast<int>((downloaded * 100) / size_bytes)) + "% " +
format_file_size(downloaded) + "/" + format_file_size(size_bytes);
}
return format_file_size(downloaded_size);
}
} // namespace
bool App::process_updates() {
bool changed = false;
while (true) {
auto update = td_.receive(0.0);
if (!update.has_value()) {
break;
}
changed = true;
handle_td_object(*update);
}
return changed;
}
void App::handle_td_object(const json& object) {
const std::string type = safe_string(object, "@type");
if (type == "updateAuthorizationState") {
authorization_state_ = object.value("authorization_state", json::object());
handle_authorization_state();
return;
}
if (type == "updateConnectionState") {
status_line_ = "Connection: " + safe_string(object.at("state"), "@type");
return;
}
if (type == "updateChatOnlineMemberCount") {
ChatInfo& chat = chats_[safe_i64(object, "chat_id")];
chat.id = safe_i64(object, "chat_id");
chat.online_member_count = safe_i32(object, "online_member_count");
chat.has_online_member_count = true;
return;
}
if (type == "updateUser") {
upsert_user(object.value("user", json::object()));
return;
}
if (type == "updateUserStatus") {
update_user_status(safe_i64(object, "user_id"), object.value("status", json::object()));
return;
}
if (type == "updateBasicGroup" || type == "basicGroup") {
upsert_basic_group(type == "basicGroup" ? object : object.value("basic_group", json::object()));
return;
}
if (type == "updateSupergroup" || type == "supergroup") {
upsert_supergroup(type == "supergroup" ? object : object.value("supergroup", json::object()));
return;
}
if (type == "updateNewChat") {
upsert_chat(object.value("chat", json::object()));
return;
}
if (type == "updateChatTitle") {
ChatInfo& chat = chats_[safe_i64(object, "chat_id")];
chat.id = safe_i64(object, "chat_id");
chat.title = safe_string(object, "title");
resort_chats();
return;
}
if (type == "updateChatPosition") {
ChatInfo& chat = chats_[safe_i64(object, "chat_id")];
chat.id = safe_i64(object, "chat_id");
apply_chat_position(chat, object.value("position", json::object()));
resort_chats();
return;
}
if (type == "updateChatLastMessage") {
ChatInfo& chat = chats_[safe_i64(object, "chat_id")];
chat.id = safe_i64(object, "chat_id");
chat.last_message_preview = preview_message(object.value("last_message", json::object()));
if (object.contains("positions") && object.at("positions").is_array()) {
chat.in_main_list = false;
chat.main_order = 0;
for (const auto& position : object.at("positions")) {
apply_chat_position(chat, position);
}
}
resort_chats();
return;
}
if (type == "updateChatReadInbox") {
ChatInfo& chat = chats_[safe_i64(object, "chat_id")];
chat.id = safe_i64(object, "chat_id");
chat.unread_count = safe_i32(object, "unread_count");
chat.last_read_inbox_message_id = safe_i64(object, "last_read_inbox_message_id");
return;
}
if (type == "updateChatReadOutbox") {
ChatInfo& chat = chats_[safe_i64(object, "chat_id")];
chat.id = safe_i64(object, "chat_id");
chat.last_read_outbox_message_id = safe_i64(object, "last_read_outbox_message_id");
return;
}
if (type == "updateNewMessage") {
const json message = object.value("message", json::object());
const std::int64_t chat_id = safe_i64(message, "chat_id");
if (chat_id == open_chat_id_ && message_scroll_ > 0) {
++message_scroll_;
}
const MessageInfo parsed = parse_message(message);
append_message(chat_id, parsed);
chats_[chat_id].last_message_preview = preview_message(message);
if (chat_id == open_chat_id_ && !parsed.is_outgoing) {
mark_message_as_read(chat_id, parsed.id);
}
return;
}
if (type == "updateMessageContent") {
const std::int64_t chat_id = safe_i64(object, "chat_id");
const std::int64_t message_id = safe_i64(object, "message_id");
auto chat_it = chats_.find(chat_id);
if (chat_it == chats_.end()) {
return;
}
for (auto& message : chat_it->second.messages) {
if (message.id == message_id) {
const json content = object.value("new_content", json::object());
message.text = content_to_text(content, true);
if (const auto attachment = parse_attachment(content); attachment.has_value()) {
message.has_attachment = true;
message.attachment = *attachment;
} else {
message.has_attachment = false;
}
break;
}
}
return;
}
if (type == "updateDeleteMessages") {
const std::int64_t chat_id = safe_i64(object, "chat_id");
auto chat_it = chats_.find(chat_id);
if (chat_it == chats_.end()) {
return;
}
std::set<std::int64_t> deleted;
for (const auto& id : object.value("message_ids", json::array())) {
if (id.is_number_integer()) {
deleted.insert(id.get<std::int64_t>());
}
}
std::vector<MessageInfo> kept;
kept.reserve(chat_it->second.messages.size());
for (const auto& message : chat_it->second.messages) {
if (deleted.find(message.id) == deleted.end()) {
kept.push_back(message);
}
}
chat_it->second.messages = std::move(kept);
return;
}
if (type == "updateFile") {
update_attachment_file(object.value("file", json::object()));
return;
}
if (type == "updateOption" || type == "ok" || type == "userFullInfo" ||
type == "updateHavePendingNotifications" || type == "updateUnreadMessageCount" ||
type == "updateUnreadChatCount") {
return;
}
if (type == "error") {
const std::string extra = safe_string(object, "@extra");
const std::string message = safe_string(object, "message");
if (extra.rfind("history:", 0) == 0) {
const auto chat_id = static_cast<std::int64_t>(std::stoll(extra.substr(8)));
chats_[chat_id].history_loading = false;
status_line_ = "History unavailable: " + message;
return;
}
status_line_ = "TDLib error: " + message;
return;
}
if (type == "user") {
upsert_user(object);
return;
}
if (type == "chat") {
upsert_chat(object);
return;
}
if (type == "chats") {
const std::string extra = safe_string(object, "@extra");
if (extra == "main_chats") {
sync_chat_ids_from_response(object);
}
return;
}
if (type == "messages") {
const std::string extra = safe_string(object, "@extra");
if (extra.rfind("history:", 0) == 0) {
const auto chat_id = static_cast<std::int64_t>(std::stoll(extra.substr(8)));
merge_history(chat_id, object.value("messages", json::array()));
}
return;
}
if (type == "updateMessageSendSucceeded") {
const json message = object.value("message", json::object());
const std::int64_t chat_id = safe_i64(message, "chat_id");
remove_message(chat_id, safe_i64(object, "old_message_id"));
append_message(chat_id, parse_message(message));
chats_[chat_id].last_message_preview = preview_message(message);
status_line_ = "Message sent.";
return;
}
const std::string extra = safe_string(object, "@extra");
if (extra == "getMe" && object.is_object()) {
my_user_id_ = safe_i64(object, "id");
}
}
void App::request_more_chats() {
td_.send({
{"@type", "getChats"},
{"chat_list", {{"@type", "chatListMain"}}},
{"limit", kChatPageSize},
{"@extra", "main_chats"},
});
status_line_ = "Loading chats...";
}
bool App::request_chat_history(std::int64_t chat_id, bool force) {
ChatInfo& chat = chats_[chat_id];
chat.id = chat_id;
if (chat.history_loading && !force) {
return false;
}
chat.history_loading = true;
td_.send({
{"@type", "getChatHistory"},
{"chat_id", chat_id},
{"from_message_id", 0},
{"offset", 0},
{"limit", kHistoryBatchSize},
{"only_local", false},
{"@extra", "history:" + std::to_string(chat_id)},
});
return true;
}
bool App::request_open_chat_history(bool force) {
const auto chat_id = open_chat_id();
if (!chat_id.has_value()) {
return false;
}
request_chat_details(*chat_id);
const bool requested = request_chat_history(*chat_id, force);
if (requested) {
status_line_ = force ? "Reloading chat history..." : "Loading chat history...";
}
return requested;
}
void App::mark_chat_messages_as_read(std::int64_t chat_id) {
auto chat_it = chats_.find(chat_id);
if (chat_it == chats_.end()) {
return;
}
std::vector<std::int64_t> unread_message_ids;
unread_message_ids.reserve(chat_it->second.messages.size());
for (const auto& message : chat_it->second.messages) {
if (message.is_outgoing || message.id == 0) {
continue;
}
if (message.id <= chat_it->second.last_read_inbox_message_id) {
continue;
}
unread_message_ids.push_back(message.id);
}
if (!unread_message_ids.empty()) {
td_.send({
{"@type", "viewMessages"},
{"chat_id", chat_id},
{"message_ids", unread_message_ids},
{"source", {{"@type", "messageSourceChatHistory"}}},
{"force_read", true},
});
}
td_.send({
{"@type", "toggleChatIsMarkedAsUnread"},
{"chat_id", chat_id},
{"is_marked_as_unread", false},
});
}
void App::mark_message_as_read(std::int64_t chat_id, std::int64_t message_id) {
if (chat_id == 0 || message_id == 0) {
return;
}
td_.send({
{"@type", "viewMessages"},
{"chat_id", chat_id},
{"message_ids", json::array({message_id})},
{"source", {{"@type", "messageSourceChatHistory"}}},
{"force_read", true},
});
}
void App::set_open_chat(std::int64_t chat_id) {
if (chat_id == 0) {
return;
}
const bool changed_chat = open_chat_id_ != chat_id;
ChatInfo& chat = chats_[chat_id];
chat.id = chat_id;
if (tdlib_open_chat_id_ != 0 && tdlib_open_chat_id_ != chat_id) {
td_.send({
{"@type", "closeChat"},
{"chat_id", tdlib_open_chat_id_},
});
}
open_chat_id_ = chat_id;
message_scroll_ = 0;
if (tdlib_open_chat_id_ != chat_id) {
td_.send({
{"@type", "openChat"},
{"chat_id", chat_id},
});
tdlib_open_chat_id_ = chat_id;
}
mark_chat_messages_as_read(chat_id);
if (changed_chat) {
request_chat_details(chat_id);
const bool should_request_history =
auto_reload_chat_history_ || !chat.history_loaded || chat.messages.empty();
const bool requested = should_request_history &&
request_chat_history(chat_id, auto_reload_chat_history_);
if (requested) {
status_line_ = auto_reload_chat_history_ && chat.history_loaded
? "Reloading chat history..."
: "Loading chat history...";
}
}
}
void App::sync_chat_ids_from_response(const json& response) {
if (!response.contains("chat_ids") || !response.at("chat_ids").is_array()) {
status_line_ = "Chat list response missing chat ids.";
return;
}
std::vector<std::int64_t> chat_ids;
for (const auto& entry : response.at("chat_ids")) {
if (!entry.is_number_integer()) {
continue;
}
const auto chat_id = entry.get<std::int64_t>();
chat_ids.push_back(chat_id);
if (chats_.find(chat_id) == chats_.end()) {
td_.send({
{"@type", "getChat"},
{"chat_id", chat_id},
});
}
}
if (!chat_ids.empty()) {
sorted_chat_ids_ = std::move(chat_ids);
if (selected_chat_index_ >= static_cast<int>(sorted_chat_ids_.size())) {
selected_chat_index_ = std::max(0, static_cast<int>(sorted_chat_ids_.size()) - 1);
}
if (!open_chat_id().has_value()) {
const auto chat_id = highlighted_chat_id();
if (chat_id.has_value()) {
set_open_chat(*chat_id);
}
}
request_open_chat_history(false);
status_line_ = "Chats loaded.";
return;
}
status_line_ = "No chats loaded.";
}
void App::append_message(std::int64_t chat_id, MessageInfo message) {
ChatInfo& chat = chats_[chat_id];
chat.id = chat_id;
auto existing = std::find_if(chat.messages.begin(), chat.messages.end(),
[&](const MessageInfo& item) { return item.id == message.id; });
if (existing != chat.messages.end()) {
*existing = std::move(message);
} else {
chat.messages.push_back(std::move(message));
}
std::sort(chat.messages.begin(), chat.messages.end(),
[](const MessageInfo& lhs, const MessageInfo& rhs) {
if (lhs.date != rhs.date) {
return lhs.date < rhs.date;
}
return lhs.id < rhs.id;
});
if (kMaxMessagesPerChat > 0 && chat.messages.size() > kMaxMessagesPerChat) {
chat.messages.erase(
chat.messages.begin(),
chat.messages.begin() +
static_cast<std::ptrdiff_t>(chat.messages.size() - kMaxMessagesPerChat));
}
}
void App::remove_message(std::int64_t chat_id, std::int64_t message_id) {
if (message_id == 0) {
return;
}
auto chat_it = chats_.find(chat_id);
if (chat_it == chats_.end()) {
return;
}
auto& messages = chat_it->second.messages;
messages.erase(
std::remove_if(
messages.begin(),
messages.end(),
[&](const MessageInfo& item) { return item.id == message_id; }),
messages.end());
}
void App::merge_history(std::int64_t chat_id, const json& messages) {
ChatInfo& chat = chats_[chat_id];
chat.history_loading = false;
chat.history_loaded = true;
for (const auto& message : messages) {
append_message(chat_id, parse_message(message));
}
if (chat_id == open_chat_id_) {
mark_chat_messages_as_read(chat_id);
}
status_line_ = "History loaded.";
}
void App::update_attachment_file(const json& file) {
const std::int32_t file_id = safe_i32(file, "id");
if (file_id == 0) {
return;
}
const std::int64_t size_bytes = std::max(safe_i64(file, "size"), safe_i64(file, "expected_size"));
const json local = file.value("local", json::object());
const std::string local_path = safe_string(local, "path");
const std::int64_t downloaded_size = safe_i64(local, "downloaded_size");
const bool is_downloading_active = local.value("is_downloading_active", false);
const bool is_downloaded = local.value("is_downloading_completed", false);
const bool can_be_downloaded = local.value("can_be_downloaded", false);
const bool can_be_deleted = local.value("can_be_deleted", false);
for (auto& [chat_id, chat] : chats_) {
(void) chat_id;
for (auto& message : chat.messages) {
if (!message.has_attachment || message.attachment.file_id != file_id) {
continue;
}
if (size_bytes > 0) {
message.attachment.size_bytes = size_bytes;
}
message.attachment.downloaded_size = downloaded_size;
message.attachment.local_path = local_path;
message.attachment.is_downloading_active = is_downloading_active;
message.attachment.is_downloaded = is_downloaded;
message.attachment.can_be_downloaded = can_be_downloaded;
message.attachment.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;
pending_attachment_open_->downloaded_size = downloaded_size;
pending_attachment_open_->local_path = local_path;
pending_attachment_open_->is_downloading_active = is_downloading_active;
pending_attachment_open_->is_downloaded = is_downloaded;
pending_attachment_open_->can_be_downloaded = can_be_downloaded;
pending_attachment_open_->can_be_deleted = can_be_deleted;
if (!is_downloaded && is_downloading_active) {
status_line_ = "Downloading attachment to open " +
format_download_progress(
pending_attachment_open_->downloaded_size,
pending_attachment_open_->size_bytes,
pending_attachment_open_->is_downloaded);
}
if (is_downloaded && !local_path.empty()) {
open_attachment(*pending_attachment_open_);
pending_attachment_open_.reset();
}
}
if (pending_attachment_download_.has_value() && pending_attachment_download_->file_id == file_id) {
pending_attachment_download_->size_bytes =
size_bytes > 0 ? size_bytes : pending_attachment_download_->size_bytes;
pending_attachment_download_->downloaded_size = downloaded_size;
pending_attachment_download_->local_path = local_path;
pending_attachment_download_->is_downloading_active = is_downloading_active;
pending_attachment_download_->is_downloaded = is_downloaded;
pending_attachment_download_->can_be_downloaded = can_be_downloaded;
pending_attachment_download_->can_be_deleted = can_be_deleted;
if (!is_downloaded && is_downloading_active) {
status_line_ = "Downloading attachment to ~/Downloads " +
format_download_progress(
pending_attachment_download_->downloaded_size,
pending_attachment_download_->size_bytes,
pending_attachment_download_->is_downloaded);
}
if (is_downloaded && !local_path.empty()) {
export_attachment_to_downloads(*pending_attachment_download_);
pending_attachment_download_.reset();
}
}
}
} // namespace telegram_tui

9
src/json.h Normal file
View File

@@ -0,0 +1,9 @@
#pragma once
#include <nlohmann/json.hpp>
namespace telegram_tui {
using json = nlohmann::json;
} // namespace telegram_tui

17
src/main.cpp Normal file
View File

@@ -0,0 +1,17 @@
#include <cstdio>
#include <exception>
#include <curses.h>
#include "app.h"
int main() {
try {
telegram_tui::App app;
return app.run();
} catch (const std::exception& error) {
endwin();
std::fprintf(stderr, "fatal: %s\n", error.what());
return 1;
}
}

25
src/models.cpp Normal file
View File

@@ -0,0 +1,25 @@
#include "models.h"
namespace telegram_tui {
std::string UserInfo::display_name() const {
std::string name;
if (!first_name.empty()) {
name += first_name;
}
if (!last_name.empty()) {
if (!name.empty()) {
name += " ";
}
name += last_name;
}
if (!name.empty()) {
return name;
}
if (!username.empty()) {
return "@" + username;
}
return "Unknown";
}
} // namespace telegram_tui

101
src/models.h Normal file
View File

@@ -0,0 +1,101 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
namespace telegram_tui {
constexpr std::size_t kMaxMessagesPerChat = 0;
constexpr int kPollTimeoutMs = 50;
constexpr int kChatPageSize = 100;
constexpr int kHistoryBatchSize = 50;
struct UserInfo {
std::int64_t id = 0;
std::string first_name;
std::string last_name;
std::string username;
std::string status;
[[nodiscard]] std::string display_name() const;
};
enum class AttachmentType {
Photo,
Video,
Document,
Audio,
Voice,
Animation,
Sticker,
};
struct AttachmentInfo {
AttachmentType type = AttachmentType::Photo;
std::string name;
std::int64_t size_bytes = 0;
std::int64_t downloaded_size = 0;
std::int32_t file_id = 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 MessageInfo {
std::int64_t id = 0;
std::int32_t date = 0;
bool is_outgoing = false;
std::int64_t reply_to_message_id = 0;
bool has_attachment = false;
AttachmentInfo attachment;
std::string forward_info;
std::string sender;
std::string text;
std::string via_bot;
};
struct ChatInfo {
std::int64_t id = 0;
std::int64_t private_user_id = 0;
std::int64_t basic_group_id = 0;
std::int64_t supergroup_id = 0;
std::int64_t last_read_inbox_message_id = 0;
std::int64_t last_read_outbox_message_id = 0;
std::string title = "Loading...";
std::string username;
std::string last_message_preview;
std::int32_t unread_count = 0;
std::int32_t member_count = 0;
std::int32_t online_member_count = 0;
std::int64_t main_order = 0;
bool is_channel = false;
bool in_main_list = false;
bool history_loading = false;
bool history_loaded = false;
bool details_requested = false;
bool has_member_count = false;
bool has_online_member_count = false;
std::vector<MessageInfo> messages;
};
enum class FocusPane {
Chats,
Messages,
};
enum class InputMode {
None,
Compose,
ApiId,
ApiHash,
PhoneNumber,
AuthCode,
Password,
FirstName,
LastName,
};
} // namespace telegram_tui

66
src/td_client.cpp Normal file
View File

@@ -0,0 +1,66 @@
#include "td_client.h"
#include <string>
#include <td/telegram/td_json_client.h>
namespace telegram_tui {
namespace {
void configure_tdlib_logging() {
const char* stream_request =
R"({"@type":"setLogStream","log_stream":{"@type":"logStreamEmpty"}})";
const char* verbosity_request =
R"({"@type":"setLogVerbosityLevel","new_verbosity_level":0})";
td_json_client_execute(nullptr, stream_request);
td_json_client_execute(nullptr, verbosity_request);
}
} // namespace
TdClient::TdClient() {
configure_tdlib_logging();
client_ = td_json_client_create();
}
TdClient::~TdClient() {
if (client_ != nullptr) {
td_json_client_destroy(client_);
}
}
void TdClient::send(const json& request) {
const std::string payload = request.dump();
td_json_client_send(client_, payload.c_str());
}
std::optional<json> TdClient::execute(const json& request) {
const std::string payload = request.dump();
const char* raw = td_json_client_execute(client_, payload.c_str());
if (raw == nullptr) {
return std::nullopt;
}
try {
return json::parse(raw);
} catch (...) {
return std::nullopt;
}
}
std::optional<json> TdClient::receive(double timeout_seconds) {
const char* raw = td_json_client_receive(client_, timeout_seconds);
if (raw == nullptr) {
return std::nullopt;
}
try {
return json::parse(raw);
} catch (...) {
return std::nullopt;
}
}
} // namespace telegram_tui

25
src/td_client.h Normal file
View File

@@ -0,0 +1,25 @@
#pragma once
#include <optional>
#include "json.h"
namespace telegram_tui {
class TdClient {
public:
TdClient();
~TdClient();
TdClient(const TdClient&) = delete;
TdClient& operator=(const TdClient&) = delete;
void send(const json& request);
[[nodiscard]] std::optional<json> execute(const json& request);
[[nodiscard]] std::optional<json> receive(double timeout_seconds);
private:
void* client_ = nullptr;
};
} // namespace telegram_tui

366
src/util.cpp Normal file
View File

@@ -0,0 +1,366 @@
#include "util.h"
#include <algorithm>
#include <cctype>
#include <cstdint>
#include <cstdlib>
#include <ctime>
#include <fstream>
#include <sstream>
#include <wchar.h>
namespace telegram_tui {
namespace {
std::size_t utf8_sequence_size(unsigned char lead) {
if ((lead & 0x80U) == 0) {
return 1;
}
if ((lead & 0xE0U) == 0xC0U) {
return 2;
}
if ((lead & 0xF0U) == 0xE0U) {
return 3;
}
if ((lead & 0xF8U) == 0xF0U) {
return 4;
}
return 1;
}
bool is_utf8_continuation(unsigned char ch) {
return (ch & 0xC0U) == 0x80U;
}
std::uint32_t decode_utf8_codepoint(const std::string& text, std::size_t offset, std::size_t* size_out = nullptr) {
if (offset >= text.size()) {
if (size_out != nullptr) {
*size_out = 0;
}
return 0;
}
const auto lead = static_cast<unsigned char>(text[offset]);
std::size_t size = utf8_sequence_size(lead);
if (offset + size > text.size()) {
size = 1;
}
for (std::size_t i = 1; i < size; ++i) {
if (!is_utf8_continuation(static_cast<unsigned char>(text[offset + i]))) {
size = 1;
break;
}
}
if (size_out != nullptr) {
*size_out = size;
}
if (size == 1) {
return lead;
}
if (size == 2) {
return ((lead & 0x1FU) << 6) |
(static_cast<unsigned char>(text[offset + 1]) & 0x3FU);
}
if (size == 3) {
return ((lead & 0x0FU) << 12) |
((static_cast<unsigned char>(text[offset + 1]) & 0x3FU) << 6) |
(static_cast<unsigned char>(text[offset + 2]) & 0x3FU);
}
return ((lead & 0x07U) << 18) |
((static_cast<unsigned char>(text[offset + 1]) & 0x3FU) << 12) |
((static_cast<unsigned char>(text[offset + 2]) & 0x3FU) << 6) |
(static_cast<unsigned char>(text[offset + 3]) & 0x3FU);
}
} // namespace
std::string get_env(const char* name) {
const char* value = std::getenv(name);
return value == nullptr ? std::string() : std::string(value);
}
std::string trim_copy(std::string value) {
auto not_space = [](unsigned char c) { return !std::isspace(c); };
while (!value.empty() && !not_space(static_cast<unsigned char>(value.front()))) {
value.erase(value.begin());
}
while (!value.empty() && !not_space(static_cast<unsigned char>(value.back()))) {
value.pop_back();
}
return value;
}
std::string single_line(std::string text) {
for (char& c : text) {
if (c == '\n' || c == '\r' || c == '\t') {
c = ' ';
}
}
return trim_copy(std::move(text));
}
bool is_decimal_number(const std::string& value) {
return !value.empty() &&
std::all_of(value.begin(), value.end(), [](unsigned char ch) { return std::isdigit(ch); });
}
std::filesystem::path data_root() {
if (const char* xdg = std::getenv("XDG_DATA_HOME"); xdg != nullptr && *xdg != '\0') {
return std::filesystem::path(xdg) / "telegram-tui";
}
if (const char* home = std::getenv("HOME"); home != nullptr && *home != '\0') {
return std::filesystem::path(home) / ".local" / "share" / "telegram-tui";
}
return std::filesystem::current_path() / ".telegram-tui-data";
}
StoredConfig load_app_config() {
const std::filesystem::path path = data_root() / "config.json";
std::ifstream input(path);
if (!input.is_open()) {
return {};
}
try {
const json config = json::parse(input, nullptr, true, true);
if (!config.is_object()) {
return {};
}
return StoredConfig{
safe_string(config, "api_id"),
safe_string(config, "api_hash"),
config.value("auto_reload_chat_history", false),
};
} catch (const json::exception&) {
return {};
}
}
bool save_app_config(const StoredConfig& config) {
const std::filesystem::path path = data_root() / "config.json";
try {
if (path.has_parent_path()) {
std::filesystem::create_directories(path.parent_path());
}
json document = json::object();
if (!config.api_id.empty()) {
document["api_id"] = config.api_id;
}
if (!config.api_hash.empty()) {
document["api_hash"] = config.api_hash;
}
document["auto_reload_chat_history"] = config.auto_reload_chat_history;
std::ofstream output(path, std::ios::trunc);
if (!output.is_open()) {
return false;
}
output << document.dump(2) << '\n';
return static_cast<bool>(output);
} catch (const std::exception&) {
return false;
}
}
std::string safe_string(const json& object, const char* key) {
if (!object.contains(key) || !object.at(key).is_string()) {
return {};
}
return object.at(key).get<std::string>();
}
std::int64_t safe_i64(const json& object, const char* key) {
if (!object.contains(key) || !object.at(key).is_number_integer()) {
return 0;
}
return object.at(key).get<std::int64_t>();
}
std::int32_t safe_i32(const json& object, const char* key) {
if (!object.contains(key) || !object.at(key).is_number_integer()) {
return 0;
}
return object.at(key).get<std::int32_t>();
}
std::string format_time(std::int32_t unix_time) {
if (unix_time <= 0) {
return "--:--";
}
std::time_t raw = unix_time;
std::tm tm = *std::localtime(&raw);
char buffer[16] = {};
std::strftime(buffer, sizeof(buffer), "%H:%M", &tm);
return buffer;
}
std::string format_date(std::int32_t unix_time) {
if (unix_time <= 0) {
return "Unknown day";
}
std::time_t raw = unix_time;
std::tm tm = *std::localtime(&raw);
char buffer[32] = {};
std::strftime(buffer, sizeof(buffer), "%Y-%m-%d", &tm);
return buffer;
}
std::string format_datetime(std::int32_t unix_time) {
if (unix_time <= 0) {
return "Unknown time";
}
std::time_t raw = unix_time;
std::tm tm = *std::localtime(&raw);
char buffer[32] = {};
std::strftime(buffer, sizeof(buffer), "%Y-%m-%d %H:%M", &tm);
return buffer;
}
std::string format_file_size(std::int64_t size_bytes) {
if (size_bytes <= 0) {
return "?";
}
static constexpr const char* units[] = {"B", "KB", "MB", "GB", "TB"};
double size = static_cast<double>(size_bytes);
std::size_t unit_index = 0;
while (size >= 1024.0 && unit_index + 1 < std::size(units)) {
size /= 1024.0;
++unit_index;
}
std::ostringstream stream;
stream.setf(std::ios::fixed);
stream.precision(unit_index == 0 ? 0 : 1);
stream << size << ' ' << units[unit_index];
return stream.str();
}
std::vector<std::string> wrap_text(const std::string& text, int width) {
if (width <= 1) {
return {text};
}
std::vector<std::string> lines;
std::stringstream stream(text);
std::string paragraph;
while (std::getline(stream, paragraph, '\n')) {
paragraph = single_line(std::move(paragraph));
if (paragraph.empty()) {
lines.emplace_back();
continue;
}
std::stringstream words(paragraph);
std::string word;
std::string current;
while (words >> word) {
if (current.empty()) {
current = word;
continue;
}
if (static_cast<int>(current.size() + 1 + word.size()) > width) {
lines.push_back(current);
current = word;
} else {
current += " ";
current += word;
}
}
if (!current.empty()) {
lines.push_back(current);
}
}
if (lines.empty()) {
lines.emplace_back();
}
return lines;
}
std::size_t utf8_byte_index_from_utf16_offset(const std::string& text, std::size_t utf16_offset) {
std::size_t byte_index = 0;
std::size_t utf16_units = 0;
while (byte_index < text.size() && utf16_units < utf16_offset) {
std::size_t size = 0;
const std::uint32_t codepoint = decode_utf8_codepoint(text, byte_index, &size);
if (size == 0) {
break;
}
const std::size_t units = codepoint > 0xFFFFU ? 2 : 1;
if (utf16_units + units > utf16_offset) {
break;
}
utf16_units += units;
byte_index += size;
}
return byte_index;
}
std::size_t utf8_prev_index(const std::string& text, std::size_t byte_index) {
if (byte_index == 0 || text.empty()) {
return 0;
}
std::size_t index = std::min(byte_index, text.size()) - 1;
while (index > 0 && is_utf8_continuation(static_cast<unsigned char>(text[index]))) {
--index;
}
return index;
}
std::size_t utf8_next_index(const std::string& text, std::size_t byte_index) {
if (byte_index >= text.size()) {
return text.size();
}
std::size_t size = 0;
decode_utf8_codepoint(text, byte_index, &size);
return std::min(text.size(), byte_index + std::max<std::size_t>(1, size));
}
int utf8_display_width(const std::string& text, std::size_t byte_limit) {
const std::size_t limit = std::min(byte_limit, text.size());
int width = 0;
std::size_t index = 0;
while (index < limit) {
std::size_t size = 0;
const std::uint32_t codepoint = decode_utf8_codepoint(text, index, &size);
if (size == 0) {
break;
}
int char_width = 1;
if (codepoint <= static_cast<std::uint32_t>(WCHAR_MAX)) {
const int measured = ::wcwidth(static_cast<wchar_t>(codepoint));
if (measured > 0) {
char_width = measured;
}
}
width += char_width;
index += size;
}
return width;
}
void pop_utf8_back(std::string& text) {
if (text.empty()) {
return;
}
std::size_t start = text.size() - 1;
while (start > 0 && is_utf8_continuation(static_cast<unsigned char>(text[start]))) {
--start;
}
text.erase(start);
}
} // namespace telegram_tui

39
src/util.h Normal file
View File

@@ -0,0 +1,39 @@
#pragma once
#include <cstdint>
#include <filesystem>
#include <string>
#include <vector>
#include "json.h"
namespace telegram_tui {
struct StoredConfig {
std::string api_id;
std::string api_hash;
bool auto_reload_chat_history = false;
};
[[nodiscard]] std::string get_env(const char* name);
[[nodiscard]] std::string trim_copy(std::string value);
[[nodiscard]] std::string single_line(std::string text);
[[nodiscard]] bool is_decimal_number(const std::string& value);
[[nodiscard]] std::filesystem::path data_root();
[[nodiscard]] StoredConfig load_app_config();
bool save_app_config(const StoredConfig& config);
[[nodiscard]] std::string safe_string(const json& object, const char* key);
[[nodiscard]] std::int64_t safe_i64(const json& object, const char* key);
[[nodiscard]] std::int32_t safe_i32(const json& object, const char* key);
[[nodiscard]] std::string format_time(std::int32_t unix_time);
[[nodiscard]] std::string format_date(std::int32_t unix_time);
[[nodiscard]] std::string format_datetime(std::int32_t unix_time);
[[nodiscard]] std::string format_file_size(std::int64_t size_bytes);
[[nodiscard]] std::vector<std::string> wrap_text(const std::string& text, int width);
[[nodiscard]] std::size_t utf8_byte_index_from_utf16_offset(const std::string& text, std::size_t utf16_offset);
[[nodiscard]] std::size_t utf8_prev_index(const std::string& text, std::size_t byte_index);
[[nodiscard]] std::size_t utf8_next_index(const std::string& text, std::size_t byte_index);
[[nodiscard]] int utf8_display_width(const std::string& text, std::size_t byte_limit = std::string::npos);
void pop_utf8_back(std::string& text);
} // namespace telegram_tui