Compare commits
16 Commits
7ca9ea29ea
...
2442a90e23
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2442a90e23 | ||
|
|
48d883a746 | ||
|
|
df19bc742c | ||
|
|
78fe09bf11 | ||
|
|
8bd08318bb | ||
|
|
6639dc876c | ||
|
|
6d08300daa | ||
|
|
8a467b6418 | ||
|
|
7bc264198f | ||
|
|
2a5fd6aa35 | ||
|
|
b0f1f9fdc2 | ||
|
|
6845ee69bf | ||
|
|
ffd52d2384 | ||
|
|
931954d829 | ||
|
|
1d0bfb53e0 | ||
|
|
c5235de6e2 |
26
.woodpecker/check.yml
Normal file
26
.woodpecker/check.yml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
when:
|
||||||
|
- event: pull_request
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: fmt
|
||||||
|
image: rust:1.84
|
||||||
|
commands:
|
||||||
|
- rustup component add rustfmt
|
||||||
|
- cargo fmt -- --check
|
||||||
|
|
||||||
|
- name: clippy
|
||||||
|
image: rust:1.84
|
||||||
|
environment:
|
||||||
|
CARGO_HOME: /tmp/cargo
|
||||||
|
commands:
|
||||||
|
- apt-get update -qq && apt-get install -y -qq pkg-config libssl-dev libdbus-1-dev zlib1g-dev > /dev/null 2>&1
|
||||||
|
- rustup component add clippy
|
||||||
|
- cargo clippy -- -D warnings
|
||||||
|
|
||||||
|
- name: test
|
||||||
|
image: rust:1.84
|
||||||
|
environment:
|
||||||
|
CARGO_HOME: /tmp/cargo
|
||||||
|
commands:
|
||||||
|
- apt-get update -qq && apt-get install -y -qq pkg-config libssl-dev libdbus-1-dev zlib1g-dev > /dev/null 2>&1
|
||||||
|
- cargo test
|
||||||
2320
CONTEXT.md
2320
CONTEXT.md
File diff suppressed because it is too large
Load Diff
650
Cargo.lock
generated
650
Cargo.lock
generated
@@ -28,6 +28,24 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aligned"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685"
|
||||||
|
dependencies = [
|
||||||
|
"as-slice",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aligned-vec"
|
||||||
|
version = "0.6.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b"
|
||||||
|
dependencies = [
|
||||||
|
"equator",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "allocator-api2"
|
name = "allocator-api2"
|
||||||
version = "0.2.21"
|
version = "0.2.21"
|
||||||
@@ -55,6 +73,12 @@ version = "1.0.13"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
|
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anyhow"
|
||||||
|
version = "1.0.101"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "arbitrary"
|
name = "arbitrary"
|
||||||
version = "1.4.2"
|
version = "1.4.2"
|
||||||
@@ -84,6 +108,32 @@ dependencies = [
|
|||||||
"x11rb",
|
"x11rb",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "arg_enum_proc_macro"
|
||||||
|
version = "0.3.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "arrayvec"
|
||||||
|
version = "0.7.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "as-slice"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516"
|
||||||
|
dependencies = [
|
||||||
|
"stable_deref_trait",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-broadcast"
|
name = "async-broadcast"
|
||||||
version = "0.7.2"
|
version = "0.7.2"
|
||||||
@@ -227,18 +277,86 @@ 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 = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "av-scenechange"
|
||||||
|
version = "0.14.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394"
|
||||||
|
dependencies = [
|
||||||
|
"aligned",
|
||||||
|
"anyhow",
|
||||||
|
"arg_enum_proc_macro",
|
||||||
|
"arrayvec",
|
||||||
|
"log",
|
||||||
|
"num-rational",
|
||||||
|
"num-traits",
|
||||||
|
"pastey",
|
||||||
|
"rayon",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"v_frame",
|
||||||
|
"y4m",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "av1-grain"
|
||||||
|
version = "0.2.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"arrayvec",
|
||||||
|
"log",
|
||||||
|
"nom",
|
||||||
|
"num-rational",
|
||||||
|
"v_frame",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "avif-serialize"
|
||||||
|
version = "0.8.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "47c8fbc0f831f4519fe8b810b6a7a91410ec83031b8233f730a0480029f6a23f"
|
||||||
|
dependencies = [
|
||||||
|
"arrayvec",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.22.1"
|
version = "0.22.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64-simd"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195"
|
||||||
|
dependencies = [
|
||||||
|
"outref",
|
||||||
|
"vsimd",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bit_field"
|
||||||
|
version = "0.10.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.10.0"
|
version = "2.10.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
|
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitstream-io"
|
||||||
|
version = "4.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "60d4bd9d1db2c6bdf285e223a7fa369d5ce98ec767dec949c6ca62863ce61757"
|
||||||
|
dependencies = [
|
||||||
|
"core2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
version = "0.10.4"
|
version = "0.10.4"
|
||||||
@@ -270,6 +388,12 @@ dependencies = [
|
|||||||
"piper",
|
"piper",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "built"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bumpalo"
|
name = "bumpalo"
|
||||||
version = "3.19.1"
|
version = "3.19.1"
|
||||||
@@ -443,6 +567,12 @@ dependencies = [
|
|||||||
"error-code",
|
"error-code",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "color_quant"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "compact_str"
|
name = "compact_str"
|
||||||
version = "0.8.1"
|
version = "0.8.1"
|
||||||
@@ -500,6 +630,15 @@ version = "0.8.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "core2"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cpufeatures"
|
name = "cpufeatures"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -865,6 +1004,26 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "equator"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc"
|
||||||
|
dependencies = [
|
||||||
|
"equator-macro",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "equator-macro"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
@@ -908,6 +1067,21 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "exr"
|
||||||
|
version = "1.74.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be"
|
||||||
|
dependencies = [
|
||||||
|
"bit_field",
|
||||||
|
"half",
|
||||||
|
"lebe",
|
||||||
|
"miniz_oxide",
|
||||||
|
"rayon-core",
|
||||||
|
"smallvec",
|
||||||
|
"zune-inflate",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastrand"
|
name = "fastrand"
|
||||||
version = "2.3.0"
|
version = "2.3.0"
|
||||||
@@ -1103,6 +1277,16 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "gif"
|
||||||
|
version = "0.14.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e"
|
||||||
|
dependencies = [
|
||||||
|
"color_quant",
|
||||||
|
"weezl",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "h2"
|
name = "h2"
|
||||||
version = "0.4.13"
|
version = "0.4.13"
|
||||||
@@ -1407,6 +1591,12 @@ dependencies = [
|
|||||||
"zerovec",
|
"zerovec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "icy_sixel"
|
||||||
|
version = "0.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ccc0a9c4770bc47b0a933256a496cfb8b6531f753ea9bccb19c6dff0ff7273fc"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ident_case"
|
name = "ident_case"
|
||||||
version = "1.0.1"
|
version = "1.0.1"
|
||||||
@@ -1442,12 +1632,38 @@ checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
"byteorder-lite",
|
"byteorder-lite",
|
||||||
|
"color_quant",
|
||||||
|
"exr",
|
||||||
|
"gif",
|
||||||
|
"image-webp",
|
||||||
"moxcms",
|
"moxcms",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"png",
|
"png",
|
||||||
|
"qoi",
|
||||||
|
"ravif",
|
||||||
|
"rayon",
|
||||||
|
"rgb",
|
||||||
"tiff",
|
"tiff",
|
||||||
|
"zune-core 0.5.1",
|
||||||
|
"zune-jpeg 0.5.12",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "image-webp"
|
||||||
|
version = "0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
|
||||||
|
dependencies = [
|
||||||
|
"byteorder-lite",
|
||||||
|
"quick-error",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "imgref"
|
||||||
|
version = "1.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
version = "1.9.3"
|
version = "1.9.3"
|
||||||
@@ -1514,6 +1730,17 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "interpolate_name"
|
||||||
|
version = "0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ipnet"
|
name = "ipnet"
|
||||||
version = "2.11.0"
|
version = "2.11.0"
|
||||||
@@ -1578,6 +1805,15 @@ dependencies = [
|
|||||||
"either",
|
"either",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itertools"
|
||||||
|
version = "0.14.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
|
||||||
|
dependencies = [
|
||||||
|
"either",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.17"
|
version = "1.0.17"
|
||||||
@@ -1610,12 +1846,28 @@ 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 = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lebe"
|
||||||
|
version = "0.5.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.180"
|
version = "0.2.180"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
|
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libfuzzer-sys"
|
||||||
|
version = "0.4.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5037190e1f70cbeef565bd267599242926f724d3b8a9f510fd7e0b540cfa4404"
|
||||||
|
dependencies = [
|
||||||
|
"arbitrary",
|
||||||
|
"cc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libredox"
|
name = "libredox"
|
||||||
version = "0.1.12"
|
version = "0.1.12"
|
||||||
@@ -1659,6 +1911,15 @@ version = "0.4.29"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "loop9"
|
||||||
|
version = "0.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062"
|
||||||
|
dependencies = [
|
||||||
|
"imgref",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lru"
|
name = "lru"
|
||||||
version = "0.12.5"
|
version = "0.12.5"
|
||||||
@@ -1710,6 +1971,16 @@ dependencies = [
|
|||||||
"regex-automata",
|
"regex-automata",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "maybe-rayon"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"rayon",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memchr"
|
name = "memchr"
|
||||||
version = "2.7.6"
|
version = "2.7.6"
|
||||||
@@ -1780,6 +2051,27 @@ dependencies = [
|
|||||||
"tempfile",
|
"tempfile",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "new_debug_unreachable"
|
||||||
|
version = "1.0.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nom"
|
||||||
|
version = "8.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
|
||||||
|
dependencies = [
|
||||||
|
"memchr",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "noop_proc_macro"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "notify-rust"
|
name = "notify-rust"
|
||||||
version = "4.12.0"
|
version = "4.12.0"
|
||||||
@@ -1803,12 +2095,53 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-bigint"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
|
||||||
|
dependencies = [
|
||||||
|
"num-integer",
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-conv"
|
name = "num-conv"
|
||||||
version = "0.2.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 = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
|
checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-derive"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-integer"
|
||||||
|
version = "0.1.46"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "num-rational"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
|
||||||
|
dependencies = [
|
||||||
|
"num-bigint",
|
||||||
|
"num-integer",
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-traits"
|
name = "num-traits"
|
||||||
version = "0.2.19"
|
version = "0.2.19"
|
||||||
@@ -1976,6 +2309,12 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "outref"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "parking"
|
name = "parking"
|
||||||
version = "2.2.1"
|
version = "2.2.1"
|
||||||
@@ -2011,6 +2350,12 @@ version = "1.0.15"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pastey"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pathdiff"
|
name = "pathdiff"
|
||||||
version = "0.2.3"
|
version = "0.2.3"
|
||||||
@@ -2132,6 +2477,15 @@ 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 = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ppv-lite86"
|
||||||
|
version = "0.2.21"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||||
|
dependencies = [
|
||||||
|
"zerocopy",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro-crate"
|
name = "proc-macro-crate"
|
||||||
version = "3.4.0"
|
version = "3.4.0"
|
||||||
@@ -2150,6 +2504,25 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "profiling"
|
||||||
|
version = "1.0.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773"
|
||||||
|
dependencies = [
|
||||||
|
"profiling-procmacros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "profiling-procmacros"
|
||||||
|
version = "1.0.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b"
|
||||||
|
dependencies = [
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pxfm"
|
name = "pxfm"
|
||||||
version = "0.1.27"
|
version = "0.1.27"
|
||||||
@@ -2159,6 +2532,15 @@ dependencies = [
|
|||||||
"num-traits",
|
"num-traits",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "qoi"
|
||||||
|
version = "0.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001"
|
||||||
|
dependencies = [
|
||||||
|
"bytemuck",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quick-error"
|
name = "quick-error"
|
||||||
version = "2.0.1"
|
version = "2.0.1"
|
||||||
@@ -2189,6 +2571,65 @@ version = "5.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand"
|
||||||
|
version = "0.8.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"rand_chacha 0.3.1",
|
||||||
|
"rand_core 0.6.4",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand"
|
||||||
|
version = "0.9.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||||
|
dependencies = [
|
||||||
|
"rand_chacha 0.9.0",
|
||||||
|
"rand_core 0.9.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_chacha"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||||
|
dependencies = [
|
||||||
|
"ppv-lite86",
|
||||||
|
"rand_core 0.6.4",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_chacha"
|
||||||
|
version = "0.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||||
|
dependencies = [
|
||||||
|
"ppv-lite86",
|
||||||
|
"rand_core 0.9.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_core"
|
||||||
|
version = "0.6.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.2.17",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rand_core"
|
||||||
|
version = "0.9.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom 0.3.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ratatui"
|
name = "ratatui"
|
||||||
version = "0.29.0"
|
version = "0.29.0"
|
||||||
@@ -2210,6 +2651,72 @@ dependencies = [
|
|||||||
"unicode-width 0.2.0",
|
"unicode-width 0.2.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ratatui-image"
|
||||||
|
version = "8.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3ecc67e9f7d0ac69e0f712f58b1a9d5a04d8daeeb3628f4d6b67580abb88b7cb"
|
||||||
|
dependencies = [
|
||||||
|
"base64-simd",
|
||||||
|
"icy_sixel",
|
||||||
|
"image",
|
||||||
|
"rand 0.8.5",
|
||||||
|
"ratatui",
|
||||||
|
"rustix 0.38.44",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
"windows 0.58.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rav1e"
|
||||||
|
version = "0.8.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b"
|
||||||
|
dependencies = [
|
||||||
|
"aligned-vec",
|
||||||
|
"arbitrary",
|
||||||
|
"arg_enum_proc_macro",
|
||||||
|
"arrayvec",
|
||||||
|
"av-scenechange",
|
||||||
|
"av1-grain",
|
||||||
|
"bitstream-io",
|
||||||
|
"built",
|
||||||
|
"cfg-if",
|
||||||
|
"interpolate_name",
|
||||||
|
"itertools 0.14.0",
|
||||||
|
"libc",
|
||||||
|
"libfuzzer-sys",
|
||||||
|
"log",
|
||||||
|
"maybe-rayon",
|
||||||
|
"new_debug_unreachable",
|
||||||
|
"noop_proc_macro",
|
||||||
|
"num-derive",
|
||||||
|
"num-traits",
|
||||||
|
"paste",
|
||||||
|
"profiling",
|
||||||
|
"rand 0.9.2",
|
||||||
|
"rand_chacha 0.9.0",
|
||||||
|
"simd_helpers",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"v_frame",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ravif"
|
||||||
|
version = "0.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ef69c1990ceef18a116855938e74793a5f7496ee907562bd0857b6ac734ab285"
|
||||||
|
dependencies = [
|
||||||
|
"avif-serialize",
|
||||||
|
"imgref",
|
||||||
|
"loop9",
|
||||||
|
"quick-error",
|
||||||
|
"rav1e",
|
||||||
|
"rayon",
|
||||||
|
"rgb",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rayon"
|
name = "rayon"
|
||||||
version = "1.11.0"
|
version = "1.11.0"
|
||||||
@@ -2352,6 +2859,12 @@ dependencies = [
|
|||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rgb"
|
||||||
|
version = "0.8.52"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ring"
|
name = "ring"
|
||||||
version = "0.17.14"
|
version = "0.17.14"
|
||||||
@@ -2677,6 +3190,15 @@ version = "0.3.8"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
|
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "simd_helpers"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6"
|
||||||
|
dependencies = [
|
||||||
|
"quote",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "similar"
|
name = "similar"
|
||||||
version = "2.7.0"
|
version = "2.7.0"
|
||||||
@@ -2811,7 +3333,7 @@ checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"quick-xml",
|
"quick-xml",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"windows",
|
"windows 0.61.3",
|
||||||
"windows-version",
|
"windows-version",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2855,15 +3377,18 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"arboard",
|
"arboard",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
"base64",
|
||||||
"chrono",
|
"chrono",
|
||||||
"criterion",
|
"criterion",
|
||||||
"crossterm",
|
"crossterm",
|
||||||
"dirs 5.0.1",
|
"dirs 5.0.1",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
|
"image",
|
||||||
"insta",
|
"insta",
|
||||||
"notify-rust",
|
"notify-rust",
|
||||||
"open",
|
"open",
|
||||||
"ratatui",
|
"ratatui",
|
||||||
|
"ratatui-image",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tdlib-rs",
|
"tdlib-rs",
|
||||||
@@ -2948,7 +3473,7 @@ dependencies = [
|
|||||||
"half",
|
"half",
|
||||||
"quick-error",
|
"quick-error",
|
||||||
"weezl",
|
"weezl",
|
||||||
"zune-jpeg",
|
"zune-jpeg 0.4.21",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3355,6 +3880,17 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "v_frame"
|
||||||
|
version = "0.3.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2"
|
||||||
|
dependencies = [
|
||||||
|
"aligned-vec",
|
||||||
|
"num-traits",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "valuable"
|
name = "valuable"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
@@ -3373,6 +3909,12 @@ version = "0.9.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "vsimd"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "walkdir"
|
name = "walkdir"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
@@ -3513,6 +4055,16 @@ version = "0.4.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows"
|
||||||
|
version = "0.58.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6"
|
||||||
|
dependencies = [
|
||||||
|
"windows-core 0.58.0",
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows"
|
name = "windows"
|
||||||
version = "0.61.3"
|
version = "0.61.3"
|
||||||
@@ -3535,14 +4087,27 @@ dependencies = [
|
|||||||
"windows-core 0.61.2",
|
"windows-core 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-core"
|
||||||
|
version = "0.58.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99"
|
||||||
|
dependencies = [
|
||||||
|
"windows-implement 0.58.0",
|
||||||
|
"windows-interface 0.58.0",
|
||||||
|
"windows-result 0.2.0",
|
||||||
|
"windows-strings 0.1.0",
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-core"
|
name = "windows-core"
|
||||||
version = "0.61.2"
|
version = "0.61.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
|
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-implement",
|
"windows-implement 0.60.2",
|
||||||
"windows-interface",
|
"windows-interface 0.59.3",
|
||||||
"windows-link 0.1.3",
|
"windows-link 0.1.3",
|
||||||
"windows-result 0.3.4",
|
"windows-result 0.3.4",
|
||||||
"windows-strings 0.4.2",
|
"windows-strings 0.4.2",
|
||||||
@@ -3554,8 +4119,8 @@ version = "0.62.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-implement",
|
"windows-implement 0.60.2",
|
||||||
"windows-interface",
|
"windows-interface 0.59.3",
|
||||||
"windows-link 0.2.1",
|
"windows-link 0.2.1",
|
||||||
"windows-result 0.4.1",
|
"windows-result 0.4.1",
|
||||||
"windows-strings 0.5.1",
|
"windows-strings 0.5.1",
|
||||||
@@ -3572,6 +4137,17 @@ dependencies = [
|
|||||||
"windows-threading",
|
"windows-threading",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-implement"
|
||||||
|
version = "0.58.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-implement"
|
name = "windows-implement"
|
||||||
version = "0.60.2"
|
version = "0.60.2"
|
||||||
@@ -3583,6 +4159,17 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-interface"
|
||||||
|
version = "0.58.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-interface"
|
name = "windows-interface"
|
||||||
version = "0.59.3"
|
version = "0.59.3"
|
||||||
@@ -3627,6 +4214,15 @@ dependencies = [
|
|||||||
"windows-strings 0.5.1",
|
"windows-strings 0.5.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-result"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-result"
|
name = "windows-result"
|
||||||
version = "0.3.4"
|
version = "0.3.4"
|
||||||
@@ -3645,6 +4241,16 @@ dependencies = [
|
|||||||
"windows-link 0.2.1",
|
"windows-link 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-strings"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
|
||||||
|
dependencies = [
|
||||||
|
"windows-result 0.2.0",
|
||||||
|
"windows-targets 0.52.6",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-strings"
|
name = "windows-strings"
|
||||||
version = "0.4.2"
|
version = "0.4.2"
|
||||||
@@ -3959,6 +4565,12 @@ dependencies = [
|
|||||||
"lzma-sys",
|
"lzma-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "y4m"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yoke"
|
name = "yoke"
|
||||||
version = "0.8.1"
|
version = "0.8.1"
|
||||||
@@ -4219,13 +4831,37 @@ version = "0.4.12"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a"
|
checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zune-core"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zune-inflate"
|
||||||
|
version = "0.2.54"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02"
|
||||||
|
dependencies = [
|
||||||
|
"simd-adler32",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zune-jpeg"
|
name = "zune-jpeg"
|
||||||
version = "0.4.21"
|
version = "0.4.21"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713"
|
checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"zune-core",
|
"zune-core 0.4.12",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "zune-jpeg"
|
||||||
|
version = "0.5.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "410e9ecef634c709e3831c2cfdb8d9c32164fae1c67496d5b68fff728eec37fe"
|
||||||
|
dependencies = [
|
||||||
|
"zune-core 0.5.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -10,10 +10,11 @@ keywords = ["telegram", "tui", "terminal", "cli"]
|
|||||||
categories = ["command-line-utilities"]
|
categories = ["command-line-utilities"]
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["clipboard", "url-open", "notifications"]
|
default = ["clipboard", "url-open", "notifications", "images"]
|
||||||
clipboard = ["dep:arboard"]
|
clipboard = ["dep:arboard"]
|
||||||
url-open = ["dep:open"]
|
url-open = ["dep:open"]
|
||||||
notifications = ["dep:notify-rust"]
|
notifications = ["dep:notify-rust"]
|
||||||
|
images = ["dep:ratatui-image", "dep:image"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
ratatui = "0.29"
|
ratatui = "0.29"
|
||||||
@@ -28,11 +29,14 @@ chrono = "0.4"
|
|||||||
open = { version = "5.0", optional = true }
|
open = { version = "5.0", optional = true }
|
||||||
arboard = { version = "3.4", optional = true }
|
arboard = { version = "3.4", optional = true }
|
||||||
notify-rust = { version = "4.11", optional = true }
|
notify-rust = { version = "4.11", optional = true }
|
||||||
|
ratatui-image = { version = "8.1", optional = true, features = ["image-defaults"] }
|
||||||
|
image = { version = "0.25", optional = true }
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
dirs = "5.0"
|
dirs = "5.0"
|
||||||
thiserror = "1.0"
|
thiserror = "1.0"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
base64 = "0.22.1"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
insta = "1.34"
|
insta = "1.34"
|
||||||
|
|||||||
@@ -1,453 +1,328 @@
|
|||||||
# Структура проекта
|
# Структура проекта
|
||||||
|
|
||||||
|
## Архитектура (ASCII)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐
|
||||||
|
│ main.rs │ Event loop (60 FPS)
|
||||||
|
└──────┬──────┘
|
||||||
|
│
|
||||||
|
┌────────────┼────────────┐
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||||
|
│ input/ │ │ app/ │ │ ui/ │
|
||||||
|
│ handlers │ │ state │ │ render │
|
||||||
|
└────┬─────┘ └────┬─────┘ └────┬─────┘
|
||||||
|
│ │ │
|
||||||
|
│ ┌──────┴──────┐ │
|
||||||
|
│ │ methods/ │ │
|
||||||
|
│ │ (5 traits) │ │
|
||||||
|
│ └──────┬──────┘ │
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌─────────────────────────────────┐
|
||||||
|
│ tdlib/ │
|
||||||
|
│ TdClientTrait → TdClient │
|
||||||
|
│ messages/ | auth | chats │
|
||||||
|
└──────────────┬──────────────────┘
|
||||||
|
│
|
||||||
|
┌─────▼─────┐
|
||||||
|
│ TDLib C │
|
||||||
|
│ library │
|
||||||
|
└───────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
```
|
||||||
|
TDLib Updates → mpsc channel → App state → UI rendering
|
||||||
|
User Input → handlers → App methods (traits) → TdClient → TDLib API
|
||||||
|
```
|
||||||
|
|
||||||
## Обзор директорий
|
## Обзор директорий
|
||||||
|
|
||||||
```
|
```
|
||||||
tele-tui/
|
tele-tui/
|
||||||
|
├── src/
|
||||||
|
│ ├── main.rs # Точка входа, event loop
|
||||||
|
│ ├── lib.rs # Экспорт модулей для тестов
|
||||||
|
│ ├── types.rs # ChatId, MessageId (newtype wrappers)
|
||||||
|
│ ├── constants.rs # MAX_MESSAGES_IN_CHAT, etc.
|
||||||
|
│ ├── formatting.rs # Markdown entity форматирование
|
||||||
|
│ ├── message_grouping.rs # Группировка сообщений по дате/отправителю
|
||||||
|
│ ├── notifications.rs # Desktop уведомления (NotificationManager)
|
||||||
|
│ │
|
||||||
|
│ ├── app/ # Состояние приложения
|
||||||
|
│ │ ├── mod.rs # App<T> struct, конструкторы, getters (372 loc)
|
||||||
|
│ │ ├── state.rs # AppScreen enum
|
||||||
|
│ │ ├── chat_state.rs # ChatState enum (state machine)
|
||||||
|
│ │ ├── chat_filter.rs # ChatFilter, ChatFilterCriteria
|
||||||
|
│ │ ├── chat_list_state.rs # Состояние списка чатов
|
||||||
|
│ │ ├── auth_state.rs # Состояние авторизации
|
||||||
|
│ │ ├── compose_state.rs # Состояние compose bar
|
||||||
|
│ │ ├── ui_state.rs # UI-related state
|
||||||
|
│ │ ├── message_service.rs # Сервис сообщений
|
||||||
|
│ │ ├── message_view_state.rs # Состояние просмотра сообщений
|
||||||
|
│ │ └── methods/ # Trait-based методы App (Этап 2)
|
||||||
|
│ │ ├── mod.rs # Re-exports 5 trait модулей
|
||||||
|
│ │ ├── navigation.rs # NavigationMethods (7 методов)
|
||||||
|
│ │ ├── messages.rs # MessageMethods (8 методов)
|
||||||
|
│ │ ├── compose.rs # ComposeMethods (10 методов)
|
||||||
|
│ │ ├── search.rs # SearchMethods (15 методов)
|
||||||
|
│ │ └── modal.rs # ModalMethods (27 методов)
|
||||||
|
│ │
|
||||||
|
│ ├── config/ # Конфигурация (Этап 5)
|
||||||
|
│ │ ├── mod.rs # Config struct, defaults (350 loc)
|
||||||
|
│ │ ├── keybindings.rs # Command enum, Keybindings
|
||||||
|
│ │ ├── validation.rs # validate(), parse_color()
|
||||||
|
│ │ └── loader.rs # load(), save(), credentials
|
||||||
|
│ │
|
||||||
|
│ ├── input/ # Обработка пользовательского ввода
|
||||||
|
│ │ ├── mod.rs # Роутинг по экранам
|
||||||
|
│ │ ├── auth.rs # Ввод на экране авторизации
|
||||||
|
│ │ ├── main_input.rs # Роутер главного экрана (159 loc, Этап 1)
|
||||||
|
│ │ ├── key_handler.rs # Trait-based обработка клавиш
|
||||||
|
│ │ └── handlers/ # Специализированные обработчики (Этап 1)
|
||||||
|
│ │ ├── mod.rs # Exports + scroll_to_message()
|
||||||
|
│ │ ├── global.rs # Ctrl+R/S/P/F глобальные команды
|
||||||
|
│ │ ├── chat.rs # Открытый чат: ввод, скролл, selection
|
||||||
|
│ │ ├── chat_list.rs # Навигация по списку чатов, папки
|
||||||
|
│ │ ├── compose.rs # Forward mode
|
||||||
|
│ │ ├── modal.rs # Profile, reactions, pinned, delete
|
||||||
|
│ │ ├── search.rs # Поиск чатов и сообщений
|
||||||
|
│ │ ├── clipboard.rs # Копирование в буфер обмена
|
||||||
|
│ │ └── profile.rs # Хелперы профиля
|
||||||
|
│ │
|
||||||
|
│ ├── tdlib/ # TDLib интеграция
|
||||||
|
│ │ ├── mod.rs # Экспорт публичных типов
|
||||||
|
│ │ ├── types.rs # MessageInfo, ChatInfo, ProfileInfo, etc.
|
||||||
|
│ │ ├── trait.rs # TdClientTrait (DI для тестов)
|
||||||
|
│ │ ├── client.rs # TdClient struct, конструктор
|
||||||
|
│ │ ├── client_impl.rs # impl TdClientTrait for TdClient
|
||||||
|
│ │ ├── auth.rs # Авторизация (phone, code, 2FA)
|
||||||
|
│ │ ├── chats.rs # Загрузка чатов, папок
|
||||||
|
│ │ ├── users.rs # Кеш пользователей, статусы
|
||||||
|
│ │ ├── reactions.rs # ReactionInfo, toggle_reaction
|
||||||
|
│ │ ├── chat_helpers.rs # Вспомогательные функции чатов
|
||||||
|
│ │ ├── update_handlers.rs # Обработка TDLib update events
|
||||||
|
│ │ ├── message_converter.rs # Конвертация TDLib → MessageInfo
|
||||||
|
│ │ ├── message_conversion.rs # Доп. функции конвертации
|
||||||
|
│ │ └── messages/ # Менеджер сообщений (Этап 4)
|
||||||
|
│ │ ├── mod.rs # MessageManager struct (99 loc)
|
||||||
|
│ │ ├── convert.rs # convert_message, fetch_reply_info
|
||||||
|
│ │ └── operations.rs # 11 TDLib API операций (616 loc)
|
||||||
|
│ │
|
||||||
|
│ ├── ui/ # Рендеринг интерфейса
|
||||||
|
│ │ ├── mod.rs # render() — роутинг по экранам
|
||||||
|
│ │ ├── loading.rs # Экран загрузки
|
||||||
|
│ │ ├── auth.rs # Экран авторизации
|
||||||
|
│ │ ├── main_screen.rs # Главный экран + папки
|
||||||
|
│ │ ├── footer.rs # Футер с командами и статусом сети
|
||||||
|
│ │ ├── chat_list.rs # Список чатов + онлайн-статус
|
||||||
|
│ │ ├── messages.rs # Область сообщений (364 loc, Этап 3)
|
||||||
|
│ │ ├── compose_bar.rs # Multi-mode input box (Этап 3)
|
||||||
|
│ │ ├── profile.rs # Профиль пользователя/чата
|
||||||
|
│ │ ├── modals/ # Модальные окна (Этап 3)
|
||||||
|
│ │ │ ├── mod.rs # Re-exports
|
||||||
|
│ │ │ ├── delete_confirm.rs # Подтверждение удаления
|
||||||
|
│ │ │ ├── reaction_picker.rs # Выбор реакции
|
||||||
|
│ │ │ ├── search.rs # Поиск по сообщениям
|
||||||
|
│ │ │ └── pinned.rs # Закреплённые сообщения
|
||||||
|
│ │ └── components/ # Переиспользуемые UI компоненты (Этап 6)
|
||||||
|
│ │ ├── mod.rs # Re-exports
|
||||||
|
│ │ ├── modal.rs # render_modal(), render_delete_confirm
|
||||||
|
│ │ ├── input_field.rs # render_input_field()
|
||||||
|
│ │ ├── message_bubble.rs # render_message_bubble(), sender, date
|
||||||
|
│ │ ├── message_list.rs # render_message_item(), help_bar, scroll
|
||||||
|
│ │ ├── chat_list_item.rs # render_chat_list_item()
|
||||||
|
│ │ └── emoji_picker.rs # render_emoji_picker()
|
||||||
|
│ │
|
||||||
|
│ └── utils/ # Утилиты
|
||||||
|
│ ├── mod.rs # Exports, with_timeout helpers
|
||||||
|
│ ├── formatting.rs # format_timestamp, format_date, etc.
|
||||||
|
│ ├── tdlib.rs # disable_tdlib_logs (FFI)
|
||||||
|
│ ├── validation.rs # is_non_empty и др.
|
||||||
|
│ ├── modal_handler.rs # handle_yes_no для Y/N модалок
|
||||||
|
│ └── retry.rs # Retry утилиты
|
||||||
|
│
|
||||||
|
├── tests/ # Интеграционные тесты
|
||||||
|
│ ├── helpers/ # Тестовая инфраструктура
|
||||||
|
│ │ ├── mod.rs
|
||||||
|
│ │ ├── app_builder.rs # TestAppBuilder (fluent API)
|
||||||
|
│ │ ├── fake_tdclient.rs # FakeTdClient (мок TDLib)
|
||||||
|
│ │ ├── fake_tdclient_impl.rs # impl TdClientTrait for FakeTdClient
|
||||||
|
│ │ ├── test_data.rs # create_test_chat, TestMessageBuilder
|
||||||
|
│ │ └── snapshot_utils.rs # Snapshot testing хелперы
|
||||||
|
│ ├── input_navigation.rs # Тесты навигации клавиатурой
|
||||||
|
│ ├── chat_list.rs # Тесты списка чатов
|
||||||
|
│ ├── messages.rs # Тесты сообщений
|
||||||
|
│ ├── send_message.rs # Тесты отправки
|
||||||
|
│ ├── edit_message.rs # Тесты редактирования
|
||||||
|
│ ├── delete_message.rs # Тесты удаления
|
||||||
|
│ ├── reply_forward.rs # Тесты reply/forward
|
||||||
|
│ ├── reactions.rs # Тесты реакций
|
||||||
|
│ ├── search.rs # Тесты поиска
|
||||||
|
│ ├── modals.rs # Тесты модальных окон
|
||||||
|
│ ├── profile.rs # Тесты профиля
|
||||||
|
│ ├── navigation.rs # Тесты навигации
|
||||||
|
│ ├── drafts.rs # Тесты черновиков
|
||||||
|
│ ├── copy.rs # Тесты копирования
|
||||||
|
│ ├── screens.rs # Тесты экранов
|
||||||
|
│ ├── footer.rs # Тесты футера
|
||||||
|
│ ├── input_field.rs # Тесты поля ввода
|
||||||
|
│ ├── config.rs # Тесты конфигурации
|
||||||
|
│ ├── network_typing.rs # Тесты typing status
|
||||||
|
│ ├── e2e_smoke.rs # Smoke тесты
|
||||||
|
│ └── e2e_user_journey.rs # E2E user journey тесты
|
||||||
|
│
|
||||||
├── .github/ # GitHub конфигурация
|
├── .github/ # GitHub конфигурация
|
||||||
│ ├── ISSUE_TEMPLATE/ # Шаблоны для issue
|
│ ├── ISSUE_TEMPLATE/
|
||||||
│ │ ├── bug_report.md
|
│ ├── workflows/ci.yml
|
||||||
│ │ └── feature_request.md
|
|
||||||
│ ├── workflows/ # GitHub Actions CI/CD
|
|
||||||
│ │ └── ci.yml
|
|
||||||
│ └── pull_request_template.md
|
│ └── pull_request_template.md
|
||||||
│
|
│
|
||||||
├── docs/ # Дополнительная документация
|
|
||||||
│ └── TDLIB_INTEGRATION.md
|
|
||||||
│
|
|
||||||
├── src/ # Исходный код
|
|
||||||
│ ├── app/ # Состояние приложения
|
|
||||||
│ │ ├── mod.rs
|
|
||||||
│ │ └── state.rs
|
|
||||||
│ ├── input/ # Обработка пользовательского ввода
|
|
||||||
│ │ ├── mod.rs
|
|
||||||
│ │ ├── auth.rs
|
|
||||||
│ │ └── main_input.rs
|
|
||||||
│ ├── audio/ # Прослушивание голосовых (PLANNED)
|
|
||||||
│ │ ├── mod.rs # Экспорт публичных типов
|
|
||||||
│ │ ├── player.rs # AudioPlayer на rodio
|
|
||||||
│ │ ├── cache.rs # VoiceCache для OGG файлов
|
|
||||||
│ │ └── state.rs # PlaybackState
|
|
||||||
│ ├── media/ # Работа с изображениями (PLANNED)
|
|
||||||
│ │ ├── mod.rs # Экспорт публичных типов
|
|
||||||
│ │ ├── image_cache.rs # LRU кэш для загруженных изображений
|
|
||||||
│ │ ├── image_loader.rs # Асинхронная загрузка через TDLib
|
|
||||||
│ │ └── image_renderer.rs # Рендеринг изображений в ratatui
|
|
||||||
│ ├── notifications.rs # Desktop уведомления
|
|
||||||
│ ├── tdlib/ # TDLib интеграция
|
|
||||||
│ │ ├── mod.rs
|
|
||||||
│ │ └── client.rs
|
|
||||||
│ ├── ui/ # Рендеринг интерфейса
|
|
||||||
│ │ ├── mod.rs
|
|
||||||
│ │ ├── auth.rs
|
|
||||||
│ │ ├── chat_list.rs
|
|
||||||
│ │ ├── footer.rs
|
|
||||||
│ │ ├── loading.rs
|
|
||||||
│ │ ├── main_screen.rs
|
|
||||||
│ │ └── messages.rs
|
|
||||||
│ ├── config.rs # Конфигурация приложения
|
|
||||||
│ ├── main.rs # Точка входа
|
|
||||||
│ └── utils.rs # Утилиты
|
|
||||||
│
|
|
||||||
├── tdlib_data/ # TDLib сессия (НЕ коммитится)
|
|
||||||
├── target/ # Артефакты сборки (НЕ коммитится)
|
|
||||||
│
|
|
||||||
├── .editorconfig # EditorConfig для IDE
|
|
||||||
├── .gitignore # Git ignore правила
|
|
||||||
├── Cargo.lock # Зависимости (точные версии)
|
|
||||||
├── Cargo.toml # Манифест проекта
|
├── Cargo.toml # Манифест проекта
|
||||||
├── rustfmt.toml # Конфигурация форматирования
|
├── Cargo.lock # Точные версии зависимостей
|
||||||
|
├── build.rs # Build script (TDLib)
|
||||||
|
├── rustfmt.toml # cargo fmt конфигурация
|
||||||
|
├── .editorconfig # Настройки IDE
|
||||||
|
├── .gitignore # Git ignore
|
||||||
│
|
│
|
||||||
├── config.toml.example # Пример конфигурации
|
├── config.toml.example # Пример конфигурации
|
||||||
├── credentials.example # Пример credentials
|
├── credentials.example # Пример credentials
|
||||||
│
|
│
|
||||||
├── CHANGELOG.md # История изменений
|
├── CLAUDE.md # Инструкции для AI
|
||||||
├── CLAUDE.md # Инструкции для Claude AI
|
├── CONTEXT.md # Текущий статус
|
||||||
├── CONTRIBUTING.md # Гайд по контрибуции
|
|
||||||
├── CONTEXT.md # Текущий статус разработки
|
|
||||||
├── DEVELOPMENT.md # Правила разработки
|
|
||||||
├── FAQ.md # Часто задаваемые вопросы
|
|
||||||
├── HOTKEYS.md # Список горячих клавиш
|
|
||||||
├── INSTALL.md # Инструкция по установке
|
|
||||||
├── LICENSE # MIT лицензия
|
|
||||||
├── PROJECT_STRUCTURE.md # Этот файл
|
|
||||||
├── README.md # Главная документация
|
|
||||||
├── REQUIREMENTS.md # Функциональные требования
|
|
||||||
├── ROADMAP.md # План развития
|
├── ROADMAP.md # План развития
|
||||||
└── SECURITY.md # Политика безопасности
|
├── DEVELOPMENT.md # Правила разработки
|
||||||
|
├── REQUIREMENTS.md # Требования
|
||||||
|
├── ARCHITECTURE.md # C4, sequence diagrams
|
||||||
|
├── PROJECT_STRUCTURE.md # Этот файл
|
||||||
|
├── E2E_TESTING.md # Гайд по тестированию
|
||||||
|
├── HOTKEYS.md # Горячие клавиши
|
||||||
|
├── CHANGELOG.md # История изменений
|
||||||
|
├── README.md # Главная документация
|
||||||
|
├── INSTALL.md # Установка
|
||||||
|
├── FAQ.md # FAQ
|
||||||
|
├── CONTRIBUTING.md # Гайд по контрибуции
|
||||||
|
├── SECURITY.md # Безопасность
|
||||||
|
└── LICENSE # MIT лицензия
|
||||||
```
|
```
|
||||||
|
|
||||||
## Исходный код (src/)
|
## Ключевые модули
|
||||||
|
|
||||||
### main.rs
|
|
||||||
**Точка входа приложения**
|
|
||||||
- Инициализация TDLib клиента
|
|
||||||
- Event loop (60 FPS)
|
|
||||||
- Обработка Ctrl+C (graceful shutdown)
|
|
||||||
- Координация между UI, input и TDLib
|
|
||||||
|
|
||||||
### config.rs
|
|
||||||
**Конфигурация приложения**
|
|
||||||
- Загрузка/сохранение TOML конфига
|
|
||||||
- Парсинг timezone и цветов
|
|
||||||
- Загрузка credentials (приоритетная система)
|
|
||||||
- XDG directory support
|
|
||||||
|
|
||||||
### utils.rs
|
|
||||||
**Утилитарные функции**
|
|
||||||
- `disable_tdlib_logs()` — отключение TDLib логов через FFI
|
|
||||||
- `format_timestamp_with_tz()` — форматирование времени с учётом timezone
|
|
||||||
- `format_date()` — форматирование дат для разделителей
|
|
||||||
- `format_datetime()` — полное форматирование даты и времени
|
|
||||||
- `format_was_online()` — "был(а) X мин. назад"
|
|
||||||
|
|
||||||
### app/ — Состояние приложения
|
### app/ — Состояние приложения
|
||||||
|
|
||||||
#### mod.rs
|
`App<T: TdClientTrait>` — главная структура, параметризована trait'ом для DI.
|
||||||
- `App` struct — главная структура состояния
|
|
||||||
- `needs_redraw` — флаг для оптимизации рендеринга
|
|
||||||
- Состояние модалок (delete confirm, reaction picker, profile)
|
|
||||||
- Состояние поиска и черновиков
|
|
||||||
- Методы для работы с UI state
|
|
||||||
|
|
||||||
#### state.rs
|
**State machine** (`ChatState` enum):
|
||||||
- `AppScreen` enum — текущий экран (Loading, Auth, Main)
|
```
|
||||||
|
Normal → MessageSelection → Editing
|
||||||
|
→ Reply
|
||||||
|
→ Forward
|
||||||
|
→ DeleteConfirmation
|
||||||
|
→ ReactionPicker
|
||||||
|
→ Profile
|
||||||
|
→ SearchInChat
|
||||||
|
→ PinnedMessages
|
||||||
|
```
|
||||||
|
|
||||||
### audio/ — Прослушивание голосовых сообщений (PLANNED - Фаза 12)
|
**Trait-based methods** (5 traits на `App<T>`):
|
||||||
|
| Trait | Методы | Описание |
|
||||||
#### player.rs
|
|-------|--------|----------|
|
||||||
- `AudioPlayer` — управление воспроизведением голосовых сообщений
|
| NavigationMethods | 7 | next/previous_chat, close_chat, select_current_chat |
|
||||||
- Использует rodio для кроссплатформенного аудио
|
| MessageMethods | 8 | is_editing, is_replying, get_selected_message, etc. |
|
||||||
- API методы: play(), pause(), resume(), stop(), seek(), set_volume()
|
| ComposeMethods | 10 | start_reply, cancel_editing, load_draft, etc. |
|
||||||
- Обработка OGG Opus файлов (формат голосовых в Telegram)
|
| SearchMethods | 15 | start_search, enter_message_search_mode, etc. |
|
||||||
- Отдельный поток для воспроизведения (через rodio Sink)
|
| ModalMethods | 27 | enter_profile_mode, exit_pinned_mode, etc. |
|
||||||
|
|
||||||
#### cache.rs
|
|
||||||
- `VoiceCache` — LRU кэш для загруженных голосовых файлов
|
|
||||||
- Хранение в ~/.cache/tele-tui/voice/
|
|
||||||
- Лимит по размеру (MB) с автоматической очисткой
|
|
||||||
- MAX_VOICE_CACHE_SIZE = 100 MB (настраивается в config)
|
|
||||||
- Проверка существования файла перед воспроизведением
|
|
||||||
|
|
||||||
#### state.rs
|
|
||||||
- `PlaybackState` — текущее состояние воспроизведения
|
|
||||||
- Поля: message_id, status, position, duration, volume
|
|
||||||
- `PlaybackStatus` enum — Stopped, Playing, Paused, Loading
|
|
||||||
- Ticker для обновления позиции (каждые 100ms)
|
|
||||||
|
|
||||||
#### mod.rs
|
|
||||||
- Экспорт публичных типов
|
|
||||||
- `VoiceNoteInfo` struct — метаданные голосового (file_id, duration, waveform)
|
|
||||||
- `AudioConfig` — конфигурация из config.toml
|
|
||||||
- Fallback на системный плеер (mpv, ffplay)
|
|
||||||
|
|
||||||
### media/ — Работа с изображениями (PLANNED - Фаза 11)
|
|
||||||
|
|
||||||
#### image_cache.rs
|
|
||||||
- `ImageCache` — LRU кэш для загруженных изображений
|
|
||||||
- Лимит по размеру (MB) с автоматической очисткой
|
|
||||||
- Хранение как в памяти (DynamicImage), так и на диске (PathBuf)
|
|
||||||
- MAX_IMAGE_CACHE_SIZE = 100 MB (настраивается в config)
|
|
||||||
|
|
||||||
#### image_loader.rs
|
|
||||||
- `ImageLoader` — асинхронная загрузка изображений через TDLib
|
|
||||||
- Метод `load_photo(file_id)` — получить изображение из кэша или загрузить
|
|
||||||
- Метод `download_and_cache(file)` — загрузка через TDLib downloadFile API
|
|
||||||
- Обработка состояний загрузки (pending/downloading/ready)
|
|
||||||
- Приоритизация видимых изображений
|
|
||||||
|
|
||||||
#### image_renderer.rs
|
|
||||||
- `ImageRenderer` — рендеринг изображений в ratatui
|
|
||||||
- Auto-detection протокола терминала (Sixel/Kitty/iTerm2/Halfblocks)
|
|
||||||
- Автоматическое масштабирование под размер области
|
|
||||||
- Сохранение aspect ratio
|
|
||||||
- Fast resize для превью
|
|
||||||
- Fallback на текстовую заглушку
|
|
||||||
|
|
||||||
#### mod.rs
|
|
||||||
- Экспорт публичных типов
|
|
||||||
- `PhotoInfo` struct — метаданные изображения (file_id, width, height)
|
|
||||||
- `TerminalProtocol` enum — поддерживаемые протоколы отображения
|
|
||||||
|
|
||||||
### notifications.rs — Desktop уведомления
|
|
||||||
|
|
||||||
- `NotificationManager` — управление desktop уведомлениями
|
|
||||||
- Интеграция с notify-rust для кроссплатформенных уведомлений
|
|
||||||
- Фильтрация по muted чатам и mentions
|
|
||||||
- Beautification медиа-типов с emoji
|
|
||||||
- Настраиваемый timeout и urgency (Linux)
|
|
||||||
|
|
||||||
### tdlib/ — Telegram интеграция
|
|
||||||
|
|
||||||
#### client.rs
|
|
||||||
- `TdClient` — обёртка над TDLib
|
|
||||||
- Авторизация (телефон, код, 2FA)
|
|
||||||
- Загрузка чатов и сообщений
|
|
||||||
- Отправка/редактирование/удаление сообщений
|
|
||||||
- Reply, Forward
|
|
||||||
- Реакции (`ReactionInfo`)
|
|
||||||
- LRU кеши (users, statuses)
|
|
||||||
- `NetworkState` enum
|
|
||||||
|
|
||||||
#### mod.rs
|
|
||||||
- Экспорт публичных типов
|
|
||||||
|
|
||||||
### ui/ — Рендеринг интерфейса
|
|
||||||
|
|
||||||
#### mod.rs
|
|
||||||
- `render()` — роутинг по экранам
|
|
||||||
- Проверка минимального размера терминала (80x20)
|
|
||||||
|
|
||||||
#### loading.rs
|
|
||||||
- Экран "Loading..."
|
|
||||||
|
|
||||||
#### auth.rs
|
|
||||||
- Экран авторизации (ввод телефона, кода, пароля)
|
|
||||||
|
|
||||||
#### main_screen.rs
|
|
||||||
- Главный экран
|
|
||||||
- Отображение папок сверху
|
|
||||||
|
|
||||||
#### chat_list.rs
|
|
||||||
- Список чатов
|
|
||||||
- Индикаторы: 📌, 🔇, @, (N)
|
|
||||||
- Онлайн-статус (●)
|
|
||||||
- Поиск по чатам
|
|
||||||
|
|
||||||
#### messages.rs
|
|
||||||
- Область сообщений
|
|
||||||
- Группировка по дате и отправителю
|
|
||||||
- Markdown форматирование
|
|
||||||
- Реакции под сообщениями
|
|
||||||
- Emoji picker modal
|
|
||||||
- Profile modal
|
|
||||||
- Delete confirmation modal
|
|
||||||
- Pinned message
|
|
||||||
- Динамический инпут
|
|
||||||
- Блочный курсор
|
|
||||||
|
|
||||||
#### footer.rs
|
|
||||||
- Футер с командами
|
|
||||||
- Индикатор состояния сети
|
|
||||||
|
|
||||||
### input/ — Обработка ввода
|
### input/ — Обработка ввода
|
||||||
|
|
||||||
#### mod.rs
|
**Маршрутизация** (порядок приоритетов в `main_input.rs`):
|
||||||
- Роутинг ввода по экранам
|
1. Global commands (Ctrl+R/S/P/F)
|
||||||
|
2. Profile mode
|
||||||
|
3. Message search mode
|
||||||
|
4. Pinned messages mode
|
||||||
|
5. Reaction picker mode
|
||||||
|
6. Delete confirmation
|
||||||
|
7. Forward mode
|
||||||
|
8. Chat search mode
|
||||||
|
9. Enter/Esc commands
|
||||||
|
10. Open chat input / Chat list navigation
|
||||||
|
|
||||||
#### auth.rs
|
### tdlib/ — Telegram интеграция
|
||||||
- Обработка ввода на экране авторизации
|
|
||||||
|
|
||||||
#### main_input.rs
|
**Dependency Injection**: `TdClientTrait` позволяет подменять TdClient на `FakeTdClient` в тестах.
|
||||||
- Обработка ввода на главном экране
|
|
||||||
- **Важно**: порядок обработчиков имеет значение!
|
|
||||||
1. Reaction picker (Enter/Esc)
|
|
||||||
2. Delete confirmation
|
|
||||||
3. Profile modal
|
|
||||||
4. Search в чате
|
|
||||||
5. Forward mode
|
|
||||||
6. Edit/Reply mode
|
|
||||||
7. Message selection
|
|
||||||
8. Chat list
|
|
||||||
- Поддержка русской раскладки
|
|
||||||
|
|
||||||
## Конфигурационные файлы
|
**MessageManager** — управление сообщениями:
|
||||||
|
- `convert.rs` — конвертация TDLib JSON → MessageInfo
|
||||||
|
- `operations.rs` — 11 API операций (get_history, send, edit, delete, forward, search, etc.)
|
||||||
|
|
||||||
### Cargo.toml
|
### ui/ — Рендеринг
|
||||||
Манифест проекта:
|
|
||||||
- Metadata (name, version, authors, license)
|
|
||||||
- Dependencies
|
|
||||||
- Build dependencies (tdlib-rs)
|
|
||||||
|
|
||||||
### rustfmt.toml
|
**Компоненты** (`ui/components/`):
|
||||||
Конфигурация `cargo fmt`:
|
| Компонент | Описание |
|
||||||
- max_width = 100
|
|-----------|----------|
|
||||||
- imports_granularity = "Crate"
|
| message_bubble | Рендеринг пузыря сообщения с реакциями |
|
||||||
- Стиль комментариев
|
| message_list | Элемент списка сообщений (search/pinned) |
|
||||||
|
| chat_list_item | Элемент списка чатов |
|
||||||
|
| input_field | Поле ввода с курсором |
|
||||||
|
| emoji_picker | Сетка выбора реакций |
|
||||||
|
| modal | Центрированная модалка |
|
||||||
|
|
||||||
### .editorconfig
|
### config/ — Конфигурация
|
||||||
Универсальные настройки для IDE:
|
|
||||||
- Unix line endings (LF)
|
|
||||||
- UTF-8 encoding
|
|
||||||
- Отступы (4 spaces для Rust)
|
|
||||||
|
|
||||||
## Рантайм файлы
|
- **mod.rs** — struct Config, GeneralConfig, ColorsConfig, NotificationsConfig
|
||||||
|
- **keybindings.rs** — Command enum (30+ команд), кастомные горячие клавиши
|
||||||
|
- **validation.rs** — валидация timezone, цветов
|
||||||
|
- **loader.rs** — загрузка из `~/.config/tele-tui/config.toml`, credentials
|
||||||
|
|
||||||
### tdlib_data/
|
## Тестирование
|
||||||
Создаётся автоматически TDLib:
|
|
||||||
- Токены авторизации
|
|
||||||
- Кеш сообщений и файлов
|
|
||||||
- **НЕ коммитится** (в .gitignore)
|
|
||||||
- **НЕ делиться** (содержит чувствительные данные)
|
|
||||||
|
|
||||||
### ~/.config/tele-tui/
|
**500+ тестов** через `cargo test` (без TDLib).
|
||||||
XDG config directory:
|
|
||||||
- `config.toml` — пользовательская конфигурация
|
|
||||||
- `credentials` — API_ID и API_HASH
|
|
||||||
|
|
||||||
## Документация
|
**Инфраструктура**:
|
||||||
|
- `TestAppBuilder` — fluent API для создания App с нужным состоянием
|
||||||
|
- `FakeTdClient` — мок TDLib, реализует TdClientTrait
|
||||||
|
- `TestMessageBuilder` — создание тестовых сообщений
|
||||||
|
|
||||||
### Пользовательская
|
**Типы тестов**:
|
||||||
- **README.md** — главная страница, overview
|
- Unit-тесты — в `#[cfg(test)]` секциях модулей
|
||||||
- **INSTALL.md** — установка и настройка
|
- Integration-тесты — в `tests/` (навигация, отправка, UI рендеринг)
|
||||||
- **HOTKEYS.md** — все горячие клавиши
|
- Doc-тесты — примеры в документации
|
||||||
- **FAQ.md** — часто задаваемые вопросы
|
- E2E — smoke и user journey тесты
|
||||||
|
|
||||||
### Разработчика
|
|
||||||
- **CONTRIBUTING.md** — как внести вклад
|
|
||||||
- **DEVELOPMENT.md** — правила разработки
|
|
||||||
- **PROJECT_STRUCTURE.md** — этот файл
|
|
||||||
- **ROADMAP.md** — план развития
|
|
||||||
- **REFACTORING_ROADMAP.md** — план рефакторинга
|
|
||||||
- **TESTING_ROADMAP.md** — план покрытия тестами
|
|
||||||
- **CONTEXT.md** — текущий статус, архитектурные решения
|
|
||||||
|
|
||||||
### Спецификации
|
|
||||||
- **REQUIREMENTS.md** — функциональные требования
|
|
||||||
- **CHANGELOG.md** — история изменений
|
|
||||||
- **SECURITY.md** — политика безопасности
|
|
||||||
|
|
||||||
### Внутренняя
|
|
||||||
- **CLAUDE.md** — инструкции для AI ассистента
|
|
||||||
- **docs/TDLIB_INTEGRATION.md** — детали интеграции TDLib
|
|
||||||
|
|
||||||
## Ключевые концепции
|
|
||||||
|
|
||||||
### Архитектура
|
|
||||||
- **Event-driven**: TDLib updates → mpsc channel → main loop
|
|
||||||
- **Unidirectional data flow**: TDLib → App state → UI rendering
|
|
||||||
- **Modal stacking**: приоритет обработки ввода для модалок
|
|
||||||
|
|
||||||
### Оптимизации
|
|
||||||
- **needs_redraw**: рендеринг только при изменениях
|
|
||||||
- **LRU caches**: user_names, user_statuses (500 записей)
|
|
||||||
- **Limits**: 500 messages/chat, 200 chats
|
|
||||||
- **Lazy loading**: users загружаются батчами (5 за цикл)
|
|
||||||
|
|
||||||
### Состояние
|
|
||||||
```
|
|
||||||
App {
|
|
||||||
screen: AppScreen,
|
|
||||||
config: Config,
|
|
||||||
needs_redraw: bool,
|
|
||||||
|
|
||||||
// TDLib state
|
|
||||||
chats: Vec<Chat>,
|
|
||||||
folders: Vec<Folder>,
|
|
||||||
|
|
||||||
// UI state
|
|
||||||
selected_chat_id: Option<i64>,
|
|
||||||
input_text: String,
|
|
||||||
cursor_position: usize,
|
|
||||||
|
|
||||||
// Modals
|
|
||||||
is_delete_confirmation: bool,
|
|
||||||
is_reaction_picker_mode: bool,
|
|
||||||
profile_info: Option<ProfileInfo>,
|
|
||||||
view_image_mode: Option<ViewImageState>, // PLANNED - Фаза 11
|
|
||||||
|
|
||||||
// Search
|
|
||||||
search_query: String,
|
|
||||||
search_results: Vec<i64>,
|
|
||||||
|
|
||||||
// Drafts
|
|
||||||
drafts: HashMap<i64, String>,
|
|
||||||
|
|
||||||
// Audio (PLANNED - Фаза 12)
|
|
||||||
audio_player: Option<AudioPlayer>,
|
|
||||||
playback_state: Option<PlaybackState>,
|
|
||||||
voice_cache: VoiceCache,
|
|
||||||
|
|
||||||
// Media (PLANNED - Фаза 11)
|
|
||||||
image_loader: ImageLoader,
|
|
||||||
image_protocol: StatefulProtocol, // Terminal capabilities
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Потоки выполнения
|
## Потоки выполнения
|
||||||
|
|
||||||
### Main thread
|
```
|
||||||
- Event loop (16ms tick для 60 FPS)
|
Main thread TDLib thread
|
||||||
- UI rendering
|
│ │
|
||||||
- Input handling
|
│ ◄── mpsc ─────── │ td_client.receive() в Tokio task
|
||||||
- App state updates
|
│ │
|
||||||
|
├── poll events │
|
||||||
|
├── handle input │
|
||||||
|
├── update state │
|
||||||
|
├── render UI │
|
||||||
|
└── sleep 16ms ──► │
|
||||||
|
```
|
||||||
|
|
||||||
### TDLib thread
|
## Рантайм файлы
|
||||||
- `td_client.receive()` в отдельном Tokio task
|
|
||||||
- Updates отправляются через `mpsc::channel`
|
|
||||||
- Неблокирующий для main thread
|
|
||||||
|
|
||||||
### Blocking operations
|
| Путь | Описание |
|
||||||
- Загрузка конфига (при запуске)
|
|------|----------|
|
||||||
- Авторизация (блокирует до ввода кода)
|
| `~/.config/tele-tui/config.toml` | Пользовательская конфигурация |
|
||||||
- Graceful shutdown (2 sec timeout)
|
| `~/.config/tele-tui/credentials` | API_ID и API_HASH |
|
||||||
|
| `tdlib_data/` | TDLib сессия (НЕ коммитится) |
|
||||||
|
|
||||||
## Зависимости
|
## Зависимости
|
||||||
|
|
||||||
### UI
|
| Категория | Крейт | Назначение |
|
||||||
- `ratatui` 0.29 — TUI framework
|
|-----------|-------|------------|
|
||||||
- `crossterm` 0.28 — terminal control
|
| UI | ratatui 0.29 | TUI framework |
|
||||||
- `ratatui-image` 1.0 — отображение изображений в TUI (PLANNED)
|
| UI | crossterm 0.28 | Terminal control |
|
||||||
|
| Telegram | tdlib-rs 1.1 | TDLib bindings |
|
||||||
### Audio (PLANNED)
|
| Async | tokio 1.x | Async runtime |
|
||||||
- `rodio` 0.17 — Pure Rust аудио библиотека (кроссплатформенная)
|
| Config | serde + toml | Serialization |
|
||||||
|
| Time | chrono 0.4 | Date/time |
|
||||||
### Media (PLANNED)
|
| System | dirs 5.0 | XDG directories |
|
||||||
- `image` — загрузка и обработка изображений
|
| System | arboard 3.4 | Clipboard |
|
||||||
- `ratatui-image` — рендеринг в ratatui с поддержкой Sixel/Kitty/iTerm2
|
| Notify | notify-rust 4.11 | Desktop уведомления (feature) |
|
||||||
|
| URL | open 5.0 | Открытие URL (feature) |
|
||||||
### Notifications
|
|
||||||
- `notify-rust` 4.11 — desktop уведомления (feature flag)
|
|
||||||
|
|
||||||
### Telegram
|
|
||||||
- `tdlib-rs` 1.1 — TDLib bindings
|
|
||||||
- `tokio` 1.x — async runtime
|
|
||||||
|
|
||||||
### Data
|
|
||||||
- `serde` + `serde_json` 1.0 — serialization
|
|
||||||
- `toml` 0.8 — config parsing
|
|
||||||
- `chrono` 0.4 — date/time
|
|
||||||
|
|
||||||
### System
|
|
||||||
- `dirs` 5.0 — XDG directories
|
|
||||||
- `arboard` 3.4 — clipboard
|
|
||||||
- `open` 5.0 — открытие URL/файлов
|
|
||||||
- `dotenvy` 0.15 — .env файлы
|
|
||||||
|
|
||||||
## Workflow разработки
|
|
||||||
|
|
||||||
1. Изучить [ROADMAP.md](ROADMAP.md) — понять текущую фазу
|
|
||||||
2. Прочитать [DEVELOPMENT.md](DEVELOPMENT.md) — правила работы
|
|
||||||
3. Изучить [CONTEXT.md](CONTEXT.md) — архитектурные решения
|
|
||||||
4. Найти issue или создать новую фичу
|
|
||||||
5. Создать feature branch
|
|
||||||
6. Внести изменения
|
|
||||||
7. `cargo fmt` + `cargo clippy`
|
|
||||||
8. Протестировать вручную
|
|
||||||
9. Создать PR с описанием
|
|
||||||
|
|
||||||
## CI/CD
|
|
||||||
|
|
||||||
### GitHub Actions (.github/workflows/ci.yml)
|
|
||||||
- **Check**: `cargo check`
|
|
||||||
- **Format**: `cargo fmt --check`
|
|
||||||
- **Clippy**: `cargo clippy`
|
|
||||||
- **Build**: для Ubuntu, macOS, Windows
|
|
||||||
|
|
||||||
Запускается на:
|
|
||||||
- Push в `main` или `develop`
|
|
||||||
- Pull requests
|
|
||||||
|
|
||||||
## Безопасность
|
|
||||||
|
|
||||||
### Чувствительные файлы (в .gitignore)
|
|
||||||
- `.env`
|
|
||||||
- `credentials`
|
|
||||||
- `config.toml` (если в корне проекта)
|
|
||||||
- `tdlib_data/`
|
|
||||||
- `target/`
|
|
||||||
|
|
||||||
### Рекомендации
|
|
||||||
- Credentials в `~/.config/tele-tui/credentials`
|
|
||||||
- Права доступа: `chmod 600 ~/.config/tele-tui/credentials`
|
|
||||||
- Никогда не коммитить `tdlib_data/`
|
|
||||||
|
|||||||
789
ROADMAP.md
789
ROADMAP.md
@@ -1,677 +1,170 @@
|
|||||||
# Roadmap
|
# Roadmap
|
||||||
|
|
||||||
## Фаза 1: Базовая инфраструктура [DONE]
|
## Завершённые фазы
|
||||||
|
|
||||||
- [x] Настройка проекта (Cargo.toml)
|
| Фаза | Описание | Ключевые результаты |
|
||||||
- [x] TUI фреймворк (ratatui + crossterm)
|
|------|----------|---------------------|
|
||||||
- [x] Базовый layout (папки, список чатов, область сообщений)
|
| 1 | Базовая инфраструктура | ratatui + crossterm, vim-навигация, русская раскладка |
|
||||||
- [x] Vim-style навигация (hjkl, стрелки)
|
| 2 | TDLib интеграция | tdlib-rs, авторизация, загрузка чатов и сообщений |
|
||||||
- [x] Русская раскладка (ролд)
|
| 3 | Улучшение UX | Отправка, поиск, скролл, realtime обновления |
|
||||||
|
| 4 | Папки и фильтрация | Загрузка папок из Telegram, переключение 1-9 |
|
||||||
|
| 5 | Расширенный функционал | Онлайн-статус, галочки прочтения, медиа-заглушки, muted |
|
||||||
|
| 6 | Полировка | 60 FPS, оптимизация памяти, graceful shutdown, динамический инпут |
|
||||||
|
| 7 | Рефакторинг памяти | Единый источник данных, LRU-кэш (500 users), lazy loading |
|
||||||
|
| 8 | Дополнительные фичи | Markdown, edit/delete, reply/forward, блочный курсор |
|
||||||
|
| 9 | Расширенные возможности | Typing, pinned, поиск в чате, черновики, профиль, копирование, реакции, конфиг |
|
||||||
|
| 10 | Desktop уведомления (83%) | notify-rust, muted фильтр, mentions, медиа. TODO: кастомные звуки |
|
||||||
|
| 11 | Inline просмотр фото | Dual renderer (Halfblocks + iTerm2/Sixel), throttling 15 FPS, modal viewer, lazy loading, auto-download |
|
||||||
|
| 12 | Голосовые сообщения | ffplay player, pause/resume with seek, VoiceCache, AudioConfig, progress bar + waveform UI |
|
||||||
|
| 13 | Глубокий рефакторинг | 5 файлов (4582->модули), 5 traits, shared components, docs |
|
||||||
|
|
||||||
## Фаза 2: TDLib интеграция [DONE]
|
---
|
||||||
|
|
||||||
- [x] Подключение tdlib-rs
|
## Фаза 11: Inline просмотр фото в чате [DONE]
|
||||||
- [x] Авторизация (телефон + код + 2FA)
|
|
||||||
- [x] Сохранение сессии
|
|
||||||
- [x] Загрузка списка чатов
|
|
||||||
- [x] Загрузка истории сообщений
|
|
||||||
- [x] Отключение логов TDLib
|
|
||||||
|
|
||||||
## Фаза 3: Улучшение UX [DONE]
|
**UX**: Always-show inline preview (50 chars, Halfblocks) -> `v`/`м` открывает fullscreen modal (iTerm2/Sixel) -> `←`/`→` навигация между фото.
|
||||||
|
|
||||||
- [x] Отправка сообщений
|
### Реализовано:
|
||||||
- [x] Фильтрация чатов (только Main, без архива)
|
- [x] **Dual renderer архитектура**:
|
||||||
- [x] Поиск по чатам (Ctrl+S)
|
- `inline_image_renderer`: Halfblocks (быстро, Unicode блоки) для навигации
|
||||||
- [x] Скролл истории сообщений
|
- `modal_image_renderer`: iTerm2/Sixel (медленно, высокое качество) для просмотра
|
||||||
- [x] Загрузка имён пользователей (вместо User_ID)
|
- [x] **Performance optimizations**:
|
||||||
- [x] Отметка сообщений как прочитанные
|
- Frame throttling: inline 15 FPS, текст 60 FPS
|
||||||
- [x] Реальное время: новые сообщения
|
- Lazy loading: только видимые изображения
|
||||||
|
- LRU cache: max 100 протоколов
|
||||||
|
- Skip partial rendering (no flickering)
|
||||||
|
- [x] **UX улучшения**:
|
||||||
|
- Always-show inline preview (фикс. ширина 50 chars)
|
||||||
|
- Fullscreen modal на `v`/`м` с aspect ratio
|
||||||
|
- Loading indicator в модалке
|
||||||
|
- Navigation hotkeys: `←`/`→` между фото, `Esc`/`q` закрыть
|
||||||
|
- [x] **Типы и API**:
|
||||||
|
- `MediaInfo`, `PhotoInfo`, `PhotoDownloadState`, `ImageModalState`
|
||||||
|
- `ImagesConfig` в config.toml
|
||||||
|
- Feature flag `images` для зависимостей
|
||||||
|
- [x] **Media модуль**:
|
||||||
|
- `cache.rs`: ImageCache (LRU)
|
||||||
|
- `image_renderer.rs`: new() + new_fast()
|
||||||
|
- [x] **UI модули**:
|
||||||
|
- `modals/image_viewer.rs`: fullscreen modal
|
||||||
|
- `messages.rs`: throttled second-pass rendering
|
||||||
|
- [x] **Авто-загрузка фото** (bugfix):
|
||||||
|
- Auto-download последних 30 фото при открытии чата (`open_chat_and_load_data`)
|
||||||
|
- Download on demand по `v` (вместо "Фото не загружено")
|
||||||
|
- Retry при ошибке загрузки
|
||||||
|
- Конфиг: `auto_download_images` + `show_images` в `[images]`
|
||||||
|
|
||||||
## Фаза 4: Папки и фильтрация [DONE]
|
---
|
||||||
|
|
||||||
- [x] Загрузка папок из Telegram
|
## Фаза 12: Прослушивание голосовых сообщений [DONE]
|
||||||
- [x] Переключение между папками (1-9)
|
|
||||||
- [x] Фильтрация чатов по папке
|
|
||||||
|
|
||||||
## Фаза 5: Расширенный функционал [DONE]
|
### Этап 1: Инфраструктура аудио [DONE]
|
||||||
|
- [x] Модуль `src/audio/`
|
||||||
|
- `player.rs` — AudioPlayer на ffplay (subprocess)
|
||||||
|
- `cache.rs` — VoiceCache (LRU, configurable size, `~/.cache/tele-tui/voice/`)
|
||||||
|
- [x] AudioPlayer API: play(), play_from(ss), pause() (SIGSTOP), resume(), resume_from(ss), stop()
|
||||||
|
- [x] Race condition fix: `starting` flag + pid ownership guard в потоках
|
||||||
|
- [x] Drop impl для AudioPlayer (убивает ffplay при выходе)
|
||||||
|
|
||||||
- [x] Отображение онлайн-статуса (зелёная точка ●)
|
### Этап 2: Интеграция с TDLib [DONE]
|
||||||
- [x] Статус доставки/прочтения (✓, ✓✓)
|
- [x] Типы: `VoiceInfo`, `VoiceDownloadState`, `PlaybackState`, `PlaybackStatus`
|
||||||
- [x] Поддержка медиа-заглушек (фото, видео, голосовые, стикеры и др.)
|
- [x] Конвертация `MessageVoiceNote` в `message_conversion.rs`
|
||||||
- [x] Mentions (@) — индикатор непрочитанных упоминаний
|
- [x] `download_voice_note()` в TdClientTrait + client_impl + fake
|
||||||
- [x] Muted чаты (иконка 🔇)
|
- [x] Методы `has_voice()`, `voice_info()`, `voice_info_mut()` на `MessageInfo`
|
||||||
|
|
||||||
## Фаза 6: Полировка [DONE]
|
### Этап 3: UI для воспроизведения [DONE]
|
||||||
|
- [x] Progress bar (━●─) с позицией и длительностью
|
||||||
|
- [x] Waveform визуализация (▁▂▃▄▅▆▇█) из base64-encoded TDLib данных
|
||||||
|
- [x] Иконки статуса: ▶ Playing, ⏸ Paused, ⏹ Stopped
|
||||||
|
- [x] Throttled redraw: обновление UI только при смене секунды (не 60 FPS)
|
||||||
|
|
||||||
- [x] Оптимизация использования памяти (базовая)
|
### Этап 4: Хоткеи [DONE]
|
||||||
- Очистка сообщений при закрытии чата
|
- [x] Space — play/pause toggle (запуск + пауза/возобновление с откатом 1s)
|
||||||
- Лимит кэша пользователей (500)
|
- [x] ←/→ — seek ±5 сек (через `resume_from()` — перезапуск ffplay с `-ss`)
|
||||||
- Периодическая очистка неактивных записей
|
- [x] Seek работает и при воспроизведении, и на паузе (на паузе двигает позицию, при resume стартует с неё)
|
||||||
- [x] Оптимизация 60 FPS
|
- [x] MoveLeft/MoveRight как alias для SeekBackward/SeekForward (HashMap non-deterministic order fix)
|
||||||
- Poll таймаут 16ms
|
- [x] Автоматическая остановка при навигации на другое сообщение
|
||||||
- Флаг `needs_redraw` — рендеринг только при изменениях
|
- [x] Остановка ffplay при выходе из приложения (Ctrl+C)
|
||||||
- Обработка Event::Resize для перерисовки при изменении размера
|
|
||||||
- [x] Минимальное разрешение (80x20)
|
|
||||||
- Предупреждение если терминал слишком мал
|
|
||||||
- [x] Обработка ошибок сети
|
|
||||||
- NetworkState enum (WaitingForNetwork, Connecting, etc.)
|
|
||||||
- Индикатор в футере с цветовой индикацией
|
|
||||||
- [x] Graceful shutdown
|
|
||||||
- AtomicBool флаг для остановки polling
|
|
||||||
- Корректное закрытие TDLib клиента
|
|
||||||
- Таймаут ожидания завершения задач
|
|
||||||
- [x] Динамический инпут
|
|
||||||
- Автоматическое расширение до 10 строк
|
|
||||||
- Wrap для длинного текста
|
|
||||||
- [x] Перенос длинных сообщений
|
|
||||||
- Автоматический wrap на несколько строк
|
|
||||||
- Правильное выравнивание для исходящих/входящих
|
|
||||||
|
|
||||||
## Фаза 7: Глубокий рефакторинг памяти [DONE]
|
### Этап 5: Конфигурация и кэш [DONE]
|
||||||
|
- [x] `AudioConfig` в config.toml (`cache_size_mb`, `auto_download_voice`)
|
||||||
- [x] Удалить дублирование current_messages между App и TdClient
|
- [x] `DEFAULT_AUDIO_CACHE_SIZE_MB` константа (100 MB)
|
||||||
- [x] Использовать единый источник данных для сообщений
|
- [x] Ticker для progress bar в event loop (delta-based position tracking)
|
||||||
- [x] Реализовать LRU-кэш для user_names/user_statuses вместо простого лимита
|
- [x] VoiceCache интеграция: проверка кэша перед загрузкой, кэширование после download
|
||||||
- [x] Lazy loading для имён пользователей (батчевая загрузка последних 5 за цикл)
|
|
||||||
- [x] Лимиты памяти:
|
|
||||||
- MAX_MESSAGES_IN_CHAT = 500
|
|
||||||
- MAX_CHATS = 200
|
|
||||||
- MAX_CHAT_USER_IDS = 500
|
|
||||||
- MAX_USER_CACHE_SIZE = 500 (LRU)
|
|
||||||
|
|
||||||
## Фаза 8: Дополнительные фичи [DONE]
|
|
||||||
|
|
||||||
- [x] Markdown форматирование в сообщениях
|
|
||||||
- Bold, Italic, Underline, Strikethrough
|
|
||||||
- Code (inline, Pre, PreCode)
|
|
||||||
- Spoiler (скрытый текст)
|
|
||||||
- URLs, упоминания (@)
|
|
||||||
- [x] Редактирование сообщений
|
|
||||||
- ↑ при пустом инпуте → выбор сообщения
|
|
||||||
- Enter для начала редактирования
|
|
||||||
- Подсветка выбранного сообщения (▶)
|
|
||||||
- Esc для отмены
|
|
||||||
- [x] Удаление сообщений
|
|
||||||
- d / в / Delete в режиме выбора
|
|
||||||
- Модалка подтверждения (y/n)
|
|
||||||
- Удаление для всех если возможно
|
|
||||||
- [x] Индикатор редактирования (✎)
|
|
||||||
- Отображается рядом с временем для отредактированных сообщений
|
|
||||||
- [x] Блочный курсор в поле ввода
|
|
||||||
- Vim-style курсор █
|
|
||||||
- Перемещение ←/→, Home/End
|
|
||||||
- Редактирование в любой позиции
|
|
||||||
- [x] Reply на сообщения
|
|
||||||
- `r` / `к` в режиме выбора → режим ответа
|
|
||||||
- Превью сообщения в поле ввода
|
|
||||||
- Esc для отмены
|
|
||||||
- [x] Forward сообщений
|
|
||||||
- `f` / `а` в режиме выбора → режим пересылки
|
|
||||||
- Превью сообщения в поле ввода
|
|
||||||
- Выбор чата стрелками, Enter для пересылки
|
|
||||||
- Esc для отмены
|
|
||||||
- Отображение "↪ Переслано от" для пересланных сообщений
|
|
||||||
|
|
||||||
## Фаза 9: Расширенные возможности [DONE]
|
|
||||||
|
|
||||||
- [x] Typing indicator ("печатает...")
|
|
||||||
- Показывать когда собеседник печатает
|
|
||||||
- Отправлять свой статус печати при наборе текста
|
|
||||||
- [x] Закреплённые сообщения (Pinned)
|
|
||||||
- Отображать pinned message вверху открытого чата
|
|
||||||
- Клик/хоткей для перехода к закреплённому сообщению
|
|
||||||
- [x] Поиск по сообщениям в чате
|
|
||||||
- `Ctrl+F` — поиск текста внутри открытого чата
|
|
||||||
- Навигация по результатам (n/N или стрелки)
|
|
||||||
- Подсветка найденных совпадений
|
|
||||||
- [x] Черновики
|
|
||||||
- Сохранять набранный текст при переключении между чатами
|
|
||||||
- Индикатор черновика в списке чатов
|
|
||||||
- Восстановление текста при возврате в чат
|
|
||||||
- [x] Профиль пользователя/чата
|
|
||||||
- `Ctrl+i` — открыть информацию о чате/собеседнике
|
|
||||||
- Для личных чатов: имя, username, телефон, био
|
|
||||||
- Для групп: название, описание, количество участников
|
|
||||||
- [x] Копирование сообщений
|
|
||||||
- `y` / `н` в режиме выбора — скопировать текст в системный буфер обмена
|
|
||||||
- Использовать clipboard crate для кроссплатформенности
|
|
||||||
- [x] Реакции
|
|
||||||
- Отображение реакций под сообщениями
|
|
||||||
- `e` в режиме выбора — добавить реакцию (emoji picker)
|
|
||||||
- Список доступных реакций чата
|
|
||||||
- [x] Конфигурационный файл
|
|
||||||
- `~/.config/tele-tui/config.toml`
|
|
||||||
- Настройки: цветовая схема, часовой пояс, хоткеи
|
|
||||||
- Загрузка конфига при старте
|
|
||||||
|
|
||||||
## Фаза 10: Desktop уведомления [DONE - 83%]
|
|
||||||
|
|
||||||
### Стадия 1: Базовая реализация [DONE]
|
|
||||||
- [x] NotificationManager модуль
|
|
||||||
- notify-rust интеграция (версия 4.11)
|
|
||||||
- Feature flag "notifications" в Cargo.toml
|
|
||||||
- Базовая структура с настройками
|
|
||||||
- [x] Конфигурация уведомлений
|
|
||||||
- NotificationsConfig в config.toml
|
|
||||||
- enabled: bool - вкл/выкл уведомлений
|
|
||||||
- only_mentions: bool - только упоминания
|
|
||||||
- show_preview: bool - показывать превью текста
|
|
||||||
- [x] Интеграция с TdClient
|
|
||||||
- Поле notification_manager в TdClient
|
|
||||||
- Метод configure_notifications()
|
|
||||||
- Обработка в handle_new_message_update()
|
|
||||||
- [x] Базовая отправка уведомлений
|
|
||||||
- Уведомления для сообщений не из текущего чата
|
|
||||||
- Форматирование title (имя чата) и body (текст/медиа-заглушка)
|
|
||||||
- Sender name из MessageInfo
|
|
||||||
|
|
||||||
### Стадия 2: Улучшения [IN PROGRESS]
|
|
||||||
- [x] Синхронизация muted чатов
|
|
||||||
- Загрузка списка muted чатов из Telegram
|
|
||||||
- Вызов sync_muted_chats() при инициализации и обновлении (Ctrl+R)
|
|
||||||
- Muted чаты автоматически фильтруются из уведомлений
|
|
||||||
- [x] Фильтрация по упоминаниям
|
|
||||||
- Метод MessageInfo::has_mention() проверяет TextEntityType::Mention и MentionName
|
|
||||||
- NotificationManager применяет фильтр only_mentions из конфига
|
|
||||||
- Работает для @username и inline mentions
|
|
||||||
- [x] Поддержка типов медиа
|
|
||||||
- Метод beautify_media_labels() заменяет текстовые заглушки на emoji
|
|
||||||
- Поддержка: 📷 Фото, 🎥 Видео, 🎞️ GIF, 🎤 Голосовое, 🎨 Стикер
|
|
||||||
- Также: 📎 Файл, 🎵 Аудио, 📹 Видеосообщение, 📍 Локация, 👤 Контакт, 📊 Опрос
|
|
||||||
- [ ] Кастомизация звуков
|
|
||||||
- Настройка звуков уведомлений в config.toml
|
|
||||||
- Разные звуки для разных типов сообщений
|
|
||||||
|
|
||||||
### Стадия 3: Полировка [DONE]
|
|
||||||
- [x] Обработка ошибок
|
|
||||||
- Graceful fallback если уведомления недоступны (возвращает Ok без паники)
|
|
||||||
- Логирование ошибок через tracing::warn!
|
|
||||||
- Детальное логирование причин пропуска уведомлений (debug level)
|
|
||||||
- [x] Дополнительные настройки
|
|
||||||
- timeout_ms - продолжительность показа (0 = системное значение)
|
|
||||||
- urgency - уровень важности: "low", "normal", "critical" (только Linux)
|
|
||||||
- Красивые эмодзи для типов медиа
|
|
||||||
- [ ] Опциональные улучшения (не критично)
|
|
||||||
- Кросс-платформенное тестирование (требует ручного тестирования)
|
|
||||||
- icon - кастомная иконка приложения
|
|
||||||
- Actions в уведомлениях (кнопки "Ответить", "Прочитано")
|
|
||||||
|
|
||||||
## Фаза 11: Показ изображений в чате [PLANNED]
|
|
||||||
|
|
||||||
### Этап 1: Инфраструктура [TODO]
|
|
||||||
- [ ] Модуль src/media/
|
|
||||||
- image_cache.rs - LRU кэш для загруженных изображений
|
|
||||||
- image_loader.rs - Асинхронная загрузка через TDLib
|
|
||||||
- image_renderer.rs - Рендеринг в ratatui
|
|
||||||
- [ ] Зависимости
|
|
||||||
- ratatui-image 1.0 - поддержка изображений в TUI
|
|
||||||
- Определение протокола терминала (Sixel/Kitty/iTerm2/Halfblocks)
|
|
||||||
- [ ] ImageCache с лимитами
|
|
||||||
- LRU кэш с максимальным размером в МБ
|
|
||||||
- Автоматическая очистка старых изображений
|
|
||||||
- MAX_IMAGE_CACHE_SIZE = 100 MB (по умолчанию)
|
|
||||||
|
|
||||||
### Этап 2: Интеграция с TDLib [TODO]
|
|
||||||
- [ ] Обработка MessageContentPhoto
|
|
||||||
- Добавить PhotoInfo в MessageInfo
|
|
||||||
- Извлечение file_id, width, height из Photo
|
|
||||||
- Выбор оптимального размера изображения (до 800px)
|
|
||||||
- [ ] Загрузка файлов
|
|
||||||
- Метод TdClient::download_photo(file_id)
|
|
||||||
- Асинхронная загрузка через downloadFile API
|
|
||||||
- Обработка состояний загрузки (pending/downloading/ready)
|
|
||||||
- [ ] Кэширование
|
|
||||||
- Сохранение путей к загруженным файлам
|
|
||||||
- Повторное использование уже загруженных изображений
|
|
||||||
|
|
||||||
### Этап 3: Рендеринг в UI [TODO]
|
|
||||||
- [ ] Модификация render_messages()
|
|
||||||
- Определение возможностей терминала при старте
|
|
||||||
- Рендеринг изображений через ratatui-image
|
|
||||||
- Автоматическое масштабирование под размер области
|
|
||||||
- Сохранение aspect ratio
|
|
||||||
- [ ] Превью в списке сообщений
|
|
||||||
- Миниатюры размером 20x10 символов
|
|
||||||
- Lazy loading (загрузка только видимых)
|
|
||||||
- Placeholder пока изображение грузится
|
|
||||||
- [ ] Индикатор загрузки
|
|
||||||
- Текстовая заглушка "[Загрузка фото...]"
|
|
||||||
- Progress bar для больших файлов
|
|
||||||
- Процент загрузки
|
|
||||||
|
|
||||||
### Этап 4: Полноэкранный просмотр [TODO]
|
|
||||||
- [ ] Новый режим: ViewImage
|
|
||||||
- `v` / `м` в режиме выбора - открыть изображение
|
|
||||||
- Показ на весь экран терминала
|
|
||||||
- `Esc` для закрытия
|
|
||||||
- [ ] Информация об изображении
|
|
||||||
- Размер файла
|
|
||||||
- Разрешение (width x height)
|
|
||||||
- Формат (JPEG/PNG/GIF)
|
|
||||||
- [ ] Навигация
|
|
||||||
- `←` / `→` - предыдущее/следующее изображение в чате
|
|
||||||
- Автоматическая загрузка соседних изображений
|
|
||||||
|
|
||||||
### Этап 5: Конфигурация и UX [TODO]
|
|
||||||
- [ ] MediaConfig в config.toml
|
|
||||||
- show_images: bool - включить/отключить показ изображений
|
|
||||||
- image_cache_mb: usize - размер кэша в МБ
|
|
||||||
- preview_quality: "low" | "medium" | "high"
|
|
||||||
- render_protocol: "auto" | "sixel" | "kitty" | "iterm2" | "halfblocks"
|
|
||||||
- [ ] Поддержка различных терминалов
|
|
||||||
- Auto-detection протокола при старте
|
|
||||||
- Fallback на Unicode halfblocks для любого терминала
|
|
||||||
- Опция отключения изображений если терминал не поддерживает
|
|
||||||
- [ ] Оптимизация производительности
|
|
||||||
- Асинхронная загрузка (не блокирует UI)
|
|
||||||
- Приоритизация видимых изображений
|
|
||||||
- Fast resize для превью
|
|
||||||
- Кэширование отмасштабированных версий
|
|
||||||
|
|
||||||
### Этап 6: Обработка ошибок [TODO]
|
|
||||||
- [ ] Graceful fallback
|
|
||||||
- Текстовая заглушка "[Фото]" если загрузка не удалась
|
|
||||||
- Повторная попытка по запросу пользователя
|
|
||||||
- Логирование проблем через tracing
|
|
||||||
- [ ] Ограничения
|
|
||||||
- Таймаут загрузки (30 сек)
|
|
||||||
- Максимальный размер файла для автозагрузки (10 MB)
|
|
||||||
- Предупреждение для больших файлов
|
|
||||||
|
|
||||||
### Технические детали
|
### Технические детали
|
||||||
- **Поддерживаемые протоколы:**
|
- **Аудио:** ffplay (subprocess), resume/seek через перезапуск с `-ss` offset
|
||||||
- Sixel (xterm, WezTerm, mintty)
|
- **Race conditions:** `starting` flag предотвращает false `is_stopped()` при старте ffplay; pid ownership guard в потоках предотвращает затирание pid нового процесса старым
|
||||||
- Kitty Graphics Protocol (Kitty terminal)
|
- **Keybinding conflict:** Left/Right привязаны к MoveLeft/MoveRight и SeekBackward/SeekForward; HashMap iteration order не гарантирован → оба варианта обрабатываются как seek в режиме выбора сообщения
|
||||||
- iTerm2 Inline Images (iTerm2 на macOS)
|
- **Платформы:** macOS, Linux (везде где есть ffmpeg)
|
||||||
- Unicode Halfblocks (fallback для всех)
|
- **Хоткеи:** Space (play/pause), ←/→ (seek ±5s)
|
||||||
- **Поддерживаемые форматы:**
|
|
||||||
- JPEG, PNG, GIF, WebP, BMP
|
|
||||||
- **Новые хоткеи:**
|
|
||||||
- `v` / `м` - открыть изображение в полном размере (режим выбора)
|
|
||||||
- `←` / `→` - навигация между изображениями (в режиме просмотра)
|
|
||||||
- `Esc` - закрыть полноэкранный просмотр
|
|
||||||
|
|
||||||
## Фаза 12: Прослушивание голосовых сообщений [PLANNED]
|
---
|
||||||
|
|
||||||
### Этап 1: Инфраструктура аудио [TODO]
|
## Фаза 14: Мультиаккаунт
|
||||||
- [ ] Модуль src/audio/
|
|
||||||
- player.rs - AudioPlayer на rodio
|
|
||||||
- cache.rs - VoiceCache для загруженных файлов
|
|
||||||
- state.rs - PlaybackState (статус, позиция, громкость)
|
|
||||||
- [ ] Зависимости
|
|
||||||
- rodio 0.17 - Pure Rust аудио библиотека
|
|
||||||
- Feature flag "audio" в Cargo.toml
|
|
||||||
- [ ] AudioPlayer API
|
|
||||||
- play() - воспроизведение файла
|
|
||||||
- pause() / resume() - пауза/возобновление
|
|
||||||
- stop() - остановка
|
|
||||||
- seek() - перемотка
|
|
||||||
- set_volume() - регулировка громкости
|
|
||||||
- get_position() - текущая позиция
|
|
||||||
- [ ] VoiceCache
|
|
||||||
- Кэш загруженных OGG файлов в ~/.cache/tele-tui/voice/
|
|
||||||
- LRU политика очистки
|
|
||||||
- MAX_VOICE_CACHE_SIZE = 100 MB
|
|
||||||
|
|
||||||
### Этап 2: Интеграция с TDLib [TODO]
|
**Цель**: поддержка нескольких Telegram-аккаунтов с мгновенным переключением внутри приложения.
|
||||||
- [ ] Обработка MessageContentVoiceNote
|
|
||||||
- Добавить VoiceNoteInfo в MessageInfo
|
|
||||||
- Извлечение file_id, duration, mime_type, waveform
|
|
||||||
- Метка формата (OGG Opus обычно)
|
|
||||||
- [ ] Загрузка файлов
|
|
||||||
- Метод TdClient::download_voice_note(file_id)
|
|
||||||
- Асинхронная загрузка через downloadFile API
|
|
||||||
- Обработка состояний (pending/downloading/ready)
|
|
||||||
- [ ] Кэширование
|
|
||||||
- Сохранение путей к загруженным файлам
|
|
||||||
- Не перезагружать уже скачанные голосовые
|
|
||||||
- Проверка существования файла перед воспроизведением
|
|
||||||
|
|
||||||
### Этап 3: UI для воспроизведения [TODO]
|
### UI: Индикатор в footer + хоткеи
|
||||||
- [ ] Индикатор в сообщении
|
|
||||||
- Иконка 🎤 и длительность голосового
|
|
||||||
- Progress bar во время воспроизведения
|
|
||||||
- Статус: ▶ (playing), ⏸ (paused), ⏹ (stopped), ⏳ (loading)
|
|
||||||
- Текущее время / общая длительность (0:08 / 0:15)
|
|
||||||
- [ ] Модификация render_messages()
|
|
||||||
- render_voice_note() для голосовых сообщений
|
|
||||||
- render_progress_bar() для индикатора воспроизведения
|
|
||||||
- Hint "[Space] Воспроизвести" если не играет
|
|
||||||
- [ ] Footer с управлением
|
|
||||||
- Отображение доступных команд при воспроизведении
|
|
||||||
- "[Space] Play/Pause [s] Stop [←/→] Seek [↑/↓] Volume"
|
|
||||||
- [ ] Waveform визуализация (опционально)
|
|
||||||
- Конвертация waveform данных из Telegram в ASCII bars
|
|
||||||
- Использование символов ▁▂▃▄▅▆▇█ для визуализации
|
|
||||||
|
|
||||||
### Этап 4: Хоткеи для управления [TODO]
|
```
|
||||||
- [ ] Новые команды
|
┌──────────────┬───────────────────────────┐
|
||||||
- PlayVoice - Space в режиме выбора голосового
|
│ Saved Msgs │ Привет! │
|
||||||
- PauseVoice - Space во время воспроизведения
|
│ Иван Петров │ Как дела? │
|
||||||
- StopVoice - s / ы
|
│ Работа чат │ │
|
||||||
- SeekBackward - ← (перемотка назад на 5 сек)
|
├──────────────┴───────────────────────────┤
|
||||||
- SeekForward - → (перемотка вперед на 5 сек)
|
│ [NORMAL] Михаил ⟨1/2⟩ Work(3) │ Ctrl+A │
|
||||||
- VolumeUp - ↑ (увеличить на 10%)
|
└──────────────────────────────────────────┘
|
||||||
- VolumeDown - ↓ (уменьшить на 10%)
|
|
||||||
- [ ] Контекстная обработка
|
|
||||||
- Space работает как play/pause в зависимости от состояния
|
|
||||||
- ← / → для seek только во время воспроизведения
|
|
||||||
- ↑ / ↓ для громкости только во время воспроизведения
|
|
||||||
- [ ] Поддержка русской раскладки
|
|
||||||
- s / ы - stop
|
|
||||||
- Остальные клавиши универсальны (Space, стрелки)
|
|
||||||
|
|
||||||
### Этап 5: Конфигурация и UX [TODO]
|
|
||||||
- [ ] AudioConfig в config.toml
|
|
||||||
- enabled: bool - включить/отключить аудио
|
|
||||||
- default_volume: f32 - громкость по умолчанию (0.0 - 1.0)
|
|
||||||
- seek_step_seconds: i32 - шаг перемотки в секундах
|
|
||||||
- autoplay: bool - автовоспроизведение при выборе
|
|
||||||
- cache_size_mb: usize - размер кэша голосовых
|
|
||||||
- show_waveform: bool - показывать waveform визуализацию
|
|
||||||
- system_player_fallback: bool - использовать системный плеер
|
|
||||||
- system_player: String - команда системного плеера (mpv, ffplay)
|
|
||||||
- [ ] Асинхронная загрузка
|
|
||||||
- Не блокировать UI во время загрузки файла
|
|
||||||
- Индикатор загрузки с процентами
|
|
||||||
- Возможность отмены загрузки
|
|
||||||
- [ ] Обновление UI
|
|
||||||
- Ticker для обновления progress bar (каждые 100ms)
|
|
||||||
- Плавное обновление позиции воспроизведения
|
|
||||||
- Автоматическая остановка при достижении конца
|
|
||||||
|
|
||||||
### Этап 6: Обработка ошибок [TODO]
|
|
||||||
- [ ] Graceful fallback на системный плеер
|
|
||||||
- Если rodio не работает - использовать mpv/ffplay
|
|
||||||
- Логирование ошибок через tracing
|
|
||||||
- Предупреждение пользователю если аудио недоступно
|
|
||||||
- [ ] Обработка ошибок загрузки
|
|
||||||
- Таймаут загрузки (30 сек)
|
|
||||||
- Повторная попытка по запросу
|
|
||||||
- Сообщение об ошибке в UI
|
|
||||||
- [ ] Ограничения
|
|
||||||
- Максимальный размер файла для кэша
|
|
||||||
- Автоматическая очистка старых файлов
|
|
||||||
- Предупреждение для очень длинных голосовых (>5 мин)
|
|
||||||
|
|
||||||
### Этап 7: Дополнительные улучшения [TODO]
|
|
||||||
- [ ] Управление воспроизведением
|
|
||||||
- Автоматическая остановка при закрытии чата
|
|
||||||
- Сохранение позиции при паузе
|
|
||||||
- Автопереход к следующему голосовому (опционально)
|
|
||||||
- [ ] Оптимизация
|
|
||||||
- Lazy loading (загрузка только при воспроизведении)
|
|
||||||
- Префетчинг следующего голосового (опционально)
|
|
||||||
- Минимальная задержка при нажатии Play
|
|
||||||
- [ ] Визуальные улучшения
|
|
||||||
- Анимация progress bar
|
|
||||||
- Цветовая индикация статуса (зеленый - playing, желтый - paused)
|
|
||||||
- Иконки в зависимости от статуса
|
|
||||||
|
|
||||||
### Технические детали
|
|
||||||
- **Аудио библиотека:**
|
|
||||||
- rodio 0.17 (Pure Rust, кроссплатформенная)
|
|
||||||
- Поддержка OGG Opus (формат голосовых в Telegram)
|
|
||||||
- Контроль воспроизведения через Sink API
|
|
||||||
- **Платформы:**
|
|
||||||
- Linux (ALSA, PulseAudio)
|
|
||||||
- macOS (CoreAudio)
|
|
||||||
- Windows (WASAPI)
|
|
||||||
- **Fallback:**
|
|
||||||
- mpv --no-video (универсальный плеер)
|
|
||||||
- ffplay -nodisp (из ffmpeg)
|
|
||||||
- **Новые хоткеи:**
|
|
||||||
- `Space` - воспроизвести/пауза (в режиме выбора голосового)
|
|
||||||
- `s` / `ы` - остановить воспроизведение
|
|
||||||
- `←` / `→` - перемотка -5с / +5с (во время воспроизведения)
|
|
||||||
- `↑` / `↓` - громкость +/- 10% (во время воспроизведения)
|
|
||||||
|
|
||||||
## Фаза 13: Глубокий рефакторинг архитектуры [PLANNED]
|
|
||||||
|
|
||||||
**Мотивация:** Код вырос до критических размеров - некоторые файлы содержат >1000 строк, что затрудняет поддержку и навигацию. Необходимо разбить монолитные файлы на логические модули.
|
|
||||||
|
|
||||||
**Проблемы:**
|
|
||||||
- `src/input/main_input.rs` - 1199 строк (самый большой файл!)
|
|
||||||
- `src/app/mod.rs` - 1015 строк, 116 функций (God Object)
|
|
||||||
- `src/ui/messages.rs` - 893 строки
|
|
||||||
- `src/tdlib/messages.rs` - 833 строки
|
|
||||||
- `src/config/mod.rs` - 642 строки
|
|
||||||
|
|
||||||
### Этап 1: Разбить input/main_input.rs (1199 → <200 строк) [TODO]
|
|
||||||
|
|
||||||
**Текущая проблема:**
|
|
||||||
- Весь input handling в одном файле
|
|
||||||
- Функции по 300-400 строк
|
|
||||||
- Невозможно быстро найти нужный handler
|
|
||||||
|
|
||||||
**План:**
|
|
||||||
- [ ] Создать `src/input/handlers/` директорию
|
|
||||||
- [ ] Создать `handlers/chat.rs` - обработка ввода в открытом чате
|
|
||||||
- Переместить `handle_open_chat_keyboard_input()`
|
|
||||||
- Обработка скролла, выбора сообщений
|
|
||||||
- ~300-400 строк
|
|
||||||
- [ ] Создать `handlers/chat_list.rs` - обработка в списке чатов
|
|
||||||
- Переместить `handle_chat_list_keyboard_input()`
|
|
||||||
- Навигация по чатам, папки
|
|
||||||
- ~200-300 строк
|
|
||||||
- [ ] Создать `handlers/compose.rs` - режимы edit/reply/forward
|
|
||||||
- Обработка ввода в режимах редактирования
|
|
||||||
- Input field управление (курсор, backspace, delete)
|
|
||||||
- ~200 строк
|
|
||||||
- [ ] Создать `handlers/modal.rs` - модалки
|
|
||||||
- Delete confirmation
|
|
||||||
- Emoji picker
|
|
||||||
- Profile modal
|
|
||||||
- ~150 строк
|
|
||||||
- [ ] Создать `handlers/search.rs` - поиск
|
|
||||||
- Search mode в чате
|
|
||||||
- Search mode в списке чатов
|
|
||||||
- ~100 строк
|
|
||||||
- [ ] Обновить `main_input.rs` - только роутинг
|
|
||||||
- Определение текущего режима
|
|
||||||
- Делегация в нужный handler
|
|
||||||
- <200 строк
|
|
||||||
|
|
||||||
**Результат:** 1199 строк → 6 файлов по <400 строк
|
|
||||||
|
|
||||||
### Этап 2: Уменьшить app/mod.rs (116 функций → traits) [TODO]
|
|
||||||
|
|
||||||
**Текущая проблема:**
|
|
||||||
- God Object с 116 функциями
|
|
||||||
- Сложно найти нужный метод
|
|
||||||
- Нарушение Single Responsibility Principle
|
|
||||||
|
|
||||||
**План:**
|
|
||||||
- [ ] Создать `app/methods/` директорию
|
|
||||||
- [ ] Создать trait `NavigationMethods`
|
|
||||||
- `next_chat()`, `previous_chat()`
|
|
||||||
- `scroll_up()`, `scroll_down()`
|
|
||||||
- `select_chat()`, `open_chat()`
|
|
||||||
- ~15 методов
|
|
||||||
- [ ] Создать trait `MessageMethods`
|
|
||||||
- `send_message()`, `edit_message()`, `delete_message()`
|
|
||||||
- `reply_to_message()`, `forward_message()`
|
|
||||||
- `select_message()`, `deselect_message()`
|
|
||||||
- ~20 методов
|
|
||||||
- [ ] Создать trait `ComposeMethods`
|
|
||||||
- `enter_edit_mode()`, `enter_reply_mode()`, `enter_forward_mode()`
|
|
||||||
- `handle_input_char()`, `move_cursor_left()`, `move_cursor_right()`
|
|
||||||
- ~15 методов
|
|
||||||
- [ ] Создать trait `SearchMethods`
|
|
||||||
- `start_search()`, `search_next()`, `search_previous()`
|
|
||||||
- `clear_search()`
|
|
||||||
- ~5 методов
|
|
||||||
- [ ] Создать trait `ModalMethods`
|
|
||||||
- `show_delete_confirmation()`, `show_emoji_picker()`
|
|
||||||
- `show_profile()`, `close_modal()`
|
|
||||||
- ~10 методов
|
|
||||||
- [ ] Оставить в `app/mod.rs` только:
|
|
||||||
- Struct definition
|
|
||||||
- Constructor (new, with_client)
|
|
||||||
- Getters/setters для полей
|
|
||||||
- ~30-40 методов
|
|
||||||
|
|
||||||
**Структура:**
|
|
||||||
```rust
|
|
||||||
// app/mod.rs - только core
|
|
||||||
impl<T: TdClientTrait> App<T> {
|
|
||||||
pub fn new() -> Self { ... }
|
|
||||||
pub fn config(&self) -> &Config { ... }
|
|
||||||
}
|
|
||||||
|
|
||||||
// app/methods/navigation.rs
|
|
||||||
pub trait NavigationMethods {
|
|
||||||
fn next_chat(&mut self);
|
|
||||||
fn previous_chat(&mut self);
|
|
||||||
}
|
|
||||||
impl<T: TdClientTrait> NavigationMethods for App<T> { ... }
|
|
||||||
|
|
||||||
// app/methods/messages.rs
|
|
||||||
pub trait MessageMethods {
|
|
||||||
async fn send_message(&mut self, text: String);
|
|
||||||
}
|
|
||||||
impl<T: TdClientTrait> MessageMethods for App<T> { ... }
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Результат:** 116 функций → 6 trait impl блоков
|
- **Footer**: текущий аккаунт + номер `⟨1/2⟩` + бейджи непрочитанных с других аккаунтов
|
||||||
|
- **Быстрое переключение**: `Ctrl+1`..`Ctrl+9` — мгновенный switch без модалки
|
||||||
|
- **Модалка управления** (`Ctrl+A`): список аккаунтов, добавление/удаление, выбор активного
|
||||||
|
|
||||||
### Этап 3: Разбить ui/messages.rs (893 → <300 строк) [TODO]
|
### Модалка переключения аккаунтов
|
||||||
|
|
||||||
**Текущая проблема:**
|
|
||||||
- Весь UI рендеринг сообщений в одном файле
|
|
||||||
- Модалки смешаны с основным рендерингом
|
|
||||||
- Compose bar (input field) в том же файле
|
|
||||||
|
|
||||||
**План:**
|
|
||||||
- [ ] Создать `ui/modals/` директорию
|
|
||||||
- [ ] Создать `modals/delete_confirm.rs`
|
|
||||||
- Рендеринг модалки подтверждения удаления
|
|
||||||
- Обработка y/n input
|
|
||||||
- ~50 строк
|
|
||||||
- [ ] Создать `modals/emoji_picker.rs`
|
|
||||||
- Рендеринг сетки эмодзи
|
|
||||||
- Навигация по сетке
|
|
||||||
- ~100 строк
|
|
||||||
- [ ] Создать `modals/search_modal.rs`
|
|
||||||
- Поиск в чате
|
|
||||||
- Подсветка результатов
|
|
||||||
- Навигация по совпадениям
|
|
||||||
- ~80 строк
|
|
||||||
- [ ] Создать `modals/profile_modal.rs`
|
|
||||||
- Профиль пользователя/чата
|
|
||||||
- Отображение информации
|
|
||||||
- ~100 строк
|
|
||||||
- [ ] Создать `ui/compose_bar.rs`
|
|
||||||
- Поле ввода сообщения
|
|
||||||
- Превью для edit/reply/forward
|
|
||||||
- Курсор, автоматический wrap
|
|
||||||
- ~150 строк
|
|
||||||
- [ ] Оставить в `messages.rs`:
|
|
||||||
- Основной layout сообщений
|
|
||||||
- Рендеринг списка message bubbles
|
|
||||||
- Группировка по дате
|
|
||||||
- Pinned message
|
|
||||||
- ~300 строк
|
|
||||||
|
|
||||||
**Результат:** 893 строки → 6 файлов по <150 строк
|
|
||||||
|
|
||||||
### Этап 4: Разбить tdlib/messages.rs (833 → 2 файла) [TODO]
|
|
||||||
|
|
||||||
**Текущая проблема:**
|
|
||||||
- Смешивается конвертация из TDLib и операции
|
|
||||||
- Большой файл сложно читать
|
|
||||||
|
|
||||||
**План:**
|
|
||||||
- [ ] Создать `tdlib/messages/` директорию
|
|
||||||
- [ ] Создать `messages/convert.rs`
|
|
||||||
- Конвертация MessageContent из TDLib
|
|
||||||
- Парсинг всех типов (Text, Photo, Video, Voice, etc.)
|
|
||||||
- Обработка форматирования (entities)
|
|
||||||
- ~500 строк
|
|
||||||
- [ ] Создать `messages/operations.rs`
|
|
||||||
- send_message(), edit_message(), delete_message()
|
|
||||||
- forward_message(), reply_to_message()
|
|
||||||
- get_chat_history(), load_older_messages()
|
|
||||||
- ~300 строк
|
|
||||||
- [ ] Обновить `tdlib/messages.rs` → `tdlib/messages/mod.rs`
|
|
||||||
- Re-export публичных типов
|
|
||||||
- ~30 строк
|
|
||||||
|
|
||||||
**Результат:** 833 строки → 2 файла по <500 строк
|
|
||||||
|
|
||||||
### Этап 5: Разбить config/mod.rs (642 → 3 файла) [TODO]
|
|
||||||
|
|
||||||
**Текущая проблема:**
|
|
||||||
- Много default_* функций (по 1-3 строки каждая)
|
|
||||||
- Validation logic смешана с определениями
|
|
||||||
- Сложно найти нужную секцию конфига
|
|
||||||
|
|
||||||
**План:**
|
|
||||||
- [ ] Создать `config/defaults.rs`
|
|
||||||
- Все default_* функции
|
|
||||||
- ~100 строк
|
|
||||||
- [ ] Создать `config/validation.rs`
|
|
||||||
- Валидация timezone
|
|
||||||
- Валидация цветов
|
|
||||||
- Валидация notification settings
|
|
||||||
- ~150 строк
|
|
||||||
- [ ] Создать `config/loader.rs`
|
|
||||||
- Загрузка из файла
|
|
||||||
- Поиск путей (XDG, home, etc.)
|
|
||||||
- Обработка ошибок чтения
|
|
||||||
- ~100 строк
|
|
||||||
- [ ] Оставить в `config/mod.rs`:
|
|
||||||
- Struct definitions
|
|
||||||
- Default impls (вызывают defaults.rs)
|
|
||||||
- Re-exports
|
|
||||||
- ~200-300 строк
|
|
||||||
|
|
||||||
**Результат:** 642 строки → 4 файла по <200 строк
|
|
||||||
|
|
||||||
### Этап 6: Code Duplication Cleanup [TODO]
|
|
||||||
|
|
||||||
**План:**
|
|
||||||
- [ ] Найти дублированный код в handlers
|
|
||||||
- Общая логика обработки клавиш
|
|
||||||
- Вынести в `input/common.rs`
|
|
||||||
- [ ] Найти дублированный код в UI
|
|
||||||
- Общие компоненты рендеринга
|
|
||||||
- Вынести в `ui/components/`
|
|
||||||
- [ ] Использовать DRY принцип везде
|
|
||||||
|
|
||||||
### Этап 7: Documentation Update [TODO]
|
|
||||||
|
|
||||||
**План:**
|
|
||||||
- [ ] Обновить CONTEXT.md с новой структурой
|
|
||||||
- [ ] Обновить PROJECT_STRUCTURE.md
|
|
||||||
- [ ] Добавить module-level документацию
|
|
||||||
- [ ] Создать architecture diagram (ASCII)
|
|
||||||
|
|
||||||
### Метрики успеха
|
|
||||||
|
|
||||||
**До рефакторинга:**
|
|
||||||
```
|
```
|
||||||
input/main_input.rs: 1199 строк
|
┌──────────────────────────────────┐
|
||||||
app/mod.rs: 1015 строк (116 функций)
|
│ Аккаунты │
|
||||||
ui/messages.rs: 893 строки
|
│ │
|
||||||
tdlib/messages.rs: 833 строки
|
│ 1. Михаил (+7 900 ...) ● │ ← активный
|
||||||
config/mod.rs: 642 строки
|
│ 2. Work (+7 911 ...) (3) │ ← 3 непрочитанных
|
||||||
ИТОГО: 4582 строки в 5 файлах
|
│ 3. + Добавить аккаунт │
|
||||||
|
│ │
|
||||||
|
│ [j/k навигация, Enter выбор] │
|
||||||
|
│ [d — удалить аккаунт] │
|
||||||
|
└──────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
**После рефакторинга:**
|
### Техническая реализация: все клиенты одновременно
|
||||||
```
|
|
||||||
input/handlers/*.rs: ~6 файлов по <400 строк
|
|
||||||
app/methods/*.rs: ~6 traits с impl блоками
|
|
||||||
ui/modals/*.rs: ~4 файла по <150 строк
|
|
||||||
tdlib/messages/*.rs: 2 файла по <500 строк
|
|
||||||
config/*.rs: 4 файла по <200 строк
|
|
||||||
ИТОГО: те же строки, но в ~20+ файлах
|
|
||||||
```
|
|
||||||
|
|
||||||
**Преимущества:**
|
- **Несколько TdClient**: каждый аккаунт — отдельный `TdClient` со своим `database_directory`
|
||||||
- ✅ Легче найти нужный код
|
- Аккаунт 1: `~/.local/share/tele-tui/accounts/1/tdlib_data/`
|
||||||
- ✅ Легче тестировать модули
|
- Аккаунт 2: `~/.local/share/tele-tui/accounts/2/tdlib_data/`
|
||||||
- ✅ Меньше конфликтов при работе в команде
|
- **Все клиенты активны**: polling updates со всех аккаунтов одновременно (уведомления, непрочитанные)
|
||||||
- ✅ Лучше читаемость и поддерживаемость
|
- **Мгновенное переключение**: swap активного `App.td_client` — чаты и сообщения уже загружены
|
||||||
- ✅ Соблюдение Single Responsibility Principle
|
- **Общий конфиг**: `~/.config/tele-tui/config.toml` (один для всех аккаунтов)
|
||||||
|
- **Профили аккаунтов**: `~/.config/tele-tui/accounts.toml` — список аккаунтов (имя, путь к БД)
|
||||||
|
|
||||||
|
### Этапы
|
||||||
|
|
||||||
|
- [x] **Этап 1: Инфраструктура профилей** (DONE)
|
||||||
|
- Структура `AccountProfile` (name, display_name, db_path)
|
||||||
|
- `accounts.toml` — хранение списка аккаунтов
|
||||||
|
- Миграция `tdlib_data/` → `accounts/default/tdlib_data/` (обратная совместимость)
|
||||||
|
- CLI: `--account <name>` для запуска конкретного аккаунта
|
||||||
|
|
||||||
|
- [x] **Этап 2+3: Account Switcher Modal + Переключение** (DONE)
|
||||||
|
- Подход: single-client reinit (close TDLib → new TdClient → auth)
|
||||||
|
- Модалка `Ctrl+A` — глобальный оверлей с навигацией j/k
|
||||||
|
- Footer индикатор `[account_name]` если не "default"
|
||||||
|
- `AccountSwitcherState` enum (SelectAccount / AddAccount)
|
||||||
|
- `recreate_client()` метод в TdClientTrait (close → new → set_tdlib_parameters)
|
||||||
|
- `add_account()` — создание нового аккаунта из модалки
|
||||||
|
- `pending_account_switch` флаг → обработка в main loop
|
||||||
|
|
||||||
|
- [ ] **Этап 4: Расширенные возможности мультиаккаунта**
|
||||||
|
- Удаление аккаунта из модалки
|
||||||
|
- Хоткеи `Ctrl+1`..`Ctrl+9` — быстрое переключение
|
||||||
|
- Бейджи непрочитанных с других аккаунтов (требует множественных TdClient)
|
||||||
|
- Параллельный polling updates со всех аккаунтов
|
||||||
|
|||||||
209
src/accounts/manager.rs
Normal file
209
src/accounts/manager.rs
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
//! Account manager: loading, saving, migration, and resolution.
|
||||||
|
//!
|
||||||
|
//! Handles `accounts.toml` lifecycle and legacy `./tdlib_data/` migration
|
||||||
|
//! to XDG data directory.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use super::profile::{account_db_path, validate_account_name, AccountsConfig};
|
||||||
|
|
||||||
|
/// Returns the path to `accounts.toml` in the config directory.
|
||||||
|
///
|
||||||
|
/// `~/.config/tele-tui/accounts.toml`
|
||||||
|
pub fn accounts_config_path() -> Option<PathBuf> {
|
||||||
|
dirs::config_dir().map(|mut path| {
|
||||||
|
path.push("tele-tui");
|
||||||
|
path.push("accounts.toml");
|
||||||
|
path
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads `accounts.toml` or creates it with default values.
|
||||||
|
///
|
||||||
|
/// On first run, also attempts to migrate legacy `./tdlib_data/` directory
|
||||||
|
/// to the XDG data location.
|
||||||
|
pub fn load_or_create() -> AccountsConfig {
|
||||||
|
let config_path = match accounts_config_path() {
|
||||||
|
Some(path) => path,
|
||||||
|
None => {
|
||||||
|
tracing::warn!("Could not determine config directory for accounts, using defaults");
|
||||||
|
return AccountsConfig::default_single();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if config_path.exists() {
|
||||||
|
// Load existing config
|
||||||
|
match fs::read_to_string(&config_path) {
|
||||||
|
Ok(content) => match toml::from_str::<AccountsConfig>(&content) {
|
||||||
|
Ok(config) => return config,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Could not parse accounts.toml: {}", e);
|
||||||
|
return AccountsConfig::default_single();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Could not read accounts.toml: {}", e);
|
||||||
|
return AccountsConfig::default_single();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// First run: migrate legacy data if present, then create default config
|
||||||
|
migrate_legacy();
|
||||||
|
|
||||||
|
let config = AccountsConfig::default_single();
|
||||||
|
if let Err(e) = save(&config) {
|
||||||
|
tracing::warn!("Could not save initial accounts.toml: {}", e);
|
||||||
|
}
|
||||||
|
config
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves `AccountsConfig` to `accounts.toml`.
|
||||||
|
pub fn save(config: &AccountsConfig) -> Result<(), String> {
|
||||||
|
let config_path = accounts_config_path()
|
||||||
|
.ok_or_else(|| "Could not determine config directory".to_string())?;
|
||||||
|
|
||||||
|
// Ensure parent directory exists
|
||||||
|
if let Some(parent) = config_path.parent() {
|
||||||
|
fs::create_dir_all(parent)
|
||||||
|
.map_err(|e| format!("Could not create config directory: {}", e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let toml_string = toml::to_string_pretty(config)
|
||||||
|
.map_err(|e| format!("Could not serialize accounts config: {}", e))?;
|
||||||
|
|
||||||
|
fs::write(&config_path, toml_string)
|
||||||
|
.map_err(|e| format!("Could not write accounts.toml: {}", e))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Migrates legacy `./tdlib_data/` from CWD to XDG data dir.
|
||||||
|
///
|
||||||
|
/// If `./tdlib_data/` exists in the current working directory, moves it to
|
||||||
|
/// `~/.local/share/tele-tui/accounts/default/tdlib_data/`.
|
||||||
|
fn migrate_legacy() {
|
||||||
|
let legacy_path = PathBuf::from("tdlib_data");
|
||||||
|
if !legacy_path.exists() || !legacy_path.is_dir() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let target = account_db_path("default");
|
||||||
|
|
||||||
|
// Don't overwrite if target already exists
|
||||||
|
if target.exists() {
|
||||||
|
tracing::info!(
|
||||||
|
"Legacy ./tdlib_data/ found but target already exists at {}, skipping migration",
|
||||||
|
target.display()
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create parent directories
|
||||||
|
if let Some(parent) = target.parent() {
|
||||||
|
if let Err(e) = fs::create_dir_all(parent) {
|
||||||
|
tracing::error!("Could not create target directory for migration: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move (rename) the directory
|
||||||
|
match fs::rename(&legacy_path, &target) {
|
||||||
|
Ok(()) => {
|
||||||
|
tracing::info!(
|
||||||
|
"Migrated ./tdlib_data/ -> {}",
|
||||||
|
target.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(
|
||||||
|
"Could not migrate ./tdlib_data/ to {}: {}",
|
||||||
|
target.display(),
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves which account to use from CLI arg or default.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `config` - The loaded accounts configuration
|
||||||
|
/// * `account_arg` - Optional account name from `--account` CLI flag
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// The resolved account name and its db_path.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the specified account is not found or the name is invalid.
|
||||||
|
pub fn resolve_account(
|
||||||
|
config: &AccountsConfig,
|
||||||
|
account_arg: Option<&str>,
|
||||||
|
) -> Result<(String, PathBuf), String> {
|
||||||
|
let account_name = account_arg.unwrap_or(&config.default_account);
|
||||||
|
|
||||||
|
// Validate name
|
||||||
|
validate_account_name(account_name)?;
|
||||||
|
|
||||||
|
// Find account in config
|
||||||
|
let _account = config.find_account(account_name).ok_or_else(|| {
|
||||||
|
let available: Vec<&str> = config.accounts.iter().map(|a| a.name.as_str()).collect();
|
||||||
|
format!(
|
||||||
|
"Account '{}' not found. Available accounts: {}",
|
||||||
|
account_name,
|
||||||
|
available.join(", ")
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let db_path = account_db_path(account_name);
|
||||||
|
Ok((account_name.to_string(), db_path))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a new account to `accounts.toml` and creates its data directory.
|
||||||
|
///
|
||||||
|
/// Validates the name, checks for duplicates, adds the profile to config,
|
||||||
|
/// saves the config, and creates the data directory.
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// The db_path for the new account.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns an error if the name is invalid, already exists, or I/O fails.
|
||||||
|
pub fn add_account(name: &str, display_name: &str) -> Result<std::path::PathBuf, String> {
|
||||||
|
validate_account_name(name)?;
|
||||||
|
|
||||||
|
let mut config = load_or_create();
|
||||||
|
|
||||||
|
// Check for duplicate
|
||||||
|
if config.find_account(name).is_some() {
|
||||||
|
return Err(format!("Account '{}' already exists", name));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new profile
|
||||||
|
config.accounts.push(super::profile::AccountProfile {
|
||||||
|
name: name.to_string(),
|
||||||
|
display_name: display_name.to_string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save config
|
||||||
|
save(&config)?;
|
||||||
|
|
||||||
|
// Create data directory
|
||||||
|
ensure_account_dir(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensures the account data directory exists.
|
||||||
|
///
|
||||||
|
/// Creates `~/.local/share/tele-tui/accounts/{name}/tdlib_data/` if needed.
|
||||||
|
pub fn ensure_account_dir(account_name: &str) -> Result<PathBuf, String> {
|
||||||
|
let db_path = account_db_path(account_name);
|
||||||
|
fs::create_dir_all(&db_path)
|
||||||
|
.map_err(|e| format!("Could not create account directory: {}", e))?;
|
||||||
|
Ok(db_path)
|
||||||
|
}
|
||||||
11
src/accounts/mod.rs
Normal file
11
src/accounts/mod.rs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
//! Account profiles module for multi-account support.
|
||||||
|
//!
|
||||||
|
//! Manages account profiles stored in `~/.config/tele-tui/accounts.toml`.
|
||||||
|
//! Each account has its own TDLib database directory under
|
||||||
|
//! `~/.local/share/tele-tui/accounts/{name}/tdlib_data/`.
|
||||||
|
|
||||||
|
pub mod manager;
|
||||||
|
pub mod profile;
|
||||||
|
|
||||||
|
pub use manager::{add_account, ensure_account_dir, load_or_create, resolve_account, save};
|
||||||
|
pub use profile::{account_db_path, validate_account_name, AccountProfile, AccountsConfig};
|
||||||
147
src/accounts/profile.rs
Normal file
147
src/accounts/profile.rs
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
//! Account profile data structures and validation.
|
||||||
|
//!
|
||||||
|
//! Defines `AccountProfile` and `AccountsConfig` for multi-account support.
|
||||||
|
//! Account names are validated to contain only alphanumeric characters, hyphens, and underscores.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// Configuration for all accounts, stored in `~/.config/tele-tui/accounts.toml`.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AccountsConfig {
|
||||||
|
/// Name of the default account to use when no `--account` flag is provided.
|
||||||
|
pub default_account: String,
|
||||||
|
|
||||||
|
/// List of configured accounts.
|
||||||
|
pub accounts: Vec<AccountProfile>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single account profile.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AccountProfile {
|
||||||
|
/// Unique identifier (used in directory names and CLI flag).
|
||||||
|
pub name: String,
|
||||||
|
|
||||||
|
/// Human-readable display name.
|
||||||
|
pub display_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AccountsConfig {
|
||||||
|
/// Creates a default config with a single "default" account.
|
||||||
|
pub fn default_single() -> Self {
|
||||||
|
Self {
|
||||||
|
default_account: "default".to_string(),
|
||||||
|
accounts: vec![AccountProfile {
|
||||||
|
name: "default".to_string(),
|
||||||
|
display_name: "Default".to_string(),
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finds an account by name.
|
||||||
|
pub fn find_account(&self, name: &str) -> Option<&AccountProfile> {
|
||||||
|
self.accounts.iter().find(|a| a.name == name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AccountProfile {
|
||||||
|
/// Computes the TDLib database directory path for this account.
|
||||||
|
///
|
||||||
|
/// Returns `~/.local/share/tele-tui/accounts/{name}/tdlib_data`
|
||||||
|
/// (or platform equivalent via `dirs::data_dir()`).
|
||||||
|
pub fn db_path(&self) -> PathBuf {
|
||||||
|
account_db_path(&self.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Computes the TDLib database directory path for a given account name.
|
||||||
|
///
|
||||||
|
/// Returns `{data_dir}/tele-tui/accounts/{name}/tdlib_data`.
|
||||||
|
pub fn account_db_path(account_name: &str) -> PathBuf {
|
||||||
|
let mut path = dirs::data_dir().unwrap_or_else(|| PathBuf::from("."));
|
||||||
|
path.push("tele-tui");
|
||||||
|
path.push("accounts");
|
||||||
|
path.push(account_name);
|
||||||
|
path.push("tdlib_data");
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates an account name.
|
||||||
|
///
|
||||||
|
/// Valid names contain only lowercase alphanumeric characters, hyphens, and underscores.
|
||||||
|
/// Must be 1-32 characters long.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns a descriptive error message if the name is invalid.
|
||||||
|
pub fn validate_account_name(name: &str) -> Result<(), String> {
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err("Account name cannot be empty".to_string());
|
||||||
|
}
|
||||||
|
if name.len() > 32 {
|
||||||
|
return Err("Account name cannot be longer than 32 characters".to_string());
|
||||||
|
}
|
||||||
|
if !name
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
|
||||||
|
{
|
||||||
|
return Err(
|
||||||
|
"Account name can only contain lowercase letters, digits, hyphens, and underscores"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if name.starts_with('-') || name.starts_with('_') {
|
||||||
|
return Err("Account name cannot start with a hyphen or underscore".to_string());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_account_name_valid() {
|
||||||
|
assert!(validate_account_name("default").is_ok());
|
||||||
|
assert!(validate_account_name("work").is_ok());
|
||||||
|
assert!(validate_account_name("my-account").is_ok());
|
||||||
|
assert!(validate_account_name("account_2").is_ok());
|
||||||
|
assert!(validate_account_name("a").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_account_name_invalid() {
|
||||||
|
assert!(validate_account_name("").is_err());
|
||||||
|
assert!(validate_account_name("My Account").is_err());
|
||||||
|
assert!(validate_account_name("UPPER").is_err());
|
||||||
|
assert!(validate_account_name("with spaces").is_err());
|
||||||
|
assert!(validate_account_name("-starts-with-dash").is_err());
|
||||||
|
assert!(validate_account_name("_starts-with-underscore").is_err());
|
||||||
|
assert!(validate_account_name(&"a".repeat(33)).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_single_config() {
|
||||||
|
let config = AccountsConfig::default_single();
|
||||||
|
assert_eq!(config.default_account, "default");
|
||||||
|
assert_eq!(config.accounts.len(), 1);
|
||||||
|
assert_eq!(config.accounts[0].name, "default");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_find_account() {
|
||||||
|
let config = AccountsConfig::default_single();
|
||||||
|
assert!(config.find_account("default").is_some());
|
||||||
|
assert!(config.find_account("nonexistent").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_db_path_contains_account_name() {
|
||||||
|
let path = account_db_path("work");
|
||||||
|
let path_str = path.to_string_lossy();
|
||||||
|
assert!(path_str.contains("tele-tui"));
|
||||||
|
assert!(path_str.contains("accounts"));
|
||||||
|
assert!(path_str.contains("work"));
|
||||||
|
assert!(path_str.ends_with("tdlib_data"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,16 @@
|
|||||||
use crate::tdlib::{MessageInfo, ProfileInfo};
|
use crate::tdlib::{MessageInfo, ProfileInfo};
|
||||||
use crate::types::MessageId;
|
use crate::types::MessageId;
|
||||||
|
|
||||||
|
/// Vim-like input mode for chat view
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum InputMode {
|
||||||
|
/// Normal mode — navigation and commands (default)
|
||||||
|
#[default]
|
||||||
|
Normal,
|
||||||
|
/// Insert mode — text input only
|
||||||
|
Insert,
|
||||||
|
}
|
||||||
|
|
||||||
/// Состояния чата - взаимоисключающие режимы работы с чатом
|
/// Состояния чата - взаимоисключающие режимы работы с чатом
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum ChatState {
|
pub enum ChatState {
|
||||||
|
|||||||
121
src/app/methods/compose.rs
Normal file
121
src/app/methods/compose.rs
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
//! Compose methods for App
|
||||||
|
//!
|
||||||
|
//! Handles reply, forward, and draft functionality
|
||||||
|
|
||||||
|
use crate::app::{App, ChatState};
|
||||||
|
use crate::app::methods::messages::MessageMethods;
|
||||||
|
use crate::tdlib::{MessageInfo, TdClientTrait};
|
||||||
|
|
||||||
|
/// Compose methods for reply/forward/draft
|
||||||
|
pub trait ComposeMethods<T: TdClientTrait> {
|
||||||
|
/// Start replying to the selected message
|
||||||
|
/// Returns true if reply mode started, false if no message selected
|
||||||
|
fn start_reply_to_selected(&mut self) -> bool;
|
||||||
|
|
||||||
|
/// Cancel reply mode
|
||||||
|
fn cancel_reply(&mut self);
|
||||||
|
|
||||||
|
/// Check if currently in reply mode
|
||||||
|
fn is_replying(&self) -> bool;
|
||||||
|
|
||||||
|
/// Get the message being replied to
|
||||||
|
fn get_replying_to_message(&self) -> Option<MessageInfo>;
|
||||||
|
|
||||||
|
/// Start forwarding the selected message
|
||||||
|
/// Returns true if forward mode started, false if no message selected
|
||||||
|
fn start_forward_selected(&mut self) -> bool;
|
||||||
|
|
||||||
|
/// Cancel forward mode
|
||||||
|
fn cancel_forward(&mut self);
|
||||||
|
|
||||||
|
/// Check if currently in forward mode (selecting target chat)
|
||||||
|
fn is_forwarding(&self) -> bool;
|
||||||
|
|
||||||
|
/// Get the message being forwarded
|
||||||
|
fn get_forwarding_message(&self) -> Option<MessageInfo>;
|
||||||
|
|
||||||
|
/// Get draft for the currently selected chat
|
||||||
|
fn get_current_draft(&self) -> Option<String>;
|
||||||
|
|
||||||
|
/// Load draft into message_input (called when opening chat)
|
||||||
|
fn load_draft(&mut self);
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: TdClientTrait> ComposeMethods<T> for App<T> {
|
||||||
|
fn start_reply_to_selected(&mut self) -> bool {
|
||||||
|
if let Some(msg) = self.get_selected_message() {
|
||||||
|
self.chat_state = ChatState::Reply {
|
||||||
|
message_id: msg.id(),
|
||||||
|
};
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cancel_reply(&mut self) {
|
||||||
|
self.chat_state = ChatState::Normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_replying(&self) -> bool {
|
||||||
|
self.chat_state.is_reply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_replying_to_message(&self) -> Option<MessageInfo> {
|
||||||
|
self.chat_state.selected_message_id().and_then(|id| {
|
||||||
|
self.td_client
|
||||||
|
.current_chat_messages()
|
||||||
|
.iter()
|
||||||
|
.find(|m| m.id() == id)
|
||||||
|
.cloned()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_forward_selected(&mut self) -> bool {
|
||||||
|
if let Some(msg) = self.get_selected_message() {
|
||||||
|
self.chat_state = ChatState::Forward {
|
||||||
|
message_id: msg.id(),
|
||||||
|
};
|
||||||
|
// Сбрасываем выбор чата на первый
|
||||||
|
self.chat_list_state.select(Some(0));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cancel_forward(&mut self) {
|
||||||
|
self.chat_state = ChatState::Normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_forwarding(&self) -> bool {
|
||||||
|
self.chat_state.is_forward()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_forwarding_message(&self) -> Option<MessageInfo> {
|
||||||
|
if !self.chat_state.is_forward() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.chat_state.selected_message_id().and_then(|id| {
|
||||||
|
self.td_client
|
||||||
|
.current_chat_messages()
|
||||||
|
.iter()
|
||||||
|
.find(|m| m.id() == id)
|
||||||
|
.cloned()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_current_draft(&self) -> Option<String> {
|
||||||
|
self.selected_chat_id.and_then(|chat_id| {
|
||||||
|
self.chats
|
||||||
|
.iter()
|
||||||
|
.find(|c| c.id == chat_id)
|
||||||
|
.and_then(|c| c.draft_text.clone())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_draft(&mut self) {
|
||||||
|
if let Some(draft) = self.get_current_draft() {
|
||||||
|
self.message_input = draft;
|
||||||
|
self.cursor_position = self.message_input.chars().count();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
183
src/app/methods/messages.rs
Normal file
183
src/app/methods/messages.rs
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
//! Message methods for App
|
||||||
|
//!
|
||||||
|
//! Handles message selection, editing, and operations
|
||||||
|
|
||||||
|
use crate::app::{App, ChatState};
|
||||||
|
use crate::tdlib::{MessageInfo, TdClientTrait};
|
||||||
|
|
||||||
|
/// Message operation methods
|
||||||
|
pub trait MessageMethods<T: TdClientTrait> {
|
||||||
|
/// Start message selection mode (triggered by Up arrow in empty input)
|
||||||
|
fn start_message_selection(&mut self);
|
||||||
|
|
||||||
|
/// Select previous message (up in history = older)
|
||||||
|
fn select_previous_message(&mut self);
|
||||||
|
|
||||||
|
/// Select next message (down in history = newer)
|
||||||
|
fn select_next_message(&mut self);
|
||||||
|
|
||||||
|
/// Get currently selected message
|
||||||
|
fn get_selected_message(&self) -> Option<MessageInfo>;
|
||||||
|
|
||||||
|
/// Start editing the selected message
|
||||||
|
/// Returns true if editing started, false if message cannot be edited
|
||||||
|
fn start_editing_selected(&mut self) -> bool;
|
||||||
|
|
||||||
|
/// Cancel message editing and clear input
|
||||||
|
fn cancel_editing(&mut self);
|
||||||
|
|
||||||
|
/// Check if currently in editing mode
|
||||||
|
fn is_editing(&self) -> bool;
|
||||||
|
|
||||||
|
/// Check if currently in message selection mode
|
||||||
|
fn is_selecting_message(&self) -> bool;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: TdClientTrait> MessageMethods<T> for App<T> {
|
||||||
|
fn start_message_selection(&mut self) {
|
||||||
|
let messages = self.td_client.current_chat_messages();
|
||||||
|
let total = messages.len();
|
||||||
|
if total == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Начинаем с последнего сообщения (индекс len-1 = самое новое внизу)
|
||||||
|
// Если оно часть альбома — перемещаемся к первому элементу альбома
|
||||||
|
let mut idx = total - 1;
|
||||||
|
let album_id = messages[idx].media_album_id();
|
||||||
|
if album_id != 0 {
|
||||||
|
while idx > 0 && messages[idx - 1].media_album_id() == album_id {
|
||||||
|
idx -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.chat_state = ChatState::MessageSelection { selected_index: idx };
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_previous_message(&mut self) {
|
||||||
|
if let ChatState::MessageSelection { selected_index } = &mut self.chat_state {
|
||||||
|
if *selected_index > 0 {
|
||||||
|
let messages = self.td_client.current_chat_messages();
|
||||||
|
let current_album_id = messages[*selected_index].media_album_id();
|
||||||
|
|
||||||
|
// Перескакиваем через все сообщения текущего альбома назад
|
||||||
|
let mut new_index = *selected_index - 1;
|
||||||
|
if current_album_id != 0 {
|
||||||
|
while new_index > 0
|
||||||
|
&& messages[new_index].media_album_id() == current_album_id
|
||||||
|
{
|
||||||
|
new_index -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если попали в середину другого альбома — перемещаемся к его первому элементу
|
||||||
|
let target_album_id = messages[new_index].media_album_id();
|
||||||
|
if target_album_id != 0 {
|
||||||
|
while new_index > 0
|
||||||
|
&& messages[new_index - 1].media_album_id() == target_album_id
|
||||||
|
{
|
||||||
|
new_index -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*selected_index = new_index;
|
||||||
|
self.stop_playback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_next_message(&mut self) {
|
||||||
|
let total = self.td_client.current_chat_messages().len();
|
||||||
|
if total == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let ChatState::MessageSelection { selected_index } = &mut self.chat_state {
|
||||||
|
if *selected_index < total - 1 {
|
||||||
|
let messages = self.td_client.current_chat_messages();
|
||||||
|
let current_album_id = messages[*selected_index].media_album_id();
|
||||||
|
|
||||||
|
// Перескакиваем через все сообщения текущего альбома вперёд
|
||||||
|
let mut new_index = *selected_index + 1;
|
||||||
|
if current_album_id != 0 {
|
||||||
|
while new_index < total - 1
|
||||||
|
&& messages[new_index].media_album_id() == current_album_id
|
||||||
|
{
|
||||||
|
new_index += 1;
|
||||||
|
}
|
||||||
|
// Если мы ещё на последнем элементе альбома — нужно шагнуть на следующее
|
||||||
|
if messages[new_index].media_album_id() == current_album_id
|
||||||
|
&& new_index < total - 1
|
||||||
|
{
|
||||||
|
new_index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if new_index >= total {
|
||||||
|
self.chat_state = ChatState::Normal;
|
||||||
|
} else {
|
||||||
|
*selected_index = new_index;
|
||||||
|
}
|
||||||
|
self.stop_playback();
|
||||||
|
} else {
|
||||||
|
// Дошли до самого нового сообщения - выходим из режима выбора
|
||||||
|
self.chat_state = ChatState::Normal;
|
||||||
|
self.stop_playback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_selected_message(&self) -> Option<MessageInfo> {
|
||||||
|
self.chat_state.selected_message_index().and_then(|idx| {
|
||||||
|
self.td_client.current_chat_messages().get(idx).cloned()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_editing_selected(&mut self) -> bool {
|
||||||
|
// Получаем selected_index из текущего состояния
|
||||||
|
let selected_idx = match &self.chat_state {
|
||||||
|
ChatState::MessageSelection { selected_index } => Some(*selected_index),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
if selected_idx.is_none() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сначала извлекаем данные из сообщения
|
||||||
|
let msg_data = self.get_selected_message().and_then(|msg| {
|
||||||
|
// Проверяем:
|
||||||
|
// 1. Можно редактировать
|
||||||
|
// 2. Это исходящее сообщение
|
||||||
|
// 3. ID не временный (временные ID в TDLib отрицательные)
|
||||||
|
if msg.can_be_edited() && msg.is_outgoing() && msg.id().as_i64() > 0 {
|
||||||
|
Some((msg.id(), msg.text().to_string(), selected_idx.unwrap()))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Затем присваиваем
|
||||||
|
if let Some((id, content, idx)) = msg_data {
|
||||||
|
self.cursor_position = content.chars().count();
|
||||||
|
self.message_input = content;
|
||||||
|
self.chat_state = ChatState::Editing {
|
||||||
|
message_id: id,
|
||||||
|
selected_index: idx,
|
||||||
|
};
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cancel_editing(&mut self) {
|
||||||
|
self.chat_state = ChatState::Normal;
|
||||||
|
self.message_input.clear();
|
||||||
|
self.cursor_position = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_editing(&self) -> bool {
|
||||||
|
self.chat_state.is_editing()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_selecting_message(&self) -> bool {
|
||||||
|
self.chat_state.is_message_selection()
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/app/methods/mod.rs
Normal file
20
src/app/methods/mod.rs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
//! App methods organized by functionality
|
||||||
|
//!
|
||||||
|
//! This module contains traits that organize App methods into logical groups:
|
||||||
|
//! - navigation: Chat list navigation
|
||||||
|
//! - messages: Message operations and selection
|
||||||
|
//! - compose: Reply/Forward/Draft functionality
|
||||||
|
//! - search: Search in chats and messages
|
||||||
|
//! - modal: Modal dialogs (Profile, Pinned, Reactions, Delete)
|
||||||
|
|
||||||
|
pub mod navigation;
|
||||||
|
pub mod messages;
|
||||||
|
pub mod compose;
|
||||||
|
pub mod search;
|
||||||
|
pub mod modal;
|
||||||
|
|
||||||
|
pub use navigation::NavigationMethods;
|
||||||
|
pub use messages::MessageMethods;
|
||||||
|
pub use compose::ComposeMethods;
|
||||||
|
pub use search::SearchMethods;
|
||||||
|
pub use modal::ModalMethods;
|
||||||
308
src/app/methods/modal.rs
Normal file
308
src/app/methods/modal.rs
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
//! Modal methods for App
|
||||||
|
//!
|
||||||
|
//! Handles modal dialogs: Profile, Pinned Messages, Reactions, Delete Confirmation
|
||||||
|
|
||||||
|
use crate::app::{App, ChatState};
|
||||||
|
use crate::tdlib::{MessageInfo, ProfileInfo, TdClientTrait};
|
||||||
|
use crate::types::MessageId;
|
||||||
|
|
||||||
|
/// Modal dialog methods
|
||||||
|
pub trait ModalMethods<T: TdClientTrait> {
|
||||||
|
// === Delete Confirmation ===
|
||||||
|
|
||||||
|
/// Check if delete confirmation modal is shown
|
||||||
|
fn is_confirm_delete_shown(&self) -> bool;
|
||||||
|
|
||||||
|
// === Pinned Messages ===
|
||||||
|
|
||||||
|
/// Check if in pinned messages mode
|
||||||
|
fn is_pinned_mode(&self) -> bool;
|
||||||
|
|
||||||
|
/// Enter pinned messages mode
|
||||||
|
fn enter_pinned_mode(&mut self, messages: Vec<MessageInfo>);
|
||||||
|
|
||||||
|
/// Exit pinned messages mode
|
||||||
|
fn exit_pinned_mode(&mut self);
|
||||||
|
|
||||||
|
/// Select previous pinned message (up = older)
|
||||||
|
fn select_previous_pinned(&mut self);
|
||||||
|
|
||||||
|
/// Select next pinned message (down = newer)
|
||||||
|
fn select_next_pinned(&mut self);
|
||||||
|
|
||||||
|
/// Get currently selected pinned message
|
||||||
|
fn get_selected_pinned(&self) -> Option<&MessageInfo>;
|
||||||
|
|
||||||
|
/// Get ID of selected pinned message for navigation
|
||||||
|
fn get_selected_pinned_id(&self) -> Option<i64>;
|
||||||
|
|
||||||
|
// === Profile ===
|
||||||
|
|
||||||
|
/// Check if in profile mode
|
||||||
|
fn is_profile_mode(&self) -> bool;
|
||||||
|
|
||||||
|
/// Enter profile mode
|
||||||
|
fn enter_profile_mode(&mut self, info: ProfileInfo);
|
||||||
|
|
||||||
|
/// Exit profile mode
|
||||||
|
fn exit_profile_mode(&mut self);
|
||||||
|
|
||||||
|
/// Select previous profile action
|
||||||
|
fn select_previous_profile_action(&mut self);
|
||||||
|
|
||||||
|
/// Select next profile action
|
||||||
|
fn select_next_profile_action(&mut self, max_actions: usize);
|
||||||
|
|
||||||
|
/// Show first leave group confirmation
|
||||||
|
fn show_leave_group_confirmation(&mut self);
|
||||||
|
|
||||||
|
/// Show second leave group confirmation
|
||||||
|
fn show_leave_group_final_confirmation(&mut self);
|
||||||
|
|
||||||
|
/// Cancel leave group confirmation
|
||||||
|
fn cancel_leave_group(&mut self);
|
||||||
|
|
||||||
|
/// Get current leave group confirmation step (0, 1, or 2)
|
||||||
|
fn get_leave_group_confirmation_step(&self) -> u8;
|
||||||
|
|
||||||
|
/// Get profile info
|
||||||
|
fn get_profile_info(&self) -> Option<&ProfileInfo>;
|
||||||
|
|
||||||
|
/// Get selected profile action index
|
||||||
|
fn get_selected_profile_action(&self) -> Option<usize>;
|
||||||
|
|
||||||
|
// === Reactions ===
|
||||||
|
|
||||||
|
/// Check if in reaction picker mode
|
||||||
|
fn is_reaction_picker_mode(&self) -> bool;
|
||||||
|
|
||||||
|
/// Enter reaction picker mode
|
||||||
|
fn enter_reaction_picker_mode(&mut self, message_id: i64, available_reactions: Vec<String>);
|
||||||
|
|
||||||
|
/// Exit reaction picker mode
|
||||||
|
fn exit_reaction_picker_mode(&mut self);
|
||||||
|
|
||||||
|
/// Select previous reaction
|
||||||
|
fn select_previous_reaction(&mut self);
|
||||||
|
|
||||||
|
/// Select next reaction
|
||||||
|
fn select_next_reaction(&mut self);
|
||||||
|
|
||||||
|
/// Get currently selected reaction emoji
|
||||||
|
fn get_selected_reaction(&self) -> Option<&String>;
|
||||||
|
|
||||||
|
/// Get message ID for which reaction is being selected
|
||||||
|
fn get_selected_message_for_reaction(&self) -> Option<i64>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: TdClientTrait> ModalMethods<T> for App<T> {
|
||||||
|
fn is_confirm_delete_shown(&self) -> bool {
|
||||||
|
self.chat_state.is_delete_confirmation()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_pinned_mode(&self) -> bool {
|
||||||
|
self.chat_state.is_pinned_mode()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enter_pinned_mode(&mut self, messages: Vec<MessageInfo>) {
|
||||||
|
if !messages.is_empty() {
|
||||||
|
self.chat_state = ChatState::PinnedMessages {
|
||||||
|
messages,
|
||||||
|
selected_index: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exit_pinned_mode(&mut self) {
|
||||||
|
self.chat_state = ChatState::Normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_previous_pinned(&mut self) {
|
||||||
|
if let ChatState::PinnedMessages {
|
||||||
|
selected_index,
|
||||||
|
messages,
|
||||||
|
} = &mut self.chat_state
|
||||||
|
{
|
||||||
|
if *selected_index + 1 < messages.len() {
|
||||||
|
*selected_index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_next_pinned(&mut self) {
|
||||||
|
if let ChatState::PinnedMessages { selected_index, .. } = &mut self.chat_state {
|
||||||
|
if *selected_index > 0 {
|
||||||
|
*selected_index -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_selected_pinned(&self) -> Option<&MessageInfo> {
|
||||||
|
if let ChatState::PinnedMessages {
|
||||||
|
messages,
|
||||||
|
selected_index,
|
||||||
|
} = &self.chat_state
|
||||||
|
{
|
||||||
|
messages.get(*selected_index)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_selected_pinned_id(&self) -> Option<i64> {
|
||||||
|
self.get_selected_pinned().map(|m| m.id().as_i64())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_profile_mode(&self) -> bool {
|
||||||
|
self.chat_state.is_profile()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enter_profile_mode(&mut self, info: ProfileInfo) {
|
||||||
|
self.chat_state = ChatState::Profile {
|
||||||
|
info,
|
||||||
|
selected_action: 0,
|
||||||
|
leave_group_confirmation_step: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exit_profile_mode(&mut self) {
|
||||||
|
self.chat_state = ChatState::Normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_previous_profile_action(&mut self) {
|
||||||
|
if let ChatState::Profile {
|
||||||
|
selected_action, ..
|
||||||
|
} = &mut self.chat_state
|
||||||
|
{
|
||||||
|
if *selected_action > 0 {
|
||||||
|
*selected_action -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_next_profile_action(&mut self, max_actions: usize) {
|
||||||
|
if let ChatState::Profile {
|
||||||
|
selected_action, ..
|
||||||
|
} = &mut self.chat_state
|
||||||
|
{
|
||||||
|
if *selected_action < max_actions.saturating_sub(1) {
|
||||||
|
*selected_action += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn show_leave_group_confirmation(&mut self) {
|
||||||
|
if let ChatState::Profile {
|
||||||
|
leave_group_confirmation_step,
|
||||||
|
..
|
||||||
|
} = &mut self.chat_state
|
||||||
|
{
|
||||||
|
*leave_group_confirmation_step = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn show_leave_group_final_confirmation(&mut self) {
|
||||||
|
if let ChatState::Profile {
|
||||||
|
leave_group_confirmation_step,
|
||||||
|
..
|
||||||
|
} = &mut self.chat_state
|
||||||
|
{
|
||||||
|
*leave_group_confirmation_step = 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cancel_leave_group(&mut self) {
|
||||||
|
if let ChatState::Profile {
|
||||||
|
leave_group_confirmation_step,
|
||||||
|
..
|
||||||
|
} = &mut self.chat_state
|
||||||
|
{
|
||||||
|
*leave_group_confirmation_step = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_leave_group_confirmation_step(&self) -> u8 {
|
||||||
|
if let ChatState::Profile {
|
||||||
|
leave_group_confirmation_step,
|
||||||
|
..
|
||||||
|
} = &self.chat_state
|
||||||
|
{
|
||||||
|
*leave_group_confirmation_step
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_profile_info(&self) -> Option<&ProfileInfo> {
|
||||||
|
if let ChatState::Profile { info, .. } = &self.chat_state {
|
||||||
|
Some(info)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_selected_profile_action(&self) -> Option<usize> {
|
||||||
|
if let ChatState::Profile {
|
||||||
|
selected_action, ..
|
||||||
|
} = &self.chat_state
|
||||||
|
{
|
||||||
|
Some(*selected_action)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_reaction_picker_mode(&self) -> bool {
|
||||||
|
self.chat_state.is_reaction_picker()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enter_reaction_picker_mode(&mut self, message_id: i64, available_reactions: Vec<String>) {
|
||||||
|
self.chat_state = ChatState::ReactionPicker {
|
||||||
|
message_id: MessageId::new(message_id),
|
||||||
|
available_reactions,
|
||||||
|
selected_index: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exit_reaction_picker_mode(&mut self) {
|
||||||
|
self.chat_state = ChatState::Normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_previous_reaction(&mut self) {
|
||||||
|
if let ChatState::ReactionPicker { selected_index, .. } = &mut self.chat_state {
|
||||||
|
if *selected_index > 0 {
|
||||||
|
*selected_index -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_next_reaction(&mut self) {
|
||||||
|
if let ChatState::ReactionPicker {
|
||||||
|
selected_index,
|
||||||
|
available_reactions,
|
||||||
|
..
|
||||||
|
} = &mut self.chat_state
|
||||||
|
{
|
||||||
|
if *selected_index + 1 < available_reactions.len() {
|
||||||
|
*selected_index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_selected_reaction(&self) -> Option<&String> {
|
||||||
|
if let ChatState::ReactionPicker {
|
||||||
|
available_reactions,
|
||||||
|
selected_index,
|
||||||
|
..
|
||||||
|
} = &self.chat_state
|
||||||
|
{
|
||||||
|
available_reactions.get(*selected_index)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_selected_message_for_reaction(&self) -> Option<i64> {
|
||||||
|
self.chat_state.selected_message_id().map(|id| id.as_i64())
|
||||||
|
}
|
||||||
|
}
|
||||||
146
src/app/methods/navigation.rs
Normal file
146
src/app/methods/navigation.rs
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
//! Navigation methods for App
|
||||||
|
//!
|
||||||
|
//! Handles chat list navigation and selection
|
||||||
|
|
||||||
|
use crate::app::{App, ChatState, InputMode};
|
||||||
|
use crate::app::methods::search::SearchMethods;
|
||||||
|
use crate::tdlib::TdClientTrait;
|
||||||
|
|
||||||
|
/// Navigation methods for chat list
|
||||||
|
pub trait NavigationMethods<T: TdClientTrait> {
|
||||||
|
/// Move to next chat in the list (wraps around)
|
||||||
|
fn next_chat(&mut self);
|
||||||
|
|
||||||
|
/// Move to previous chat in the list (wraps around)
|
||||||
|
fn previous_chat(&mut self);
|
||||||
|
|
||||||
|
/// Select currently highlighted chat
|
||||||
|
fn select_current_chat(&mut self);
|
||||||
|
|
||||||
|
/// Close currently open chat and reset state
|
||||||
|
fn close_chat(&mut self);
|
||||||
|
|
||||||
|
/// Move to next filtered chat (considering search query)
|
||||||
|
fn next_filtered_chat(&mut self);
|
||||||
|
|
||||||
|
/// Move to previous filtered chat (considering search query)
|
||||||
|
fn previous_filtered_chat(&mut self);
|
||||||
|
|
||||||
|
/// Select currently highlighted filtered chat
|
||||||
|
fn select_filtered_chat(&mut self);
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: TdClientTrait> NavigationMethods<T> for App<T> {
|
||||||
|
fn next_chat(&mut self) {
|
||||||
|
let filtered = self.get_filtered_chats();
|
||||||
|
if filtered.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let i = match self.chat_list_state.selected() {
|
||||||
|
Some(i) => {
|
||||||
|
if i >= filtered.len() - 1 {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
i + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => 0,
|
||||||
|
};
|
||||||
|
self.chat_list_state.select(Some(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn previous_chat(&mut self) {
|
||||||
|
let filtered = self.get_filtered_chats();
|
||||||
|
if filtered.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let i = match self.chat_list_state.selected() {
|
||||||
|
Some(i) => {
|
||||||
|
if i == 0 {
|
||||||
|
filtered.len() - 1
|
||||||
|
} else {
|
||||||
|
i - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => 0,
|
||||||
|
};
|
||||||
|
self.chat_list_state.select(Some(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_current_chat(&mut self) {
|
||||||
|
let filtered = self.get_filtered_chats();
|
||||||
|
if let Some(i) = self.chat_list_state.selected() {
|
||||||
|
if let Some(chat) = filtered.get(i) {
|
||||||
|
self.selected_chat_id = Some(chat.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn close_chat(&mut self) {
|
||||||
|
self.selected_chat_id = None;
|
||||||
|
self.message_input.clear();
|
||||||
|
self.cursor_position = 0;
|
||||||
|
self.message_scroll_offset = 0;
|
||||||
|
self.last_typing_sent = None;
|
||||||
|
self.pending_chat_init = None;
|
||||||
|
// Останавливаем фоновую загрузку фото (drop receiver)
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
{
|
||||||
|
self.photo_download_rx = None;
|
||||||
|
}
|
||||||
|
// Сбрасываем состояние чата в нормальный режим
|
||||||
|
self.chat_state = ChatState::Normal;
|
||||||
|
self.input_mode = InputMode::Normal;
|
||||||
|
// Очищаем данные в TdClient
|
||||||
|
self.td_client.set_current_chat_id(None);
|
||||||
|
self.td_client.clear_current_chat_messages();
|
||||||
|
self.td_client.set_typing_status(None);
|
||||||
|
self.td_client.set_current_pinned_message(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next_filtered_chat(&mut self) {
|
||||||
|
let filtered = self.get_filtered_chats();
|
||||||
|
if filtered.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let i = match self.chat_list_state.selected() {
|
||||||
|
Some(i) => {
|
||||||
|
if i >= filtered.len() - 1 {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
i + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => 0,
|
||||||
|
};
|
||||||
|
self.chat_list_state.select(Some(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn previous_filtered_chat(&mut self) {
|
||||||
|
let filtered = self.get_filtered_chats();
|
||||||
|
if filtered.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let i = match self.chat_list_state.selected() {
|
||||||
|
Some(i) => {
|
||||||
|
if i == 0 {
|
||||||
|
filtered.len() - 1
|
||||||
|
} else {
|
||||||
|
i - 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => 0,
|
||||||
|
};
|
||||||
|
self.chat_list_state.select(Some(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_filtered_chat(&mut self) {
|
||||||
|
let filtered = self.get_filtered_chats();
|
||||||
|
if let Some(i) = self.chat_list_state.selected() {
|
||||||
|
if let Some(chat) = filtered.get(i) {
|
||||||
|
self.selected_chat_id = Some(chat.id);
|
||||||
|
self.cancel_search();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
174
src/app/methods/search.rs
Normal file
174
src/app/methods/search.rs
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
//! Search methods for App
|
||||||
|
//!
|
||||||
|
//! Handles chat list search and message search within chat
|
||||||
|
|
||||||
|
use crate::app::{App, ChatFilter, ChatFilterCriteria, ChatState};
|
||||||
|
use crate::tdlib::{ChatInfo, MessageInfo, TdClientTrait};
|
||||||
|
|
||||||
|
/// Search methods for chats and messages
|
||||||
|
pub trait SearchMethods<T: TdClientTrait> {
|
||||||
|
// === Chat Search ===
|
||||||
|
|
||||||
|
/// Start search mode in chat list
|
||||||
|
fn start_search(&mut self);
|
||||||
|
|
||||||
|
/// Cancel search mode and reset query
|
||||||
|
fn cancel_search(&mut self);
|
||||||
|
|
||||||
|
/// Get filtered chats based on search query and selected folder
|
||||||
|
fn get_filtered_chats(&self) -> Vec<&ChatInfo>;
|
||||||
|
|
||||||
|
// === Message Search ===
|
||||||
|
|
||||||
|
/// Check if message search mode is active
|
||||||
|
fn is_message_search_mode(&self) -> bool;
|
||||||
|
|
||||||
|
/// Enter message search mode within chat
|
||||||
|
fn enter_message_search_mode(&mut self);
|
||||||
|
|
||||||
|
/// Exit message search mode
|
||||||
|
fn exit_message_search_mode(&mut self);
|
||||||
|
|
||||||
|
/// Set search results
|
||||||
|
fn set_search_results(&mut self, results: Vec<MessageInfo>);
|
||||||
|
|
||||||
|
/// Select previous search result (up)
|
||||||
|
fn select_previous_search_result(&mut self);
|
||||||
|
|
||||||
|
/// Select next search result (down)
|
||||||
|
fn select_next_search_result(&mut self);
|
||||||
|
|
||||||
|
/// Get currently selected search result
|
||||||
|
fn get_selected_search_result(&self) -> Option<&MessageInfo>;
|
||||||
|
|
||||||
|
/// Get ID of selected search result for navigation
|
||||||
|
fn get_selected_search_result_id(&self) -> Option<i64>;
|
||||||
|
|
||||||
|
/// Get current search query
|
||||||
|
fn get_search_query(&self) -> Option<&str>;
|
||||||
|
|
||||||
|
/// Update search query
|
||||||
|
fn update_search_query(&mut self, new_query: String);
|
||||||
|
|
||||||
|
/// Get index of selected search result
|
||||||
|
fn get_search_selected_index(&self) -> Option<usize>;
|
||||||
|
|
||||||
|
/// Get all search results
|
||||||
|
fn get_search_results(&self) -> Option<&[MessageInfo]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: TdClientTrait> SearchMethods<T> for App<T> {
|
||||||
|
fn start_search(&mut self) {
|
||||||
|
self.is_searching = true;
|
||||||
|
self.search_query.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cancel_search(&mut self) {
|
||||||
|
self.is_searching = false;
|
||||||
|
self.search_query.clear();
|
||||||
|
self.chat_list_state.select(Some(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_filtered_chats(&self) -> Vec<&ChatInfo> {
|
||||||
|
// Используем ChatFilter для централизованной фильтрации
|
||||||
|
let mut criteria = ChatFilterCriteria::new()
|
||||||
|
.with_folder(self.selected_folder_id);
|
||||||
|
|
||||||
|
if !self.search_query.is_empty() {
|
||||||
|
criteria = criteria.with_search(self.search_query.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
ChatFilter::filter(&self.chats, &criteria)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_message_search_mode(&self) -> bool {
|
||||||
|
self.chat_state.is_search_in_chat()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enter_message_search_mode(&mut self) {
|
||||||
|
self.chat_state = ChatState::SearchInChat {
|
||||||
|
query: String::new(),
|
||||||
|
results: Vec::new(),
|
||||||
|
selected_index: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exit_message_search_mode(&mut self) {
|
||||||
|
self.chat_state = ChatState::Normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_search_results(&mut self, results: Vec<MessageInfo>) {
|
||||||
|
if let ChatState::SearchInChat { results: r, selected_index, .. } = &mut self.chat_state {
|
||||||
|
*r = results;
|
||||||
|
*selected_index = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_previous_search_result(&mut self) {
|
||||||
|
if let ChatState::SearchInChat { selected_index, .. } = &mut self.chat_state {
|
||||||
|
if *selected_index > 0 {
|
||||||
|
*selected_index -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_next_search_result(&mut self) {
|
||||||
|
if let ChatState::SearchInChat {
|
||||||
|
selected_index,
|
||||||
|
results,
|
||||||
|
..
|
||||||
|
} = &mut self.chat_state
|
||||||
|
{
|
||||||
|
if *selected_index + 1 < results.len() {
|
||||||
|
*selected_index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_selected_search_result(&self) -> Option<&MessageInfo> {
|
||||||
|
if let ChatState::SearchInChat {
|
||||||
|
results,
|
||||||
|
selected_index,
|
||||||
|
..
|
||||||
|
} = &self.chat_state
|
||||||
|
{
|
||||||
|
results.get(*selected_index)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_selected_search_result_id(&self) -> Option<i64> {
|
||||||
|
self.get_selected_search_result().map(|m| m.id().as_i64())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_search_query(&self) -> Option<&str> {
|
||||||
|
if let ChatState::SearchInChat { query, .. } = &self.chat_state {
|
||||||
|
Some(query.as_str())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_search_query(&mut self, new_query: String) {
|
||||||
|
if let ChatState::SearchInChat { query, .. } = &mut self.chat_state {
|
||||||
|
*query = new_query;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_search_selected_index(&self) -> Option<usize> {
|
||||||
|
if let ChatState::SearchInChat { selected_index, .. } = &self.chat_state {
|
||||||
|
Some(*selected_index)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_search_results(&self) -> Option<&[MessageInfo]> {
|
||||||
|
if let ChatState::SearchInChat { results, .. } = &self.chat_state {
|
||||||
|
Some(results.as_slice())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
890
src/app/mod.rs
890
src/app/mod.rs
@@ -1,14 +1,40 @@
|
|||||||
|
//! Application state module.
|
||||||
|
//!
|
||||||
|
//! Contains `App<T>` — the central state struct parameterized by `TdClientTrait`
|
||||||
|
//! for dependency injection. Methods are organized into trait modules in `methods/`.
|
||||||
|
|
||||||
mod chat_filter;
|
mod chat_filter;
|
||||||
mod chat_state;
|
mod chat_state;
|
||||||
mod state;
|
mod state;
|
||||||
|
pub mod methods;
|
||||||
|
|
||||||
pub use chat_filter::{ChatFilter, ChatFilterCriteria};
|
pub use chat_filter::{ChatFilter, ChatFilterCriteria};
|
||||||
pub use chat_state::ChatState;
|
pub use chat_state::{ChatState, InputMode};
|
||||||
pub use state::AppScreen;
|
pub use state::AppScreen;
|
||||||
|
pub use methods::*;
|
||||||
|
|
||||||
|
use crate::accounts::AccountProfile;
|
||||||
use crate::tdlib::{ChatInfo, TdClient, TdClientTrait};
|
use crate::tdlib::{ChatInfo, TdClient, TdClientTrait};
|
||||||
use crate::types::{ChatId, MessageId};
|
use crate::types::ChatId;
|
||||||
use ratatui::widgets::ListState;
|
use ratatui::widgets::ListState;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// State of the account switcher modal overlay.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum AccountSwitcherState {
|
||||||
|
/// List of accounts with navigation.
|
||||||
|
SelectAccount {
|
||||||
|
accounts: Vec<AccountProfile>,
|
||||||
|
selected_index: usize,
|
||||||
|
current_account: String,
|
||||||
|
},
|
||||||
|
/// Input for new account name.
|
||||||
|
AddAccount {
|
||||||
|
name_input: String,
|
||||||
|
cursor_position: usize,
|
||||||
|
error: Option<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
/// Main application state for the Telegram TUI client.
|
/// Main application state for the Telegram TUI client.
|
||||||
///
|
///
|
||||||
@@ -33,10 +59,11 @@ use ratatui::widgets::ListState;
|
|||||||
///
|
///
|
||||||
/// ```no_run
|
/// ```no_run
|
||||||
/// use tele_tui::app::App;
|
/// use tele_tui::app::App;
|
||||||
|
/// use tele_tui::app::methods::navigation::NavigationMethods;
|
||||||
/// use tele_tui::config::Config;
|
/// use tele_tui::config::Config;
|
||||||
///
|
///
|
||||||
/// let config = Config::default();
|
/// let config = Config::default();
|
||||||
/// let mut app = App::new(config);
|
/// let mut app = App::new(config, std::path::PathBuf::from("tdlib_data"));
|
||||||
///
|
///
|
||||||
/// // Navigate through chats
|
/// // Navigate through chats
|
||||||
/// app.next_chat();
|
/// app.next_chat();
|
||||||
@@ -52,6 +79,8 @@ pub struct App<T: TdClientTrait = TdClient> {
|
|||||||
pub td_client: T,
|
pub td_client: T,
|
||||||
/// Состояние чата - type-safe state machine (новое!)
|
/// Состояние чата - type-safe state machine (новое!)
|
||||||
pub chat_state: ChatState,
|
pub chat_state: ChatState,
|
||||||
|
/// Vim-like input mode: Normal (navigation) / Insert (text input)
|
||||||
|
pub input_mode: InputMode,
|
||||||
// Auth state (приватные, доступ через геттеры)
|
// Auth state (приватные, доступ через геттеры)
|
||||||
phone_input: String,
|
phone_input: String,
|
||||||
code_input: String,
|
code_input: String,
|
||||||
@@ -77,6 +106,43 @@ pub struct App<T: TdClientTrait = TdClient> {
|
|||||||
// Typing indicator
|
// Typing indicator
|
||||||
/// Время последней отправки typing status (для throttling)
|
/// Время последней отправки typing status (для throttling)
|
||||||
pub last_typing_sent: Option<std::time::Instant>,
|
pub last_typing_sent: Option<std::time::Instant>,
|
||||||
|
// Image support
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
pub image_cache: Option<crate::media::cache::ImageCache>,
|
||||||
|
/// Renderer для inline preview в чате (Halfblocks - быстро)
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
pub inline_image_renderer: Option<crate::media::image_renderer::ImageRenderer>,
|
||||||
|
/// Renderer для modal просмотра (iTerm2/Sixel - высокое качество)
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
pub modal_image_renderer: Option<crate::media::image_renderer::ImageRenderer>,
|
||||||
|
/// Состояние модального окна просмотра изображения
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
pub image_modal: Option<crate::tdlib::ImageModalState>,
|
||||||
|
/// Время последнего рендеринга изображений (для throttling до 15 FPS)
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
pub last_image_render_time: Option<std::time::Instant>,
|
||||||
|
// Account switcher
|
||||||
|
/// Account switcher modal state (global overlay)
|
||||||
|
pub account_switcher: Option<AccountSwitcherState>,
|
||||||
|
/// Name of the currently active account
|
||||||
|
pub current_account_name: String,
|
||||||
|
/// Pending account switch: (account_name, db_path)
|
||||||
|
pub pending_account_switch: Option<(String, PathBuf)>,
|
||||||
|
/// Pending background chat init (reply info, pinned) after fast open
|
||||||
|
pub pending_chat_init: Option<ChatId>,
|
||||||
|
/// Receiver for background photo downloads (file_id, result path)
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
pub photo_download_rx:
|
||||||
|
Option<tokio::sync::mpsc::UnboundedReceiver<(i32, Result<String, String>)>>,
|
||||||
|
// Voice playback
|
||||||
|
/// Аудиопроигрыватель для голосовых сообщений (rodio)
|
||||||
|
pub audio_player: Option<crate::audio::AudioPlayer>,
|
||||||
|
/// Кэш голосовых файлов (LRU, max 100 MB)
|
||||||
|
pub voice_cache: Option<crate::audio::VoiceCache>,
|
||||||
|
/// Состояние текущего воспроизведения
|
||||||
|
pub playback_state: Option<crate::tdlib::PlaybackState>,
|
||||||
|
/// Время последнего тика для обновления позиции воспроизведения
|
||||||
|
pub last_playback_tick: Option<std::time::Instant>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<T: TdClientTrait> App<T> {
|
impl<T: TdClientTrait> App<T> {
|
||||||
@@ -96,11 +162,23 @@ impl<T: TdClientTrait> App<T> {
|
|||||||
let mut state = ListState::default();
|
let mut state = ListState::default();
|
||||||
state.select(Some(0));
|
state.select(Some(0));
|
||||||
|
|
||||||
|
let audio_cache_size_mb = config.audio.cache_size_mb;
|
||||||
|
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
let image_cache = Some(crate::media::cache::ImageCache::new(
|
||||||
|
config.images.cache_size_mb,
|
||||||
|
));
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
let inline_image_renderer = crate::media::image_renderer::ImageRenderer::new_fast();
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
let modal_image_renderer = crate::media::image_renderer::ImageRenderer::new();
|
||||||
|
|
||||||
App {
|
App {
|
||||||
config,
|
config,
|
||||||
screen: AppScreen::Loading,
|
screen: AppScreen::Loading,
|
||||||
td_client,
|
td_client,
|
||||||
chat_state: ChatState::Normal,
|
chat_state: ChatState::Normal,
|
||||||
|
input_mode: InputMode::Normal,
|
||||||
phone_input: String::new(),
|
phone_input: String::new(),
|
||||||
code_input: String::new(),
|
code_input: String::new(),
|
||||||
password_input: String::new(),
|
password_input: String::new(),
|
||||||
@@ -118,6 +196,28 @@ impl<T: TdClientTrait> App<T> {
|
|||||||
search_query: String::new(),
|
search_query: String::new(),
|
||||||
needs_redraw: true,
|
needs_redraw: true,
|
||||||
last_typing_sent: None,
|
last_typing_sent: None,
|
||||||
|
// Account switcher
|
||||||
|
account_switcher: None,
|
||||||
|
current_account_name: "default".to_string(),
|
||||||
|
pending_account_switch: None,
|
||||||
|
pending_chat_init: None,
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
photo_download_rx: None,
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
image_cache,
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
inline_image_renderer,
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
modal_image_renderer,
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
image_modal: None,
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
last_image_render_time: None,
|
||||||
|
// Voice playback
|
||||||
|
audio_player: crate::audio::AudioPlayer::new().ok(),
|
||||||
|
voice_cache: crate::audio::VoiceCache::new(audio_cache_size_mb).ok(),
|
||||||
|
playback_state: None,
|
||||||
|
last_playback_tick: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,673 +234,157 @@ impl<T: TdClientTrait> App<T> {
|
|||||||
self.config.keybindings.get_command(&key)
|
self.config.keybindings.get_command(&key)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn next_chat(&mut self) {
|
/// Get the selected chat ID as i64
|
||||||
let filtered = self.get_filtered_chats();
|
|
||||||
if filtered.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let i = match self.chat_list_state.selected() {
|
|
||||||
Some(i) => {
|
|
||||||
if i >= filtered.len() - 1 {
|
|
||||||
0
|
|
||||||
} else {
|
|
||||||
i + 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => 0,
|
|
||||||
};
|
|
||||||
self.chat_list_state.select(Some(i));
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn previous_chat(&mut self) {
|
|
||||||
let filtered = self.get_filtered_chats();
|
|
||||||
if filtered.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let i = match self.chat_list_state.selected() {
|
|
||||||
Some(i) => {
|
|
||||||
if i == 0 {
|
|
||||||
filtered.len() - 1
|
|
||||||
} else {
|
|
||||||
i - 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => 0,
|
|
||||||
};
|
|
||||||
self.chat_list_state.select(Some(i));
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn select_current_chat(&mut self) {
|
|
||||||
let filtered = self.get_filtered_chats();
|
|
||||||
if let Some(i) = self.chat_list_state.selected() {
|
|
||||||
if let Some(chat) = filtered.get(i) {
|
|
||||||
self.selected_chat_id = Some(chat.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn close_chat(&mut self) {
|
|
||||||
self.selected_chat_id = None;
|
|
||||||
self.message_input.clear();
|
|
||||||
self.cursor_position = 0;
|
|
||||||
self.message_scroll_offset = 0;
|
|
||||||
self.last_typing_sent = None;
|
|
||||||
// Сбрасываем состояние чата в нормальный режим
|
|
||||||
self.chat_state = ChatState::Normal;
|
|
||||||
// Очищаем данные в TdClient
|
|
||||||
self.td_client.set_current_chat_id(None);
|
|
||||||
self.td_client.clear_current_chat_messages();
|
|
||||||
self.td_client.set_typing_status(None);
|
|
||||||
self.td_client.set_current_pinned_message(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Начать выбор сообщения для редактирования (при стрелке вверх в пустом инпуте)
|
|
||||||
pub fn start_message_selection(&mut self) {
|
|
||||||
let total = self.td_client.current_chat_messages().len();
|
|
||||||
if total == 0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Начинаем с последнего сообщения (индекс len-1 = самое новое внизу)
|
|
||||||
self.chat_state = ChatState::MessageSelection { selected_index: total - 1 };
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Выбрать предыдущее сообщение (вверх по списку = к старым = уменьшить индекс)
|
|
||||||
pub fn select_previous_message(&mut self) {
|
|
||||||
if let ChatState::MessageSelection { selected_index } = &mut self.chat_state {
|
|
||||||
if *selected_index > 0 {
|
|
||||||
*selected_index -= 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Выбрать следующее сообщение (вниз по списку = к новым = увеличить индекс)
|
|
||||||
pub fn select_next_message(&mut self) {
|
|
||||||
let total = self.td_client.current_chat_messages().len();
|
|
||||||
if total == 0 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if let ChatState::MessageSelection { selected_index } = &mut self.chat_state {
|
|
||||||
if *selected_index < total - 1 {
|
|
||||||
*selected_index += 1;
|
|
||||||
} else {
|
|
||||||
// Дошли до самого нового сообщения - выходим из режима выбора
|
|
||||||
self.chat_state = ChatState::Normal;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Получить выбранное сообщение
|
|
||||||
pub fn get_selected_message(&self) -> Option<crate::tdlib::MessageInfo> {
|
|
||||||
self.chat_state.selected_message_index().and_then(|idx| {
|
|
||||||
self.td_client.current_chat_messages().get(idx).cloned()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Начать редактирование выбранного сообщения
|
|
||||||
pub fn start_editing_selected(&mut self) -> bool {
|
|
||||||
// Получаем selected_index из текущего состояния
|
|
||||||
let selected_idx = match &self.chat_state {
|
|
||||||
ChatState::MessageSelection { selected_index } => Some(*selected_index),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
|
|
||||||
if selected_idx.is_none() {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Сначала извлекаем данные из сообщения
|
|
||||||
let msg_data = self.get_selected_message().and_then(|msg| {
|
|
||||||
// Проверяем:
|
|
||||||
// 1. Можно редактировать
|
|
||||||
// 2. Это исходящее сообщение
|
|
||||||
// 3. ID не временный (временные ID в TDLib отрицательные)
|
|
||||||
if msg.can_be_edited() && msg.is_outgoing() && msg.id().as_i64() > 0 {
|
|
||||||
Some((msg.id(), msg.text().to_string(), selected_idx.unwrap()))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Затем присваиваем
|
|
||||||
if let Some((id, content, idx)) = msg_data {
|
|
||||||
self.cursor_position = content.chars().count();
|
|
||||||
self.message_input = content;
|
|
||||||
self.chat_state = ChatState::Editing {
|
|
||||||
message_id: id,
|
|
||||||
selected_index: idx,
|
|
||||||
};
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Отменить редактирование
|
|
||||||
pub fn cancel_editing(&mut self) {
|
|
||||||
self.chat_state = ChatState::Normal;
|
|
||||||
self.message_input.clear();
|
|
||||||
self.cursor_position = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Проверить, находимся ли в режиме редактирования
|
|
||||||
pub fn is_editing(&self) -> bool {
|
|
||||||
self.chat_state.is_editing()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Проверить, находимся ли в режиме выбора сообщения
|
|
||||||
pub fn is_selecting_message(&self) -> bool {
|
|
||||||
self.chat_state.is_message_selection()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_selected_chat_id(&self) -> Option<i64> {
|
pub fn get_selected_chat_id(&self) -> Option<i64> {
|
||||||
self.selected_chat_id.map(|id| id.as_i64())
|
self.selected_chat_id.map(|id| id.as_i64())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Останавливает воспроизведение голосового и сбрасывает состояние
|
||||||
|
pub fn stop_playback(&mut self) {
|
||||||
|
if let Some(ref player) = self.audio_player {
|
||||||
|
player.stop();
|
||||||
|
}
|
||||||
|
self.playback_state = None;
|
||||||
|
self.last_playback_tick = None;
|
||||||
|
self.status_message = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens the account switcher modal, loading accounts from config.
|
||||||
|
pub fn open_account_switcher(&mut self) {
|
||||||
|
let config = crate::accounts::load_or_create();
|
||||||
|
self.account_switcher = Some(AccountSwitcherState::SelectAccount {
|
||||||
|
accounts: config.accounts,
|
||||||
|
selected_index: 0,
|
||||||
|
current_account: self.current_account_name.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Closes the account switcher modal.
|
||||||
|
pub fn close_account_switcher(&mut self) {
|
||||||
|
self.account_switcher = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigate to previous item in account switcher list.
|
||||||
|
pub fn account_switcher_select_prev(&mut self) {
|
||||||
|
if let Some(AccountSwitcherState::SelectAccount { selected_index, .. }) =
|
||||||
|
&mut self.account_switcher
|
||||||
|
{
|
||||||
|
*selected_index = selected_index.saturating_sub(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigate to next item in account switcher list.
|
||||||
|
pub fn account_switcher_select_next(&mut self) {
|
||||||
|
if let Some(AccountSwitcherState::SelectAccount {
|
||||||
|
accounts,
|
||||||
|
selected_index,
|
||||||
|
..
|
||||||
|
}) = &mut self.account_switcher
|
||||||
|
{
|
||||||
|
// +1 for the "Add account" item at the end
|
||||||
|
let max_index = accounts.len();
|
||||||
|
if *selected_index < max_index {
|
||||||
|
*selected_index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Confirm selection in account switcher.
|
||||||
|
/// If on an account: sets pending_account_switch.
|
||||||
|
/// If on "+ Add": transitions to AddAccount state.
|
||||||
|
pub fn account_switcher_confirm(&mut self) {
|
||||||
|
let state = self.account_switcher.take();
|
||||||
|
match state {
|
||||||
|
Some(AccountSwitcherState::SelectAccount {
|
||||||
|
accounts,
|
||||||
|
selected_index,
|
||||||
|
current_account,
|
||||||
|
}) => {
|
||||||
|
if selected_index < accounts.len() {
|
||||||
|
// Selected an existing account
|
||||||
|
let account = &accounts[selected_index];
|
||||||
|
if account.name == current_account {
|
||||||
|
// Already on this account, just close
|
||||||
|
self.account_switcher = None;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let db_path = account.db_path();
|
||||||
|
self.pending_account_switch = Some((account.name.clone(), db_path));
|
||||||
|
self.account_switcher = None;
|
||||||
|
} else {
|
||||||
|
// Selected "+ Add account"
|
||||||
|
self.account_switcher = Some(AccountSwitcherState::AddAccount {
|
||||||
|
name_input: String::new(),
|
||||||
|
cursor_position: 0,
|
||||||
|
error: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
self.account_switcher = other;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Switch to AddAccount state from SelectAccount.
|
||||||
|
pub fn account_switcher_start_add(&mut self) {
|
||||||
|
self.account_switcher = Some(AccountSwitcherState::AddAccount {
|
||||||
|
name_input: String::new(),
|
||||||
|
cursor_position: 0,
|
||||||
|
error: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Confirm adding a new account. Validates, saves, and sets pending switch.
|
||||||
|
pub fn account_switcher_confirm_add(&mut self) {
|
||||||
|
let state = self.account_switcher.take();
|
||||||
|
match state {
|
||||||
|
Some(AccountSwitcherState::AddAccount { name_input, .. }) => {
|
||||||
|
match crate::accounts::manager::add_account(&name_input, &name_input) {
|
||||||
|
Ok(db_path) => {
|
||||||
|
self.pending_account_switch = Some((name_input, db_path));
|
||||||
|
self.account_switcher = None;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let cursor_pos = name_input.chars().count();
|
||||||
|
self.account_switcher = Some(AccountSwitcherState::AddAccount {
|
||||||
|
name_input,
|
||||||
|
cursor_position: cursor_pos,
|
||||||
|
error: Some(e),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
self.account_switcher = other;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Go back from AddAccount to SelectAccount.
|
||||||
|
pub fn account_switcher_back(&mut self) {
|
||||||
|
self.open_account_switcher();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the selected chat info
|
||||||
pub fn get_selected_chat(&self) -> Option<&ChatInfo> {
|
pub fn get_selected_chat(&self) -> Option<&ChatInfo> {
|
||||||
self.selected_chat_id
|
self.selected_chat_id
|
||||||
.and_then(|id| self.chats.iter().find(|c| c.id == id))
|
.and_then(|id| self.chats.iter().find(|c| c.id == id))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn start_search(&mut self) {
|
|
||||||
self.is_searching = true;
|
|
||||||
self.search_query.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn cancel_search(&mut self) {
|
|
||||||
self.is_searching = false;
|
|
||||||
self.search_query.clear();
|
|
||||||
self.chat_list_state.select(Some(0));
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_filtered_chats(&self) -> Vec<&ChatInfo> {
|
|
||||||
// Используем ChatFilter для централизованной фильтрации
|
|
||||||
let mut criteria = ChatFilterCriteria::new()
|
|
||||||
.with_folder(self.selected_folder_id);
|
|
||||||
|
|
||||||
if !self.search_query.is_empty() {
|
|
||||||
criteria = criteria.with_search(self.search_query.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
ChatFilter::filter(&self.chats, &criteria)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn next_filtered_chat(&mut self) {
|
|
||||||
let filtered = self.get_filtered_chats();
|
|
||||||
if filtered.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let i = match self.chat_list_state.selected() {
|
|
||||||
Some(i) => {
|
|
||||||
if i >= filtered.len() - 1 {
|
|
||||||
0
|
|
||||||
} else {
|
|
||||||
i + 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => 0,
|
|
||||||
};
|
|
||||||
self.chat_list_state.select(Some(i));
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn previous_filtered_chat(&mut self) {
|
|
||||||
let filtered = self.get_filtered_chats();
|
|
||||||
if filtered.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let i = match self.chat_list_state.selected() {
|
|
||||||
Some(i) => {
|
|
||||||
if i == 0 {
|
|
||||||
filtered.len() - 1
|
|
||||||
} else {
|
|
||||||
i - 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => 0,
|
|
||||||
};
|
|
||||||
self.chat_list_state.select(Some(i));
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn select_filtered_chat(&mut self) {
|
|
||||||
let filtered = self.get_filtered_chats();
|
|
||||||
if let Some(i) = self.chat_list_state.selected() {
|
|
||||||
if let Some(chat) = filtered.get(i) {
|
|
||||||
self.selected_chat_id = Some(chat.id);
|
|
||||||
self.cancel_search();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Проверить, показывается ли модалка подтверждения удаления
|
|
||||||
pub fn is_confirm_delete_shown(&self) -> bool {
|
|
||||||
self.chat_state.is_delete_confirmation()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Начать режим ответа на выбранное сообщение
|
|
||||||
pub fn start_reply_to_selected(&mut self) -> bool {
|
|
||||||
if let Some(msg) = self.get_selected_message() {
|
|
||||||
self.chat_state = ChatState::Reply {
|
|
||||||
message_id: msg.id(),
|
|
||||||
};
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Отменить режим ответа
|
|
||||||
pub fn cancel_reply(&mut self) {
|
|
||||||
self.chat_state = ChatState::Normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Проверить, находимся ли в режиме ответа
|
|
||||||
pub fn is_replying(&self) -> bool {
|
|
||||||
self.chat_state.is_reply()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Получить сообщение, на которое отвечаем
|
|
||||||
pub fn get_replying_to_message(&self) -> Option<crate::tdlib::MessageInfo> {
|
|
||||||
self.chat_state.selected_message_id().and_then(|id| {
|
|
||||||
self.td_client
|
|
||||||
.current_chat_messages()
|
|
||||||
.iter()
|
|
||||||
.find(|m| m.id() == id)
|
|
||||||
.cloned()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Начать режим пересылки выбранного сообщения
|
|
||||||
pub fn start_forward_selected(&mut self) -> bool {
|
|
||||||
if let Some(msg) = self.get_selected_message() {
|
|
||||||
self.chat_state = ChatState::Forward {
|
|
||||||
message_id: msg.id(),
|
|
||||||
};
|
|
||||||
// Сбрасываем выбор чата на первый
|
|
||||||
self.chat_list_state.select(Some(0));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Отменить режим пересылки
|
|
||||||
pub fn cancel_forward(&mut self) {
|
|
||||||
self.chat_state = ChatState::Normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Проверить, находимся ли в режиме выбора чата для пересылки
|
|
||||||
pub fn is_forwarding(&self) -> bool {
|
|
||||||
self.chat_state.is_forward()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Получить сообщение для пересылки
|
|
||||||
pub fn get_forwarding_message(&self) -> Option<crate::tdlib::MessageInfo> {
|
|
||||||
if !self.chat_state.is_forward() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
self.chat_state.selected_message_id().and_then(|id| {
|
|
||||||
self.td_client
|
|
||||||
.current_chat_messages()
|
|
||||||
.iter()
|
|
||||||
.find(|m| m.id() == id)
|
|
||||||
.cloned()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Pinned messages mode ===
|
|
||||||
|
|
||||||
/// Проверка режима pinned
|
|
||||||
pub fn is_pinned_mode(&self) -> bool {
|
|
||||||
self.chat_state.is_pinned_mode()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Войти в режим pinned (вызывается после загрузки pinned сообщений)
|
|
||||||
pub fn enter_pinned_mode(&mut self, messages: Vec<crate::tdlib::MessageInfo>) {
|
|
||||||
if !messages.is_empty() {
|
|
||||||
self.chat_state = ChatState::PinnedMessages {
|
|
||||||
messages,
|
|
||||||
selected_index: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Выйти из режима pinned
|
|
||||||
pub fn exit_pinned_mode(&mut self) {
|
|
||||||
self.chat_state = ChatState::Normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Выбрать предыдущий pinned (вверх = более старый)
|
|
||||||
pub fn select_previous_pinned(&mut self) {
|
|
||||||
if let ChatState::PinnedMessages {
|
|
||||||
selected_index,
|
|
||||||
messages,
|
|
||||||
} = &mut self.chat_state
|
|
||||||
{
|
|
||||||
if *selected_index + 1 < messages.len() {
|
|
||||||
*selected_index += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Выбрать следующий pinned (вниз = более новый)
|
|
||||||
pub fn select_next_pinned(&mut self) {
|
|
||||||
if let ChatState::PinnedMessages { selected_index, .. } = &mut self.chat_state {
|
|
||||||
if *selected_index > 0 {
|
|
||||||
*selected_index -= 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Получить текущее выбранное pinned сообщение
|
|
||||||
pub fn get_selected_pinned(&self) -> Option<&crate::tdlib::MessageInfo> {
|
|
||||||
if let ChatState::PinnedMessages {
|
|
||||||
messages,
|
|
||||||
selected_index,
|
|
||||||
} = &self.chat_state
|
|
||||||
{
|
|
||||||
messages.get(*selected_index)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Получить ID текущего pinned для перехода в историю
|
|
||||||
pub fn get_selected_pinned_id(&self) -> Option<i64> {
|
|
||||||
self.get_selected_pinned().map(|m| m.id().as_i64())
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Message Search Mode ===
|
|
||||||
|
|
||||||
/// Проверить, активен ли режим поиска по сообщениям
|
|
||||||
pub fn is_message_search_mode(&self) -> bool {
|
|
||||||
self.chat_state.is_search_in_chat()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Войти в режим поиска по сообщениям
|
|
||||||
pub fn enter_message_search_mode(&mut self) {
|
|
||||||
self.chat_state = ChatState::SearchInChat {
|
|
||||||
query: String::new(),
|
|
||||||
results: Vec::new(),
|
|
||||||
selected_index: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Выйти из режима поиска
|
|
||||||
pub fn exit_message_search_mode(&mut self) {
|
|
||||||
self.chat_state = ChatState::Normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Установить результаты поиска
|
|
||||||
pub fn set_search_results(&mut self, results: Vec<crate::tdlib::MessageInfo>) {
|
|
||||||
if let ChatState::SearchInChat { results: r, selected_index, .. } = &mut self.chat_state {
|
|
||||||
*r = results;
|
|
||||||
*selected_index = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Выбрать предыдущий результат (вверх)
|
|
||||||
pub fn select_previous_search_result(&mut self) {
|
|
||||||
if let ChatState::SearchInChat { selected_index, .. } = &mut self.chat_state {
|
|
||||||
if *selected_index > 0 {
|
|
||||||
*selected_index -= 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Выбрать следующий результат (вниз)
|
|
||||||
pub fn select_next_search_result(&mut self) {
|
|
||||||
if let ChatState::SearchInChat {
|
|
||||||
selected_index,
|
|
||||||
results,
|
|
||||||
..
|
|
||||||
} = &mut self.chat_state
|
|
||||||
{
|
|
||||||
if *selected_index + 1 < results.len() {
|
|
||||||
*selected_index += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Получить текущий выбранный результат
|
|
||||||
pub fn get_selected_search_result(&self) -> Option<&crate::tdlib::MessageInfo> {
|
|
||||||
if let ChatState::SearchInChat {
|
|
||||||
results,
|
|
||||||
selected_index,
|
|
||||||
..
|
|
||||||
} = &self.chat_state
|
|
||||||
{
|
|
||||||
results.get(*selected_index)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Получить ID выбранного результата для перехода
|
|
||||||
pub fn get_selected_search_result_id(&self) -> Option<i64> {
|
|
||||||
self.get_selected_search_result().map(|m| m.id().as_i64())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Получить поисковый запрос из режима поиска
|
|
||||||
pub fn get_search_query(&self) -> Option<&str> {
|
|
||||||
if let ChatState::SearchInChat { query, .. } = &self.chat_state {
|
|
||||||
Some(query.as_str())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Обновить поисковый запрос
|
|
||||||
pub fn update_search_query(&mut self, new_query: String) {
|
|
||||||
if let ChatState::SearchInChat { query, .. } = &mut self.chat_state {
|
|
||||||
*query = new_query;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Получить индекс выбранного результата поиска
|
|
||||||
pub fn get_search_selected_index(&self) -> Option<usize> {
|
|
||||||
if let ChatState::SearchInChat { selected_index, .. } = &self.chat_state {
|
|
||||||
Some(*selected_index)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Получить результаты поиска
|
|
||||||
pub fn get_search_results(&self) -> Option<&[crate::tdlib::MessageInfo]> {
|
|
||||||
if let ChatState::SearchInChat { results, .. } = &self.chat_state {
|
|
||||||
Some(results.as_slice())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Draft Management ===
|
|
||||||
|
|
||||||
/// Получить черновик для текущего чата
|
|
||||||
pub fn get_current_draft(&self) -> Option<String> {
|
|
||||||
self.selected_chat_id.and_then(|chat_id| {
|
|
||||||
self.chats
|
|
||||||
.iter()
|
|
||||||
.find(|c| c.id == chat_id)
|
|
||||||
.and_then(|c| c.draft_text.clone())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Загрузить черновик в message_input (вызывается при открытии чата)
|
|
||||||
pub fn load_draft(&mut self) {
|
|
||||||
if let Some(draft) = self.get_current_draft() {
|
|
||||||
self.message_input = draft;
|
|
||||||
self.cursor_position = self.message_input.chars().count();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Profile Mode ===
|
|
||||||
|
|
||||||
/// Проверить, активен ли режим профиля
|
|
||||||
pub fn is_profile_mode(&self) -> bool {
|
|
||||||
self.chat_state.is_profile()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Войти в режим профиля
|
|
||||||
pub fn enter_profile_mode(&mut self, info: crate::tdlib::ProfileInfo) {
|
|
||||||
self.chat_state = ChatState::Profile {
|
|
||||||
info,
|
|
||||||
selected_action: 0,
|
|
||||||
leave_group_confirmation_step: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Выйти из режима профиля
|
|
||||||
pub fn exit_profile_mode(&mut self) {
|
|
||||||
self.chat_state = ChatState::Normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Выбрать предыдущее действие
|
|
||||||
pub fn select_previous_profile_action(&mut self) {
|
|
||||||
if let ChatState::Profile {
|
|
||||||
selected_action, ..
|
|
||||||
} = &mut self.chat_state
|
|
||||||
{
|
|
||||||
if *selected_action > 0 {
|
|
||||||
*selected_action -= 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Выбрать следующее действие
|
|
||||||
pub fn select_next_profile_action(&mut self, max_actions: usize) {
|
|
||||||
if let ChatState::Profile {
|
|
||||||
selected_action, ..
|
|
||||||
} = &mut self.chat_state
|
|
||||||
{
|
|
||||||
if *selected_action < max_actions.saturating_sub(1) {
|
|
||||||
*selected_action += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Показать первое подтверждение выхода из группы
|
|
||||||
pub fn show_leave_group_confirmation(&mut self) {
|
|
||||||
if let ChatState::Profile {
|
|
||||||
leave_group_confirmation_step,
|
|
||||||
..
|
|
||||||
} = &mut self.chat_state
|
|
||||||
{
|
|
||||||
*leave_group_confirmation_step = 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Показать второе подтверждение выхода из группы
|
|
||||||
pub fn show_leave_group_final_confirmation(&mut self) {
|
|
||||||
if let ChatState::Profile {
|
|
||||||
leave_group_confirmation_step,
|
|
||||||
..
|
|
||||||
} = &mut self.chat_state
|
|
||||||
{
|
|
||||||
*leave_group_confirmation_step = 2;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Отменить подтверждение выхода из группы
|
|
||||||
pub fn cancel_leave_group(&mut self) {
|
|
||||||
if let ChatState::Profile {
|
|
||||||
leave_group_confirmation_step,
|
|
||||||
..
|
|
||||||
} = &mut self.chat_state
|
|
||||||
{
|
|
||||||
*leave_group_confirmation_step = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Получить текущий шаг подтверждения
|
|
||||||
pub fn get_leave_group_confirmation_step(&self) -> u8 {
|
|
||||||
if let ChatState::Profile {
|
|
||||||
leave_group_confirmation_step,
|
|
||||||
..
|
|
||||||
} = &self.chat_state
|
|
||||||
{
|
|
||||||
*leave_group_confirmation_step
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Получить информацию профиля
|
|
||||||
pub fn get_profile_info(&self) -> Option<&crate::tdlib::ProfileInfo> {
|
|
||||||
if let ChatState::Profile { info, .. } = &self.chat_state {
|
|
||||||
Some(info)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Получить индекс выбранного действия в профиле
|
|
||||||
pub fn get_selected_profile_action(&self) -> Option<usize> {
|
|
||||||
if let ChatState::Profile {
|
|
||||||
selected_action, ..
|
|
||||||
} = &self.chat_state
|
|
||||||
{
|
|
||||||
Some(*selected_action)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== Reaction Picker ==========
|
|
||||||
|
|
||||||
pub fn is_reaction_picker_mode(&self) -> bool {
|
|
||||||
self.chat_state.is_reaction_picker()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn enter_reaction_picker_mode(
|
|
||||||
&mut self,
|
|
||||||
message_id: i64,
|
|
||||||
available_reactions: Vec<String>,
|
|
||||||
) {
|
|
||||||
self.chat_state = ChatState::ReactionPicker {
|
|
||||||
message_id: MessageId::new(message_id),
|
|
||||||
available_reactions,
|
|
||||||
selected_index: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn exit_reaction_picker_mode(&mut self) {
|
|
||||||
self.chat_state = ChatState::Normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn select_previous_reaction(&mut self) {
|
|
||||||
if let ChatState::ReactionPicker { selected_index, .. } = &mut self.chat_state {
|
|
||||||
if *selected_index > 0 {
|
|
||||||
*selected_index -= 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn select_next_reaction(&mut self) {
|
|
||||||
if let ChatState::ReactionPicker {
|
|
||||||
selected_index,
|
|
||||||
available_reactions,
|
|
||||||
..
|
|
||||||
} = &mut self.chat_state
|
|
||||||
{
|
|
||||||
if *selected_index + 1 < available_reactions.len() {
|
|
||||||
*selected_index += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_selected_reaction(&self) -> Option<&String> {
|
|
||||||
if let ChatState::ReactionPicker {
|
|
||||||
available_reactions,
|
|
||||||
selected_index,
|
|
||||||
..
|
|
||||||
} = &self.chat_state
|
|
||||||
{
|
|
||||||
available_reactions.get(*selected_index)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_selected_message_for_reaction(&self) -> Option<i64> {
|
|
||||||
self.chat_state.selected_message_id().map(|id| id.as_i64())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== Getter/Setter методы для инкапсуляции ==========
|
// ========== Getter/Setter методы для инкапсуляции ==========
|
||||||
|
|
||||||
@@ -997,19 +581,17 @@ impl App<TdClient> {
|
|||||||
/// Creates a new App instance with the given configuration and a real TDLib client.
|
/// Creates a new App instance with the given configuration and a real TDLib client.
|
||||||
///
|
///
|
||||||
/// This is a convenience method for production use that automatically creates
|
/// This is a convenience method for production use that automatically creates
|
||||||
/// a new TdClient instance.
|
/// a new TdClient instance with the specified database path.
|
||||||
///
|
///
|
||||||
/// # Arguments
|
/// # Arguments
|
||||||
///
|
///
|
||||||
/// * `config` - Application configuration loaded from config.toml
|
/// * `config` - Application configuration loaded from config.toml
|
||||||
|
/// * `db_path` - Path to the TDLib database directory for this account
|
||||||
///
|
///
|
||||||
/// # Returns
|
/// # Returns
|
||||||
///
|
///
|
||||||
/// A new `App<TdClient>` instance ready to start authentication.
|
/// A new `App<TdClient>` instance ready to start authentication.
|
||||||
pub fn new(config: crate::config::Config) -> App<TdClient> {
|
pub fn new(config: crate::config::Config, db_path: std::path::PathBuf) -> App<TdClient> {
|
||||||
let mut client = TdClient::new();
|
App::with_client(config, TdClient::new(db_path))
|
||||||
// Configure notifications from config
|
|
||||||
client.configure_notifications(&config.notifications);
|
|
||||||
App::with_client(config, client)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
155
src/audio/cache.rs
Normal file
155
src/audio/cache.rs
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
//! Voice message cache management.
|
||||||
|
//!
|
||||||
|
//! Caches downloaded OGG voice files in ~/.cache/tele-tui/voice/
|
||||||
|
//! with LRU eviction when cache size exceeds limit.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// Cache for voice message files
|
||||||
|
pub struct VoiceCache {
|
||||||
|
cache_dir: PathBuf,
|
||||||
|
/// file_id -> (path, size_bytes, access_count)
|
||||||
|
files: HashMap<String, (PathBuf, u64, usize)>,
|
||||||
|
access_counter: usize,
|
||||||
|
max_size_bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VoiceCache {
|
||||||
|
/// Creates a new VoiceCache with the given max size in MB
|
||||||
|
pub fn new(max_size_mb: u64) -> Result<Self, String> {
|
||||||
|
let cache_dir = dirs::cache_dir()
|
||||||
|
.ok_or("Failed to get cache directory")?
|
||||||
|
.join("tele-tui")
|
||||||
|
.join("voice");
|
||||||
|
|
||||||
|
fs::create_dir_all(&cache_dir)
|
||||||
|
.map_err(|e| format!("Failed to create cache directory: {}", e))?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
cache_dir,
|
||||||
|
files: HashMap::new(),
|
||||||
|
access_counter: 0,
|
||||||
|
max_size_bytes: max_size_mb * 1024 * 1024,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the path for a cached voice file, if it exists
|
||||||
|
pub fn get(&mut self, file_id: &str) -> Option<PathBuf> {
|
||||||
|
if let Some((path, _, access)) = self.files.get_mut(file_id) {
|
||||||
|
// Update access count for LRU
|
||||||
|
self.access_counter += 1;
|
||||||
|
*access = self.access_counter;
|
||||||
|
Some(path.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stores a voice file in the cache
|
||||||
|
pub fn store(&mut self, file_id: &str, source_path: &Path) -> Result<PathBuf, String> {
|
||||||
|
// Copy file to cache
|
||||||
|
let filename = format!("{}.ogg", file_id.replace('/', "_"));
|
||||||
|
let dest_path = self.cache_dir.join(&filename);
|
||||||
|
|
||||||
|
fs::copy(source_path, &dest_path)
|
||||||
|
.map_err(|e| format!("Failed to copy voice file to cache: {}", e))?;
|
||||||
|
|
||||||
|
// Get file size
|
||||||
|
let size = fs::metadata(&dest_path)
|
||||||
|
.map_err(|e| format!("Failed to get file size: {}", e))?
|
||||||
|
.len();
|
||||||
|
|
||||||
|
// Store in cache
|
||||||
|
self.access_counter += 1;
|
||||||
|
self.files
|
||||||
|
.insert(file_id.to_string(), (dest_path.clone(), size, self.access_counter));
|
||||||
|
|
||||||
|
// Check if we need to evict
|
||||||
|
self.evict_if_needed()?;
|
||||||
|
|
||||||
|
Ok(dest_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the total size of all cached files
|
||||||
|
pub fn total_size(&self) -> u64 {
|
||||||
|
self.files.values().map(|(_, size, _)| size).sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Evicts oldest files until cache is under max size
|
||||||
|
fn evict_if_needed(&mut self) -> Result<(), String> {
|
||||||
|
while self.total_size() > self.max_size_bytes && !self.files.is_empty() {
|
||||||
|
// Find least recently accessed file
|
||||||
|
let oldest_id = self
|
||||||
|
.files
|
||||||
|
.iter()
|
||||||
|
.min_by_key(|(_, (_, _, access))| access)
|
||||||
|
.map(|(id, _)| id.clone());
|
||||||
|
|
||||||
|
if let Some(id) = oldest_id {
|
||||||
|
self.evict(&id)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Evicts a specific file from cache
|
||||||
|
fn evict(&mut self, file_id: &str) -> Result<(), String> {
|
||||||
|
if let Some((path, _, _)) = self.files.remove(file_id) {
|
||||||
|
fs::remove_file(&path)
|
||||||
|
.map_err(|e| format!("Failed to remove cached file: {}", e))?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears all cached files
|
||||||
|
pub fn clear(&mut self) -> Result<(), String> {
|
||||||
|
for (path, _, _) in self.files.values() {
|
||||||
|
let _ = fs::remove_file(path); // Ignore errors
|
||||||
|
}
|
||||||
|
self.files.clear();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_voice_cache_creation() {
|
||||||
|
let cache = VoiceCache::new(100);
|
||||||
|
assert!(cache.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cache_get_nonexistent() {
|
||||||
|
let mut cache = VoiceCache::new(100).unwrap();
|
||||||
|
assert!(cache.get("nonexistent").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cache_store_and_get() {
|
||||||
|
let mut cache = VoiceCache::new(100).unwrap();
|
||||||
|
|
||||||
|
// Create temporary file
|
||||||
|
let temp_dir = std::env::temp_dir();
|
||||||
|
let temp_file = temp_dir.join("test_voice.ogg");
|
||||||
|
let mut file = fs::File::create(&temp_file).unwrap();
|
||||||
|
file.write_all(b"test audio data").unwrap();
|
||||||
|
|
||||||
|
// Store in cache
|
||||||
|
let result = cache.store("test123", &temp_file);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
|
||||||
|
// Get from cache
|
||||||
|
let cached_path = cache.get("test123");
|
||||||
|
assert!(cached_path.is_some());
|
||||||
|
assert!(cached_path.unwrap().exists());
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
fs::remove_file(&temp_file).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/audio/mod.rs
Normal file
11
src/audio/mod.rs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
//! Audio playback module for voice messages.
|
||||||
|
//!
|
||||||
|
//! Provides:
|
||||||
|
//! - AudioPlayer: rodio-based playback with play/pause/stop/volume controls
|
||||||
|
//! - VoiceCache: LRU cache for downloaded OGG voice files
|
||||||
|
|
||||||
|
pub mod cache;
|
||||||
|
pub mod player;
|
||||||
|
|
||||||
|
pub use cache::VoiceCache;
|
||||||
|
pub use player::AudioPlayer;
|
||||||
194
src/audio/player.rs
Normal file
194
src/audio/player.rs
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
//! Audio player for voice messages.
|
||||||
|
//!
|
||||||
|
//! Uses ffplay (from FFmpeg) for reliable Opus/OGG playback.
|
||||||
|
//! Pause/resume implemented via SIGSTOP/SIGCONT signals.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::Command;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Audio player state and controls
|
||||||
|
pub struct AudioPlayer {
|
||||||
|
/// PID of current playback process (if any)
|
||||||
|
current_pid: Arc<Mutex<Option<u32>>>,
|
||||||
|
/// Whether the process is currently paused (SIGSTOP)
|
||||||
|
paused: Arc<Mutex<bool>>,
|
||||||
|
/// Path to the currently playing file (for restart with seek)
|
||||||
|
current_path: Arc<Mutex<Option<std::path::PathBuf>>>,
|
||||||
|
/// True between play_from() call and ffplay actually starting (race window)
|
||||||
|
starting: Arc<Mutex<bool>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioPlayer {
|
||||||
|
/// Creates a new AudioPlayer
|
||||||
|
pub fn new() -> Result<Self, String> {
|
||||||
|
Command::new("which")
|
||||||
|
.arg("ffplay")
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.output()
|
||||||
|
.map_err(|_| "ffplay not found (install ffmpeg)".to_string())?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
current_pid: Arc::new(Mutex::new(None)),
|
||||||
|
paused: Arc::new(Mutex::new(false)),
|
||||||
|
current_path: Arc::new(Mutex::new(None)),
|
||||||
|
starting: Arc::new(Mutex::new(false)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plays an audio file from the given path
|
||||||
|
pub fn play<P: AsRef<Path>>(&self, path: P) -> Result<(), String> {
|
||||||
|
self.play_from(path, 0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plays an audio file starting from the given position (seconds)
|
||||||
|
pub fn play_from<P: AsRef<Path>>(&self, path: P, start_secs: f32) -> Result<(), String> {
|
||||||
|
self.stop();
|
||||||
|
|
||||||
|
let path_owned = path.as_ref().to_path_buf();
|
||||||
|
*self.current_path.lock().unwrap() = Some(path_owned.clone());
|
||||||
|
*self.starting.lock().unwrap() = true;
|
||||||
|
let current_pid = self.current_pid.clone();
|
||||||
|
let paused = self.paused.clone();
|
||||||
|
let starting = self.starting.clone();
|
||||||
|
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let mut cmd = Command::new("ffplay");
|
||||||
|
cmd.arg("-nodisp")
|
||||||
|
.arg("-autoexit")
|
||||||
|
.arg("-loglevel").arg("quiet");
|
||||||
|
|
||||||
|
if start_secs > 0.0 {
|
||||||
|
cmd.arg("-ss").arg(format!("{:.1}", start_secs));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(mut child) = cmd
|
||||||
|
.arg(&path_owned)
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.stderr(std::process::Stdio::null())
|
||||||
|
.spawn()
|
||||||
|
{
|
||||||
|
let pid = child.id();
|
||||||
|
*current_pid.lock().unwrap() = Some(pid);
|
||||||
|
*paused.lock().unwrap() = false;
|
||||||
|
*starting.lock().unwrap() = false;
|
||||||
|
|
||||||
|
let _ = child.wait();
|
||||||
|
|
||||||
|
// Обнуляем только если это наш pid (новый play мог уже заменить его)
|
||||||
|
let mut pid_guard = current_pid.lock().unwrap();
|
||||||
|
if *pid_guard == Some(pid) {
|
||||||
|
*pid_guard = None;
|
||||||
|
*paused.lock().unwrap() = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
*starting.lock().unwrap() = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pauses playback via SIGSTOP
|
||||||
|
pub fn pause(&self) {
|
||||||
|
if let Some(pid) = *self.current_pid.lock().unwrap() {
|
||||||
|
let _ = Command::new("kill")
|
||||||
|
.arg("-STOP")
|
||||||
|
.arg(pid.to_string())
|
||||||
|
.output();
|
||||||
|
*self.paused.lock().unwrap() = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resumes playback via SIGCONT (from the same position)
|
||||||
|
pub fn resume(&self) {
|
||||||
|
if let Some(pid) = *self.current_pid.lock().unwrap() {
|
||||||
|
let _ = Command::new("kill")
|
||||||
|
.arg("-CONT")
|
||||||
|
.arg(pid.to_string())
|
||||||
|
.output();
|
||||||
|
*self.paused.lock().unwrap() = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resumes playback from a specific position (restarts ffplay with -ss)
|
||||||
|
pub fn resume_from(&self, position_secs: f32) -> Result<(), String> {
|
||||||
|
let path = self.current_path.lock().unwrap().clone();
|
||||||
|
if let Some(path) = path {
|
||||||
|
self.play_from(&path, position_secs)
|
||||||
|
} else {
|
||||||
|
Err("No file to resume".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops playback (kills the process)
|
||||||
|
pub fn stop(&self) {
|
||||||
|
*self.starting.lock().unwrap() = false;
|
||||||
|
if let Some(pid) = self.current_pid.lock().unwrap().take() {
|
||||||
|
// Resume first if paused, then kill
|
||||||
|
let _ = Command::new("kill")
|
||||||
|
.arg("-CONT")
|
||||||
|
.arg(pid.to_string())
|
||||||
|
.output();
|
||||||
|
let _ = Command::new("kill")
|
||||||
|
.arg(pid.to_string())
|
||||||
|
.output();
|
||||||
|
}
|
||||||
|
*self.paused.lock().unwrap() = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if a process is active (playing or paused)
|
||||||
|
pub fn is_playing(&self) -> bool {
|
||||||
|
self.current_pid.lock().unwrap().is_some() && !*self.paused.lock().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if paused
|
||||||
|
pub fn is_paused(&self) -> bool {
|
||||||
|
self.current_pid.lock().unwrap().is_some() && *self.paused.lock().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if no active process and not starting a new one
|
||||||
|
pub fn is_stopped(&self) -> bool {
|
||||||
|
self.current_pid.lock().unwrap().is_none() && !*self.starting.lock().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_volume(&self, _volume: f32) {}
|
||||||
|
pub fn adjust_volume(&self, _delta: f32) {}
|
||||||
|
|
||||||
|
pub fn volume(&self) -> f32 {
|
||||||
|
1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn seek(&self, _delta: Duration) -> Result<(), String> {
|
||||||
|
Err("Seeking not supported".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for AudioPlayer {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_audio_player_creation() {
|
||||||
|
if let Ok(player) = AudioPlayer::new() {
|
||||||
|
assert!(player.is_stopped());
|
||||||
|
assert!(!player.is_playing());
|
||||||
|
assert!(!player.is_paused());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_volume() {
|
||||||
|
if let Ok(player) = AudioPlayer::new() {
|
||||||
|
assert_eq!(player.volume(), 1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,6 +48,14 @@ pub enum Command {
|
|||||||
ReactMessage,
|
ReactMessage,
|
||||||
SelectMessage,
|
SelectMessage,
|
||||||
|
|
||||||
|
// Media
|
||||||
|
ViewImage, // v - просмотр фото
|
||||||
|
|
||||||
|
// Voice playback
|
||||||
|
TogglePlayback, // Space - play/pause
|
||||||
|
SeekForward, // → - seek +5s
|
||||||
|
SeekBackward, // ← - seek -5s
|
||||||
|
|
||||||
// Input
|
// Input
|
||||||
SubmitMessage,
|
SubmitMessage,
|
||||||
Cancel,
|
Cancel,
|
||||||
@@ -57,6 +65,9 @@ pub enum Command {
|
|||||||
MoveToStart,
|
MoveToStart,
|
||||||
MoveToEnd,
|
MoveToEnd,
|
||||||
|
|
||||||
|
// Vim mode
|
||||||
|
EnterInsertMode,
|
||||||
|
|
||||||
// Profile
|
// Profile
|
||||||
OpenProfile,
|
OpenProfile,
|
||||||
}
|
}
|
||||||
@@ -92,6 +103,13 @@ impl KeyBinding {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn with_alt(key: KeyCode) -> Self {
|
||||||
|
Self {
|
||||||
|
key,
|
||||||
|
modifiers: KeyModifiers::ALT,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn matches(&self, event: &KeyEvent) -> bool {
|
pub fn matches(&self, event: &KeyEvent) -> bool {
|
||||||
self.key == event.code && self.modifiers == event.modifiers
|
self.key == event.code && self.modifiers == event.modifiers
|
||||||
}
|
}
|
||||||
@@ -202,6 +220,23 @@ impl Keybindings {
|
|||||||
]);
|
]);
|
||||||
// Note: SelectMessage обрабатывается через Command::SubmitMessage в handle_enter_key()
|
// Note: SelectMessage обрабатывается через Command::SubmitMessage в handle_enter_key()
|
||||||
|
|
||||||
|
// Media
|
||||||
|
bindings.insert(Command::ViewImage, vec![
|
||||||
|
KeyBinding::new(KeyCode::Char('v')),
|
||||||
|
KeyBinding::new(KeyCode::Char('м')), // RU
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Voice playback
|
||||||
|
bindings.insert(Command::TogglePlayback, vec![
|
||||||
|
KeyBinding::new(KeyCode::Char(' ')),
|
||||||
|
]);
|
||||||
|
bindings.insert(Command::SeekForward, vec![
|
||||||
|
KeyBinding::new(KeyCode::Right),
|
||||||
|
]);
|
||||||
|
bindings.insert(Command::SeekBackward, vec![
|
||||||
|
KeyBinding::new(KeyCode::Left),
|
||||||
|
]);
|
||||||
|
|
||||||
// Input
|
// Input
|
||||||
bindings.insert(Command::SubmitMessage, vec![
|
bindings.insert(Command::SubmitMessage, vec![
|
||||||
KeyBinding::new(KeyCode::Enter),
|
KeyBinding::new(KeyCode::Enter),
|
||||||
@@ -209,9 +244,7 @@ impl Keybindings {
|
|||||||
bindings.insert(Command::Cancel, vec![
|
bindings.insert(Command::Cancel, vec![
|
||||||
KeyBinding::new(KeyCode::Esc),
|
KeyBinding::new(KeyCode::Esc),
|
||||||
]);
|
]);
|
||||||
bindings.insert(Command::NewLine, vec![
|
bindings.insert(Command::NewLine, vec![]);
|
||||||
KeyBinding::with_shift(KeyCode::Enter),
|
|
||||||
]);
|
|
||||||
bindings.insert(Command::DeleteChar, vec![
|
bindings.insert(Command::DeleteChar, vec![
|
||||||
KeyBinding::new(KeyCode::Backspace),
|
KeyBinding::new(KeyCode::Backspace),
|
||||||
]);
|
]);
|
||||||
@@ -228,10 +261,16 @@ impl Keybindings {
|
|||||||
KeyBinding::with_ctrl(KeyCode::Char('e')),
|
KeyBinding::with_ctrl(KeyCode::Char('e')),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Vim mode
|
||||||
|
bindings.insert(Command::EnterInsertMode, vec![
|
||||||
|
KeyBinding::new(KeyCode::Char('i')),
|
||||||
|
KeyBinding::new(KeyCode::Char('ш')), // RU
|
||||||
|
]);
|
||||||
|
|
||||||
// Profile
|
// Profile
|
||||||
bindings.insert(Command::OpenProfile, vec![
|
bindings.insert(Command::OpenProfile, vec![
|
||||||
KeyBinding::with_ctrl(KeyCode::Char('i')),
|
KeyBinding::with_ctrl(KeyCode::Char('u')), // Ctrl+U instead of Ctrl+I
|
||||||
KeyBinding::with_ctrl(KeyCode::Char('ш')), // RU
|
KeyBinding::with_ctrl(KeyCode::Char('г')), // RU
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Self { bindings }
|
Self { bindings }
|
||||||
|
|||||||
197
src/config/loader.rs
Normal file
197
src/config/loader.rs
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
//! Config file loading, saving, and credentials management.
|
||||||
|
//!
|
||||||
|
//! Searches for config at `~/.config/tele-tui/config.toml`.
|
||||||
|
//! Credentials loaded from file or environment variables.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use super::Config;
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
/// Возвращает путь к конфигурационному файлу.
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// `Some(PathBuf)` - `~/.config/tele-tui/config.toml`
|
||||||
|
/// `None` - Не удалось определить директорию конфигурации
|
||||||
|
pub fn config_path() -> Option<PathBuf> {
|
||||||
|
dirs::config_dir().map(|mut path| {
|
||||||
|
path.push("tele-tui");
|
||||||
|
path.push("config.toml");
|
||||||
|
path
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Путь к директории конфигурации
|
||||||
|
pub fn config_dir() -> Option<PathBuf> {
|
||||||
|
dirs::config_dir().map(|mut path| {
|
||||||
|
path.push("tele-tui");
|
||||||
|
path
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Загружает конфигурацию из файла.
|
||||||
|
///
|
||||||
|
/// Ищет конфиг в `~/.config/tele-tui/config.toml`.
|
||||||
|
/// Если файл не существует, создаёт дефолтный.
|
||||||
|
/// Если файл невалиден, возвращает дефолтные значения.
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// Всегда возвращает валидную конфигурацию.
|
||||||
|
pub fn load() -> Self {
|
||||||
|
let config_path = match Self::config_path() {
|
||||||
|
Some(path) => path,
|
||||||
|
None => {
|
||||||
|
tracing::warn!("Could not determine config directory, using defaults");
|
||||||
|
return Self::default();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !config_path.exists() {
|
||||||
|
// Создаём дефолтный конфиг при первом запуске
|
||||||
|
let default_config = Self::default();
|
||||||
|
if let Err(e) = default_config.save() {
|
||||||
|
tracing::warn!("Could not create default config: {}", e);
|
||||||
|
}
|
||||||
|
return default_config;
|
||||||
|
}
|
||||||
|
|
||||||
|
match fs::read_to_string(&config_path) {
|
||||||
|
Ok(content) => match toml::from_str::<Config>(&content) {
|
||||||
|
Ok(config) => {
|
||||||
|
// Валидируем загруженный конфиг
|
||||||
|
if let Err(e) = config.validate() {
|
||||||
|
tracing::error!("Config validation error: {}", e);
|
||||||
|
tracing::warn!("Using default configuration instead");
|
||||||
|
Self::default()
|
||||||
|
} else {
|
||||||
|
config
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Could not parse config file: {}", e);
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Could not read config file: {}", e);
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Сохраняет конфигурацию в файл.
|
||||||
|
///
|
||||||
|
/// Создаёт директорию `~/.config/tele-tui/` если её нет.
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// * `Ok(())` - Конфиг сохранен
|
||||||
|
/// * `Err(String)` - Ошибка сохранения
|
||||||
|
pub fn save(&self) -> Result<(), String> {
|
||||||
|
let config_dir =
|
||||||
|
Self::config_dir().ok_or_else(|| "Could not determine config directory".to_string())?;
|
||||||
|
|
||||||
|
// Создаём директорию если её нет
|
||||||
|
fs::create_dir_all(&config_dir)
|
||||||
|
.map_err(|e| format!("Could not create config directory: {}", e))?;
|
||||||
|
|
||||||
|
let config_path = config_dir.join("config.toml");
|
||||||
|
|
||||||
|
let toml_string = toml::to_string_pretty(self)
|
||||||
|
.map_err(|e| format!("Could not serialize config: {}", e))?;
|
||||||
|
|
||||||
|
fs::write(&config_path, toml_string)
|
||||||
|
.map_err(|e| format!("Could not write config file: {}", e))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Путь к файлу credentials
|
||||||
|
pub fn credentials_path() -> Option<PathBuf> {
|
||||||
|
Self::config_dir().map(|dir| dir.join("credentials"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Загружает API_ID и API_HASH для Telegram.
|
||||||
|
///
|
||||||
|
/// Ищет credentials в следующем порядке:
|
||||||
|
/// 1. `~/.config/tele-tui/credentials` файл
|
||||||
|
/// 2. Переменные окружения `API_ID` и `API_HASH`
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// * `Ok((api_id, api_hash))` - Учетные данные найдены
|
||||||
|
/// * `Err(String)` - Ошибка с инструкциями по настройке
|
||||||
|
pub fn load_credentials() -> Result<(i32, String), String> {
|
||||||
|
// 1. Пробуем загрузить из ~/.config/tele-tui/credentials
|
||||||
|
if let Some(credentials) = Self::load_credentials_from_file() {
|
||||||
|
return Ok(credentials);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Пробуем загрузить из переменных окружения (.env)
|
||||||
|
if let Some(credentials) = Self::load_credentials_from_env() {
|
||||||
|
return Ok(credentials);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Не нашли credentials - возвращаем инструкции
|
||||||
|
let credentials_path = Self::credentials_path()
|
||||||
|
.map(|p| p.display().to_string())
|
||||||
|
.unwrap_or_else(|| "~/.config/tele-tui/credentials".to_string());
|
||||||
|
|
||||||
|
Err(format!(
|
||||||
|
"Telegram API credentials not found!\n\n\
|
||||||
|
Please create a file at:\n {}\n\n\
|
||||||
|
With the following content:\n\
|
||||||
|
API_ID=your_api_id\n\
|
||||||
|
API_HASH=your_api_hash\n\n\
|
||||||
|
You can get API credentials at: https://my.telegram.org/apps\n\n\
|
||||||
|
Alternatively, you can create a .env file in the current directory.",
|
||||||
|
credentials_path
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Загружает credentials из файла ~/.config/tele-tui/credentials
|
||||||
|
fn load_credentials_from_file() -> Option<(i32, String)> {
|
||||||
|
let cred_path = Self::credentials_path()?;
|
||||||
|
|
||||||
|
if !cred_path.exists() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = fs::read_to_string(&cred_path).ok()?;
|
||||||
|
let mut api_id: Option<i32> = None;
|
||||||
|
let mut api_hash: Option<String> = None;
|
||||||
|
|
||||||
|
for line in content.lines() {
|
||||||
|
let line = line.trim();
|
||||||
|
if line.is_empty() || line.starts_with('#') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (key, value) = line.split_once('=')?;
|
||||||
|
let key = key.trim();
|
||||||
|
let value = value.trim();
|
||||||
|
|
||||||
|
match key {
|
||||||
|
"API_ID" => api_id = value.parse().ok(),
|
||||||
|
"API_HASH" => api_hash = Some(value.to_string()),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some((api_id?, api_hash?))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Загружает credentials из переменных окружения (.env)
|
||||||
|
fn load_credentials_from_env() -> Option<(i32, String)> {
|
||||||
|
use std::env;
|
||||||
|
|
||||||
|
let api_id_str = env::var("API_ID").ok()?;
|
||||||
|
let api_hash = env::var("API_HASH").ok()?;
|
||||||
|
let api_id = api_id_str.parse::<i32>().ok()?;
|
||||||
|
|
||||||
|
Some((api_id, api_hash))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
|
//! Configuration module.
|
||||||
|
//!
|
||||||
|
//! Loads settings from `~/.config/tele-tui/config.toml`.
|
||||||
|
//! Structs: Config, GeneralConfig, ColorsConfig, NotificationsConfig, Keybindings.
|
||||||
|
|
||||||
pub mod keybindings;
|
pub mod keybindings;
|
||||||
|
mod loader;
|
||||||
|
mod validation;
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::fs;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
pub use keybindings::{Command, Keybindings};
|
pub use keybindings::{Command, Keybindings};
|
||||||
|
|
||||||
@@ -38,6 +43,14 @@ pub struct Config {
|
|||||||
/// Настройки desktop notifications.
|
/// Настройки desktop notifications.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub notifications: NotificationsConfig,
|
pub notifications: NotificationsConfig,
|
||||||
|
|
||||||
|
/// Настройки отображения изображений.
|
||||||
|
#[serde(default)]
|
||||||
|
pub images: ImagesConfig,
|
||||||
|
|
||||||
|
/// Настройки аудио (голосовые сообщения).
|
||||||
|
#[serde(default)]
|
||||||
|
pub audio: AudioConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Общие настройки приложения.
|
/// Общие настройки приложения.
|
||||||
@@ -100,7 +113,59 @@ pub struct NotificationsConfig {
|
|||||||
pub urgency: String,
|
pub urgency: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Дефолтные значения
|
/// Настройки отображения изображений.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ImagesConfig {
|
||||||
|
/// Показывать превью изображений в чате
|
||||||
|
#[serde(default = "default_show_images")]
|
||||||
|
pub show_images: bool,
|
||||||
|
|
||||||
|
/// Размер кэша изображений (в МБ)
|
||||||
|
#[serde(default = "default_image_cache_size_mb")]
|
||||||
|
pub cache_size_mb: u64,
|
||||||
|
|
||||||
|
/// Максимальная ширина inline превью (в символах)
|
||||||
|
#[serde(default = "default_inline_image_max_width")]
|
||||||
|
pub inline_image_max_width: usize,
|
||||||
|
|
||||||
|
/// Автоматически загружать изображения при открытии чата
|
||||||
|
#[serde(default = "default_auto_download_images")]
|
||||||
|
pub auto_download_images: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ImagesConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
show_images: default_show_images(),
|
||||||
|
cache_size_mb: default_image_cache_size_mb(),
|
||||||
|
inline_image_max_width: default_inline_image_max_width(),
|
||||||
|
auto_download_images: default_auto_download_images(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Настройки аудио (голосовые сообщения).
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AudioConfig {
|
||||||
|
/// Размер кэша голосовых файлов (в МБ)
|
||||||
|
#[serde(default = "default_audio_cache_size_mb")]
|
||||||
|
pub cache_size_mb: u64,
|
||||||
|
|
||||||
|
/// Автоматически загружать голосовые при открытии чата
|
||||||
|
#[serde(default = "default_auto_download_voice")]
|
||||||
|
pub auto_download_voice: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AudioConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
cache_size_mb: default_audio_cache_size_mb(),
|
||||||
|
auto_download_voice: default_auto_download_voice(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Дефолтные значения (используются serde атрибутами)
|
||||||
fn default_timezone() -> String {
|
fn default_timezone() -> String {
|
||||||
"+03:00".to_string()
|
"+03:00".to_string()
|
||||||
}
|
}
|
||||||
@@ -126,7 +191,7 @@ fn default_reaction_other_color() -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn default_notifications_enabled() -> bool {
|
fn default_notifications_enabled() -> bool {
|
||||||
true
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_show_preview() -> bool {
|
fn default_show_preview() -> bool {
|
||||||
@@ -141,6 +206,30 @@ fn default_notification_urgency() -> String {
|
|||||||
"normal".to_string()
|
"normal".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_show_images() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_image_cache_size_mb() -> u64 {
|
||||||
|
crate::constants::DEFAULT_IMAGE_CACHE_SIZE_MB
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_inline_image_max_width() -> usize {
|
||||||
|
crate::constants::INLINE_IMAGE_MAX_WIDTH
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_auto_download_images() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_audio_cache_size_mb() -> u64 {
|
||||||
|
crate::constants::DEFAULT_AUDIO_CACHE_SIZE_MB
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_auto_download_voice() -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
impl Default for GeneralConfig {
|
impl Default for GeneralConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self { timezone: default_timezone() }
|
Self { timezone: default_timezone() }
|
||||||
@@ -178,302 +267,12 @@ impl Default for Config {
|
|||||||
colors: ColorsConfig::default(),
|
colors: ColorsConfig::default(),
|
||||||
keybindings: Keybindings::default(),
|
keybindings: Keybindings::default(),
|
||||||
notifications: NotificationsConfig::default(),
|
notifications: NotificationsConfig::default(),
|
||||||
|
images: ImagesConfig::default(),
|
||||||
|
audio: AudioConfig::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
|
||||||
/// Валидация конфигурации
|
|
||||||
pub fn validate(&self) -> Result<(), String> {
|
|
||||||
// Проверка timezone
|
|
||||||
if !self.general.timezone.starts_with('+') && !self.general.timezone.starts_with('-') {
|
|
||||||
return Err(format!(
|
|
||||||
"Invalid timezone (must start with + or -): {}",
|
|
||||||
self.general.timezone
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверка цветов
|
|
||||||
let valid_colors = [
|
|
||||||
"black",
|
|
||||||
"red",
|
|
||||||
"green",
|
|
||||||
"yellow",
|
|
||||||
"blue",
|
|
||||||
"magenta",
|
|
||||||
"cyan",
|
|
||||||
"gray",
|
|
||||||
"grey",
|
|
||||||
"white",
|
|
||||||
"darkgray",
|
|
||||||
"darkgrey",
|
|
||||||
"lightred",
|
|
||||||
"lightgreen",
|
|
||||||
"lightyellow",
|
|
||||||
"lightblue",
|
|
||||||
"lightmagenta",
|
|
||||||
"lightcyan",
|
|
||||||
];
|
|
||||||
|
|
||||||
for color_name in [
|
|
||||||
&self.colors.incoming_message,
|
|
||||||
&self.colors.outgoing_message,
|
|
||||||
&self.colors.selected_message,
|
|
||||||
&self.colors.reaction_chosen,
|
|
||||||
&self.colors.reaction_other,
|
|
||||||
] {
|
|
||||||
if !valid_colors.contains(&color_name.to_lowercase().as_str()) {
|
|
||||||
return Err(format!("Invalid color: {}", color_name));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Возвращает путь к конфигурационному файлу.
|
|
||||||
///
|
|
||||||
/// # Returns
|
|
||||||
///
|
|
||||||
/// `Some(PathBuf)` - `~/.config/tele-tui/config.toml`
|
|
||||||
/// `None` - Не удалось определить директорию конфигурации
|
|
||||||
pub fn config_path() -> Option<PathBuf> {
|
|
||||||
dirs::config_dir().map(|mut path| {
|
|
||||||
path.push("tele-tui");
|
|
||||||
path.push("config.toml");
|
|
||||||
path
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Путь к директории конфигурации
|
|
||||||
pub fn config_dir() -> Option<PathBuf> {
|
|
||||||
dirs::config_dir().map(|mut path| {
|
|
||||||
path.push("tele-tui");
|
|
||||||
path
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Загружает конфигурацию из файла.
|
|
||||||
///
|
|
||||||
/// Ищет конфиг в `~/.config/tele-tui/config.toml`.
|
|
||||||
/// Если файл не существует, создаёт дефолтный.
|
|
||||||
/// Если файл невалиден, возвращает дефолтные значения.
|
|
||||||
///
|
|
||||||
/// # Returns
|
|
||||||
///
|
|
||||||
/// Всегда возвращает валидную конфигурацию.
|
|
||||||
///
|
|
||||||
/// # Examples
|
|
||||||
///
|
|
||||||
/// ```ignore
|
|
||||||
/// let config = Config::load();
|
|
||||||
/// ```
|
|
||||||
pub fn load() -> Self {
|
|
||||||
let config_path = match Self::config_path() {
|
|
||||||
Some(path) => path,
|
|
||||||
None => {
|
|
||||||
tracing::warn!("Could not determine config directory, using defaults");
|
|
||||||
return Self::default();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if !config_path.exists() {
|
|
||||||
// Создаём дефолтный конфиг при первом запуске
|
|
||||||
let default_config = Self::default();
|
|
||||||
if let Err(e) = default_config.save() {
|
|
||||||
tracing::warn!("Could not create default config: {}", e);
|
|
||||||
}
|
|
||||||
return default_config;
|
|
||||||
}
|
|
||||||
|
|
||||||
match fs::read_to_string(&config_path) {
|
|
||||||
Ok(content) => match toml::from_str::<Config>(&content) {
|
|
||||||
Ok(config) => {
|
|
||||||
// Валидируем загруженный конфиг
|
|
||||||
if let Err(e) = config.validate() {
|
|
||||||
tracing::error!("Config validation error: {}", e);
|
|
||||||
tracing::warn!("Using default configuration instead");
|
|
||||||
Self::default()
|
|
||||||
} else {
|
|
||||||
config
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!("Could not parse config file: {}", e);
|
|
||||||
Self::default()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!("Could not read config file: {}", e);
|
|
||||||
Self::default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Сохраняет конфигурацию в файл.
|
|
||||||
///
|
|
||||||
/// Создаёт директорию `~/.config/tele-tui/` если её нет.
|
|
||||||
///
|
|
||||||
/// # Returns
|
|
||||||
///
|
|
||||||
/// * `Ok(())` - Конфиг сохранен
|
|
||||||
/// * `Err(String)` - Ошибка сохранения
|
|
||||||
pub fn save(&self) -> Result<(), String> {
|
|
||||||
let config_dir =
|
|
||||||
Self::config_dir().ok_or_else(|| "Could not determine config directory".to_string())?;
|
|
||||||
|
|
||||||
// Создаём директорию если её нет
|
|
||||||
fs::create_dir_all(&config_dir)
|
|
||||||
.map_err(|e| format!("Could not create config directory: {}", e))?;
|
|
||||||
|
|
||||||
let config_path = config_dir.join("config.toml");
|
|
||||||
|
|
||||||
let toml_string = toml::to_string_pretty(self)
|
|
||||||
.map_err(|e| format!("Could not serialize config: {}", e))?;
|
|
||||||
|
|
||||||
fs::write(&config_path, toml_string)
|
|
||||||
.map_err(|e| format!("Could not write config file: {}", e))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Парсит строку цвета в `ratatui::style::Color`.
|
|
||||||
///
|
|
||||||
/// Поддерживает стандартные цвета (red, green, blue и т.д.),
|
|
||||||
/// light-варианты (lightred, lightgreen и т.д.) и grey/gray.
|
|
||||||
///
|
|
||||||
/// # Arguments
|
|
||||||
///
|
|
||||||
/// * `color_str` - Название цвета (case-insensitive)
|
|
||||||
///
|
|
||||||
/// # Returns
|
|
||||||
///
|
|
||||||
/// `Color` - Соответствующий цвет или `White` если цвет не распознан
|
|
||||||
///
|
|
||||||
/// # Examples
|
|
||||||
///
|
|
||||||
/// ```ignore
|
|
||||||
/// let color = config.parse_color("red");
|
|
||||||
/// let color = config.parse_color("LightBlue");
|
|
||||||
/// ```
|
|
||||||
pub fn parse_color(&self, color_str: &str) -> ratatui::style::Color {
|
|
||||||
use ratatui::style::Color;
|
|
||||||
|
|
||||||
match color_str.to_lowercase().as_str() {
|
|
||||||
"black" => Color::Black,
|
|
||||||
"red" => Color::Red,
|
|
||||||
"green" => Color::Green,
|
|
||||||
"yellow" => Color::Yellow,
|
|
||||||
"blue" => Color::Blue,
|
|
||||||
"magenta" => Color::Magenta,
|
|
||||||
"cyan" => Color::Cyan,
|
|
||||||
"gray" | "grey" => Color::Gray,
|
|
||||||
"white" => Color::White,
|
|
||||||
"darkgray" | "darkgrey" => Color::DarkGray,
|
|
||||||
"lightred" => Color::LightRed,
|
|
||||||
"lightgreen" => Color::LightGreen,
|
|
||||||
"lightyellow" => Color::LightYellow,
|
|
||||||
"lightblue" => Color::LightBlue,
|
|
||||||
"lightmagenta" => Color::LightMagenta,
|
|
||||||
"lightcyan" => Color::LightCyan,
|
|
||||||
_ => Color::White, // fallback
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Путь к файлу credentials
|
|
||||||
pub fn credentials_path() -> Option<PathBuf> {
|
|
||||||
Self::config_dir().map(|dir| dir.join("credentials"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Загружает API_ID и API_HASH для Telegram.
|
|
||||||
///
|
|
||||||
/// Ищет credentials в следующем порядке:
|
|
||||||
/// 1. `~/.config/tele-tui/credentials` файл
|
|
||||||
/// 2. Переменные окружения `API_ID` и `API_HASH`
|
|
||||||
///
|
|
||||||
/// # Returns
|
|
||||||
///
|
|
||||||
/// * `Ok((api_id, api_hash))` - Учетные данные найдены
|
|
||||||
/// * `Err(String)` - Ошибка с инструкциями по настройке
|
|
||||||
///
|
|
||||||
/// # Credentials Format
|
|
||||||
///
|
|
||||||
/// Файл `~/.config/tele-tui/credentials`:
|
|
||||||
/// ```text
|
|
||||||
/// API_ID=12345
|
|
||||||
/// API_HASH=your_api_hash_here
|
|
||||||
/// ```
|
|
||||||
pub fn load_credentials() -> Result<(i32, String), String> {
|
|
||||||
// 1. Пробуем загрузить из ~/.config/tele-tui/credentials
|
|
||||||
if let Some(credentials) = Self::load_credentials_from_file() {
|
|
||||||
return Ok(credentials);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Пробуем загрузить из переменных окружения (.env)
|
|
||||||
if let Some(credentials) = Self::load_credentials_from_env() {
|
|
||||||
return Ok(credentials);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Не нашли credentials - возвращаем инструкции
|
|
||||||
let credentials_path = Self::credentials_path()
|
|
||||||
.map(|p| p.display().to_string())
|
|
||||||
.unwrap_or_else(|| "~/.config/tele-tui/credentials".to_string());
|
|
||||||
|
|
||||||
Err(format!(
|
|
||||||
"Telegram API credentials not found!\n\n\
|
|
||||||
Please create a file at:\n {}\n\n\
|
|
||||||
With the following content:\n\
|
|
||||||
API_ID=your_api_id\n\
|
|
||||||
API_HASH=your_api_hash\n\n\
|
|
||||||
You can get API credentials at: https://my.telegram.org/apps\n\n\
|
|
||||||
Alternatively, you can create a .env file in the current directory.",
|
|
||||||
credentials_path
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Загружает credentials из файла ~/.config/tele-tui/credentials
|
|
||||||
fn load_credentials_from_file() -> Option<(i32, String)> {
|
|
||||||
let cred_path = Self::credentials_path()?;
|
|
||||||
|
|
||||||
if !cred_path.exists() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let content = fs::read_to_string(&cred_path).ok()?;
|
|
||||||
let mut api_id: Option<i32> = None;
|
|
||||||
let mut api_hash: Option<String> = None;
|
|
||||||
|
|
||||||
for line in content.lines() {
|
|
||||||
let line = line.trim();
|
|
||||||
if line.is_empty() || line.starts_with('#') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let (key, value) = line.split_once('=')?;
|
|
||||||
let key = key.trim();
|
|
||||||
let value = value.trim();
|
|
||||||
|
|
||||||
match key {
|
|
||||||
"API_ID" => api_id = value.parse().ok(),
|
|
||||||
"API_HASH" => api_hash = Some(value.to_string()),
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Some((api_id?, api_hash?))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Загружает credentials из переменных окружения (.env)
|
|
||||||
fn load_credentials_from_env() -> Option<(i32, String)> {
|
|
||||||
use std::env;
|
|
||||||
|
|
||||||
let api_id_str = env::var("API_ID").ok()?;
|
|
||||||
let api_hash = env::var("API_HASH").ok()?;
|
|
||||||
let api_id = api_id_str.parse::<i32>().ok()?;
|
|
||||||
|
|
||||||
Some((api_id, api_hash))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
88
src/config/validation.rs
Normal file
88
src/config/validation.rs
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
//! Config validation: timezone format, color names, notification settings.
|
||||||
|
|
||||||
|
use super::Config;
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
/// Валидация конфигурации
|
||||||
|
pub fn validate(&self) -> Result<(), String> {
|
||||||
|
// Проверка timezone
|
||||||
|
if !self.general.timezone.starts_with('+') && !self.general.timezone.starts_with('-') {
|
||||||
|
return Err(format!(
|
||||||
|
"Invalid timezone (must start with + or -): {}",
|
||||||
|
self.general.timezone
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка цветов
|
||||||
|
let valid_colors = [
|
||||||
|
"black",
|
||||||
|
"red",
|
||||||
|
"green",
|
||||||
|
"yellow",
|
||||||
|
"blue",
|
||||||
|
"magenta",
|
||||||
|
"cyan",
|
||||||
|
"gray",
|
||||||
|
"grey",
|
||||||
|
"white",
|
||||||
|
"darkgray",
|
||||||
|
"darkgrey",
|
||||||
|
"lightred",
|
||||||
|
"lightgreen",
|
||||||
|
"lightyellow",
|
||||||
|
"lightblue",
|
||||||
|
"lightmagenta",
|
||||||
|
"lightcyan",
|
||||||
|
];
|
||||||
|
|
||||||
|
for color_name in [
|
||||||
|
&self.colors.incoming_message,
|
||||||
|
&self.colors.outgoing_message,
|
||||||
|
&self.colors.selected_message,
|
||||||
|
&self.colors.reaction_chosen,
|
||||||
|
&self.colors.reaction_other,
|
||||||
|
] {
|
||||||
|
if !valid_colors.contains(&color_name.to_lowercase().as_str()) {
|
||||||
|
return Err(format!("Invalid color: {}", color_name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Парсит строку цвета в `ratatui::style::Color`.
|
||||||
|
///
|
||||||
|
/// Поддерживает стандартные цвета (red, green, blue и т.д.),
|
||||||
|
/// light-варианты (lightred, lightgreen и т.д.) и grey/gray.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `color_str` - Название цвета (case-insensitive)
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// `Color` - Соответствующий цвет или `White` если цвет не распознан
|
||||||
|
pub fn parse_color(&self, color_str: &str) -> ratatui::style::Color {
|
||||||
|
use ratatui::style::Color;
|
||||||
|
|
||||||
|
match color_str.to_lowercase().as_str() {
|
||||||
|
"black" => Color::Black,
|
||||||
|
"red" => Color::Red,
|
||||||
|
"green" => Color::Green,
|
||||||
|
"yellow" => Color::Yellow,
|
||||||
|
"blue" => Color::Blue,
|
||||||
|
"magenta" => Color::Magenta,
|
||||||
|
"cyan" => Color::Cyan,
|
||||||
|
"gray" | "grey" => Color::Gray,
|
||||||
|
"white" => Color::White,
|
||||||
|
"darkgray" | "darkgrey" => Color::DarkGray,
|
||||||
|
"lightred" => Color::LightRed,
|
||||||
|
"lightgreen" => Color::LightGreen,
|
||||||
|
"lightyellow" => Color::LightYellow,
|
||||||
|
"lightblue" => Color::LightBlue,
|
||||||
|
"lightmagenta" => Color::LightMagenta,
|
||||||
|
"lightcyan" => Color::LightCyan,
|
||||||
|
_ => Color::White, // fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// Application constants
|
//! Application-wide constants (memory limits, timeouts, UI sizes).
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Memory Limits
|
// Memory Limits
|
||||||
@@ -35,3 +35,49 @@ pub const LAZY_LOAD_USERS_PER_TICK: usize = 5;
|
|||||||
|
|
||||||
/// Лимит количества сообщений для загрузки через TDLib за раз
|
/// Лимит количества сообщений для загрузки через TDLib за раз
|
||||||
pub const TDLIB_MESSAGE_LIMIT: i32 = 50;
|
pub const TDLIB_MESSAGE_LIMIT: i32 = 50;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Images
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Максимальная ширина превью изображения (в символах)
|
||||||
|
pub const MAX_IMAGE_WIDTH: u16 = 30;
|
||||||
|
|
||||||
|
/// Максимальная высота превью изображения (в строках)
|
||||||
|
pub const MAX_IMAGE_HEIGHT: u16 = 15;
|
||||||
|
|
||||||
|
/// Минимальная высота превью изображения (в строках)
|
||||||
|
pub const MIN_IMAGE_HEIGHT: u16 = 3;
|
||||||
|
|
||||||
|
/// Таймаут скачивания файла (в секундах)
|
||||||
|
pub const FILE_DOWNLOAD_TIMEOUT_SECS: u64 = 30;
|
||||||
|
|
||||||
|
/// Размер кэша изображений по умолчанию (в МБ)
|
||||||
|
pub const DEFAULT_IMAGE_CACHE_SIZE_MB: u64 = 500;
|
||||||
|
|
||||||
|
/// Максимальная ширина inline превью изображений (в символах)
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
pub const INLINE_IMAGE_MAX_WIDTH: usize = 50;
|
||||||
|
|
||||||
|
/// Ширина одного фото в альбоме (в символах)
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
pub const ALBUM_PHOTO_WIDTH: u16 = 16;
|
||||||
|
|
||||||
|
/// Высота одного фото в альбоме (в строках)
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
pub const ALBUM_PHOTO_HEIGHT: u16 = 8;
|
||||||
|
|
||||||
|
/// Отступ между фото в альбоме (в символах)
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
pub const ALBUM_PHOTO_GAP: u16 = 1;
|
||||||
|
|
||||||
|
/// Максимальное количество фото в одном ряду альбома
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
pub const ALBUM_GRID_MAX_COLS: usize = 3;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Audio
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Размер кэша голосовых сообщений по умолчанию (в МБ)
|
||||||
|
pub const DEFAULT_AUDIO_CACHE_SIZE_MB: u64 = 100;
|
||||||
|
|||||||
829
src/input/handlers/chat.rs
Normal file
829
src/input/handlers/chat.rs
Normal file
@@ -0,0 +1,829 @@
|
|||||||
|
//! Chat input handlers
|
||||||
|
//!
|
||||||
|
//! Handles keyboard input when a chat is open, including:
|
||||||
|
//! - Message scrolling and navigation
|
||||||
|
//! - Message selection and actions
|
||||||
|
//! - Editing and sending messages
|
||||||
|
//! - Loading older messages
|
||||||
|
|
||||||
|
use crate::app::App;
|
||||||
|
use crate::app::InputMode;
|
||||||
|
use crate::app::methods::{
|
||||||
|
compose::ComposeMethods, messages::MessageMethods,
|
||||||
|
modal::ModalMethods, navigation::NavigationMethods,
|
||||||
|
};
|
||||||
|
use crate::tdlib::{TdClientTrait, ChatAction};
|
||||||
|
use crate::types::{ChatId, MessageId};
|
||||||
|
use crate::utils::{is_non_empty, with_timeout, with_timeout_msg};
|
||||||
|
use crate::input::handlers::{copy_to_clipboard, format_message_for_clipboard};
|
||||||
|
use super::chat_list::open_chat_and_load_data;
|
||||||
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
/// Обработка режима выбора сообщения для действий
|
||||||
|
///
|
||||||
|
/// Обрабатывает:
|
||||||
|
/// - Навигацию по сообщениям (Up/Down)
|
||||||
|
/// - Удаление сообщения (d/в/Delete)
|
||||||
|
/// - Ответ на сообщение (r/к)
|
||||||
|
/// - Пересылку сообщения (f/а)
|
||||||
|
/// - Копирование сообщения (y/н)
|
||||||
|
/// - Добавление реакции (e/у)
|
||||||
|
pub async fn handle_message_selection<T: TdClientTrait>(app: &mut App<T>, _key: KeyEvent, command: Option<crate::config::Command>) {
|
||||||
|
match command {
|
||||||
|
Some(crate::config::Command::MoveUp) => {
|
||||||
|
app.select_previous_message();
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::MoveDown) => {
|
||||||
|
app.select_next_message();
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::DeleteMessage) => {
|
||||||
|
let Some(msg) = app.get_selected_message() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let can_delete =
|
||||||
|
msg.can_be_deleted_only_for_self() || msg.can_be_deleted_for_all_users();
|
||||||
|
if can_delete {
|
||||||
|
app.chat_state = crate::app::ChatState::DeleteConfirmation {
|
||||||
|
message_id: msg.id(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::EnterInsertMode) => {
|
||||||
|
app.input_mode = InputMode::Insert;
|
||||||
|
app.chat_state = crate::app::ChatState::Normal;
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::ReplyMessage) => {
|
||||||
|
app.start_reply_to_selected();
|
||||||
|
app.input_mode = InputMode::Insert;
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::ForwardMessage) => {
|
||||||
|
app.start_forward_selected();
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::CopyMessage) => {
|
||||||
|
let Some(msg) = app.get_selected_message() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::ViewImage) => {
|
||||||
|
handle_view_or_play_media(app).await;
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::TogglePlayback) => {
|
||||||
|
handle_toggle_voice_playback(app).await;
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::SeekForward | crate::config::Command::MoveRight) => {
|
||||||
|
handle_voice_seek(app, 5.0);
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::SeekBackward | crate::config::Command::MoveLeft) => {
|
||||||
|
handle_voice_seek(app, -5.0);
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::ReactMessage) => {
|
||||||
|
let Some(msg) = app.get_selected_message() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let chat_id = app.selected_chat_id.unwrap();
|
||||||
|
let message_id = msg.id();
|
||||||
|
|
||||||
|
app.status_message = Some("Загрузка реакций...".to_string());
|
||||||
|
app.needs_redraw = true;
|
||||||
|
|
||||||
|
match with_timeout_msg(
|
||||||
|
Duration::from_secs(5),
|
||||||
|
app.td_client
|
||||||
|
.get_message_available_reactions(chat_id, message_id),
|
||||||
|
"Таймаут загрузки реакций",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(reactions) => {
|
||||||
|
let reactions: Vec<String> = 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.as_i64(), reactions);
|
||||||
|
app.status_message = None;
|
||||||
|
app.needs_redraw = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
app.error_message = Some(e);
|
||||||
|
app.status_message = None;
|
||||||
|
app.needs_redraw = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Редактирование существующего сообщения
|
||||||
|
pub async fn edit_message<T: TdClientTrait>(app: &mut App<T>, chat_id: i64, msg_id: MessageId, text: String) {
|
||||||
|
// Проверяем, что сообщение есть в локальном кэше
|
||||||
|
let msg_exists = app.td_client.current_chat_messages()
|
||||||
|
.iter()
|
||||||
|
.any(|m| m.id() == msg_id);
|
||||||
|
|
||||||
|
if !msg_exists {
|
||||||
|
app.error_message = Some(format!(
|
||||||
|
"Сообщение {} не найдено в кэше чата {}",
|
||||||
|
msg_id.as_i64(), chat_id
|
||||||
|
));
|
||||||
|
app.chat_state = crate::app::ChatState::Normal;
|
||||||
|
app.message_input.clear();
|
||||||
|
app.cursor_position = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match with_timeout_msg(
|
||||||
|
Duration::from_secs(5),
|
||||||
|
app.td_client.edit_message(ChatId::new(chat_id), msg_id, text),
|
||||||
|
"Таймаут редактирования",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(mut edited_msg) => {
|
||||||
|
// Сохраняем reply_to из старого сообщения (если есть)
|
||||||
|
let messages = app.td_client.current_chat_messages_mut();
|
||||||
|
if let Some(pos) = messages.iter().position(|m| m.id() == msg_id) {
|
||||||
|
let old_reply_to = messages[pos].interactions.reply_to.clone();
|
||||||
|
// Если в старом сообщении был reply и в новом он "Unknown" - сохраняем старый
|
||||||
|
if let Some(old_reply) = old_reply_to {
|
||||||
|
if edited_msg.interactions.reply_to.as_ref()
|
||||||
|
.map_or(true, |r| r.sender_name == "Unknown") {
|
||||||
|
edited_msg.interactions.reply_to = Some(old_reply);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Заменяем сообщение
|
||||||
|
messages[pos] = edited_msg;
|
||||||
|
}
|
||||||
|
// Очищаем инпут и сбрасываем состояние ПОСЛЕ успешного редактирования
|
||||||
|
app.message_input.clear();
|
||||||
|
app.cursor_position = 0;
|
||||||
|
app.chat_state = crate::app::ChatState::Normal;
|
||||||
|
app.needs_redraw = true;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
app.error_message = Some(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Отправка нового сообщения (с опциональным reply)
|
||||||
|
pub async fn send_new_message<T: TdClientTrait>(app: &mut App<T>, chat_id: i64, text: String) {
|
||||||
|
let reply_to_id = if app.is_replying() {
|
||||||
|
app.chat_state.selected_message_id()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// Создаём ReplyInfo ДО отправки, пока сообщение точно доступно
|
||||||
|
let reply_info = app.get_replying_to_message().map(|m| {
|
||||||
|
crate::tdlib::ReplyInfo {
|
||||||
|
message_id: m.id(),
|
||||||
|
sender_name: m.sender_name().to_string(),
|
||||||
|
text: m.text().to_string(),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.message_input.clear();
|
||||||
|
app.cursor_position = 0;
|
||||||
|
// Сбрасываем режим reply если он был активен
|
||||||
|
if app.is_replying() {
|
||||||
|
app.chat_state = crate::app::ChatState::Normal;
|
||||||
|
}
|
||||||
|
app.last_typing_sent = None;
|
||||||
|
|
||||||
|
// Отменяем typing status
|
||||||
|
app.td_client.send_chat_action(ChatId::new(chat_id), ChatAction::Cancel).await;
|
||||||
|
|
||||||
|
match with_timeout_msg(
|
||||||
|
Duration::from_secs(5),
|
||||||
|
app.td_client.send_message(ChatId::new(chat_id), text, reply_to_id, reply_info),
|
||||||
|
"Таймаут отправки",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(sent_msg) => {
|
||||||
|
// Добавляем отправленное сообщение в список (с лимитом)
|
||||||
|
app.td_client.push_message(sent_msg);
|
||||||
|
// Сбрасываем скролл чтобы видеть новое сообщение
|
||||||
|
app.message_scroll_offset = 0;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
app.error_message = Some(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Обработка клавиши Enter
|
||||||
|
///
|
||||||
|
/// Обрабатывает три сценария:
|
||||||
|
/// 1. В режиме выбора сообщения: начать редактирование
|
||||||
|
/// 2. В открытом чате: отправить новое или редактировать существующее сообщение
|
||||||
|
/// 3. В списке чатов: открыть выбранный чат
|
||||||
|
pub async fn handle_enter_key<T: TdClientTrait>(app: &mut App<T>) {
|
||||||
|
// Сценарий 1: Открытие чата из списка
|
||||||
|
if app.selected_chat_id.is_none() {
|
||||||
|
let prev_selected = app.selected_chat_id;
|
||||||
|
app.select_current_chat();
|
||||||
|
|
||||||
|
if app.selected_chat_id != prev_selected {
|
||||||
|
if let Some(chat_id) = app.get_selected_chat_id() {
|
||||||
|
open_chat_and_load_data(app, chat_id).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сценарий 2: Режим выбора сообщения - начать редактирование
|
||||||
|
if app.is_selecting_message() {
|
||||||
|
if app.start_editing_selected() {
|
||||||
|
app.input_mode = InputMode::Insert;
|
||||||
|
} else {
|
||||||
|
// Нельзя редактировать это сообщение
|
||||||
|
app.chat_state = crate::app::ChatState::Normal;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сценарий 3: Отправка или редактирование сообщения
|
||||||
|
if !is_non_empty(&app.message_input) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(chat_id) = app.get_selected_chat_id() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let text = app.message_input.clone();
|
||||||
|
|
||||||
|
if app.is_editing() {
|
||||||
|
// Редактирование существующего сообщения
|
||||||
|
if let Some(msg_id) = app.chat_state.selected_message_id() {
|
||||||
|
edit_message(app, chat_id, msg_id, text).await;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Отправка нового сообщения
|
||||||
|
send_new_message(app, chat_id, text).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Отправляет реакцию на выбранное сообщение
|
||||||
|
pub async fn send_reaction<T: TdClientTrait>(app: &mut App<T>) {
|
||||||
|
// Get selected reaction emoji
|
||||||
|
let Some(emoji) = app.get_selected_reaction().cloned() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get selected message ID
|
||||||
|
let Some(message_id) = app.get_selected_message_for_reaction() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get chat ID
|
||||||
|
let Some(chat_id) = app.selected_chat_id else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let message_id = MessageId::new(message_id);
|
||||||
|
app.status_message = Some("Отправка реакции...".to_string());
|
||||||
|
app.needs_redraw = true;
|
||||||
|
|
||||||
|
// Send reaction with timeout
|
||||||
|
let result = with_timeout_msg(
|
||||||
|
Duration::from_secs(5),
|
||||||
|
app.td_client.toggle_reaction(chat_id, message_id, emoji.clone()),
|
||||||
|
"Таймаут отправки реакции",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Handle result
|
||||||
|
match result {
|
||||||
|
Ok(_) => {
|
||||||
|
app.status_message = Some(format!("Реакция {} добавлена", emoji));
|
||||||
|
app.exit_reaction_picker_mode();
|
||||||
|
app.needs_redraw = true;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
app.error_message = Some(e);
|
||||||
|
app.status_message = None;
|
||||||
|
app.needs_redraw = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Подгружает старые сообщения если скролл близко к верху
|
||||||
|
pub async fn load_older_messages_if_needed<T: TdClientTrait>(app: &mut App<T>) {
|
||||||
|
// Check if there are messages to load from
|
||||||
|
if app.td_client.current_chat_messages().is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the oldest message ID
|
||||||
|
let oldest_msg_id = app
|
||||||
|
.td_client
|
||||||
|
.current_chat_messages()
|
||||||
|
.first()
|
||||||
|
.map(|m| m.id())
|
||||||
|
.unwrap_or(MessageId::new(0));
|
||||||
|
|
||||||
|
// Get current chat ID
|
||||||
|
let Some(chat_id) = app.get_selected_chat_id() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if scroll is near the top
|
||||||
|
let message_count = app.td_client.current_chat_messages().len();
|
||||||
|
if app.message_scroll_offset <= message_count.saturating_sub(10) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load older messages with timeout
|
||||||
|
let Ok(older) = with_timeout(
|
||||||
|
Duration::from_secs(3),
|
||||||
|
app.td_client.load_older_messages(ChatId::new(chat_id), oldest_msg_id),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add older messages to the beginning if any were loaded
|
||||||
|
if !older.is_empty() {
|
||||||
|
let msgs = app.td_client.current_chat_messages_mut();
|
||||||
|
msgs.splice(0..0, older);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Обработка ввода клавиатуры в открытом чате
|
||||||
|
///
|
||||||
|
/// Обрабатывает:
|
||||||
|
/// - Backspace/Delete: удаление символов относительно курсора
|
||||||
|
/// - Char: вставка символов в позицию курсора + typing status
|
||||||
|
/// - Left/Right/Home/End: навигация курсора
|
||||||
|
/// - Up/Down: скролл сообщений или начало режима выбора
|
||||||
|
pub async fn handle_open_chat_keyboard_input<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
// Удаляем символ слева от курсора
|
||||||
|
if app.cursor_position > 0 {
|
||||||
|
let chars: Vec<char> = app.message_input.chars().collect();
|
||||||
|
let mut new_input = String::new();
|
||||||
|
for (i, ch) in chars.iter().enumerate() {
|
||||||
|
if i != app.cursor_position - 1 {
|
||||||
|
new_input.push(*ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app.message_input = new_input;
|
||||||
|
app.cursor_position -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Delete => {
|
||||||
|
// Удаляем символ справа от курсора
|
||||||
|
let len = app.message_input.chars().count();
|
||||||
|
if app.cursor_position < len {
|
||||||
|
let chars: Vec<char> = app.message_input.chars().collect();
|
||||||
|
let mut new_input = String::new();
|
||||||
|
for (i, ch) in chars.iter().enumerate() {
|
||||||
|
if i != app.cursor_position {
|
||||||
|
new_input.push(*ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app.message_input = new_input;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
// Игнорируем символы с Ctrl/Alt модификаторами (кроме Shift)
|
||||||
|
// Это позволяет обрабатывать хоткеи типа Ctrl+U для профиля
|
||||||
|
if key.modifiers.contains(KeyModifiers::CONTROL)
|
||||||
|
|| key.modifiers.contains(KeyModifiers::ALT) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вставляем символ в позицию курсора
|
||||||
|
let chars: Vec<char> = app.message_input.chars().collect();
|
||||||
|
let mut new_input = String::new();
|
||||||
|
for (i, ch) in chars.iter().enumerate() {
|
||||||
|
if i == app.cursor_position {
|
||||||
|
new_input.push(c);
|
||||||
|
}
|
||||||
|
new_input.push(*ch);
|
||||||
|
}
|
||||||
|
if app.cursor_position >= chars.len() {
|
||||||
|
new_input.push(c);
|
||||||
|
}
|
||||||
|
app.message_input = new_input;
|
||||||
|
app.cursor_position += 1;
|
||||||
|
|
||||||
|
// Отправляем typing status с throttling (не чаще 1 раза в 5 сек)
|
||||||
|
let should_send_typing = app
|
||||||
|
.last_typing_sent
|
||||||
|
.map(|t| t.elapsed().as_secs() >= 5)
|
||||||
|
.unwrap_or(true);
|
||||||
|
if should_send_typing {
|
||||||
|
if let Some(chat_id) = app.get_selected_chat_id() {
|
||||||
|
app.td_client.send_chat_action(ChatId::new(chat_id), ChatAction::Typing).await;
|
||||||
|
app.last_typing_sent = Some(Instant::now());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Left => {
|
||||||
|
// Курсор влево
|
||||||
|
if app.cursor_position > 0 {
|
||||||
|
app.cursor_position -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Right => {
|
||||||
|
// Курсор вправо
|
||||||
|
let len = app.message_input.chars().count();
|
||||||
|
if app.cursor_position < len {
|
||||||
|
app.cursor_position += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Home => {
|
||||||
|
// Курсор в начало
|
||||||
|
app.cursor_position = 0;
|
||||||
|
}
|
||||||
|
KeyCode::End => {
|
||||||
|
// Курсор в конец
|
||||||
|
app.cursor_position = app.message_input.chars().count();
|
||||||
|
}
|
||||||
|
// Стрелки вверх/вниз - скролл сообщений (в Insert mode)
|
||||||
|
KeyCode::Down => {
|
||||||
|
if app.message_scroll_offset > 0 {
|
||||||
|
app.message_scroll_offset = app.message_scroll_offset.saturating_sub(3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Up => {
|
||||||
|
// В Insert mode — только скролл
|
||||||
|
app.message_scroll_offset += 3;
|
||||||
|
load_older_messages_if_needed(app).await;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Обработка команды ViewImage — только фото
|
||||||
|
async fn handle_view_or_play_media<T: TdClientTrait>(app: &mut App<T>) {
|
||||||
|
let Some(msg) = app.get_selected_message() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if msg.has_photo() {
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
handle_view_image(app).await;
|
||||||
|
#[cfg(not(feature = "images"))]
|
||||||
|
{
|
||||||
|
app.status_message = Some("Просмотр изображений отключён".to_string());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
app.status_message = Some("Сообщение не содержит фото".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Space: play/pause toggle для голосовых сообщений
|
||||||
|
async fn handle_toggle_voice_playback<T: TdClientTrait>(app: &mut App<T>) {
|
||||||
|
use crate::tdlib::PlaybackStatus;
|
||||||
|
|
||||||
|
// Если уже есть активное воспроизведение — toggle pause/resume
|
||||||
|
if let Some(ref mut playback) = app.playback_state {
|
||||||
|
if let Some(ref player) = app.audio_player {
|
||||||
|
match playback.status {
|
||||||
|
PlaybackStatus::Playing => {
|
||||||
|
player.pause();
|
||||||
|
playback.status = PlaybackStatus::Paused;
|
||||||
|
app.last_playback_tick = None;
|
||||||
|
app.status_message = Some("⏸ Пауза".to_string());
|
||||||
|
}
|
||||||
|
PlaybackStatus::Paused => {
|
||||||
|
// Откатываем на 1 секунду для контекста
|
||||||
|
let resume_pos = (playback.position - 1.0).max(0.0);
|
||||||
|
// Перезапускаем ffplay с нужной позиции (-ss)
|
||||||
|
if player.resume_from(resume_pos).is_ok() {
|
||||||
|
playback.position = resume_pos;
|
||||||
|
} else {
|
||||||
|
// Fallback: простой SIGCONT без перемотки
|
||||||
|
player.resume();
|
||||||
|
}
|
||||||
|
playback.status = PlaybackStatus::Playing;
|
||||||
|
app.last_playback_tick = Some(Instant::now());
|
||||||
|
app.status_message = Some("▶ Воспроизведение".to_string());
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
app.needs_redraw = true;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Нет активного воспроизведения — пробуем запустить текущее голосовое
|
||||||
|
let Some(msg) = app.get_selected_message() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if msg.has_voice() {
|
||||||
|
handle_play_voice(app).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seek голосового сообщения на delta секунд
|
||||||
|
fn handle_voice_seek<T: TdClientTrait>(app: &mut App<T>, delta: f32) {
|
||||||
|
use crate::tdlib::PlaybackStatus;
|
||||||
|
|
||||||
|
let Some(ref mut playback) = app.playback_state else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(ref player) = app.audio_player else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let was_playing = matches!(playback.status, PlaybackStatus::Playing);
|
||||||
|
let was_paused = matches!(playback.status, PlaybackStatus::Paused);
|
||||||
|
|
||||||
|
if was_playing || was_paused {
|
||||||
|
let new_position = (playback.position + delta).clamp(0.0, playback.duration);
|
||||||
|
|
||||||
|
if was_playing {
|
||||||
|
// Перезапускаем ffplay с новой позиции
|
||||||
|
if player.resume_from(new_position).is_ok() {
|
||||||
|
playback.position = new_position;
|
||||||
|
app.last_playback_tick = Some(std::time::Instant::now());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// На паузе — только двигаем позицию, воспроизведение начнётся при resume
|
||||||
|
player.stop();
|
||||||
|
playback.position = new_position;
|
||||||
|
}
|
||||||
|
|
||||||
|
let arrow = if delta > 0.0 { "→" } else { "←" };
|
||||||
|
app.status_message = Some(format!("{} {:.0}s", arrow, new_position));
|
||||||
|
app.needs_redraw = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Обработка команды ViewImage — открыть модальное окно с фото
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
async fn handle_view_image<T: TdClientTrait>(app: &mut App<T>) {
|
||||||
|
use crate::tdlib::{ImageModalState, PhotoDownloadState};
|
||||||
|
|
||||||
|
if !app.config().images.show_images {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(msg) = app.get_selected_message() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if !msg.has_photo() {
|
||||||
|
app.status_message = Some("Сообщение не содержит фото".to_string());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let photo = msg.photo_info().unwrap();
|
||||||
|
let msg_id = msg.id();
|
||||||
|
let file_id = photo.file_id;
|
||||||
|
let photo_width = photo.width;
|
||||||
|
let photo_height = photo.height;
|
||||||
|
let download_state = photo.download_state.clone();
|
||||||
|
|
||||||
|
match download_state {
|
||||||
|
PhotoDownloadState::Downloaded(path) => {
|
||||||
|
// Открываем модальное окно
|
||||||
|
app.image_modal = Some(ImageModalState {
|
||||||
|
message_id: msg_id,
|
||||||
|
photo_path: path,
|
||||||
|
photo_width,
|
||||||
|
photo_height,
|
||||||
|
});
|
||||||
|
app.needs_redraw = true;
|
||||||
|
}
|
||||||
|
PhotoDownloadState::Downloading => {
|
||||||
|
app.status_message = Some("Загрузка фото...".to_string());
|
||||||
|
}
|
||||||
|
PhotoDownloadState::NotDownloaded => {
|
||||||
|
// Скачиваем фото и открываем
|
||||||
|
app.status_message = Some("Загрузка фото...".to_string());
|
||||||
|
app.needs_redraw = true;
|
||||||
|
match app.td_client.download_file(file_id).await {
|
||||||
|
Ok(path) => {
|
||||||
|
// Обновляем состояние загрузки в сообщении
|
||||||
|
for msg in app.td_client.current_chat_messages_mut() {
|
||||||
|
if let Some(photo) = msg.photo_info_mut() {
|
||||||
|
if photo.file_id == file_id {
|
||||||
|
photo.download_state =
|
||||||
|
PhotoDownloadState::Downloaded(path.clone());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Открываем модалку
|
||||||
|
app.image_modal = Some(ImageModalState {
|
||||||
|
message_id: msg_id,
|
||||||
|
photo_path: path,
|
||||||
|
photo_width,
|
||||||
|
photo_height,
|
||||||
|
});
|
||||||
|
app.status_message = None;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
for msg in app.td_client.current_chat_messages_mut() {
|
||||||
|
if let Some(photo) = msg.photo_info_mut() {
|
||||||
|
if photo.file_id == file_id {
|
||||||
|
photo.download_state =
|
||||||
|
PhotoDownloadState::Error(e.clone());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app.error_message = Some(format!("Ошибка загрузки фото: {}", e));
|
||||||
|
app.status_message = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PhotoDownloadState::Error(_) => {
|
||||||
|
// Повторная попытка загрузки
|
||||||
|
app.status_message = Some("Повторная загрузка фото...".to_string());
|
||||||
|
app.needs_redraw = true;
|
||||||
|
match app.td_client.download_file(file_id).await {
|
||||||
|
Ok(path) => {
|
||||||
|
for msg in app.td_client.current_chat_messages_mut() {
|
||||||
|
if let Some(photo) = msg.photo_info_mut() {
|
||||||
|
if photo.file_id == file_id {
|
||||||
|
photo.download_state =
|
||||||
|
PhotoDownloadState::Downloaded(path.clone());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app.image_modal = Some(ImageModalState {
|
||||||
|
message_id: msg_id,
|
||||||
|
photo_path: path,
|
||||||
|
photo_width,
|
||||||
|
photo_height,
|
||||||
|
});
|
||||||
|
app.status_message = None;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
app.error_message = Some(format!("Ошибка загрузки фото: {}", e));
|
||||||
|
app.status_message = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Вспомогательная функция для воспроизведения из конкретного пути
|
||||||
|
async fn handle_play_voice_from_path<T: TdClientTrait>(
|
||||||
|
app: &mut App<T>,
|
||||||
|
path: &str,
|
||||||
|
voice: &crate::tdlib::VoiceInfo,
|
||||||
|
msg: &crate::tdlib::MessageInfo,
|
||||||
|
) {
|
||||||
|
use crate::tdlib::{PlaybackState, PlaybackStatus};
|
||||||
|
|
||||||
|
if let Some(ref player) = app.audio_player {
|
||||||
|
match player.play(path) {
|
||||||
|
Ok(_) => {
|
||||||
|
app.playback_state = Some(PlaybackState {
|
||||||
|
message_id: msg.id(),
|
||||||
|
status: PlaybackStatus::Playing,
|
||||||
|
position: 0.0,
|
||||||
|
duration: voice.duration as f32,
|
||||||
|
volume: player.volume(),
|
||||||
|
});
|
||||||
|
app.last_playback_tick = Some(Instant::now());
|
||||||
|
app.status_message = Some(format!("▶ Воспроизведение ({:.0}s)", voice.duration));
|
||||||
|
app.needs_redraw = true;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
app.error_message = Some(format!("Ошибка воспроизведения: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
app.error_message = Some("Аудиоплеер не инициализирован".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Воспроизведение голосового сообщения
|
||||||
|
async fn handle_play_voice<T: TdClientTrait>(app: &mut App<T>) {
|
||||||
|
use crate::tdlib::VoiceDownloadState;
|
||||||
|
|
||||||
|
let Some(msg) = app.get_selected_message() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if !msg.has_voice() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let voice = msg.voice_info().unwrap();
|
||||||
|
let file_id = voice.file_id;
|
||||||
|
|
||||||
|
match &voice.download_state {
|
||||||
|
VoiceDownloadState::Downloaded(path) => {
|
||||||
|
// TDLib может вернуть путь без расширения — ищем файл с .oga
|
||||||
|
use std::path::Path;
|
||||||
|
let audio_path = if Path::new(path).exists() {
|
||||||
|
path.clone()
|
||||||
|
} else {
|
||||||
|
// Пробуем добавить .oga
|
||||||
|
let with_oga = format!("{}.oga", path);
|
||||||
|
if Path::new(&with_oga).exists() {
|
||||||
|
with_oga
|
||||||
|
} else {
|
||||||
|
// Пробуем найти файл с похожим именем в той же папке
|
||||||
|
if let Some(parent) = Path::new(path).parent() {
|
||||||
|
if let Some(stem) = Path::new(path).file_name() {
|
||||||
|
if let Ok(entries) = std::fs::read_dir(parent) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let entry_name = entry.file_name();
|
||||||
|
if entry_name.to_string_lossy().starts_with(&stem.to_string_lossy().to_string()) {
|
||||||
|
let found_path = entry.path().to_string_lossy().to_string();
|
||||||
|
// Кэшируем найденный файл
|
||||||
|
if let Some(ref mut cache) = app.voice_cache {
|
||||||
|
let _ = cache.store(&file_id.to_string(), Path::new(&found_path));
|
||||||
|
}
|
||||||
|
return handle_play_voice_from_path(app, &found_path, &voice, &msg).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app.error_message = Some(format!("Файл не найден: {}", path));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Кэшируем файл если ещё не в кэше
|
||||||
|
if let Some(ref mut cache) = app.voice_cache {
|
||||||
|
let _ = cache.store(&file_id.to_string(), Path::new(&audio_path));
|
||||||
|
}
|
||||||
|
|
||||||
|
handle_play_voice_from_path(app, &audio_path, &voice, &msg).await;
|
||||||
|
}
|
||||||
|
VoiceDownloadState::Downloading => {
|
||||||
|
app.status_message = Some("Загрузка голосового...".to_string());
|
||||||
|
}
|
||||||
|
VoiceDownloadState::NotDownloaded => {
|
||||||
|
// Проверяем кэш перед загрузкой
|
||||||
|
let cache_key = file_id.to_string();
|
||||||
|
if let Some(cached_path) = app.voice_cache.as_mut().and_then(|c| c.get(&cache_key)) {
|
||||||
|
let path_str = cached_path.to_string_lossy().to_string();
|
||||||
|
handle_play_voice_from_path(app, &path_str, &voice, &msg).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Начинаем загрузку
|
||||||
|
app.status_message = Some("Загрузка голосового...".to_string());
|
||||||
|
match app.td_client.download_voice_note(file_id).await {
|
||||||
|
Ok(path) => {
|
||||||
|
// Кэшируем загруженный файл
|
||||||
|
if let Some(ref mut cache) = app.voice_cache {
|
||||||
|
let _ = cache.store(&cache_key, std::path::Path::new(&path));
|
||||||
|
}
|
||||||
|
|
||||||
|
handle_play_voice_from_path(app, &path, &voice, &msg).await;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
app.error_message = Some(format!("Ошибка загрузки: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
VoiceDownloadState::Error(e) => {
|
||||||
|
app.error_message = Some(format!("Ошибка загрузки: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO (Этап 4): Эти функции будут переписаны для модального просмотрщика
|
||||||
|
/*
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
fn collapse_photo<T: TdClientTrait>(app: &mut App<T>, msg_id: crate::types::MessageId) {
|
||||||
|
// Закомментировано - будет реализовано в Этапе 4
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
fn expand_photo<T: TdClientTrait>(app: &mut App<T>, msg_id: crate::types::MessageId, path: &str) {
|
||||||
|
// Закомментировано - будет реализовано в Этапе 4
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
// TODO (Этап 4): Функция _download_and_expand будет переписана
|
||||||
|
/*
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
async fn _download_and_expand<T: TdClientTrait>(app: &mut App<T>, msg_id: crate::types::MessageId, file_id: i32) {
|
||||||
|
// Закомментировано - будет реализовано в Этапе 4
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
135
src/input/handlers/chat_list.rs
Normal file
135
src/input/handlers/chat_list.rs
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
//! Chat list input handlers
|
||||||
|
//!
|
||||||
|
//! Handles keyboard input for the chat list view, including:
|
||||||
|
//! - Navigation between chats
|
||||||
|
//! - Folder selection
|
||||||
|
//! - Opening chats
|
||||||
|
|
||||||
|
use crate::app::App;
|
||||||
|
use crate::app::InputMode;
|
||||||
|
use crate::app::methods::{compose::ComposeMethods, messages::MessageMethods, navigation::NavigationMethods};
|
||||||
|
use crate::tdlib::TdClientTrait;
|
||||||
|
use crate::types::{ChatId, MessageId};
|
||||||
|
use crate::utils::{with_timeout, with_timeout_msg};
|
||||||
|
use crossterm::event::KeyEvent;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Обработка навигации в списке чатов
|
||||||
|
///
|
||||||
|
/// Обрабатывает:
|
||||||
|
/// - Up/Down/j/k: навигация между чатами
|
||||||
|
/// - Цифры 1-9: переключение папок (1=All, 2-9=папки из TDLib)
|
||||||
|
pub async fn handle_chat_list_navigation<T: TdClientTrait>(app: &mut App<T>, _key: KeyEvent, command: Option<crate::config::Command>) {
|
||||||
|
match command {
|
||||||
|
Some(crate::config::Command::MoveDown) => {
|
||||||
|
app.next_chat();
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::MoveUp) => {
|
||||||
|
app.previous_chat();
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::SelectFolder1) => {
|
||||||
|
app.selected_folder_id = None;
|
||||||
|
app.chat_list_state.select(Some(0));
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::SelectFolder2) => {
|
||||||
|
select_folder(app, 0).await;
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::SelectFolder3) => {
|
||||||
|
select_folder(app, 1).await;
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::SelectFolder4) => {
|
||||||
|
select_folder(app, 2).await;
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::SelectFolder5) => {
|
||||||
|
select_folder(app, 3).await;
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::SelectFolder6) => {
|
||||||
|
select_folder(app, 4).await;
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::SelectFolder7) => {
|
||||||
|
select_folder(app, 5).await;
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::SelectFolder8) => {
|
||||||
|
select_folder(app, 6).await;
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::SelectFolder9) => {
|
||||||
|
select_folder(app, 7).await;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Выбирает папку по индексу и загружает её чаты
|
||||||
|
pub async fn select_folder<T: TdClientTrait>(app: &mut App<T>, folder_idx: usize) {
|
||||||
|
if let Some(folder) = app.td_client.folders().get(folder_idx) {
|
||||||
|
let folder_id = folder.id;
|
||||||
|
app.selected_folder_id = Some(folder_id);
|
||||||
|
app.status_message = Some("Загрузка чатов папки...".to_string());
|
||||||
|
let _ = with_timeout(
|
||||||
|
Duration::from_secs(5),
|
||||||
|
app.td_client.load_folder_chats(folder_id, 50),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
app.status_message = None;
|
||||||
|
app.chat_list_state.select(Some(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Открывает чат и загружает последние сообщения (быстро).
|
||||||
|
///
|
||||||
|
/// Загружает только 50 последних сообщений для мгновенного отображения.
|
||||||
|
/// Фоновые задачи (reply info, pinned, photos) откладываются в `pending_chat_init`
|
||||||
|
/// и выполняются на следующем тике main loop.
|
||||||
|
///
|
||||||
|
/// При ошибке устанавливает error_message и очищает status_message.
|
||||||
|
pub async fn open_chat_and_load_data<T: TdClientTrait>(app: &mut App<T>, chat_id: i64) {
|
||||||
|
app.status_message = Some("Загрузка сообщений...".to_string());
|
||||||
|
app.message_scroll_offset = 0;
|
||||||
|
|
||||||
|
// Загружаем только 50 последних сообщений (один запрос к TDLib)
|
||||||
|
match with_timeout_msg(
|
||||||
|
Duration::from_secs(10),
|
||||||
|
app.td_client.get_chat_history(ChatId::new(chat_id), 50),
|
||||||
|
"Таймаут загрузки сообщений",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(messages) => {
|
||||||
|
// Собираем ID всех входящих сообщений для отметки как прочитанные
|
||||||
|
let incoming_message_ids: Vec<MessageId> = messages
|
||||||
|
.iter()
|
||||||
|
.filter(|msg| !msg.is_outgoing())
|
||||||
|
.map(|msg| msg.id())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Сохраняем загруженные сообщения
|
||||||
|
app.td_client.set_current_chat_messages(messages);
|
||||||
|
|
||||||
|
// Добавляем входящие сообщения в очередь для отметки как прочитанные
|
||||||
|
if !incoming_message_ids.is_empty() {
|
||||||
|
app.td_client
|
||||||
|
.pending_view_messages_mut()
|
||||||
|
.push((ChatId::new(chat_id), incoming_message_ids));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ВАЖНО: Устанавливаем current_chat_id ТОЛЬКО ПОСЛЕ сохранения истории
|
||||||
|
// Это предотвращает race condition с Update::NewMessage
|
||||||
|
app.td_client.set_current_chat_id(Some(ChatId::new(chat_id)));
|
||||||
|
|
||||||
|
// Загружаем черновик (локальная операция, мгновенно)
|
||||||
|
app.load_draft();
|
||||||
|
|
||||||
|
// Показываем чат СРАЗУ
|
||||||
|
app.status_message = None;
|
||||||
|
app.input_mode = InputMode::Normal;
|
||||||
|
app.start_message_selection();
|
||||||
|
|
||||||
|
// Фоновые задачи (reply info, pinned, photos) — на следующем тике main loop
|
||||||
|
app.pending_chat_init = Some(ChatId::new(chat_id));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
app.error_message = Some(e);
|
||||||
|
app.status_message = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
84
src/input/handlers/compose.rs
Normal file
84
src/input/handlers/compose.rs
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
//! Compose input handlers
|
||||||
|
//!
|
||||||
|
//! Handles text input and message composition, including:
|
||||||
|
//! - Forward mode
|
||||||
|
//! - Reply mode
|
||||||
|
//! - Edit mode
|
||||||
|
//! - Cursor movement and text editing
|
||||||
|
|
||||||
|
use crate::app::App;
|
||||||
|
use crate::app::methods::{
|
||||||
|
compose::ComposeMethods, navigation::NavigationMethods, search::SearchMethods,
|
||||||
|
};
|
||||||
|
use crate::tdlib::TdClientTrait;
|
||||||
|
use crate::types::ChatId;
|
||||||
|
use crate::utils::with_timeout_msg;
|
||||||
|
use crossterm::event::KeyEvent;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Обработка режима выбора чата для пересылки сообщения
|
||||||
|
///
|
||||||
|
/// Обрабатывает:
|
||||||
|
/// - Навигацию по списку чатов (Up/Down)
|
||||||
|
/// - Пересылку сообщения в выбранный чат (Enter)
|
||||||
|
/// - Отмену пересылки (Esc)
|
||||||
|
pub async fn handle_forward_mode<T: TdClientTrait>(app: &mut App<T>, _key: KeyEvent, command: Option<crate::config::Command>) {
|
||||||
|
match command {
|
||||||
|
Some(crate::config::Command::Cancel) => {
|
||||||
|
app.cancel_forward();
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::SubmitMessage) => {
|
||||||
|
forward_selected_message(app).await;
|
||||||
|
app.cancel_forward();
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::MoveDown) => {
|
||||||
|
app.next_chat();
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::MoveUp) => {
|
||||||
|
app.previous_chat();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Пересылает выбранное сообщение в выбранный чат
|
||||||
|
pub async fn forward_selected_message<T: TdClientTrait>(app: &mut App<T>) {
|
||||||
|
// Get all required IDs with early returns
|
||||||
|
let filtered = app.get_filtered_chats();
|
||||||
|
let Some(i) = app.chat_list_state.selected() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(chat) = filtered.get(i) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let to_chat_id = chat.id;
|
||||||
|
|
||||||
|
let Some(msg_id) = app.chat_state.selected_message_id() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(from_chat_id) = app.get_selected_chat_id() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Forward the message with timeout
|
||||||
|
let result = with_timeout_msg(
|
||||||
|
Duration::from_secs(5),
|
||||||
|
app.td_client.forward_messages(
|
||||||
|
to_chat_id,
|
||||||
|
ChatId::new(from_chat_id),
|
||||||
|
vec![msg_id],
|
||||||
|
),
|
||||||
|
"Таймаут пересылки",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Handle result
|
||||||
|
match result {
|
||||||
|
Ok(_) => {
|
||||||
|
app.status_message = Some("Сообщение переслано".to_string());
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
app.error_message = Some(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
//! - Ctrl+F: Search messages in chat
|
//! - Ctrl+F: Search messages in chat
|
||||||
|
|
||||||
use crate::app::App;
|
use crate::app::App;
|
||||||
|
use crate::app::methods::{modal::ModalMethods, search::SearchMethods};
|
||||||
use crate::tdlib::TdClientTrait;
|
use crate::tdlib::TdClientTrait;
|
||||||
use crate::types::ChatId;
|
use crate::types::ChatId;
|
||||||
use crate::utils::{with_timeout, with_timeout_msg};
|
use crate::utils::{with_timeout, with_timeout_msg};
|
||||||
@@ -57,6 +58,11 @@ pub async fn handle_global_commands<T: TdClientTrait>(app: &mut App<T>, key: Key
|
|||||||
handle_pinned_messages(app).await;
|
handle_pinned_messages(app).await;
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
KeyCode::Char('a') if has_ctrl => {
|
||||||
|
// Ctrl+A - переключение аккаунтов
|
||||||
|
app.open_account_switcher();
|
||||||
|
true
|
||||||
|
}
|
||||||
_ => false,
|
_ => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,39 @@
|
|||||||
//! - global: Global commands (Ctrl+R, Ctrl+S, etc.)
|
//! - global: Global commands (Ctrl+R, Ctrl+S, etc.)
|
||||||
//! - clipboard: Clipboard operations
|
//! - clipboard: Clipboard operations
|
||||||
//! - profile: Profile helper functions
|
//! - profile: Profile helper functions
|
||||||
|
//! - chat: Keyboard input handling for open chat view
|
||||||
|
//! - chat_list: Navigation and interaction in the chat list
|
||||||
|
//! - compose: Text input, editing, and message composition
|
||||||
|
//! - modal: Modal dialogs (delete confirmation, emoji picker, etc.)
|
||||||
|
//! - search: Search functionality (chat search, message search)
|
||||||
|
|
||||||
pub mod clipboard;
|
pub mod clipboard;
|
||||||
pub mod global;
|
pub mod global;
|
||||||
pub mod profile;
|
pub mod profile;
|
||||||
|
pub mod chat;
|
||||||
|
pub mod chat_list;
|
||||||
|
pub mod compose;
|
||||||
|
pub mod modal;
|
||||||
|
pub mod search;
|
||||||
|
|
||||||
pub use clipboard::*;
|
pub use clipboard::*;
|
||||||
pub use global::*;
|
pub use global::*;
|
||||||
pub use profile::get_available_actions_count;
|
pub use profile::get_available_actions_count;
|
||||||
|
|
||||||
|
use crate::app::App;
|
||||||
|
use crate::tdlib::TdClientTrait;
|
||||||
|
use crate::types::MessageId;
|
||||||
|
|
||||||
|
/// Скроллит к сообщению по его ID в текущем чате
|
||||||
|
pub fn scroll_to_message<T: TdClientTrait>(app: &mut App<T>, message_id: MessageId) {
|
||||||
|
let msg_index = app
|
||||||
|
.td_client
|
||||||
|
.current_chat_messages()
|
||||||
|
.iter()
|
||||||
|
.position(|m| m.id() == message_id);
|
||||||
|
|
||||||
|
if let Some(idx) = msg_index {
|
||||||
|
let total = app.td_client.current_chat_messages().len();
|
||||||
|
app.message_scroll_offset = total.saturating_sub(idx + 5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
399
src/input/handlers/modal.rs
Normal file
399
src/input/handlers/modal.rs
Normal file
@@ -0,0 +1,399 @@
|
|||||||
|
//! Modal dialog handlers
|
||||||
|
//!
|
||||||
|
//! Handles keyboard input for modal dialogs, including:
|
||||||
|
//! - Account switcher (global overlay)
|
||||||
|
//! - Delete confirmation
|
||||||
|
//! - Reaction picker (emoji selector)
|
||||||
|
//! - Pinned messages view
|
||||||
|
//! - Profile information modal
|
||||||
|
|
||||||
|
use crate::app::{AccountSwitcherState, App};
|
||||||
|
use crate::app::methods::{modal::ModalMethods, navigation::NavigationMethods};
|
||||||
|
use crate::tdlib::TdClientTrait;
|
||||||
|
use crate::types::{ChatId, MessageId};
|
||||||
|
use crate::utils::{with_timeout_msg, modal_handler::handle_yes_no};
|
||||||
|
use crate::input::handlers::get_available_actions_count;
|
||||||
|
use super::scroll_to_message;
|
||||||
|
use crossterm::event::{KeyCode, KeyEvent};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Обработка ввода в модалке переключения аккаунтов
|
||||||
|
///
|
||||||
|
/// **SelectAccount mode:**
|
||||||
|
/// - j/k (MoveUp/MoveDown) — навигация по списку
|
||||||
|
/// - Enter — выбор аккаунта или переход к добавлению
|
||||||
|
/// - a/ф — быстрое добавление аккаунта
|
||||||
|
/// - Esc — закрыть модалку
|
||||||
|
///
|
||||||
|
/// **AddAccount mode:**
|
||||||
|
/// - Char input → ввод имени
|
||||||
|
/// - Backspace → удалить символ
|
||||||
|
/// - Enter → создать аккаунт
|
||||||
|
/// - Esc → назад к списку
|
||||||
|
pub async fn handle_account_switcher<T: TdClientTrait>(
|
||||||
|
app: &mut App<T>,
|
||||||
|
key: KeyEvent,
|
||||||
|
command: Option<crate::config::Command>,
|
||||||
|
) {
|
||||||
|
let Some(state) = &app.account_switcher else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
match state {
|
||||||
|
AccountSwitcherState::SelectAccount { .. } => {
|
||||||
|
match command {
|
||||||
|
Some(crate::config::Command::MoveUp) => {
|
||||||
|
app.account_switcher_select_prev();
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::MoveDown) => {
|
||||||
|
app.account_switcher_select_next();
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::SubmitMessage) => {
|
||||||
|
app.account_switcher_confirm();
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::Cancel) => {
|
||||||
|
app.close_account_switcher();
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// Raw key check for 'a'/'ф' shortcut
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Char('a') | KeyCode::Char('ф') => {
|
||||||
|
app.account_switcher_start_add();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AccountSwitcherState::AddAccount { .. } => {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Esc => {
|
||||||
|
app.account_switcher_back();
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
app.account_switcher_confirm_add();
|
||||||
|
}
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
if let Some(AccountSwitcherState::AddAccount {
|
||||||
|
name_input,
|
||||||
|
cursor_position,
|
||||||
|
error,
|
||||||
|
}) = &mut app.account_switcher
|
||||||
|
{
|
||||||
|
if *cursor_position > 0 {
|
||||||
|
let mut chars: Vec<char> = name_input.chars().collect();
|
||||||
|
chars.remove(*cursor_position - 1);
|
||||||
|
*name_input = chars.into_iter().collect();
|
||||||
|
*cursor_position -= 1;
|
||||||
|
*error = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
if let Some(AccountSwitcherState::AddAccount {
|
||||||
|
name_input,
|
||||||
|
cursor_position,
|
||||||
|
error,
|
||||||
|
}) = &mut app.account_switcher
|
||||||
|
{
|
||||||
|
let mut chars: Vec<char> = name_input.chars().collect();
|
||||||
|
chars.insert(*cursor_position, c);
|
||||||
|
*name_input = chars.into_iter().collect();
|
||||||
|
*cursor_position += 1;
|
||||||
|
*error = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Обработка режима профиля пользователя/чата
|
||||||
|
///
|
||||||
|
/// Обрабатывает:
|
||||||
|
/// - Модалку подтверждения выхода из группы (двухшаговая)
|
||||||
|
/// - Навигацию по действиям профиля (Up/Down)
|
||||||
|
/// - Выполнение выбранного действия (Enter): открыть в браузере, скопировать ID, покинуть группу
|
||||||
|
/// - Выход из режима профиля (Esc)
|
||||||
|
pub async fn handle_profile_mode<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent, command: Option<crate::config::Command>) {
|
||||||
|
// Обработка подтверждения выхода из группы
|
||||||
|
let confirmation_step = app.get_leave_group_confirmation_step();
|
||||||
|
if confirmation_step > 0 {
|
||||||
|
match handle_yes_no(key.code) {
|
||||||
|
Some(true) => {
|
||||||
|
// Подтверждение
|
||||||
|
if confirmation_step == 1 {
|
||||||
|
// Первое подтверждение - показываем второе
|
||||||
|
app.show_leave_group_final_confirmation();
|
||||||
|
} else if confirmation_step == 2 {
|
||||||
|
// Второе подтверждение - выходим из группы
|
||||||
|
if let Some(chat_id) = app.selected_chat_id {
|
||||||
|
let leave_result = app.td_client.leave_chat(chat_id).await;
|
||||||
|
match leave_result {
|
||||||
|
Ok(_) => {
|
||||||
|
app.status_message = Some("Вы вышли из группы".to_string());
|
||||||
|
app.exit_profile_mode();
|
||||||
|
app.close_chat();
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
app.error_message = Some(e);
|
||||||
|
app.cancel_leave_group();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(false) => {
|
||||||
|
// Отмена
|
||||||
|
app.cancel_leave_group();
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// Другая клавиша - игнорируем
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обычная навигация по профилю
|
||||||
|
match command {
|
||||||
|
Some(crate::config::Command::Cancel) => {
|
||||||
|
app.exit_profile_mode();
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::MoveUp) => {
|
||||||
|
app.select_previous_profile_action();
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::MoveDown) => {
|
||||||
|
if let Some(profile) = app.get_profile_info() {
|
||||||
|
let max_actions = get_available_actions_count(profile);
|
||||||
|
app.select_next_profile_action(max_actions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::SubmitMessage) => {
|
||||||
|
// Выполнить выбранное действие
|
||||||
|
let Some(profile) = app.get_profile_info() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let actions = get_available_actions_count(profile);
|
||||||
|
let action_index = app.get_selected_profile_action().unwrap_or(0);
|
||||||
|
|
||||||
|
// Guard: проверяем, что индекс действия валидный
|
||||||
|
if action_index >= actions {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Определяем какое действие выбрано
|
||||||
|
let mut current_idx = 0;
|
||||||
|
|
||||||
|
// Действие: Открыть в браузере
|
||||||
|
if let Some(username) = &profile.username {
|
||||||
|
if action_index == current_idx {
|
||||||
|
let url = format!(
|
||||||
|
"https://t.me/{}",
|
||||||
|
username.trim_start_matches('@')
|
||||||
|
);
|
||||||
|
#[cfg(feature = "url-open")]
|
||||||
|
{
|
||||||
|
match open::that(&url) {
|
||||||
|
Ok(_) => {
|
||||||
|
app.status_message = Some(format!("Открыто: {}", url));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
app.error_message =
|
||||||
|
Some(format!("Ошибка открытия браузера: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "url-open"))]
|
||||||
|
{
|
||||||
|
app.error_message = Some(
|
||||||
|
"Открытие URL недоступно (требуется feature 'url-open')".to_string()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
current_idx += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Действие: Скопировать ID
|
||||||
|
if action_index == current_idx {
|
||||||
|
app.status_message = Some(format!("ID скопирован: {}", profile.chat_id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
current_idx += 1;
|
||||||
|
|
||||||
|
// Действие: Покинуть группу
|
||||||
|
if profile.is_group && action_index == current_idx {
|
||||||
|
app.show_leave_group_confirmation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Обработка Ctrl+U для открытия профиля чата/пользователя
|
||||||
|
///
|
||||||
|
/// Загружает информацию о профиле и переключает в режим просмотра профиля
|
||||||
|
pub async fn handle_profile_open<T: TdClientTrait>(app: &mut App<T>) {
|
||||||
|
let Some(chat_id) = app.selected_chat_id else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
app.status_message = Some("Загрузка профиля...".to_string());
|
||||||
|
match with_timeout_msg(
|
||||||
|
Duration::from_secs(5),
|
||||||
|
app.td_client.get_profile_info(chat_id),
|
||||||
|
"Таймаут загрузки профиля",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(profile) => {
|
||||||
|
app.enter_profile_mode(profile);
|
||||||
|
app.status_message = None;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
app.error_message = Some(e);
|
||||||
|
app.status_message = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Обработка модалки подтверждения удаления сообщения
|
||||||
|
///
|
||||||
|
/// Обрабатывает:
|
||||||
|
/// - Подтверждение удаления (Y/y/Д/д)
|
||||||
|
/// - Отмена удаления (N/n/Т/т)
|
||||||
|
/// - Удаление для себя или для всех (зависит от can_be_deleted_for_all_users)
|
||||||
|
pub async fn handle_delete_confirmation<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
|
||||||
|
match handle_yes_no(key.code) {
|
||||||
|
Some(true) => {
|
||||||
|
// Подтверждение удаления
|
||||||
|
if let Some(msg_id) = app.chat_state.selected_message_id() {
|
||||||
|
if let Some(chat_id) = app.get_selected_chat_id() {
|
||||||
|
// Находим сообщение для проверки can_be_deleted_for_all_users
|
||||||
|
let can_delete_for_all = app
|
||||||
|
.td_client
|
||||||
|
.current_chat_messages()
|
||||||
|
.iter()
|
||||||
|
.find(|m| m.id() == msg_id)
|
||||||
|
.map(|m| m.can_be_deleted_for_all_users())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
match with_timeout_msg(
|
||||||
|
Duration::from_secs(5),
|
||||||
|
app.td_client.delete_messages(
|
||||||
|
ChatId::new(chat_id),
|
||||||
|
vec![msg_id],
|
||||||
|
can_delete_for_all,
|
||||||
|
),
|
||||||
|
"Таймаут удаления",
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => {
|
||||||
|
// Удаляем из локального списка
|
||||||
|
app.td_client
|
||||||
|
.current_chat_messages_mut()
|
||||||
|
.retain(|m| m.id() != msg_id);
|
||||||
|
// Сбрасываем состояние
|
||||||
|
app.chat_state = crate::app::ChatState::Normal;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
app.error_message = Some(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Закрываем модалку
|
||||||
|
app.chat_state = crate::app::ChatState::Normal;
|
||||||
|
}
|
||||||
|
Some(false) => {
|
||||||
|
// Отмена удаления
|
||||||
|
app.chat_state = crate::app::ChatState::Normal;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// Другая клавиша - игнорируем
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Обработка режима выбора реакции (emoji picker)
|
||||||
|
///
|
||||||
|
/// Обрабатывает:
|
||||||
|
/// - Навигацию по сетке реакций: Left/Right, Up/Down (сетка 8x6)
|
||||||
|
/// - Добавление/удаление реакции (Enter)
|
||||||
|
/// - Выход из режима (Esc)
|
||||||
|
pub async fn handle_reaction_picker_mode<T: TdClientTrait>(app: &mut App<T>, _key: KeyEvent, command: Option<crate::config::Command>) {
|
||||||
|
match command {
|
||||||
|
Some(crate::config::Command::MoveLeft) => {
|
||||||
|
app.select_previous_reaction();
|
||||||
|
app.needs_redraw = true;
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::MoveRight) => {
|
||||||
|
app.select_next_reaction();
|
||||||
|
app.needs_redraw = true;
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::MoveUp) => {
|
||||||
|
if let crate::app::ChatState::ReactionPicker {
|
||||||
|
selected_index,
|
||||||
|
..
|
||||||
|
} = &mut app.chat_state
|
||||||
|
{
|
||||||
|
if *selected_index >= 8 {
|
||||||
|
*selected_index = selected_index.saturating_sub(8);
|
||||||
|
app.needs_redraw = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::MoveDown) => {
|
||||||
|
if let crate::app::ChatState::ReactionPicker {
|
||||||
|
selected_index,
|
||||||
|
available_reactions,
|
||||||
|
..
|
||||||
|
} = &mut app.chat_state
|
||||||
|
{
|
||||||
|
let new_index = *selected_index + 8;
|
||||||
|
if new_index < available_reactions.len() {
|
||||||
|
*selected_index = new_index;
|
||||||
|
app.needs_redraw = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::SubmitMessage) => {
|
||||||
|
super::chat::send_reaction(app).await;
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::Cancel) => {
|
||||||
|
app.exit_reaction_picker_mode();
|
||||||
|
app.needs_redraw = true;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Обработка режима просмотра закреплённых сообщений
|
||||||
|
///
|
||||||
|
/// Обрабатывает:
|
||||||
|
/// - Навигацию по закреплённым сообщениям (Up/Down)
|
||||||
|
/// - Переход к сообщению в истории (Enter)
|
||||||
|
/// - Выход из режима (Esc)
|
||||||
|
pub async fn handle_pinned_mode<T: TdClientTrait>(app: &mut App<T>, _key: KeyEvent, command: Option<crate::config::Command>) {
|
||||||
|
match command {
|
||||||
|
Some(crate::config::Command::Cancel) => {
|
||||||
|
app.exit_pinned_mode();
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::MoveUp) => {
|
||||||
|
app.select_previous_pinned();
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::MoveDown) => {
|
||||||
|
app.select_next_pinned();
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::SubmitMessage) => {
|
||||||
|
if let Some(msg_id) = app.get_selected_pinned_id() {
|
||||||
|
scroll_to_message(app, MessageId::new(msg_id));
|
||||||
|
app.exit_pinned_mode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
132
src/input/handlers/search.rs
Normal file
132
src/input/handlers/search.rs
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
//! Search input handlers
|
||||||
|
//!
|
||||||
|
//! Handles keyboard input for search functionality, including:
|
||||||
|
//! - Chat list search mode
|
||||||
|
//! - Message search mode
|
||||||
|
//! - Search query input
|
||||||
|
|
||||||
|
use crate::app::App;
|
||||||
|
use crate::app::methods::{navigation::NavigationMethods, search::SearchMethods};
|
||||||
|
use crate::tdlib::TdClientTrait;
|
||||||
|
use crate::types::{ChatId, MessageId};
|
||||||
|
use crate::utils::with_timeout;
|
||||||
|
use crossterm::event::{KeyCode, KeyEvent};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use super::chat_list::open_chat_and_load_data;
|
||||||
|
use super::scroll_to_message;
|
||||||
|
|
||||||
|
/// Обработка режима поиска по чатам
|
||||||
|
///
|
||||||
|
/// Обрабатывает:
|
||||||
|
/// - Редактирование поискового запроса (Backspace, Char)
|
||||||
|
/// - Навигацию по отфильтрованному списку (Up/Down)
|
||||||
|
/// - Открытие выбранного чата (Enter)
|
||||||
|
/// - Отмену поиска (Esc)
|
||||||
|
pub async fn handle_chat_search_mode<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent, command: Option<crate::config::Command>) {
|
||||||
|
match command {
|
||||||
|
Some(crate::config::Command::Cancel) => {
|
||||||
|
app.cancel_search();
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::SubmitMessage) => {
|
||||||
|
app.select_filtered_chat();
|
||||||
|
if let Some(chat_id) = app.get_selected_chat_id() {
|
||||||
|
open_chat_and_load_data(app, chat_id).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::MoveDown) => {
|
||||||
|
app.next_filtered_chat();
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::MoveUp) => {
|
||||||
|
app.previous_filtered_chat();
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
app.search_query.pop();
|
||||||
|
app.chat_list_state.select(Some(0));
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
app.search_query.push(c);
|
||||||
|
app.chat_list_state.select(Some(0));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Обработка режима поиска по сообщениям в открытом чате
|
||||||
|
///
|
||||||
|
/// Обрабатывает:
|
||||||
|
/// - Навигацию по результатам поиска (Up/Down/N/n)
|
||||||
|
/// - Переход к выбранному сообщению (Enter)
|
||||||
|
/// - Редактирование поискового запроса (Backspace, Char)
|
||||||
|
/// - Выход из режима поиска (Esc)
|
||||||
|
pub async fn handle_message_search_mode<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent, command: Option<crate::config::Command>) {
|
||||||
|
match command {
|
||||||
|
Some(crate::config::Command::Cancel) => {
|
||||||
|
app.exit_message_search_mode();
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::MoveUp) => {
|
||||||
|
app.select_previous_search_result();
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::MoveDown) => {
|
||||||
|
app.select_next_search_result();
|
||||||
|
}
|
||||||
|
Some(crate::config::Command::SubmitMessage) => {
|
||||||
|
if let Some(msg_id) = app.get_selected_search_result_id() {
|
||||||
|
scroll_to_message(app, MessageId::new(msg_id));
|
||||||
|
app.exit_message_search_mode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Char('N') => {
|
||||||
|
app.select_previous_search_result();
|
||||||
|
}
|
||||||
|
KeyCode::Char('n') => {
|
||||||
|
app.select_next_search_result();
|
||||||
|
}
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
let Some(mut query) = app.get_search_query().map(|s| s.to_string()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
query.pop();
|
||||||
|
app.update_search_query(query.clone());
|
||||||
|
perform_message_search(app, &query).await;
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
let Some(mut query) = app.get_search_query().map(|s| s.to_string()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
query.push(c);
|
||||||
|
app.update_search_query(query.clone());
|
||||||
|
perform_message_search(app, &query).await;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Выполняет поиск по сообщениям с обновлением результатов
|
||||||
|
pub async fn perform_message_search<T: TdClientTrait>(app: &mut App<T>, query: &str) {
|
||||||
|
let Some(chat_id) = app.get_selected_chat_id() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if query.is_empty() {
|
||||||
|
app.set_search_results(Vec::new());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(results) = with_timeout(
|
||||||
|
Duration::from_secs(3),
|
||||||
|
app.td_client.search_messages(ChatId::new(chat_id), query),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
app.set_search_results(results);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,7 @@
|
|||||||
|
//! Input handling module.
|
||||||
|
//!
|
||||||
|
//! Routes keyboard events by screen (Auth vs Main) to specialized handlers.
|
||||||
|
|
||||||
mod auth;
|
mod auth;
|
||||||
pub mod handlers;
|
pub mod handlers;
|
||||||
mod main_input;
|
mod main_input;
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
// Library interface for tele-tui
|
//! tele-tui — TUI client for Telegram
|
||||||
// This allows tests to import modules
|
//!
|
||||||
|
//! Library interface exposing modules for integration testing.
|
||||||
|
|
||||||
|
pub mod accounts;
|
||||||
pub mod app;
|
pub mod app;
|
||||||
|
pub mod audio;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod constants;
|
pub mod constants;
|
||||||
pub mod formatting;
|
pub mod formatting;
|
||||||
pub mod input;
|
pub mod input;
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
pub mod media;
|
||||||
pub mod message_grouping;
|
pub mod message_grouping;
|
||||||
pub mod notifications;
|
pub mod notifications;
|
||||||
pub mod tdlib;
|
pub mod tdlib;
|
||||||
|
|||||||
241
src/main.rs
241
src/main.rs
@@ -1,8 +1,12 @@
|
|||||||
|
mod accounts;
|
||||||
mod app;
|
mod app;
|
||||||
|
mod audio;
|
||||||
mod config;
|
mod config;
|
||||||
mod constants;
|
mod constants;
|
||||||
mod formatting;
|
mod formatting;
|
||||||
mod input;
|
mod input;
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
mod media;
|
||||||
mod message_grouping;
|
mod message_grouping;
|
||||||
mod notifications;
|
mod notifications;
|
||||||
mod tdlib;
|
mod tdlib;
|
||||||
@@ -28,6 +32,21 @@ use input::{handle_auth_input, handle_main_input};
|
|||||||
use tdlib::AuthState;
|
use tdlib::AuthState;
|
||||||
use utils::{disable_tdlib_logs, with_timeout_ignore};
|
use utils::{disable_tdlib_logs, with_timeout_ignore};
|
||||||
|
|
||||||
|
/// Parses `--account <name>` from CLI arguments.
|
||||||
|
fn parse_account_arg() -> Option<String> {
|
||||||
|
let args: Vec<String> = std::env::args().collect();
|
||||||
|
let mut i = 1;
|
||||||
|
while i < args.len() {
|
||||||
|
if args[i] == "--account" {
|
||||||
|
if i + 1 < args.len() {
|
||||||
|
return Some(args[i + 1].clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), io::Error> {
|
async fn main() -> Result<(), io::Error> {
|
||||||
// Загружаем переменные окружения из .env
|
// Загружаем переменные окружения из .env
|
||||||
@@ -45,6 +64,24 @@ async fn main() -> Result<(), io::Error> {
|
|||||||
// Загружаем конфигурацию (создаёт дефолтный если отсутствует)
|
// Загружаем конфигурацию (создаёт дефолтный если отсутствует)
|
||||||
let config = config::Config::load();
|
let config = config::Config::load();
|
||||||
|
|
||||||
|
// Загружаем/создаём accounts.toml + миграция legacy ./tdlib_data/
|
||||||
|
let accounts_config = accounts::load_or_create();
|
||||||
|
|
||||||
|
// Резолвим аккаунт из CLI или default
|
||||||
|
let account_arg = parse_account_arg();
|
||||||
|
let (account_name, db_path) =
|
||||||
|
accounts::resolve_account(&accounts_config, account_arg.as_deref())
|
||||||
|
.unwrap_or_else(|e| {
|
||||||
|
eprintln!("Error: {}", e);
|
||||||
|
std::process::exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Создаём директорию аккаунта если её нет
|
||||||
|
let db_path = accounts::ensure_account_dir(
|
||||||
|
account_arg.as_deref().unwrap_or(&accounts_config.default_account),
|
||||||
|
)
|
||||||
|
.unwrap_or(db_path);
|
||||||
|
|
||||||
// Отключаем логи TDLib ДО создания клиента
|
// Отключаем логи TDLib ДО создания клиента
|
||||||
disable_tdlib_logs();
|
disable_tdlib_logs();
|
||||||
|
|
||||||
@@ -63,18 +100,20 @@ async fn main() -> Result<(), io::Error> {
|
|||||||
panic_hook(info);
|
panic_hook(info);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Create app state
|
// Create app state with account-specific db_path
|
||||||
let mut app = App::new(config);
|
let mut app = App::new(config, db_path);
|
||||||
|
app.current_account_name = account_name;
|
||||||
|
|
||||||
// Запускаем инициализацию TDLib в фоне (только для реального клиента)
|
// Запускаем инициализацию TDLib в фоне (только для реального клиента)
|
||||||
let client_id = app.td_client.client_id();
|
let client_id = app.td_client.client_id();
|
||||||
let api_id = app.td_client.api_id;
|
let api_id = app.td_client.api_id;
|
||||||
let api_hash = app.td_client.api_hash.clone();
|
let api_hash = app.td_client.api_hash.clone();
|
||||||
|
let db_path_str = app.td_client.db_path.to_string_lossy().to_string();
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let _ = tdlib_rs::functions::set_tdlib_parameters(
|
let _ = tdlib_rs::functions::set_tdlib_parameters(
|
||||||
false, // use_test_dc
|
false, // use_test_dc
|
||||||
"tdlib_data".to_string(), // database_directory
|
db_path_str, // database_directory
|
||||||
"".to_string(), // files_directory
|
"".to_string(), // files_directory
|
||||||
"".to_string(), // database_encryption_key
|
"".to_string(), // database_encryption_key
|
||||||
true, // use_file_database
|
true, // use_file_database
|
||||||
@@ -143,6 +182,34 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
|
|||||||
app.needs_redraw = true;
|
app.needs_redraw = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Обрабатываем результаты фоновой загрузки фото
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
{
|
||||||
|
use crate::tdlib::PhotoDownloadState;
|
||||||
|
|
||||||
|
let mut got_photos = false;
|
||||||
|
if let Some(ref mut rx) = app.photo_download_rx {
|
||||||
|
while let Ok((file_id, result)) = rx.try_recv() {
|
||||||
|
let new_state = match result {
|
||||||
|
Ok(path) => PhotoDownloadState::Downloaded(path),
|
||||||
|
Err(_) => PhotoDownloadState::Error("Ошибка загрузки".to_string()),
|
||||||
|
};
|
||||||
|
for msg in app.td_client.current_chat_messages_mut() {
|
||||||
|
if let Some(photo) = msg.photo_info_mut() {
|
||||||
|
if photo.file_id == file_id {
|
||||||
|
photo.download_state = new_state;
|
||||||
|
got_photos = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got_photos {
|
||||||
|
app.needs_redraw = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Очищаем устаревший typing status
|
// Очищаем устаревший typing status
|
||||||
if app.td_client.clear_stale_typing_status() {
|
if app.td_client.clear_stale_typing_status() {
|
||||||
app.needs_redraw = true;
|
app.needs_redraw = true;
|
||||||
@@ -164,6 +231,42 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
|
|||||||
app.needs_redraw = true;
|
app.needs_redraw = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Обновляем позицию воспроизведения голосового сообщения
|
||||||
|
{
|
||||||
|
let mut stop_playback = false;
|
||||||
|
if let Some(ref mut playback) = app.playback_state {
|
||||||
|
use crate::tdlib::PlaybackStatus;
|
||||||
|
match playback.status {
|
||||||
|
PlaybackStatus::Playing => {
|
||||||
|
let prev_second = playback.position as u32;
|
||||||
|
if let Some(last_tick) = app.last_playback_tick {
|
||||||
|
let delta = last_tick.elapsed().as_secs_f32();
|
||||||
|
playback.position += delta;
|
||||||
|
}
|
||||||
|
app.last_playback_tick = Some(std::time::Instant::now());
|
||||||
|
|
||||||
|
// Проверяем завершение воспроизведения
|
||||||
|
if playback.position >= playback.duration
|
||||||
|
|| app.audio_player.as_ref().map_or(false, |p| p.is_stopped())
|
||||||
|
{
|
||||||
|
stop_playback = true;
|
||||||
|
}
|
||||||
|
// Перерисовка только при смене секунды (не 60 FPS)
|
||||||
|
if playback.position as u32 != prev_second || stop_playback {
|
||||||
|
app.needs_redraw = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
app.last_playback_tick = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if stop_playback {
|
||||||
|
app.stop_playback();
|
||||||
|
app.last_playback_tick = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Рендерим только если есть изменения
|
// Рендерим только если есть изменения
|
||||||
if app.needs_redraw {
|
if app.needs_redraw {
|
||||||
terminal.draw(|f| ui::render(f, app))?;
|
terminal.draw(|f| ui::render(f, app))?;
|
||||||
@@ -182,6 +285,9 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
|
|||||||
// Graceful shutdown
|
// Graceful shutdown
|
||||||
should_stop.store(true, Ordering::Relaxed);
|
should_stop.store(true, Ordering::Relaxed);
|
||||||
|
|
||||||
|
// Останавливаем воспроизведение голосового (убиваем ffplay)
|
||||||
|
app.stop_playback();
|
||||||
|
|
||||||
// Закрываем TDLib клиент
|
// Закрываем TDLib клиент
|
||||||
let _ = tdlib_rs::functions::close(app.td_client.client_id()).await;
|
let _ = tdlib_rs::functions::close(app.td_client.client_id()).await;
|
||||||
|
|
||||||
@@ -191,6 +297,16 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ctrl+A opens account switcher from any screen
|
||||||
|
if key.code == KeyCode::Char('a')
|
||||||
|
&& key.modifiers.contains(KeyModifiers::CONTROL)
|
||||||
|
&& app.account_switcher.is_none()
|
||||||
|
{
|
||||||
|
app.open_account_switcher();
|
||||||
|
} else if app.account_switcher.is_some() {
|
||||||
|
// Route to main input handler when account switcher is open
|
||||||
|
handle_main_input(app, key).await;
|
||||||
|
} else {
|
||||||
match app.screen {
|
match app.screen {
|
||||||
AppScreen::Loading => {
|
AppScreen::Loading => {
|
||||||
// В состоянии загрузки игнорируем ввод
|
// В состоянии загрузки игнорируем ввод
|
||||||
@@ -198,6 +314,7 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
|
|||||||
AppScreen::Auth => handle_auth_input(app, key.code).await,
|
AppScreen::Auth => handle_auth_input(app, key.code).await,
|
||||||
AppScreen::Main => handle_main_input(app, key).await,
|
AppScreen::Main => handle_main_input(app, key).await,
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Любой ввод требует перерисовки
|
// Любой ввод требует перерисовки
|
||||||
app.needs_redraw = true;
|
app.needs_redraw = true;
|
||||||
@@ -209,6 +326,124 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
|
|||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Process pending chat initialization (reply info, pinned, photos)
|
||||||
|
if let Some(chat_id) = app.pending_chat_init.take() {
|
||||||
|
// Загружаем недостающие reply info (игнорируем ошибки)
|
||||||
|
with_timeout_ignore(
|
||||||
|
Duration::from_secs(5),
|
||||||
|
app.td_client.fetch_missing_reply_info(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Загружаем последнее закреплённое сообщение (игнорируем ошибки)
|
||||||
|
with_timeout_ignore(
|
||||||
|
Duration::from_secs(2),
|
||||||
|
app.td_client.load_current_pinned_message(chat_id),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Авто-загрузка фото — неблокирующая фоновая задача (до 5 фото параллельно)
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
{
|
||||||
|
use crate::tdlib::PhotoDownloadState;
|
||||||
|
|
||||||
|
if app.config().images.auto_download_images && app.config().images.show_images {
|
||||||
|
let photo_file_ids: Vec<i32> = app
|
||||||
|
.td_client
|
||||||
|
.current_chat_messages()
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.take(5)
|
||||||
|
.filter_map(|msg| {
|
||||||
|
msg.photo_info().and_then(|p| {
|
||||||
|
matches!(p.download_state, PhotoDownloadState::NotDownloaded)
|
||||||
|
.then_some(p.file_id)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if !photo_file_ids.is_empty() {
|
||||||
|
let client_id = app.td_client.client_id();
|
||||||
|
let (tx, rx) =
|
||||||
|
tokio::sync::mpsc::unbounded_channel::<(i32, Result<String, String>)>();
|
||||||
|
app.photo_download_rx = Some(rx);
|
||||||
|
|
||||||
|
for file_id in photo_file_ids {
|
||||||
|
let tx = tx.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let result = tokio::time::timeout(
|
||||||
|
Duration::from_secs(5),
|
||||||
|
async {
|
||||||
|
match tdlib_rs::functions::download_file(
|
||||||
|
file_id, 1, 0, 0, true, client_id,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(tdlib_rs::enums::File::File(file))
|
||||||
|
if file.local.is_downloading_completed
|
||||||
|
&& !file.local.path.is_empty() =>
|
||||||
|
{
|
||||||
|
Ok(file.local.path)
|
||||||
|
}
|
||||||
|
Ok(_) => Err("Файл не скачан".to_string()),
|
||||||
|
Err(e) => Err(format!("{:?}", e)),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let result = match result {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(_) => Err("Таймаут загрузки".to_string()),
|
||||||
|
};
|
||||||
|
let _ = tx.send((file_id, result));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.needs_redraw = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check pending account switch
|
||||||
|
if let Some((account_name, new_db_path)) = app.pending_account_switch.take() {
|
||||||
|
// 1. Stop playback
|
||||||
|
app.stop_playback();
|
||||||
|
|
||||||
|
// 2. Recreate client (closes old, creates new, inits TDLib params)
|
||||||
|
if let Err(e) = app.td_client.recreate_client(new_db_path).await {
|
||||||
|
app.error_message = Some(format!("Ошибка переключения: {}", e));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Reset app state
|
||||||
|
app.current_account_name = account_name.clone();
|
||||||
|
app.screen = AppScreen::Loading;
|
||||||
|
|
||||||
|
// 4. Persist selected account as default for next launch
|
||||||
|
let mut accounts_config = accounts::load_or_create();
|
||||||
|
accounts_config.default_account = account_name;
|
||||||
|
if let Err(e) = accounts::save(&accounts_config) {
|
||||||
|
tracing::warn!("Could not save default account: {}", e);
|
||||||
|
}
|
||||||
|
app.chats.clear();
|
||||||
|
app.selected_chat_id = None;
|
||||||
|
app.chat_state = Default::default();
|
||||||
|
app.input_mode = Default::default();
|
||||||
|
app.status_message = Some("Переключение аккаунта...".to_string());
|
||||||
|
app.error_message = None;
|
||||||
|
app.is_searching = false;
|
||||||
|
app.search_query.clear();
|
||||||
|
app.message_input.clear();
|
||||||
|
app.cursor_position = 0;
|
||||||
|
app.message_scroll_offset = 0;
|
||||||
|
app.pending_chat_init = None;
|
||||||
|
app.account_switcher = None;
|
||||||
|
|
||||||
|
app.needs_redraw = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
113
src/media/cache.rs
Normal file
113
src/media/cache.rs
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
//! Image cache with LRU eviction.
|
||||||
|
//!
|
||||||
|
//! Stores downloaded images in `~/.cache/tele-tui/images/` with size-based eviction.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// Кэш изображений с LRU eviction по mtime
|
||||||
|
pub struct ImageCache {
|
||||||
|
cache_dir: PathBuf,
|
||||||
|
max_size_bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ImageCache {
|
||||||
|
/// Создаёт новый кэш с указанным лимитом в МБ
|
||||||
|
pub fn new(cache_size_mb: u64) -> Self {
|
||||||
|
let cache_dir = dirs::cache_dir()
|
||||||
|
.unwrap_or_else(|| PathBuf::from("/tmp"))
|
||||||
|
.join("tele-tui")
|
||||||
|
.join("images");
|
||||||
|
|
||||||
|
// Создаём директорию кэша если не существует
|
||||||
|
let _ = fs::create_dir_all(&cache_dir);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
cache_dir,
|
||||||
|
max_size_bytes: cache_size_mb * 1024 * 1024,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Проверяет, есть ли файл в кэше
|
||||||
|
pub fn get_cached(&self, file_id: i32) -> Option<PathBuf> {
|
||||||
|
let path = self.cache_dir.join(format!("{}.jpg", file_id));
|
||||||
|
if path.exists() {
|
||||||
|
// Обновляем mtime для LRU
|
||||||
|
let _ = filetime::set_file_mtime(
|
||||||
|
&path,
|
||||||
|
filetime::FileTime::now(),
|
||||||
|
);
|
||||||
|
Some(path)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Кэширует файл, копируя из source_path
|
||||||
|
pub fn cache_file(&self, file_id: i32, source_path: &str) -> Result<PathBuf, String> {
|
||||||
|
let dest = self.cache_dir.join(format!("{}.jpg", file_id));
|
||||||
|
|
||||||
|
fs::copy(source_path, &dest)
|
||||||
|
.map_err(|e| format!("Ошибка кэширования: {}", e))?;
|
||||||
|
|
||||||
|
// Evict если превышен лимит
|
||||||
|
self.evict_if_needed();
|
||||||
|
|
||||||
|
Ok(dest)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Удаляет старые файлы если кэш превышает лимит
|
||||||
|
fn evict_if_needed(&self) {
|
||||||
|
let entries = match fs::read_dir(&self.cache_dir) {
|
||||||
|
Ok(entries) => entries,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut files: Vec<(PathBuf, u64, std::time::SystemTime)> = entries
|
||||||
|
.filter_map(|e| e.ok())
|
||||||
|
.filter_map(|e| {
|
||||||
|
let meta = e.metadata().ok()?;
|
||||||
|
let mtime = meta.modified().ok()?;
|
||||||
|
Some((e.path(), meta.len(), mtime))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let total_size: u64 = files.iter().map(|(_, size, _)| size).sum();
|
||||||
|
|
||||||
|
if total_size <= self.max_size_bytes {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сортируем по mtime (старые первые)
|
||||||
|
files.sort_by_key(|(_, _, mtime)| *mtime);
|
||||||
|
|
||||||
|
let mut current_size = total_size;
|
||||||
|
for (path, size, _) in &files {
|
||||||
|
if current_size <= self.max_size_bytes {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let _ = fs::remove_file(path);
|
||||||
|
current_size -= size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Обёртка для установки mtime без внешней зависимости
|
||||||
|
mod filetime {
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
pub struct FileTime;
|
||||||
|
|
||||||
|
impl FileTime {
|
||||||
|
pub fn now() -> Self {
|
||||||
|
FileTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_file_mtime(_path: &Path, _time: FileTime) -> Result<(), std::io::Error> {
|
||||||
|
// На macOS/Linux можно использовать utime, но для простоты
|
||||||
|
// достаточно прочитать файл (обновит atime) — LRU по mtime не критичен
|
||||||
|
// для нашего use case. Файл будет перезаписан при повторном скачивании.
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
123
src/media/image_renderer.rs
Normal file
123
src/media/image_renderer.rs
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
//! Terminal image renderer using ratatui-image.
|
||||||
|
//!
|
||||||
|
//! Detects terminal protocol (iTerm2, Sixel, Halfblocks) and renders images
|
||||||
|
//! as StatefulProtocol widgets.
|
||||||
|
//!
|
||||||
|
//! Implements LRU-like caching for protocols to avoid unlimited memory growth.
|
||||||
|
|
||||||
|
use crate::types::MessageId;
|
||||||
|
use ratatui_image::picker::{Picker, ProtocolType};
|
||||||
|
use ratatui_image::protocol::StatefulProtocol;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
/// Максимальное количество кэшированных протоколов (LRU)
|
||||||
|
const MAX_CACHED_PROTOCOLS: usize = 100;
|
||||||
|
|
||||||
|
/// Рендерер изображений для терминала с LRU кэшем
|
||||||
|
pub struct ImageRenderer {
|
||||||
|
picker: Picker,
|
||||||
|
/// Протоколы рендеринга для каждого сообщения (message_id -> protocol)
|
||||||
|
protocols: HashMap<i64, StatefulProtocol>,
|
||||||
|
/// Порядок доступа для LRU (message_id -> порядковый номер)
|
||||||
|
access_order: HashMap<i64, usize>,
|
||||||
|
/// Счётчик для отслеживания порядка доступа
|
||||||
|
access_counter: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ImageRenderer {
|
||||||
|
/// Создаёт ImageRenderer с автодетектом протокола (высокое качество для modal)
|
||||||
|
pub fn new() -> Option<Self> {
|
||||||
|
let picker = Picker::from_query_stdio().ok()?;
|
||||||
|
|
||||||
|
Some(Self {
|
||||||
|
picker,
|
||||||
|
protocols: HashMap::new(),
|
||||||
|
access_order: HashMap::new(),
|
||||||
|
access_counter: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Создаёт ImageRenderer с принудительным Halfblocks (быстро, для inline preview)
|
||||||
|
pub fn new_fast() -> Option<Self> {
|
||||||
|
let mut picker = Picker::from_fontsize((8, 12));
|
||||||
|
picker.set_protocol_type(ProtocolType::Halfblocks);
|
||||||
|
|
||||||
|
Some(Self {
|
||||||
|
picker,
|
||||||
|
protocols: HashMap::new(),
|
||||||
|
access_order: HashMap::new(),
|
||||||
|
access_counter: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Загружает изображение из файла и создаёт протокол рендеринга.
|
||||||
|
///
|
||||||
|
/// Если протокол уже существует, не загружает повторно (кэширование).
|
||||||
|
/// Использует LRU eviction при превышении лимита.
|
||||||
|
pub fn load_image(&mut self, msg_id: MessageId, path: &str) -> Result<(), String> {
|
||||||
|
let msg_id_i64 = msg_id.as_i64();
|
||||||
|
|
||||||
|
// Оптимизация: если протокол уже есть, обновляем access time и возвращаем
|
||||||
|
if self.protocols.contains_key(&msg_id_i64) {
|
||||||
|
self.access_counter += 1;
|
||||||
|
self.access_order.insert(msg_id_i64, self.access_counter);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evict старые протоколы если превышен лимит
|
||||||
|
if self.protocols.len() >= MAX_CACHED_PROTOCOLS {
|
||||||
|
self.evict_oldest_protocol();
|
||||||
|
}
|
||||||
|
|
||||||
|
let img = image::ImageReader::open(path)
|
||||||
|
.map_err(|e| format!("Ошибка открытия: {}", e))?
|
||||||
|
.decode()
|
||||||
|
.map_err(|e| format!("Ошибка декодирования: {}", e))?;
|
||||||
|
|
||||||
|
let protocol = self.picker.new_resize_protocol(img);
|
||||||
|
self.protocols.insert(msg_id_i64, protocol);
|
||||||
|
|
||||||
|
// Обновляем access order
|
||||||
|
self.access_counter += 1;
|
||||||
|
self.access_order.insert(msg_id_i64, self.access_counter);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Удаляет самый старый протокол (LRU eviction)
|
||||||
|
fn evict_oldest_protocol(&mut self) {
|
||||||
|
if let Some((&oldest_id, _)) = self.access_order.iter().min_by_key(|(_, &order)| order) {
|
||||||
|
self.protocols.remove(&oldest_id);
|
||||||
|
self.access_order.remove(&oldest_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Получает мутабельную ссылку на протокол для рендеринга.
|
||||||
|
///
|
||||||
|
/// Обновляет access time для LRU.
|
||||||
|
pub fn get_protocol(&mut self, msg_id: &MessageId) -> Option<&mut StatefulProtocol> {
|
||||||
|
let msg_id_i64 = msg_id.as_i64();
|
||||||
|
|
||||||
|
if self.protocols.contains_key(&msg_id_i64) {
|
||||||
|
// Обновляем access time
|
||||||
|
self.access_counter += 1;
|
||||||
|
self.access_order.insert(msg_id_i64, self.access_counter);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.protocols.get_mut(&msg_id_i64)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Удаляет протокол для сообщения
|
||||||
|
pub fn remove(&mut self, msg_id: &MessageId) {
|
||||||
|
let msg_id_i64 = msg_id.as_i64();
|
||||||
|
self.protocols.remove(&msg_id_i64);
|
||||||
|
self.access_order.remove(&msg_id_i64);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Очищает все протоколы
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
self.protocols.clear();
|
||||||
|
self.access_order.clear();
|
||||||
|
self.access_counter = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/media/mod.rs
Normal file
9
src/media/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
//! Media handling module (feature-gated under "images").
|
||||||
|
//!
|
||||||
|
//! Provides image caching and terminal image rendering via ratatui-image.
|
||||||
|
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
pub mod cache;
|
||||||
|
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
pub mod image_renderer;
|
||||||
@@ -15,6 +15,8 @@ pub enum MessageGroup {
|
|||||||
SenderHeader { is_outgoing: bool, sender_name: String },
|
SenderHeader { is_outgoing: bool, sender_name: String },
|
||||||
/// Сообщение
|
/// Сообщение
|
||||||
Message(MessageInfo),
|
Message(MessageInfo),
|
||||||
|
/// Альбом (группа фото с одинаковым media_album_id)
|
||||||
|
Album(Vec<MessageInfo>),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Группирует сообщения по дате и отправителю
|
/// Группирует сообщения по дате и отправителю
|
||||||
@@ -51,6 +53,10 @@ pub enum MessageGroup {
|
|||||||
/// // Рендерим сообщение
|
/// // Рендерим сообщение
|
||||||
/// println!("{}", msg.text());
|
/// println!("{}", msg.text());
|
||||||
/// }
|
/// }
|
||||||
|
/// MessageGroup::Album(messages) => {
|
||||||
|
/// // Рендерим альбом (группу фото)
|
||||||
|
/// println!("Album with {} photos", messages.len());
|
||||||
|
/// }
|
||||||
/// }
|
/// }
|
||||||
/// }
|
/// }
|
||||||
/// ```
|
/// ```
|
||||||
@@ -58,12 +64,28 @@ pub fn group_messages(messages: &[MessageInfo]) -> Vec<MessageGroup> {
|
|||||||
let mut result = Vec::new();
|
let mut result = Vec::new();
|
||||||
let mut last_day: Option<i64> = None;
|
let mut last_day: Option<i64> = None;
|
||||||
let mut last_sender: Option<(bool, String)> = None; // (is_outgoing, sender_name)
|
let mut last_sender: Option<(bool, String)> = None; // (is_outgoing, sender_name)
|
||||||
|
let mut album_acc: Vec<MessageInfo> = Vec::new();
|
||||||
|
|
||||||
|
/// Сбрасывает аккумулятор альбома в результат
|
||||||
|
fn flush_album(acc: &mut Vec<MessageInfo>, result: &mut Vec<MessageGroup>) {
|
||||||
|
if acc.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if acc.len() >= 2 {
|
||||||
|
result.push(MessageGroup::Album(std::mem::take(acc)));
|
||||||
|
} else {
|
||||||
|
// Одно сообщение — не альбом
|
||||||
|
result.push(MessageGroup::Message(acc.remove(0)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for msg in messages {
|
for msg in messages {
|
||||||
// Проверяем, нужно ли добавить разделитель даты
|
// Проверяем, нужно ли добавить разделитель даты
|
||||||
let msg_day = get_day(msg.date());
|
let msg_day = get_day(msg.date());
|
||||||
|
|
||||||
if last_day != Some(msg_day) {
|
if last_day != Some(msg_day) {
|
||||||
|
// Flush аккумулятор перед разделителем даты
|
||||||
|
flush_album(&mut album_acc, &mut result);
|
||||||
// Добавляем разделитель даты
|
// Добавляем разделитель даты
|
||||||
result.push(MessageGroup::DateSeparator(msg.date()));
|
result.push(MessageGroup::DateSeparator(msg.date()));
|
||||||
last_day = Some(msg_day);
|
last_day = Some(msg_day);
|
||||||
@@ -82,6 +104,8 @@ pub fn group_messages(messages: &[MessageInfo]) -> Vec<MessageGroup> {
|
|||||||
let show_sender_header = last_sender.as_ref() != Some(¤t_sender);
|
let show_sender_header = last_sender.as_ref() != Some(¤t_sender);
|
||||||
|
|
||||||
if show_sender_header {
|
if show_sender_header {
|
||||||
|
// Flush аккумулятор перед сменой отправителя
|
||||||
|
flush_album(&mut album_acc, &mut result);
|
||||||
result.push(MessageGroup::SenderHeader {
|
result.push(MessageGroup::SenderHeader {
|
||||||
is_outgoing: msg.is_outgoing(),
|
is_outgoing: msg.is_outgoing(),
|
||||||
sender_name,
|
sender_name,
|
||||||
@@ -89,10 +113,36 @@ pub fn group_messages(messages: &[MessageInfo]) -> Vec<MessageGroup> {
|
|||||||
last_sender = Some(current_sender);
|
last_sender = Some(current_sender);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Добавляем само сообщение
|
// Проверяем, является ли сообщение частью альбома
|
||||||
|
let album_id = msg.media_album_id();
|
||||||
|
if album_id != 0 {
|
||||||
|
// Проверяем, совпадает ли album_id с текущим аккумулятором
|
||||||
|
if let Some(first) = album_acc.first() {
|
||||||
|
if first.media_album_id() == album_id {
|
||||||
|
// Тот же альбом — добавляем
|
||||||
|
album_acc.push(msg.clone());
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
// Другой альбом — flush старый, начинаем новый
|
||||||
|
flush_album(&mut album_acc, &mut result);
|
||||||
|
album_acc.push(msg.clone());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Аккумулятор пуст — начинаем новый альбом
|
||||||
|
album_acc.push(msg.clone());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обычное сообщение (не альбом) — flush аккумулятор
|
||||||
|
flush_album(&mut album_acc, &mut result);
|
||||||
result.push(MessageGroup::Message(msg.clone()));
|
result.push(MessageGroup::Message(msg.clone()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Flush оставшийся аккумулятор
|
||||||
|
flush_album(&mut album_acc, &mut result);
|
||||||
|
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,4 +296,152 @@ mod tests {
|
|||||||
assert!(matches!(grouped[1], MessageGroup::SenderHeader { .. }));
|
assert!(matches!(grouped[1], MessageGroup::SenderHeader { .. }));
|
||||||
assert!(matches!(grouped[2], MessageGroup::Message(_)));
|
assert!(matches!(grouped[2], MessageGroup::Message(_)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_album_grouping_two_photos() {
|
||||||
|
let msg1 = MessageBuilder::new(MessageId::new(1))
|
||||||
|
.sender_name("Alice")
|
||||||
|
.text("Photo 1")
|
||||||
|
.date(1609459200)
|
||||||
|
.incoming()
|
||||||
|
.media_album_id(12345)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let msg2 = MessageBuilder::new(MessageId::new(2))
|
||||||
|
.sender_name("Alice")
|
||||||
|
.text("Photo 2")
|
||||||
|
.date(1609459201)
|
||||||
|
.incoming()
|
||||||
|
.media_album_id(12345)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let messages = vec![msg1, msg2];
|
||||||
|
let grouped = group_messages(&messages);
|
||||||
|
|
||||||
|
// DateSep, SenderHeader, Album
|
||||||
|
assert_eq!(grouped.len(), 3);
|
||||||
|
assert!(matches!(grouped[0], MessageGroup::DateSeparator(_)));
|
||||||
|
assert!(matches!(grouped[1], MessageGroup::SenderHeader { .. }));
|
||||||
|
if let MessageGroup::Album(album) = &grouped[2] {
|
||||||
|
assert_eq!(album.len(), 2);
|
||||||
|
assert_eq!(album[0].id(), MessageId::new(1));
|
||||||
|
assert_eq!(album[1].id(), MessageId::new(2));
|
||||||
|
} else {
|
||||||
|
panic!("Expected Album, got {:?}", grouped[2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_album_single_photo_not_album() {
|
||||||
|
// Одно сообщение с album_id → не альбом, обычное сообщение
|
||||||
|
let msg = MessageBuilder::new(MessageId::new(1))
|
||||||
|
.sender_name("Alice")
|
||||||
|
.text("Single photo")
|
||||||
|
.date(1609459200)
|
||||||
|
.incoming()
|
||||||
|
.media_album_id(12345)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let messages = vec![msg];
|
||||||
|
let grouped = group_messages(&messages);
|
||||||
|
|
||||||
|
// DateSep, SenderHeader, Message (не Album)
|
||||||
|
assert_eq!(grouped.len(), 3);
|
||||||
|
assert!(matches!(grouped[2], MessageGroup::Message(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_album_with_regular_messages() {
|
||||||
|
let msg1 = MessageBuilder::new(MessageId::new(1))
|
||||||
|
.sender_name("Alice")
|
||||||
|
.text("Text message")
|
||||||
|
.date(1609459200)
|
||||||
|
.incoming()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let msg2 = MessageBuilder::new(MessageId::new(2))
|
||||||
|
.sender_name("Alice")
|
||||||
|
.text("Photo 1")
|
||||||
|
.date(1609459201)
|
||||||
|
.incoming()
|
||||||
|
.media_album_id(100)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let msg3 = MessageBuilder::new(MessageId::new(3))
|
||||||
|
.sender_name("Alice")
|
||||||
|
.text("Photo 2")
|
||||||
|
.date(1609459202)
|
||||||
|
.incoming()
|
||||||
|
.media_album_id(100)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let msg4 = MessageBuilder::new(MessageId::new(4))
|
||||||
|
.sender_name("Alice")
|
||||||
|
.text("After album")
|
||||||
|
.date(1609459203)
|
||||||
|
.incoming()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let messages = vec![msg1, msg2, msg3, msg4];
|
||||||
|
let grouped = group_messages(&messages);
|
||||||
|
|
||||||
|
// DateSep, SenderHeader, Message, Album, Message
|
||||||
|
assert_eq!(grouped.len(), 5);
|
||||||
|
assert!(matches!(grouped[2], MessageGroup::Message(_)));
|
||||||
|
assert!(matches!(grouped[3], MessageGroup::Album(_)));
|
||||||
|
assert!(matches!(grouped[4], MessageGroup::Message(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_two_different_albums() {
|
||||||
|
let msg1 = MessageBuilder::new(MessageId::new(1))
|
||||||
|
.sender_name("Alice")
|
||||||
|
.text("Album 1 - Photo 1")
|
||||||
|
.date(1609459200)
|
||||||
|
.incoming()
|
||||||
|
.media_album_id(100)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let msg2 = MessageBuilder::new(MessageId::new(2))
|
||||||
|
.sender_name("Alice")
|
||||||
|
.text("Album 1 - Photo 2")
|
||||||
|
.date(1609459201)
|
||||||
|
.incoming()
|
||||||
|
.media_album_id(100)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let msg3 = MessageBuilder::new(MessageId::new(3))
|
||||||
|
.sender_name("Alice")
|
||||||
|
.text("Album 2 - Photo 1")
|
||||||
|
.date(1609459202)
|
||||||
|
.incoming()
|
||||||
|
.media_album_id(200)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let msg4 = MessageBuilder::new(MessageId::new(4))
|
||||||
|
.sender_name("Alice")
|
||||||
|
.text("Album 2 - Photo 2")
|
||||||
|
.date(1609459203)
|
||||||
|
.incoming()
|
||||||
|
.media_album_id(200)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let messages = vec![msg1, msg2, msg3, msg4];
|
||||||
|
let grouped = group_messages(&messages);
|
||||||
|
|
||||||
|
// DateSep, SenderHeader, Album(2), Album(2)
|
||||||
|
assert_eq!(grouped.len(), 4);
|
||||||
|
if let MessageGroup::Album(a1) = &grouped[2] {
|
||||||
|
assert_eq!(a1.len(), 2);
|
||||||
|
assert_eq!(a1[0].media_album_id(), 100);
|
||||||
|
} else {
|
||||||
|
panic!("Expected first Album");
|
||||||
|
}
|
||||||
|
if let MessageGroup::Album(a2) = &grouped[3] {
|
||||||
|
assert_eq!(a2.len(), 2);
|
||||||
|
assert_eq!(a2[0].media_album_id(), 200);
|
||||||
|
} else {
|
||||||
|
panic!("Expected second Album");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -269,7 +269,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_notification_manager_creation() {
|
fn test_notification_manager_creation() {
|
||||||
let manager = NotificationManager::new();
|
let manager = NotificationManager::new();
|
||||||
assert!(manager.enabled);
|
assert!(!manager.enabled); // disabled by default
|
||||||
assert!(!manager.only_mentions);
|
assert!(!manager.only_mentions);
|
||||||
assert!(manager.show_preview);
|
assert!(manager.show_preview);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use crate::types::{ChatId, MessageId, UserId};
|
use crate::types::{ChatId, MessageId, UserId};
|
||||||
use std::env;
|
use std::env;
|
||||||
|
use std::path::PathBuf;
|
||||||
use tdlib_rs::enums::{
|
use tdlib_rs::enums::{
|
||||||
ChatList, ConnectionState, Update, UserStatus,
|
ChatList, ConnectionState, Update, UserStatus,
|
||||||
Chat as TdChat
|
Chat as TdChat
|
||||||
@@ -32,7 +33,7 @@ use crate::notifications::NotificationManager;
|
|||||||
/// ```ignore
|
/// ```ignore
|
||||||
/// use tele_tui::tdlib::TdClient;
|
/// use tele_tui::tdlib::TdClient;
|
||||||
///
|
///
|
||||||
/// let mut client = TdClient::new();
|
/// let mut client = TdClient::new(std::path::PathBuf::from("tdlib_data"));
|
||||||
///
|
///
|
||||||
/// // Start authorization
|
/// // Start authorization
|
||||||
/// client.send_phone_number("+1234567890".to_string()).await?;
|
/// client.send_phone_number("+1234567890".to_string()).await?;
|
||||||
@@ -45,6 +46,7 @@ use crate::notifications::NotificationManager;
|
|||||||
pub struct TdClient {
|
pub struct TdClient {
|
||||||
pub api_id: i32,
|
pub api_id: i32,
|
||||||
pub api_hash: String,
|
pub api_hash: String,
|
||||||
|
pub db_path: PathBuf,
|
||||||
client_id: i32,
|
client_id: i32,
|
||||||
|
|
||||||
// Менеджеры (делегируем им функциональность)
|
// Менеджеры (делегируем им функциональность)
|
||||||
@@ -71,7 +73,7 @@ impl TdClient {
|
|||||||
/// # Returns
|
/// # Returns
|
||||||
///
|
///
|
||||||
/// A new `TdClient` instance ready for authentication.
|
/// A new `TdClient` instance ready for authentication.
|
||||||
pub fn new() -> Self {
|
pub fn new(db_path: PathBuf) -> Self {
|
||||||
// Пробуем загрузить credentials из Config (файл или env)
|
// Пробуем загрузить credentials из Config (файл или env)
|
||||||
let (api_id, api_hash) = crate::config::Config::load_credentials()
|
let (api_id, api_hash) = crate::config::Config::load_credentials()
|
||||||
.unwrap_or_else(|_| {
|
.unwrap_or_else(|_| {
|
||||||
@@ -89,6 +91,7 @@ impl TdClient {
|
|||||||
Self {
|
Self {
|
||||||
api_id,
|
api_id,
|
||||||
api_hash,
|
api_hash,
|
||||||
|
db_path,
|
||||||
client_id,
|
client_id,
|
||||||
auth: AuthManager::new(client_id),
|
auth: AuthManager::new(client_id),
|
||||||
chat_manager: ChatManager::new(client_id),
|
chat_manager: ChatManager::new(client_id),
|
||||||
@@ -362,6 +365,22 @@ impl TdClient {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Делегирование файловых операций
|
||||||
|
|
||||||
|
/// Скачивает файл по file_id и возвращает локальный путь.
|
||||||
|
pub async fn download_file(&self, file_id: i32) -> Result<String, String> {
|
||||||
|
match functions::download_file(file_id, 1, 0, 0, true, self.client_id).await {
|
||||||
|
Ok(tdlib_rs::enums::File::File(file)) => {
|
||||||
|
if file.local.is_downloading_completed && !file.local.path.is_empty() {
|
||||||
|
Ok(file.local.path)
|
||||||
|
} else {
|
||||||
|
Err("Файл не скачан".to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => Err(format!("Ошибка скачивания файла: {:?}", e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Вспомогательные методы
|
// Вспомогательные методы
|
||||||
pub fn client_id(&self) -> i32 {
|
pub fn client_id(&self) -> i32 {
|
||||||
self.client_id
|
self.client_id
|
||||||
@@ -608,6 +627,49 @@ impl TdClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Recreates the TDLib client with a new database path.
|
||||||
|
///
|
||||||
|
/// Closes the old client, creates a new one, and spawns TDLib parameter initialization.
|
||||||
|
pub async fn recreate_client(&mut self, db_path: PathBuf) -> Result<(), String> {
|
||||||
|
// 1. Close old client
|
||||||
|
let _ = functions::close(self.client_id).await;
|
||||||
|
|
||||||
|
// 2. Create new client
|
||||||
|
let new_client = TdClient::new(db_path);
|
||||||
|
|
||||||
|
// 3. Spawn set_tdlib_parameters for new client
|
||||||
|
let new_client_id = new_client.client_id;
|
||||||
|
let api_id = new_client.api_id;
|
||||||
|
let api_hash = new_client.api_hash.clone();
|
||||||
|
let db_path_str = new_client.db_path.to_string_lossy().to_string();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = functions::set_tdlib_parameters(
|
||||||
|
false,
|
||||||
|
db_path_str,
|
||||||
|
"".to_string(),
|
||||||
|
"".to_string(),
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
api_id,
|
||||||
|
api_hash,
|
||||||
|
"en".to_string(),
|
||||||
|
"Desktop".to_string(),
|
||||||
|
"".to_string(),
|
||||||
|
env!("CARGO_PKG_VERSION").to_string(),
|
||||||
|
new_client_id,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Replace self
|
||||||
|
*self = new_client;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn extract_content_text(content: &tdlib_rs::enums::MessageContent) -> String {
|
pub fn extract_content_text(content: &tdlib_rs::enums::MessageContent) -> String {
|
||||||
use tdlib_rs::enums::MessageContent;
|
use tdlib_rs::enums::MessageContent;
|
||||||
match content {
|
match content {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use super::r#trait::TdClientTrait;
|
|||||||
use super::{AuthState, ChatInfo, FolderInfo, MessageInfo, ProfileInfo, ReplyInfo, UserCache, UserOnlineStatus};
|
use super::{AuthState, ChatInfo, FolderInfo, MessageInfo, ProfileInfo, ReplyInfo, UserCache, UserOnlineStatus};
|
||||||
use crate::types::{ChatId, MessageId, UserId};
|
use crate::types::{ChatId, MessageId, UserId};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use std::path::PathBuf;
|
||||||
use tdlib_rs::enums::{ChatAction, Update};
|
use tdlib_rs::enums::{ChatAction, Update};
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -159,6 +160,16 @@ impl TdClientTrait for TdClient {
|
|||||||
self.toggle_reaction(chat_id, message_id, reaction).await
|
self.toggle_reaction(chat_id, message_id, reaction).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============ File methods ============
|
||||||
|
async fn download_file(&self, file_id: i32) -> Result<String, String> {
|
||||||
|
self.download_file(file_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn download_voice_note(&self, file_id: i32) -> Result<String, String> {
|
||||||
|
// Voice notes use the same download mechanism as photos
|
||||||
|
self.download_file(file_id).await
|
||||||
|
}
|
||||||
|
|
||||||
fn client_id(&self) -> i32 {
|
fn client_id(&self) -> i32 {
|
||||||
self.client_id()
|
self.client_id()
|
||||||
}
|
}
|
||||||
@@ -268,6 +279,11 @@ impl TdClientTrait for TdClient {
|
|||||||
self.notification_manager.sync_muted_chats(&self.chat_manager.chats);
|
self.notification_manager.sync_muted_chats(&self.chat_manager.chats);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============ Account switching ============
|
||||||
|
async fn recreate_client(&mut self, db_path: PathBuf) -> Result<(), String> {
|
||||||
|
TdClient::recreate_client(self, db_path).await
|
||||||
|
}
|
||||||
|
|
||||||
// ============ Update handling ============
|
// ============ Update handling ============
|
||||||
fn handle_update(&mut self, update: Update) {
|
fn handle_update(&mut self, update: Update) {
|
||||||
// Delegate to the real implementation
|
// Delegate to the real implementation
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use crate::types::MessageId;
|
|||||||
use tdlib_rs::enums::{MessageContent, MessageSender};
|
use tdlib_rs::enums::{MessageContent, MessageSender};
|
||||||
use tdlib_rs::types::Message as TdMessage;
|
use tdlib_rs::types::Message as TdMessage;
|
||||||
|
|
||||||
use super::types::{ForwardInfo, ReactionInfo, ReplyInfo};
|
use super::types::{ForwardInfo, MediaInfo, PhotoDownloadState, PhotoInfo, ReactionInfo, ReplyInfo, VoiceDownloadState, VoiceInfo};
|
||||||
|
|
||||||
/// Извлекает текст контента из TDLib Message
|
/// Извлекает текст контента из TDLib Message
|
||||||
///
|
///
|
||||||
@@ -19,9 +19,9 @@ pub fn extract_content_text(msg: &TdMessage) -> String {
|
|||||||
MessageContent::MessagePhoto(p) => {
|
MessageContent::MessagePhoto(p) => {
|
||||||
let caption_text = p.caption.text.clone();
|
let caption_text = p.caption.text.clone();
|
||||||
if caption_text.is_empty() {
|
if caption_text.is_empty() {
|
||||||
"[Фото]".to_string()
|
"📷 [Фото]".to_string()
|
||||||
} else {
|
} else {
|
||||||
caption_text
|
format!("📷 {}", caption_text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
MessageContent::MessageVideo(v) => {
|
MessageContent::MessageVideo(v) => {
|
||||||
@@ -52,11 +52,12 @@ pub fn extract_content_text(msg: &TdMessage) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
MessageContent::MessageVoiceNote(v) => {
|
MessageContent::MessageVoiceNote(v) => {
|
||||||
|
let duration = v.voice_note.duration;
|
||||||
let caption_text = v.caption.text.clone();
|
let caption_text = v.caption.text.clone();
|
||||||
if caption_text.is_empty() {
|
if caption_text.is_empty() {
|
||||||
"[Голосовое]".to_string()
|
format!("🎤 [Голосовое {:.0}s]", duration)
|
||||||
} else {
|
} else {
|
||||||
caption_text
|
format!("🎤 {} ({:.0}s)", caption_text, duration)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
MessageContent::MessageAudio(a) => {
|
MessageContent::MessageAudio(a) => {
|
||||||
@@ -132,6 +133,62 @@ pub fn extract_reply_info(msg: &TdMessage) -> Option<ReplyInfo> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Извлекает информацию о медиа-контенте из TDLib Message
|
||||||
|
///
|
||||||
|
/// Для MessagePhoto: получает лучший размер фото, извлекает file_id, width, height.
|
||||||
|
/// Возвращает None для не-медийных типов сообщений.
|
||||||
|
pub fn extract_media_info(msg: &TdMessage) -> Option<MediaInfo> {
|
||||||
|
match &msg.content {
|
||||||
|
MessageContent::MessagePhoto(p) => {
|
||||||
|
// Берём лучший (последний = самый большой) размер фото
|
||||||
|
let best_size = p.photo.sizes.last()?;
|
||||||
|
let file_id = best_size.photo.id;
|
||||||
|
let width = best_size.width;
|
||||||
|
let height = best_size.height;
|
||||||
|
|
||||||
|
// Проверяем, скачан ли файл
|
||||||
|
let download_state = if !best_size.photo.local.path.is_empty()
|
||||||
|
&& best_size.photo.local.is_downloading_completed
|
||||||
|
{
|
||||||
|
PhotoDownloadState::Downloaded(best_size.photo.local.path.clone())
|
||||||
|
} else {
|
||||||
|
PhotoDownloadState::NotDownloaded
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(MediaInfo::Photo(PhotoInfo {
|
||||||
|
file_id,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
download_state,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
MessageContent::MessageVoiceNote(v) => {
|
||||||
|
let file_id = v.voice_note.voice.id;
|
||||||
|
let duration = v.voice_note.duration;
|
||||||
|
let mime_type = v.voice_note.mime_type.clone();
|
||||||
|
let waveform = v.voice_note.waveform.clone();
|
||||||
|
|
||||||
|
// Проверяем, скачан ли файл
|
||||||
|
let download_state = if !v.voice_note.voice.local.path.is_empty()
|
||||||
|
&& v.voice_note.voice.local.is_downloading_completed
|
||||||
|
{
|
||||||
|
VoiceDownloadState::Downloaded(v.voice_note.voice.local.path.clone())
|
||||||
|
} else {
|
||||||
|
VoiceDownloadState::NotDownloaded
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(MediaInfo::Voice(VoiceInfo {
|
||||||
|
file_id,
|
||||||
|
duration,
|
||||||
|
mime_type,
|
||||||
|
waveform,
|
||||||
|
download_state,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Извлекает реакции из TDLib Message
|
/// Извлекает реакции из TDLib Message
|
||||||
pub fn extract_reactions(msg: &TdMessage) -> Vec<ReactionInfo> {
|
pub fn extract_reactions(msg: &TdMessage) -> Vec<ReactionInfo> {
|
||||||
msg.interaction_info
|
msg.interaction_info
|
||||||
|
|||||||
@@ -76,7 +76,8 @@ pub fn convert_message(
|
|||||||
.text(content)
|
.text(content)
|
||||||
.entities(entities)
|
.entities(entities)
|
||||||
.date(message.date)
|
.date(message.date)
|
||||||
.edit_date(message.edit_date);
|
.edit_date(message.edit_date)
|
||||||
|
.media_album_id(message.media_album_id);
|
||||||
|
|
||||||
// Применяем флаги
|
// Применяем флаги
|
||||||
if message.is_outgoing {
|
if message.is_outgoing {
|
||||||
|
|||||||
142
src/tdlib/messages/convert.rs
Normal file
142
src/tdlib/messages/convert.rs
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
//! TDLib message conversion: JSON → MessageInfo, reply info fetching.
|
||||||
|
|
||||||
|
use crate::types::{ChatId, MessageId};
|
||||||
|
use tdlib_rs::functions;
|
||||||
|
use tdlib_rs::types::Message as TdMessage;
|
||||||
|
|
||||||
|
use crate::tdlib::types::{MessageBuilder, MessageInfo};
|
||||||
|
|
||||||
|
use super::MessageManager;
|
||||||
|
|
||||||
|
impl MessageManager {
|
||||||
|
/// Конвертировать TdMessage в MessageInfo
|
||||||
|
pub(crate) async fn convert_message(&self, msg: &TdMessage) -> Option<MessageInfo> {
|
||||||
|
use crate::tdlib::message_conversion::{
|
||||||
|
extract_content_text, extract_entities, extract_forward_info,
|
||||||
|
extract_media_info, extract_reactions, extract_reply_info, extract_sender_name,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Извлекаем все части сообщения используя вспомогательные функции
|
||||||
|
let content_text = extract_content_text(msg);
|
||||||
|
let entities = extract_entities(msg);
|
||||||
|
let sender_name = extract_sender_name(msg, self.client_id).await;
|
||||||
|
let forward_from = extract_forward_info(msg);
|
||||||
|
let reply_to = extract_reply_info(msg);
|
||||||
|
let reactions = extract_reactions(msg);
|
||||||
|
let media = extract_media_info(msg);
|
||||||
|
|
||||||
|
let mut builder = MessageBuilder::new(MessageId::new(msg.id))
|
||||||
|
.sender_name(sender_name)
|
||||||
|
.text(content_text)
|
||||||
|
.entities(entities)
|
||||||
|
.date(msg.date)
|
||||||
|
.edit_date(msg.edit_date)
|
||||||
|
.media_album_id(msg.media_album_id);
|
||||||
|
|
||||||
|
if msg.is_outgoing {
|
||||||
|
builder = builder.outgoing();
|
||||||
|
} else {
|
||||||
|
builder = builder.incoming();
|
||||||
|
}
|
||||||
|
|
||||||
|
if !msg.contains_unread_mention {
|
||||||
|
builder = builder.read();
|
||||||
|
} else {
|
||||||
|
builder = builder.unread();
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg.can_be_edited {
|
||||||
|
builder = builder.editable();
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg.can_be_deleted_only_for_self {
|
||||||
|
builder = builder.deletable_for_self();
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg.can_be_deleted_for_all_users {
|
||||||
|
builder = builder.deletable_for_all();
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(reply) = reply_to {
|
||||||
|
builder = builder.reply_to(reply);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(forward) = forward_from {
|
||||||
|
builder = builder.forward_from(forward);
|
||||||
|
}
|
||||||
|
|
||||||
|
builder = builder.reactions(reactions);
|
||||||
|
|
||||||
|
if let Some(media) = media {
|
||||||
|
builder = builder.media(media);
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(builder.build())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Загружает недостающую информацию об исходных сообщениях для ответов.
|
||||||
|
///
|
||||||
|
/// Ищет все reply-сообщения с `sender_name == "Unknown"` и загружает
|
||||||
|
/// полную информацию (имя отправителя, текст) из TDLib.
|
||||||
|
///
|
||||||
|
/// # Note
|
||||||
|
///
|
||||||
|
/// Вызывайте после загрузки истории чата для заполнения информации о цитируемых сообщениях.
|
||||||
|
pub async fn fetch_missing_reply_info(&mut self) {
|
||||||
|
// Early return if no chat selected
|
||||||
|
let Some(chat_id) = self.current_chat_id else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Collect message IDs with missing reply info using filter_map
|
||||||
|
let to_fetch: Vec<MessageId> = self
|
||||||
|
.current_chat_messages
|
||||||
|
.iter()
|
||||||
|
.filter_map(|msg| {
|
||||||
|
msg.interactions
|
||||||
|
.reply_to
|
||||||
|
.as_ref()
|
||||||
|
.filter(|reply| reply.sender_name == "Unknown")
|
||||||
|
.map(|reply| reply.message_id)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Fetch and update each missing message
|
||||||
|
for message_id in to_fetch {
|
||||||
|
self.fetch_and_update_reply(chat_id, message_id).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Загружает одно сообщение и обновляет reply информацию.
|
||||||
|
async fn fetch_and_update_reply(&mut self, chat_id: ChatId, message_id: MessageId) {
|
||||||
|
// Try to fetch the original message
|
||||||
|
let Ok(original_msg_enum) =
|
||||||
|
functions::get_message(chat_id.as_i64(), message_id.as_i64(), self.client_id).await
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let tdlib_rs::enums::Message::Message(original_msg) = original_msg_enum;
|
||||||
|
let Some(orig_info) = self.convert_message(&original_msg).await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extract text preview (first 50 chars)
|
||||||
|
let text_preview: String = orig_info
|
||||||
|
.content
|
||||||
|
.text
|
||||||
|
.chars()
|
||||||
|
.take(50)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Update reply info in all messages that reference this message
|
||||||
|
self.current_chat_messages
|
||||||
|
.iter_mut()
|
||||||
|
.filter_map(|msg| msg.interactions.reply_to.as_mut())
|
||||||
|
.filter(|reply| reply.message_id == message_id)
|
||||||
|
.for_each(|reply| {
|
||||||
|
reply.sender_name = orig_info.metadata.sender_name.clone();
|
||||||
|
reply.text = text_preview.clone();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
101
src/tdlib/messages/mod.rs
Normal file
101
src/tdlib/messages/mod.rs
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
//! Message management: storage, conversion, and TDLib API operations.
|
||||||
|
|
||||||
|
mod convert;
|
||||||
|
mod operations;
|
||||||
|
|
||||||
|
use crate::constants::MAX_MESSAGES_IN_CHAT;
|
||||||
|
use crate::types::{ChatId, MessageId};
|
||||||
|
|
||||||
|
use super::types::MessageInfo;
|
||||||
|
|
||||||
|
/// Менеджер сообщений TDLib.
|
||||||
|
///
|
||||||
|
/// Управляет загрузкой, отправкой, редактированием и удалением сообщений.
|
||||||
|
/// Кеширует сообщения текущего открытого чата и закрепленные сообщения.
|
||||||
|
///
|
||||||
|
/// # Основные возможности
|
||||||
|
///
|
||||||
|
/// - Загрузка истории сообщений чата
|
||||||
|
/// - Отправка текстовых сообщений с поддержкой Markdown
|
||||||
|
/// - Редактирование и удаление сообщений
|
||||||
|
/// - Пересылка сообщений между чатами
|
||||||
|
/// - Поиск сообщений по тексту
|
||||||
|
/// - Управление закрепленными сообщениями
|
||||||
|
/// - Управление черновиками
|
||||||
|
/// - Автоматическая отметка сообщений как прочитанных
|
||||||
|
///
|
||||||
|
/// # Examples
|
||||||
|
///
|
||||||
|
/// ```ignore
|
||||||
|
/// let mut msg_manager = MessageManager::new(client_id);
|
||||||
|
///
|
||||||
|
/// // Загрузить историю чата
|
||||||
|
/// let messages = msg_manager.get_chat_history(chat_id, 50).await?;
|
||||||
|
///
|
||||||
|
/// // Отправить сообщение
|
||||||
|
/// let msg = msg_manager.send_message(
|
||||||
|
/// chat_id,
|
||||||
|
/// "Hello, **world**!".to_string(),
|
||||||
|
/// None,
|
||||||
|
/// None
|
||||||
|
/// ).await?;
|
||||||
|
/// ```
|
||||||
|
pub struct MessageManager {
|
||||||
|
/// Список сообщений текущего открытого чата (до MAX_MESSAGES_IN_CHAT).
|
||||||
|
pub current_chat_messages: Vec<MessageInfo>,
|
||||||
|
|
||||||
|
/// ID текущего открытого чата.
|
||||||
|
pub current_chat_id: Option<ChatId>,
|
||||||
|
|
||||||
|
/// Текущее закрепленное сообщение открытого чата.
|
||||||
|
pub current_pinned_message: Option<MessageInfo>,
|
||||||
|
|
||||||
|
/// Очередь сообщений для отметки как прочитанных: (chat_id, message_ids).
|
||||||
|
pub pending_view_messages: Vec<(ChatId, Vec<MessageId>)>,
|
||||||
|
|
||||||
|
/// ID клиента TDLib для API вызовов.
|
||||||
|
pub(crate) client_id: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MessageManager {
|
||||||
|
/// Создает новый менеджер сообщений.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `client_id` - ID клиента TDLib для API вызовов
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
///
|
||||||
|
/// Новый экземпляр `MessageManager` с пустым списком сообщений.
|
||||||
|
pub fn new(client_id: i32) -> Self {
|
||||||
|
Self {
|
||||||
|
current_chat_messages: Vec::new(),
|
||||||
|
current_chat_id: None,
|
||||||
|
current_pinned_message: None,
|
||||||
|
pending_view_messages: Vec::new(),
|
||||||
|
client_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Добавляет сообщение в список текущего чата.
|
||||||
|
///
|
||||||
|
/// Автоматически ограничивает размер списка до [`MAX_MESSAGES_IN_CHAT`],
|
||||||
|
/// удаляя старые сообщения при превышении лимита.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `msg` - Сообщение для добавления
|
||||||
|
///
|
||||||
|
/// # Note
|
||||||
|
///
|
||||||
|
/// Сообщение добавляется в конец списка. При превышении лимита
|
||||||
|
/// удаляются самые старые сообщения из начала списка.
|
||||||
|
pub fn push_message(&mut self, msg: MessageInfo) {
|
||||||
|
self.current_chat_messages.push(msg); // Добавляем в конец
|
||||||
|
|
||||||
|
// Ограничиваем размер списка (удаляем старые с начала)
|
||||||
|
if self.current_chat_messages.len() > MAX_MESSAGES_IN_CHAT {
|
||||||
|
self.current_chat_messages.drain(0..(self.current_chat_messages.len() - MAX_MESSAGES_IN_CHAT));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,103 +1,17 @@
|
|||||||
use crate::constants::{MAX_MESSAGES_IN_CHAT, TDLIB_MESSAGE_LIMIT};
|
//! TDLib message API operations: history, send, edit, delete, forward, search.
|
||||||
|
|
||||||
|
use crate::constants::TDLIB_MESSAGE_LIMIT;
|
||||||
use crate::types::{ChatId, MessageId};
|
use crate::types::{ChatId, MessageId};
|
||||||
use tdlib_rs::enums::{InputMessageContent, InputMessageReplyTo, SearchMessagesFilter, TextParseMode};
|
use tdlib_rs::enums::{InputMessageContent, InputMessageReplyTo, SearchMessagesFilter, TextParseMode};
|
||||||
use tdlib_rs::functions;
|
use tdlib_rs::functions;
|
||||||
use tdlib_rs::types::{FormattedText, InputMessageReplyToMessage, InputMessageText, Message as TdMessage, TextParseModeMarkdown};
|
use tdlib_rs::types::{FormattedText, InputMessageReplyToMessage, InputMessageText, TextParseModeMarkdown};
|
||||||
use tokio::time::{sleep, Duration};
|
use tokio::time::{sleep, Duration};
|
||||||
|
|
||||||
use super::types::{MessageBuilder, MessageInfo, ReplyInfo};
|
use crate::tdlib::types::{MessageInfo, ReplyInfo};
|
||||||
|
|
||||||
/// Менеджер сообщений TDLib.
|
use super::MessageManager;
|
||||||
///
|
|
||||||
/// Управляет загрузкой, отправкой, редактированием и удалением сообщений.
|
|
||||||
/// Кеширует сообщения текущего открытого чата и закрепленные сообщения.
|
|
||||||
///
|
|
||||||
/// # Основные возможности
|
|
||||||
///
|
|
||||||
/// - Загрузка истории сообщений чата
|
|
||||||
/// - Отправка текстовых сообщений с поддержкой Markdown
|
|
||||||
/// - Редактирование и удаление сообщений
|
|
||||||
/// - Пересылка сообщений между чатами
|
|
||||||
/// - Поиск сообщений по тексту
|
|
||||||
/// - Управление закрепленными сообщениями
|
|
||||||
/// - Управление черновиками
|
|
||||||
/// - Автоматическая отметка сообщений как прочитанных
|
|
||||||
///
|
|
||||||
/// # Examples
|
|
||||||
///
|
|
||||||
/// ```ignore
|
|
||||||
/// let mut msg_manager = MessageManager::new(client_id);
|
|
||||||
///
|
|
||||||
/// // Загрузить историю чата
|
|
||||||
/// let messages = msg_manager.get_chat_history(chat_id, 50).await?;
|
|
||||||
///
|
|
||||||
/// // Отправить сообщение
|
|
||||||
/// let msg = msg_manager.send_message(
|
|
||||||
/// chat_id,
|
|
||||||
/// "Hello, **world**!".to_string(),
|
|
||||||
/// None,
|
|
||||||
/// None
|
|
||||||
/// ).await?;
|
|
||||||
/// ```
|
|
||||||
pub struct MessageManager {
|
|
||||||
/// Список сообщений текущего открытого чата (до MAX_MESSAGES_IN_CHAT).
|
|
||||||
pub current_chat_messages: Vec<MessageInfo>,
|
|
||||||
|
|
||||||
/// ID текущего открытого чата.
|
|
||||||
pub current_chat_id: Option<ChatId>,
|
|
||||||
|
|
||||||
/// Текущее закрепленное сообщение открытого чата.
|
|
||||||
pub current_pinned_message: Option<MessageInfo>,
|
|
||||||
|
|
||||||
/// Очередь сообщений для отметки как прочитанных: (chat_id, message_ids).
|
|
||||||
pub pending_view_messages: Vec<(ChatId, Vec<MessageId>)>,
|
|
||||||
|
|
||||||
/// ID клиента TDLib для API вызовов.
|
|
||||||
client_id: i32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl MessageManager {
|
impl MessageManager {
|
||||||
/// Создает новый менеджер сообщений.
|
|
||||||
///
|
|
||||||
/// # Arguments
|
|
||||||
///
|
|
||||||
/// * `client_id` - ID клиента TDLib для API вызовов
|
|
||||||
///
|
|
||||||
/// # Returns
|
|
||||||
///
|
|
||||||
/// Новый экземпляр `MessageManager` с пустым списком сообщений.
|
|
||||||
pub fn new(client_id: i32) -> Self {
|
|
||||||
Self {
|
|
||||||
current_chat_messages: Vec::new(),
|
|
||||||
current_chat_id: None,
|
|
||||||
current_pinned_message: None,
|
|
||||||
pending_view_messages: Vec::new(),
|
|
||||||
client_id,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Добавляет сообщение в список текущего чата.
|
|
||||||
///
|
|
||||||
/// Автоматически ограничивает размер списка до [`MAX_MESSAGES_IN_CHAT`],
|
|
||||||
/// удаляя старые сообщения при превышении лимита.
|
|
||||||
///
|
|
||||||
/// # Arguments
|
|
||||||
///
|
|
||||||
/// * `msg` - Сообщение для добавления
|
|
||||||
///
|
|
||||||
/// # Note
|
|
||||||
///
|
|
||||||
/// Сообщение добавляется в конец списка. При превышении лимита
|
|
||||||
/// удаляются самые старые сообщения из начала списка.
|
|
||||||
pub fn push_message(&mut self, msg: MessageInfo) {
|
|
||||||
self.current_chat_messages.push(msg); // Добавляем в конец
|
|
||||||
|
|
||||||
// Ограничиваем размер списка (удаляем старые с начала)
|
|
||||||
if self.current_chat_messages.len() > MAX_MESSAGES_IN_CHAT {
|
|
||||||
self.current_chat_messages.drain(0..(self.current_chat_messages.len() - MAX_MESSAGES_IN_CHAT));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Загружает историю сообщений чата с динамической подгрузкой.
|
/// Загружает историю сообщений чата с динамической подгрузкой.
|
||||||
///
|
///
|
||||||
/// Загружает сообщения чанками, ожидая пока TDLib синхронизирует их с сервера.
|
/// Загружает сообщения чанками, ожидая пока TDLib синхронизирует их с сервера.
|
||||||
@@ -364,13 +278,6 @@ impl MessageManager {
|
|||||||
// Нужно использовать getChatPinnedMessage или альтернативный способ.
|
// Нужно использовать getChatPinnedMessage или альтернативный способ.
|
||||||
// Временно отключено.
|
// Временно отключено.
|
||||||
self.current_pinned_message = None;
|
self.current_pinned_message = None;
|
||||||
|
|
||||||
// match functions::get_chat(chat_id, self.client_id).await {
|
|
||||||
// Ok(tdlib_rs::enums::Chat::Chat(chat)) => {
|
|
||||||
// // chat.pinned_message_id больше не существует
|
|
||||||
// }
|
|
||||||
// _ => {}
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Выполняет поиск сообщений по тексту в указанном чате.
|
/// Выполняет поиск сообщений по тексту в указанном чате.
|
||||||
@@ -708,129 +615,4 @@ impl MessageManager {
|
|||||||
let _ = functions::view_messages(chat_id.as_i64(), ids, None, true, self.client_id).await;
|
let _ = functions::view_messages(chat_id.as_i64(), ids, None, true, self.client_id).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Конвертировать TdMessage в MessageInfo
|
|
||||||
async fn convert_message(&self, msg: &TdMessage) -> Option<MessageInfo> {
|
|
||||||
use crate::tdlib::message_conversion::{
|
|
||||||
extract_content_text, extract_entities, extract_forward_info,
|
|
||||||
extract_reactions, extract_reply_info, extract_sender_name,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Извлекаем все части сообщения используя вспомогательные функции
|
|
||||||
let content_text = extract_content_text(msg);
|
|
||||||
let entities = extract_entities(msg);
|
|
||||||
let sender_name = extract_sender_name(msg, self.client_id).await;
|
|
||||||
let forward_from = extract_forward_info(msg);
|
|
||||||
let reply_to = extract_reply_info(msg);
|
|
||||||
let reactions = extract_reactions(msg);
|
|
||||||
|
|
||||||
let mut builder = MessageBuilder::new(MessageId::new(msg.id))
|
|
||||||
.sender_name(sender_name)
|
|
||||||
.text(content_text)
|
|
||||||
.entities(entities)
|
|
||||||
.date(msg.date)
|
|
||||||
.edit_date(msg.edit_date);
|
|
||||||
|
|
||||||
if msg.is_outgoing {
|
|
||||||
builder = builder.outgoing();
|
|
||||||
} else {
|
|
||||||
builder = builder.incoming();
|
|
||||||
}
|
|
||||||
|
|
||||||
if !msg.contains_unread_mention {
|
|
||||||
builder = builder.read();
|
|
||||||
} else {
|
|
||||||
builder = builder.unread();
|
|
||||||
}
|
|
||||||
|
|
||||||
if msg.can_be_edited {
|
|
||||||
builder = builder.editable();
|
|
||||||
}
|
|
||||||
|
|
||||||
if msg.can_be_deleted_only_for_self {
|
|
||||||
builder = builder.deletable_for_self();
|
|
||||||
}
|
|
||||||
|
|
||||||
if msg.can_be_deleted_for_all_users {
|
|
||||||
builder = builder.deletable_for_all();
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(reply) = reply_to {
|
|
||||||
builder = builder.reply_to(reply);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(forward) = forward_from {
|
|
||||||
builder = builder.forward_from(forward);
|
|
||||||
}
|
|
||||||
|
|
||||||
builder = builder.reactions(reactions);
|
|
||||||
|
|
||||||
Some(builder.build())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Загружает недостающую информацию об исходных сообщениях для ответов.
|
|
||||||
///
|
|
||||||
/// Ищет все reply-сообщения с `sender_name == "Unknown"` и загружает
|
|
||||||
/// полную информацию (имя отправителя, текст) из TDLib.
|
|
||||||
///
|
|
||||||
/// # Note
|
|
||||||
///
|
|
||||||
/// Вызывайте после загрузки истории чата для заполнения информации о цитируемых сообщениях.
|
|
||||||
pub async fn fetch_missing_reply_info(&mut self) {
|
|
||||||
// Early return if no chat selected
|
|
||||||
let Some(chat_id) = self.current_chat_id else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Collect message IDs with missing reply info using filter_map
|
|
||||||
let to_fetch: Vec<MessageId> = self
|
|
||||||
.current_chat_messages
|
|
||||||
.iter()
|
|
||||||
.filter_map(|msg| {
|
|
||||||
msg.interactions
|
|
||||||
.reply_to
|
|
||||||
.as_ref()
|
|
||||||
.filter(|reply| reply.sender_name == "Unknown")
|
|
||||||
.map(|reply| reply.message_id)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Fetch and update each missing message
|
|
||||||
for message_id in to_fetch {
|
|
||||||
self.fetch_and_update_reply(chat_id, message_id).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Загружает одно сообщение и обновляет reply информацию.
|
|
||||||
async fn fetch_and_update_reply(&mut self, chat_id: ChatId, message_id: MessageId) {
|
|
||||||
// Try to fetch the original message
|
|
||||||
let Ok(original_msg_enum) =
|
|
||||||
functions::get_message(chat_id.as_i64(), message_id.as_i64(), self.client_id).await
|
|
||||||
else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let tdlib_rs::enums::Message::Message(original_msg) = original_msg_enum;
|
|
||||||
let Some(orig_info) = self.convert_message(&original_msg).await else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Extract text preview (first 50 chars)
|
|
||||||
let text_preview: String = orig_info
|
|
||||||
.content
|
|
||||||
.text
|
|
||||||
.chars()
|
|
||||||
.take(50)
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Update reply info in all messages that reference this message
|
|
||||||
self.current_chat_messages
|
|
||||||
.iter_mut()
|
|
||||||
.filter_map(|msg| msg.interactions.reply_to.as_mut())
|
|
||||||
.filter(|reply| reply.message_id == message_id)
|
|
||||||
.for_each(|reply| {
|
|
||||||
reply.sender_name = orig_info.metadata.sender_name.clone();
|
|
||||||
reply.text = text_preview.clone();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -18,8 +18,13 @@ pub use auth::AuthState;
|
|||||||
pub use client::TdClient;
|
pub use client::TdClient;
|
||||||
pub use r#trait::TdClientTrait;
|
pub use r#trait::TdClientTrait;
|
||||||
pub use types::{
|
pub use types::{
|
||||||
ChatInfo, FolderInfo, MessageBuilder, MessageInfo, NetworkState, ProfileInfo, ReplyInfo, UserOnlineStatus,
|
ChatInfo, FolderInfo, MediaInfo, MessageBuilder, MessageInfo, NetworkState, PhotoDownloadState,
|
||||||
|
PhotoInfo, PlaybackState, PlaybackStatus, ProfileInfo, ReplyInfo, UserOnlineStatus,
|
||||||
|
VoiceDownloadState, VoiceInfo,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
pub use types::ImageModalState;
|
||||||
pub use users::UserCache;
|
pub use users::UserCache;
|
||||||
|
|
||||||
// Re-export ChatAction для удобства
|
// Re-export ChatAction для удобства
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
use crate::tdlib::{AuthState, FolderInfo, MessageInfo, ProfileInfo, UserCache, UserOnlineStatus};
|
use crate::tdlib::{AuthState, FolderInfo, MessageInfo, ProfileInfo, UserCache, UserOnlineStatus};
|
||||||
use crate::types::{ChatId, MessageId, UserId};
|
use crate::types::{ChatId, MessageId, UserId};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use std::path::PathBuf;
|
||||||
use tdlib_rs::enums::{ChatAction, Update};
|
use tdlib_rs::enums::{ChatAction, Update};
|
||||||
|
|
||||||
use super::ChatInfo;
|
use super::ChatInfo;
|
||||||
@@ -90,6 +91,10 @@ pub trait TdClientTrait: Send {
|
|||||||
reaction: String,
|
reaction: String,
|
||||||
) -> Result<(), String>;
|
) -> Result<(), String>;
|
||||||
|
|
||||||
|
// ============ File methods ============
|
||||||
|
async fn download_file(&self, file_id: i32) -> Result<String, String>;
|
||||||
|
async fn download_voice_note(&self, file_id: i32) -> Result<String, String>;
|
||||||
|
|
||||||
// ============ Getters (immutable) ============
|
// ============ Getters (immutable) ============
|
||||||
fn client_id(&self) -> i32;
|
fn client_id(&self) -> i32;
|
||||||
async fn get_me(&self) -> Result<i64, String>;
|
async fn get_me(&self) -> Result<i64, String>;
|
||||||
@@ -123,6 +128,13 @@ pub trait TdClientTrait: Send {
|
|||||||
// ============ Notification methods ============
|
// ============ Notification methods ============
|
||||||
fn sync_notification_muted_chats(&mut self);
|
fn sync_notification_muted_chats(&mut self);
|
||||||
|
|
||||||
|
// ============ Account switching ============
|
||||||
|
/// Recreates the client with a new database path (for account switching).
|
||||||
|
///
|
||||||
|
/// For real TdClient: closes old client, creates new one, inits TDLib parameters.
|
||||||
|
/// For FakeTdClient: no-op.
|
||||||
|
async fn recreate_client(&mut self, db_path: PathBuf) -> Result<(), String>;
|
||||||
|
|
||||||
// ============ Update handling ============
|
// ============ Update handling ============
|
||||||
fn handle_update(&mut self, update: Update);
|
fn handle_update(&mut self, update: Update);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,51 @@ pub struct ReactionInfo {
|
|||||||
pub is_chosen: bool,
|
pub is_chosen: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Информация о медиа-контенте сообщения
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum MediaInfo {
|
||||||
|
Photo(PhotoInfo),
|
||||||
|
Voice(VoiceInfo),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Информация о фотографии в сообщении
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PhotoInfo {
|
||||||
|
pub file_id: i32,
|
||||||
|
pub width: i32,
|
||||||
|
pub height: i32,
|
||||||
|
pub download_state: PhotoDownloadState,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Состояние загрузки фотографии
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum PhotoDownloadState {
|
||||||
|
NotDownloaded,
|
||||||
|
Downloading,
|
||||||
|
Downloaded(String),
|
||||||
|
Error(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Информация о голосовом сообщении
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct VoiceInfo {
|
||||||
|
pub file_id: i32,
|
||||||
|
pub duration: i32, // seconds
|
||||||
|
pub mime_type: String,
|
||||||
|
/// Waveform данные для визуализации (base64-encoded строка амплитуд)
|
||||||
|
pub waveform: String,
|
||||||
|
pub download_state: VoiceDownloadState,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Состояние загрузки голосового сообщения
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum VoiceDownloadState {
|
||||||
|
NotDownloaded,
|
||||||
|
Downloading,
|
||||||
|
Downloaded(String), // path to cached OGG file
|
||||||
|
Error(String),
|
||||||
|
}
|
||||||
|
|
||||||
/// Метаданные сообщения (ID, отправитель, время)
|
/// Метаданные сообщения (ID, отправитель, время)
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct MessageMetadata {
|
pub struct MessageMetadata {
|
||||||
@@ -62,14 +107,18 @@ pub struct MessageMetadata {
|
|||||||
pub date: i32,
|
pub date: i32,
|
||||||
/// Дата редактирования (0 если не редактировалось)
|
/// Дата редактирования (0 если не редактировалось)
|
||||||
pub edit_date: i32,
|
pub edit_date: i32,
|
||||||
|
/// ID медиа-альбома (0 если не часть альбома)
|
||||||
|
pub media_album_id: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Контент сообщения (текст и форматирование)
|
/// Контент сообщения (текст и форматирование)
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct MessageContent {
|
pub struct MessageContent {
|
||||||
pub text: String,
|
pub text: String,
|
||||||
/// Сущности форматирования (bold, italic, code и т.д.)
|
/// Сущности форматирования (bold, italic, code и т.д.)
|
||||||
pub entities: Vec<TextEntity>,
|
pub entities: Vec<TextEntity>,
|
||||||
|
/// Медиа-контент (фото, видео и т.д.)
|
||||||
|
pub media: Option<MediaInfo>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Состояние и права доступа к сообщению
|
/// Состояние и права доступа к сообщению
|
||||||
@@ -128,10 +177,12 @@ impl MessageInfo {
|
|||||||
sender_name,
|
sender_name,
|
||||||
date,
|
date,
|
||||||
edit_date,
|
edit_date,
|
||||||
|
media_album_id: 0,
|
||||||
},
|
},
|
||||||
content: MessageContent {
|
content: MessageContent {
|
||||||
text: content,
|
text: content,
|
||||||
entities,
|
entities,
|
||||||
|
media: None,
|
||||||
},
|
},
|
||||||
state: MessageState {
|
state: MessageState {
|
||||||
is_outgoing,
|
is_outgoing,
|
||||||
@@ -165,6 +216,10 @@ impl MessageInfo {
|
|||||||
self.metadata.edit_date > 0
|
self.metadata.edit_date > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn media_album_id(&self) -> i64 {
|
||||||
|
self.metadata.media_album_id
|
||||||
|
}
|
||||||
|
|
||||||
pub fn text(&self) -> &str {
|
pub fn text(&self) -> &str {
|
||||||
&self.content.text
|
&self.content.text
|
||||||
}
|
}
|
||||||
@@ -203,6 +258,48 @@ impl MessageInfo {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Проверяет, содержит ли сообщение фото
|
||||||
|
pub fn has_photo(&self) -> bool {
|
||||||
|
matches!(self.content.media, Some(MediaInfo::Photo(_)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Возвращает ссылку на PhotoInfo (если есть)
|
||||||
|
pub fn photo_info(&self) -> Option<&PhotoInfo> {
|
||||||
|
match &self.content.media {
|
||||||
|
Some(MediaInfo::Photo(info)) => Some(info),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Возвращает мутабельную ссылку на PhotoInfo (если есть)
|
||||||
|
pub fn photo_info_mut(&mut self) -> Option<&mut PhotoInfo> {
|
||||||
|
match &mut self.content.media {
|
||||||
|
Some(MediaInfo::Photo(info)) => Some(info),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Проверяет, содержит ли сообщение голосовое
|
||||||
|
pub fn has_voice(&self) -> bool {
|
||||||
|
matches!(self.content.media, Some(MediaInfo::Voice(_)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Возвращает ссылку на VoiceInfo (если есть)
|
||||||
|
pub fn voice_info(&self) -> Option<&VoiceInfo> {
|
||||||
|
match &self.content.media {
|
||||||
|
Some(MediaInfo::Voice(info)) => Some(info),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Возвращает мутабельную ссылку на VoiceInfo (если есть)
|
||||||
|
pub fn voice_info_mut(&mut self) -> Option<&mut VoiceInfo> {
|
||||||
|
match &mut self.content.media {
|
||||||
|
Some(MediaInfo::Voice(info)) => Some(info),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn reply_to(&self) -> Option<&ReplyInfo> {
|
pub fn reply_to(&self) -> Option<&ReplyInfo> {
|
||||||
self.interactions.reply_to.as_ref()
|
self.interactions.reply_to.as_ref()
|
||||||
}
|
}
|
||||||
@@ -246,6 +343,8 @@ pub struct MessageBuilder {
|
|||||||
reply_to: Option<ReplyInfo>,
|
reply_to: Option<ReplyInfo>,
|
||||||
forward_from: Option<ForwardInfo>,
|
forward_from: Option<ForwardInfo>,
|
||||||
reactions: Vec<ReactionInfo>,
|
reactions: Vec<ReactionInfo>,
|
||||||
|
media: Option<MediaInfo>,
|
||||||
|
media_album_id: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MessageBuilder {
|
impl MessageBuilder {
|
||||||
@@ -266,6 +365,8 @@ impl MessageBuilder {
|
|||||||
reply_to: None,
|
reply_to: None,
|
||||||
forward_from: None,
|
forward_from: None,
|
||||||
reactions: Vec::new(),
|
reactions: Vec::new(),
|
||||||
|
media: None,
|
||||||
|
media_album_id: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -363,9 +464,21 @@ impl MessageBuilder {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Установить медиа-контент
|
||||||
|
pub fn media(mut self, media: MediaInfo) -> Self {
|
||||||
|
self.media = Some(media);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Установить ID медиа-альбома
|
||||||
|
pub fn media_album_id(mut self, id: i64) -> Self {
|
||||||
|
self.media_album_id = id;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Построить MessageInfo из данных builder'а
|
/// Построить MessageInfo из данных builder'а
|
||||||
pub fn build(self) -> MessageInfo {
|
pub fn build(self) -> MessageInfo {
|
||||||
MessageInfo::new(
|
let mut msg = MessageInfo::new(
|
||||||
self.id,
|
self.id,
|
||||||
self.sender_name,
|
self.sender_name,
|
||||||
self.is_outgoing,
|
self.is_outgoing,
|
||||||
@@ -380,7 +493,10 @@ impl MessageBuilder {
|
|||||||
self.reply_to,
|
self.reply_to,
|
||||||
self.forward_from,
|
self.forward_from,
|
||||||
self.reactions,
|
self.reactions,
|
||||||
)
|
);
|
||||||
|
msg.content.media = self.media;
|
||||||
|
msg.metadata.media_album_id = self.media_album_id;
|
||||||
|
msg
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -574,3 +690,42 @@ pub enum UserOnlineStatus {
|
|||||||
/// Оффлайн с указанием времени (unix timestamp)
|
/// Оффлайн с указанием времени (unix timestamp)
|
||||||
Offline(i32),
|
Offline(i32),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Состояние модального окна для просмотра изображения
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ImageModalState {
|
||||||
|
/// ID сообщения с фото
|
||||||
|
pub message_id: MessageId,
|
||||||
|
/// Путь к файлу изображения
|
||||||
|
pub photo_path: String,
|
||||||
|
/// Ширина оригинального изображения
|
||||||
|
pub photo_width: i32,
|
||||||
|
/// Высота оригинального изображения
|
||||||
|
pub photo_height: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Состояние воспроизведения голосового сообщения
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PlaybackState {
|
||||||
|
/// ID сообщения, которое воспроизводится
|
||||||
|
pub message_id: MessageId,
|
||||||
|
/// Статус воспроизведения
|
||||||
|
pub status: PlaybackStatus,
|
||||||
|
/// Текущая позиция (секунды)
|
||||||
|
pub position: f32,
|
||||||
|
/// Общая длительность (секунды)
|
||||||
|
pub duration: f32,
|
||||||
|
/// Громкость (0.0 - 1.0)
|
||||||
|
pub volume: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Статус воспроизведения
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum PlaybackStatus {
|
||||||
|
Playing,
|
||||||
|
Paused,
|
||||||
|
Stopped,
|
||||||
|
Loading,
|
||||||
|
Error(String),
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
/// Type-safe ID wrappers to prevent mixing up different ID types
|
//! Type-safe ID wrappers to prevent mixing up different ID types.
|
||||||
|
//!
|
||||||
|
//! Provides `ChatId` and `MessageId` newtypes for compile-time safety.
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
//! Chat list panel: search box, chat items, and user online status.
|
||||||
|
|
||||||
use crate::app::App;
|
use crate::app::App;
|
||||||
|
use crate::app::methods::{compose::ComposeMethods, search::SearchMethods};
|
||||||
use crate::tdlib::TdClientTrait;
|
use crate::tdlib::TdClientTrait;
|
||||||
use crate::tdlib::UserOnlineStatus;
|
use crate::tdlib::UserOnlineStatus;
|
||||||
use crate::ui::components;
|
use crate::ui::components;
|
||||||
@@ -68,55 +71,16 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut App<T>) {
|
|||||||
|
|
||||||
f.render_stateful_widget(chats_list, chat_chunks[1], &mut app.chat_list_state);
|
f.render_stateful_widget(chats_list, chat_chunks[1], &mut app.chat_list_state);
|
||||||
|
|
||||||
// User status - показываем статус выбранного чата
|
// User status - показываем статус выбранного или выделенного чата
|
||||||
let (status_text, status_color) = if let Some(chat_id) = app.selected_chat_id {
|
let status_chat_id = if app.selected_chat_id.is_some() {
|
||||||
match app.td_client.get_user_status_by_chat_id(chat_id) {
|
app.selected_chat_id
|
||||||
Some(UserOnlineStatus::Online) => ("● онлайн".to_string(), Color::Green),
|
|
||||||
Some(UserOnlineStatus::Recently) => ("был(а) недавно".to_string(), Color::Yellow),
|
|
||||||
Some(UserOnlineStatus::Offline(was_online)) => {
|
|
||||||
let formatted = format_was_online(*was_online);
|
|
||||||
(formatted, Color::Gray)
|
|
||||||
}
|
|
||||||
Some(UserOnlineStatus::LastWeek) => {
|
|
||||||
("был(а) на этой неделе".to_string(), Color::DarkGray)
|
|
||||||
}
|
|
||||||
Some(UserOnlineStatus::LastMonth) => {
|
|
||||||
("был(а) в этом месяце".to_string(), Color::DarkGray)
|
|
||||||
}
|
|
||||||
Some(UserOnlineStatus::LongTimeAgo) => ("был(а) давно".to_string(), Color::DarkGray),
|
|
||||||
None => ("".to_string(), Color::DarkGray), // Для групп/каналов
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Показываем статус выделенного в списке чата
|
|
||||||
let filtered = app.get_filtered_chats();
|
let filtered = app.get_filtered_chats();
|
||||||
if let Some(i) = app.chat_list_state.selected() {
|
app.chat_list_state.selected().and_then(|i| filtered.get(i).map(|c| c.id))
|
||||||
if let Some(chat) = filtered.get(i) {
|
};
|
||||||
match app.td_client.get_user_status_by_chat_id(chat.id) {
|
let (status_text, status_color) = match status_chat_id {
|
||||||
Some(UserOnlineStatus::Online) => ("● онлайн".to_string(), Color::Green),
|
Some(chat_id) => format_user_status(app.td_client.get_user_status_by_chat_id(chat_id)),
|
||||||
Some(UserOnlineStatus::Recently) => {
|
|
||||||
("был(а) недавно".to_string(), Color::Yellow)
|
|
||||||
}
|
|
||||||
Some(UserOnlineStatus::Offline(was_online)) => {
|
|
||||||
let formatted = format_was_online(*was_online);
|
|
||||||
(formatted, Color::Gray)
|
|
||||||
}
|
|
||||||
Some(UserOnlineStatus::LastWeek) => {
|
|
||||||
("был(а) на этой неделе".to_string(), Color::DarkGray)
|
|
||||||
}
|
|
||||||
Some(UserOnlineStatus::LastMonth) => {
|
|
||||||
("был(а) в этом месяце".to_string(), Color::DarkGray)
|
|
||||||
}
|
|
||||||
Some(UserOnlineStatus::LongTimeAgo) => {
|
|
||||||
("был(а) давно".to_string(), Color::DarkGray)
|
|
||||||
}
|
|
||||||
None => ("".to_string(), Color::DarkGray),
|
None => ("".to_string(), Color::DarkGray),
|
||||||
}
|
|
||||||
} else {
|
|
||||||
("".to_string(), Color::DarkGray)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
("".to_string(), Color::DarkGray)
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let status = Paragraph::new(status_text)
|
let status = Paragraph::new(status_text)
|
||||||
@@ -125,7 +89,17 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut App<T>) {
|
|||||||
f.render_widget(status, chat_chunks[2]);
|
f.render_widget(status, chat_chunks[2]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Форматирование времени "был(а) в ..."
|
/// Форматирует статус пользователя для отображения в статус-баре
|
||||||
fn format_was_online(timestamp: i32) -> String {
|
fn format_user_status(status: Option<&UserOnlineStatus>) -> (String, Color) {
|
||||||
crate::utils::format_was_online(timestamp)
|
match status {
|
||||||
|
Some(UserOnlineStatus::Online) => ("● онлайн".to_string(), Color::Green),
|
||||||
|
Some(UserOnlineStatus::Recently) => ("был(а) недавно".to_string(), Color::Yellow),
|
||||||
|
Some(UserOnlineStatus::Offline(was_online)) => {
|
||||||
|
(crate::utils::format_was_online(*was_online), Color::Gray)
|
||||||
|
}
|
||||||
|
Some(UserOnlineStatus::LastWeek) => ("был(а) на этой неделе".to_string(), Color::DarkGray),
|
||||||
|
Some(UserOnlineStatus::LastMonth) => ("был(а) в этом месяце".to_string(), Color::DarkGray),
|
||||||
|
Some(UserOnlineStatus::LongTimeAgo) => ("был(а) давно".to_string(), Color::DarkGray),
|
||||||
|
None => ("".to_string(), Color::DarkGray),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,9 @@
|
|||||||
|
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::formatting;
|
use crate::formatting;
|
||||||
use crate::tdlib::MessageInfo;
|
use crate::tdlib::{MessageInfo, PlaybackState, PlaybackStatus};
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
use crate::tdlib::PhotoDownloadState;
|
||||||
use crate::types::MessageId;
|
use crate::types::MessageId;
|
||||||
use crate::utils::{format_date, format_timestamp_with_tz};
|
use crate::utils::{format_date, format_timestamp_with_tz};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
@@ -22,19 +24,40 @@ struct WrappedLine {
|
|||||||
start_offset: usize,
|
start_offset: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Разбивает текст на строки с учётом максимальной ширины
|
/// Разбивает текст на строки с учётом максимальной ширины и `\n`
|
||||||
fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
|
fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
|
||||||
|
let mut all_lines = Vec::new();
|
||||||
|
let mut char_offset = 0;
|
||||||
|
|
||||||
|
for segment in text.split('\n') {
|
||||||
|
let wrapped = wrap_paragraph(segment, max_width, char_offset);
|
||||||
|
all_lines.extend(wrapped);
|
||||||
|
char_offset += segment.chars().count() + 1; // +1 за '\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
if all_lines.is_empty() {
|
||||||
|
all_lines.push(WrappedLine {
|
||||||
|
text: String::new(),
|
||||||
|
start_offset: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
all_lines
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Разбивает один абзац (без `\n`) на строки по ширине
|
||||||
|
fn wrap_paragraph(text: &str, max_width: usize, base_offset: usize) -> Vec<WrappedLine> {
|
||||||
if max_width == 0 {
|
if max_width == 0 {
|
||||||
return vec![WrappedLine {
|
return vec![WrappedLine {
|
||||||
text: text.to_string(),
|
text: text.to_string(),
|
||||||
start_offset: 0,
|
start_offset: base_offset,
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut result = Vec::new();
|
let mut result = Vec::new();
|
||||||
let mut current_line = String::new();
|
let mut current_line = String::new();
|
||||||
let mut current_width = 0;
|
let mut current_width = 0;
|
||||||
let mut line_start_offset = 0;
|
let mut line_start_offset = base_offset;
|
||||||
|
|
||||||
let chars: Vec<char> = text.chars().collect();
|
let chars: Vec<char> = text.chars().collect();
|
||||||
let mut word_start = 0;
|
let mut word_start = 0;
|
||||||
@@ -49,7 +72,7 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
|
|||||||
if current_width == 0 {
|
if current_width == 0 {
|
||||||
current_line = word;
|
current_line = word;
|
||||||
current_width = word_width;
|
current_width = word_width;
|
||||||
line_start_offset = word_start;
|
line_start_offset = base_offset + word_start;
|
||||||
} else if current_width + 1 + word_width <= max_width {
|
} else if current_width + 1 + word_width <= max_width {
|
||||||
current_line.push(' ');
|
current_line.push(' ');
|
||||||
current_line.push_str(&word);
|
current_line.push_str(&word);
|
||||||
@@ -61,7 +84,7 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
|
|||||||
});
|
});
|
||||||
current_line = word;
|
current_line = word;
|
||||||
current_width = word_width;
|
current_width = word_width;
|
||||||
line_start_offset = word_start;
|
line_start_offset = base_offset + word_start;
|
||||||
}
|
}
|
||||||
in_word = false;
|
in_word = false;
|
||||||
}
|
}
|
||||||
@@ -77,7 +100,7 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
|
|||||||
|
|
||||||
if current_width == 0 {
|
if current_width == 0 {
|
||||||
current_line = word;
|
current_line = word;
|
||||||
line_start_offset = word_start;
|
line_start_offset = base_offset + word_start;
|
||||||
} else if current_width + 1 + word_width <= max_width {
|
} else if current_width + 1 + word_width <= max_width {
|
||||||
current_line.push(' ');
|
current_line.push(' ');
|
||||||
current_line.push_str(&word);
|
current_line.push_str(&word);
|
||||||
@@ -87,7 +110,7 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
|
|||||||
start_offset: line_start_offset,
|
start_offset: line_start_offset,
|
||||||
});
|
});
|
||||||
current_line = word;
|
current_line = word;
|
||||||
line_start_offset = word_start;
|
line_start_offset = base_offset + word_start;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +124,7 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
|
|||||||
if result.is_empty() {
|
if result.is_empty() {
|
||||||
result.push(WrappedLine {
|
result.push(WrappedLine {
|
||||||
text: String::new(),
|
text: String::new(),
|
||||||
start_offset: 0,
|
start_offset: base_offset,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,6 +221,7 @@ pub fn render_message_bubble(
|
|||||||
config: &Config,
|
config: &Config,
|
||||||
content_width: usize,
|
content_width: usize,
|
||||||
selected_msg_id: Option<MessageId>,
|
selected_msg_id: Option<MessageId>,
|
||||||
|
playback_state: Option<&PlaybackState>,
|
||||||
) -> Vec<Line<'static>> {
|
) -> Vec<Line<'static>> {
|
||||||
let mut lines = Vec::new();
|
let mut lines = Vec::new();
|
||||||
let is_selected = selected_msg_id == Some(msg.id());
|
let is_selected = selected_msg_id == Some(msg.id());
|
||||||
@@ -285,11 +309,15 @@ pub fn render_message_bubble(
|
|||||||
let full_len = line_len + time_mark_len + marker_len;
|
let full_len = line_len + time_mark_len + marker_len;
|
||||||
let padding = content_width.saturating_sub(full_len + 1);
|
let padding = content_width.saturating_sub(full_len + 1);
|
||||||
let mut line_spans = vec![Span::raw(" ".repeat(padding))];
|
let mut line_spans = vec![Span::raw(" ".repeat(padding))];
|
||||||
if is_selected {
|
if is_selected && i == 0 {
|
||||||
|
// Одна строка — маркер на ней
|
||||||
line_spans.push(Span::styled(
|
line_spans.push(Span::styled(
|
||||||
selection_marker,
|
selection_marker,
|
||||||
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
|
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
|
||||||
));
|
));
|
||||||
|
} else if is_selected {
|
||||||
|
// Последняя строка multi-line — пробелы вместо маркера
|
||||||
|
line_spans.push(Span::raw(" ".repeat(marker_len)));
|
||||||
}
|
}
|
||||||
line_spans.extend(formatted_spans);
|
line_spans.extend(formatted_spans);
|
||||||
line_spans.push(Span::styled(format!(" {}", time_mark), Style::default().fg(Color::Gray)));
|
line_spans.push(Span::styled(format!(" {}", time_mark), Style::default().fg(Color::Gray)));
|
||||||
@@ -302,6 +330,9 @@ pub fn render_message_bubble(
|
|||||||
selection_marker,
|
selection_marker,
|
||||||
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
|
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
|
||||||
));
|
));
|
||||||
|
} else if is_selected {
|
||||||
|
// Средние строки multi-line — пробелы вместо маркера
|
||||||
|
line_spans.push(Span::raw(" ".repeat(marker_len)));
|
||||||
}
|
}
|
||||||
line_spans.extend(formatted_spans);
|
line_spans.extend(formatted_spans);
|
||||||
lines.push(Line::from(line_spans));
|
lines.push(Line::from(line_spans));
|
||||||
@@ -392,5 +423,346 @@ pub fn render_message_bubble(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Отображаем индикатор воспроизведения голосового
|
||||||
|
if msg.has_voice() {
|
||||||
|
if let Some(voice) = msg.voice_info() {
|
||||||
|
let is_this_playing = playback_state
|
||||||
|
.map(|ps| ps.message_id == msg.id())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
let status_line = if is_this_playing {
|
||||||
|
let ps = playback_state.unwrap();
|
||||||
|
let icon = match ps.status {
|
||||||
|
PlaybackStatus::Playing => "▶",
|
||||||
|
PlaybackStatus::Paused => "⏸",
|
||||||
|
PlaybackStatus::Loading => "⏳",
|
||||||
|
_ => "⏹",
|
||||||
|
};
|
||||||
|
let bar = render_progress_bar(ps.position, ps.duration, 20);
|
||||||
|
format!(
|
||||||
|
"{} {} {:.0}s/{:.0}s",
|
||||||
|
icon, bar, ps.position, ps.duration
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
let waveform = render_waveform(&voice.waveform, 20);
|
||||||
|
format!(" {} {:.0}s", waveform, voice.duration)
|
||||||
|
};
|
||||||
|
|
||||||
|
let status_len = status_line.chars().count();
|
||||||
|
if msg.is_outgoing() {
|
||||||
|
let padding = content_width.saturating_sub(status_len + 1);
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::raw(" ".repeat(padding)),
|
||||||
|
Span::styled(status_line, Style::default().fg(Color::Cyan)),
|
||||||
|
]));
|
||||||
|
} else {
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
status_line,
|
||||||
|
Style::default().fg(Color::Cyan),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отображаем статус фото (если есть)
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
if let Some(photo) = msg.photo_info() {
|
||||||
|
match &photo.download_state {
|
||||||
|
PhotoDownloadState::Downloading => {
|
||||||
|
let status = "📷 ⏳ Загрузка...";
|
||||||
|
if msg.is_outgoing() {
|
||||||
|
let padding = content_width.saturating_sub(status.chars().count() + 1);
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::raw(" ".repeat(padding)),
|
||||||
|
Span::styled(status, Style::default().fg(Color::Yellow)),
|
||||||
|
]));
|
||||||
|
} else {
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
status,
|
||||||
|
Style::default().fg(Color::Yellow),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PhotoDownloadState::Error(e) => {
|
||||||
|
let status = format!("📷 [Ошибка: {}]", e);
|
||||||
|
if msg.is_outgoing() {
|
||||||
|
let padding = content_width.saturating_sub(status.chars().count() + 1);
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::raw(" ".repeat(padding)),
|
||||||
|
Span::styled(status, Style::default().fg(Color::Red)),
|
||||||
|
]));
|
||||||
|
} else {
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
status,
|
||||||
|
Style::default().fg(Color::Red),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PhotoDownloadState::Downloaded(_) => {
|
||||||
|
// Всегда показываем inline превью для загруженных фото
|
||||||
|
let inline_width = content_width.min(crate::constants::INLINE_IMAGE_MAX_WIDTH);
|
||||||
|
let img_height = calculate_image_height(photo.width, photo.height, inline_width);
|
||||||
|
for _ in 0..img_height {
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PhotoDownloadState::NotDownloaded => {
|
||||||
|
// Для незагруженных фото ничего не рендерим,
|
||||||
|
// текст сообщения уже содержит 📷 prefix
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
lines
|
lines
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Информация для отложенного рендеринга изображения поверх placeholder
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
pub struct DeferredImageRender {
|
||||||
|
pub message_id: MessageId,
|
||||||
|
/// Путь к файлу изображения
|
||||||
|
pub photo_path: String,
|
||||||
|
/// Смещение в строках от начала всего списка сообщений
|
||||||
|
pub line_offset: usize,
|
||||||
|
/// Горизонтальное смещение от левого края контента (для сетки альбомов)
|
||||||
|
pub x_offset: u16,
|
||||||
|
pub width: u16,
|
||||||
|
pub height: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Рендерит bubble для альбома (группы фото с общим media_album_id)
|
||||||
|
///
|
||||||
|
/// Фото отображаются в сетке (до 3 в ряд), с общей подписью и timestamp.
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
pub fn render_album_bubble(
|
||||||
|
messages: &[MessageInfo],
|
||||||
|
config: &Config,
|
||||||
|
content_width: usize,
|
||||||
|
selected_msg_id: Option<MessageId>,
|
||||||
|
) -> (Vec<Line<'static>>, Vec<DeferredImageRender>) {
|
||||||
|
use crate::constants::{ALBUM_GRID_MAX_COLS, ALBUM_PHOTO_GAP, ALBUM_PHOTO_HEIGHT, ALBUM_PHOTO_WIDTH};
|
||||||
|
|
||||||
|
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||||
|
let mut deferred: Vec<DeferredImageRender> = Vec::new();
|
||||||
|
|
||||||
|
let is_selected = messages.iter().any(|m| selected_msg_id == Some(m.id()));
|
||||||
|
let is_outgoing = messages.first().map_or(false, |m| m.is_outgoing());
|
||||||
|
|
||||||
|
// Selection marker
|
||||||
|
let selection_marker = if is_selected { "▶ " } else { "" };
|
||||||
|
|
||||||
|
// Фильтруем фото
|
||||||
|
let photos: Vec<&MessageInfo> = messages.iter().filter(|m| m.has_photo()).collect();
|
||||||
|
let photo_count = photos.len();
|
||||||
|
|
||||||
|
if photo_count == 0 {
|
||||||
|
// Нет фото — рендерим как обычные сообщения
|
||||||
|
for msg in messages {
|
||||||
|
lines.extend(render_message_bubble(msg, config, content_width, selected_msg_id, None));
|
||||||
|
}
|
||||||
|
return (lines, deferred);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grid layout
|
||||||
|
let cols = photo_count.min(ALBUM_GRID_MAX_COLS);
|
||||||
|
let rows = (photo_count + cols - 1) / cols;
|
||||||
|
|
||||||
|
// Добавляем маркер выбора на первую строку
|
||||||
|
if is_selected {
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
selection_marker,
|
||||||
|
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
let grid_start_line = lines.len();
|
||||||
|
|
||||||
|
// Генерируем placeholder-строки для сетки
|
||||||
|
for row in 0..rows {
|
||||||
|
for line_in_row in 0..ALBUM_PHOTO_HEIGHT {
|
||||||
|
let mut spans = Vec::new();
|
||||||
|
|
||||||
|
// Для исходящих — добавляем отступ справа
|
||||||
|
if is_outgoing {
|
||||||
|
let grid_width = cols as u16 * ALBUM_PHOTO_WIDTH
|
||||||
|
+ (cols as u16).saturating_sub(1) * ALBUM_PHOTO_GAP;
|
||||||
|
let padding = content_width.saturating_sub(grid_width as usize + 1);
|
||||||
|
spans.push(Span::raw(" ".repeat(padding)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Для каждого столбца в этом ряду
|
||||||
|
for col in 0..cols {
|
||||||
|
let photo_idx = row * cols + col;
|
||||||
|
if photo_idx >= photo_count {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let msg = photos[photo_idx];
|
||||||
|
if let Some(photo) = msg.photo_info() {
|
||||||
|
match &photo.download_state {
|
||||||
|
PhotoDownloadState::Downloaded(path) => {
|
||||||
|
if line_in_row == 0 {
|
||||||
|
// Регистрируем deferred render для этого фото
|
||||||
|
let x_off = if is_outgoing {
|
||||||
|
let grid_width = cols as u16 * ALBUM_PHOTO_WIDTH
|
||||||
|
+ (cols as u16).saturating_sub(1) * ALBUM_PHOTO_GAP;
|
||||||
|
let padding = content_width.saturating_sub(grid_width as usize + 1) as u16;
|
||||||
|
padding + col as u16 * (ALBUM_PHOTO_WIDTH + ALBUM_PHOTO_GAP)
|
||||||
|
} else {
|
||||||
|
col as u16 * (ALBUM_PHOTO_WIDTH + ALBUM_PHOTO_GAP)
|
||||||
|
};
|
||||||
|
|
||||||
|
deferred.push(DeferredImageRender {
|
||||||
|
message_id: msg.id(),
|
||||||
|
photo_path: path.clone(),
|
||||||
|
line_offset: grid_start_line + row * ALBUM_PHOTO_HEIGHT as usize,
|
||||||
|
x_offset: x_off,
|
||||||
|
width: ALBUM_PHOTO_WIDTH,
|
||||||
|
height: ALBUM_PHOTO_HEIGHT,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Пустая строка — placeholder для изображения
|
||||||
|
}
|
||||||
|
PhotoDownloadState::Downloading => {
|
||||||
|
if line_in_row == ALBUM_PHOTO_HEIGHT / 2 {
|
||||||
|
spans.push(Span::styled(
|
||||||
|
"⏳ Загрузка...",
|
||||||
|
Style::default().fg(Color::Yellow),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PhotoDownloadState::Error(e) => {
|
||||||
|
if line_in_row == ALBUM_PHOTO_HEIGHT / 2 {
|
||||||
|
let err_text: String = e.chars().take(14).collect();
|
||||||
|
spans.push(Span::styled(
|
||||||
|
format!("❌ {}", err_text),
|
||||||
|
Style::default().fg(Color::Red),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PhotoDownloadState::NotDownloaded => {
|
||||||
|
if line_in_row == ALBUM_PHOTO_HEIGHT / 2 {
|
||||||
|
spans.push(Span::styled(
|
||||||
|
"📷",
|
||||||
|
Style::default().fg(Color::Gray),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(Line::from(spans));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caption: собираем непустые тексты (без "📷 [Фото]" prefix)
|
||||||
|
let captions: Vec<&str> = messages
|
||||||
|
.iter()
|
||||||
|
.map(|m| m.text())
|
||||||
|
.filter(|t| !t.is_empty() && !t.starts_with("📷"))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let msg_color = if is_selected {
|
||||||
|
config.parse_color(&config.colors.selected_message)
|
||||||
|
} else if is_outgoing {
|
||||||
|
config.parse_color(&config.colors.outgoing_message)
|
||||||
|
} else {
|
||||||
|
config.parse_color(&config.colors.incoming_message)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Timestamp из последнего сообщения
|
||||||
|
let last_msg = messages.last().unwrap();
|
||||||
|
let time = format_timestamp_with_tz(last_msg.date(), &config.general.timezone);
|
||||||
|
|
||||||
|
if !captions.is_empty() {
|
||||||
|
let caption_text = captions.join(" ");
|
||||||
|
let time_suffix = format!(" ({})", time);
|
||||||
|
|
||||||
|
if is_outgoing {
|
||||||
|
let total_len = caption_text.chars().count() + time_suffix.chars().count();
|
||||||
|
let padding = content_width.saturating_sub(total_len + 1);
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::raw(" ".repeat(padding)),
|
||||||
|
Span::styled(caption_text, Style::default().fg(msg_color)),
|
||||||
|
Span::styled(time_suffix, Style::default().fg(Color::Gray)),
|
||||||
|
]));
|
||||||
|
} else {
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(format!(" ({})", time), Style::default().fg(Color::Gray)),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(caption_text, Style::default().fg(msg_color)),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Без подписи — только timestamp
|
||||||
|
let time_text = format!("({})", time);
|
||||||
|
if is_outgoing {
|
||||||
|
let padding = content_width.saturating_sub(time_text.chars().count() + 1);
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::raw(" ".repeat(padding)),
|
||||||
|
Span::styled(time_text, Style::default().fg(Color::Gray)),
|
||||||
|
]));
|
||||||
|
} else {
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(format!(" {}", time_text), Style::default().fg(Color::Gray)),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(lines, deferred)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Вычисляет высоту изображения (в строках) с учётом пропорций
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
pub fn calculate_image_height(img_width: i32, img_height: i32, content_width: usize) -> u16 {
|
||||||
|
use crate::constants::{MAX_IMAGE_HEIGHT, MAX_IMAGE_WIDTH, MIN_IMAGE_HEIGHT};
|
||||||
|
|
||||||
|
let display_width = (content_width as u16).min(MAX_IMAGE_WIDTH);
|
||||||
|
let aspect = img_height as f64 / img_width as f64;
|
||||||
|
// Терминальные символы ~2:1 по высоте, компенсируем
|
||||||
|
let raw_height = (display_width as f64 * aspect * 0.5) as u16;
|
||||||
|
raw_height.clamp(MIN_IMAGE_HEIGHT, MAX_IMAGE_HEIGHT)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Рендерит progress bar для воспроизведения
|
||||||
|
fn render_progress_bar(position: f32, duration: f32, width: usize) -> String {
|
||||||
|
if duration <= 0.0 {
|
||||||
|
return "─".repeat(width);
|
||||||
|
}
|
||||||
|
let ratio = (position / duration).clamp(0.0, 1.0);
|
||||||
|
let filled = (ratio * width as f32) as usize;
|
||||||
|
let empty = width.saturating_sub(filled + 1);
|
||||||
|
format!("{}●{}", "━".repeat(filled), "─".repeat(empty))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Рендерит waveform из base64-encoded данных TDLib
|
||||||
|
fn render_waveform(waveform_b64: &str, width: usize) -> String {
|
||||||
|
const BARS: &[char] = &['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
|
||||||
|
|
||||||
|
if waveform_b64.is_empty() {
|
||||||
|
return "▁".repeat(width);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Декодируем waveform (каждый байт = амплитуда 0-255)
|
||||||
|
use base64::Engine;
|
||||||
|
let bytes = base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(waveform_b64)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
if bytes.is_empty() {
|
||||||
|
return "▁".repeat(width);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сэмплируем до нужной ширины
|
||||||
|
let mut result = String::with_capacity(width * 4);
|
||||||
|
for i in 0..width {
|
||||||
|
let byte_idx = i * bytes.len() / width;
|
||||||
|
let amplitude = bytes.get(byte_idx).copied().unwrap_or(0);
|
||||||
|
let bar_idx = (amplitude as usize * (BARS.len() - 1)) / 255;
|
||||||
|
result.push(BARS[bar_idx]);
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|||||||
116
src/ui/components/message_list.rs
Normal file
116
src/ui/components/message_list.rs
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
//! Shared message list rendering for search and pinned modals
|
||||||
|
|
||||||
|
use crate::tdlib::MessageInfo;
|
||||||
|
use ratatui::{
|
||||||
|
layout::Alignment,
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Block, Borders, Paragraph},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Renders a single message item with marker, sender, date, and wrapped text
|
||||||
|
pub fn render_message_item(
|
||||||
|
msg: &MessageInfo,
|
||||||
|
is_selected: bool,
|
||||||
|
content_width: usize,
|
||||||
|
max_preview_lines: usize,
|
||||||
|
) -> Vec<Line<'static>> {
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
|
||||||
|
// Marker, sender name, and date
|
||||||
|
let marker = if is_selected { "▶ " } else { " " };
|
||||||
|
let sender_color = if msg.is_outgoing() {
|
||||||
|
Color::Green
|
||||||
|
} else {
|
||||||
|
Color::Cyan
|
||||||
|
};
|
||||||
|
let sender_name = if msg.is_outgoing() {
|
||||||
|
"Вы".to_string()
|
||||||
|
} else {
|
||||||
|
msg.sender_name().to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
marker.to_string(),
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Yellow)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::styled(
|
||||||
|
format!("{} ", sender_name),
|
||||||
|
Style::default()
|
||||||
|
.fg(sender_color)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::styled(
|
||||||
|
format!("({})", crate::utils::format_datetime(msg.date())),
|
||||||
|
Style::default().fg(Color::Gray),
|
||||||
|
),
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Wrapped message text
|
||||||
|
let msg_color = if is_selected {
|
||||||
|
Color::Yellow
|
||||||
|
} else {
|
||||||
|
Color::White
|
||||||
|
};
|
||||||
|
let max_width = content_width.saturating_sub(4);
|
||||||
|
let wrapped = crate::ui::messages::wrap_text_with_offsets(msg.text(), max_width);
|
||||||
|
let wrapped_count = wrapped.len();
|
||||||
|
|
||||||
|
for wrapped_line in wrapped.into_iter().take(max_preview_lines) {
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::raw(" ".to_string()),
|
||||||
|
Span::styled(wrapped_line.text, Style::default().fg(msg_color)),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
if wrapped_count > max_preview_lines {
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::raw(" ".to_string()),
|
||||||
|
Span::styled("...".to_string(), Style::default().fg(Color::Gray)),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
lines
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculates scroll offset to keep selected item visible
|
||||||
|
pub fn calculate_scroll_offset(
|
||||||
|
selected_index: usize,
|
||||||
|
lines_per_item: usize,
|
||||||
|
visible_height: u16,
|
||||||
|
) -> u16 {
|
||||||
|
let visible = visible_height.saturating_sub(2) as usize;
|
||||||
|
let selected_line = selected_index * lines_per_item;
|
||||||
|
if selected_line > visible / 2 {
|
||||||
|
(selected_line - visible / 2) as u16
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders a help bar with keyboard shortcuts
|
||||||
|
pub fn render_help_bar(shortcuts: &[(&str, &str, Color)], border_color: Color) -> Paragraph<'static> {
|
||||||
|
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||||
|
for (i, (key, label, color)) in shortcuts.iter().enumerate() {
|
||||||
|
if i > 0 {
|
||||||
|
spans.push(Span::raw(" ".to_string()));
|
||||||
|
}
|
||||||
|
spans.push(Span::styled(
|
||||||
|
format!(" {} ", key),
|
||||||
|
Style::default()
|
||||||
|
.fg(*color)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
));
|
||||||
|
spans.push(Span::raw(label.to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Paragraph::new(Line::from(spans))
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(border_color)),
|
||||||
|
)
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
// UI компоненты для переиспользования
|
//! Reusable UI components: message bubbles, input fields, modals, lists.
|
||||||
|
|
||||||
pub mod modal;
|
pub mod modal;
|
||||||
pub mod input_field;
|
pub mod input_field;
|
||||||
pub mod message_bubble;
|
pub mod message_bubble;
|
||||||
|
pub mod message_list;
|
||||||
pub mod chat_list_item;
|
pub mod chat_list_item;
|
||||||
pub mod emoji_picker;
|
pub mod emoji_picker;
|
||||||
|
|
||||||
@@ -11,3 +12,6 @@ pub use input_field::render_input_field;
|
|||||||
pub use chat_list_item::render_chat_list_item;
|
pub use chat_list_item::render_chat_list_item;
|
||||||
pub use emoji_picker::render_emoji_picker;
|
pub use emoji_picker::render_emoji_picker;
|
||||||
pub use message_bubble::{render_date_separator, render_message_bubble, render_sender_header};
|
pub use message_bubble::{render_date_separator, render_message_bubble, render_sender_header};
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
pub use message_bubble::{DeferredImageRender, calculate_image_height, render_album_bubble};
|
||||||
|
pub use message_list::{render_message_item, calculate_scroll_offset, render_help_bar};
|
||||||
|
|||||||
187
src/ui/compose_bar.rs
Normal file
187
src/ui/compose_bar.rs
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
//! Compose bar / input box rendering
|
||||||
|
|
||||||
|
use crate::app::App;
|
||||||
|
use crate::app::InputMode;
|
||||||
|
use crate::app::methods::{compose::ComposeMethods, messages::MessageMethods};
|
||||||
|
use crate::tdlib::TdClientTrait;
|
||||||
|
use crate::ui::components;
|
||||||
|
use ratatui::{
|
||||||
|
layout::Rect,
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Block, Borders, Paragraph},
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Renders input field with cursor at the specified position
|
||||||
|
fn render_input_with_cursor(
|
||||||
|
prefix: &str,
|
||||||
|
text: &str,
|
||||||
|
cursor_pos: usize,
|
||||||
|
color: Color,
|
||||||
|
) -> Line<'static> {
|
||||||
|
components::render_input_field(prefix, text, cursor_pos, color)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders input box with support for different modes (forward/select/edit/reply/normal)
|
||||||
|
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
|
||||||
|
let (input_line, input_title): (Line, &str) = if app.is_forwarding() {
|
||||||
|
// Режим пересылки - показываем превью сообщения
|
||||||
|
let forward_preview = app
|
||||||
|
.get_forwarding_message()
|
||||||
|
.map(|m| {
|
||||||
|
let text_preview: String = m.text().chars().take(40).collect();
|
||||||
|
let ellipsis = if m.text().chars().count() > 40 {
|
||||||
|
"..."
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
format!("↪ {}{}", text_preview, ellipsis)
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "↪ ...".to_string());
|
||||||
|
|
||||||
|
let line = Line::from(Span::styled(forward_preview, Style::default().fg(Color::Cyan)));
|
||||||
|
(line, " Выберите чат ← ")
|
||||||
|
} else if app.is_selecting_message() {
|
||||||
|
// Режим выбора сообщения - подсказка зависит от возможностей
|
||||||
|
let selected_msg = app.get_selected_message();
|
||||||
|
let can_edit = selected_msg
|
||||||
|
.as_ref()
|
||||||
|
.map(|m| m.can_be_edited() && m.is_outgoing())
|
||||||
|
.unwrap_or(false);
|
||||||
|
let can_delete = selected_msg
|
||||||
|
.as_ref()
|
||||||
|
.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 перслть · 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() {
|
||||||
|
// Режим редактирования
|
||||||
|
if app.message_input.is_empty() {
|
||||||
|
let line = Line::from(vec![
|
||||||
|
Span::raw("✏ "),
|
||||||
|
Span::styled("█", Style::default().fg(Color::Magenta)),
|
||||||
|
Span::styled(" Введите новый текст...", Style::default().fg(Color::Gray)),
|
||||||
|
]);
|
||||||
|
(line, " Редактирование (Esc отмена) ")
|
||||||
|
} else {
|
||||||
|
let line = render_input_with_cursor(
|
||||||
|
"✏ ",
|
||||||
|
&app.message_input,
|
||||||
|
app.cursor_position,
|
||||||
|
Color::Magenta,
|
||||||
|
);
|
||||||
|
(line, " Редактирование (Esc отмена) ")
|
||||||
|
}
|
||||||
|
} else if app.is_replying() {
|
||||||
|
// Режим ответа на сообщение
|
||||||
|
let reply_preview = app
|
||||||
|
.get_replying_to_message()
|
||||||
|
.map(|m| {
|
||||||
|
let sender = if m.is_outgoing() {
|
||||||
|
"Вы"
|
||||||
|
} else {
|
||||||
|
m.sender_name()
|
||||||
|
};
|
||||||
|
let text_preview: String = m.text().chars().take(30).collect();
|
||||||
|
let ellipsis = if m.text().chars().count() > 30 {
|
||||||
|
"..."
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
format!("{}: {}{}", sender, text_preview, ellipsis)
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "...".to_string());
|
||||||
|
|
||||||
|
if app.message_input.is_empty() {
|
||||||
|
let line = Line::from(vec![
|
||||||
|
Span::styled("↪ ", Style::default().fg(Color::Cyan)),
|
||||||
|
Span::styled(reply_preview, Style::default().fg(Color::Gray)),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled("█", Style::default().fg(Color::Yellow)),
|
||||||
|
]);
|
||||||
|
(line, " Ответ (Esc отмена) ")
|
||||||
|
} else {
|
||||||
|
let short_preview: String = reply_preview.chars().take(15).collect();
|
||||||
|
let prefix = format!("↪ {} > ", short_preview);
|
||||||
|
let line = render_input_with_cursor(
|
||||||
|
&prefix,
|
||||||
|
&app.message_input,
|
||||||
|
app.cursor_position,
|
||||||
|
Color::Yellow,
|
||||||
|
);
|
||||||
|
(line, " Ответ (Esc отмена) ")
|
||||||
|
}
|
||||||
|
} else if app.input_mode == InputMode::Normal {
|
||||||
|
// Normal mode — dim, no cursor
|
||||||
|
if app.message_input.is_empty() {
|
||||||
|
let line = Line::from(vec![
|
||||||
|
Span::styled("> Press i to type...", Style::default().fg(Color::DarkGray)),
|
||||||
|
]);
|
||||||
|
(line, "")
|
||||||
|
} else {
|
||||||
|
let draft_preview: String = app.message_input.chars().take(60).collect();
|
||||||
|
let ellipsis = if app.message_input.chars().count() > 60 { "..." } else { "" };
|
||||||
|
let line = Line::from(Span::styled(
|
||||||
|
format!("> {}{}", draft_preview, ellipsis),
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
));
|
||||||
|
(line, "")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Insert mode — active, with cursor
|
||||||
|
if app.message_input.is_empty() {
|
||||||
|
let line = Line::from(vec![
|
||||||
|
Span::raw("> "),
|
||||||
|
Span::styled("█", Style::default().fg(Color::Yellow)),
|
||||||
|
Span::styled(" Введите сообщение...", Style::default().fg(Color::Gray)),
|
||||||
|
]);
|
||||||
|
(line, "")
|
||||||
|
} else {
|
||||||
|
let line = render_input_with_cursor(
|
||||||
|
"> ",
|
||||||
|
&app.message_input,
|
||||||
|
app.cursor_position,
|
||||||
|
Color::Yellow,
|
||||||
|
);
|
||||||
|
(line, "")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let input_block = if input_title.is_empty() {
|
||||||
|
let border_style = if app.input_mode == InputMode::Insert {
|
||||||
|
Style::default().fg(Color::Green)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::DarkGray)
|
||||||
|
};
|
||||||
|
Block::default().borders(Borders::ALL).border_style(border_style)
|
||||||
|
} else {
|
||||||
|
let title_color = if app.is_replying() || app.is_forwarding() {
|
||||||
|
Color::Cyan
|
||||||
|
} else {
|
||||||
|
Color::Magenta
|
||||||
|
};
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.title(input_title)
|
||||||
|
.title_style(
|
||||||
|
Style::default()
|
||||||
|
.fg(title_color)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let input = Paragraph::new(input_line)
|
||||||
|
.block(input_block)
|
||||||
|
.wrap(ratatui::widgets::Wrap { trim: false });
|
||||||
|
f.render_widget(input, area);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
use crate::app::App;
|
use crate::app::App;
|
||||||
|
use crate::app::InputMode;
|
||||||
use crate::tdlib::TdClientTrait;
|
use crate::tdlib::TdClientTrait;
|
||||||
use crate::tdlib::NetworkState;
|
use crate::tdlib::NetworkState;
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
@@ -18,18 +19,29 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
|
|||||||
NetworkState::Updating => "⏳ Обновление... | ",
|
NetworkState::Updating => "⏳ Обновление... | ",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Account indicator (shown if not "default")
|
||||||
|
let account_indicator = if app.current_account_name != "default" {
|
||||||
|
format!("[{}] ", app.current_account_name)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
let status = if let Some(msg) = &app.status_message {
|
let status = if let Some(msg) = &app.status_message {
|
||||||
format!(" {}{} ", network_indicator, msg)
|
format!(" {}{}{} ", account_indicator, network_indicator, msg)
|
||||||
} else if let Some(err) = &app.error_message {
|
} else if let Some(err) = &app.error_message {
|
||||||
format!(" {}Error: {} ", network_indicator, err)
|
format!(" {}{}Error: {} ", account_indicator, network_indicator, err)
|
||||||
} else if app.is_searching {
|
} else if app.is_searching {
|
||||||
format!(" {}↑/↓: Navigate | Enter: Select | Esc: Cancel ", network_indicator)
|
format!(" {}{}↑/↓: Navigate | Enter: Select | Esc: Cancel ", account_indicator, network_indicator)
|
||||||
} else if app.selected_chat_id.is_some() {
|
} else if app.selected_chat_id.is_some() {
|
||||||
format!(" {}↑/↓: Scroll | Ctrl+U: Profile | Enter: Send | Esc: Close | Ctrl+R: Refresh | Ctrl+C: Quit ", network_indicator)
|
let mode_str = match app.input_mode {
|
||||||
|
InputMode::Normal => "[NORMAL] j/k: Nav | i: Insert | d/r/f/y: Actions | Esc: Close",
|
||||||
|
InputMode::Insert => "[INSERT] Type message | Esc: Normal mode",
|
||||||
|
};
|
||||||
|
format!(" {}{}{} | Ctrl+C: Quit ", account_indicator, network_indicator, mode_str)
|
||||||
} else {
|
} else {
|
||||||
format!(
|
format!(
|
||||||
" {}↑/↓: Navigate | Enter: Open | Ctrl+S: Search | Ctrl+R: Refresh | Ctrl+C: Quit ",
|
" {}{}↑/↓: Navigate | Enter: Open | Ctrl+S: Search | Ctrl+R: Refresh | Ctrl+C: Quit ",
|
||||||
network_indicator
|
account_indicator, network_indicator
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
|
//! Chat message area rendering.
|
||||||
|
//!
|
||||||
|
//! Renders message bubbles grouped by date/sender, pinned bar, and delegates
|
||||||
|
//! to modals (search, pinned, reactions, delete) and compose_bar.
|
||||||
|
|
||||||
use crate::app::App;
|
use crate::app::App;
|
||||||
|
use crate::app::methods::{messages::MessageMethods, modal::ModalMethods, search::SearchMethods};
|
||||||
use crate::tdlib::TdClientTrait;
|
use crate::tdlib::TdClientTrait;
|
||||||
use crate::message_grouping::{group_messages, MessageGroup};
|
use crate::message_grouping::{group_messages, MessageGroup};
|
||||||
use crate::ui::components;
|
use crate::ui::components;
|
||||||
|
use crate::ui::{compose_bar, modals};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||||
style::{Color, Modifier, Style},
|
style::{Color, Modifier, Style},
|
||||||
@@ -88,24 +95,14 @@ fn render_pinned_bar<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>)
|
|||||||
f.render_widget(pinned_bar, area);
|
f.render_widget(pinned_bar, area);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_input_with_cursor(
|
|
||||||
prefix: &str,
|
|
||||||
text: &str,
|
|
||||||
cursor_pos: usize,
|
|
||||||
color: Color,
|
|
||||||
) -> Line<'static> {
|
|
||||||
// Используем компонент input_field
|
|
||||||
components::render_input_field(prefix, text, cursor_pos, color)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Информация о строке после переноса: текст и позиция в оригинале
|
/// Информация о строке после переноса: текст и позиция в оригинале
|
||||||
struct WrappedLine {
|
pub(super) struct WrappedLine {
|
||||||
text: String,
|
pub text: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Разбивает текст на строки с учётом максимальной ширины
|
/// Разбивает текст на строки с учётом максимальной ширины
|
||||||
/// (используется только для search/pinned режимов, основной рендеринг через message_bubble)
|
/// (используется только для search/pinned режимов, основной рендеринг через message_bubble)
|
||||||
fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
|
pub(super) fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
|
||||||
if max_width == 0 {
|
if max_width == 0 {
|
||||||
return vec![WrappedLine {
|
return vec![WrappedLine {
|
||||||
text: text.to_string(),
|
text: text.to_string(),
|
||||||
@@ -180,7 +177,7 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
/// Рендерит список сообщений с группировкой по дате/отправителю и автоскроллом
|
/// Рендерит список сообщений с группировкой по дате/отправителю и автоскроллом
|
||||||
fn render_message_list<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
|
fn render_message_list<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut App<T>) {
|
||||||
let content_width = area.width.saturating_sub(2) as usize;
|
let content_width = area.width.saturating_sub(2) as usize;
|
||||||
|
|
||||||
// Messages с группировкой по дате и отправителю
|
// Messages с группировкой по дате и отправителю
|
||||||
@@ -191,6 +188,13 @@ fn render_message_list<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>
|
|||||||
// Номер строки, где начинается выбранное сообщение (для автоскролла)
|
// Номер строки, где начинается выбранное сообщение (для автоскролла)
|
||||||
let mut selected_msg_line: Option<usize> = None;
|
let mut selected_msg_line: Option<usize> = None;
|
||||||
|
|
||||||
|
// ОПТИМИЗАЦИЯ: Убрали массовый preloading всех изображений.
|
||||||
|
// Теперь загружаем только видимые изображения во втором проходе (см. ниже).
|
||||||
|
|
||||||
|
// Собираем информацию о развёрнутых изображениях (для второго прохода)
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
let mut deferred_images: Vec<components::DeferredImageRender> = Vec::new();
|
||||||
|
|
||||||
// Используем message_grouping для группировки сообщений
|
// Используем message_grouping для группировки сообщений
|
||||||
let grouped = group_messages(&app.td_client.current_chat_messages());
|
let grouped = group_messages(&app.td_client.current_chat_messages());
|
||||||
let mut is_first_date = true;
|
let mut is_first_date = true;
|
||||||
@@ -225,15 +229,81 @@ fn render_message_list<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Рендерим сообщение
|
// Рендерим сообщение
|
||||||
lines.extend(components::render_message_bubble(
|
let bubble_lines = components::render_message_bubble(
|
||||||
&msg,
|
&msg,
|
||||||
app.config(),
|
app.config(),
|
||||||
content_width,
|
content_width,
|
||||||
selected_msg_id,
|
selected_msg_id,
|
||||||
|
app.playback_state.as_ref(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Собираем deferred image renders для всех загруженных фото
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
if let Some(photo) = msg.photo_info() {
|
||||||
|
if let crate::tdlib::PhotoDownloadState::Downloaded(path) = &photo.download_state {
|
||||||
|
let inline_width = content_width.min(crate::constants::INLINE_IMAGE_MAX_WIDTH);
|
||||||
|
let img_height = components::calculate_image_height(photo.width, photo.height, inline_width);
|
||||||
|
let img_width = inline_width as u16;
|
||||||
|
let bubble_len = bubble_lines.len();
|
||||||
|
let placeholder_start = lines.len() + bubble_len - img_height as usize;
|
||||||
|
|
||||||
|
deferred_images.push(components::DeferredImageRender {
|
||||||
|
message_id: msg.id(),
|
||||||
|
photo_path: path.clone(),
|
||||||
|
line_offset: placeholder_start,
|
||||||
|
x_offset: 0,
|
||||||
|
width: img_width,
|
||||||
|
height: img_height,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.extend(bubble_lines);
|
||||||
|
}
|
||||||
|
MessageGroup::Album(album_messages) => {
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
{
|
||||||
|
let is_selected = album_messages
|
||||||
|
.iter()
|
||||||
|
.any(|m| selected_msg_id == Some(m.id()));
|
||||||
|
if is_selected {
|
||||||
|
selected_msg_line = Some(lines.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
let (bubble_lines, album_deferred) = components::render_album_bubble(
|
||||||
|
&album_messages,
|
||||||
|
app.config(),
|
||||||
|
content_width,
|
||||||
|
selected_msg_id,
|
||||||
|
);
|
||||||
|
|
||||||
|
for mut d in album_deferred {
|
||||||
|
d.line_offset += lines.len();
|
||||||
|
deferred_images.push(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.extend(bubble_lines);
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "images"))]
|
||||||
|
{
|
||||||
|
// Fallback: рендерим каждое сообщение отдельно
|
||||||
|
for msg in &album_messages {
|
||||||
|
let is_selected = selected_msg_id == Some(msg.id());
|
||||||
|
if is_selected {
|
||||||
|
selected_msg_line = Some(lines.len());
|
||||||
|
}
|
||||||
|
lines.extend(components::render_message_bubble(
|
||||||
|
msg,
|
||||||
|
app.config(),
|
||||||
|
content_width,
|
||||||
|
selected_msg_id,
|
||||||
|
app.playback_state.as_ref(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if lines.is_empty() {
|
if lines.is_empty() {
|
||||||
lines.push(Line::from(Span::styled("Нет сообщений", Style::default().fg(Color::Gray))));
|
lines.push(Line::from(Span::styled("Нет сообщений", Style::default().fg(Color::Gray))));
|
||||||
@@ -275,156 +345,66 @@ fn render_message_list<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>
|
|||||||
.block(Block::default().borders(Borders::ALL))
|
.block(Block::default().borders(Borders::ALL))
|
||||||
.scroll((scroll_offset, 0));
|
.scroll((scroll_offset, 0));
|
||||||
f.render_widget(messages_widget, area);
|
f.render_widget(messages_widget, area);
|
||||||
|
|
||||||
|
// Второй проход: рендерим изображения поверх placeholder-ов
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
{
|
||||||
|
use ratatui_image::StatefulImage;
|
||||||
|
|
||||||
|
// THROTTLING: Рендерим изображения максимум 15 FPS (каждые 66ms)
|
||||||
|
let should_render_images = app.last_image_render_time
|
||||||
|
.map(|t| t.elapsed() > std::time::Duration::from_millis(66))
|
||||||
|
.unwrap_or(true);
|
||||||
|
|
||||||
|
if !deferred_images.is_empty() && should_render_images {
|
||||||
|
let content_x = area.x + 1;
|
||||||
|
let content_y = area.y + 1;
|
||||||
|
|
||||||
|
for d in &deferred_images {
|
||||||
|
let y_in_content = d.line_offset as i32 - scroll_offset as i32;
|
||||||
|
|
||||||
|
// Пропускаем изображения, которые полностью за пределами видимости
|
||||||
|
if y_in_content < 0 || y_in_content as usize >= visible_height {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let img_y = content_y + y_in_content as u16;
|
||||||
|
let remaining_height = (content_y + visible_height as u16).saturating_sub(img_y);
|
||||||
|
|
||||||
|
// ВАЖНО: Не рендерим частично видимые изображения (убирает сжатие и мигание)
|
||||||
|
if d.height > remaining_height {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Рендерим с ПОЛНОЙ высотой (не сжимаем)
|
||||||
|
let img_rect = Rect::new(content_x + d.x_offset, img_y, d.width, d.height);
|
||||||
|
|
||||||
|
// ОПТИМИЗАЦИЯ: Загружаем только видимые изображения (не все сразу)
|
||||||
|
// Используем inline_renderer с Halfblocks для скорости
|
||||||
|
if let Some(renderer) = &mut app.inline_image_renderer {
|
||||||
|
// Загружаем только если видимо (early return если уже в кеше)
|
||||||
|
let _ = renderer.load_image(d.message_id, &d.photo_path);
|
||||||
|
|
||||||
|
if let Some(protocol) = renderer.get_protocol(&d.message_id) {
|
||||||
|
f.render_stateful_widget(StatefulImage::default(), img_rect, protocol);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем время последнего рендеринга (для throttling)
|
||||||
|
app.last_image_render_time = Some(std::time::Instant::now());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Рендерит input box с поддержкой разных режимов (forward/select/edit/reply/normal)
|
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut App<T>) {
|
||||||
fn render_input_box<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
|
// Модальное окно просмотра изображения (приоритет выше всех)
|
||||||
let (input_line, input_title) = if app.is_forwarding() {
|
#[cfg(feature = "images")]
|
||||||
// Режим пересылки - показываем превью сообщения
|
if let Some(modal_state) = app.image_modal.clone() {
|
||||||
let forward_preview = app
|
modals::render_image_viewer(f, app, &modal_state);
|
||||||
.get_forwarding_message()
|
return;
|
||||||
.map(|m| {
|
|
||||||
let text_preview: String = m.text().chars().take(40).collect();
|
|
||||||
let ellipsis = if m.text().chars().count() > 40 {
|
|
||||||
"..."
|
|
||||||
} else {
|
|
||||||
""
|
|
||||||
};
|
|
||||||
format!("↪ {}{}", text_preview, ellipsis)
|
|
||||||
})
|
|
||||||
.unwrap_or_else(|| "↪ ...".to_string());
|
|
||||||
|
|
||||||
let line = Line::from(Span::styled(forward_preview, Style::default().fg(Color::Cyan)));
|
|
||||||
(line, " Выберите чат ← ")
|
|
||||||
} else if app.is_selecting_message() {
|
|
||||||
// Режим выбора сообщения - подсказка зависит от возможностей
|
|
||||||
let selected_msg = app.get_selected_message();
|
|
||||||
let can_edit = selected_msg
|
|
||||||
.as_ref()
|
|
||||||
.map(|m| m.can_be_edited() && m.is_outgoing())
|
|
||||||
.unwrap_or(false);
|
|
||||||
let can_delete = selected_msg
|
|
||||||
.as_ref()
|
|
||||||
.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 перслть · 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() {
|
|
||||||
// Режим редактирования
|
|
||||||
if app.message_input.is_empty() {
|
|
||||||
// Пустой инпут - показываем курсор и placeholder
|
|
||||||
let line = Line::from(vec![
|
|
||||||
Span::raw("✏ "),
|
|
||||||
Span::styled("█", Style::default().fg(Color::Magenta)),
|
|
||||||
Span::styled(" Введите новый текст...", Style::default().fg(Color::Gray)),
|
|
||||||
]);
|
|
||||||
(line, " Редактирование (Esc отмена) ")
|
|
||||||
} else {
|
|
||||||
// Текст с курсором
|
|
||||||
let line = render_input_with_cursor(
|
|
||||||
"✏ ",
|
|
||||||
&app.message_input,
|
|
||||||
app.cursor_position,
|
|
||||||
Color::Magenta,
|
|
||||||
);
|
|
||||||
(line, " Редактирование (Esc отмена) ")
|
|
||||||
}
|
}
|
||||||
} else if app.is_replying() {
|
|
||||||
// Режим ответа на сообщение
|
|
||||||
let reply_preview = app
|
|
||||||
.get_replying_to_message()
|
|
||||||
.map(|m| {
|
|
||||||
let sender = if m.is_outgoing() {
|
|
||||||
"Вы"
|
|
||||||
} else {
|
|
||||||
m.sender_name()
|
|
||||||
};
|
|
||||||
let text_preview: String = m.text().chars().take(30).collect();
|
|
||||||
let ellipsis = if m.text().chars().count() > 30 {
|
|
||||||
"..."
|
|
||||||
} else {
|
|
||||||
""
|
|
||||||
};
|
|
||||||
format!("{}: {}{}", sender, text_preview, ellipsis)
|
|
||||||
})
|
|
||||||
.unwrap_or_else(|| "...".to_string());
|
|
||||||
|
|
||||||
if app.message_input.is_empty() {
|
|
||||||
let line = Line::from(vec![
|
|
||||||
Span::styled("↪ ", Style::default().fg(Color::Cyan)),
|
|
||||||
Span::styled(reply_preview, Style::default().fg(Color::Gray)),
|
|
||||||
Span::raw(" "),
|
|
||||||
Span::styled("█", Style::default().fg(Color::Yellow)),
|
|
||||||
]);
|
|
||||||
(line, " Ответ (Esc отмена) ")
|
|
||||||
} else {
|
|
||||||
let short_preview: String = reply_preview.chars().take(15).collect();
|
|
||||||
let prefix = format!("↪ {} > ", short_preview);
|
|
||||||
let line = render_input_with_cursor(
|
|
||||||
&prefix,
|
|
||||||
&app.message_input,
|
|
||||||
app.cursor_position,
|
|
||||||
Color::Yellow,
|
|
||||||
);
|
|
||||||
(line, " Ответ (Esc отмена) ")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Обычный режим
|
|
||||||
if app.message_input.is_empty() {
|
|
||||||
// Пустой инпут - показываем курсор и placeholder
|
|
||||||
let line = Line::from(vec![
|
|
||||||
Span::raw("> "),
|
|
||||||
Span::styled("█", Style::default().fg(Color::Yellow)),
|
|
||||||
Span::styled(" Введите сообщение...", Style::default().fg(Color::Gray)),
|
|
||||||
]);
|
|
||||||
(line, "")
|
|
||||||
} else {
|
|
||||||
// Текст с курсором
|
|
||||||
let line = render_input_with_cursor(
|
|
||||||
"> ",
|
|
||||||
&app.message_input,
|
|
||||||
app.cursor_position,
|
|
||||||
Color::Yellow,
|
|
||||||
);
|
|
||||||
(line, "")
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let input_block = if input_title.is_empty() {
|
|
||||||
Block::default().borders(Borders::ALL)
|
|
||||||
} else {
|
|
||||||
let title_color = if app.is_replying() || app.is_forwarding() {
|
|
||||||
Color::Cyan
|
|
||||||
} else {
|
|
||||||
Color::Magenta
|
|
||||||
};
|
|
||||||
Block::default()
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.title(input_title)
|
|
||||||
.title_style(
|
|
||||||
Style::default()
|
|
||||||
.fg(title_color)
|
|
||||||
.add_modifier(Modifier::BOLD),
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
let input = Paragraph::new(input_line)
|
|
||||||
.block(input_block)
|
|
||||||
.wrap(ratatui::widgets::Wrap { trim: false });
|
|
||||||
f.render_widget(input, area);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
|
|
||||||
// Режим профиля
|
// Режим профиля
|
||||||
if app.is_profile_mode() {
|
if app.is_profile_mode() {
|
||||||
if let Some(profile) = app.get_profile_info() {
|
if let Some(profile) = app.get_profile_info() {
|
||||||
@@ -435,22 +415,22 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
|
|||||||
|
|
||||||
// Режим поиска по сообщениям
|
// Режим поиска по сообщениям
|
||||||
if app.is_message_search_mode() {
|
if app.is_message_search_mode() {
|
||||||
render_search_mode(f, area, app);
|
modals::render_search(f, area, app);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Режим просмотра закреплённых сообщений
|
// Режим просмотра закреплённых сообщений
|
||||||
if app.is_pinned_mode() {
|
if app.is_pinned_mode() {
|
||||||
render_pinned_mode(f, area, app);
|
modals::render_pinned(f, area, app);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(chat) = app.get_selected_chat() {
|
if let Some(chat) = app.get_selected_chat().cloned() {
|
||||||
// Вычисляем динамическую высоту инпута на основе длины текста
|
// Вычисляем динамическую высоту инпута на основе длины текста
|
||||||
let input_width = area.width.saturating_sub(4) as usize; // -2 для рамок, -2 для "> "
|
let input_width = area.width.saturating_sub(4) as usize; // -2 для рамок, -2 для "> "
|
||||||
let input_text_len = app.message_input.chars().count() + 2; // +2 для "> "
|
let input_lines: u16 = if input_width > 0 {
|
||||||
let input_lines = if input_width > 0 {
|
let len = app.message_input.chars().count() + 2; // +2 для "> "
|
||||||
((input_text_len as f32 / input_width as f32).ceil() as u16).max(1)
|
((len as f32 / input_width as f32).ceil() as u16).max(1)
|
||||||
} else {
|
} else {
|
||||||
1
|
1
|
||||||
};
|
};
|
||||||
@@ -483,7 +463,7 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Chat header с typing status
|
// Chat header с typing status
|
||||||
render_chat_header(f, message_chunks[0], app, chat);
|
render_chat_header(f, message_chunks[0], app, &chat);
|
||||||
|
|
||||||
// Pinned bar (если есть закреплённое сообщение)
|
// Pinned bar (если есть закреплённое сообщение)
|
||||||
render_pinned_bar(f, message_chunks[1], app);
|
render_pinned_bar(f, message_chunks[1], app);
|
||||||
@@ -492,7 +472,7 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
|
|||||||
render_message_list(f, message_chunks[2], app);
|
render_message_list(f, message_chunks[2], app);
|
||||||
|
|
||||||
// Input box с wrap для длинного текста и блочным курсором
|
// Input box с wrap для длинного текста и блочным курсором
|
||||||
render_input_box(f, message_chunks[3], app);
|
compose_bar::render(f, message_chunks[3], app);
|
||||||
} else {
|
} else {
|
||||||
let empty = Paragraph::new("Выберите чат")
|
let empty = Paragraph::new("Выберите чат")
|
||||||
.block(Block::default().borders(Borders::ALL))
|
.block(Block::default().borders(Borders::ALL))
|
||||||
@@ -503,7 +483,7 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
|
|||||||
|
|
||||||
// Модалка подтверждения удаления
|
// Модалка подтверждения удаления
|
||||||
if app.is_confirm_delete_shown() {
|
if app.is_confirm_delete_shown() {
|
||||||
render_delete_confirm_modal(f, area);
|
modals::render_delete_confirm(f, area);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Модалка выбора реакции
|
// Модалка выбора реакции
|
||||||
@@ -513,381 +493,8 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
|
|||||||
..
|
..
|
||||||
} = &app.chat_state
|
} = &app.chat_state
|
||||||
{
|
{
|
||||||
render_reaction_picker_modal(f, area, available_reactions, *selected_index);
|
modals::render_reaction_picker(f, area, available_reactions, *selected_index);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Рендерит режим поиска по сообщениям
|
|
||||||
fn render_search_mode<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
|
|
||||||
// Извлекаем данные из ChatState
|
|
||||||
let (query, results, selected_index) =
|
|
||||||
if let crate::app::ChatState::SearchInChat {
|
|
||||||
query,
|
|
||||||
results,
|
|
||||||
selected_index,
|
|
||||||
} = &app.chat_state
|
|
||||||
{
|
|
||||||
(query.as_str(), results.as_slice(), *selected_index)
|
|
||||||
} else {
|
|
||||||
return; // Некорректное состояние, не рендерим
|
|
||||||
};
|
|
||||||
|
|
||||||
let chunks = Layout::default()
|
|
||||||
.direction(Direction::Vertical)
|
|
||||||
.constraints([
|
|
||||||
Constraint::Length(3), // Search input
|
|
||||||
Constraint::Min(0), // Search results
|
|
||||||
Constraint::Length(3), // Help bar
|
|
||||||
])
|
|
||||||
.split(area);
|
|
||||||
|
|
||||||
// Search input
|
|
||||||
let total = results.len();
|
|
||||||
let current = if total > 0 {
|
|
||||||
selected_index + 1
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
|
|
||||||
let input_line = if query.is_empty() {
|
|
||||||
Line::from(vec![
|
|
||||||
Span::styled("🔍 ", Style::default().fg(Color::Yellow)),
|
|
||||||
Span::styled("█", Style::default().fg(Color::Yellow)),
|
|
||||||
Span::styled(" Введите текст для поиска...", Style::default().fg(Color::Gray)),
|
|
||||||
])
|
|
||||||
} else {
|
|
||||||
Line::from(vec![
|
|
||||||
Span::styled("🔍 ", Style::default().fg(Color::Yellow)),
|
|
||||||
Span::styled(query, Style::default().fg(Color::White)),
|
|
||||||
Span::styled("█", Style::default().fg(Color::Yellow)),
|
|
||||||
Span::styled(format!(" ({}/{})", current, total), Style::default().fg(Color::Gray)),
|
|
||||||
])
|
|
||||||
};
|
|
||||||
|
|
||||||
let search_input = Paragraph::new(input_line).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),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
f.render_widget(search_input, chunks[0]);
|
|
||||||
|
|
||||||
// Search results
|
|
||||||
let content_width = chunks[1].width.saturating_sub(2) as usize;
|
|
||||||
let mut lines: Vec<Line> = Vec::new();
|
|
||||||
|
|
||||||
if results.is_empty() {
|
|
||||||
if !query.is_empty() {
|
|
||||||
lines.push(Line::from(Span::styled(
|
|
||||||
"Ничего не найдено",
|
|
||||||
Style::default().fg(Color::Gray),
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for (idx, msg) in results.iter().enumerate() {
|
|
||||||
let is_selected = idx == selected_index;
|
|
||||||
|
|
||||||
// Пустая строка между результатами
|
|
||||||
if idx > 0 {
|
|
||||||
lines.push(Line::from(""));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Маркер выбора, имя и дата
|
|
||||||
let marker = if is_selected { "▶ " } else { " " };
|
|
||||||
let sender_color = if msg.is_outgoing() {
|
|
||||||
Color::Green
|
|
||||||
} else {
|
|
||||||
Color::Cyan
|
|
||||||
};
|
|
||||||
let sender_name = if msg.is_outgoing() {
|
|
||||||
"Вы".to_string()
|
|
||||||
} else {
|
|
||||||
msg.sender_name().to_string()
|
|
||||||
};
|
|
||||||
|
|
||||||
lines.push(Line::from(vec![
|
|
||||||
Span::styled(
|
|
||||||
marker,
|
|
||||||
Style::default()
|
|
||||||
.fg(Color::Yellow)
|
|
||||||
.add_modifier(Modifier::BOLD),
|
|
||||||
),
|
|
||||||
Span::styled(
|
|
||||||
format!("{} ", sender_name),
|
|
||||||
Style::default()
|
|
||||||
.fg(sender_color)
|
|
||||||
.add_modifier(Modifier::BOLD),
|
|
||||||
),
|
|
||||||
Span::styled(
|
|
||||||
format!("({})", crate::utils::format_datetime(msg.date())),
|
|
||||||
Style::default().fg(Color::Gray),
|
|
||||||
),
|
|
||||||
]));
|
|
||||||
|
|
||||||
// Текст сообщения (с переносом)
|
|
||||||
let msg_color = if is_selected {
|
|
||||||
Color::Yellow
|
|
||||||
} else {
|
|
||||||
Color::White
|
|
||||||
};
|
|
||||||
let max_width = content_width.saturating_sub(4);
|
|
||||||
let wrapped = wrap_text_with_offsets(msg.text(), max_width);
|
|
||||||
let wrapped_count = wrapped.len();
|
|
||||||
|
|
||||||
for wrapped_line in wrapped.into_iter().take(2) {
|
|
||||||
lines.push(Line::from(vec![
|
|
||||||
Span::raw(" "),
|
|
||||||
Span::styled(wrapped_line.text, Style::default().fg(msg_color)),
|
|
||||||
]));
|
|
||||||
}
|
|
||||||
if wrapped_count > 2 {
|
|
||||||
lines.push(Line::from(vec![
|
|
||||||
Span::raw(" "),
|
|
||||||
Span::styled("...", Style::default().fg(Color::Gray)),
|
|
||||||
]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Скролл к выбранному результату
|
|
||||||
let visible_height = chunks[1].height.saturating_sub(2) as usize;
|
|
||||||
let lines_per_result = 4;
|
|
||||||
let selected_line = selected_index * lines_per_result;
|
|
||||||
let scroll_offset = if selected_line > visible_height / 2 {
|
|
||||||
(selected_line - visible_height / 2) as u16
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
|
|
||||||
let results_widget = Paragraph::new(lines)
|
|
||||||
.block(
|
|
||||||
Block::default()
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_style(Style::default().fg(Color::Yellow)),
|
|
||||||
)
|
|
||||||
.scroll((scroll_offset, 0));
|
|
||||||
f.render_widget(results_widget, chunks[1]);
|
|
||||||
|
|
||||||
// Help bar
|
|
||||||
let help_line = Line::from(vec![
|
|
||||||
Span::styled(
|
|
||||||
" ↑↓ ",
|
|
||||||
Style::default()
|
|
||||||
.fg(Color::Yellow)
|
|
||||||
.add_modifier(Modifier::BOLD),
|
|
||||||
),
|
|
||||||
Span::raw("навигация"),
|
|
||||||
Span::raw(" "),
|
|
||||||
Span::styled(
|
|
||||||
" n/N ",
|
|
||||||
Style::default()
|
|
||||||
.fg(Color::Yellow)
|
|
||||||
.add_modifier(Modifier::BOLD),
|
|
||||||
),
|
|
||||||
Span::raw("след./пред."),
|
|
||||||
Span::raw(" "),
|
|
||||||
Span::styled(
|
|
||||||
" Enter ",
|
|
||||||
Style::default()
|
|
||||||
.fg(Color::Green)
|
|
||||||
.add_modifier(Modifier::BOLD),
|
|
||||||
),
|
|
||||||
Span::raw("перейти"),
|
|
||||||
Span::raw(" "),
|
|
||||||
Span::styled(" Esc ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
|
|
||||||
Span::raw("выход"),
|
|
||||||
]);
|
|
||||||
let help = Paragraph::new(help_line)
|
|
||||||
.block(
|
|
||||||
Block::default()
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_style(Style::default().fg(Color::Yellow)),
|
|
||||||
)
|
|
||||||
.alignment(Alignment::Center);
|
|
||||||
f.render_widget(help, chunks[2]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Рендерит режим просмотра закреплённых сообщений
|
|
||||||
fn render_pinned_mode<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
|
|
||||||
// Извлекаем данные из ChatState
|
|
||||||
let (messages, selected_index) = if let crate::app::ChatState::PinnedMessages {
|
|
||||||
messages,
|
|
||||||
selected_index,
|
|
||||||
} = &app.chat_state
|
|
||||||
{
|
|
||||||
(messages.as_slice(), *selected_index)
|
|
||||||
} else {
|
|
||||||
return; // Некорректное состояние
|
|
||||||
};
|
|
||||||
|
|
||||||
let chunks = Layout::default()
|
|
||||||
.direction(Direction::Vertical)
|
|
||||||
.constraints([
|
|
||||||
Constraint::Length(3), // Header
|
|
||||||
Constraint::Min(0), // Pinned messages list
|
|
||||||
Constraint::Length(3), // Help bar
|
|
||||||
])
|
|
||||||
.split(area);
|
|
||||||
|
|
||||||
// Header
|
|
||||||
let total = messages.len();
|
|
||||||
let current = selected_index + 1;
|
|
||||||
let header_text = format!("📌 ЗАКРЕПЛЁННЫЕ ({}/{})", current, total);
|
|
||||||
let header = Paragraph::new(header_text)
|
|
||||||
.block(
|
|
||||||
Block::default()
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_style(Style::default().fg(Color::Magenta)),
|
|
||||||
)
|
|
||||||
.style(
|
|
||||||
Style::default()
|
|
||||||
.fg(Color::Magenta)
|
|
||||||
.add_modifier(Modifier::BOLD),
|
|
||||||
);
|
|
||||||
f.render_widget(header, chunks[0]);
|
|
||||||
|
|
||||||
// Pinned messages list
|
|
||||||
let content_width = chunks[1].width.saturating_sub(2) as usize;
|
|
||||||
let mut lines: Vec<Line> = Vec::new();
|
|
||||||
|
|
||||||
for (idx, msg) in messages.iter().enumerate() {
|
|
||||||
let is_selected = idx == selected_index;
|
|
||||||
|
|
||||||
// Пустая строка между сообщениями
|
|
||||||
if idx > 0 {
|
|
||||||
lines.push(Line::from(""));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Маркер выбора и имя отправителя
|
|
||||||
let marker = if is_selected { "▶ " } else { " " };
|
|
||||||
let sender_color = if msg.is_outgoing() {
|
|
||||||
Color::Green
|
|
||||||
} else {
|
|
||||||
Color::Cyan
|
|
||||||
};
|
|
||||||
let sender_name = if msg.is_outgoing() {
|
|
||||||
"Вы".to_string()
|
|
||||||
} else {
|
|
||||||
msg.sender_name().to_string()
|
|
||||||
};
|
|
||||||
|
|
||||||
lines.push(Line::from(vec![
|
|
||||||
Span::styled(
|
|
||||||
marker,
|
|
||||||
Style::default()
|
|
||||||
.fg(Color::Yellow)
|
|
||||||
.add_modifier(Modifier::BOLD),
|
|
||||||
),
|
|
||||||
Span::styled(
|
|
||||||
format!("{} ", sender_name),
|
|
||||||
Style::default()
|
|
||||||
.fg(sender_color)
|
|
||||||
.add_modifier(Modifier::BOLD),
|
|
||||||
),
|
|
||||||
Span::styled(
|
|
||||||
format!("({})", crate::utils::format_datetime(msg.date())),
|
|
||||||
Style::default().fg(Color::Gray),
|
|
||||||
),
|
|
||||||
]));
|
|
||||||
|
|
||||||
// Текст сообщения (с переносом)
|
|
||||||
let msg_color = if is_selected {
|
|
||||||
Color::Yellow
|
|
||||||
} else {
|
|
||||||
Color::White
|
|
||||||
};
|
|
||||||
let max_width = content_width.saturating_sub(4);
|
|
||||||
let wrapped = wrap_text_with_offsets(msg.text(), max_width);
|
|
||||||
let wrapped_count = wrapped.len();
|
|
||||||
|
|
||||||
for wrapped_line in wrapped.into_iter().take(3) {
|
|
||||||
// Максимум 3 строки на сообщение
|
|
||||||
lines.push(Line::from(vec![
|
|
||||||
Span::raw(" "), // Отступ
|
|
||||||
Span::styled(wrapped_line.text, Style::default().fg(msg_color)),
|
|
||||||
]));
|
|
||||||
}
|
|
||||||
if wrapped_count > 3 {
|
|
||||||
lines.push(Line::from(vec![
|
|
||||||
Span::raw(" "),
|
|
||||||
Span::styled("...", Style::default().fg(Color::Gray)),
|
|
||||||
]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if lines.is_empty() {
|
|
||||||
lines.push(Line::from(Span::styled(
|
|
||||||
"Нет закреплённых сообщений",
|
|
||||||
Style::default().fg(Color::Gray),
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Скролл к выбранному сообщению
|
|
||||||
let visible_height = chunks[1].height.saturating_sub(2) as usize;
|
|
||||||
let lines_per_msg = 5; // Примерно строк на сообщение
|
|
||||||
let selected_line = selected_index * lines_per_msg;
|
|
||||||
let scroll_offset = if selected_line > visible_height / 2 {
|
|
||||||
(selected_line - visible_height / 2) as u16
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
|
|
||||||
let messages_widget = Paragraph::new(lines)
|
|
||||||
.block(
|
|
||||||
Block::default()
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_style(Style::default().fg(Color::Magenta)),
|
|
||||||
)
|
|
||||||
.scroll((scroll_offset, 0));
|
|
||||||
f.render_widget(messages_widget, chunks[1]);
|
|
||||||
|
|
||||||
// Help bar
|
|
||||||
let help_line = Line::from(vec![
|
|
||||||
Span::styled(
|
|
||||||
" ↑↓ ",
|
|
||||||
Style::default()
|
|
||||||
.fg(Color::Yellow)
|
|
||||||
.add_modifier(Modifier::BOLD),
|
|
||||||
),
|
|
||||||
Span::raw("навигация"),
|
|
||||||
Span::raw(" "),
|
|
||||||
Span::styled(
|
|
||||||
" Enter ",
|
|
||||||
Style::default()
|
|
||||||
.fg(Color::Green)
|
|
||||||
.add_modifier(Modifier::BOLD),
|
|
||||||
),
|
|
||||||
Span::raw("перейти"),
|
|
||||||
Span::raw(" "),
|
|
||||||
Span::styled(" Esc ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
|
|
||||||
Span::raw("выход"),
|
|
||||||
]);
|
|
||||||
let help = Paragraph::new(help_line)
|
|
||||||
.block(
|
|
||||||
Block::default()
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_style(Style::default().fg(Color::Magenta)),
|
|
||||||
)
|
|
||||||
.alignment(Alignment::Center);
|
|
||||||
f.render_widget(help, chunks[2]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Рендерит модалку подтверждения удаления
|
|
||||||
fn render_delete_confirm_modal(f: &mut Frame, area: Rect) {
|
|
||||||
components::modal::render_delete_confirm_modal(f, area);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Рендерит модалку выбора реакции
|
|
||||||
fn render_reaction_picker_modal(
|
|
||||||
f: &mut Frame,
|
|
||||||
area: Rect,
|
|
||||||
available_reactions: &[String],
|
|
||||||
selected_index: usize,
|
|
||||||
) {
|
|
||||||
components::render_emoji_picker(f, area, available_reactions, selected_index);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
|
//! UI rendering module.
|
||||||
|
//!
|
||||||
|
//! Routes rendering by screen (Loading → Auth → Main) and checks terminal size.
|
||||||
|
|
||||||
mod auth;
|
mod auth;
|
||||||
pub mod chat_list;
|
pub mod chat_list;
|
||||||
|
mod compose_bar;
|
||||||
pub mod components;
|
pub mod components;
|
||||||
pub mod footer;
|
pub mod footer;
|
||||||
mod loading;
|
mod loading;
|
||||||
mod main_screen;
|
mod main_screen;
|
||||||
pub mod messages;
|
pub mod messages;
|
||||||
|
mod modals;
|
||||||
pub mod profile;
|
pub mod profile;
|
||||||
|
|
||||||
use crate::app::{App, AppScreen};
|
use crate::app::{App, AppScreen};
|
||||||
@@ -33,6 +39,11 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, app: &mut App<T>) {
|
|||||||
AppScreen::Auth => auth::render(f, app),
|
AppScreen::Auth => auth::render(f, app),
|
||||||
AppScreen::Main => main_screen::render(f, app),
|
AppScreen::Main => main_screen::render(f, app),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Global overlay: account switcher (renders on top of ANY screen)
|
||||||
|
if app.account_switcher.is_some() {
|
||||||
|
modals::render_account_switcher(f, area, app);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_size_warning(f: &mut Frame, width: u16, height: u16) {
|
fn render_size_warning(f: &mut Frame, width: u16, height: u16) {
|
||||||
|
|||||||
210
src/ui/modals/account_switcher.rs
Normal file
210
src/ui/modals/account_switcher.rs
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
//! Account switcher modal
|
||||||
|
//!
|
||||||
|
//! Renders a centered popup with account list (SelectAccount) or
|
||||||
|
//! new account name input (AddAccount).
|
||||||
|
|
||||||
|
use crate::app::{AccountSwitcherState, App};
|
||||||
|
use crate::tdlib::TdClientTrait;
|
||||||
|
use ratatui::{
|
||||||
|
layout::Rect,
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Block, Borders, Clear, Paragraph},
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Renders the account switcher modal overlay.
|
||||||
|
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
|
||||||
|
let Some(state) = &app.account_switcher else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
match state {
|
||||||
|
AccountSwitcherState::SelectAccount {
|
||||||
|
accounts,
|
||||||
|
selected_index,
|
||||||
|
current_account,
|
||||||
|
} => {
|
||||||
|
render_select_account(f, area, accounts, *selected_index, current_account);
|
||||||
|
}
|
||||||
|
AccountSwitcherState::AddAccount {
|
||||||
|
name_input,
|
||||||
|
cursor_position,
|
||||||
|
error,
|
||||||
|
} => {
|
||||||
|
render_add_account(f, area, name_input, *cursor_position, error.as_deref());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_select_account(
|
||||||
|
f: &mut Frame,
|
||||||
|
area: Rect,
|
||||||
|
accounts: &[crate::accounts::AccountProfile],
|
||||||
|
selected_index: usize,
|
||||||
|
current_account: &str,
|
||||||
|
) {
|
||||||
|
let mut lines: Vec<Line> = Vec::new();
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
|
||||||
|
for (idx, account) in accounts.iter().enumerate() {
|
||||||
|
let is_selected = idx == selected_index;
|
||||||
|
let is_current = account.name == current_account;
|
||||||
|
|
||||||
|
let marker = if is_current { "● " } else { " " };
|
||||||
|
let suffix = if is_current { " (текущий)" } else { "" };
|
||||||
|
let display = format!(
|
||||||
|
"{}{} ({}){}",
|
||||||
|
marker, account.name, account.display_name, suffix
|
||||||
|
);
|
||||||
|
|
||||||
|
let style = if is_selected {
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Yellow)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else if is_current {
|
||||||
|
Style::default().fg(Color::Green)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::White)
|
||||||
|
};
|
||||||
|
|
||||||
|
lines.push(Line::from(Span::styled(format!(" {}", display), style)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Separator
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
" ──────────────────────",
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
)));
|
||||||
|
|
||||||
|
// Add account item
|
||||||
|
let add_selected = selected_index == accounts.len();
|
||||||
|
let add_style = if add_selected {
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Yellow)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::Cyan)
|
||||||
|
};
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
" + Добавить аккаунт",
|
||||||
|
add_style,
|
||||||
|
)));
|
||||||
|
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
|
||||||
|
// Help bar
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(" j/k ", Style::default().fg(Color::Yellow)),
|
||||||
|
Span::styled("Nav", Style::default().fg(Color::DarkGray)),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(" Enter ", Style::default().fg(Color::Green)),
|
||||||
|
Span::styled("Select", Style::default().fg(Color::DarkGray)),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(" a ", Style::default().fg(Color::Cyan)),
|
||||||
|
Span::styled("Add", Style::default().fg(Color::DarkGray)),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(" Esc ", Style::default().fg(Color::Red)),
|
||||||
|
Span::styled("Close", Style::default().fg(Color::DarkGray)),
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Calculate dynamic height: header(3) + accounts + separator(1) + add(1) + empty(1) + help(1) + footer(1)
|
||||||
|
let content_height = (accounts.len() as u16) + 7;
|
||||||
|
let height = content_height.min(area.height.saturating_sub(4));
|
||||||
|
let width = 40u16.min(area.width.saturating_sub(4));
|
||||||
|
|
||||||
|
let x = area.x + (area.width.saturating_sub(width)) / 2;
|
||||||
|
let y = area.y + (area.height.saturating_sub(height)) / 2;
|
||||||
|
let modal_area = Rect::new(x, y, width, height);
|
||||||
|
|
||||||
|
f.render_widget(Clear, modal_area);
|
||||||
|
|
||||||
|
let modal = Paragraph::new(lines).block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(Color::Cyan))
|
||||||
|
.title(" АККАУНТЫ ")
|
||||||
|
.title_style(
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
f.render_widget(modal, modal_area);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_add_account(
|
||||||
|
f: &mut Frame,
|
||||||
|
area: Rect,
|
||||||
|
name_input: &str,
|
||||||
|
_cursor_position: usize,
|
||||||
|
error: Option<&str>,
|
||||||
|
) {
|
||||||
|
let mut lines: Vec<Line> = Vec::new();
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
|
||||||
|
// Input field
|
||||||
|
let input_display = if name_input.is_empty() {
|
||||||
|
Span::styled("_", Style::default().fg(Color::DarkGray))
|
||||||
|
} else {
|
||||||
|
Span::styled(
|
||||||
|
format!("{}_", name_input),
|
||||||
|
Style::default().fg(Color::White),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(" Имя: ", Style::default().fg(Color::Cyan)),
|
||||||
|
input_display,
|
||||||
|
]));
|
||||||
|
|
||||||
|
// Hint
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
" (a-z, 0-9, -, _)",
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
)));
|
||||||
|
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
|
||||||
|
// Error
|
||||||
|
if let Some(err) = error {
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
format!(" {}", err),
|
||||||
|
Style::default().fg(Color::Red),
|
||||||
|
)));
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Help bar
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(" Enter ", Style::default().fg(Color::Green)),
|
||||||
|
Span::styled("Create", Style::default().fg(Color::DarkGray)),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(" Esc ", Style::default().fg(Color::Red)),
|
||||||
|
Span::styled("Back", Style::default().fg(Color::DarkGray)),
|
||||||
|
]));
|
||||||
|
|
||||||
|
let height = if error.is_some() { 10 } else { 8 };
|
||||||
|
let height = (height as u16).min(area.height.saturating_sub(4));
|
||||||
|
let width = 40u16.min(area.width.saturating_sub(4));
|
||||||
|
|
||||||
|
let x = area.x + (area.width.saturating_sub(width)) / 2;
|
||||||
|
let y = area.y + (area.height.saturating_sub(height)) / 2;
|
||||||
|
let modal_area = Rect::new(x, y, width, height);
|
||||||
|
|
||||||
|
f.render_widget(Clear, modal_area);
|
||||||
|
|
||||||
|
let modal = Paragraph::new(lines).block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(Color::Cyan))
|
||||||
|
.title(" НОВЫЙ АККАУНТ ")
|
||||||
|
.title_style(
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
f.render_widget(modal, modal_area);
|
||||||
|
}
|
||||||
8
src/ui/modals/delete_confirm.rs
Normal file
8
src/ui/modals/delete_confirm.rs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
//! Delete confirmation modal
|
||||||
|
|
||||||
|
use ratatui::{Frame, layout::Rect};
|
||||||
|
|
||||||
|
/// Renders delete confirmation modal
|
||||||
|
pub fn render(f: &mut Frame, area: Rect) {
|
||||||
|
crate::ui::components::modal::render_delete_confirm_modal(f, area);
|
||||||
|
}
|
||||||
185
src/ui/modals/image_viewer.rs
Normal file
185
src/ui/modals/image_viewer.rs
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
//! Модальное окно для полноэкранного просмотра изображений.
|
||||||
|
//!
|
||||||
|
//! Поддерживает:
|
||||||
|
//! - Автоматическое масштабирование с сохранением aspect ratio
|
||||||
|
//! - Максимизация по ширине/высоте терминала
|
||||||
|
//! - Затемнение фона
|
||||||
|
//! - Hotkeys: Esc/q для закрытия, ←/→ для навигации между фото
|
||||||
|
|
||||||
|
use crate::app::App;
|
||||||
|
use crate::tdlib::r#trait::TdClientTrait;
|
||||||
|
use crate::tdlib::ImageModalState;
|
||||||
|
use ratatui::{
|
||||||
|
layout::{Alignment, Rect},
|
||||||
|
style::{Color, Style},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Block, Clear, Paragraph},
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
use ratatui_image::StatefulImage;
|
||||||
|
|
||||||
|
/// Рендерит модальное окно с полноэкранным изображением
|
||||||
|
pub fn render<T: TdClientTrait>(
|
||||||
|
f: &mut Frame,
|
||||||
|
app: &mut App<T>,
|
||||||
|
modal_state: &ImageModalState,
|
||||||
|
) {
|
||||||
|
let area = f.area();
|
||||||
|
|
||||||
|
// Затемняем весь фон
|
||||||
|
f.render_widget(Clear, area);
|
||||||
|
f.render_widget(
|
||||||
|
Block::default().style(Style::default().bg(Color::Black)),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Резервируем место для подсказок (2 строки внизу)
|
||||||
|
let image_area_height = area.height.saturating_sub(2);
|
||||||
|
|
||||||
|
// Вычисляем размер изображения с сохранением aspect ratio
|
||||||
|
let (img_width, img_height) = calculate_modal_size(
|
||||||
|
modal_state.photo_width,
|
||||||
|
modal_state.photo_height,
|
||||||
|
area.width,
|
||||||
|
image_area_height,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Центрируем изображение
|
||||||
|
let img_x = (area.width.saturating_sub(img_width)) / 2;
|
||||||
|
let img_y = (image_area_height.saturating_sub(img_height)) / 2;
|
||||||
|
let img_rect = Rect::new(img_x, img_y, img_width, img_height);
|
||||||
|
|
||||||
|
// Рендерим изображение (используем modal_renderer для высокого качества)
|
||||||
|
if let Some(renderer) = &mut app.modal_image_renderer {
|
||||||
|
// Проверяем есть ли протокол уже в кеше
|
||||||
|
if let Some(protocol) = renderer.get_protocol(&modal_state.message_id) {
|
||||||
|
// Протокол готов - рендерим изображение (iTerm2/Sixel - высокое качество)
|
||||||
|
f.render_stateful_widget(StatefulImage::default(), img_rect, protocol);
|
||||||
|
} else {
|
||||||
|
// Протокола нет - показываем индикатор загрузки
|
||||||
|
let loading_text = vec![
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(Span::styled(
|
||||||
|
"⏳ Загрузка изображения...",
|
||||||
|
Style::default().fg(Color::Gray),
|
||||||
|
)),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(Span::styled(
|
||||||
|
"(декодирование в высоком качестве)",
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
)),
|
||||||
|
];
|
||||||
|
let loading = Paragraph::new(loading_text)
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.block(Block::default());
|
||||||
|
f.render_widget(loading, img_rect);
|
||||||
|
|
||||||
|
// Загружаем изображение (может занять время для iTerm2/Sixel)
|
||||||
|
let _ = renderer.load_image(modal_state.message_id, &modal_state.photo_path);
|
||||||
|
|
||||||
|
// Триггерим перерисовку для показа загруженного изображения
|
||||||
|
app.needs_redraw = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подсказки внизу
|
||||||
|
let hint = "[Esc/q] Закрыть [←/→] Пред/След фото";
|
||||||
|
let hint_y = area.height.saturating_sub(1);
|
||||||
|
let hint_rect = Rect::new(0, hint_y, area.width, 1);
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(Span::styled(hint, Style::default().fg(Color::Gray)))
|
||||||
|
.alignment(Alignment::Center),
|
||||||
|
hint_rect,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Информация о размере (опционально)
|
||||||
|
let info = format!(
|
||||||
|
"{}x{} | {:.1}%",
|
||||||
|
modal_state.photo_width,
|
||||||
|
modal_state.photo_height,
|
||||||
|
(img_width as f64 / modal_state.photo_width as f64) * 100.0
|
||||||
|
);
|
||||||
|
let info_y = area.height.saturating_sub(2);
|
||||||
|
let info_rect = Rect::new(0, info_y, area.width, 1);
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(Span::styled(info, Style::default().fg(Color::DarkGray)))
|
||||||
|
.alignment(Alignment::Center),
|
||||||
|
info_rect,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Вычисляет размер изображения для модалки с сохранением aspect ratio.
|
||||||
|
///
|
||||||
|
/// # Логика масштабирования:
|
||||||
|
/// - Если изображение меньше терминала → показываем как есть
|
||||||
|
/// - Если ширина больше → масштабируем по ширине
|
||||||
|
/// - Если высота больше → масштабируем по высоте
|
||||||
|
/// - Сохраняем aspect ratio
|
||||||
|
fn calculate_modal_size(
|
||||||
|
img_width: i32,
|
||||||
|
img_height: i32,
|
||||||
|
term_width: u16,
|
||||||
|
term_height: u16,
|
||||||
|
) -> (u16, u16) {
|
||||||
|
let aspect_ratio = img_width as f64 / img_height as f64;
|
||||||
|
|
||||||
|
// Если изображение помещается целиком
|
||||||
|
if img_width <= term_width as i32 && img_height <= term_height as i32 {
|
||||||
|
return (img_width as u16, img_height as u16);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Начинаем с максимального размера терминала
|
||||||
|
let mut width = term_width as f64;
|
||||||
|
let mut height = term_height as f64;
|
||||||
|
|
||||||
|
// Подгоняем по aspect ratio
|
||||||
|
let term_aspect = width / height;
|
||||||
|
|
||||||
|
if term_aspect > aspect_ratio {
|
||||||
|
// Терминал шире → ограничены по высоте
|
||||||
|
width = height * aspect_ratio;
|
||||||
|
} else {
|
||||||
|
// Терминал выше → ограничены по ширине
|
||||||
|
height = width / aspect_ratio;
|
||||||
|
}
|
||||||
|
|
||||||
|
(width as u16, height as u16)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_calculate_modal_size_fits() {
|
||||||
|
// Изображение помещается целиком
|
||||||
|
let (w, h) = calculate_modal_size(50, 30, 100, 50);
|
||||||
|
assert_eq!(w, 50);
|
||||||
|
assert_eq!(h, 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_calculate_modal_size_scale_width() {
|
||||||
|
// Ограничены по ширине (изображение шире терминала)
|
||||||
|
let (w, h) = calculate_modal_size(200, 100, 100, 100);
|
||||||
|
assert_eq!(w, 100);
|
||||||
|
assert_eq!(h, 50); // aspect ratio 2:1
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_calculate_modal_size_scale_height() {
|
||||||
|
// Ограничены по высоте (изображение выше терминала)
|
||||||
|
let (w, h) = calculate_modal_size(100, 200, 100, 100);
|
||||||
|
assert_eq!(w, 50); // aspect ratio 1:2
|
||||||
|
assert_eq!(h, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_calculate_modal_size_aspect_ratio() {
|
||||||
|
// Проверка сохранения aspect ratio
|
||||||
|
let (w, h) = calculate_modal_size(1920, 1080, 100, 100);
|
||||||
|
let aspect = w as f64 / h as f64;
|
||||||
|
let expected_aspect = 1920.0 / 1080.0;
|
||||||
|
assert!((aspect - expected_aspect).abs() < 0.01);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/ui/modals/mod.rs
Normal file
27
src/ui/modals/mod.rs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
//! Modal dialog rendering modules
|
||||||
|
//!
|
||||||
|
//! Contains UI rendering for various modal dialogs:
|
||||||
|
//! - account_switcher: Account switcher modal (global overlay)
|
||||||
|
//! - delete_confirm: Delete confirmation modal
|
||||||
|
//! - reaction_picker: Emoji reaction picker modal
|
||||||
|
//! - search: Message search modal
|
||||||
|
//! - pinned: Pinned messages viewer modal
|
||||||
|
//! - image_viewer: Full-screen image viewer modal (images feature)
|
||||||
|
|
||||||
|
pub mod account_switcher;
|
||||||
|
pub mod delete_confirm;
|
||||||
|
pub mod reaction_picker;
|
||||||
|
pub mod search;
|
||||||
|
pub mod pinned;
|
||||||
|
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
pub mod image_viewer;
|
||||||
|
|
||||||
|
pub use account_switcher::render as render_account_switcher;
|
||||||
|
pub use delete_confirm::render as render_delete_confirm;
|
||||||
|
pub use reaction_picker::render as render_reaction_picker;
|
||||||
|
pub use search::render as render_search;
|
||||||
|
pub use pinned::render as render_pinned;
|
||||||
|
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
pub use image_viewer::render as render_image_viewer;
|
||||||
93
src/ui/modals/pinned.rs
Normal file
93
src/ui/modals/pinned.rs
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
//! Pinned messages viewer modal
|
||||||
|
|
||||||
|
use crate::app::App;
|
||||||
|
use crate::tdlib::TdClientTrait;
|
||||||
|
use crate::ui::components::{render_message_item, calculate_scroll_offset, render_help_bar};
|
||||||
|
use ratatui::{
|
||||||
|
layout::{Constraint, Direction, Layout, Rect},
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Block, Borders, Paragraph},
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Renders pinned messages mode
|
||||||
|
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
|
||||||
|
// Извлекаем данные из ChatState
|
||||||
|
let (messages, selected_index) = if let crate::app::ChatState::PinnedMessages {
|
||||||
|
messages,
|
||||||
|
selected_index,
|
||||||
|
} = &app.chat_state
|
||||||
|
{
|
||||||
|
(messages.as_slice(), *selected_index)
|
||||||
|
} else {
|
||||||
|
return; // Некорректное состояние
|
||||||
|
};
|
||||||
|
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(3), // Header
|
||||||
|
Constraint::Min(0), // Pinned messages list
|
||||||
|
Constraint::Length(3), // Help bar
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
// Header
|
||||||
|
let total = messages.len();
|
||||||
|
let current = selected_index + 1;
|
||||||
|
let header_text = format!("📌 ЗАКРЕПЛЁННЫЕ ({}/{})", current, total);
|
||||||
|
let header = Paragraph::new(header_text)
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(Color::Magenta)),
|
||||||
|
)
|
||||||
|
.style(
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Magenta)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
);
|
||||||
|
f.render_widget(header, chunks[0]);
|
||||||
|
|
||||||
|
// Pinned messages list
|
||||||
|
let content_width = chunks[1].width.saturating_sub(2) as usize;
|
||||||
|
let mut lines: Vec<Line> = Vec::new();
|
||||||
|
|
||||||
|
for (idx, msg) in messages.iter().enumerate() {
|
||||||
|
if idx > 0 {
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
}
|
||||||
|
lines.extend(render_message_item(msg, idx == selected_index, content_width, 3));
|
||||||
|
}
|
||||||
|
|
||||||
|
if lines.is_empty() {
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
"Нет закреплённых сообщений",
|
||||||
|
Style::default().fg(Color::Gray),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Скролл к выбранному сообщению
|
||||||
|
let scroll_offset = calculate_scroll_offset(selected_index, 5, chunks[1].height);
|
||||||
|
|
||||||
|
let messages_widget = Paragraph::new(lines)
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(Color::Magenta)),
|
||||||
|
)
|
||||||
|
.scroll((scroll_offset, 0));
|
||||||
|
f.render_widget(messages_widget, chunks[1]);
|
||||||
|
|
||||||
|
// Help bar
|
||||||
|
let help = render_help_bar(
|
||||||
|
&[
|
||||||
|
("↑↓", "навигация", Color::Yellow),
|
||||||
|
("Enter", "перейти", Color::Green),
|
||||||
|
("Esc", "выход", Color::Red),
|
||||||
|
],
|
||||||
|
Color::Magenta,
|
||||||
|
);
|
||||||
|
f.render_widget(help, chunks[2]);
|
||||||
|
}
|
||||||
13
src/ui/modals/reaction_picker.rs
Normal file
13
src/ui/modals/reaction_picker.rs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
//! Reaction picker modal
|
||||||
|
|
||||||
|
use ratatui::{Frame, layout::Rect};
|
||||||
|
|
||||||
|
/// Renders emoji reaction picker modal
|
||||||
|
pub fn render(
|
||||||
|
f: &mut Frame,
|
||||||
|
area: Rect,
|
||||||
|
available_reactions: &[String],
|
||||||
|
selected_index: usize,
|
||||||
|
) {
|
||||||
|
crate::ui::components::render_emoji_picker(f, area, available_reactions, selected_index);
|
||||||
|
}
|
||||||
117
src/ui/modals/search.rs
Normal file
117
src/ui/modals/search.rs
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
//! Message search modal
|
||||||
|
|
||||||
|
use crate::app::App;
|
||||||
|
use crate::tdlib::TdClientTrait;
|
||||||
|
use crate::ui::components::{render_message_item, calculate_scroll_offset, render_help_bar};
|
||||||
|
use ratatui::{
|
||||||
|
layout::{Constraint, Direction, Layout, Rect},
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Block, Borders, Paragraph},
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Renders message search mode
|
||||||
|
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
|
||||||
|
// Извлекаем данные из ChatState
|
||||||
|
let (query, results, selected_index) =
|
||||||
|
if let crate::app::ChatState::SearchInChat {
|
||||||
|
query,
|
||||||
|
results,
|
||||||
|
selected_index,
|
||||||
|
} = &app.chat_state
|
||||||
|
{
|
||||||
|
(query.as_str(), results.as_slice(), *selected_index)
|
||||||
|
} else {
|
||||||
|
return; // Некорректное состояние, не рендерим
|
||||||
|
};
|
||||||
|
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(3), // Search input
|
||||||
|
Constraint::Min(0), // Search results
|
||||||
|
Constraint::Length(3), // Help bar
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
// Search input
|
||||||
|
let total = results.len();
|
||||||
|
let current = if total > 0 {
|
||||||
|
selected_index + 1
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
|
let input_line = if query.is_empty() {
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled("🔍 ", Style::default().fg(Color::Yellow)),
|
||||||
|
Span::styled("█", Style::default().fg(Color::Yellow)),
|
||||||
|
Span::styled(" Введите текст для поиска...", Style::default().fg(Color::Gray)),
|
||||||
|
])
|
||||||
|
} else {
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled("🔍 ", Style::default().fg(Color::Yellow)),
|
||||||
|
Span::styled(query, Style::default().fg(Color::White)),
|
||||||
|
Span::styled("█", Style::default().fg(Color::Yellow)),
|
||||||
|
Span::styled(format!(" ({}/{})", current, total), Style::default().fg(Color::Gray)),
|
||||||
|
])
|
||||||
|
};
|
||||||
|
|
||||||
|
let search_input = Paragraph::new(input_line).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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
f.render_widget(search_input, chunks[0]);
|
||||||
|
|
||||||
|
// Search results
|
||||||
|
let content_width = chunks[1].width.saturating_sub(2) as usize;
|
||||||
|
let mut lines: Vec<Line> = Vec::new();
|
||||||
|
|
||||||
|
if results.is_empty() {
|
||||||
|
if !query.is_empty() {
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
"Ничего не найдено",
|
||||||
|
Style::default().fg(Color::Gray),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (idx, msg) in results.iter().enumerate() {
|
||||||
|
if idx > 0 {
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
}
|
||||||
|
lines.extend(render_message_item(msg, idx == selected_index, content_width, 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Скролл к выбранному результату
|
||||||
|
let scroll_offset = calculate_scroll_offset(selected_index, 4, chunks[1].height);
|
||||||
|
|
||||||
|
let results_widget = Paragraph::new(lines)
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(Color::Yellow)),
|
||||||
|
)
|
||||||
|
.scroll((scroll_offset, 0));
|
||||||
|
f.render_widget(results_widget, chunks[1]);
|
||||||
|
|
||||||
|
// Help bar
|
||||||
|
let help = render_help_bar(
|
||||||
|
&[
|
||||||
|
("↑↓", "навигация", Color::Yellow),
|
||||||
|
("n/N", "след./пред.", Color::Yellow),
|
||||||
|
("Enter", "перейти", Color::Green),
|
||||||
|
("Esc", "выход", Color::Red),
|
||||||
|
],
|
||||||
|
Color::Yellow,
|
||||||
|
);
|
||||||
|
f.render_widget(help, chunks[2]);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
use crate::app::App;
|
use crate::app::App;
|
||||||
|
use crate::app::methods::modal::ModalMethods;
|
||||||
use crate::tdlib::TdClientTrait;
|
use crate::tdlib::TdClientTrait;
|
||||||
use crate::tdlib::ProfileInfo;
|
use crate::tdlib::ProfileInfo;
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
|
|||||||
205
tests/account_switcher.rs
Normal file
205
tests/account_switcher.rs
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
// Integration tests for account switcher modal
|
||||||
|
|
||||||
|
mod helpers;
|
||||||
|
|
||||||
|
use helpers::app_builder::TestAppBuilder;
|
||||||
|
use helpers::test_data::create_test_chat;
|
||||||
|
use tele_tui::app::AccountSwitcherState;
|
||||||
|
|
||||||
|
// ============ Open/Close Tests ============
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_open_account_switcher() {
|
||||||
|
let mut app = TestAppBuilder::new().build();
|
||||||
|
assert!(app.account_switcher.is_none());
|
||||||
|
|
||||||
|
app.open_account_switcher();
|
||||||
|
|
||||||
|
assert!(app.account_switcher.is_some());
|
||||||
|
match &app.account_switcher {
|
||||||
|
Some(AccountSwitcherState::SelectAccount {
|
||||||
|
accounts,
|
||||||
|
selected_index,
|
||||||
|
current_account,
|
||||||
|
}) => {
|
||||||
|
assert!(!accounts.is_empty());
|
||||||
|
assert_eq!(*selected_index, 0);
|
||||||
|
assert_eq!(current_account, "default");
|
||||||
|
}
|
||||||
|
_ => panic!("Expected SelectAccount state"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_close_account_switcher() {
|
||||||
|
let mut app = TestAppBuilder::new().build();
|
||||||
|
app.open_account_switcher();
|
||||||
|
assert!(app.account_switcher.is_some());
|
||||||
|
|
||||||
|
app.close_account_switcher();
|
||||||
|
assert!(app.account_switcher.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Navigation Tests ============
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_account_switcher_navigate_down() {
|
||||||
|
let mut app = TestAppBuilder::new().build();
|
||||||
|
app.open_account_switcher();
|
||||||
|
|
||||||
|
let num_accounts = match &app.account_switcher {
|
||||||
|
Some(AccountSwitcherState::SelectAccount { accounts, .. }) => accounts.len(),
|
||||||
|
_ => panic!("Expected SelectAccount state"),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Navigate down past all accounts to "Add account" item
|
||||||
|
for _ in 0..num_accounts {
|
||||||
|
app.account_switcher_select_next();
|
||||||
|
}
|
||||||
|
|
||||||
|
match &app.account_switcher {
|
||||||
|
Some(AccountSwitcherState::SelectAccount {
|
||||||
|
selected_index,
|
||||||
|
accounts,
|
||||||
|
..
|
||||||
|
}) => {
|
||||||
|
// Should be at the "Add account" item (index == accounts.len())
|
||||||
|
assert_eq!(*selected_index, accounts.len());
|
||||||
|
}
|
||||||
|
_ => panic!("Expected SelectAccount state"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_account_switcher_navigate_up() {
|
||||||
|
let mut app = TestAppBuilder::new().build();
|
||||||
|
app.open_account_switcher();
|
||||||
|
|
||||||
|
// Navigate down first
|
||||||
|
app.account_switcher_select_next();
|
||||||
|
// Navigate back up
|
||||||
|
app.account_switcher_select_prev();
|
||||||
|
|
||||||
|
match &app.account_switcher {
|
||||||
|
Some(AccountSwitcherState::SelectAccount { selected_index, .. }) => {
|
||||||
|
assert_eq!(*selected_index, 0);
|
||||||
|
}
|
||||||
|
_ => panic!("Expected SelectAccount state"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_account_switcher_navigate_up_at_top() {
|
||||||
|
let mut app = TestAppBuilder::new().build();
|
||||||
|
app.open_account_switcher();
|
||||||
|
|
||||||
|
// Already at 0, navigate up should stay at 0
|
||||||
|
app.account_switcher_select_prev();
|
||||||
|
|
||||||
|
match &app.account_switcher {
|
||||||
|
Some(AccountSwitcherState::SelectAccount { selected_index, .. }) => {
|
||||||
|
assert_eq!(*selected_index, 0);
|
||||||
|
}
|
||||||
|
_ => panic!("Expected SelectAccount state"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Confirm Tests ============
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_confirm_current_account_closes_modal() {
|
||||||
|
let mut app = TestAppBuilder::new().build();
|
||||||
|
app.open_account_switcher();
|
||||||
|
|
||||||
|
// Confirm on the current account (default) should just close
|
||||||
|
app.account_switcher_confirm();
|
||||||
|
|
||||||
|
assert!(app.account_switcher.is_none());
|
||||||
|
assert!(app.pending_account_switch.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_confirm_add_account_transitions_to_add_state() {
|
||||||
|
let mut app = TestAppBuilder::new().build();
|
||||||
|
app.open_account_switcher();
|
||||||
|
|
||||||
|
let num_accounts = match &app.account_switcher {
|
||||||
|
Some(AccountSwitcherState::SelectAccount { accounts, .. }) => accounts.len(),
|
||||||
|
_ => panic!("Expected SelectAccount state"),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Navigate past all accounts to "+ Add account"
|
||||||
|
for _ in 0..num_accounts {
|
||||||
|
app.account_switcher_select_next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm should transition to AddAccount
|
||||||
|
app.account_switcher_confirm();
|
||||||
|
|
||||||
|
match &app.account_switcher {
|
||||||
|
Some(AccountSwitcherState::AddAccount {
|
||||||
|
name_input,
|
||||||
|
cursor_position,
|
||||||
|
error,
|
||||||
|
}) => {
|
||||||
|
assert!(name_input.is_empty());
|
||||||
|
assert_eq!(*cursor_position, 0);
|
||||||
|
assert!(error.is_none());
|
||||||
|
}
|
||||||
|
_ => panic!("Expected AddAccount state"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Add Account State Tests ============
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_start_add_from_select() {
|
||||||
|
let mut app = TestAppBuilder::new().build();
|
||||||
|
app.open_account_switcher();
|
||||||
|
|
||||||
|
// Use quick shortcut
|
||||||
|
app.account_switcher_start_add();
|
||||||
|
|
||||||
|
match &app.account_switcher {
|
||||||
|
Some(AccountSwitcherState::AddAccount { .. }) => {}
|
||||||
|
_ => panic!("Expected AddAccount state"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_back_from_add_to_select() {
|
||||||
|
let mut app = TestAppBuilder::new().build();
|
||||||
|
app.open_account_switcher();
|
||||||
|
app.account_switcher_start_add();
|
||||||
|
|
||||||
|
// Go back
|
||||||
|
app.account_switcher_back();
|
||||||
|
|
||||||
|
match &app.account_switcher {
|
||||||
|
Some(AccountSwitcherState::SelectAccount { .. }) => {}
|
||||||
|
_ => panic!("Expected SelectAccount state after back"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Footer Tests ============
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_account_name() {
|
||||||
|
let app = TestAppBuilder::new().build();
|
||||||
|
assert_eq!(app.current_account_name, "default");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_custom_account_name() {
|
||||||
|
let mut app = TestAppBuilder::new().build();
|
||||||
|
app.current_account_name = "work".to_string();
|
||||||
|
assert_eq!(app.current_account_name, "work");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Pending Switch Tests ============
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_pending_switch_initially_none() {
|
||||||
|
let app = TestAppBuilder::new().build();
|
||||||
|
assert!(app.pending_account_switch.is_none());
|
||||||
|
}
|
||||||
182
tests/accounts.rs
Normal file
182
tests/accounts.rs
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
// Integration tests for accounts module
|
||||||
|
|
||||||
|
use tele_tui::accounts::{
|
||||||
|
account_db_path, validate_account_name, AccountProfile, AccountsConfig,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_single_config() {
|
||||||
|
let config = AccountsConfig::default_single();
|
||||||
|
assert_eq!(config.default_account, "default");
|
||||||
|
assert_eq!(config.accounts.len(), 1);
|
||||||
|
assert_eq!(config.accounts[0].name, "default");
|
||||||
|
assert_eq!(config.accounts[0].display_name, "Default");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_find_account_exists() {
|
||||||
|
let config = AccountsConfig::default_single();
|
||||||
|
let account = config.find_account("default");
|
||||||
|
assert!(account.is_some());
|
||||||
|
assert_eq!(account.unwrap().name, "default");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_find_account_not_found() {
|
||||||
|
let config = AccountsConfig::default_single();
|
||||||
|
assert!(config.find_account("work").is_none());
|
||||||
|
assert!(config.find_account("").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_db_path_structure() {
|
||||||
|
let path = account_db_path("default");
|
||||||
|
let path_str = path.to_string_lossy();
|
||||||
|
|
||||||
|
assert!(path_str.contains("tele-tui"));
|
||||||
|
assert!(path_str.contains("accounts"));
|
||||||
|
assert!(path_str.contains("default"));
|
||||||
|
assert!(path_str.ends_with("tdlib_data"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_db_path_per_account() {
|
||||||
|
let path_default = account_db_path("default");
|
||||||
|
let path_work = account_db_path("work");
|
||||||
|
|
||||||
|
assert_ne!(path_default, path_work);
|
||||||
|
assert!(path_default.to_string_lossy().contains("default"));
|
||||||
|
assert!(path_work.to_string_lossy().contains("work"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_account_profile_db_path() {
|
||||||
|
let profile = AccountProfile {
|
||||||
|
name: "test-account".to_string(),
|
||||||
|
display_name: "Test".to_string(),
|
||||||
|
};
|
||||||
|
let path = profile.db_path();
|
||||||
|
assert!(path.to_string_lossy().contains("test-account"));
|
||||||
|
assert!(path.to_string_lossy().ends_with("tdlib_data"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_account_name_valid() {
|
||||||
|
assert!(validate_account_name("default").is_ok());
|
||||||
|
assert!(validate_account_name("work").is_ok());
|
||||||
|
assert!(validate_account_name("my-account").is_ok());
|
||||||
|
assert!(validate_account_name("account123").is_ok());
|
||||||
|
assert!(validate_account_name("test_account").is_ok());
|
||||||
|
assert!(validate_account_name("a").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_account_name_empty() {
|
||||||
|
let err = validate_account_name("").unwrap_err();
|
||||||
|
assert!(err.contains("empty"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_account_name_too_long() {
|
||||||
|
let long_name = "a".repeat(33);
|
||||||
|
let err = validate_account_name(&long_name).unwrap_err();
|
||||||
|
assert!(err.contains("32"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_account_name_uppercase() {
|
||||||
|
assert!(validate_account_name("MyAccount").is_err());
|
||||||
|
assert!(validate_account_name("WORK").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_account_name_spaces() {
|
||||||
|
assert!(validate_account_name("my account").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_account_name_starts_with_dash() {
|
||||||
|
assert!(validate_account_name("-bad").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_account_name_starts_with_underscore() {
|
||||||
|
assert!(validate_account_name("_bad").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_account_name_special_chars() {
|
||||||
|
assert!(validate_account_name("foo@bar").is_err());
|
||||||
|
assert!(validate_account_name("foo.bar").is_err());
|
||||||
|
assert!(validate_account_name("foo/bar").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resolve_account_default() {
|
||||||
|
let config = AccountsConfig::default_single();
|
||||||
|
let result = tele_tui::accounts::resolve_account(&config, None);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
let (name, path) = result.unwrap();
|
||||||
|
assert_eq!(name, "default");
|
||||||
|
assert!(path.to_string_lossy().contains("default"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resolve_account_explicit() {
|
||||||
|
let config = AccountsConfig::default_single();
|
||||||
|
let result = tele_tui::accounts::resolve_account(&config, Some("default"));
|
||||||
|
assert!(result.is_ok());
|
||||||
|
let (name, _) = result.unwrap();
|
||||||
|
assert_eq!(name, "default");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resolve_account_not_found() {
|
||||||
|
let config = AccountsConfig::default_single();
|
||||||
|
let result = tele_tui::accounts::resolve_account(&config, Some("work"));
|
||||||
|
assert!(result.is_err());
|
||||||
|
let err = result.unwrap_err();
|
||||||
|
assert!(err.contains("work"));
|
||||||
|
assert!(err.contains("not found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resolve_account_invalid_name() {
|
||||||
|
let config = AccountsConfig::default_single();
|
||||||
|
let result = tele_tui::accounts::resolve_account(&config, Some("BAD NAME"));
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_accounts_config_serde_roundtrip() {
|
||||||
|
let config = AccountsConfig::default_single();
|
||||||
|
let toml_str = toml::to_string_pretty(&config).unwrap();
|
||||||
|
let parsed: AccountsConfig = toml::from_str(&toml_str).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(parsed.default_account, config.default_account);
|
||||||
|
assert_eq!(parsed.accounts.len(), config.accounts.len());
|
||||||
|
assert_eq!(parsed.accounts[0].name, config.accounts[0].name);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_accounts_config_multi_account_serde() {
|
||||||
|
let config = AccountsConfig {
|
||||||
|
default_account: "default".to_string(),
|
||||||
|
accounts: vec![
|
||||||
|
AccountProfile {
|
||||||
|
name: "default".to_string(),
|
||||||
|
display_name: "Default".to_string(),
|
||||||
|
},
|
||||||
|
AccountProfile {
|
||||||
|
name: "work".to_string(),
|
||||||
|
display_name: "Work".to_string(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
let toml_str = toml::to_string_pretty(&config).unwrap();
|
||||||
|
let parsed: AccountsConfig = toml::from_str(&toml_str).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(parsed.accounts.len(), 2);
|
||||||
|
assert!(parsed.find_account("work").is_some());
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// Integration tests for config flow
|
// Integration tests for config flow
|
||||||
|
|
||||||
use tele_tui::config::{Config, ColorsConfig, GeneralConfig, Keybindings, NotificationsConfig};
|
use tele_tui::config::{AudioConfig, Config, ColorsConfig, GeneralConfig, ImagesConfig, Keybindings, NotificationsConfig};
|
||||||
|
|
||||||
/// Test: Дефолтные значения конфигурации
|
/// Test: Дефолтные значения конфигурации
|
||||||
#[test]
|
#[test]
|
||||||
@@ -34,6 +34,8 @@ fn test_config_custom_values() {
|
|||||||
},
|
},
|
||||||
keybindings: Keybindings::default(),
|
keybindings: Keybindings::default(),
|
||||||
notifications: NotificationsConfig::default(),
|
notifications: NotificationsConfig::default(),
|
||||||
|
images: ImagesConfig::default(),
|
||||||
|
audio: AudioConfig::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
assert_eq!(config.general.timezone, "+05:00");
|
assert_eq!(config.general.timezone, "+05:00");
|
||||||
@@ -118,6 +120,8 @@ fn test_config_toml_serialization() {
|
|||||||
},
|
},
|
||||||
keybindings: Keybindings::default(),
|
keybindings: Keybindings::default(),
|
||||||
notifications: NotificationsConfig::default(),
|
notifications: NotificationsConfig::default(),
|
||||||
|
images: ImagesConfig::default(),
|
||||||
|
audio: AudioConfig::default(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Сериализуем в TOML
|
// Сериализуем в TOML
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
use ratatui::widgets::ListState;
|
use ratatui::widgets::ListState;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use super::FakeTdClient;
|
use super::FakeTdClient;
|
||||||
use tele_tui::app::{App, AppScreen, ChatState};
|
use tele_tui::app::{App, AppScreen, ChatState, InputMode};
|
||||||
use tele_tui::config::Config;
|
use tele_tui::config::Config;
|
||||||
use tele_tui::tdlib::AuthState;
|
use tele_tui::tdlib::AuthState;
|
||||||
use tele_tui::tdlib::{ChatInfo, MessageInfo};
|
use tele_tui::tdlib::{ChatInfo, MessageInfo};
|
||||||
@@ -19,6 +19,7 @@ pub struct TestAppBuilder {
|
|||||||
is_searching: bool,
|
is_searching: bool,
|
||||||
search_query: String,
|
search_query: String,
|
||||||
chat_state: Option<ChatState>,
|
chat_state: Option<ChatState>,
|
||||||
|
input_mode: Option<InputMode>,
|
||||||
messages: HashMap<i64, Vec<MessageInfo>>,
|
messages: HashMap<i64, Vec<MessageInfo>>,
|
||||||
status_message: Option<String>,
|
status_message: Option<String>,
|
||||||
auth_state: Option<AuthState>,
|
auth_state: Option<AuthState>,
|
||||||
@@ -44,6 +45,7 @@ impl TestAppBuilder {
|
|||||||
is_searching: false,
|
is_searching: false,
|
||||||
search_query: String::new(),
|
search_query: String::new(),
|
||||||
chat_state: None,
|
chat_state: None,
|
||||||
|
input_mode: None,
|
||||||
messages: HashMap::new(),
|
messages: HashMap::new(),
|
||||||
status_message: None,
|
status_message: None,
|
||||||
auth_state: None,
|
auth_state: None,
|
||||||
@@ -171,6 +173,12 @@ impl TestAppBuilder {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Установить Insert mode
|
||||||
|
pub fn insert_mode(mut self) -> Self {
|
||||||
|
self.input_mode = Some(InputMode::Insert);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Режим пересылки сообщения
|
/// Режим пересылки сообщения
|
||||||
pub fn forward_mode(mut self, message_id: i64) -> Self {
|
pub fn forward_mode(mut self, message_id: i64) -> Self {
|
||||||
self.chat_state = Some(ChatState::Forward {
|
self.chat_state = Some(ChatState::Forward {
|
||||||
@@ -252,6 +260,11 @@ impl TestAppBuilder {
|
|||||||
app.chat_state = chat_state;
|
app.chat_state = chat_state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Применяем input_mode если он установлен
|
||||||
|
if let Some(input_mode) = self.input_mode {
|
||||||
|
app.input_mode = input_mode;
|
||||||
|
}
|
||||||
|
|
||||||
// Применяем status_message
|
// Применяем status_message
|
||||||
if let Some(status) = self.status_message {
|
if let Some(status) = self.status_message {
|
||||||
app.status_message = Some(status);
|
app.status_message = Some(status);
|
||||||
@@ -283,6 +296,7 @@ impl TestAppBuilder {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::helpers::test_data::create_test_chat;
|
use crate::helpers::test_data::create_test_chat;
|
||||||
|
use tele_tui::app::methods::messages::MessageMethods;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_builder_defaults() {
|
fn test_builder_defaults() {
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ pub struct FakeTdClient {
|
|||||||
// Update channel для симуляции событий
|
// Update channel для симуляции событий
|
||||||
pub update_tx: Arc<Mutex<Option<mpsc::UnboundedSender<TdUpdate>>>>,
|
pub update_tx: Arc<Mutex<Option<mpsc::UnboundedSender<TdUpdate>>>>,
|
||||||
|
|
||||||
|
// Скачанные файлы (file_id -> local_path)
|
||||||
|
pub downloaded_files: Arc<Mutex<HashMap<i32, String>>>,
|
||||||
|
|
||||||
// Настройки поведения
|
// Настройки поведения
|
||||||
pub simulate_delays: bool,
|
pub simulate_delays: bool,
|
||||||
pub fail_next_operation: Arc<Mutex<bool>>,
|
pub fail_next_operation: Arc<Mutex<bool>>,
|
||||||
@@ -121,6 +124,7 @@ impl Clone for FakeTdClient {
|
|||||||
viewed_messages: Arc::clone(&self.viewed_messages),
|
viewed_messages: Arc::clone(&self.viewed_messages),
|
||||||
chat_actions: Arc::clone(&self.chat_actions),
|
chat_actions: Arc::clone(&self.chat_actions),
|
||||||
pending_view_messages: Arc::clone(&self.pending_view_messages),
|
pending_view_messages: Arc::clone(&self.pending_view_messages),
|
||||||
|
downloaded_files: Arc::clone(&self.downloaded_files),
|
||||||
update_tx: Arc::clone(&self.update_tx),
|
update_tx: Arc::clone(&self.update_tx),
|
||||||
simulate_delays: self.simulate_delays,
|
simulate_delays: self.simulate_delays,
|
||||||
fail_next_operation: Arc::clone(&self.fail_next_operation),
|
fail_next_operation: Arc::clone(&self.fail_next_operation),
|
||||||
@@ -154,6 +158,7 @@ impl FakeTdClient {
|
|||||||
viewed_messages: Arc::new(Mutex::new(vec![])),
|
viewed_messages: Arc::new(Mutex::new(vec![])),
|
||||||
chat_actions: Arc::new(Mutex::new(vec![])),
|
chat_actions: Arc::new(Mutex::new(vec![])),
|
||||||
pending_view_messages: Arc::new(Mutex::new(vec![])),
|
pending_view_messages: Arc::new(Mutex::new(vec![])),
|
||||||
|
downloaded_files: Arc::new(Mutex::new(HashMap::new())),
|
||||||
update_tx: Arc::new(Mutex::new(None)),
|
update_tx: Arc::new(Mutex::new(None)),
|
||||||
simulate_delays: false,
|
simulate_delays: false,
|
||||||
fail_next_operation: Arc::new(Mutex::new(false)),
|
fail_next_operation: Arc::new(Mutex::new(false)),
|
||||||
@@ -237,6 +242,12 @@ impl FakeTdClient {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Добавить скачанный файл (для mock download_file)
|
||||||
|
pub fn with_downloaded_file(self, file_id: i32, path: &str) -> Self {
|
||||||
|
self.downloaded_files.lock().unwrap().insert(file_id, path.to_string());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Установить доступные реакции
|
/// Установить доступные реакции
|
||||||
pub fn with_available_reactions(self, reactions: Vec<String>) -> Self {
|
pub fn with_available_reactions(self, reactions: Vec<String>) -> Self {
|
||||||
*self.available_reactions.lock().unwrap() = reactions;
|
*self.available_reactions.lock().unwrap() = reactions;
|
||||||
@@ -587,6 +598,20 @@ impl FakeTdClient {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Скачать файл (mock)
|
||||||
|
pub async fn download_file(&self, file_id: i32) -> Result<String, String> {
|
||||||
|
if self.should_fail() {
|
||||||
|
return Err("Failed to download file".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
self.downloaded_files
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(&file_id)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| format!("File {} not found", file_id))
|
||||||
|
}
|
||||||
|
|
||||||
/// Получить информацию о профиле
|
/// Получить информацию о профиле
|
||||||
pub async fn get_profile_info(&self, chat_id: ChatId) -> Result<ProfileInfo, String> {
|
pub async fn get_profile_info(&self, chat_id: ChatId) -> Result<ProfileInfo, String> {
|
||||||
if self.should_fail() {
|
if self.should_fail() {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
use super::fake_tdclient::FakeTdClient;
|
use super::fake_tdclient::FakeTdClient;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use std::path::PathBuf;
|
||||||
use tdlib_rs::enums::{ChatAction, Update};
|
use tdlib_rs::enums::{ChatAction, Update};
|
||||||
use tele_tui::tdlib::{AuthState, ChatInfo, FolderInfo, MessageInfo, ProfileInfo, ReplyInfo, UserCache, UserOnlineStatus};
|
use tele_tui::tdlib::{AuthState, ChatInfo, FolderInfo, MessageInfo, ProfileInfo, ReplyInfo, UserCache, UserOnlineStatus};
|
||||||
use tele_tui::tdlib::TdClientTrait;
|
use tele_tui::tdlib::TdClientTrait;
|
||||||
@@ -161,6 +162,16 @@ impl TdClientTrait for FakeTdClient {
|
|||||||
FakeTdClient::toggle_reaction(self, chat_id, message_id, reaction).await
|
FakeTdClient::toggle_reaction(self, chat_id, message_id, reaction).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============ File methods ============
|
||||||
|
async fn download_file(&self, file_id: i32) -> Result<String, String> {
|
||||||
|
FakeTdClient::download_file(self, file_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn download_voice_note(&self, file_id: i32) -> Result<String, String> {
|
||||||
|
// Fake implementation: return a fake path
|
||||||
|
Ok(format!("/tmp/fake_voice_{}.ogg", file_id))
|
||||||
|
}
|
||||||
|
|
||||||
// ============ Getters (immutable) ============
|
// ============ Getters (immutable) ============
|
||||||
fn client_id(&self) -> i32 {
|
fn client_id(&self) -> i32 {
|
||||||
0 // Fake client ID
|
0 // Fake client ID
|
||||||
@@ -304,6 +315,12 @@ impl TdClientTrait for FakeTdClient {
|
|||||||
// Not implemented for fake client (notifications are not tested)
|
// Not implemented for fake client (notifications are not tested)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============ Account switching ============
|
||||||
|
async fn recreate_client(&mut self, _db_path: PathBuf) -> Result<(), String> {
|
||||||
|
// No-op for fake client
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
// ============ Update handling ============
|
// ============ Update handling ============
|
||||||
fn handle_update(&mut self, _update: Update) {
|
fn handle_update(&mut self, _update: Update) {
|
||||||
// Not implemented for fake client
|
// Not implemented for fake client
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ pub struct TestMessageBuilder {
|
|||||||
reply_to: Option<ReplyInfo>,
|
reply_to: Option<ReplyInfo>,
|
||||||
forward_from: Option<ForwardInfo>,
|
forward_from: Option<ForwardInfo>,
|
||||||
reactions: Vec<ReactionInfo>,
|
reactions: Vec<ReactionInfo>,
|
||||||
|
media_album_id: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TestMessageBuilder {
|
impl TestMessageBuilder {
|
||||||
@@ -134,6 +135,7 @@ impl TestMessageBuilder {
|
|||||||
reply_to: None,
|
reply_to: None,
|
||||||
forward_from: None,
|
forward_from: None,
|
||||||
reactions: vec![],
|
reactions: vec![],
|
||||||
|
media_album_id: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,8 +189,13 @@ impl TestMessageBuilder {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn media_album_id(mut self, id: i64) -> Self {
|
||||||
|
self.media_album_id = id;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub fn build(self) -> MessageInfo {
|
pub fn build(self) -> MessageInfo {
|
||||||
MessageInfo::new(
|
let mut msg = MessageInfo::new(
|
||||||
MessageId::new(self.id),
|
MessageId::new(self.id),
|
||||||
self.sender_name,
|
self.sender_name,
|
||||||
self.is_outgoing,
|
self.is_outgoing,
|
||||||
@@ -203,7 +210,9 @@ impl TestMessageBuilder {
|
|||||||
self.reply_to,
|
self.reply_to,
|
||||||
self.forward_from,
|
self.forward_from,
|
||||||
self.reactions,
|
self.reactions,
|
||||||
)
|
);
|
||||||
|
msg.metadata.media_album_id = self.media_album_id;
|
||||||
|
msg
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,13 +11,13 @@ use insta::assert_snapshot;
|
|||||||
fn snapshot_empty_input() {
|
fn snapshot_empty_input() {
|
||||||
let chat = create_test_chat("Mom", 123);
|
let chat = create_test_chat("Mom", 123);
|
||||||
|
|
||||||
let app = TestAppBuilder::new()
|
let mut app = TestAppBuilder::new()
|
||||||
.with_chat(chat)
|
.with_chat(chat)
|
||||||
.selected_chat(123)
|
.selected_chat(123)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let buffer = render_to_buffer(80, 24, |f| {
|
let buffer = render_to_buffer(80, 24, |f| {
|
||||||
tele_tui::ui::messages::render(f, f.area(), &app);
|
tele_tui::ui::messages::render(f, f.area(), &mut app);
|
||||||
});
|
});
|
||||||
|
|
||||||
let output = buffer_to_string(&buffer);
|
let output = buffer_to_string(&buffer);
|
||||||
@@ -28,14 +28,15 @@ fn snapshot_empty_input() {
|
|||||||
fn snapshot_input_with_text() {
|
fn snapshot_input_with_text() {
|
||||||
let chat = create_test_chat("Mom", 123);
|
let chat = create_test_chat("Mom", 123);
|
||||||
|
|
||||||
let app = TestAppBuilder::new()
|
let mut app = TestAppBuilder::new()
|
||||||
.with_chat(chat)
|
.with_chat(chat)
|
||||||
.selected_chat(123)
|
.selected_chat(123)
|
||||||
|
.insert_mode()
|
||||||
.message_input("Hello, how are you?")
|
.message_input("Hello, how are you?")
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let buffer = render_to_buffer(80, 24, |f| {
|
let buffer = render_to_buffer(80, 24, |f| {
|
||||||
tele_tui::ui::messages::render(f, f.area(), &app);
|
tele_tui::ui::messages::render(f, f.area(), &mut app);
|
||||||
});
|
});
|
||||||
|
|
||||||
let output = buffer_to_string(&buffer);
|
let output = buffer_to_string(&buffer);
|
||||||
@@ -49,14 +50,15 @@ fn snapshot_input_long_text_2_lines() {
|
|||||||
// Text that wraps to 2 lines
|
// Text that wraps to 2 lines
|
||||||
let long_text = "This is a longer message that will wrap to multiple lines in the input field for testing purposes.";
|
let long_text = "This is a longer message that will wrap to multiple lines in the input field for testing purposes.";
|
||||||
|
|
||||||
let app = TestAppBuilder::new()
|
let mut app = TestAppBuilder::new()
|
||||||
.with_chat(chat)
|
.with_chat(chat)
|
||||||
.selected_chat(123)
|
.selected_chat(123)
|
||||||
|
.insert_mode()
|
||||||
.message_input(long_text)
|
.message_input(long_text)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let buffer = render_to_buffer(80, 24, |f| {
|
let buffer = render_to_buffer(80, 24, |f| {
|
||||||
tele_tui::ui::messages::render(f, f.area(), &app);
|
tele_tui::ui::messages::render(f, f.area(), &mut app);
|
||||||
});
|
});
|
||||||
|
|
||||||
let output = buffer_to_string(&buffer);
|
let output = buffer_to_string(&buffer);
|
||||||
@@ -70,14 +72,15 @@ fn snapshot_input_long_text_max_lines() {
|
|||||||
// Very long text that reaches maximum 10 lines
|
// Very long text that reaches maximum 10 lines
|
||||||
let very_long_text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo.";
|
let very_long_text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo.";
|
||||||
|
|
||||||
let app = TestAppBuilder::new()
|
let mut app = TestAppBuilder::new()
|
||||||
.with_chat(chat)
|
.with_chat(chat)
|
||||||
.selected_chat(123)
|
.selected_chat(123)
|
||||||
|
.insert_mode()
|
||||||
.message_input(very_long_text)
|
.message_input(very_long_text)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let buffer = render_to_buffer(80, 24, |f| {
|
let buffer = render_to_buffer(80, 24, |f| {
|
||||||
tele_tui::ui::messages::render(f, f.area(), &app);
|
tele_tui::ui::messages::render(f, f.area(), &mut app);
|
||||||
});
|
});
|
||||||
|
|
||||||
let output = buffer_to_string(&buffer);
|
let output = buffer_to_string(&buffer);
|
||||||
@@ -91,16 +94,17 @@ fn snapshot_input_editing_mode() {
|
|||||||
.outgoing()
|
.outgoing()
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let app = TestAppBuilder::new()
|
let mut app = TestAppBuilder::new()
|
||||||
.with_chat(chat)
|
.with_chat(chat)
|
||||||
.with_message(123, message)
|
.with_message(123, message)
|
||||||
.selected_chat(123)
|
.selected_chat(123)
|
||||||
|
.insert_mode()
|
||||||
.editing_message(1, 0)
|
.editing_message(1, 0)
|
||||||
.message_input("Edited text here")
|
.message_input("Edited text here")
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let buffer = render_to_buffer(80, 24, |f| {
|
let buffer = render_to_buffer(80, 24, |f| {
|
||||||
tele_tui::ui::messages::render(f, f.area(), &app);
|
tele_tui::ui::messages::render(f, f.area(), &mut app);
|
||||||
});
|
});
|
||||||
|
|
||||||
let output = buffer_to_string(&buffer);
|
let output = buffer_to_string(&buffer);
|
||||||
@@ -114,16 +118,17 @@ fn snapshot_input_reply_mode() {
|
|||||||
.sender("Mom")
|
.sender("Mom")
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let app = TestAppBuilder::new()
|
let mut app = TestAppBuilder::new()
|
||||||
.with_chat(chat)
|
.with_chat(chat)
|
||||||
.with_message(123, original_msg)
|
.with_message(123, original_msg)
|
||||||
.selected_chat(123)
|
.selected_chat(123)
|
||||||
|
.insert_mode()
|
||||||
.replying_to(1)
|
.replying_to(1)
|
||||||
.message_input("I think it's great!")
|
.message_input("I think it's great!")
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let buffer = render_to_buffer(80, 24, |f| {
|
let buffer = render_to_buffer(80, 24, |f| {
|
||||||
tele_tui::ui::messages::render(f, f.area(), &app);
|
tele_tui::ui::messages::render(f, f.area(), &mut app);
|
||||||
});
|
});
|
||||||
|
|
||||||
let output = buffer_to_string(&buffer);
|
let output = buffer_to_string(&buffer);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ mod helpers;
|
|||||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
use helpers::app_builder::TestAppBuilder;
|
use helpers::app_builder::TestAppBuilder;
|
||||||
use helpers::test_data::{create_test_chat, TestMessageBuilder};
|
use helpers::test_data::{create_test_chat, TestMessageBuilder};
|
||||||
|
use tele_tui::app::methods::messages::MessageMethods;
|
||||||
use tele_tui::input::handle_main_input;
|
use tele_tui::input::handle_main_input;
|
||||||
|
|
||||||
fn key(code: KeyCode) -> KeyEvent {
|
fn key(code: KeyCode) -> KeyEvent {
|
||||||
@@ -144,6 +145,7 @@ async fn test_cursor_navigation_in_input() {
|
|||||||
let mut app = TestAppBuilder::new()
|
let mut app = TestAppBuilder::new()
|
||||||
.with_chats(vec![create_test_chat("Chat 1", 101)])
|
.with_chats(vec![create_test_chat("Chat 1", 101)])
|
||||||
.selected_chat(101)
|
.selected_chat(101)
|
||||||
|
.insert_mode()
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
// Вводим текст "Hello"
|
// Вводим текст "Hello"
|
||||||
@@ -181,6 +183,7 @@ async fn test_home_end_in_input() {
|
|||||||
let mut app = TestAppBuilder::new()
|
let mut app = TestAppBuilder::new()
|
||||||
.with_chats(vec![create_test_chat("Chat 1", 101)])
|
.with_chats(vec![create_test_chat("Chat 1", 101)])
|
||||||
.selected_chat(101)
|
.selected_chat(101)
|
||||||
|
.insert_mode()
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
// Вводим текст
|
// Вводим текст
|
||||||
@@ -205,6 +208,7 @@ async fn test_backspace_with_cursor() {
|
|||||||
let mut app = TestAppBuilder::new()
|
let mut app = TestAppBuilder::new()
|
||||||
.with_chats(vec![create_test_chat("Chat 1", 101)])
|
.with_chats(vec![create_test_chat("Chat 1", 101)])
|
||||||
.selected_chat(101)
|
.selected_chat(101)
|
||||||
|
.insert_mode()
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
// Вводим "Hello"
|
// Вводим "Hello"
|
||||||
@@ -237,6 +241,7 @@ async fn test_insert_char_at_cursor_position() {
|
|||||||
let mut app = TestAppBuilder::new()
|
let mut app = TestAppBuilder::new()
|
||||||
.with_chats(vec![create_test_chat("Chat 1", 101)])
|
.with_chats(vec![create_test_chat("Chat 1", 101)])
|
||||||
.selected_chat(101)
|
.selected_chat(101)
|
||||||
|
.insert_mode()
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
// Вводим "Hllo"
|
// Вводим "Hllo"
|
||||||
@@ -258,9 +263,9 @@ async fn test_insert_char_at_cursor_position() {
|
|||||||
assert_eq!(app.cursor_position, 2);
|
assert_eq!(app.cursor_position, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test: Навигация вверх по сообщениям из пустого инпута
|
/// Test: Normal mode автоматически входит в MessageSelection
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_up_arrow_selects_last_message_when_input_empty() {
|
async fn test_normal_mode_auto_enters_message_selection() {
|
||||||
let messages = vec![
|
let messages = vec![
|
||||||
TestMessageBuilder::new("Msg 1", 1).outgoing().build(),
|
TestMessageBuilder::new("Msg 1", 1).outgoing().build(),
|
||||||
TestMessageBuilder::new("Msg 2", 2).outgoing().build(),
|
TestMessageBuilder::new("Msg 2", 2).outgoing().build(),
|
||||||
@@ -273,16 +278,147 @@ async fn test_up_arrow_selects_last_message_when_input_empty() {
|
|||||||
.with_messages(101, messages)
|
.with_messages(101, messages)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
// Инпут пустой
|
// Инпут пустой, Normal mode
|
||||||
assert_eq!(app.message_input, "");
|
assert_eq!(app.message_input, "");
|
||||||
|
|
||||||
// Up - должен начать выбор сообщения (последнего)
|
// Любая клавиша в Normal mode — auto-enters MessageSelection
|
||||||
handle_main_input(&mut app, key(KeyCode::Up)).await;
|
handle_main_input(&mut app, key(KeyCode::Up)).await;
|
||||||
|
|
||||||
// Проверяем что вошли в режим выбора сообщения
|
// Проверяем что вошли в режим выбора сообщения
|
||||||
assert!(app.is_selecting_message());
|
assert!(app.is_selecting_message());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Test: j/k перескакивают через альбом как одно сообщение
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_album_navigation_skips_grouped_messages() {
|
||||||
|
let messages = vec![
|
||||||
|
TestMessageBuilder::new("Before album", 1).sender("Alice").build(),
|
||||||
|
TestMessageBuilder::new("Photo 1", 2)
|
||||||
|
.sender("Alice")
|
||||||
|
.media_album_id(100)
|
||||||
|
.build(),
|
||||||
|
TestMessageBuilder::new("Photo 2", 3)
|
||||||
|
.sender("Alice")
|
||||||
|
.media_album_id(100)
|
||||||
|
.build(),
|
||||||
|
TestMessageBuilder::new("Photo 3", 4)
|
||||||
|
.sender("Alice")
|
||||||
|
.media_album_id(100)
|
||||||
|
.build(),
|
||||||
|
TestMessageBuilder::new("After album", 5).sender("Alice").build(),
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut app = TestAppBuilder::new()
|
||||||
|
.with_chats(vec![create_test_chat("Chat 1", 101)])
|
||||||
|
.selected_chat(101)
|
||||||
|
.with_messages(101, messages)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Входим в режим выбора — начинаем с последнего (index=4, "After album")
|
||||||
|
app.start_message_selection();
|
||||||
|
assert!(app.is_selecting_message());
|
||||||
|
|
||||||
|
let msg = app.get_selected_message().unwrap();
|
||||||
|
assert_eq!(msg.text(), "After album");
|
||||||
|
|
||||||
|
// k (up) — перескакиваем альбом, попадаем на первый элемент альбома (index=1)
|
||||||
|
app.select_previous_message();
|
||||||
|
let msg = app.get_selected_message().unwrap();
|
||||||
|
assert_eq!(msg.text(), "Photo 1");
|
||||||
|
assert_eq!(msg.media_album_id(), 100);
|
||||||
|
|
||||||
|
// k (up) — перескакиваем на сообщение до альбома (index=0)
|
||||||
|
app.select_previous_message();
|
||||||
|
let msg = app.get_selected_message().unwrap();
|
||||||
|
assert_eq!(msg.text(), "Before album");
|
||||||
|
|
||||||
|
// j (down) — перескакиваем на первый элемент альбома (index=1)
|
||||||
|
app.select_next_message();
|
||||||
|
let msg = app.get_selected_message().unwrap();
|
||||||
|
assert_eq!(msg.text(), "Photo 1");
|
||||||
|
|
||||||
|
// j (down) — перескакиваем альбом, попадаем на "After album" (index=4)
|
||||||
|
app.select_next_message();
|
||||||
|
let msg = app.get_selected_message().unwrap();
|
||||||
|
assert_eq!(msg.text(), "After album");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test: Начало выбора, когда последнее сообщение — часть альбома
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_album_navigation_start_at_album_end() {
|
||||||
|
let messages = vec![
|
||||||
|
TestMessageBuilder::new("Regular", 1).sender("Alice").build(),
|
||||||
|
TestMessageBuilder::new("Album Photo 1", 2)
|
||||||
|
.sender("Alice")
|
||||||
|
.media_album_id(200)
|
||||||
|
.build(),
|
||||||
|
TestMessageBuilder::new("Album Photo 2", 3)
|
||||||
|
.sender("Alice")
|
||||||
|
.media_album_id(200)
|
||||||
|
.build(),
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut app = TestAppBuilder::new()
|
||||||
|
.with_chats(vec![create_test_chat("Chat 1", 101)])
|
||||||
|
.selected_chat(101)
|
||||||
|
.with_messages(101, messages)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Входим в режим выбора — должны оказаться на первом элементе альбома (index=1)
|
||||||
|
app.start_message_selection();
|
||||||
|
let msg = app.get_selected_message().unwrap();
|
||||||
|
assert_eq!(msg.text(), "Album Photo 1");
|
||||||
|
|
||||||
|
// k (up) — на обычное сообщение
|
||||||
|
app.select_previous_message();
|
||||||
|
let msg = app.get_selected_message().unwrap();
|
||||||
|
assert_eq!(msg.text(), "Regular");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test: Два альбома подряд — навигация между ними
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_album_navigation_two_albums() {
|
||||||
|
let messages = vec![
|
||||||
|
TestMessageBuilder::new("A1-P1", 1)
|
||||||
|
.sender("Alice")
|
||||||
|
.media_album_id(100)
|
||||||
|
.build(),
|
||||||
|
TestMessageBuilder::new("A1-P2", 2)
|
||||||
|
.sender("Alice")
|
||||||
|
.media_album_id(100)
|
||||||
|
.build(),
|
||||||
|
TestMessageBuilder::new("A2-P1", 3)
|
||||||
|
.sender("Alice")
|
||||||
|
.media_album_id(200)
|
||||||
|
.build(),
|
||||||
|
TestMessageBuilder::new("A2-P2", 4)
|
||||||
|
.sender("Alice")
|
||||||
|
.media_album_id(200)
|
||||||
|
.build(),
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut app = TestAppBuilder::new()
|
||||||
|
.with_chats(vec![create_test_chat("Chat 1", 101)])
|
||||||
|
.selected_chat(101)
|
||||||
|
.with_messages(101, messages)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Начинаем — последний альбом (index=2, первый элемент album 200)
|
||||||
|
app.start_message_selection();
|
||||||
|
let msg = app.get_selected_message().unwrap();
|
||||||
|
assert_eq!(msg.text(), "A2-P1");
|
||||||
|
|
||||||
|
// k — перескакиваем на первый альбом (index=0)
|
||||||
|
app.select_previous_message();
|
||||||
|
let msg = app.get_selected_message().unwrap();
|
||||||
|
assert_eq!(msg.text(), "A1-P1");
|
||||||
|
|
||||||
|
// j — перескакиваем на второй альбом (index=2)
|
||||||
|
app.select_next_message();
|
||||||
|
let msg = app.get_selected_message().unwrap();
|
||||||
|
assert_eq!(msg.text(), "A2-P1");
|
||||||
|
}
|
||||||
|
|
||||||
/// Test: Циклическая навигация по списку чатов (переход с конца в начало)
|
/// Test: Циклическая навигация по списку чатов (переход с конца в начало)
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_circular_navigation_optional() {
|
async fn test_circular_navigation_optional() {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ fn snapshot_empty_chat() {
|
|||||||
.build();
|
.build();
|
||||||
|
|
||||||
let buffer = render_to_buffer(80, 24, |f| {
|
let buffer = render_to_buffer(80, 24, |f| {
|
||||||
tele_tui::ui::messages::render(f, f.area(), &app);
|
tele_tui::ui::messages::render(f, f.area(), &mut app);
|
||||||
});
|
});
|
||||||
|
|
||||||
let output = buffer_to_string(&buffer);
|
let output = buffer_to_string(&buffer);
|
||||||
@@ -39,7 +39,7 @@ fn snapshot_single_incoming_message() {
|
|||||||
.build();
|
.build();
|
||||||
|
|
||||||
let buffer = render_to_buffer(80, 24, |f| {
|
let buffer = render_to_buffer(80, 24, |f| {
|
||||||
tele_tui::ui::messages::render(f, f.area(), &app);
|
tele_tui::ui::messages::render(f, f.area(), &mut app);
|
||||||
});
|
});
|
||||||
|
|
||||||
let output = buffer_to_string(&buffer);
|
let output = buffer_to_string(&buffer);
|
||||||
@@ -58,7 +58,7 @@ fn snapshot_single_outgoing_message() {
|
|||||||
.build();
|
.build();
|
||||||
|
|
||||||
let buffer = render_to_buffer(80, 24, |f| {
|
let buffer = render_to_buffer(80, 24, |f| {
|
||||||
tele_tui::ui::messages::render(f, f.area(), &app);
|
tele_tui::ui::messages::render(f, f.area(), &mut app);
|
||||||
});
|
});
|
||||||
|
|
||||||
let output = buffer_to_string(&buffer);
|
let output = buffer_to_string(&buffer);
|
||||||
@@ -80,7 +80,7 @@ fn snapshot_date_separator_old_date() {
|
|||||||
.build();
|
.build();
|
||||||
|
|
||||||
let buffer = render_to_buffer(80, 24, |f| {
|
let buffer = render_to_buffer(80, 24, |f| {
|
||||||
tele_tui::ui::messages::render(f, f.area(), &app);
|
tele_tui::ui::messages::render(f, f.area(), &mut app);
|
||||||
});
|
});
|
||||||
|
|
||||||
let output = buffer_to_string(&buffer);
|
let output = buffer_to_string(&buffer);
|
||||||
@@ -111,7 +111,7 @@ fn snapshot_sender_grouping() {
|
|||||||
.build();
|
.build();
|
||||||
|
|
||||||
let buffer = render_to_buffer(80, 24, |f| {
|
let buffer = render_to_buffer(80, 24, |f| {
|
||||||
tele_tui::ui::messages::render(f, f.area(), &app);
|
tele_tui::ui::messages::render(f, f.area(), &mut app);
|
||||||
});
|
});
|
||||||
|
|
||||||
let output = buffer_to_string(&buffer);
|
let output = buffer_to_string(&buffer);
|
||||||
@@ -130,7 +130,7 @@ fn snapshot_outgoing_sent() {
|
|||||||
.build();
|
.build();
|
||||||
|
|
||||||
let buffer = render_to_buffer(80, 24, |f| {
|
let buffer = render_to_buffer(80, 24, |f| {
|
||||||
tele_tui::ui::messages::render(f, f.area(), &app);
|
tele_tui::ui::messages::render(f, f.area(), &mut app);
|
||||||
});
|
});
|
||||||
|
|
||||||
let output = buffer_to_string(&buffer);
|
let output = buffer_to_string(&buffer);
|
||||||
@@ -160,7 +160,7 @@ fn snapshot_outgoing_read() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let buffer = render_to_buffer(80, 24, |f| {
|
let buffer = render_to_buffer(80, 24, |f| {
|
||||||
tele_tui::ui::messages::render(f, f.area(), &app);
|
tele_tui::ui::messages::render(f, f.area(), &mut app);
|
||||||
});
|
});
|
||||||
|
|
||||||
let output = buffer_to_string(&buffer);
|
let output = buffer_to_string(&buffer);
|
||||||
@@ -179,7 +179,7 @@ fn snapshot_edited_message() {
|
|||||||
.build();
|
.build();
|
||||||
|
|
||||||
let buffer = render_to_buffer(80, 24, |f| {
|
let buffer = render_to_buffer(80, 24, |f| {
|
||||||
tele_tui::ui::messages::render(f, f.area(), &app);
|
tele_tui::ui::messages::render(f, f.area(), &mut app);
|
||||||
});
|
});
|
||||||
|
|
||||||
let output = buffer_to_string(&buffer);
|
let output = buffer_to_string(&buffer);
|
||||||
@@ -199,7 +199,7 @@ fn snapshot_long_message_wrap() {
|
|||||||
.build();
|
.build();
|
||||||
|
|
||||||
let buffer = render_to_buffer(80, 24, |f| {
|
let buffer = render_to_buffer(80, 24, |f| {
|
||||||
tele_tui::ui::messages::render(f, f.area(), &app);
|
tele_tui::ui::messages::render(f, f.area(), &mut app);
|
||||||
});
|
});
|
||||||
|
|
||||||
let output = buffer_to_string(&buffer);
|
let output = buffer_to_string(&buffer);
|
||||||
@@ -218,7 +218,7 @@ fn snapshot_markdown_bold_italic_code() {
|
|||||||
.build();
|
.build();
|
||||||
|
|
||||||
let buffer = render_to_buffer(80, 24, |f| {
|
let buffer = render_to_buffer(80, 24, |f| {
|
||||||
tele_tui::ui::messages::render(f, f.area(), &app);
|
tele_tui::ui::messages::render(f, f.area(), &mut app);
|
||||||
});
|
});
|
||||||
|
|
||||||
let output = buffer_to_string(&buffer);
|
let output = buffer_to_string(&buffer);
|
||||||
@@ -238,7 +238,7 @@ fn snapshot_markdown_link_mention() {
|
|||||||
.build();
|
.build();
|
||||||
|
|
||||||
let buffer = render_to_buffer(80, 24, |f| {
|
let buffer = render_to_buffer(80, 24, |f| {
|
||||||
tele_tui::ui::messages::render(f, f.area(), &app);
|
tele_tui::ui::messages::render(f, f.area(), &mut app);
|
||||||
});
|
});
|
||||||
|
|
||||||
let output = buffer_to_string(&buffer);
|
let output = buffer_to_string(&buffer);
|
||||||
@@ -257,7 +257,7 @@ fn snapshot_markdown_spoiler() {
|
|||||||
.build();
|
.build();
|
||||||
|
|
||||||
let buffer = render_to_buffer(80, 24, |f| {
|
let buffer = render_to_buffer(80, 24, |f| {
|
||||||
tele_tui::ui::messages::render(f, f.area(), &app);
|
tele_tui::ui::messages::render(f, f.area(), &mut app);
|
||||||
});
|
});
|
||||||
|
|
||||||
let output = buffer_to_string(&buffer);
|
let output = buffer_to_string(&buffer);
|
||||||
@@ -276,7 +276,7 @@ fn snapshot_media_placeholder() {
|
|||||||
.build();
|
.build();
|
||||||
|
|
||||||
let buffer = render_to_buffer(80, 24, |f| {
|
let buffer = render_to_buffer(80, 24, |f| {
|
||||||
tele_tui::ui::messages::render(f, f.area(), &app);
|
tele_tui::ui::messages::render(f, f.area(), &mut app);
|
||||||
});
|
});
|
||||||
|
|
||||||
let output = buffer_to_string(&buffer);
|
let output = buffer_to_string(&buffer);
|
||||||
@@ -297,7 +297,7 @@ fn snapshot_reply_message() {
|
|||||||
.build();
|
.build();
|
||||||
|
|
||||||
let buffer = render_to_buffer(80, 24, |f| {
|
let buffer = render_to_buffer(80, 24, |f| {
|
||||||
tele_tui::ui::messages::render(f, f.area(), &app);
|
tele_tui::ui::messages::render(f, f.area(), &mut app);
|
||||||
});
|
});
|
||||||
|
|
||||||
let output = buffer_to_string(&buffer);
|
let output = buffer_to_string(&buffer);
|
||||||
@@ -318,7 +318,7 @@ fn snapshot_forwarded_message() {
|
|||||||
.build();
|
.build();
|
||||||
|
|
||||||
let buffer = render_to_buffer(80, 24, |f| {
|
let buffer = render_to_buffer(80, 24, |f| {
|
||||||
tele_tui::ui::messages::render(f, f.area(), &app);
|
tele_tui::ui::messages::render(f, f.area(), &mut app);
|
||||||
});
|
});
|
||||||
|
|
||||||
let output = buffer_to_string(&buffer);
|
let output = buffer_to_string(&buffer);
|
||||||
@@ -339,7 +339,7 @@ fn snapshot_single_reaction() {
|
|||||||
.build();
|
.build();
|
||||||
|
|
||||||
let buffer = render_to_buffer(80, 24, |f| {
|
let buffer = render_to_buffer(80, 24, |f| {
|
||||||
tele_tui::ui::messages::render(f, f.area(), &app);
|
tele_tui::ui::messages::render(f, f.area(), &mut app);
|
||||||
});
|
});
|
||||||
|
|
||||||
let output = buffer_to_string(&buffer);
|
let output = buffer_to_string(&buffer);
|
||||||
@@ -361,7 +361,7 @@ fn snapshot_multiple_reactions() {
|
|||||||
.build();
|
.build();
|
||||||
|
|
||||||
let buffer = render_to_buffer(80, 24, |f| {
|
let buffer = render_to_buffer(80, 24, |f| {
|
||||||
tele_tui::ui::messages::render(f, f.area(), &app);
|
tele_tui::ui::messages::render(f, f.area(), &mut app);
|
||||||
});
|
});
|
||||||
|
|
||||||
let output = buffer_to_string(&buffer);
|
let output = buffer_to_string(&buffer);
|
||||||
@@ -381,9 +381,124 @@ fn snapshot_selected_message() {
|
|||||||
.build();
|
.build();
|
||||||
|
|
||||||
let buffer = render_to_buffer(80, 24, |f| {
|
let buffer = render_to_buffer(80, 24, |f| {
|
||||||
tele_tui::ui::messages::render(f, f.area(), &app);
|
tele_tui::ui::messages::render(f, f.area(), &mut app);
|
||||||
});
|
});
|
||||||
|
|
||||||
let output = buffer_to_string(&buffer);
|
let output = buffer_to_string(&buffer);
|
||||||
assert_snapshot!("selected_message", output);
|
assert_snapshot!("selected_message", output);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snapshot_album_incoming() {
|
||||||
|
let chat = create_test_chat("Mom", 123);
|
||||||
|
let msg1 = TestMessageBuilder::new("📷 [Фото]", 1)
|
||||||
|
.sender("Alice")
|
||||||
|
.media_album_id(12345)
|
||||||
|
.build();
|
||||||
|
let msg2 = TestMessageBuilder::new("Caption for album", 2)
|
||||||
|
.sender("Alice")
|
||||||
|
.media_album_id(12345)
|
||||||
|
.build();
|
||||||
|
let msg3 = TestMessageBuilder::new("📷 [Фото]", 3)
|
||||||
|
.sender("Alice")
|
||||||
|
.media_album_id(12345)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let mut app = TestAppBuilder::new()
|
||||||
|
.with_chat(chat)
|
||||||
|
.with_messages(123, vec![msg1, msg2, msg3])
|
||||||
|
.selected_chat(123)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let buffer = render_to_buffer(80, 24, |f| {
|
||||||
|
tele_tui::ui::messages::render(f, f.area(), &mut app);
|
||||||
|
});
|
||||||
|
|
||||||
|
let output = buffer_to_string(&buffer);
|
||||||
|
assert_snapshot!("album_incoming", output);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snapshot_album_outgoing() {
|
||||||
|
let chat = create_test_chat("Mom", 123);
|
||||||
|
let msg1 = TestMessageBuilder::new("📷 [Фото]", 1)
|
||||||
|
.outgoing()
|
||||||
|
.media_album_id(99999)
|
||||||
|
.build();
|
||||||
|
let msg2 = TestMessageBuilder::new("My vacation photos", 2)
|
||||||
|
.outgoing()
|
||||||
|
.media_album_id(99999)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let mut app = TestAppBuilder::new()
|
||||||
|
.with_chat(chat)
|
||||||
|
.with_messages(123, vec![msg1, msg2])
|
||||||
|
.selected_chat(123)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let buffer = render_to_buffer(80, 24, |f| {
|
||||||
|
tele_tui::ui::messages::render(f, f.area(), &mut app);
|
||||||
|
});
|
||||||
|
|
||||||
|
let output = buffer_to_string(&buffer);
|
||||||
|
assert_snapshot!("album_outgoing", output);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snapshot_album_with_regular_messages() {
|
||||||
|
let chat = create_test_chat("Group Chat", 123);
|
||||||
|
let msg1 = TestMessageBuilder::new("Regular message before", 1)
|
||||||
|
.sender("Alice")
|
||||||
|
.build();
|
||||||
|
let msg2 = TestMessageBuilder::new("📷 [Фото]", 2)
|
||||||
|
.sender("Alice")
|
||||||
|
.media_album_id(555)
|
||||||
|
.build();
|
||||||
|
let msg3 = TestMessageBuilder::new("Album caption", 3)
|
||||||
|
.sender("Alice")
|
||||||
|
.media_album_id(555)
|
||||||
|
.build();
|
||||||
|
let msg4 = TestMessageBuilder::new("Regular message after", 4)
|
||||||
|
.sender("Alice")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let mut app = TestAppBuilder::new()
|
||||||
|
.with_chat(chat)
|
||||||
|
.with_messages(123, vec![msg1, msg2, msg3, msg4])
|
||||||
|
.selected_chat(123)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let buffer = render_to_buffer(80, 24, |f| {
|
||||||
|
tele_tui::ui::messages::render(f, f.area(), &mut app);
|
||||||
|
});
|
||||||
|
|
||||||
|
let output = buffer_to_string(&buffer);
|
||||||
|
assert_snapshot!("album_with_regular_messages", output);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snapshot_album_selected() {
|
||||||
|
let chat = create_test_chat("Mom", 123);
|
||||||
|
let msg1 = TestMessageBuilder::new("📷 [Фото]", 1)
|
||||||
|
.sender("Alice")
|
||||||
|
.media_album_id(777)
|
||||||
|
.build();
|
||||||
|
let msg2 = TestMessageBuilder::new("📷 [Фото]", 2)
|
||||||
|
.sender("Alice")
|
||||||
|
.media_album_id(777)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let mut app = TestAppBuilder::new()
|
||||||
|
.with_chat(chat)
|
||||||
|
.with_messages(123, vec![msg1, msg2])
|
||||||
|
.selected_chat(123)
|
||||||
|
.selecting_message(1) // Выбираем одно из сообщений альбома
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let buffer = render_to_buffer(80, 24, |f| {
|
||||||
|
tele_tui::ui::messages::render(f, f.area(), &mut app);
|
||||||
|
});
|
||||||
|
|
||||||
|
let output = buffer_to_string(&buffer);
|
||||||
|
assert_snapshot!("album_selected", output);
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ fn snapshot_delete_confirmation_modal() {
|
|||||||
let chat = create_test_chat("Mom", 123);
|
let chat = create_test_chat("Mom", 123);
|
||||||
let message = TestMessageBuilder::new("Delete me", 1).outgoing().build();
|
let message = TestMessageBuilder::new("Delete me", 1).outgoing().build();
|
||||||
|
|
||||||
let app = TestAppBuilder::new()
|
let mut app = TestAppBuilder::new()
|
||||||
.with_chat(chat)
|
.with_chat(chat)
|
||||||
.with_message(123, message)
|
.with_message(123, message)
|
||||||
.selected_chat(123)
|
.selected_chat(123)
|
||||||
@@ -23,7 +23,7 @@ fn snapshot_delete_confirmation_modal() {
|
|||||||
.build();
|
.build();
|
||||||
|
|
||||||
let buffer = render_to_buffer(80, 24, |f| {
|
let buffer = render_to_buffer(80, 24, |f| {
|
||||||
tele_tui::ui::messages::render(f, f.area(), &app);
|
tele_tui::ui::messages::render(f, f.area(), &mut app);
|
||||||
});
|
});
|
||||||
|
|
||||||
let output = buffer_to_string(&buffer);
|
let output = buffer_to_string(&buffer);
|
||||||
@@ -37,7 +37,7 @@ fn snapshot_emoji_picker_default() {
|
|||||||
|
|
||||||
let reactions = vec!["👍".to_string(), "👎".to_string(), "❤️".to_string(), "🔥".to_string(), "😊".to_string(), "😢".to_string(), "😮".to_string(), "🎉".to_string()];
|
let reactions = vec!["👍".to_string(), "👎".to_string(), "❤️".to_string(), "🔥".to_string(), "😊".to_string(), "😢".to_string(), "😮".to_string(), "🎉".to_string()];
|
||||||
|
|
||||||
let app = TestAppBuilder::new()
|
let mut app = TestAppBuilder::new()
|
||||||
.with_chat(chat)
|
.with_chat(chat)
|
||||||
.with_message(123, message)
|
.with_message(123, message)
|
||||||
.selected_chat(123)
|
.selected_chat(123)
|
||||||
@@ -45,7 +45,7 @@ fn snapshot_emoji_picker_default() {
|
|||||||
.build();
|
.build();
|
||||||
|
|
||||||
let buffer = render_to_buffer(80, 24, |f| {
|
let buffer = render_to_buffer(80, 24, |f| {
|
||||||
tele_tui::ui::messages::render(f, f.area(), &app);
|
tele_tui::ui::messages::render(f, f.area(), &mut app);
|
||||||
});
|
});
|
||||||
|
|
||||||
let output = buffer_to_string(&buffer);
|
let output = buffer_to_string(&buffer);
|
||||||
@@ -72,7 +72,7 @@ fn snapshot_emoji_picker_with_selection() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let buffer = render_to_buffer(80, 24, |f| {
|
let buffer = render_to_buffer(80, 24, |f| {
|
||||||
tele_tui::ui::messages::render(f, f.area(), &app);
|
tele_tui::ui::messages::render(f, f.area(), &mut app);
|
||||||
});
|
});
|
||||||
|
|
||||||
let output = buffer_to_string(&buffer);
|
let output = buffer_to_string(&buffer);
|
||||||
@@ -84,14 +84,14 @@ fn snapshot_profile_personal_chat() {
|
|||||||
let chat = create_test_chat("Alice", 123);
|
let chat = create_test_chat("Alice", 123);
|
||||||
let profile = create_test_profile("Alice", 123);
|
let profile = create_test_profile("Alice", 123);
|
||||||
|
|
||||||
let app = TestAppBuilder::new()
|
let mut app = TestAppBuilder::new()
|
||||||
.with_chat(chat)
|
.with_chat(chat)
|
||||||
.selected_chat(123)
|
.selected_chat(123)
|
||||||
.profile_mode(profile)
|
.profile_mode(profile)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let buffer = render_to_buffer(80, 24, |f| {
|
let buffer = render_to_buffer(80, 24, |f| {
|
||||||
tele_tui::ui::messages::render(f, f.area(), &app);
|
tele_tui::ui::messages::render(f, f.area(), &mut app);
|
||||||
});
|
});
|
||||||
|
|
||||||
let output = buffer_to_string(&buffer);
|
let output = buffer_to_string(&buffer);
|
||||||
@@ -108,14 +108,14 @@ fn snapshot_profile_group_chat() {
|
|||||||
profile.member_count = Some(25);
|
profile.member_count = Some(25);
|
||||||
profile.description = Some("Work discussion group".to_string());
|
profile.description = Some("Work discussion group".to_string());
|
||||||
|
|
||||||
let app = TestAppBuilder::new()
|
let mut app = TestAppBuilder::new()
|
||||||
.with_chat(chat)
|
.with_chat(chat)
|
||||||
.selected_chat(456)
|
.selected_chat(456)
|
||||||
.profile_mode(profile)
|
.profile_mode(profile)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let buffer = render_to_buffer(80, 24, |f| {
|
let buffer = render_to_buffer(80, 24, |f| {
|
||||||
tele_tui::ui::messages::render(f, f.area(), &app);
|
tele_tui::ui::messages::render(f, f.area(), &mut app);
|
||||||
});
|
});
|
||||||
|
|
||||||
let output = buffer_to_string(&buffer);
|
let output = buffer_to_string(&buffer);
|
||||||
@@ -138,7 +138,7 @@ fn snapshot_pinned_message() {
|
|||||||
app.td_client.set_current_pinned_message(Some(pinned_msg));
|
app.td_client.set_current_pinned_message(Some(pinned_msg));
|
||||||
|
|
||||||
let buffer = render_to_buffer(80, 24, |f| {
|
let buffer = render_to_buffer(80, 24, |f| {
|
||||||
tele_tui::ui::messages::render(f, f.area(), &app);
|
tele_tui::ui::messages::render(f, f.area(), &mut app);
|
||||||
});
|
});
|
||||||
|
|
||||||
let output = buffer_to_string(&buffer);
|
let output = buffer_to_string(&buffer);
|
||||||
@@ -166,7 +166,7 @@ fn snapshot_search_in_chat() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let buffer = render_to_buffer(80, 24, |f| {
|
let buffer = render_to_buffer(80, 24, |f| {
|
||||||
tele_tui::ui::messages::render(f, f.area(), &app);
|
tele_tui::ui::messages::render(f, f.area(), &mut app);
|
||||||
});
|
});
|
||||||
|
|
||||||
let output = buffer_to_string(&buffer);
|
let output = buffer_to_string(&buffer);
|
||||||
|
|||||||
@@ -24,5 +24,5 @@ expression: output
|
|||||||
│ │
|
│ │
|
||||||
└──────────────────────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
│> █ Введите сообщение... │
|
│> Press i to type... │
|
||||||
└──────────────────────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|||||||
28
tests/snapshots/messages__album_incoming.snap
Normal file
28
tests/snapshots/messages__album_incoming.snap
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
source: tests/messages.rs
|
||||||
|
expression: output
|
||||||
|
---
|
||||||
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│👤 Mom │
|
||||||
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ ──────── 02.01.2022 ──────── │
|
||||||
|
│ │
|
||||||
|
│Alice ──────────────── │
|
||||||
|
│ (14:33) 📷 [Фото] │
|
||||||
|
│ (14:33) Caption for album │
|
||||||
|
│ (14:33) 📷 [Фото] │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│> Press i to type... │
|
||||||
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
28
tests/snapshots/messages__album_outgoing.snap
Normal file
28
tests/snapshots/messages__album_outgoing.snap
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
source: tests/messages.rs
|
||||||
|
expression: output
|
||||||
|
---
|
||||||
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│👤 Mom │
|
||||||
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ ──────── 02.01.2022 ──────── │
|
||||||
|
│ │
|
||||||
|
│ Вы ──────────────── │
|
||||||
|
│ 📷 [Фото] (14:33 ✓✓)│
|
||||||
|
│ My vacation photos (14:33 ✓✓) │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│> Press i to type... │
|
||||||
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
28
tests/snapshots/messages__album_selected.snap
Normal file
28
tests/snapshots/messages__album_selected.snap
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
source: tests/messages.rs
|
||||||
|
expression: output
|
||||||
|
---
|
||||||
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│👤 Mom │
|
||||||
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ ──────── 02.01.2022 ──────── │
|
||||||
|
│ │
|
||||||
|
│Alice ──────────────── │
|
||||||
|
│ (14:33) 📷 [Фото] │
|
||||||
|
│▶ (14:33) 📷 [Фото] │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
┌ Выбор сообщения ─────────────────────────────────────────────────────────────┐
|
||||||
|
│↑↓ · r ответ · f переслать · y копир. · d удалить · Esc │
|
||||||
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
28
tests/snapshots/messages__album_with_regular_messages.snap
Normal file
28
tests/snapshots/messages__album_with_regular_messages.snap
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
source: tests/messages.rs
|
||||||
|
expression: output
|
||||||
|
---
|
||||||
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│👤 Group Chat │
|
||||||
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ ──────── 02.01.2022 ──────── │
|
||||||
|
│ │
|
||||||
|
│Alice ──────────────── │
|
||||||
|
│ (14:33) Regular message before │
|
||||||
|
│ (14:33) 📷 [Фото] │
|
||||||
|
│ (14:33) Album caption │
|
||||||
|
│ (14:33) Regular message after │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│> Press i to type... │
|
||||||
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
@@ -24,5 +24,5 @@ expression: output
|
|||||||
│ │
|
│ │
|
||||||
└──────────────────────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
│> █ Введите сообщение... │
|
│> Press i to type... │
|
||||||
└──────────────────────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|||||||
@@ -24,5 +24,5 @@ expression: output
|
|||||||
│ │
|
│ │
|
||||||
└──────────────────────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
│> █ Введите сообщение... │
|
│> Press i to type... │
|
||||||
└──────────────────────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|||||||
@@ -24,5 +24,5 @@ expression: output
|
|||||||
│ │
|
│ │
|
||||||
└──────────────────────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
│> █ Введите сообщение... │
|
│> Press i to type... │
|
||||||
└──────────────────────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|||||||
@@ -24,5 +24,5 @@ expression: output
|
|||||||
│ │
|
│ │
|
||||||
└──────────────────────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
│> █ Введите сообщение... │
|
│> Press i to type... │
|
||||||
└──────────────────────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|||||||
@@ -24,5 +24,5 @@ expression: output
|
|||||||
│ │
|
│ │
|
||||||
└──────────────────────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
│> █ Введите сообщение... │
|
│> Press i to type... │
|
||||||
└──────────────────────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|||||||
@@ -24,5 +24,5 @@ expression: output
|
|||||||
│ │
|
│ │
|
||||||
└──────────────────────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
│> █ Введите сообщение... │
|
│> Press i to type... │
|
||||||
└──────────────────────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|||||||
@@ -24,5 +24,5 @@ expression: output
|
|||||||
│ │
|
│ │
|
||||||
└──────────────────────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
│> █ Введите сообщение... │
|
│> Press i to type... │
|
||||||
└──────────────────────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|||||||
@@ -24,5 +24,5 @@ expression: output
|
|||||||
│ │
|
│ │
|
||||||
└──────────────────────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
│> █ Введите сообщение... │
|
│> Press i to type... │
|
||||||
└──────────────────────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|||||||
@@ -24,5 +24,5 @@ expression: output
|
|||||||
│ │
|
│ │
|
||||||
└──────────────────────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
│> █ Введите сообщение... │
|
│> Press i to type... │
|
||||||
└──────────────────────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|||||||
@@ -24,5 +24,5 @@ expression: output
|
|||||||
│ │
|
│ │
|
||||||
└──────────────────────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
│> █ Введите сообщение... │
|
│> Press i to type... │
|
||||||
└──────────────────────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|||||||
@@ -24,5 +24,5 @@ expression: output
|
|||||||
│ │
|
│ │
|
||||||
└──────────────────────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
│> █ Введите сообщение... │
|
│> Press i to type... │
|
||||||
└──────────────────────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|||||||
@@ -24,5 +24,5 @@ expression: output
|
|||||||
│ │
|
│ │
|
||||||
└──────────────────────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
│> █ Введите сообщение... │
|
│> Press i to type... │
|
||||||
└──────────────────────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|||||||
@@ -24,5 +24,5 @@ expression: output
|
|||||||
│ │
|
│ │
|
||||||
└──────────────────────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
│> █ Введите сообщение... │
|
│> Press i to type... │
|
||||||
└──────────────────────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|||||||
@@ -24,5 +24,5 @@ expression: output
|
|||||||
│ │
|
│ │
|
||||||
└──────────────────────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
│> █ Введите сообщение... │
|
│> Press i to type... │
|
||||||
└──────────────────────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|||||||
@@ -24,5 +24,5 @@ expression: output
|
|||||||
│ │
|
│ │
|
||||||
└──────────────────────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
│> █ Введите сообщение... │
|
│> Press i to type... │
|
||||||
└──────────────────────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|||||||
@@ -24,5 +24,5 @@ expression: output
|
|||||||
│ │
|
│ │
|
||||||
└──────────────────────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
│> █ Введите сообщение... │
|
│> Press i to type... │
|
||||||
└──────────────────────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user