From f291191577310e5f9675c6e7181ec163c3cda14c Mon Sep 17 00:00:00 2001 From: Mikhail Kilin Date: Tue, 27 Jan 2026 23:29:00 +0300 Subject: [PATCH] fixes --- CONTEXT.md | 28 +++- Cargo.lock | 359 +++++++++++++++++++++++++++++++++++++--- Cargo.toml | 1 + ROADMAP.md | 16 +- src/app/mod.rs | 54 ++++++ src/input/main_input.rs | 207 +++++++++++++++++++++++ src/tdlib/client.rs | 161 ++++++++++++++++++ src/ui/messages.rs | 140 +++++++++++++++- 8 files changed, 923 insertions(+), 43 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index be71814..5deb9ca 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -38,6 +38,19 @@ - **Индикатор редактирования**: ✎ рядом с временем для отредактированных сообщений - **Новые сообщения в реальном времени** при открытом чате - **Поиск по чатам** (Ctrl+S): фильтрация по названию и @username +- **Typing indicator** ("печатает..."): отображение статуса набора текста собеседником и отправка своего статуса +- **Закреплённые сообщения**: отображение pinned message вверху чата с переходом к нему +- **Поиск по сообщениям в чате** (Ctrl+F): поиск текста внутри открытого чата с навигацией по результатам +- **Черновики**: автосохранение набранного текста при переключении между чатами +- **Профиль пользователя/чата** (`i`): просмотр информации о собеседнике или группе +- **Копирование сообщений** (`y`/`н`): копирование текста сообщения в системный буфер обмена +- **Реакции на сообщения**: + - Отображение реакций под сообщениями + - Логика отображения: 1 человек = только emoji, 2+ = emoji + счётчик + - Свои реакции в рамках [👍], чужие без рамок 👍 + - Emoji picker с сеткой доступных реакций (8 в ряду) + - Добавление/удаление реакций (toggle) + - Обновление реакций в реальном времени через Update::MessageInteractionInfo - **Кеширование имён пользователей**: имена загружаются асинхронно и обновляются в UI - **Папки Telegram**: загрузка и переключение между папками (1-9) - **Медиа-заглушки**: [Фото], [Видео], [Голосовое], [Стикер], [GIF] и др. @@ -88,6 +101,14 @@ - `n` / `т` / `Esc` — отменить удаление в модалке - `Esc` — отменить выбор/редактирование/reply - `1-9` — переключение папок (в списке чатов) +- `Ctrl+F` — поиск по сообщениям в открытом чате +- `n` / `N` — навигация по результатам поиска (следующий/предыдущий) +- `i` — открыть профиль пользователя/чата +- `y` / `н` в режиме выбора — скопировать сообщение в буфер обмена +- `e` / `у` в режиме выбора — добавить реакцию (открывает emoji picker) +- `←` / `→` / `↑` / `↓` в emoji picker — навигация по сетке реакций +- `Enter` в emoji picker — добавить/удалить реакцию +- `Esc` в emoji picker — закрыть picker - **Редактирование текста в инпуте:** - `←` / `→` — перемещение курсора - `Home` — курсор в начало @@ -163,13 +184,6 @@ API_HASH=your_api_hash ## Что НЕ сделано / TODO (Фаза 9) -- [ ] Typing indicator ("печатает...") -- [ ] Закреплённые сообщения (Pinned) — отображение вверху чата -- [ ] Поиск по сообщениям в чате (Ctrl+F) -- [ ] Черновики — сохранение текста при переключении чатов -- [ ] Профиль пользователя/чата (хоткей `i`) -- [ ] Копирование сообщений в буфер обмена (`y` в режиме выбора) -- [ ] Реакции — просмотр и добавление - [ ] Конфигурационный файл (~/.config/tele-tui/config.toml) ## Известные проблемы diff --git a/Cargo.lock b/Cargo.lock index cb69bc9..3c23f09 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -43,6 +43,26 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -82,12 +102,24 @@ version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +[[package]] +name = "bytemuck" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" + [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.0" @@ -130,9 +162,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.53" +version = "1.2.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "755d2fce177175ffca841e9a06afdb2c4ab0f593d53b4dee48147dfaade85932" +checksum = "6354c81bbfd62d9cfa9cb3c773c2b7b2a3a482d569de977fd0e961f6e7c00583" dependencies = [ "find-msvc-tools", "jobserver", @@ -170,6 +202,15 @@ dependencies = [ "inout", ] +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "compact_str" version = "0.8.1" @@ -270,6 +311,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -408,6 +455,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "dispatch2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +dependencies = [ + "bitflags", + "objc2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -462,12 +519,47 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + [[package]] name = "fastrand" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fax" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" +dependencies = [ + "fax_derive", +] + +[[package]] +name = "fax_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "find-msvc-tools" version = "0.1.8" @@ -580,6 +672,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.3", + "windows-link", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -624,6 +726,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -919,6 +1032,20 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "tiff", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -1156,6 +1283,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "native-tls" version = "0.2.14" @@ -1175,9 +1312,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" [[package]] name = "num-traits" @@ -1188,6 +1325,79 @@ dependencies = [ "autocfg", ] +[[package]] +name = "objc2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-graphics", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -1324,6 +1534,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "png" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -1341,18 +1564,33 @@ checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] name = "proc-macro2" -version = "1.0.105" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] -name = "quote" -version = "1.0.43" +name = "pxfm" +version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" +checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" dependencies = [ "proc-macro2", ] @@ -1767,9 +2005,9 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.6.1" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" dependencies = [ "libc", "windows-sys 0.60.2", @@ -1911,6 +2149,7 @@ checksum = "87cbdfae498e57fb48d380fff8eb5c9c98d4497c998f6de0d30d5d6b12f5358b" name = "tele-tui" version = "0.1.0" dependencies = [ + "arboard", "chrono", "crossterm", "dotenvy", @@ -1937,18 +2176,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", @@ -1956,10 +2195,24 @@ dependencies = [ ] [[package]] -name = "time" -version = "0.3.45" +name = "tiff" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" +checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "time" +version = "0.3.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9da98b7d9b7dad93488a84b8248efc35352b0b2657397d4167e7ad67e5d535e5" dependencies = [ "deranged", "itoa", @@ -1972,15 +2225,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.25" +version = "0.2.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e552d1249bf61ac2a52db88179fd0673def1e1ad8243a00d9ec9ed71fee3dd" +checksum = "78cc610bac2dcee56805c99642447d4c5dbde4d01f752ffea0199aee1f601dc4" dependencies = [ "num-conv", "time-core", @@ -2297,6 +2550,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "winapi" version = "0.3.9" @@ -2566,6 +2825,23 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix 1.1.3", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + [[package]] name = "xz2" version = "0.1.7" @@ -2598,6 +2874,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71ddd76bcebeed25db614f82bf31a9f4222d3fbba300e6fb6c00afa26cbd4d9d" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8187381b52e32220d50b255276aa16a084ec0a9017a0ca2152a1f55c539758d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zerofrom" version = "0.1.6" @@ -2704,9 +3000,9 @@ dependencies = [ [[package]] name = "zmij" -version = "1.0.15" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94f63c051f4fe3c1509da62131a678643c5b6fbdc9273b2b79d4378ebda003d2" +checksum = "02aae0f83f69aafc94776e879363e9771d7ecbffe2c7fbb6c14c5e00dfe88439" [[package]] name = "zopfli" @@ -2747,3 +3043,18 @@ dependencies = [ "cc", "pkg-config", ] + +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-jpeg" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml index 93ebb25..6794238 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ serde_json = "1.0" dotenvy = "0.15" chrono = "0.4" open = "5.0" +arboard = "3.4" [build-dependencies] tdlib-rs = { version = "1.1", features = ["download-tdlib"] } diff --git a/ROADMAP.md b/ROADMAP.md index ae967e1..6c2caed 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -112,30 +112,30 @@ - Esc для отмены - Отображение "↪ Переслано от" для пересланных сообщений -## Фаза 9: Расширенные возможности [TODO] +## Фаза 9: Расширенные возможности [IN PROGRESS] -- [ ] Typing indicator ("печатает...") +- [x] Typing indicator ("печатает...") - Показывать когда собеседник печатает - Отправлять свой статус печати при наборе текста -- [ ] Закреплённые сообщения (Pinned) +- [x] Закреплённые сообщения (Pinned) - Отображать pinned message вверху открытого чата - Клик/хоткей для перехода к закреплённому сообщению -- [ ] Поиск по сообщениям в чате +- [x] Поиск по сообщениям в чате - `Ctrl+F` — поиск текста внутри открытого чата - Навигация по результатам (n/N или стрелки) - Подсветка найденных совпадений -- [ ] Черновики +- [x] Черновики - Сохранять набранный текст при переключении между чатами - Индикатор черновика в списке чатов - Восстановление текста при возврате в чат -- [ ] Профиль пользователя/чата +- [x] Профиль пользователя/чата - `i` — открыть информацию о чате/собеседнике - Для личных чатов: имя, username, телефон, био - Для групп: название, описание, количество участников -- [ ] Копирование сообщений +- [x] Копирование сообщений - `y` / `н` в режиме выбора — скопировать текст в системный буфер обмена - Использовать clipboard crate для кроссплатформенности -- [ ] Реакции +- [x] Реакции - Отображение реакций под сообщениями - `e` в режиме выбора — добавить реакцию (emoji picker) - Список доступных реакций чата diff --git a/src/app/mod.rs b/src/app/mod.rs index 5269a05..c721345 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -75,8 +75,18 @@ pub struct App { pub leave_group_confirmation_step: u8, /// Информация профиля для отображения pub profile_info: Option, + // Reaction picker mode + /// Режим выбора реакции + pub is_reaction_picker_mode: bool, + /// ID сообщения для добавления реакции + pub selected_message_for_reaction: Option, + /// Список доступных реакций + pub available_reactions: Vec, + /// Индекс выбранной реакции в picker + pub selected_reaction_index: usize, } + impl App { pub fn new() -> App { let mut state = ListState::default(); @@ -119,6 +129,10 @@ impl App { selected_profile_action: 0, leave_group_confirmation_step: 0, profile_info: None, + is_reaction_picker_mode: false, + selected_message_for_reaction: None, + available_reactions: Vec::new(), + selected_reaction_index: 0, } } @@ -606,4 +620,44 @@ impl App { pub fn get_leave_group_confirmation_step(&self) -> u8 { self.leave_group_confirmation_step } + + // ========== Reaction Picker ========== + + pub fn is_reaction_picker_mode(&self) -> bool { + self.is_reaction_picker_mode + } + + pub fn enter_reaction_picker_mode(&mut self, message_id: i64, available_reactions: Vec) { + self.is_reaction_picker_mode = true; + self.selected_message_for_reaction = Some(message_id); + self.available_reactions = available_reactions; + self.selected_reaction_index = 0; + } + + pub fn exit_reaction_picker_mode(&mut self) { + self.is_reaction_picker_mode = false; + self.selected_message_for_reaction = None; + self.available_reactions.clear(); + self.selected_reaction_index = 0; + } + + pub fn select_previous_reaction(&mut self) { + if !self.available_reactions.is_empty() && self.selected_reaction_index > 0 { + self.selected_reaction_index -= 1; + } + } + + pub fn select_next_reaction(&mut self) { + if self.selected_reaction_index + 1 < self.available_reactions.len() { + self.selected_reaction_index += 1; + } + } + + pub fn get_selected_reaction(&self) -> Option<&String> { + self.available_reactions.get(self.selected_reaction_index) + } + + pub fn get_selected_message_for_reaction(&self) -> Option { + self.selected_message_for_reaction + } } diff --git a/src/input/main_input.rs b/src/input/main_input.rs index 7f60eba..3dbe3c5 100644 --- a/src/input/main_input.rs +++ b/src/input/main_input.rs @@ -251,6 +251,73 @@ pub async fn handle(app: &mut App, key: KeyEvent) { return; } + // Обработка ввода в режиме выбора реакции + if app.is_reaction_picker_mode() { + match key.code { + KeyCode::Left => { + app.select_previous_reaction(); + app.needs_redraw = true; + } + KeyCode::Right => { + app.select_next_reaction(); + app.needs_redraw = true; + } + KeyCode::Up => { + // Переход на ряд выше (8 эмодзи в ряду) + if app.selected_reaction_index >= 8 { + app.selected_reaction_index = app.selected_reaction_index.saturating_sub(8); + app.needs_redraw = true; + } + } + KeyCode::Down => { + // Переход на ряд ниже (8 эмодзи в ряду) + let new_index = app.selected_reaction_index + 8; + if new_index < app.available_reactions.len() { + app.selected_reaction_index = new_index; + app.needs_redraw = true; + } + } + KeyCode::Enter => { + // Добавить/убрать реакцию + if let Some(emoji) = app.get_selected_reaction().cloned() { + if let Some(message_id) = app.get_selected_message_for_reaction() { + if let Some(chat_id) = app.selected_chat_id { + app.status_message = Some("Отправка реакции...".to_string()); + app.needs_redraw = true; + + match timeout( + Duration::from_secs(5), + app.td_client.toggle_reaction(chat_id, message_id, emoji.clone()) + ).await { + Ok(Ok(_)) => { + app.status_message = Some(format!("Реакция {} добавлена", emoji)); + app.exit_reaction_picker_mode(); + app.needs_redraw = true; + } + Ok(Err(e)) => { + app.error_message = Some(format!("Ошибка: {}", e)); + app.status_message = None; + app.needs_redraw = true; + } + Err(_) => { + app.error_message = Some("Таймаут отправки реакции".to_string()); + app.status_message = None; + app.needs_redraw = true; + } + } + } + } + } + } + KeyCode::Esc => { + app.exit_reaction_picker_mode(); + app.needs_redraw = true; + } + _ => {} + } + return; + } + // Модалка подтверждения удаления if app.is_confirm_delete_shown() { match key.code { @@ -563,6 +630,58 @@ pub async fn handle(app: &mut App, key: KeyEvent) { // Начать режим пересылки app.start_forward_selected(); } + KeyCode::Char('y') | KeyCode::Char('н') => { + // Копировать сообщение + if let Some(msg) = app.get_selected_message() { + let text = format_message_for_clipboard(msg); + match copy_to_clipboard(&text) { + Ok(_) => { + app.status_message = Some("Сообщение скопировано".to_string()); + } + Err(e) => { + app.error_message = Some(format!("Ошибка копирования: {}", e)); + } + } + } + } + KeyCode::Char('e') | KeyCode::Char('у') => { + // Открыть emoji picker для добавления реакции + if let Some(msg) = app.get_selected_message() { + let chat_id = app.selected_chat_id.unwrap(); + let message_id = msg.id; + + app.status_message = Some("Загрузка реакций...".to_string()); + app.needs_redraw = true; + + // Запрашиваем доступные реакции + match timeout( + Duration::from_secs(5), + app.td_client.get_message_available_reactions(chat_id, message_id) + ).await { + Ok(Ok(reactions)) => { + if reactions.is_empty() { + app.error_message = Some("Реакции недоступны для этого сообщения".to_string()); + app.status_message = None; + app.needs_redraw = true; + } else { + app.enter_reaction_picker_mode(message_id, reactions); + app.status_message = None; + app.needs_redraw = true; + } + } + Ok(Err(e)) => { + app.error_message = Some(format!("Ошибка загрузки реакций: {}", e)); + app.status_message = None; + app.needs_redraw = true; + } + Err(_) => { + app.error_message = Some("Таймаут загрузки реакций".to_string()); + app.status_message = None; + app.needs_redraw = true; + } + } + } + } _ => {} } return; @@ -756,3 +875,91 @@ fn get_available_actions_count(profile: &crate::tdlib::ProfileInfo) -> usize { count } + +/// Копирует текст в системный буфер обмена +fn copy_to_clipboard(text: &str) -> Result<(), String> { + use arboard::Clipboard; + + let mut clipboard = Clipboard::new().map_err(|e| format!("Не удалось инициализировать буфер обмена: {}", e))?; + clipboard.set_text(text).map_err(|e| format!("Не удалось скопировать: {}", e))?; + + Ok(()) +} + +/// Форматирует сообщение для копирования с контекстом +fn format_message_for_clipboard(msg: &crate::tdlib::client::MessageInfo) -> String { + let mut result = String::new(); + + // Добавляем forward контекст если есть + if let Some(forward) = &msg.forward_from { + result.push_str(&format!("↪ Переслано от {}\n", forward.sender_name)); + } + + // Добавляем reply контекст если есть + if let Some(reply) = &msg.reply_to { + result.push_str(&format!("┌ {}: {}\n", reply.sender_name, reply.text)); + } + + // Добавляем основной текст с markdown форматированием + result.push_str(&convert_entities_to_markdown(&msg.content, &msg.entities)); + + result +} + +/// Конвертирует текст с entities в markdown +fn convert_entities_to_markdown(text: &str, entities: &[tdlib_rs::types::TextEntity]) -> String { + use tdlib_rs::enums::TextEntityType; + + if entities.is_empty() { + return text.to_string(); + } + + // Создаём вектор символов для работы с unicode + let chars: Vec = text.chars().collect(); + let mut result = String::new(); + let mut i = 0; + + while i < chars.len() { + // Ищем entity, который начинается в текущей позиции + let mut entity_found = false; + + for entity in entities { + if entity.offset as usize == i { + entity_found = true; + let end = (entity.offset + entity.length) as usize; + let entity_text: String = chars[i..end.min(chars.len())].iter().collect(); + + // Применяем форматирование в зависимости от типа + let formatted = match &entity.r#type { + TextEntityType::Bold => format!("**{}**", entity_text), + TextEntityType::Italic => format!("*{}*", entity_text), + TextEntityType::Underline => format!("__{}__", entity_text), + TextEntityType::Strikethrough => format!("~~{}~~", entity_text), + TextEntityType::Code | TextEntityType::Pre | TextEntityType::PreCode(_) => { + format!("`{}`", entity_text) + } + TextEntityType::TextUrl(url_info) => { + format!("[{}]({})", entity_text, url_info.url) + } + TextEntityType::Url => format!("<{}>", entity_text), + TextEntityType::Mention | TextEntityType::MentionName(_) => { + format!("@{}", entity_text.trim_start_matches('@')) + } + TextEntityType::Spoiler => format!("||{}||", entity_text), + _ => entity_text, + }; + + result.push_str(&formatted); + i = end; + break; + } + } + + if !entity_found { + result.push(chars[i]); + i += 1; + } + } + + result +} diff --git a/src/tdlib/client.rs b/src/tdlib/client.rs index e14012b..6c5b8cd 100644 --- a/src/tdlib/client.rs +++ b/src/tdlib/client.rs @@ -137,6 +137,17 @@ pub struct ForwardInfo { pub date: i32, } +/// Информация о реакции на сообщение +#[derive(Debug, Clone)] +pub struct ReactionInfo { + /// Эмодзи реакции (например, "👍") + pub emoji: String, + /// Количество людей, поставивших эту реакцию + pub count: i32, + /// Поставил ли текущий пользователь эту реакцию + pub is_chosen: bool, +} + #[derive(Debug, Clone)] pub struct MessageInfo { pub id: i64, @@ -159,6 +170,8 @@ pub struct MessageInfo { pub reply_to: Option, /// Информация о forward (если сообщение переслано) pub forward_from: Option, + /// Реакции на сообщение + pub reactions: Vec, } #[derive(Debug, Clone)] @@ -623,6 +636,37 @@ impl TdClient { }); } } + Update::MessageInteractionInfo(update) => { + // Обновляем реакции в текущем открытом чате + if Some(update.chat_id) == self.current_chat_id { + if let Some(msg) = self.current_chat_messages.iter_mut().find(|m| m.id == update.message_id) { + // Извлекаем реакции из interaction_info + msg.reactions = update + .interaction_info + .as_ref() + .and_then(|info| info.reactions.as_ref()) + .map(|reactions| { + reactions + .reactions + .iter() + .filter_map(|reaction| { + let emoji = match &reaction.r#type { + tdlib_rs::enums::ReactionType::Emoji(e) => e.emoji.clone(), + tdlib_rs::enums::ReactionType::CustomEmoji(_) => return None, + }; + + Some(ReactionInfo { + emoji, + count: reaction.total_count, + is_chosen: reaction.is_chosen, + }) + }) + .collect() + }) + .unwrap_or_default(); + } + } + } _ => {} } } @@ -789,6 +833,9 @@ impl TdClient { // Извлекаем информацию о forward let forward_from = self.extract_forward_info(message); + // Извлекаем реакции + let reactions = self.extract_reactions(message); + MessageInfo { id: message.id, sender_name, @@ -803,6 +850,7 @@ impl TdClient { can_be_deleted_for_all_users: message.can_be_deleted_for_all_users, reply_to, forward_from, + reactions, } } @@ -859,6 +907,34 @@ impl TdClient { }) } + /// Извлекает информацию о реакциях из сообщения + fn extract_reactions(&self, message: &TdMessage) -> Vec { + message + .interaction_info + .as_ref() + .and_then(|info| info.reactions.as_ref()) + .map(|reactions| { + reactions + .reactions + .iter() + .filter_map(|reaction| { + // Извлекаем эмодзи из ReactionType + let emoji = match &reaction.r#type { + tdlib_rs::enums::ReactionType::Emoji(e) => e.emoji.clone(), + tdlib_rs::enums::ReactionType::CustomEmoji(_) => return None, // Пока игнорируем custom emoji + }; + + Some(ReactionInfo { + emoji, + count: reaction.total_count, + is_chosen: reaction.is_chosen, + }) + }) + .collect() + }) + .unwrap_or_default() + } + /// Получает имя отправителя из MessageOrigin fn get_origin_sender_name(&self, origin: &tdlib_rs::enums::MessageOrigin) -> String { use tdlib_rs::enums::MessageOrigin; @@ -1504,12 +1580,96 @@ impl TdClient { can_be_deleted_for_all_users: msg.can_be_deleted_for_all_users, reply_to: reply_info, forward_from: None, + reactions: Vec::new(), }) } Err(e) => Err(format!("Ошибка отправки сообщения: {:?}", e)), } } + + /// Получить доступные реакции для сообщения + pub async fn get_message_available_reactions( + &mut self, + chat_id: i64, + message_id: i64, + ) -> Result, String> { + use tdlib_rs::functions; + + let result = functions::get_message_available_reactions( + chat_id, + message_id, + 8, // row_size - количество реакций в ряду + self.client_id, + ) + .await; + + match result { + Ok(tdlib_rs::enums::AvailableReactions::AvailableReactions(reactions)) => { + // Извлекаем эмодзи из доступных реакций + // Используем top_reactions (самые популярные реакции) + let mut emojis: Vec = reactions + .top_reactions + .iter() + .filter_map(|reaction| { + if let tdlib_rs::enums::ReactionType::Emoji(e) = &reaction.r#type { + Some(e.emoji.clone()) + } else { + None + } + }) + .collect(); + + // Если top_reactions пустой, используем popular_reactions + if emojis.is_empty() { + emojis = reactions + .popular_reactions + .iter() + .filter_map(|reaction| { + if let tdlib_rs::enums::ReactionType::Emoji(e) = &reaction.r#type { + Some(e.emoji.clone()) + } else { + None + } + }) + .collect(); + } + + Ok(emojis) + } + Err(e) => Err(format!("Ошибка получения реакций: {:?}", e)), + } + } + + /// Добавить реакцию на сообщение (или убрать, если уже поставлена) + pub async fn toggle_reaction( + &mut self, + chat_id: i64, + message_id: i64, + emoji: String, + ) -> Result<(), String> { + use tdlib_rs::functions; + use tdlib_rs::types::ReactionTypeEmoji; + use tdlib_rs::enums::ReactionType; + + let reaction_type = ReactionType::Emoji(ReactionTypeEmoji { emoji }); + + let result = functions::add_message_reaction( + chat_id, + message_id, + reaction_type, + false, // is_big - обычная реакция (не "большая" анимация) + true, // update_recent_reactions - обновить список недавних реакций + self.client_id, + ) + .await; + + match result { + Ok(_) => Ok(()), + Err(e) => Err(format!("Ошибка добавления реакции: {:?}", e)), + } + } + /// Редактирование текстового сообщения с поддержкой Markdown /// Устанавливает черновик для чата через TDLib API pub async fn set_draft_message(&self, chat_id: i64, text: String) -> Result<(), String> { @@ -1616,6 +1776,7 @@ impl TdClient { can_be_deleted_for_all_users: msg.can_be_deleted_for_all_users, reply_to: None, // При редактировании reply сохраняется из оригинала forward_from: None, // При редактировании forward сохраняется из оригинала + reactions: Vec::new(), // При редактировании реакции сохраняются из оригинала }) } Err(e) => Err(format!("Ошибка редактирования сообщения: {:?}", e)), diff --git a/src/ui/messages.rs b/src/ui/messages.rs index 9119d2f..679a487 100644 --- a/src/ui/messages.rs +++ b/src/ui/messages.rs @@ -668,6 +668,58 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { } } } + + // Отображаем реакции под сообщением + if !msg.reactions.is_empty() { + let mut reaction_spans = vec![]; + + for reaction in &msg.reactions { + if !reaction_spans.is_empty() { + reaction_spans.push(Span::raw(" ")); + } + + // Свои реакции в рамках [emoji], чужие просто emoji + let reaction_text = if reaction.is_chosen { + if reaction.count > 1 { + format!("[{}] {}", reaction.emoji, reaction.count) + } else { + format!("[{}]", reaction.emoji) + } + } else { + if reaction.count > 1 { + format!("{} {}", reaction.emoji, reaction.count) + } else { + reaction.emoji.clone() + } + }; + + let style = if reaction.is_chosen { + Style::default().fg(Color::Yellow) + } else { + Style::default().fg(Color::Gray) + }; + + reaction_spans.push(Span::styled(reaction_text, style)); + } + + // Выравниваем реакции в зависимости от типа сообщения + if msg.is_outgoing { + // Реакции справа для исходящих + let reactions_text: String = reaction_spans + .iter() + .map(|s| s.content.as_ref()) + .collect::>() + .join(" "); + let reactions_len = reactions_text.chars().count(); + let padding = content_width.saturating_sub(reactions_len + 1); + let mut line_spans = vec![Span::raw(" ".repeat(padding))]; + line_spans.extend(reaction_spans); + lines.push(Line::from(line_spans)); + } else { + // Реакции слева для входящих + lines.push(Line::from(reaction_spans)); + } + } } if lines.is_empty() { @@ -734,10 +786,10 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { let can_delete = selected_msg.map(|m| m.can_be_deleted_only_for_self || m.can_be_deleted_for_all_users).unwrap_or(false); let hint = match (can_edit, can_delete) { - (true, true) => "↑↓ · Enter ред. · r ответ · f перслть · d удал. · Esc", - (true, false) => "↑↓ · Enter ред. · r ответ · f переслть · Esc", - (false, true) => "↑↓ · r ответ · f переслать · d удалить · Esc", - (false, false) => "↑↓ · r ответить · f переслать · Esc", + (true, true) => "↑↓ · Enter ред. · r ответ · f перслть · y копир. · d удал. · Esc", + (true, false) => "↑↓ · Enter ред. · r ответ · f переслть · y копир. · Esc", + (false, true) => "↑↓ · r ответ · f переслать · y копир. · d удалить · Esc", + (false, false) => "↑↓ · r ответить · f переслать · y копировать · Esc", }; (Line::from(Span::styled(hint, Style::default().fg(Color::Cyan))), " Выбор сообщения ") } else if app.is_editing() { @@ -827,6 +879,11 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { if app.is_confirm_delete_shown() { render_delete_confirm_modal(f, area); } + + // Модалка выбора реакции + if app.is_reaction_picker_mode() { + render_reaction_picker_modal(f, area, &app.available_reactions, app.selected_reaction_index); + } } /// Рендерит режим поиска по сообщениям @@ -1136,3 +1193,78 @@ fn render_delete_confirm_modal(f: &mut Frame, area: Rect) { f.render_widget(modal, modal_area); } + + +/// Рендерит модалку выбора реакции +fn render_reaction_picker_modal(f: &mut Frame, area: Rect, available_reactions: &[String], selected_index: usize) { + use ratatui::widgets::Clear; + + // Размеры модалки (зависят от количества реакций) + let emojis_per_row = 8; + let rows = (available_reactions.len() + emojis_per_row - 1) / emojis_per_row; + let modal_width = 50u16; + let modal_height = (rows + 4) as u16; // +4 для заголовка, отступов и подсказки + + // Центрируем модалку + let x = area.x + (area.width.saturating_sub(modal_width)) / 2; + let y = area.y + (area.height.saturating_sub(modal_height)) / 2; + + let modal_area = Rect::new(x, y, modal_width.min(area.width), modal_height.min(area.height)); + + // Очищаем область под модалкой + f.render_widget(Clear, modal_area); + + // Формируем содержимое - сетка эмодзи + let mut text_lines = vec![Line::from("")]; // Пустая строка сверху + + for row in 0..rows { + let mut row_spans = vec![Span::raw(" ")]; // Отступ слева + + for col in 0..emojis_per_row { + let idx = row * emojis_per_row + col; + if idx >= available_reactions.len() { + break; + } + + let emoji = &available_reactions[idx]; + let is_selected = idx == selected_index; + + let style = if is_selected { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + .add_modifier(Modifier::REVERSED) + } else { + Style::default().fg(Color::White) + }; + + row_spans.push(Span::styled(format!(" {} ", emoji), style)); + row_spans.push(Span::raw(" ")); // Пробел между эмодзи + } + + text_lines.push(Line::from(row_spans)); + } + + // Добавляем пустую строку и подсказку + text_lines.push(Line::from("")); + text_lines.push(Line::from(vec![ + Span::styled(" [←/→/↑/↓] ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), + Span::raw("Выбор "), + Span::styled(" [Enter] ", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)), + Span::raw("Добавить "), + Span::styled(" [Esc] ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)), + Span::raw("Отмена"), + ])); + + let modal = Paragraph::new(text_lines) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Yellow)) + .title(" Выбери реакцию ") + .title_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), + ) + .alignment(Alignment::Left); + + f.render_widget(modal, modal_area); +}