Merge pull request 'ci/woodpecker-checks' (#21) from ci/woodpecker-checks into main

This commit is contained in:
2026-02-22 15:12:46 +00:00
136 changed files with 11110 additions and 7144 deletions

View File

@@ -1,50 +0,0 @@
name: CI
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
env:
CARGO_TERM_COLOR: always
jobs:
check:
name: Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- run: cargo check --all-features
fmt:
name: Format
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
- run: cargo fmt --all -- --check
clippy:
name: Clippy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- run: cargo clippy --all-features -- -D warnings
build:
name: Build
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- run: cargo build --release --all-features

26
.woodpecker/check.yml Normal file
View File

@@ -0,0 +1,26 @@
when:
- event: pull_request
steps:
- name: fmt
image: rust:latest
commands:
- rustup component add rustfmt
- cargo fmt -- --check
- name: clippy
image: rust:latest
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:latest
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

File diff suppressed because it is too large Load Diff

650
Cargo.lock generated
View File

@@ -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]]

View File

@@ -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"

View File

@@ -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/
├── .github/ # GitHub конфигурация ├── src/
│ ├── ISSUE_TEMPLATE/ # Шаблоны для issue │ ├── main.rs # Точка входа, event loop
│ ├── bug_report.md │ ├── lib.rs # Экспорт модулей для тестов
│ └── feature_request.md ├── types.rs # ChatId, MessageId (newtype wrappers)
│ ├── workflows/ # GitHub Actions CI/CD │ ├── constants.rs # MAX_MESSAGES_IN_CHAT, etc.
│ └── ci.yml ├── 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 конфигурация
│ ├── ISSUE_TEMPLATE/
│ ├── workflows/ci.yml
│ └── pull_request_template.md │ └── pull_request_template.md
├── docs/ # Дополнительная документация ├── Cargo.toml # Манифест проекта
│ └── TDLIB_INTEGRATION.md ├── Cargo.lock # Точные версии зависимостей
├── build.rs # Build script (TDLib)
├── rustfmt.toml # cargo fmt конфигурация
├── .editorconfig # Настройки IDE
├── .gitignore # Git ignore
├── src/ # Исходный код ├── config.toml.example # Пример конфигурации
│ ├── app/ # Состояние приложения ├── credentials.example # Пример credentials
│ │ ├── 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 сессия (НЕ коммитится) ├── CLAUDE.md # Инструкции для AI
├── target/ # Артефакты сборки (НЕ коммитится) ├── CONTEXT.md # Текущий статус
├── ROADMAP.md # План развития
├── .editorconfig # EditorConfig для IDE ├── DEVELOPMENT.md # Правила разработки
├── .gitignore # Git ignore правила ├── REQUIREMENTS.md # Требования
├── Cargo.lock # Зависимости (точные версии) ├── ARCHITECTURE.md # C4, sequence diagrams
├── Cargo.toml # Манифест проекта ├── PROJECT_STRUCTURE.md # Этот файл
├── rustfmt.toml # Конфигурация форматирования ├── E2E_TESTING.md # Гайд по тестированию
├── HOTKEYS.md # Горячие клавиши
├── config.toml.example # Пример конфигурации ├── CHANGELOG.md # История изменений
├── credentials.example # Пример credentials ├── README.md # Главная документация
├── INSTALL.md # Установка
├── CHANGELOG.md # История изменений ├── FAQ.md # FAQ
├── CLAUDE.md # Инструкции для Claude AI ├── CONTRIBUTING.md # Гайд по контрибуции
├── CONTRIBUTING.md # Гайд по контрибуции ├── SECURITY.md # Безопасность
── CONTEXT.md # Текущий статус разработки ── LICENSE # MIT лицензия
├── DEVELOPMENT.md # Правила разработки
├── FAQ.md # Часто задаваемые вопросы
├── HOTKEYS.md # Список горячих клавиш
├── INSTALL.md # Инструкция по установке
├── LICENSE # MIT лицензия
├── PROJECT_STRUCTURE.md # Этот файл
├── README.md # Главная документация
├── REQUIREMENTS.md # Функциональные требования
├── ROADMAP.md # План развития
└── SECURITY.md # Политика безопасности
``` ```
## Исходный код (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/`

View File

@@ -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 со всех аккаунтов

View File

@@ -1,6 +1,6 @@
use criterion::{black_box, criterion_group, criterion_main, Criterion}; use criterion::{black_box, criterion_group, criterion_main, Criterion};
use tele_tui::formatting::format_text_with_entities;
use tdlib_rs::enums::{TextEntity, TextEntityType}; use tdlib_rs::enums::{TextEntity, TextEntityType};
use tele_tui::formatting::format_text_with_entities;
fn create_text_with_entities() -> (String, Vec<TextEntity>) { fn create_text_with_entities() -> (String, Vec<TextEntity>) {
let text = "This is bold and italic text with code and a link and mention".to_string(); let text = "This is bold and italic text with code and a link and mention".to_string();
@@ -41,9 +41,7 @@ fn benchmark_format_simple_text(c: &mut Criterion) {
let entities = vec![]; let entities = vec![];
c.bench_function("format_simple_text", |b| { c.bench_function("format_simple_text", |b| {
b.iter(|| { b.iter(|| format_text_with_entities(black_box(&text), black_box(&entities)));
format_text_with_entities(black_box(&text), black_box(&entities))
});
}); });
} }
@@ -51,9 +49,7 @@ fn benchmark_format_markdown_text(c: &mut Criterion) {
let (text, entities) = create_text_with_entities(); let (text, entities) = create_text_with_entities();
c.bench_function("format_markdown_text", |b| { c.bench_function("format_markdown_text", |b| {
b.iter(|| { b.iter(|| format_text_with_entities(black_box(&text), black_box(&entities)));
format_text_with_entities(black_box(&text), black_box(&entities))
});
}); });
} }
@@ -77,9 +73,7 @@ fn benchmark_format_long_text(c: &mut Criterion) {
} }
c.bench_function("format_long_text_with_100_entities", |b| { c.bench_function("format_long_text_with_100_entities", |b| {
b.iter(|| { b.iter(|| format_text_with_entities(black_box(&text), black_box(&entities)));
format_text_with_entities(black_box(&text), black_box(&entities))
});
}); });
} }

View File

@@ -1,5 +1,5 @@
use criterion::{black_box, criterion_group, criterion_main, Criterion}; use criterion::{black_box, criterion_group, criterion_main, Criterion};
use tele_tui::utils::formatting::{format_timestamp_with_tz, format_date, get_day}; use tele_tui::utils::formatting::{format_date, format_timestamp_with_tz, get_day};
fn benchmark_format_timestamp(c: &mut Criterion) { fn benchmark_format_timestamp(c: &mut Criterion) {
c.bench_function("format_timestamp_50_times", |b| { c.bench_function("format_timestamp_50_times", |b| {
@@ -34,10 +34,5 @@ fn benchmark_get_day(c: &mut Criterion) {
}); });
} }
criterion_group!( criterion_group!(benches, benchmark_format_timestamp, benchmark_format_date, benchmark_get_day);
benches,
benchmark_format_timestamp,
benchmark_format_date,
benchmark_get_day
);
criterion_main!(benches); criterion_main!(benches);

View File

@@ -8,7 +8,10 @@ fn create_test_messages(count: usize) -> Vec<tele_tui::tdlib::MessageInfo> {
.map(|i| { .map(|i| {
let builder = MessageBuilder::new(MessageId::new(i as i64)) let builder = MessageBuilder::new(MessageId::new(i as i64))
.sender_name(&format!("User{}", i % 10)) .sender_name(&format!("User{}", i % 10))
.text(&format!("Test message number {} with some longer text to make it more realistic", i)) .text(&format!(
"Test message number {} with some longer text to make it more realistic",
i
))
.date(1640000000 + (i as i32 * 60)); .date(1640000000 + (i as i32 * 60));
if i % 2 == 0 { if i % 2 == 0 {
@@ -24,9 +27,7 @@ fn benchmark_group_100_messages(c: &mut Criterion) {
let messages = create_test_messages(100); let messages = create_test_messages(100);
c.bench_function("group_100_messages", |b| { c.bench_function("group_100_messages", |b| {
b.iter(|| { b.iter(|| group_messages(black_box(&messages)));
group_messages(black_box(&messages))
});
}); });
} }
@@ -34,9 +35,7 @@ fn benchmark_group_500_messages(c: &mut Criterion) {
let messages = create_test_messages(500); let messages = create_test_messages(500);
c.bench_function("group_500_messages", |b| { c.bench_function("group_500_messages", |b| {
b.iter(|| { b.iter(|| group_messages(black_box(&messages)));
group_messages(black_box(&messages))
});
}); });
} }

View File

@@ -6,15 +6,6 @@ max_width = 100
tab_spaces = 4 tab_spaces = 4
newline_style = "Unix" newline_style = "Unix"
# Imports
imports_granularity = "Crate"
group_imports = "StdExternalCrate"
# Comments
wrap_comments = true
comment_width = 80
normalize_comments = true
# Formatting # Formatting
use_small_heuristics = "Default" use_small_heuristics = "Default"
fn_call_width = 80 fn_call_width = 80

202
src/accounts/manager.rs Normal file
View File

@@ -0,0 +1,202 @@
//! 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)
}

13
src/accounts/mod.rs Normal file
View File

@@ -0,0 +1,13 @@
//! 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;
#[allow(unused_imports)]
pub use manager::{add_account, ensure_account_dir, load_or_create, resolve_account, save};
#[allow(unused_imports)]
pub use profile::{account_db_path, validate_account_name, AccountProfile, AccountsConfig};

147
src/accounts/profile.rs Normal file
View 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"));
}
}

View File

@@ -6,10 +6,10 @@
/// - По статусу (archived, muted, и т.д.) /// - По статусу (archived, muted, и т.д.)
/// ///
/// Используется как в App, так и в UI слое для консистентной фильтрации. /// Используется как в App, так и в UI слое для консистентной фильтрации.
use crate::tdlib::ChatInfo; use crate::tdlib::ChatInfo;
/// Критерии фильтрации чатов /// Критерии фильтрации чатов
#[allow(dead_code)]
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct ChatFilterCriteria { pub struct ChatFilterCriteria {
/// Фильтр по папке (folder_id) /// Фильтр по папке (folder_id)
@@ -34,6 +34,7 @@ pub struct ChatFilterCriteria {
pub hide_archived: bool, pub hide_archived: bool,
} }
#[allow(dead_code)]
impl ChatFilterCriteria { impl ChatFilterCriteria {
/// Создаёт критерии с дефолтными значениями /// Создаёт критерии с дефолтными значениями
pub fn new() -> Self { pub fn new() -> Self {
@@ -42,18 +43,12 @@ impl ChatFilterCriteria {
/// Фильтр только по папке /// Фильтр только по папке
pub fn by_folder(folder_id: Option<i32>) -> Self { pub fn by_folder(folder_id: Option<i32>) -> Self {
Self { Self { folder_id, ..Default::default() }
folder_id,
..Default::default()
}
} }
/// Фильтр только по поисковому запросу /// Фильтр только по поисковому запросу
pub fn by_search(query: String) -> Self { pub fn by_search(query: String) -> Self {
Self { Self { search_query: Some(query), ..Default::default() }
search_query: Some(query),
..Default::default()
}
} }
/// Builder: установить папку /// Builder: установить папку
@@ -154,8 +149,10 @@ impl ChatFilterCriteria {
} }
/// Централизованный фильтр чатов /// Централизованный фильтр чатов
#[allow(dead_code)]
pub struct ChatFilter; pub struct ChatFilter;
#[allow(dead_code)]
impl ChatFilter { impl ChatFilter {
/// Фильтрует список чатов по критериям /// Фильтрует список чатов по критериям
/// ///
@@ -176,10 +173,7 @@ impl ChatFilter {
/// ///
/// let filtered = ChatFilter::filter(&all_chats, &criteria); /// let filtered = ChatFilter::filter(&all_chats, &criteria);
/// ``` /// ```
pub fn filter<'a>( pub fn filter<'a>(chats: &'a [ChatInfo], criteria: &ChatFilterCriteria) -> Vec<&'a ChatInfo> {
chats: &'a [ChatInfo],
criteria: &ChatFilterCriteria,
) -> Vec<&'a ChatInfo> {
chats.iter().filter(|chat| criteria.matches(chat)).collect() chats.iter().filter(|chat| criteria.matches(chat)).collect()
} }
@@ -309,8 +303,7 @@ mod tests {
let filtered = ChatFilter::filter(&chats, &criteria); let filtered = ChatFilter::filter(&chats, &criteria);
assert_eq!(filtered.len(), 2); // Chat 1 and Chat 3 have unread assert_eq!(filtered.len(), 2); // Chat 1 and Chat 3 have unread
let criteria = ChatFilterCriteria::new() let criteria = ChatFilterCriteria::new().pinned_only(true);
.pinned_only(true);
let filtered = ChatFilter::filter(&chats, &criteria); let filtered = ChatFilter::filter(&chats, &criteria);
assert_eq!(filtered.len(), 1); // Only Chat 1 is pinned assert_eq!(filtered.len(), 1); // Only Chat 1 is pinned
@@ -330,5 +323,4 @@ mod tests {
assert_eq!(ChatFilter::count_unread(&chats, &criteria), 15); // 5 + 10 assert_eq!(ChatFilter::count_unread(&chats, &criteria), 15); // 5 + 10
assert_eq!(ChatFilter::count_unread_mentions(&chats, &criteria), 3); // 1 + 2 assert_eq!(ChatFilter::count_unread_mentions(&chats, &criteria), 3); // 1 + 2
} }
} }

View File

@@ -3,10 +3,21 @@
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, Default)]
pub enum ChatState { pub enum ChatState {
/// Обычный режим - просмотр сообщений, набор текста /// Обычный режим - просмотр сообщений, набор текста
#[default]
Normal, Normal,
/// Выбор сообщения для действия (edit/delete/reply/forward/reaction) /// Выбор сообщения для действия (edit/delete/reply/forward/reaction)
@@ -80,12 +91,6 @@ pub enum ChatState {
}, },
} }
impl Default for ChatState {
fn default() -> Self {
ChatState::Normal
}
}
impl ChatState { impl ChatState {
/// Проверка: находимся в режиме выбора сообщения /// Проверка: находимся в режиме выбора сообщения
pub fn is_message_selection(&self) -> bool { pub fn is_message_selection(&self) -> bool {

117
src/app/methods/compose.rs Normal file
View File

@@ -0,0 +1,117 @@
//! Compose methods for App
//!
//! Handles reply, forward, and draft functionality
use crate::app::methods::messages::MessageMethods;
use crate::app::{App, ChatState};
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();
}
}
}

179
src/app/methods/messages.rs Normal file
View File

@@ -0,0 +1,179 @@
//! 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()
}
}

25
src/app/methods/mod.rs Normal file
View File

@@ -0,0 +1,25 @@
//! 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 compose;
pub mod messages;
pub mod modal;
pub mod navigation;
pub mod search;
#[allow(unused_imports)]
pub use compose::ComposeMethods;
#[allow(unused_imports)]
pub use messages::MessageMethods;
#[allow(unused_imports)]
pub use modal::ModalMethods;
#[allow(unused_imports)]
pub use navigation::NavigationMethods;
#[allow(unused_imports)]
pub use search::SearchMethods;

266
src/app/methods/modal.rs Normal file
View File

@@ -0,0 +1,266 @@
//! 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())
}
}

View File

@@ -0,0 +1,146 @@
//! Navigation methods for App
//!
//! Handles chat list navigation and selection
use crate::app::methods::search::SearchMethods;
use crate::app::{App, ChatState, InputMode};
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();
}
}
}
}

165
src/app/methods/search.rs Normal file
View File

@@ -0,0 +1,165 @@
//! 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
#[allow(dead_code)]
fn get_search_selected_index(&self) -> Option<usize>;
/// Get all search results
#[allow(dead_code)]
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
}
}
}

View File

@@ -1,14 +1,41 @@
//! 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;
pub mod methods;
mod state; mod state;
pub use chat_filter::{ChatFilter, ChatFilterCriteria}; pub use chat_filter::{ChatFilter, ChatFilterCriteria};
pub use chat_state::ChatState; pub use chat_state::{ChatState, InputMode};
#[allow(unused_imports)]
pub use methods::*;
pub use state::AppScreen; pub use state::AppScreen;
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 +60,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 +80,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,8 +107,47 @@ 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
#[allow(dead_code)]
#[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>,
} }
#[allow(dead_code)]
impl<T: TdClientTrait> App<T> { impl<T: TdClientTrait> App<T> {
/// Creates a new App instance with the given configuration and client. /// Creates a new App instance with the given configuration and client.
/// ///
@@ -96,11 +165,21 @@ 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 +197,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,674 +235,141 @@ 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 методы для инкапсуляции ==========
// Config // Config
@@ -997,19 +565,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
View 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
#[allow(dead_code)]
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
View 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;

198
src/audio/player.rs Normal file
View File

@@ -0,0 +1,198 @@
//! 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)
#[allow(dead_code)]
pub fn is_playing(&self) -> bool {
self.current_pid.lock().unwrap().is_some() && !*self.paused.lock().unwrap()
}
/// Returns true if paused
#[allow(dead_code)]
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()
}
#[allow(dead_code)]
pub fn set_volume(&self, _volume: f32) {}
#[allow(dead_code)]
pub fn adjust_volume(&self, _delta: f32) {}
pub fn volume(&self) -> f32 {
1.0
}
#[allow(dead_code)]
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);
}
}
}

View File

@@ -4,7 +4,6 @@
/// - Загрузку из конфигурационного файла /// - Загрузку из конфигурационного файла
/// - Множественные binding для одной команды (EN/RU раскладки) /// - Множественные binding для одной команды (EN/RU раскладки)
/// - Type-safe команды через enum /// - Type-safe команды через enum
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
@@ -48,6 +47,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 +64,9 @@ pub enum Command {
MoveToStart, MoveToStart,
MoveToEnd, MoveToEnd,
// Vim mode
EnterInsertMode,
// Profile // Profile
OpenProfile, OpenProfile,
} }
@@ -72,24 +82,21 @@ pub struct KeyBinding {
impl KeyBinding { impl KeyBinding {
pub fn new(key: KeyCode) -> Self { pub fn new(key: KeyCode) -> Self {
Self { Self { key, modifiers: KeyModifiers::NONE }
key,
modifiers: KeyModifiers::NONE,
}
} }
pub fn with_ctrl(key: KeyCode) -> Self { pub fn with_ctrl(key: KeyCode) -> Self {
Self { Self { key, modifiers: KeyModifiers::CONTROL }
key,
modifiers: KeyModifiers::CONTROL,
}
} }
#[allow(dead_code)]
pub fn with_shift(key: KeyCode) -> Self { pub fn with_shift(key: KeyCode) -> Self {
Self { Self { key, modifiers: KeyModifiers::SHIFT }
key, }
modifiers: KeyModifiers::SHIFT,
} #[allow(dead_code)]
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 {
@@ -105,55 +112,81 @@ pub struct Keybindings {
} }
impl Keybindings { impl Keybindings {
/// Создаёт дефолтную конфигурацию /// Ищет команду по клавише
pub fn default() -> Self { pub fn get_command(&self, event: &KeyEvent) -> Option<Command> {
for (command, bindings) in &self.bindings {
if bindings.iter().any(|binding| binding.matches(event)) {
return Some(*command);
}
}
None
}
}
impl Default for Keybindings {
fn default() -> Self {
let mut bindings = HashMap::new(); let mut bindings = HashMap::new();
// Navigation // Navigation
bindings.insert(Command::MoveUp, vec![ bindings.insert(
KeyBinding::new(KeyCode::Up), Command::MoveUp,
KeyBinding::new(KeyCode::Char('k')), vec![
KeyBinding::new(KeyCode::Char('р')), // RU (custom mapping, not standard ЙЦУКЕН) KeyBinding::new(KeyCode::Up),
]); KeyBinding::new(KeyCode::Char('k')),
bindings.insert(Command::MoveDown, vec![ KeyBinding::new(KeyCode::Char('р')), // RU (custom mapping, not standard ЙЦУКЕН)
KeyBinding::new(KeyCode::Down), ],
KeyBinding::new(KeyCode::Char('j')), );
KeyBinding::new(KeyCode::Char('о')), // RU bindings.insert(
]); Command::MoveDown,
bindings.insert(Command::MoveLeft, vec![ vec![
KeyBinding::new(KeyCode::Left), KeyBinding::new(KeyCode::Down),
KeyBinding::new(KeyCode::Char('h')), KeyBinding::new(KeyCode::Char('j')),
KeyBinding::new(KeyCode::Char('л')), // RU (custom mapping, not standard ЙЦУКЕН) KeyBinding::new(KeyCode::Char('о')), // RU
]); ],
bindings.insert(Command::MoveRight, vec![ );
KeyBinding::new(KeyCode::Right), bindings.insert(
KeyBinding::new(KeyCode::Char('l')), Command::MoveLeft,
KeyBinding::new(KeyCode::Char('д')), // RU vec![
]); KeyBinding::new(KeyCode::Left),
bindings.insert(Command::PageUp, vec![ KeyBinding::new(KeyCode::Char('h')),
KeyBinding::new(KeyCode::PageUp), KeyBinding::new(KeyCode::Char('л')), // RU (custom mapping, not standard ЙЦУКЕН)
KeyBinding::with_ctrl(KeyCode::Char('u')), ],
]); );
bindings.insert(Command::PageDown, vec![ bindings.insert(
KeyBinding::new(KeyCode::PageDown), Command::MoveRight,
KeyBinding::with_ctrl(KeyCode::Char('d')), vec![
]); KeyBinding::new(KeyCode::Right),
KeyBinding::new(KeyCode::Char('l')),
KeyBinding::new(KeyCode::Char('д')), // RU
],
);
bindings.insert(
Command::PageUp,
vec![
KeyBinding::new(KeyCode::PageUp),
KeyBinding::with_ctrl(KeyCode::Char('u')),
],
);
bindings.insert(
Command::PageDown,
vec![
KeyBinding::new(KeyCode::PageDown),
KeyBinding::with_ctrl(KeyCode::Char('d')),
],
);
// Global // Global
bindings.insert(Command::Quit, vec![ bindings.insert(
KeyBinding::new(KeyCode::Char('q')), Command::Quit,
KeyBinding::new(KeyCode::Char('й')), // RU vec![
KeyBinding::with_ctrl(KeyCode::Char('c')), KeyBinding::new(KeyCode::Char('q')),
]); KeyBinding::new(KeyCode::Char('й')), // RU
bindings.insert(Command::OpenSearch, vec![ KeyBinding::with_ctrl(KeyCode::Char('c')),
KeyBinding::with_ctrl(KeyCode::Char('s')), ],
]); );
bindings.insert(Command::OpenSearchInChat, vec![ bindings.insert(Command::OpenSearch, vec![KeyBinding::with_ctrl(KeyCode::Char('s'))]);
KeyBinding::with_ctrl(KeyCode::Char('f')), bindings.insert(Command::OpenSearchInChat, vec![KeyBinding::with_ctrl(KeyCode::Char('f'))]);
]); bindings.insert(Command::Help, vec![KeyBinding::new(KeyCode::Char('?'))]);
bindings.insert(Command::Help, vec![
KeyBinding::new(KeyCode::Char('?')),
]);
// Chat list // Chat list
// Note: Enter обрабатывается через Command::SubmitMessage в handle_enter_key() // Note: Enter обрабатывается через Command::SubmitMessage в handle_enter_key()
@@ -170,88 +203,117 @@ impl Keybindings {
9 => Command::SelectFolder9, 9 => Command::SelectFolder9,
_ => unreachable!(), _ => unreachable!(),
}; };
bindings.insert(cmd, vec![ bindings.insert(
KeyBinding::new(KeyCode::Char(char::from_digit(i, 10).unwrap())), cmd,
]); vec![KeyBinding::new(KeyCode::Char(
char::from_digit(i, 10).unwrap(),
))],
);
} }
// Message actions // Message actions
// Note: EditMessage (Up) обрабатывается напрямую в handle_open_chat_keyboard_input // Note: EditMessage (Up) обрабатывается напрямую в handle_open_chat_keyboard_input
// в зависимости от контекста (пустой инпут). Не привязываем здесь, чтобы не // в зависимости от контекста (пустой инпут). Не привязываем здесь, чтобы не
// конфликтовать с Command::MoveUp в списке чатов. // конфликтовать с Command::MoveUp в списке чатов.
bindings.insert(Command::DeleteMessage, vec![ bindings.insert(
KeyBinding::new(KeyCode::Delete), Command::DeleteMessage,
KeyBinding::new(KeyCode::Char('d')), vec![
KeyBinding::new(KeyCode::Char('в')), // RU KeyBinding::new(KeyCode::Delete),
]); KeyBinding::new(KeyCode::Char('d')),
bindings.insert(Command::ReplyMessage, vec![ KeyBinding::new(KeyCode::Char('в')), // RU
KeyBinding::new(KeyCode::Char('r')), ],
KeyBinding::new(KeyCode::Char('к')), // RU );
]); bindings.insert(
bindings.insert(Command::ForwardMessage, vec![ Command::ReplyMessage,
KeyBinding::new(KeyCode::Char('f')), vec![
KeyBinding::new(KeyCode::Char('а')), // RU KeyBinding::new(KeyCode::Char('r')),
]); KeyBinding::new(KeyCode::Char('к')), // RU
bindings.insert(Command::CopyMessage, vec![ ],
KeyBinding::new(KeyCode::Char('y')), );
KeyBinding::new(KeyCode::Char('н')), // RU bindings.insert(
]); Command::ForwardMessage,
bindings.insert(Command::ReactMessage, vec![ vec![
KeyBinding::new(KeyCode::Char('e')), KeyBinding::new(KeyCode::Char('f')),
KeyBinding::new(KeyCode::Char('у')), // RU KeyBinding::new(KeyCode::Char('а')), // RU
]); ],
);
bindings.insert(
Command::CopyMessage,
vec![
KeyBinding::new(KeyCode::Char('y')),
KeyBinding::new(KeyCode::Char('н')), // RU
],
);
bindings.insert(
Command::ReactMessage,
vec![
KeyBinding::new(KeyCode::Char('e')),
KeyBinding::new(KeyCode::Char('у')), // RU
],
);
// 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), bindings.insert(Command::Cancel, vec![KeyBinding::new(KeyCode::Esc)]);
]); bindings.insert(Command::NewLine, vec![]);
bindings.insert(Command::Cancel, vec![ bindings.insert(Command::DeleteChar, vec![KeyBinding::new(KeyCode::Backspace)]);
KeyBinding::new(KeyCode::Esc), bindings.insert(
]); Command::DeleteWord,
bindings.insert(Command::NewLine, vec![ vec![
KeyBinding::with_shift(KeyCode::Enter), KeyBinding::with_ctrl(KeyCode::Backspace),
]); KeyBinding::with_ctrl(KeyCode::Char('w')),
bindings.insert(Command::DeleteChar, vec![ ],
KeyBinding::new(KeyCode::Backspace), );
]); bindings.insert(
bindings.insert(Command::DeleteWord, vec![ Command::MoveToStart,
KeyBinding::with_ctrl(KeyCode::Backspace), vec![
KeyBinding::with_ctrl(KeyCode::Char('w')), KeyBinding::new(KeyCode::Home),
]); KeyBinding::with_ctrl(KeyCode::Char('a')),
bindings.insert(Command::MoveToStart, vec![ ],
KeyBinding::new(KeyCode::Home), );
KeyBinding::with_ctrl(KeyCode::Char('a')), bindings.insert(
]); Command::MoveToEnd,
bindings.insert(Command::MoveToEnd, vec![ vec![
KeyBinding::new(KeyCode::End), KeyBinding::new(KeyCode::End),
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(
KeyBinding::with_ctrl(KeyCode::Char('i')), Command::OpenProfile,
KeyBinding::with_ctrl(KeyCode::Char('ш')), // RU vec![
]); KeyBinding::with_ctrl(KeyCode::Char('u')), // Ctrl+U instead of Ctrl+I
KeyBinding::with_ctrl(KeyCode::Char('г')), // RU
],
);
Self { bindings } Self { bindings }
} }
/// Ищет команду по клавише
pub fn get_command(&self, event: &KeyEvent) -> Option<Command> {
for (command, bindings) in &self.bindings {
if bindings.iter().any(|binding| binding.matches(event)) {
return Some(*command);
}
}
None
}
}
impl Default for Keybindings {
fn default() -> Self {
Self::default()
}
} }
/// Сериализация KeyModifiers /// Сериализация KeyModifiers
@@ -356,14 +418,15 @@ mod key_code_serde {
let s = String::deserialize(deserializer)?; let s = String::deserialize(deserializer)?;
if s.starts_with("Char('") && s.ends_with("')") { if s.starts_with("Char('") && s.ends_with("')") {
let c = s.chars().nth(6).ok_or_else(|| { let c = s
serde::de::Error::custom("Invalid Char format") .chars()
})?; .nth(6)
.ok_or_else(|| serde::de::Error::custom("Invalid Char format"))?;
return Ok(KeyCode::Char(c)); return Ok(KeyCode::Char(c));
} }
if s.starts_with("F") { if let Some(suffix) = s.strip_prefix("F") {
let n = s[1..].parse().map_err(serde::de::Error::custom)?; let n = suffix.parse().map_err(serde::de::Error::custom)?;
return Ok(KeyCode::F(n)); return Ok(KeyCode::F(n));
} }

197
src/config/loader.rs Normal file
View 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))
}
}

View File

@@ -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};
@@ -21,7 +26,7 @@ pub use keybindings::{Command, Keybindings};
/// println!("Timezone: {}", config.general.timezone); /// println!("Timezone: {}", config.general.timezone);
/// println!("Incoming color: {}", config.colors.incoming_message); /// println!("Incoming color: {}", config.colors.incoming_message);
/// ``` /// ```
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Config { pub struct Config {
/// Общие настройки (timezone и т.д.). /// Общие настройки (timezone и т.д.).
#[serde(default)] #[serde(default)]
@@ -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() }
@@ -171,309 +260,6 @@ impl Default for NotificationsConfig {
} }
} }
impl Default for Config {
fn default() -> Self {
Self {
general: GeneralConfig::default(),
colors: ColorsConfig::default(),
keybindings: Keybindings::default(),
notifications: NotificationsConfig::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::*;
@@ -485,10 +271,22 @@ mod tests {
let keybindings = &config.keybindings; let keybindings = &config.keybindings;
// Test that keybindings exist for common commands // Test that keybindings exist for common commands
assert!(keybindings.get_command(&KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)) == Some(Command::ReplyMessage)); assert!(
assert!(keybindings.get_command(&KeyEvent::new(KeyCode::Char('к'), KeyModifiers::NONE)) == Some(Command::ReplyMessage)); keybindings.get_command(&KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE))
assert!(keybindings.get_command(&KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE)) == Some(Command::ForwardMessage)); == Some(Command::ReplyMessage)
assert!(keybindings.get_command(&KeyEvent::new(KeyCode::Char('а'), KeyModifiers::NONE)) == Some(Command::ForwardMessage)); );
assert!(
keybindings.get_command(&KeyEvent::new(KeyCode::Char('к'), KeyModifiers::NONE))
== Some(Command::ReplyMessage)
);
assert!(
keybindings.get_command(&KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE))
== Some(Command::ForwardMessage)
);
assert!(
keybindings.get_command(&KeyEvent::new(KeyCode::Char('а'), KeyModifiers::NONE))
== Some(Command::ForwardMessage)
);
} }
#[test] #[test]
@@ -556,10 +354,24 @@ mod tests {
#[test] #[test]
fn test_config_validate_valid_all_standard_colors() { fn test_config_validate_valid_all_standard_colors() {
let colors = [ let colors = [
"black", "red", "green", "yellow", "blue", "magenta", "black",
"cyan", "gray", "grey", "white", "darkgray", "darkgrey", "red",
"lightred", "lightgreen", "lightyellow", "lightblue", "green",
"lightmagenta", "lightcyan" "yellow",
"blue",
"magenta",
"cyan",
"gray",
"grey",
"white",
"darkgray",
"darkgrey",
"lightred",
"lightgreen",
"lightyellow",
"lightblue",
"lightmagenta",
"lightcyan",
]; ];
for color in colors { for color in colors {
@@ -570,11 +382,7 @@ mod tests {
config.colors.reaction_chosen = color.to_string(); config.colors.reaction_chosen = color.to_string();
config.colors.reaction_other = color.to_string(); config.colors.reaction_other = color.to_string();
assert!( assert!(config.validate().is_ok(), "Color '{}' should be valid", color);
config.validate().is_ok(),
"Color '{}' should be valid",
color
);
} }
} }

88
src/config/validation.rs Normal file
View 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
}
}
}

View File

@@ -1,4 +1,4 @@
// Application constants //! Application-wide constants (memory limits, timeouts, UI sizes).
// ============================================================================ // ============================================================================
// Memory Limits // Memory Limits
@@ -35,3 +35,50 @@ 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;
/// Таймаут скачивания файла (в секундах)
#[allow(dead_code)]
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;

View File

@@ -126,23 +126,25 @@ pub fn format_text_with_entities(
let start = entity.offset as usize; let start = entity.offset as usize;
let end = (entity.offset + entity.length) as usize; let end = (entity.offset + entity.length) as usize;
for i in start..end.min(chars.len()) { for item in char_styles
.iter_mut()
.take(end.min(chars.len()))
.skip(start)
{
match &entity.r#type { match &entity.r#type {
TextEntityType::Bold => char_styles[i].bold = true, TextEntityType::Bold => item.bold = true,
TextEntityType::Italic => char_styles[i].italic = true, TextEntityType::Italic => item.italic = true,
TextEntityType::Underline => char_styles[i].underline = true, TextEntityType::Underline => item.underline = true,
TextEntityType::Strikethrough => char_styles[i].strikethrough = true, TextEntityType::Strikethrough => item.strikethrough = true,
TextEntityType::Code | TextEntityType::Pre | TextEntityType::PreCode(_) => { TextEntityType::Code | TextEntityType::Pre | TextEntityType::PreCode(_) => {
char_styles[i].code = true item.code = true
} }
TextEntityType::Spoiler => char_styles[i].spoiler = true, TextEntityType::Spoiler => item.spoiler = true,
TextEntityType::Url TextEntityType::Url
| TextEntityType::TextUrl(_) | TextEntityType::TextUrl(_)
| TextEntityType::EmailAddress | TextEntityType::EmailAddress
| TextEntityType::PhoneNumber => char_styles[i].url = true, | TextEntityType::PhoneNumber => item.url = true,
TextEntityType::Mention | TextEntityType::MentionName(_) => { TextEntityType::Mention | TextEntityType::MentionName(_) => item.mention = true,
char_styles[i].mention = true
}
_ => {} _ => {}
} }
} }
@@ -277,11 +279,7 @@ mod tests {
#[test] #[test]
fn test_format_text_with_bold() { fn test_format_text_with_bold() {
let text = "Hello"; let text = "Hello";
let entities = vec![TextEntity { let entities = vec![TextEntity { offset: 0, length: 5, r#type: TextEntityType::Bold }];
offset: 0,
length: 5,
r#type: TextEntityType::Bold,
}];
let spans = format_text_with_entities(text, &entities, Color::White); let spans = format_text_with_entities(text, &entities, Color::White);
assert_eq!(spans.len(), 1); assert_eq!(spans.len(), 1);

View File

@@ -20,7 +20,8 @@ pub async fn handle<T: TdClientTrait>(app: &mut App<T>, key_code: KeyCode) {
app.status_message = Some("Отправка номера...".to_string()); app.status_message = Some("Отправка номера...".to_string());
match with_timeout_msg( match with_timeout_msg(
Duration::from_secs(10), Duration::from_secs(10),
app.td_client.send_phone_number(app.phone_input().to_string()), app.td_client
.send_phone_number(app.phone_input().to_string()),
"Таймаут отправки номера", "Таймаут отправки номера",
) )
.await .await
@@ -84,7 +85,8 @@ pub async fn handle<T: TdClientTrait>(app: &mut App<T>, key_code: KeyCode) {
app.status_message = Some("Проверка пароля...".to_string()); app.status_message = Some("Проверка пароля...".to_string());
match with_timeout_msg( match with_timeout_msg(
Duration::from_secs(10), Duration::from_secs(10),
app.td_client.send_password(app.password_input().to_string()), app.td_client
.send_password(app.password_input().to_string()),
"Таймаут проверки пароля", "Таймаут проверки пароля",
) )
.await .await

857
src/input/handlers/chat.rs Normal file
View File

@@ -0,0 +1,857 @@
//! 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 super::chat_list::open_chat_and_load_data;
use crate::app::methods::{
compose::ComposeMethods, messages::MessageMethods, modal::ModalMethods,
navigation::NavigationMethods,
};
use crate::app::App;
use crate::app::InputMode;
use crate::input::handlers::{copy_to_clipboard, format_message_for_clipboard};
use crate::tdlib::{ChatAction, TdClientTrait};
use crate::types::{ChatId, MessageId};
use crate::utils::{is_non_empty, with_timeout, with_timeout_msg};
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()
.is_none_or(|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
}
*/

View File

@@ -0,0 +1,140 @@
//! Chat list input handlers
//!
//! Handles keyboard input for the chat list view, including:
//! - Navigation between chats
//! - Folder selection
//! - Opening chats
use crate::app::methods::{
compose::ComposeMethods, messages::MessageMethods, navigation::NavigationMethods,
};
use crate::app::App;
use crate::app::InputMode;
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;
}
}
}

View File

@@ -0,0 +1,85 @@
//! Compose input handlers
//!
//! Handles text input and message composition, including:
//! - Forward mode
//! - Reply mode
//! - Edit mode
//! - Cursor movement and text editing
use crate::app::methods::{
compose::ComposeMethods, navigation::NavigationMethods, search::SearchMethods,
};
use crate::app::App;
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);
}
}
}

View File

@@ -6,6 +6,7 @@
//! - Ctrl+P: View pinned messages //! - Ctrl+P: View pinned messages
//! - Ctrl+F: Search messages in chat //! - Ctrl+F: Search messages in chat
use crate::app::methods::{modal::ModalMethods, search::SearchMethods};
use crate::app::App; use crate::app::App;
use crate::tdlib::TdClientTrait; use crate::tdlib::TdClientTrait;
use crate::types::ChatId; use crate::types::ChatId;
@@ -46,7 +47,8 @@ pub async fn handle_global_commands<T: TdClientTrait>(app: &mut App<T>, key: Key
KeyCode::Char('r') if has_ctrl => { KeyCode::Char('r') if has_ctrl => {
// Ctrl+R - обновить список чатов // Ctrl+R - обновить список чатов
app.status_message = Some("Обновление чатов...".to_string()); app.status_message = Some("Обновление чатов...".to_string());
let _ = with_timeout(Duration::from_secs(5), app.td_client.load_chats(50)).await; let _ =
with_timeout(Duration::from_secs(5), app.td_client.load_chats(50)).await;
// Синхронизируем muted чаты после обновления // Синхронизируем muted чаты после обновления
app.td_client.sync_notification_muted_chats(); app.td_client.sync_notification_muted_chats();
app.status_message = None; app.status_message = None;
@@ -57,6 +59,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,
} }
} }

View File

@@ -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 chat;
pub mod chat_list;
pub mod clipboard; pub mod clipboard;
pub mod compose;
pub mod global; pub mod global;
pub mod modal;
pub mod profile; pub mod profile;
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);
}
}

404
src/input/handlers/modal.rs Normal file
View File

@@ -0,0 +1,404 @@
//! 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 super::scroll_to_message;
use crate::app::methods::{modal::ModalMethods, navigation::NavigationMethods};
use crate::app::{AccountSwitcherState, App};
use crate::input::handlers::get_available_actions_count;
use crate::tdlib::TdClientTrait;
use crate::types::{ChatId, MessageId};
use crate::utils::{modal_handler::handle_yes_no, with_timeout_msg};
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();
}
}
_ => {}
}
}

View File

@@ -0,0 +1,136 @@
//! Search input handlers
//!
//! Handles keyboard input for search functionality, including:
//! - Chat list search mode
//! - Message search mode
//! - Search query input
use crate::app::methods::{navigation::NavigationMethods, search::SearchMethods};
use crate::app::App;
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

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,19 @@ 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" && 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
@@ -38,13 +55,32 @@ async fn main() -> Result<(), io::Error> {
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_env_filter( .with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env() tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")) .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")),
) )
.init(); .init();
// Загружаем конфигурацию (создаёт дефолтный если отсутствует) // Загружаем конфигурацию (создаёт дефолтный если отсутствует)
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,24 +99,26 @@ 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
true, // use_chat_info_database true, // use_chat_info_database
true, // use_message_database true, // use_message_database
false, // use_secret_chats false, // use_secret_chats
api_id, api_id,
api_hash, api_hash,
"en".to_string(), // system_language_code "en".to_string(), // system_language_code
@@ -121,7 +159,7 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
let polling_handle = tokio::spawn(async move { let polling_handle = tokio::spawn(async move {
while !should_stop_clone.load(Ordering::Relaxed) { while !should_stop_clone.load(Ordering::Relaxed) {
// receive() с таймаутом 0.1 сек чтобы периодически проверять флаг // receive() с таймаутом 0.1 сек чтобы периодически проверять флаг
let result = tokio::task::spawn_blocking(|| tdlib_rs::receive()).await; let result = tokio::task::spawn_blocking(tdlib_rs::receive).await;
if let Ok(Some((update, _client_id))) = result { if let Ok(Some((update, _client_id))) = result {
if update_tx.send(update).is_err() { if update_tx.send(update).is_err() {
break; // Канал закрыт, выходим break; // Канал закрыт, выходим
@@ -143,6 +181,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 +230,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().is_some_and(|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,21 +284,39 @@ 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;
// Ждём завершения polling задачи (с таймаутом) // Ждём завершения polling задачи (с таймаутом)
with_timeout_ignore(Duration::from_secs(SHUTDOWN_TIMEOUT_SECS), polling_handle).await; with_timeout_ignore(
Duration::from_secs(SHUTDOWN_TIMEOUT_SECS),
polling_handle,
)
.await;
return Ok(()); return Ok(());
} }
match app.screen { // Ctrl+A opens account switcher from any screen
AppScreen::Loading => { 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 {
AppScreen::Loading => {
// В состоянии загрузки игнорируем ввод
}
AppScreen::Auth => handle_auth_input(app, key.code).await,
AppScreen::Main => handle_main_input(app, key).await,
} }
AppScreen::Auth => handle_auth_input(app, key.code).await,
AppScreen::Main => handle_main_input(app, key).await,
} }
// Любой ввод требует перерисовки // Любой ввод требует перерисовки
@@ -209,6 +329,118 @@ 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;
}
} }
} }

112
src/media/cache.rs Normal file
View File

@@ -0,0 +1,112 @@
//! 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
#[allow(dead_code)]
pub struct ImageCache {
cache_dir: PathBuf,
max_size_bytes: u64,
}
#[allow(dead_code)]
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 без внешней зависимости
#[allow(dead_code)]
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(())
}
}

125
src/media/image_renderer.rs Normal file
View File

@@ -0,0 +1,125 @@
//! 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)
}
/// Удаляет протокол для сообщения
#[allow(dead_code)]
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);
}
/// Очищает все протоколы
#[allow(dead_code)]
pub fn clear(&mut self) {
self.protocols.clear();
self.access_order.clear();
self.access_counter = 0;
}
}

9
src/media/mod.rs Normal file
View 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;

View File

@@ -12,9 +12,14 @@ pub enum MessageGroup {
/// Разделитель даты (день в формате timestamp) /// Разделитель даты (день в формате timestamp)
DateSeparator(i32), DateSeparator(i32),
/// Заголовок отправителя (is_outgoing, sender_name) /// Заголовок отправителя (is_outgoing, sender_name)
SenderHeader { is_outgoing: bool, sender_name: String }, SenderHeader {
is_outgoing: bool,
sender_name: String,
},
/// Сообщение /// Сообщение
Message(MessageInfo), Message(Box<MessageInfo>),
/// Альбом (группа фото с одинаковым media_album_id)
Album(Vec<MessageInfo>),
} }
/// Группирует сообщения по дате и отправителю /// Группирует сообщения по дате и отправителю
@@ -51,6 +56,10 @@ pub enum MessageGroup {
/// // Рендерим сообщение /// // Рендерим сообщение
/// println!("{}", msg.text()); /// println!("{}", msg.text());
/// } /// }
/// MessageGroup::Album(messages) => {
/// // Рендерим альбом (группу фото)
/// println!("Album with {} photos", messages.len());
/// }
/// } /// }
/// } /// }
/// ``` /// ```
@@ -58,12 +67,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(Box::new(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,17 +107,42 @@ pub fn group_messages(messages: &[MessageInfo]) -> Vec<MessageGroup> {
let show_sender_header = last_sender.as_ref() != Some(&current_sender); let show_sender_header = last_sender.as_ref() != Some(&current_sender);
if show_sender_header { if show_sender_header {
result.push(MessageGroup::SenderHeader { // Flush аккумулятор перед сменой отправителя
is_outgoing: msg.is_outgoing(), flush_album(&mut album_acc, &mut result);
sender_name, result.push(MessageGroup::SenderHeader { is_outgoing: msg.is_outgoing(), sender_name });
});
last_sender = Some(current_sender); last_sender = Some(current_sender);
} }
// Добавляем само сообщение // Проверяем, является ли сообщение частью альбома
result.push(MessageGroup::Message(msg.clone())); 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(Box::new(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");
}
}
} }

View File

@@ -10,6 +10,7 @@ use std::collections::HashSet;
use notify_rust::{Notification, Timeout}; use notify_rust::{Notification, Timeout};
/// Manages desktop notifications /// Manages desktop notifications
#[allow(dead_code)]
pub struct NotificationManager { pub struct NotificationManager {
/// Whether notifications are enabled /// Whether notifications are enabled
enabled: bool, enabled: bool,
@@ -25,6 +26,7 @@ pub struct NotificationManager {
urgency: String, urgency: String,
} }
#[allow(dead_code)]
impl NotificationManager { impl NotificationManager {
/// Creates a new notification manager with default settings /// Creates a new notification manager with default settings
pub fn new() -> Self { pub fn new() -> Self {
@@ -39,11 +41,7 @@ impl NotificationManager {
} }
/// Creates a notification manager with custom settings /// Creates a notification manager with custom settings
pub fn with_config( pub fn with_config(enabled: bool, only_mentions: bool, show_preview: bool) -> Self {
enabled: bool,
only_mentions: bool,
show_preview: bool,
) -> Self {
Self { Self {
enabled, enabled,
muted_chats: HashSet::new(), muted_chats: HashSet::new(),
@@ -269,7 +267,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);
} }
@@ -311,22 +309,13 @@ mod tests {
#[test] #[test]
fn test_beautify_media_labels() { fn test_beautify_media_labels() {
// Test photo // Test photo
assert_eq!( assert_eq!(NotificationManager::beautify_media_labels("[Фото]"), "📷 Фото");
NotificationManager::beautify_media_labels("[Фото]"),
"📷 Фото"
);
// Test video // Test video
assert_eq!( assert_eq!(NotificationManager::beautify_media_labels("[Видео]"), "🎥 Видео");
NotificationManager::beautify_media_labels("[Видео]"),
"🎥 Видео"
);
// Test sticker with emoji // Test sticker with emoji
assert_eq!( assert_eq!(NotificationManager::beautify_media_labels("[Стикер: 😊]"), "🎨 Стикер: 😊]");
NotificationManager::beautify_media_labels("[Стикер: 😊]"),
"🎨 Стикер: 😊]"
);
// Test audio with title // Test audio with title
assert_eq!( assert_eq!(
@@ -341,10 +330,7 @@ mod tests {
); );
// Test regular text (no changes) // Test regular text (no changes)
assert_eq!( assert_eq!(NotificationManager::beautify_media_labels("Hello, world!"), "Hello, world!");
NotificationManager::beautify_media_labels("Hello, world!"),
"Hello, world!"
);
// Test mixed content // Test mixed content
assert_eq!( assert_eq!(

View File

@@ -5,6 +5,7 @@ use tdlib_rs::functions;
/// ///
/// Отслеживает текущий этап аутентификации пользователя, /// Отслеживает текущий этап аутентификации пользователя,
/// от инициализации TDLib до полной авторизации. /// от инициализации TDLib до полной авторизации.
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub enum AuthState { pub enum AuthState {
/// Ожидание параметров TDLib (начальное состояние). /// Ожидание параметров TDLib (начальное состояние).
@@ -72,6 +73,7 @@ pub struct AuthManager {
client_id: i32, client_id: i32,
} }
#[allow(dead_code)]
impl AuthManager { impl AuthManager {
/// Создает новый менеджер авторизации. /// Создает новый менеджер авторизации.
/// ///
@@ -83,10 +85,7 @@ impl AuthManager {
/// ///
/// Новый экземпляр `AuthManager` в состоянии `WaitTdlibParameters`. /// Новый экземпляр `AuthManager` в состоянии `WaitTdlibParameters`.
pub fn new(client_id: i32) -> Self { pub fn new(client_id: i32) -> Self {
Self { Self { state: AuthState::WaitTdlibParameters, client_id }
state: AuthState::WaitTdlibParameters,
client_id,
}
} }
/// Проверяет, завершена ли авторизация. /// Проверяет, завершена ли авторизация.

View File

@@ -3,7 +3,7 @@
//! This module contains utility functions for managing chats, //! This module contains utility functions for managing chats,
//! including finding, updating, and adding/removing chats. //! including finding, updating, and adding/removing chats.
use crate::constants::{MAX_CHAT_USER_IDS, MAX_CHATS}; use crate::constants::{MAX_CHATS, MAX_CHAT_USER_IDS};
use crate::types::{ChatId, MessageId, UserId}; use crate::types::{ChatId, MessageId, UserId};
use tdlib_rs::enums::{Chat as TdChat, ChatList, ChatType}; use tdlib_rs::enums::{Chat as TdChat, ChatList, ChatType};
@@ -33,7 +33,9 @@ pub fn add_or_update_chat(client: &mut TdClient, td_chat_enum: &TdChat) {
// Пропускаем удалённые аккаунты // Пропускаем удалённые аккаунты
if td_chat.title == "Deleted Account" || td_chat.title.is_empty() { if td_chat.title == "Deleted Account" || td_chat.title.is_empty() {
// Удаляем из списка если уже был добавлен // Удаляем из списка если уже был добавлен
client.chats_mut().retain(|c| c.id != ChatId::new(td_chat.id)); client
.chats_mut()
.retain(|c| c.id != ChatId::new(td_chat.id));
return; return;
} }
@@ -70,7 +72,9 @@ pub fn add_or_update_chat(client: &mut TdClient, td_chat_enum: &TdChat) {
let user_id = UserId::new(private.user_id); let user_id = UserId::new(private.user_id);
client.user_cache.chat_user_ids.insert(chat_id, user_id); client.user_cache.chat_user_ids.insert(chat_id, user_id);
// Проверяем, есть ли уже username в кэше (peek не обновляет LRU) // Проверяем, есть ли уже username в кэше (peek не обновляет LRU)
client.user_cache.user_usernames client
.user_cache
.user_usernames
.peek(&user_id) .peek(&user_id)
.map(|u| format!("@{}", u)) .map(|u| format!("@{}", u))
} }

View File

@@ -197,10 +197,7 @@ impl ChatManager {
ChatType::Secret(_) => "Секретный чат", ChatType::Secret(_) => "Секретный чат",
}; };
let is_group = matches!( let is_group = matches!(&chat.r#type, ChatType::Supergroup(_) | ChatType::BasicGroup(_));
&chat.r#type,
ChatType::Supergroup(_) | ChatType::BasicGroup(_)
);
// Для личных чатов получаем информацию о пользователе // Для личных чатов получаем информацию о пользователе
let (bio, phone_number, username, online_status) = if let ChatType::Private(private_chat) = let (bio, phone_number, username, online_status) = if let ChatType::Private(private_chat) =
@@ -208,13 +205,15 @@ impl ChatManager {
{ {
match functions::get_user(private_chat.user_id, self.client_id).await { match functions::get_user(private_chat.user_id, self.client_id).await {
Ok(tdlib_rs::enums::User::User(user)) => { Ok(tdlib_rs::enums::User::User(user)) => {
let bio_opt = if let Ok(tdlib_rs::enums::UserFullInfo::UserFullInfo(full_info)) = let bio_opt =
functions::get_user_full_info(private_chat.user_id, self.client_id).await if let Ok(tdlib_rs::enums::UserFullInfo::UserFullInfo(full_info)) =
{ functions::get_user_full_info(private_chat.user_id, self.client_id)
full_info.bio.map(|b| b.text) .await
} else { {
None full_info.bio.map(|b| b.text)
}; } else {
None
};
let online_status_str = match user.status { let online_status_str = match user.status {
tdlib_rs::enums::UserStatus::Online(_) => Some("В сети".to_string()), tdlib_rs::enums::UserStatus::Online(_) => Some("В сети".to_string()),
@@ -234,10 +233,7 @@ impl ChatManager {
_ => None, _ => None,
}; };
let username_opt = user let username_opt = user.usernames.as_ref().map(|u| u.editable_username.clone());
.usernames
.as_ref()
.map(|u| u.editable_username.clone());
(bio_opt, Some(user.phone_number.clone()), username_opt, online_status_str) (bio_opt, Some(user.phone_number.clone()), username_opt, online_status_str)
} }
@@ -257,7 +253,10 @@ impl ChatManager {
} else { } else {
None None
}; };
let link = full_info.invite_link.as_ref().map(|l| l.invite_link.clone()); let link = full_info
.invite_link
.as_ref()
.map(|l| l.invite_link.clone());
(Some(full_info.member_count), desc, link) (Some(full_info.member_count), desc, link)
} }
_ => (None, None, None), _ => (None, None, None),
@@ -324,7 +323,8 @@ impl ChatManager {
/// ).await; /// ).await;
/// ``` /// ```
pub async fn send_chat_action(&self, chat_id: ChatId, action: ChatAction) { pub async fn send_chat_action(&self, chat_id: ChatId, action: ChatAction) {
let _ = functions::send_chat_action(chat_id.as_i64(), 0, Some(action), self.client_id).await; let _ =
functions::send_chat_action(chat_id.as_i64(), 0, Some(action), self.client_id).await;
} }
/// Очищает устаревший typing-статус. /// Очищает устаревший typing-статус.
@@ -371,6 +371,7 @@ impl ChatManager {
/// println!("Status: {}", typing_text); /// println!("Status: {}", typing_text);
/// } /// }
/// ``` /// ```
#[allow(dead_code)]
pub fn get_typing_text(&self) -> Option<String> { pub fn get_typing_text(&self) -> Option<String> {
self.typing_status self.typing_status
.as_ref() .as_ref()

View File

@@ -1,19 +1,17 @@
use crate::types::{ChatId, MessageId, UserId}; use crate::types::{ChatId, MessageId, UserId};
use std::env; use std::env;
use tdlib_rs::enums::{ use std::path::PathBuf;
ChatList, ConnectionState, Update, UserStatus, use tdlib_rs::enums::{Chat as TdChat, ChatList, ConnectionState, Update, UserStatus};
Chat as TdChat
};
use tdlib_rs::types::Message as TdMessage;
use tdlib_rs::functions; use tdlib_rs::functions;
use tdlib_rs::types::Message as TdMessage;
use super::auth::{AuthManager, AuthState}; use super::auth::{AuthManager, AuthState};
use super::chats::ChatManager; use super::chats::ChatManager;
use super::messages::MessageManager; use super::messages::MessageManager;
use super::reactions::ReactionManager; use super::reactions::ReactionManager;
use super::types::{ChatInfo, FolderInfo, MessageInfo, NetworkState, ProfileInfo, UserOnlineStatus}; use super::types::{
ChatInfo, FolderInfo, MessageInfo, NetworkState, ProfileInfo, UserOnlineStatus,
};
use super::users::UserCache; use super::users::UserCache;
use crate::notifications::NotificationManager; use crate::notifications::NotificationManager;
@@ -32,7 +30,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 +43,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,
// Менеджеры (делегируем им функциональность) // Менеджеры (делегируем им функциональность)
@@ -59,6 +58,7 @@ pub struct TdClient {
pub network_state: NetworkState, pub network_state: NetworkState,
} }
#[allow(dead_code)]
impl TdClient { impl TdClient {
/// Creates a new TDLib client instance. /// Creates a new TDLib client instance.
/// ///
@@ -71,24 +71,24 @@ 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(|_| { // Fallback на прямое чтение из env (старое поведение)
// Fallback на прямое чтение из env (старое поведение) let api_id = env::var("API_ID")
let api_id = env::var("API_ID") .unwrap_or_else(|_| "0".to_string())
.unwrap_or_else(|_| "0".to_string()) .parse()
.parse() .unwrap_or(0);
.unwrap_or(0); let api_hash = env::var("API_HASH").unwrap_or_default();
let api_hash = env::var("API_HASH").unwrap_or_default(); (api_id, api_hash)
(api_id, api_hash) });
});
let client_id = tdlib_rs::create_client(); let client_id = tdlib_rs::create_client();
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),
@@ -103,9 +103,11 @@ impl TdClient {
/// Configures notification manager from app config /// Configures notification manager from app config
pub fn configure_notifications(&mut self, config: &crate::config::NotificationsConfig) { pub fn configure_notifications(&mut self, config: &crate::config::NotificationsConfig) {
self.notification_manager.set_enabled(config.enabled); self.notification_manager.set_enabled(config.enabled);
self.notification_manager.set_only_mentions(config.only_mentions); self.notification_manager
.set_only_mentions(config.only_mentions);
self.notification_manager.set_timeout(config.timeout_ms); self.notification_manager.set_timeout(config.timeout_ms);
self.notification_manager.set_urgency(config.urgency.clone()); self.notification_manager
.set_urgency(config.urgency.clone());
// Note: show_preview is used when formatting notification body // Note: show_preview is used when formatting notification body
} }
@@ -113,7 +115,8 @@ impl TdClient {
/// ///
/// Should be called after chats are loaded to ensure muted chats don't trigger notifications. /// Should be called after chats are loaded to ensure muted chats don't trigger notifications.
pub fn sync_notification_muted_chats(&mut self) { pub fn sync_notification_muted_chats(&mut self) {
self.notification_manager.sync_muted_chats(&self.chat_manager.chats); self.notification_manager
.sync_muted_chats(&self.chat_manager.chats);
} }
// Делегирование к auth // Делегирование к auth
@@ -254,12 +257,17 @@ impl TdClient {
.await .await
} }
pub async fn get_pinned_messages(&mut self, chat_id: ChatId) -> Result<Vec<MessageInfo>, String> { pub async fn get_pinned_messages(
&mut self,
chat_id: ChatId,
) -> Result<Vec<MessageInfo>, String> {
self.message_manager.get_pinned_messages(chat_id).await self.message_manager.get_pinned_messages(chat_id).await
} }
pub async fn load_current_pinned_message(&mut self, chat_id: ChatId) { pub async fn load_current_pinned_message(&mut self, chat_id: ChatId) {
self.message_manager.load_current_pinned_message(chat_id).await self.message_manager
.load_current_pinned_message(chat_id)
.await
} }
pub async fn search_messages( pub async fn search_messages(
@@ -362,6 +370,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
@@ -423,7 +447,10 @@ impl TdClient {
self.chat_manager.typing_status.as_ref() self.chat_manager.typing_status.as_ref()
} }
pub fn set_typing_status(&mut self, status: Option<(crate::types::UserId, String, std::time::Instant)>) { pub fn set_typing_status(
&mut self,
status: Option<(crate::types::UserId, String, std::time::Instant)>,
) {
self.chat_manager.typing_status = status; self.chat_manager.typing_status = status;
} }
@@ -431,7 +458,9 @@ impl TdClient {
&self.message_manager.pending_view_messages &self.message_manager.pending_view_messages
} }
pub fn pending_view_messages_mut(&mut self) -> &mut Vec<(crate::types::ChatId, Vec<crate::types::MessageId>)> { pub fn pending_view_messages_mut(
&mut self,
) -> &mut Vec<(crate::types::ChatId, Vec<crate::types::MessageId>)> {
&mut self.message_manager.pending_view_messages &mut self.message_manager.pending_view_messages
} }
@@ -462,19 +491,6 @@ impl TdClient {
// ==================== Helper методы для упрощения обработки updates ==================== // ==================== Helper методы для упрощения обработки updates ====================
/// Находит мутабельную ссылку на чат по ID.
///
/// Упрощает повторяющийся паттерн `self.chats_mut().iter_mut().find(...)`.
///
/// # Arguments
///
/// * `chat_id` - ID чата для поиска
///
/// # Returns
///
/// * `Some(&mut ChatInfo)` - если чат найден
/// * `None` - если чат не найден
/// Обрабатываем одно обновление от TDLib /// Обрабатываем одно обновление от TDLib
pub fn handle_update(&mut self, update: Update) { pub fn handle_update(&mut self, update: Update) {
match update { match update {
@@ -500,7 +516,11 @@ impl TdClient {
}); });
// Обновляем позиции если они пришли // Обновляем позиции если они пришли
for pos in update.positions.iter().filter(|p| matches!(p.list, ChatList::Main)) { for pos in update
.positions
.iter()
.filter(|p| matches!(p.list, ChatList::Main))
{
crate::tdlib::chat_helpers::update_chat(self, chat_id, |chat| { crate::tdlib::chat_helpers::update_chat(self, chat_id, |chat| {
chat.order = pos.order; chat.order = pos.order;
chat.is_pinned = pos.is_pinned; chat.is_pinned = pos.is_pinned;
@@ -511,27 +531,43 @@ impl TdClient {
self.chats_mut().sort_by(|a, b| b.order.cmp(&a.order)); self.chats_mut().sort_by(|a, b| b.order.cmp(&a.order));
} }
Update::ChatReadInbox(update) => { Update::ChatReadInbox(update) => {
crate::tdlib::chat_helpers::update_chat(self, ChatId::new(update.chat_id), |chat| { crate::tdlib::chat_helpers::update_chat(
chat.unread_count = update.unread_count; self,
}); ChatId::new(update.chat_id),
|chat| {
chat.unread_count = update.unread_count;
},
);
} }
Update::ChatUnreadMentionCount(update) => { Update::ChatUnreadMentionCount(update) => {
crate::tdlib::chat_helpers::update_chat(self, ChatId::new(update.chat_id), |chat| { crate::tdlib::chat_helpers::update_chat(
chat.unread_mention_count = update.unread_mention_count; self,
}); ChatId::new(update.chat_id),
|chat| {
chat.unread_mention_count = update.unread_mention_count;
},
);
} }
Update::ChatNotificationSettings(update) => { Update::ChatNotificationSettings(update) => {
crate::tdlib::chat_helpers::update_chat(self, ChatId::new(update.chat_id), |chat| { crate::tdlib::chat_helpers::update_chat(
// mute_for > 0 означает что чат замьючен self,
chat.is_muted = update.notification_settings.mute_for > 0; ChatId::new(update.chat_id),
}); |chat| {
// mute_for > 0 означает что чат замьючен
chat.is_muted = update.notification_settings.mute_for > 0;
},
);
} }
Update::ChatReadOutbox(update) => { Update::ChatReadOutbox(update) => {
// Обновляем last_read_outbox_message_id когда собеседник прочитал сообщения // Обновляем last_read_outbox_message_id когда собеседник прочитал сообщения
let last_read_msg_id = MessageId::new(update.last_read_outbox_message_id); let last_read_msg_id = MessageId::new(update.last_read_outbox_message_id);
crate::tdlib::chat_helpers::update_chat(self, ChatId::new(update.chat_id), |chat| { crate::tdlib::chat_helpers::update_chat(
chat.last_read_outbox_message_id = last_read_msg_id; self,
}); ChatId::new(update.chat_id),
|chat| {
chat.last_read_outbox_message_id = last_read_msg_id;
},
);
// Если это текущий открытый чат — обновляем is_read у сообщений // Если это текущий открытый чат — обновляем is_read у сообщений
if Some(ChatId::new(update.chat_id)) == self.current_chat_id() { if Some(ChatId::new(update.chat_id)) == self.current_chat_id() {
for msg in self.current_chat_messages_mut().iter_mut() { for msg in self.current_chat_messages_mut().iter_mut() {
@@ -569,7 +605,9 @@ impl TdClient {
UserStatus::LastMonth(_) => UserOnlineStatus::LastMonth, UserStatus::LastMonth(_) => UserOnlineStatus::LastMonth,
UserStatus::Empty => UserOnlineStatus::LongTimeAgo, UserStatus::Empty => UserOnlineStatus::LongTimeAgo,
}; };
self.user_cache.user_statuses.insert(UserId::new(update.user_id), status); self.user_cache
.user_statuses
.insert(UserId::new(update.user_id), status);
} }
Update::ConnectionState(update) => { Update::ConnectionState(update) => {
// Обновляем состояние сетевого соединения // Обновляем состояние сетевого соединения
@@ -597,17 +635,62 @@ impl TdClient {
} }
} }
// Helper functions // Helper functions
pub fn extract_message_text_static(message: &TdMessage) -> (String, Vec<tdlib_rs::types::TextEntity>) { pub fn extract_message_text_static(
message: &TdMessage,
) -> (String, Vec<tdlib_rs::types::TextEntity>) {
use tdlib_rs::enums::MessageContent; use tdlib_rs::enums::MessageContent;
match &message.content { match &message.content {
MessageContent::MessageText(text) => (text.text.text.clone(), text.text.entities.clone()), MessageContent::MessageText(text) => {
(text.text.text.clone(), text.text.entities.clone())
}
_ => (String::new(), Vec::new()), _ => (String::new(), Vec::new()),
} }
} }
/// 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 {

View File

@@ -4,9 +4,13 @@
use super::client::TdClient; use super::client::TdClient;
use super::r#trait::TdClientTrait; 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]
@@ -51,11 +55,19 @@ impl TdClientTrait for TdClient {
} }
// ============ Message methods ============ // ============ Message methods ============
async fn get_chat_history(&mut self, chat_id: ChatId, limit: i32) -> Result<Vec<MessageInfo>, String> { async fn get_chat_history(
&mut self,
chat_id: ChatId,
limit: i32,
) -> Result<Vec<MessageInfo>, String> {
self.get_chat_history(chat_id, limit).await self.get_chat_history(chat_id, limit).await
} }
async fn load_older_messages(&mut self, chat_id: ChatId, from_message_id: MessageId) -> Result<Vec<MessageInfo>, String> { async fn load_older_messages(
&mut self,
chat_id: ChatId,
from_message_id: MessageId,
) -> Result<Vec<MessageInfo>, String> {
self.load_older_messages(chat_id, from_message_id).await self.load_older_messages(chat_id, from_message_id).await
} }
@@ -67,7 +79,11 @@ impl TdClientTrait for TdClient {
self.load_current_pinned_message(chat_id).await self.load_current_pinned_message(chat_id).await
} }
async fn search_messages(&self, chat_id: ChatId, query: &str) -> Result<Vec<MessageInfo>, String> { async fn search_messages(
&self,
chat_id: ChatId,
query: &str,
) -> Result<Vec<MessageInfo>, String> {
self.search_messages(chat_id, query).await self.search_messages(chat_id, query).await
} }
@@ -147,7 +163,8 @@ impl TdClientTrait for TdClient {
chat_id: ChatId, chat_id: ChatId,
message_id: MessageId, message_id: MessageId,
) -> Result<Vec<String>, String> { ) -> Result<Vec<String>, String> {
self.get_message_available_reactions(chat_id, message_id).await self.get_message_available_reactions(chat_id, message_id)
.await
} }
async fn toggle_reaction( async fn toggle_reaction(
@@ -159,6 +176,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()
} }
@@ -265,7 +292,13 @@ impl TdClientTrait for TdClient {
// ============ Notification methods ============ // ============ Notification methods ============
fn sync_notification_muted_chats(&mut self) { fn sync_notification_muted_chats(&mut self) {
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 ============

View File

@@ -7,7 +7,10 @@ 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 +22,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 +55,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) => {
@@ -94,9 +98,9 @@ pub async fn extract_sender_name(msg: &TdMessage, client_id: i32) -> String {
match &msg.sender_id { match &msg.sender_id {
MessageSender::User(user) => { MessageSender::User(user) => {
match tdlib_rs::functions::get_user(user.user_id, client_id).await { match tdlib_rs::functions::get_user(user.user_id, client_id).await {
Ok(tdlib_rs::enums::User::User(u)) => { Ok(tdlib_rs::enums::User::User(u)) => format!("{} {}", u.first_name, u.last_name)
format!("{} {}", u.first_name, u.last_name).trim().to_string() .trim()
} .to_string(),
_ => format!("User {}", user.user_id), _ => format!("User {}", user.user_id),
} }
} }
@@ -132,6 +136,57 @@ 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

View File

@@ -11,11 +11,7 @@ use super::client::TdClient;
use super::types::{ForwardInfo, MessageInfo, ReactionInfo, ReplyInfo}; use super::types::{ForwardInfo, MessageInfo, ReactionInfo, ReplyInfo};
/// Конвертирует TDLib сообщение в MessageInfo /// Конвертирует TDLib сообщение в MessageInfo
pub fn convert_message( pub fn convert_message(client: &mut TdClient, message: &TdMessage, chat_id: ChatId) -> MessageInfo {
client: &mut TdClient,
message: &TdMessage,
chat_id: ChatId,
) -> MessageInfo {
let sender_name = match &message.sender_id { let sender_name = match &message.sender_id {
tdlib_rs::enums::MessageSender::User(user) => { tdlib_rs::enums::MessageSender::User(user) => {
// Пробуем получить имя из кеша (get обновляет LRU порядок) // Пробуем получить имя из кеша (get обновляет LRU порядок)
@@ -76,7 +72,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 {
@@ -119,7 +116,7 @@ pub fn extract_reply_info(client: &TdClient, message: &TdMessage) -> Option<Repl
let sender_name = reply let sender_name = reply
.origin .origin
.as_ref() .as_ref()
.map(|origin| get_origin_sender_name(origin)) .map(get_origin_sender_name)
.unwrap_or_else(|| { .unwrap_or_else(|| {
// Пробуем найти оригинальное сообщение в текущем списке // Пробуем найти оригинальное сообщение в текущем списке
let reply_msg_id = MessageId::new(reply.message_id); let reply_msg_id = MessageId::new(reply.message_id);
@@ -137,12 +134,7 @@ pub fn extract_reply_info(client: &TdClient, message: &TdMessage) -> Option<Repl
.quote .quote
.as_ref() .as_ref()
.map(|q| q.text.text.clone()) .map(|q| q.text.text.clone())
.or_else(|| { .or_else(|| reply.content.as_ref().map(TdClient::extract_content_text))
reply
.content
.as_ref()
.map(TdClient::extract_content_text)
})
.unwrap_or_else(|| { .unwrap_or_else(|| {
// Пробуем найти в текущих сообщениях // Пробуем найти в текущих сообщениях
client client
@@ -153,11 +145,7 @@ pub fn extract_reply_info(client: &TdClient, message: &TdMessage) -> Option<Repl
.unwrap_or_default() .unwrap_or_default()
}); });
Some(ReplyInfo { Some(ReplyInfo { message_id: reply_msg_id, sender_name, text })
message_id: reply_msg_id,
sender_name,
text,
})
} }
_ => None, _ => None,
} }
@@ -218,12 +206,7 @@ pub fn update_reply_info_from_loaded_messages(client: &mut TdClient) {
let msg_data: std::collections::HashMap<i64, (String, String)> = client let msg_data: std::collections::HashMap<i64, (String, String)> = client
.current_chat_messages() .current_chat_messages()
.iter() .iter()
.map(|m| { .map(|m| (m.id().as_i64(), (m.sender_name().to_string(), m.text().to_string())))
(
m.id().as_i64(),
(m.sender_name().to_string(), m.text().to_string()),
)
})
.collect(); .collect();
// Обновляем reply_to для сообщений с неполными данными // Обновляем reply_to для сообщений с неполными данными

View File

@@ -0,0 +1,137 @@
//! 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();
});
}
}

102
src/tdlib/messages/mod.rs Normal file
View File

@@ -0,0 +1,102 @@
//! 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));
}
}
}

View File

@@ -1,103 +1,21 @@
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 синхронизирует их с сервера.
@@ -189,9 +107,10 @@ impl MessageManager {
// Если это первая загрузка и получили мало сообщений - продолжаем попытки // Если это первая загрузка и получили мало сообщений - продолжаем попытки
// TDLib может подгружать данные с сервера постепенно // TDLib может подгружать данные с сервера постепенно
if all_messages.is_empty() && if all_messages.is_empty()
received_count < (chunk_size as usize) && && received_count < (chunk_size as usize)
attempt < max_attempts_per_chunk { && attempt < max_attempts_per_chunk
{
// Даём TDLib время на синхронизацию с сервером // Даём TDLib время на синхронизацию с сервером
sleep(Duration::from_millis(100)).await; sleep(Duration::from_millis(100)).await;
continue; continue;
@@ -287,11 +206,9 @@ impl MessageManager {
match result { match result {
Ok(tdlib_rs::enums::Messages::Messages(messages_obj)) => { Ok(tdlib_rs::enums::Messages::Messages(messages_obj)) => {
let mut messages = Vec::new(); let mut messages = Vec::new();
for msg_opt in messages_obj.messages.iter().rev() { for msg in messages_obj.messages.iter().rev().flatten() {
if let Some(msg) = msg_opt { if let Some(info) = self.convert_message(msg).await {
if let Some(info) = self.convert_message(msg).await { messages.push(info);
messages.push(info);
}
} }
} }
Ok(messages) Ok(messages)
@@ -319,17 +236,20 @@ impl MessageManager {
/// let pinned = msg_manager.get_pinned_messages(chat_id).await?; /// let pinned = msg_manager.get_pinned_messages(chat_id).await?;
/// println!("Found {} pinned messages", pinned.len()); /// println!("Found {} pinned messages", pinned.len());
/// ``` /// ```
pub async fn get_pinned_messages(&mut self, chat_id: ChatId) -> Result<Vec<MessageInfo>, String> { pub async fn get_pinned_messages(
&mut self,
chat_id: ChatId,
) -> Result<Vec<MessageInfo>, String> {
let result = functions::search_chat_messages( let result = functions::search_chat_messages(
chat_id.as_i64(), chat_id.as_i64(),
String::new(), String::new(),
None, None,
0, // from_message_id 0, // from_message_id
0, // offset 0, // offset
100, // limit 100, // limit
Some(SearchMessagesFilter::Pinned), Some(SearchMessagesFilter::Pinned),
0, // message_thread_id 0, // message_thread_id
0, // saved_messages_topic_id 0, // saved_messages_topic_id
self.client_id, self.client_id,
) )
.await; .await;
@@ -364,13 +284,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 больше не существует
// }
// _ => {}
// }
} }
/// Выполняет поиск сообщений по тексту в указанном чате. /// Выполняет поиск сообщений по тексту в указанном чате.
@@ -403,8 +316,8 @@ impl MessageManager {
0, // offset 0, // offset
100, // limit 100, // limit
None, None,
0, // message_thread_id 0, // message_thread_id
0, // saved_messages_topic_id 0, // saved_messages_topic_id
self.client_id, self.client_id,
) )
.await; .await;
@@ -474,15 +387,9 @@ impl MessageManager {
.await .await
{ {
Ok(tdlib_rs::enums::FormattedText::FormattedText(ft)) => { Ok(tdlib_rs::enums::FormattedText::FormattedText(ft)) => {
FormattedText { FormattedText { text: ft.text, entities: ft.entities }
text: ft.text,
entities: ft.entities,
}
} }
Err(_) => FormattedText { Err(_) => FormattedText { text: text.clone(), entities: vec![] },
text: text.clone(),
entities: vec![],
},
}; };
let content = InputMessageContent::InputMessageText(InputMessageText { let content = InputMessageContent::InputMessageText(InputMessageText {
@@ -553,15 +460,9 @@ impl MessageManager {
.await .await
{ {
Ok(tdlib_rs::enums::FormattedText::FormattedText(ft)) => { Ok(tdlib_rs::enums::FormattedText::FormattedText(ft)) => {
FormattedText { FormattedText { text: ft.text, entities: ft.entities }
text: ft.text,
entities: ft.entities,
}
} }
Err(_) => FormattedText { Err(_) => FormattedText { text: text.clone(), entities: vec![] },
text: text.clone(),
entities: vec![],
},
}; };
let content = InputMessageContent::InputMessageText(InputMessageText { let content = InputMessageContent::InputMessageText(InputMessageText {
@@ -570,8 +471,13 @@ impl MessageManager {
clear_draft: true, clear_draft: true,
}); });
let result = let result = functions::edit_message_text(
functions::edit_message_text(chat_id.as_i64(), message_id.as_i64(), content, self.client_id).await; chat_id.as_i64(),
message_id.as_i64(),
content,
self.client_id,
)
.await;
match result { match result {
Ok(tdlib_rs::enums::Message::Message(msg)) => self Ok(tdlib_rs::enums::Message::Message(msg)) => self
@@ -602,7 +508,8 @@ impl MessageManager {
) -> Result<(), String> { ) -> Result<(), String> {
let message_ids_i64: Vec<i64> = message_ids.into_iter().map(|id| id.as_i64()).collect(); let message_ids_i64: Vec<i64> = message_ids.into_iter().map(|id| id.as_i64()).collect();
let result = let result =
functions::delete_messages(chat_id.as_i64(), message_ids_i64, revoke, self.client_id).await; functions::delete_messages(chat_id.as_i64(), message_ids_i64, revoke, self.client_id)
.await;
match result { match result {
Ok(_) => Ok(()), Ok(_) => Ok(()),
Err(e) => Err(format!("Ошибка удаления: {:?}", e)), Err(e) => Err(format!("Ошибка удаления: {:?}", e)),
@@ -670,17 +577,15 @@ impl MessageManager {
reply_to: None, reply_to: None,
date: 0, date: 0,
input_message_text: InputMessageContent::InputMessageText(InputMessageText { input_message_text: InputMessageContent::InputMessageText(InputMessageText {
text: FormattedText { text: FormattedText { text: text.clone(), entities: vec![] },
text: text.clone(),
entities: vec![],
},
link_preview_options: None, link_preview_options: None,
clear_draft: false, clear_draft: false,
}), }),
}) })
}; };
let result = functions::set_chat_draft_message(chat_id.as_i64(), 0, draft, self.client_id).await; let result =
functions::set_chat_draft_message(chat_id.as_i64(), 0, draft, self.client_id).await;
match result { match result {
Ok(_) => Ok(()), Ok(_) => Ok(()),
@@ -705,132 +610,8 @@ impl MessageManager {
for (chat_id, message_ids) in batch { for (chat_id, message_ids) in batch {
let ids: Vec<i64> = message_ids.iter().map(|id| id.as_i64()).collect(); let ids: Vec<i64> = message_ids.iter().map(|id| id.as_i64()).collect();
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();
});
}
} }

View File

@@ -4,8 +4,8 @@ mod chat_helpers; // Chat management helpers
pub mod chats; pub mod chats;
pub mod client; pub mod client;
mod client_impl; // Private module for trait implementation mod client_impl; // Private module for trait implementation
mod message_converter; // Message conversion utilities (for client.rs)
mod message_conversion; // Message conversion utilities (for messages.rs) mod message_conversion; // Message conversion utilities (for messages.rs)
mod message_converter; // Message conversion utilities (for client.rs)
pub mod messages; pub mod messages;
pub mod reactions; pub mod reactions;
pub mod r#trait; pub mod r#trait;
@@ -17,9 +17,15 @@ pub mod users;
pub use auth::AuthState; pub use auth::AuthState;
pub use client::TdClient; pub use client::TdClient;
pub use r#trait::TdClientTrait; pub use r#trait::TdClientTrait;
#[allow(unused_imports)]
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 для удобства

View File

@@ -69,7 +69,8 @@ impl ReactionManager {
message_id: MessageId, message_id: MessageId,
) -> Result<Vec<String>, String> { ) -> Result<Vec<String>, String> {
// Получаем сообщение // Получаем сообщение
let msg_result = functions::get_message(chat_id.as_i64(), message_id.as_i64(), self.client_id).await; let msg_result =
functions::get_message(chat_id.as_i64(), message_id.as_i64(), self.client_id).await;
let _msg = match msg_result { let _msg = match msg_result {
Ok(m) => m, Ok(m) => m,
Err(e) => return Err(format!("Ошибка получения сообщения: {:?}", e)), Err(e) => return Err(format!("Ошибка получения сообщения: {:?}", e)),

View File

@@ -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;
@@ -13,6 +14,7 @@ use super::ChatInfo;
/// ///
/// This trait defines the interface for both real and fake TDLib clients, /// This trait defines the interface for both real and fake TDLib clients,
/// enabling dependency injection and easier testing. /// enabling dependency injection and easier testing.
#[allow(dead_code)]
#[async_trait] #[async_trait]
pub trait TdClientTrait: Send { pub trait TdClientTrait: Send {
// ============ Auth methods ============ // ============ Auth methods ============
@@ -31,11 +33,23 @@ pub trait TdClientTrait: Send {
fn clear_stale_typing_status(&mut self) -> bool; fn clear_stale_typing_status(&mut self) -> bool;
// ============ Message methods ============ // ============ Message methods ============
async fn get_chat_history(&mut self, chat_id: ChatId, limit: i32) -> Result<Vec<MessageInfo>, String>; async fn get_chat_history(
async fn load_older_messages(&mut self, chat_id: ChatId, from_message_id: MessageId) -> Result<Vec<MessageInfo>, String>; &mut self,
chat_id: ChatId,
limit: i32,
) -> Result<Vec<MessageInfo>, String>;
async fn load_older_messages(
&mut self,
chat_id: ChatId,
from_message_id: MessageId,
) -> Result<Vec<MessageInfo>, String>;
async fn get_pinned_messages(&mut self, chat_id: ChatId) -> Result<Vec<MessageInfo>, String>; async fn get_pinned_messages(&mut self, chat_id: ChatId) -> Result<Vec<MessageInfo>, String>;
async fn load_current_pinned_message(&mut self, chat_id: ChatId); async fn load_current_pinned_message(&mut self, chat_id: ChatId);
async fn search_messages(&self, chat_id: ChatId, query: &str) -> Result<Vec<MessageInfo>, String>; async fn search_messages(
&self,
chat_id: ChatId,
query: &str,
) -> Result<Vec<MessageInfo>, String>;
async fn send_message( async fn send_message(
&mut self, &mut self,
@@ -90,6 +104,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 +141,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);
} }

View File

@@ -54,6 +54,54 @@ 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,
}
/// Состояние загрузки фотографии
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub enum PhotoDownloadState {
NotDownloaded,
Downloading,
Downloaded(String),
Error(String),
}
/// Информация о голосовом сообщении
#[allow(dead_code)]
#[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,
}
/// Состояние загрузки голосового сообщения
#[allow(dead_code)]
#[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 +110,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>,
} }
/// Состояние и права доступа к сообщению /// Состояние и права доступа к сообщению
@@ -106,6 +158,7 @@ pub struct MessageInfo {
impl MessageInfo { impl MessageInfo {
/// Создать новое сообщение /// Создать новое сообщение
#[allow(clippy::too_many_arguments)]
pub fn new( pub fn new(
id: MessageId, id: MessageId,
sender_name: String, sender_name: String,
@@ -128,11 +181,9 @@ impl MessageInfo {
sender_name, sender_name,
date, date,
edit_date, edit_date,
media_album_id: 0,
}, },
content: MessageContent { content: MessageContent { text: content, entities, media: None },
text: content,
entities,
},
state: MessageState { state: MessageState {
is_outgoing, is_outgoing,
is_read, is_read,
@@ -140,11 +191,7 @@ impl MessageInfo {
can_be_deleted_only_for_self, can_be_deleted_only_for_self,
can_be_deleted_for_all_users, can_be_deleted_for_all_users,
}, },
interactions: MessageInteractions { interactions: MessageInteractions { reply_to, forward_from, reactions },
reply_to,
forward_from,
reactions,
},
} }
} }
@@ -165,6 +212,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
} }
@@ -196,13 +247,53 @@ impl MessageInfo {
/// Checks if the message contains a mention (@username or user mention) /// Checks if the message contains a mention (@username or user mention)
pub fn has_mention(&self) -> bool { pub fn has_mention(&self) -> bool {
self.content.entities.iter().any(|entity| { self.content.entities.iter().any(|entity| {
matches!( matches!(entity.r#type, TextEntityType::Mention | TextEntityType::MentionName(_))
entity.r#type,
TextEntityType::Mention | TextEntityType::MentionName(_)
)
}) })
} }
/// Проверяет, содержит ли сообщение фото
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 (если есть)
#[allow(dead_code)]
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 +337,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 +359,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 +458,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,11 +487,13 @@ 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
} }
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -452,9 +561,7 @@ mod tests {
#[test] #[test]
fn test_message_builder_with_reactions() { fn test_message_builder_with_reactions() {
let reaction = ReactionInfo { let reaction = ReactionInfo {
emoji: "👍".to_string(), emoji: "👍".to_string(), count: 5, is_chosen: true
count: 5,
is_chosen: true,
}; };
let message = MessageBuilder::new(MessageId::new(300)) let message = MessageBuilder::new(MessageId::new(300))
@@ -512,9 +619,9 @@ mod tests {
.entities(vec![TextEntity { .entities(vec![TextEntity {
offset: 6, offset: 6,
length: 4, length: 4,
r#type: TextEntityType::MentionName( r#type: TextEntityType::MentionName(tdlib_rs::types::TextEntityTypeMentionName {
tdlib_rs::types::TextEntityTypeMentionName { user_id: 123 }, user_id: 123,
), }),
}]) }])
.build(); .build();
assert!(message_with_mention_name.has_mention()); assert!(message_with_mention_name.has_mention());
@@ -574,3 +681,44 @@ 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,
}
/// Состояние воспроизведения голосового сообщения
#[allow(dead_code)]
#[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,
}
/// Статус воспроизведения
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq)]
pub enum PlaybackStatus {
Playing,
Paused,
Stopped,
Loading,
Error(String),
}

View File

@@ -5,12 +5,10 @@
use crate::types::{ChatId, MessageId, UserId}; use crate::types::{ChatId, MessageId, UserId};
use std::time::Instant; use std::time::Instant;
use tdlib_rs::enums::{ use tdlib_rs::enums::{AuthorizationState, ChatAction, ChatList, MessageSender};
AuthorizationState, ChatAction, ChatList, MessageSender,
};
use tdlib_rs::types::{ use tdlib_rs::types::{
UpdateChatAction, UpdateChatDraftMessage, UpdateChatPosition, UpdateChatAction, UpdateChatDraftMessage, UpdateChatPosition, UpdateMessageInteractionInfo,
UpdateMessageInteractionInfo, UpdateMessageSendSucceeded, UpdateNewMessage, UpdateUser, UpdateMessageSendSucceeded, UpdateNewMessage, UpdateUser,
}; };
use super::auth::AuthState; use super::auth::AuthState;
@@ -25,24 +23,24 @@ pub fn handle_new_message_update(client: &mut TdClient, new_msg: UpdateNewMessag
if Some(chat_id) != client.current_chat_id() { if Some(chat_id) != client.current_chat_id() {
// Find and clone chat info to avoid borrow checker issues // Find and clone chat info to avoid borrow checker issues
if let Some(chat) = client.chats().iter().find(|c| c.id == chat_id).cloned() { if let Some(chat) = client.chats().iter().find(|c| c.id == chat_id).cloned() {
let msg_info = crate::tdlib::message_converter::convert_message(client, &new_msg.message, chat_id); let msg_info =
crate::tdlib::message_converter::convert_message(client, &new_msg.message, chat_id);
// Get sender name (from message or user cache) // Get sender name (from message or user cache)
let sender_name = msg_info.sender_name(); let sender_name = msg_info.sender_name();
// Send notification // Send notification
let _ = client.notification_manager.notify_new_message( let _ = client
&chat, .notification_manager
&msg_info, .notify_new_message(&chat, &msg_info, sender_name);
sender_name,
);
} }
return; return;
} }
// Добавляем новое сообщение если это текущий открытый чат // Добавляем новое сообщение если это текущий открытый чат
let msg_info = crate::tdlib::message_converter::convert_message(client, &new_msg.message, chat_id); let msg_info =
crate::tdlib::message_converter::convert_message(client, &new_msg.message, chat_id);
let msg_id = msg_info.id(); let msg_id = msg_info.id();
let is_incoming = !msg_info.is_outgoing(); let is_incoming = !msg_info.is_outgoing();
@@ -74,7 +72,9 @@ pub fn handle_new_message_update(client: &mut TdClient, new_msg: UpdateNewMessag
client.push_message(msg_info.clone()); client.push_message(msg_info.clone());
// Если это входящее сообщение — добавляем в очередь для отметки как прочитанное // Если это входящее сообщение — добавляем в очередь для отметки как прочитанное
if is_incoming { if is_incoming {
client.pending_view_messages_mut().push((chat_id, vec![msg_id])); client
.pending_view_messages_mut()
.push((chat_id, vec![msg_id]));
} }
} }
} }
@@ -105,7 +105,7 @@ pub fn handle_chat_action_update(client: &mut TdClient, update: UpdateChatAction
ChatAction::ChoosingSticker => Some("выбирает стикер...".to_string()), ChatAction::ChoosingSticker => Some("выбирает стикер...".to_string()),
ChatAction::RecordingVideoNote => Some("записывает видеосообщение...".to_string()), ChatAction::RecordingVideoNote => Some("записывает видеосообщение...".to_string()),
ChatAction::UploadingVideoNote(_) => Some("отправляет видеосообщение...".to_string()), ChatAction::UploadingVideoNote(_) => Some("отправляет видеосообщение...".to_string()),
ChatAction::Cancel | _ => None, // Отмена или неизвестное действие _ => None, // Отмена или неизвестное действие
}; };
match action_text { match action_text {
@@ -181,14 +181,21 @@ pub fn handle_user_update(client: &mut TdClient, update: UpdateUser) {
} else { } else {
format!("{} {}", user.first_name, user.last_name) format!("{} {}", user.first_name, user.last_name)
}; };
client.user_cache.user_names.insert(UserId::new(user.id), display_name); client
.user_cache
.user_names
.insert(UserId::new(user.id), display_name);
// Сохраняем username если есть (с упрощённым извлечением через and_then) // Сохраняем username если есть (с упрощённым извлечением через and_then)
if let Some(username) = user.usernames if let Some(username) = user
.usernames
.as_ref() .as_ref()
.and_then(|u| u.active_usernames.first()) .and_then(|u| u.active_usernames.first())
{ {
client.user_cache.user_usernames.insert(UserId::new(user.id), username.to_string()); client
.user_cache
.user_usernames
.insert(UserId::new(user.id), username.to_string());
// Обновляем username в чатах, связанных с этим пользователем // Обновляем username в чатах, связанных с этим пользователем
for (&chat_id, &user_id) in &client.user_cache.chat_user_ids.clone() { for (&chat_id, &user_id) in &client.user_cache.chat_user_ids.clone() {
if user_id == UserId::new(user.id) { if user_id == UserId::new(user.id) {
@@ -273,7 +280,8 @@ pub fn handle_message_send_succeeded_update(
}; };
// Конвертируем новое сообщение // Конвертируем новое сообщение
let mut new_msg = crate::tdlib::message_converter::convert_message(client, &update.message, chat_id); let mut new_msg =
crate::tdlib::message_converter::convert_message(client, &update.message, chat_id);
// Сохраняем reply_info из старого сообщения (если было) // Сохраняем reply_info из старого сообщения (если было)
let old_reply = client.current_chat_messages()[idx] let old_reply = client.current_chat_messages()[idx]

View File

@@ -175,7 +175,9 @@ impl UserCache {
} }
// Сохраняем имя // Сохраняем имя
let display_name = format!("{} {}", user.first_name, user.last_name).trim().to_string(); let display_name = format!("{} {}", user.first_name, user.last_name)
.trim()
.to_string();
self.user_names.insert(UserId::new(user_id), display_name); self.user_names.insert(UserId::new(user_id), display_name);
// Обновляем статус // Обновляем статус
@@ -211,6 +213,7 @@ impl UserCache {
/// # Returns /// # Returns
/// ///
/// Имя пользователя (first_name + last_name) или "User {id}" если не найден. /// Имя пользователя (first_name + last_name) или "User {id}" если не найден.
#[allow(dead_code)]
pub async fn get_user_name(&self, user_id: UserId) -> String { pub async fn get_user_name(&self, user_id: UserId) -> String {
// Сначала пытаемся получить из кэша // Сначала пытаемся получить из кэша
if let Some(name) = self.user_names.peek(&user_id) { if let Some(name) = self.user_names.peek(&user_id) {
@@ -220,7 +223,9 @@ impl UserCache {
// Загружаем пользователя // Загружаем пользователя
match functions::get_user(user_id.as_i64(), self.client_id).await { match functions::get_user(user_id.as_i64(), self.client_id).await {
Ok(User::User(user)) => { Ok(User::User(user)) => {
let name = format!("{} {}", user.first_name, user.last_name).trim().to_string(); let name = format!("{} {}", user.first_name, user.last_name)
.trim()
.to_string();
name name
} }
_ => format!("User {}", user_id.as_i64()), _ => format!("User {}", user_id.as_i64()),
@@ -257,8 +262,7 @@ impl UserCache {
} }
Err(_) => { Err(_) => {
// Если не удалось загрузить, сохраняем placeholder // Если не удалось загрузить, сохраняем placeholder
self.user_names self.user_names.insert(user_id, format!("User {}", user_id));
.insert(user_id, format!("User {}", user_id));
} }
} }
} }

View File

@@ -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;

View File

@@ -1,6 +1,6 @@
use crate::app::App; use crate::app::App;
use crate::tdlib::TdClientTrait;
use crate::tdlib::AuthState; use crate::tdlib::AuthState;
use crate::tdlib::TdClientTrait;
use ratatui::{ use ratatui::{
layout::{Alignment, Constraint, Direction, Layout}, layout::{Alignment, Constraint, Direction, Layout},
style::{Color, Modifier, Style}, style::{Color, Modifier, Style},

View File

@@ -1,3 +1,6 @@
//! Chat list panel: search box, chat items, and user online status.
use crate::app::methods::{compose::ComposeMethods, search::SearchMethods};
use crate::app::App; use crate::app::App;
use crate::tdlib::TdClientTrait; use crate::tdlib::TdClientTrait;
use crate::tdlib::UserOnlineStatus; use crate::tdlib::UserOnlineStatus;
@@ -68,55 +71,18 @@ 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
if let Some(chat) = filtered.get(i) { .selected()
match app.td_client.get_user_status_by_chat_id(chat.id) { .and_then(|i| filtered.get(i).map(|c| c.id))
Some(UserOnlineStatus::Online) => ("● онлайн".to_string(), Color::Green), };
Some(UserOnlineStatus::Recently) => { let (status_text, status_color) = match status_chat_id {
("был(а) недавно".to_string(), Color::Yellow) Some(chat_id) => format_user_status(app.td_client.get_user_status_by_chat_id(chat_id)),
} None => ("".to_string(), Color::DarkGray),
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 {
("".to_string(), Color::DarkGray)
}
} else {
("".to_string(), Color::DarkGray)
}
}; };
let status = Paragraph::new(status_text) let status = Paragraph::new(status_text)
@@ -125,7 +91,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),
}
} }

View File

@@ -21,7 +21,7 @@ pub fn render_emoji_picker(
) { ) {
// Размеры модалки (зависят от количества реакций) // Размеры модалки (зависят от количества реакций)
let emojis_per_row = 8; let emojis_per_row = 8;
let rows = (available_reactions.len() + emojis_per_row - 1) / emojis_per_row; let rows = available_reactions.len().div_ceil(emojis_per_row);
let modal_width = 50u16; let modal_width = 50u16;
let modal_height = (rows + 4) as u16; // +4 для заголовка, отступов и подсказки let modal_height = (rows + 4) as u16; // +4 для заголовка, отступов и подсказки
@@ -29,12 +29,7 @@ pub fn render_emoji_picker(
let x = area.x + (area.width.saturating_sub(modal_width)) / 2; let x = area.x + (area.width.saturating_sub(modal_width)) / 2;
let y = area.y + (area.height.saturating_sub(modal_height)) / 2; let y = area.y + (area.height.saturating_sub(modal_height)) / 2;
let modal_area = Rect::new( let modal_area = Rect::new(x, y, modal_width.min(area.width), modal_height.min(area.height));
x,
y,
modal_width.min(area.width),
modal_height.min(area.height),
);
// Очищаем область под модалкой // Очищаем область под модалкой
f.render_widget(Clear, modal_area); f.render_widget(Clear, modal_area);
@@ -87,10 +82,7 @@ pub fn render_emoji_picker(
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
), ),
Span::raw("Добавить "), Span::raw("Добавить "),
Span::styled( Span::styled(" [Esc] ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
" [Esc] ",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
Span::raw("Отмена"), Span::raw("Отмена"),
])); ]));

View File

@@ -34,10 +34,7 @@ pub fn render_input_field(
// Символ под курсором (или █ если курсор в конце) // Символ под курсором (или █ если курсор в конце)
if safe_cursor_pos < chars.len() { if safe_cursor_pos < chars.len() {
let cursor_char = chars[safe_cursor_pos].to_string(); let cursor_char = chars[safe_cursor_pos].to_string();
spans.push(Span::styled( spans.push(Span::styled(cursor_char, Style::default().fg(Color::Black).bg(color)));
cursor_char,
Style::default().fg(Color::Black).bg(color),
));
} else { } else {
// Курсор в конце - показываем блок // Курсор в конце - показываем блок
spans.push(Span::styled("", Style::default().fg(color))); spans.push(Span::styled("", Style::default().fg(color)));

View File

@@ -7,7 +7,9 @@
use crate::config::Config; use crate::config::Config;
use crate::formatting; use crate::formatting;
use crate::tdlib::MessageInfo; #[cfg(feature = "images")]
use crate::tdlib::PhotoDownloadState;
use crate::tdlib::{MessageInfo, PlaybackState, PlaybackStatus};
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,34 @@ 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(), start_offset: base_offset }];
text: text.to_string(),
start_offset: 0,
}];
} }
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 +66,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 +78,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 +94,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 +104,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;
} }
} }
@@ -99,10 +116,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(), start_offset: base_offset });
text: String::new(),
start_offset: 0,
});
} }
result result
@@ -115,7 +129,11 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
/// * `date` - timestamp сообщения /// * `date` - timestamp сообщения
/// * `content_width` - ширина области для центрирования /// * `content_width` - ширина области для центрирования
/// * `is_first` - первый ли это разделитель (если нет, добавляется пустая строка сверху) /// * `is_first` - первый ли это разделитель (если нет, добавляется пустая строка сверху)
pub fn render_date_separator(date: i32, content_width: usize, is_first: bool) -> Vec<Line<'static>> { pub fn render_date_separator(
date: i32,
content_width: usize,
is_first: bool,
) -> Vec<Line<'static>> {
let mut lines = Vec::new(); let mut lines = Vec::new();
if !is_first { if !is_first {
@@ -198,6 +216,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());
@@ -252,10 +271,8 @@ pub fn render_message_bubble(
Span::styled(reply_line, Style::default().fg(Color::Cyan)), Span::styled(reply_line, Style::default().fg(Color::Cyan)),
])); ]));
} else { } else {
lines.push(Line::from(vec![Span::styled( lines
reply_line, .push(Line::from(vec![Span::styled(reply_line, Style::default().fg(Color::Cyan))]));
Style::default().fg(Color::Cyan),
)]));
} }
} }
@@ -277,22 +294,35 @@ pub fn render_message_bubble(
let is_last_line = i == total_wrapped - 1; let is_last_line = i == total_wrapped - 1;
let line_len = wrapped.text.chars().count(); let line_len = wrapped.text.chars().count();
let line_entities = let line_entities = formatting::adjust_entities_for_substring(
formatting::adjust_entities_for_substring(msg.entities(), wrapped.start_offset, line_len); msg.entities(),
let formatted_spans = formatting::format_text_with_entities(&wrapped.text, &line_entities, msg_color); wrapped.start_offset,
line_len,
);
let formatted_spans =
formatting::format_text_with_entities(&wrapped.text, &line_entities, msg_color);
if is_last_line { if is_last_line {
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),
));
lines.push(Line::from(line_spans)); lines.push(Line::from(line_spans));
} else { } else {
let padding = content_width.saturating_sub(line_len + marker_len + 1); let padding = content_width.saturating_sub(line_len + marker_len + 1);
@@ -300,8 +330,13 @@ pub fn render_message_bubble(
if i == 0 && is_selected { if i == 0 && is_selected {
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);
lines.push(Line::from(line_spans)); lines.push(Line::from(line_spans));
@@ -319,19 +354,26 @@ pub fn render_message_bubble(
for (i, wrapped) in wrapped_lines.into_iter().enumerate() { for (i, wrapped) in wrapped_lines.into_iter().enumerate() {
let line_len = wrapped.text.chars().count(); let line_len = wrapped.text.chars().count();
let line_entities = let line_entities = formatting::adjust_entities_for_substring(
formatting::adjust_entities_for_substring(msg.entities(), wrapped.start_offset, line_len); msg.entities(),
let formatted_spans = formatting::format_text_with_entities(&wrapped.text, &line_entities, msg_color); wrapped.start_offset,
line_len,
);
let formatted_spans =
formatting::format_text_with_entities(&wrapped.text, &line_entities, msg_color);
if i == 0 { if i == 0 {
let mut line_spans = vec![]; let mut line_spans = vec![];
if is_selected { if is_selected {
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),
)); ));
} }
line_spans.push(Span::styled(format!(" {}", time_str), Style::default().fg(Color::Gray))); line_spans
.push(Span::styled(format!(" {}", time_str), Style::default().fg(Color::Gray)));
line_spans.push(Span::raw(" ")); line_spans.push(Span::raw(" "));
line_spans.extend(formatted_spans); line_spans.extend(formatted_spans);
lines.push(Line::from(line_spans)); lines.push(Line::from(line_spans));
@@ -359,12 +401,10 @@ pub fn render_message_bubble(
} else { } else {
format!("[{}]", reaction.emoji) format!("[{}]", reaction.emoji)
} }
} else if reaction.count > 1 {
format!("{} {}", reaction.emoji, reaction.count)
} else { } else {
if reaction.count > 1 { reaction.emoji.clone()
format!("{} {}", reaction.emoji, reaction.count)
} else {
reaction.emoji.clone()
}
}; };
let style = if reaction.is_chosen { let style = if reaction.is_chosen {
@@ -392,5 +432,338 @@ 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().is_some_and(|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.div_ceil(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
}

View File

@@ -0,0 +1,117 @@
//! 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)
}

View File

@@ -1,13 +1,17 @@
// UI компоненты для переиспользования //! Reusable UI components: message bubbles, input fields, modals, lists.
pub mod modal;
pub mod input_field;
pub mod message_bubble;
pub mod chat_list_item; pub mod chat_list_item;
pub mod emoji_picker; pub mod emoji_picker;
pub mod input_field;
pub mod message_bubble;
pub mod message_list;
pub mod modal;
// Экспорт основных функций // Экспорт основных функций
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 input_field::render_input_field;
#[cfg(feature = "images")]
pub use message_bubble::{calculate_image_height, render_album_bubble, DeferredImageRender};
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};
pub use message_list::{calculate_scroll_offset, render_help_bar, render_message_item};

View File

@@ -74,10 +74,7 @@ pub fn render_delete_confirm_modal(f: &mut Frame, area: Rect) {
), ),
Span::raw("Да"), Span::raw("Да"),
Span::raw(" "), Span::raw(" "),
Span::styled( Span::styled(" [n/Esc] ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
" [n/Esc] ",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
Span::raw("Нет"), Span::raw("Нет"),
]), ]),
]; ];

194
src/ui/compose_bar.rs Normal file
View File

@@ -0,0 +1,194 @@
//! Compose bar / input box rendering
use crate::app::methods::{compose::ComposeMethods, messages::MessageMethods};
use crate::app::App;
use crate::app::InputMode;
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);
}

View File

@@ -1,6 +1,7 @@
use crate::app::App; use crate::app::App;
use crate::tdlib::TdClientTrait; use crate::app::InputMode;
use crate::tdlib::NetworkState; use crate::tdlib::NetworkState;
use crate::tdlib::TdClientTrait;
use ratatui::{ use ratatui::{
layout::Rect, layout::Rect,
style::{Color, Style}, style::{Color, Style},
@@ -18,18 +19,32 @@ 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
) )
}; };

View File

@@ -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::methods::{messages::MessageMethods, modal::ModalMethods, search::SearchMethods};
use crate::app::App; use crate::app::App;
use crate::tdlib::TdClientTrait;
use crate::message_grouping::{group_messages, MessageGroup}; use crate::message_grouping::{group_messages, MessageGroup};
use crate::tdlib::TdClientTrait;
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},
@@ -11,7 +18,12 @@ use ratatui::{
}; };
/// Рендерит заголовок чата с typing status /// Рендерит заголовок чата с typing status
fn render_chat_header<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>, chat: &crate::tdlib::ChatInfo) { fn render_chat_header<T: TdClientTrait>(
f: &mut Frame,
area: Rect,
app: &App<T>,
chat: &crate::tdlib::ChatInfo,
) {
let typing_action = app let typing_action = app
.td_client .td_client
.typing_status() .typing_status()
@@ -27,10 +39,7 @@ fn render_chat_header<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>,
.add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD),
)]; )];
if let Some(username) = &chat.username { if let Some(username) = &chat.username {
spans.push(Span::styled( spans.push(Span::styled(format!(" {}", username), Style::default().fg(Color::Gray)));
format!(" {}", username),
Style::default().fg(Color::Gray),
));
} }
spans.push(Span::styled( spans.push(Span::styled(
format!(" {}", action), format!(" {}", action),
@@ -83,33 +92,20 @@ fn render_pinned_bar<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>)
Span::raw(" ".repeat(padding)), Span::raw(" ".repeat(padding)),
Span::styled(pinned_hint, Style::default().fg(Color::Gray)), Span::styled(pinned_hint, Style::default().fg(Color::Gray)),
]); ]);
let pinned_bar = let pinned_bar = Paragraph::new(pinned_line).style(Style::default().bg(Color::Rgb(40, 20, 40)));
Paragraph::new(pinned_line).style(Style::default().bg(Color::Rgb(40, 20, 40)));
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(),
}];
} }
let mut result = Vec::new(); let mut result = Vec::new();
@@ -134,9 +130,7 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
current_line.push_str(&word); current_line.push_str(&word);
current_width += 1 + word_width; current_width += 1 + word_width;
} else { } else {
result.push(WrappedLine { result.push(WrappedLine { text: current_line });
text: current_line,
});
current_line = word; current_line = word;
current_width = word_width; current_width = word_width;
} }
@@ -158,29 +152,23 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
current_line.push(' '); current_line.push(' ');
current_line.push_str(&word); current_line.push_str(&word);
} else { } else {
result.push(WrappedLine { result.push(WrappedLine { text: current_line });
text: current_line,
});
current_line = word; current_line = word;
} }
} }
if !current_line.is_empty() { if !current_line.is_empty() {
result.push(WrappedLine { result.push(WrappedLine { text: current_line });
text: current_line,
});
} }
if result.is_empty() { if result.is_empty() {
result.push(WrappedLine { result.push(WrappedLine { text: String::new() });
text: String::new(),
});
} }
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 +179,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;
@@ -204,10 +199,7 @@ fn render_message_list<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>
is_first_date = false; is_first_date = false;
is_first_sender = true; // Сбрасываем счётчик заголовков после даты is_first_sender = true; // Сбрасываем счётчик заголовков после даты
} }
MessageGroup::SenderHeader { MessageGroup::SenderHeader { is_outgoing, sender_name } => {
is_outgoing,
sender_name,
} => {
// Рендерим заголовок отправителя // Рендерим заголовок отправителя
lines.extend(components::render_sender_header( lines.extend(components::render_sender_header(
is_outgoing, is_outgoing,
@@ -225,12 +217,85 @@ 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(),
));
}
}
} }
} }
} }
@@ -244,11 +309,7 @@ fn render_message_list<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>
let total_lines = lines.len(); let total_lines = lines.len();
// Базовый скролл (показываем последние сообщения) // Базовый скролл (показываем последние сообщения)
let base_scroll = if total_lines > visible_height { let base_scroll = total_lines.saturating_sub(visible_height);
total_lines - visible_height
} else {
0
};
// Если выбрано сообщение, автоскроллим к нему // Если выбрано сообщение, автоскроллим к нему
let scroll_offset = if app.is_selecting_message() { let scroll_offset = if app.is_selecting_message() {
@@ -275,156 +336,67 @@ 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,27 +407,27 @@ 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
}; };
// Минимум 3 строки (1 контент + 2 рамки), максимум 10 // Минимум 3 строки (1 контент + 2 рамки), максимум 10
let input_height = (input_lines + 2).min(10).max(3); let input_height = (input_lines + 2).clamp(3, 10);
// Проверяем, есть ли закреплённое сообщение // Проверяем, есть ли закреплённое сообщение
let has_pinned = app.td_client.current_pinned_message().is_some(); let has_pinned = app.td_client.current_pinned_message().is_some();
@@ -483,7 +455,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 +464,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,391 +475,13 @@ 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);
} }
// Модалка выбора реакции // Модалка выбора реакции
if let crate::app::ChatState::ReactionPicker { if let crate::app::ChatState::ReactionPicker { available_reactions, selected_index, .. } =
available_reactions, &app.chat_state
selected_index,
..
} = &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);
}

View File

@@ -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;
pub mod components; pub mod components;
mod compose_bar;
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) {

View File

@@ -0,0 +1,190 @@
//! 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);
}

View File

@@ -0,0 +1,8 @@
//! Delete confirmation modal
use ratatui::{layout::Rect, Frame};
/// Renders delete confirmation modal
pub fn render(f: &mut Frame, area: Rect) {
crate::ui::components::modal::render_delete_confirm_modal(f, area);
}

View File

@@ -0,0 +1,178 @@
//! Модальное окно для полноэкранного просмотра изображений.
//!
//! Поддерживает:
//! - Автоматическое масштабирование с сохранением 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
View 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 pinned;
pub mod reaction_picker;
pub mod search;
#[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 pinned::render as render_pinned;
pub use reaction_picker::render as render_reaction_picker;
pub use search::render as render_search;
#[cfg(feature = "images")]
pub use image_viewer::render as render_image_viewer;

91
src/ui/modals/pinned.rs Normal file
View File

@@ -0,0 +1,91 @@
//! Pinned messages viewer modal
use crate::app::App;
use crate::tdlib::TdClientTrait;
use crate::ui::components::{calculate_scroll_offset, render_help_bar, render_message_item};
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]);
}

View File

@@ -0,0 +1,8 @@
//! Reaction picker modal
use ratatui::{layout::Rect, Frame};
/// 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);
}

110
src/ui/modals/search.rs Normal file
View File

@@ -0,0 +1,110 @@
//! Message search modal
use crate::app::App;
use crate::tdlib::TdClientTrait;
use crate::ui::components::{calculate_scroll_offset, render_help_bar, render_message_item};
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]);
}

View File

@@ -1,6 +1,7 @@
use crate::app::methods::modal::ModalMethods;
use crate::app::App; use crate::app::App;
use crate::tdlib::TdClientTrait;
use crate::tdlib::ProfileInfo; use crate::tdlib::ProfileInfo;
use crate::tdlib::TdClientTrait;
use ratatui::{ use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect}, layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style}, style::{Color, Modifier, Style},

View File

@@ -6,6 +6,6 @@ pub mod validation;
pub use formatting::*; pub use formatting::*;
// pub use modal_handler::*; // Используется через явный import // pub use modal_handler::*; // Используется через явный import
pub use retry::{with_timeout, with_timeout_msg, with_timeout_ignore}; pub use retry::{with_timeout, with_timeout_ignore, with_timeout_msg};
pub use tdlib::*; pub use tdlib::*;
pub use validation::*; pub use validation::*;

View File

@@ -105,10 +105,9 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_with_timeout_success() { async fn test_with_timeout_success() {
let result = with_timeout(Duration::from_secs(1), async { let result =
Ok::<_, String>("success".to_string()) with_timeout(Duration::from_secs(1), async { Ok::<_, String>("success".to_string()) })
}) .await;
.await;
assert!(result.is_ok()); assert!(result.is_ok());
assert_eq!(result.unwrap(), "success"); assert_eq!(result.unwrap(), "success");

193
tests/account_switcher.rs Normal file
View File

@@ -0,0 +1,193 @@
// 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());
}

180
tests/accounts.rs Normal file
View File

@@ -0,0 +1,180 @@
// 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());
}

View File

@@ -65,9 +65,7 @@ fn test_incoming_message_shows_unread_badge() {
.last_message("Как дела?") .last_message("Как дела?")
.build(); .build();
let mut app = TestAppBuilder::new() let mut app = TestAppBuilder::new().with_chat(chat).build();
.with_chat(chat)
.build();
// Рендерим UI - должно быть без "(1)" // Рендерим UI - должно быть без "(1)"
let buffer_before = render_to_buffer(80, 24, |f| { let buffer_before = render_to_buffer(80, 24, |f| {
@@ -89,7 +87,11 @@ fn test_incoming_message_shows_unread_badge() {
let output_after = buffer_to_string(&buffer_after); let output_after = buffer_to_string(&buffer_after);
// Проверяем что появилось "(1)" в первой строке чата // Проверяем что появилось "(1)" в первой строке чата
assert!(output_after.contains("(1)"), "After: should contain (1)\nActual output:\n{}", output_after); assert!(
output_after.contains("(1)"),
"After: should contain (1)\nActual output:\n{}",
output_after
);
} }
#[tokio::test] #[tokio::test]
@@ -129,7 +131,11 @@ async fn test_opening_chat_clears_unread_badge() {
let output_before = buffer_to_string(&buffer_before); let output_before = buffer_to_string(&buffer_before);
// Проверяем что есть "(3)" в списке чатов // Проверяем что есть "(3)" в списке чатов
assert!(output_before.contains("(3)"), "Before opening: should contain (3)\nActual output:\n{}", output_before); assert!(
output_before.contains("(3)"),
"Before opening: should contain (3)\nActual output:\n{}",
output_before
);
// Симулируем открытие чата - загружаем историю // Симулируем открытие чата - загружаем историю
let chat_id = ChatId::new(999); let chat_id = ChatId::new(999);
@@ -146,7 +152,8 @@ async fn test_opening_chat_clears_unread_badge() {
assert_eq!(incoming_message_ids.len(), 3, "Should have 3 incoming messages"); assert_eq!(incoming_message_ids.len(), 3, "Should have 3 incoming messages");
// Добавляем в очередь для отметки как прочитанные (напрямую через Mutex) // Добавляем в очередь для отметки как прочитанные (напрямую через Mutex)
app.td_client.pending_view_messages app.td_client
.pending_view_messages
.lock() .lock()
.unwrap() .unwrap()
.push((chat_id, incoming_message_ids)); .push((chat_id, incoming_message_ids));
@@ -171,7 +178,11 @@ async fn test_opening_chat_clears_unread_badge() {
let output_after = buffer_to_string(&buffer_after); let output_after = buffer_to_string(&buffer_after);
// Проверяем что "(3)" больше нет // Проверяем что "(3)" больше нет
assert!(!output_after.contains("(3)"), "After opening: should not contain (3)\nActual output:\n{}", output_after); assert!(
!output_after.contains("(3)"),
"After opening: should not contain (3)\nActual output:\n{}",
output_after
);
} }
#[tokio::test] #[tokio::test]
@@ -254,8 +265,8 @@ async fn test_chat_history_chunked_loading() {
// Проверяем что сообщения в правильном порядке (от старых к новым) // Проверяем что сообщения в правильном порядке (от старых к новым)
assert_eq!(loaded_messages[0].text(), "Message 1"); assert_eq!(loaded_messages[0].text(), "Message 1");
assert_eq!(loaded_messages[49].text(), "Message 50"); // Граница первого чанка assert_eq!(loaded_messages[49].text(), "Message 50"); // Граница первого чанка
assert_eq!(loaded_messages[50].text(), "Message 51"); // Начало второго чанка assert_eq!(loaded_messages[50].text(), "Message 51"); // Начало второго чанка
assert_eq!(loaded_messages[99].text(), "Message 100"); assert_eq!(loaded_messages[99].text(), "Message 100");
// Тест 2: Загружаем все 120 сообщений // Тест 2: Загружаем все 120 сообщений
@@ -307,7 +318,11 @@ async fn test_chat_history_loads_all_without_limit() {
// Загружаем без лимита (i32::MAX) // Загружаем без лимита (i32::MAX)
let chat_id = ChatId::new(1001); let chat_id = ChatId::new(1001);
let all = app.td_client.get_chat_history(chat_id, i32::MAX).await.unwrap(); let all = app
.td_client
.get_chat_history(chat_id, i32::MAX)
.await
.unwrap();
assert_eq!(all.len(), 200, "Should load all 200 messages without limit"); assert_eq!(all.len(), 200, "Should load all 200 messages without limit");
assert_eq!(all[0].text(), "Msg 1", "First message should be oldest"); assert_eq!(all[0].text(), "Msg 1", "First message should be oldest");
@@ -355,7 +370,11 @@ async fn test_load_older_messages_pagination() {
let msg_101_id = all_messages[100].id(); // index 100 = Msg 101 let msg_101_id = all_messages[100].id(); // index 100 = Msg 101
// Загружаем сообщения старше 101 // Загружаем сообщения старше 101
let older_batch = app.td_client.load_older_messages(chat_id, msg_101_id).await.unwrap(); let older_batch = app
.td_client
.load_older_messages(chat_id, msg_101_id)
.await
.unwrap();
// Должны получить сообщения 1-100 (все что старше 101) // Должны получить сообщения 1-100 (все что старше 101)
assert_eq!(older_batch.len(), 100, "Should load 100 older messages"); assert_eq!(older_batch.len(), 100, "Should load 100 older messages");
@@ -493,4 +512,3 @@ fn snapshot_chat_with_online_status() {
let output = buffer_to_string(&buffer); let output = buffer_to_string(&buffer);
assert_snapshot!("chat_with_online_status", output); assert_snapshot!("chat_with_online_status", output);
} }

View File

@@ -1,6 +1,9 @@
// Integration tests for config flow // Integration tests for config flow
use tele_tui::config::{Config, ColorsConfig, GeneralConfig, Keybindings, NotificationsConfig}; use tele_tui::config::{
AudioConfig, ColorsConfig, Config, GeneralConfig, ImagesConfig, Keybindings,
NotificationsConfig,
};
/// Test: Дефолтные значения конфигурации /// Test: Дефолтные значения конфигурации
#[test] #[test]
@@ -22,9 +25,7 @@ fn test_config_default_values() {
#[test] #[test]
fn test_config_custom_values() { fn test_config_custom_values() {
let config = Config { let config = Config {
general: GeneralConfig { general: GeneralConfig { timezone: "+05:00".to_string() },
timezone: "+05:00".to_string(),
},
colors: ColorsConfig { colors: ColorsConfig {
incoming_message: "cyan".to_string(), incoming_message: "cyan".to_string(),
outgoing_message: "blue".to_string(), outgoing_message: "blue".to_string(),
@@ -34,6 +35,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");
@@ -106,9 +109,7 @@ fn test_parse_color_case_insensitive() {
#[test] #[test]
fn test_config_toml_serialization() { fn test_config_toml_serialization() {
let original_config = Config { let original_config = Config {
general: GeneralConfig { general: GeneralConfig { timezone: "-05:00".to_string() },
timezone: "-05:00".to_string(),
},
colors: ColorsConfig { colors: ColorsConfig {
incoming_message: "cyan".to_string(), incoming_message: "cyan".to_string(),
outgoing_message: "blue".to_string(), outgoing_message: "blue".to_string(),
@@ -118,6 +119,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
@@ -160,25 +163,19 @@ mod timezone_tests {
#[test] #[test]
fn test_timezone_formats() { fn test_timezone_formats() {
let positive = Config { let positive = Config {
general: GeneralConfig { general: GeneralConfig { timezone: "+03:00".to_string() },
timezone: "+03:00".to_string(),
},
..Default::default() ..Default::default()
}; };
assert_eq!(positive.general.timezone, "+03:00"); assert_eq!(positive.general.timezone, "+03:00");
let negative = Config { let negative = Config {
general: GeneralConfig { general: GeneralConfig { timezone: "-05:00".to_string() },
timezone: "-05:00".to_string(),
},
..Default::default() ..Default::default()
}; };
assert_eq!(negative.general.timezone, "-05:00"); assert_eq!(negative.general.timezone, "-05:00");
let zero = Config { let zero = Config {
general: GeneralConfig { general: GeneralConfig { timezone: "+00:00".to_string() },
timezone: "+00:00".to_string(),
},
..Default::default() ..Default::default()
}; };
assert_eq!(zero.general.timezone, "+00:00"); assert_eq!(zero.general.timezone, "+00:00");

View File

@@ -12,13 +12,19 @@ async fn test_delete_message_removes_from_list() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
// Отправляем сообщение // Отправляем сообщение
let msg = client.send_message(ChatId::new(123), "Delete me".to_string(), None, None).await.unwrap(); let msg = client
.send_message(ChatId::new(123), "Delete me".to_string(), None, None)
.await
.unwrap();
// Проверяем что сообщение есть // Проверяем что сообщение есть
assert_eq!(client.get_messages(123).len(), 1); assert_eq!(client.get_messages(123).len(), 1);
// Удаляем сообщение // Удаляем сообщение
client.delete_messages(ChatId::new(123), vec![msg.id()], false).await.unwrap(); client
.delete_messages(ChatId::new(123), vec![msg.id()], false)
.await
.unwrap();
// Проверяем что удаление записалось // Проверяем что удаление записалось
assert_eq!(client.get_deleted_messages().len(), 1); assert_eq!(client.get_deleted_messages().len(), 1);
@@ -34,15 +40,30 @@ async fn test_delete_multiple_messages() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
// Отправляем 3 сообщения // Отправляем 3 сообщения
let msg1 = client.send_message(ChatId::new(123), "Message 1".to_string(), None, None).await.unwrap(); let msg1 = client
let msg2 = client.send_message(ChatId::new(123), "Message 2".to_string(), None, None).await.unwrap(); .send_message(ChatId::new(123), "Message 1".to_string(), None, None)
let msg3 = client.send_message(ChatId::new(123), "Message 3".to_string(), None, None).await.unwrap(); .await
.unwrap();
let msg2 = client
.send_message(ChatId::new(123), "Message 2".to_string(), None, None)
.await
.unwrap();
let msg3 = client
.send_message(ChatId::new(123), "Message 3".to_string(), None, None)
.await
.unwrap();
assert_eq!(client.get_messages(123).len(), 3); assert_eq!(client.get_messages(123).len(), 3);
// Удаляем первое и третье // Удаляем первое и третье
client.delete_messages(ChatId::new(123), vec![msg1.id()], false).await.unwrap(); client
client.delete_messages(ChatId::new(123), vec![msg3.id()], false).await.unwrap(); .delete_messages(ChatId::new(123), vec![msg1.id()], false)
.await
.unwrap();
client
.delete_messages(ChatId::new(123), vec![msg3.id()], false)
.await
.unwrap();
// Проверяем историю удалений // Проверяем историю удалений
assert_eq!(client.get_deleted_messages().len(), 2); assert_eq!(client.get_deleted_messages().len(), 2);
@@ -89,12 +110,18 @@ async fn test_delete_nonexistent_message() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
// Отправляем одно сообщение // Отправляем одно сообщение
let msg = client.send_message(ChatId::new(123), "Exists".to_string(), None, None).await.unwrap(); let msg = client
.send_message(ChatId::new(123), "Exists".to_string(), None, None)
.await
.unwrap();
assert_eq!(client.get_messages(123).len(), 1); assert_eq!(client.get_messages(123).len(), 1);
// Пытаемся удалить несуществующее // Пытаемся удалить несуществующее
client.delete_messages(ChatId::new(123), vec![MessageId::new(999)], false).await.unwrap(); client
.delete_messages(ChatId::new(123), vec![MessageId::new(999)], false)
.await
.unwrap();
// Удаление записалось в историю // Удаление записалось в историю
assert_eq!(client.get_deleted_messages().len(), 1); assert_eq!(client.get_deleted_messages().len(), 1);
@@ -112,7 +139,10 @@ async fn test_delete_nonexistent_message() {
async fn test_delete_with_confirmation_flow() { async fn test_delete_with_confirmation_flow() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
let msg = client.send_message(ChatId::new(123), "To delete".to_string(), None, None).await.unwrap(); let msg = client
.send_message(ChatId::new(123), "To delete".to_string(), None, None)
.await
.unwrap();
// Шаг 1: Пользователь нажал 'd' -> показывается модалка (в App) // Шаг 1: Пользователь нажал 'd' -> показывается модалка (в App)
// В FakeTdClient просто проверяем что сообщение ещё есть // В FakeTdClient просто проверяем что сообщение ещё есть
@@ -120,7 +150,10 @@ async fn test_delete_with_confirmation_flow() {
assert_eq!(client.get_deleted_messages().len(), 0); assert_eq!(client.get_deleted_messages().len(), 0);
// Шаг 2: Пользователь подтвердил 'y' -> удаляем // Шаг 2: Пользователь подтвердил 'y' -> удаляем
client.delete_messages(ChatId::new(123), vec![msg.id()], false).await.unwrap(); client
.delete_messages(ChatId::new(123), vec![msg.id()], false)
.await
.unwrap();
// Проверяем что удалено // Проверяем что удалено
assert_eq!(client.get_messages(123).len(), 0); assert_eq!(client.get_messages(123).len(), 0);
@@ -132,7 +165,10 @@ async fn test_delete_with_confirmation_flow() {
async fn test_cancel_delete_keeps_message() { async fn test_cancel_delete_keeps_message() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
let msg = client.send_message(ChatId::new(123), "Keep me".to_string(), None, None).await.unwrap(); let msg = client
.send_message(ChatId::new(123), "Keep me".to_string(), None, None)
.await
.unwrap();
// Шаг 1: Пользователь нажал 'd' -> показалась модалка // Шаг 1: Пользователь нажал 'd' -> показалась модалка
assert_eq!(client.get_messages(123).len(), 1); assert_eq!(client.get_messages(123).len(), 1);

View File

@@ -3,8 +3,8 @@
mod helpers; mod helpers;
use helpers::test_data::{create_test_chat, TestChatBuilder}; use helpers::test_data::{create_test_chat, TestChatBuilder};
use tele_tui::types::{ChatId, MessageId};
use std::collections::HashMap; use std::collections::HashMap;
use tele_tui::types::{ChatId, MessageId};
/// Простая структура для хранения черновиков (как в реальном App) /// Простая структура для хранения черновиков (как в реальном App)
struct DraftManager { struct DraftManager {

View File

@@ -23,10 +23,7 @@ async fn test_user_journey_app_launch_to_chat_list() {
let chat2 = TestChatBuilder::new("Work Group", 102).build(); let chat2 = TestChatBuilder::new("Work Group", 102).build();
let chat3 = TestChatBuilder::new("Boss", 103).build(); let chat3 = TestChatBuilder::new("Boss", 103).build();
let client = client let client = client.with_chat(chat1).with_chat(chat2).with_chat(chat3);
.with_chat(chat1)
.with_chat(chat2)
.with_chat(chat3);
// 4. Симулируем загрузку чатов через load_chats // 4. Симулируем загрузку чатов через load_chats
let loaded_chats = client.load_chats(50).await.unwrap(); let loaded_chats = client.load_chats(50).await.unwrap();
@@ -58,9 +55,7 @@ async fn test_user_journey_open_chat_send_message() {
.outgoing() .outgoing()
.build(); .build();
let client = client let client = client.with_message(123, msg1).with_message(123, msg2);
.with_message(123, msg1)
.with_message(123, msg2);
// 3. Открываем чат // 3. Открываем чат
client.open_chat(ChatId::new(123)).await.unwrap(); client.open_chat(ChatId::new(123)).await.unwrap();
@@ -77,12 +72,10 @@ async fn test_user_journey_open_chat_send_message() {
assert_eq!(history[1].text(), "I'm good, thanks!"); assert_eq!(history[1].text(), "I'm good, thanks!");
// 7. Отправляем новое сообщение // 7. Отправляем новое сообщение
let _new_msg = client.send_message( let _new_msg = client
ChatId::new(123), .send_message(ChatId::new(123), "What's for dinner?".to_string(), None, None)
"What's for dinner?".to_string(), .await
None, .unwrap();
None
).await.unwrap();
// 8. Проверяем что сообщение отправлено // 8. Проверяем что сообщение отправлено
assert_eq!(client.get_sent_messages().len(), 1); assert_eq!(client.get_sent_messages().len(), 1);
@@ -153,34 +146,43 @@ async fn test_user_journey_multi_step_conversation() {
client.set_update_channel(tx); client.set_update_channel(tx);
// 4. Входящее сообщение от Alice // 4. Входящее сообщение от Alice
client.simulate_incoming_message(ChatId::new(789), "How's the project going?".to_string(), "Alice"); client.simulate_incoming_message(
ChatId::new(789),
"How's the project going?".to_string(),
"Alice",
);
// Проверяем update // Проверяем update
let update = rx.try_recv().ok(); let update = rx.try_recv().ok();
assert!(matches!(update, Some(TdUpdate::NewMessage { .. }))); assert!(matches!(update, Some(TdUpdate::NewMessage { .. })));
// 5. Отвечаем // 5. Отвечаем
client.send_message( client
ChatId::new(789), .send_message(
"Almost done! Just need to finish tests.".to_string(), ChatId::new(789),
None, "Almost done! Just need to finish tests.".to_string(),
None None,
).await.unwrap(); None,
)
.await
.unwrap();
// 6. Проверяем историю после первого обмена // 6. Проверяем историю после первого обмена
let history1 = client.get_chat_history(ChatId::new(789), 50).await.unwrap(); let history1 = client.get_chat_history(ChatId::new(789), 50).await.unwrap();
assert_eq!(history1.len(), 2); assert_eq!(history1.len(), 2);
// 7. Еще одно входящее сообщение // 7. Еще одно входящее сообщение
client.simulate_incoming_message(ChatId::new(789), "Great! Let me know if you need help.".to_string(), "Alice"); client.simulate_incoming_message(
ChatId::new(789),
"Great! Let me know if you need help.".to_string(),
"Alice",
);
// 8. Снова отвечаем // 8. Снова отвечаем
client.send_message( client
ChatId::new(789), .send_message(ChatId::new(789), "Will do, thanks!".to_string(), None, None)
"Will do, thanks!".to_string(), .await
None, .unwrap();
None
).await.unwrap();
// 9. Финальная проверка истории // 9. Финальная проверка истории
let final_history = client.get_chat_history(ChatId::new(789), 50).await.unwrap(); let final_history = client.get_chat_history(ChatId::new(789), 50).await.unwrap();
@@ -219,24 +221,20 @@ async fn test_user_journey_switch_chats() {
assert_eq!(client.get_current_chat_id(), Some(111)); assert_eq!(client.get_current_chat_id(), Some(111));
// 3. Отправляем сообщение в первом чате // 3. Отправляем сообщение в первом чате
client.send_message( client
ChatId::new(111), .send_message(ChatId::new(111), "Message in chat 1".to_string(), None, None)
"Message in chat 1".to_string(), .await
None, .unwrap();
None
).await.unwrap();
// 4. Переключаемся на второй чат // 4. Переключаемся на второй чат
client.open_chat(ChatId::new(222)).await.unwrap(); client.open_chat(ChatId::new(222)).await.unwrap();
assert_eq!(client.get_current_chat_id(), Some(222)); assert_eq!(client.get_current_chat_id(), Some(222));
// 5. Отправляем сообщение во втором чате // 5. Отправляем сообщение во втором чате
client.send_message( client
ChatId::new(222), .send_message(ChatId::new(222), "Message in chat 2".to_string(), None, None)
"Message in chat 2".to_string(), .await
None, .unwrap();
None
).await.unwrap();
// 6. Переключаемся на третий чат // 6. Переключаемся на третий чат
client.open_chat(ChatId::new(333)).await.unwrap(); client.open_chat(ChatId::new(333)).await.unwrap();
@@ -270,12 +268,10 @@ async fn test_user_journey_edit_during_conversation() {
client.open_chat(ChatId::new(555)).await.unwrap(); client.open_chat(ChatId::new(555)).await.unwrap();
// 2. Отправляем сообщение с опечаткой // 2. Отправляем сообщение с опечаткой
let msg = client.send_message( let msg = client
ChatId::new(555), .send_message(ChatId::new(555), "I'll be there at 5pm tomorow".to_string(), None, None)
"I'll be there at 5pm tomorow".to_string(), .await
None, .unwrap();
None
).await.unwrap();
// 3. Проверяем что сообщение отправлено // 3. Проверяем что сообщение отправлено
let history = client.get_chat_history(ChatId::new(555), 50).await.unwrap(); let history = client.get_chat_history(ChatId::new(555), 50).await.unwrap();
@@ -283,17 +279,19 @@ async fn test_user_journey_edit_during_conversation() {
assert_eq!(history[0].text(), "I'll be there at 5pm tomorow"); assert_eq!(history[0].text(), "I'll be there at 5pm tomorow");
// 4. Исправляем опечатку // 4. Исправляем опечатку
client.edit_message( client
ChatId::new(555), .edit_message(ChatId::new(555), msg.id(), "I'll be there at 5pm tomorrow".to_string())
msg.id(), .await
"I'll be there at 5pm tomorrow".to_string() .unwrap();
).await.unwrap();
// 5. Проверяем что сообщение отредактировано // 5. Проверяем что сообщение отредактировано
let edited_history = client.get_chat_history(ChatId::new(555), 50).await.unwrap(); let edited_history = client.get_chat_history(ChatId::new(555), 50).await.unwrap();
assert_eq!(edited_history.len(), 1); assert_eq!(edited_history.len(), 1);
assert_eq!(edited_history[0].text(), "I'll be there at 5pm tomorrow"); assert_eq!(edited_history[0].text(), "I'll be there at 5pm tomorrow");
assert!(edited_history[0].metadata.edit_date > 0, "Должна быть установлена дата редактирования"); assert!(
edited_history[0].metadata.edit_date > 0,
"Должна быть установлена дата редактирования"
);
// 6. Проверяем историю редактирований // 6. Проверяем историю редактирований
assert_eq!(client.get_edited_messages().len(), 1); assert_eq!(client.get_edited_messages().len(), 1);
@@ -315,7 +313,11 @@ async fn test_user_journey_reply_in_conversation() {
client.set_update_channel(tx); client.set_update_channel(tx);
// 3. Входящее сообщение с вопросом // 3. Входящее сообщение с вопросом
client.simulate_incoming_message(ChatId::new(666), "Can you send me the report?".to_string(), "Charlie"); client.simulate_incoming_message(
ChatId::new(666),
"Can you send me the report?".to_string(),
"Charlie",
);
let update = rx.try_recv().ok(); let update = rx.try_recv().ok();
assert!(matches!(update, Some(TdUpdate::NewMessage { .. }))); assert!(matches!(update, Some(TdUpdate::NewMessage { .. })));
@@ -324,12 +326,10 @@ async fn test_user_journey_reply_in_conversation() {
let question_msg_id = history[0].id(); let question_msg_id = history[0].id();
// 4. Отправляем другое сообщение (не связанное) // 4. Отправляем другое сообщение (не связанное)
client.send_message( client
ChatId::new(666), .send_message(ChatId::new(666), "Working on it now".to_string(), None, None)
"Working on it now".to_string(), .await
None, .unwrap();
None
).await.unwrap();
// 5. Отвечаем на конкретный вопрос (reply) // 5. Отвечаем на конкретный вопрос (reply)
let reply_info = Some(tele_tui::tdlib::ReplyInfo { let reply_info = Some(tele_tui::tdlib::ReplyInfo {
@@ -338,12 +338,15 @@ async fn test_user_journey_reply_in_conversation() {
text: "Can you send me the report?".to_string(), text: "Can you send me the report?".to_string(),
}); });
client.send_message( client
ChatId::new(666), .send_message(
"Sure, sending now!".to_string(), ChatId::new(666),
Some(question_msg_id), "Sure, sending now!".to_string(),
reply_info Some(question_msg_id),
).await.unwrap(); reply_info,
)
.await
.unwrap();
// 6. Проверяем что reply сохранён // 6. Проверяем что reply сохранён
let final_history = client.get_chat_history(ChatId::new(666), 50).await.unwrap(); let final_history = client.get_chat_history(ChatId::new(666), 50).await.unwrap();
@@ -376,12 +379,10 @@ async fn test_user_journey_network_state_changes() {
// 4. Открываем чат и отправляем сообщение // 4. Открываем чат и отправляем сообщение
client.open_chat(ChatId::new(888)).await.unwrap(); client.open_chat(ChatId::new(888)).await.unwrap();
client.send_message( client
ChatId::new(888), .send_message(ChatId::new(888), "Test message".to_string(), None, None)
"Test message".to_string(), .await
None, .unwrap();
None
).await.unwrap();
// Очищаем канал от update NewMessage // Очищаем канал от update NewMessage
let _ = rx.try_recv(); let _ = rx.try_recv();
@@ -391,8 +392,14 @@ async fn test_user_journey_network_state_changes() {
// Проверяем update // Проверяем update
let update = rx.try_recv().ok(); let update = rx.try_recv().ok();
assert!(matches!(update, Some(TdUpdate::ConnectionState { state: NetworkState::WaitingForNetwork })), assert!(
"Expected ConnectionState update, got: {:?}", update); matches!(
update,
Some(TdUpdate::ConnectionState { state: NetworkState::WaitingForNetwork })
),
"Expected ConnectionState update, got: {:?}",
update
);
// 6. Проверяем что состояние изменилось // 6. Проверяем что состояние изменилось
assert_eq!(client.get_network_state(), NetworkState::WaitingForNetwork); assert_eq!(client.get_network_state(), NetworkState::WaitingForNetwork);
@@ -405,12 +412,10 @@ async fn test_user_journey_network_state_changes() {
assert_eq!(client.get_network_state(), NetworkState::Ready); assert_eq!(client.get_network_state(), NetworkState::Ready);
// 8. Отправляем сообщение после восстановления // 8. Отправляем сообщение после восстановления
client.send_message( client
ChatId::new(888), .send_message(ChatId::new(888), "Connection restored!".to_string(), None, None)
"Connection restored!".to_string(), .await
None, .unwrap();
None
).await.unwrap();
// 9. Проверяем что оба сообщения в истории // 9. Проверяем что оба сообщения в истории
let history = client.get_chat_history(ChatId::new(888), 50).await.unwrap(); let history = client.get_chat_history(ChatId::new(888), 50).await.unwrap();

View File

@@ -12,10 +12,16 @@ async fn test_edit_message_changes_text() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
// Отправляем сообщение // Отправляем сообщение
let msg = client.send_message(ChatId::new(123), "Original text".to_string(), None, None).await.unwrap(); let msg = client
.send_message(ChatId::new(123), "Original text".to_string(), None, None)
.await
.unwrap();
// Редактируем сообщение // Редактируем сообщение
client.edit_message(ChatId::new(123), msg.id(), "Edited text".to_string()).await.unwrap(); client
.edit_message(ChatId::new(123), msg.id(), "Edited text".to_string())
.await
.unwrap();
// Проверяем что редактирование записалось // Проверяем что редактирование записалось
assert_eq!(client.get_edited_messages().len(), 1); assert_eq!(client.get_edited_messages().len(), 1);
@@ -34,7 +40,10 @@ async fn test_edit_message_sets_edit_date() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
// Отправляем сообщение // Отправляем сообщение
let msg = client.send_message(ChatId::new(123), "Original".to_string(), None, None).await.unwrap(); let msg = client
.send_message(ChatId::new(123), "Original".to_string(), None, None)
.await
.unwrap();
// Получаем дату до редактирования // Получаем дату до редактирования
let messages_before = client.get_messages(123); let messages_before = client.get_messages(123);
@@ -42,7 +51,10 @@ async fn test_edit_message_sets_edit_date() {
assert_eq!(messages_before[0].metadata.edit_date, 0); // Не редактировалось assert_eq!(messages_before[0].metadata.edit_date, 0); // Не редактировалось
// Редактируем сообщение // Редактируем сообщение
client.edit_message(ChatId::new(123), msg.id(), "Edited".to_string()).await.unwrap(); client
.edit_message(ChatId::new(123), msg.id(), "Edited".to_string())
.await
.unwrap();
// Проверяем что edit_date установлена // Проверяем что edit_date установлена
let messages_after = client.get_messages(123); let messages_after = client.get_messages(123);
@@ -78,16 +90,28 @@ async fn test_can_only_edit_own_messages() {
async fn test_multiple_edits_of_same_message() { async fn test_multiple_edits_of_same_message() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
let msg = client.send_message(ChatId::new(123), "Version 1".to_string(), None, None).await.unwrap(); let msg = client
.send_message(ChatId::new(123), "Version 1".to_string(), None, None)
.await
.unwrap();
// Первое редактирование // Первое редактирование
client.edit_message(ChatId::new(123), msg.id(), "Version 2".to_string()).await.unwrap(); client
.edit_message(ChatId::new(123), msg.id(), "Version 2".to_string())
.await
.unwrap();
// Второе редактирование // Второе редактирование
client.edit_message(ChatId::new(123), msg.id(), "Version 3".to_string()).await.unwrap(); client
.edit_message(ChatId::new(123), msg.id(), "Version 3".to_string())
.await
.unwrap();
// Третье редактирование // Третье редактирование
client.edit_message(ChatId::new(123), msg.id(), "Final version".to_string()).await.unwrap(); client
.edit_message(ChatId::new(123), msg.id(), "Final version".to_string())
.await
.unwrap();
// Проверяем что все 3 редактирования записаны // Проверяем что все 3 редактирования записаны
assert_eq!(client.get_edited_messages().len(), 3); assert_eq!(client.get_edited_messages().len(), 3);
@@ -107,7 +131,9 @@ async fn test_edit_nonexistent_message() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
// Пытаемся отредактировать несуществующее сообщение // Пытаемся отредактировать несуществующее сообщение
let result = client.edit_message(ChatId::new(123), MessageId::new(999), "New text".to_string()).await; let result = client
.edit_message(ChatId::new(123), MessageId::new(999), "New text".to_string())
.await;
// Должна вернуться ошибка // Должна вернуться ошибка
assert!(result.is_err()); assert!(result.is_err());
@@ -124,7 +150,10 @@ async fn test_edit_nonexistent_message() {
async fn test_edit_history_tracking() { async fn test_edit_history_tracking() {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
let msg = client.send_message(ChatId::new(123), "Original".to_string(), None, None).await.unwrap(); let msg = client
.send_message(ChatId::new(123), "Original".to_string(), None, None)
.await
.unwrap();
// Симулируем начало редактирования -> изменение -> отмена // Симулируем начало редактирования -> изменение -> отмена
// Отменять на уровне FakeTdClient нельзя, но можно проверить что original сохранён // Отменять на уровне FakeTdClient нельзя, но можно проверить что original сохранён
@@ -134,14 +163,20 @@ async fn test_edit_history_tracking() {
let original = messages_before[0].text().to_string(); let original = messages_before[0].text().to_string();
// Редактируем // Редактируем
client.edit_message(ChatId::new(123), msg.id(), "Edited".to_string()).await.unwrap(); client
.edit_message(ChatId::new(123), msg.id(), "Edited".to_string())
.await
.unwrap();
// Проверяем что изменилось // Проверяем что изменилось
let messages_edited = client.get_messages(123); let messages_edited = client.get_messages(123);
assert_eq!(messages_edited[0].text(), "Edited"); assert_eq!(messages_edited[0].text(), "Edited");
// Можем "отменить" редактирование вернув original // Можем "отменить" редактирование вернув original
client.edit_message(ChatId::new(123), msg.id(), original).await.unwrap(); client
.edit_message(ChatId::new(123), msg.id(), original)
.await
.unwrap();
// Проверяем что вернулось // Проверяем что вернулось
let messages_restored = client.get_messages(123); let messages_restored = client.get_messages(123);

View File

@@ -1,15 +1,16 @@
// Test App builder // Test App builder
use super::FakeTdClient;
use ratatui::widgets::ListState; use ratatui::widgets::ListState;
use std::collections::HashMap; use std::collections::HashMap;
use super::FakeTdClient; use tele_tui::app::{App, AppScreen, ChatState, InputMode};
use tele_tui::app::{App, AppScreen, ChatState};
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};
use tele_tui::types::{ChatId, MessageId}; use tele_tui::types::{ChatId, MessageId};
/// Builder для создания тестового App с FakeTdClient\n///\n/// Использует trait-based DI для подмены TdClient на FakeTdClient в тестах. /// Builder для создания тестового App с FakeTdClient\n///\n/// Использует trait-based DI для подмены TdClient на FakeTdClient в тестах.
#[allow(dead_code)]
pub struct TestAppBuilder { pub struct TestAppBuilder {
config: Config, config: Config,
screen: AppScreen, screen: AppScreen,
@@ -19,6 +20,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>,
@@ -33,6 +35,7 @@ impl Default for TestAppBuilder {
} }
} }
#[allow(dead_code)]
impl TestAppBuilder { impl TestAppBuilder {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
@@ -44,6 +47,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,
@@ -133,7 +137,8 @@ impl TestAppBuilder {
/// Подтверждение удаления /// Подтверждение удаления
pub fn delete_confirmation(mut self, message_id: i64) -> Self { pub fn delete_confirmation(mut self, message_id: i64) -> Self {
self.chat_state = Some(ChatState::DeleteConfirmation { message_id: MessageId::new(message_id) }); self.chat_state =
Some(ChatState::DeleteConfirmation { message_id: MessageId::new(message_id) });
self self
} }
@@ -171,11 +176,15 @@ 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 { message_id: MessageId::new(message_id) });
message_id: MessageId::new(message_id),
});
self self
} }
@@ -252,6 +261,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 +297,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() {

View File

@@ -2,25 +2,53 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use tele_tui::tdlib::{AuthState, ChatInfo, MessageInfo, NetworkState, ProfileInfo, ReplyInfo};
use tele_tui::tdlib::types::{FolderInfo, ReactionInfo}; use tele_tui::tdlib::types::{FolderInfo, ReactionInfo};
use tele_tui::tdlib::{AuthState, ChatInfo, MessageInfo, NetworkState, ProfileInfo, ReplyInfo};
use tele_tui::types::{ChatId, MessageId, UserId}; use tele_tui::types::{ChatId, MessageId, UserId};
use tokio::sync::mpsc; use tokio::sync::mpsc;
/// Update события от TDLib (упрощённая версия) /// Update события от TDLib (упрощённая версия)
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
#[allow(dead_code)]
pub enum TdUpdate { pub enum TdUpdate {
NewMessage { chat_id: ChatId, message: MessageInfo }, NewMessage {
MessageContent { chat_id: ChatId, message_id: MessageId, new_text: String }, chat_id: ChatId,
DeleteMessages { chat_id: ChatId, message_ids: Vec<MessageId> }, message: MessageInfo,
ChatAction { chat_id: ChatId, user_id: UserId, action: String }, },
MessageInteractionInfo { chat_id: ChatId, message_id: MessageId, reactions: Vec<ReactionInfo> }, MessageContent {
ConnectionState { state: NetworkState }, chat_id: ChatId,
ChatReadOutbox { chat_id: ChatId, last_read_outbox_message_id: MessageId }, message_id: MessageId,
ChatDraftMessage { chat_id: ChatId, draft_text: Option<String> }, new_text: String,
},
DeleteMessages {
chat_id: ChatId,
message_ids: Vec<MessageId>,
},
ChatAction {
chat_id: ChatId,
user_id: UserId,
action: String,
},
MessageInteractionInfo {
chat_id: ChatId,
message_id: MessageId,
reactions: Vec<ReactionInfo>,
},
ConnectionState {
state: NetworkState,
},
ChatReadOutbox {
chat_id: ChatId,
last_read_outbox_message_id: MessageId,
},
ChatDraftMessage {
chat_id: ChatId,
draft_text: Option<String>,
},
} }
/// Упрощённый mock TDLib клиента для тестов /// Упрощённый mock TDLib клиента для тестов
#[allow(dead_code)]
pub struct FakeTdClient { pub struct FakeTdClient {
// Данные // Данные
pub chats: Arc<Mutex<Vec<ChatInfo>>>, pub chats: Arc<Mutex<Vec<ChatInfo>>>,
@@ -45,18 +73,22 @@ pub struct FakeTdClient {
pub forwarded_messages: Arc<Mutex<Vec<ForwardedMessages>>>, pub forwarded_messages: Arc<Mutex<Vec<ForwardedMessages>>>,
pub searched_queries: Arc<Mutex<Vec<SearchQuery>>>, pub searched_queries: Arc<Mutex<Vec<SearchQuery>>>,
pub viewed_messages: Arc<Mutex<Vec<(i64, Vec<i64>)>>>, // (chat_id, message_ids) pub viewed_messages: Arc<Mutex<Vec<(i64, Vec<i64>)>>>, // (chat_id, message_ids)
pub chat_actions: Arc<Mutex<Vec<(i64, String)>>>, // (chat_id, action) pub chat_actions: Arc<Mutex<Vec<(i64, String)>>>, // (chat_id, action)
pub pending_view_messages: Arc<Mutex<Vec<(ChatId, Vec<MessageId>)>>>, // Очередь для отметки как прочитанные pub pending_view_messages: Arc<Mutex<Vec<(ChatId, Vec<MessageId>)>>>, // Очередь для отметки как прочитанные
// 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>>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct SentMessage { pub struct SentMessage {
pub chat_id: i64, pub chat_id: i64,
pub text: String, pub text: String,
@@ -65,6 +97,7 @@ pub struct SentMessage {
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct EditedMessage { pub struct EditedMessage {
pub chat_id: i64, pub chat_id: i64,
pub message_id: MessageId, pub message_id: MessageId,
@@ -72,6 +105,7 @@ pub struct EditedMessage {
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct DeletedMessages { pub struct DeletedMessages {
pub chat_id: i64, pub chat_id: i64,
pub message_ids: Vec<MessageId>, pub message_ids: Vec<MessageId>,
@@ -79,6 +113,7 @@ pub struct DeletedMessages {
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct ForwardedMessages { pub struct ForwardedMessages {
pub from_chat_id: i64, pub from_chat_id: i64,
pub to_chat_id: i64, pub to_chat_id: i64,
@@ -86,6 +121,7 @@ pub struct ForwardedMessages {
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct SearchQuery { pub struct SearchQuery {
pub chat_id: i64, pub chat_id: i64,
pub query: String, pub query: String,
@@ -121,6 +157,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),
@@ -128,6 +165,7 @@ impl Clone for FakeTdClient {
} }
} }
#[allow(dead_code)]
impl FakeTdClient { impl FakeTdClient {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
@@ -138,8 +176,14 @@ impl FakeTdClient {
profiles: Arc::new(Mutex::new(HashMap::new())), profiles: Arc::new(Mutex::new(HashMap::new())),
drafts: Arc::new(Mutex::new(HashMap::new())), drafts: Arc::new(Mutex::new(HashMap::new())),
available_reactions: Arc::new(Mutex::new(vec![ available_reactions: Arc::new(Mutex::new(vec![
"👍".to_string(), "❤️".to_string(), "😂".to_string(), "😮".to_string(), "👍".to_string(),
"😢".to_string(), "🙏".to_string(), "👏".to_string(), "🔥".to_string(), "❤️".to_string(),
"😂".to_string(),
"😮".to_string(),
"😢".to_string(),
"🙏".to_string(),
"👏".to_string(),
"🔥".to_string(),
])), ])),
network_state: Arc::new(Mutex::new(NetworkState::Ready)), network_state: Arc::new(Mutex::new(NetworkState::Ready)),
typing_chat_id: Arc::new(Mutex::new(None)), typing_chat_id: Arc::new(Mutex::new(None)),
@@ -154,6 +198,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)),
@@ -200,16 +245,16 @@ impl FakeTdClient {
/// Добавить несколько сообщений в чат /// Добавить несколько сообщений в чат
pub fn with_messages(self, chat_id: i64, messages: Vec<MessageInfo>) -> Self { pub fn with_messages(self, chat_id: i64, messages: Vec<MessageInfo>) -> Self {
self.messages self.messages.lock().unwrap().insert(chat_id, messages);
.lock()
.unwrap()
.insert(chat_id, messages);
self self
} }
/// Добавить папку /// Добавить папку
pub fn with_folder(self, id: i32, name: &str) -> Self { pub fn with_folder(self, id: i32, name: &str) -> Self {
self.folders.lock().unwrap().push(FolderInfo { id, name: name.to_string() }); self.folders
.lock()
.unwrap()
.push(FolderInfo { id, name: name.to_string() });
self self
} }
@@ -237,6 +282,15 @@ 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;
@@ -255,7 +309,14 @@ impl FakeTdClient {
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
} }
let chats = self.chats.lock().unwrap().iter().take(limit).cloned().collect(); let chats = self
.chats
.lock()
.unwrap()
.iter()
.take(limit)
.cloned()
.collect();
Ok(chats) Ok(chats)
} }
@@ -270,7 +331,11 @@ impl FakeTdClient {
} }
/// Получить историю чата /// Получить историю чата
pub async fn get_chat_history(&self, chat_id: ChatId, limit: i32) -> Result<Vec<MessageInfo>, String> { pub async fn get_chat_history(
&self,
chat_id: ChatId,
limit: i32,
) -> Result<Vec<MessageInfo>, String> {
if self.should_fail() { if self.should_fail() {
return Err("Failed to load history".to_string()); return Err("Failed to load history".to_string());
} }
@@ -279,7 +344,8 @@ impl FakeTdClient {
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
} }
let messages = self.messages let messages = self
.messages
.lock() .lock()
.unwrap() .unwrap()
.get(&chat_id.as_i64()) .get(&chat_id.as_i64())
@@ -290,7 +356,11 @@ impl FakeTdClient {
} }
/// Загрузить старые сообщения /// Загрузить старые сообщения
pub async fn load_older_messages(&self, chat_id: ChatId, from_message_id: MessageId) -> Result<Vec<MessageInfo>, String> { pub async fn load_older_messages(
&self,
chat_id: ChatId,
from_message_id: MessageId,
) -> Result<Vec<MessageInfo>, String> {
if self.should_fail() { if self.should_fail() {
return Err("Failed to load older messages".to_string()); return Err("Failed to load older messages".to_string());
} }
@@ -335,7 +405,7 @@ impl FakeTdClient {
let message = MessageInfo::new( let message = MessageInfo::new(
message_id, message_id,
"You".to_string(), "You".to_string(),
true, // is_outgoing true, // is_outgoing
text.clone(), text.clone(),
vec![], // entities vec![], // entities
chrono::Utc::now().timestamp() as i32, chrono::Utc::now().timestamp() as i32,
@@ -345,7 +415,7 @@ impl FakeTdClient {
true, // can_be_deleted_only_for_self true, // can_be_deleted_only_for_self
true, // can_be_deleted_for_all_users true, // can_be_deleted_for_all_users
reply_info, reply_info,
None, // forward_from None, // forward_from
vec![], // reactions vec![], // reactions
); );
@@ -358,10 +428,7 @@ impl FakeTdClient {
.push(message.clone()); .push(message.clone());
// Отправляем Update::NewMessage // Отправляем Update::NewMessage
self.send_update(TdUpdate::NewMessage { self.send_update(TdUpdate::NewMessage { chat_id, message: message.clone() });
chat_id,
message: message.clone(),
});
Ok(message) Ok(message)
} }
@@ -398,11 +465,7 @@ impl FakeTdClient {
drop(messages); // Освобождаем lock перед отправкой update drop(messages); // Освобождаем lock перед отправкой update
// Отправляем Update // Отправляем Update
self.send_update(TdUpdate::MessageContent { self.send_update(TdUpdate::MessageContent { chat_id, message_id, new_text });
chat_id,
message_id,
new_text,
});
return Ok(updated); return Ok(updated);
} }
@@ -440,10 +503,7 @@ impl FakeTdClient {
drop(messages); drop(messages);
// Отправляем Update // Отправляем Update
self.send_update(TdUpdate::DeleteMessages { self.send_update(TdUpdate::DeleteMessages { chat_id, message_ids });
chat_id,
message_ids,
});
Ok(()) Ok(())
} }
@@ -463,17 +523,24 @@ impl FakeTdClient {
tokio::time::sleep(tokio::time::Duration::from_millis(150)).await; tokio::time::sleep(tokio::time::Duration::from_millis(150)).await;
} }
self.forwarded_messages.lock().unwrap().push(ForwardedMessages { self.forwarded_messages
from_chat_id: from_chat_id.as_i64(), .lock()
to_chat_id: to_chat_id.as_i64(), .unwrap()
message_ids, .push(ForwardedMessages {
}); from_chat_id: from_chat_id.as_i64(),
to_chat_id: to_chat_id.as_i64(),
message_ids,
});
Ok(()) Ok(())
} }
/// Поиск сообщений в чате /// Поиск сообщений в чате
pub async fn search_messages(&self, chat_id: ChatId, query: &str) -> Result<Vec<MessageInfo>, String> { pub async fn search_messages(
&self,
chat_id: ChatId,
query: &str,
) -> Result<Vec<MessageInfo>, String> {
if self.should_fail() { if self.should_fail() {
return Err("Failed to search messages".to_string()); return Err("Failed to search messages".to_string());
} }
@@ -503,7 +570,10 @@ impl FakeTdClient {
if text.is_empty() { if text.is_empty() {
self.drafts.lock().unwrap().remove(&chat_id.as_i64()); self.drafts.lock().unwrap().remove(&chat_id.as_i64());
} else { } else {
self.drafts.lock().unwrap().insert(chat_id.as_i64(), text.clone()); self.drafts
.lock()
.unwrap()
.insert(chat_id.as_i64(), text.clone());
} }
self.send_update(TdUpdate::ChatDraftMessage { self.send_update(TdUpdate::ChatDraftMessage {
@@ -516,7 +586,10 @@ impl FakeTdClient {
/// Отправить действие в чате (typing, etc.) /// Отправить действие в чате (typing, etc.)
pub async fn send_chat_action(&self, chat_id: ChatId, action: String) { pub async fn send_chat_action(&self, chat_id: ChatId, action: String) {
self.chat_actions.lock().unwrap().push((chat_id.as_i64(), action.clone())); self.chat_actions
.lock()
.unwrap()
.push((chat_id.as_i64(), action.clone()));
if action == "Typing" { if action == "Typing" {
*self.typing_chat_id.lock().unwrap() = Some(chat_id.as_i64()); *self.typing_chat_id.lock().unwrap() = Some(chat_id.as_i64());
@@ -556,7 +629,10 @@ impl FakeTdClient {
let reactions = &mut msg.interactions.reactions; let reactions = &mut msg.interactions.reactions;
// Toggle logic // Toggle logic
if let Some(pos) = reactions.iter().position(|r| r.emoji == emoji && r.is_chosen) { if let Some(pos) = reactions
.iter()
.position(|r| r.emoji == emoji && r.is_chosen)
{
// Удаляем свою реакцию // Удаляем свою реакцию
reactions.remove(pos); reactions.remove(pos);
} else if let Some(reaction) = reactions.iter_mut().find(|r| r.emoji == emoji) { } else if let Some(reaction) = reactions.iter_mut().find(|r| r.emoji == emoji) {
@@ -587,6 +663,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() {
@@ -678,11 +768,7 @@ impl FakeTdClient {
/// Симулировать typing от собеседника /// Симулировать typing от собеседника
pub fn simulate_typing(&self, chat_id: ChatId, user_id: UserId) { pub fn simulate_typing(&self, chat_id: ChatId, user_id: UserId) {
self.send_update(TdUpdate::ChatAction { self.send_update(TdUpdate::ChatAction { chat_id, user_id, action: "Typing".to_string() });
chat_id,
user_id,
action: "Typing".to_string(),
});
} }
/// Симулировать изменение состояния сети /// Симулировать изменение состояния сети
@@ -811,7 +897,9 @@ mod tests {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
let chat_id = ChatId::new(123); let chat_id = ChatId::new(123);
let result = client.send_message(chat_id, "Hello".to_string(), None, None).await; let result = client
.send_message(chat_id, "Hello".to_string(), None, None)
.await;
assert!(result.is_ok()); assert!(result.is_ok());
let sent = client.get_sent_messages(); let sent = client.get_sent_messages();
@@ -825,10 +913,15 @@ mod tests {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
let chat_id = ChatId::new(123); let chat_id = ChatId::new(123);
let msg = client.send_message(chat_id, "Hello".to_string(), None, None).await.unwrap(); let msg = client
.send_message(chat_id, "Hello".to_string(), None, None)
.await
.unwrap();
let msg_id = msg.id(); let msg_id = msg.id();
let _ = client.edit_message(chat_id, msg_id, "Hello World".to_string()).await; let _ = client
.edit_message(chat_id, msg_id, "Hello World".to_string())
.await;
let edited = client.get_edited_messages(); let edited = client.get_edited_messages();
assert_eq!(edited.len(), 1); assert_eq!(edited.len(), 1);
@@ -841,7 +934,10 @@ mod tests {
let client = FakeTdClient::new(); let client = FakeTdClient::new();
let chat_id = ChatId::new(123); let chat_id = ChatId::new(123);
let msg = client.send_message(chat_id, "Hello".to_string(), None, None).await.unwrap(); let msg = client
.send_message(chat_id, "Hello".to_string(), None, None)
.await
.unwrap();
let msg_id = msg.id(); let msg_id = msg.id();
let _ = client.delete_messages(chat_id, vec![msg_id], false).await; let _ = client.delete_messages(chat_id, vec![msg_id], false).await;
@@ -857,7 +953,9 @@ mod tests {
let chat_id = ChatId::new(123); let chat_id = ChatId::new(123);
// Отправляем сообщение // Отправляем сообщение
let _ = client.send_message(chat_id, "Test".to_string(), None, None).await; let _ = client
.send_message(chat_id, "Test".to_string(), None, None)
.await;
// Проверяем что получили Update // Проверяем что получили Update
if let Some(update) = rx.recv().await { if let Some(update) = rx.recv().await {
@@ -899,11 +997,15 @@ mod tests {
client.fail_next(); client.fail_next();
// Следующая операция должна упасть // Следующая операция должна упасть
let result = client.send_message(chat_id, "Test".to_string(), None, None).await; let result = client
.send_message(chat_id, "Test".to_string(), None, None)
.await;
assert!(result.is_err()); assert!(result.is_err());
// Но следующая должна пройти // Но следующая должна пройти
let result2 = client.send_message(chat_id, "Test2".to_string(), None, None).await; let result2 = client
.send_message(chat_id, "Test2".to_string(), None, None)
.await;
assert!(result2.is_ok()); assert!(result2.is_ok());
} }
} }

View File

@@ -2,9 +2,13 @@
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::TdClientTrait; use tele_tui::tdlib::TdClientTrait;
use tele_tui::tdlib::{
AuthState, ChatInfo, FolderInfo, MessageInfo, ProfileInfo, ReplyInfo, UserCache,
UserOnlineStatus,
};
use tele_tui::types::{ChatId, MessageId, UserId}; use tele_tui::types::{ChatId, MessageId, UserId};
#[async_trait] #[async_trait]
@@ -54,11 +58,19 @@ impl TdClientTrait for FakeTdClient {
} }
// ============ Message methods ============ // ============ Message methods ============
async fn get_chat_history(&mut self, chat_id: ChatId, limit: i32) -> Result<Vec<MessageInfo>, String> { async fn get_chat_history(
&mut self,
chat_id: ChatId,
limit: i32,
) -> Result<Vec<MessageInfo>, String> {
FakeTdClient::get_chat_history(self, chat_id, limit).await FakeTdClient::get_chat_history(self, chat_id, limit).await
} }
async fn load_older_messages(&mut self, chat_id: ChatId, from_message_id: MessageId) -> Result<Vec<MessageInfo>, String> { async fn load_older_messages(
&mut self,
chat_id: ChatId,
from_message_id: MessageId,
) -> Result<Vec<MessageInfo>, String> {
FakeTdClient::load_older_messages(self, chat_id, from_message_id).await FakeTdClient::load_older_messages(self, chat_id, from_message_id).await
} }
@@ -71,7 +83,11 @@ impl TdClientTrait for FakeTdClient {
// Not implemented for fake // Not implemented for fake
} }
async fn search_messages(&self, chat_id: ChatId, query: &str) -> Result<Vec<MessageInfo>, String> { async fn search_messages(
&self,
chat_id: ChatId,
query: &str,
) -> Result<Vec<MessageInfo>, String> {
FakeTdClient::search_messages(self, chat_id, query).await FakeTdClient::search_messages(self, chat_id, query).await
} }
@@ -129,7 +145,10 @@ impl TdClientTrait for FakeTdClient {
let mut pending = self.pending_view_messages.lock().unwrap(); let mut pending = self.pending_view_messages.lock().unwrap();
for (chat_id, message_ids) in pending.drain(..) { for (chat_id, message_ids) in pending.drain(..) {
let ids: Vec<i64> = message_ids.iter().map(|id| id.as_i64()).collect(); let ids: Vec<i64> = message_ids.iter().map(|id| id.as_i64()).collect();
self.viewed_messages.lock().unwrap().push((chat_id.as_i64(), ids)); self.viewed_messages
.lock()
.unwrap()
.push((chat_id.as_i64(), ids));
} }
} }
@@ -161,6 +180,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
@@ -182,9 +211,13 @@ impl TdClientTrait for FakeTdClient {
let current = self.auth_state.lock().unwrap(); let current = self.auth_state.lock().unwrap();
match *current { match *current {
AuthState::Ready => &AUTH_STATE_READY, AuthState::Ready => &AUTH_STATE_READY,
AuthState::WaitPhoneNumber => AUTH_STATE_WAIT_PHONE.get_or_init(|| AuthState::WaitPhoneNumber), AuthState::WaitPhoneNumber => {
AUTH_STATE_WAIT_PHONE.get_or_init(|| AuthState::WaitPhoneNumber)
}
AuthState::WaitCode => AUTH_STATE_WAIT_CODE.get_or_init(|| AuthState::WaitCode), AuthState::WaitCode => AUTH_STATE_WAIT_CODE.get_or_init(|| AuthState::WaitCode),
AuthState::WaitPassword => AUTH_STATE_WAIT_PASSWORD.get_or_init(|| AuthState::WaitPassword), AuthState::WaitPassword => {
AUTH_STATE_WAIT_PASSWORD.get_or_init(|| AuthState::WaitPassword)
}
_ => &AUTH_STATE_READY, _ => &AUTH_STATE_READY,
} }
} }
@@ -304,6 +337,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

View File

@@ -1,10 +1,11 @@
// Test data builders and fixtures // Test data builders and fixtures
use tele_tui::tdlib::{ChatInfo, MessageInfo, ProfileInfo, ReplyInfo};
use tele_tui::tdlib::types::{ForwardInfo, ReactionInfo}; use tele_tui::tdlib::types::{ForwardInfo, ReactionInfo};
use tele_tui::tdlib::{ChatInfo, MessageInfo, ProfileInfo, ReplyInfo};
use tele_tui::types::{ChatId, MessageId}; use tele_tui::types::{ChatId, MessageId};
/// Builder для создания тестового чата /// Builder для создания тестового чата
#[allow(dead_code)]
pub struct TestChatBuilder { pub struct TestChatBuilder {
id: i64, id: i64,
title: String, title: String,
@@ -21,6 +22,7 @@ pub struct TestChatBuilder {
draft_text: Option<String>, draft_text: Option<String>,
} }
#[allow(dead_code)]
impl TestChatBuilder { impl TestChatBuilder {
pub fn new(title: &str, id: i64) -> Self { pub fn new(title: &str, id: i64) -> Self {
Self { Self {
@@ -100,6 +102,7 @@ impl TestChatBuilder {
} }
/// Builder для создания тестового сообщения /// Builder для создания тестового сообщения
#[allow(dead_code)]
pub struct TestMessageBuilder { pub struct TestMessageBuilder {
id: i64, id: i64,
sender_name: String, sender_name: String,
@@ -115,8 +118,10 @@ 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,
} }
#[allow(dead_code)]
impl TestMessageBuilder { impl TestMessageBuilder {
pub fn new(content: &str, id: i64) -> Self { pub fn new(content: &str, id: i64) -> Self {
Self { Self {
@@ -134,6 +139,7 @@ impl TestMessageBuilder {
reply_to: None, reply_to: None,
forward_from: None, forward_from: None,
reactions: vec![], reactions: vec![],
media_album_id: 0,
} }
} }
@@ -175,9 +181,7 @@ impl TestMessageBuilder {
} }
pub fn forwarded_from(mut self, sender: &str) -> Self { pub fn forwarded_from(mut self, sender: &str) -> Self {
self.forward_from = Some(ForwardInfo { self.forward_from = Some(ForwardInfo { sender_name: sender.to_string() });
sender_name: sender.to_string(),
});
self self
} }
@@ -187,8 +191,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 +212,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
} }
} }

Some files were not shown because too many files have changed in this diff Show More