init
This commit is contained in:
1610
src/app.cpp
Normal file
1610
src/app.cpp
Normal file
File diff suppressed because it is too large
Load Diff
186
src/app.h
Normal file
186
src/app.h
Normal 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
1356
src/app_attachments.cpp
Normal file
File diff suppressed because it is too large
Load Diff
669
src/app_auth.cpp
Normal file
669
src/app_auth.cpp
Normal 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
269
src/app_chats.cpp
Normal 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
545
src/app_state.cpp
Normal 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
9
src/json.h
Normal 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
17
src/main.cpp
Normal 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
25
src/models.cpp
Normal 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
101
src/models.h
Normal 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
66
src/td_client.cpp
Normal 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
25
src/td_client.h
Normal 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
366
src/util.cpp
Normal 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
39
src/util.h
Normal 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
|
||||
Reference in New Issue
Block a user