This commit is contained in:
Mikhail Kilin
2026-01-27 23:29:00 +03:00
parent 356d2d3064
commit f291191577
8 changed files with 923 additions and 43 deletions

View File

@@ -38,6 +38,19 @@
- **Индикатор редактирования**: ✎ рядом с временем для отредактированных сообщений - **Индикатор редактирования**: ✎ рядом с временем для отредактированных сообщений
- **Новые сообщения в реальном времени** при открытом чате - **Новые сообщения в реальном времени** при открытом чате
- **Поиск по чатам** (Ctrl+S): фильтрация по названию и @username - **Поиск по чатам** (Ctrl+S): фильтрация по названию и @username
- **Typing indicator** ("печатает..."): отображение статуса набора текста собеседником и отправка своего статуса
- **Закреплённые сообщения**: отображение pinned message вверху чата с переходом к нему
- **Поиск по сообщениям в чате** (Ctrl+F): поиск текста внутри открытого чата с навигацией по результатам
- **Черновики**: автосохранение набранного текста при переключении между чатами
- **Профиль пользователя/чата** (`i`): просмотр информации о собеседнике или группе
- **Копирование сообщений** (`y`/`н`): копирование текста сообщения в системный буфер обмена
- **Реакции на сообщения**:
- Отображение реакций под сообщениями
- Логика отображения: 1 человек = только emoji, 2+ = emoji + счётчик
- Свои реакции в рамках [👍], чужие без рамок 👍
- Emoji picker с сеткой доступных реакций (8 в ряду)
- Добавление/удаление реакций (toggle)
- Обновление реакций в реальном времени через Update::MessageInteractionInfo
- **Кеширование имён пользователей**: имена загружаются асинхронно и обновляются в UI - **Кеширование имён пользователей**: имена загружаются асинхронно и обновляются в UI
- **Папки Telegram**: загрузка и переключение между папками (1-9) - **Папки Telegram**: загрузка и переключение между папками (1-9)
- **Медиа-заглушки**: [Фото], [Видео], [Голосовое], [Стикер], [GIF] и др. - **Медиа-заглушки**: [Фото], [Видео], [Голосовое], [Стикер], [GIF] и др.
@@ -88,6 +101,14 @@
- `n` / `т` / `Esc` — отменить удаление в модалке - `n` / `т` / `Esc` — отменить удаление в модалке
- `Esc` — отменить выбор/редактирование/reply - `Esc` — отменить выбор/редактирование/reply
- `1-9` — переключение папок (в списке чатов) - `1-9` — переключение папок (в списке чатов)
- `Ctrl+F` — поиск по сообщениям в открытом чате
- `n` / `N` — навигация по результатам поиска (следующий/предыдущий)
- `i` — открыть профиль пользователя/чата
- `y` / `н` в режиме выбора — скопировать сообщение в буфер обмена
- `e` / `у` в режиме выбора — добавить реакцию (открывает emoji picker)
- `←` / `→` / `↑` / `↓` в emoji picker — навигация по сетке реакций
- `Enter` в emoji picker — добавить/удалить реакцию
- `Esc` в emoji picker — закрыть picker
- **Редактирование текста в инпуте:** - **Редактирование текста в инпуте:**
- `←` / `→` — перемещение курсора - `←` / `→` — перемещение курсора
- `Home` — курсор в начало - `Home` — курсор в начало
@@ -163,13 +184,6 @@ API_HASH=your_api_hash
## Что НЕ сделано / TODO (Фаза 9) ## Что НЕ сделано / TODO (Фаза 9)
- [ ] Typing indicator ("печатает...")
- [ ] Закреплённые сообщения (Pinned) — отображение вверху чата
- [ ] Поиск по сообщениям в чате (Ctrl+F)
- [ ] Черновики — сохранение текста при переключении чатов
- [ ] Профиль пользователя/чата (хоткей `i`)
- [ ] Копирование сообщений в буфер обмена (`y` в режиме выбора)
- [ ] Реакции — просмотр и добавление
- [ ] Конфигурационный файл (~/.config/tele-tui/config.toml) - [ ] Конфигурационный файл (~/.config/tele-tui/config.toml)
## Известные проблемы ## Известные проблемы

359
Cargo.lock generated
View File

@@ -43,6 +43,26 @@ dependencies = [
"derive_arbitrary", "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]] [[package]]
name = "atomic-waker" name = "atomic-waker"
version = "1.1.2" version = "1.1.2"
@@ -82,12 +102,24 @@ version = "3.19.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
[[package]]
name = "bytemuck"
version = "1.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4"
[[package]] [[package]]
name = "byteorder" name = "byteorder"
version = "1.5.0" version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]] [[package]]
name = "bytes" name = "bytes"
version = "1.11.0" version = "1.11.0"
@@ -130,9 +162,9 @@ dependencies = [
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.53" version = "1.2.54"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "755d2fce177175ffca841e9a06afdb2c4ab0f593d53b4dee48147dfaade85932" checksum = "6354c81bbfd62d9cfa9cb3c773c2b7b2a3a482d569de977fd0e961f6e7c00583"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"jobserver", "jobserver",
@@ -170,6 +202,15 @@ dependencies = [
"inout", "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]] [[package]]
name = "compact_str" name = "compact_str"
version = "0.8.1" version = "0.8.1"
@@ -270,6 +311,12 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]] [[package]]
name = "crypto-common" name = "crypto-common"
version = "0.1.7" version = "0.1.7"
@@ -408,6 +455,16 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "displaydoc" name = "displaydoc"
version = "0.2.5" version = "0.2.5"
@@ -462,12 +519,47 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "fastrand" name = "fastrand"
version = "2.3.0" version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" 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]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.8" version = "0.1.8"
@@ -580,6 +672,16 @@ dependencies = [
"version_check", "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]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.2.17" version = "0.2.17"
@@ -624,6 +726,17 @@ dependencies = [
"tracing", "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]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.12.3" version = "0.12.3"
@@ -919,6 +1032,20 @@ dependencies = [
"icu_properties", "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]] [[package]]
name = "indexmap" name = "indexmap"
version = "1.9.3" version = "1.9.3"
@@ -1156,6 +1283,16 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "native-tls" name = "native-tls"
version = "0.2.14" version = "0.2.14"
@@ -1175,9 +1312,9 @@ dependencies = [
[[package]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.1.0" version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
[[package]] [[package]]
name = "num-traits" name = "num-traits"
@@ -1188,6 +1325,79 @@ dependencies = [
"autocfg", "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]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.3" version = "1.21.3"
@@ -1324,6 +1534,19 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" 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]] [[package]]
name = "potential_utf" name = "potential_utf"
version = "0.1.4" version = "0.1.4"
@@ -1341,18 +1564,33 @@ checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.105" version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]] [[package]]
name = "quote" name = "pxfm"
version = "1.0.43" version = "0.1.27"
source = "registry+https://github.com/rust-lang/crates.io-index" 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 = [ dependencies = [
"proc-macro2", "proc-macro2",
] ]
@@ -1767,9 +2005,9 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]] [[package]]
name = "socket2" name = "socket2"
version = "0.6.1" version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.60.2", "windows-sys 0.60.2",
@@ -1911,6 +2149,7 @@ checksum = "87cbdfae498e57fb48d380fff8eb5c9c98d4497c998f6de0d30d5d6b12f5358b"
name = "tele-tui" name = "tele-tui"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"arboard",
"chrono", "chrono",
"crossterm", "crossterm",
"dotenvy", "dotenvy",
@@ -1937,18 +2176,18 @@ dependencies = [
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.17" version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [ dependencies = [
"thiserror-impl", "thiserror-impl",
] ]
[[package]] [[package]]
name = "thiserror-impl" name = "thiserror-impl"
version = "2.0.17" version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -1956,10 +2195,24 @@ dependencies = [
] ]
[[package]] [[package]]
name = "time" name = "tiff"
version = "0.3.45" version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index" 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 = [ dependencies = [
"deranged", "deranged",
"itoa", "itoa",
@@ -1972,15 +2225,15 @@ dependencies = [
[[package]] [[package]]
name = "time-core" name = "time-core"
version = "0.1.7" version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca" checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
[[package]] [[package]]
name = "time-macros" name = "time-macros"
version = "0.2.25" version = "0.2.26"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71e552d1249bf61ac2a52db88179fd0673def1e1ad8243a00d9ec9ed71fee3dd" checksum = "78cc610bac2dcee56805c99642447d4c5dbde4d01f752ffea0199aee1f601dc4"
dependencies = [ dependencies = [
"num-conv", "num-conv",
"time-core", "time-core",
@@ -2297,6 +2550,12 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "weezl"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]] [[package]]
name = "winapi" name = "winapi"
version = "0.3.9" version = "0.3.9"
@@ -2566,6 +2825,23 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" 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]] [[package]]
name = "xz2" name = "xz2"
version = "0.1.7" version = "0.1.7"
@@ -2598,6 +2874,26 @@ dependencies = [
"synstructure", "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]] [[package]]
name = "zerofrom" name = "zerofrom"
version = "0.1.6" version = "0.1.6"
@@ -2704,9 +3000,9 @@ dependencies = [
[[package]] [[package]]
name = "zmij" name = "zmij"
version = "1.0.15" version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94f63c051f4fe3c1509da62131a678643c5b6fbdc9273b2b79d4378ebda003d2" checksum = "02aae0f83f69aafc94776e879363e9771d7ecbffe2c7fbb6c14c5e00dfe88439"
[[package]] [[package]]
name = "zopfli" name = "zopfli"
@@ -2747,3 +3043,18 @@ dependencies = [
"cc", "cc",
"pkg-config", "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",
]

View File

@@ -13,6 +13,7 @@ serde_json = "1.0"
dotenvy = "0.15" dotenvy = "0.15"
chrono = "0.4" chrono = "0.4"
open = "5.0" open = "5.0"
arboard = "3.4"
[build-dependencies] [build-dependencies]
tdlib-rs = { version = "1.1", features = ["download-tdlib"] } tdlib-rs = { version = "1.1", features = ["download-tdlib"] }

View File

@@ -112,30 +112,30 @@
- Esc для отмены - Esc для отмены
- Отображение "↪ Переслано от" для пересланных сообщений - Отображение "↪ Переслано от" для пересланных сообщений
## Фаза 9: Расширенные возможности [TODO] ## Фаза 9: Расширенные возможности [IN PROGRESS]
- [ ] Typing indicator ("печатает...") - [x] Typing indicator ("печатает...")
- Показывать когда собеседник печатает - Показывать когда собеседник печатает
- Отправлять свой статус печати при наборе текста - Отправлять свой статус печати при наборе текста
- [ ] Закреплённые сообщения (Pinned) - [x] Закреплённые сообщения (Pinned)
- Отображать pinned message вверху открытого чата - Отображать pinned message вверху открытого чата
- Клик/хоткей для перехода к закреплённому сообщению - Клик/хоткей для перехода к закреплённому сообщению
- [ ] Поиск по сообщениям в чате - [x] Поиск по сообщениям в чате
- `Ctrl+F` — поиск текста внутри открытого чата - `Ctrl+F` — поиск текста внутри открытого чата
- Навигация по результатам (n/N или стрелки) - Навигация по результатам (n/N или стрелки)
- Подсветка найденных совпадений - Подсветка найденных совпадений
- [ ] Черновики - [x] Черновики
- Сохранять набранный текст при переключении между чатами - Сохранять набранный текст при переключении между чатами
- Индикатор черновика в списке чатов - Индикатор черновика в списке чатов
- Восстановление текста при возврате в чат - Восстановление текста при возврате в чат
- [ ] Профиль пользователя/чата - [x] Профиль пользователя/чата
- `i` — открыть информацию о чате/собеседнике - `i` — открыть информацию о чате/собеседнике
- Для личных чатов: имя, username, телефон, био - Для личных чатов: имя, username, телефон, био
- Для групп: название, описание, количество участников - Для групп: название, описание, количество участников
- [ ] Копирование сообщений - [x] Копирование сообщений
- `y` / `н` в режиме выбора — скопировать текст в системный буфер обмена - `y` / `н` в режиме выбора — скопировать текст в системный буфер обмена
- Использовать clipboard crate для кроссплатформенности - Использовать clipboard crate для кроссплатформенности
- [ ] Реакции - [x] Реакции
- Отображение реакций под сообщениями - Отображение реакций под сообщениями
- `e` в режиме выбора — добавить реакцию (emoji picker) - `e` в режиме выбора — добавить реакцию (emoji picker)
- Список доступных реакций чата - Список доступных реакций чата

View File

@@ -75,8 +75,18 @@ pub struct App {
pub leave_group_confirmation_step: u8, pub leave_group_confirmation_step: u8,
/// Информация профиля для отображения /// Информация профиля для отображения
pub profile_info: Option<crate::tdlib::ProfileInfo>, pub profile_info: Option<crate::tdlib::ProfileInfo>,
// Reaction picker mode
/// Режим выбора реакции
pub is_reaction_picker_mode: bool,
/// ID сообщения для добавления реакции
pub selected_message_for_reaction: Option<i64>,
/// Список доступных реакций
pub available_reactions: Vec<String>,
/// Индекс выбранной реакции в picker
pub selected_reaction_index: usize,
} }
impl App { impl App {
pub fn new() -> App { pub fn new() -> App {
let mut state = ListState::default(); let mut state = ListState::default();
@@ -119,6 +129,10 @@ impl App {
selected_profile_action: 0, selected_profile_action: 0,
leave_group_confirmation_step: 0, leave_group_confirmation_step: 0,
profile_info: None, 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 { pub fn get_leave_group_confirmation_step(&self) -> u8 {
self.leave_group_confirmation_step 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<String>) {
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<i64> {
self.selected_message_for_reaction
}
} }

View File

@@ -251,6 +251,73 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
return; 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() { if app.is_confirm_delete_shown() {
match key.code { match key.code {
@@ -563,6 +630,58 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
// Начать режим пересылки // Начать режим пересылки
app.start_forward_selected(); 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; return;
@@ -756,3 +875,91 @@ fn get_available_actions_count(profile: &crate::tdlib::ProfileInfo) -> usize {
count 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<char> = 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
}

View File

@@ -137,6 +137,17 @@ pub struct ForwardInfo {
pub date: i32, pub date: i32,
} }
/// Информация о реакции на сообщение
#[derive(Debug, Clone)]
pub struct ReactionInfo {
/// Эмодзи реакции (например, "👍")
pub emoji: String,
/// Количество людей, поставивших эту реакцию
pub count: i32,
/// Поставил ли текущий пользователь эту реакцию
pub is_chosen: bool,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct MessageInfo { pub struct MessageInfo {
pub id: i64, pub id: i64,
@@ -159,6 +170,8 @@ pub struct MessageInfo {
pub reply_to: Option<ReplyInfo>, pub reply_to: Option<ReplyInfo>,
/// Информация о forward (если сообщение переслано) /// Информация о forward (если сообщение переслано)
pub forward_from: Option<ForwardInfo>, pub forward_from: Option<ForwardInfo>,
/// Реакции на сообщение
pub reactions: Vec<ReactionInfo>,
} }
#[derive(Debug, Clone)] #[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 // Извлекаем информацию о forward
let forward_from = self.extract_forward_info(message); let forward_from = self.extract_forward_info(message);
// Извлекаем реакции
let reactions = self.extract_reactions(message);
MessageInfo { MessageInfo {
id: message.id, id: message.id,
sender_name, sender_name,
@@ -803,6 +850,7 @@ impl TdClient {
can_be_deleted_for_all_users: message.can_be_deleted_for_all_users, can_be_deleted_for_all_users: message.can_be_deleted_for_all_users,
reply_to, reply_to,
forward_from, forward_from,
reactions,
} }
} }
@@ -859,6 +907,34 @@ impl TdClient {
}) })
} }
/// Извлекает информацию о реакциях из сообщения
fn extract_reactions(&self, message: &TdMessage) -> Vec<ReactionInfo> {
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 /// Получает имя отправителя из MessageOrigin
fn get_origin_sender_name(&self, origin: &tdlib_rs::enums::MessageOrigin) -> String { fn get_origin_sender_name(&self, origin: &tdlib_rs::enums::MessageOrigin) -> String {
use tdlib_rs::enums::MessageOrigin; use tdlib_rs::enums::MessageOrigin;
@@ -1504,12 +1580,96 @@ impl TdClient {
can_be_deleted_for_all_users: msg.can_be_deleted_for_all_users, can_be_deleted_for_all_users: msg.can_be_deleted_for_all_users,
reply_to: reply_info, reply_to: reply_info,
forward_from: None, forward_from: None,
reactions: Vec::new(),
}) })
} }
Err(e) => Err(format!("Ошибка отправки сообщения: {:?}", e)), Err(e) => Err(format!("Ошибка отправки сообщения: {:?}", e)),
} }
} }
/// Получить доступные реакции для сообщения
pub async fn get_message_available_reactions(
&mut self,
chat_id: i64,
message_id: i64,
) -> Result<Vec<String>, 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<String> = 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 /// Редактирование текстового сообщения с поддержкой Markdown
/// Устанавливает черновик для чата через TDLib API /// Устанавливает черновик для чата через TDLib API
pub async fn set_draft_message(&self, chat_id: i64, text: String) -> Result<(), String> { 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, can_be_deleted_for_all_users: msg.can_be_deleted_for_all_users,
reply_to: None, // При редактировании reply сохраняется из оригинала reply_to: None, // При редактировании reply сохраняется из оригинала
forward_from: None, // При редактировании forward сохраняется из оригинала forward_from: None, // При редактировании forward сохраняется из оригинала
reactions: Vec::new(), // При редактировании реакции сохраняются из оригинала
}) })
} }
Err(e) => Err(format!("Ошибка редактирования сообщения: {:?}", e)), Err(e) => Err(format!("Ошибка редактирования сообщения: {:?}", e)),

View File

@@ -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::<Vec<_>>()
.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() { 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 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) { let hint = match (can_edit, can_delete) {
(true, true) => "↑↓ · Enter ред. · r ответ · f перслть · d удал. · Esc", (true, true) => "↑↓ · Enter ред. · r ответ · f перслть · y копир. · d удал. · Esc",
(true, false) => "↑↓ · Enter ред. · r ответ · f переслть · Esc", (true, false) => "↑↓ · Enter ред. · r ответ · f переслть · y копир. · Esc",
(false, true) => "↑↓ · r ответ · f переслать · d удалить · Esc", (false, true) => "↑↓ · r ответ · f переслать · y копир. · d удалить · Esc",
(false, false) => "↑↓ · r ответить · f переслать · Esc", (false, false) => "↑↓ · r ответить · f переслать · y копировать · Esc",
}; };
(Line::from(Span::styled(hint, Style::default().fg(Color::Cyan))), " Выбор сообщения ") (Line::from(Span::styled(hint, Style::default().fg(Color::Cyan))), " Выбор сообщения ")
} else if app.is_editing() { } 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() { if app.is_confirm_delete_shown() {
render_delete_confirm_modal(f, area); 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); 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);
}