Refactor app modules and add clang-format

This commit is contained in:
2026-04-24 06:34:30 +03:00
parent 0de7073f00
commit 70c2e77957
12 changed files with 2354 additions and 2011 deletions

14
.clang-format Normal file
View File

@@ -0,0 +1,14 @@
BasedOnStyle: LLVM
UseTab: ForIndentation
IndentWidth: 8
TabWidth: 8
ContinuationIndentWidth: 8
ColumnLimit: 100
SortIncludes: false
AlignConsecutiveAssignments: false
AlignConsecutiveDeclarations: false
AlignOperands: Align
AllowShortFunctionsOnASingleLine: Empty
BreakBeforeBraces: Attach
IndentCaseLabels: false
PointerAlignment: Right

View File

@@ -6,6 +6,8 @@ set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
find_program(CLANG_FORMAT_BIN clang-format)
include(FetchContent)
option(TELEGRAM_TUI_USE_SYSTEM_TDLIB "Use an installed TDLib package instead of fetching it." OFF)
@@ -36,8 +38,13 @@ add_executable(
shinoa
src/app_attachments.cpp
src/app_chats.cpp
src/app_commands.cpp
src/app.cpp
src/app_auth.cpp
src/app_help.cpp
src/app_input.cpp
src/app_messages.cpp
src/app_shell.cpp
src/app_state.cpp
src/main.cpp
src/models.cpp
@@ -45,6 +52,19 @@ add_executable(
src/util.cpp
)
if(CLANG_FORMAT_BIN)
file(GLOB_RECURSE SHINOA_FORMAT_SOURCES CONFIGURE_DEPENDS
src/*.cpp
src/*.h
)
add_custom_target(
format
COMMAND ${CLANG_FORMAT_BIN} -i ${SHINOA_FORMAT_SOURCES}
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
COMMENT "Formatting C++ sources with clang-format"
)
endif()
target_include_directories(shinoa PRIVATE ${CURSES_INCLUDE_DIRS})
target_link_libraries(shinoa PRIVATE ${CURSES_LIBRARIES})

File diff suppressed because it is too large Load Diff

123
src/app.h
View File

@@ -21,64 +21,83 @@ class App {
int run();
private:
enum class ComposeCommandKind {
None,
Reply,
Edit,
Delete,
Forward,
};
struct ComposeCommand {
ComposeCommandKind kind = ComposeCommandKind::None;
std::int64_t message_id = 0;
std::vector<std::int64_t> message_ids;
std::string text;
};
void init_curses();
void shutdown_curses();
bool process_updates();
void handle_td_object(const json& object);
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 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 edit_message(std::int64_t chat_id, std::int64_t message_id, const std::string& text);
void forward_message(
std::int64_t source_chat_id,
const std::vector<std::int64_t>& message_ids,
void prepare_reply_input(std::int64_t chat_id, std::int64_t message_id,
std::string initial_text = {});
void send_message(std::int64_t chat_id, const std::string &text,
std::optional<std::int64_t> reply_to_message_id = std::nullopt);
void edit_message(std::int64_t chat_id, std::int64_t message_id, const std::string &text);
void delete_message(std::int64_t chat_id, const std::vector<std::int64_t> &message_ids);
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,
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::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 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 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 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;
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;
[[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();
@@ -87,23 +106,31 @@ class App {
bool move_message_attachment_selection(int delta);
[[nodiscard]] std::optional<AttachmentInfo> selected_message_attachment() 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::tuple<bool, std::int64_t, std::string> parse_edit_command(const std::string& value) const;
[[nodiscard]] std::optional<std::vector<std::int64_t>> parse_forward_command(const std::string& value) const;
[[nodiscard]] std::int64_t resolve_message_ref(const ChatInfo& chat, std::int64_t ref) 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 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]] ComposeCommand parse_compose_command(const std::string &value) const;
[[nodiscard]] std::tuple<bool, std::int64_t, std::string>
parse_single_message_command(const std::string &value, const char *prefix) const;
[[nodiscard]] std::optional<std::vector<std::int64_t>>
parse_message_list_command(const std::string &value, const char *prefix) const;
[[nodiscard]] std::int64_t resolve_message_ref(const ChatInfo &chat,
std::int64_t ref) 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 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;
[[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);
@@ -154,6 +181,7 @@ class App {
int attachment_action_index_ = 0;
int attachment_viewer_scroll_ = 0;
int forward_target_index_ = 0;
int help_menu_page_ = 0;
std::int64_t open_chat_id_ = 0;
std::int64_t tdlib_open_chat_id_ = 0;
std::int64_t message_attachment_message_id_ = 0;
@@ -178,6 +206,7 @@ class App {
std::optional<AttachmentInfo> pending_attachment_open_;
std::optional<AttachmentInfo> pending_attachment_download_;
std::optional<AttachmentInfo> attachment_viewer_attachment_;
std::optional<std::int64_t> compose_reply_to_message_id_;
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;

View File

@@ -16,8 +16,8 @@ namespace telegram_tui {
namespace {
struct ClipboardImageType {
const char* mime = "";
const char* extension = "";
const char *mime = "";
const char *extension = "";
};
constexpr std::array<ClipboardImageType, 5> kClipboardImageTypes = {{
@@ -28,7 +28,7 @@ constexpr std::array<ClipboardImageType, 5> kClipboardImageTypes = {{
{"image/tiff", ".tiff"},
}};
std::string shell_quote(const std::string& value) {
std::string shell_quote(const std::string &value) {
std::string quoted = "'";
for (char ch : value) {
if (ch == '\'') {
@@ -41,8 +41,8 @@ std::string shell_quote(const std::string& value) {
return quoted;
}
std::string run_command_capture(const std::string& command) {
FILE* pipe = popen(command.c_str(), "r");
std::string run_command_capture(const std::string &command) {
FILE *pipe = popen(command.c_str(), "r");
if (pipe == nullptr) {
return {};
}
@@ -58,12 +58,14 @@ std::string run_command_capture(const std::string& command) {
return output;
}
bool command_exists(const char* command) {
const std::string resolved = run_command_capture("command -v " + std::string(command) + " 2>/dev/null");
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) {
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";
@@ -80,8 +82,9 @@ std::string clipboard_capture_command(const std::string& mime_type, const std::s
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) {
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;
}
@@ -90,7 +93,7 @@ std::optional<ClipboardImageType> detect_clipboard_image_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) {
for (const auto &type : kClipboardImageTypes) {
if (targets_output.find(type.mime) != std::string::npos) {
return type;
}
@@ -102,9 +105,9 @@ std::optional<ClipboardImageType> detect_clipboard_image_type() {
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) {
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();
}
@@ -116,21 +119,7 @@ std::optional<std::string> parse_clipboard_compose_command(const std::string& va
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 markdown_formatted_text(TdClient &td, const std::string &text) {
json formatted_text = {
{"@type", "formattedText"},
{"text", text},
@@ -280,7 +269,7 @@ void App::request_chat_details(std::int64_t chat_id) {
return;
}
ChatInfo& chat = chat_it->second;
ChatInfo &chat = chat_it->second;
if (chat.details_requested) {
return;
}
@@ -329,6 +318,7 @@ void App::clear_input() {
input_buffer_.clear();
input_cursor_ = 0;
input_hidden_ = false;
compose_reply_to_message_id_.reset();
curs_set(0);
}
@@ -344,6 +334,7 @@ void App::submit_input() {
status_line_ = "Telegram API ID must be numeric.";
return;
}
const auto pending_reply_to_message_id = compose_reply_to_message_id_;
clear_input();
if (mode == InputMode::Compose) {
@@ -352,36 +343,72 @@ void App::submit_input() {
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);
const ComposeCommand command = parse_compose_command(value);
if (command.kind == ComposeCommandKind::Forward) {
if (command.message_ids.empty()) {
status_line_ =
"Forward command needs one or more valid message refs.";
return;
}
const auto [is_edit, edit_message_id, edit_text] = parse_edit_command(value);
if (is_edit) {
if (edit_message_id == 0) {
open_forward_target_menu(*chat_id, command.message_ids);
return;
}
if (command.kind == ComposeCommandKind::Delete) {
if (command.message_ids.empty()) {
status_line_ = "Delete command needs a valid message ref.";
return;
}
const auto chat_it = chats_.find(*chat_id);
if (chat_it != chats_.end()) {
for (const std::int64_t delete_message_id : command.message_ids) {
const auto message_it = std::find_if(
chat_it->second.messages.begin(),
chat_it->second.messages.end(),
[&](const MessageInfo &message) {
return message.id == delete_message_id;
});
if (message_it != chat_it->second.messages.end() &&
!message_it->is_outgoing) {
status_line_ = "Can delete only your own messages.";
return;
}
}
}
delete_message(*chat_id, command.message_ids);
return;
}
if (command.kind == ComposeCommandKind::Edit) {
if (command.message_id == 0) {
status_line_ = "Edit command needs a valid message ref.";
return;
}
if (edit_text.empty()) {
if (command.text.empty()) {
status_line_ = "Edit text cannot be empty.";
return;
}
edit_message(*chat_id, edit_message_id, edit_text);
edit_message(*chat_id, command.message_id, command.text);
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);
std::string text = value;
if (command.kind == ComposeCommandKind::Reply) {
if (command.message_id == 0) {
status_line_ = "Reply command needs a valid message ref.";
return;
}
prepare_reply_input(*chat_id, command.message_id, command.text);
return;
}
if (const auto clipboard_caption = parse_clipboard_compose_command(text);
clipboard_caption.has_value()) {
preview_clipboard_photo_message(*chat_id, *clipboard_caption,
pending_reply_to_message_id);
return;
}
if (text.empty()) {
status_line_ = "Reply text cannot be empty.";
status_line_ = "Message text cannot be empty.";
return;
}
send_message(*chat_id, text, is_reply ? std::optional<std::int64_t>(reply_to_message_id) : std::nullopt);
send_message(*chat_id, text, pending_reply_to_message_id);
return;
}
if (mode == InputMode::ApiId) {
@@ -439,9 +466,22 @@ void App::submit_input() {
}
}
void App::send_message(
std::int64_t chat_id,
const std::string& text,
void App::prepare_reply_input(std::int64_t chat_id, std::int64_t message_id,
std::string initial_text) {
const auto chat_it = chats_.find(chat_id);
if (chat_it == chats_.end()) {
status_line_ = "Chat unavailable.";
return;
}
compose_reply_to_message_id_ = message_id;
input_buffer_ = std::move(initial_text);
start_input(InputMode::Compose, "Reply " + format_message_ref(chat_it->second, message_id),
false);
status_line_ = "Reply prepared.";
}
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);
@@ -465,7 +505,7 @@ void App::send_message(
status_line_ = "Message queued.";
}
void App::edit_message(std::int64_t chat_id, std::int64_t message_id, const std::string& text) {
void App::edit_message(std::int64_t chat_id, std::int64_t message_id, const std::string &text) {
td_.send({
{"@type", "editMessageText"},
{"chat_id", chat_id},
@@ -481,9 +521,17 @@ void App::edit_message(std::int64_t chat_id, std::int64_t message_id, const std:
status_line_ = "Editing message...";
}
void App::forward_message(
std::int64_t source_chat_id,
const std::vector<std::int64_t>& message_ids,
void App::delete_message(std::int64_t chat_id, const std::vector<std::int64_t> &message_ids) {
td_.send({
{"@type", "deleteMessages"},
{"chat_id", chat_id},
{"message_ids", message_ids},
{"revoke", true},
});
status_line_ = message_ids.size() == 1 ? "Deleting message..." : "Deleting messages...";
}
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"},
@@ -496,10 +544,8 @@ void App::forward_message(
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,
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"},
@@ -529,20 +575,19 @@ void App::send_photo_message(
status_line_ = "Photo queued.";
}
bool App::preview_clipboard_photo_message(
std::int64_t chat_id,
const std::string& caption,
bool App::preview_clipboard_photo_message(std::int64_t chat_id, const std::string &caption,
std::optional<std::int64_t> reply_to_message_id) {
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.";
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) {
} catch (const std::exception &error) {
status_line_ = std::string("Failed to prepare clipboard cache: ") + error.what();
return false;
}
@@ -550,8 +595,8 @@ bool App::preview_clipboard_photo_message(
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);
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;
@@ -562,8 +607,10 @@ bool App::preview_clipboard_photo_message(
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)) {
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;
}
@@ -574,7 +621,7 @@ bool App::preview_clipboard_photo_message(
status_line_ = "Clipboard image is empty.";
return false;
}
} catch (const std::exception&) {
} catch (const std::exception &) {
status_line_ = "Clipboard image couldn't be validated.";
return false;
}
@@ -605,94 +652,4 @@ bool App::preview_clipboard_photo_message(
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

View File

@@ -2,15 +2,19 @@
#include <algorithm>
#include <curses.h>
#include "app_ui.h"
#include "util.h"
namespace telegram_tui {
namespace {
std::string join_with_separator_local(const std::vector<std::string>& parts, const char* separator) {
std::string join_with_separator_local(const std::vector<std::string> &parts,
const char *separator) {
std::string joined;
for (const auto& part : parts) {
for (const auto &part : parts) {
if (part.empty()) {
continue;
}
@@ -22,7 +26,7 @@ std::string join_with_separator_local(const std::vector<std::string>& parts, con
return joined;
}
std::string format_user_status(const json& status) {
std::string format_user_status(const json &status) {
const std::string type = safe_string(status, "@type");
if (type == "userStatusOnline") {
return "online";
@@ -43,11 +47,11 @@ std::string format_user_status(const json& status) {
return {};
}
std::string primary_username(const json& object) {
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) {
for (const auto &username : active_usernames) {
if (username.is_string()) {
const std::string value = username.get<std::string>();
if (!value.empty()) {
@@ -61,17 +65,17 @@ std::string primary_username(const json& object) {
} // namespace
void App::update_user_status(std::int64_t user_id, const json& status) {
UserInfo& info = users_[user_id];
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) {
void App::upsert_user(const json &user) {
if (user.is_null()) {
return;
}
UserInfo& info = users_[safe_i64(user, "id")];
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");
@@ -79,13 +83,13 @@ void App::upsert_user(const json& user) {
info.status = format_user_status(user.value("status", json::object()));
}
void App::upsert_basic_group(const json& basic_group) {
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_) {
for (auto &[chat_id, chat] : chats_) {
if (chat.basic_group_id != basic_group_id) {
continue;
}
@@ -96,7 +100,7 @@ void App::upsert_basic_group(const json& basic_group) {
}
}
void App::upsert_supergroup(const json& supergroup) {
void App::upsert_supergroup(const json &supergroup) {
if (supergroup.is_null()) {
return;
}
@@ -104,7 +108,7 @@ void App::upsert_supergroup(const json& supergroup) {
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_) {
for (auto &[chat_id, chat] : chats_) {
if (chat.supergroup_id != supergroup_id) {
continue;
}
@@ -116,13 +120,13 @@ void App::upsert_supergroup(const json& supergroup) {
}
}
void App::upsert_chat(const json& chat_object) {
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];
ChatInfo &chat = chats_[chat_id];
chat.id = chat_id;
if (chat_object.contains("title")) {
chat.title = safe_string(chat_object, "title");
@@ -169,7 +173,7 @@ void App::upsert_chat(const json& chat_object) {
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")) {
for (const auto &position : chat_object.at("positions")) {
apply_chat_position(chat, position);
}
}
@@ -179,7 +183,7 @@ void App::upsert_chat(const json& chat_object) {
resort_chats();
}
void App::apply_chat_position(ChatInfo& chat, const json& position) {
void App::apply_chat_position(ChatInfo &chat, const json &position) {
if (!position.is_object()) {
return;
}
@@ -194,17 +198,18 @@ void App::apply_chat_position(ChatInfo& chat, const json& position) {
void App::resort_chats() {
if (sorted_chat_ids_.empty()) {
for (const auto& [chat_id, chat] : chats_) {
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_) {
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()) {
if (std::find(sorted_chat_ids_.begin(), sorted_chat_ids_.end(), chat_id) ==
sorted_chat_ids_.end()) {
sorted_chat_ids_.push_back(chat_id);
}
}
@@ -214,13 +219,19 @@ void App::resort_chats() {
[&](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;
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;
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;
});
@@ -230,7 +241,15 @@ void App::resort_chats() {
}
}
std::string App::format_open_chat_header(const ChatInfo& chat) const {
std::string App::user_status_label(std::int64_t user_id) const {
const auto user_it = users_.find(user_id);
if (user_it == users_.end()) {
return {};
}
return user_it->second.status;
}
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);
@@ -256,8 +275,8 @@ std::string App::format_open_chat_header(const ChatInfo& chat) const {
parts.push_back("@" + chat.username);
}
if (chat.has_member_count) {
parts.push_back(
std::to_string(chat.member_count) + " " + (chat.is_channel ? "subscribers" : "members"));
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");
@@ -266,4 +285,60 @@ std::string App::format_open_chat_header(const ChatInfo& chat) const {
return join_with_separator_local(parts, " | ");
}
void App::draw_chat_pane(int top, int height, int width) {
const int visible_rows = std::max(1, height);
int first_index = 0;
if (selected_chat_index_ >= visible_rows) {
first_index = selected_chat_index_ - visible_rows + 1;
}
for (int row = 0; row < visible_rows; ++row) {
const int index = first_index + row;
const int y = top + row;
mvhline(y, 0, ' ', width);
if (index >= static_cast<int>(sorted_chat_ids_.size())) {
continue;
}
const auto chat_id = sorted_chat_ids_[index];
const auto chat_it = chats_.find(chat_id);
const bool selected = index == selected_chat_index_;
const bool is_open = open_chat_id_ == chat_id;
if (selected) {
attron(A_BOLD | (focus_ == FocusPane::Chats ? A_REVERSE : A_NORMAL));
}
if (is_open) {
attron(COLOR_PAIR(kColorPairOpenChat));
}
const bool has_chat = chat_it != chats_.end();
const ChatInfo *chat = has_chat ? &chat_it->second : nullptr;
std::string line = (chat != nullptr && chat->unread_count > 0) ? "[U] " : "[ ] ";
line += is_open ? ">" : " ";
if (chat != nullptr) {
line += chat->title;
} else {
line += "Chat " + std::to_string(chat_id);
}
if (chat != nullptr && chat->unread_count > 0) {
line += " [" + std::to_string(chat->unread_count) + "]";
}
if (chat != nullptr && !chat->last_message_preview.empty()) {
line += " - " + chat->last_message_preview;
}
if (static_cast<int>(line.size()) > width - 2) {
line.resize(static_cast<std::size_t>(width - 5));
line += "...";
}
mvprintw(y, 1, "%s", line.c_str());
if (is_open) {
attroff(COLOR_PAIR(kColorPairOpenChat));
}
if (selected) {
attroff(A_BOLD | (focus_ == FocusPane::Chats ? A_REVERSE : A_NORMAL));
}
}
}
} // namespace telegram_tui

180
src/app_commands.cpp Normal file
View File

@@ -0,0 +1,180 @@
#include "app.h"
#include <algorithm>
#include <cctype>
#include <set>
#include <sstream>
#include "util.h"
namespace telegram_tui {
namespace {
bool starts_with_at(const std::string &text, std::size_t index, const char *prefix) {
const std::size_t prefix_size = std::char_traits<char>::length(prefix);
return text.compare(index, prefix_size, prefix) == 0;
}
} // namespace
std::int64_t App::resolve_message_ref(const ChatInfo &chat, std::int64_t ref) const {
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::tuple<bool, std::int64_t, std::string>
App::parse_single_message_command(const std::string &value, const char *prefix) const {
if (!starts_with_at(value, 0, prefix)) {
return {false, 0, value};
}
const std::size_t prefix_size = std::char_traits<char>::length(prefix);
std::size_t id_start = prefix_size;
while (id_start < value.size() && value[id_start] == ' ') {
++id_start;
}
std::size_t id_end = id_start;
while (id_end < value.size() && std::isdigit(static_cast<unsigned char>(value[id_end]))) {
++id_end;
}
if (id_end == id_start) {
return {true, 0, {}};
}
std::int64_t parsed_value = 0;
try {
parsed_value = std::stoll(value.substr(id_start, id_end - id_start));
} catch (...) {
return {true, 0, {}};
}
while (id_end < value.size() && value[id_end] == ' ') {
++id_end;
}
const auto chat_id = open_chat_id();
if (!chat_id.has_value()) {
return {true, 0, value.substr(id_end)};
}
const auto chat_it = chats_.find(*chat_id);
if (chat_it == chats_.end()) {
return {true, 0, value.substr(id_end)};
}
const ChatInfo &chat = chat_it->second;
return {true, resolve_message_ref(chat, parsed_value), value.substr(id_end)};
}
std::optional<std::vector<std::int64_t>> App::parse_message_list_command(const std::string &value,
const char *prefix) const {
if (!starts_with_at(value, 0, prefix)) {
return std::nullopt;
}
const std::size_t prefix_size = std::char_traits<char>::length(prefix);
std::string remainder = value.substr(prefix_size);
remainder = trim_copy(std::move(remainder));
if (remainder.empty()) {
return std::vector<std::int64_t>{};
}
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;
for (char &ch : remainder) {
if (ch == ',') {
ch = ' ';
}
}
std::set<std::int64_t> unique_message_ids;
std::stringstream stream(remainder);
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(chat, 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(chat, 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());
}
App::ComposeCommand App::parse_compose_command(const std::string &value) const {
if (const auto message_ids = parse_message_list_command(value, ">f ");
message_ids.has_value()) {
return ComposeCommand{ComposeCommandKind::Forward, 0, *message_ids, {}};
}
if (const auto message_ids = parse_message_list_command(value, ">d ");
message_ids.has_value()) {
return ComposeCommand{ComposeCommandKind::Delete, 0, *message_ids, {}};
}
if (const auto [matched, message_id, text] = parse_single_message_command(value, ">e ");
matched) {
return ComposeCommand{ComposeCommandKind::Edit, message_id, {}, text};
}
if (const auto [matched, message_id, text] = parse_single_message_command(value, ">r ");
matched) {
return ComposeCommand{ComposeCommandKind::Reply, message_id, {}, text};
}
return ComposeCommand{};
}
} // namespace telegram_tui

174
src/app_help.cpp Normal file
View File

@@ -0,0 +1,174 @@
#include "app.h"
#include <algorithm>
#include <vector>
#include <curses.h>
#include "util.h"
namespace telegram_tui {
namespace {
const std::vector<std::string> kShinoaBanner = {
" ____ _ _ ", " / ___|| |__ (_)_ __ ___ __ _ ",
" \\___ \\| '_ \\| | '_ \\ / _ \\ / _` |", " ___) | | | | | | | | (_) | (_| |",
" |____/|_| |_|_|_| |_|\\___/ \\__,_|",
};
} // namespace
void App::handle_help_menu_key(int ch) {
switch (ch) {
case 27:
case 'h':
case '?':
case KEY_F(1):
help_menu_open_ = false;
status_line_ = "Closed help.";
return;
case '1':
help_menu_page_ = 0;
status_line_ = "Help.";
return;
case '2':
help_menu_page_ = 1;
status_line_ = "Settings.";
return;
case '3':
help_menu_page_ = 2;
status_line_ = "About.";
return;
case KEY_LEFT:
help_menu_page_ = (help_menu_page_ + 2) % 3;
return;
case KEY_RIGHT:
help_menu_page_ = (help_menu_page_ + 1) % 3;
return;
case 't':
if (help_menu_page_ != 1) {
return;
}
auto_reload_chat_history_ = !auto_reload_chat_history_;
persist_config();
status_line_ = auto_reload_chat_history_ ? "Auto-reload history enabled."
: "Auto-reload history disabled.";
return;
default:
return;
}
}
void App::draw_help_menu(int height, int width) {
const int menu_width = std::min(width - 4, 92);
const int menu_height = std::min(height - 4, 24);
const int top = std::max(1, (height - menu_height) / 2);
const int left = std::max(1, (width - menu_width) / 2);
WINDOW *window = newwin(menu_height, menu_width, top, left);
if (window == nullptr) {
return;
}
box(window, 0, 0);
const char *title =
help_menu_page_ == 0 ? " Help " : (help_menu_page_ == 1 ? " Settings " : " About ");
mvwprintw(window, 0, 2, "%s", title);
const std::string page_tabs =
help_menu_page_ == 0 ? "[1] Help 2 Settings 3 About"
: (help_menu_page_ == 1 ? "1 Help [2] Settings 3 About"
: "1 Help 2 Settings [3] About");
mvwaddnstr(window, 1, 2, page_tabs.c_str(), menu_width - 4);
mvwhline(window, 2, 1, ACS_HLINE, menu_width - 2);
const auto help_item = [](const std::string &key, const std::string &description) {
std::string line = " " + key;
const int key_width = std::max(16, utf8_display_width(key));
const int padding = std::max(2, key_width - utf8_display_width(key) + 2);
line.append(static_cast<std::size_t>(padding), ' ');
line += description;
return line;
};
std::vector<std::string> lines;
if (help_menu_page_ == 0) {
lines = {
"Navigation",
help_item("? / h", "Open or close help"),
help_item("Tab", "Switch focus"),
help_item("Enter", "Open selected chat"),
help_item("Up / Down", "Move chats or attachment selection"),
help_item("PgUp / PgDn", "Scroll messages"),
"",
"Compose",
help_item("i", "Compose a message"),
help_item("a", "Prepare reply to latest"),
help_item(">r <msg> [text]", "Prepare a reply"),
help_item(">paste [caption]", "Send clipboard image"),
"",
"Message Actions",
help_item(">f <msg...>", "Forward messages"),
help_item(">e <msg> <text>", "Edit a message"),
help_item(">d <msg...>", "Delete your messages"),
help_item("m / o", "Attachments menu / open selected"),
};
} else if (help_menu_page_ == 1) {
lines = {
"Settings",
std::string(" [") + (auto_reload_chat_history_ ? 'x' : ' ') +
"] Auto-reload open chat history [t]",
"",
"Behavior",
" On: Enter in the message pane reloads history.",
" Off: Press r to reload the current chat manually.",
"",
"Menu",
help_item("1 / 2 / 3", "Switch help pages"),
help_item("Left / Right", "Cycle pages"),
help_item("Esc", "Close the help overlay"),
};
} else {
lines = {
"",
"",
};
}
const int content_top = 3;
const int content_height = menu_height - 6;
if (help_menu_page_ == 2) {
int row = content_top;
for (const auto &line : kShinoaBanner) {
if (row >= content_top + content_height) {
break;
}
const int x = std::max(2, (menu_width - static_cast<int>(line.size())) / 2);
mvwaddnstr(window, row++, x, line.c_str(), menu_width - x - 1);
}
const std::vector<std::string> about_lines = {
"",
"Telegram TUI with message refs, reply prep, attachment navigation,",
"and keyboard-first chat workflows.",
};
for (const auto &line : about_lines) {
if (row >= content_top + content_height) {
break;
}
const int x = std::max(2, (menu_width - static_cast<int>(line.size())) / 2);
mvwaddnstr(window, row++, x, line.c_str(), menu_width - x - 1);
}
} else {
for (std::size_t i = 0; i < lines.size() && static_cast<int>(i) < content_height;
++i) {
mvwaddnstr(window, static_cast<int>(i) + content_top, 2, lines[i].c_str(),
menu_width - 4);
}
}
mvwhline(window, menu_height - 3, 1, ACS_HLINE, menu_width - 2);
mvwaddnstr(window, menu_height - 2, 2, "Esc close", menu_width - 4);
wrefresh(window);
delwin(window);
}
} // namespace telegram_tui

396
src/app_input.cpp Normal file
View File

@@ -0,0 +1,396 @@
#include "app.h"
#include <algorithm>
#include <climits>
#include <cwchar>
#include <cwctype>
#include <curses.h>
#include "util.h"
namespace telegram_tui {
namespace {
std::optional<int> mapped_layout_hotkey(wchar_t ch) {
switch (std::towlower(ch)) {
case L'й':
return 'q';
case L'р':
return 'h';
case L'ш':
return 'i';
case L'к':
return 'r';
case L'щ':
return 'o';
default:
return std::nullopt;
}
}
std::string utf8_from_wchar(wchar_t ch) {
char buffer[MB_LEN_MAX] = {};
std::mbstate_t state = std::mbstate_t{};
const std::size_t size = std::wcrtomb(buffer, ch, &state);
if (size == static_cast<std::size_t>(-1)) {
return {};
}
return std::string(buffer, size);
}
} // namespace
void App::start_reply_to_latest_message() {
const auto chat_id = open_chat_id();
if (!chat_id.has_value()) {
status_line_ = "Open chat first.";
return;
}
const auto chat_it = chats_.find(*chat_id);
if (chat_it == chats_.end() || chat_it->second.messages.empty()) {
status_line_ = "No message available to reply to.";
return;
}
prepare_reply_input(*chat_id, chat_it->second.messages.back().id);
}
void App::open_forward_target_menu(std::int64_t source_chat_id,
std::vector<std::int64_t> message_ids) {
if (source_chat_id == 0 || message_ids.empty()) {
status_line_ = "Forward command needs one or more valid message refs.";
return;
}
const std::vector<std::int64_t> target_chat_ids = forward_target_chat_ids();
if (target_chat_ids.empty()) {
status_line_ = "No non-channel chats available to forward to.";
return;
}
forward_source_chat_id_ = source_chat_id;
forward_message_ids_ = std::move(message_ids);
forward_target_index_ = selected_chat_index_;
if (forward_target_index_ < 0 ||
forward_target_index_ >= static_cast<int>(target_chat_ids.size())) {
forward_target_index_ = 0;
}
if (target_chat_ids.size() > 1 &&
target_chat_ids[static_cast<std::size_t>(forward_target_index_)] == source_chat_id) {
if (forward_target_index_ + 1 < static_cast<int>(target_chat_ids.size())) {
++forward_target_index_;
} else {
--forward_target_index_;
}
}
forward_target_menu_open_ = true;
status_line_ = forward_message_ids_.size() == 1
? "Select a chat to forward the message to."
: "Select a chat to forward the messages to.";
}
void App::handle_key(int ch) {
if (input_mode_ != InputMode::None) {
handle_input_key(ch);
return;
}
if (forward_target_menu_open_) {
handle_forward_target_menu_key(ch);
return;
}
if (attachment_viewer_open_) {
handle_attachment_viewer_key(ch);
return;
}
if (attachment_action_menu_open_) {
handle_attachment_action_menu_key(ch);
return;
}
if (attachments_menu_open_) {
handle_attachments_menu_key(ch);
return;
}
if (help_menu_open_) {
handle_help_menu_key(ch);
return;
}
switch (ch) {
case 'q':
running_ = false;
return;
case 'h':
case '?':
case KEY_F(1):
help_menu_open_ = true;
help_menu_page_ = 0;
status_line_ = "Help.";
return;
case 'm':
if (!open_chat_id().has_value()) {
status_line_ = "Open chat first.";
return;
}
attachments_menu_open_ = true;
attachment_category_index_ = 0;
attachment_selection_index_ = 0;
status_line_ = "Attachments.";
return;
case '\t':
focus_ = focus_ == FocusPane::Chats ? FocusPane::Messages : FocusPane::Chats;
return;
case 'r':
if (focus_ == FocusPane::Chats) {
request_more_chats();
} else {
request_open_chat_history(true);
}
return;
case 'a':
if (focus_ == FocusPane::Messages) {
start_reply_to_latest_message();
}
return;
case 'o':
if (focus_ != FocusPane::Messages) {
return;
}
sync_message_attachment_selection();
if (const auto attachment = selected_message_attachment(); attachment.has_value()) {
open_attachment(*attachment);
} else {
status_line_ = "No attachment available to open.";
}
return;
case 'i':
if (!authorized_) {
status_line_ = "Finish login first.";
return;
}
if (!open_chat_id().has_value()) {
status_line_ = "Open chat first.";
return;
}
start_input(InputMode::Compose, "Message", false);
status_line_ = "Compose mode.";
return;
case KEY_UP:
if (focus_ == FocusPane::Chats && selected_chat_index_ > 0) {
--selected_chat_index_;
} else if (focus_ == FocusPane::Messages) {
if (!move_message_attachment_selection(-1)) {
++message_scroll_;
}
}
return;
case KEY_DOWN:
if (focus_ == FocusPane::Chats &&
selected_chat_index_ + 1 < static_cast<int>(sorted_chat_ids_.size())) {
++selected_chat_index_;
} else if (focus_ == FocusPane::Messages) {
if (!move_message_attachment_selection(1) && message_scroll_ > 0) {
--message_scroll_;
}
}
return;
case KEY_PPAGE:
message_scroll_ += 10;
return;
case KEY_NPAGE:
message_scroll_ = std::max(0, message_scroll_ - 10);
return;
case '\n':
case KEY_ENTER:
if (focus_ == FocusPane::Chats) {
const auto chat_id = highlighted_chat_id();
if (chat_id.has_value()) {
const bool is_same_chat = open_chat_id_ == *chat_id;
set_open_chat(*chat_id);
if (is_same_chat) {
const auto chat_it = chats_.find(*chat_id);
const bool should_request_history =
auto_reload_chat_history_ ||
(chat_it != chats_.end() &&
(!chat_it->second.history_loaded ||
chat_it->second.messages.empty()));
const bool requested = should_request_history &&
request_open_chat_history(true);
if (!requested) {
status_line_ = "Opened chat.";
}
}
}
} else if (auto_reload_chat_history_) {
const bool requested = request_open_chat_history(true);
if (!requested) {
status_line_ = "Opened chat.";
}
} else {
status_line_ = "History reload is manual. Press r to reload.";
}
return;
case KEY_RESIZE:
default:
if (focus_ == FocusPane::Messages && authorized_ && open_chat_id().has_value() &&
(ch >= 32 && ch <= 126)) {
input_buffer_.clear();
input_buffer_.push_back(static_cast<char>(ch));
input_cursor_ = input_buffer_.size();
start_input(InputMode::Compose, "Message", false);
status_line_ = "Compose mode.";
return;
}
return;
}
}
void App::handle_wide_char(wint_t ch) {
const std::string encoded = utf8_from_wchar(static_cast<wchar_t>(ch));
if (encoded.empty()) {
return;
}
if (input_mode_ != InputMode::None) {
input_buffer_.insert(input_cursor_, encoded);
input_cursor_ += encoded.size();
return;
}
if (attachment_viewer_open_ || attachment_action_menu_open_) {
return;
}
if (const auto hotkey = mapped_layout_hotkey(static_cast<wchar_t>(ch));
hotkey.has_value()) {
handle_key(*hotkey);
return;
}
if (attachments_menu_open_) {
return;
}
if (forward_target_menu_open_) {
return;
}
if (help_menu_open_) {
return;
}
if (focus_ == FocusPane::Messages && authorized_ && open_chat_id().has_value()) {
input_buffer_ = encoded;
input_cursor_ = input_buffer_.size();
start_input(InputMode::Compose, "Message", false);
status_line_ = "Compose mode.";
}
}
void App::handle_input_key(int ch) {
switch (ch) {
case 27:
if (input_mode_ == InputMode::Compose) {
clear_input();
status_line_ = "Compose cancelled.";
}
return;
case '\n':
case KEY_ENTER:
submit_input();
return;
case KEY_BACKSPACE:
case 127:
case 8:
if (input_cursor_ > 0) {
const std::size_t previous = utf8_prev_index(input_buffer_, input_cursor_);
input_buffer_.erase(previous, input_cursor_ - previous);
input_cursor_ = previous;
}
return;
case KEY_DC:
if (input_cursor_ < input_buffer_.size()) {
const std::size_t next = utf8_next_index(input_buffer_, input_cursor_);
input_buffer_.erase(input_cursor_, next - input_cursor_);
}
return;
case KEY_LEFT:
input_cursor_ = utf8_prev_index(input_buffer_, input_cursor_);
return;
case KEY_RIGHT:
input_cursor_ = utf8_next_index(input_buffer_, input_cursor_);
return;
case KEY_HOME:
input_cursor_ = 0;
return;
case KEY_END:
input_cursor_ = input_buffer_.size();
return;
default:
if (ch >= 32 && ch <= 126) {
input_buffer_.insert(input_cursor_, 1, static_cast<char>(ch));
++input_cursor_;
}
return;
}
}
void App::handle_forward_target_menu_key(int ch) {
const std::vector<std::int64_t> target_chat_ids = forward_target_chat_ids();
if (target_chat_ids.empty()) {
forward_target_menu_open_ = false;
status_line_ = "No non-channel chats available to forward to.";
return;
}
switch (ch) {
case 27:
case 'q':
forward_target_menu_open_ = false;
forward_message_ids_.clear();
status_line_ = "Cancelled forwarding.";
return;
case KEY_UP:
if (forward_target_index_ > 0) {
--forward_target_index_;
}
return;
case KEY_DOWN:
if (forward_target_index_ + 1 < static_cast<int>(target_chat_ids.size())) {
++forward_target_index_;
}
return;
case KEY_PPAGE:
forward_target_index_ = std::max(0, forward_target_index_ - 10);
return;
case KEY_NPAGE:
forward_target_index_ = std::min(static_cast<int>(target_chat_ids.size()) - 1,
forward_target_index_ + 10);
return;
case '\n':
case KEY_ENTER:
case ' ': {
const std::int64_t target_chat_id =
target_chat_ids[static_cast<std::size_t>(forward_target_index_)];
forward_target_menu_open_ = false;
forward_message(forward_source_chat_id_, forward_message_ids_, target_chat_id);
forward_message_ids_.clear();
}
return;
default:
return;
}
}
std::vector<std::int64_t> App::forward_target_chat_ids() const {
std::vector<std::int64_t> target_chat_ids;
target_chat_ids.reserve(sorted_chat_ids_.size());
for (const auto chat_id : sorted_chat_ids_) {
const auto chat_it = chats_.find(chat_id);
if (chat_it != chats_.end() && chat_it->second.is_channel) {
continue;
}
target_chat_ids.push_back(chat_id);
}
return target_chat_ids;
}
} // namespace telegram_tui

869
src/app_messages.cpp Normal file
View File

@@ -0,0 +1,869 @@
#include "app.h"
#include <algorithm>
#include <cctype>
#include <set>
#include <vector>
#include <curses.h>
#include "app_ui.h"
#include "util.h"
namespace telegram_tui {
namespace {
struct TextInsertion {
std::size_t offset = 0;
int order = 0;
std::size_t span_length = 0;
std::string text;
};
struct RenderLine {
bool is_day_separator = false;
bool is_selected_attachment = false;
std::int64_t message_numeric_id = 0;
std::int64_t reply_target_message_id = 0;
int body_indent = 0;
std::string timestamp;
std::string message_id;
std::string sender;
std::string forward_info;
std::string via_bot;
std::string reply_prefix;
std::string reply_ref;
std::string body;
std::string attachment_hint;
std::string state;
bool is_continuation = false;
};
bool is_word_char(unsigned char ch) {
return std::isalnum(ch) || ch == '_';
}
bool starts_with_at(const std::string &text, std::size_t index, const char *prefix) {
const std::size_t prefix_size = std::char_traits<char>::length(prefix);
return text.compare(index, prefix_size, prefix) == 0;
}
short sender_color_pair(const std::string &sender) {
static constexpr short sender_pairs[] = {
kColorPairSenderBlue, kColorPairSenderCyan, kColorPairSenderGreen,
kColorPairSenderYellow, kColorPairSenderMagenta, kColorPairSenderRed,
};
return sender_pairs[std::hash<std::string>{}(sender) % std::size(sender_pairs)];
}
short message_id_color_pair(std::int64_t message_id) {
static constexpr short id_pairs[] = {
kColorPairSenderBlue, kColorPairSenderCyan, kColorPairSenderGreen,
kColorPairSenderYellow, kColorPairSenderMagenta, kColorPairSenderRed,
};
return id_pairs[std::hash<std::int64_t>{}(message_id) % std::size(id_pairs)];
}
std::int64_t file_size_from_object(const json &file) {
return std::max(safe_i64(file, "size"), safe_i64(file, "expected_size"));
}
std::int32_t file_id_from_object(const json &file) {
return safe_i32(file, "id");
}
std::string local_path_from_file(const json &file) {
return safe_string(file.value("local", json::object()), "path");
}
bool file_can_be_downloaded(const json &file) {
return file.value("local", json::object()).value("can_be_downloaded", false);
}
bool file_can_be_deleted(const json &file) {
return file.value("local", json::object()).value("can_be_deleted", false);
}
std::int64_t file_downloaded_size(const json &file) {
return safe_i64(file.value("local", json::object()), "downloaded_size");
}
bool file_is_downloading_active(const json &file) {
return file.value("local", json::object()).value("is_downloading_active", false);
}
bool file_is_downloaded(const json &file) {
return file.value("local", json::object()).value("is_downloading_completed", false);
}
AttachmentInfo attachment_from_file(AttachmentType type, std::string name, const json &file,
std::int64_t size_bytes) {
AttachmentInfo attachment;
attachment.type = type;
attachment.name = std::move(name);
attachment.size_bytes = size_bytes;
attachment.downloaded_size = file_downloaded_size(file);
attachment.file_id = file_id_from_object(file);
attachment.local_path = local_path_from_file(file);
attachment.is_downloading_active = file_is_downloading_active(file);
attachment.can_be_downloaded = file_can_be_downloaded(file);
attachment.can_be_deleted = file_can_be_deleted(file);
attachment.is_downloaded = file_is_downloaded(file);
return attachment;
}
json largest_photo_file(const json &photo) {
json best_file = json::object();
std::int64_t best_size = -1;
const auto &sizes = photo.value("sizes", json::array());
if (!sizes.is_array()) {
return best_file;
}
for (const auto &item : sizes) {
const json file = item.value("photo", json::object());
const std::int64_t size = file_size_from_object(file);
if (size > best_size) {
best_size = size;
best_file = file;
}
}
return best_file;
}
std::int64_t photo_size_from_object(const json &photo) {
std::int64_t size = 0;
const auto &sizes = photo.value("sizes", json::array());
if (!sizes.is_array()) {
return 0;
}
for (const auto &item : sizes) {
size = std::max(size, file_size_from_object(item.value("photo", json::object())));
}
return size;
}
void draw_colored_span(int y, int &x, int max_x, const std::string &text, chtype attrs) {
if (x >= max_x || text.empty()) {
return;
}
const int remaining = max_x - x;
std::size_t count = 0;
int used_width = 0;
while (count < text.size() && used_width < remaining) {
const std::size_t next = utf8_next_index(text, count);
const int next_width =
utf8_display_width(text, next) - utf8_display_width(text, count);
if (used_width + next_width > remaining) {
break;
}
count = next;
used_width += next_width;
}
if (count == 0) {
return;
}
attron(attrs);
mvaddnstr(y, x, text.c_str(), static_cast<int>(count));
attroff(attrs);
x += used_width;
}
std::string entity_prefix(const json &type) {
const std::string type_name = safe_string(type, "@type");
if (type_name == "textEntityTypeBold") {
return "*";
}
if (type_name == "textEntityTypeItalic") {
return "_";
}
if (type_name == "textEntityTypeCode" || type_name == "textEntityTypePre" ||
type_name == "textEntityTypePreCode") {
return "`";
}
if (type_name == "textEntityTypeStrikethrough") {
return "~";
}
if (type_name == "textEntityTypeUnderline") {
return "__";
}
if (type_name == "textEntityTypeSpoiler") {
return "||";
}
if (type_name == "textEntityTypeTextUrl") {
return "[";
}
return {};
}
std::string entity_suffix(const json &type) {
const std::string type_name = safe_string(type, "@type");
if (type_name == "textEntityTypeBold") {
return "*";
}
if (type_name == "textEntityTypeItalic") {
return "_";
}
if (type_name == "textEntityTypeCode" || type_name == "textEntityTypePre" ||
type_name == "textEntityTypePreCode") {
return "`";
}
if (type_name == "textEntityTypeStrikethrough") {
return "~";
}
if (type_name == "textEntityTypeUnderline") {
return "__";
}
if (type_name == "textEntityTypeSpoiler") {
return "||";
}
if (type_name == "textEntityTypeTextUrl") {
const std::string url = safe_string(type, "url");
return url.empty() ? "]" : "](" + url + ")";
}
return {};
}
void draw_message_body(int y, int &x, int max_x, const std::string &text) {
std::size_t i = 0;
while (i < text.size() && x < max_x) {
if (starts_with_at(text, i, "http://") || starts_with_at(text, i, "https://") ||
starts_with_at(text, i, "t.me/")) {
std::size_t j = i;
while (j < text.size() &&
!std::isspace(static_cast<unsigned char>(text[j]))) {
++j;
}
draw_colored_span(y, x, max_x, text.substr(i, j - i),
COLOR_PAIR(kColorPairLink) | A_UNDERLINE);
i = j;
continue;
}
if (text[i] == '@') {
std::size_t j = i + 1;
while (j < text.size() &&
is_word_char(static_cast<unsigned char>(text[j]))) {
++j;
}
if (j > i + 1) {
draw_colored_span(y, x, max_x, text.substr(i, j - i),
COLOR_PAIR(kColorPairLink) | A_BOLD);
i = j;
continue;
}
}
if (text[i] == '*' || text[i] == '_' || text[i] == '`' || text[i] == '~' ||
text[i] == '[' || text[i] == ']' || text[i] == '(' || text[i] == ')') {
draw_colored_span(y, x, max_x, text.substr(i, 1),
COLOR_PAIR(kColorPairMarkdown) | A_BOLD);
++i;
continue;
}
std::size_t j = i;
while (j < text.size()) {
if (starts_with_at(text, j, "http://") ||
starts_with_at(text, j, "https://") ||
starts_with_at(text, j, "t.me/") || text[j] == '@' || text[j] == '*' ||
text[j] == '_' || text[j] == '`' || text[j] == '~' || text[j] == '[' ||
text[j] == ']' || text[j] == '(' || text[j] == ')') {
break;
}
++j;
}
draw_colored_span(y, x, max_x, text.substr(i, j - i), A_NORMAL);
i = j;
}
}
std::string truncate_to_width(std::string text, int max_width) {
if (max_width <= 0) {
return {};
}
if (static_cast<int>(text.size()) <= max_width) {
return text;
}
if (max_width <= 3) {
return text.substr(0, static_cast<std::size_t>(max_width));
}
text.resize(static_cast<std::size_t>(max_width - 3));
text += "...";
return text;
}
} // namespace
std::string App::sender_label(const json &sender) const {
const std::string type = safe_string(sender, "@type");
if (type == "messageSenderUser") {
const auto user_id = safe_i64(sender, "user_id");
auto it = users_.find(user_id);
if (it != users_.end()) {
return it->second.display_name();
}
if (user_id == my_user_id_) {
return "You";
}
return "User " + std::to_string(user_id);
}
if (type == "messageSenderChat") {
const auto chat_id = safe_i64(sender, "chat_id");
auto it = chats_.find(chat_id);
if (it != chats_.end() && !it->second.title.empty()) {
return it->second.title;
}
return "Chat " + std::to_string(chat_id);
}
return "Unknown";
}
std::string App::forward_origin_label(const json &forward_info) const {
if (!forward_info.is_object()) {
return {};
}
const json origin = forward_info.value("origin", json::object());
const std::string origin_type = safe_string(origin, "@type");
if (origin_type.empty()) {
return {};
}
if (origin_type == "messageOriginUser") {
const auto user_id = safe_i64(origin, "sender_user_id");
const auto user_it = users_.find(user_id);
if (user_it != users_.end()) {
return "fwd " + user_it->second.display_name();
}
return "fwd User " + std::to_string(user_id);
}
if (origin_type == "messageOriginHiddenUser") {
const std::string sender_name = safe_string(origin, "sender_name");
return sender_name.empty() ? "fwd" : "fwd " + sender_name;
}
if (origin_type == "messageOriginChat") {
const auto sender_chat_id = safe_i64(origin, "sender_chat_id");
std::string label;
const auto chat_it = chats_.find(sender_chat_id);
if (chat_it != chats_.end() && !chat_it->second.title.empty()) {
label = chat_it->second.title;
} else {
label = "Chat " + std::to_string(sender_chat_id);
}
const std::string signature = safe_string(origin, "author_signature");
if (!signature.empty()) {
label += " (" + signature + ")";
}
return "fwd " + label;
}
if (origin_type == "messageOriginChannel") {
const auto channel_chat_id = safe_i64(origin, "chat_id");
std::string label;
const auto chat_it = chats_.find(channel_chat_id);
if (chat_it != chats_.end() && !chat_it->second.title.empty()) {
label = chat_it->second.title;
} else {
label = "Channel " + std::to_string(channel_chat_id);
}
const std::string signature = safe_string(origin, "author_signature");
if (!signature.empty()) {
label += " (" + signature + ")";
}
return "fwd " + label;
}
return "fwd";
}
std::string App::via_bot_label(std::int64_t via_bot_user_id) const {
if (via_bot_user_id == 0) {
return {};
}
const auto user_it = users_.find(via_bot_user_id);
if (user_it != users_.end()) {
if (!user_it->second.username.empty()) {
return "@" + user_it->second.username;
}
return user_it->second.display_name();
}
return "via";
}
std::string App::preview_message(const json &message) const {
if (!message.is_object()) {
return {};
}
return single_line(content_to_text(message.value("content", json::object()), false));
}
std::optional<AttachmentInfo> App::parse_attachment(const json &content) const {
const std::string type = safe_string(content, "@type");
if (type == "messagePhoto") {
const json photo_file = largest_photo_file(content.value("photo", json::object()));
return attachment_from_file(
AttachmentType::Photo, "photo", photo_file,
photo_size_from_object(content.value("photo", json::object())));
}
if (type == "messageVideo") {
const json video = content.value("video", json::object());
std::string name = safe_string(video, "file_name");
if (name.empty()) {
name = "video";
}
const json file = video.value("video", json::object());
return attachment_from_file(AttachmentType::Video, name, file,
file_size_from_object(file));
}
if (type == "messageVideoNote") {
const json file =
content.value("video_note", json::object()).value("video", json::object());
return attachment_from_file(AttachmentType::Video, "video note", file,
file_size_from_object(file));
}
if (type == "messageDocument") {
const json document = content.value("document", json::object());
std::string name = safe_string(document, "file_name");
if (name.empty()) {
name = "document";
}
const json file = document.value("document", json::object());
return attachment_from_file(AttachmentType::Document, name, file,
file_size_from_object(file));
}
if (type == "messageAudio") {
const json audio = content.value("audio", json::object());
std::string name = safe_string(audio, "title");
if (name.empty()) {
name = safe_string(audio, "file_name");
}
if (name.empty()) {
name = "audio";
}
const json file = audio.value("audio", json::object());
return attachment_from_file(AttachmentType::Audio, name, file,
file_size_from_object(file));
}
if (type == "messageVoiceNote") {
const json file =
content.value("voice_note", json::object()).value("voice", json::object());
return attachment_from_file(AttachmentType::Voice, "voice note", file,
file_size_from_object(file));
}
if (type == "messageAnimation") {
const json animation = content.value("animation", json::object());
std::string name = safe_string(animation, "file_name");
if (name.empty()) {
name = "animation";
}
const json file = animation.value("animation", json::object());
return attachment_from_file(AttachmentType::Animation, name, file,
file_size_from_object(file));
}
if (type == "messageSticker") {
const json sticker = content.value("sticker", json::object());
std::string name = safe_string(sticker, "emoji");
if (name.empty()) {
name = "sticker";
}
const json file = sticker.value("sticker", json::object());
return attachment_from_file(AttachmentType::Sticker, name, file,
file_size_from_object(file));
}
return std::nullopt;
}
std::string App::content_to_text(const json &content, bool decorate) const {
auto format_text = [&](const json &formatted) {
const std::string base = safe_string(formatted, "text");
if (!decorate || !formatted.contains("entities") ||
!formatted.at("entities").is_array()) {
return base;
}
std::vector<TextInsertion> insertions;
for (const auto &entity : formatted.at("entities")) {
if (!entity.is_object()) {
continue;
}
const std::size_t utf16_offset =
static_cast<std::size_t>(safe_i32(entity, "offset"));
const std::size_t utf16_length =
static_cast<std::size_t>(safe_i32(entity, "length"));
if (utf16_length == 0) {
continue;
}
const json type = entity.value("type", json::object());
const std::string prefix = entity_prefix(type);
const std::string suffix = entity_suffix(type);
if (prefix.empty() && suffix.empty()) {
continue;
}
const std::size_t start =
utf8_byte_index_from_utf16_offset(base, utf16_offset);
const std::size_t end = utf8_byte_index_from_utf16_offset(
base, utf16_offset + utf16_length);
if (start > end || end > base.size()) {
continue;
}
if (!suffix.empty()) {
insertions.push_back({end, 0, utf16_length, suffix});
}
if (!prefix.empty()) {
insertions.push_back({start, 1, utf16_length, prefix});
}
}
std::sort(insertions.begin(), insertions.end(),
[](const TextInsertion &lhs, const TextInsertion &rhs) {
if (lhs.offset != rhs.offset) {
return lhs.offset > rhs.offset;
}
if (lhs.order != rhs.order) {
return lhs.order < rhs.order;
}
return lhs.span_length < rhs.span_length;
});
std::string decorated = base;
for (const auto &insertion : insertions) {
decorated.insert(insertion.offset, insertion.text);
}
return decorated;
};
const std::string type = safe_string(content, "@type");
if (type == "messageText") {
return format_text(content.value("text", json::object()));
}
if (type == "messagePhoto") {
const std::string caption = format_text(content.value("caption", json::object()));
return caption.empty() ? "[photo]" : "[photo] " + caption;
}
if (type == "messageVideo") {
const std::string caption = format_text(content.value("caption", json::object()));
return caption.empty() ? "[video]" : "[video] " + caption;
}
if (type == "messageDocument") {
const std::string file_name =
safe_string(content.value("document", json::object()), "file_name");
const std::string caption = format_text(content.value("caption", json::object()));
if (!caption.empty()) {
return file_name.empty() ? "[document] " + caption
: "[document] " + file_name + " " + caption;
}
return file_name.empty() ? "[document]" : "[document] " + file_name;
}
if (type == "messageSticker") {
const std::string emoji =
safe_string(content.value("sticker", json::object()), "emoji");
return emoji.empty() ? "[sticker]" : "[sticker] " + emoji;
}
if (type == "messageAnimation") {
const std::string caption = format_text(content.value("caption", json::object()));
return caption.empty() ? "[animation]" : "[animation] " + caption;
}
if (type == "messageVoiceNote") {
return "[voice message]";
}
if (type == "messageAudio") {
const std::string caption = format_text(content.value("caption", json::object()));
return caption.empty() ? "[audio]" : "[audio] " + caption;
}
if (type == "messageCall") {
return "[call]";
}
if (type == "messagePoll") {
return "[poll] " + safe_string(content.value("poll", json::object()), "question");
}
if (type == "messageUnsupported") {
return "[unsupported message]";
}
return "[" + type + "]";
}
MessageInfo App::parse_message(const json &message) const {
MessageInfo parsed;
parsed.id = safe_i64(message, "id");
parsed.date = safe_i32(message, "date");
parsed.is_outgoing = message.value("is_outgoing", false);
const json reply_to = message.value("reply_to", json::object());
if (safe_string(reply_to, "@type") == "messageReplyToMessage") {
parsed.reply_to_message_id = safe_i64(reply_to, "message_id");
}
parsed.forward_info = forward_origin_label(message.value("forward_info", json::object()));
parsed.sender = parsed.is_outgoing
? "You"
: sender_label(message.value("sender_id", json::object()));
const json content = message.value("content", json::object());
if (const auto attachment = parse_attachment(content); attachment.has_value()) {
parsed.has_attachment = true;
parsed.attachment = *attachment;
}
parsed.text = content_to_text(content, true);
parsed.via_bot = via_bot_label(safe_i64(message, "via_bot_user_id"));
return parsed;
}
void App::draw_message_pane(int top, int height, int left, int width) {
for (int row = 0; row < height; ++row) {
mvhline(top + row, left, ' ', width);
}
const auto chat_id = open_chat_id();
if (!chat_id.has_value()) {
mvprintw(top, left + 1, "%s",
authorized_ ? "Open a chat with Enter." : "Waiting for authorization.");
return;
}
const auto chat_it = chats_.find(*chat_id);
if (chat_it == chats_.end()) {
mvprintw(top, left + 1, "Chat %lld unavailable.", static_cast<long long>(*chat_id));
return;
}
const ChatInfo &chat = chat_it->second;
const std::string header =
truncate_to_width(format_open_chat_header(chat), std::max(1, width - 2));
attron(COLOR_PAIR(kColorPairOpenChat) | A_BOLD);
mvprintw(top, left + 1, "%s", header.c_str());
attroff(COLOR_PAIR(kColorPairOpenChat) | A_BOLD);
std::vector<RenderLine> lines;
std::string current_day;
std::set<std::int64_t> replied_message_ids;
sync_message_attachment_selection();
for (const auto &message : chat.messages) {
if (message.reply_to_message_id != 0) {
replied_message_ids.insert(message.reply_to_message_id);
}
}
for (const auto &message : chat.messages) {
const std::string message_day = format_date(message.date);
if (message_day != current_day) {
current_day = message_day;
RenderLine separator;
separator.is_day_separator = true;
separator.body = message_day;
lines.push_back(separator);
}
std::string state = "[in]";
if (message.is_outgoing) {
state = (chat.last_read_outbox_message_id != 0 &&
message.id <= chat.last_read_outbox_message_id)
? "[read]"
: "[sent]";
} else if (chat.last_read_inbox_message_id != 0 &&
message.id > chat.last_read_inbox_message_id) {
state = "[new]";
}
const std::string timestamp = "[" + format_time(message.date) + "]";
const std::string message_id =
"[" + std::to_string(&message - &chat.messages[0] + 1) + "]";
const std::string reply_prefix = message.reply_to_message_id != 0 ? "re " : "";
const std::string reply_ref =
message.reply_to_message_id != 0
? format_message_ref(chat, message.reply_to_message_id)
: "";
const std::string forward_info = message.forward_info;
const std::string via_bot = message.via_bot;
const std::string attachment_hint =
message.id == message_attachment_message_id_ ? " o to open" : "";
const int reserved_state_width = static_cast<int>(state.size()) + 1;
std::string prefix = timestamp + " " + message_id + " " + message.sender + ": ";
if (!forward_info.empty()) {
prefix += forward_info + " ";
}
if (!via_bot.empty()) {
prefix += via_bot + " ";
}
if (!reply_ref.empty()) {
prefix += reply_prefix + reply_ref + " ";
}
const int wrap_width = std::max(10, width - 3 - reserved_state_width);
const int prefix_width = utf8_display_width(prefix);
const int body_indent =
prefix_width < std::max(8, wrap_width - 12) ? prefix_width : 0;
std::vector<std::string> wrapped = wrap_text(
message.text, std::max(10, wrap_width - prefix_width -
utf8_display_width(attachment_hint)));
if (wrapped.empty()) {
RenderLine line;
line.is_selected_attachment = message.id == message_attachment_message_id_;
line.message_numeric_id = message.id;
line.reply_target_message_id = message.reply_to_message_id;
line.body_indent = body_indent;
line.timestamp = timestamp;
line.message_id = message_id;
line.sender = message.sender;
line.forward_info = forward_info;
line.via_bot = via_bot;
line.reply_prefix = reply_prefix;
line.reply_ref = reply_ref;
line.attachment_hint = attachment_hint;
line.state = state;
lines.push_back(line);
continue;
}
RenderLine first_line;
first_line.is_selected_attachment = message.id == message_attachment_message_id_;
first_line.message_numeric_id = message.id;
first_line.reply_target_message_id = message.reply_to_message_id;
first_line.body_indent = body_indent;
first_line.timestamp = timestamp;
first_line.message_id = message_id;
first_line.sender = message.sender;
first_line.forward_info = forward_info;
first_line.via_bot = via_bot;
first_line.reply_prefix = reply_prefix;
first_line.reply_ref = reply_ref;
first_line.body = wrapped.front();
first_line.attachment_hint = attachment_hint;
first_line.state = state;
lines.push_back(first_line);
for (std::size_t i = 1; i < wrapped.size(); ++i) {
RenderLine continuation;
continuation.body_indent = body_indent;
continuation.body = wrapped[i];
continuation.is_continuation = true;
lines.push_back(continuation);
}
}
if (lines.empty()) {
mvprintw(top + 1, left + 1, "%s", "No messages loaded.");
return;
}
const int available_rows = std::max(1, height - 2);
const int max_scroll = std::max(0, static_cast<int>(lines.size()) - available_rows);
int selected_attachment_line_index = -1;
for (int i = 0; i < static_cast<int>(lines.size()); ++i) {
if (lines[static_cast<std::size_t>(i)].is_selected_attachment) {
selected_attachment_line_index = i;
break;
}
}
if (message_scroll_ > max_scroll) {
message_scroll_ = max_scroll;
}
int first_line =
std::max(0, static_cast<int>(lines.size()) - available_rows - message_scroll_);
if (selected_attachment_line_index >= 0) {
if (selected_attachment_line_index < first_line) {
message_scroll_ =
std::max(0, static_cast<int>(lines.size()) - available_rows -
selected_attachment_line_index);
} else if (selected_attachment_line_index >= first_line + available_rows) {
message_scroll_ = std::max(0, static_cast<int>(lines.size()) -
selected_attachment_line_index - 1);
}
if (message_scroll_ > max_scroll) {
message_scroll_ = max_scroll;
}
first_line = std::max(0, static_cast<int>(lines.size()) - available_rows -
message_scroll_);
}
for (int row = 0; row < available_rows; ++row) {
const int line_index = first_line + row;
if (line_index >= static_cast<int>(lines.size())) {
break;
}
const auto &line = lines[line_index];
const int y = top + 1 + row;
if (line.is_day_separator) {
const int separator_width = std::max(0, width - 2);
std::string label = " " + line.body + " ";
if (static_cast<int>(label.size()) > separator_width) {
label.resize(static_cast<std::size_t>(separator_width));
}
attron(COLOR_PAIR(kColorPairTimestamp) | A_DIM);
mvhline(y, left + 1, ACS_HLINE, separator_width);
mvaddnstr(y,
left + 1 +
std::max(0, (separator_width -
static_cast<int>(label.size())) /
2),
label.c_str(), separator_width);
attroff(COLOR_PAIR(kColorPairTimestamp) | A_DIM);
continue;
}
int x = left + 1;
const int max_x = left + width;
if (!line.is_continuation) {
chtype state_attrs = A_DIM;
if (line.state == "[new]") {
state_attrs = COLOR_PAIR(kColorPairMarkdown) | A_BOLD;
} else if (line.state == "[read]") {
state_attrs = COLOR_PAIR(kColorPairOpenChat) | A_BOLD;
} else if (line.state == "[sent]") {
state_attrs = COLOR_PAIR(kColorPairTimestamp) | A_DIM;
}
const int state_x =
std::max(left + 1, max_x - static_cast<int>(line.state.size()));
const int content_max_x = std::max(left + 1, state_x - 1);
const chtype message_id_attrs =
replied_message_ids.find(line.message_numeric_id) !=
replied_message_ids.end()
? (COLOR_PAIR(
message_id_color_pair(line.message_numeric_id)) |
A_BOLD)
: (COLOR_PAIR(kColorPairMarkdown) | A_BOLD);
draw_colored_span(y, x, content_max_x, line.timestamp,
COLOR_PAIR(kColorPairTimestamp));
draw_colored_span(y, x, content_max_x, " ", A_NORMAL);
draw_colored_span(y, x, content_max_x, line.message_id, message_id_attrs);
draw_colored_span(y, x, content_max_x, " ", A_NORMAL);
draw_colored_span(y, x, content_max_x, line.sender,
COLOR_PAIR(sender_color_pair(line.sender)) | A_BOLD);
draw_colored_span(y, x, content_max_x, ": ", A_NORMAL);
if (!line.forward_info.empty()) {
draw_colored_span(y, x, content_max_x, line.forward_info,
COLOR_PAIR(kColorPairMarkdown) | A_DIM);
draw_colored_span(y, x, content_max_x, " ", A_NORMAL);
}
if (!line.via_bot.empty()) {
draw_colored_span(y, x, content_max_x, line.via_bot,
COLOR_PAIR(kColorPairMarkdown) | A_DIM);
draw_colored_span(y, x, content_max_x, " ", A_NORMAL);
}
if (!line.reply_ref.empty()) {
draw_colored_span(y, x, content_max_x, line.reply_prefix,
COLOR_PAIR(kColorPairMarkdown) | A_DIM);
draw_colored_span(
y, x, content_max_x, line.reply_ref,
replied_message_ids.find(line.reply_target_message_id) !=
replied_message_ids.end()
? (COLOR_PAIR(message_id_color_pair(
line.reply_target_message_id)) |
A_BOLD)
: (COLOR_PAIR(kColorPairMarkdown) | A_BOLD));
draw_colored_span(y, x, content_max_x, " ", A_NORMAL);
}
int state_draw_x = state_x;
draw_colored_span(y, state_draw_x, max_x, line.state, state_attrs);
draw_message_body(y, x, content_max_x, line.body);
if (!line.attachment_hint.empty()) {
draw_colored_span(y, x, content_max_x, line.attachment_hint, A_DIM);
}
} else {
x = std::min(max_x, left + 1 + line.body_indent);
draw_message_body(y, x, max_x, line.body);
}
}
}
} // namespace telegram_tui

262
src/app_shell.cpp Normal file
View File

@@ -0,0 +1,262 @@
#include "app.h"
#include <algorithm>
#include <clocale>
#include <curses.h>
#include "app_ui.h"
#include "util.h"
namespace telegram_tui {
namespace {
std::string truncate_to_width(std::string text, int max_width) {
if (max_width <= 0) {
return {};
}
if (static_cast<int>(text.size()) <= max_width) {
return text;
}
if (max_width <= 3) {
return text.substr(0, static_cast<std::size_t>(max_width));
}
text.resize(static_cast<std::size_t>(max_width - 3));
text += "...";
return text;
}
} // namespace
void App::init_curses() {
std::setlocale(LC_ALL, "");
initscr();
init_colors();
cbreak();
noecho();
keypad(stdscr, TRUE);
#ifdef NCURSES_VERSION
set_escdelay(25);
#endif
timeout(kPollTimeoutMs);
curs_set(0);
}
void App::init_colors() {
if (!has_colors()) {
return;
}
start_color();
use_default_colors();
init_pair(kColorPairSenderBlue, COLOR_BLUE, -1);
init_pair(kColorPairSenderCyan, COLOR_CYAN, -1);
init_pair(kColorPairSenderGreen, COLOR_GREEN, -1);
init_pair(kColorPairSenderYellow, COLOR_YELLOW, -1);
init_pair(kColorPairSenderMagenta, COLOR_MAGENTA, -1);
init_pair(kColorPairSenderRed, COLOR_RED, -1);
init_pair(kColorPairLink, COLOR_CYAN, -1);
init_pair(kColorPairMarkdown, COLOR_MAGENTA, -1);
init_pair(kColorPairTimestamp, COLOR_YELLOW, -1);
init_pair(kColorPairOpenChat, COLOR_GREEN, -1);
}
void App::shutdown_curses() {
endwin();
}
void App::draw() {
erase();
int height = 0;
int width = 0;
getmaxyx(stdscr, height, width);
if (height < 8 || width < 40) {
mvprintw(0, 0, "Terminal too small.");
refresh();
return;
}
const int header_y = 0;
const int content_top = 1;
const int footer_y = height - 2;
const int input_y = height - 1;
const int content_height = footer_y - content_top;
const int chat_width = std::max(24, width / 3);
const int message_width = width - chat_width - 1;
attron(A_REVERSE);
mvhline(header_y, 0, ' ', width);
const std::string header_label = use_test_dc_ ? "shinoa [TEST DC]" : "shinoa";
mvprintw(header_y, 1, "%s", header_label.c_str());
const std::string auth_label = authorized_ ? "ready" : current_auth_label();
const int auth_x = std::max(1, width - static_cast<int>(auth_label.size()) - 2);
mvprintw(header_y, auth_x, "%s", auth_label.c_str());
attroff(A_REVERSE);
mvvline(content_top, chat_width, ACS_VLINE, content_height);
draw_chat_pane(content_top, content_height, chat_width);
draw_message_pane(content_top, content_height, chat_width + 1, message_width);
attron(A_REVERSE);
mvhline(footer_y, 0, ' ', width);
const std::string footer_status = use_test_dc_ ? "[TEST DC] " + status_line_ : status_line_;
mvprintw(footer_y, 1, "%s", footer_status.c_str());
const std::string footer_hint = "? for help";
const int footer_hint_x = std::max(1, width - static_cast<int>(footer_hint.size()) - 2);
if (footer_hint_x > 1 + static_cast<int>(footer_status.size())) {
attron(A_DIM);
mvprintw(footer_y, footer_hint_x, "%s", footer_hint.c_str());
attroff(A_DIM);
}
attroff(A_REVERSE);
const std::string help =
input_mode_ == InputMode::None
? "? for help"
: input_prompt_ + ": " +
(input_hidden_ ? std::string(input_buffer_.size(), '*')
: input_buffer_);
mvhline(input_y, 0, ' ', width);
mvprintw(input_y, 0, "%s", help.c_str());
if (input_mode_ != InputMode::None) {
const std::string prefix = input_prompt_ + ": ";
const int cursor_x = std::min(
width - 1, utf8_display_width(prefix) +
utf8_display_width(input_buffer_, input_cursor_));
move(input_y, std::max(0, cursor_x));
}
refresh();
if (attachment_viewer_open_) {
draw_attachment_viewer(height, width);
} else if (attachment_action_menu_open_) {
draw_attachment_action_menu(height, width);
} else if (attachments_menu_open_) {
draw_attachments_menu(height, width);
} else if (forward_target_menu_open_) {
draw_forward_target_menu(height, width);
} else if (help_menu_open_) {
draw_help_menu(height, width);
} else {
clear_attachment_preview_graphics();
}
}
void App::draw_forward_target_menu(int height, int width) {
const std::vector<std::int64_t> target_chat_ids = forward_target_chat_ids();
if (target_chat_ids.empty()) {
forward_target_menu_open_ = false;
status_line_ = "No non-channel chats available to forward to.";
return;
}
const int menu_width = std::min(width - 4, 72);
const int menu_height = std::min(height - 4, 20);
const int top = std::max(1, (height - menu_height) / 2);
const int left = std::max(1, (width - menu_width) / 2);
WINDOW *window = newwin(menu_height, menu_width, top, left);
if (window == nullptr) {
return;
}
if (forward_target_index_ < 0) {
forward_target_index_ = 0;
}
if (forward_target_index_ >= static_cast<int>(target_chat_ids.size())) {
forward_target_index_ = static_cast<int>(target_chat_ids.size()) - 1;
}
box(window, 0, 0);
mvwprintw(window, 0, 2, " Forward To ");
std::string source_label =
forward_message_ids_.size() == 1
? "1 message"
: (std::to_string(forward_message_ids_.size()) + " messages");
const auto source_chat_it = chats_.find(forward_source_chat_id_);
if (source_chat_it != chats_.end()) {
if (forward_message_ids_.size() == 1) {
const auto message_index = find_message_index(source_chat_it->second,
forward_message_ids_.front());
if (message_index.has_value()) {
source_label =
"Message [" + std::to_string(*message_index + 1) + "]";
}
}
if (!source_chat_it->second.title.empty()) {
source_label += " from " + source_chat_it->second.title;
}
}
mvwaddnstr(window, 1, 2, truncate_to_width(source_label, menu_width - 4).c_str(),
menu_width - 4);
mvwhline(window, 2, 1, ACS_HLINE, menu_width - 2);
const int list_top = 3;
const int list_height = std::max(1, menu_height - 5);
int first_index = 0;
if (forward_target_index_ >= list_height) {
first_index = forward_target_index_ - list_height + 1;
}
for (int row = 0; row < list_height; ++row) {
const int item_index = first_index + row;
const int y = list_top + row;
mvwhline(window, y, 1, ' ', menu_width - 2);
if (item_index >= static_cast<int>(target_chat_ids.size())) {
continue;
}
const std::int64_t chat_id = target_chat_ids[static_cast<std::size_t>(item_index)];
const auto chat_it = chats_.find(chat_id);
std::string label = chat_it != chats_.end() ? chat_it->second.title
: ("Chat " + std::to_string(chat_id));
if (chat_id == forward_source_chat_id_) {
label += " (current)";
}
if (item_index == forward_target_index_) {
wattron(window, A_REVERSE | A_BOLD);
}
mvwaddnstr(window, y, 2, truncate_to_width(label, menu_width - 4).c_str(),
menu_width - 4);
if (item_index == forward_target_index_) {
wattroff(window, A_REVERSE | A_BOLD);
}
}
mvwaddnstr(window, menu_height - 1, 2, "Enter forward Esc cancel Up/Down move",
menu_width - 4);
wrefresh(window);
delwin(window);
}
std::string App::current_auth_label() const {
const std::string type = safe_string(authorization_state_, "@type");
if (type == "authorizationStateWaitTdlibParameters") {
return "need API";
}
if (type == "authorizationStateWaitPhoneNumber") {
return "need phone";
}
if (type == "authorizationStateWaitCode") {
return "need code";
}
if (type == "authorizationStateWaitEncryptionKey") {
return "unlock db";
}
if (type == "authorizationStateWaitPassword") {
return "need password";
}
if (type == "authorizationStateReady") {
return "ready";
}
if (type.empty()) {
return "starting";
}
return type;
}
} // namespace telegram_tui

16
src/app_ui.h Normal file
View File

@@ -0,0 +1,16 @@
#pragma once
namespace telegram_tui {
constexpr short kColorPairSenderBlue = 1;
constexpr short kColorPairSenderCyan = 2;
constexpr short kColorPairSenderGreen = 3;
constexpr short kColorPairSenderYellow = 4;
constexpr short kColorPairSenderMagenta = 5;
constexpr short kColorPairSenderRed = 6;
constexpr short kColorPairLink = 7;
constexpr short kColorPairMarkdown = 8;
constexpr short kColorPairTimestamp = 9;
constexpr short kColorPairOpenChat = 10;
} // namespace telegram_tui