diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6ce9517..751e351 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,6 +102,8 @@ jobs: - name: Check installer scripts shell: bash run: bash -n scripts/install.sh + - name: Validate bilingual Wiki sources + run: python3 scripts/export-wiki.py --check - name: Parse PowerShell installer shell: pwsh run: | @@ -119,6 +121,15 @@ jobs: - run: cargo fmt --all -- --check - run: cargo clippy --locked --all-targets --all-features -- -D warnings - run: cargo test --locked --all-targets --all-features + - name: Verify own-deletion update payloads + run: >- + cargo test --locked --manifest-path vendor/grammers-mtsender/Cargo.toml + --lib affected_updates_keep_payloads --target-dir target/mtsender-regression + - name: Verify upstream SQLite close-ownership regression + run: >- + cargo test --locked --manifest-path vendor/libsql/Cargo.toml + --no-default-features --features core --lib + drop_closes_the_handle_exactly_once --target-dir target/libsql-regression build: name: Build (${{ matrix.platform }}) @@ -201,7 +212,7 @@ jobs: set -euo pipefail [[ "$TELEGRAM_API_ID" =~ ^[0-9]+$ ]] [[ "$TELEGRAM_API_HASH" =~ ^[0-9a-fA-F]{32}$ ]] - - name: Build and package every pushed commit + - name: Install from Git and package every pushed commit shell: bash env: VERGEN_GIT_BRANCH: ${{ github.head_ref || github.ref_name }} @@ -210,6 +221,8 @@ jobs: run: | set -euo pipefail mkdir -p dist + source_url="$(python3 -c 'from pathlib import Path; print(Path.cwd().as_uri())')" + install_root="$(pwd)/dist/cargo-install" while IFS=$'\t' read -r sha version; do sha="${sha%$'\r'}" version="${version%$'\r'}" @@ -218,8 +231,13 @@ jobs: rustup target add "${{ matrix.target }}" export TERMGRAM_BUILD_VERSION="$version" cargo test --locked --all-targets --all-features - cargo build --locked --release --target "${{ matrix.target }}" - binary="target/${{ matrix.target }}/release/tg" + # Exercise Cargo's Git checkout, path patches, install tracking and + # release profile. Keep immutable Git-source artifacts separate from + # workspace builds, including build-script executables and metadata. + cargo install --locked --git "$source_url" --rev "$sha" \ + --bin tg --target "${{ matrix.target }}" --target-dir target/cargo-install \ + --root "$install_root" --force termgram + binary="$install_root/bin/tg" if [[ "$RUNNER_OS" == Windows ]]; then binary+=".exe" fi @@ -240,12 +258,12 @@ jobs: if [[ "$RUNNER_OS" == Windows ]]; then ( - cd "target/${{ matrix.target }}/release" + cd "$install_root/bin" 7z a -tzip \ - "../../../dist/termgram-$version-${{ matrix.platform }}.zip" tg.exe + "../../termgram-$version-${{ matrix.platform }}.zip" tg.exe ) else - tar -C "target/${{ matrix.target }}/release" -czf \ + tar -C "$install_root/bin" -czf \ "dist/termgram-$version-${{ matrix.platform }}.tar.gz" tg fi done < <(jq -r '.[] | [.sha, .version] | @tsv' <<<"$RELEASE_PLAN") diff --git a/.gitignore b/.gitignore index 7c2498e..16c5895 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,13 @@ /target/ +/.cargo-ok /dist/ .env *.session *.session-* *.log .DS_Store +/dev-notes/ +*.cache.sqlite3 +*.cache.sqlite3-* + +*.media/ diff --git a/Cargo.lock b/Cargo.lock index 557e7d1..7e5d834 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -133,6 +133,27 @@ dependencies = [ "num-traits", ] +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.59.0", + "wl-clipboard-rs", + "x11rb", +] + [[package]] name = "arc-swap" version = "1.9.2" @@ -149,6 +170,126 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d62b7694a562cdf5a74227903507c56ab2cc8bdd1f781ed5cb4cf9c9f810bfc" +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.4", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.4", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.92" @@ -292,6 +433,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "bon" version = "3.9.3" @@ -451,7 +605,7 @@ dependencies = [ "num-traits", "serde", "wasm-bindgen", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -591,6 +745,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "const-oid" version = "0.10.2" @@ -652,6 +815,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + [[package]] name = "crossterm" version = "0.29.0" @@ -926,6 +1095,12 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "ds_parser" version = "0.4.0" @@ -952,6 +1127,33 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -985,6 +1187,26 @@ version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "exr" version = "1.74.2" @@ -1038,6 +1260,12 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.9" @@ -1048,6 +1276,12 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -1117,6 +1351,19 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.34" @@ -1157,6 +1404,16 @@ dependencies = [ "slab", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link 0.2.1", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1226,8 +1483,6 @@ dependencies = [ [[package]] name = "grammers-client" version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f330139772e71b5e104f5a7bbf43bbda92fd8a734b4cf9c57839e04e949cf9b" dependencies = [ "chrono", "futures-util", @@ -1280,8 +1535,6 @@ dependencies = [ [[package]] name = "grammers-mtsender" version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f39b8556ddc94f7ec935135dcfbf84ca8628538e103779cdc701423b759b078e" dependencies = [ "bytes", "grammers-crypto", @@ -1349,6 +1602,15 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -1357,7 +1619,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -1368,7 +1630,7 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", "serde", "serde_core", ] @@ -1379,6 +1641,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + [[package]] name = "hex" version = "0.4.3" @@ -1788,7 +2056,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde_core", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -1842,7 +2110,7 @@ dependencies = [ "simd_cesu8", "thiserror 2.0.20", "walkdir", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -1930,7 +2198,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ "cfg-if", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -1951,8 +2219,6 @@ dependencies = [ [[package]] name = "libsql" version = "0.9.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30fe980ac5693ed1f3db490559fb578885e913a018df64af8a1a46e1959a78df" dependencies = [ "async-trait", "bitflags 2.13.2", @@ -2074,6 +2340,20 @@ dependencies = [ "which 8.0.6", ] +[[package]] +name = "mac-notification-sys" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca" +dependencies = [ + "cc", + "log", + "objc2", + "objc2-foundation", + "time", + "uuid", +] + [[package]] name = "md5" version = "0.8.1" @@ -2237,6 +2517,20 @@ dependencies = [ "memchr", ] +[[package]] +name = "notify-rust" +version = "4.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5b4c1b4f2aa9f25f63a7a49d3dd0ed567b3670da15330a66b29434be899b891" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + [[package]] name = "num-bigint" version = "0.4.8" @@ -2300,6 +2594,18 @@ dependencies = [ "objc2-encode", ] +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-core-graphics", + "objc2-foundation", +] + [[package]] name = "objc2-cloud-kit" version = "0.3.2" @@ -2494,6 +2800,16 @@ dependencies = [ "serde", ] +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "os_info" version = "3.15.0" @@ -2509,6 +2825,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "outref" version = "0.5.2" @@ -2555,6 +2881,12 @@ dependencies = [ "libm", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2575,7 +2907,7 @@ dependencies = [ "libc", "redox_syscall", "smallvec", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -2606,12 +2938,34 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap 2.14.2", +] + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.34" @@ -2626,7 +2980,7 @@ checksum = "2896bade328c13f7042a297ea5ac5b0951f6cf989dea5f32c2fd98da398195cb" dependencies = [ "base64 0.23.1", "indexmap 2.14.2", - "quick-xml", + "quick-xml 0.42.0", "serde", "time", ] @@ -2644,6 +2998,20 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + [[package]] name = "portable-atomic" version = "1.15.0" @@ -2693,6 +3061,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -2770,6 +3147,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + [[package]] name = "quick-xml" version = "0.42.0" @@ -3355,6 +3741,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -3630,6 +4027,17 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "tauri-winrt-notification" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade" +dependencies = [ + "thiserror 2.0.20", + "windows 0.61.3", + "windows-version", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -3650,25 +4058,40 @@ dependencies = [ "anstream", "anstyle", "anyhow", + "arboard", + "base64 0.22.1", "chrono", + "clap", "directories", "dotenvy", "futures-util", + "getrandom 0.4.3", "grammers-client", "grammers-session", "image", + "libsql", + "mlua", "moxcms 0.9.1", + "notify-rust", "qrcode", + "quick-xml 0.42.0", "ratatui", "ratatui-image", + "regex", + "serde", + "serde_json", "sha2", + "tempfile", "tokio", "toml", "unicode-segmentation", "unicode-width", + "url", "vergen-gitcl", "yazi-adapter", + "yazi-config", "yazi-emulator", + "yazi-shared", "yazi-term", "yazi-tty", ] @@ -3861,6 +4284,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.25.15+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +dependencies = [ + "indexmap 2.14.2", + "toml_datetime", + "toml_parser", + "winnow", +] + [[package]] name = "toml_parser" version = "1.1.3+spec-1.1.0" @@ -3971,6 +4406,17 @@ dependencies = [ "windows 0.62.2", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom 8.0.0", + "petgraph", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -4115,6 +4561,18 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + [[package]] name = "uzers" version = "0.12.2" @@ -4273,6 +4731,76 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.4", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.2", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml 0.41.0", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "pkg-config", +] + [[package]] name = "web-sys" version = "0.3.104" @@ -4370,16 +4898,38 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections 0.2.0", + "windows-core 0.61.2", + "windows-future 0.2.1", + "windows-link 0.1.3", + "windows-numerics 0.2.0", +] + [[package]] name = "windows" version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" dependencies = [ - "windows-collections", + "windows-collections 0.3.2", "windows-core 0.62.2", - "windows-future", - "windows-numerics", + "windows-future 0.3.2", + "windows-numerics 0.3.1", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", ] [[package]] @@ -4404,6 +4954,19 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -4412,11 +4975,22 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement 0.60.2", "windows-interface 0.59.3", - "windows-link", + "windows-link 0.2.1", "windows-result 0.4.1", "windows-strings 0.5.1", ] +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading 0.1.0", +] + [[package]] name = "windows-future" version = "0.3.2" @@ -4424,8 +4998,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" dependencies = [ "windows-core 0.62.2", - "windows-link", - "windows-threading", + "windows-link 0.2.1", + "windows-threading 0.2.1", ] [[package]] @@ -4472,12 +5046,28 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + [[package]] name = "windows-numerics" version = "0.3.1" @@ -4485,7 +5075,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" dependencies = [ "windows-core 0.62.2", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -4497,13 +5087,22 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + [[package]] name = "windows-result" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -4516,13 +5115,22 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + [[package]] name = "windows-strings" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -4549,7 +5157,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -4568,13 +5176,31 @@ dependencies = [ "windows_x86_64_msvc", ] +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + [[package]] name = "windows-threading" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" dependencies = [ - "windows-link", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", ] [[package]] @@ -4630,6 +5256,27 @@ name = "winnow" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wl-clipboard-rs" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 1.1.4", + "thiserror 2.0.20", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] [[package]] name = "writeable" @@ -4646,6 +5293,23 @@ dependencies = [ "tap", ] +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + [[package]] name = "yazi-adapter" version = "26.9.1" @@ -4792,7 +5456,7 @@ dependencies = [ "dirs", "ds_parser", "either", - "foldhash", + "foldhash 0.2.0", "hashbrown 0.17.1", "inventory", "libc", @@ -4838,7 +5502,7 @@ dependencies = [ "clap", "compact_str 0.10.0", "dyn-clone", - "foldhash", + "foldhash 0.2.0", "futures", "hashbrown 0.17.1", "indexmap 2.14.2", @@ -4918,7 +5582,6 @@ dependencies = [ [[package]] name = "yazi-tty" version = "26.9.1" -source = "git+https://github.com/sxyazi/yazi?rev=9203fd2604f867ab5ec18f24203b918975c4c00a#9203fd2604f867ab5ec18f24203b918975c4c00a" dependencies = [ "base64 0.23.1", "bitflags 2.13.2", @@ -4982,6 +5645,76 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.4", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + [[package]] name = "zerocopy" version = "0.7.35" @@ -5112,3 +5845,44 @@ checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ "zune-core", ] + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.6", + "winnow", +] diff --git a/Cargo.toml b/Cargo.toml index 71f71ac..2db2c7d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,28 +15,45 @@ path = "src/main.rs" anstream = "=1.0.0" anstyle = "=1.0.14" anyhow = "1.0" -chrono = { version = "0.4.45", default-features = false, features = ["clock"] } +base64 = "=0.22.1" +arboard = { version = "=3.6.1", default-features = false, features = ["image-data", "wayland-data-control"] } +chrono = { version = "0.4.45", default-features = false, features = ["clock", "serde"] } +clap = { version = "=4.6.7", features = ["derive"] } directories = "6.0" dotenvy = "0.15" +getrandom = "=0.4.3" futures-util = { version = "0.3.32", default-features = false } grammers-client = { version = "=0.10.0", default-features = false, features = ["fs"] } grammers-session = { version = "=0.10.0", default-features = false, features = ["sqlite-storage"] } image = { version = "=0.25.10", default-features = false } moxcms = "=0.9.1" +notify-rust = "=4.18.0" +mlua = { version = "=0.12.1", features = ["lua55", "vendored", "serde", "error-send"] } +libsql = { version = "=0.9.30", default-features = false, features = ["core"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +regex = "=1.13.1" qrcode = { version = "=0.14.1", default-features = false } ratatui-image = { version = "=11.1.0", default-features = false } ratatui = { version = "=0.30.2", default-features = false, features = ["crossterm"] } sha2 = "=0.11.0" -tokio = { version = "1.52", default-features = false, features = ["macros", "rt", "signal", "sync", "time"] } +tempfile = "=3.27.0" +tokio = { version = "1.52", default-features = false, features = ["macros", "rt", "signal", "sync", "time", "process", "io-util"] } toml = "=1.1.6" unicode-segmentation = "1.13.3" unicode-width = "=0.2.2" +url = "=2.5.8" +yazi-config = { git = "https://github.com/sxyazi/yazi", rev = "9203fd2604f867ab5ec18f24203b918975c4c00a" } +yazi-shared = { git = "https://github.com/sxyazi/yazi", rev = "9203fd2604f867ab5ec18f24203b918975c4c00a" } yazi-adapter = { git = "https://github.com/sxyazi/yazi", rev = "9203fd2604f867ab5ec18f24203b918975c4c00a" } yazi-emulator = { git = "https://github.com/sxyazi/yazi", rev = "9203fd2604f867ab5ec18f24203b918975c4c00a" } yazi-term = { git = "https://github.com/sxyazi/yazi", rev = "9203fd2604f867ab5ec18f24203b918975c4c00a" } yazi-tty = { git = "https://github.com/sxyazi/yazi", rev = "9203fd2604f867ab5ec18f24203b918975c4c00a" } +[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies] +quick-xml = "=0.42.0" + [build-dependencies] vergen-gitcl = "=10.0.3" @@ -56,10 +73,14 @@ pedantic = "warn" [patch."https://github.com/sxyazi/yazi"] yazi-term = { path = "vendor/yazi-term" } +yazi-tty = { path = "vendor/yazi-tty" } [workspace] members = ["."] -exclude = ["vendor/yazi-term", "vendor/grammers-session"] +exclude = ["vendor/yazi-term", "vendor/yazi-tty", "vendor/grammers-session", "vendor/grammers-client", "vendor/grammers-mtsender", "vendor/libsql"] [patch.crates-io] grammers-session = { path = "vendor/grammers-session" } +grammers-client = { path = "vendor/grammers-client" } +grammers-mtsender = { path = "vendor/grammers-mtsender" } +libsql = { path = "vendor/libsql" } diff --git a/README.md b/README.md index ac924ac..84bc013 100644 --- a/README.md +++ b/README.md @@ -1,349 +1,64 @@ # Termgram -Termgram is a focused, keyboard-first Telegram client for the terminal. It uses -Telegram's full MTProto user API—not the Bot API—so it can open your existing -direct messages and groups. +A keyboard-first Telegram client for the terminal, built with Rust, Grammers and +Ratatui. Read direct messages and groups, navigate with Vim-style keys, and keep +your configuration in Lua. -The first release deliberately does less: +Termgram opens cached conversations immediately and synchronizes in the +background. It supports Telegram folders, local regex search, per-chat colors, +inline media previews, and file-manager reveal. This is an unofficial client; +broadcast channels and secret chats are outside its current scope. -- phone/login-code, optional 2FA password, and QR-code authentication; -- up to eight locally persisted Telegram accounts with fast in-app switching; -- direct-message and group conversation list with unread counts; -- recent history, live incoming messages, and read acknowledgements; -- plain-text messages with a Unicode-aware, multiline, per-chat draft; -- native replies with a visible target, optimistic delivery, and exact retry; -- drag-and-drop upload of photos and arbitrary files; -- lazy download and opening of incoming photos, files, video, audio, and stickers; -- in-app navigation for supported Telegram chat and message links; -- terminal previews for photos, image documents, and stickers; -- Unicode fallbacks for stickers and custom emoji; -- optional terminal mouse input for chats, media, scrolling, settings, and accounts; -- wide split-pane and narrow single-pane layouts; -- chat filtering, contextual help, connection/error states, and safe terminal cleanup. +**[English guide](docs/wiki/en/Home.md) · [简体中文指南](docs/wiki/zh-CN/Home.md)** -Broadcast channels are intentionally hidden. Telegram requires third-party -clients that display channels to implement additional sponsored-message -behavior, which is outside this essential messaging scope. +Documentation follows the source revision you are reading. Features on an open +PR branch may not yet be in the latest release. -## Install +## Get started -Prebuilt releases target x86_64 and ARM64 Linux, Apple silicon and Intel macOS, -and x64 and ARM64 Windows. The installers select the native architecture and -reject unsupported combinations before downloading a binary. Linux and macOS -also require Bash, `curl`, `tar`, and either `sha256sum` or `shasum`; Windows -requires PowerShell 5.1 or newer. - -On Linux or macOS: +Linux / macOS: ```sh curl --proto '=https' --tlsv1.2 -sSfL \ https://github.com/iebb/termgram/releases/latest/download/install.sh | bash ``` -This installs `tg` in `~/.local/bin`. To install the newest prerelease, or use -another directory, pass noninteractive environment overrides to `bash`: - -```sh -curl --proto '=https' --tlsv1.2 -sSfL \ - https://github.com/iebb/termgram/releases/latest/download/install.sh \ - | CHANNEL=prerelease INSTALL_DIR="$HOME/bin" bash -``` - -The shortest form pipes the download directly into the shell. To inspect the -installer first, download it and run the saved copy only after reviewing it: - -```sh -curl --proto '=https' --tlsv1.2 -fLo termgram-install.sh \ - https://github.com/iebb/termgram/releases/latest/download/install.sh -less termgram-install.sh -bash termgram-install.sh -``` - -On Windows PowerShell: +Windows PowerShell: ```powershell $installer = Invoke-RestMethod 'https://github.com/iebb/termgram/releases/latest/download/install.ps1' & ([scriptblock]::Create([string]$installer)) ``` -The default destination is `%LOCALAPPDATA%\Programs\Termgram\bin`. Parameters -provide the same noninteractive overrides: - -```powershell -& ([scriptblock]::Create([string]$installer)) -Channel prerelease -BinDir "$HOME\bin" -``` - -To inspect before executing: - -```powershell -Invoke-WebRequest 'https://github.com/iebb/termgram/releases/latest/download/install.ps1' -OutFile termgram-install.ps1 -Get-Content termgram-install.ps1 -$installer = Get-Content termgram-install.ps1 -Raw -& ([scriptblock]::Create($installer)) -``` - -Both installers default to the highest stable `0.1.Z` release. The prerelease -channel chooses the highest `0.1.Z` release of either type, so it never -downgrades past a newer stable release. Downloads come from the public -`iebb/termgram` repository and are checked against that release's -`SHA256SUMS`; the archive must contain only `tg` or `tg.exe`. Replacement is -atomic and fails without damaging an existing binary. The scripts do not ask -for elevation or edit `PATH`; if the destination is not already present, they -print the directory to add. - -After installation, launch Termgram with `tg`. Update within the terminal with -`tg update`, or opt into the newest prerelease with `tg update --prerelease`. -Termgram checks the selected channel at most once per day in the background and -shows a quiet footer hint when an update is available; applying it is always an -explicit command. The updater trusts GitHub's HTTPS release delivery and uses -`SHA256SUMS` to verify download integrity before atomic replacement. -On Windows, replacement completes after the updating process exits. If another -`tg` process keeps the executable locked, the next launch shows a fixed warning -and `tg update` performs a fresh verified download. - -Press `s` while navigating to open the small settings overlay. It stores four -non-sensitive preferences: automatic update checks, stable/prerelease channel, -temporary-download reveal behavior, and the optional right-aligned message-ID -column. The same private settings file remembers the selected account slot; -Telegram login credentials remain exclusively in separate session databases. - -## Source build - -Official GitHub release archives include the project's Telegram application -credentials from encrypted Actions secrets, so those binaries can go directly -to the login screen. The credentials identify the client application, not your -Telegram account; like any credentials compiled into a client binary, they can -be recovered by someone inspecting that binary. Your phone, login code, 2FA -password, and session are never placed in CI and remain local. - -For a source build: - -1. Create an application at [my.telegram.org/apps](https://my.telegram.org/apps), - or use credentials for an application you already control. -2. Copy `.env.example` to `.env` and enter its API ID and API hash. -3. Run: - - ```sh - cargo run --release - ``` - -Termgram uses Rust 1.98, installed automatically by `rustup` from the pinned -`rust-toolchain.toml`. The session database defaults to the operating system's -application-data directory. It is effectively an account credential; keep it -private and never commit or share it. Set `TERMGRAM_SESSION` to override its -location for Account 1; additional account sessions use a private `accounts` -subdirectory beside it. Upgrades continue to recognize `TUIGRAM_SESSION` and an -existing TUIGram session database, so renaming the application does not sign -you out. - -After the first build, launch the optimized client directly with: +From source on any supported platform, with Rust 1.98.0 and native build tools: ```sh -./target/release/tg -``` - -On Windows, run `target\release\tg.exe`. GitHub Actions tests and packages six -native targets across Linux, macOS, and Windows. Every successful -default-branch commit is published as a prerelease with version `0.1.Z`, where -`Z` is its first-parent commit height. A commit whose subject starts with -`release:` is published as a normal release instead. Archives include the -version and platform/architecture, for example -`termgram-0.1.42-linux-aarch64.tar.gz` or -`termgram-0.1.42-macos-x86_64.tar.gz`. The legacy `linux`, `macos`, and `windows` -asset names remain the x86_64 Linux, Apple-silicon macOS, and x64 Windows builds -so existing auto-updaters remain compatible. Run `tg --version` to inspect a -binary's version and build identity: - -```text -version 0.1.0 -commit abcdef12* -branch feat/yazi-terminal-backend -os macOS -arch arm64 -build 26 +cargo +1.98.0 install --locked --git https://github.com/iebb/termgram --bin tg termgram ``` -Labels are bold cyan in a color-capable terminal; values use its default text -color. Columns use spaces so they align independently of terminal tab stops. -Piped output is plain text by default; `NO_COLOR=1` and `TERM=dumb` also disable -styling. A `*` after the commit hash means the build included tracked modifications -or untracked files (Git-ignored files do not count). The build number is the first-parent commit count, matching release -versioning. Builds from a detached checkout report `HEAD` unless -`VERGEN_GIT_BRANCH` supplies the branch name, as it does in CI. Source archives -without Git metadata report `unknown` for unavailable fields. - -The public repository stores only the `TELEGRAM_API_ID` and -`TELEGRAM_API_HASH` secret names. GitHub encrypts their values and injects them -only into trusted default-branch builds, never pull-request builds. - -## Lightweight by design - -Termgram uses one async runtime thread plus Yazi's platform input reader. It has -no idle animation wake-up while the interface is static and bounds message -histories, outgoing commands, and network caches. It still keeps a private -SQLite session so login credentials and Telegram update state survive restarts. -Multiple `tg` processes may use the same account session concurrently; SQLite -serializes short writes and waits up to five seconds for competing writers. - -## Terminal integration - -Terminal input, protocol parsing, capability detection, and platform restoration -come from Yazi crates pinned to one upstream revision. Ratatui draws through the -same Yazi TTY writer. There is one terminal input reader; Crossterm is used only -as Ratatui's output backend. - -The terminal layer consumes capability replies separately from user input. It -detects graphics protocols, terminal identity, colors, and cell pixel size for -media previews. Detection runs alongside input and times out when a -terminal does not answer. Yazi chooses the graphics protocol; `ratatui-image` -provides multiple inline images and row-by-row clipping during timeline scrolling. -Ghostty/Kitty use Kitty graphics, iTerm2/WezTerm use inline images, and supported -terminals use Sixel. Other terminals use Unicode half-blocks without external -helpers. Decoding retains Yazi's ICC color conversion and applies EXIF orientation. -Decoding and encoding run off the UI thread, and only visible images are retained -in the encoded cache. Help, settings, account changes, resizing, and shutdown -clear or redraw the affected image areas. - -Yazi's enhanced keyboard handling supports shifted keys and associated Unicode -text from keyboard layouts. `Ctrl+J` remains a newline fallback on terminals -that cannot distinguish `Shift+Enter`. Paste, focus, mouse, resize, and keyboard -events feed the existing application state machine. Normal exit, initialization -failure, and the panic hook restore terminal modes. - -For tmux, `TERMGRAM_TMUX_PASSTHROUGH=1 tg` opts into Yazi's second-stage terminal -probe. This runs the upstream tmux setup, which sets the pane's -`allow-passthrough=all` and the server's `input-buffer-size=104857600`; those tmux -options persist after exit. Without the opt-in, Termgram uses the capabilities -reported by tmux itself and does not change these options. Set this in the shell -environment before launch, not in the Telegram credentials `.env` file. - -See [vendor/README.md](vendor/README.md) for the pinned revision, the small -`yazi-term` access patch, and the upstream files used by the integration. - -## Keyboard - -| Context | Keys | -| --- | --- | -| Anywhere | `Ctrl+C` quit, `Ctrl+L` redraw, `F2` next account, `F3` add account | -| Sign in | `Enter` submit, `Tab` opens QR login or switches its compact/full-cell display, `Esc` return to phone | -| Chats | `↑`/`↓` or `j`/`k` move, `Enter` open, `/` filter | -| Accounts | `a` open picker, `↑`/`↓` select, `Enter` switch/add, `1`–`8` direct | -| Conversation | `PgUp`/`PgDn` scroll, `Home` oldest loaded, `End`/`G` latest | -| Message actions | click or use `o`/`O` to select each action; `Enter` activates media, links, and supported bot buttons; `l` follows the selected/first link | -| Media preview | photos and stickers appear inline automatically; click/`Enter` retries a failed preview; `Ctrl+L` redraws | -| Message cursor | `[`/`]` select older/newer loaded messages, starting at the latest | -| Replies | `R` replies to the selected/latest message; right-click replies under the pointer; select an existing reply with `o`/`O`, then `r` jumps to its target | -| Wide layout | `Tab` switch pane | -| Narrow conversation | `Esc` return to chats | -| Composer | `i` or `Enter` start, `Enter` send, `Ctrl+J`/`Shift+Enter` newline; `Esc` cancels reply context first, then preserves the draft and leaves | -| Editing | arrows, `Home`/`End`, `Ctrl+A`/`Ctrl+E`, `Ctrl+W`, `Ctrl+U` | - -`?` opens contextual help while navigating. In a conversation, `/` starts a -message so Telegram bot commands remain usable; chat filtering is only active -from the chat list. Press `s` to configure the message-ID column and the other -essential preferences. - -When the terminal supports mouse reporting, clicking a chat opens it, clicking -media, a link, or an inline bot button activates it, right-clicking a message -starts a reply, and the wheel scrolls the pane under the pointer. Settings and -account rows are clickable too. Every mouse action has a keyboard equivalent, -so terminals without mouse reporting remain fully usable. - -Account switching keeps Termgram lightweight: only one account is connected at -a time. Each account has an isolated private SQLite session. Press `a` from the -chat list for the picker, or use `F2`/`F3` even from login and error screens so -an unfinished new login never traps you away from an existing account. +Run `tg`, sign in with your phone or press Tab for QR login. Open a chat with +Enter, press `i` to compose, and `?` for help. Repeat the Cargo command to update +a source install; use `tg update` for release binaries. -During sign-in, Termgram shows a fixed progress message while Telegram checks a -phone number, login code, or 2FA password. Password entry stays masked. QR login -rotates its short-lived code automatically; scan it from Telegram on a device -where the account is already signed in. Its default display follows established -terminal QR tools: exact RGB black-on-white `█`/`▀`/`▄` cells, two QR rows per -terminal row, and a two-module quiet zone. It fits an 80 × 24 terminal; press -`Tab` for a larger compatibility display made only from colored spaces when -block glyphs render poorly. QR tokens, codes, passwords, and -phone numbers are never written to settings or logs. - -Drag one or more files from the desktop into an open conversation and drop them -on the terminal. Text already in the composer becomes the first file's caption, -including when replying. JPG, JPEG, PNG, and WebP files are sent as compressed -Telegram photos; other inputs are preserved as documents. Photos, image documents, -and stickers appear directly in the chat timeline with no preview popup. Visible -media downloads automatically, with at most two preview downloads in progress; -scrolling away does not download the rest of the conversation. Files are saved in -a private per-process temporary directory with sanitized remote filenames. -Animated TGS/WebM stickers use Telegram's raster thumbnail and are labeled as -static previews; animation playback is not implemented. A missing thumbnail or -decoding failure leaves a retryable message in the timeline. Edits, deletes, and -account switches invalidate stale results. Other attachments download only when -activated and retain their second-activation reveal behavior in the operating -system's file manager; Termgram never executes downloaded files. -Downloads remain available for the current session and are left to the operating -system's normal temporary-file cleanup. - -Termgram renders Telegram URL entities—including URLs hidden behind display -text—as explicit selectable rows. Public `t.me`/`telegram.me` and `tg://resolve` -targets open in-app; ordinary HTTP(S) targets open through the operating system -without a shell. Private `t.me/c` and `tg://privatepost` links work for groups -already loaded in the conversation list. Inline URL, web-view, callback, and -game buttons are supported; password-gated, payment, contact/location, and -peer-selection buttons are shown as requiring a graphical Telegram client. -Invite links and broadcast channel links remain outside the client's scope. - -Run `cargo test` for the reducer, input, sanitization, wrapping, and rendering -behavior. - -## Design - -Termgram learns from [paul-nameless/tg](https://github.com/paul-nameless/tg) -without copying its breadth or its index-based shared mutable state. A typed, -single-owner reducer keeps selection, drafts, and scroll state stable while a -separate async Telegram worker handles network calls. The interface borrows the -calm interaction principles of modern Claude Code: a fixed composer, minimal -chrome, contextual help, explicit async states, overlays that own input, and a -real structural change on narrow terminals. - -The client uses [grammers](https://codeberg.org/Lonami/grammers), a native Rust -MTProto implementation. Grammers states that its protocol/cryptography code has -not received a formal security audit. This project is unofficial and is not -affiliated with Telegram. - -## Intentionally out of scope - -Broadcast channels, reactions, sticker/GIF selection, calls, stories, contacts, -chat creation, edits/deletes/forwarding, message search, group -administration, notifications, polls, and secret chats. +Releases support Linux x86_64/ARM64, macOS Intel/Apple silicon, and Windows +x64/ARM64. See [installation and source builds](docs/wiki/en/Getting-Started.md) +for prerequisites, installer options, and Telegram API credentials. ## Development -Follow [AGENTS.md](AGENTS.md): prefer upstream implementations, design and -implement before testing, and keep only minimal durable regression tests. -Remove temporary test scaffolding once validation is complete. +Rust 1.98 is pinned in `rust-toolchain.toml`. Create Telegram application +credentials at [my.telegram.org/apps](https://my.telegram.org/apps), copy +`.env.example` to `.env`, and fill in your API ID and hash. ```sh +cargo run --release cargo fmt --all -- --check -cargo clippy --all-targets --all-features -- -D warnings -cargo test --all-targets -cargo build --release -``` - -## Publishing - -Pushing an ordinary commit to the default branch creates a GitHub prerelease -after every check and all three native builds pass. Release publication reuses -those exact tested artifacts, the two installation scripts, and `SHA256SUMS`; -a failed CI run never publishes anything. - -To promote the current tree as a stable release, create a release commit and -push it: - -```sh -git commit --allow-empty -m "release: stable" -git push +cargo clippy --locked --all-targets --all-features -- -D warnings +cargo test --locked --all-targets --all-features ``` -Versions stay on the `0.1` line for now. `Z` advances once per first-parent -default-branch commit, so merge internals do not unexpectedly consume several -versions. Failed or interrupted publications are retried by the next push. -Existing tags are never moved; a height collision fails closed. Automatic -publishing starts at `0.1.2`, the commit that introduced this release pipeline. +See [development and architecture](docs/wiki/en/Development.md), +[engineering principles](AGENTS.md), and [upstream provenance](vendor/README.md). +The [Wiki source](docs/wiki/README.md) is reviewed alongside code changes and +provides English and Simplified Chinese pages. Local working plans live in the +Git-ignored `dev-notes/` directory. diff --git a/build.rs b/build.rs index c7d9f79..dd9fa9f 100644 --- a/build.rs +++ b/build.rs @@ -21,8 +21,13 @@ fn main() -> Result<(), Box> { .ok() .filter(|output| output.status.success()) .and_then(|output| String::from_utf8(output.stdout).ok()) - .and_then(|number| number.trim().parse::().ok()) - .map_or_else(|| "unknown".to_owned(), |number| number.to_string()); + .and_then(|number| number.trim().parse::().ok()); + let source_version = number.map_or_else( + || env!("CARGO_PKG_VERSION").to_owned(), + |height| format!("0.1.{height}"), + ); + let number = number.map_or_else(|| "unknown".to_owned(), |number| number.to_string()); println!("cargo:rustc-env=TERMGRAM_BUILD_NUMBER={number}"); + println!("cargo:rustc-env=TERMGRAM_SOURCE_VERSION={source_version}"); Ok(()) } diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md new file mode 100644 index 0000000..a4d9c4f --- /dev/null +++ b/docs/wiki/Home.md @@ -0,0 +1,13 @@ +# Termgram Wiki + +[English](en/Home.md) · [简体中文](zh-CN/Home.md) + +Termgram is a keyboard-first Telegram client for direct messages and groups. +Choose a language for setup, keybindings, Lua configuration, daily workflows, +and maintenance documentation. + +Termgram 是面向私聊与群组的终端 Telegram 客户端。请选择语言,查看安装、快捷键、 +Lua 配置、日常操作与维护说明。 + +Documentation follows its source revision. Open PR features may not be available +in the latest release. 文档对应其来源版本,尚未合并的 PR 功能不一定已包含在最新发行版中。 diff --git a/docs/wiki/README.md b/docs/wiki/README.md new file mode 100644 index 0000000..862fe7e --- /dev/null +++ b/docs/wiki/README.md @@ -0,0 +1,46 @@ +# Maintaining the Wiki + +[English guide](en/Home.md) · [简体中文指南](zh-CN/Home.md) + +This directory is the reviewed source for the project Wiki. User documentation +is tracked with its features; local development plans stay in Git-ignored +`dev-notes/`. Keep the same page filenames in `en/` and `zh-CN/`, use relative +inline Markdown links, and update both languages in the feature's PR. + +The exporter uses Python 3.9+ standard-library modules. It validates language +parity and local link targets, flattens `en/Configuration.md` to +`en-Configuration.md`, rewrites links, and generates a language sidebar. It does +not render Markdown, push Git commits, or remove unrelated Wiki pages. + +```sh +python3 scripts/export-wiki.py --check +python3 scripts/export-wiki.py --output dist/wiki +``` + +Repository links in exports point to the current commit. Export from a clean, +published source revision; `--source-ref REF` can select another published ref. +Documentation on an unmerged branch describes that branch, not necessarily the +latest installer release. Source pages remain browsable on GitHub without export. + +To publish the approved source, initialize the Wiki with one page on GitHub if it +does not already exist, then clone its separate repository: + +```sh +git clone https://github.com/iebb/termgram.wiki.git ../termgram.wiki +python3 scripts/export-wiki.py --output ../termgram.wiki +git -C ../termgram.wiki diff +git -C ../termgram.wiki add -- '*.md' +git -C ../termgram.wiki commit -m "docs: synchronize bilingual user guides" +git -C ../termgram.wiki push +``` + +Review renamed/deleted pages manually as part of the diff. GitHub only publishes +the Wiki's default branch. See the official +[Wiki editing instructions](https://docs.github.com/en/communities/documenting-your-project-with-wikis/adding-or-editing-wiki-pages). +The normal feature PR does not automatically merge or publish Wiki changes. + +这里是 Wiki 的可审阅源文件。英文与简体中文保持相同页名,使用相对 Markdown 链接, +随功能 PR 同步修改。`--check` 校验两种语言的页面和内部链接;`--output` 导出为 +GitHub Wiki 的扁平页名并生成语言侧栏。脚本不联网、不推送、不删除无关页面。 +从干净且已发布的源码提交导出,审阅 Wiki diff 后再提交推送;首次发布需要在 GitHub +先创建一个页面。未合并分支的文档可能包含最新发行版尚未提供的功能。 diff --git a/docs/wiki/en/Appearance.md b/docs/wiki/en/Appearance.md new file mode 100644 index 0000000..4005806 --- /dev/null +++ b/docs/wiki/en/Appearance.md @@ -0,0 +1,111 @@ +# Appearance + +[简体中文](../zh-CN/Appearance.md) · [Guide](Home.md) + +Press `c` on a chat, or in its conversation, to choose its color. Press `C` in the +chat list to color the current folder. Up/Down selects a named terminal color; +Enter applies it and Esc cancels. The list marker and selection styling remain +visible independently of the chosen color. + +Chat rows and conversation titles use the terminal's default foreground unless +configured otherwise. Folder titles use a stable color chosen from six ANSI +palette entries. Your terminal theme controls the actual RGB values; the app +does not assume a dark background for these colors. + +Precedence is built-in default → Lua → in-app override. Choose **Follow +configuration** to remove an override. **Terminal default** is an explicit color +choice and can override a colored Lua default. + +```lua +return { + colors = { + chats = { [-1001234567890] = "cyan" }, + folders = { [2] = "yellow" }, + }, +} +``` + +Values are `default`, `black`, `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`, +`gray`, `dark_gray`, `light_red`, `light_green`, `light_yellow`, `light_blue`, +`light_magenta`, `light_cyan`, and `white`. `g i` shows chat and current folder IDs. + +In-app overrides live in `appearance.json` beside `settings.conf`, keyed by the +actual Telegram account ID. Switching accounts or clearing the message cache +does not mix or delete them. Writes reuse the atomic settings writer. A malformed +preferences file is reported and is not silently replaced. Your Lua configuration +and the server's folder colors are never rewritten by this picker. + +## Nerd Font icons + +Nerd Font support is opt-in. Install a [Nerd Font](https://www.nerdfonts.com/font-downloads) +and select its **Nerd Font Mono** variant in the terminal profile, then add this +to `config.lua` and restart Termgram: + +```lua +return { + nerd_font = true, +} +``` + +Use a v3+ font. Icons identify chat types, folders, Archive, pins and attachments; +text labels, key hints and terminal colors remain visible. The Mono variant keeps +icons within terminal cells. With SSH or tmux, configure the font on the terminal +that displays the session. Termgram uses the terminal's selected font and does +not install fonts or change terminal preferences. + +The default `nerd_font = false` retains the ordinary text presentation and `^` +pin marker. Turn the option off if glyphs appear as boxes or overlap adjacent text. +Configuration errors are reported through the existing Lua configuration loader. + +Font reference: [Nerd Fonts font variants](https://github.com/ryanoasis/nerd-fonts/wiki/FAQ-and-Troubleshooting). + +## Chat list columns + +Titles use the configured chat color. Time defaults to cyan and unread counts to +yellow; both are right-aligned independently of title length. A marker and an +underlined selected title identify focus without covering colors with a reversed +row. Nerd Font chat/pin icons share the title column; CJK and emoji names truncate +by terminal cells. The compact sidebar defaults to 30 columns and F4 toggles it. +See [Lua configuration](Configuration.md) for width and semantic color options. + +## Conversation layout + +Each message has a separate author header. Full user names include both first and +last names, and long names wrap instead of being cut off by metadata. Incoming +authors use stable terminal colors; outgoing authors use green. Body text keeps +the terminal's default foreground. + +Messages have no empty separator row by default. Alternating backgrounds use a +subtle shade of the terminal's reported background, working with dark and light +themes. Until a terminal reports its background, both use the default background. +Configure density or choose an ANSI background explicitly: + +```lua +messages = { + spacing = 0, -- 0–2 blank rows + alternating = true, + -- alternate_background = "dark_gray", -- omit for automatic theme shading +}, +``` + +The bottom bar's `message` item shows the selected message, or the last visible +message when no selection is active: local time/date, optional message ID, +delivery, pin and download/preview state. Select a message to inspect its details; +`context` supplies the effective Lua keys. On small terminals lower-priority +segments yield space and details can be shortened. + +Replies show an indented author and original-message excerpt above text/media, +limited to two rows. Click the quote, or select the message and press Enter, to +jump to the original. IDs and loading/unavailable details belong to the bottom +bar. Clicking media still only selects it; `o` opens its preview. A thin left marker identifies the +selected message; the current reply, attachment or link action also gains emphasis. +Body, action rows and inline media share a small two-column gutter. Inline images +do not repeat a `photo` or `sticker` filename above the preview. While an image is +not available, a small placeholder preserves its target; preview status appears +in the bottom bar. `o` expands selected media, `O` reveals its original in the +system file manager, `i` replies, and `g n` / `g p` move among actions. + +Resizing and sidebar toggles retain the anchored message. If reflow removes the +old physical row, the viewport returns to that message's header rather than its +trailing separator. This is a message/row anchor, not an exact text-character +position across different wrapping widths. diff --git a/docs/wiki/en/Attachments.md b/docs/wiki/en/Attachments.md new file mode 100644 index 0000000..6621587 --- /dev/null +++ b/docs/wiki/en/Attachments.md @@ -0,0 +1,163 @@ +# Attachments and file previews + +[简体中文](../zh-CN/Attachments.md) · [Guide](Home.md) + +## Prepare files to send + +Use `:attach /absolute/path` in an open chat. Multiple paths can be quoted using +your platform's argument syntax; local `file:` URLs also work. Paths are parsed +by Yazi, without executing a shell or expanding variables. Plain terminal paste +in the conversation enters the composer as text by default, including paths. + +Files are added to that chat's draft in the background, without uploading. +The composer shows the count. Open `:attachments` or press Ctrl-O in the composer +to review the names, sizes and chosen send format. Click a row or use j/k to select. + +| Key in attachment review | Behavior | +| --- | --- | +| a | Open `:attach` to add files | +| p | Toggle a supported image between Telegram photo and original file | +| d / Delete | Remove the selected reference, or cancel pending preparation | +| o / Enter | Preview a supported image; Esc returns to the list | +| O | Reveal the original in the system file manager | +| i | Return to the caption editor | +| Esc | Return to the previous view, keeping the draft | + +Enter **in the composer** sends the reviewed files. The first file carries the +composer caption and reply target. There are at most eight files and 2 GiB per +draft. Uploads and downloads share a queue of at most 32 tasks with three running +at once. Files in the same chat are uploaded in admission order. This revision sends individual messages; album grouping follows later. + +File references, chosen formats and content fingerprints survive normal restarts +with the [local draft](UX.md). Removing an attachment never deletes its original. +Before any upload, Termgram checks the fingerprint while creating a private +upload copy. Missing or changed originals fail with a retryable status; reattach +changed files to review their new content. Upload copies are removed when their +transfer finishes or is cancelled. Adding files to one chat and switching chats +while preparation runs does not move the result to the new conversation. + +To stage recognizable pasted paths automatically, opt in through Lua: + +```lua +attachments = { auto_attach_paths = true }, +``` + +This adds files to the draft; it still requires explicit send. Unrecognized input +remains text. Bindings use the `attachments` context and the actions `attach`, +`attachments`, `remove_attachment`, `attachment_format`, `preview` and `reveal`. + +## Paste from the clipboard + +Use `:paste`, Ctrl-V or Ctrl-Alt-V in the conversation/composer. In attachment +review these shortcuts also add files, and `?` opens the effective key list. +With no detected terminal MIME capability, the native clipboard reads a file list first, preserving every original file +within the draft limit. If there is no file list, an image becomes a PNG draft +asset; ordinary text is inserted at the saved cursor. Nothing is sent until you +send from the composer. Lua action: `paste_clipboard`. + +```lua +attachments = { + auto_attach_paths = false, + clipboard_as_photo = true, + terminal_clipboard = true, +}, +``` + +Set `clipboard_as_photo = false` to start clipboard images in original-file +mode; `p` still changes individual images in review. Bitmap data is limited to +128 MiB after the OS read, the PNG asset to 16 MiB and text to 1 MiB. A ten-second +preparation timeout keeps the interface responsive. If the OS call is still +running, another read is rejected until it finishes; timeout does not spawn a +second blocked reader. Late results are discarded and their unused assets removed. + +Unsent screenshots live beside the user-state database in +`.attachments//`, outside download-cache +retention. Normal restart keeps referenced drafts. Removal and successful send +release owned files after the last active reference; failures keep the retry's +file. Startup removes orphaned screenshot assets for that account, preserving +persisted drafts and in-process drafts still waiting for a background save. + +| Environment | Clipboard backend | +| --- | --- | +| macOS / Windows | arboard native file lists, images and text | +| Linux X11 / XWayland | arboard X11 backend | +| Linux Wayland | arboard data-control backend when the compositor supports it; X11 fallback | +| WSL | Native first, then Windows PowerShell clipboard data and `wslpath`; child processes have output limits and a four-second timeout | +| SSH / no desktop clipboard | A detected OSC 5522 terminal can supply images/text; otherwise use `:attach` with files readable on this host | + +Cmd-V and Ctrl-Shift-V are usually handled by the terminal. They may deliver +text paths, or no image at all. Use `:paste` when the terminal captures the +application shortcuts. Reading the native clipboard on an SSH host does not +read the local desktop's clipboard. Cross-platform clipboard access depends on +the desktop session and its permissions; no additional terminal input reader is used. + +## Terminal MIME paste + +When the terminal positively reports OSC 5522 support, `:paste` and the clipboard +shortcuts request its clipboard instead. The terminal's own paste shortcut can +also supply a MIME paste event. Set `attachments.terminal_clipboard = false` to +keep native reads and ordinary terminal text paste. + +Files are preferred, followed by PNG/JPEG/WebP/GIF and plain UTF-8 text. Encoded +images keep their original bytes in draft-owned files; they are not converted to +PNG. GIFs use original-file mode. Images are limited to 16 MiB encoded, 8192 pixels +per dimension and 128 MiB decoded. Text and file lists are limited to 1 MiB. + +Only one terminal read is pending at a time. Each request phase expires after ten +seconds. A timeout, denied permission, busy terminal or unsupported content is +reported in the status bar; it does not silently read a different clipboard. +Switching chats keeps the captured draft destination. Cancellation or account +switching discards late results. Search, filter, command and login fields accept +only plain text and cancel if their destination changes. + +Over SSH the terminal can transmit image bytes and text. A file URL still refers +to a path on the terminal's host; Termgram rejects that file list rather than +assuming the remote machine has the same file. Use `:attach` for files already +available remotely. OSC 5522 requires support through the complete terminal and +multiplexer chain; SSH/tmux combinations have not all been exercised. The single +Yazi input parser and TTY owner handle negotiation, payloads and exit restoration. + +## Received files + +Select an attachment with `j/k` (or click it) and press uppercase `O` (`Shift-O`) +to reveal it in your system file manager. This is a single press, not a timed +hold gesture. Reported key-repeat events are ignored. A second activation while +the same file is downloading does not start another transfer. + +An existing full-size image preview or downloaded file is reused. Otherwise +Termgram downloads the original attachment and reveals it when ready. Animated +sticker thumbnails are previews only; `O` downloads the original. Missing cached +files are downloaded again. Failures appear in the status area and can be retried. + +macOS selects the file in Finder; Windows selects it in Explorer. Linux opens the +containing directory through `xdg-open` (selection support varies by file manager). +The file itself is not executed. `O` is an explicit reveal action even when the +settings menu's Enter download behavior is **Keep in cache**. + +A single click selects media and shows its available keys. Press `o` to expand an +image or sticker in the terminal; Esc closes the preview, `i` replies, and `O` +reveals the original file. Selection itself does not activate links, bot buttons +or file-manager operations. Use `g n` / `g p` to cycle actionable items and Enter +to activate one. Lua actions are `preview`, `next_action`, `previous_action` and +`reveal`; the expanded view uses the `preview` context. + +Original media IDs and request generations prevent an older transfer from +replacing an edited or deleted attachment. Download mappings are stored with the +message cache and survive restart. Completed files live in +`.media//`. Termgram keeps at most 512 +completed files and targets a 1 GiB budget per directory, removing older files. +The file just downloaded is retained even if it alone exceeds the byte budget; +a later cleanup can remove it. Copy files you want to keep outside this cache. + +Partial downloads use the maintained `tempfile` crate and stay separate from +completed files. Cancellation removes partial files; interrupted-process remnants +are cleaned before the next transfer. The account cache has one process owner, +and outstanding media tasks retain its lock through cleanup. This also prevents +two clients from independently advancing one message-cache checkpoint. + +Photos, image documents and stickers preview in the timeline. Only visible +previews are requested, with at most two preview downloads pending in the app; +all network transfers share a bounded worker. Animated TGS/WebM stickers show +Telegram's raster thumbnail and are labeled as static previews. Failed previews +remain retryable through `o` or Enter after selection. Ctrl-L redraws; terminal protocol details +are in [Terminal](Terminal.md). Use `:attach` to prepare local files before sending. diff --git a/docs/wiki/en/Chats.md b/docs/wiki/en/Chats.md new file mode 100644 index 0000000..063afd1 --- /dev/null +++ b/docs/wiki/en/Chats.md @@ -0,0 +1,54 @@ +# Chat discovery and details + +[简体中文](../zh-CN/Chats.md) · [Guide](Home.md) + +## Open a conversation + +Use `:chat` and Tab to choose a cached chat by title, configured alias or ID. +Use `:open @alice_name` to look up a username on Telegram, then `i` to compose. +`:open https://t.me/example/123` also opens a linked message. Existing drafts, +unread counters and folder membership are preserved. + +Remote resolution starts only after Enter and has a timeout; typing never sends +lookup requests. Starting to compose or opening another chat cancels the pending +navigation, and superseded results cannot change your view. Cached chats can be +opened offline. Broadcast channels remain unsupported in this revision. + +## Preview and join an invitation + +`:join https://t.me/+hash` previews a group invitation; `t.me/joinchat/hash` and +`tg://join?invite=hash` work too. Opening an invite with `:open` or activating its +message link shows the same preview. It shows the title, available description, +member count and Telegram scam/fake warning. + +Cancel is selected initially: use Down to select Join/Request to join, then +Enter to confirm. A mouse click selects an option; Enter confirms. Already joined +groups offer Open chat. The server is checked again before joining. + +Admin approval remains “request submitted” until approved. Closing during +submission keeps the request running and reports its result without changing +your view. If confirmation fails or times out, reopen the invitation to check +current membership before trying again. Expired/revoked links report Telegram's +error; paid subscriptions, broadcast channels and bot verification require the +official client. Invite hashes are excluded from request logs and are not written +to preferences or disk command history. + +## Inspect details and sending restrictions + +`:info` opens details for the selected chat: full name, username, description, +member count when available, your role, notification mute, text/photo/file +permissions and slow mode. Scroll with j/k or the mouse wheel; Ctrl-R refreshes; +Esc closes. Long names and descriptions wrap. + +The active conversation refreshes in the background at most once a minute, with +one details request at a time. Permission updates invalidate older results. +Offline details show their age. Known restrictions appear in the composer ghost +text and statusline. Sending while restricted keeps the text, reply and +attachments in the draft. Slow mode shows a countdown and allows one pending +message or attachment at a time. + +Server rejections explain permission or wait errors and preserve retry content; +Telegram still checks permission at send time. This viewer does not modify roles, +ban users, or pay for messages. [Notification mute](Notifications.md) is separate +from write restrictions. Use [Commands](Commands.md) for command completion and +[Keybindings](Keybindings.md) for navigation. diff --git a/docs/wiki/en/Commands.md b/docs/wiki/en/Commands.md new file mode 100644 index 0000000..54bccb6 --- /dev/null +++ b/docs/wiki/en/Commands.md @@ -0,0 +1,93 @@ +# Commands + +[简体中文](../zh-CN/Commands.md) · [Guide](Home.md) + +Press `:` while navigating chats or a conversation. The bottom command line +shows available commands, their descriptions, argument usage and effective +shortcuts. Unavailable actions explain the missing selection or connection. +The header identifies the account and target captured when commands opened. +Typing `:` in a composer, search, filter or login field inserts ordinary text. + +| Key | Behavior | +| --- | --- | +| Tab / Ctrl-N | Complete the next candidate | +| Shift-Tab / Ctrl-P | Complete the previous candidate | +| Up / Down | Recall older/newer commands matching the input prefix | +| Enter | Run the complete command; empty input closes | +| Esc / Ctrl-C | Cancel and return to the conversation | +| Left/Right, Home/End, Ctrl-A/Ctrl-E | Move the input cursor | +| Backspace/Delete, Ctrl-W, Ctrl-U | Edit or clear input | + +Clicking a candidate fills the command without executing it. Missing arguments +and errors stay in the editor. Use a complete command name or the explicit `h` +and `q` aliases; a prefix such as `qui` only becomes executable after completion. +Command history holds up to 64 entries per account in memory and is not written +to disk. Multiline paste cannot execute a series of commands. + +| Command | Behavior | +| --- | --- | +| `help [command]`, `h` | Browse commands or explain one command | +| `chat ` | Open a cached chat in this account; Tab filters titles and configured aliases | +| `folder ` | Choose All chats, Archive, or an official Telegram folder | +| `account [slot]` | Open the account picker or switch to an existing slot | +| `search [regex]` | Open local search or submit a verbatim pattern in the current local scope | +| `search --cloud [filters] [text]` | Search the open chat on Telegram; filter by sender, UTC date or media ([syntax](Search.md)) | +| `attach ` | Prepare local files in the captured chat draft; never sends automatically | +| `paste` | Paste clipboard files, an image or text into the captured chat draft | +| `attachments` | Review the open conversation’s staged files | +| `latest` | Return the open conversation to its latest messages | +| `unread` | Open the first incoming message after the chat's saved read boundary | +| `read` | Explicitly mark the entire captured chat read and clear its unread reminder | +| `mark-unread` | Set Telegram's unread reminder without rewinding message receipts | +| `edit` | Edit the selected delivered message or resume this chat’s saved edit | +| `edit-discard` | Discard this chat’s local edit, preserving the ordinary draft | +| `delete` | Review the selected message and explicitly choose its deletion scope | +| `copy [text, link]` | Copy the selected message’s text/caption (default) or its Telegram message link | +| `forward ` | Choose a destination using Tab, then review the selected message before forwarding | +| `save` | Review a native forward of the selected message to this account’s Saved Messages | +| `saved` | Open Saved Messages, including when absent from the recent chat list | +| `open <@username or Telegram link>` | Look up a user, group or message outside the cached chat list ([details](Chats.md)) | +| `join ` | Preview an invitation before explicitly joining or requesting approval ([details](Chats.md)) | +| `info` | Inspect chat details, permissions and slow mode ([details](Chats.md)) | +| `reply` | Reply to an explicitly selected message | +| `poll` | Review and vote in the selected [poll or quiz](Polls.md) | +| `react` | Add or remove standard emoji [reactions](Reactions.md) on the selected message | +| `spoiler` | Reveal/hide the selected message’s spoilers | +| `quote` | Expand/collapse its expandable quotes | +| `preview` | Expand a selected image or sticker | +| `reveal` | Download if needed and reveal the selected attachment in Finder, Explorer or the file manager | +| `pins` | Browse the open conversation's pinned messages | +| `pin chat`, `unpin chat` | Set the chat's pin state in the captured folder | +| `pin message`, `unpin message` | Set the selected message's pin state using Telegram's existing options prompt | +| `mute [duration]`, `unmute` | Set Telegram notification state for the captured chat ([details](Notifications.md)) | +| `mentions` | Browse unread mentions and replies to you in the captured chat ([details](Notifications.md)) | +| `archive`, `unarchive` | Move the captured chat into or out of Archive | +| `sidebar [show, hide or toggle]` | Control the sidebar; omitted argument toggles | +| `color chat`, `color folder` | Open the target's color picker | +| `settings` | Open application settings | +| `config reload`, `reload` | Validate and apply Lua settings together; retain current settings on failure | +| `status` | Inspect connection, DC, recent ping, cache loading, config and clipboard capability | +| `refresh` | Refresh chat and folder lists | +| `quit`, `q` | Exit normally | + +Chat names must match a unique full title when executing. Use Tab to choose an +ID from partial title matches; duplicate titles are never guessed. Completion +uses loaded data and does not issue network searches while typing. Archive and +pin commands use the captured stable target even if incoming messages reorder +the chat list. Already pinned/archived targets keep their requested state. + +The text after the first space in `search` is the regex as written, including +backslashes and trailing spaces. Local regex does not use shell quoting. The explicit `--cloud` prefix selects +Yazi platform argument quoting and typed filter flags; neither mode executes shell commands. `status` uses existing observations: unavailable DC and stale +ping measurements are shown as unavailable, not as zero. It distinguishes +messages in memory from the persisted cache's search coverage. + +Rebind the entry action `command` in `chats` and `conversation`. The `command` +context has `complete_next`, `complete_previous`, `history_previous` and +`history_next` actions, in addition to normal editor actions. See +[Lua configuration](Configuration.md). Keyboard bindings and command actions +share the same application dispatch and descriptions. + +`:config reload` and `:reload` atomically reload the Lua file. Errors retain the +current settings. `:status` shows the config path, revision, last error, active +notification policy, media-paste capability and build identity. See [configuration](Configuration.md). diff --git a/docs/wiki/en/Configuration.md b/docs/wiki/en/Configuration.md new file mode 100644 index 0000000..2ed7c2c --- /dev/null +++ b/docs/wiki/en/Configuration.md @@ -0,0 +1,267 @@ +# Lua configuration + +[简体中文](../zh-CN/Configuration.md) · [Guide](Home.md) + +## Load configuration + +Place `config.lua` beside Termgram's `settings.conf`, or set `TERMGRAM_CONFIG` to +an explicit file path. Use `:config reload` (or `:reload`) to load changes without restarting. Start with [the example](../../../examples/config.lua). +The file returns a Lua table; tables, strings, mathematics and UTF-8 helpers are +available. Filesystem, process and plugin APIs are deliberately outside this +configuration interface. Invalid files use defaults at startup; a failed reload +keeps the settings already in use and reports the error. + +```lua +return { + chats = { work = -1001234567890 }, + ghost_text = "{send} to send · {newline} for a new line", + nerd_font = false, + keymap = { + { context = "conversation", on = { "" }, run = "message_up", count = 20 }, + { context = "conversation", on = { "g", "w" }, run = "jump work" }, + { context = "compose", on = { "" }, run = "newline" }, + { context = "compose", on = { "" }, run = "send" }, + }, +} +``` + +Contexts are `global`, `chats`, `conversation`, `compose`, `edit`, `forward`, `poll`, `reactions`, `attachments`, `command`, `input` (login and chat +filter), `overlay`, `preview`, `pins`, and `search`. A context takes precedence over global bindings. Rebinding +an existing key replaces that binding. Use `run = "noop"` to remove a binding. +A chord is a list of individual keys, such as `{ "g", "w" }`; ambiguous prefixes +are rejected. Chords expire after one second; Escape cancels a pending chord. +Key spelling follows Yazi: ``, ``, ``, ``, ``. + +Chat aliases use stable numeric Telegram IDs, including the `-100…` IDs of +supergroups. The destination must be present in the cached conversation list. +The optional `desc` field supplies the binding's label in help. Help always +reflects the effective bindings; use the arrow keys to scroll it. + +Navigation defaults: `j/k` move between chats or actual messages; `20k` moves +20 messages up, fetching older pages if necessary. `G` or End returns to the +latest messages; `gg` or Home goes to the oldest message in the loaded window. +Arrow keys scroll rendered rows. PageUp/PageDown scroll ten rows. `i` starts +composing, `R` replies, `r` opens a reply target, `/` filters the chat list, +Tab switches panes, `s` opens settings, `a` opens accounts, and `?` opens help. +Counts and navigation chords never consume ordinary composer text. + +Use `count = 20` with `message_up` for a single shortcut that moves up 20 +messages. The optional count defaults to 1 and accepts 1–9999 for `up`, `down`, +`message_up`, `message_down`, `page_up`, and `page_down`. A typed prefix multiplies +the configured count, capped at 9999. Other actions do not accept a count. + +Composer placeholders use the effective `{send}`, `{newline}` and `{cancel}` +bindings. Set `ghost_text = ""` to hide them. Managed in-app preferences remain +in their own file; Termgram does not rewrite your Lua configuration. + +Press `g i` to show the current chat ID for an alias. + +Set `nerd_font = true` when your terminal uses a Nerd Font Mono (v3+). It enables +chat, folder, archive, pin and attachment icons; `false` is the default. See +[Appearance](Appearance.md) for font selection and fallback behavior. + +## Bottom statusline + +The one-row bottom bar replaces the old application header. Configure its ordered +segments; omitted fields keep these defaults: + +```lua +statusline = { + enabled = true, + left = { "mode", "app", "account", "message", "context" }, + right = { "notifications", "connection", "latency", "dc", "position" }, +}, +``` + +| Item | Meaning | +| --- | --- | +| `mode` | CHATS, NORMAL, SELECT, INSERT, COMMAND or the current overlay mode | +| `app` | Termgram | +| `account` | Local account slot and Telegram display name | +| `message` | Selected/last-visible message time, delivery, pin and media state | +| `context` | Effective selection/help keys or an available update | +| `notifications` | Focused chat’s confirmed mute state and local deadline ([details](Notifications.md)) | +| `connection` | Connecting, online, reconnecting or offline | +| `latency` | Latest completed primary-connection Ping in milliseconds | +| `dc` | The authenticated session's home data center ID | +| `position` | Live edge, rows from the bottom or new arrivals while reading history | + +Each item can appear once across both sides. Unknown or duplicate items reject +the configuration. Empty lists remove a side. At narrow widths, optional items +are removed before mode and contextual actions; their order within each side is +preserved. `enabled = false` hides the bar. Errors remain readable above it. + +Latency is an optional diagnostic: at most one Ping runs every 60 seconds, with +a five-second display deadline, on the existing Telegram connection. A late +probe displays unavailable; another probe waits until the previous request +finishes, avoiding a backlog during outages. It includes SDK +queueing/retries and is **not** the time for a message to arrive. No measurement +runs during drawing. Removing `latency` from both sides, or disabling the bar, +disables these extra requests. A dash means unavailable; disconnects invalidate +measurements, samples older than 90 seconds are not displayed, and account +switches clear all observations. DC identifies the primary session, not every +server used for media transfers. The bar does not infer DC from geography. + +## Sidebar + +```lua +sidebar = { width = 30, time_color = "cyan", unread_color = "yellow" }, +``` + +Width accepts 24–60 terminal columns and shrinks when necessary to leave at +least 48 columns for the conversation. Below 80 columns, one pane is shown. +Colors use the named [terminal palette](Appearance.md). Titles keep their +per-chat color; time and unread counts have independent fixed columns. Counts +over 999 display `999+` without widening the column. + +F4 toggles the sidebar in navigation and composition. In wide windows it keeps +the composer active; in a narrow window it returns to the chat list and keeps +the draft. Tab/Shift-Tab or returning to Chats reveals the sidebar. Holding F4 +does not repeatedly toggle where the terminal reports repeat events. This is +a press-to-toggle action, so release-event support is unnecessary. Rebind it: + +```lua +{ context = "global", on = { "" }, run = "toggle_sidebar" }, +``` + +The toggle leaves dialogs, selection and messages intact. An empty session keeps +the list visible until a chat is opened. Sidebar visibility is local UI state; +it is not a Telegram folder setting. + +## File locations + +`config.lua`, `settings.conf`, `appearance.json` and `navigation.json` use the configuration directory. +`navigation.json` remembers the last opened chat by Telegram account ID; it stores no draft text. +The session, message database and media use the data directory. Defaults: + +| OS | Configuration directory | Data directory | +| --- | --- | --- | +| Linux | `$XDG_CONFIG_HOME/termgram` or `~/.config/termgram` | `$XDG_DATA_HOME/termgram` or `~/.local/share/termgram` | +| macOS | `~/Library/Application Support/dev.termgram.Termgram` | Same | +| Windows | `%APPDATA%/termgram/Termgram/config` | `%LOCALAPPDATA%/termgram/Termgram/data` | + +Account 1 uses `termgram.session`; other slots use +`accounts/.account-N` beside it. Each gets its own appended +`.cache.sqlite3` database and `.media` directory. Session files contain account +credentials; cached messages and media are local plaintext. Cache cleanup is +described in [Synchronization](Synchronization.md). + +| Environment variable | Purpose | +| --- | --- | +| `TELEGRAM_API_ID`, `TELEGRAM_API_HASH` | Source-build application credentials; environment or `.env` takes precedence over embedded values | +| `TERMGRAM_SESSION` | Override Account 1 session path; other slots derive from it | +| `TERMGRAM_CONFIG` | Override the Lua file; set in the shell before starting | +| `TERMGRAM_TMUX_PASSTHROUGH=1` | Opt into terminal passthrough setup; see [Terminal](Terminal.md) | + +Lua is loaded before the credentials `.env`, so `TERMGRAM_CONFIG` must already +be in the process environment. A previous `TUIGRAM_SESSION` override and an +existing TUIGram default session remain recognized for login continuity. +Changing the session path does not move settings or colors. + +Lua source is limited to 64 KiB, an 8 MiB VM budget, and approximately one million +instructions. Unknown configuration fields, actions, aliases and ambiguous +same-context prefixes are reported; the entire invalid configuration falls back +to defaults at startup. A failed `:config reload` keeps the previous configuration. There is no arbitrary plugin API. + +## Action reference + +Bind actions in an appropriate context. A custom global binding is a fallback; +a more specific binding or chord prefix takes precedence. Help labels can be +customized with `desc`. `noop` removes that context's key, so an existing global +binding can become visible again. + +| Actions | Usual context and behavior | +| --- | --- | +| `quit`, `redraw`, `next_account`, `add_account` | Global lifecycle/account controls | +| `help`, `settings`, `accounts` | Navigation; open or toggle the overlay | +| `reload_config` | Validate and apply the Lua file without restarting | +| `open`, `cancel`, `focus` | Contextual activation, dismissal, pane/QR switching | +| `toggle_sidebar` | Show/hide the sidebar in navigation or composition | +| `up`, `down`, `page_up`, `page_down` | List selection, rendered-row scrolling, or search selection; accept `count` | +| `message_up`, `message_down` | Conversation message cursor; accept `count` | +| `oldest`, `latest` | First/last chat, or loaded-history start/latest conversation | +| `first_unread`, `mark_read`, `mark_unread` | Jump to the first unread message; explicitly read the chat or set Telegram’s unread reminder | +| `mentions` | Browse Telegram unread mentions and replies to you | +| `mute_chat`, `unmute_chat` | Set the current chat's Telegram notification mute | +| `compose`, `send`, `newline` | Enter a draft or reply to the explicit conversation selection / send / newline | +| `edit_message`, `discard_edit`, `delete_message` | Edit a delivered message, discard a local edit, or review deletion | +| `copy_text`, `copy_link`, `forward_message`, `save_message`, `saved_messages` | Copy, review forwarding, or open Saved Messages | +| `attach`, `attachments`, `paste_clipboard` | Add files, review attachments, or paste into the draft | +| `remove_attachment`, `attachment_format` | Remove a draft file or switch photo/original format in attachment review | +| `poll`, `toggle_poll_answer`, `retract_vote` | Open a poll, choose answers, or prepare vote retraction | +| `reactions`, `clear_reactions` | Open emoji reactions or remove your choices in the picker | +| `spoilers`, `expand_quote` | Reveal/hide spoilers and expand/collapse quotes in the conversation | +| `preview` | Expand a selected image or sticker; `preview` context controls the expanded view | +| `home`, `end`, `left`, `right`, `backspace`, `delete`, `clear`, `delete_word` | Editors | +| `filter`, `refresh`, `chat_info` | Navigation; title filter, lists refresh, IDs | +| `folder_previous`, `folder_next` | Chat list folders | +| `pin`, `pin_up`, `pin_down`, `archive` | Chat list; native folder pins and archive | +| `reply`, `reply_target`, `open_link`, `next_action`, `previous_action`, `reveal` | Conversation actions | +| `chat_color`, `folder_color` | Appearance pickers | +| `pin`, `pins` | Conversation; confirm pin/unpin, browse all pins | +| `pins_more`, `pins_previous`, `unpin_all` | Pinned-message overlay (`pins` context) | +| `search` | Open local search | +| `search_scope`, `search_query`, `search_more`, `search_previous` | Search overlay | +| `jump ALIAS` | Open the configured stable chat ID; returns to navigation | +| `noop` | Remove a binding from its declared context | + +Use [Keybindings](Keybindings.md) for defaults and [Appearance](Appearance.md) +for the `colors` table and the precedence of in-app overrides. + +The `command` action opens the [command line](Commands.md) in navigation. Its +`command` context can rebind `complete_next`, `complete_previous`, +`history_previous`, `history_next`, `open`, `cancel` and input editing actions. +Ctrl-C cancels command entry by default; it still quits in other contexts. + +## Attachment input + +`attachments = { auto_attach_paths = false }` keeps ordinary paste as text. Set it to `true` to stage recognized local paths, with explicit send still required. Use `:attach` for explicit file selection. Details and review keys: [Attachments](Attachments.md). + +`attachments.clipboard_as_photo = true` starts clipboard images as Telegram photos. Set `false` for original files. Rebind `paste_clipboard` in the `conversation`, `compose` and `attachments` contexts. + +`attachments.terminal_clipboard = true` uses detected OSC 5522 MIME paste before native clipboard reads. Set `false` to disable this terminal mode. See [Terminal](Terminal.md). + +Message editing uses the `edit` context. `edit_message` opens/resumes a saved edit in the conversation; `discard_edit` discards it. `send`, `cancel`, `newline` and the usual input actions work in `edit`. The statusline displays EDIT and the selected message’s edit time. + +The conversation action `delete_message` opens deletion review. It is separate +from the editor action `delete`, which deletes the next character. The review +uses `overlay` bindings: `up`, `down`, `open` and `cancel`. The footer displays +DELETE and the effective scope/confirmation keys. + +`copy_text` and `copy_link` act on the explicit message selection in the +`conversation` context (defaults `y` and `Y`). The `attachments.terminal_clipboard` +switch controls OSC 5522 media paste; it does not disable user-requested OSC 52 +text copying. Native text copying uses the same installed clipboard backend. + +`forward_message` (`f`) starts chat completion, `save_message` (`g S`) prepares a +forward to yourself, and `saved_messages` (`g s`) opens Saved Messages. The +`forward` context uses `send` (Enter) and `cancel` (Esc/Ctrl-C). Its footer says +FORWARD and displays the effective bindings. Repeated key-down events cannot +submit a newly opened forward preview. + +Desktop alert policy uses the Lua `notifications` table; see [Notifications](Notifications.md) +for backends, previews, quiet delivery and grouping. This is separate from the +`notifications` statusline item. + +Poll panel keys use the `poll` context; see [Polls](Polls.md). + +Reaction picker keys use the `reactions` context; see [Reactions](Reactions.md). + + +## Reload while running + +`:config reload` validates the whole file on a background worker, then replaces +bindings, aliases, colors, layout, ghost text, icons, notifications and media-paste +settings together. A missing, unreadable or invalid file leaves the current +configuration and drafts intact; `:status` retains the last error and config path. +Use `return {}` to restore defaults. The command reloads the path chosen at startup; +it does not reread environment variables, credentials or account preferences. + +Latency probe changes reach the current Telegram worker without reconnecting. +Disabling a probe suppresses its old result; one already submitted RPC may finish. +The existing terminal owner changes MIME-paste mode. An in-progress terminal paste +is cancelled cleanly; repeat the paste after reloading. Queued alerts are cancelled +so an old preview policy is not used after changing notification settings. + +`reload_config` is the bindable action. The `?` view includes effective shortcuts +and all commands, and both help and `:status` support j/k and mouse-wheel scrolling. diff --git a/docs/wiki/en/Development.md b/docs/wiki/en/Development.md new file mode 100644 index 0000000..3c8479d --- /dev/null +++ b/docs/wiki/en/Development.md @@ -0,0 +1,164 @@ +# Development and architecture + +[简体中文](../zh-CN/Development.md) · [Guide](Home.md) + +Follow [AGENTS.md](../../../AGENTS.md). Start from a described problem, inspect +upstream behavior and callers, implement, then validate. Local plans and +investigation output belong in ignored `dev-notes/`; user documentation belongs +here in both languages. Use descriptive feature branches and atomic buildable +Conventional Commits. Submit separate PRs, using explicit base branches when +changes depend on one another. + +## Build and validate + +Setup and credentials are in [Get started](Getting-Started.md). Use the pinned +Rust toolchain and committed lockfile: + +```sh +cargo fmt --all -- --check +cargo clippy --locked --all-targets --all-features -- -D warnings +cargo test --locked --all-targets --all-features +cargo build --locked --release +python3 scripts/export-wiki.py --check +``` + +Keep small regressions for observable behavior and integration boundaries. +Existing tests cover reducer state, Unicode input, terminal rendering, cache +restart/replay, concurrent session processes, message-box recovery, folder +membership, Lua routing, local regex search, attachment identity and retention. +Use synthetic fixtures, not account credentials or downloaded private messages. +No TDD requirement or new general test framework is imposed. Do not duplicate +upstream parser/database tests. Formatting and linting apply to the root +workspace, preserving vendored upstream source conventions. + +CI runs the same checks and installs each release with `cargo install --git` +from the checked-out Git revision on six native hosts. It exercises package +selection, repository-local patches, the lockfile, installation tracking and +the installed binary's version before packaging that executable. Build artifacts +are reused in `target/cargo-install`, separate from workspace builds and their +build-script metadata. See [Updates](Updates.md) +for versioning and publication. Automated fixtures do not establish real Telegram +latency parity or verify a desktop file manager's selection behavior. For those, +use an authorized test account and the target operating system; do not send +messages to other people as an implicit test. + +## Ownership and boundaries + +```mermaid +flowchart LR + T[One Yazi terminal reader] --> A[App reducer] + A --> L[Local coordinator] + L --> C[SQLite message cache] + L --> N[Grammers worker] + N --> L + L --> A + A --> R[Ratatui and preview renderer] + L --> S[Bounded local search worker] + N --> M[Bounded media transfers] +``` + +- `src/app.rs` owns selection, focus, drafts and request generations. Feature + reducers live in `src/app/`; rendering stays in `src/ui/`. +- `src/keymap.rs` loads declarative Lua and maps Yazi keys to actions. Contexts, + counts, chords and hints share one binding collection. `config.lua` is user + owned; managed settings and account-specific appearance are separate files. +- `src/actions.rs` and `src/commands.rs` share finite action metadata with help + and completion. Command targets are captured by account/chat/message identity. + Reload prepares a whole configuration before the terminal owner applies it; + a failed parse or terminal-mode change retains the previous settings. +- `src/drafts.rs` keeps ordinary and edit drafts by Telegram user/chat identity, + separately from discardable history. `src/clipboard.rs` and + `src/terminal/clipboard.rs` feed existing attachment staging; native clipboard + work has one owner, and terminal MIME events use the existing Yazi reader. +- `src/telegram/local.rs` opens the account cache before networking, serves local + requests, bounds commands waiting for authentication, and persists changes. + `src/cache.rs` owns schema migration, history coverage, revisions and retention. +- `src/telegram/mod.rs` owns the client, update stream and task lifetimes. + `requests.rs` bounds ordinary RPC work; transfers have a separate limit. + Slow dialog/history/peer RPCs do not occupy the update-consumption path. +- `src/search.rs` scans local message text with the maintained regex engine. + It has one active blocking scan and one replaceable pending request, generation + checks, cancellation and cursor pagination. It never fetches remote history. +- `src/telegram/media_cache.rs` manages completed media and `tempfile` partials; + `src/app/attachments.rs` adapts selected attachments to download/reveal actions. + `src/terminal.rs` owns modes and cleanup, while `src/media.rs` coordinates + graphics with the same output stream. + +## Synchronization contracts + +Cached display must not wait for authentication, dialogs or a full history scan. +The live update receive stays pinned while command and completion events are +handled; cancelling and recreating it can discard in-progress recovery. The +currently focused group uses Grammers' update engine with server-directed +channel-difference timeouts. General push processing and gap recovery continue +when active polling stops. + +A durable cursor belongs to a complete covered update batch. Commit messages, +edits, tombstones and that cursor together; advancing a session cursor alone is +not evidence that message content is cached. Restore the application cursor on +restart. Too-long differences invalidate affected history before replacement +content. Replayed incoming messages must not double-count unread state. + +Chat previews use the dialog's actual top message ID, not the last message in +a currently loaded historical window. In-flight snapshots cannot restore deleted +messages or overwrite newer edits/read state. Exact cached page boundaries +prevent sparse reply/search context from masquerading as complete history. +Account switching cancels old work and rejects stale request generations. + +Only one process owns an account's message cache. File transfers and cleanup +retain the same lock lifetime. The underlying session format still supports +serialized SQLite writes, but two complete clients must not advance one message +cache independently. Keep the current rollback journal until upgrading the +bundled SQLite engine and reviewing the upstream WAL-reset fix. + +## Reuse decisions + +| Upstream | Reused or studied | Local responsibility | +| --- | --- | --- | +| Yazi `9203fd2604f867ab5ec18f24203b918975c4c00a` | Terminal lifecycle, parser, capabilities, TTY, public key normalization; pinned crates | Small adapters and visibility patch; no second input reader | +| Codex `78245b47af2a7aafcabe025828ceecca69db4df1` | Context precedence, chord cancellation, effective hints and composer UX | Design reference; no Codex TUI fork or copied plugin runtime | +| Telegram Desktop `4d4da471fbee771c10e173a83c003ba1728989f1` | Active-group recovery, native filter semantics and local-first storage | Behavioral reference through Telegram API/Grammers; no Desktop storage-format clone | +| Grammers 0.10.0 | MTProto transport and update ordering | Narrow batch/cursor/active-channel patches; retained upstream licenses | +| libSQL / regex / mlua / tempfile / ratatui-image | Storage, regex, bounded Lua evaluation, partial files, inline rendering | Application schemas, limits, commands and UX | +| arboard / notify-rust | Native clipboard and desktop notification adapters | Draft staging, account/request identity, alert eligibility and privacy | + +Source references: [Yazi](https://github.com/sxyazi/yazi/tree/9203fd2604f867ab5ec18f24203b918975c4c00a), +[Codex TUI](https://github.com/openai/codex/tree/78245b47af2a7aafcabe025828ceecca69db4df1/codex-rs/tui), +[Telegram Desktop](https://github.com/telegramdesktop/tdesktop/tree/4d4da471fbee771c10e173a83c003ba1728989f1), +[Telegram updates](https://core.telegram.org/api/updates), +[dialog filters](https://core.telegram.org/api/folders). + +TDLib supplies a maintained client/database stack, but adopting it here would +replace authentication, message mapping, media and the existing Grammers +integration. The present change keeps Grammers and confines required adaptations +to documented SDK boundaries. Reconsider that choice if upstream maintenance or +update correctness can no longer be kept narrow. + +Update all Yazi Git revisions together. Prefer released dependencies or immutable +Git revisions; never depend on sibling local checkouts. For copied code retain +licenses and document the repository, revision, source path and adaptations in +[vendor/README.md](../../../vendor/README.md). Remove superseded infrastructure +instead of maintaining two paths indefinitely. + +## UX maintenance + +Hints come from effective bindings. Empty editors use ghost text, and overlays +own input and provide an escape route. Preserve drafts and reading position +across view changes. State scope explicitly: loaded history is not full history, +cached search is not a server query, and a loading badge is not an empty result. +Color supplements focus markers rather than carrying meaning alone. + +Keep English and Simplified Chinese pages together. The small +[Wiki export workflow](../README.md) validates page parity and internal links, +then maps this directory to GitHub Wiki filenames without a documentation framework. + +The statusline follows Yazi’s ordered status components and Codex’s finite item +list, with no copied code. `src/statusline.rs` owns declarative configuration and +ephemeral metrics; `src/ui/statusline.rs` fits them to terminal cells. Optional +Ping observations use the existing Grammers client in an owned task that aborts +on shutdown/account switch and never consumes the update stream. + +`src/ui/transcript.rs` owns message presentation; `src/ui/chats.rs` uses Ratatui +Table for semantic columns. The transcript retains the existing text wrapping, +action dispatch, media slots and viewport ownership; small render regressions +cover hit precedence and message visibility when width changes. diff --git a/docs/wiki/en/Folders.md b/docs/wiki/en/Folders.md new file mode 100644 index 0000000..f89f3cc --- /dev/null +++ b/docs/wiki/en/Folders.md @@ -0,0 +1,49 @@ +# Telegram folders + +[简体中文](../zh-CN/Folders.md) · [Guide](Home.md) + +Termgram reads the folders already configured in Telegram, including shared +folders. Their order, pinned chats and inclusion/exclusion rules are synchronized +and cached per account. Use Telegram Desktop or another official client to create +or edit them; changes appear in Termgram automatically. + +In the chat list, `[` and `]` select the previous/next folder. The list border shows +the folder name and position. These keys are configurable as `folder_previous` +and `folder_next` in the `chats` context. `/` filters the selected folder. +Switching folders leaves the open conversation, reading position and draft intact. +A configured `jump alias` opens its destination from All chats or Archive, so it +also works when the destination is outside the selected folder. See [configuration](Configuration.md). + +Explicit exclusions take priority, followed by explicit inclusions and pins. +Dynamic rules support contacts, non-contacts, bots, groups, unread, muted and +archived conversations. Bots are a distinct category. Muted chats with unread +mentions remain eligible unless archived, matching Telegram Desktop. Notification +defaults are inherited when the chat has no explicit mute override. Other clients' +read notifications update unread-folder membership. `Ctrl-R` refreshes folders +and conversations if a retry is needed. + +Termgram currently exposes people and groups. Broadcast channels remain outside +its messaging scope, including when they appear in an upstream folder. + +## Archive + +Archive is a separate folder (ID `1`); All chats (ID `0`) excludes archived chats. +Select a chat and press `e` (`archive`) to archive it, or restore it from Archive. +Custom Telegram folders still follow their own inclusion and `exclude_archived` +rules. Changes from another client update membership automatically. + +## Pinned chats + +In the chat list, `p` (`pin`) toggles the selected chat's server-side pin. +`Ctrl-K` / `Ctrl-J` (`pin_up` / `pin_down`) move a pinned chat up/down. Main, +Archive, and each custom folder have independent server orders. A `^` marker +identifies pins; newer messages do not move other chats ahead of them. Selection +stays on the same chat after reordering, and cached pin order survives restart. + +Unpinning in a custom folder keeps that chat explicitly included, as in Telegram +Desktop. Folder updates preserve server titles, colors, shared-folder settings +and filter rules. Pin/Archive mutations require a connection and wait for server +confirmation; server limits and permission failures are reported without a local +success indicator. Local appearance settings do not modify Telegram folders. + +Implementation reference: [Telegram dialog filters](https://core.telegram.org/api/folders). diff --git a/docs/wiki/en/Getting-Started.md b/docs/wiki/en/Getting-Started.md new file mode 100644 index 0000000..234e3b9 --- /dev/null +++ b/docs/wiki/en/Getting-Started.md @@ -0,0 +1,170 @@ +# Get started + +[简体中文](../zh-CN/Getting-Started.md) · [Guide](Home.md) + +## Install a release + +Native releases cover Linux x86_64/ARM64, macOS Intel/Apple silicon, and Windows +x64/ARM64. Linux/macOS need Bash, `curl`, `tar`, and `sha256sum` or `shasum`. +Windows needs PowerShell 5.1 or newer. Use a terminal with at least 40 × 10 cells; +80 × 24 or larger is more comfortable. + +```sh +curl --proto '=https' --tlsv1.2 -sSfL \ + https://github.com/iebb/termgram/releases/latest/download/install.sh | bash +``` + +The default destination is `~/.local/bin`. To inspect the installer before +running it, save the same URL to a file, read it, then run `bash install.sh`. +For a prerelease or another destination: + +```sh +curl --proto '=https' --tlsv1.2 -sSfL \ + https://github.com/iebb/termgram/releases/latest/download/install.sh \ + | CHANNEL=prerelease INSTALL_DIR="$HOME/bin" bash +``` + +Windows PowerShell: + +```powershell +$installer = Invoke-RestMethod 'https://github.com/iebb/termgram/releases/latest/download/install.ps1' +& ([scriptblock]::Create([string]$installer)) +# Optional: +& ([scriptblock]::Create([string]$installer)) -Channel prerelease -BinDir "$HOME\bin" +``` + +The Windows default is `%LOCALAPPDATA%\Programs\Termgram\bin`. Inspect `$installer` +first if desired. Installers verify the release's `SHA256SUMS`, select the native +architecture, and print the destination to add to PATH when needed. They do not +request elevation or edit PATH. Release selection and updates are described in +[Updates](Updates.md). + +## Sign in and send a message + +1. Run `tg`. Official release binaries include the project's Telegram application + credentials. A source build needs your own credentials as described below. +2. Enter your phone in international format, then the login code and optional 2FA + password. Password entry is masked. Tab on the phone screen starts QR login; + scan it in Telegram under **Settings → Devices → Link Desktop Device**. +3. Move through chats with `j/k` or arrows, then Enter to open one. +4. Press `i`, type your message, and Enter to send. Ctrl-J adds a newline. + Esc leaves the editor while keeping a local draft for this chat and account, + including across normal restarts. See [drafts and replies](UX.md#compose-and-reply). +5. `G` returns to the newest messages; `?` opens help. `q` quits from navigation. + +QR codes rotate automatically. Tab switches between compact block rendering and +larger full-cell rendering. Esc returns to phone sign-in. F2 switches to the next +existing account; F3 adds an account, including from login/error screens. +All listed keys are defaults and can be [configured](Configuration.md). + +## Install from source with Cargo + +The same Cargo command supports Linux x86_64/ARM64, macOS Intel/Apple silicon, +and Windows x64/ARM64. It builds the selected Git source with the committed +lockfile and installs `tg` (`tg.exe` on Windows) using Cargo's release profile. + +### Set up the build tools once + +Install [Rust through rustup](https://rustup.rs/) and Git, plus the native C +compiler/linker. Lua and SQLite are compiled with the application; separate Lua +or SQLite packages are not required. + +| Platform | Native build tools | +| --- | --- | +| Linux | C compiler, linker, make and Git; on Debian/Ubuntu: `sudo apt-get install build-essential git` | +| macOS | Xcode Command Line Tools: `xcode-select --install` | +| Windows | Visual Studio C++ build tools and Windows SDK, with the MSVC tools for your CPU; ARM64 needs the ARM64 component. Follow the [rustup MSVC setup](https://rust-lang.github.io/rustup/installation/windows-msvc.html). | + +Install the project's pinned compiler without changing your default toolchain: + +```sh +rustup toolchain install 1.98.0 --profile minimal +``` + +### Install or update + +This single command works in Bash, Zsh and PowerShell: + +```sh +cargo +1.98.0 install --locked --git https://github.com/iebb/termgram --bin tg termgram +``` + +It installs the repository's default branch. Repeat it to update to the latest +source on that branch. `+1.98.0` selects the compiler explicitly, including when +you run the command outside a checkout. A fetched repository's toolchain file +does not select the Cargo process you already started. + +Cargo normally installs to `~/.cargo/bin/tg` on Linux/macOS and +`%USERPROFILE%\.cargo\bin\tg.exe` on Windows. Ensure that directory is on PATH. +A custom `CARGO_HOME` or Cargo install-root setting can change the destination; +`--root DIR` installs into `DIR/bin`, so pass the parent of the desired bin folder. +If an older `tg` takes precedence, inspect `type -a tg` in Bash/Zsh or +`Get-Command tg -All` in PowerShell and remove the obsolete executable from PATH. + +To select another branch, add `--branch BRANCH`; for a specific commit, use +`--rev COMMIT` instead. Replace these placeholders with the desired Git reference. +PR features require that PR's source branch until merged. The Git URL is required +for this installation route; do not substitute `cargo install termgram`, which +would look for a crates.io package. + +For the exact version described by the published Wiki, open its **Documentation +source** footer and use that commit with `--rev COMMIT`. This avoids installing +the default branch while following guides for changes that are still in review. + +`cargo uninstall termgram` removes the Cargo-managed executable, leaving your +Termgram configuration and account data. If you installed with `--root`, pass the +same root when updating or uninstalling. `--force` rebuilds the same revision, +for example after changing embedded application credentials. `--target-dir DIR` +can keep compilation artifacts for later source updates. + +See [Cargo's installation reference](https://doc.rust-lang.org/cargo/commands/cargo-install.html) +for installation tracking and options, and [Updates](Updates.md) for the difference +between a Cargo source update and `tg update`. + +### Telegram application credentials + +Git source installations need your own application API ID and hash from +[my.telegram.org/apps](https://my.telegram.org/apps). These identify the client; +you still sign in with your Telegram account after starting `tg`. Source installs +do not receive the project's GitHub Actions release credentials. + +Set these runtime variables before launching. In Bash/Zsh: + +```sh +export TELEGRAM_API_ID=123456 +export TELEGRAM_API_HASH=your_api_hash +tg +``` + +In PowerShell: + +```powershell +$env:TELEGRAM_API_ID = '123456' +$env:TELEGRAM_API_HASH = 'your_api_hash' +tg +``` + +Replace the example values. Runtime variables override embedded defaults. +Alternatively, the app reads `.env` from its working directory or a parent. +For a personal binary that works without runtime variables, set +`TERMGRAM_EMBEDDED_API_ID` and `TERMGRAM_EMBEDDED_API_HASH` in the build environment +before running Cargo. Those application credentials become part of the executable; +your account session is never embedded. + +### Install a local checkout + +For development, clone the repository and optionally copy `.env.example` to +`.env` for runtime credentials, then install the checkout: + +```sh +git clone https://github.com/iebb/termgram.git +cd termgram +cargo install --locked --path . --bin tg +``` + +This uses the checkout's pinned toolchain. `cargo run --locked --release` runs it +without installing. Do not commit `.env` or session databases. + +Use [configuration](Configuration.md) for paths and overrides, +[terminal integration](Terminal.md) for graphics/key issues, and +[synchronization](Synchronization.md) for offline/cache behavior. diff --git a/docs/wiki/en/Home.md b/docs/wiki/en/Home.md new file mode 100644 index 0000000..e335fe1 --- /dev/null +++ b/docs/wiki/en/Home.md @@ -0,0 +1,43 @@ +# Termgram guide + +[简体中文](../zh-CN/Home.md) · [Language selection](../Home.md) + +Termgram uses Telegram's MTProto user API to access your direct messages and +groups. It supports up to eight account slots, with one account connected at a +time. Cached messages are available while it reconnects. + +| I want to… | Read | +| --- | --- | +| Install, sign in, or build from source | [Get started](Getting-Started.md) | +| Learn the keys or migrate older bindings | [Keybindings](Keybindings.md) | +| Browse commands and complete chat or folder names | [Commands](Commands.md) | +| Remap keys, jump to a saved chat, change ghost text | [Lua configuration](Configuration.md) | +| Read, reply, send files, and switch accounts | [Daily workflows](UX.md) | +| Open a username, join an invitation, or inspect chat permissions | [Chat discovery and details](Chats.md) | +| Navigate Telegram folders | [Folders](Folders.md) | +| Pin chats or messages, browse pins | [Pins](Pins.md) | +| Read polls and quizzes, vote or retract a vote | [Polls](Polls.md) | +| Add, remove and read message reactions | [Reactions](Reactions.md) | +| Search local or cloud history with filters | [Message search](Search.md) | +| Desktop alerts, chat mute and unread mentions | [Notification settings](Notifications.md) | +| Color a chat or folder | [Appearance](Appearance.md) | +| Preview or reveal a file | [Attachments](Attachments.md) | +| Understand offline behavior or clear the cache | [Cache and synchronization](Synchronization.md) | +| Troubleshoot graphics, keys, or tmux | [Terminal integration](Terminal.md) | +| Update the app or understand releases | [Updates](Updates.md) | +| Contribute and maintain the implementation | [Development](Development.md) | + +These pages describe their source revision; check the release you installed +with `tg --version`. The app's `?` help reflects your actual Lua bindings. +Documentation is available in English and Simplified Chinese; the interface +currently uses English text, with configurable composer ghost text. + +Broadcast channels, secret chats, calls, stories, sticker/GIF pickers, +server-wide message search, contact management, creating groups and group +administration are outside this revision’s scope. Forum-topic navigation, cloud +drafts, simultaneous account connections, durable send queues, albums, scheduled +or silent sending and typing indicators are also not implemented. +Message editing, deletion, copying, forwarding and Saved Messages are supported; +see [daily workflows](UX.md). +Folders are created and edited in an official client. Supported messages, links +and bot buttons are described in [daily workflows](UX.md). diff --git a/docs/wiki/en/Keybindings.md b/docs/wiki/en/Keybindings.md new file mode 100644 index 0000000..581a583 --- /dev/null +++ b/docs/wiki/en/Keybindings.md @@ -0,0 +1,112 @@ +# Keybindings + +[简体中文](../zh-CN/Keybindings.md) · [Guide](Home.md) + +These are defaults. `?` displays the current Lua bindings. Uppercase keys mean +Shift plus that letter. `g g`, `g i`, `g n`, and `g p` are successive presses; a pending +chord times out after one second and Esc cancels it. Keys apply to the focused +pane or overlay, so ordinary letters in an editor remain text. + +| Context | Keys | Action | +| --- | --- | --- | +| Chats / Conversation | g m | Browse unread mentions and replies to you | +| Search / Mentions | Ctrl-R | Refresh the current search or unread mentions | +| Global | Ctrl-C / Ctrl-L | Quit / redraw | +| Global | F2 / F3 | Next account / add account | +| Main view | F4 | Toggle sidebar; retain the draft | +| Login | Enter / Esc | Submit / restart phone sign-in | +| Login | Tab or Shift-Tab | Start QR login or change QR display | +| Chats | j/k or Down/Up | Select next/previous chat | +| Chats | Enter or Right | Open selected chat | +| Chats | i | Compose in the current chat, or resume the last chat for this account | +| Chats | G / g g | Last / first chat in the filtered list | +| Chats | PageDown/PageUp | Move ten chats | +| Chats | / | Filter titles in this folder | +| Chats | Ctrl-F | Open local regex search | +| Chats | [ / ] | Previous / next Telegram folder | +| Chats | c / C | Chat / folder color picker | +| Chats | p | Pin/unpin in the current folder | +| Chats | Ctrl-K / Ctrl-J | Move a pinned chat up/down | +| Chats | e | Archive / restore selected chat | +| Conversation | j/k or ]/[ | Select next/previous message | +| Conversation | 20k | Move up 20 messages; fetch older pages if needed | +| Conversation | Up/Down | Scroll rendered rows | +| Conversation | PageUp/PageDown | Scroll ten rendered rows | +| Conversation | G or End | Return to latest messages and follow incoming messages | +| Conversation | g u | Resume from the first unread message | +| Chats / Conversation | g r / g U | Mark the entire chat read / mark unread as a reminder | +| Conversation | g g or Home | Oldest message in the loaded window | +| Conversation | i | Reply to an explicitly selected message; otherwise compose | +| Conversation | Enter | Activate the selection; compose if nothing is selected | +| Conversation | R / r | Reply to selection/latest / open its reply target | +| Conversation | o | Expand selected image or sticker | +| Conversation | g n / g p | Next / previous actionable item | +| Conversation | l | Open the selected or first supported link | +| Conversation | O | Reveal attachment in the system file manager | +| Conversation | / / c | Local regex search / chat color | +| Conversation | p / P | Pin/unpin selected message / browse pins | +| Conversation | v | Open the selected [poll or quiz](Polls.md) | +| Poll | Space/Enter, then Ctrl-S | Choose answers, then submit | +| Poll | u / Ctrl-R / Esc | Prepare vote retraction / refresh / close | +| Conversation | g e | Open the selected message's [reactions](Reactions.md) | +| Reactions | Enter/Space / u / Ctrl-R / Esc | Toggle an emoji / remove your emoji reactions / refresh / close | +| Chat details (`:info`) | j/k or mouse wheel / Ctrl-R / Esc | Scroll / refresh / close | +| Invite preview (`:join`) | Up/Down, then Enter | Choose and confirm; initially selects Cancel | +| Help / Status | j/k or mouse wheel / Esc | Scroll wrapped content / close | +| Pinned messages | Enter / Esc | Open selected message / close | +| Pinned messages | Ctrl-N / Ctrl-P | Next / previous page | +| Pinned messages | p / U | Confirm unpin / unpin all | +| Pinned messages | Ctrl-R | Refresh pins | +| Navigation | Tab or Shift-Tab | Switch between chat list and conversation | +| Conversation | Esc | Clear message selection first; then return to the chat list | +| Conversation | Left | Return to the chat list | +| Image preview | Esc or q / i / O | Close / reply / reveal original file | +| Navigation | g i / Ctrl-R | Show chat and folder IDs / refresh lists | +| Navigation | ? / s / a / q | Help / settings / accounts / quit | +| Navigation | : | Open the [command line](Commands.md) | +| Commands | Tab/Shift-Tab or Ctrl-N/Ctrl-P | Complete next/previous candidate | +| Commands | Up/Down | Prefix-matching command history | +| Commands | Enter / Esc or Ctrl-C | Execute / cancel | +| Composer | Enter | Send | +| Composer | Shift-Enter or Ctrl-J | Newline | +| Composer | Esc | Cancel reply first; then leave with draft kept | +| Editors | Left/Right, Home/End, Ctrl-A/Ctrl-E | Move cursor | +| Editors | Backspace/Delete, Ctrl-W, Ctrl-U | Delete character / previous word / clear | +| Chat filter | Enter / Esc | Open match / clear filter and leave | +| Overlays | Up/Down or k/j | Select or scroll | +| Overlays | Enter or Space | Apply setting, color, or account selection | +| Overlays | Esc or ? | Close | +| Search | Enter / Esc | Search or open result / close | +| Search | Tab / Ctrl-F | Change scope / edit query | +| Search | Ctrl-N / Ctrl-P | Next / previous result page | +| Search | Up/Down, PageUp/PageDown | Select result / move ten results | +| Conversation | e | Edit selected message or resume local edit | +| Conversation | d | Review deletion for the selected delivered message | +| Conversation | y / Y | Copy message text or caption / copy Telegram message link | +| Conversation | f / g S | Choose a forward destination / prepare a forward to Saved Messages | +| Navigation | g s | Open Saved Messages for the active account | +| Forward preview | Enter / Esc or Ctrl-C | Forward / cancel; a submitted request continues in the background | +| Deletion prompt | Up/Down or k/j, then Enter | Choose scope and confirm; initially selects Cancel | +| Deletion prompt | Esc | Close; an already submitted request continues | +| Message editor | Enter / Esc or Ctrl-C / Ctrl-D | Save / keep and close / discard edit | +| Message editor | Shift-Enter or Ctrl-J | Newline | + +A number prefix such as `20k` works in the two navigation panes. For a single +key that jumps N messages, use `count` in [Lua configuration](Configuration.md). +`gg` is scoped to loaded history; it does not download every message back to the +start of a group. `G` reloads the latest page when reading older history. + +Changed defaults from earlier Termgram versions: conversation `/` now opens +local search; type bot `/commands` after `i`. Uppercase `O` now reveals files; +action cycling moves to `g n` / `g p`, and `o` expands an image preview. +`i` also works from the chat list and replies when a message is explicitly selected. Use the account picker with arrows and Enter; +digit keys in navigation are counts, and the old account-number hints are gone. +There is no separate hardcoded keyboard fallback after Lua resolution. + +Click a reply quote to jump to its original. For a selected reply, Enter opens +that target by default; use `g n` / `g p` for its other actions. Media clicks select +the attachment, so Enter then activates that media action. + +Composer Ctrl-O opens attachment review. Its a/p/d/o/O/i keys add, choose photo or file, remove, preview, reveal and edit the caption. See [Attachments](Attachments.md). + +Ctrl-V / Ctrl-Alt-V paste native clipboard files, images or text in the conversation, composer and attachment review. `:paste` is the command equivalent; no paste sends automatically. diff --git a/docs/wiki/en/Notifications.md b/docs/wiki/en/Notifications.md new file mode 100644 index 0000000..e8f014b --- /dev/null +++ b/docs/wiki/en/Notifications.md @@ -0,0 +1,128 @@ +# Notifications and mute settings + +[简体中文](../zh-CN/Notifications.md) · [Guide](Home.md) + +Use `:mute`, `:mute 1h`, `:mute 8h`, `:mute 2d` or `:mute forever` on a chat. +The default is **forever**. `:unmute` explicitly enables this chat's notifications, +including when the account's default for groups or users is muted. In the chat +list the command captures the selected chat; in a conversation it captures that +conversation. The command header identifies the target, and incoming messages +that reorder the list cannot change it. + +These commands update [Telegram's notification settings](https://core.telegram.org/method/account.updateNotifySettings) +and therefore synchronize with official clients. They require a connection. +Termgram preserves the existing preview, silent-post and desktop sound preferences +while changing mute time. It also preserves the peer's existing story settings. +It waits for Telegram's actual state instead of changing the UI optimistically. +If another notification update arrives while a request runs, a fresh dialog +refresh settles the state; a late snapshot cannot replace that update. A failed +request leaves the last confirmed state visible and allows retry. Muting does +not mark messages read or change drafts. + +Muted chats show `[m]` in the sidebar, or the bell-slash glyph when Nerd Font is +enabled. The configurable `notifications` statusline item displays **Muted forever** +or a local date/time deadline for the focused chat. It disappears when unmuted or +when a timed mute expires. The marker and deadline are independent of color. +Folders that exclude muted chats follow their existing Telegram membership rules. + +The Lua actions `mute_chat` and `unmute_chat` can be bound in `chats` or +`conversation`; `mute_chat` means forever. They have no default keyboard binding: + +```lua +keymap = { + { context = "chats", on = { "m" }, run = "mute_chat" }, + { context = "chats", on = { "M" }, run = "unmute_chat" }, +}, +``` + +## Unread mentions and replies to you + +Press `g m` from a chat list or conversation, or use `:mentions`. The **Unread +mentions** panel uses Telegram's [unread-mention list](https://core.telegram.org/method/messages.getUnreadMentions), +including replies to your messages. A colored `@` in the sidebar identifies chats +with unread mentions; selecting a mentioned message also shows its state in the +bottom statusline. These indicators remain recognizable without color or a Nerd Font. + +Use Up/Down to select, Enter to load and select the original with surrounding +history, Ctrl-N/Ctrl-P to page, Ctrl-R to refresh, and Esc to close. `/` returns +to the retained results; `:search` switches to local regex. The panel captures +the chat you started from and preserves drafts. Its total is Telegram's count +at search time; refresh after changes on another device. Read or deleted rows +are removed as live updates arrive. Search previews do not mark mentions read. +There is no query field in this panel. The `mentions` action is configurable in +`chats` and `conversation`; panel controls use the `search` context. + +Viewing a text mention in a focused conversation acknowledges its content only +after the message end was drawn. Loading history, searching, opening an overlay +or leaving the terminal unfocused does not acknowledge it. Content receipts and +ordinary inbox read boundaries are separate. Voice, round-video and disappearing +media mentions require actual consumption; displaying their message does not +mark them as played. Until Termgram's playback flow supports those receipts, +consume them in another Telegram client. `:read` marks history read but does not +pretend to play this media. + +Acknowledgements received from other clients update the timeline, retained +results and local cache. Slow history/search snapshots cannot restore an older +unread flag. Mention navigation requires a connection; Ctrl-R retries after +reconnecting. + +## Desktop alerts + +Desktop alerts are enabled by default for new, unseen incoming messages. Messages +visible in the focused conversation remain silent, including the interval before +the read acknowledgement completes. Alerts from the same chat combine for 800 ms; +the queue has bounded chat/message counts and stays off the input/rendering path. + +Set these fields inside the table returned by `config.lua`: + +```lua +notifications = { + enabled = true, + backend = "auto", + when = "unseen", + previews = true, + sound = true, + group_delay_ms = 800, +}, +``` + +- `enabled = false` disables local desktop delivery; Telegram's mute settings + continue to synchronize. +- `when = "unseen"` also allows alerts from other chats while Termgram is focused. + `"unfocused"` requires the terminal to be unfocused. Both honor read boundaries. +- `previews = false` hides message text and sender excerpts. When enabled, previews + still require Telegram's effective preview setting; protected messages use a + generic body. The alert title includes the account and chat. +- `sound = false` requests quiet native alerts. Telegram silent messages and a + chat's sound-none setting also stay quiet. Custom Telegram ringtones are not + downloaded; native alerts use the platform's default sound when allowed. +- `group_delay_ms` is 100–5000 ms. It is a fixed window from the first message, + so a busy chat does not postpone delivery forever. + +`auto` uses the native desktop on local sessions. Over SSH it uses OSC 9 on +terminals detected as Ghostty, iTerm2, Kitty, Warp or WezTerm, with BEL otherwise. +Local native errors can fall back through the terminal. `native`, `osc9` and `bell` +explicitly choose one backend. Terminal OSC 9 and BEL cannot request a quiet alert, +so they omit messages whose sound is disabled. Terminal notifications require the +terminal's own notification permissions; tmux forwarding uses the existing +[passthrough settings](Terminal.md). Native notifications use notify-rust's +macOS, Windows and Linux desktop integrations; Linux needs a desktop session bus. +No native callback waits or second terminal reader are added. + +Termgram reads effective Telegram notification preferences asynchronously. A muted +chat stays quiet except for mentions/replies whose sender is unmuted, following +Desktop's rule. The chat's preview and sound settings still apply. Unknown or +failed settings do not produce an alert; requests are bounded and expire. Changes +from another client invalidate the settings snapshot and queued deliveries. + +Startup and reconnection begin a fresh notification window: only messages dated +after that connection boundary and at most 30 seconds old can alert. Old cached +or recovered messages, duplicates, outgoing messages and Saved Messages stay +silent. This intentionally avoids a burst for messages missed while disconnected; +those messages remain visible in history and unread counts. Notification freshness +uses the system clock and Telegram message timestamps. + +Reading, deleting, muting, switching accounts or quitting cancels applicable +pending deliveries. Coalescing occurs before desktop delivery; the operating +system owns notifications already delivered. Notification clicks/replies and +replacement of already delivered cards are not provided by this interface. diff --git a/docs/wiki/en/Pins.md b/docs/wiki/en/Pins.md new file mode 100644 index 0000000..6111ba1 --- /dev/null +++ b/docs/wiki/en/Pins.md @@ -0,0 +1,49 @@ +# Pinned chats and messages + +[简体中文](../zh-CN/Pins.md) · [Guide](Home.md) + +## Chat lists + +Select a chat and press `p` to pin/unpin it in the current folder. `Ctrl-K` and +`Ctrl-J` move it within that folder's pinned order. Main, Archive, and custom +folders have independent Telegram orders. Pins remain above ordinary chats +when messages arrive, and restart uses the last cached order. See [Folders](Folders.md) +for archive behavior and custom-folder inclusion rules. + +## Conversation pins + +Select a sent message with `j/k`, then press `p`. A confirmation overlay offers +the applicable Telegram options. In ordinary private chats, the default is +**Pin only for me**; the second option pins for both participants. In groups, +a new pin defaults to notifying members, with a silent option. Pinning a message +older than the known top pin is silent, following Desktop's older-message prompt. +Saved Messages does not need a participant option. Arrow keys select; Enter +confirms; Esc dismisses. Changes appear after Telegram accepts them. Missing +permissions or server errors remain visible and do not report success. + +Press `p` on a pinned message to confirm unpinning. This uses Telegram's normal +unpin operation. The confirmation identifies the message before any change. + +The conversation shows a compact pinned-message banner when a pin is available. +Press `P` to browse all pins, newest message first. `j/k` select, `Ctrl-N` / `Ctrl-P` +page, `Enter` opens the message and older context, and `Esc` returns. `p` unpins +the selection; `U` confirms unpinning all messages. `Ctrl-R` retries a refresh. +All bindings are configurable; the list uses the `pins` Lua context, and its +confirmation uses `overlay`. + +Opening a pin keeps the draft and detaches the reading position without marking +the entire conversation read. `G` returns to the latest history. Other clients' +pin/unpin and deletion updates refresh the banner/list. Pins and message previews +are fetched in bounded background requests, so history loading and live updates +continue while the list synchronizes. + +If Telegram requires a fresh synchronization, the open pin list reloads and +outdated message confirmations close. Drafts are kept. + +Cached pins can be browsed before connecting. The list labels cached coverage; +it may be incomplete because message retention is bounded. Server pages replace +that view once connected. Mutating pins requires a connection. + +Protocol references: [Telegram message pins](https://core.telegram.org/api/pin), +[pin options](https://core.telegram.org/method/messages.updatePinnedMessage). +Desktop behavior reference: [pin message dialogs](https://github.com/telegramdesktop/tdesktop/blob/4d4da471fbee771c10e173a83c003ba1728989f1/Telegram/SourceFiles/boxes/pin_messages_box.cpp). diff --git a/docs/wiki/en/Polls.md b/docs/wiki/en/Polls.md new file mode 100644 index 0000000..e7c5ae5 --- /dev/null +++ b/docs/wiki/en/Polls.md @@ -0,0 +1,54 @@ +# Polls and quizzes + +[简体中文](../zh-CN/Polls.md) · [Guide](Home.md) + +Poll questions and answers appear inline with message text. Select a poll and +press `v` or run `:poll` to open its voting panel. Enter also opens the panel when +the selected message action is the poll. The panel first refreshes the message; +cached polls remain readable while offline. + +| In the poll panel | Action | +| --- | --- | +| j/k or Down/Up | Move between answers | +| Click an answer, Space or Enter | Toggle the selected answer locally | +| Ctrl-S | Submit the selected answers | +| u, then Ctrl-S | Retract an existing vote, when Telegram permits it | +| Ctrl-R | Refresh the poll and restore the server's current selection | +| PageUp/PageDown, Home/End, mouse wheel | Scroll long questions, answers or errors | +| s | Reveal or hide spoilers | +| Esc or q | Close the panel | + +Choosing an answer does not send a vote. A single-choice poll keeps one selected +answer; multiple-choice polls allow several. Empty selection retracts an existing +vote only after Ctrl-S. Quiz answers and polls that prohibit revoting cannot be +changed. Closing the panel discards an unsent selection; a submitted request +continues in the background. If the request fails or its result is uncertain, +refresh before retrying. Refresh replaces the local selection with the server's +confirmed choices. + +The panel identifies public votes versus anonymous votes before submission. +Telegram checks membership, country restrictions and other eligibility rules. +Changed options invalidate an open selection. The request checks the latest +message again and sends Telegram's stable answer IDs, so answer reordering cannot +redirect a vote to a different answer. + +Vote counts and percentages appear after voting or closing, subject to Telegram's +“hide results until close” setting. Quiz explanations appear when results are +available. Spoilers remain masked; the first selection on a concealed poll +reveals its text without choosing an answer. Poll state also appears in the +bottom bar's `message` component. Contextual keys use your effective Lua bindings. + +Live updates refresh cached copies of the same poll. Visible polls additionally +refresh in the background, normally every 30 seconds, with at most two requests +at once. Leaving the view or losing terminal focus stops new refreshes. Completed +closed polls stop periodic refreshes. Cached vote counts may be older while +offline; opening the voting panel always requests fresh information. + +This revision reads ordinary polls and quizzes, submits votes and retracts votes. +Poll creation, voter lists, adding answers to an open-answer poll and media inside +poll questions or answers are not implemented. Media polls show an explicit +read-only notice to review and vote in an official client. + +Rebind entry with `run = "poll"` in `conversation`. The `poll` context supports +`up`, `down`, `toggle_poll_answer`, `send`, `retract_vote`, `refresh`, `spoilers`, +`page_up`, `page_down`, `home`, `end`, and `cancel`. diff --git a/docs/wiki/en/Reactions.md b/docs/wiki/en/Reactions.md new file mode 100644 index 0000000..f3b046e --- /dev/null +++ b/docs/wiki/en/Reactions.md @@ -0,0 +1,49 @@ +# Message reactions + +[简体中文](../zh-CN/Reactions.md) · [Guide](Home.md) + +Reactions appear in a compact row under their message, with emoji and counts. +Your choices have a `●` marker and an accent color. The bottom bar includes the +reaction total and the keys for the selected action. + +Select a delivered message, then press `g e` or run `:react`. Existing reaction +rows are also selectable with the mouse; Enter opens their picker. The picker +loads the emoji that Telegram permits in this chat. + +| In the reaction picker | Action | +| --- | --- | +| j/k or Down/Up | Select a reaction | +| PageUp/PageDown, Home/End | Move through the choices | +| Mouse click / wheel | Select a row / move selection | +| Enter or Space | Add the selected emoji, or remove it if already yours | +| u | Remove all your ordinary and custom emoji reactions from this message | +| Ctrl-R | Refresh counts, your choices and chat permissions | +| Esc or q | Close | + +A mouse click selects without sending. Enter and Space submit the change. +Only one change can be pending at once. Closing a pending request leaves it +running; failures still appear in the bottom bar. After an error or uncertain +result, refresh before retrying. Hold/repeat events cannot repeatedly submit. + +Each change re-fetches the exact message and Telegram's available reactions and +limits. Existing choices keep their order. When adding beyond your personal +limit, the oldest choice is replaced, matching Telegram's behavior. If your +choices changed in another client, the operation asks you to refresh instead of +overwriting that change. Unavailable emoji and the chat's unique-reaction limit +are enforced, and Telegram remains authoritative for permissions. + +Counts are cached for offline reading. Live updates apply to the conversation, +search results, pins and reply previews; minimal updates preserve your choices. +Visible incoming messages with reactions additionally refresh in batches, at +most once every 30 seconds for an unchanged view. Only one batch can be pending; +hidden or unfocused views do not schedule new requests. + +This revision adds and removes ordinary emoji. Existing custom emoji counts use +a text fallback, and their identities are preserved when changing other choices. +It does not offer custom emoji selection, animations, reactor lists, paid +reactions or Saved Messages tag editing. Star counters can be read; no payment +request is sent. Saved Messages tags show a read-only explanation. + +The entry action is `reactions` in `conversation`; picker keys use the +`reactions` context. Its actions are `open`, `clear_reactions`, `refresh`, `up`, +`down`, `page_up`, `page_down`, `home`, `end`, and `cancel`. diff --git a/docs/wiki/en/Search.md b/docs/wiki/en/Search.md new file mode 100644 index 0000000..ca54416 --- /dev/null +++ b/docs/wiki/en/Search.md @@ -0,0 +1,82 @@ +# Search messages + +[简体中文](../zh-CN/Search.md) · [Guide](Home.md) + +By default, `/` in a conversation or `Ctrl-F` in the chat list opens local search. +`:search [regex]` explicitly selects local search. Type a regular expression and +press Enter. Tab cycles through the open chat, selected folder, +and this account. Searching works offline and never requests Telegram history. +The header identifies the scope; the results report the cached message count and +date range. This is cache coverage, not a claim that the account's full history +has been downloaded. A range can contain gaps. + +Results are newest first, with 100 hits per page. Up/Down selects a result; +PageUp/PageDown moves ten rows; Enter opens it with cached neighboring messages. +Ctrl-N/Ctrl-P changes result pages, Ctrl-F returns to the query, and Esc closes +search. Press `/` again from the conversation to return to the retained results. +`G` returns from an old result to the latest messages. Opening a historical result +does not itself mark the chat's unseen history as read. + +Examples: `(?i)invoice` (case insensitive), `error.*timeout`, `^TODO`, or +`東京|上海`. Syntax follows the maintained Rust [regex crate](https://docs.rs/regex/latest/regex/#syntax). +Lookaround and backreferences are not supported. Invalid patterns display an +editable error; patterns are limited to 4096 bytes and compiled with memory limits. +Only message text is matched, including captions; attachment contents are not indexed. + +Search runs on a dedicated blocking worker with one active scan and one replaceable +pending request. Esc, a new query, or switching accounts cancels it. Deletions +and edits already persisted in the cache are reflected in new searches. A hit is +revalidated before opening; deleted or evicted hits ask you to run the search again. + +Use `:search --cloud` in an open chat for Telegram's server search. The same panel +shows **Telegram search**, the captured chat, active filters and Telegram's result +count. Text follows Telegram's word search, not Rust regex syntax. This mode +requires a connection and searches only that chat; Tab does not change its scope. +Press Esc and use `:search --cloud ...` again to change filters or the target chat. +`/` returns to retained results, including cloud results; `:search` returns to local +regex search. + +Examples: + +```text +:search --cloud release notes +:search --cloud --from me --media file +:search --cloud --from @alice --after 2026-09-01 --before 2026-10-01 release +:search --cloud --media photo +:search --cloud -- --literal-leading-dash +``` + +Put filter flags before the text. `--from` accepts `me`, `@username`, or a known +Telegram peer ID. A username resolves once per search so subsequent pages keep +the same sender identity. `--media` accepts `all`, `photo`, `video`, `file`, `music`, +`voice`, `round` (round video), `gif`, `link`, and `poll`. Dates use `YYYY-MM-DD` at +midnight **UTC**: `--after` is strictly later than that instant, and `--before` +strictly earlier. Both must fit Telegram's 32-bit timestamp range; the UI validates +1970-01-02 through 2038-01-19. These filters use +[Telegram's messages.search API](https://core.telegram.org/method/messages.search). +Only cloud arguments use the existing Yazi platform quoting rules (Unix shell +quotes on Unix, Windows command-line quotes on Windows); no shell is executed. +Unprefixed local regex arguments retain their backslashes and trailing spaces. + +Cloud pages contain at most 100 results, with bounded lookahead to identify the +next page. A filter-only command opens the text field; press Enter to search +without text, or enter optional words. If Telegram rejects a query, the panel +keeps the query editable. Enter on a result fetches the original and up to 79 +preceding messages, then selects the original without marking unseen history read. +`G` returns to latest messages. Draft text and attachments stay intact. + +Esc or a new search cancels the previous cloud task; a late result cannot replace +the new query. Cloud results are reconciled with incoming edits and deletions +before display and persistence. They populate the same bounded cache without +advancing its sync cursor. Existing results reflect live edits/deletions; rerun +search to refresh matching membership and server counts. Counts and pages are not +a frozen snapshot when chat history changes. A history resync invalidates old +results and asks you to search again. Cache limits still apply; cloud search does +not download the entire chat. + +All controls are configurable in the `search` context. Actions are `open`, +`cancel`, `up`, `down`, `page_up`, `page_down`, `search_scope`, `search_query`, +`search_more`, and `search_previous`. + +Ctrl-R (`refresh`) reruns the search from the first page. `g m` / `:mentions` uses +the same paging and original-message navigation for [unread mentions](Notifications.md). diff --git a/docs/wiki/en/Synchronization.md b/docs/wiki/en/Synchronization.md new file mode 100644 index 0000000..5153224 --- /dev/null +++ b/docs/wiki/en/Synchronization.md @@ -0,0 +1,65 @@ +# Cache and synchronization + +[简体中文](../zh-CN/Synchronization.md) · [Guide](Home.md) + +Termgram displays cached conversations before connecting to Telegram. Opening a +cached conversation reads a local page at its unread boundary, or its most recent +page when already read, while a network request refreshes it. Messages remain visible during synchronization. Sending and server +operations wait for sign-in to finish. + +Updates continue while history, dialog and other requests run in the background. +The currently viewed group also receives periodic difference checks using +Telegram's requested interval. Leaving the conversation or unfocusing the +terminal stops active polling. Ordinary push updates and gap recovery continue. + +Automatic read receipts follow the displayed conversation. A focused terminal +acknowledges only through the highest incoming message whose bottom is visible, +after a successful frame. Opening a chat, loading history, or receiving a message +does not itself mark it read. Overlays, hidden conversation panes, and background +windows do not generate receipts. Visiting an older reply does not mark the +entire conversation read. + +The server's incoming read boundary is cached with each chat. Receipts are +coalesced per chat and retried after failures; a late acknowledgement cannot +clear messages newer than its boundary. Remaining unread counts come from a +bounded request for that chat. If the count refresh fails or a newer message +arrives first, the displayed count may temporarily remain high until the next +server update or successful receipt. Older caches acquire the boundary from +Telegram before generating automatic receipts. + +Each session has a separate `*.cache.sqlite3` database beside its session file. +The cache contains message text, chat metadata and downloaded attachment paths. +It does not contain authentication keys or user preferences. On Unix the file is +restricted to its owner. The database is not encrypted. + +The cache contains messages Termgram has received or loaded, not the account's +entire Telegram history. Up to 100,000 message records are retained per account; +older records are discarded. An old gap that Telegram can no longer replay +invalidates the affected local history, which is fetched again when viewed. + +To clear the cache, exit Termgram and remove that session's `.cache.sqlite3` +file. Media can be cleared separately by removing its `.media` directory. Keep +the `.session` file, `settings.conf` and `appearance.json` to preserve login and +preferences. Only one process may own a given account cache at a time. +The next launch reconnects and starts a fresh message cache. + +Drafts live in a separate database: the base session path followed by +`.state.sqlite3` (for example, `termgram.session.state.sqlite3`). All account +slots share this file, with records keyed by Telegram user ID, chat and optional +topic ID. It contains local message text and reply targets, not authentication +keys, and is not encrypted. **Keep this file when clearing message caches.** +Drafts are excluded from message-cache eviction and gap invalidation. + +Cached accounts load their drafts before showing conversations. SQLite reads +and coalesced writes run off the terminal thread. Normal exit flushes the latest +snapshot; a failed save is reported and retried while the app remains open. A +failed database transaction preserves the previous drafts. Draft text, replies +and Unicode cursor positions return together after a restart. + +Full update batches and their covered cursor are committed in order. Restarting +after an interrupted write may replay updates, but must never skip uncommitted +messages. Session peer metadata is separate from this message checkpoint. + +See [Telegram's update protocol](https://core.telegram.org/api/updates) for the +rules used by the underlying Grammers update engine. No end-to-end latency +comparison against Telegram Desktop has been measured yet. diff --git a/docs/wiki/en/Terminal.md b/docs/wiki/en/Terminal.md new file mode 100644 index 0000000..14828c4 --- /dev/null +++ b/docs/wiki/en/Terminal.md @@ -0,0 +1,48 @@ +# Terminal integration + +[简体中文](../zh-CN/Terminal.md) · [Guide](Home.md) + +Yazi's pinned terminal crates own input parsing, terminal capabilities, the TTY +writer, and restoration. Ratatui draws using that writer; Crossterm supplies its +output backend, not a second input reader. Paste, focus, mouse, resize, enhanced +key reports, and associated Unicode text enter one application event stream. + +Shift-Enter requires a terminal that reports it separately. Ctrl-J is the newline +fallback. Function keys or Alt combinations can also be intercepted by your +terminal or desktop; choose another binding in [Lua](Configuration.md). +Ctrl-L redraws the screen after graphical corruption. + +## Images + +Yazi chooses the graphics protocol. `ratatui-image` renders multiple inline +images and clips them while scrolling. Ghostty/Kitty can use Kitty graphics; +iTerm2/WezTerm can use inline images; Sixel is used where detected. Other terminals +fall back to Unicode half-blocks without an external image-overlay process. +Actual selection depends on reported capabilities and any multiplexer in between. + +Decoding applies EXIF orientation and retains upstream ICC-to-sRGB conversion. +Image decoding/encoding runs away from the UI thread. Only visible encoded +images are retained, and resizing or overlays clear affected image areas. +Animated stickers use a static thumbnail. For downloads and retry behavior, see +[Attachments](Attachments.md). + +## tmux + +By default Termgram uses tmux's reported capabilities. To opt into Yazi's +second-stage terminal probe, set this **in the shell environment before launch**: + +```sh +TERMGRAM_TMUX_PASSTHROUGH=1 tg +``` + +This calls upstream tmux setup, setting the pane's `allow-passthrough=all` and +the server's `input-buffer-size=104857600`. Those settings remain after Termgram +exits. This option is read before the Telegram credentials `.env` is loaded. + +The app restores terminal modes on normal exit, initialization failure and the +panic hook. One owner coordinates text and graphics cleanup. Upstream revisions, +licenses, and small adaptations are recorded in [vendor provenance](../../../vendor/README.md). + +## Clipboard + +Ctrl-V / Ctrl-Alt-V and `:paste` use OSC 5522 when the terminal reports support, or arboard on a background thread otherwise. Terminal MIME paste reuses Yazi’s parser and formatter, with request IDs and bounded transfers. The terminal owner enables and restores the negotiated mode. Disable it with `attachments.terminal_clipboard = false`. Ordinary text paste remains supported. Backend details, limits and WSL/SSH behavior: [Attachments](Attachments.md). diff --git a/docs/wiki/en/UX.md b/docs/wiki/en/UX.md new file mode 100644 index 0000000..d87a7f1 --- /dev/null +++ b/docs/wiki/en/UX.md @@ -0,0 +1,242 @@ +# Daily workflows + +[简体中文](../zh-CN/UX.md) · [Guide](Home.md) + +## Read and navigate + +The compact chat list and conversation share a wide window, with the composer +aligned below the conversation. F4 toggles the sidebar. Narrow windows show one +pane at a time; F4 or returning to Chats reveals the list and retains the draft. A focus border and selection marker show where keys will act; +Tab switches focus. Esc clears an explicit message selection before returning +from a conversation to the list. +Use `j/k` to select messages, arrows to scroll their rendered rows, and `G` to +return to the latest page. While you read earlier messages, new arrivals retain +your reading position and show a count instead of pulling the view down. + +Chats with unread messages open at the saved incoming read boundary. A compact +`Unread messages` separator stays at that entry point while receipts advance. +Local pages appear provisionally; Telegram verifies the position before automatic +receipts start. Use `g u` or `:unread` to locate the current first unread message +again. Continue with `j`, Down or PageDown at the end of a page to load newer +history without skipping the intervening messages. The footer shows `continue` +and `latest` hints while more history remains. `G` explicitly returns to the +latest page. A failed position lookup keeps the cache visible; retry with `g u` +or choose `G` to leave that position. + +`g r` / `:read` marks the entire target chat read. `g U` / `:mark-unread` sets +Telegram's unread reminder, shown as a colored dot when there is no actual +unread count. It does not change receipts already sent to other participants. +Reopening and displaying that chat clears the reminder; ordinary visible-message +receipts remain bounded. Commands retain the selected chat even if the list +reorders while the command line is open. + +`[`/`]` in the list switch [folders](Folders.md). `/` filters titles in that +folder. A [configured chat alias](Configuration.md) opens a stable Telegram ID +from All chats. `g i` shows the selected chat and folder IDs for configuration. + +Cached content appears first. The connection state and loading indicator describe +background reconciliation. Cached search and local preferences work without a +connection; sending and server operations need an authenticated connection. +The outgoing queue is bounded, and a failed send stays visible for retry. + + +The bottom statusline shows keyboard mode, account and connection information. +Selection changes its contextual hints; INSERT identifies the live composer. +Optional Ping/DC diagnostics and reading position are configured in +[Lua](Configuration.md). Long errors expand above the bar so their cause remains +readable without replacing the current mode. + +Author, reply, body and media have separate visual rows. The current message +and its active action have distinct markers. Full author names wrap. Messages +have alternating backgrounds and no empty separator row by default; time, optional +IDs, delivery and media state are shown in the bottom bar. + +## Read formatted messages + +Telegram bold, italic, underline and strikethrough formatting is preserved, +including nested styles and Unicode emoji. Inline code is highlighted; code +blocks keep indentation and trailing spaces and show their language when given. +Quotes have an indented rail. Code and text wrap to the available terminal width. +Custom emoji keep Telegram's Unicode fallback; the terminal does not play their +animation. Literal Markdown in an ordinary message stays literal. + +Spoilers start covered with visible placeholders. Click their message row, or +select the spoiler action with `g n` / `g p` and press Enter, to reveal or hide +them. `:spoiler` does the same for the selected message. Telegram's collapsible +quotes start with at most three rows; click the quote or press Enter on its action +to expand/collapse it, or use `:quote`. The bottom bar shows the current action. +Reply navigation retains its own action and click area. + +Reveals are local to this account and process, and reset when the text or its +formatting changes. Reply excerpts, chat previews, search results, pinned-message +summaries, forwarding/deletion previews and notifications always cover spoilers. +A message's link rows remain hidden until its spoilers are revealed, including +hidden link targets. Explicit copy/edit still uses the full original text. +Formatting is cached with messages; older cached entries acquire it when fetched +again. Termgram reads server entities; creating formatted text in the composer +is not part of this revision. Lua can bind `spoilers` and `expand_quote` in the +`conversation` context. + +## Compose and reply + +Click the composer or press `i` to write. From the chat list, `i` continues the +current conversation, or restores the last chat for this Telegram account after +restart. If it is unavailable, the highlighted chat is used; an empty list shows +a synchronization hint. The composer shows a configurable ghost hint when empty, +without a second permanent row repeating the send key. Enter sends; Shift-Enter +or Ctrl-J inserts a newline. Bot commands are typed here, including their `/`. +Each chat has its own local draft, including its text, reply target and cursor. +Esc, account switches and normal restarts preserve it. Drafts are keyed by the +Telegram user identity, so reusing a local account slot does not expose another +account's draft. Background saves coalesce edits over 400 ms; normal exit flushes +the final edit. A forced kill or power loss can lose edits not yet committed. +These drafts are local to this installation; cloud draft synchronization is not +part of this revision. + +Select a message and press `i` or `R` to reply, or use `R` with no selection for the +latest message. Clicking the composer preserves an existing reply draft without +creating a reply from the message selection. The composer shows the target. Esc cancels the reply first, +keeping text; another Esc returns to navigation. `r` opens a selected message's reply target. Clicking the indented quote also +jumps there; Enter opens the selected reply action. Excerpts load from memory, +then local cache, then a bounded background batch for visible missing targets. +An unavailable original is distinct from an excerpt still loading. Edits and +deletions update excerpts, including while a slower request is in flight. Failed sends keep their content and reply target; activating a +failed outgoing message returns it to the composer for retry. + +## Edit a message or caption + +Select a delivered message and press `e` or use `:edit`. Termgram fetches the +current original and checks its author, media type and Telegram's server-provided +editing time limit. Unsupported, forwarded or inline-bot messages show the +reason in the footer. The server remains authoritative about chat permissions. + +The editor identifies the chat and message, and the footer shows EDIT plus the +effective keys. Enter saves; Shift-Enter or Ctrl-J adds a newline. Esc/Ctrl-C +closes the editor while keeping the edit. `e` or `:edit` resumes that chat's +saved edit; Ctrl-D in the editor or `:edit-discard` discards it. One edit is kept +per chat alongside its normal draft, reply and files. It survives account +switches and normal restarts, using the same local draft save lifecycle. + +Failed saves keep the input. A changed server revision is reported as a conflict; +it never silently replaces your edit with new server text. To start from that +new original, discard the local edit and reopen it. Telegram does not provide an +atomic compare-and-set edit, so an external edit in the brief interval between +verification and save can still race. Saving an empty caption removes the caption; +a text message must remain nonempty. Media replacement is not part of this editor. + +Formatting before/after the changed range is retained with UTF-16 offsets; +formatting spanning the full replacement adjusts its length. Partly changed +entities and changed hidden-link/emoji targets are removed instead of attached +to unrelated text. Editing several separated ranges may remove formatting +between the first and last change. Server edit updates follow the existing ordered +synchronization stream and update cached replies. The selected message's edit +time appears in the bottom bar. + +## Delete a message + +Select a delivered message, then `d` or `:delete`. A fresh server snapshot shows +the chat, message preview and available scopes. Cancel is initially selected; +choose a scope with Up/Down or k/j and press Enter to submit. Ordinary chats may +offer only-for-me and, when permitted, for-everyone deletion. Supergroups use +Telegram's for-everyone deletion; Saved Messages offers only-for-me. Service +messages are not supported by this action. The server enforces final permissions. + +Termgram checks the exact peer, message revision and permissions again before +submitting. A changed message invalidates the review; close and reopen it. Telegram +does not offer an atomic revision-checked delete, so a remote change between the +last check and the RPC can still race. Errors keep the message and reset the +prompt to Cancel. Esc closes the prompt; if already submitted, the request keeps +running and reports its outcome in the footer. Confirmed deletion passes through +the ordered sync stream and removes cached content, search results and reply +previews before the corresponding sync checkpoint is stored. + +## Copy message content + +Select a delivered message and use `y` or `:copy` / `:copy text` for its text or +caption, without sender labels or timestamps. `Y` / `:copy link` asks Telegram for +its message link, including a topic/thread when available. Links are available +for supergroups/channels, including members-only private links; ordinary private +chats and basic groups have no exportable message link. + +Copy checks the latest message and chat permissions online. Protected text is +not copied; empty text and unavailable messages leave the clipboard unchanged. +The captured message remains the target while the request runs, even if you +navigate elsewhere. One preparation and one clipboard write may run at a time. +The footer reports errors and uses your configured copy key in selection hints. + +Local text writes use the native clipboard in the background. Linux retains its +clipboard owner while Termgram runs; pasting after exit depends on the desktop's +clipboard manager. WSL falls back to Windows PowerShell when needed. SSH sends +Yazi's OSC 52 request to the attached terminal, and tmux also receives a terminal +copy request. Native failure likewise falls back to OSC 52. Terminal delivery +has no acknowledgement: “Clipboard request sent” means the terminal still needs +to accept it, and tmux/terminal clipboard settings can prevent it. Copy payloads +are limited to 100,000 UTF-8 bytes and never written to logs or command arguments. + +## Forward and Saved Messages + +Select one delivered message and press `f`, or enter `:forward `. Tab uses +the same cached chat names, IDs and Lua aliases as `:chat`; `saved` is the current +account’s Saved Messages. Enter on an incomplete command chooses no destination. +After choosing a destination, a preview shows its name/ID, account, source and +latest message. Press Enter to forward, or Esc/Ctrl-C to cancel. These are the +`send`/`cancel` actions in the configurable `forward` context. + +`g S` / `:save` prepares the same preview addressed to Saved Messages. `g s` / +`:saved` opens that conversation without forwarding anything. It is identified +by the authenticated account, so it works without a matching recent-chat title. +An already cached Saved Messages dialog can be opened offline. + +Native forwarding keeps the original attribution and media and leaves existing +source/destination drafts untouched. Current protection is checked before the +preview and again before sending. Service messages, expiring media, protected +content or server restrictions produce an error. Remote edits/deletions invalidate +an open preview. The server remains authoritative about destination permissions; +an edit between the final check and submission can still race. + +Errors keep the review and the same Telegram deduplication ID for retry. Esc +before submission discards the review; after submission it closes the view while +the request completes. If that background request fails, `f` resumes its review. +Reviews and their retry IDs currently last for this process; cancelling a review +and starting another creates a new forwarding intent. This entry point forwards +one selected message, not a whole album or multiple selections. The destination +picker selects chats; it does not select a specific forum topic. + +## Files, links and mouse input + +Use `:attach ` to prepare files in the chat draft. Plain pasted paths +remain text by default. Ctrl-O in the composer opens attachment review for format, +preview and removal; Enter in the composer explicitly sends, using its text and +reply target for the first file. See [Attachments](Attachments.md). + +Click media to select it, then `o` for a larger preview or `i` to reply. Use +`g n` / `g p` to select actionable entries, then Enter to activate. Telegram +URL entities appear as selectable link rows, including hidden-text links. +Public `t.me`, `telegram.me`, and `tg://resolve` chat/message links open in-app; +private `t.me/c` and `tg://privatepost` links work for known groups. Other HTTP(S) +links open through the operating system. Invite links show a [confirmation preview](Chats.md); broadcast-channel links +remain unsupported. URL, web-view, callback and game bot buttons are supported; +payment, password-gated, contact/location and peer-selection buttons identify +that a graphical client is required. + +Mouse support is optional: click a chat to open it, click a message or action to +select it, right-click a message to reply, and scroll the pane under the pointer. +Clicking the chat list or timeline leaves input mode and keeps the draft. Settings/account rows are also +clickable. Keyboard equivalents remain available. + +## Settings and accounts + +`s` opens automatic update checks, release channel, Enter download behavior, +and message IDs in the bottom bar. `c` and `C` open [color pickers](Appearance.md). +These persist without rewriting Lua. Esc dismisses an overlay, returning focus +to the previous view. `?` shows effective bindings instead of a static cheat sheet. + +`a` opens the account picker; select an account or its add row and press Enter. +F2 cycles existing slots and F3 adds a slot, up to eight. Only the active account +has a running network worker. Sessions, messages, media, and color overrides are +isolated. Switching resets searches and conversation views while retaining each +account's local drafts. + +To open a new private chat, use `:open @username`, then `i` to compose. +Use `:info` to inspect the chat's identity and write restrictions. See +[Chat discovery and details](Chats.md) for invitations and permissions. diff --git a/docs/wiki/en/Updates.md b/docs/wiki/en/Updates.md new file mode 100644 index 0000000..34c2da4 --- /dev/null +++ b/docs/wiki/en/Updates.md @@ -0,0 +1,63 @@ +# Updates and releases + +[简体中文](../zh-CN/Updates.md) · [Guide](Home.md) + +Run `tg update` for the selected channel, `tg update --stable` for stable, or +`tg update --prerelease` to include prereleases. The stable channel selects the +highest stable `0.1.Z`; the prerelease channel selects the highest `0.1.Z` of +either type, so a newer stable release is not replaced by an older prerelease. + +Automatic checking runs in the background at most once per day for the selected +channel and shows a footer hint. Applying an update requires the explicit +command. `s` opens settings to disable checking or change channels. The updater +verifies `SHA256SUMS` from GitHub HTTPS delivery before replacing the executable. +On Windows replacement finishes after the updater exits. Another process can +keep the binary locked; a later `tg update` downloads a freshly verified copy. + +`tg --version` shows the version, commit, branch, OS, architecture and build +number. A `*` marks a source tree with non-ignored changes. Piped version output +is plain text; `NO_COLOR=1` or `TERM=dumb` also disables its styling. Source +archives without Git metadata show `unknown` for unavailable fields. + +## Cargo source installs + +Repeat the [Cargo installation command](Getting-Started.md) to rebuild from the +latest source, keeping the same `--branch`, `--rev` or `--root` options when used. +A fixed `--rev` stays on that commit until you change it. Cargo tracks the Git +source even though the manifest's base package version remains `0.1.0`. + +Git source builds display `0.1.Z` using their first-parent commit height. Check +the displayed commit to identify the source; Cargo's checkout can show a local +branch such as `master` or `HEAD` instead of the requested source branch. Archives +without Git metadata fall back to the manifest +version; CI can set an explicit version. + +`tg update` downloads a prebuilt GitHub release; it does not run Cargo or follow +your source branch. Use the Cargo command to continue following source changes. + +## Maintainer release workflow + +CI checks formatting, strict Clippy, tests, installers, and six native packages: +Linux x86_64/ARM64, macOS Intel/Apple silicon, Windows x64/ARM64. Default-branch +builds receive application API credentials from the `TELEGRAM_API_ID` and +`TELEGRAM_API_HASH` Actions secrets; PR builds do not receive them. + +Every successful default-branch commit is published as `0.1.Z`, where `Z` is its +first-parent height. Ordinary commits produce prereleases. A subject beginning +with `release:` produces a stable release; for example: + +```sh +git commit --allow-empty -m "release: stable" +git push +``` + +Publication reuses tested artifacts and includes installers and `SHA256SUMS`. +Failed checks do not publish. Interrupted publications are retried on a later +push; existing tags are never moved. Automatic publication begins at height 2. +The legacy asset labels `linux`, `macos`, and `windows` mean Linux x86_64, +Apple-silicon macOS, and Windows x64; other architectures have explicit suffixes. + +Application API credentials can be recovered from a compiled binary; they are +not a user's Telegram session. User phone numbers, login codes, passwords and +session files are never supplied to CI. This project is unofficial and is not +affiliated with Telegram. diff --git a/docs/wiki/zh-CN/Appearance.md b/docs/wiki/zh-CN/Appearance.md new file mode 100644 index 0000000..8b701ef --- /dev/null +++ b/docs/wiki/zh-CN/Appearance.md @@ -0,0 +1,91 @@ +# 外观 + +[English](../en/Appearance.md) · [指南首页](Home.md) + +在聊天列表或聊天内容区按 `c` 设置聊天颜色;在列表按 `C` 设置当前文件夹颜色。 +Up/Down 选择带名称的终端颜色,Enter 应用,Esc 取消。列表选中标记和选中样式不依赖 +颜色本身,因此颜色不是唯一的状态提示。 + +聊天行和聊天标题默认使用终端前景色。文件夹标题从六种 ANSI 调色板颜色中稳定 +选取,终端主题决定实际 RGB 值;这些颜色不假定终端一定采用深色背景。 + +优先级为:内置默认值 → Lua → 应用内覆盖。选择 **Follow configuration** 移除 +覆盖并重新跟随配置。**Terminal default** 则是明确使用终端前景色,可以覆盖 Lua +中指定的彩色默认值。 + +```lua +return { + colors = { + chats = { [-1001234567890] = "cyan" }, + folders = { [2] = "yellow" }, + }, +} +``` + +可用值:`default`、`black`、`red`、`green`、`yellow`、`blue`、`magenta`、`cyan`、 +`gray`、`dark_gray`、`light_red`、`light_green`、`light_yellow`、`light_blue`、 +`light_magenta`、`light_cyan`、`white`。`g i` 显示聊天 ID 和当前文件夹 ID。 + +应用内覆盖保存在 `settings.conf` 同目录的 `appearance.json`,按真实 Telegram +账号 ID 隔离。切换账号或清理消息缓存不会混用或删除配色。保存复用现有原子写入 +逻辑;格式错误会明确报错,不会悄悄覆盖损坏的文件。调色板不会改写 Lua 配置或 +服务端文件夹颜色。 + +## Nerd Font 图标 + +Nerd Font 支持需要主动开启。安装 [Nerd Font 字体](https://www.nerdfonts.com/font-downloads), +在终端配置中选择其 **Nerd Font Mono** 变体,再在 `config.lua` 中添加配置并重启 Termgram: + +```lua +return { + nerd_font = true, +} +``` + +使用 v3+ 字体。图标用于区分聊天类型、文件夹、Archive、置顶和附件,文字标签、按键提示 +与终端配色仍会保留。Mono 变体让图标位于单个终端单元格内。通过 SSH 或 tmux 使用时, +需要在实际显示会话的本地终端设置字体。Termgram 使用终端选定的字体,不会安装字体或 +修改终端偏好。 + +默认 `nerd_font = false` 使用普通文字显示,置顶标记为 `^`。如果图标显示为方框或与邻近 +文字重叠,将此选项关闭即可恢复。配置错误通过现有 Lua 加载流程显示。 + +字体参考:[Nerd Fonts 字体变体](https://github.com/ryanoasis/nerd-fonts/wiki/FAQ-and-Troubleshooting)。 + +## 聊天列表列布局 + +标题使用聊天配置的颜色;时间默认青色、未读数默认黄色,独立右对齐,不随标题长度改变。 +指示符和选中标题的下划线标明焦点,不用整行反色覆盖颜色。Nerd Font 聊天/置顶图标 +放在标题列,中文和 emoji 名称按终端列宽截断。紧凑侧栏默认 30 列,F4 切换显示。 +宽度和语义颜色选项见 [Lua 配置](Configuration.md)。 + +## 会话布局 + +作者独占一行,用户显示名包含名和姓,较长名字自动折行,不再被时间或 ID 挤掉。 +收到的消息按作者分配稳定的终端颜色,发出消息作者为绿色;正文保持终端默认文字颜色。 + +消息默认不留空白分隔行,通过交替底色区分。自动底色从终端报告的背景略微提亮或 +压暗,适应深色与浅色主题;未收到背景报告时保持终端默认背景。可调整间距或指定 +终端调色板颜色: + +```lua +messages = { + spacing = 0, -- 接受 0–2 行空白 + alternating = true, + -- alternate_background = "dark_gray", -- 省略时自动适配背景 +}, +``` + +底栏的 `message` 组件显示选中消息的信息;没有选择时显示会话中最后一条可见消息。 +内容包括本地时间/日期、可选 ID、发送状态、置顶和下载/预览状态。`context` 组件显示 +Lua 配置后的真实操作键。窄窗口会先隐藏次要组件,消息详情也可能缩短。 + +回复在正文上方显示带缩进的原作者和摘要,最多两行。点击引用区,或选中回复消息 +后按 Enter,可跳到原消息。ID 和加载/不可用状态显示在底栏。媒体点击仍只选择, +`o` 打开大图。左侧细标记表示消息选择,当前回复、附件或链接操作另有强调。正文、操作项和内嵌 +媒体共用两列缩进。图片或贴纸预览不再重复显示 `photo` 等标签;尚无图片时以小型 +占位标记保留可选目标,预览状态放在底栏。`o` 放大选中媒体,`O` 在系统文件管理器 +定位原文件,`i` 回复,`g n` / `g p` 切换操作项。 + +调整窗口或切换侧栏保留锚定的消息;原物理行不存在时回到该消息作者行。 +这里保留消息/行锚点,不承诺不同折行宽度下的精确文字字符位置。 diff --git a/docs/wiki/zh-CN/Attachments.md b/docs/wiki/zh-CN/Attachments.md new file mode 100644 index 0000000..2ce6e0c --- /dev/null +++ b/docs/wiki/zh-CN/Attachments.md @@ -0,0 +1,131 @@ +# 附件与文件预览 + +[English](../en/Attachments.md) · [指南首页](Home.md) + +## 准备发送文件 + +在打开的聊天中输入 `:attach /绝对路径`。多文件使用当前平台的参数引号规则, +也支持本机 `file:` URL。路径直接复用 Yazi 的解析器,不执行 shell、不展开变量。 +默认情况下,会话中的终端粘贴会进入输入框作为文字,文件路径也如此。 + +文件在后台加入发起操作的聊天草稿,此时不会上传。输入框显示附件数量。 +用 `:attachments` 或在输入框按 Ctrl-O 查看文件名、大小和发送格式,点击行或用 j/k 选择。 + +| 附件列表中的按键 | 行为 | +| --- | --- | +| a | 打开 `:attach`,继续添加 | +| p | 支持的图片在 Telegram 照片与原始文件之间切换 | +| d / Delete | 移除所选引用;准备期间则取消准备 | +| o / Enter | 预览支持的图片,Esc 返回列表 | +| O | 在系统文件管理器中定位原件 | +| i | 回到说明文字输入框 | +| Esc | 返回之前的视图,保留草稿 | + +在**输入框内**按 Enter 才会发送,第一份文件携带输入框中的说明文字和回复对象。 +每个草稿最多八份文件、共 2 GiB。上传与下载共享最多 32 个任务的队列,同时执行三个。 +同一聊天的文件按入队顺序上传。当前版本逐条发送文件,相册组合会在后续版本加入。 + +文件引用、格式选择和内容摘要随[本地草稿](UX.md)保存,正常重启后恢复。 +移除附件不会删除原文件。上传前会一边生成私有临时副本,一边检查内容摘要; +原文件缺失或发生变化时,会在上传前报错。更改过的文件需要重新添加、重新检查。 +传输结束或取消后自动删除上传副本。在准备期间切换聊天,结果仍加入最初的聊天草稿。 + +如需把识别到的粘贴路径自动加入草稿,在 Lua 中启用: + +```lua +attachments = { auto_attach_paths = true }, +``` + +此选项也不会自动发送;无法识别的内容仍作为文字。列表使用 `attachments` 按键上下文, +动作包括 `attach`、`attachments`、`remove_attachment`、`attachment_format`、`preview`、`reveal`。 + +## 剪贴板粘贴 + +在会话或输入框使用 `:paste`、Ctrl-V 或 Ctrl-Alt-V。附件列表也支持这两个快捷键, +`?` 可查看当前生效的绑定。未检测到终端 MIME 能力时,优先读取原生文件列表,在草稿数量限制内保留各个原件; +没有文件列表时,把图片保存为 PNG 草稿文件,普通文字插入保存的光标位置。 +这些操作不会发送;仍需回到输入框主动发送。Lua 动作为 `paste_clipboard`。 + +```lua +attachments = { + auto_attach_paths = false, + clipboard_as_photo = true, + terminal_clipboard = true, +}, +``` + +设 `clipboard_as_photo = false` 可让剪贴板图片默认作为原文件发送,列表中的 `p` +仍可逐项切换。系统返回的位图最多接受 128 MiB RGBA,PNG 文件上限 16 MiB,文字上限 1 MiB。 +准备过程十秒超时;如果底层系统调用尚未结束,新的读取会被拒绝,不会叠加阻塞线程。 +过期结果会丢弃,未加入草稿的截图文件会清理。 + +未发送截图保存在用户状态数据库旁的 `<状态文件名>.attachments//`, +不参与下载缓存淘汰。正常重启保留有引用的草稿;移除或成功发送后,在最后一个使用者释放时 +删除临时原件,失败则保留重试文件。启动时只清理当前账号的孤立截图,同时保护已保存的草稿 +和还在等待后台写入的进程内草稿。 + +| 环境 | 剪贴板后端 | +| --- | --- | +| macOS / Windows | arboard 原生文件列表、图片、文字 | +| Linux X11 / XWayland | arboard X11 后端 | +| Linux Wayland | 合成器支持时使用 data-control,否则尝试 X11 | +| WSL | 原生优先,失败后使用 Windows PowerShell 和 `wslpath`,子进程有输出限制与四秒超时 | +| SSH / 无桌面剪贴板 | 检测到 OSC 5522 时可由终端提供图片和文字;否则用 `:attach` 添加当前主机可读取的文件 | + +Cmd-V、Ctrl-Shift-V 通常由终端处理,可能只传入文字路径,也可能不传入图片。 +终端截获快捷键时可用 `:paste`。读取 SSH 主机的原生剪贴板不会读取本地桌面的剪贴板。 +各平台能力取决于桌面会话及其权限;不会为此新增终端输入读取器。 + +## 终端 MIME 粘贴 + +终端明确报告支持 OSC 5522 时,`:paste` 和剪贴板快捷键改为请求终端的剪贴板; +终端自己的粘贴快捷键也可以传入 MIME 粘贴事件。设置 +`attachments.terminal_clipboard = false` 可使用原生读取和普通终端文字粘贴。 + +类型优先级为文件列表、PNG/JPEG/WebP/GIF、UTF-8 纯文本。图片保留原始编码字节, +作为草稿拥有的文件保存,不统一转为 PNG;GIF 以原文件发送。图片编码上限 16 MiB, +单边最多 8192 像素,解码体积上限 128 MiB;文字与文件列表上限 1 MiB。 + +同时最多等待一次读取,每个请求阶段十秒超时。超时、权限拒绝、终端忙碌或内容不支持 +会在底栏提示,不会悄悄改读另一处剪贴板。切换聊天仍写入发起时的草稿;取消或切换账号 +后丢弃迟到结果。搜索、过滤、命令和登录输入只接受纯文本,输入目标改变时取消粘贴。 + +通过 SSH 时,终端可以传递图片字节和文字。文件 URL 仍指向终端所在主机的路径, +不能证明远端有同一份文件,所以此时拒绝文件列表;已在远端的文件用 `:attach` 添加。 +OSC 5522 需要终端与复用器的整条链路支持,未实测所有 SSH/tmux 组合。 +协商、数据解析和退出恢复仍由唯一的 Yazi 输入解析器和 TTY 所有者负责。 + +## 收到的文件 + +用 `j/k` 选择附件消息,或点击它,再按大写 `O`(`Shift-O`),即可在系统文件管理器 +中显示文件。这里是单次按键,不依赖长按计时;终端上报的重复按键事件会被忽略。 +同一附件下载期间重复操作不会启动额外下载。 + +已有原尺寸图片预览或下载文件时直接复用,否则先下载原始附件,再自动显示。 +动态贴纸的缩略图只用于预览,`O` 下载其原始文件。缓存文件已被删除时会重新下载。 +失败原因显示在状态区,可再次操作重试。 + +macOS 在 Finder 中选中文件,Windows 在资源管理器中选中文件;Linux 用 `xdg-open` +打开所在目录,能否选中文件取决于文件管理器。不会执行附件本身。即使设置中 +Enter 下载行为选择 **Keep in cache**,`O` 仍表示明确的“显示文件”操作。 + +单击媒体只选择并显示可用按键。`o` 在终端展开图片或贴纸,Esc 关闭大图,`i` 回复, +`O` 定位原始文件。选择不会直接激活链接、机器人按钮或文件管理器操作。 +`g n` / `g p` 切换可操作项,Enter 激活。Lua 动作为 `preview`、`next_action`、 +`previous_action` 和 `reveal`;大图使用 `preview` 上下文。 + +使用 Telegram 媒体 ID 和请求编号校验结果,避免旧下载覆盖已编辑或已删除的附件。 +文件映射随消息缓存保存,重启后可复用。完成的文件位于 +`<会话文件名>.media//`。每个目录最多保留 512 个已完成文件, +目标体积上限为 1 GiB,超过后清理较旧文件。刚下载的文件即使单独超过体积上限也会 +保留,之后的清理仍可能移除它。需要长期保存的文件应复制到缓存目录之外。 + +下载中的文件复用维护中的 `tempfile` 库,与已完成文件分开。取消下载会清理临时 +文件,进程中断遗留的文件会在下一次开始传输前清理。同一账号缓存只允许一个进程 +持有,未结束的媒体任务会保留锁直到清理完成,避免两个客户端同时推进同一缓存的 +同步进度。 + +照片、图片文档和贴纸在消息流内预览,只请求可见预览,应用最多同时等待两个预览下载, +全部网络传输共享有界工作任务。动态 TGS/WebM 贴纸显示 Telegram 的栅格缩略图并标注为 +静态预览;失败预览选中后可按 `o` 或 Enter 重试。Ctrl-L 重绘,协议说明见[终端](Terminal.md)。 +本地文件使用 `:attach` 加入草稿,检查后再发送。 diff --git a/docs/wiki/zh-CN/Chats.md b/docs/wiki/zh-CN/Chats.md new file mode 100644 index 0000000..676ff16 --- /dev/null +++ b/docs/wiki/zh-CN/Chats.md @@ -0,0 +1,44 @@ +# 聊天发现与资料 + +[English](../en/Chats.md) · [使用指南](Home.md) + +## 打开聊天 + +用 `:chat` 和 Tab 按名称、配置别名或 ID 选择缓存聊天。 +用 `:open @alice_name` 在 Telegram 上查找用户名,再按 `i` 输入。 +`:open https://t.me/example/123` 也可以定位消息链接。 +已有草稿、未读数及文件夹状态会保留。 + +只有按 Enter 后才请求 Telegram,输入过程中不联网查询;请求有超时。 +开始输入或打开其他聊天会取消等待中的导航,过期结果不会改变当前视图。 +缓存聊天可以离线打开。本版本仍不支持广播频道。 + +## 预览和加入邀请 + +`:join https://t.me/+hash` 先预览群邀请,也支持 `t.me/joinchat/hash` 和 +`tg://join?invite=hash`。用 `:open` 或激活消息中的邀请链接,也会进入同一个预览。 +预览显示群名、可用简介、人数和 Telegram 的诈骗/冒充标记。 + +默认选择取消:按 Down 选择加入或申请加入,再按 Enter 确认; +鼠标点击只选中选项,由 Enter 确认。已经加入的群提供“Open chat”。 +提交前会重新检查邀请。 + +需要管理员批准时显示申请已提交。提交中关闭面板不会取消服务端请求, +结果通过状态提示报告,也不会改变当前聊天。超时或失败后应重新打开邀请, +检查当前成员身份再重试。过期或撤销的邀请显示 Telegram 错误;订阅付费、广播频道、 +机器人验证需要官方客户端。请求日志不包含邀请 token,偏好设置和磁盘命令历史不保存邀请。 + +## 查看资料和发送限制 + +`:info` 打开选中聊天的资料:完整名称、用户名、简介、可用人数、当前身份、通知静音、 +文字/照片/文件权限和慢速模式。j/k 或鼠标滚轮滚动,Ctrl-R 刷新,Esc 关闭。 +长名称和简介会换行。 + +当前可见聊天最多每分钟后台刷新一次,同时只有一个资料请求;权限更新会废弃旧结果。 +离线查看显示资料获取时间。已知发送限制会显示在输入框 ghost text 和底栏。 +受限时尝试发送会保留正文、回复和附件草稿。慢速模式显示等待倒计时, +每次允许一个待发送消息或附件。 + +服务端拒绝会显示可读的权限或等待原因,并保留重试内容;最终发送权限由 Telegram 检查。 +此页面不修改身份、不封禁用户、不支付消息费用。[通知静音](Notifications.md)和禁止发言 +是不同的状态。命令补全见[冒号命令](Commands.md),导航方式见[快捷键](Keybindings.md)。 diff --git a/docs/wiki/zh-CN/Commands.md b/docs/wiki/zh-CN/Commands.md new file mode 100644 index 0000000..be8bd78 --- /dev/null +++ b/docs/wiki/zh-CN/Commands.md @@ -0,0 +1,82 @@ +# 冒号命令 + +[English](../en/Commands.md) · [使用指南](Home.md) + +在聊天列表或会话的导航模式按 `:`,底部会显示命令、说明、参数用法和实际生效的快捷键。 +不可用操作会说明缺少的选择或连接条件。标题保留打开命令栏时的账号和操作对象。 +在输入框、搜索框、聊天筛选框或登录框输入 `:` 时,它仍是普通文字。 + +| 按键 | 行为 | +| --- | --- | +| Tab / Ctrl-N | 补全下一个候选 | +| Shift-Tab / Ctrl-P | 补全上一个候选 | +| Up / Down | 召回匹配当前输入前缀的较早/较新命令 | +| Enter | 执行完整命令;空输入时关闭 | +| Esc / Ctrl-C | 取消并返回会话 | +| Left/Right、Home/End、Ctrl-A/Ctrl-E | 移动光标 | +| Backspace/Delete、Ctrl-W、Ctrl-U | 编辑或清空输入 | + +鼠标点击候选只填入命令,不会执行。缺少参数或出错时保留输入,继续编辑。 +只执行完整命令名及明确提供的 `h`、`q` 别名;例如 `qui` 需要先补全再执行。 +历史按账号在进程内保留,最多各 64 条,不写入磁盘。多行粘贴不会自动执行一串命令。 + +| 命令 | 行为 | +| --- | --- | +| `help [命令]`、`h` | 浏览全部命令或查看某条命令说明 | +| `chat <别名、ID 或名称>` | 打开当前账号的缓存聊天;Tab 筛选名称及 Lua 别名 | +| `folder ` | 选择 All chats、Archive 或 Telegram 文件夹 | +| `account [槽位]` | 打开账号选择器,或切换到已有账号槽位 | +| `search [正则]` | 打开本地搜索,或在当前本地范围提交原样正则 | +| `search --cloud [筛选] [文字]` | 搜索当前聊天的云端历史,按发送者、UTC 日期、媒体筛选([语法](Search.md)) | +| `attach ` | 将本地文件加入目标聊天草稿,不会自动发送 | +| `paste` | 将剪贴板的文件、图片或文字加入目标聊天草稿 | +| `attachments` | 查看当前会话草稿中的附件 | +| `latest` | 回到已打开会话的最新消息 | +| `unread` | 定位到聊天已读边界后的首条入站消息 | +| `read` | 明确将目标聊天全部标读,并清除未读提醒 | +| `mark-unread` | 设置 Telegram 的未读提醒,不倒退消息回执 | +| `edit` | 编辑所选已送达消息,或继续当前聊天保存的编辑 | +| `edit-discard` | 丢弃当前聊天的本地编辑,保留普通草稿 | +| `delete` | 检查所选消息,明确选择删除范围后确认 | +| `copy [text, link]` | 复制所选消息正文/说明(默认),或 Telegram 消息链接 | +| `forward ` | 用 Tab 选择目标,再预览所选消息并确认转发 | +| `save` | 预览并将所选消息原生转发到当前账号的 Saved Messages | +| `saved` | 打开 Saved Messages,即使它尚未出现在最近聊天中 | +| `open <@username or Telegram link>` | 查找并打开缓存列表之外的用户、群组或消息([说明](Chats.md)) | +| `join ` | 预览邀请,明确确认后加入或申请加入([说明](Chats.md)) | +| `info` | 查看聊天资料、权限和慢速模式([说明](Chats.md)) | +| `reply` | 回复明确选中的消息 | +| `poll` | 查看选中的[投票或测验](Polls.md),明确提交后投票 | +| `react` | 添加或移除选中消息的普通 emoji [回应](Reactions.md) | +| `spoiler` | 揭示或隐藏选中消息的剧透 | +| `quote` | 展开或收起可折叠引用 | +| `preview` | 放大选中的图片或贴纸 | +| `reveal` | 必要时下载,并在 Finder、Explorer 或文件管理器中定位附件 | +| `pins` | 浏览当前会话的置顶消息 | +| `pin chat`、`unpin chat` | 设置聊天在打开命令栏时的文件夹中的置顶状态 | +| `pin message`、`unpin message` | 设置选中消息的置顶状态,沿用 Telegram 选项确认界面 | +| `mute [duration]`、`unmute` | 修改捕获聊天的 Telegram 通知状态([说明](Notifications.md)) | +| `mentions` | 浏览目标聊天的未读提及及回复给你的消息([说明](Notifications.md)) | +| `archive`、`unarchive` | 将指定聊天归档,或从 Archive 恢复 | +| `sidebar [show、hide 或 toggle]` | 显示、隐藏或切换侧栏;省略参数时切换 | +| `color chat`、`color folder` | 打开对应颜色选择器 | +| `settings` | 打开应用设置 | +| `config reload`、`reload` | 校验后整体应用 Lua 配置;失败时保留当前设置 | +| `status` | 查看连接、DC、最近 Ping、缓存加载、配置及剪贴板能力 | +| `refresh` | 刷新聊天和文件夹列表 | +| `quit`、`q` | 正常退出 | + +直接执行聊天名称时,需要唯一且完整匹配。输入部分名称后用 Tab 选取 ID;同名聊天不会猜测。 +补全使用已加载数据,输入时不会发送网络搜索。聊天列表因新消息重排时,置顶和归档仍使用 +打开命令栏时的稳定对象。重复执行置顶或归档命令会保留期望状态,不会反向切换。 + +`search` 后第一个空格之后的内容按原样作为正则,包括反斜杠和末尾空格,不使用 shell 引号规则。明确的 `--cloud` 前缀启用 Yazi 平台参数引号与类型化筛选;两种模式均不执行 shell。 +`status` 只读取现有观测值:没有 DC 或 Ping 已过期时显示不可用, +不会显示成零;页面也区分内存中的消息和持久缓存的搜索覆盖范围。 + +可在 `chats` 和 `conversation` 上下文重绑入口动作 `command`。`command` 上下文提供 +`complete_next`、`complete_previous`、`history_previous`、`history_next`,以及普通编辑动作。 +参见 [Lua 配置](Configuration.md)。快捷键和命令共用业务动作与说明。 + +`:config reload` 和 `:reload` 原子重载 Lua 文件,错误时保留当前配置。`:status` 显示配置路径、 +修订次数、最近错误、通知策略、媒体粘贴能力和构建身份,详见[配置](Configuration.md)。 diff --git a/docs/wiki/zh-CN/Configuration.md b/docs/wiki/zh-CN/Configuration.md new file mode 100644 index 0000000..ef3cc3c --- /dev/null +++ b/docs/wiki/zh-CN/Configuration.md @@ -0,0 +1,232 @@ +# Lua 配置 + +[English](../en/Configuration.md) · [指南首页](Home.md) + +## 加载配置 + +把 `config.lua` 放在 `settings.conf` 旁,或用 `TERMGRAM_CONFIG` 指定路径。 +使用 `:config reload`(或 `:reload`)即可加载修改,无需重启,可以从[配置示例](../../../examples/config.lua)开始。配置返回一个 Lua table, +支持 table、字符串、数学和 UTF-8 辅助函数,不提供文件系统、进程或插件接口。 +启动时配置错误会显示提示并使用默认值;运行中重载失败则保留已经生效的设置。 + +```lua +return { + chats = { work = -1001234567890 }, + ghost_text = "{send} 发送 · {newline} 换行", + nerd_font = false, + keymap = { + { context = "conversation", on = { "" }, run = "message_up", count = 20 }, + { context = "conversation", on = { "g", "w" }, run = "jump work" }, + { context = "compose", on = { "" }, run = "newline" }, + { context = "compose", on = { "" }, run = "send" }, + }, +} +``` + +上下文包括 `global`、`chats`、`conversation`、`compose`、`edit`、`forward`、`poll`、`reactions`、`attachments`、`command`、`input`(登录与聊天过滤) +、`overlay`、`preview`、`pins` 和 `search`。具体上下文优先于全局绑定。同一上下文中配置相同按键会替换默认绑定; +`run = "noop"` 删除绑定。组合键用独立按键列表表示,例如 `{ "g", "w" }`。 +配置会检查前缀冲突。组合键一秒后过期,Escape 可以取消尚未完成的组合键。 +按键表示法沿用 Yazi,例如 ``、``、``、``、``。 + +聊天别名使用稳定的 Telegram 数字 ID,超级群为 `-100…` 格式;目标需要已经出现在 +本地聊天列表中。绑定可选填 `desc` 作为帮助页说明。帮助页读取当前生效的绑定, +使用方向键滚动。 + +默认 `j/k` 在聊天之间或实际消息之间移动,`20k` 向上移动 20 条消息,必要时继续 +加载更早的历史。`G` 或 End 回到最新消息;`gg` 或 Home 到当前已加载窗口的最早消息。 +方向键按显示行滚动,PageUp/PageDown 每次滚动十行。`i` 输入,`R` 回复,`r` 跳转到 +回复目标,`/` 在聊天列表中过滤,Tab 切换面板,`s` 设置,`a` 账号,`?` 帮助。 +输入框内的普通文字不会触发导航组合键或数字前缀。 + +给 `message_up` 配置 `count = 20`,即可用一个快捷键向上跳 20 条。`count` 默认 1, +范围 1–9999,支持 `up`、`down`、`message_up`、`message_down`、`page_up`、`page_down`。 +输入的数字前缀会乘以配置值,最终限制在 9999;其他动作不接受数量参数。 + +输入框占位提示中的 `{send}`、`{newline}`、`{cancel}` 会替换为实际快捷键。 +`ghost_text = ""` 隐藏提示。应用内偏好单独保存,不会改写 Lua 文件。 + +按 `g i` 显示当前聊天 ID,可用来配置别名。 + +终端使用 Nerd Font Mono(v3+)时,可设置 `nerd_font = true`,启用聊天、文件夹、 +归档、置顶和附件图标;默认 `false`。字体选择和回退方法见[外观](Appearance.md)。 + +## 底部状态栏 + +单行底栏替代原来的应用顶栏。可以配置左右组件及顺序;省略的字段保留以下默认值: + +```lua +statusline = { + enabled = true, + left = { "mode", "app", "account", "message", "context" }, + right = { "notifications", "connection", "latency", "dc", "position" }, +}, +``` + +| 组件 | 含义 | +| --- | --- | +| `mode` | CHATS、NORMAL、SELECT、INSERT 或当前浮层模式 | +| `app` | Termgram | +| `account` | 本地账号槽位与 Telegram 显示名称 | +| `message` | 选中或最后可见消息的时间、送达、置顶和媒体状态 | +| `context` | 当前选择/帮助的实际快捷键,或可用更新 | +| `notifications` | 当前焦点聊天已确认的静音状态和本地截止时间([说明](Notifications.md)) | +| `connection` | 正在连接、在线、重连或离线 | +| `latency` | 主连接最近一次成功 Ping 的毫秒数 | +| `dc` | 已认证会话的主数据中心 ID | +| `position` | 最新位置、距底部的显示行数,或阅读历史时的新消息数 | + +每个组件在左右两侧合计只能出现一次,未知或重复组件会导致配置报错。空列表可以清空 +一侧。窄窗口先隐藏次要组件,优先保留模式与当前操作;每侧的剩余组件维持原有顺序。 +`enabled = false` 隐藏底栏,错误仍会在上方可读地显示。 + +延迟是可选诊断:在现有 Telegram 连接上每 60 秒最多发起一次 Ping,超时为 5 秒。 +五秒未完成即显示不可用;旧探测结束前不追加探测,避免断网时积累请求。 +结果包含 SDK 排队与重试时间,**不是消息到达延迟**。绘制过程中不发网络请求。 +从两侧移除 `latency` 或关闭底栏,会停止这些额外请求。破折号表示尚不可用;断线会使 +测量失效,超过 90 秒的样本不再显示,切换账号清空观测。DC 是会话的主数据中心, +不代表全部媒体传输服务器,也不根据地理位置猜测。 + +## 侧栏 + +```lua +sidebar = { width = 30, time_color = "cyan", unread_color = "yellow" }, +``` + +宽度接受 24–60 个终端列,必要时收窄,给会话保留至少 48 列。低于 80 列时一次显示 +一个面板。颜色使用[终端命名调色板](Appearance.md)。标题保留聊天单独配置的颜色; +时间与未读数各自占固定列,超过 999 显示 `999+`,不会撑宽列。 + +F4 在导航和输入时切换侧栏。宽窗口保留输入状态;窄窗口返回聊天列表并保留草稿。 +Tab/Shift-Tab 或返回 Chats 也会展开侧栏。终端支持重复事件时,按住 F4 不会反复切换。 +这里采用单次按键切换,不依赖按键释放事件。可以重新绑定: + +```lua +{ context = "global", on = { "" }, run = "toggle_sidebar" }, +``` + +切换不会丢失会话、选择和消息。尚未打开聊天时保留列表;可见性是本地 UI 状态, +不会改变 Telegram 文件夹。 + +## 文件路径 + +`config.lua`、`settings.conf`、`appearance.json`、`navigation.json` 位于配置目录; +`navigation.json` 按 Telegram 账号 ID 记录上次聊天,不保存草稿文字。会话、消息库和媒体位于 +数据目录。默认值如下: + +| 系统 | 配置目录 | 数据目录 | +| --- | --- | --- | +| Linux | `$XDG_CONFIG_HOME/termgram` 或 `~/.config/termgram` | `$XDG_DATA_HOME/termgram` 或 `~/.local/share/termgram` | +| macOS | `~/Library/Application Support/dev.termgram.Termgram` | 相同 | +| Windows | `%APPDATA%/termgram/Termgram/config` | `%LOCALAPPDATA%/termgram/Termgram/data` | + +账号 1 使用 `termgram.session`,其他槽位使用它旁边的 +`accounts/.account-N`,各自追加 `.cache.sqlite3` 数据库与 `.media` +目录。会话文件包含账号凭据,消息缓存与媒体是本地明文。清理方法见[缓存与同步](Synchronization.md)。 + +| 环境变量 | 用途 | +| --- | --- | +| `TELEGRAM_API_ID`、`TELEGRAM_API_HASH` | 源码构建的应用凭据;环境或 `.env` 优先于内置值 | +| `TERMGRAM_SESSION` | 指定账号 1 会话路径,其他槽位据此派生 | +| `TERMGRAM_CONFIG` | 指定 Lua 文件,在启动前的 shell 中设置 | +| `TERMGRAM_TMUX_PASSTHROUGH=1` | 启用终端透传设置,见[终端](Terminal.md) | + +Lua 在凭据 `.env` 之前加载,因此 `TERMGRAM_CONFIG` 必须已存在于进程环境。 +旧的 `TUIGRAM_SESSION` 和已有 TUIGram 默认会话仍被识别,以保留登录状态。 +更改会话路径不会移动设置或颜色文件。 + +Lua 源码限制 64 KiB、VM 内存 8 MiB、约一百万条指令。未知字段、动作、别名和同上下文 +前缀冲突会报错;启动时整个无效配置回退为默认值,`:config reload` 失败则保留当前配置。 +不提供任意插件 API。 + +## 动作表 + +把动作放在合适的上下文。自定义 `global` 绑定作为后备,具体上下文的绑定或组合键前缀 +优先。`desc` 自定义帮助文字;`noop` 删除当前上下文的绑定后,已有全局绑定可能重新生效。 + +| 动作 | 常用上下文与行为 | +| --- | --- | +| `quit`、`redraw`、`next_account`、`add_account` | 全局退出、重绘、账号控制 | +| `help`、`settings`、`accounts` | 导航;打开或切换浮层 | +| `reload_config` | 校验并应用 Lua 文件,无需重启 | +| `open`、`cancel`、`focus` | 按上下文激活、关闭、切换面板或二维码 | +| `toggle_sidebar` | 在导航或输入时展开/收起侧栏 | +| `up`、`down`、`page_up`、`page_down` | 列表选择、显示行滚动或搜索选择,支持 `count` | +| `message_up`、`message_down` | 会话消息光标,支持 `count` | +| `oldest`、`latest` | 首末聊天,或已加载历史起点/最新会话 | +| `first_unread`、`mark_read`、`mark_unread` | 跳到首条未读、明确将聊天全部标为已读、设置 Telegram 未读提醒 | +| `mentions` | 浏览 Telegram 未读提及及回复给你的消息 | +| `mute_chat`、`unmute_chat` | 设置当前聊天的 Telegram 通知静音 | +| `compose`、`send`、`newline` | 进入草稿或回复会话中明确选中的消息 / 发送 / 换行 | +| `edit_message`、`discard_edit`、`delete_message` | 编辑已送达消息、丢弃本地编辑或检查删除范围 | +| `copy_text`、`copy_link`、`forward_message`、`save_message`、`saved_messages` | 复制、预览转发或打开 Saved Messages | +| `attach`、`attachments`、`paste_clipboard` | 添加文件、查看附件或粘贴到草稿 | +| `remove_attachment`、`attachment_format` | 在附件列表中移除文件或切换照片/原文件格式 | +| `poll`、`toggle_poll_answer`、`retract_vote` | 打开投票、选择答案或准备撤回 | +| `reactions`、`clear_reactions` | 打开 emoji 回应或在选择器中移除本人的选择 | +| `spoilers`、`expand_quote` | 在会话中揭示/隐藏剧透、展开/收起引用 | +| `preview` | 展开所选图片或贴纸;大图通过 `preview` 上下文配置按键 | +| `home`、`end`、`left`、`right`、`backspace`、`delete`、`clear`、`delete_word` | 编辑器 | +| `filter`、`refresh`、`chat_info` | 导航;标题过滤、列表刷新、显示 ID | +| `folder_previous`、`folder_next` | 聊天列表文件夹 | +| `pin`、`pin_up`、`pin_down`、`archive` | 聊天列表;官方置顶排序和归档 | +| `reply`、`reply_target`、`open_link`、`next_action`、`previous_action`、`reveal` | 会话操作 | +| `chat_color`、`folder_color` | 外观选择器 | +| `pin`、`pins` | 会话;确认置顶/取消置顶,打开置顶列表 | +| `pins_more`、`pins_previous`、`unpin_all` | 置顶消息浮层(`pins` 上下文) | +| `search` | 打开本地搜索 | +| `search_scope`、`search_query`、`search_more`、`search_previous` | 搜索浮层 | +| `jump ALIAS` | 打开配置的稳定聊天 ID,返回导航状态 | +| `noop` | 移除指定上下文中的绑定 | + +默认按键见[快捷键](Keybindings.md),`colors` 表及应用内覆盖优先级见[外观](Appearance.md)。 + +导航模式的 `command` 动作打开[冒号命令](Commands.md)。新增 `command` 上下文, +可重绑 `complete_next`、`complete_previous`、`history_previous`、`history_next`、 +`open`、`cancel` 及输入编辑动作。底栏的模式会显示 COMMAND。 +命令栏默认用 Ctrl-C 取消;其他上下文仍用 Ctrl-C 退出。 + +## 附件输入 + +`attachments = { auto_attach_paths = false }` 默认将普通粘贴保留为文字。设为 `true` 后,识别到的本机路径会加入草稿,仍需主动发送。也可用 `:attach` 明确添加文件。列表操作见[附件](Attachments.md)。 + +`attachments.clipboard_as_photo = true` 将剪贴板图片默认作为 Telegram 照片,设为 `false` 则作为原文件。可在 `conversation`、`compose`、`attachments` 上下文重绑 `paste_clipboard`。 + +`attachments.terminal_clipboard = true` 在检测到支持时优先使用 OSC 5522 MIME 粘贴。设为 `false` 关闭此终端模式,使用原生剪贴板。见[终端](Terminal.md)。 + +消息编辑使用 `edit` 上下文。会话中的 `edit_message` 打开/继续本地编辑,`discard_edit` 丢弃编辑;`edit` 中支持 `send`、`cancel`、`newline` 和常规输入动作。底栏显示 EDIT,所选消息的编辑时间也显示在底栏。 + +会话动作 `delete_message` 打开删除确认;它与编辑器中删除下一个字符的 `delete` +不同。确认框使用 `overlay` 上下文中的 `up`、`down`、`open` 和 `cancel`; +底栏显示 DELETE,以及实际的范围选择和确认快捷键。 + +`conversation` 中的 `copy_text` 和 `copy_link` 操作明确选中的消息,默认键为 `y` 和 `Y`。 +`attachments.terminal_clipboard` 控制 OSC 5522 媒体粘贴,不影响用户主动触发的 OSC 52 +文字复制。本机文字复制复用已有原生剪贴板依赖。 + +`forward_message`(`f`)打开目标聊天补全,`save_message`(`g S`)准备转发给自己, +`saved_messages`(`g s`)打开 Saved Messages。转发预览使用 `forward` 上下文, +动作是 `send`(Enter)和 `cancel`(Esc/Ctrl-C);底栏显示 FORWARD 和实际绑定。 +长按按键产生的重复事件不会提交刚打开的转发预览。 + +桌面提醒使用 Lua 的 `notifications` 表,后端、预览、无声投递和合并方式见 +[通知设置](Notifications.md)。它与底栏名为 `notifications` 的组件分别配置。 + +投票面板快捷键使用 `poll` 上下文,见[投票与测验](Polls.md)。 + +回应选择器使用 `reactions` 上下文,见[消息回应](Reactions.md)。 + + +## 运行中重载 + +`:config reload` 在后台校验整个文件,通过后一次替换快捷键、别名、颜色、布局、ghost text、 +图标、通知和媒体粘贴设置。文件丢失、无法读取或配置错误时保留当前配置和草稿;`:status` +保留最近错误和配置路径。用 `return {}` 恢复默认。重载使用启动时选定的路径,不重读环境变量、 +凭据或账号偏好文件。 + +延迟探测开关会直接传给当前 Telegram worker,无需重连。关闭后丢弃旧观测值,已提交的一次 +RPC 可以完成。MIME 粘贴模式由现有终端 owner 更改;正在进行的终端粘贴会完整取消, +重载后重新粘贴即可。排队中的通知会取消,以免采用旧的隐私预览策略。 + +可绑定动作 `reload_config`。`?` 中包含所有有效快捷键与命令;帮助和 `:status` 都支持 +j/k 和鼠标滚轮滚动。 diff --git a/docs/wiki/zh-CN/Development.md b/docs/wiki/zh-CN/Development.md new file mode 100644 index 0000000..f905b2b --- /dev/null +++ b/docs/wiki/zh-CN/Development.md @@ -0,0 +1,127 @@ +# 开发与架构 + +[English](../en/Development.md) · [指南首页](Home.md) + +遵循 [AGENTS.md](../../../AGENTS.md):先理解问题、上游实现及调用方,决定设计、完成实现后 +再验证。本地计划与调查输出放在忽略的 `dev-notes/`,用户文档在这里同步维护两种语言。 +使用描述性功能分支与可构建的原子 Conventional Commits;功能拆分为独立 PR,有依赖时明确基底分支。 + +## 构建与验证 + +环境和凭据见[快速入门](Getting-Started.md),使用固定工具链与提交的锁文件: + +```sh +cargo fmt --all -- --check +cargo clippy --locked --all-targets --all-features -- -D warnings +cargo test --locked --all-targets --all-features +cargo build --locked --release +python3 scripts/export-wiki.py --check +``` + +只保留验证可观察行为和集成边界的少量回归测试。现有覆盖包括 reducer、Unicode 输入、 +终端渲染、缓存重启/重放、会话跨进程写入、更新恢复、文件夹规则、Lua 路由、本地正则搜索、 +附件身份与淘汰。使用合成数据,不使用真实账号凭据或私人消息。 +不要求 TDD,不引入通用测试框架,不重复上游解析器或数据库测试。 +格式与 lint 面向根工作区,保留 vendored 上游代码原有风格。 + +CI 在六个平台上通过 `cargo install --git` 安装检出的 Git 提交,验证包选择、仓库内依赖 +补丁、锁文件、Cargo 安装记录和已安装程序的版本,再打包该可执行文件;通过 Cargo +目录 `target/cargo-install` 复用产物,与工作区构建及其 build-script 元数据分开。 +版本与发布见[更新](Updates.md)。自动化数据不能证明 +与 Telegram 的真实延迟一致,也不能代替目标平台文件管理器的人工验证。 +这些验证需要获准的测试账号与对应系统,不应默认给其他人发消息来测试。 + +## 所有权和边界 + +```mermaid +flowchart LR + T[单个 Yazi 输入读取器] --> A[App reducer] + A --> L[本地协调器] + L --> C[SQLite 消息缓存] + L --> N[Grammers 工作任务] + N --> L + L --> A + A --> R[Ratatui 与预览渲染] + L --> S[有界本地搜索] + N --> M[有界媒体传输] +``` + +- `src/app.rs` 管理选择、焦点、草稿和请求代次,功能 reducer 在 `src/app/`,渲染在 `src/ui/`。 +- `src/keymap.rs` 加载声明式 Lua,把 Yazi 按键映射为动作。上下文、数量、组合键和提示使用 + 同一组绑定。`config.lua` 归用户所有,托管设置和账号外观另存。 +- `src/actions.rs`、`src/commands.rs` 为命令、帮助和补全共享有限的动作元数据, + 命令保留账号/聊天/消息身份。配置完整校验后才由终端所有者应用;解析或模式切换失败保留旧设置。 +- `src/drafts.rs` 按 Telegram 用户和聊天身份保存正文及编辑草稿,与可清理的历史缓存分开。 + `src/clipboard.rs`、`src/terminal/clipboard.rs` 将数据交给现有附件暂存流程; + 原生剪贴板只有一个所有者,终端 MIME 事件使用已有 Yazi reader。 +- `src/telegram/local.rs` 在联网前打开账号缓存,处理本地请求、限制认证前的待执行命令并 + 持久保存更新。`src/cache.rs` 负责迁移、历史覆盖范围、修订与保留策略。 +- `src/telegram/mod.rs` 管理客户端、更新流和任务生命周期,`requests.rs` 限制普通 RPC, + 传输另有额度。较慢的列表、历史或 peer RPC 不占用更新消费路径。 +- `src/search.rs` 用维护中的 regex 引擎扫描本地文本,一个运行中的阻塞扫描加一个可替换 + 待执行请求,支持代次校验、取消和游标分页,不请求远端历史。 +- `src/telegram/media_cache.rs` 管理完整文件和 `tempfile` 临时文件, + `src/app/attachments.rs` 连接消息选择与下载/定位操作。 + `src/terminal.rs` 负责模式及恢复,`src/media.rs` 通过同一输出流协调图形。 + +## 同步约束 + +缓存展示不能等待认证、完整聊天列表或全历史扫描。更新接收 future 在处理命令和完成事件 +期间保持存活,取消重建它可能丢失进行中的恢复。当前聚焦群组使用 Grammers 更新引擎和 +服务端要求的 channel difference 间隔;停止活跃轮询时,一般推送与缺口恢复继续工作。 + +持久游标属于完整且已覆盖的更新批次。消息、编辑、删除标记与游标一起提交;会话游标推进 +不代表消息已缓存。重启恢复应用自己的游标。过长差异先使相关历史失效,再接收替代数据。 +重放新消息不能重复增加未读数。 + +聊天预览以真正的 top message ID 为准,不使用当前历史窗口最后一条。进行中的旧快照不能 +恢复已删消息或覆盖新编辑、已读状态。精确的缓存分页边界避免把稀疏回复/搜索上下文当成 +完整历史。切换账号取消旧工作,并拒绝过期请求代次。 + +每个账号消息缓存只能有一个进程所有者,文件传输与清理共享该锁生命周期。 +底层 session 格式仍支持串行化 SQLite 写入,但两个完整客户端不能独立推进同一个消息缓存。 +升级内置 SQLite 并检查上游 WAL-reset 修复前,保留当前 rollback journal。 + +## 复用决策 + +| 上游 | 复用或调研内容 | 本地职责 | +| --- | --- | --- | +| Yazi `9203fd2604f867ab5ec18f24203b918975c4c00a` | 终端生命周期、解析、能力探测、TTY、公开按键标准化;固定版本依赖 | 小型适配层与可见性补丁,不创建第二个输入读取器 | +| Codex `78245b47af2a7aafcabe025828ceecca69db4df1` | 上下文优先级、组合键取消、实际按键提示、输入框 UX | 设计参考,不 fork 整个 TUI 或复制插件运行时 | +| Telegram Desktop `4d4da471fbee771c10e173a83c003ba1728989f1` | 活跃群恢复、原生过滤规则、本地优先存储 | 通过 Telegram API/Grammers 对齐行为,不复制 Desktop 存储格式 | +| Grammers 0.10.0 | MTProto 传输与更新顺序 | 批次/游标/活跃群的局部补丁,保留上游许可证 | +| libSQL / regex / mlua / tempfile / ratatui-image | 数据库、正则、有界 Lua 求值、临时文件、内联渲染 | 应用 schema、资源限制、命令与 UX | +| arboard / notify-rust | 原生剪贴板与桌面通知适配 | 草稿暂存、账号/请求身份、通知条件与隐私 | + +参考源码:[Yazi](https://github.com/sxyazi/yazi/tree/9203fd2604f867ab5ec18f24203b918975c4c00a)、 +[Codex TUI](https://github.com/openai/codex/tree/78245b47af2a7aafcabe025828ceecca69db4df1/codex-rs/tui)、 +[Telegram Desktop](https://github.com/telegramdesktop/tdesktop/tree/4d4da471fbee771c10e173a83c003ba1728989f1)、 +[Telegram 更新协议](https://core.telegram.org/api/updates)、 +[文件夹协议](https://core.telegram.org/api/folders)。 + +TDLib 提供维护中的客户端/数据库体系,但在这里采用它将替换认证、消息映射、媒体和现有 +Grammers 集成。本次保留 Grammers,把所需修改限制在有说明的 SDK 边界;如果以后无法 +以小范围维护保证更新正确性,再重新评估。 + +升级时统一更新所有 Yazi Git revision。优先发行依赖或不可变 Git revision,不能依赖 +机器旁边的本地 checkout。复制源码必须保留许可证,并在 +[vendor/README.md](../../../vendor/README.md) 记录仓库、版本、原路径与修改。 +替换后删除废弃基础设施,避免长期维护两条路径。 + +## UX 维护 + +提示来自实际绑定;空输入框用 ghost text,浮层接管输入并保留退出路径。 +切换视图保留草稿与阅读位置。明确范围:加载窗口不是全量历史,缓存搜索不是服务器搜索, +加载中不等于没有结果。颜色辅助焦点标记,不单独承担状态含义。 + +英文与简体中文一起更新。[Wiki 导出流程](../README.md) 校验页面对应关系和内部链接, +再将源目录映射为 GitHub Wiki 文件名,无需引入文档框架。 + +底栏借鉴 Yazi 的有序状态组件与 Codex 的有限组件列表,没有复制代码。 +`src/statusline.rs` 管理声明式配置与临时观测,`src/ui/statusline.rs` 根据终端列宽排版。 +可选 Ping 使用现有 Grammers 客户端,在由 worker 管理的任务内运行;关闭或切换账号 +会终止该任务,不读取更新流。 + +`src/ui/transcript.rs` 负责消息展示,`src/ui/chats.rs` 用 Ratatui Table 对齐语义列。 +消息排版继续使用现有文字折行、动作派发、媒体槽位和视口所有权;小型渲染回归验证 +点击优先级与宽度变化后的消息可见性。 diff --git a/docs/wiki/zh-CN/Folders.md b/docs/wiki/zh-CN/Folders.md new file mode 100644 index 0000000..d3a835e --- /dev/null +++ b/docs/wiki/zh-CN/Folders.md @@ -0,0 +1,40 @@ +# Telegram 文件夹 + +[English](../en/Folders.md) · [指南首页](Home.md) + +Termgram 读取 Telegram 中已配置的文件夹,包括共享文件夹。文件夹顺序、置顶聊天、 +包含与排除规则会同步,并按账号缓存。使用 Telegram Desktop 等官方客户端创建或 +编辑文件夹后,Termgram 会自动更新。 + +在聊天列表中用 `[`、`]` 切换上一个、下一个文件夹,边框显示名称与位置。对应的 +Lua 动作为 `chats` 上下文中的 `folder_previous` 和 `folder_next`。`/` 只过滤当前 +文件夹。切换文件夹保留已打开的聊天、阅读位置和草稿。`jump alias` 会从“全部聊天”或“归档” +打开目标,因此目标不在当前文件夹时也能跳转。参阅[配置](Configuration.md)。 + +显式排除优先,其次是显式包含与置顶,最后才是动态规则。支持联系人、非联系人、 +机器人、群组,以及未读、静音、归档筛选。机器人属于独立类别。按照 Telegram +Desktop 的规则,静音但有未读提及的聊天仍可进入文件夹,除非已归档;没有独立静音 +设置的聊天继承账号的通知默认设置。其他客户端产生的已读通知会更新“未读”文件夹。 +需要重试时可按 `Ctrl-R` 刷新文件夹和聊天列表。 + +Termgram 目前提供私聊和群聊,暂不展示广播频道,包括文件夹中的广播频道。 + +## 归档 + +Archive(ID `1`)是独立文件夹;All chats(ID `0`)不展示归档聊天。 +选中聊天后按 `e`(`archive`)归档,在 Archive 中按相同按键取消归档。 +自定义文件夹仍遵循自己的包含规则与 `exclude_archived` 规则;其他客户端的变更会自动同步。 + +## 置顶聊天 + +在聊天列表按 `p`(`pin`)置顶或取消置顶,`Ctrl-K` / `Ctrl-J`(`pin_up` / `pin_down`) +上移或下移置顶聊天。主列表、Archive 和每个自定义文件夹分别使用独立的服务端顺序。 +`^` 标记表示置顶,其他聊天的新消息不会挤到置顶聊天前面。排序后仍选中同一聊天, +置顶顺序会缓存,重启后立即可见。 + +与 Telegram Desktop 一样,在自定义文件夹内取消置顶后,聊天仍保留为显式包含项。 +修改置顶会保留服务端的标题、颜色、共享文件夹属性与筛选规则。置顶与归档操作需要 +联网,等待服务端确认后更新;服务端数量限制或权限错误会明确显示。本地外观设置 +不会修改 Telegram 的共享文件夹数据。 + +实现参考:[Telegram 文件夹协议](https://core.telegram.org/api/folders)。 diff --git a/docs/wiki/zh-CN/Getting-Started.md b/docs/wiki/zh-CN/Getting-Started.md new file mode 100644 index 0000000..1ee80fd --- /dev/null +++ b/docs/wiki/zh-CN/Getting-Started.md @@ -0,0 +1,148 @@ +# 快速入门 + +[English](../en/Getting-Started.md) · [指南首页](Home.md) + +## 安装发行版 + +提供 Linux x86_64/ARM64、macOS Intel/Apple silicon、Windows x64/ARM64 原生构建。 +Linux/macOS 需要 Bash、`curl`、`tar`,以及 `sha256sum` 或 `shasum`;Windows 需要 +PowerShell 5.1 或更高版本。终端至少 40 × 10,建议使用 80 × 24 或更大窗口。 + +```sh +curl --proto '=https' --tlsv1.2 -sSfL \ + https://github.com/iebb/termgram/releases/latest/download/install.sh | bash +``` + +默认安装到 `~/.local/bin`。如果希望先审阅脚本,可把相同 URL 下载为文件,阅读后执行 +`bash install.sh`。选择预发布版或更改目录: + +```sh +curl --proto '=https' --tlsv1.2 -sSfL \ + https://github.com/iebb/termgram/releases/latest/download/install.sh \ + | CHANNEL=prerelease INSTALL_DIR="$HOME/bin" bash +``` + +Windows PowerShell: + +```powershell +$installer = Invoke-RestMethod 'https://github.com/iebb/termgram/releases/latest/download/install.ps1' +& ([scriptblock]::Create([string]$installer)) +# 可选参数: +& ([scriptblock]::Create([string]$installer)) -Channel prerelease -BinDir "$HOME\bin" +``` + +Windows 默认目录为 `%LOCALAPPDATA%\Programs\Termgram\bin`,也可先查看 `$installer` +内容。安装器会校验发行版 `SHA256SUMS`、选择原生架构,并在需要时提示加入 PATH 的目录; +不会提权或自行修改 PATH。版本选择与升级规则见[更新](Updates.md)。 + +## 登录并发送消息 + +1. 执行 `tg`。官方发行包内置项目的 Telegram 应用凭据;源码构建需要下文所述的自有凭据。 +2. 输入国际格式手机号、验证码以及可选的两步验证密码。密码输入会掩码显示。 + 手机号页按 Tab 使用二维码,再用已登录的 Telegram 在 **设置 → 设备 → 连接桌面设备** 扫码。 +3. 用 `j/k` 或方向键选择聊天,Enter 打开。 +4. 按 `i` 进入输入框,输入消息后 Enter 发送,Ctrl-J 换行。 + Esc 退出输入,为当前聊天和账号保留本地草稿,正常重启后也会恢复。 + 详见[输入与回复](UX.md)。 +5. `G` 回到最新消息,`?` 打开帮助;导航状态按 `q` 退出。 + +二维码会自动轮换。Tab 切换紧凑字符块与较大的全单元格显示,Esc 返回手机号登录。 +F2 切换已有账号,F3 添加账号;登录页和错误页也能使用。 +以上均为默认按键,可通过 [Lua 配置](Configuration.md) 修改。 + +## 使用 Cargo 从源码安装 + +Linux x86_64/ARM64、macOS Intel/Apple silicon、Windows x64/ARM64 使用同一条 Cargo +命令。Cargo 按仓库锁文件编译选定的 Git 源码,使用 release profile 安装 `tg`, +Windows 下为 `tg.exe`。 + +### 首次准备构建工具 + +通过 [rustup](https://rustup.rs/) 安装 Rust,并准备 Git 和平台的 C 编译器、链接器。 +Lua 与 SQLite 随应用编译,无需单独安装它们的系统包。 + +| 平台 | 原生构建工具 | +| --- | --- | +| Linux | C 编译器、链接器、make 和 Git;Debian/Ubuntu 可执行 `sudo apt-get install build-essential git` | +| macOS | Xcode Command Line Tools:`xcode-select --install` | +| Windows | Visual Studio C++ 构建工具和 Windows SDK,选择对应 CPU 的 MSVC 工具;ARM64 需要 ARM64 组件。参阅 [rustup MSVC 配置](https://rust-lang.github.io/rustup/installation/windows-msvc.html)。 | + +安装项目固定的编译器,不改变系统默认 Rust 工具链: + +```sh +rustup toolchain install 1.98.0 --profile minimal +``` + +### 一条命令安装或升级 + +以下命令可直接用于 Bash、Zsh 和 PowerShell: + +```sh +cargo +1.98.0 install --locked --git https://github.com/iebb/termgram --bin tg termgram +``` + +它安装仓库默认分支;再次执行即可升级到该分支的最新源码。`+1.98.0` 明确选择编译器, +在仓库外执行也有效。下载后的仓库工具链文件不会替换已经启动的 Cargo 进程。 + +默认安装位置为 Linux/macOS 的 `~/.cargo/bin/tg`,Windows 的 +`%USERPROFILE%\.cargo\bin\tg.exe`,需要把该目录加入 PATH。自定义 `CARGO_HOME` 或 +Cargo 安装根目录设置会改变位置;`--root DIR` 安装到 `DIR/bin`,参数应为 bin 的父目录。 +若旧版 `tg` 优先被找到,用 Bash/Zsh 的 `type -a tg` 或 PowerShell 的 +`Get-Command tg -All` 查看,并移除 PATH 中过期的可执行文件。 + +安装其他分支时添加 `--branch BRANCH`;固定提交则改用 `--rev COMMIT`,把占位文字替换 +为所需 Git 引用。PR 功能在合并前需要选择该 PR 的源码分支。此安装方式需要保留 Git URL; +不要改成 `cargo install termgram`,后者会查找 crates.io 上的包。 + +若要安装与已发布 Wiki 完全一致的版本,打开页尾的 **文档来源**,取得对应提交, +再用 `--rev COMMIT` 安装。这样可以避免阅读尚在评审中的新功能文档时,实际安装了默认分支。 + +`cargo uninstall termgram` 卸载 Cargo 管理的程序,保留 Termgram 配置与账号数据。 +使用过 `--root` 时,更新和卸载也应提供相同目录。`--force` 可以重编同一提交,例如修改了 +内置应用凭据后;`--target-dir DIR` 可以保留构建产物,供后续源码更新复用。 + +更多选项与安装记录规则见 [Cargo 安装文档](https://doc.rust-lang.org/cargo/commands/cargo-install.html)。 +Cargo 源码升级与 `tg update` 的区别见[更新](Updates.md)。 + +### Telegram 应用凭据 + +Git 源码安装需要在 [my.telegram.org/apps](https://my.telegram.org/apps) 创建自己的应用, +获取 API ID 与 API hash。它们标识客户端;启动 `tg` 后仍需登录 Telegram 账号。 +源码安装不会获得项目 GitHub Actions 的发行版凭据。 + +启动前设置运行时环境变量。Bash/Zsh: + +```sh +export TELEGRAM_API_ID=123456 +export TELEGRAM_API_HASH=your_api_hash +tg +``` + +PowerShell: + +```powershell +$env:TELEGRAM_API_ID = '123456' +$env:TELEGRAM_API_HASH = 'your_api_hash' +tg +``` + +请替换示例值,运行时变量优先于程序内置默认值。应用也会读取工作目录或其父目录中的 +`.env`。若需要一个不依赖运行时变量的个人可执行文件,可在构建环境中设置 +`TERMGRAM_EMBEDDED_API_ID` 与 `TERMGRAM_EMBEDDED_API_HASH` 后执行 Cargo;这些应用 +凭据会成为可执行文件的一部分,账号会话不会被内置。 + +### 安装本地 checkout + +开发时克隆仓库,可把 `.env.example` 复制为 `.env` 设置运行时凭据,再安装当前源码: + +```sh +git clone https://github.com/iebb/termgram.git +cd termgram +cargo install --locked --path . --bin tg +``` + +此时使用仓库固定的工具链。`cargo run --locked --release` 可以直接运行而不安装。 +不要提交 `.env` 或会话数据库。 + +路径与环境变量见[配置](Configuration.md),图片/按键问题见[终端集成](Terminal.md), +离线与缓存行为见[缓存与同步](Synchronization.md)。 diff --git a/docs/wiki/zh-CN/Home.md b/docs/wiki/zh-CN/Home.md new file mode 100644 index 0000000..48d3abb --- /dev/null +++ b/docs/wiki/zh-CN/Home.md @@ -0,0 +1,37 @@ +# Termgram 使用指南 + +[English](../en/Home.md) · [选择语言](../Home.md) + +Termgram 使用 Telegram 的 MTProto 用户 API,访问已有私聊和群组。最多支持八个账号槽位, +同一时间连接一个账号。重新连接时,可以先阅读本地缓存。 + +| 我想…… | 对应文档 | +| --- | --- | +| 安装、登录、从源码构建 | [快速入门](Getting-Started.md) | +| 学习快捷键、迁移旧绑定 | [快捷键](Keybindings.md) | +| 浏览命令、补全聊天或文件夹名称 | [冒号命令](Commands.md) | +| 改键、配置群聊直达、修改输入提示 | [Lua 配置](Configuration.md) | +| 阅读、回复、发文件、切换账号 | [日常操作](UX.md) | +| 按用户名打开聊天、加入邀请、查看权限 | [聊天发现与资料](Chats.md) | +| 切换 Telegram 文件夹 | [文件夹](Folders.md) | +| 置顶聊天或消息、浏览置顶 | [置顶](Pins.md) | +| 阅读投票与测验、投票或撤回 | [投票](Polls.md) | +| 添加、移除和阅读消息回应 | [回应](Reactions.md) | +| 搜索本地或云端历史并筛选 | [消息搜索](Search.md) | +| 桌面提醒、聊天静音与未读提及 | [通知设置](Notifications.md) | +| 设置聊天和文件夹颜色 | [外观](Appearance.md) | +| 预览文件、在文件管理器中定位 | [附件](Attachments.md) | +| 理解离线行为、清理缓存 | [缓存与同步](Synchronization.md) | +| 排查图片、按键或 tmux 问题 | [终端集成](Terminal.md) | +| 更新程序、了解发行规则 | [更新](Updates.md) | +| 参与开发和维护 | [开发指南](Development.md) | + +文档描述的是其来源版本;用 `tg --version` 查看已安装版本。程序里的 `?` 帮助会显示 +实际生效的 Lua 绑定。文档提供英文和简体中文;当前界面使用英文,输入框占位提示可自定义。 + +当前范围不包含广播频道、秘密聊天、通话、故事、贴纸/GIF 选择器、全服务器消息搜索、 +联系人管理、新建群组和群管理。也未实现话题导航、云草稿、多个账号同时在线、持久发送队列、 +相册、定时/静默发送及 typing 状态。 +已支持编辑、删除、复制、转发和 Saved Messages,见[日常操作](UX.md)。 +文件夹在官方客户端创建与编辑。 +支持的链接和机器人按钮见[日常操作](UX.md)。 diff --git a/docs/wiki/zh-CN/Keybindings.md b/docs/wiki/zh-CN/Keybindings.md new file mode 100644 index 0000000..328ccfd --- /dev/null +++ b/docs/wiki/zh-CN/Keybindings.md @@ -0,0 +1,108 @@ +# 快捷键 + +[English](../en/Keybindings.md) · [指南首页](Home.md) + +下表是默认值,`?` 显示当前 Lua 配置实际生效的绑定。大写字母表示 Shift 加该字母。 +`g g`、`g i`、`g n`、`g p` 是依次按下;未完成的组合键一秒后过期,Esc 可取消。 +按键作用于焦点面板或浮层,输入框里的普通字母仍然是文字。 + +| 上下文 | 按键 | 动作 | +| --- | --- | --- | +| 聊天列表 / 内容区 | g m | 浏览未读提及及回复给你的消息 | +| 搜索 / 提及 | Ctrl-R | 刷新当前搜索或未读提及 | +| 全局 | Ctrl-C / Ctrl-L | 退出 / 重绘 | +| 全局 | F2 / F3 | 下一个账号 / 添加账号 | +| 主界面 | F4 | 展开/收起侧栏,保留草稿 | +| 登录 | Enter / Esc | 提交 / 重新开始手机号登录 | +| 登录 | Tab 或 Shift-Tab | 开始二维码登录或切换二维码显示 | +| 聊天列表 | j/k 或 Down/Up | 选择下一个/上一个聊天 | +| 聊天列表 | Enter 或 Right | 打开所选聊天 | +| 聊天列表 | i | 在当前聊天输入;没有打开聊天时恢复本账号上次聊天 | +| 聊天列表 | G / g g | 过滤列表的最后/第一个聊天 | +| 聊天列表 | PageDown/PageUp | 移动十个聊天 | +| 聊天列表 | / | 在当前文件夹中过滤标题 | +| 聊天列表 | Ctrl-F | 打开本地正则搜索 | +| 聊天列表 | [ / ] | 上一个/下一个 Telegram 文件夹 | +| 聊天列表 | c / C | 聊天/文件夹颜色选择器 | +| 聊天列表 | p | 在当前文件夹中置顶/取消置顶 | +| 聊天列表 | Ctrl-K / Ctrl-J | 上移/下移置顶聊天 | +| 聊天列表 | e | 归档/恢复所选聊天 | +| 会话 | j/k 或 ]/[ | 选择下一条/上一条消息 | +| 会话 | 20k | 向上跳 20 条,必要时加载更早的分页 | +| 会话 | Up/Down | 按显示行滚动 | +| 会话 | PageUp/PageDown | 滚动十个显示行 | +| 会话 | G 或 End | 回到最新消息并跟随新消息 | +| 会话 | g u | 从首条未读继续阅读 | +| 聊天列表 / 会话 | g r / g U | 将整个聊天标为已读 / 标记未读提醒 | +| 会话 | g g 或 Home | 当前加载窗口中最早的消息 | +| 会话 | i | 回复明确选中的消息;未选择时输入消息 | +| 会话 | Enter | 激活选中项;无选择时进入输入 | +| 会话 | R / r | 回复选中/最新消息;跳到它引用的消息 | +| 会话 | o | 展开所选图片或贴纸 | +| 会话 | g n / g p | 下一个/上一个可操作项 | +| 会话 | l | 打开选中或首个支持的链接 | +| 会话 | O | 在系统文件管理器中定位附件 | +| 会话 | / / c | 本地正则搜索 / 聊天颜色 | +| 会话 | p / P | 置顶/取消置顶所选消息;打开置顶列表 | +| 会话 | v | 打开选中的[投票或测验](Polls.md) | +| 投票 | Space/Enter,然后 Ctrl-S | 选择答案,然后提交 | +| 投票 | u / Ctrl-R / Esc | 准备撤回投票 / 刷新 / 关闭 | +| 会话 | g e | 打开选中消息的[回应](Reactions.md) | +| 回应 | Enter/Space / u / Ctrl-R / Esc | 添加或移除 / 移除本人的 emoji 回应 / 刷新 / 关闭 | +| 聊天资料(`:info`) | j/k 或滚轮 / Ctrl-R / Esc | 滚动 / 刷新 / 关闭 | +| 邀请预览(`:join`) | Up/Down,然后 Enter | 选择并确认;默认选中取消 | +| 帮助 / 状态 | j/k 或滚轮 / Esc | 按换行后的内容滚动 / 关闭 | +| 置顶消息 | Enter / Esc | 打开所选消息/关闭 | +| 置顶消息 | Ctrl-N / Ctrl-P | 下一页/上一页 | +| 置顶消息 | p / U | 确认取消置顶/取消全部置顶 | +| 置顶消息 | Ctrl-R | 刷新置顶 | +| 导航 | Tab 或 Shift-Tab | 切换列表与会话焦点 | +| 会话 | Esc | 先清除消息选择,再返回聊天列表 | +| 会话 | Left | 返回聊天列表 | +| 大图预览 | Esc 或 q / i / O | 关闭 / 回复 / 显示原始文件 | +| 导航 | g i / Ctrl-R | 显示聊天和文件夹 ID / 刷新列表 | +| 导航 | ? / s / a / q | 帮助 / 设置 / 账号 / 退出 | +| 输入框 | Enter | 发送 | +| 输入框 | Shift-Enter 或 Ctrl-J | 换行 | +| 输入框 | Esc | 先取消回复,再退出输入并保留草稿 | +| 编辑器 | Left/Right、Home/End、Ctrl-A/Ctrl-E | 移动光标 | +| 编辑器 | Backspace/Delete、Ctrl-W、Ctrl-U | 删字符 / 前一个词 / 清空 | +| 聊天过滤 | Enter / Esc | 打开匹配聊天 / 清空并退出过滤 | +| 浮层 | Up/Down 或 k/j | 选择或滚动 | +| 浮层 | Enter 或 Space | 应用设置、颜色或账号选择 | +| 浮层 | Esc 或 ? | 关闭 | +| 搜索 | Enter / Esc | 搜索或打开结果 / 关闭 | +| 搜索 | Tab / Ctrl-F | 更改范围 / 编辑查询 | +| 搜索 | Ctrl-N / Ctrl-P | 下一页 / 上一页结果 | +| 搜索 | Up/Down、PageUp/PageDown | 选择结果 / 移动十个结果 | +| 会话 | e | 编辑所选消息或继续本地编辑 | +| 会话 | d | 检查所选已送达消息的删除范围 | +| 会话 | y / Y | 复制正文或说明 / 复制 Telegram 消息链接 | +| 会话 | f / g S | 选择转发目标 / 准备转发到 Saved Messages | +| 导航 | g s | 打开当前账号的 Saved Messages | +| 转发预览 | Enter / Esc 或 Ctrl-C | 转发 / 取消;已提交请求继续在后台执行 | +| 删除确认框 | Up/Down 或 k/j,然后 Enter | 选择范围并确认;默认选中取消 | +| 删除确认框 | Esc | 关闭;已经提交的请求继续执行 | +| 消息编辑 | Enter / Esc 或 Ctrl-C / Ctrl-D | 保存 / 保留并关闭 / 丢弃编辑 | +| 消息编辑 | Shift-Enter 或 Ctrl-J | 换行 | + +`20k` 这类数字前缀在两个导航面板中生效。如果希望一个快捷键直接跳 N 条, +在 [Lua 配置](Configuration.md) 中设置 `count`。 +`gg` 只到当前已加载历史的顶部,不会下载整个群从建立以来的全部消息; +阅读旧历史时,`G` 会重新加载最新页。 + +旧版默认值迁移:会话中的 `/` 现在打开本地搜索,机器人 `/命令` 请先按 `i` 再输入。 +大写 `O` 定位文件,可操作项切换改为 `g n` / `g p`,`o` 展开图片预览。 +`i` 也可从聊天列表进入输入;会话中明确选中消息时则回复该消息。账号选择使用方向键和 Enter; +导航中的数字用作数量前缀,已去掉旧的账号数字直达提示。 +Lua 解析之后不再有另一套硬编码按键兜底。 + +点击回复引用区跳到原消息。选中回复时 Enter 默认跳转该目标,`g n` / `g p` +切换其他操作。点击媒体会选中附件,此时 Enter 激活附件操作。 + +导航模式按 `:` 打开[冒号命令](Commands.md)。Tab/Shift-Tab 或 Ctrl-N/Ctrl-P +补全前后候选;Up/Down 召回匹配前缀的历史;Enter 执行;Esc/Ctrl-C 取消。 + +输入框 Ctrl-O 打开附件列表,其中 a/p/d/o/O/i 分别添加、切换照片或文件、移除、预览、定位和编辑说明,见[附件](Attachments.md)。 + +会话、输入框和附件列表中的 Ctrl-V / Ctrl-Alt-V 粘贴原生剪贴板的文件、图片或文字;也可用 `:paste`,不会自动发送。 diff --git a/docs/wiki/zh-CN/Notifications.md b/docs/wiki/zh-CN/Notifications.md new file mode 100644 index 0000000..b414e2e --- /dev/null +++ b/docs/wiki/zh-CN/Notifications.md @@ -0,0 +1,101 @@ +# 通知与静音设置 + +[English](../en/Notifications.md) · [指南首页](Home.md) + +对聊天使用 `:mute`、`:mute 1h`、`:mute 8h`、`:mute 2d` 或 `:mute forever`。 +默认是**永久静音**。`:unmute` 明确启用该聊天的通知,即使账号的私聊或群聊默认设置 +为静音。在聊天列表中,命令捕获当前选择的聊天;在内容区则捕获当前会话。命令标题 +显示目标,新消息造成的列表重排不会改变它。 + +这些命令修改 [Telegram 官方通知设置](https://core.telegram.org/method/account.updateNotifySettings), +因此会同步到官方客户端,需要网络连接。修改静音时间时,Termgram 保留已有的预览、 +静默发帖及桌面声音偏好,也保留该会话已有的故事设置。界面等待 Telegram 确认的实际 +状态,不提前假定成功。请求期间若收到新的通知设置更新,将通过刷新聊天列表取得实际 +状态;迟到快照不会覆盖新更新。失败时保留最后确认的状态,并允许重试。静音不会 +标读消息或修改草稿。 + +侧栏以 `[m]` 标记静音聊天;开启 Nerd Font 后使用划线铃铛图标。可配置的底栏 +`notifications` 组件显示当前焦点聊天的 **Muted forever**,或本地日期/时间的 +静音截止时间。取消静音或到期后该信息消失,标记和截止时间不依赖颜色区分。 +排除静音聊天的文件夹继续遵循已有的 Telegram 成员规则。 + +Lua 动作 `mute_chat`、`unmute_chat` 可绑定在 `chats` 或 `conversation` 上下文; +`mute_chat` 表示永久静音。默认没有分配快捷键,例如: + +```lua +keymap = { + { context = "chats", on = { "m" }, run = "mute_chat" }, + { context = "chats", on = { "M" }, run = "unmute_chat" }, +}, +``` + +## 未读提及与回复给你的消息 + +在聊天列表或内容区按 `g m`,或者使用 `:mentions`。**Unread mentions** 面板使用 +Telegram 的[未读提及列表](https://core.telegram.org/method/messages.getUnreadMentions), +包括回复给你的消息。侧栏中的彩色 `@` 标记有未读提及的聊天;选中提及消息后,底栏 +也显示其状态。即使关闭颜色或没有 Nerd Font,这些标记仍可辨认。 + +用上下键选择,Enter 加载上下文并选中原消息,Ctrl-N/Ctrl-P 翻页,Ctrl-R 刷新, +Esc 关闭。`/` 返回保留的结果;`:search` 切回本地正则搜索。面板固定打开时的目标 +聊天,并保留草稿。总数是搜索时 Telegram 返回的计数;其他设备发生变化后可刷新。 +收到已读或删除更新时会移除对应结果。搜索中的摘要不会标读提及。本面板没有查询输入框。 +Lua 动作 `mentions` 可用于 `chats`、`conversation`,面板按键使用 `search` 上下文。 + +只有终端获得焦点、聊天中确实绘制到消息末尾时,才确认文字提及的内容已读。加载历史、 +搜索、打开浮层或终端失去焦点时不会确认。提及的内容回执和普通聊天已读边界相互独立。 +语音、圆形视频和阅后即焚媒体需要实际消费;看到消息不会将其标记为已播放。在 Termgram +的播放流程接入这些回执前,可在其他 Telegram 客户端中消费。`:read` 将聊天历史标读, +不会假定这些媒体已经播放。 + +其他客户端的内容回执会更新消息、保留的结果和本地缓存。较慢的历史或搜索快照不会 +恢复旧的未读标记。提及导航需要连接;重连后可用 Ctrl-R 重试。 + +## 桌面提醒 + +默认对新收到、尚未看到的入站消息启用桌面提醒。终端获得焦点且消息已经显示在当前 +聊天中时保持安静,即使已读回执尚未完成。同一聊天在 800 毫秒内的消息会合并; +队列限制聊天数和消息数,系统投递在输入、绘制之外进行。 + +在 `config.lua` 返回的表中配置: + +```lua +notifications = { + enabled = true, + backend = "auto", + when = "unseen", + previews = true, + sound = true, + group_delay_ms = 800, +}, +``` + +- `enabled = false` 关闭本机桌面投递;Telegram 静音设置仍会同步。 +- `when = "unseen"` 允许在 Termgram 获得焦点时提醒其他聊天的消息;`"unfocused"` + 只在终端失去焦点时提醒。两种模式都遵守已读边界。 +- `previews = false` 隐藏消息正文和发送者摘要。开启时也要遵守 Telegram 的实际预览 + 设置;被标为保护内容的消息使用通用提示。标题包含账号和聊天名称。 +- `sound = false` 请求无声的原生提醒;Telegram 静默消息以及聊天的禁用声音设置也 + 保持无声。不会下载 Telegram 自定义铃声;允许声音时使用系统默认声音。 +- `group_delay_ms` 范围是 100–5000 毫秒,从第一条消息起固定计时,不会因为聊天 + 持续活跃而无限推迟投递。 + +`auto` 在本地使用系统通知。通过 SSH 时,在检测为 Ghostty、iTerm2、Kitty、Warp、 +WezTerm 的终端中使用 OSC 9,否则使用 BEL。本地原生投递报错时可以回退到终端。 +`native`、`osc9`、`bell` 可明确选择后端。OSC 9/BEL 无法要求安静投递,因此会跳过 +禁用声音的提醒。终端通知需要终端自身的通知权限;tmux 使用既有的 +[穿透设置](Terminal.md)。原生通知复用 notify-rust 的 macOS、Windows、Linux 集成, +Linux 需要桌面会话总线。没有增加原生通知回调等待或第二个终端输入 reader。 + +Termgram 异步读取 Telegram 实际生效的通知偏好。静音聊天保持安静;如果提及或回复 +给你的消息来自未静音的发送者,则按 Desktop 规则允许提醒,聊天的预览和声音设置仍然 +生效。设置未知或读取失败时不会弹出提醒,请求有并发和时间限制。其他客户端的设置 +变更会使旧快照和已排队的投递失效。 + +启动和重连后重新建立提醒时间范围:只有时间晚于该次连接边界、且不超过 30 秒的消息 +可以提醒。旧缓存、补回的历史、重复消息、自己发送的消息及收藏夹不会提醒。这会避免 +断网期间错过的消息在重连后集中弹出;消息本身和未读计数仍正常显示。新旧判断使用 +系统时钟和 Telegram 消息时间。 + +读取、删除、静音、切换账号或退出会取消相应的待发送提醒。合并发生在交给系统之前; +已经投递的通知由操作系统管理。此界面暂不提供点击通知跳转、通知内回复或替换已投递卡片。 diff --git a/docs/wiki/zh-CN/Pins.md b/docs/wiki/zh-CN/Pins.md new file mode 100644 index 0000000..e94f2cb --- /dev/null +++ b/docs/wiki/zh-CN/Pins.md @@ -0,0 +1,39 @@ +# 聊天与消息置顶 + +[English](../en/Pins.md) · [指南首页](Home.md) + +## 聊天列表 + +选中聊天后按 `p`,在当前文件夹内置顶或取消置顶;`Ctrl-K` / `Ctrl-J` 调整置顶顺序。 +主列表、Archive 和自定义文件夹使用独立的 Telegram 服务端顺序。其他聊天收到新消息 +不会超过置顶项;重启后先读取缓存的顺序。归档和文件夹包含规则见[文件夹](Folders.md)。 + +## 会话内置顶 + +用 `j/k` 选中已发送的消息,再按 `p`。确认浮层按聊天类型提供 Telegram 对应选项: +普通私聊默认“仅为自己置顶”,另一选项为双方置顶;群内新的置顶默认通知成员, +也可以静默置顶。消息早于已知的最新置顶时,按 Desktop 的旧消息提示静默置顶。 +Saved Messages 不需要选择另一参与者。用方向键选择、Enter 确认、Esc 关闭。 +服务端接受后才更新状态;权限不足或服务端错误会明确显示。 + +在已置顶消息上按 `p` 可确认取消置顶,使用 Telegram 的常规取消置顶操作。 +确认浮层会先展示消息信息。 + +有置顶消息时,会话顶部显示一行紧凑提示。按 `P` 打开所有置顶消息,按消息从新到旧 +排列。`j/k` 选择,`Ctrl-N` / `Ctrl-P` 翻页,Enter 打开该消息及较早上下文,Esc 返回。 +`p` 取消所选消息置顶,`U` 确认取消全部置顶,`Ctrl-R` 刷新或重试。 +所有按键均可配置:列表使用 Lua `pins` 上下文,确认浮层使用 `overlay`。 + +打开置顶消息会保留草稿和阅读位置,不会把整个会话标为已读;`G` 回到最新历史。 +其他客户端的置顶、取消置顶、删除操作会更新列表与顶部提示。后台请求限制并发与 +每页大小,浏览置顶时仍继续加载历史和接收实时更新。 + +如果 Telegram 要求重新同步,打开的置顶列表会重新加载,过期消息的确认浮层会关闭, +草稿仍会保留。 + +联网前也可以浏览已缓存的置顶消息,界面会标明缓存范围;消息缓存有保留上限, +因此缓存列表可能不完整。联网后用服务端分页结果更新。修改置顶需要联网。 + +协议参考:[Telegram 消息置顶](https://core.telegram.org/api/pin)、 +[置顶选项](https://core.telegram.org/method/messages.updatePinnedMessage)。 +Desktop 行为参考:[置顶提示框](https://github.com/telegramdesktop/tdesktop/blob/4d4da471fbee771c10e173a83c003ba1728989f1/Telegram/SourceFiles/boxes/pin_messages_box.cpp)。 diff --git a/docs/wiki/zh-CN/Polls.md b/docs/wiki/zh-CN/Polls.md new file mode 100644 index 0000000..188ae8c --- /dev/null +++ b/docs/wiki/zh-CN/Polls.md @@ -0,0 +1,42 @@ +# 投票与测验 + +[English](../en/Polls.md) · [指南](Home.md) + +投票题目和选项直接显示在消息中。选中投票后按 `v` 或运行 `:poll` 打开投票面板; +当前消息动作是投票时,Enter 也可以打开。面板会先刷新消息,离线时仍可阅读缓存。 + +| 投票面板内 | 操作 | +| --- | --- | +| j/k 或 Down/Up | 移动到其他选项 | +| 点击选项、Space 或 Enter | 在本地勾选或取消选项 | +| Ctrl-S | 提交所选答案 | +| u,再按 Ctrl-S | Telegram 允许时撤回已有投票 | +| Ctrl-R | 刷新,并恢复服务器记录的本人选择 | +| PageUp/PageDown、Home/End、鼠标滚轮 | 滚动长题目、选项或错误提示 | +| s | 展开或隐藏剧透 | +| Esc 或 q | 关闭面板 | + +勾选不会立即投票。单选题只保留一个选项,多选题可以勾选多个。 +清空选择后按 Ctrl-S 才会撤回已有投票。测验及禁止重新投票的题目不允许修改答案。 +关闭面板会丢弃尚未提交的选择;已经提交的请求继续在后台执行。 +请求失败或无法确定结果时,需要先刷新再重试;刷新会用服务器确认的选择替换本地选择。 + +提交前,面板会说明这是公开投票还是匿名投票。成员资格、地区限制及其他投票条件由 +Telegram 验证。题目选项变化会使当前选择失效。发送前还会重新检查最新消息, +使用 Telegram 原始的选项 ID 提交,避免选项重排后投到另一个答案。 + +投票后或题目关闭后显示票数及百分比,并遵守“关闭前隐藏结果”的设置。 +测验解析在结果可用时显示。剧透默认隐藏;第一次选择隐藏的投票会先显示内容,不勾选答案。 +投票状态也显示在底栏的 `message` 组件中,操作提示使用实际生效的 Lua 快捷键。 + +实时更新会同步同一投票的缓存副本。可见投票还会在后台定期刷新,通常间隔 30 秒, +最多两个请求同时进行。离开视图或终端失去焦点后不再发起新刷新;确认已关闭的投票 +停止定期刷新。离线缓存中的票数可能较旧;打开投票面板时始终请求最新信息。 + +本版本支持阅读普通投票和测验、投票及撤回。尚未支持创建投票、查看投票者列表、 +为开放选项题目添加答案,以及题目或答案中的媒体。媒体投票会明确显示只读提示, +需要在官方客户端查看并投票。 + +可以在 `conversation` 中用 `run = "poll"` 改绑入口。`poll` 上下文支持 +`up`、`down`、`toggle_poll_answer`、`send`、`retract_vote`、`refresh`、`spoilers`、 +`page_up`、`page_down`、`home`、`end` 和 `cancel`。 diff --git a/docs/wiki/zh-CN/Reactions.md b/docs/wiki/zh-CN/Reactions.md new file mode 100644 index 0000000..526f000 --- /dev/null +++ b/docs/wiki/zh-CN/Reactions.md @@ -0,0 +1,41 @@ +# 消息回应 + +[English](../en/Reactions.md) · [指南](Home.md) + +回应以紧凑的一行显示在消息下方,包含 emoji 和数量。本人选择有 `●` 标记并使用 +强调色。底栏显示回应总数,以及当前动作的快捷键。 + +选中已送达的消息后按 `g e`,或运行 `:react`。已有回应行也可以用鼠标选中, +再按 Enter 打开选择器。选择器会读取 Telegram 在当前聊天中允许的 emoji。 + +| 回应选择器内 | 操作 | +| --- | --- | +| j/k 或 Down/Up | 选择回应 | +| PageUp/PageDown、Home/End | 翻页或跳到首尾 | +| 鼠标点击 / 滚轮 | 选中一行 / 移动选择 | +| Enter 或 Space | 添加选中的 emoji;已由本人添加则移除 | +| u | 移除此消息上本人的全部普通及自定义 emoji 回应 | +| Ctrl-R | 刷新数量、本人选择和聊天权限 | +| Esc 或 q | 关闭 | + +点击只选择,Enter 和 Space 才提交。一次只允许一个修改请求。 +关闭面板后,已经提交的请求继续执行,失败仍会显示在底栏。 +出错或无法确定结果时,先刷新再重试。按住按键产生的重复事件不会反复提交。 + +每次修改都会重新读取确切消息、可用回应和 Telegram 的数量限制,并保留其他选择的顺序。 +添加后超过个人上限时,会按官方行为替换最早的选择。 +如果本人选择已在其他客户端改变,会提示刷新,避免覆盖该变化。 +不可用 emoji、聊天的不同回应种类上限都会检查,最终权限以 Telegram 为准。 + +回应数量会缓存,离线也能阅读。实时更新同步到对话、搜索结果、置顶和回复预览, +不包含本人选择的部分更新不会清掉已知选择。有回应的可见接收消息还会批量定期刷新, +视图不变时最多每 30 秒一次,同时最多一个批量请求;隐藏或失去焦点时不发起新请求。 + +本版本支持添加和移除普通 emoji。已有自定义 emoji 的数量以文本回退显示, +修改其他回应时保留其身份。尚未提供自定义 emoji 选择、动画、回应者列表、 +付费回应或 Saved Messages 标签编辑。可以阅读星星计数,不发送支付请求; +Saved Messages 标签会显示只读原因。 + +入口动作是 `conversation` 中的 `reactions`;选择器使用 `reactions` 上下文, +支持 `open`、`clear_reactions`、`refresh`、`up`、`down`、`page_up`、`page_down`、 +`home`、`end` 和 `cancel`。 diff --git a/docs/wiki/zh-CN/Search.md b/docs/wiki/zh-CN/Search.md new file mode 100644 index 0000000..ec37787 --- /dev/null +++ b/docs/wiki/zh-CN/Search.md @@ -0,0 +1,68 @@ +# 搜索消息 + +[English](../en/Search.md) · [指南首页](Home.md) + +默认在聊天内容区按 `/`,或在聊天列表按 `Ctrl-F`,打开本地搜索;`:search [正则]` +明确选择本地搜索。输入正则表达式后按 Enter。Tab 在当前 +聊天、当前文件夹、当前账号三个范围间切换。搜索可以离线使用,不会向 Telegram +请求历史记录。标题显示范围,结果显示缓存消息数与日期范围。这些数字只表示缓存 +覆盖情况,不代表已下载完整历史;日期范围内也可能有缺口。 + +结果从新到旧排列,每页 100 条命中。Up/Down 选择结果,PageUp/PageDown 移动十行, +Enter 打开消息及其缓存前后文。Ctrl-N/Ctrl-P 翻页,Ctrl-F 返回查询输入,Esc 关闭。 +在聊天中再次按 `/` 可回到保留的结果。`G` 从历史结果回到最新消息。打开历史搜索 +结果本身不会把聊天中尚未阅读的消息全部标为已读。 + +示例:`(?i)invoice` 忽略大小写,`error.*timeout` 匹配两个词之间的内容,`^TODO` +匹配开头,`东京|上海` 匹配任意一个词。语法采用维护中的 Rust +[regex 库](https://docs.rs/regex/latest/regex/#syntax),不支持环视和反向引用。错误的 +表达式会显示可修正的错误提示;表达式最多 4096 字节,编译内存也有上限。匹配消息 +文字和附件说明,不索引附件内部内容。 + +扫描在独立后台工作线程中进行,同时最多一个扫描和一个可替换的待执行请求。Esc、 +修改查询或切换账号会取消扫描。重新搜索会反映已写入缓存的编辑和删除。打开命中项 +前会再次检查缓存;消息已删除或被清理时,会提示重新搜索。 + +在已打开的聊天中使用 `:search --cloud` 进入 Telegram 云端搜索。同一面板会标明 +**Telegram search**、捕获的聊天、当前筛选条件及 Telegram 返回的结果数量。文字使用 +Telegram 的词语搜索规则,不是 Rust 正则。云端模式需要网络连接,只搜索指定聊天, +Tab 不切换范围。按 Esc,再使用 `:search --cloud ...` 可修改筛选条件或目标聊天。 +`/` 返回保留的结果,包括云端结果;`:search` 返回本地正则搜索。 + +示例: + +```text +:search --cloud release notes +:search --cloud --from me --media file +:search --cloud --from @alice --after 2026-09-01 --before 2026-10-01 release +:search --cloud --media photo +:search --cloud -- --literal-leading-dash +``` + +筛选参数放在查询文字前。`--from` 接受 `me`、`@username` 或已知的 Telegram peer ID; +用户名在一次搜索中只解析一次,后续翻页保持同一发送者身份。`--media` 支持 `all`、 +`photo`、`video`、`file`、`music`、`voice`、`round`(圆形视频)、`gif`、`link`、`poll`。 +日期为 `YYYY-MM-DD`,按 **UTC 零点**计算:`--after` 严格晚于该时刻,`--before` +严格早于该时刻。日期必须落在 Telegram 的 32 位时间戳范围内;界面接受 +1970-01-02 至 2038-01-19。筛选使用 +[Telegram messages.search API](https://core.telegram.org/method/messages.search)。 +只有云端参数使用现有的 Yazi 平台引号规则(Unix shell 引号或 Windows 命令行引号), +不会执行 shell。本地正则参数仍保留反斜杠和末尾空格。 + +云端每页最多 100 条,通过有界预取判断下一页。只有筛选条件、没有文字的命令会先打开 +输入框;按 Enter 可直接筛选,也可输入文字后再搜。Telegram 拒绝查询时,保留查询并 +允许修改。在结果上按 Enter 会加载原消息及其之前最多 79 条消息,然后定位原消息, +不会因此把尚未阅读的历史全部标为已读。`G` 回到最新消息。正文与附件草稿均保留。 + +Esc 或新搜索会取消旧云端任务,迟到的结果不会替换新查询。显示和持久化之前,云端结果 +会与期间收到的编辑、删除合并。结果写入同一个有上限的缓存,不推进同步游标。已有结果 +反映实时编辑和删除;需要刷新匹配项和服务端计数时请重新搜索。聊天历史变化时,数量和 +分页并非冻结的快照。历史重新同步会使旧结果失效并提示重搜。缓存仍受原有容量限制, +云端搜索不会下载整个聊天。 + +所有操作都可在 Lua 的 `search` 上下文中配置:`open`、`cancel`、`up`、`down`、 +`page_up`、`page_down`、`search_scope`、`search_query`、`search_more`、 +`search_previous`。 + +Ctrl-R(`refresh`)从第一页重新搜索。`g m` / `:mentions` 复用同样的翻页和原消息跳转, +用于[未读提及](Notifications.md)。 diff --git a/docs/wiki/zh-CN/Synchronization.md b/docs/wiki/zh-CN/Synchronization.md new file mode 100644 index 0000000..66556c9 --- /dev/null +++ b/docs/wiki/zh-CN/Synchronization.md @@ -0,0 +1,48 @@ +# 缓存与同步 + +[English](../en/Synchronization.md) · [指南首页](Home.md) + +Termgram 会先显示本地缓存的聊天列表,再连接 Telegram。打开已有缓存的聊天时, +先读取未读边界附近的本地消息;没有未读时读取最近一页,同时在后台刷新。同步期间仍可阅读缓存。 +发送消息等服务端操作会等待登录完成。 + +拉取历史、刷新聊天列表等请求不会阻塞实时消息。对于正在查看的群聊,还会按照 +Telegram 返回的间隔主动补取差异。离开聊天或终端失去焦点后,会停止这项主动轮询; +普通推送和差异恢复继续运行。 + +自动已读回执以实际显示的会话为准:终端有焦点时,成功绘制后只确认到末尾已出现在 +可见区域内的最新入站消息。打开聊天、加载历史或收到消息本身不会将其标为已读。 +弹窗、隐藏的会话面板和后台窗口不会产生回执。跳转查看较早的回复也不会将整个聊天 +标为已读。 + +每个聊天会缓存服务端的入站已读边界。同一聊天的回执会合并,失败后退避重试;较晚 +返回的确认不会清掉边界之后的新消息。剩余未读数通过有时限的单聊天请求更新。 +如果计数刷新失败,或新消息比旧计数先到达,显示的未读数可能暂时偏高,直到下一次 +服务端更新或回执成功。旧版缓存会先从 Telegram 获取已读边界,再产生自动回执。 + +每个会话文件旁都有独立的 `*.cache.sqlite3` 数据库,保存消息正文、聊天元数据和 +已下载附件的路径。登录凭据和用户设置分别保存。Unix 系统下数据库文件只允许当前 +用户访问;数据库本身不加密。 + +缓存只包含 Termgram 已收到或加载过的消息,不代表账号的完整历史。每个账号最多 +保留 100,000 条消息记录,旧记录会被清理。如果 Telegram 已无法重放某段过旧的差异, +对应的缓存会失效,并在查看时重新拉取。 + +清理缓存时,先退出 Termgram,再删除对应会话的 `.cache.sqlite3` 文件。保留 +`.session`、`settings.conf` 和 `appearance.json` 即可保留登录状态与偏好。媒体文件可 +单独删除对应的 `.media` 目录。同一账号缓存同时只允许一个进程持有。下次启动会重新建立消息缓存。 + +草稿保存在独立数据库:基础会话路径后加 `.state.sqlite3`,例如 +`termgram.session.state.sqlite3`。各账号槽位共用此文件,记录按 Telegram 用户 ID、聊天 +和可选话题 ID 隔离。它保存本地消息正文和回复对象,不保存登录密钥,文件本身不加密。 +**清理消息缓存时保留此文件。** 消息缓存的淘汰和同步缺口失效不会删除草稿。 + +已有缓存的账号会先恢复草稿,再显示会话。SQLite 读取与合并写入运行在终端线程之外。 +正常退出会写完最后一批编辑;写入失败时在应用内提示并重试。事务失败会保留之前的草稿。 +重启后同时恢复正文、回复对象和 Unicode 光标位置。 + +完整更新批次及其同步进度按顺序提交。写入中断后可能重放已收到的更新,但不会用 +超前的进度跳过尚未提交的消息。Telegram 会话中的 peer 元数据与消息缓存进度分开管理。 + +底层 Grammers 更新引擎遵循 [Telegram 更新协议](https://core.telegram.org/api/updates)。 +目前尚未进行与 Telegram Desktop 的端到端延迟对比测试。 diff --git a/docs/wiki/zh-CN/Terminal.md b/docs/wiki/zh-CN/Terminal.md new file mode 100644 index 0000000..7b1a85a --- /dev/null +++ b/docs/wiki/zh-CN/Terminal.md @@ -0,0 +1,41 @@ +# 终端集成 + +[English](../en/Terminal.md) · [指南首页](Home.md) + +固定版本的 Yazi 终端库负责输入解析、能力探测、TTY 输出和模式恢复。Ratatui 使用同一个 +输出通道;Crossterm 仅作为输出后端,不另起输入读取器。粘贴、焦点、鼠标、窗口大小、 +增强按键报告和关联 Unicode 文字进入同一个应用事件流。 + +Shift-Enter 需要终端能够单独报告,不能区分时用 Ctrl-J 换行。功能键或 Alt 组合也可能 +被终端或桌面拦截,可在 [Lua](Configuration.md) 中换键。显示损坏时可用 Ctrl-L 重绘。 + +## 图片 + +Yazi 选择图形协议,`ratatui-image` 负责多个内联图片及滚动时的裁剪。 +Ghostty/Kitty 可以使用 Kitty 图形协议,iTerm2/WezTerm 可以使用内联图片,检测到支持时 +使用 Sixel;其他终端回退到 Unicode 半块字符,不需要额外图片覆盖进程。 +实际选择取决于终端能力报告和中间的复用器。 + +解码保留上游 ICC 到 sRGB 转换并处理 EXIF 方向;解码和编码不在 UI 线程中进行。 +只保留可见图片的编码缓存,调整窗口或打开浮层时清理对应图像区域。 +动态贴纸使用静态缩略图。下载与重试说明见[附件](Attachments.md)。 + +## tmux + +默认使用 tmux 自身报告的能力。如果需要 Yazi 第二阶段探测,必须在启动前设置到 +**shell 环境**: + +```sh +TERMGRAM_TMUX_PASSTHROUGH=1 tg +``` + +这会调用上游 tmux 设置,把窗格的 `allow-passthrough` 设为 `all`,服务器的 +`input-buffer-size` 设为 `104857600`。退出 Termgram 后这些设置仍保留。 +读取该选项时,Telegram 凭据 `.env` 尚未加载。 + +正常退出、初始化失败和 panic hook 都会恢复终端模式。文本与图形清理由单一所有者协调。 +上游版本、许可证与局部修改记录在 [vendor 说明](../../../vendor/README.md)。 + +## 剪贴板 + +Ctrl-V / Ctrl-Alt-V 和 `:paste` 在检测到支持时使用 OSC 5522,否则由 arboard 在后台读取系统剪贴板。MIME 粘贴复用 Yazi 解析器与格式化器,用请求 ID 区分操作并限制传输体积;终端所有者负责开启和恢复协商后的模式。可用 `attachments.terminal_clipboard = false` 关闭,普通文字粘贴仍可用。平台、体积限制与 WSL/SSH 行为见[附件](Attachments.md)。 diff --git a/docs/wiki/zh-CN/UX.md b/docs/wiki/zh-CN/UX.md new file mode 100644 index 0000000..c4faeae --- /dev/null +++ b/docs/wiki/zh-CN/UX.md @@ -0,0 +1,179 @@ +# 日常操作 + +[English](../en/UX.md) · [指南首页](Home.md) + +## 阅读和导航 + +宽窗口并排显示紧凑聊天列表和会话,输入框在会话下方对齐。F4 切换侧栏; +窄窗口一次显示一个面板,F4 或返回 Chats 显示列表并保留草稿。焦点边框与选择标记表明按键 +将作用于哪里。Tab 切换焦点,Esc 先清除明确的消息选择,再从会话返回列表。 +`j/k` 选择消息,方向键按实际显示行滚动,`G` 回到最新页。 +阅读较早消息时,新消息保留当前阅读位置并显示计数,不会强行把视图拉到底部。 + +有未读消息的聊天会从保存的入站已读边界开始。紧凑的 `Unread messages` 分界会留在 +进入时的位置,不会随着回执移动。先显示本地页面,Telegram 确认位置后才开始自动回执。 +按 `g u` 或 `:unread` 可再次定位到当前首条未读。读到一页末尾时继续按 `j`、Down 或 +PageDown,会加载后续历史,不会跳过中间消息。还有后续页时,底栏显示 `continue` 和 +`latest` 提示;`G` 明确回到最新页。位置请求失败时保留缓存,可用 `g u` 重试,或用 `G` +离开该位置。 + +`g r` / `:read` 将目标聊天全部标读;`g U` / `:mark-unread` 设置 Telegram 的未读提醒。 +没有实际未读消息时,提醒显示为彩色圆点,不会倒退已发送给其他人的回执。重新打开并 +显示聊天会清除提醒,普通自动回执仍只确认实际可见的消息。打开命令栏后即使列表重排, +命令仍作用于原先选中的聊天。 + +列表里的 `[`/`]` 切换[文件夹](Folders.md),`/` 过滤当前文件夹的标题。 +[预设聊天别名](Configuration.md) 按稳定的 Telegram ID 从 All chats 打开目标; +`g i` 显示聊天和文件夹 ID,便于配置。 + +先显示缓存,连接状态与加载提示说明后台同步进度。本地搜索和本地偏好可以离线使用; +发送以及服务器操作需要完成认证并联网。待执行命令队列有上限,发送失败会保留可重试消息。 + + +底栏显示键盘模式、账号和连接信息。选择消息会改变上下文提示;INSERT 表示正在输入。 +可以在 [Lua](Configuration.md) 中配置可选 Ping/DC 诊断与阅读位置。 +长错误在底栏上方展开,保留具体原因和当前模式。 + +作者、回复引用、正文和媒体分行显示。当前消息与当前操作有各自的可见标记; +完整作者名自动折行;消息默认不留空白分隔行,以交替底色区分。时间、可选 ID、 +送达和媒体状态集中在底栏。 + +## 阅读带格式的消息 + +支持 Telegram 粗体、斜体、下划线和删除线,以及嵌套样式和 Unicode 表情。 +行内代码高亮;代码块保留缩进和行尾空格,并在提供时显示语言。引用使用缩进竖线。 +代码和正文按终端可用宽度换行。自定义表情保留 Telegram 提供的 Unicode 回退文字, +终端不播放其动画。普通消息里的 Markdown 符号保持原样。 + +剧透默认以遮罩显示。点击对应消息行,或用 `g n` / `g p` 选中剧透操作后按 Enter, +可以揭示或再次隐藏;`:spoiler` 对选中消息执行同样操作。Telegram 可折叠引用默认 +最多显示三行,点击引用或在对应操作上按 Enter 可展开/收起,也可使用 `:quote`。 +底栏显示当前可用操作。回复原消息的跳转仍保留独立操作和点击区域。 + +揭示状态仅属于当前账号和进程;正文或格式变化后重新隐藏。回复摘要、聊天预览、 +搜索结果、置顶摘要、转发/删除预览以及通知始终遮住剧透。有剧透的消息在揭示前 +不显示其链接操作行,包括隐藏链接的真实目标。显式复制和编辑仍使用完整原文。 +格式随消息缓存;旧缓存会在重新获取该消息时补齐。当前读取服务端提供的格式, +输入框暂不提供创建格式的操作。Lua 可在 `conversation` 上下文绑定 `spoilers` +和 `expand_quote`。 + +## 输入和回复 + +点击输入框或按 `i` 输入。聊天列表中的 `i` 会继续当前会话;没有打开的会话时,恢复 +当前 Telegram 账号上次打开的聊天,重启后也有效。目标不可用时选择列表中高亮的聊天, +空列表会提示等待同步。空白输入框显示可配置的 ghost text,不再常驻另一行重复发送按键。 +Enter 发送,Shift-Enter 或 Ctrl-J 换行,机器人命令也在这里输入,包括开头的 `/`。 +每个聊天保留独立的本地草稿,包含正文、回复对象和光标位置。Esc、切换账号和正常重启都会 +保留草稿。草稿使用 Telegram 用户身份隔离;复用本地账号槽位不会显示另一个账号的草稿。 +后台以 400 毫秒窗口合并编辑,正常退出时写完最后一批。强制终止或断电可能丢失尚未提交的编辑。 +本版本的草稿仅保存在这台设备,尚不包含云草稿同步。 + +选中消息按 `i` 或 `R` 回复;未选择时 `R` 回复最新消息。输入框显示回复目标。 +点击输入框会保留已有回复草稿,不会因为消息选择而新建回复。 +第一次 Esc 取消回复关系并保留文字,第二次 Esc 返回导航。`r` 跳到消息引用的目标。 +发送失败会保留文字和回复目标;激活失败的发出消息,可回到输入框重试。 + +回复引用显示原作者和最多两行摘要,点击引用区或选中回复后按 Enter 跳到原消息; +`r` 也可直接跳转。先查内存和本地缓存,同屏缺失目标再合并为有界后台请求。 +尚在加载与原消息不可用会在底栏区分,编辑/删除不会被晚返回的旧摘要覆盖。 + +## 编辑消息或说明文字 + +选中已送达消息,按 `e` 或输入 `:edit`。应用先获取服务器最新原文,检查作者、媒体类型 +及 Telegram 配置中的编辑时限。转发、机器人代发或不支持的消息会在底栏说明原因; +聊天权限最终以服务器返回为准。 + +编辑框显示聊天与消息编号,底栏显示 EDIT 和当前快捷键。Enter 保存,Shift-Enter 或 +Ctrl-J 换行。Esc/Ctrl-C 关闭并保留编辑,`e` 或 `:edit` 继续当前聊天保存的编辑。 +编辑框中 Ctrl-D,或 `:edit-discard`,丢弃这份本地编辑。每个聊天保存一份编辑, +与普通草稿、回复对象和附件分开;采用本地草稿的保存机制,正常重启或切换账号后保留。 + +保存失败保留输入;服务器原文已经变化时提示冲突,不用服务器内容覆盖你的修改。 +要从最新原文重新开始,可丢弃本地编辑后重新打开。Telegram 没有原子“比较版本再编辑” +接口,因此校验与提交之间的短暂时间内仍可能与其他客户端竞争。允许清空媒体说明, +纯文字消息不能保存为空;此编辑器不更换媒体本身。 + +修改范围前后的格式按 UTF-16 偏移保留;覆盖整个修改范围的样式相应调整长度。 +部分被修改的实体,以及内容改变的隐藏链接/自定义表情目标,会被移除,避免错误关联。 +一次编辑多个分散位置时,首末修改之间的格式也可能被移除。编辑更新走现有有序同步流, +并更新缓存和回复预览;选中消息的编辑时间显示在底栏。 + +## 文件、链接和鼠标 + +用 `:attach <路径...>` 将文件加入聊天草稿。默认粘贴路径保留为文字。 +输入框 Ctrl-O 打开附件列表,可选发送格式、预览和移除;回到输入框按 Enter 发送, +第一份文件携带当前文字和回复目标。详见[附件](Attachments.md)。 + +点击媒体选择后,`o` 打开大图、`i` 回复。用 `g n` / `g p` 选择可操作项,Enter 激活。Telegram URL 实体会显示为可选择的链接行, +包括隐藏在文字后的链接。公开 `t.me`、`telegram.me`、`tg://resolve` 聊天/消息链接 +在应用内打开;私有 `t.me/c`、`tg://privatepost` 支持已知群组。其他 HTTP(S) 链接由 +系统打开。邀请链接会先显示确认预览(见命令页);广播频道链接仍不支持。 +支持 URL、web-view、callback、game 机器人按钮;支付、密码保护、联系人/位置和选择 +聊天对象的按钮会提示需要图形客户端。 + +鼠标是可选入口:单击聊天打开,单击消息或操作项选择,右键消息回复,滚轮滚动指针所在面板。 +点击聊天列表或消息区会退出输入模式并保留草稿。 +设置与账号行也可点击;对应键盘操作始终可用。 + +## 设置与账号 + +`s` 打开自动检查更新、发行通道、Enter 下载行为、底栏中的消息 ID 设置。 +`c`、`C` 打开[颜色选择器](Appearance.md)。这些偏好会持久保存,不改写 Lua。 +Esc 关闭浮层并恢复先前视图;`?` 显示当前生效的绑定。 + +`a` 打开账号选择器,用方向键选中账号或添加行,再按 Enter。 +F2 轮换已有槽位,F3 添加槽位,最多八个。只有当前账号运行网络工作任务。 +会话、消息、媒体、颜色覆盖按账号隔离;切换时重置搜索和会话视图,保留各账号的本地草稿。 + +## 删除消息 + +选中已送达消息,按 `d` 或输入 `:delete`。应用从服务器获取最新消息,展示聊天、 +消息预览和可用范围。默认选中“取消”;用 Up/Down 或 k/j 选择范围,再按 Enter 提交。 +普通聊天按权限提供“仅自己”和“所有人”;超级群采用 Telegram 的全体删除语义; +Saved Messages 仅自己删除。此入口暂不处理服务消息,最终权限由服务器判断。 + +提交前再次核对消息所属聊天、版本及权限。确认期间消息发生变化会使本次预览失效, +需要关闭后重开。Telegram 没有原子“比较版本再删除”接口,最后核对与请求之间仍可能 +发生远端修改。失败保留消息,并将选择恢复为取消。Esc 关闭确认框;已提交的请求继续 +执行,结果显示在底栏。删除经有序同步流移除缓存、搜索结果与回复预览,然后才保存 +对应同步进度,避免重启后残留已删除内容。 + +## 复制消息内容 + +选中已送达消息,按 `y` 或使用 `:copy` / `:copy text` 复制正文或说明文字,不附加作者 +和时间。`Y` / `:copy link` 向 Telegram 获取消息链接,并在可用时包含话题/线程。 +超级群和频道支持消息链接;私有链接仍需要访问权限。普通私聊和基础群没有可导出的消息链接。 + +复制时在线检查最新消息和聊天权限;受保护内容、无文字或不可用消息不会改动剪贴板。 +请求期间即使切换聊天,仍以开始时的消息为目标。最多同时准备一份内容、写入一次剪贴板。 +底栏显示错误,所选消息的操作提示使用 Lua 中实际绑定的复制键。 + +本机剪贴板在后台写入。Linux 在应用运行期间保留剪贴板所有者;退出后是否还能粘贴取决于 +桌面的剪贴板管理器。WSL 必要时使用 Windows PowerShell。SSH 通过 Yazi 的 OSC 52 +向所连接的终端请求复制;tmux 也会收到终端复制请求。本机写入失败时同样回退到 OSC 52。 +终端协议不提供送达确认,因此“Clipboard request sent”表示已发送请求;终端或 tmux +设置仍可能阻止复制。内容最多 100,000 个 UTF-8 字节,不写入日志或子进程命令参数。 + +## 转发与 Saved Messages + +选中一条已送达消息,按 `f`,或输入 `:forward `。Tab 复用 `:chat` 的缓存聊天、 +ID 和 Lua 别名补全;`saved` 表示当前账号的 Saved Messages。命令缺少参数时按 Enter +不会自动选定目标。选好目标后,预览展示目标名称/ID、账号、来源和最新消息。 +Enter 转发,Esc/Ctrl-C 取消;对应可配置 `forward` 上下文中的 `send` / `cancel`。 + +`g S` / `:save` 打开以 Saved Messages 为目标的同一个预览;`g s` / `:saved` 只打开 +收藏聊天,不转发任何内容。目标来自已认证账号身份,不依赖最近聊天中是否存在同名项。 +已经缓存的 Saved Messages 聊天可离线打开。 + +原生转发保留来源署名与媒体,不改动来源和目标聊天的正文草稿。展示预览前和实际提交前 +都会检查最新内容保护。服务消息、过期媒体、受保护内容及服务器限制会显示错误。 +远端编辑/删除会使已打开预览失效;目标发送权限最终由服务器决定。最后检查与提交之间 +仍可能发生远端修改。 + +失败保留预览和同一个 Telegram 去重 ID,供再次发送。提交前 Esc 丢弃预览;提交后 Esc +关闭视图,请求继续执行。如果后台转发失败,`f` 可继续该预览。预览与重试 ID 当前只在 +本次进程中保留;取消后重新选择目标属于新的转发意图。此入口只转发明确选中的一条消息, +不将整组相册或多选消息一起发送。当前目标选择器按聊天选择,不能指定某一个群话题。 + +新建私聊使用 `:open @username`,打开后按 `i` 输入。`:info` 查看聊天身份和发送限制。 +邀请和权限说明见[聊天发现与资料](Chats.md)。 diff --git a/docs/wiki/zh-CN/Updates.md b/docs/wiki/zh-CN/Updates.md new file mode 100644 index 0000000..6cba9d9 --- /dev/null +++ b/docs/wiki/zh-CN/Updates.md @@ -0,0 +1,51 @@ +# 更新与发行 + +[English](../en/Updates.md) · [指南首页](Home.md) + +`tg update` 使用所选通道,`tg update --stable` 选择稳定版, +`tg update --prerelease` 包含预发布版。稳定通道选最高的稳定 `0.1.Z`;预发布通道在 +两类发行中选最高的 `0.1.Z`,因此不会用较旧预发布版覆盖更新的稳定版。 + +后台对所选通道每天最多自动检查一次,在底部轻量提示。实际安装必须执行更新命令。 +`s` 设置中可以关闭检查或更改通道。更新器通过 GitHub HTTPS 获取并验证 `SHA256SUMS`, +再替换可执行文件。Windows 在更新进程退出后完成替换;其他进程可能占用程序, +再次执行 `tg update` 会重新下载并校验。 + +`tg --version` 显示版本、提交、分支、系统、架构和构建号。提交号后的 `*` 表示源码树 +有非忽略的改动。重定向时输出纯文本;`NO_COLOR=1` 或 `TERM=dumb` 也能关闭版本信息着色。 +没有 Git 元数据的源码包对缺失字段显示 `unknown`。 + +## Cargo 源码安装的更新 + +再次执行 [Cargo 安装命令](Getting-Started.md) 即可从最新源码重编;使用过 +`--branch`、`--rev` 或 `--root` 时保留相应选项。固定 `--rev` 会一直停留在该提交, +需要主动改为其他提交才能更新。即使 manifest 的包版本仍是 `0.1.0`,Cargo 也会记录 Git 来源。 + +Git 源码构建按 first-parent 提交高度显示 `0.1.Z`,通过显示的提交号确认源码;Cargo 的 +checkout 可能显示本地的 `master` 或 `HEAD`,不一定是指定的源码分支。没有 Git 元数据的源码包回退到 manifest +版本,CI 可以指定版本覆盖值。 + +`tg update` 下载 GitHub 上预编译的发行版,不会执行 Cargo 或跟随源码分支。 +要继续跟随源码更新,请使用 Cargo 命令。 + +## 维护者发行流程 + +CI 检查格式、严格 Clippy、测试、安装器,并构建六个原生包:Linux x86_64/ARM64、 +macOS Intel/Apple silicon、Windows x64/ARM64。默认分支构建从 Actions 的 +`TELEGRAM_API_ID`、`TELEGRAM_API_HASH` secrets 获取应用凭据,PR 构建不会获取它们。 + +每个成功的默认分支提交发布为 `0.1.Z`,`Z` 是 first-parent 提交高度。 +普通提交为预发布版,标题以 `release:` 开头则发布稳定版,例如: + +```sh +git commit --allow-empty -m "release: stable" +git push +``` + +发布复用通过验证的构建产物,包含安装器与 `SHA256SUMS`。检查失败不会发布,中断的发布 +在后续推送时重试,不移动已有标签。自动发布从高度 2 开始。 +旧资产名 `linux`、`macos`、`windows` 分别表示 Linux x86_64、Apple-silicon macOS、 +Windows x64;其他架构使用明确后缀。 + +编译进程序的应用 API 凭据可以被提取,它们不是用户的 Telegram 会话。 +用户手机号、验证码、密码、会话文件不进入 CI。Termgram 是非官方项目,与 Telegram 无隶属关系。 diff --git a/examples/config.lua b/examples/config.lua new file mode 100644 index 0000000..c6a6bd1 --- /dev/null +++ b/examples/config.lua @@ -0,0 +1,23 @@ +-- Apply changes in the app with :config reload (or :reload). +-- Save as config.lua beside settings.conf, or set TERMGRAM_CONFIG. +-- Settings are declarative: return a table. See docs/wiki/en/Configuration.md. +return { + chats = { + -- work = -1001234567890, -- stable Telegram chat ID + }, + ghost_text = "{send} to send", + nerd_font = false, -- true when the terminal uses a Nerd Font Mono (v3+) + sidebar = { width = 30, time_color = "cyan", unread_color = "yellow" }, + messages = { spacing = 0, alternating = true }, -- automatic terminal background shading + statusline = { + enabled = true, + left = { "mode", "app", "account", "message", "context" }, + right = { "notifications", "connection", "latency", "dc", "position" }, + }, + keymap = { + -- { context = "conversation", on = { "" }, run = "message_up", count = 20 }, + -- { context = "conversation", on = { "g", "w" }, run = "jump work" }, + -- { context = "compose", on = { "" }, run = "newline" }, + -- { context = "compose", on = { "" }, run = "send" }, + }, +} diff --git a/scripts/export-wiki.py b/scripts/export-wiki.py new file mode 100644 index 0000000..29e26e8 --- /dev/null +++ b/scripts/export-wiki.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""Validate reviewed Markdown and export it to GitHub Wiki's flat page names. + +Only inline Markdown links are used by these guides. Keep GitHub's renderer; +this adapter does not render Markdown, contact GitHub, or push a repository. +""" + +import argparse +from pathlib import Path +import re +import subprocess +from urllib.parse import quote, urlsplit + + +ROOT = Path(__file__).resolve().parents[1] +SOURCE = ROOT / "docs/wiki" +REPOSITORY = "https://github.com/iebb/termgram" +LINK = re.compile(r"\]\(([^\s)]+)\)") + + +def pages(): + english = {path.name for path in (SOURCE / "en").glob("*.md")} + chinese = {path.name for path in (SOURCE / "zh-CN").glob("*.md")} + if not english or english != chinese: + raise ValueError(f"English/Chinese page mismatch: {english ^ chinese}") + paths = [SOURCE / "Home.md"] + sorted(SOURCE.glob("*/*.md")) + return { + path: "-".join(path.relative_to(SOURCE).with_suffix("").parts) + for path in paths + } + + +def rewrite(text, source, names, revision): + def link(match): + target = match[1] + if urlsplit(target).scheme or target.startswith(("#", "//")): + return match[0] + path, separator, anchor = target.partition("#") + resolved = (source.parent / path).resolve() + if not resolved.is_relative_to(ROOT) or not resolved.exists(): + raise ValueError(f"Broken local link in {source.relative_to(ROOT)}: {target}") + if resolved in names: + url = names[resolved] + else: + kind = "tree" if resolved.is_dir() else "blob" + url = f"{REPOSITORY}/{kind}/{quote(revision, safe='/')}/{resolved.relative_to(ROOT).as_posix()}" + return f"]({url}{separator}{anchor})" + + return LINK.sub(link, text) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + mode = parser.add_mutually_exclusive_group(required=True) + mode.add_argument("--check", action="store_true", help="validate without writing") + mode.add_argument("--output", type=Path, help="export into a directory or wiki checkout") + parser.add_argument("--source-ref", help="source Git revision used by repository links") + args = parser.parse_args() + revision = args.source_ref or subprocess.check_output( + ["git", "rev-parse", "HEAD"], cwd=ROOT, text=True + ).strip() + names = pages() + rendered = { + name: rewrite(path.read_text(encoding="utf-8"), path, names, revision) + for path, name in names.items() + } + for path in [ROOT / "README.md", SOURCE / "README.md"]: + rewrite(path.read_text(encoding="utf-8"), path, names, revision) + if args.check: + print(f"Validated {len(names)} wiki pages, language parity, and local links") + return + output = args.output.resolve() + if output == ROOT or output == SOURCE or SOURCE.is_relative_to(output) or output.is_relative_to(SOURCE): + raise ValueError("Choose an export directory outside the documentation source") + output.mkdir(parents=True, exist_ok=True) + source_url = f"{REPOSITORY}/tree/{quote(revision, safe='/')}/docs/wiki" + for name, content in rendered.items(): + (output / f"{name}.md").write_text( + f"{content.rstrip()}\n\n---\n[Documentation source / 文档来源]({source_url})\n", + encoding="utf-8", + ) + sidebar = ["[Languages / 语言](Home)", ""] + for locale, title in [("en", "English"), ("zh-CN", "简体中文")]: + sidebar.extend([f"**{title}**", ""]) + for path, name in names.items(): + if path.parent.name == locale: + title = path.read_text(encoding="utf-8").splitlines()[0].removeprefix("# ") + sidebar.append(f"- [{title}]({name})") + sidebar.append("") + (output / "_Sidebar.md").write_text("\n".join(sidebar), encoding="utf-8") + print(f"Exported {len(names) + 1} pages to {output}; review the diff before publishing") + + +if __name__ == "__main__": + try: + main() + except ValueError as error: + raise SystemExit(str(error)) from error diff --git a/src/actions.rs b/src/actions.rs new file mode 100644 index 0000000..9a05a8b --- /dev/null +++ b/src/actions.rs @@ -0,0 +1,126 @@ +//! One typed action vocabulary for Lua, help and the application command line. +//! +//! Like Codex's slash commands and which-key descriptions, metadata belongs to +//! the action, not to each presentation of it. No upstream runtime is copied. + +macro_rules! actions { + ($( $variant:ident => ($name:literal, $description:literal) ),+ $(,)?) => { + #[derive(Clone, Debug, Eq, PartialEq)] + pub enum Action { + $( $variant, )+ + Jump(String), + } + impl Action { + #[must_use] + pub fn parse(value: &str) -> Option { + match value { + $( $name => Some(Self::$variant), )+ + _ => value.strip_prefix("jump ") + .filter(|alias| !alias.is_empty()) + .map(|alias| Self::Jump(alias.to_owned())), + } + } + #[must_use] + pub const fn name(&self) -> &'static str { + match self { $( Self::$variant => $name, )+ Self::Jump(_) => "jump" } + } + #[must_use] + pub const fn description(&self) -> &'static str { + match self { $( Self::$variant => $description, )+ Self::Jump(_) => "Open a configured chat alias" } + } + } + } +} + +actions! { + Quit => ("quit", "Exit Termgram"), + ReloadConfig => ("reload_config", "Reload the Lua configuration atomically"), + CommandLine => ("command", "Browse and run application commands"), + PasteClipboard => ("paste_clipboard", "Paste clipboard files, an image or text into the draft"), + Attach => ("attach", "Add files to the current draft"), + Attachments => ("attachments", "Review the draft's attachments"), + RemoveAttachment => ("remove_attachment", "Remove the selected draft attachment"), + AttachmentFormat => ("attachment_format", "Switch between photo and original file"), + CompleteNext => ("complete_next", "Complete the next command candidate"), + CompletePrevious => ("complete_previous", "Complete the previous command candidate"), + HistoryPrevious => ("history_previous", "Recall an older command with this prefix"), + HistoryNext => ("history_next", "Recall a newer command with this prefix"), + Help => ("help", "Show effective keyboard shortcuts"), + Settings => ("settings", "Open application settings"), + Accounts => ("accounts", "Switch Telegram accounts"), + NextAccount => ("next_account", "Switch to the next account"), + AddAccount => ("add_account", "Add a Telegram account"), + Open => ("open", "Activate the selected item"), + Preview => ("preview", "Expand the selected image or sticker"), + Compose => ("compose", "Compose a message or reply to the selection"), + ForwardMessage => ("forward_message", "Choose where to forward the selected message"), + SaveMessage => ("save_message", "Forward the selected message to Saved Messages"), + SavedMessages => ("saved_messages", "Open Saved Messages for this account"), + CopyText => ("copy_text", "Copy the selected message text or caption"), + CopyLink => ("copy_link", "Copy the selected Telegram message link"), + DeleteMessage => ("delete_message", "Review the selected message and confirm its deletion scope"), + EditMessage => ("edit_message", "Edit a message or resume a saved edit"), + DiscardEdit => ("discard_edit", "Discard this chat’s local message edit"), + Send => ("send", "Send the current draft"), + Newline => ("newline", "Insert a new line"), + Cancel => ("cancel", "Cancel or return to the previous view"), + Focus => ("focus", "Switch pane focus"), + Up => ("up", "Move up"), + Down => ("down", "Move down"), + MessageUp => ("message_up", "Select older messages"), + MessageDown => ("message_down", "Select newer messages"), + PageUp => ("page_up", "Move one page up"), + PageDown => ("page_down", "Move one page down"), + Oldest => ("oldest", "Go to the oldest loaded message"), + Latest => ("latest", "Go to the latest messages"), + FirstUnread => ("first_unread", "Go to the first unread message"), + MarkRead => ("mark_read", "Mark this entire chat read"), + MarkUnread => ("mark_unread", "Mark this chat unread as a reminder"), + MuteChat => ("mute_chat", "Mute this chat on Telegram until unmuted"), + UnmuteChat => ("unmute_chat", "Enable this chat's Telegram notifications"), + Mentions => ("mentions", "Browse this chat's unread mentions and replies to you"), + Filter => ("filter", "Filter chat titles"), + Refresh => ("refresh", "Refresh chats and folders"), + Reply => ("reply", "Reply to the selected message"), + ReplyTarget => ("reply_target", "Open the original message"), + OpenLink => ("open_link", "Open the selected link"), + NextAction => ("next_action", "Select the next message action"), + PreviousAction => ("previous_action", "Select the previous message action"), + Reactions => ("reactions", "Choose an emoji reaction"), + ClearReactions => ("clear_reactions", "Remove your emoji reactions from this message"), + Poll => ("poll", "Read a poll and review your vote"), + TogglePollAnswer => ("toggle_poll_answer", "Select or deselect this poll answer"), + RetractVote => ("retract_vote", "Prepare to retract your poll vote"), + Spoilers => ("spoilers", "Reveal or hide the selected message’s spoilers"), + ExpandQuote => ("expand_quote", "Expand or collapse the selected message’s quote"), + Reveal => ("reveal", "Reveal the selected file in the system file manager"), + Redraw => ("redraw", "Redraw the terminal"), + Home => ("home", "Move to the start of the input"), + End => ("end", "Move to the end of the input"), + Left => ("left", "Move left"), + Right => ("right", "Move right"), + Backspace => ("backspace", "Delete the previous character"), + Delete => ("delete", "Delete the next character"), + Clear => ("clear", "Clear the current input"), + DeleteWord => ("delete_word", "Delete the previous word"), + Noop => ("noop", "Remove this binding"), + ChatColor => ("chat_color", "Choose a chat color"), + FolderColor => ("folder_color", "Choose a folder color"), + Search => ("search", "Search locally cached messages with a regex"), + SearchScope => ("search_scope", "Change the search scope"), + SearchMore => ("search_more", "Show the next search page"), + SearchPrevious => ("search_previous", "Show the previous search page"), + SearchQuery => ("search_query", "Edit the search query"), + ChatInfo => ("chat_info", "Show chat and folder identifiers"), + FolderNext => ("folder_next", "Switch to the next folder"), + FolderPrevious => ("folder_previous", "Switch to the previous folder"), + Pin => ("pin", "Change the selected Telegram pin"), + PinUp => ("pin_up", "Move a pinned chat up"), + PinDown => ("pin_down", "Move a pinned chat down"), + Archive => ("archive", "Change the chat archive state"), + Pins => ("pins", "Browse pinned messages"), + PinsMore => ("pins_more", "Show the next pinned-message page"), + PinsPrevious => ("pins_previous", "Show the previous pinned-message page"), + UnpinAll => ("unpin_all", "Unpin all messages after confirmation"), + ToggleSidebar => ("toggle_sidebar", "Show or hide the sidebar"), +} diff --git a/src/app.rs b/src/app.rs index aed412b..31a6f79 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,5 +1,30 @@ //! Pure, single-owner application state and transitions. +mod alerts; +mod appearance; +mod attachments; +pub(crate) mod chat_info; +mod commands; +mod composition; +pub(crate) mod configuration; +mod deletion; +mod drafts; +mod editing; +mod entities; +mod forwarding; +pub(crate) mod invites; +mod message_pins; +mod message_views; +mod notifications; +mod pins; +pub(crate) mod polls; +pub(crate) mod reactions; +mod reads; +mod replies; +pub(crate) mod search; +mod sharing; +mod staging; + use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; use std::process::Command; @@ -8,9 +33,11 @@ use chrono::Utc; use yazi_term::event::{KeyEvent, MouseButton, MouseEvent, MouseEventKind}; use crate::{ + actions::Action, config::{DownloadBehavior, MAX_ACCOUNTS, Settings}, event::{AppEvent, AuthPrompt, ConnectionStatus, NetworkEvent, TelegramCommand}, - input::{KeyAction, TextInput, key_action}, + input::{KeyAction, TextInput}, + keymap::Context, model::{ Attachment, AttachmentKind, Chat, ChatId, Delivery, Message, MessageLink, ReplyInfo, sanitize_terminal_line, sanitize_terminal_text, @@ -20,7 +47,6 @@ use crate::{ const PAGE_STEP: usize = 10; const MAX_MESSAGES_PER_CHAT: usize = 160; const MAX_CACHED_CHATS: usize = 12; -const MAX_DROPPED_FILES: usize = 8; type MessageHitRegion = (u16, u16, u16, (i32, Option)); @@ -47,6 +73,10 @@ pub enum AttachmentState { pub enum MessageAction { Attachment, Reply, + Spoilers, + ExpandQuote, + Poll, + Reactions, Link(usize), Button(usize), } @@ -147,7 +177,22 @@ pub enum Mode { #[default] Navigate, Compose, + Edit, + DeletePrompt, + Invite, + ChatInfo, + ForwardPrompt, + Poll, + Reactions, + Command, + Status, + Attachments, + Preview, Filter, + Search, + PinnedMessages, + PinPrompt, + Colors, Help, Settings, Accounts, @@ -171,9 +216,8 @@ struct ReplyRequest { #[derive(Clone, Debug, Eq, PartialEq)] struct AttachmentRetry { - path: PathBuf, + attachment: crate::staging::Attachment, caption: String, - as_photo: bool, reply_to: Option, } @@ -187,8 +231,22 @@ pub struct App { pub mode: Mode, pub focus: Focus, pub connection: ConnectionStatus, + pub metrics: crate::statusline::Metrics, + pub notifications: notifications::State, + alerts: alerts::State, pub user_name: Option, + pub account_user_id: Option, + pub appearance: crate::appearance::Preferences, + navigation: composition::Navigation, + pub color_picker: Option, pub chats: Vec, + pub folders: Vec, + pub folder_id: i32, + pub pins: pins::State, + pub message_pins: message_pins::State, + pub polls: polls::State, + pub reactions: reactions::State, + replies: replies::State, /// Position in the filtered chat list, never a persistent model identity. pub selected_chat: usize, pub active_chat_id: Option, @@ -198,8 +256,19 @@ pub struct App { /// Action within the selected message. Messages may contain several links /// and inline bot buttons, so selection cannot be message-only. pub selected_action: usize, + text_visibility: BTreeMap<(ChatId, i32), entities::Visibility>, pub filter: TextInput, + pub search: search::State, + pub commands: commands::State, + pub attachment_draft: staging::State, + pub editing: editing::State, + pub deletion: deletion::State, + pub invites: invites::State, + pub chat_info: chat_info::State, + pub sharing: sharing::State, + pub forwarding: forwarding::State, pub narrow_conversation: bool, + pub sidebar_hidden: bool, pub should_quit: bool, pub status_message: Option, pub media_previews: BTreeMap<(ChatId, i32), MediaPreview>, @@ -218,7 +287,10 @@ pub struct App { /// Semantic top-of-viewport anchor used to survive wrapping changes. pub viewport_anchor_message: Option, pub viewport_anchor_row: usize, + /// Last rendered conversation width, used to detect a wrapping change. + pub viewport_width: u16, pub terminal_focused: bool, + pub terminal_background: Option<[u8; 3]>, settings: Settings, settings_path: Option, settings_selection: usize, @@ -231,24 +303,34 @@ pub struct App { /// Ignore late prompts from an authentication attempt after the user has /// explicitly restarted it, until the worker confirms the phone phase. auth_restart_pending: bool, - drafts: BTreeMap, - /// Per-chat reply context travels with its draft and is consumed only - /// after a send is queued successfully. - reply_targets: BTreeMap, + drafts: BTreeMap, + draft_accounts: BTreeSet, + draft_modified_accounts: BTreeSet, + drafts_dirty: bool, retry_message_ids: BTreeMap, retry_attachments: BTreeMap<(ChatId, i32), AttachmentRetry>, mode_before_help: Mode, mode_before_settings: Mode, mode_before_accounts: Mode, force_redraw: bool, + pub keymap: crate::keymap::Keymap, + pub configuration: configuration::State, + pub help_scroll: usize, next_pending_id: i32, next_history_request_id: u64, active_history_request: Option<(ChatId, u64)>, + history_changes: BTreeMap>, + history_read_max: i32, + older_motion: Option<(ChatId, u64, usize)>, + browsing_older: bool, + changed_dialogs: BTreeSet, active_reply_request: Option, history_target_message: Option, - read_ack_pending: BTreeSet, + reads: reads::State, refresh_dialogs_pending: bool, - downloading_attachments: BTreeSet<(ChatId, i32)>, + downloading_attachments: BTreeMap<(ChatId, i32), u64>, + next_download_request_id: u64, + reveal_after_download: BTreeSet<(ChatId, i32)>, downloaded_attachments: BTreeMap<(ChatId, i32), PathBuf>, pending_telegram_link: Option, /// Chats reached through links are kept until a dialog snapshot contains @@ -264,27 +346,54 @@ pub struct App { account_hit_regions: Vec<(u16, u16, u16, usize)>, /// Frame-local pane bounds used to route wheel events by pointer location. chat_pane_region: Option<(u16, u16, u16, u16)>, + composer_region: Option<(u16, u16, u16, u16)>, conversation_pane_region: Option<(u16, u16, u16, u16)>, } pub type AppState = App; impl Default for App { + #[allow(clippy::too_many_lines)] fn default() -> Self { Self { screen: Screen::Connecting, mode: Mode::Navigate, focus: Focus::Chats, connection: ConnectionStatus::Connecting, + metrics: crate::statusline::Metrics::default(), + notifications: notifications::State::default(), + alerts: alerts::State::default(), user_name: None, + account_user_id: None, + appearance: crate::appearance::Preferences::default(), + navigation: composition::Navigation::default(), + color_picker: None, chats: Vec::new(), + folders: vec![crate::folders::Folder::all()], + folder_id: 0, + pins: pins::State::default(), + message_pins: message_pins::State::default(), + polls: polls::State::default(), + reactions: reactions::State::default(), + replies: replies::State::default(), selected_chat: 0, active_chat_id: None, messages: BTreeMap::new(), selected_message: None, selected_action: 0, + text_visibility: BTreeMap::new(), filter: TextInput::new(), + search: search::State::default(), + commands: commands::State::default(), + attachment_draft: staging::State::default(), + editing: editing::State::default(), + deletion: deletion::State::default(), + invites: invites::State::default(), + chat_info: chat_info::State::default(), + sharing: sharing::State::default(), + forwarding: forwarding::State::default(), narrow_conversation: false, + sidebar_hidden: false, should_quit: false, status_message: None, media_previews: BTreeMap::new(), @@ -298,7 +407,9 @@ impl Default for App { new_messages_to_anchor: 0, viewport_anchor_message: None, viewport_anchor_row: 0, + viewport_width: 0, terminal_focused: true, + terminal_background: None, settings: Settings::default(), settings_path: None, settings_selection: 0, @@ -308,21 +419,33 @@ impl Default for App { qr_render_mode: QrRenderMode::default(), auth_restart_pending: false, drafts: BTreeMap::new(), - reply_targets: BTreeMap::new(), + draft_accounts: BTreeSet::new(), + draft_modified_accounts: BTreeSet::new(), + drafts_dirty: false, retry_message_ids: BTreeMap::new(), retry_attachments: BTreeMap::new(), mode_before_help: Mode::Navigate, mode_before_settings: Mode::Navigate, mode_before_accounts: Mode::Navigate, force_redraw: false, + keymap: crate::keymap::Keymap::default(), + configuration: configuration::State::default(), + help_scroll: 0, next_pending_id: -1, next_history_request_id: 1, active_history_request: None, + history_changes: BTreeMap::new(), + history_read_max: 0, + older_motion: None, + browsing_older: false, + changed_dialogs: BTreeSet::new(), active_reply_request: None, history_target_message: None, - read_ack_pending: BTreeSet::new(), + reads: reads::State::default(), refresh_dialogs_pending: false, - downloading_attachments: BTreeSet::new(), + downloading_attachments: BTreeMap::new(), + next_download_request_id: 1, + reveal_after_download: BTreeSet::new(), downloaded_attachments: BTreeMap::new(), pending_telegram_link: None, linked_chat_ids: BTreeSet::new(), @@ -331,6 +454,7 @@ impl Default for App { settings_hit_regions: Vec::new(), account_hit_regions: Vec::new(), chat_pane_region: None, + composer_region: None, conversation_pane_region: None, } } @@ -407,6 +531,13 @@ impl App { self.available_update.as_deref() } + #[must_use] + pub fn sync_target(&self) -> Option { + (self.terminal_focused && self.screen == Screen::Main && self.focus == Focus::Conversation) + .then_some(self.active_chat_id) + .flatten() + } + pub fn update(&mut self, event: AppEvent) -> Vec { match event { AppEvent::Key(key) => self.handle_key(&key), @@ -422,6 +553,21 @@ impl App { } } AppEvent::Tick => { + if self + .metrics + .latency + .is_some_and(crate::statusline::Latency::expired) + { + self.metrics.latency = None; + } + if self.keymap.expire() + && self + .status_message + .as_deref() + .is_some_and(|status| status.starts_with("Keys: ")) + { + self.status_message = None; + } self.tick = self.tick.wrapping_add(1); Vec::new() } @@ -432,11 +578,17 @@ impl App { /// render them (for example a terminal-too-small warning). pub fn clear_message_hit_regions(&mut self) { self.media_slots.clear(); + self.reads.visible = None; + self.reads.visible_mentions.clear(); self.message_hit_regions.clear(); self.chat_hit_regions.clear(); self.settings_hit_regions.clear(); self.account_hit_regions.clear(); + self.commands.hit_regions.clear(); + self.invites.hit_regions.clear(); + self.attachment_draft.hit_regions.clear(); self.chat_pane_region = None; + self.composer_region = None; self.conversation_pane_region = None; } @@ -451,25 +603,559 @@ impl App { } pub fn handle_key(&mut self, key: &KeyEvent) -> Vec { - let editing = matches!(self.screen, Screen::Auth(_)) - || (self.screen == Screen::Main && matches!(self.mode, Mode::Compose | Mode::Filter)); - if editing && let Some(text) = key.text(&mut [0; 4]) { - // Yazi resolves keyboard-layout modifiers and Kitty associated text. - // This is typed text, not a paste: never interpret it as an upload path. + use crate::keymap::{Context, Resolution}; + let context = if matches!(self.screen, Screen::Auth(_)) { + Context::Input + } else { + match self.mode { + Mode::Compose => Context::Compose, + Mode::Edit => Context::Edit, + Mode::ForwardPrompt => Context::Forward, + Mode::Poll => Context::Poll, + Mode::Reactions => Context::Reactions, + Mode::Command => Context::Command, + Mode::Attachments => Context::Attachments, + Mode::Preview => Context::Preview, + Mode::Filter => Context::Input, + Mode::Search => Context::Search, + Mode::PinnedMessages => Context::Pins, + Mode::ChatInfo + | Mode::Invite + | Mode::DeletePrompt + | Mode::PinPrompt + | Mode::Help + | Mode::Settings + | Mode::Accounts + | Mode::Colors + | Mode::Status => Context::Overlay, + Mode::Navigate if self.focus == Focus::Chats => Context::Chats, + Mode::Navigate => Context::Conversation, + } + }; + match self.keymap.feed(context, key) { + Resolution::Action { run, count } => { + if ((self.mode == Mode::Invite && run == Action::Open) + || (self.mode == Mode::Reactions + && matches!(run, Action::Open | Action::Send | Action::ClearReactions)) + || matches!(run, Action::Reveal | Action::ToggleSidebar) + || (matches!(self.mode, Mode::ForwardPrompt | Mode::Poll) + && run == Action::Send)) + && key.kind == yazi_term::event::KeyEventKind::Repeat + { + return Vec::new(); + } + if self + .status_message + .as_deref() + .is_some_and(|status| status.starts_with("Keys: ")) + { + self.status_message = None; + } + return self.run_action(&run, count); + } + Resolution::Pending(hint) => { + self.status_message = (!hint.is_empty()).then(|| format!("Keys: {hint}")); + return Vec::new(); + } + Resolution::Unbound => {} + } + if (matches!( + context, + Context::Compose | Context::Edit | Context::Input | Context::Command + ) || (context == Context::Search && self.search.editing)) + && key.kind != yazi_term::event::KeyEventKind::Release + && let Some(text) = key.text(&mut [0; 4]) + { return text .chars() .filter(|c| !c.is_control()) .flat_map(|c| self.handle_action(KeyAction::Character(c))) .collect(); } - key_action(key).map_or_else(Vec::new, |action| self.handle_action(action)) + Vec::new() + } + + #[cfg(test)] + fn run_binding(&mut self, run: &str, count: usize) -> Vec { + self.run_action(&Action::parse(run).expect("known test action"), count) + } + + #[allow(clippy::too_many_lines)] + fn run_action(&mut self, run: &Action, count: usize) -> Vec { + if let Some(commands) = self.chat_info_binding(run, count) { + return commands; + } + if let Some(commands) = self.invite_binding(run) { + return commands; + } + if let Some(commands) = self.reaction_binding(run, count) { + return commands; + } + if let Some(commands) = self.poll_binding(run, count) { + return commands; + } + if let Some(commands) = self.forwarding_binding(run) { + return commands; + } + if let Some(commands) = self.deletion_binding(run, count) { + return commands; + } + if let Some(commands) = self.editing_binding(run) { + return commands; + } + if let Some(commands) = self.attachment_binding(run) { + return commands; + } + if let Some(commands) = self.command_binding(run) { + return commands; + } + self.older_motion = None; + if let Some(commands) = self.pin_binding(run.name(), count) { + return commands; + } + if self.mode == Mode::Search + && let Some(commands) = self.search_binding(run.name(), count) + { + return commands; + } + if let Action::Jump(alias) = run { + if let Some(id) = self.keymap.chats.get(alias).copied() { + if self.chats.iter().any(|chat| chat.id == id) { + return self.open_chat_by_id(id); + } + self.status_message = Some(format!( + "Chat alias {alias} is not in cached conversations yet" + )); + } + return Vec::new(); + } + match run { + Action::CommandLine => return self.begin_command(), + Action::ReloadConfig => { + self.request_config_reload(); + return Vec::new(); + } + Action::Attachments => return self.open_attachments(), + Action::Mentions => return self.focused_mentions(), + Action::FirstUnread => return self.first_unread(), + Action::MarkRead => return self.mark_focused_chat(false), + Action::MarkUnread => return self.mark_focused_chat(true), + Action::MuteChat => return self.mute_focused_chat(crate::notifications::Mute::Forever), + Action::UnmuteChat => return self.mute_focused_chat(crate::notifications::Mute::Off), + Action::CopyText => return self.copy_message(false), + Action::CopyLink => return self.copy_message(true), + Action::PasteClipboard => return self.paste_clipboard(), + Action::Attach => { + self.begin_command(); + if self.mode == Mode::Command { + self.commands.input.set_value("attach "); + } + return Vec::new(); + } + Action::ToggleSidebar => { + if self.screen != Screen::Main + || !matches!(self.mode, Mode::Navigate | Mode::Compose) + { + return Vec::new(); + } + if self.active_chat_id.is_none() { + self.status_message = Some("Open a chat before hiding the sidebar".to_owned()); + return Vec::new(); + } + let narrow = self + .conversation_pane_region + .is_some_and(|(left, right, _, _)| { + right.saturating_sub(left) < crate::sidebar::MIN_SPLIT_WIDTH + }); + self.sidebar_hidden = self.chat_pane_region.is_some(); + if self.sidebar_hidden { + self.focus = Focus::Conversation; + self.narrow_conversation = true; + } else { + if narrow { + self.mode = Mode::Navigate; + } + if self.mode == Mode::Navigate { + self.focus = Focus::Chats; + self.narrow_conversation = false; + } + } + self.clear_message_hit_regions(); + self.force_redraw = true; + return Vec::new(); + } + Action::Archive => return self.toggle_archive(), + Action::Pin if self.focus == Focus::Conversation => { + return self.begin_message_pin(false); + } + Action::Pin | Action::PinUp | Action::PinDown => { + return self.change_chat_pin(run.name()); + } + Action::Pins => return self.open_pinned_messages(), + Action::Search => return self.open_search(), + Action::ChatColor => return self.begin_color_picker(false), + Action::FolderColor => return self.begin_color_picker(true), + Action::FolderNext | Action::FolderPrevious => { + let current = self + .folders + .iter() + .position(|folder| folder.id == self.folder_id) + .unwrap_or(0); + let total = self.folders.len(); + if total > 0 { + let index = if *run == Action::FolderNext { + (current + 1) % total + } else { + (current + total - 1) % total + }; + self.folder_id = self.folders[index].id; + self.selected_chat = 0; + self.focus = Focus::Chats; + self.narrow_conversation = false; + self.mode = Mode::Navigate; + } + return Vec::new(); + } + Action::ChatInfo => { + let chat = if self.focus == Focus::Chats { + self.selected_chat_entry() + } else { + self.chats + .iter() + .find(|chat| Some(chat.id) == self.active_chat_id) + }; + self.status_message = chat.map(|chat| { + format!( + "{} · chat ID {} · folder ID {}", + chat.title, chat.id, self.folder_id + ) + }); + return Vec::new(); + } + Action::Help | Action::Settings | Action::Accounts if self.screen == Screen::Main => { + if (*run == Action::Help && self.mode == Mode::Help) + || (*run == Action::Settings && self.mode == Mode::Settings) + || (*run == Action::Accounts && self.mode == Mode::Accounts) + { + return self.handle_main(KeyAction::Escape); + } + let character = match run { + Action::Help => '?', + Action::Settings => 's', + _ => 'a', + }; + return self.handle_navigation(KeyAction::Character(character)); + } + Action::MessageUp => return self.move_messages_up(count), + Action::MessageDown => { + let messages = self.active_messages(); + if messages.is_empty() { + return Vec::new(); + } + let index = self + .selected_message + .and_then(|id| messages.iter().position(|message| message.id == id)) + .unwrap_or(messages.len() - 1); + let target = index.saturating_add(count).min(messages.len() - 1); + let id = messages[target].id; + self.select_message_id(id, true); + if target == self.active_messages().len().saturating_sub(1) { + return self.next_unread_page(); + } + return Vec::new(); + } + Action::Up if self.mode == Mode::Navigate => return self.move_up(count), + Action::Down if self.mode == Mode::Navigate => return self.move_down(count), + Action::PageUp => return self.move_up(PAGE_STEP.saturating_mul(count)), + Action::PageDown => return self.move_down(PAGE_STEP.saturating_mul(count)), + Action::Latest + if (self.browsing_older || self.reads.entry.is_some()) + && self.focus == Focus::Conversation => + { + return self.latest_history(); + } + Action::Latest => return self.move_to_end(), + Action::Oldest => return self.move_to_start(), + Action::Refresh => { + let mut commands = self.request_dialog_refresh(); + commands.push(TelegramCommand::RefreshFolders); + return commands; + } + Action::Filter => { + self.focus = Focus::Chats; + self.mode = Mode::Filter; + return Vec::new(); + } + Action::Compose => return self.compose_or_reply(), + Action::Preview => return self.preview_selected_media(), + Action::Send => { + return self + .active_chat_id + .map_or_else(Vec::new, |id| self.send_draft(id)); + } + Action::Reply => return self.start_replying_to_selected(), + Action::ReplyTarget => return self.navigate_to_selected_reply(), + Action::OpenLink => return self.activate_selected_link(), + Action::NextAction => return self.select_actionable_message(true), + Action::PreviousAction => return self.select_actionable_message(false), + Action::Reveal => return self.reveal_selected_attachment(), + Action::Spoilers | Action::ExpandQuote => { + self.toggle_text_details(*run == Action::Spoilers); + return Vec::new(); + } + Action::Noop => return Vec::new(), + _ => {} + } + let action = match run { + Action::Quit => KeyAction::Quit, + Action::Help => KeyAction::Character('?'), + Action::Settings => KeyAction::Character('s'), + Action::Accounts => KeyAction::Character('a'), + Action::NextAccount => KeyAction::NextAccount, + Action::AddAccount => KeyAction::AddAccount, + Action::Open => KeyAction::Enter, + Action::Cancel => KeyAction::Escape, + Action::Focus => KeyAction::Tab, + Action::Newline => KeyAction::Newline, + Action::Redraw => KeyAction::Redraw, + Action::Up => KeyAction::Up, + Action::Down => KeyAction::Down, + Action::Home => KeyAction::Home, + Action::End => KeyAction::End, + Action::Left => KeyAction::Left, + Action::Right => KeyAction::Right, + Action::Backspace => KeyAction::Backspace, + Action::Delete => KeyAction::Delete, + Action::Clear => KeyAction::Clear, + Action::DeleteWord => KeyAction::DeleteWord, + _ => return Vec::new(), + }; + self.handle_action(action) + } + + fn move_messages_up(&mut self, count: usize) -> Vec { + let messages = self.active_messages(); + if messages.is_empty() { + return Vec::new(); + } + let index = self + .selected_message + .and_then(|id| messages.iter().position(|message| message.id == id)) + .unwrap_or(messages.len() - 1); + let target = index.saturating_sub(count); + let id = messages[target].id; + self.select_message_id(id, true); + if count <= index { + return Vec::new(); + } + let Some(chat_id) = self.active_chat_id else { + return Vec::new(); + }; + let request_id = self.next_history_request_id; + self.next_history_request_id = request_id.wrapping_add(1).max(1); + self.active_history_request = None; + self.history_changes.clear(); + self.older_motion = Some((chat_id, request_id, count - index)); + self.status_message = Some("Loading earlier messages…".to_owned()); + vec![TelegramCommand::LoadOlder { + chat_id, + request_id, + before_id: id, + }] + } + + fn finish_older( + &mut self, + chat_id: ChatId, + request_id: u64, + before_id: i32, + mut messages: Vec, + ) -> Vec { + let Some((chat, request, remaining)) = self.older_motion else { + return Vec::new(); + }; + if chat != chat_id || request != request_id || self.active_chat_id != Some(chat_id) { + return Vec::new(); + } + self.older_motion = None; + messages.retain(|message| { + message.id < before_id + && !self + .history_changes + .get(&message.id) + .is_some_and(Option::is_none) + }); + for message in &mut messages { + if let Some(Some(current)) = self.history_changes.get(&message.id) { + *message = current.clone(); + } + sanitize_message(message); + } + if messages.is_empty() { + self.status_message = Some("Start of available history".to_owned()); + return Vec::new(); + } + let current = self.messages.remove(&chat_id).unwrap_or_default(); + let pending = current + .iter() + .filter(|message| message.id < 0) + .cloned() + .collect::>(); + let mut combined = messages; + combined.extend(current.into_iter().filter(|message| message.id > 0)); + combined.sort_by_key(|message| (message.timestamp, message.id)); + combined.dedup_by_key(|message| message.id); + combined.truncate(MAX_MESSAGES_PER_CHAT.saturating_sub(pending.len())); + combined.extend(pending); + if self.reads.entry.is_some() || self.reads.forward.is_some() { + self.reads.forward = combined + .iter() + .filter(|message| message.id > 0) + .map(|message| message.id) + .max() + .map(|id| (chat_id, id)); + } + self.messages.insert(chat_id, combined); + self.browsing_older = true; + self.loading_history = false; + self.selected_message = Some(before_id); + self.move_messages_up(remaining) } + #[allow(clippy::too_many_lines)] pub fn handle_mouse(&mut self, mouse: MouseEvent) -> Vec { + if matches!(self.mode, Mode::Status | Mode::Help) { + let scroll = if self.mode == Mode::Status { + &mut self.configuration.status_scroll + } else { + &mut self.help_scroll + }; + match mouse.kind { + MouseEventKind::ScrollUp => *scroll = scroll.saturating_sub(3), + MouseEventKind::ScrollDown => *scroll = scroll.saturating_add(3), + _ => {} + } + return Vec::new(); + } + if self.mode == Mode::ChatInfo { + match mouse.kind { + MouseEventKind::ScrollUp => { + self.chat_info_binding(&Action::Up, 3); + } + MouseEventKind::ScrollDown => { + self.chat_info_binding(&Action::Down, 3); + } + _ => {} + } + return Vec::new(); + } + if self.mode == Mode::Invite { + if mouse.kind == MouseEventKind::Down(MouseButton::Left) + && let Some(index) = + pointer_row_hit(&self.invites.hit_regions, mouse.column, mouse.row) + { + self.invites.selected = index; + } + return Vec::new(); + } + if matches!( + mouse.kind, + MouseEventKind::Down(_) | MouseEventKind::ScrollUp | MouseEventKind::ScrollDown + ) { + self.older_motion = None; + } if !matches!(self.screen, Screen::Main) { return Vec::new(); } - if self.mode == Mode::Help { + if self.mode == Mode::Reactions { + match mouse.kind { + MouseEventKind::Down(MouseButton::Left) => { + if let Some(index) = + pointer_row_hit(&self.reactions.hit_rows, mouse.column, mouse.row) + && let Some(panel) = &mut self.reactions.panel + { + panel.selected = index; + } + } + MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => { + let action = if mouse.kind == MouseEventKind::ScrollUp { + Action::Up + } else { + Action::Down + }; + return self.reaction_binding(&action, 3).unwrap_or_default(); + } + _ => {} + } + return Vec::new(); + } + if self.mode == Mode::Poll { + match mouse.kind { + MouseEventKind::Down(MouseButton::Left) => { + if let Some(index) = + pointer_row_hit(&self.polls.hit_rows, mouse.column, mouse.row) + { + if let Some(review) = &mut self.polls.review { + review.selected = index; + } + self.toggle_poll_answer(); + } + } + MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => { + if let Some(review) = &mut self.polls.review { + review.follow = false; + if mouse.kind == MouseEventKind::ScrollUp { + review.top = review.top.saturating_sub(3); + } else { + review.top = review.top.saturating_add(3); + } + } + } + _ => {} + } + return Vec::new(); + } + if self.mode == Mode::Attachments { + match mouse.kind { + MouseEventKind::Down(MouseButton::Left) => { + if let Some(index) = + pointer_row_hit(&self.attachment_draft.hit_regions, mouse.column, mouse.row) + { + self.attachment_draft.selected = index; + } + } + MouseEventKind::ScrollUp => { + self.attachment_binding(&Action::Up); + } + MouseEventKind::ScrollDown => { + self.attachment_binding(&Action::Down); + } + _ => {} + } + return Vec::new(); + } + if self.mode == Mode::Command { + if mouse.kind == MouseEventKind::Down(MouseButton::Left) + && let Some(index) = + pointer_row_hit(&self.commands.hit_regions, mouse.column, mouse.row) + { + self.click_command(index); + } + return Vec::new(); + } + if matches!( + self.mode, + Mode::Help + | Mode::Edit + | Mode::DeletePrompt + | Mode::ForwardPrompt + | Mode::Status + | Mode::Search + | Mode::Colors + | Mode::PinnedMessages + | Mode::PinPrompt + | Mode::Preview + ) { return Vec::new(); } if self.mode == Mode::Settings { @@ -509,13 +1195,17 @@ impl App { MouseEventKind::ScrollUp if pointer_in_region(self.chat_pane_region, mouse.column, mouse.row) => { - self.focus = Focus::Chats; + if self.mode != Mode::Compose { + self.focus = Focus::Chats; + } self.move_chat_up(3) } MouseEventKind::ScrollDown if pointer_in_region(self.chat_pane_region, mouse.column, mouse.row) => { - self.focus = Focus::Chats; + if self.mode != Mode::Compose { + self.focus = Focus::Chats; + } self.move_chat_down(3) } MouseEventKind::ScrollUp @@ -534,11 +1224,15 @@ impl App { } MouseEventKind::Down(MouseButton::Right) => self.handle_reply_click(mouse), MouseEventKind::Down(MouseButton::Left) => { + if pointer_in_region(self.composer_region, mouse.column, mouse.row) { + return self.start_composing(); + } if let Some(selection) = pointer_row_hit(&self.chat_hit_regions, mouse.column, mouse.row) { self.selected_chat = selection; self.focus = Focus::Chats; + self.mode = Mode::Navigate; self.narrow_conversation = false; self.selected_message = None; return self.open_selected_chat(); @@ -548,17 +1242,34 @@ impl App { { self.focus = Focus::Conversation; self.narrow_conversation = true; - self.selected_message = Some(message_id); - if let Some(action_index) = action_index { - self.selected_action = action_index; + self.mode = Mode::Navigate; + self.select_message_id(message_id, false); + self.selected_action = action_index.unwrap_or(0); + if action_index.is_some() + && self + .active_messages() + .iter() + .find(|message| message.id == message_id) + .is_some_and(|message| { + matches!( + self.message_actions(message).get(self.selected_action), + Some( + MessageAction::Reply + | MessageAction::Spoilers + | MessageAction::ExpandQuote + ) + ) + }) + { return self.activate_selected_message(); } - self.select_message_id(message_id, false); return Vec::new(); } if pointer_in_region(self.conversation_pane_region, mouse.column, mouse.row) { self.focus = Focus::Conversation; self.narrow_conversation = true; + self.mode = Mode::Navigate; + self.selected_message = None; } Vec::new() } @@ -599,59 +1310,397 @@ impl App { #[allow(clippy::too_many_lines)] pub fn handle_network(&mut self, event: NetworkEvent) -> Vec { + self.observe_chat_info(&event); + if let Some(commands) = self.observe_reactions(&event) { + return commands; + } + if let Some(commands) = self.observe_polls(&event) { + return commands; + } + self.observe_alerts(&event); + self.observe_search(&event); + self.observe_deletion(&event); + self.observe_forward(&event); + self.update_reply_previews(&event); + self.track_snapshot_changes(&event); + if let NetworkEvent::MessageUpdated(message) = &event { + self.update_pin_preview(message); + } match event { - NetworkEvent::Auth(prompt) => { - if self.auth_restart_pending && !matches!(&prompt, AuthPrompt::Phone) { - return Vec::new(); - } - if matches!(&prompt, AuthPrompt::Phone) { - self.auth_restart_pending = false; - } - let preserve_qr_error = matches!(&prompt, AuthPrompt::Phone) - && (matches!( - self.auth_progress, - Some(AuthProgress::StartQr | AuthProgress::WaitQr) - ) || matches!(self.screen, Screen::Auth(AuthPhase::Qr { .. }))) - && self.status_message.is_some(); - self.auth_input.clear(); - self.auth_progress = - matches!(&prompt, AuthPrompt::Qr { .. }).then_some(AuthProgress::WaitQr); - self.screen = Screen::Auth(prompt.into()); - if !preserve_qr_error { - self.status_message = None; - } - Vec::new() + event @ (NetworkEvent::InviteReady { .. } | NetworkEvent::InviteJoined { .. }) => { + self.observe_invite(event) } - NetworkEvent::Ready { user_name } => { - self.auth_progress = None; - self.auth_restart_pending = false; - self.user_name = Some(sanitize_terminal_line(&user_name)); - self.screen = Screen::Main; - self.connection = ConnectionStatus::Online; - self.status_message = None; + NetworkEvent::SavedReady { request_id, result } => self.saved_ready(request_id, result), + NetworkEvent::ForwardReady { request_id, result } => { + self.forward_ready(request_id, result); Vec::new() } - NetworkEvent::Dialogs(chats) => { - self.refresh_dialogs_pending = false; - self.replace_dialogs(chats); + NetworkEvent::ForwardFinished { request_id, error } => { + self.forward_finished(request_id, error); Vec::new() } - NetworkEvent::DialogsFailed(error) => { - self.refresh_dialogs_pending = false; - self.status_message = Some(sanitize_terminal_line(&error)); - Vec::new() + NetworkEvent::MessageCopyReady { request_id, result } => { + self.message_copy_ready(request_id, result) } - NetworkEvent::History { + NetworkEvent::DeletionReady { chat_id, + message_id, request_id, - messages, - } => self.finish_history(chat_id, request_id, Ok(messages)), - NetworkEvent::HistoryFailed { + result, + } => { + self.deletion_ready(chat_id, message_id, request_id, result); + Vec::new() + } + NetworkEvent::DeleteFinished { chat_id, + message_id, request_id, error, - } => self.finish_history(chat_id, request_id, Err(error)), - NetworkEvent::MessageLoaded { + } => { + self.deletion_finished(chat_id, message_id, request_id, error); + Vec::new() + } + NetworkEvent::EditLoaded { + chat_id, + request_id, + result, + } => { + self.edit_loaded(chat_id, request_id, result); + Vec::new() + } + NetworkEvent::EditFinished { + chat_id, + request_id, + error, + } => { + self.edit_finished(chat_id, request_id, error); + Vec::new() + } + NetworkEvent::Telemetry { dc_id, latency } => { + self.metrics.dc_id = dc_id; + self.metrics.latency = latency.filter(|sample| { + self.connection == ConnectionStatus::Online + && self.keymap.statusline.measures_latency() + && sample.started >= self.metrics.reset_at + && !sample.expired() + }); + Vec::new() + } + NetworkEvent::PinnedMessages { + chat_id, + request_id, + page, + } => self.finish_pinned_messages(chat_id, request_id, page), + NetworkEvent::PinnedMessagesFailed { + chat_id, + request_id, + error, + } => { + self.fail_pinned_messages(chat_id, request_id, &error); + Vec::new() + } + NetworkEvent::PinnedContext { + chat_id, + message_id, + request_id, + messages, + } => { + self.finish_pinned_context(chat_id, message_id, request_id, messages); + Vec::new() + } + NetworkEvent::MessagePinsChanged { + chat_id, + message_ids, + pinned, + } => self.pin_messages_changed(chat_id, Some(&message_ids), pinned), + NetworkEvent::MessagePinsCleared { chat_id } => { + self.pin_messages_changed(chat_id, None, false) + } + NetworkEvent::MessagePinFinished { + chat_id, + request_id, + error, + } => self.finish_message_pin(chat_id, request_id, error), + NetworkEvent::ArchiveChanged { chat_id, archived } => { + let selected_id = self.selected_chat_entry().map(|chat| chat.id); + if let Some(chat) = self.chats.iter_mut().find(|chat| chat.id == chat_id) { + chat.membership.archived = archived; + } + self.preserve_chat_selection(selected_id); + Vec::new() + } + NetworkEvent::DialogPins(pins) => { + let selected_id = self.selected_chat_entry().map(|chat| chat.id); + self.pins.dialogs = pins; + self.preserve_chat_selection(selected_id); + Vec::new() + } + NetworkEvent::DialogPinFinished { request_id, error } => { + self.finish_dialog_pin(request_id, error); + Vec::new() + } + NetworkEvent::AccountIdentity { user_id } => { + self.account_user_id = Some(user_id); + Vec::new() + } + NetworkEvent::LocalDrafts { user_id, drafts } => { + self.restore_drafts(user_id, drafts); + Vec::new() + } + NetworkEvent::AttachmentsPrepared { + key, + request_id, + result, + } => { + self.attachments_prepared(key, request_id, result); + Vec::new() + } + NetworkEvent::SearchResults { request_id, page } => { + self.finish_search(request_id, Ok(search::Results::Local(page))); + Vec::new() + } + NetworkEvent::CloudSearchResults { + request_id, page, .. + } => { + self.finish_search(request_id, Ok(search::Results::Cloud(page))); + Vec::new() + } + NetworkEvent::ChatMuteChanged { chat_id, until } => { + self.apply_chat_mute(chat_id, until); + Vec::new() + } + NetworkEvent::ChatMuteFinished { + chat_id, + request_id, + result, + } => { + self.finish_chat_mute(chat_id, request_id, result); + Vec::new() + } + NetworkEvent::SearchFailed { request_id, error } => { + self.finish_search(request_id, Err(error)); + Vec::new() + } + NetworkEvent::CloudSearchContext { + request_id, + chat_id, + message_id, + messages, + } + | NetworkEvent::CachedContext { + request_id, + chat_id, + message_id, + messages, + } => { + self.open_search_context(request_id, chat_id, message_id, messages); + Vec::new() + } + NetworkEvent::OlderHistory { + chat_id, + request_id, + before_id, + messages, + } => self.finish_older(chat_id, request_id, before_id, messages), + NetworkEvent::OlderHistoryFailed { + chat_id, + request_id, + error, + } => { + if self + .older_motion + .is_some_and(|(chat, request, _)| chat == chat_id && request == request_id) + { + self.older_motion = None; + self.status_message = Some(error); + } + Vec::new() + } + NetworkEvent::Folders(mut folders) => { + let selected_id = self.selected_chat_entry().map(|chat| chat.id); + if !folders.iter().any(|folder| folder.id == 1) { + folders.push(crate::folders::Folder::archive()); + } + self.folders = folders; + if !self + .folders + .iter() + .any(|folder| folder.id == self.folder_id) + { + self.folder_id = 0; + } + self.preserve_chat_selection(selected_id); + Vec::new() + } + NetworkEvent::UnreadChanged { + chat_id, + max_id, + unread, + } => { + if let Some(chat) = self.chats.iter_mut().find(|chat| chat.id == chat_id) { + crate::read_state::inbox_update(chat, max_id, unread); + } + self.clamp_chat_selection(); + Vec::new() + } + NetworkEvent::ChatUnreadChanged { chat_id, unread } => { + if let Some(chat) = self.chats.iter_mut().find(|chat| chat.id == chat_id) { + crate::read_state::unread_mark(chat, unread); + } + self.clamp_chat_selection(); + Vec::new() + } + NetworkEvent::ChatUnreadFinished { + chat_id, + unread, + request_id, + snapshot, + error, + } => { + if error.is_none() + && let Some(chat) = self.chats.iter_mut().find(|chat| chat.id == chat_id) + { + crate::read_state::unread_mark(chat, unread); + if let Some(snapshot) = &snapshot { + crate::read_state::acknowledge(chat, snapshot.max_id, Some(snapshot)); + } + } + self.finish_chat_unread(chat_id, request_id, unread, error); + Vec::new() + } + NetworkEvent::CachedSnapshot { user_name, chats } => { + self.user_name = user_name; + self.screen = Screen::Main; + self.replace_dialogs(chats); + self.status_message = Some("Cached conversations · connecting…".to_owned()); + Vec::new() + } + NetworkEvent::CachedHistory { + chat_id, + request_id, + messages, + } => { + if self.active_history_request == Some((chat_id, request_id)) && !self.reads.paging + { + self.merge_history(chat_id, messages, self.history_target_message); + self.position_unread_entry(chat_id, false); + } + Vec::new() + } + NetworkEvent::ChatInfoReady { .. } + | NetworkEvent::ChatInfoInvalidated { .. } + | NetworkEvent::ReactionsChanged(_) + | NetworkEvent::ReactionsLoading { .. } + | NetworkEvent::ReactionsLoaded { .. } + | NetworkEvent::ReactionsFinished { .. } + | NetworkEvent::PollChanged(_) + | NetworkEvent::PollLoading { .. } + | NetworkEvent::PollLoaded { .. } + | NetworkEvent::PollFinished { .. } + | NetworkEvent::CloudSearchLoading { .. } + | NetworkEvent::CloudSearchCancelled { .. } + | NetworkEvent::HistoryLoading { .. } + | NetworkEvent::ReplyPreviewsLoading { .. } + | NetworkEvent::ReplyPreviews { .. } + | NetworkEvent::ReplyPreviewsFailed { .. } + | NetworkEvent::PinnedMessagesLoading { .. } + | NetworkEvent::SyncCheckpoint(_) + | NetworkEvent::AttachmentDownloadStarted { .. } + | NetworkEvent::NotificationSettingsChanged + | NetworkEvent::AlertSettingsReady { .. } + | NetworkEvent::CacheMessage(_) => Vec::new(), + NetworkEvent::CacheAccountReset { .. } => { + self.reset_for_account_switch(self.active_account()); + Vec::new() + } + NetworkEvent::CacheInvalidated { chat_id } => { + self.downloading_attachments + .retain(|(id, _), _| chat_id.is_some_and(|chat_id| *id != chat_id)); + self.reveal_after_download + .retain(|(id, _)| chat_id.is_some_and(|chat_id| *id != chat_id)); + self.downloaded_attachments + .retain(|(id, _), _| chat_id.is_some_and(|chat_id| *id != chat_id)); + if let Some(chat_id) = chat_id { + self.messages.remove(&chat_id); + } else { + self.messages.clear(); + } + let mut commands = self.invalidate_message_pins(chat_id); + commands.extend(self.request_dialog_refresh()); + if let Some(active) = self.active_chat_id + && chat_id.is_none_or(|id| id == active) + { + let request_id = self.next_history_request_id; + self.next_history_request_id = request_id.wrapping_add(1).max(1); + self.active_history_request = Some((active, request_id)); + self.history_changes.clear(); + self.history_read_max = 0; + self.loading_history = true; + commands.push(TelegramCommand::LoadHistory { + chat_id: active, + request_id, + after_id: None, + }); + } + commands + } + NetworkEvent::Auth(prompt) => { + if self.auth_restart_pending && !matches!(&prompt, AuthPrompt::Phone) { + return Vec::new(); + } + if matches!(&prompt, AuthPrompt::Phone) { + self.auth_restart_pending = false; + } + let preserve_qr_error = matches!(&prompt, AuthPrompt::Phone) + && (matches!( + self.auth_progress, + Some(AuthProgress::StartQr | AuthProgress::WaitQr) + ) || matches!(self.screen, Screen::Auth(AuthPhase::Qr { .. }))) + && self.status_message.is_some(); + self.auth_input.clear(); + self.auth_progress = + matches!(&prompt, AuthPrompt::Qr { .. }).then_some(AuthProgress::WaitQr); + self.screen = Screen::Auth(prompt.into()); + if !preserve_qr_error { + self.status_message = None; + } + Vec::new() + } + NetworkEvent::Ready { user_name } => { + self.auth_progress = None; + self.auth_restart_pending = false; + self.user_name = Some(sanitize_terminal_line(&user_name)); + self.screen = Screen::Main; + self.connection = ConnectionStatus::Online; + self.status_message = None; + Vec::new() + } + NetworkEvent::DialogsLoading => { + self.refresh_dialogs_pending = true; + self.changed_dialogs.clear(); + Vec::new() + } + NetworkEvent::Dialogs(chats) => { + self.refresh_dialogs_pending = false; + self.replace_dialogs(chats); + Vec::new() + } + NetworkEvent::DialogsFailed(error) => { + self.refresh_dialogs_pending = false; + self.status_message = Some(sanitize_terminal_line(&error)); + Vec::new() + } + NetworkEvent::History { + chat_id, + request_id, + messages, + } => self.finish_history(chat_id, request_id, Ok(messages)), + NetworkEvent::HistoryFailed { + chat_id, + request_id, + error, + } => self.finish_history(chat_id, request_id, Err(error)), + NetworkEvent::MessageLoaded { chat_id, message_id, request_id, @@ -675,12 +1724,38 @@ impl App { self.accept_message(chat_id, local_id); Vec::new() } - NetworkEvent::ReadMarked { chat_id } => { - self.read_ack_pending.remove(&chat_id); + NetworkEvent::MessageContentsRead { + channel_id, + message_ids, + } => { + self.contents_read(channel_id, &message_ids); + Vec::new() + } + NetworkEvent::MentionsReadFinished { + chat_id, + message_ids, + error, + } => { + self.mentions_read_finished(chat_id, &message_ids, error); + Vec::new() + } + NetworkEvent::ReadMarked { + chat_id, + max_id, + snapshot, + } => { + self.read_finished(chat_id, max_id, false); + if let Some(chat) = self.chats.iter_mut().find(|chat| chat.id == chat_id) { + crate::read_state::acknowledge(chat, max_id, snapshot.as_ref()); + } Vec::new() } - NetworkEvent::ReadMarkFailed { chat_id, error } => { - self.read_ack_pending.remove(&chat_id); + NetworkEvent::ReadMarkFailed { + chat_id, + max_id, + error, + } => { + self.read_finished(chat_id, max_id, true); self.status_message = Some(sanitize_terminal_line(&error)); Vec::new() } @@ -704,9 +1779,8 @@ impl App { NetworkEvent::AttachmentSendFailed { chat_id, local_id, - path, + attachment, caption, - as_photo, reply_to, error, } => { @@ -725,9 +1799,8 @@ impl App { self.retry_attachments.insert( (chat_id, local_id), AttachmentRetry { - path, + attachment, caption, - as_photo, reply_to, }, ); @@ -795,19 +1868,27 @@ impl App { message_ids, } => { self.remove_deleted_messages(channel_id, &message_ids); - Vec::new() + self.pinned_messages_deleted(channel_id, &message_ids) } NetworkEvent::AttachmentDownloaded { + request_id, chat_id, message_id, path, } => { + if self.downloading_attachments.get(&(chat_id, message_id)) != Some(&request_id) { + return Vec::new(); + } self.downloading_attachments.remove(&(chat_id, message_id)); self.downloaded_attachments .insert((chat_id, message_id), path.clone()); + if self.reveal_after_download.remove(&(chat_id, message_id)) { + self.reveal_download(&path); + return Vec::new(); + } let location = sanitize_terminal_line(&path.display().to_string()); self.status_message = Some(match self.settings.download_behavior { - DownloadBehavior::TempOnly => format!("Downloaded to {location}"), + DownloadBehavior::CacheOnly => format!("Downloaded to {location}"), DownloadBehavior::RevealOnActivation => { format!("Downloaded to {location} · activate again to reveal") } @@ -815,25 +1896,34 @@ impl App { Vec::new() } NetworkEvent::AttachmentDownloadFailed { + request_id, chat_id, message_id, error, } => { + if self.downloading_attachments.get(&(chat_id, message_id)) != Some(&request_id) { + return Vec::new(); + } self.downloading_attachments.remove(&(chat_id, message_id)); + self.reveal_after_download.remove(&(chat_id, message_id)); self.status_message = Some(format!( "Download failed: {}", sanitize_terminal_line(&error) )); Vec::new() } - NetworkEvent::LinkResolved { chat, message } => { + NetworkEvent::LinkResolved { url, chat, message } => { + if self.pending_telegram_link.as_deref() != Some(url.as_str()) { + return Vec::new(); + } self.pending_telegram_link = None; self.open_resolved_link(chat, message) } NetworkEvent::LinkFailed { url, error } => { - if self.pending_telegram_link.as_deref() == Some(url.as_str()) { - self.pending_telegram_link = None; + if self.pending_telegram_link.as_deref() != Some(url.as_str()) { + return Vec::new(); } + self.pending_telegram_link = None; self.status_message = Some(format!( "Could not open Telegram link: {}", sanitize_terminal_line(&error) @@ -873,6 +1963,9 @@ impl App { } NetworkEvent::Status(status) => { self.connection = status; + if status != ConnectionStatus::Online { + self.metrics = crate::statusline::Metrics::default(); + } if status == ConnectionStatus::Online { self.status_message = None; } @@ -951,16 +2044,31 @@ impl App { #[must_use] pub fn filtered_chat_indices(&self) -> Vec { let query = self.filter.value().to_lowercase(); - self.chats + let folder = self + .folders + .iter() + .find(|folder| folder.id == self.folder_id); + let now = chrono::Utc::now().timestamp(); + let mut indices: Vec<_> = self + .chats .iter() .enumerate() .filter_map(|(index, chat)| { - (query.is_empty() - || chat.title.to_lowercase().contains(&query) - || chat.last_message.to_lowercase().contains(&query)) + (folder.is_none_or(|folder| folder.contains(chat, now)) + && (query.is_empty() + || chat.title.to_lowercase().contains(&query) + || chat.last_message.to_lowercase().contains(&query))) .then_some(index) }) - .collect() + .collect(); + indices.sort_by_key(|&index| { + ( + self.chat_pin_position(self.chats[index].id) + .unwrap_or(usize::MAX), + std::cmp::Reverse(self.chats[index].last_activity), + ) + }); + indices } #[must_use] @@ -992,18 +2100,33 @@ impl App { #[must_use] pub fn active_draft(&self) -> Option<&TextInput> { - self.active_chat_id.and_then(|id| self.drafts.get(&id)) + self.active_chat_id.and_then(|id| self.draft_for(id)) } #[must_use] pub fn active_reply_target(&self) -> Option<&ReplyInfo> { self.active_chat_id - .and_then(|id| self.reply_targets.get(&id)) + .and_then(|id| self.draft_data(id)) + .and_then(|draft| draft.reply.as_ref()) + } + + /// Explicit selection, otherwise the last visible message in the conversation. + #[must_use] + pub fn inspected_message(&self) -> Option<&Message> { + if self.focus != Focus::Conversation { + return None; + } + let id = self + .selected_message + .or_else(|| self.message_hit_regions.last().map(|region| region.3.0))?; + self.active_messages() + .iter() + .find(|message| message.id == id) } #[must_use] pub fn draft_for(&self, chat_id: ChatId) -> Option<&TextInput> { - self.drafts.get(&chat_id) + self.draft_data(chat_id).map(|draft| &draft.input) } #[must_use] @@ -1015,7 +2138,7 @@ impl App { AttachmentState::Downloaded } else if self .downloading_attachments - .contains(&(chat_id, message_id)) + .contains_key(&(chat_id, message_id)) { AttachmentState::Downloading } else { @@ -1031,6 +2154,9 @@ impl App { #[must_use] pub fn message_actions(&self, message: &Message) -> Vec { let mut actions = Vec::new(); + if message.reply_to.is_some() { + actions.push(MessageAction::Reply); + } if (message.id > 0 && message.attachment.is_some()) || self .retry_attachments @@ -1038,10 +2164,23 @@ impl App { { actions.push(MessageAction::Attachment); } - if message.reply_to.is_some() { - actions.push(MessageAction::Reply); + if message.poll.is_some() { + actions.push(MessageAction::Poll); + } + if message.has_spoilers() { + actions.push(MessageAction::Spoilers); + } + if message.entities.iter().any(|entity| { + matches!( + entity.kind, + crate::entities::Kind::Quote { collapsed: true } + ) && entity.valid_for(&message.text) + }) { + actions.push(MessageAction::ExpandQuote); + } + if !message.has_spoilers() || self.spoilers_revealed(message) { + actions.extend((0..message.links.len()).map(MessageAction::Link)); } - actions.extend((0..message.links.len()).map(MessageAction::Link)); actions.extend( message .buttons @@ -1050,6 +2189,14 @@ impl App { .filter(|(_, button)| button.kind.is_supported()) .map(|(index, _)| MessageAction::Button(index)), ); + if message.id > 0 + && message + .reactions + .as_ref() + .is_some_and(|summary| !summary.counts.is_empty()) + { + actions.push(MessageAction::Reactions); + } actions } @@ -1074,6 +2221,10 @@ impl App { self.chat_pane_region = Some(region); } + pub const fn set_composer_region(&mut self, region: (u16, u16, u16, u16)) { + self.composer_region = Some(region); + } + pub const fn set_conversation_pane_region(&mut self, region: (u16, u16, u16, u16)) { self.conversation_pane_region = Some(region); } @@ -1092,10 +2243,21 @@ impl App { #[must_use] pub fn needs_animation(&self) -> bool { + if self + .metrics + .latency + .is_some_and(crate::statusline::Latency::expired) + { + return true; + } + if self.keymap.pending() { + return true; + } match self.screen { Screen::Connecting | Screen::Auth(AuthPhase::Qr { .. }) => true, Screen::Main => { self.loading_history + || self.search.loading || self.media_previews.values().any(|preview| preview.loading) || matches!( self.connection, @@ -1108,23 +2270,20 @@ impl App { } fn handle_paste(&mut self, text: &str) -> Vec { + if self.screen == Screen::Main && self.mode == Mode::Command { + self.paste_command(text); + return Vec::new(); + } let normalized = sanitize_terminal_text(&text.replace("\r\n", "\n").replace('\r', "\n")); - if matches!(self.screen, Screen::Main) + if self.screen == Screen::Main && matches!(self.mode, Mode::Navigate | Mode::Compose) && self.focus == Focus::Conversation - && let (Some(chat_id), Some(paths)) = - (self.active_chat_id, dropped_file_paths(&normalized)) + && self.active_chat_id.is_some() { - let caption = (self.mode == Mode::Compose) - .then(|| self.drafts.get_mut(&chat_id)) - .flatten() - .filter(|draft| !draft.value().trim().is_empty()) - .map(TextInput::take) - .unwrap_or_default(); - let reply_to = (self.mode == Mode::Compose) - .then(|| self.reply_targets.remove(&chat_id)) - .flatten(); - return self.send_dropped_files(chat_id, paths, caption, reply_to); + if self.keymap.attachments.auto_attach_paths { + return self.prepare_attachments(normalized, true); + } + self.mode = Mode::Compose; } match self.screen { Screen::Auth( @@ -1132,14 +2291,22 @@ impl App { ) if self.auth_progress.is_none() => { self.auth_input.insert_str(&normalized.replace('\n', "")); } + Screen::Main if self.mode == Mode::Edit && !self.message_edit_pending() => { + if let Some(chat) = self.active_chat_id + && let Some(edit) = &mut self.draft_data_mut(chat).edit + { + edit.input.insert_str(&normalized); + } + } Screen::Main if self.mode == Mode::Compose => { if let Some(chat_id) = self.active_chat_id { - self.drafts - .entry(chat_id) - .or_default() - .insert_str(&normalized); + self.draft_data_mut(chat_id).input.insert_str(&normalized); } } + Screen::Main if self.mode == Mode::Search && self.search.editing => { + self.search.query.insert_str(&normalized.replace('\n', "")); + return self.search_edited(); + } Screen::Main if self.mode == Mode::Filter => { self.filter .insert_str(&normalized.split_whitespace().collect::>().join(" ")); @@ -1252,7 +2419,42 @@ impl App { match self.mode { Mode::Navigate => self.handle_navigation(action), Mode::Compose => self.handle_compose(action), + Mode::Edit => self.edit_message_input(action), + Mode::Command => self.edit_command(action), + Mode::Status => { + match action { + KeyAction::Escape => self.mode = Mode::Navigate, + KeyAction::Up => { + self.configuration.status_scroll = + self.configuration.status_scroll.saturating_sub(1); + } + KeyAction::Down => { + self.configuration.status_scroll = + self.configuration.status_scroll.saturating_add(1); + } + _ => {} + } + Vec::new() + } + Mode::Preview => { + if action == KeyAction::Escape { + self.mode = Mode::Navigate; + self.force_redraw = true; + } + Vec::new() + } Mode::Filter => self.handle_filter(action), + Mode::Search => self.edit_search(action), + Mode::ChatInfo + | Mode::Invite + | Mode::Attachments + | Mode::PinnedMessages + | Mode::PinPrompt + | Mode::DeletePrompt + | Mode::ForwardPrompt + | Mode::Poll + | Mode::Reactions => Vec::new(), + Mode::Colors => self.handle_colors(action), Mode::Help => self.handle_help(action), Mode::Settings => self.handle_settings(action), Mode::Accounts => self.handle_accounts(action), @@ -1291,9 +2493,7 @@ impl App { self.insert_active('/'); Vec::new() } - KeyAction::Character('i') if self.focus == Focus::Conversation => { - self.start_composing() - } + KeyAction::Character('i') => self.compose_or_reply(), KeyAction::Tab | KeyAction::BackTab => { self.toggle_focus(); if self.focus == Focus::Conversation && self.message_scroll == 0 { @@ -1302,7 +2502,12 @@ impl App { Vec::new() } } + KeyAction::Escape if self.selected_message.is_some() => { + self.selected_message = None; + Vec::new() + } KeyAction::Escape | KeyAction::Left => { + self.sidebar_hidden = false; self.narrow_conversation = false; self.focus = Focus::Chats; self.selected_message = None; @@ -1325,10 +2530,10 @@ impl App { } } KeyAction::Character('o') if self.focus == Focus::Conversation => { - self.select_actionable_message(true) + self.preview_selected_media() } KeyAction::Character('O') if self.focus == Focus::Conversation => { - self.select_actionable_message(false) + self.reveal_selected_attachment() } KeyAction::Character('l') if self.focus == Focus::Conversation => { self.activate_selected_link() @@ -1365,7 +2570,7 @@ impl App { return Vec::new(); }; if action == KeyAction::Escape { - if self.reply_targets.remove(&chat_id).is_some() { + if self.draft_data_mut(chat_id).reply.take().is_some() { self.selected_message = None; self.status_message = Some("Reply cancelled · draft kept".to_owned()); return Vec::new(); @@ -1376,7 +2581,7 @@ impl App { if action == KeyAction::Enter { return self.send_draft(chat_id); } - let draft = self.drafts.entry(chat_id).or_default(); + let draft = &mut self.draft_data_mut(chat_id).input; match action { KeyAction::Character(character) => draft.insert(character), KeyAction::Newline => draft.insert('\n'), @@ -1468,6 +2673,17 @@ impl App { } fn handle_help(&mut self, action: KeyAction) -> Vec { + match action { + KeyAction::Up => { + self.help_scroll = self.help_scroll.saturating_sub(1); + return Vec::new(); + } + KeyAction::Down => { + self.help_scroll = self.help_scroll.saturating_add(1); + return Vec::new(); + } + _ => {} + } match action { KeyAction::Character('q') => self.quit(), KeyAction::Escape | KeyAction::Character('?') => { @@ -1574,7 +2790,11 @@ impl App { fn switch_to_next_account(&mut self) -> Vec { if self.settings.account_count < 2 { - self.status_message = Some("Only one account · press F3 to add another".to_owned()); + self.status_message = Some(format!( + "Only one account · {} to add another", + self.keymap + .hint(crate::keymap::Context::Global, "add_account") + )); return Vec::new(); } let account = if self.settings.active_account == self.settings.account_count { @@ -1626,16 +2846,42 @@ impl App { } fn reset_for_account_switch(&mut self, account: u8) { + self.cancel_alerts(); let settings = self.settings; let settings_path = self.settings_path.clone(); let available_update = self.available_update.clone(); let terminal_focused = self.terminal_focused; + let terminal_background = self.terminal_background; let qr_render_mode = self.qr_render_mode; + let appearance = self.appearance.clone(); + let navigation = self.navigation.clone(); + let sidebar_hidden = self.sidebar_hidden; + let mut drafts = std::mem::take(&mut self.drafts); + drafts.retain(|key, _| key.account != 0); + let draft_accounts = std::mem::take(&mut self.draft_accounts); + let draft_modified_accounts = std::mem::take(&mut self.draft_modified_accounts); + let drafts_dirty = self.drafts_dirty; + let mut commands = std::mem::take(&mut self.commands); + commands.reset_session(); + let configuration = self.configuration.clone(); + let mut keymap = self.keymap.clone(); + keymap.reset(); *self = Self { + drafts, + draft_accounts, + draft_modified_accounts, + drafts_dirty, + appearance, + navigation, + sidebar_hidden, + commands, + keymap, + configuration, settings, settings_path, available_update, terminal_focused, + terminal_background, qr_render_mode, status_message: Some(format!("Switching to Account {account}…")), force_redraw: true, @@ -1695,40 +2941,47 @@ impl App { Focus::Chats }; self.narrow_conversation = self.focus == Focus::Conversation; - } - - fn start_composing(&mut self) -> Vec { - if let Some(chat_id) = self.active_chat_id { - self.drafts.entry(chat_id).or_default(); - self.mode = Mode::Compose; - self.focus = Focus::Conversation; - self.selected_message = None; + if self.focus == Focus::Chats { + self.sidebar_hidden = false; } - Vec::new() } fn start_plain_composing(&mut self) -> Vec { if let Some(chat_id) = self.active_chat_id { - self.reply_targets.remove(&chat_id); + self.draft_data_mut(chat_id).reply.take(); } self.start_composing() } fn insert_active(&mut self, character: char) { if let Some(chat_id) = self.active_chat_id { - self.drafts.entry(chat_id).or_default().insert(character); + self.draft_data_mut(chat_id).input.insert(character); } } fn send_draft(&mut self, chat_id: ChatId) -> Vec { - let Some(draft) = self.drafts.get_mut(&chat_id) else { + if let Some(reason) = self.draft_restriction(chat_id) { + self.status_message = Some(format!("{reason} · draft kept · :info for details")); return Vec::new(); - }; + } + if self.preparing_attachments() { + self.status_message = Some( + "Wait for attachment preparation or cancel it in the attachment list".to_owned(), + ); + return Vec::new(); + } + if self + .draft_data(chat_id) + .is_some_and(|draft| !draft.attachments.is_empty()) + { + return self.send_staged_attachments(chat_id); + } + let draft = &mut self.draft_data_mut(chat_id).input; if draft.value().trim().is_empty() { return Vec::new(); } let text = draft.take(); - let reply_to = self.reply_targets.remove(&chat_id); + let reply_to = self.draft_data_mut(chat_id).reply.take(); let replacing_failed = self.retry_message_ids.remove(&chat_id); if let Some(failed_id) = replacing_failed && let Some(messages) = self.messages.get_mut(&chat_id) @@ -1738,6 +2991,13 @@ impl App { let local_id = self.next_pending_id; self.next_pending_id = self.next_pending_id.checked_sub(1).unwrap_or(-1); let pending = Message { + reactions: None, + poll: None, + entities: Vec::new(), + notification: None, + mention: None, + edited_at: None, + pinned: false, id: local_id, chat_id, sender: "You".to_owned(), @@ -1761,20 +3021,25 @@ impl App { commands } - fn send_dropped_files( - &mut self, - chat_id: ChatId, - paths: Vec, - mut caption: String, - mut reply_to: Option, - ) -> Vec { - let total = paths.len(); + fn send_staged_attachments(&mut self, chat_id: ChatId) -> Vec { + let mut draft = std::mem::take(self.draft_data_mut(chat_id)); + self.draft_data_mut(chat_id).edit = draft.edit.take(); + let mut caption = draft.input.value().to_owned(); + let mut reply_to = draft.reply; + let total = draft.attachments.len(); let mut commands = Vec::new(); - for (index, path) in paths.into_iter().take(MAX_DROPPED_FILES).enumerate() { + for (index, staged) in draft.attachments.into_iter().enumerate() { + let path = &staged.path; let local_id = self.next_pending_id; self.next_pending_id = self.next_pending_id.checked_sub(1).unwrap_or(-1); - let attachment = attachment_from_path(&path); - let as_photo = attachment.kind == AttachmentKind::Photo; + let mut attachment = attachment_from_path(path); + attachment.size = Some(staged.size); + let as_photo = staged.as_photo; + attachment.kind = if as_photo { + AttachmentKind::Photo + } else { + AttachmentKind::File + }; let item_caption = if index == 0 { std::mem::take(&mut caption) } else { @@ -1782,6 +3047,13 @@ impl App { }; let item_reply = if index == 0 { reply_to.take() } else { None }; let pending = Message { + reactions: None, + poll: None, + entities: Vec::new(), + notification: None, + mention: None, + edited_at: None, + pinned: false, id: local_id, chat_id, sender: "You".to_owned(), @@ -1798,20 +3070,12 @@ impl App { commands.push(TelegramCommand::SendAttachment { chat_id, local_id, - path, + attachment: staged, caption: item_caption, - as_photo, reply_to: item_reply.map(|reply| reply.message_id), }); } - let sent = total.min(MAX_DROPPED_FILES); - self.status_message = Some(if total > MAX_DROPPED_FILES { - format!("Sending {sent} files · drop at most {MAX_DROPPED_FILES} at a time") - } else if sent == 1 { - "Sending attachment…".to_owned() - } else { - format!("Sending {sent} attachments…") - }); + self.status_message = Some(format!("Sending {total} attachment(s)…")); commands } @@ -1846,24 +3110,28 @@ impl App { self.viewport_anchor_row = 0; self.message_scroll = 1; } - self.status_message = Some(format!("Selected #{message_id} · R reply")); + self.status_message = None; } fn start_replying_to_selected(&mut self) -> Vec { + if self.mode == Mode::Preview && self.selected_message.is_none() { + self.status_message = Some(format!( + "Previewed message is no longer available · {} close", + self.keymap.hint(Context::Preview, "cancel") + )); + return Vec::new(); + } let Some(chat_id) = self.active_chat_id else { return Vec::new(); }; let target = self - .selected_message - .and_then(|message_id| { - self.messages - .get(&chat_id) - .and_then(|messages| messages.iter().find(|message| message.id == message_id)) - }) - .or_else(|| { - self.messages - .get(&chat_id) - .and_then(|messages| messages.iter().rev().find(|message| message.id > 0)) + .messages + .get(&chat_id) + .and_then(|messages| { + self.selected_message.map_or_else( + || messages.iter().rev().find(|message| message.id > 0), + |id| messages.iter().find(|message| message.id == id), + ) }) .cloned(); let Some(target) = target else { @@ -1875,15 +3143,11 @@ impl App { Some("Wait for this message to finish sending before replying".to_owned()); return Vec::new(); } - self.reply_targets.insert( + self.draft_data_mut(chat_id).reply = Some(ReplyInfo { + message_id: target.id, chat_id, - ReplyInfo { - message_id: target.id, - chat_id, - sender: Some(target.sender), - }, - ); - self.drafts.entry(chat_id).or_default(); + sender: Some(target.sender), + }); self.mode = Mode::Compose; self.focus = Focus::Conversation; self.selected_message = Some(target.id); @@ -1970,9 +3234,19 @@ impl App { if action == MessageAction::Attachment { return self.activate_attachment(chat_id, message_id, &message); } + if action == MessageAction::Reactions { + return self.begin_reactions(); + } + if action == MessageAction::Poll { + return self.begin_poll(); + } if action == MessageAction::Reply { return self.navigate_to_selected_reply(); } + if matches!(action, MessageAction::Spoilers | MessageAction::ExpandQuote) { + self.toggle_text_details(action == MessageAction::Spoilers); + return Vec::new(); + } if let MessageAction::Link(index) = action { let Some(link) = message.links.get(index) else { return Vec::new(); @@ -2005,11 +3279,6 @@ impl App { message: &Message, ) -> Vec { if let Some(retry) = self.retry_attachments.get(&(chat_id, message_id)).cloned() { - if !retry.path.is_file() { - self.status_message = - Some("Original attachment no longer exists · drop it again".to_owned()); - return Vec::new(); - } if let Some(message) = self .messages .get_mut(&chat_id) @@ -2022,9 +3291,8 @@ impl App { return vec![TelegramCommand::SendAttachment { chat_id, local_id: message_id, - path: retry.path, + attachment: retry.attachment, caption: retry.caption, - as_photo: retry.as_photo, reply_to: retry.reply_to.map(|reply| reply.message_id), }]; } @@ -2032,7 +3300,11 @@ impl App { if message.id > 0 && let Some(attachment) = message.attachment.as_ref().filter(|a| a.supports_preview()) { - // A click retries a failed inline download; it never opens a modal. + self.mode = Mode::Preview; + self.focus = Focus::Conversation; + self.status_message = None; + // Explicit activation can retry a failed inline download while the + // same renderer expands the preview into the available viewport. if self .media_previews .get(&(chat_id, message_id)) @@ -2051,7 +3323,7 @@ impl App { { if path.is_file() { let location = sanitize_terminal_line(&path.display().to_string()); - if self.settings.download_behavior == DownloadBehavior::TempOnly { + if self.settings.download_behavior == DownloadBehavior::CacheOnly { self.status_message = Some(format!( "Downloaded to {location} · reveal is disabled in settings" )); @@ -2067,15 +3339,14 @@ impl App { } self.downloaded_attachments.remove(&(chat_id, message_id)); } - if !self.downloading_attachments.insert((chat_id, message_id)) { - self.status_message = Some("Attachment is downloading…".to_owned()); - return Vec::new(); - } - self.status_message = Some("Downloading attachment…".to_owned()); - return vec![TelegramCommand::DownloadAttachment { + return self.queue_download( chat_id, message_id, - }]; + message + .attachment + .as_ref() + .and_then(|attachment| attachment.source_id), + ); } Vec::new() @@ -2141,18 +3412,25 @@ impl App { 2_usize.saturating_sub(self.media_previews.values().filter(|p| p.loading).count()); let mut commands = Vec::new(); for slot in self.media_slots.clone() { + let crate::media::MediaSource::Message { + chat_id, + message_id, + } = slot.source + else { + continue; + }; if budget == 0 { break; } let Some(attachment) = self .messages - .get(&slot.chat_id) - .and_then(|messages| messages.iter().find(|m| m.id == slot.message_id)) + .get(&chat_id) + .and_then(|messages| messages.iter().find(|m| m.id == message_id)) .and_then(|message| message.attachment.clone()) else { continue; }; - let requested = self.request_media_preview(slot.chat_id, slot.message_id, &attachment); + let requested = self.request_media_preview(chat_id, message_id, &attachment); budget = budget.saturating_sub(requested.len()); commands.extend(requested); } @@ -2160,11 +3438,17 @@ impl App { } pub fn media_preview_failed(&mut self, chat_id: ChatId, message_id: i32, error: &str) { + let context = if self.mode == Mode::Preview { + Context::Preview + } else { + Context::Conversation + }; if let Some(preview) = self.media_previews.get_mut(&(chat_id, message_id)) { preview.path = None; preview.loading = false; preview.status = format!( - "Preview unavailable · click to retry: {}", + "Preview unavailable · {} retry: {}", + self.keymap.hint(context, "preview"), sanitize_terminal_line(error) ); } @@ -2173,6 +3457,19 @@ impl App { fn remove_deleted_messages(&mut self, channel_id: Option, message_ids: &[i32]) { let affected_chat = |id: ChatId| channel_id.map_or(id > -1_000_000_000_000, |channel| channel == id); + for chat in &mut self.chats { + if affected_chat(chat.id) + && chat + .last_message_id + .is_some_and(|id| message_ids.contains(&id)) + { + chat.last_message.clear(); + chat.last_message_id = None; + if self.refresh_dialogs_pending { + self.changed_dialogs.insert(chat.id); + } + } + } self.media_previews.retain(|&(chat_id, message_id), _| { !affected_chat(chat_id) || !message_ids.contains(&message_id) }); @@ -2186,9 +3483,12 @@ impl App { !affected_chat(chat_id) || !message_ids.contains(&message_id) }); self.downloading_attachments - .retain(|&(chat_id, message_id)| { + .retain(|&(chat_id, message_id), _| { !affected_chat(chat_id) || !message_ids.contains(&message_id) }); + self.reveal_after_download.retain(|&(chat_id, message_id)| { + !affected_chat(chat_id) || !message_ids.contains(&message_id) + }); if self.active_chat_id.is_some_and(affected_chat) && self .selected_message @@ -2209,9 +3509,18 @@ impl App { }) .cloned() else { - self.status_message = Some("Select a link with o first".to_owned()); + self.status_message = Some(format!( + "Select a link with {} first", + self.keymap + .hint(crate::keymap::Context::Conversation, "next_action") + )); return Vec::new(); }; + if message.has_spoilers() && !self.spoilers_revealed(&message) { + self.status_message = + Some("Reveal the message's spoilers before opening links".to_owned()); + return Vec::new(); + } let links = &message.links; let selected = self .message_actions(&message) @@ -2229,6 +3538,9 @@ impl App { } fn activate_url(&mut self, url: &str) -> Vec { + if let Some(hash) = crate::invites::hash(url) { + return self.begin_invite(hash); + } if telegram_link(url).is_some() { self.pending_telegram_link = Some(url.to_owned()); self.status_message = Some("Opening Telegram link…".to_owned()); @@ -2261,20 +3573,28 @@ impl App { }) .cloned() else { - self.status_message = Some("Select a reply with o first".to_owned()); + self.status_message = Some(format!( + "Select a reply with {} first", + self.keymap + .hint(crate::keymap::Context::Conversation, "next_action") + )); return Vec::new(); }; let Some(reply) = message.reply_to else { self.status_message = Some("Selected message is not a reply".to_owned()); return Vec::new(); }; - if let Some(target) = self.messages.get(&reply.chat_id).and_then(|messages| { - messages - .iter() - .find(|message| message.id == reply.message_id) - .cloned() - }) { + if self.reply_is_unavailable(&reply) { + self.status_message = Some("Original message is no longer available".to_owned()); + return Vec::new(); + } + if let Some(target) = self.reply_message(&reply).cloned() { if reply.chat_id == chat_id { + upsert_message_preserving( + self.messages.entry(chat_id).or_default(), + target, + reply.message_id, + ); self.focus_reply_target(reply.message_id); return Vec::new(); } @@ -2315,6 +3635,7 @@ impl App { } fn focus_reply_target(&mut self, message_id: i32) { + self.clear_unread_navigation(); self.selected_message = Some(message_id); self.selected_action = 0; self.viewport_anchor_message = Some(message_id); @@ -2330,21 +3651,25 @@ impl App { mut chat: Chat, message: Option, ) -> Vec { + self.pending_telegram_link = None; let chat_id = chat.id; let selected_message = message.as_ref().map(|message| message.id); let mut known_chat = self.chats.iter().position(|chat| chat.id == chat_id); chat.title = sanitize_terminal_line(&chat.title); chat.last_message = sanitize_terminal_text(&chat.last_message); if let Some(index) = known_chat { - self.chats[index] = chat; + // A username lookup has no dialog counters or ordering. Preserve + // those server-owned fields instead of replacing them with zeros. + self.chats[index].title = chat.title; } else { self.chats.push(chat); known_chat = Some(self.chats.len() - 1); self.linked_chat_ids.insert(chat_id); } if let Some(index) = known_chat { + self.folder_id = i32::from(self.chats[index].membership.archived); self.filter.clear(); - self.selected_chat = index; + self.preserve_chat_selection(Some(chat_id)); } // Pin the destination before insertion so the bounded global cache // cannot evict an older exact link/reply target during navigation. @@ -2369,24 +3694,28 @@ impl App { let request_id = self.next_history_request_id; self.next_history_request_id = self.next_history_request_id.wrapping_add(1).max(1); self.active_history_request = Some((chat_id, request_id)); + self.older_motion = None; + self.browsing_older = false; + self.history_changes.clear(); + self.history_read_max = 0; self.history_target_message = selected_message; - if let Some(chat) = self.chats.iter_mut().find(|chat| chat.id == chat_id) { - chat.unread = 0; - } self.status_message = None; - let mut commands = vec![TelegramCommand::LoadHistory { + self.remember_chat(chat_id); + self.prepare_unread_entry(chat_id, false); + vec![TelegramCommand::LoadHistory { chat_id, request_id, - }]; - commands.extend(self.request_mark_read(chat_id)); - commands + after_id: None, + }] } fn open_selected_chat(&mut self) -> Vec { let Some(chat_id) = self.selected_chat_entry().map(|chat| chat.id) else { return Vec::new(); }; + self.pending_telegram_link = None; self.active_chat_id = Some(chat_id); + self.remember_chat(chat_id); self.selected_message = None; self.focus = Focus::Conversation; self.narrow_conversation = true; @@ -2398,16 +3727,18 @@ impl App { let request_id = self.next_history_request_id; self.next_history_request_id = self.next_history_request_id.wrapping_add(1).max(1); self.active_history_request = Some((chat_id, request_id)); + self.older_motion = None; + self.browsing_older = false; + self.history_changes.clear(); + self.history_read_max = 0; self.history_target_message = None; - if let Some(chat) = self.chats.iter_mut().find(|chat| chat.id == chat_id) { - chat.unread = 0; - } - let mut commands = vec![TelegramCommand::LoadHistory { + let after_id = self.prepare_unread_entry(chat_id, true); + self.position_unread_entry(chat_id, false); + vec![TelegramCommand::LoadHistory { chat_id, request_id, - }]; - commands.extend(self.request_mark_read(chat_id)); - commands + after_id, + }] } fn move_up(&mut self, amount: usize) -> Vec { @@ -2473,26 +3804,10 @@ impl App { } fn reach_bottom(&mut self) -> Vec { - let Some(chat_id) = self.active_chat_id else { - return Vec::new(); - }; - let had_unread = self.new_messages_while_scrolled > 0 - || self - .chats - .iter() - .find(|chat| chat.id == chat_id) - .is_some_and(|chat| chat.unread > 0); self.new_messages_while_scrolled = 0; self.new_messages_to_anchor = 0; self.clear_viewport_anchor(); - if let Some(chat) = self.chats.iter_mut().find(|chat| chat.id == chat_id) { - chat.unread = 0; - } - if had_unread { - self.request_mark_read(chat_id) - } else { - Vec::new() - } + self.next_unread_page() } fn clamp_chat_selection(&mut self) { @@ -2500,6 +3815,53 @@ impl App { self.selected_chat = self.selected_chat.min(length.saturating_sub(1)); } + /// Overlay events received after a snapshot request. A slower history RPC + /// must not resurrect deleted messages or undo a live edit/read receipt. + fn track_snapshot_changes(&mut self, event: &NetworkEvent) { + let history_chat = self + .active_history_request + .map(|(chat_id, _)| chat_id) + .or_else(|| self.older_motion.map(|(chat_id, _, _)| chat_id)); + let history_chat = self.pin_context_chat().or(history_chat); + match event { + NetworkEvent::NewMessage(message) + | NetworkEvent::MessageUpdated(message) + | NetworkEvent::MessageSent { message, .. } => { + if history_chat == Some(message.chat_id) { + self.history_changes + .insert(message.id, Some(message.clone())); + } + if self.refresh_dialogs_pending { + self.changed_dialogs.insert(message.chat_id); + } + } + NetworkEvent::MessagesDeleted { + channel_id, + message_ids, + } => { + if let Some(chat_id) = history_chat + && channel_id.map_or(chat_id > -1_000_000_000_000, |id| id == chat_id) + { + for id in message_ids { + self.history_changes.insert(*id, None); + } + } + } + NetworkEvent::MessagesRead { chat_id, max_id } if history_chat == Some(*chat_id) => { + self.history_read_max = self.history_read_max.max(*max_id); + } + NetworkEvent::ReadMarked { chat_id, .. } + | NetworkEvent::ChatUnreadChanged { chat_id, .. } + | NetworkEvent::ChatUnreadFinished { chat_id, .. } + | NetworkEvent::UnreadChanged { chat_id, .. } + if self.refresh_dialogs_pending => + { + self.changed_dialogs.insert(*chat_id); + } + _ => {} + } + } + fn replace_dialogs(&mut self, mut chats: Vec) { let selected_id = self.selected_chat_entry().map(|chat| chat.id); let transient_linked = self @@ -2512,6 +3874,40 @@ impl App { .cloned() .collect::>(); chats.extend(transient_linked); + for chat in &mut chats { + if self.changed_dialogs.contains(&chat.id) { + if let Some(current) = self.chats.iter().find(|current| current.id == chat.id) { + chat.last_message.clone_from(¤t.last_message); + chat.last_activity = current.last_activity; + chat.last_message_id = current.last_message_id; + chat.unread = current.unread; + chat.membership.unread_mark = current.membership.unread_mark; + chat.read_inbox_max_id = chat.read_inbox_max_id.max(current.read_inbox_max_id); + } else if let Some(message) = self + .messages + .get(&chat.id) + .and_then(|messages| messages.last()) + && chat + .last_activity + .is_none_or(|timestamp| message.timestamp >= timestamp) + { + chat.last_message = message.preview_text(); + chat.last_message_id = Some(message.id); + chat.last_activity = Some(message.timestamp); + } + } + } + for current in &self.chats { + if self.changed_dialogs.contains(¤t.id) + && !chats.iter().any(|chat| chat.id == current.id) + { + chats.push(current.clone()); + } + } + if !self.changed_dialogs.is_empty() { + chats.sort_by_key(|chat| std::cmp::Reverse(chat.last_activity)); + } + self.changed_dialogs.clear(); self.linked_chat_ids .retain(|chat_id| chats.iter().any(|chat| chat.id == *chat_id)); for chat in &mut chats { @@ -2558,17 +3954,28 @@ impl App { sanitize_message(&mut message); let chat_id = message.chat_id; let outgoing = message.outgoing; + let preview = message.preview_text(); let text = message.text.clone(); let reply_to = message.reply_to.as_ref().map(|reply| reply.message_id); let timestamp = message.timestamp; let active = self.active_chat_id == Some(chat_id); - let viewing = active && self.focus == Focus::Conversation; - let detached = active && self.message_scroll > 0; + let outside_forward_page = active + && self + .reads + .forward + .is_some_and(|(chat, after)| chat == chat_id && message.id > after); + let detached = active && (self.message_scroll > 0 || outside_forward_page); let message_id = message.id; let was_new = self .messages .get(&chat_id) .is_none_or(|messages| !messages.iter().any(|current| current.id == message_id)); + let was_new = was_new + && (!outside_forward_page + || self + .active_chat() + .and_then(|chat| chat.last_message_id) + .is_none_or(|id| message_id > id)); let reconciled_local_id = (reconcile_accepted && was_new && message_id > 0 && outgoing) .then(|| { self.messages @@ -2579,7 +3986,11 @@ impl App { if let Some(local_id) = reconciled_local_id { self.clear_reconciled_retry(chat_id, local_id, &text, reply_to); } - let retained = { + let retained = if outside_forward_page { + // Keep a contiguous history window while reading forward. The + // coordinator already persisted the arrival for the later page. + true + } else { let messages = self.messages.entry(chat_id).or_default(); if let Some(preserved_id) = active.then_some(self.selected_message).flatten() { upsert_message_preserving(messages, message, preserved_id); @@ -2592,7 +4003,9 @@ impl App { self.prune_message_cache(); if genuinely_new && count_as_new && detached { self.new_messages_while_scrolled = self.new_messages_while_scrolled.saturating_add(1); - self.new_messages_to_anchor = self.new_messages_to_anchor.saturating_add(1); + if !outside_forward_page { + self.new_messages_to_anchor = self.new_messages_to_anchor.saturating_add(1); + } } let selected_id = self.selected_chat_entry().map(|chat| chat.id); @@ -2601,16 +4014,19 @@ impl App { }; { let chat = &mut self.chats[chat_index]; - chat.last_message = text; - chat.last_activity = Some(timestamp); + if message_id < 0 || chat.last_message_id.is_none_or(|last| message_id >= last) { + chat.last_message = preview; + if message_id > 0 { + chat.last_message_id = Some(message_id); + } + chat.last_activity = Some(timestamp); + } if genuinely_new && count_as_new && !outgoing - && (!viewing || detached || !self.terminal_focused) + && chat.read_inbox_max_id.is_none_or(|seen| message_id > seen) { chat.unread = chat.unread.saturating_add(1); - } else if viewing && !detached && self.terminal_focused { - chat.unread = 0; } } let chat = self.chats.remove(chat_index); @@ -2624,17 +4040,7 @@ impl App { .unwrap_or(0); self.clamp_chat_selection(); - if genuinely_new - && count_as_new - && !outgoing - && viewing - && !detached - && self.terminal_focused - { - self.request_mark_read(chat_id) - } else { - Vec::new() - } + Vec::new() } fn confirm_message(&mut self, local_id: i32, message: Message) -> Vec { @@ -2673,12 +4079,67 @@ impl App { } match result { Ok(messages) => { - self.merge_history(chat_id, messages, self.history_target_message); + let mut messages = messages; + let forward = self.reads.paging + || self + .reads + .entry + .as_ref() + .is_some_and(|entry| entry.chat_id == chat_id && !entry.confirmed); + if forward { + let last = messages + .iter() + .filter(|message| message.id > 0) + .map(|message| message.id) + .max(); + self.reads.forward = last + .filter(|last| { + self.active_chat() + .and_then(|chat| chat.last_message_id) + .is_some_and(|top| top > *last) + && messages.len() >= crate::telegram::HISTORY_LIMIT + }) + .map(|id| (chat_id, id)); + self.browsing_older = self.reads.forward.is_some(); + } + messages.retain(|message| !self.history_changes.contains_key(&message.id)); + messages.extend( + self.history_changes + .values() + .flatten() + .filter(|message| { + self.reads.forward.is_none_or(|(_, end)| message.id <= end) + }) + .cloned(), + ); + for message in &mut messages { + if message.outgoing && message.id > 0 && message.id <= self.history_read_max { + message.delivery = Delivery::Read; + } + } + if self.reads.paging { + let mut combined = self.active_messages().to_vec(); + combined.retain(|message| { + !self + .history_changes + .get(&message.id) + .is_some_and(Option::is_none) + }); + for message in messages { + upsert_message(&mut combined, message); + } + self.merge_history(chat_id, combined, self.history_target_message); + } else { + self.merge_history(chat_id, messages, self.history_target_message); + self.position_unread_entry(chat_id, true); + } self.status_message = None; } Err(error) => self.status_message = Some(sanitize_terminal_line(&error)), } self.active_history_request = None; + self.history_changes.clear(); + self.reads.paging = false; self.history_target_message = None; self.loading_history = false; Vec::new() @@ -2728,6 +4189,14 @@ impl App { return Vec::new(); } self.active_reply_request = None; + if self.reply_is_unavailable(&ReplyInfo { + chat_id: request.target_chat, + message_id: request.target_message, + sender: None, + }) { + self.status_message = Some("Original message is no longer available".to_owned()); + return Vec::new(); + } match result { Ok(mut message) if message.id == request.target_message @@ -2825,14 +4294,16 @@ impl App { if !failed_visible { return Vec::new(); } - let has_new_reply = self.reply_targets.contains_key(&chat_id); - let draft = self.drafts.entry(chat_id).or_default(); + let has_new_reply = self + .draft_data(chat_id) + .is_some_and(|draft| draft.reply.is_some()); + let draft = &mut self.draft_data_mut(chat_id).input; let retry_available = draft.is_empty() && !has_new_reply; if retry_available { draft.set_value(sanitize_terminal_text(text)); self.retry_message_ids.insert(chat_id, local_id); if let Some(reply_to) = reply_to { - self.reply_targets.insert(chat_id, reply_to); + self.draft_data_mut(chat_id).reply = Some(reply_to); } } let error = sanitize_terminal_line(error); @@ -2879,20 +4350,37 @@ impl App { sanitize_message(&mut message); let chat_id = message.chat_id; let message_id = message.id; - self.media_previews.remove(&(chat_id, message_id)); - // Telegram can replace media while retaining its name, MIME type, and size. - self.downloaded_attachments.remove(&(chat_id, message_id)); - let attachment_changed = self.messages.get(&chat_id).is_some_and(|messages| { - messages - .iter() - .find(|current| current.id == message_id) - .is_some_and(|current| current.attachment != message.attachment) - }); - if attachment_changed { + let media_id = message + .attachment + .as_ref() + .and_then(|attachment| attachment.source_id); + let same_media = media_id.is_some() + && self + .messages + .get(&chat_id) + .and_then(|messages| messages.iter().find(|current| current.id == message_id)) + .and_then(|current| current.attachment.as_ref()) + .and_then(|attachment| attachment.source_id) + == media_id; + if !same_media { + self.media_previews.remove(&(chat_id, message_id)); self.downloaded_attachments.remove(&(chat_id, message_id)); - self.downloading_attachments.remove(&(chat_id, message_id)); + if self + .downloading_attachments + .remove(&(chat_id, message_id)) + .is_some() + { + self.status_message = Some(format!( + "Attachment changed · {} to try again", + self.keymap + .hint(crate::keymap::Context::Conversation, "reveal") + )); + } + self.reveal_after_download.remove(&(chat_id, message_id)); } - let preview = message.text.clone(); + let text = message.text.clone(); + let preview = message.preview_text(); + let timestamp = message.timestamp; let reply_to = message.reply_to.as_ref().map(|reply| reply.message_id); let reconciled_local_id = { let messages = self.messages.entry(chat_id).or_default(); @@ -2902,21 +4390,32 @@ impl App { .flatten() }; if let Some(local_id) = reconciled_local_id { - self.clear_reconciled_retry(chat_id, local_id, &preview, reply_to); + self.clear_reconciled_retry(chat_id, local_id, &text, reply_to); } let preserved = (self.active_chat_id == Some(chat_id)) .then_some(self.selected_message) .flatten(); - let messages = self.messages.entry(chat_id).or_default(); - if let Some(preserved_id) = preserved { - upsert_message_preserving(messages, message, preserved_id); - } else { - upsert_message(messages, message); + if !self + .reads + .forward + .is_some_and(|(chat, end)| chat == chat_id && message_id > end) + { + let messages = self.messages.entry(chat_id).or_default(); + if let Some(preserved_id) = preserved { + upsert_message_preserving(messages, message, preserved_id); + } else { + upsert_message(messages, message); + } } - let is_latest = messages - .last() - .is_some_and(|latest| latest.id == message_id); - if is_latest && let Some(chat) = self.chats.iter_mut().find(|chat| chat.id == chat_id) { + if let Some(chat) = self.chats.iter_mut().find(|chat| chat.id == chat_id) + && chat.last_message_id.map_or_else( + || { + chat.last_activity + .is_none_or(|activity| timestamp >= activity) + }, + |id| message_id == id, + ) + { chat.last_message = preview; } self.prune_message_cache(); @@ -2977,10 +4476,11 @@ impl App { text: &str, reply_to: Option, ) { - let attachment_retry = self - .retry_attachments - .remove(&(chat_id, local_id)) - .is_some(); + let attachment_retry = self.retry_attachments.remove(&(chat_id, local_id)); + if let Some(retry) = &attachment_retry { + retry.attachment.discard_owned(); + } + let attachment_retry = attachment_retry.is_some(); if self.selected_message == Some(local_id) { self.selected_message = None; } @@ -2995,18 +4495,17 @@ impl App { if self.retry_message_ids.get(&chat_id) == Some(&local_id) { self.retry_message_ids.remove(&chat_id); if self - .drafts - .get(&chat_id) + .draft_for(chat_id) .is_some_and(|draft| draft.value() == text) { - self.drafts.entry(chat_id).or_default().clear(); + self.draft_data_mut(chat_id).input.clear(); if self - .reply_targets - .get(&chat_id) + .draft_data(chat_id) + .and_then(|draft| draft.reply.as_ref()) .map(|reply| reply.message_id) == reply_to { - self.reply_targets.remove(&chat_id); + self.draft_data_mut(chat_id).reply.take(); } } if self.active_chat_id == Some(chat_id) { @@ -3015,14 +4514,6 @@ impl App { } } - fn request_mark_read(&mut self, chat_id: ChatId) -> Vec { - self.read_ack_pending - .insert(chat_id) - .then_some(TelegramCommand::MarkRead { chat_id }) - .into_iter() - .collect() - } - fn request_dialog_refresh(&mut self) -> Vec { if self.refresh_dialogs_pending { Vec::new() @@ -3045,11 +4536,13 @@ impl App { .is_some_and(|messages| messages.iter().any(|message| message.id == message_id)) }); self.downloading_attachments - .retain(|&(chat_id, message_id)| { + .retain(|&(chat_id, message_id), _| { self.messages .get(&chat_id) .is_some_and(|messages| messages.iter().any(|message| message.id == message_id)) }); + self.reveal_after_download + .retain(|key| self.downloading_attachments.contains_key(key)); if self.messages.len() <= MAX_CACHED_CHATS { return; } @@ -3072,12 +4565,6 @@ impl App { .then_some(chat_id) })); self.messages.retain(|chat_id, _| keep.contains(chat_id)); - self.drafts.retain(|chat_id, draft| { - !draft.is_empty() || keep.contains(chat_id) || Some(*chat_id) == active - }); - self.reply_targets.retain(|chat_id, _| { - self.drafts.contains_key(chat_id) || keep.contains(chat_id) || Some(*chat_id) == active - }); } } @@ -3184,6 +4671,7 @@ pub fn telegram_link(text: &str) -> Option { || lower.starts_with("http://t.me/") || lower.starts_with("https://telegram.me/") || lower.starts_with("http://telegram.me/") + || lower.starts_with("tg://join?") || lower.starts_with("tg://resolve?") || lower.starts_with("tg://privatepost?") { @@ -3200,9 +4688,11 @@ pub fn telegram_link(text: &str) -> Option { let lower = normalized.to_ascii_lowercase(); let has_target = if lower.starts_with("tg://") { lower.split_once('?').is_some_and(|(_, query)| { - query - .split('&') - .any(|field| field.starts_with("domain=") || field.starts_with("channel=")) + query.split('&').any(|field| { + field.starts_with("domain=") + || field.starts_with("channel=") + || field.starts_with("invite=") + }) }) } else { lower @@ -3227,64 +4717,6 @@ fn pointer_in_region(region: Option<(u16, u16, u16, u16)>, column: u16, row: u16 }) } -fn dropped_file_paths(text: &str) -> Option> { - let trimmed = text.trim(); - if trimmed.is_empty() { - return None; - } - let direct = Path::new(trimmed); - if direct.is_file() { - return std::fs::canonicalize(direct).ok().map(|path| vec![path]); - } - - let tokens = shellish_paths(trimmed)?; - if tokens.is_empty() { - return None; - } - tokens - .into_iter() - .map(PathBuf::from) - .map(|path| { - path.is_file() - .then(|| std::fs::canonicalize(path).ok()) - .flatten() - }) - .collect::>>() -} - -fn shellish_paths(value: &str) -> Option> { - let mut paths = Vec::new(); - let mut current = String::new(); - let mut quote = None; - let mut escaped = false; - for character in value.chars() { - if escaped { - current.push(character); - escaped = false; - continue; - } - match (quote, character) { - (Some('\''), '\'') | (Some('"'), '"') => quote = None, - (None, '\\') => escaped = true, - (Some(_), _) => current.push(character), - (None, '\'' | '"') => quote = Some(character), - (None, character) if character.is_whitespace() => { - if !current.is_empty() { - paths.push(std::mem::take(&mut current)); - } - } - (None, character) => current.push(character), - } - } - if escaped || quote.is_some() { - return None; - } - if !current.is_empty() { - paths.push(current); - } - Some(paths) -} - fn attachment_from_path(path: &Path) -> Attachment { let extension = path .extension() @@ -3305,13 +4737,14 @@ fn attachment_from_path(path: &Path) -> Attachment { _ => (AttachmentKind::File, None), }; Attachment { + source_id: None, kind, file_name: path .file_name() .and_then(|name| name.to_str()) .map(sanitize_terminal_line), mime_type: mime_type.map(str::to_owned), - size: path.metadata().ok().map(|metadata| metadata.len()), + size: None, fallback_emoji: None, } } @@ -3323,7 +4756,11 @@ fn reveal_path(path: &Path) -> std::io::Result<()> { "downloaded file no longer exists", )); } - reveal_path_platform(path).map(|_| ()) + let mut child = reveal_path_platform(path)?; + std::thread::spawn(move || { + let _ = child.wait(); + }); + Ok(()) } #[cfg(target_os = "macos")] @@ -3489,7 +4926,7 @@ mod tests { use std::fs; use chrono::{TimeZone, Utc}; - use yazi_term::event::{Modifiers, MouseButton, MouseEvent, MouseEventKind}; + use yazi_term::event::{KeyEvent, Modifiers, MouseButton, MouseEvent, MouseEventKind}; use super::{ App, AttachmentState, AuthPhase, Focus, MAX_CACHED_CHATS, MAX_MESSAGES_PER_CHAT, Mode, @@ -3507,10 +4944,13 @@ mod tests { fn chat(id: i64, title: &str) -> Chat { Chat { + read_inbox_max_id: Some(0), + membership: crate::folders::ChatMembership::default(), id, title: title.to_owned(), kind: ChatKind::Direct, unread: 3, + last_message_id: None, last_message: format!("from {title}"), last_activity: None, } @@ -3518,6 +4958,13 @@ mod tests { fn message(id: i32, chat_id: i64, text: &str, outgoing: bool) -> Message { Message { + reactions: None, + poll: None, + entities: Vec::new(), + notification: None, + mention: None, + edited_at: None, + pinned: false, id, chat_id, sender: if outgoing { "Me" } else { "Them" }.to_owned(), @@ -3546,6 +4993,13 @@ mod tests { } fn open_first(app: &mut App) { + // General interaction fixtures start in an already-read conversation. + // Unread-entry tests open an explicit server boundary separately. + app.chats + .iter_mut() + .find(|chat| chat.id == 1) + .unwrap() + .unread = 0; app.handle_action(KeyAction::Enter); app.handle_network(NetworkEvent::History { chat_id: 1, @@ -3557,1748 +5011,4739 @@ mod tests { } #[test] - fn password_is_masked_and_submitted_without_trimming() { - let mut app = App::new(); - app.handle_network(NetworkEvent::Auth(AuthPrompt::Password { - hint: Some("pet".to_owned()), - })); - app.handle_action(KeyAction::Character('界')); - app.handle_action(KeyAction::Character('x')); - assert_eq!(app.auth_display_value(), "••"); - assert_eq!(app.auth_cursor_display_width(), 2); - assert_eq!( - app.handle_action(KeyAction::Enter), - vec![TelegramCommand::SubmitPassword("界x".to_owned())] + #[allow(clippy::too_many_lines)] + fn reactions_use_reviewed_identity_explicit_keys_and_bounded_refresh() { + let mut app = ready_app(); + open_first(&mut app); + app.connection = crate::event::ConnectionStatus::Online; + app.selected_message = Some(20); + let review = crate::reactions::example(); + app.messages + .get_mut(&1) + .unwrap() + .last_mut() + .unwrap() + .reactions = Some(review.summary.clone()); + let commands = app.run_binding("reactions", 1); + let [TelegramCommand::LoadReactions { request_id, .. }] = commands.as_slice() else { + panic!("load choices") + }; + assert_eq!(app.mode, Mode::Reactions); + assert!(app.run_binding("open", 1).is_empty()); + app.handle_network(NetworkEvent::ReactionsLoaded { + chat_id: 1, + message_id: 20, + request_id: *request_id, + result: Ok(review.clone()), + }); + for width in [40, 110] { + let mut terminal = + ratatui::Terminal::new(ratatui::backend::TestBackend::new(width, 24)).unwrap(); + terminal + .draw(|frame| crate::ui::render(frame, &mut app)) + .unwrap(); + let text = terminal + .backend() + .buffer() + .content + .iter() + .map(ratatui::buffer::Cell::symbol) + .collect::(); + assert!(text.contains("Reactions")); + assert!(text.contains("Love")); + assert!(text.contains('●')); + } + let (x, _, y, _) = app.reactions.hit_rows[1]; + assert!( + app.handle_mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: x, + row: y, + modifiers: Modifiers::empty() + }) + .is_empty(), + "click only selects" + ); + let commands = app.run_binding("open", 1); + let [ + TelegramCommand::ChangeReaction { + request_id, + expected, + emoji, + .. + }, + ] = commands.as_slice() + else { + panic!("explicit toggle") + }; + assert_eq!(emoji.as_deref(), Some("❤")); + assert_eq!(*expected, review.summary.chosen()); + assert!(app.run_binding("open", 1).is_empty()); + app.handle_network(NetworkEvent::ReactionsFinished { + chat_id: 1, + message_id: Some(20), + request_id: *request_id, + error: Some("Timeout".to_owned()), + }); + assert!( + app.run_binding("open", 1).is_empty(), + "refresh before uncertain retry" + ); + let commands = app.run_binding("refresh", 1); + let [TelegramCommand::LoadReactions { request_id, .. }] = commands.as_slice() else { + panic!("refresh") + }; + app.handle_network(NetworkEvent::ReactionsLoaded { + chat_id: 1, + message_id: 20, + request_id: *request_id, + result: Ok(review), + }); + let commands = app.run_binding("clear_reactions", 1); + let [ + TelegramCommand::ChangeReaction { + request_id, + emoji: None, + .. + }, + ] = commands.as_slice() + else { + panic!("remove mine") + }; + app.run_binding("cancel", 1); + assert!( + app.run_binding("reactions", 1).is_empty(), + "retain pending write after close" + ); + app.handle_network(NetworkEvent::ReactionsFinished { + chat_id: 1, + message_id: Some(20), + request_id: *request_id, + error: Some("not applied".to_owned()), + }); + assert!(app.status_message.as_ref().unwrap().contains("not applied")); + let commands = app.request_visible_reactions(); + let [ + TelegramCommand::RefreshReactions { + chat_id, + request_id, + message_ids, + }, + ] = commands.as_slice() + else { + panic!("batch visible reactions") + }; + assert_eq!(message_ids, &[20]); + assert!(app.request_visible_reactions().is_empty()); + app.handle_network(NetworkEvent::ReactionsFinished { + chat_id: *chat_id, + message_id: None, + request_id: *request_id, + error: None, + }); + app.set_visible_reactions(Vec::new()); + app.set_visible_reactions(vec![(1, 20)]); + assert!( + app.request_visible_reactions().is_empty(), + "redrawing does not bypass the refresh interval" + ); + app.terminal_focused = false; + assert!(app.next_reaction_deadline().is_none()); + let commands = app.run_binding("reactions", 1); + let [TelegramCommand::LoadReactions { request_id, .. }] = commands.as_slice() else { + panic!("new review") + }; + app.handle_network(NetworkEvent::MessagesDeleted { + channel_id: None, + message_ids: vec![20], + }); + app.handle_network(NetworkEvent::ReactionsLoaded { + chat_id: 1, + message_id: 20, + request_id: *request_id, + result: Ok(crate::reactions::example()), + }); + assert!(!app.reactions.panel.as_ref().unwrap().ready); + assert!( + app.reactions + .panel + .as_ref() + .unwrap() + .error + .as_ref() + .unwrap() + .contains("deleted") + ); + } + + #[test] + #[allow(clippy::too_many_lines)] + fn poll_review_requires_explicit_submission_and_keeps_errors_readable() { + let mut app = ready_app(); + open_first(&mut app); + app.connection = crate::event::ConnectionStatus::Online; + app.selected_message = Some(20); + let poll = crate::polls::example(); + app.messages.get_mut(&1).unwrap().last_mut().unwrap().poll = Some(poll.clone()); + let commands = app.run_binding("poll", 1); + let [TelegramCommand::LoadPoll { request_id, .. }] = commands.as_slice() else { + panic!("refresh before voting") + }; + assert_eq!(app.mode, Mode::Poll); + assert!(app.run_binding("send", 1).is_empty()); + app.handle_network(NetworkEvent::PollLoaded { + chat_id: 1, + message_id: 20, + request_id: *request_id, + result: Ok(poll.clone()), + }); + for width in [40, 110] { + let mut terminal = + ratatui::Terminal::new(ratatui::backend::TestBackend::new(width, 24)).unwrap(); + terminal + .draw(|frame| crate::ui::render(frame, &mut app)) + .unwrap(); + let text = terminal + .backend() + .buffer() + .content + .iter() + .map(ratatui::buffer::Cell::symbol) + .collect::(); + assert!(text.contains("Choose your drink")); + assert!(text.contains("anonymous")); + assert!(!text.contains('%'), "hide results before voting"); + } + let (x, _, y, _) = app.polls.hit_rows[0]; + assert!( + app.handle_mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: x, + row: y, + modifiers: Modifiers::empty() + }) + .is_empty() + ); + assert!(app.run_binding("down", 1).is_empty()); + assert!(app.run_binding("toggle_poll_answer", 1).is_empty()); + assert_eq!(app.polls.review.as_ref().unwrap().choices.len(), 2); + let commands = app.run_binding("send", 1); + let [ + TelegramCommand::VotePoll { + request_id, + options, + revision, + .. + }, + ] = commands.as_slice() + else { + panic!("explicit send") + }; + assert_eq!(options, &[vec![0, 255], vec![1, 128]]); + assert_eq!(*revision, poll.definition.revision()); + assert!(app.run_binding("send", 1).is_empty(), "one pending request"); + let error = "Vote status is uncertain; refresh the poll before retrying"; + app.handle_network(NetworkEvent::PollFinished { + chat_id: 1, + message_id: 20, + request_id: *request_id, + voting: true, + error: Some(error.to_owned()), + }); + assert_eq!(app.polls.review.as_ref().unwrap().choices.len(), 2); + assert!( + app.run_binding("send", 1).is_empty(), + "refresh after uncertain RPC" + ); + let mut terminal = + ratatui::Terminal::new(ratatui::backend::TestBackend::new(40, 24)).unwrap(); + terminal + .draw(|frame| crate::ui::render(frame, &mut app)) + .unwrap(); + let text = terminal + .backend() + .buffer() + .content + .iter() + .map(ratatui::buffer::Cell::symbol) + .collect::(); + assert!(text.contains("Vote status is uncertain")); + let commands = app.run_binding("refresh", 1); + let [TelegramCommand::LoadPoll { request_id, .. }] = commands.as_slice() else { + panic!("refresh") + }; + let mut voted = poll.clone(); + voted.results.counts.push(crate::polls::Count { + option: vec![0, 255], + chosen: true, + voters: Some(2), + correct: false, + }); + app.handle_network(NetworkEvent::PollLoaded { + chat_id: 1, + message_id: 20, + request_id: *request_id, + result: Ok(voted), + }); + assert!(app.run_binding("retract_vote", 1).is_empty()); + let commands = app.run_binding("send", 1); + let [ + TelegramCommand::VotePoll { + request_id, + options, + .. + }, + ] = commands.as_slice() + else { + panic!("explicit retract") + }; + assert!(options.is_empty()); + app.run_binding("cancel", 1); + assert_eq!(app.mode, Mode::Navigate); + assert!( + app.run_binding("poll", 1).is_empty(), + "pending vote retained after closing" + ); + assert!( + app.handle_network(NetworkEvent::PollFinished { + chat_id: 1, + message_id: 20, + request_id: *request_id, + voting: true, + error: None + }) + .is_empty() + ); + assert!(app.polls.review.is_none()); + let commands = app.run_binding("poll", 1); + let [TelegramCommand::LoadPoll { request_id, .. }] = commands.as_slice() else { + panic!("open again") + }; + app.handle_network(NetworkEvent::MessagesDeleted { + channel_id: None, + message_ids: vec![20], + }); + app.handle_network(NetworkEvent::PollLoaded { + chat_id: 1, + message_id: 20, + request_id: *request_id, + result: Ok(poll), + }); + assert!(!app.polls.review.as_ref().unwrap().ready); + assert!( + app.polls + .review + .as_ref() + .unwrap() + .error + .as_ref() + .unwrap() + .contains("deleted") + ); + } + + #[test] + fn poll_refresh_is_bounded_visible_and_focus_aware() { + let mut app = ready_app(); + open_first(&mut app); + app.connection = crate::event::ConnectionStatus::Online; + for message in app.messages.get_mut(&1).unwrap() { + message.poll = Some(crate::polls::example()); + } + app.set_visible_polls((1..=20).map(|id| (1, id)).collect()); + let commands = app.request_visible_polls(); + assert_eq!(commands.len(), 2); + assert!(app.request_visible_polls().is_empty()); + assert!(app.next_poll_deadline().is_none()); + for command in commands { + let TelegramCommand::RefreshPoll { + chat_id, + message_id, + request_id, + .. + } = command + else { + unreachable!() + }; + app.handle_network(NetworkEvent::PollFinished { + chat_id, + message_id, + request_id, + voting: false, + error: None, + }); + } + app.terminal_focused = false; + assert!(app.request_visible_polls().is_empty()); + assert!(app.next_poll_deadline().is_none()); + app.terminal_focused = true; + app.set_visible_polls(Vec::new()); + assert!(app.request_visible_polls().is_empty()); + assert!(app.next_poll_deadline().is_none()); + } + + #[test] + fn unread_entry_verifies_cached_position_and_pages_without_live_message_gaps() { + let mut app = ready_app(); + app.chats[0].read_inbox_max_id = Some(10); + app.chats[0].last_message_id = Some(200); + app.chats[0].unread = 190; + assert_eq!( + app.handle_action(KeyAction::Enter), + vec![TelegramCommand::LoadHistory { + chat_id: 1, + request_id: 1, + after_id: Some(10), + }] + ); + app.handle_network(NetworkEvent::CachedHistory { + chat_id: 1, + request_id: 1, + messages: vec![message(150, 1, "cached gap", false)], + }); + app.set_visible_read_boundary(1, Some(150)); + assert!(app.request_visible_read().is_empty()); + app.handle_network(NetworkEvent::History { + chat_id: 1, + request_id: 1, + messages: (11..=90).map(|id| message(id, 1, "page", false)).collect(), + }); + assert_eq!(app.unread_separator(), Some(11)); + assert_eq!(app.viewport_anchor_message, Some(11)); + app.handle_network(NetworkEvent::ReadMarked { + chat_id: 1, + max_id: 20, + snapshot: None, + }); + assert_eq!(app.unread_separator(), Some(11)); + app.handle_network(NetworkEvent::NewMessage(message(201, 1, "live", false))); + app.handle_network(NetworkEvent::MessageUpdated(message( + 201, + 1, + "live edit", + false, + ))); + assert!( + !app.active_messages() + .iter() + .any(|message| message.id == 201) + ); + assert_eq!(app.new_messages_to_anchor, 0); + app.selected_message = Some(90); + let next = app.run_binding("message_down", 1); + assert!(matches!( + next.as_slice(), + [TelegramCommand::LoadHistory { + after_id: Some(90), + .. + }] + )); + let request_id = app.active_history_request.unwrap().1; + app.handle_network(NetworkEvent::History { + chat_id: 1, + request_id, + messages: (91..=170).map(|id| message(id, 1, "next", false)).collect(), + }); + assert!(app.active_messages().iter().any(|message| message.id == 90)); + assert!( + app.active_messages() + .iter() + .any(|message| message.id == 170) + ); + assert!( + !app.active_messages() + .iter() + .any(|message| message.id == 201) + ); + assert!(matches!( + app.run_binding("latest", 1).as_slice(), + [TelegramCommand::LoadHistory { after_id: None, .. }] + )); + assert_eq!(app.unread_separator(), None); + assert_eq!(app.message_scroll, 0); + } + + #[test] + fn unread_reminder_keeps_its_captured_target_and_never_rewinds_receipts() { + let mut app = ready_app(); + app.chats[0].read_inbox_max_id = Some(20); + app.chats[0].last_message_id = Some(20); + app.chats[0].unread = 0; + app.run_binding("command", 1); + app.commands.input.set_value("mark-unread"); + app.chats.reverse(); + app.selected_chat = 0; + let outgoing = app.run_binding("open", 1); + let [ + TelegramCommand::SetChatUnread { + chat_id: 1, + unread: true, + read_history: false, + request_id, + }, + ] = outgoing.as_slice() + else { + panic!("captured target") + }; + let request_id = *request_id; + assert!( + !app.chats + .iter() + .find(|chat| chat.id == 1) + .unwrap() + .membership + .unread_mark + ); + app.handle_network(NetworkEvent::ChatUnreadFinished { + chat_id: 1, + unread: true, + request_id, + snapshot: None, + error: None, + }); + let chat = app.chats.iter().find(|chat| chat.id == 1).unwrap(); + assert!(chat.membership.unread_mark); + assert_eq!(chat.read_inbox_max_id, Some(20)); + let open = app.open_chat_by_id(1); + let [TelegramCommand::LoadHistory { request_id, .. }] = open.as_slice() else { + panic!("history request") + }; + app.handle_network(NetworkEvent::History { + chat_id: 1, + request_id: *request_id, + messages: vec![message(20, 1, "already read", false)], + }); + app.set_visible_read_boundary(1, Some(20)); + assert!(matches!( + app.request_visible_read().as_slice(), + [TelegramCommand::SetChatUnread { + chat_id: 1, + unread: false, + read_history: false, + .. + }] + )); + } + + #[test] + fn mute_commands_capture_chat_and_wait_for_authoritative_settings() { + use crate::notifications::Mute; + let mut app = ready_app(); + app.run_binding("command", 1); + app.commands.input.set_value("mute 8h"); + app.chats.reverse(); + app.selected_chat = 0; + let commands = app.run_binding("open", 1); + let [ + TelegramCommand::SetChatMute { + chat_id: 1, + mute: Mute::EightHours, + request_id, + }, + ] = commands.as_slice() + else { + panic!("mute original target") + }; + assert_eq!( + app.chats + .iter() + .find(|chat| chat.id == 1) + .unwrap() + .membership + .mute_until, + 0 + ); + assert!( + app.set_chat_mute(1, Mute::Off).is_empty(), + "serialize settings changes for one chat" + ); + let until = i64::from(Mute::EightHours.until(chrono::Utc::now().timestamp())); + app.handle_network(NetworkEvent::ChatMuteFinished { + chat_id: 1, + request_id: *request_id, + result: Ok(Some(until)), + }); + assert_eq!( + app.chats + .iter() + .find(|chat| chat.id == 1) + .unwrap() + .membership + .mute_until, + until + ); + app.preserve_chat_selection(Some(1)); + assert!(app.focused_mute_label().unwrap().starts_with("Muted until")); + app.run_binding("command", 1); + app.commands.input.set_value("mute unsupported"); + assert!(app.run_binding("open", 1).is_empty()); + assert_eq!(app.mode, Mode::Command); + app.commands.input.set_value("unmute"); + let commands = app.run_binding("open", 1); + let [ + TelegramCommand::SetChatMute { + request_id, + mute: Mute::Off, + .. + }, + ] = commands.as_slice() + else { + panic!("unmute") + }; + app.handle_network(NetworkEvent::ChatMuteFinished { + chat_id: 1, + request_id: *request_id, + result: Err("failed".to_owned()), + }); + assert_eq!( + app.chats + .iter() + .find(|chat| chat.id == 1) + .unwrap() + .membership + .mute_until, + until + ); + let commands = app.run_binding("unmute_chat", 1); + let [TelegramCommand::SetChatMute { request_id, .. }] = commands.as_slice() else { + panic!("retry") + }; + app.handle_network(NetworkEvent::ChatMuteChanged { + chat_id: 1, + until: i64::from(i32::MAX), + }); + app.handle_network(NetworkEvent::ChatMuteFinished { + chat_id: 1, + request_id: *request_id, + result: Ok(None), + }); + assert_eq!(app.focused_mute_label().as_deref(), Some("Muted forever")); + app.handle_network(NetworkEvent::ChatMuteChanged { + chat_id: 1, + until: 0, + }); + assert!(app.focused_mute_label().is_none()); + app.connection = crate::event::ConnectionStatus::Offline; + assert!(app.run_binding("mute_chat", 1).is_empty()); + } + + #[test] + fn commands_keep_stable_targets_and_never_guess_incomplete_commands() { + let mut app = ready_app(); + app.account_user_id = Some(42); + app.run_binding("command", 1); + app.commands.input.set_value("pin"); + assert!(app.run_binding("open", 1).is_empty()); + assert_eq!(app.mode, Mode::Command); + assert_eq!(app.commands.input.value(), "pin "); + app.commands.input.set_value("pin chat"); + app.chats.reverse(); + app.selected_chat = 0; + assert!(matches!( + app.run_binding("open", 1).as_slice(), + [TelegramCommand::ChangeDialogPin { + chat_id: 1, + action: crate::pins::DialogAction::Set(true), + .. + }] + )); + app.pins.dialogs.main = vec![1]; + app.preserve_chat_selection(Some(1)); + app.run_binding("command", 1); + app.commands.input.set_value("pin chat"); + assert!(app.run_binding("open", 1).is_empty()); + assert_eq!(app.status_message.as_deref(), Some("Already pinned")); + app.run_binding("command", 1); + app.commands.input.set_value("qui"); + assert!(app.run_binding("open", 1).is_empty()); + assert!(!app.should_quit); + app.commands.input.set_value("archive"); + app.account_user_id = Some(43); + assert!(app.run_binding("open", 1).is_empty()); + assert!( + app.commands + .notice + .as_deref() + .unwrap() + .contains("account changed") + ); + app.run_binding("cancel", 1); + app.chats + .iter_mut() + .find(|chat| chat.id == 2) + .unwrap() + .membership + .archived = true; + app.run_binding("command", 1); + app.commands.input.set_value("chat 2"); + app.run_binding("open", 1); + assert_eq!(app.active_chat_id, Some(2)); + app.browsing_older = true; + app.run_binding("command", 1); + app.commands.input.set_value("latest"); + assert!( + app.run_binding("open", 1) + .iter() + .any(|command| matches!(command, TelegramCommand::LoadHistory { chat_id: 2, .. })) + ); + assert_eq!(app.active_chat_id, Some(2)); + } + + #[test] + fn command_completion_history_and_paste_preserve_editor_context() { + use yazi_term::event::KeyCode; + let mut app = ready_app(); + app.account_user_id = Some(42); + open_first(&mut app); + app.message_scroll = 4; + app.selected_message = Some(8); + app.draft_data_mut(1).input = crate::input::TextInput::from_value("Unsent draft"); + app.handle_key(&KeyEvent::new(KeyCode::Char(':'), Modifiers::empty())); + assert_eq!(app.mode, Mode::Command); + app.commands.input.set_value("sta"); + app.handle_key(&KeyEvent::new(KeyCode::Tab, Modifiers::empty())); + assert_eq!(app.commands.input.value(), "status"); + app.handle_key(&KeyEvent::new(KeyCode::Char('c'), Modifiers::CONTROL)); + assert_eq!(app.mode, Mode::Navigate); + assert!(!app.should_quit); + assert_eq!(app.selected_message, Some(8)); + assert_eq!(app.message_scroll, 4); + assert_eq!(app.active_draft().unwrap().value(), "Unsent draft"); + app.run_binding("command", 1); + app.commands.input.set_value("search "); + app.update(AppEvent::Paste("\\b中文\\s+ ".to_owned())); + let outgoing = app.run_binding("open", 1); + assert!( + matches!(&outgoing[0], TelegramCommand::SearchCached(request) if request.pattern == "\\b中文\\s+ ") + ); + app.run_binding("cancel", 1); + app.run_binding("command", 1); + app.commands.input.set_value("se"); + app.handle_key(&KeyEvent::new(KeyCode::Up, Modifiers::empty())); + assert_eq!(app.commands.input.value(), "search \\b中文\\s+ "); + app.handle_key(&KeyEvent::new(KeyCode::Down, Modifiers::empty())); + assert_eq!(app.commands.input.value(), "se"); + app.run_binding("cancel", 1); + app.account_user_id = Some(43); + app.run_binding("command", 1); + app.handle_key(&KeyEvent::new(KeyCode::Up, Modifiers::empty())); + assert!(app.commands.input.is_empty()); + app.update(AppEvent::Paste("quit\nquit".to_owned())); + assert!(!app.should_quit); + assert!(!app.commands.input.value().contains('\n')); + app.run_binding("cancel", 1); + app.run_binding("compose", 1); + app.handle_key(&KeyEvent::new(KeyCode::Char(':'), Modifiers::empty())); + assert!(app.active_draft().unwrap().value().ends_with(':')); + } + + #[test] + fn restored_drafts_preserve_cursor_reply_and_account_identity() { + let mut app = ready_app(); + let saved = crate::drafts::Stored { + edit: None, + attachments: Vec::new(), + chat: 1, + topic: 0, + text: "a界🙂 old draft".to_owned(), + cursor: 1, + reply: Some(ReplyInfo { + chat_id: 1, + message_id: 5, + sender: Some("Original author".to_owned()), + }), + }; + app.handle_network(NetworkEvent::LocalDrafts { + user_id: 100, + drafts: vec![saved.clone()], + }); + app.handle_network(NetworkEvent::AccountIdentity { user_id: 100 }); + open_first(&mut app); + assert_eq!(app.active_draft().unwrap().cursor(), 1); + assert_eq!(app.active_reply_target().unwrap().message_id, 5); + app.run_binding("compose", 1); + app.handle_action(KeyAction::Character('X')); + assert_eq!(app.active_draft().unwrap().value(), "aX界🙂 old draft"); + app.handle_network(NetworkEvent::CacheInvalidated { chat_id: None }); + assert_eq!(app.active_reply_target().unwrap().message_id, 5); + app.reset_for_account_switch(2); + app.handle_network(NetworkEvent::LocalDrafts { + user_id: 200, + drafts: vec![crate::drafts::Stored { + text: "Second account".to_owned(), + ..saved.clone() + }], + }); + app.handle_network(NetworkEvent::AccountIdentity { user_id: 200 }); + assert_eq!(app.draft_for(1).unwrap().value(), "Second account"); + let pending = app.take_draft_snapshot().unwrap(); + assert_eq!(pending[&100][0].text, "aX界🙂 old draft"); + assert!( + !pending.contains_key(&200), + "merely loading another account must not rewrite it" + ); + app.reset_for_account_switch(1); + app.handle_network(NetworkEvent::LocalDrafts { + user_id: 100, + drafts: vec![saved], + }); + app.handle_network(NetworkEvent::AccountIdentity { user_id: 100 }); + assert_eq!(app.draft_for(1).unwrap().value(), "aX界🙂 old draft"); + assert_eq!(app.draft_for(1).unwrap().cursor(), 2); + app.draft_data_mut(1).input.clear(); + app.draft_data_mut(1).reply = None; + assert!(app.take_draft_snapshot().unwrap()[&100].is_empty()); + } + + #[test] + fn counted_message_motion_continues_across_a_history_page() { + let mut app = ready_app(); + app.handle_action(KeyAction::Enter); + app.handle_network(NetworkEvent::History { + chat_id: 1, + request_id: 1, + messages: (81..=160) + .map(|id| message(id, 1, "wrapped\nmessage", false)) + .collect(), + }); + app.keymap = crate::keymap::Keymap::parse( + "return {keymap={{context='conversation',on={''},run='message_up',count=100}}}", + ) + .unwrap(); + let commands = app.handle_key(&KeyEvent::new( + yazi_term::event::KeyCode::Char('u'), + Modifiers::CONTROL, + )); + let TelegramCommand::LoadOlder { + request_id, + before_id, + .. + } = commands[0] + else { + panic!("load older page"); + }; + assert_eq!(before_id, 81); + app.handle_network(NetworkEvent::OlderHistory { + chat_id: 1, + request_id, + before_id, + messages: (1..=80).map(|id| message(id, 1, "older", false)).collect(), + }); + assert_eq!(app.selected_message, Some(60)); + assert!( + app.run_binding("latest", 1) + .iter() + .any(|command| matches!(command, TelegramCommand::LoadHistory { .. })) + ); + } + + #[test] + fn cached_conversations_are_usable_before_network_authentication() { + let mut app = App::new(); + app.handle_network(NetworkEvent::CachedSnapshot { + user_name: Some("Ada".to_owned()), + chats: vec![chat(1, "Cached")], + }); + assert_eq!(app.screen, Screen::Main); + assert_eq!(app.connection, crate::event::ConnectionStatus::Connecting); + app.handle_action(KeyAction::Enter); + app.handle_network(NetworkEvent::CachedHistory { + chat_id: 1, + request_id: 1, + messages: vec![message(1, 1, "available offline", false)], + }); + assert_eq!(app.active_messages()[0].text, "available offline"); + assert!( + app.loading_history, + "cached content remains visible during reconciliation" + ); + } + + #[test] + fn global_overlay_toggles_restore_the_composer_and_draft() { + use yazi_term::event::KeyCode; + let mut app = ready_app(); + open_first(&mut app); + app.start_composing(); + app.handle_action(KeyAction::Character('x')); + app.keymap = crate::keymap::Keymap::parse( + r"return {keymap={ + {context='global',on={''},run='help'}, + {context='global',on={''},run='settings'}, + {context='global',on={''},run='accounts'}, + }}", + ) + .unwrap(); + for (key, mode) in [(4, Mode::Help), (5, Mode::Settings), (6, Mode::Accounts)] { + let event = KeyEvent::new(KeyCode::Fn(key), Modifiers::empty()); + app.handle_key(&event); + assert_eq!(app.mode, mode); + app.handle_key(&event); + assert_eq!(app.mode, Mode::Compose); + assert_eq!(app.active_draft().unwrap().value(), "x"); + } + } + + #[test] + fn password_is_masked_and_submitted_without_trimming() { + let mut app = App::new(); + app.handle_network(NetworkEvent::Auth(AuthPrompt::Password { + hint: Some("pet".to_owned()), + })); + app.handle_action(KeyAction::Character('界')); + app.handle_action(KeyAction::Character('x')); + assert_eq!(app.auth_display_value(), "••"); + assert_eq!(app.auth_cursor_display_width(), 2); + assert_eq!( + app.handle_action(KeyAction::Enter), + vec![TelegramCommand::SubmitPassword("界x".to_owned())] + ); + assert_eq!( + app.screen, + Screen::Auth(AuthPhase::Password { + hint: Some("pet".to_owned()) + }) + ); + assert_eq!(app.auth_progress_label(), Some("Checking 2FA password…")); + assert!(app.auth_is_submitting()); + } + + #[test] + fn auth_submissions_show_phase_specific_progress_and_block_duplicate_input() { + let mut app = App::new(); + app.handle_network(NetworkEvent::Auth(AuthPrompt::Phone)); + app.handle_action(KeyAction::Character('+')); + app.handle_action(KeyAction::Character('1')); + assert_eq!( + app.handle_action(KeyAction::Enter), + vec![TelegramCommand::SubmitPhone("+1".to_owned())] + ); + assert_eq!(app.auth_progress_label(), Some("Requesting a login code…")); + assert!(app.auth_input().is_empty()); + app.handle_action(KeyAction::Character('9')); + assert!(app.auth_input().is_empty()); + assert!(app.handle_action(KeyAction::Enter).is_empty()); + + app.handle_network(NetworkEvent::Auth(AuthPrompt::Code { + phone: "+1".to_owned(), + })); + app.handle_action(KeyAction::Character('1')); + app.handle_action(KeyAction::Character('2')); + assert_eq!( + app.handle_action(KeyAction::Enter), + vec![TelegramCommand::SubmitCode("12".to_owned())] + ); + assert_eq!(app.auth_progress_label(), Some("Checking login code…")); + + app.handle_network(NetworkEvent::Error("Incorrect code".to_owned())); + assert!(!app.auth_is_submitting()); + assert_eq!(app.status_message.as_deref(), Some("Incorrect code")); + app.handle_action(KeyAction::Character('3')); + assert_eq!(app.auth_input().value(), "3"); + } + + #[test] + fn tab_starts_qr_login_and_escape_drops_the_transient_token() { + use yazi_term::event::{KeyCode, KeyEvent}; + let tab = KeyEvent::new(KeyCode::Tab, Modifiers::empty()); + let back_tab = KeyEvent::new(KeyCode::Tab, Modifiers::SHIFT); + let escape = KeyEvent::new(KeyCode::Escape, Modifiers::empty()); + let mut app = App::new(); + app.handle_network(NetworkEvent::Auth(AuthPrompt::Phone)); + app.handle_action(KeyAction::Character('+')); + assert_eq!(app.handle_key(&tab), vec![TelegramCommand::StartQrAuth]); + assert!(app.auth_input().is_empty()); + assert_eq!(app.auth_progress_label(), Some("Preparing QR sign-in…")); + + let secret_url = "tg://login?token=do-not-print"; + app.handle_network(NetworkEvent::Auth(AuthPrompt::Qr { + url: secret_url.to_owned(), + })); + assert_eq!( + app.auth_progress_label(), + Some("Waiting for approval in Telegram…") + ); + assert!(app.needs_animation()); + assert!(!format!("{:?}", app.screen).contains(secret_url)); + assert_eq!(app.qr_render_mode(), QrRenderMode::Compact); + assert!(app.handle_key(&tab).is_empty()); + assert_eq!(app.qr_render_mode(), QrRenderMode::Compatible); + assert!(app.handle_key(&back_tab).is_empty()); + assert_eq!(app.qr_render_mode(), QrRenderMode::Compact); + assert_eq!( + app.auth_progress_label(), + Some("Waiting for approval in Telegram…") + ); + assert_eq!(app.handle_key(&escape), vec![TelegramCommand::RestartAuth]); + assert_eq!(app.screen, Screen::Auth(AuthPhase::Phone)); + assert!(app.auth_is_submitting()); + assert_eq!( + app.auth_progress_label(), + Some("Returning to phone sign-in…") + ); + app.handle_network(NetworkEvent::Auth(AuthPrompt::Phone)); + assert!(!app.auth_is_submitting()); + } + + #[test] + fn qr_start_error_survives_the_worker_returning_to_phone_login() { + let mut app = App::new(); + app.handle_network(NetworkEvent::Auth(AuthPrompt::Phone)); + app.handle_action(KeyAction::Tab); + app.handle_network(NetworkEvent::Error( + "Could not start QR login: unavailable".to_owned(), + )); + app.handle_network(NetworkEvent::Auth(AuthPrompt::Phone)); + + assert_eq!(app.screen, Screen::Auth(AuthPhase::Phone)); + assert_eq!( + app.status_message.as_deref(), + Some("Could not start QR login: unavailable") + ); + assert!(!app.auth_is_submitting()); + app.handle_action(KeyAction::Character('+')); + assert_eq!(app.auth_input().value(), "+"); + + app.handle_action(KeyAction::Escape); + assert!(app.status_message.is_none()); + } + + #[test] + fn auth_prompt_metadata_cannot_inject_terminal_content() { + let mut app = App::new(); + app.handle_network(NetworkEvent::Auth(AuthPrompt::Code { + phone: "+81\u{1b}[2J\n90".to_owned(), + })); + assert_eq!( + app.screen, + Screen::Auth(AuthPhase::Code { + phone: "+81 90".to_owned() + }) + ); + + app.handle_network(NetworkEvent::Auth(AuthPrompt::Password { + hint: Some("first\u{1b}[31m\nsecond".to_owned()), + })); + assert_eq!( + app.screen, + Screen::Auth(AuthPhase::Password { + hint: Some("first second".to_owned()) + }) + ); + } + + #[test] + fn auth_credentials_are_redacted_from_debug_output() { + let secret = "tg://login?token=secret-value"; + let phase = AuthPhase::Qr { + url: secret.to_owned(), + }; + assert!(!format!("{phase:?}").contains(secret)); + assert!(format!("{phase:?}").contains("")); + + let phase = AuthPhase::Code { + phone: "+1 555 0100".to_owned(), + }; + assert!(!format!("{phase:?}").contains("555")); + } + + #[test] + fn escape_restarts_code_or_password_auth_but_only_clears_phone_input() { + let mut app = App::new(); + app.handle_network(NetworkEvent::Auth(AuthPrompt::Code { + phone: "+81 90".to_owned(), + })); + app.handle_action(KeyAction::Character('1')); + app.status_message = Some("bad code".to_owned()); + assert_eq!( + app.handle_action(KeyAction::Escape), + vec![TelegramCommand::RestartAuth] + ); + assert_eq!(app.screen, Screen::Auth(AuthPhase::Phone)); + assert!(app.auth_input().is_empty()); + assert!(app.status_message.is_none()); + + app.handle_network(NetworkEvent::Auth(AuthPrompt::Phone)); + app.handle_action(KeyAction::Character('+')); + assert!(app.handle_action(KeyAction::Escape).is_empty()); + assert_eq!(app.screen, Screen::Auth(AuthPhase::Phone)); + assert!(app.auth_input().is_empty()); + } + + #[test] + fn escape_cancels_a_pending_phone_request_before_starting_over() { + let mut app = App::new(); + app.handle_network(NetworkEvent::Auth(AuthPrompt::Phone)); + app.handle_action(KeyAction::Character('+')); + app.handle_action(KeyAction::Character('1')); + app.handle_action(KeyAction::Enter); + assert!(app.auth_is_submitting()); + + assert_eq!( + app.handle_action(KeyAction::Escape), + vec![TelegramCommand::RestartAuth] + ); + assert_eq!(app.screen, Screen::Auth(AuthPhase::Phone)); + assert!(app.auth_is_submitting()); + assert!(app.auth_input().is_empty()); + + app.handle_action(KeyAction::Character('9')); + assert!(app.auth_input().is_empty()); + assert!(app.handle_action(KeyAction::Tab).is_empty()); + assert!(app.handle_action(KeyAction::Enter).is_empty()); + assert_eq!( + app.auth_progress_label(), + Some("Returning to phone sign-in…") + ); + + app.handle_network(NetworkEvent::Auth(AuthPrompt::Phone)); + assert!(!app.auth_is_submitting()); + app.handle_action(KeyAction::Character('9')); + assert_eq!(app.auth_input().value(), "9"); + } + + #[test] + fn restart_ignores_late_auth_prompts_until_phone_confirmation() { + let mut app = App::new(); + app.handle_network(NetworkEvent::Auth(AuthPrompt::Phone)); + app.handle_action(KeyAction::Character('+')); + app.handle_action(KeyAction::Character('1')); + app.handle_action(KeyAction::Enter); + app.handle_action(KeyAction::Escape); + + app.handle_network(NetworkEvent::Auth(AuthPrompt::Code { + phone: "+1".to_owned(), + })); + app.handle_network(NetworkEvent::Auth(AuthPrompt::Qr { + url: "tg://login?token=stale".to_owned(), + })); + app.handle_network(NetworkEvent::Error("stale failure".to_owned())); + assert_eq!(app.screen, Screen::Auth(AuthPhase::Phone)); + assert!(app.status_message.is_none()); + + app.handle_network(NetworkEvent::Auth(AuthPrompt::Phone)); + app.handle_network(NetworkEvent::Auth(AuthPrompt::Code { + phone: "+2".to_owned(), + })); + assert_eq!( + app.screen, + Screen::Auth(AuthPhase::Code { + phone: "+2".to_owned() + }) + ); + } + + #[test] + fn ready_does_not_duplicate_the_workers_dialog_load() { + let mut app = App::new(); + assert!( + app.handle_network(NetworkEvent::Ready { + user_name: "Ada".to_owned() + }) + .is_empty() + ); + assert_eq!(app.screen, Screen::Main); + } + + #[test] + fn chat_selection_survives_reordering_by_identity() { + let mut app = ready_app(); + app.selected_chat = 1; + app.handle_network(NetworkEvent::Dialogs(vec![ + chat(3, "Gamma"), + chat(1, "Alpha"), + chat(2, "Beta"), + ])); + assert_eq!(app.selected_chat_entry().map(|chat| chat.id), Some(2)); + } + + #[test] + fn composer_is_multiline_unicode_aware_and_optimistic() { + let mut app = ready_app(); + open_first(&mut app); + app.handle_action(KeyAction::Character('i')); + app.handle_action(KeyAction::Character('界')); + app.handle_action(KeyAction::Newline); + app.handle_action(KeyAction::Character('🙂')); + let commands = app.handle_action(KeyAction::Enter); + assert!(matches!( + commands.as_slice(), + [TelegramCommand::SendMessage { chat_id: 1, local_id: -1, text, reply_to: None }] if text == "界\n🙂" + )); + let pending = app.active_messages().last().unwrap(); + assert_eq!(pending.delivery, Delivery::Pending); + assert_eq!(app.mode, Mode::Compose); + } + + #[test] + fn associated_text_uses_layout_text_without_executing_shortcuts() { + use yazi_term::event::{KeyCode, KeyEvent, KeyEventKind}; + + let mut app = ready_app(); + open_first(&mut app); + app.handle_action(KeyAction::Character('i')); + let mut key = KeyEvent { + code: KeyCode::Char('q'), + modifiers: Modifiers::CONTROL | Modifiers::ALT, + text: "你好🙂".into(), + ..KeyEvent::default() + }; + assert!(app.handle_key(&key).is_empty()); + assert_eq!(app.active_draft().unwrap().value(), "你好🙂"); + assert!(!app.should_quit); + key.kind = KeyEventKind::Release; + assert!(app.handle_key(&key).is_empty()); + assert_eq!(app.active_draft().unwrap().value(), "你好🙂"); + } + + #[test] + fn message_cursor_composes_a_native_reply_and_keeps_optimistic_context() { + let mut app = ready_app(); + open_first(&mut app); + + app.handle_action(KeyAction::Character('[')); + assert_eq!(app.selected_message, Some(20)); + app.handle_action(KeyAction::Character('[')); + assert_eq!(app.selected_message, Some(19)); + app.handle_action(KeyAction::Character(']')); + assert_eq!(app.selected_message, Some(20)); + + app.handle_action(KeyAction::Character('R')); + assert_eq!(app.mode, Mode::Compose); + assert_eq!( + app.active_reply_target().map(|reply| reply.message_id), + Some(20) + ); + app.handle_action(KeyAction::Character('h')); + app.handle_action(KeyAction::Character('i')); + let commands = app.handle_action(KeyAction::Enter); + assert_eq!( + commands.last(), + Some(&TelegramCommand::SendMessage { + chat_id: 1, + local_id: -1, + text: "hi".to_owned(), + reply_to: Some(20), + }) + ); + let pending = app.active_messages().last().expect("optimistic reply"); + assert_eq!( + pending.reply_to.as_ref().map(|reply| reply.message_id), + Some(20) + ); + assert!(app.active_reply_target().is_none()); + } + + #[test] + fn failed_reply_restores_its_draft_and_exact_target_for_retry() { + let mut app = ready_app(); + open_first(&mut app); + app.handle_action(KeyAction::Character('R')); + app.handle_action(KeyAction::Character('x')); + app.handle_action(KeyAction::Enter); + + app.handle_network(NetworkEvent::SendFailed { + chat_id: 1, + local_id: -1, + text: "x".to_owned(), + reply_to: Some(20), + error: "offline".to_owned(), + }); + assert_eq!(app.active_draft().map(TextInput::value), Some("x")); + assert_eq!( + app.active_reply_target().map(|reply| reply.message_id), + Some(20) + ); + assert!(matches!( + app.handle_action(KeyAction::Enter).last(), + Some(TelegramCommand::SendMessage { + local_id: -2, + reply_to: Some(20), + .. + }) + )); + } + + #[test] + fn escape_cancels_reply_context_before_leaving_the_composer() { + let mut app = ready_app(); + open_first(&mut app); + app.handle_action(KeyAction::Character('R')); + app.handle_action(KeyAction::Character('x')); + + app.handle_action(KeyAction::Escape); + assert_eq!(app.mode, Mode::Compose); + assert!(app.active_reply_target().is_none()); + assert_eq!(app.active_draft().map(TextInput::value), Some("x")); + assert_eq!( + app.status_message.as_deref(), + Some("Reply cancelled · draft kept") + ); + + app.handle_action(KeyAction::Escape); + assert_eq!(app.mode, Mode::Navigate); + app.handle_action(KeyAction::Character('i')); + assert_eq!(app.mode, Mode::Compose); + assert_eq!(app.active_draft().map(TextInput::value), Some("x")); + } + + #[test] + fn failed_send_is_visible_and_restored_without_overwriting_newer_draft() { + let mut app = ready_app(); + open_first(&mut app); + app.handle_action(KeyAction::Character('i')); + app.handle_action(KeyAction::Character('x')); + app.handle_action(KeyAction::Enter); + app.handle_action(KeyAction::Character('n')); + app.handle_network(NetworkEvent::SendFailed { + chat_id: 1, + local_id: -1, + text: "x".to_owned(), + reply_to: None, + error: "offline".to_owned(), + }); + assert_eq!( + app.active_messages().last().unwrap().delivery, + Delivery::Failed ); + assert_eq!(app.active_draft().map(TextInput::value), Some("n")); + assert!(app.status_message.as_deref().unwrap().contains("offline")); + } + + #[test] + fn retry_replaces_the_failed_timeline_entry() { + let mut app = ready_app(); + open_first(&mut app); + app.handle_action(KeyAction::Character('i')); + app.handle_action(KeyAction::Character('x')); + app.handle_action(KeyAction::Enter); + app.handle_network(NetworkEvent::SendFailed { + chat_id: 1, + local_id: -1, + text: "x".to_owned(), + reply_to: None, + error: "offline".to_owned(), + }); + assert_eq!(app.active_draft().map(TextInput::value), Some("x")); + + let commands = app.handle_action(KeyAction::Enter); + assert!(matches!( + commands.last(), + Some(TelegramCommand::SendMessage { local_id: -2, .. }) + )); + assert!(!app.active_messages().iter().any(|message| message.id == -1)); + assert_eq!(app.active_messages().last().unwrap().id, -2); + } + + #[test] + fn history_refresh_preserves_pending_messages() { + let mut app = ready_app(); + open_first(&mut app); + app.handle_action(KeyAction::Character('i')); + app.handle_action(KeyAction::Character('x')); + app.handle_action(KeyAction::Enter); + let request_id = 2; + app.active_history_request = Some((1, request_id)); + app.handle_network(NetworkEvent::History { + chat_id: 1, + request_id, + messages: vec![message(1, 1, "server", false)], + }); + assert!(app.active_messages().iter().any(|message| message.id == -1)); + } + + #[test] + fn history_snapshot_cannot_undo_live_messages_edits_deletions_or_reads() { + let mut app = ready_app(); + app.handle_action(KeyAction::Enter); + app.handle_network(NetworkEvent::MessageUpdated(message(1, 1, "edited", false))); + app.handle_network(NetworkEvent::MessagesDeleted { + channel_id: None, + message_ids: vec![2], + }); + app.handle_network(NetworkEvent::NewMessage(message( + 4, + 1, + "arrived during request", + false, + ))); + app.handle_network(NetworkEvent::MessagesRead { + chat_id: 1, + max_id: 3, + }); + app.handle_network(NetworkEvent::History { + chat_id: 1, + request_id: 1, + messages: vec![ + message(1, 1, "old", false), + message(2, 1, "deleted", false), + message(3, 1, "sent", true), + ], + }); + let messages = app.active_messages(); assert_eq!( - app.screen, - Screen::Auth(AuthPhase::Password { - hint: Some("pet".to_owned()) - }) + messages + .iter() + .map(|message| message.id) + .collect::>(), + vec![1, 3, 4] ); - assert_eq!(app.auth_progress_label(), Some("Checking 2FA password…")); - assert!(app.auth_is_submitting()); + assert_eq!(messages[0].text, "edited"); + assert_eq!(messages[1].delivery, Delivery::Read); + assert_eq!(messages[2].text, "arrived during request"); } #[test] - fn auth_submissions_show_phase_specific_progress_and_block_duplicate_input() { - let mut app = App::new(); - app.handle_network(NetworkEvent::Auth(AuthPrompt::Phone)); - app.handle_action(KeyAction::Character('+')); - app.handle_action(KeyAction::Character('1')); + fn dialog_snapshot_cannot_undo_activity_or_reads_during_refresh() { + let mut app = ready_app(); + app.handle_network(NetworkEvent::DialogsLoading); + app.handle_network(NetworkEvent::NewMessage(message( + 25, + 2, + "live preview", + false, + ))); + let unread = app.chats.iter().find(|chat| chat.id == 2).unwrap().unread; + app.handle_network(NetworkEvent::Dialogs(vec![ + chat(1, "Renamed"), + chat(2, "Beta"), + ])); + assert_eq!(app.chats[0].id, 2); + assert_eq!(app.chats[0].last_message, "live preview"); + assert_eq!(app.chats[0].unread, unread); + assert_eq!(app.chats[1].title, "Renamed"); + } + + #[test] + fn histories_and_live_updates_keep_only_the_lightweight_history_window() { + let mut app = ready_app(); + app.handle_action(KeyAction::Enter); + app.handle_network(NetworkEvent::History { + chat_id: 1, + request_id: 1, + messages: (1..=600) + .map(|id| message(id, 1, &format!("message {id}"), false)) + .collect(), + }); + let history = app.messages.get(&1).expect("bounded history"); + assert_eq!(history.len(), MAX_MESSAGES_PER_CHAT); + assert_eq!(history.first().map(|message| message.id), Some(441)); + assert_eq!(history.last().map(|message| message.id), Some(600)); + + app.handle_network(NetworkEvent::NewMessage(message(601, 1, "latest", false))); + let history = app.messages.get(&1).expect("bounded history"); + assert_eq!(history.len(), MAX_MESSAGES_PER_CHAT); + assert_eq!(history.first().map(|message| message.id), Some(442)); + assert_eq!(history.last().map(|message| message.id), Some(601)); + } + + #[test] + fn message_cache_evicts_inactive_chats_but_keeps_the_active_one() { + let mut app = ready_app(); + app.chats = (1_i64..=14) + .map(|id| chat(id, &format!("Chat {id}"))) + .collect(); + app.active_chat_id = Some(14); + for chat_id in 1_i64..=14 { + app.handle_network(NetworkEvent::MessageUpdated(message( + i32::try_from(chat_id).expect("small chat id"), + chat_id, + "cached", + false, + ))); + } + + assert_eq!(app.messages.len(), MAX_CACHED_CHATS + 1); + assert!(app.messages.contains_key(&14)); + assert!(!app.messages.contains_key(&13)); + } + + #[test] + fn detached_history_never_jumps_and_defers_read_ack() { + let mut app = ready_app(); + open_first(&mut app); + app.handle_network(NetworkEvent::ReadMarked { + chat_id: 1, + max_id: 20, + snapshot: None, + }); + app.handle_action(KeyAction::PageUp); + assert_eq!(app.message_scroll, 10); + assert!( + app.handle_network(NetworkEvent::NewMessage(message(21, 1, "new", false))) + .is_empty() + ); + assert_eq!(app.message_scroll, 10); + assert_eq!(app.new_messages_while_scrolled, 1); + assert_eq!(app.new_messages_to_anchor, 1); + assert!(app.active_chat().unwrap().unread > 0); + assert!(app.handle_action(KeyAction::End).is_empty()); + app.set_visible_read_boundary(1, Some(21)); assert_eq!( - app.handle_action(KeyAction::Enter), - vec![TelegramCommand::SubmitPhone("+1".to_owned())] + app.request_visible_read(), + vec![TelegramCommand::MarkRead { + chat_id: 1, + max_id: 21 + }] ); - assert_eq!(app.auth_progress_label(), Some("Requesting a login code…")); - assert!(app.auth_input().is_empty()); - app.handle_action(KeyAction::Character('9')); - assert!(app.auth_input().is_empty()); - assert!(app.handle_action(KeyAction::Enter).is_empty()); + assert_eq!(app.new_messages_while_scrolled, 0); + assert_eq!(app.new_messages_to_anchor, 0); + } - app.handle_network(NetworkEvent::Auth(AuthPrompt::Code { - phone: "+1".to_owned(), - })); - app.handle_action(KeyAction::Character('1')); - app.handle_action(KeyAction::Character('2')); + #[test] + fn accepted_then_echoed_outgoing_message_reconciles_without_a_new_badge() { + let mut app = ready_app(); + open_first(&mut app); + app.handle_action(KeyAction::Character('i')); + app.handle_action(KeyAction::Character('x')); + app.handle_action(KeyAction::Enter); + app.handle_network(NetworkEvent::MessageAccepted { + chat_id: 1, + local_id: -1, + }); assert_eq!( - app.handle_action(KeyAction::Enter), - vec![TelegramCommand::SubmitCode("12".to_owned())] + app.active_messages() + .iter() + .find(|message| message.id == -1) + .map(|message| message.delivery), + Some(Delivery::Sent) ); - assert_eq!(app.auth_progress_label(), Some("Checking login code…")); - app.handle_network(NetworkEvent::Error("Incorrect code".to_owned())); - assert!(!app.auth_is_submitting()); - assert_eq!(app.status_message.as_deref(), Some("Incorrect code")); - app.handle_action(KeyAction::Character('3')); - assert_eq!(app.auth_input().value(), "3"); + app.message_scroll = 5; + let mut echoed = message(21, 1, "x", true); + echoed.timestamp = Utc::now(); + app.handle_network(NetworkEvent::NewMessage(echoed)); + assert!(!app.active_messages().iter().any(|message| message.id < 0)); + assert!(app.active_messages().iter().any(|message| message.id == 21)); + assert_eq!(app.new_messages_while_scrolled, 0); + assert_eq!(app.new_messages_to_anchor, 0); } #[test] - fn tab_starts_qr_login_and_escape_drops_the_transient_token() { - let mut app = App::new(); - app.handle_network(NetworkEvent::Auth(AuthPrompt::Phone)); - app.handle_action(KeyAction::Character('+')); - assert_eq!( - app.handle_action(KeyAction::Tab), - vec![TelegramCommand::StartQrAuth] + fn confirming_one_of_two_identical_sends_keeps_the_other_optimistic_entry() { + let mut app = ready_app(); + open_first(&mut app); + app.handle_action(KeyAction::Character('i')); + app.handle_action(KeyAction::Character('x')); + app.handle_action(KeyAction::Enter); + app.handle_action(KeyAction::Character('x')); + app.handle_action(KeyAction::Enter); + + app.handle_network(NetworkEvent::MessageSent { + local_id: -1, + message: message(21, 1, "x", true), + }); + assert!(app.active_messages().iter().any(|message| message.id == 21)); + assert!(app.active_messages().iter().any(|message| message.id == -2)); + assert!(!app.active_messages().iter().any(|message| message.id == -1)); + } + + #[test] + fn background_focus_defers_read_acknowledgement() { + let mut app = ready_app(); + open_first(&mut app); + app.update(AppEvent::TerminalFocus(false)); + assert!( + app.handle_network(NetworkEvent::NewMessage(message(21, 1, "new", false))) + .is_empty() ); - assert!(app.auth_input().is_empty()); - assert_eq!(app.auth_progress_label(), Some("Preparing QR sign-in…")); + assert!(app.active_chat().unwrap().unread > 0); + } - let secret_url = "tg://login?token=do-not-print"; - app.handle_network(NetworkEvent::Auth(AuthPrompt::Qr { - url: secret_url.to_owned(), - })); + #[test] + fn older_read_confirmation_preserves_arrivals_and_coalesces_the_next_receipt() { + let mut app = ready_app(); + open_first(&mut app); + app.set_visible_read_boundary(1, Some(20)); assert_eq!( - app.auth_progress_label(), - Some("Waiting for approval in Telegram…") + app.request_visible_read(), + vec![TelegramCommand::MarkRead { + chat_id: 1, + max_id: 20, + }] ); - assert!(app.needs_animation()); - assert!(!format!("{:?}", app.screen).contains(secret_url)); - assert_eq!(app.qr_render_mode(), QrRenderMode::Compact); - assert!(app.handle_action(KeyAction::Tab).is_empty()); - assert_eq!(app.qr_render_mode(), QrRenderMode::Compatible); - assert!(app.handle_action(KeyAction::BackTab).is_empty()); - assert_eq!(app.qr_render_mode(), QrRenderMode::Compact); + app.handle_network(NetworkEvent::NewMessage(message(21, 1, "unseen", false))); + let unread = app.active_chat().unwrap().unread; + app.set_visible_read_boundary(1, Some(21)); + assert!(app.request_visible_read().is_empty()); + app.handle_network(NetworkEvent::ReadMarked { + chat_id: 1, + max_id: 20, + snapshot: Some(crate::read_state::Snapshot { + max_id: 20, + unread: 0, + top_message: 20, + }), + }); + assert_eq!(app.active_chat().unwrap().unread, unread); + assert_eq!(app.active_chat().unwrap().read_inbox_max_id, Some(20)); assert_eq!( - app.auth_progress_label(), - Some("Waiting for approval in Telegram…") + app.request_visible_read(), + vec![TelegramCommand::MarkRead { + chat_id: 1, + max_id: 21, + }] ); + app.handle_network(NetworkEvent::ReadMarked { + chat_id: 1, + max_id: 21, + snapshot: None, + }); + app.handle_network(NetworkEvent::UnreadChanged { + chat_id: 1, + max_id: 20, + unread: 8, + }); + assert_eq!(app.active_chat().unwrap().unread, 0); + assert_eq!(app.active_chat().unwrap().read_inbox_max_id, Some(21)); + } + + #[test] + fn slash_in_conversation_is_a_bot_command_not_a_filter() { + let mut app = ready_app(); + open_first(&mut app); + app.handle_action(KeyAction::Character('/')); + assert_eq!(app.mode, Mode::Compose); + assert_eq!(app.active_draft().map(TextInput::value), Some("/")); + } + + #[test] + fn paste_normalizes_newlines_and_strips_terminal_controls() { + let mut app = ready_app(); + open_first(&mut app); + app.handle_action(KeyAction::Character('i')); + app.update(AppEvent::Paste("a\r\nb\u{1b}[31m!".to_owned())); + assert_eq!(app.active_draft().map(TextInput::value), Some("a\nb!")); + } + + #[test] + fn filtering_and_help_are_contextual() { + let mut app = ready_app(); + app.handle_action(KeyAction::Character('/')); + app.handle_action(KeyAction::Character('g')); + assert_eq!(app.visible_chats()[0].title, "Gamma"); + app.handle_action(KeyAction::Escape); + app.handle_action(KeyAction::Character('?')); + assert_eq!(app.mode, Mode::Help); + app.handle_action(KeyAction::Character('?')); + assert_eq!(app.mode, Mode::Navigate); + } + + #[test] + fn settings_screen_toggles_and_persists_essential_preferences() { + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock") + .as_nanos(); + let directory = std::env::temp_dir().join(format!( + "termgram-app-settings-{}-{nonce}", + std::process::id() + )); + let path = directory.join("settings.conf"); + let mut app = App::with_settings(Settings::default(), path.clone()); + app.screen = Screen::Main; + + app.handle_action(KeyAction::Character('s')); + assert_eq!(app.mode, Mode::Settings); + app.handle_action(KeyAction::Enter); + app.handle_action(KeyAction::Down); + app.handle_action(KeyAction::Enter); + app.handle_action(KeyAction::Down); + app.handle_action(KeyAction::Enter); + app.handle_action(KeyAction::Down); + app.handle_action(KeyAction::Enter); assert_eq!( - app.handle_action(KeyAction::Escape), - vec![TelegramCommand::RestartAuth] + *app.settings(), + Settings { + automatic_update_checks: false, + release_channel: ReleaseChannel::Prerelease, + download_behavior: DownloadBehavior::CacheOnly, + show_message_ids: true, + ..Settings::default() + } ); - assert_eq!(app.screen, Screen::Auth(AuthPhase::Phone)); - assert!(app.auth_is_submitting()); assert_eq!( - app.auth_progress_label(), - Some("Returning to phone sign-in…") + Settings::load_from(&path).expect("persisted settings"), + *app.settings() ); - app.handle_network(NetworkEvent::Auth(AuthPrompt::Phone)); - assert!(!app.auth_is_submitting()); + + app.handle_action(KeyAction::Escape); + assert_eq!(app.mode, Mode::Navigate); + fs::remove_file(path).expect("remove settings"); + fs::remove_dir(directory).expect("remove settings directory"); } #[test] - fn qr_start_error_survives_the_worker_returning_to_phone_login() { - let mut app = App::new(); - app.handle_network(NetworkEvent::Auth(AuthPrompt::Phone)); - app.handle_action(KeyAction::Tab); - app.handle_network(NetworkEvent::Error( - "Could not start QR login: unavailable".to_owned(), + fn account_picker_adds_switches_and_isolates_account_state() { + let nonce = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock") + .as_nanos(); + let directory = std::env::temp_dir().join(format!( + "termgram-app-accounts-{}-{nonce}", + std::process::id() )); - app.handle_network(NetworkEvent::Auth(AuthPrompt::Phone)); + let path = directory.join("settings.conf"); + let mut app = App::with_settings(Settings::default(), path.clone()); + app.screen = Screen::Main; + app.user_name = Some("First".to_owned()); + app.chats.push(chat(7, "Private chat")); + app.messages + .insert(7, vec![message(1, 7, "account one", false)]); - assert_eq!(app.screen, Screen::Auth(AuthPhase::Phone)); + assert!(app.handle_action(KeyAction::Character('a')).is_empty()); + assert_eq!(app.mode, Mode::Accounts); + assert_eq!(app.account_selection(), 0); + assert!(app.handle_action(KeyAction::Down).is_empty()); assert_eq!( - app.status_message.as_deref(), - Some("Could not start QR login: unavailable") + app.handle_action(KeyAction::Enter), + vec![TelegramCommand::SwitchAccount { account: 2 }] ); - assert!(!app.auth_is_submitting()); - app.handle_action(KeyAction::Character('+')); - assert_eq!(app.auth_input().value(), "+"); - - app.handle_action(KeyAction::Escape); - assert!(app.status_message.is_none()); - } - - #[test] - fn auth_prompt_metadata_cannot_inject_terminal_content() { - let mut app = App::new(); - app.handle_network(NetworkEvent::Auth(AuthPrompt::Code { - phone: "+81\u{1b}[2J\n90".to_owned(), - })); + assert_eq!(app.active_account(), 2); + assert_eq!(app.account_count(), 2); + assert_eq!(app.screen, Screen::Connecting); + assert!(app.chats.is_empty()); + assert!(app.messages.is_empty()); + assert!(app.user_name.is_none()); assert_eq!( - app.screen, - Screen::Auth(AuthPhase::Code { - phone: "+81 90".to_owned() - }) + app.status_message.as_deref(), + Some("Switching to Account 2…") ); - - app.handle_network(NetworkEvent::Auth(AuthPrompt::Password { - hint: Some("first\u{1b}[31m\nsecond".to_owned()), - })); assert_eq!( - app.screen, - Screen::Auth(AuthPhase::Password { - hint: Some("first second".to_owned()) - }) + Settings::load_from(&path).expect("persisted account selection"), + *app.settings() ); - } - #[test] - fn auth_credentials_are_redacted_from_debug_output() { - let secret = "tg://login?token=secret-value"; - let phase = AuthPhase::Qr { - url: secret.to_owned(), - }; - assert!(!format!("{phase:?}").contains(secret)); - assert!(format!("{phase:?}").contains("")); - - let phase = AuthPhase::Code { - phone: "+1 555 0100".to_owned(), - }; - assert!(!format!("{phase:?}").contains("555")); - } - - #[test] - fn escape_restarts_code_or_password_auth_but_only_clears_phone_input() { - let mut app = App::new(); - app.handle_network(NetworkEvent::Auth(AuthPrompt::Code { - phone: "+81 90".to_owned(), - })); - app.handle_action(KeyAction::Character('1')); - app.status_message = Some("bad code".to_owned()); + app.handle_network(NetworkEvent::Ready { + user_name: "Second".to_owned(), + }); assert_eq!( - app.handle_action(KeyAction::Escape), - vec![TelegramCommand::RestartAuth] + app.handle_action(KeyAction::NextAccount), + vec![TelegramCommand::SwitchAccount { account: 1 }] ); - assert_eq!(app.screen, Screen::Auth(AuthPhase::Phone)); - assert!(app.auth_input().is_empty()); - assert!(app.status_message.is_none()); + assert_eq!(app.active_account(), 1); - app.handle_network(NetworkEvent::Auth(AuthPrompt::Phone)); - app.handle_action(KeyAction::Character('+')); - assert!(app.handle_action(KeyAction::Escape).is_empty()); - assert_eq!(app.screen, Screen::Auth(AuthPhase::Phone)); - assert!(app.auth_input().is_empty()); + fs::remove_file(path).expect("remove settings"); + fs::remove_dir(directory).expect("remove settings directory"); } #[test] - fn escape_cancels_a_pending_phone_request_before_starting_over() { - let mut app = App::new(); + fn account_shortcuts_work_during_login_and_enforce_the_slot_limit() { + let mut app = App::with_ephemeral_settings(Settings { + active_account: 1, + account_count: 2, + ..Settings::default() + }); app.handle_network(NetworkEvent::Auth(AuthPrompt::Phone)); app.handle_action(KeyAction::Character('+')); - app.handle_action(KeyAction::Character('1')); - app.handle_action(KeyAction::Enter); - assert!(app.auth_is_submitting()); - assert_eq!( - app.handle_action(KeyAction::Escape), - vec![TelegramCommand::RestartAuth] + app.handle_action(KeyAction::NextAccount), + vec![TelegramCommand::SwitchAccount { account: 2 }] ); - assert_eq!(app.screen, Screen::Auth(AuthPhase::Phone)); - assert!(app.auth_is_submitting()); assert!(app.auth_input().is_empty()); + assert_eq!(app.screen, Screen::Connecting); - app.handle_action(KeyAction::Character('9')); - assert!(app.auth_input().is_empty()); - assert!(app.handle_action(KeyAction::Tab).is_empty()); - assert!(app.handle_action(KeyAction::Enter).is_empty()); + let mut full = App::with_ephemeral_settings(Settings { + active_account: MAX_ACCOUNTS, + account_count: MAX_ACCOUNTS, + ..Settings::default() + }); + full.screen = Screen::Fatal("network failed".to_owned()); + assert!(full.handle_action(KeyAction::AddAccount).is_empty()); + assert_eq!(full.active_account(), MAX_ACCOUNTS); assert_eq!( - app.auth_progress_label(), - Some("Returning to phone sign-in…") + full.status_message.as_deref(), + Some("Account limit reached (8)") ); - - app.handle_network(NetworkEvent::Auth(AuthPrompt::Phone)); - assert!(!app.auth_is_submitting()); - app.handle_action(KeyAction::Character('9')); - assert_eq!(app.auth_input().value(), "9"); } #[test] - fn restart_ignores_late_auth_prompts_until_phone_confirmation() { + fn update_notification_is_sanitized_and_kept_separate_from_status() { let mut app = App::new(); - app.handle_network(NetworkEvent::Auth(AuthPrompt::Phone)); - app.handle_action(KeyAction::Character('+')); - app.handle_action(KeyAction::Character('1')); - app.handle_action(KeyAction::Enter); - app.handle_action(KeyAction::Escape); - - app.handle_network(NetworkEvent::Auth(AuthPrompt::Code { - phone: "+1".to_owned(), - })); - app.handle_network(NetworkEvent::Auth(AuthPrompt::Qr { - url: "tg://login?token=stale".to_owned(), - })); - app.handle_network(NetworkEvent::Error("stale failure".to_owned())); - assert_eq!(app.screen, Screen::Auth(AuthPhase::Phone)); - assert!(app.status_message.is_none()); + app.status_message = Some("Message failed".to_owned()); + app.set_available_update(" 0.1.9\u{1b}[31m "); + assert_eq!(app.available_update(), Some("0.1.9")); + assert_eq!(app.status_message.as_deref(), Some("Message failed")); + app.clear_available_update(); + assert_eq!(app.available_update(), None); - app.handle_network(NetworkEvent::Auth(AuthPrompt::Phone)); - app.handle_network(NetworkEvent::Auth(AuthPrompt::Code { - phone: "+2".to_owned(), - })); - assert_eq!( - app.screen, - Screen::Auth(AuthPhase::Code { - phone: "+2".to_owned() - }) - ); + app.settings.automatic_update_checks = false; + app.set_available_update("0.2.0"); + assert_eq!(app.available_update(), None); } #[test] - fn ready_does_not_duplicate_the_workers_dialog_load() { - let mut app = App::new(); + fn temp_only_downloads_never_reveal_remote_files() { + let mut app = ready_app(); + app.settings.download_behavior = DownloadBehavior::CacheOnly; + open_first(&mut app); + let mut attachment = message(21, 1, "", false); + attachment.attachment = Some(Attachment { + source_id: None, + kind: AttachmentKind::File, + file_name: Some("remote.command".to_owned()), + mime_type: None, + size: Some(1), + fallback_emoji: None, + }); + app.messages.get_mut(&1).unwrap().push(attachment); + let path = std::env::temp_dir().join(format!("termgram-temp-only-{}", std::process::id())); + fs::write(&path, b"x").expect("download fixture"); + app.downloading_attachments.insert((1, 21), 1); + app.handle_network(NetworkEvent::AttachmentDownloaded { + request_id: 1, + chat_id: 1, + message_id: 21, + path: path.clone(), + }); + app.selected_message = Some(21); + + assert!(app.handle_action(KeyAction::Enter).is_empty()); assert!( - app.handle_network(NetworkEvent::Ready { - user_name: "Ada".to_owned() - }) - .is_empty() + app.status_message + .as_deref() + .is_some_and(|message| message.contains("reveal is disabled")) ); - assert_eq!(app.screen, Screen::Main); + fs::remove_file(path).expect("remove fixture"); } #[test] - fn chat_selection_survives_reordering_by_identity() { + fn q_is_text_in_an_editor_but_quits_in_navigation() { let mut app = ready_app(); - app.selected_chat = 1; - app.handle_network(NetworkEvent::Dialogs(vec![ - chat(3, "Gamma"), - chat(1, "Alpha"), - chat(2, "Beta"), - ])); - assert_eq!(app.selected_chat_entry().map(|chat| chat.id), Some(2)); + app.handle_action(KeyAction::Character('/')); + app.handle_action(KeyAction::Character('q')); + assert_eq!(app.filter.value(), "q"); + app.handle_action(KeyAction::Escape); + assert_eq!( + app.handle_action(KeyAction::Character('q')), + vec![TelegramCommand::Shutdown] + ); } #[test] - fn composer_is_multiline_unicode_aware_and_optimistic() { + fn control_l_requests_a_complete_redraw() { let mut app = ready_app(); - open_first(&mut app); - app.handle_action(KeyAction::Character('i')); - app.handle_action(KeyAction::Character('界')); - app.handle_action(KeyAction::Newline); - app.handle_action(KeyAction::Character('🙂')); - let commands = app.handle_action(KeyAction::Enter); - assert!(matches!( - commands.as_slice(), - [TelegramCommand::SendMessage { chat_id: 1, local_id: -1, text, reply_to: None }] if text == "界\n🙂" - )); - let pending = app.active_messages().last().unwrap(); - assert_eq!(pending.delivery, Delivery::Pending); - assert_eq!(app.mode, Mode::Compose); + app.handle_action(KeyAction::Redraw); + assert!(app.take_force_redraw()); + assert!(!app.take_force_redraw()); } #[test] - fn associated_text_uses_layout_text_without_executing_shortcuts() { - use yazi_term::event::{KeyCode, KeyEvent, KeyEventKind}; - + fn fatal_strings_cannot_inject_terminal_sequences() { let mut app = ready_app(); - open_first(&mut app); - app.handle_action(KeyAction::Character('i')); - let mut key = KeyEvent { - code: KeyCode::Char('q'), - modifiers: Modifiers::CONTROL | Modifiers::ALT, - text: "你好🙂".into(), - ..KeyEvent::default() - }; - assert!(app.handle_key(&key).is_empty()); - assert_eq!(app.active_draft().unwrap().value(), "你好🙂"); - assert!(!app.should_quit); - key.kind = KeyEventKind::Release; - assert!(app.handle_key(&key).is_empty()); - assert_eq!(app.active_draft().unwrap().value(), "你好🙂"); + app.handle_network(NetworkEvent::Fatal("bad\u{1b}[2Jnews".to_owned())); + assert_eq!(app.screen, Screen::Fatal("badnews".to_owned())); + assert_eq!( + app.handle_action(KeyAction::Character('q')), + vec![TelegramCommand::Shutdown] + ); } #[test] - fn message_cursor_composes_a_native_reply_and_keeps_optimistic_context() { + fn navigation_opens_chat_and_controls_narrow_view() { let mut app = ready_app(); - open_first(&mut app); - - app.handle_action(KeyAction::Character('[')); - assert_eq!(app.selected_message, Some(20)); - app.handle_action(KeyAction::Character('[')); - assert_eq!(app.selected_message, Some(19)); - app.handle_action(KeyAction::Character(']')); - assert_eq!(app.selected_message, Some(20)); - - app.handle_action(KeyAction::Character('R')); - assert_eq!(app.mode, Mode::Compose); - assert_eq!( - app.active_reply_target().map(|reply| reply.message_id), - Some(20) - ); - app.handle_action(KeyAction::Character('h')); - app.handle_action(KeyAction::Character('i')); - let commands = app.handle_action(KeyAction::Enter); - assert_eq!( - commands.last(), - Some(&TelegramCommand::SendMessage { - chat_id: 1, - local_id: -1, - text: "hi".to_owned(), - reply_to: Some(20), - }) - ); - let pending = app.active_messages().last().expect("optimistic reply"); + app.handle_action(KeyAction::Down); assert_eq!( - pending.reply_to.as_ref().map(|reply| reply.message_id), - Some(20) + app.handle_action(KeyAction::Enter), + vec![TelegramCommand::LoadHistory { + chat_id: 2, + request_id: 1, + after_id: Some(0), + },] ); - assert!(app.active_reply_target().is_none()); + assert_eq!(app.focus, Focus::Conversation); + assert!(app.narrow_conversation); + app.handle_action(KeyAction::Escape); + assert_eq!(app.focus, Focus::Chats); + assert!(!app.narrow_conversation); } #[test] - fn failed_reply_restores_its_draft_and_exact_target_for_retry() { + fn tab_keeps_narrow_pane_visibility_in_sync_with_focus() { let mut app = ready_app(); open_first(&mut app); - app.handle_action(KeyAction::Character('R')); - app.handle_action(KeyAction::Character('x')); - app.handle_action(KeyAction::Enter); + assert_eq!(app.focus, Focus::Conversation); + assert!(app.narrow_conversation); - app.handle_network(NetworkEvent::SendFailed { - chat_id: 1, - local_id: -1, - text: "x".to_owned(), - reply_to: Some(20), - error: "offline".to_owned(), - }); - assert_eq!(app.active_draft().map(TextInput::value), Some("x")); - assert_eq!( - app.active_reply_target().map(|reply| reply.message_id), - Some(20) - ); - assert!(matches!( - app.handle_action(KeyAction::Enter).last(), - Some(TelegramCommand::SendMessage { - local_id: -2, - reply_to: Some(20), - .. - }) - )); + app.message_scroll = 3; + app.handle_action(KeyAction::Tab); + assert_eq!(app.focus, Focus::Chats); + assert!(!app.narrow_conversation); + app.handle_network(NetworkEvent::NewMessage(message(21, 1, "new", false))); + assert_eq!(app.new_messages_to_anchor, 1); + + app.handle_action(KeyAction::Tab); + assert_eq!(app.focus, Focus::Conversation); + assert!(app.narrow_conversation); } #[test] - fn escape_cancels_reply_context_before_leaving_the_composer() { + fn plain_path_paste_remains_text_even_when_the_file_exists() { let mut app = ready_app(); open_first(&mut app); - app.handle_action(KeyAction::Character('R')); - app.handle_action(KeyAction::Character('x')); - - app.handle_action(KeyAction::Escape); + app.mode = Mode::Navigate; + let file = tempfile::NamedTempFile::new().expect("fixture"); + let text = format!("'{}'", file.path().display()); + assert!(app.update(AppEvent::Paste(text.clone())).is_empty()); assert_eq!(app.mode, Mode::Compose); - assert!(app.active_reply_target().is_none()); - assert_eq!(app.active_draft().map(TextInput::value), Some("x")); - assert_eq!( - app.status_message.as_deref(), - Some("Reply cancelled · draft kept") - ); + assert_eq!(app.active_draft().unwrap().value(), text); + assert!(!app.active_messages().iter().any(|message| message.id < 0)); + } - app.handle_action(KeyAction::Escape); - assert_eq!(app.mode, Mode::Navigate); - app.handle_action(KeyAction::Character('i')); - assert_eq!(app.mode, Mode::Compose); - assert_eq!(app.active_draft().map(TextInput::value), Some("x")); + fn prepare_file(app: &mut App, path: &std::path::Path) { + let commands = app.prepare_attachments(path.display().to_string(), false); + let [TelegramCommand::PrepareAttachments(request)] = commands.as_slice() else { + panic!("only a preparation request is emitted before confirmation"); + }; + app.handle_network(NetworkEvent::AttachmentsPrepared { + key: request.key, + request_id: request.id, + result: crate::staging::prepare(request).map_err(|error| error.to_string()), + }); } #[test] - fn failed_send_is_visible_and_restored_without_overwriting_newer_draft() { + fn staged_files_preserve_caption_reply_and_source_chat_until_explicit_send() { let mut app = ready_app(); open_first(&mut app); - app.handle_action(KeyAction::Character('i')); - app.handle_action(KeyAction::Character('x')); - app.handle_action(KeyAction::Enter); - app.handle_action(KeyAction::Character('n')); - app.handle_network(NetworkEvent::SendFailed { - chat_id: 1, - local_id: -1, - text: "x".to_owned(), - reply_to: None, - error: "offline".to_owned(), + app.handle_action(KeyAction::Character('R')); + app.draft_data_mut(1).input.set_value("caption"); + let file = tempfile::NamedTempFile::new().expect("fixture"); + fs::write(file.path(), b"hello").unwrap(); + let commands = app.prepare_attachments(file.path().display().to_string(), false); + let [TelegramCommand::PrepareAttachments(request)] = commands.as_slice() else { + panic!("prepare"); + }; + let prepared = crate::staging::prepare(request).unwrap(); + assert!( + app.send_draft(1).is_empty(), + "cannot send while preparation is pending" + ); + app.active_chat_id = Some(2); + app.handle_network(NetworkEvent::AttachmentsPrepared { + key: request.key, + request_id: request.id, + result: Ok(prepared), }); + assert!(app.draft_attachments().is_empty()); + assert_eq!(app.draft_data(1).unwrap().attachments.len(), 1); + app.active_chat_id = Some(1); + assert!(!app.active_messages().iter().any(|message| message.id < 0)); + let commands = app.send_draft(1); + assert!( + matches!(commands.as_slice(), [TelegramCommand::SendAttachment { + chat_id: 1, local_id: -1, caption, reply_to: Some(20), attachment: crate::staging::Attachment { as_photo: false, .. }, .. + }] if caption == "caption") + ); + let pending = app.active_messages().last().expect("optimistic attachment"); + assert_eq!(pending.text, "caption"); assert_eq!( - app.active_messages().last().unwrap().delivery, - Delivery::Failed + pending.reply_to.as_ref().map(|reply| reply.message_id), + Some(20) ); - assert_eq!(app.active_draft().map(TextInput::value), Some("n")); - assert!(app.status_message.as_deref().unwrap().contains("offline")); + assert!(app.draft_data(1).unwrap().is_empty()); } #[test] - fn retry_replaces_the_failed_timeline_entry() { + fn attachment_review_reuses_preview_and_does_not_send_or_delete_originals() { let mut app = ready_app(); open_first(&mut app); - app.handle_action(KeyAction::Character('i')); - app.handle_action(KeyAction::Character('x')); - app.handle_action(KeyAction::Enter); - app.handle_network(NetworkEvent::SendFailed { - chat_id: 1, - local_id: -1, - text: "x".to_owned(), - reply_to: None, - error: "offline".to_owned(), - }); - assert_eq!(app.active_draft().map(TextInput::value), Some("x")); - - let commands = app.handle_action(KeyAction::Enter); - assert!(matches!( - commands.last(), - Some(TelegramCommand::SendMessage { local_id: -2, .. }) - )); - assert!(!app.active_messages().iter().any(|message| message.id == -1)); - assert_eq!(app.active_messages().last().unwrap().id, -2); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("picture.png"); + image::RgbImage::new(2, 2).save(&path).unwrap(); + prepare_file(&mut app, &path); + app.open_attachments(); + for width in [40, 100] { + let mut terminal = + ratatui::Terminal::new(ratatui::backend::TestBackend::new(width, 15)).unwrap(); + terminal + .draw(|frame| crate::ui::render(frame, &mut app)) + .unwrap(); + assert_eq!(app.attachment_draft.hit_regions.len(), 1); + assert!(app.run_binding("preview", 1).is_empty()); + terminal + .draw(|frame| crate::ui::render(frame, &mut app)) + .unwrap(); + assert!(matches!( + app.media_slots.as_slice(), + [crate::media::MediaSlot { + source: crate::media::MediaSource::File(_), + .. + }] + )); + assert!( + app.request_visible_media().is_empty(), + "local preview never issues Telegram RPCs" + ); + app.run_binding("cancel", 1); + } + app.run_binding("attachment_format", 1); + assert!(!app.draft_attachments()[0].as_photo); + app.run_binding("remove_attachment", 1); + assert!(app.draft_attachments().is_empty()); + assert!(path.exists()); + assert!(!app.active_messages().iter().any(|message| message.id < 0)); } #[test] - fn history_refresh_preserves_pending_messages() { + fn dropped_path_never_sends_from_chat_focus_filter_or_help() { let mut app = ready_app(); open_first(&mut app); - app.handle_action(KeyAction::Character('i')); - app.handle_action(KeyAction::Character('x')); - app.handle_action(KeyAction::Enter); - let request_id = 2; - app.active_history_request = Some((1, request_id)); - app.handle_network(NetworkEvent::History { - chat_id: 1, - request_id, - messages: vec![message(1, 1, "server", false)], - }); - assert!(app.active_messages().iter().any(|message| message.id == -1)); + let path = + std::env::temp_dir().join(format!("termgram-no-send-{}.txt", std::process::id())); + fs::write(&path, b"private").expect("create fixture"); + let paste = AppEvent::Paste(path.display().to_string()); + + app.focus = Focus::Chats; + assert!(app.update(paste.clone()).is_empty()); + app.focus = Focus::Conversation; + app.mode = Mode::Filter; + assert!(app.update(paste.clone()).is_empty()); + app.mode = Mode::Help; + assert!(app.update(paste).is_empty()); + assert!(!app.active_messages().iter().any(|message| message.id < 0)); + fs::remove_file(path).expect("remove fixture"); } #[test] - fn histories_and_live_updates_keep_only_the_lightweight_history_window() { - let mut app = ready_app(); - app.handle_action(KeyAction::Enter); - app.handle_network(NetworkEvent::History { - chat_id: 1, - request_id: 1, - messages: (1..=600) - .map(|id| message(id, 1, &format!("message {id}"), false)) - .collect(), - }); - let history = app.messages.get(&1).expect("bounded history"); - assert_eq!(history.len(), MAX_MESSAGES_PER_CHAT); - assert_eq!(history.first().map(|message| message.id), Some(441)); - assert_eq!(history.last().map(|message| message.id), Some(600)); - - app.handle_network(NetworkEvent::NewMessage(message(601, 1, "latest", false))); - let history = app.messages.get(&1).expect("bounded history"); - assert_eq!(history.len(), MAX_MESSAGES_PER_CHAT); - assert_eq!(history.first().map(|message| message.id), Some(442)); - assert_eq!(history.last().map(|message| message.id), Some(601)); + fn telegram_links_are_resolved_but_ordinary_urls_are_not() { + assert_eq!( + super::telegram_link("see https://t.me/rustlang/42."), + Some("https://t.me/rustlang/42".to_owned()) + ); + assert_eq!( + super::telegram_link(""), + Some("tg://resolve?domain=rustlang&post=42".to_owned()) + ); + assert_eq!( + super::telegram_link("telegram.me/rustlang"), + Some("https://telegram.me/rustlang".to_owned()) + ); + assert_eq!(super::telegram_link("https://example.com/file"), None); } #[test] - fn message_cache_evicts_inactive_chats_but_keeps_the_active_one() { + fn caption_link_has_explicit_activation_separate_from_media() { let mut app = ready_app(); - app.chats = (1_i64..=14) - .map(|id| chat(id, &format!("Chat {id}"))) - .collect(); - app.active_chat_id = Some(14); - for chat_id in 1_i64..=14 { - app.handle_network(NetworkEvent::MessageUpdated(message( - i32::try_from(chat_id).expect("small chat id"), - chat_id, - "cached", - false, - ))); - } + open_first(&mut app); + let mut photo = message(21, 1, "see https://t.me/rustlang/42", false); + photo.attachment = Some(Attachment { + source_id: None, + kind: AttachmentKind::Photo, + file_name: Some("photo.jpg".to_owned()), + mime_type: Some("image/jpeg".to_owned()), + size: Some(10), + fallback_emoji: None, + }); + app.handle_network(NetworkEvent::NewMessage(photo)); + app.selected_message = Some(21); - assert_eq!(app.messages.len(), MAX_CACHED_CHATS + 1); - assert!(app.messages.contains_key(&14)); - assert!(!app.messages.contains_key(&13)); + assert_eq!( + app.handle_action(KeyAction::Character('l')), + vec![TelegramCommand::ResolveTelegramLink { + url: "https://t.me/rustlang/42".to_owned() + }] + ); } #[test] - fn detached_history_never_jumps_and_defers_read_ack() { + fn message_actions_cycle_across_links_and_inline_buttons() { let mut app = ready_app(); open_first(&mut app); - app.handle_network(NetworkEvent::ReadMarked { chat_id: 1 }); - app.handle_action(KeyAction::PageUp); - assert_eq!(app.message_scroll, 10); - assert!( - app.handle_network(NetworkEvent::NewMessage(message(21, 1, "new", false))) - .is_empty() + let mut actionable = message(21, 1, "actions", false); + actionable.links = vec![MessageLink { + label: "Rust chat".to_owned(), + url: "https://t.me/rustlang/42".to_owned(), + }]; + actionable.buttons = vec![MessageButton { + label: "Confirm".to_owned(), + index: 3, + kind: MessageButtonKind::Callback, + }]; + app.handle_network(NetworkEvent::NewMessage(actionable)); + + app.run_binding("next_action", 1); + assert_eq!(app.selected_message, Some(21)); + assert_eq!(app.selected_action, 0); + assert_eq!( + app.handle_action(KeyAction::Enter), + vec![TelegramCommand::ResolveTelegramLink { + url: "https://t.me/rustlang/42".to_owned(), + }] ); - assert_eq!(app.message_scroll, 10); - assert_eq!(app.new_messages_while_scrolled, 1); - assert_eq!(app.new_messages_to_anchor, 1); - assert!(app.active_chat().unwrap().unread > 0); + + app.run_binding("next_action", 1); + assert_eq!(app.selected_action, 1); assert_eq!( - app.handle_action(KeyAction::End), - vec![TelegramCommand::MarkRead { chat_id: 1 }] + app.handle_action(KeyAction::Enter), + vec![TelegramCommand::ActivateButton { + chat_id: 1, + message_id: 21, + button_index: 3, + }] ); - assert_eq!(app.new_messages_while_scrolled, 0); - assert_eq!(app.new_messages_to_anchor, 0); } #[test] - fn accepted_then_echoed_outgoing_message_reconciles_without_a_new_badge() { + fn configuration_reload_keeps_state_on_error_and_applies_a_complete_snapshot() { let mut app = ready_app(); open_first(&mut app); - app.handle_action(KeyAction::Character('i')); - app.handle_action(KeyAction::Character('x')); - app.handle_action(KeyAction::Enter); - app.handle_network(NetworkEvent::MessageAccepted { - chat_id: 1, - local_id: -1, - }); - assert_eq!( - app.active_messages() + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.lua"); + app.configuration.path = Some(path.clone()); + app.draft_data_mut(1).input.set_value("unsent draft"); + app.run_binding("command", 1); + app.commands.input.set_value("config reload"); + assert!(app.run_binding("open", 1).is_empty()); + assert_eq!(app.take_config_reload(), Some(path.clone())); + assert!(app.take_config_reload().is_none()); + app.configuration_failed( + &crate::keymap::Keymap::reload(&path) + .unwrap_err() + .to_string(), + ); + assert_eq!(app.configuration.revision, 0); + assert!(!app.keymap.nerd_font); + fs::write( + &path, + "return {nerd_font=true, ghost_text='new', statusline={right={'bogus'}}}", + ) + .unwrap(); + let error = crate::keymap::Keymap::reload(&path).unwrap_err(); + app.configuration_failed(&error.to_string()); + assert_eq!(app.keymap.ghost_text, "{send} to send"); + fs::write(&path, "return {nerd_font=true, ghost_text='new', statusline={right={'dc'}}, notifications={enabled=false}}").unwrap(); + app.install_configuration(crate::keymap::Keymap::reload(&path).unwrap()); + assert!(app.keymap.nerd_font); + assert_eq!(app.keymap.ghost_text, "new"); + assert!(!app.keymap.statusline.measures_latency()); + assert!(!app.keymap.notifications.enabled); + assert_eq!(app.configuration.revision, 1); + assert!(app.configuration.error.is_none()); + assert_eq!(app.draft_data(1).unwrap().input.value(), "unsent draft"); + assert_eq!(app.active_chat_id, Some(1)); + assert!( + app.help_lines() .iter() - .find(|message| message.id == -1) - .map(|message| message.delivery), - Some(Delivery::Sent) + .any(|line| line.starts_with(":config reload")) ); - - app.message_scroll = 5; - let mut echoed = message(21, 1, "x", true); - echoed.timestamp = Utc::now(); - app.handle_network(NetworkEvent::NewMessage(echoed)); - assert!(!app.active_messages().iter().any(|message| message.id < 0)); - assert!(app.active_messages().iter().any(|message| message.id == 21)); - assert_eq!(app.new_messages_while_scrolled, 0); - assert_eq!(app.new_messages_to_anchor, 0); + for mode in [Mode::Help, Mode::Status] { + app.mode = mode; + app.help_scroll = usize::MAX; + app.configuration.status_scroll = usize::MAX; + let mut terminal = + ratatui::Terminal::new(ratatui::backend::TestBackend::new(40, 12)).unwrap(); + terminal + .draw(|frame| crate::ui::render(frame, &mut app)) + .unwrap(); + let text = terminal + .backend() + .buffer() + .content + .iter() + .map(ratatui::buffer::Cell::symbol) + .collect::(); + if mode == Mode::Help { + assert!(text.contains(":quit")); + } else { + assert!(text.contains("build")); + } + } + app.reset_for_account_switch(2); + assert_eq!(app.configuration.path, Some(path)); + assert_eq!(app.configuration.revision, 1); } #[test] - fn confirming_one_of_two_identical_sends_keeps_the_other_optimistic_entry() { + fn chat_information_invalidates_old_permissions_and_preserves_restricted_drafts() { + use crate::chat_info::{Info, Restriction}; let mut app = ready_app(); open_first(&mut app); - app.handle_action(KeyAction::Character('i')); - app.handle_action(KeyAction::Character('x')); - app.handle_action(KeyAction::Enter); - app.handle_action(KeyAction::Character('x')); - app.handle_action(KeyAction::Enter); - - app.handle_network(NetworkEvent::MessageSent { - local_id: -1, - message: message(21, 1, "x", true), + app.connection = crate::event::ConnectionStatus::Online; + let outgoing = app.request_visible_chat_info(); + let [TelegramCommand::LoadChatInfo { request_id, .. }] = outgoing.as_slice() else { + panic!("details") + }; + let info = Info { + title: "Group".to_owned(), + role: "Member".to_owned(), + about: "Long description ".repeat(100), + restrictions: vec![Restriction { + content: None, + reason: "Read only".to_owned(), + until: 0, + }], + ..Info::default() + }; + app.handle_network(NetworkEvent::ChatInfoInvalidated { chat_id: 1 }); + app.handle_network(NetworkEvent::ChatInfoReady { + chat_id: 1, + request_id: *request_id, + result: Ok(info.clone()), }); - assert!(app.active_messages().iter().any(|message| message.id == 21)); - assert!(app.active_messages().iter().any(|message| message.id == -2)); - assert!(!app.active_messages().iter().any(|message| message.id == -1)); + assert!(app.chat_info.entries.is_empty()); + let outgoing = app.request_visible_chat_info(); + let [TelegramCommand::LoadChatInfo { request_id, .. }] = outgoing.as_slice() else { + panic!("refreshed details") + }; + app.handle_network(NetworkEvent::ChatInfoReady { + chat_id: 1, + request_id: *request_id, + result: Ok(info), + }); + assert!(app.request_visible_chat_info().is_empty()); + app.start_composing(); + app.draft_data_mut(1).input.set_value("keep my draft"); + assert!(app.send_draft(1).is_empty()); + assert_eq!(app.draft_data(1).unwrap().input.value(), "keep my draft"); + assert!(app.status_message.as_ref().unwrap().contains("Read only")); + app.mode = Mode::Navigate; + app.run_binding("command", 1); + app.commands.input.set_value("info"); + app.run_binding("open", 1); + assert_eq!(app.mode, Mode::ChatInfo); + for (width, height) in [(90, 28), (40, 12)] { + let mut terminal = + ratatui::Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap(); + terminal + .draw(|frame| crate::ui::render(frame, &mut app)) + .unwrap(); + app.run_binding("down", 20); + terminal + .draw(|frame| crate::ui::render(frame, &mut app)) + .unwrap(); + assert!(app.chat_info.scroll > 0); + } + app.run_binding("cancel", 1); + assert_eq!(app.mode, Mode::Navigate); + app.handle_network(NetworkEvent::ChatInfoInvalidated { chat_id: 1 }); + assert_eq!(app.draft_restriction(1), None); + assert_eq!(app.draft_data(1).unwrap().input.value(), "keep my draft"); } #[test] - fn background_focus_defers_read_acknowledgement() { + fn invitation_requires_confirmation_and_late_results_do_not_navigate() { + use crate::invites::{Outcome, Preview}; let mut app = ready_app(); open_first(&mut app); - app.update(AppEvent::TerminalFocus(false)); + app.connection = crate::event::ConnectionStatus::Online; + let preview = Preview { + title: "Test group".to_owned(), + about: "A group".to_owned(), + participants: Some(12), + request_needed: true, + warning: None, + blocked: None, + joined: None, + }; + let outgoing = app.activate_url("https://t.me/+test_invite"); + let [TelegramCommand::PreviewInvite { request_id, .. }] = outgoing.as_slice() else { + panic!("preview only") + }; + let old = *request_id; + app.run_binding("cancel", 1); + app.handle_network(NetworkEvent::InviteReady { + request_id: old, + result: Ok(preview.clone()), + }); + assert_eq!(app.mode, Mode::Navigate); + assert!(app.invites.preview.is_none()); + app.run_binding("command", 1); + app.commands + .input + .set_value("join tg://join?invite=test_invite"); + let outgoing = app.run_binding("open", 1); + let [TelegramCommand::PreviewInvite { request_id, .. }] = outgoing.as_slice() else { + panic!("preview") + }; + app.handle_network(NetworkEvent::InviteReady { + request_id: *request_id, + result: Ok(preview), + }); + assert_eq!(app.invites.selected, 0); + assert_eq!(app.invite_action(), Some("Request to join")); + for (width, height) in [(82, 24), (40, 12)] { + let mut terminal = + ratatui::Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap(); + terminal + .draw(|frame| crate::ui::render(frame, &mut app)) + .unwrap(); + assert!(!app.invites.hit_regions.is_empty()); + } + app.run_binding("down", 1); + let mut repeat = KeyEvent::new(yazi_term::event::KeyCode::Enter, Modifiers::empty()); + repeat.kind = yazi_term::event::KeyEventKind::Repeat; + assert!(app.handle_key(&repeat).is_empty()); + let outgoing = app.run_binding("open", 1); + let [ + TelegramCommand::JoinInvite { + request_id, + request_needed, + .. + }, + ] = outgoing.as_slice() + else { + panic!("join") + }; + assert!(*request_needed); + assert!(app.run_binding("open", 1).is_empty()); + app.run_binding("cancel", 1); + app.handle_network(NetworkEvent::InviteJoined { + request_id: *request_id, + result: Ok(Outcome::Requested), + }); + assert_eq!(app.mode, Mode::Navigate); + assert_eq!(app.active_chat_id, Some(1)); assert!( - app.handle_network(NetworkEvent::NewMessage(message(21, 1, "new", false))) - .is_empty() + app.status_message + .as_ref() + .unwrap() + .contains("waiting for an administrator") ); - assert!(app.active_chat().unwrap().unread > 0); + assert!(!format!("{:?}", outgoing[0]).contains("test_invite")); } #[test] - fn slash_in_conversation_is_a_bot_command_not_a_filter() { + fn open_command_resolves_usernames_and_ignores_superseded_results() { let mut app = ready_app(); open_first(&mut app); - app.handle_action(KeyAction::Character('/')); + app.connection = crate::event::ConnectionStatus::Online; + app.draft_data_mut(1).input.set_value("keep this draft"); + app.run_binding("command", 1); + app.commands.input.set_value("open not a username"); + assert!(app.run_binding("open", 1).is_empty()); + assert_eq!(app.mode, Mode::Command); + app.commands.input.set_value("open @alice_name"); + assert_eq!( + app.run_binding("open", 1), + [TelegramCommand::ResolveTelegramLink { + url: "https://t.me/alice_name".to_owned() + }] + ); + app.run_binding("command", 1); + app.commands.input.set_value("open @bob_name"); + app.run_binding("open", 1); + app.handle_network(NetworkEvent::LinkResolved { + url: "https://t.me/alice_name".to_owned(), + chat: chat(90, "Alice"), + message: None, + }); + assert_eq!(app.active_chat_id, Some(1)); + app.handle_network(NetworkEvent::LinkFailed { + url: "https://t.me/alice_name".to_owned(), + error: "old failure".to_owned(), + }); + assert!( + !app.status_message + .as_deref() + .unwrap_or_default() + .contains("old failure") + ); + let commands = app.handle_network(NetworkEvent::LinkResolved { + url: "https://t.me/bob_name".to_owned(), + chat: chat(91, "Bob"), + message: None, + }); + assert!(matches!( + commands.as_slice(), + [TelegramCommand::LoadHistory { chat_id: 91, .. }] + )); + assert_eq!(app.active_chat_id, Some(91)); + assert_eq!(app.draft_data(1).unwrap().input.value(), "keep this draft"); + app.activate_url("https://t.me/alice_name"); + app.start_composing(); + app.handle_network(NetworkEvent::LinkResolved { + url: "https://t.me/alice_name".to_owned(), + chat: chat(90, "Alice"), + message: None, + }); + assert_eq!(app.active_chat_id, Some(91)); assert_eq!(app.mode, Mode::Compose); - assert_eq!(app.active_draft().map(TextInput::value), Some("/")); } #[test] - fn paste_normalizes_newlines_and_strips_terminal_controls() { + fn username_lookup_keeps_existing_dialog_counters_and_folder_membership() { let mut app = ready_app(); - open_first(&mut app); - app.handle_action(KeyAction::Character('i')); - app.update(AppEvent::Paste("a\r\nb\u{1b}[31m!".to_owned())); - assert_eq!(app.active_draft().map(TextInput::value), Some("a\nb!")); + let known = app.chats.iter_mut().find(|chat| chat.id == 2).unwrap(); + known.membership.archived = true; + known.last_message_id = Some(500); + known.last_message = "Latest message".to_owned(); + let before = known.clone(); + app.activate_url("https://t.me/beta"); + let mut lookup = chat(2, "New title"); + lookup.unread = 0; + app.handle_network(NetworkEvent::LinkResolved { + url: "https://t.me/beta".to_owned(), + chat: lookup, + message: None, + }); + let after = app.chats.iter().find(|chat| chat.id == 2).unwrap(); + assert_eq!(after.unread, before.unread); + assert_eq!(after.last_message_id, before.last_message_id); + assert_eq!(after.last_message, before.last_message); + assert_eq!(after.membership, before.membership); + assert_eq!(after.title, "New title"); + assert_eq!(app.folder_id, 1); } #[test] - fn filtering_and_help_are_contextual() { + fn linked_target_survives_history_and_dialog_refresh_with_anchor() { let mut app = ready_app(); - app.handle_action(KeyAction::Character('/')); - app.handle_action(KeyAction::Character('g')); - assert_eq!(app.visible_chats()[0].title, "Gamma"); - app.handle_action(KeyAction::Escape); - app.handle_action(KeyAction::Character('?')); - assert_eq!(app.mode, Mode::Help); - app.handle_action(KeyAction::Character('?')); - assert_eq!(app.mode, Mode::Navigate); - } - - #[test] - fn settings_screen_toggles_and_persists_essential_preferences() { - let nonce = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system clock") - .as_nanos(); - let directory = std::env::temp_dir().join(format!( - "termgram-app-settings-{}-{nonce}", - std::process::id() - )); - let path = directory.join("settings.conf"); - let mut app = App::with_settings(Settings::default(), path.clone()); - app.screen = Screen::Main; - - app.handle_action(KeyAction::Character('s')); - assert_eq!(app.mode, Mode::Settings); - app.handle_action(KeyAction::Enter); - app.handle_action(KeyAction::Down); - app.handle_action(KeyAction::Enter); - app.handle_action(KeyAction::Down); - app.handle_action(KeyAction::Enter); - app.handle_action(KeyAction::Down); - app.handle_action(KeyAction::Enter); - assert_eq!( - *app.settings(), - Settings { - automatic_update_checks: false, - release_channel: ReleaseChannel::Prerelease, - download_behavior: DownloadBehavior::TempOnly, - show_message_ids: true, - ..Settings::default() - } - ); + open_first(&mut app); + let linked = message(5, 99, "linked target", false); + let linked_chat = Chat { + read_inbox_max_id: Some(0), + membership: crate::folders::ChatMembership::default(), + id: 99, + title: "Linked group".to_owned(), + kind: ChatKind::Group, + unread: 0, + last_message_id: None, + last_message: "linked target".to_owned(), + last_activity: None, + }; + app.pending_telegram_link = Some("https://t.me/example/5".to_owned()); + let commands = app.handle_network(NetworkEvent::LinkResolved { + url: "https://t.me/example/5".to_owned(), + chat: linked_chat, + message: Some(linked), + }); + let request_id = commands + .iter() + .find_map(|command| match command { + TelegramCommand::LoadHistory { request_id, .. } => Some(*request_id), + _ => None, + }) + .expect("linked history request"); + assert_eq!(app.viewport_anchor_message, Some(5)); + assert_eq!(app.selected_message, Some(5)); + app.handle_action(KeyAction::Character('i')); assert_eq!( - Settings::load_from(&path).expect("persisted settings"), - *app.settings() + app.active_reply_target().map(|reply| reply.message_id), + Some(5) ); - app.handle_action(KeyAction::Escape); - assert_eq!(app.mode, Mode::Navigate); - fs::remove_file(path).expect("remove settings"); - fs::remove_dir(directory).expect("remove settings directory"); + app.handle_network(NetworkEvent::History { + chat_id: 99, + request_id, + messages: (100..180) + .map(|id| message(id, 99, &format!("recent {id}"), false)) + .collect(), + }); + assert!(app.active_messages().iter().any(|message| message.id == 5)); + assert_eq!(app.viewport_anchor_message, Some(5)); + + app.handle_network(NetworkEvent::Dialogs(vec![chat(1, "Alpha")])); + assert_eq!(app.active_chat_id, Some(99)); + assert!(app.chats.iter().any(|chat| chat.id == 99)); } #[test] - fn account_picker_adds_switches_and_isolates_account_state() { - let nonce = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system clock") - .as_nanos(); - let directory = std::env::temp_dir().join(format!( - "termgram-app-accounts-{}-{nonce}", - std::process::id() - )); - let path = directory.join("settings.conf"); - let mut app = App::with_settings(Settings::default(), path.clone()); - app.screen = Screen::Main; - app.user_name = Some("First".to_owned()); - app.chats.push(chat(7, "Private chat")); - app.messages - .insert(7, vec![message(1, 7, "account one", false)]); - - assert!(app.handle_action(KeyAction::Character('a')).is_empty()); - assert_eq!(app.mode, Mode::Accounts); - assert_eq!(app.account_selection(), 0); - assert!(app.handle_action(KeyAction::Down).is_empty()); - assert_eq!( - app.handle_action(KeyAction::Enter), - vec![TelegramCommand::SwitchAccount { account: 2 }] - ); - assert_eq!(app.active_account(), 2); - assert_eq!(app.account_count(), 2); - assert_eq!(app.screen, Screen::Connecting); - assert!(app.chats.is_empty()); - assert!(app.messages.is_empty()); - assert!(app.user_name.is_none()); - assert_eq!( - app.status_message.as_deref(), - Some("Switching to Account 2…") - ); - assert_eq!( - Settings::load_from(&path).expect("persisted account selection"), - *app.settings() + fn old_link_target_survives_a_full_cached_destination() { + let mut app = ready_app(); + open_first(&mut app); + app.selected_message = Some(20); + app.messages.insert( + 2, + (1000..1000 + i32::try_from(MAX_MESSAGES_PER_CHAT).expect("small cap")) + .map(|id| message(id, 2, "recent", false)) + .collect(), ); - app.handle_network(NetworkEvent::Ready { - user_name: "Second".to_owned(), + app.pending_telegram_link = Some("https://t.me/example/5".to_owned()); + app.handle_network(NetworkEvent::LinkResolved { + url: "https://t.me/example/5".to_owned(), + chat: chat(2, "Beta"), + message: Some(message(5, 2, "old exact target", false)), }); - assert_eq!( - app.handle_action(KeyAction::NextAccount), - vec![TelegramCommand::SwitchAccount { account: 1 }] + + assert_eq!(app.active_chat_id, Some(2)); + assert_eq!(app.selected_message, Some(5)); + assert!( + app.active_messages() + .iter() + .any(|message| message.id == 5 && message.text == "old exact target") ); - assert_eq!(app.active_account(), 1); + } - fs::remove_file(path).expect("remove settings"); - fs::remove_dir(directory).expect("remove settings directory"); + #[test] + fn selecting_actionable_message_sets_a_visible_semantic_anchor() { + let mut app = ready_app(); + open_first(&mut app); + app.messages.insert( + 1, + (1..=30) + .map(|id| { + message( + id, + 1, + if id == 2 { + "https://t.me/rustlang/2" + } else { + "ordinary" + }, + false, + ) + }) + .collect(), + ); + app.run_binding("next_action", 1); + assert_eq!(app.selected_message, Some(2)); + assert_eq!(app.viewport_anchor_message, Some(2)); + assert!(app.message_scroll > 0); + + app.handle_action(KeyAction::PageDown); + assert_eq!(app.selected_message, None); + assert_eq!(app.viewport_anchor_message, None); } #[test] - fn account_shortcuts_work_during_login_and_enforce_the_slot_limit() { - let mut app = App::with_ephemeral_settings(Settings { - active_account: 1, - account_count: 2, - ..Settings::default() + fn reply_previews_batch_visible_targets_and_keep_live_edits_and_deletions() { + let mut app = ready_app(); + open_first(&mut app); + let mut replies = Vec::new(); + for (id, target) in [(21, 10), (22, 10), (23, 11)] { + let mut source = message(id, 1, "reply", false); + source.reply_to = Some(ReplyInfo { + chat_id: 1, + message_id: target, + sender: None, + }); + replies.push(source); + } + app.messages.insert(1, replies); + app.set_message_hit_regions(vec![ + (0, 80, 1, (21, None)), + (0, 80, 2, (22, None)), + (0, 80, 3, (23, None)), + ]); + let commands = app.request_visible_replies(); + let [ + TelegramCommand::LoadReplyPreviews { + chat_id: 1, + message_ids, + request_id, + }, + ] = commands.as_slice() + else { + panic!("one batch expected") + }; + assert_eq!(message_ids, &[10, 11]); + assert!(app.request_visible_replies().is_empty()); + app.handle_network(NetworkEvent::MessageUpdated(message( + 10, 1, "new edit", false, + ))); + app.handle_network(NetworkEvent::MessagesDeleted { + channel_id: None, + message_ids: vec![11], }); - app.handle_network(NetworkEvent::Auth(AuthPrompt::Phone)); - app.handle_action(KeyAction::Character('+')); - assert_eq!( - app.handle_action(KeyAction::NextAccount), - vec![TelegramCommand::SwitchAccount { account: 2 }] - ); - assert!(app.auth_input().is_empty()); - assert_eq!(app.screen, Screen::Connecting); + app.handle_network(NetworkEvent::ReplyPreviews { + chat_id: 1, + request_id: *request_id, + messages: vec![ + message(10, 1, "stale", false), + message(11, 1, "deleted", false), + ], + unavailable: Vec::new(), + complete: true, + }); + let first = app.messages[&1] + .iter() + .find(|message| message.id == 21) + .unwrap() + .reply_to + .as_ref() + .unwrap(); + assert_eq!(app.reply_message(first).unwrap().text, "new edit"); + let deleted = app.messages[&1] + .iter() + .find(|message| message.id == 23) + .unwrap() + .reply_to + .as_ref() + .unwrap(); + assert!(app.reply_message(deleted).is_none()); + assert!(app.reply_preview_status(deleted).contains("unavailable")); + assert_eq!(app.new_messages_while_scrolled, 0); + } - let mut full = App::with_ephemeral_settings(Settings { - active_account: MAX_ACCOUNTS, - account_count: MAX_ACCOUNTS, - ..Settings::default() + #[test] + fn reply_excerpt_enters_the_timeline_only_when_explicitly_opened() { + let mut app = ready_app(); + open_first(&mut app); + let mut source = message(21, 1, "reply", false); + source.reply_to = Some(ReplyInfo { + chat_id: 1, + message_id: 10, + sender: None, }); - full.screen = Screen::Fatal("network failed".to_owned()); - assert!(full.handle_action(KeyAction::AddAccount).is_empty()); - assert_eq!(full.active_account(), MAX_ACCOUNTS); - assert_eq!( - full.status_message.as_deref(), - Some("Account limit reached (8)") - ); + app.messages.insert(1, vec![source]); + app.set_message_hit_regions(vec![(0, 80, 1, (21, None))]); + let commands = app.request_visible_replies(); + let [TelegramCommand::LoadReplyPreviews { request_id, .. }] = commands.as_slice() else { + panic!("one batch expected") + }; + app.handle_network(NetworkEvent::ReplyPreviews { + chat_id: 1, + request_id: *request_id, + messages: vec![message(10, 1, "original", false)], + unavailable: Vec::new(), + complete: true, + }); + assert_eq!(app.active_messages().len(), 1); + app.selected_message = Some(21); + app.selected_action = 0; + assert!(app.handle_action(KeyAction::Enter).is_empty()); + assert_eq!(app.selected_message, Some(10)); + assert_eq!(app.viewport_anchor_message, Some(10)); + assert_eq!(app.active_messages().len(), 2); } #[test] - fn update_notification_is_sanitized_and_kept_separate_from_status() { - let mut app = App::new(); - app.status_message = Some("Message failed".to_owned()); - app.set_available_update(" 0.1.9\u{1b}[31m "); - assert_eq!(app.available_update(), Some("0.1.9")); - assert_eq!(app.status_message.as_deref(), Some("Message failed")); - app.clear_available_update(); - assert_eq!(app.available_update(), None); + fn reply_selection_jumps_to_a_cached_target() { + let mut app = ready_app(); + open_first(&mut app); + let replying = app + .messages + .get_mut(&1) + .unwrap() + .iter_mut() + .find(|message| message.id == 20) + .unwrap(); + replying.reply_to = Some(ReplyInfo { + message_id: 5, + chat_id: 1, + sender: Some("Them".to_owned()), + }); - app.settings.automatic_update_checks = false; - app.set_available_update("0.2.0"); - assert_eq!(app.available_update(), None); + assert!(app.run_binding("next_action", 1).is_empty()); + assert_eq!(app.selected_message, Some(20)); + assert!(app.handle_action(KeyAction::Character('r')).is_empty()); + assert_eq!(app.selected_message, Some(5)); + assert_eq!(app.viewport_anchor_message, Some(5)); + assert!(app.message_scroll > 0); } #[test] - fn temp_only_downloads_never_reveal_remote_files() { + fn reply_navigation_loads_an_uncached_target_and_ignores_stale_results() { let mut app = ready_app(); - app.settings.download_behavior = DownloadBehavior::TempOnly; open_first(&mut app); - let mut attachment = message(21, 1, "", false); - attachment.attachment = Some(Attachment { - kind: AttachmentKind::File, - file_name: Some("remote.command".to_owned()), - mime_type: None, - size: Some(1), - fallback_emoji: None, + let replying = app.messages.get_mut(&1).unwrap().last_mut().unwrap(); + replying.reply_to = Some(ReplyInfo { + message_id: 80, + chat_id: 1, + sender: None, }); - app.messages.get_mut(&1).unwrap().push(attachment); - let path = std::env::temp_dir().join(format!("termgram-temp-only-{}", std::process::id())); - fs::write(&path, b"x").expect("download fixture"); - app.handle_network(NetworkEvent::AttachmentDownloaded { + app.run_binding("next_action", 1); + assert_eq!( + app.handle_action(KeyAction::Character('r')), + vec![TelegramCommand::LoadMessage { + chat_id: 1, + source_message_id: 20, + message_id: 80, + request_id: 2, + }] + ); + app.handle_network(NetworkEvent::MessageLoadFailed { chat_id: 1, - message_id: 21, - path: path.clone(), + message_id: 80, + request_id: 999, + error: "stale".to_owned(), }); - app.selected_message = Some(21); - - assert!(app.handle_action(KeyAction::Enter).is_empty()); assert!( app.status_message .as_deref() - .is_some_and(|message| message.contains("reveal is disabled")) + .is_some_and(|status| status.contains("Loading reply")) + ); + + let mut target = message(80, 1, "reply target", false); + target.sender = "Target author".to_owned(); + app.handle_network(NetworkEvent::MessageLoaded { + chat_id: 1, + message_id: 80, + request_id: 2, + message: target, + }); + assert_eq!(app.selected_message, Some(80)); + assert_eq!(app.viewport_anchor_message, Some(80)); + assert!( + app.active_messages() + .iter() + .any(|message| message.id == 80 && message.text == "reply target") + ); + assert_eq!( + app.active_messages() + .iter() + .find(|message| message.id == 20) + .and_then(|message| message.reply_to.as_ref()) + .and_then(|reply| reply.sender.as_deref()), + Some("Target author") ); - fs::remove_file(path).expect("remove fixture"); } #[test] - fn q_is_text_in_an_editor_but_quits_in_navigation() { + fn cross_chat_reply_navigation_uses_the_returned_target_conversation() { let mut app = ready_app(); - app.handle_action(KeyAction::Character('/')); - app.handle_action(KeyAction::Character('q')); - assert_eq!(app.filter.value(), "q"); - app.handle_action(KeyAction::Escape); + open_first(&mut app); + let replying = app.messages.get_mut(&1).unwrap().last_mut().unwrap(); + replying.reply_to = Some(ReplyInfo { + message_id: 99, + chat_id: 2, + sender: Some("@beta".to_owned()), + }); + app.run_binding("next_action", 1); + let commands = app.handle_action(KeyAction::Character('r')); assert_eq!( - app.handle_action(KeyAction::Character('q')), - vec![TelegramCommand::Shutdown] + commands, + vec![TelegramCommand::LoadMessage { + chat_id: 1, + source_message_id: 20, + message_id: 99, + request_id: 2, + }] ); - } - #[test] - fn control_l_requests_a_complete_redraw() { - let mut app = ready_app(); - app.handle_action(KeyAction::Redraw); - assert!(app.take_force_redraw()); - assert!(!app.take_force_redraw()); + let commands = app.handle_network(NetworkEvent::MessageLoaded { + chat_id: 1, + message_id: 99, + request_id: 2, + message: message(99, 2, "from beta", false), + }); + + assert_eq!(app.active_chat_id, Some(2)); + assert_eq!(app.selected_message, Some(99)); + assert_eq!(app.viewport_anchor_message, Some(99)); + assert!( + commands + .iter() + .any(|command| matches!(command, TelegramCommand::LoadHistory { chat_id: 2, .. })) + ); } #[test] - fn fatal_strings_cannot_inject_terminal_sequences() { + fn cross_chat_reply_requests_distinguish_source_and_target_identity() { let mut app = ready_app(); - app.handle_network(NetworkEvent::Fatal("bad\u{1b}[2Jnews".to_owned())); - assert_eq!(app.screen, Screen::Fatal("badnews".to_owned())); + open_first(&mut app); + for (source_id, target_chat_id) in [(20, 2), (19, 3)] { + app.messages + .get_mut(&1) + .unwrap() + .iter_mut() + .find(|message| message.id == source_id) + .unwrap() + .reply_to = Some(ReplyInfo { + message_id: 99, + chat_id: target_chat_id, + sender: None, + }); + } + + app.selected_message = Some(20); assert_eq!( - app.handle_action(KeyAction::Character('q')), - vec![TelegramCommand::Shutdown] + app.handle_action(KeyAction::Character('r')), + vec![TelegramCommand::LoadMessage { + chat_id: 1, + source_message_id: 20, + message_id: 99, + request_id: 2, + }] ); - } + // Repeating exactly the same action is deduplicated. + assert!(app.handle_action(KeyAction::Character('r')).is_empty()); - #[test] - fn navigation_opens_chat_and_controls_narrow_view() { - let mut app = ready_app(); - app.handle_action(KeyAction::Down); + app.selected_message = Some(19); assert_eq!( - app.handle_action(KeyAction::Enter), - vec![ - TelegramCommand::LoadHistory { - chat_id: 2, - request_id: 1, - }, - TelegramCommand::MarkRead { chat_id: 2 } - ] + app.handle_action(KeyAction::Character('r')), + vec![TelegramCommand::LoadMessage { + chat_id: 1, + source_message_id: 19, + message_id: 99, + request_id: 3, + }] + ); + + // The first request has been superseded even though its target has the + // same numeric message ID. + assert!( + app.handle_network(NetworkEvent::MessageLoaded { + chat_id: 1, + message_id: 99, + request_id: 2, + message: message(99, 2, "from beta", false), + }) + .is_empty() + ); + assert_eq!(app.active_chat_id, Some(1)); + + let mut target = message(99, 3, "from gamma", false); + target.sender = "Gamma author".to_owned(); + app.handle_network(NetworkEvent::MessageLoaded { + chat_id: 1, + message_id: 99, + request_id: 3, + message: target, + }); + assert_eq!(app.active_chat_id, Some(3)); + let source_messages = app.messages.get(&1).unwrap(); + assert_eq!( + source_messages + .iter() + .find(|message| message.id == 19) + .and_then(|message| message.reply_to.as_ref()) + .and_then(|reply| reply.sender.as_deref()), + Some("Gamma author") + ); + assert_eq!( + source_messages + .iter() + .find(|message| message.id == 20) + .and_then(|message| message.reply_to.as_ref()) + .and_then(|reply| reply.sender.as_deref()), + None ); - assert_eq!(app.focus, Focus::Conversation); - assert!(app.narrow_conversation); - app.handle_action(KeyAction::Escape); - assert_eq!(app.focus, Focus::Chats); - assert!(!app.narrow_conversation); } #[test] - fn tab_keeps_narrow_pane_visibility_in_sync_with_focus() { + fn reply_result_is_rejected_when_source_target_changed_or_peer_is_wrong() { let mut app = ready_app(); open_first(&mut app); - assert_eq!(app.focus, Focus::Conversation); - assert!(app.narrow_conversation); + let source = app.messages.get_mut(&1).unwrap().last_mut().unwrap(); + source.reply_to = Some(ReplyInfo { + message_id: 99, + chat_id: 2, + sender: None, + }); + app.selected_message = Some(20); + app.handle_action(KeyAction::Character('r')); - app.message_scroll = 3; - app.handle_action(KeyAction::Tab); - assert_eq!(app.focus, Focus::Chats); - assert!(!app.narrow_conversation); - app.handle_network(NetworkEvent::NewMessage(message(21, 1, "new", false))); - assert_eq!(app.new_messages_to_anchor, 1); + // An edit that retargets the source makes the in-flight result stale. + app.messages + .get_mut(&1) + .unwrap() + .last_mut() + .unwrap() + .reply_to + .as_mut() + .unwrap() + .chat_id = 3; + assert!( + app.handle_network(NetworkEvent::MessageLoaded { + chat_id: 1, + message_id: 99, + request_id: 2, + message: message(99, 2, "stale beta", false), + }) + .is_empty() + ); + assert_eq!(app.active_chat_id, Some(1)); + assert!( + app.status_message + .as_deref() + .is_some_and(|status| status.contains("target changed")) + ); - app.handle_action(KeyAction::Tab); - assert_eq!(app.focus, Focus::Conversation); - assert!(app.narrow_conversation); - } + // A fresh request records peer 3 and must reject the same message ID + // returned from peer 2. + app.handle_action(KeyAction::Character('r')); + app.handle_network(NetworkEvent::MessageLoaded { + chat_id: 1, + message_id: 99, + request_id: 3, + message: message(99, 2, "wrong peer", false), + }); + assert_eq!(app.active_chat_id, Some(1)); + assert!( + app.status_message + .as_deref() + .is_some_and(|status| status.contains("wrong reply target")) + ); + assert!( + !app.messages + .get(&2) + .is_some_and(|messages| messages.iter().any(|message| message.id == 99)) + ); - #[test] - fn dropped_existing_path_sends_attachment_outside_compose_mode() { - let mut app = ready_app(); - open_first(&mut app); - app.mode = Mode::Navigate; - let path = std::env::temp_dir().join(format!( - "termgram-drag-{}-{}.txt", - std::process::id(), - Utc::now().timestamp_nanos_opt().unwrap_or_default() - )); - fs::write(&path, b"hello").expect("create dragged fixture"); - let commands = app.update(AppEvent::Paste(format!("'{}'", path.display()))); - assert!(matches!( - commands.as_slice(), - [TelegramCommand::SendAttachment { chat_id: 1, local_id: -1, path: sent, as_photo: false, .. }] - if sent == &fs::canonicalize(&path).expect("canonical fixture") - )); - assert!(app.active_messages().last().unwrap().attachment.is_some()); - fs::remove_file(path).expect("remove fixture"); + // Leaving the source conversation while a valid request is in flight + // must not let its eventual response hijack the active chat. + app.handle_action(KeyAction::Character('r')); + app.active_chat_id = Some(2); + app.selected_message = None; + app.handle_network(NetworkEvent::MessageLoaded { + chat_id: 1, + message_id: 99, + request_id: 4, + message: message(99, 3, "late target", false), + }); + assert_eq!(app.active_chat_id, Some(2)); + assert!( + !app.messages + .get(&3) + .is_some_and(|messages| messages.iter().any(|message| message.id == 99)) + ); } #[test] - fn dropped_file_uses_the_composer_as_a_caption_and_preserves_reply_context() { + fn failed_attachment_remains_selectable_and_retries_exact_path() { let mut app = ready_app(); open_first(&mut app); - app.handle_action(KeyAction::Character('R')); - for character in "caption".chars() { - app.handle_action(KeyAction::Character(character)); - } - let path = std::env::temp_dir().join(format!( - "termgram-caption-{}-{}.txt", - std::process::id(), - Utc::now().timestamp_nanos_opt().unwrap_or_default() - )); - fs::write(&path, b"hello").expect("create caption fixture"); - - let commands = app.update(AppEvent::Paste(path.display().to_string())); - assert!(matches!( - commands.as_slice(), - [TelegramCommand::SendAttachment { - chat_id: 1, - local_id: -1, - caption, - reply_to: Some(20), - .. - }] if caption == "caption" - )); - let pending = app.active_messages().last().expect("optimistic attachment"); - assert_eq!(pending.text, "caption"); + let path = std::env::temp_dir().join(format!("termgram-retry-{}.png", std::process::id())); + fs::write(&path, b"png").expect("create retry fixture"); + prepare_file(&mut app, &path); + let command = app.send_draft(1); + app.mode = Mode::Navigate; + let TelegramCommand::SendAttachment { + local_id, + attachment: sent_attachment, + .. + } = command.into_iter().next().expect("send command") + else { + panic!("expected attachment send"); + }; + app.handle_network(NetworkEvent::AttachmentSendFailed { + chat_id: 1, + local_id, + attachment: sent_attachment.clone(), + caption: String::new(), + reply_to: None, + error: "offline".to_owned(), + }); + assert_eq!(app.selected_message, Some(local_id)); assert_eq!( - pending.reply_to.as_ref().map(|reply| reply.message_id), - Some(20) + app.handle_action(KeyAction::Enter), + vec![TelegramCommand::SendAttachment { + chat_id: 1, + local_id, + attachment: sent_attachment, + caption: String::new(), + reply_to: None, + }] ); - assert!(app.active_draft().is_some_and(TextInput::is_empty)); - assert!(app.active_reply_target().is_none()); - fs::remove_file(path).expect("remove caption fixture"); - } - - #[test] - fn dropped_path_never_sends_from_chat_focus_filter_or_help() { - let mut app = ready_app(); - open_first(&mut app); - let path = - std::env::temp_dir().join(format!("termgram-no-send-{}.txt", std::process::id())); - fs::write(&path, b"private").expect("create fixture"); - let paste = AppEvent::Paste(path.display().to_string()); - - app.focus = Focus::Chats; - assert!(app.update(paste.clone()).is_empty()); - app.focus = Focus::Conversation; - app.mode = Mode::Filter; - assert!(app.update(paste.clone()).is_empty()); - app.mode = Mode::Help; - assert!(app.update(paste).is_empty()); - assert!(!app.active_messages().iter().any(|message| message.id < 0)); fs::remove_file(path).expect("remove fixture"); } #[test] - fn quoted_windows_paths_preserve_separator_backslashes() { + fn optimistic_attachments_reconcile_by_identity_not_empty_caption() { + let first = Message { + reactions: None, + poll: None, + entities: Vec::new(), + notification: None, + mention: None, + edited_at: None, + pinned: false, + id: -1, + chat_id: 1, + sender: "You".to_owned(), + reply_to: None, + text: String::new(), + timestamp: Utc::now(), + outgoing: true, + delivery: Delivery::Pending, + attachment: Some(Attachment { + source_id: None, + kind: AttachmentKind::File, + file_name: Some("first.txt".to_owned()), + mime_type: Some("text/plain".to_owned()), + size: Some(1), + fallback_emoji: None, + }), + links: Vec::new(), + buttons: Vec::new(), + }; + let second = Message { + reactions: None, + poll: None, + entities: Vec::new(), + notification: None, + mention: None, + edited_at: None, + pinned: false, + id: -2, + attachment: Some(Attachment { + source_id: None, + file_name: Some("second.txt".to_owned()), + ..first.attachment.clone().unwrap() + }), + ..first.clone() + }; + let server = Message { + reactions: None, + poll: None, + entities: Vec::new(), + notification: None, + mention: None, + edited_at: None, + pinned: false, + id: 42, + attachment: second.attachment.clone(), + delivery: Delivery::Sent, + ..second.clone() + }; + let mut pending = vec![first, second]; assert_eq!( - super::shellish_paths(r#""C:\Users\A B\image.jpg" "D:\two.txt""#), - Some(vec![ - r"C:\Users\A B\image.jpg".to_owned(), - r"D:\two.txt".to_owned(), - ]) + super::remove_matching_optimistic(&mut pending, &server), + Some(-2) ); + assert_eq!(pending[0].id, -1); } #[test] - fn telegram_links_are_resolved_but_ordinary_urls_are_not() { - assert_eq!( - super::telegram_link("see https://t.me/rustlang/42."), - Some("https://t.me/rustlang/42".to_owned()) - ); - assert_eq!( - super::telegram_link(""), - Some("tg://resolve?domain=rustlang&post=42".to_owned()) - ); + fn optimistic_photo_reconciles_when_telegram_renames_it() { + let mut local = message(-1, 1, "", true); + local.timestamp = Utc::now(); + local.delivery = Delivery::Pending; + local.attachment = Some(Attachment { + source_id: None, + kind: AttachmentKind::Photo, + file_name: Some("holiday.png".to_owned()), + mime_type: Some("image/png".to_owned()), + size: Some(10), + fallback_emoji: None, + }); + let mut server = local.clone(); + server.id = 42; + server.delivery = Delivery::Sent; + server.attachment.as_mut().unwrap().file_name = Some("photo.jpg".to_owned()); + let mut pending = vec![local]; + assert_eq!( - super::telegram_link("telegram.me/rustlang"), - Some("https://telegram.me/rustlang".to_owned()) + super::remove_matching_optimistic(&mut pending, &server), + Some(-1) ); - assert_eq!(super::telegram_link("https://example.com/file"), None); + assert!(pending.is_empty()); } #[test] - fn caption_link_has_explicit_activation_separate_from_media() { + fn attachment_success_clears_status_and_stale_selection() { let mut app = ready_app(); open_first(&mut app); - let mut photo = message(21, 1, "see https://t.me/rustlang/42", false); - photo.attachment = Some(Attachment { - kind: AttachmentKind::Photo, - file_name: Some("photo.jpg".to_owned()), - mime_type: Some("image/jpeg".to_owned()), + let mut local = message(-1, 1, "", true); + local.delivery = Delivery::Pending; + local.attachment = Some(Attachment { + source_id: None, + kind: AttachmentKind::File, + file_name: Some("notes.txt".to_owned()), + mime_type: Some("text/plain".to_owned()), size: Some(10), fallback_emoji: None, }); - app.handle_network(NetworkEvent::NewMessage(photo)); - app.selected_message = Some(21); + app.messages.get_mut(&1).unwrap().push(local); + app.selected_message = Some(-1); + app.status_message = Some("Sending attachment…".to_owned()); - assert_eq!( - app.handle_action(KeyAction::Character('l')), - vec![TelegramCommand::ResolveTelegramLink { - url: "https://t.me/rustlang/42".to_owned() - }] - ); + app.handle_network(NetworkEvent::MessageAccepted { + chat_id: 1, + local_id: -1, + }); + assert_eq!(app.selected_message, None); + assert_eq!(app.status_message, None); } #[test] - fn message_actions_cycle_across_links_and_inline_buttons() { + fn edited_attachment_invalidates_downloaded_state() { let mut app = ready_app(); open_first(&mut app); - let mut actionable = message(21, 1, "actions", false); - actionable.links = vec![MessageLink { - label: "Rust chat".to_owned(), - url: "https://t.me/rustlang/42".to_owned(), - }]; - actionable.buttons = vec![MessageButton { - label: "Confirm".to_owned(), - index: 3, - kind: MessageButtonKind::Callback, - }]; - app.handle_network(NetworkEvent::NewMessage(actionable)); - - app.handle_action(KeyAction::Character('o')); - assert_eq!(app.selected_message, Some(21)); - assert_eq!(app.selected_action, 0); - assert_eq!( - app.handle_action(KeyAction::Enter), - vec![TelegramCommand::ResolveTelegramLink { - url: "https://t.me/rustlang/42".to_owned(), - }] - ); + let mut original = message(21, 1, "", false); + original.attachment = Some(Attachment { + source_id: None, + kind: AttachmentKind::File, + file_name: Some("before.txt".to_owned()), + mime_type: Some("text/plain".to_owned()), + size: Some(1), + fallback_emoji: None, + }); + app.handle_network(NetworkEvent::NewMessage(original.clone())); + app.downloading_attachments.insert((1, 21), 1); + app.handle_network(NetworkEvent::AttachmentDownloaded { + request_id: 1, + chat_id: 1, + message_id: 21, + path: std::env::temp_dir().join("termgram-edited-fixture"), + }); + assert_eq!(app.attachment_state(1, 21), AttachmentState::Downloaded); - app.handle_action(KeyAction::Character('o')); - assert_eq!(app.selected_action, 1); - assert_eq!( - app.handle_action(KeyAction::Enter), - vec![TelegramCommand::ActivateButton { - chat_id: 1, - message_id: 21, - button_index: 3, - }] - ); + original.attachment.as_mut().unwrap().file_name = Some("after.txt".to_owned()); + app.handle_network(NetworkEvent::MessageUpdated(original)); + assert_eq!(app.attachment_state(1, 21), AttachmentState::Ready); } #[test] - fn linked_target_survives_history_and_dialog_refresh_with_anchor() { + fn inline_media_downloads_are_bounded_and_ignore_superseded_messages() { let mut app = ready_app(); open_first(&mut app); - let linked = message(5, 99, "linked target", false); - let linked_chat = Chat { - id: 99, - title: "Linked group".to_owned(), - kind: ChatKind::Group, - unread: 0, - last_message: "linked target".to_owned(), - last_activity: None, - }; - let commands = app.handle_network(NetworkEvent::LinkResolved { - chat: linked_chat, - message: Some(linked), + for id in 21..=23 { + let mut photo = message(id, 1, "caption", false); + photo.attachment = Some(Attachment { + source_id: None, + kind: AttachmentKind::Sticker, + file_name: Some("sticker.tgs".to_owned()), + mime_type: Some("application/x-tgsticker".to_owned()), + size: None, + fallback_emoji: Some("🙂".to_owned()), + }); + app.handle_network(NetworkEvent::NewMessage(photo)); + app.media_slots.push(crate::media::MediaSlot { + source: crate::media::MediaSource::Message { + chat_id: 1, + message_id: id, + }, + viewport: ratatui::layout::Rect::new(0, 0, 24, 30), + offset: 0, + size: ratatui::layout::Size::new(24, 6), + }); + } + let requests = app.request_visible_media(); + assert_eq!(requests.len(), 2); + assert!(requests.iter().all(|c| matches!( + c, + TelegramCommand::DownloadPreview { + thumbnail: true, + .. + } + ))); + assert!(app.request_visible_media().is_empty()); + app.handle_action(KeyAction::Character('i')); + assert_eq!(app.mode, Mode::Compose); + let mut edited = app.messages[&1] + .iter() + .find(|m| m.id == 21) + .unwrap() + .clone(); + edited.text = "new caption".to_owned(); + app.handle_network(NetworkEvent::MessageUpdated(edited)); + assert_eq!(app.request_visible_media().len(), 1); + app.handle_network(NetworkEvent::PreviewDownloaded { + chat_id: 1, + message_id: 21, + request_id: 1, + path: "stale.webp".into(), + }); + assert!(app.media_previews[&(1, 21)].path.is_none()); + app.handle_network(NetworkEvent::PreviewDownloaded { + chat_id: 1, + message_id: 21, + request_id: 3, + path: "current.webp".into(), + }); + assert!( + app.media_previews[&(1, 21)] + .status + .contains("static preview") + ); + assert!(!app.downloaded_attachments.contains_key(&(1, 21))); + app.handle_network(NetworkEvent::MessagesDeleted { + channel_id: None, + message_ids: vec![21, 22, 23], }); - let request_id = commands - .iter() - .find_map(|command| match command { - TelegramCommand::LoadHistory { request_id, .. } => Some(*request_id), - _ => None, - }) - .expect("linked history request"); - assert_eq!(app.viewport_anchor_message, Some(5)); - assert_eq!(app.selected_message, Some(5)); - app.handle_action(KeyAction::Character('i')); - assert_eq!(app.selected_message, None); - - app.handle_network(NetworkEvent::History { - chat_id: 99, - request_id, - messages: (100..180) - .map(|id| message(id, 99, &format!("recent {id}"), false)) - .collect(), + app.handle_network(NetworkEvent::PreviewDownloaded { + chat_id: 1, + message_id: 22, + request_id: 2, + path: "deleted.webp".into(), }); - assert!(app.active_messages().iter().any(|message| message.id == 5)); - assert_eq!(app.viewport_anchor_message, Some(5)); - - app.handle_network(NetworkEvent::Dialogs(vec![chat(1, "Alpha")])); - assert_eq!(app.active_chat_id, Some(99)); - assert!(app.chats.iter().any(|chat| chat.id == 99)); + assert!(app.media_previews.is_empty()); + assert!(app.request_visible_media().is_empty()); } #[test] - fn old_link_target_survives_a_full_cached_destination() { - let mut app = ready_app(); - open_first(&mut app); - app.selected_message = Some(20); - app.messages.insert( - 2, - (1000..1000 + i32::try_from(MAX_MESSAGES_PER_CHAT).expect("small cap")) - .map(|id| message(id, 2, "recent", false)) - .collect(), - ); - - app.handle_network(NetworkEvent::LinkResolved { - chat: chat(2, "Beta"), - message: Some(message(5, 2, "old exact target", false)), - }); - - assert_eq!(app.active_chat_id, Some(2)); - assert_eq!(app.selected_message, Some(5)); - assert!( - app.active_messages() - .iter() - .any(|message| message.id == 5 && message.text == "old exact target") - ); + fn compose_restores_last_chat_by_account_across_restart() { + use yazi_term::event::KeyCode; + + let directory = + std::env::temp_dir().join(format!("termgram-navigation-{}", std::process::id())); + let settings_path = directory.join("settings.conf"); + let mut first = ready_app(); + first.settings_path = Some(settings_path.clone()); + first.account_user_id = Some(101); + first.selected_chat = 1; + first.open_selected_chat(); + + let mut restarted = ready_app(); + restarted.settings_path = Some(settings_path.clone()); + restarted.account_user_id = Some(101); + restarted.load_navigation().unwrap(); + let commands = restarted.handle_key(&KeyEvent::new(KeyCode::Char('i'), Modifiers::empty())); + assert!(matches!( + commands.first(), + Some(TelegramCommand::LoadHistory { chat_id: 2, .. }) + )); + assert_eq!(restarted.mode, Mode::Compose); + assert_eq!(restarted.active_chat_id, Some(2)); + + let mut other = ready_app(); + other.settings_path = Some(settings_path); + other.account_user_id = Some(102); + other.load_navigation().unwrap(); + other.handle_key(&KeyEvent::new(KeyCode::Char('i'), Modifiers::empty())); + assert_eq!(other.active_chat_id, Some(1)); + assert!(other.active_reply_target().is_none()); + let stored: serde_json::Value = + serde_json::from_str(&fs::read_to_string(directory.join("navigation.json")).unwrap()) + .unwrap(); + assert_eq!(stored["accounts"]["101"], 2); + assert_eq!(stored["accounts"]["102"], 1); + fs::remove_dir_all(directory).unwrap(); } #[test] - fn selecting_actionable_message_sets_a_visible_semantic_anchor() { + fn media_click_selects_then_explicit_keys_preview_and_reply() { + use yazi_term::event::KeyCode; + let click = |column, row| MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column, + row, + modifiers: Modifiers::empty(), + }; let mut app = ready_app(); open_first(&mut app); - app.messages.insert( - 1, - (1..=30) - .map(|id| { - message( - id, - 1, - if id == 2 { - "https://t.me/rustlang/2" - } else { - "ordinary" - }, - false, - ) - }) - .collect(), + let mut media = message(21, 1, "image", false); + media.attachment = Some(Attachment { + source_id: None, + kind: AttachmentKind::Photo, + file_name: None, + mime_type: Some("image/jpeg".to_owned()), + size: None, + fallback_emoji: None, + }); + app.handle_network(NetworkEvent::NewMessage(media)); + app.start_composing(); + app.handle_action(KeyAction::Character('x')); + app.set_message_hit_regions(vec![(10, 70, 8, (21, Some(0)))]); + app.set_composer_region((0, 80, 20, 23)); + assert!(app.handle_mouse(click(20, 8)).is_empty()); + assert_eq!(app.mode, Mode::Navigate); + assert_eq!(app.selected_message, Some(21)); + let commands = app.handle_key(&KeyEvent::new(KeyCode::Char('o'), Modifiers::empty())); + assert_eq!(app.mode, Mode::Preview); + assert!(matches!( + commands.first(), + Some(TelegramCommand::DownloadPreview { message_id: 21, .. }) + )); + let mut deleted = app.clone(); + deleted.handle_network(NetworkEvent::MessagesDeleted { + channel_id: None, + message_ids: vec![21], + }); + deleted.handle_key(&KeyEvent::new(KeyCode::Char('i'), Modifiers::empty())); + assert_eq!(deleted.mode, Mode::Preview); + assert!(deleted.active_reply_target().is_none()); + app.handle_key(&KeyEvent::new(KeyCode::Escape, Modifiers::empty())); + app.handle_key(&KeyEvent::new(KeyCode::Char('i'), Modifiers::empty())); + assert_eq!( + app.active_reply_target().map(|reply| reply.message_id), + Some(21) ); - app.handle_action(KeyAction::Character('o')); - assert_eq!(app.selected_message, Some(2)); - assert_eq!(app.viewport_anchor_message, Some(2)); - assert!(app.message_scroll > 0); - - app.handle_action(KeyAction::PageDown); - assert_eq!(app.selected_message, None); - assert_eq!(app.viewport_anchor_message, None); + app.handle_mouse(click(10, 21)); + assert_eq!(app.mode, Mode::Compose); + assert_eq!( + app.active_reply_target().map(|reply| reply.message_id), + Some(21) + ); + assert_eq!(app.active_draft().unwrap().value(), "x"); + app.set_chat_hit_regions(vec![(0, 30, 4, 1)]); + app.handle_mouse(click(5, 4)); + assert_eq!(app.mode, Mode::Navigate); + assert_eq!(app.active_chat_id, Some(2)); + assert_eq!(app.draft_for(1).unwrap().value(), "x"); } #[test] - fn reply_selection_jumps_to_a_cached_target() { + fn telemetry_rejects_observations_from_before_disconnect_or_account_switch() { + use crate::event::ConnectionStatus; + use crate::statusline::Latency; + use std::time::{Duration, Instant}; let mut app = ready_app(); - open_first(&mut app); - let replying = app - .messages - .get_mut(&1) - .unwrap() - .iter_mut() - .find(|message| message.id == 20) - .unwrap(); - replying.reply_to = Some(ReplyInfo { - message_id: 5, - chat_id: 1, - sender: Some("Them".to_owned()), + let sample = Latency { + started: Instant::now(), + elapsed: Duration::from_millis(42), + }; + app.handle_network(NetworkEvent::Telemetry { + dc_id: Some(2), + latency: Some(sample), }); - - assert!(app.handle_action(KeyAction::Character('o')).is_empty()); - assert_eq!(app.selected_message, Some(20)); - assert!(app.handle_action(KeyAction::Character('r')).is_empty()); - assert_eq!(app.selected_message, Some(5)); - assert_eq!(app.viewport_anchor_message, Some(5)); - assert!(app.message_scroll > 0); + assert_eq!(app.metrics.latency, Some(sample)); + app.handle_network(NetworkEvent::Status(ConnectionStatus::Reconnecting)); + assert!(app.metrics.latency.is_none()); + app.handle_network(NetworkEvent::Status(ConnectionStatus::Online)); + app.handle_network(NetworkEvent::Telemetry { + dc_id: Some(2), + latency: Some(sample), + }); + assert!(app.metrics.latency.is_none()); + let fresh = Latency { + started: Instant::now(), + ..sample + }; + app.handle_network(NetworkEvent::Telemetry { + dc_id: Some(4), + latency: Some(fresh), + }); + assert_eq!(app.metrics.latency, Some(fresh)); + app.reset_for_account_switch(2); + assert!(app.metrics.dc_id.is_none()); + assert!(app.metrics.latency.is_none()); + app.connection = ConnectionStatus::Online; + app.handle_network(NetworkEvent::Telemetry { + dc_id: Some(4), + latency: Some(fresh), + }); + assert!(app.metrics.latency.is_none()); } #[test] - fn reply_navigation_loads_an_uncached_target_and_ignores_stale_results() { + fn right_clicking_a_message_starts_a_reply_without_activating_its_actions() { let mut app = ready_app(); open_first(&mut app); - let replying = app.messages.get_mut(&1).unwrap().last_mut().unwrap(); - replying.reply_to = Some(ReplyInfo { - message_id: 80, - chat_id: 1, - sender: None, - }); - app.handle_action(KeyAction::Character('o')); - assert_eq!( - app.handle_action(KeyAction::Character('r')), - vec![TelegramCommand::LoadMessage { - chat_id: 1, - source_message_id: 20, - message_id: 80, - request_id: 2, - }] - ); - app.handle_network(NetworkEvent::MessageLoadFailed { - chat_id: 1, - message_id: 80, - request_id: 999, - error: "stale".to_owned(), - }); - assert!( - app.status_message - .as_deref() - .is_some_and(|status| status.contains("Loading reply")) - ); + app.set_message_hit_regions(vec![(10, 70, 8, (20, None))]); - let mut target = message(80, 1, "reply target", false); - target.sender = "Target author".to_owned(); - app.handle_network(NetworkEvent::MessageLoaded { - chat_id: 1, - message_id: 80, - request_id: 2, - message: target, - }); - assert_eq!(app.selected_message, Some(80)); - assert_eq!(app.viewport_anchor_message, Some(80)); assert!( - app.active_messages() - .iter() - .any(|message| message.id == 80 && message.text == "reply target") + app.update(AppEvent::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Right), + column: 20, + row: 8, + modifiers: Modifiers::empty(), + })) + .is_empty() ); + assert_eq!(app.mode, Mode::Compose); + assert_eq!(app.selected_message, Some(20)); assert_eq!( - app.active_messages() - .iter() - .find(|message| message.id == 20) - .and_then(|message| message.reply_to.as_ref()) - .and_then(|reply| reply.sender.as_deref()), - Some("Target author") + app.active_reply_target().map(|reply| reply.message_id), + Some(20) ); } #[test] - fn cross_chat_reply_navigation_uses_the_returned_target_conversation() { + fn mouse_clicks_open_chats_and_activate_overlay_rows() { + let click = |column, row| { + AppEvent::Mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column, + row, + modifiers: Modifiers::empty(), + }) + }; + let mut app = ready_app(); - open_first(&mut app); - let replying = app.messages.get_mut(&1).unwrap().last_mut().unwrap(); - replying.reply_to = Some(ReplyInfo { - message_id: 99, - chat_id: 2, - sender: Some("@beta".to_owned()), - }); - app.handle_action(KeyAction::Character('o')); - let commands = app.handle_action(KeyAction::Character('r')); + app.set_chat_hit_regions(vec![(1, 30, 4, 1)]); + let commands = app.update(click(5, 4)); + assert_eq!(app.active_chat_id, Some(2)); + assert!(matches!( + commands.first(), + Some(TelegramCommand::LoadHistory { chat_id: 2, .. }) + )); + + app.mode = Mode::Settings; + app.set_settings_hit_regions(vec![(20, 70, 8, 0)]); + let before = app.settings.automatic_update_checks; + assert!(app.update(click(25, 8)).is_empty()); + assert_eq!(app.settings.automatic_update_checks, !before); + + app.mode = Mode::Accounts; + app.settings.active_account = 1; + app.settings.account_count = 2; + app.set_account_hit_regions(vec![(20, 70, 10, 2)]); assert_eq!( - commands, - vec![TelegramCommand::LoadMessage { - chat_id: 1, - source_message_id: 20, - message_id: 99, - request_id: 2, - }] + app.update(click(25, 10)), + vec![TelegramCommand::SwitchAccount { account: 3 }] ); + assert_eq!(app.active_account(), 3); + } - let commands = app.handle_network(NetworkEvent::MessageLoaded { - chat_id: 1, - message_id: 99, - request_id: 2, - message: message(99, 2, "from beta", false), + #[test] + fn mouse_wheel_routes_by_pane_instead_of_keyboard_focus() { + let mut app = ready_app(); + app.focus = Focus::Conversation; + app.selected_chat = 2; + app.set_chat_pane_region((0, 30, 1, 20)); + app.set_conversation_pane_region((30, 100, 1, 20)); + + app.handle_mouse(MouseEvent { + kind: MouseEventKind::ScrollUp, + column: 10, + row: 5, + modifiers: Modifiers::empty(), }); + assert_eq!(app.focus, Focus::Chats); + assert_eq!(app.selected_chat, 0); - assert_eq!(app.active_chat_id, Some(2)); - assert_eq!(app.selected_message, Some(99)); - assert_eq!(app.viewport_anchor_message, Some(99)); - assert!( - commands + app.handle_mouse(MouseEvent { + kind: MouseEventKind::ScrollUp, + column: 50, + row: 5, + modifiers: Modifiers::empty(), + }); + assert_eq!(app.focus, Focus::Conversation); + } + + use crate::input::TextInput; + #[test] + fn folders_preserve_pins_membership_drafts_and_alias_destinations() { + use crate::folders::Folder; + let mut app = App { + screen: Screen::Main, + chats: vec![chat(1, "One"), chat(2, "Two"), chat(3, "Three")], + ..App::default() + }; + app.folders.push(Folder { + id: 9, + title: "Work".to_owned(), + pinned: vec![2], + include: vec![1], + ..Folder::default() + }); + app.active_chat_id = Some(3); + app.draft_data_mut(3).input = TextInput::from_value("draft"); + app.run_binding("folder_next", 1); + assert_eq!( + app.visible_chats() .iter() - .any(|command| matches!(command, TelegramCommand::LoadHistory { chat_id: 2, .. })) + .map(|chat| chat.id) + .collect::>(), + vec![2, 1] ); + assert_eq!(app.active_chat_id, Some(3)); + assert_eq!(app.draft_for(3).unwrap().value(), "draft"); + app.keymap.chats.insert("three".to_owned(), 3); + app.run_binding("jump three", 1); + assert_eq!(app.folder_id, 0); + assert_eq!(app.selected_chat_entry().unwrap().id, 3); + app.handle_network(NetworkEvent::Folders(vec![Folder::all()])); + assert_eq!(app.folder_id, 0); } #[test] - fn cross_chat_reply_requests_distinguish_source_and_target_identity() { + fn server_pins_preserve_selection_and_archive_has_its_own_order_and_aliases() { + use crate::{ + folders::Folder, + pins::{DialogAction, DialogPins, DialogScope}, + }; let mut app = ready_app(); - open_first(&mut app); - for (source_id, target_chat_id) in [(20, 2), (19, 3)] { - app.messages - .get_mut(&1) - .unwrap() - .iter_mut() - .find(|message| message.id == source_id) - .unwrap() - .reply_to = Some(ReplyInfo { - message_id: 99, - chat_id: target_chat_id, - sender: None, - }); - } - - app.selected_message = Some(20); + app.chats = vec![chat(1, "One"), chat(2, "Two"), chat(3, "Archived")]; + app.chats[2].membership.archived = true; + app.folders.push(Folder::archive()); + app.selected_chat = 0; + app.focus = Focus::Chats; + app.handle_network(NetworkEvent::DialogPins(DialogPins { + main: vec![2, 1], + archive: vec![3], + })); assert_eq!( - app.handle_action(KeyAction::Character('r')), - vec![TelegramCommand::LoadMessage { + app.visible_chats() + .iter() + .map(|chat| chat.id) + .collect::>(), + vec![2, 1] + ); + assert_eq!(app.selected_chat_entry().unwrap().id, 1); + let commands = app.run_binding("pin_up", 1); + assert!(matches!( + commands.as_slice(), + [TelegramCommand::ChangeDialogPin { chat_id: 1, - source_message_id: 20, - message_id: 99, - request_id: 2, + scope: DialogScope::Main, + action: DialogAction::MoveUp, + .. }] - ); - // Repeating exactly the same action is deduplicated. - assert!(app.handle_action(KeyAction::Character('r')).is_empty()); + )); + assert!(app.run_binding("pin_up", 1).is_empty()); + app.handle_network(NetworkEvent::DialogPinFinished { + request_id: 1, + error: Some("PINNED_DIALOGS_TOO_MUCH".into()), + }); + assert_eq!(app.pins.dialogs.main, vec![2, 1]); + app.keymap.chats.insert("archived".into(), 3); + app.run_binding("jump archived", 1); + assert_eq!(app.folder_id, 1); + assert_eq!(app.active_chat_id, Some(3)); + assert_eq!(app.selected_chat_entry().unwrap().id, 3); + app.handle_network(NetworkEvent::ArchiveChanged { + chat_id: 3, + archived: false, + }); + assert!(app.visible_chats().is_empty()); + app.folder_id = 0; + assert_eq!(app.visible_chats().len(), 3); + } - app.selected_message = Some(19); + #[test] + fn message_pin_confirmation_defaults_and_failures_keep_the_selected_target() { + use crate::pins::MessageAction as PinAction; + let mut app = ready_app(); + app.active_chat_id = Some(1); + app.chats[0].kind = crate::model::ChatKind::Direct; + app.account_user_id = Some(99); + app.focus = Focus::Conversation; + app.messages + .insert(1, vec![message(10, 1, "keep this", false)]); + app.selected_message = Some(10); + assert!(app.run_binding("pin", 1).is_empty()); + assert_eq!(app.mode, Mode::PinPrompt); + let commands = app.run_binding("open", 1); + let [ + TelegramCommand::ChangeMessagePin { + request_id, action, .. + }, + ] = commands.as_slice() + else { + panic!("pin mutation"); + }; assert_eq!( - app.handle_action(KeyAction::Character('r')), - vec![TelegramCommand::LoadMessage { - chat_id: 1, - source_message_id: 19, - message_id: 99, - request_id: 3, - }] + *action, + PinAction::Pin { + notify: false, + only_self: true + } + ); + assert!(app.run_binding("open", 1).is_empty()); + app.handle_network(NetworkEvent::MessagePinFinished { + chat_id: 1, + request_id: *request_id, + error: Some("CHAT_ADMIN_REQUIRED".into()), + }); + assert_eq!(app.mode, Mode::PinPrompt); + assert!(!app.active_messages()[0].pinned); + app.run_binding("cancel", 1); + app.chats[0].kind = crate::model::ChatKind::Group; + app.run_binding("pin", 1); + assert_eq!( + app.message_pins.prompt.as_ref().unwrap().options[0].1, + PinAction::Pin { + notify: true, + only_self: false + } ); + } - // The first request has been superseded even though its target has the - // same numeric message ID. + #[test] + fn pinned_navigation_preserves_drafts_and_does_not_resurrect_deleted_targets() { + let mut app = ready_app(); + app.active_chat_id = Some(1); + app.focus = Focus::Conversation; + app.draft_data_mut(1).input = TextInput::from_value("unsent"); + let mut pin = message(20, 1, "target", false); + pin.pinned = true; + let commands = app.run_binding("pins", 1); + let [TelegramCommand::LoadPinnedMessages { request_id, .. }] = commands.as_slice() else { + panic!("pin list"); + }; + app.handle_network(NetworkEvent::PinnedMessages { + chat_id: 1, + request_id: *request_id, + page: crate::pins::MessagePage { + messages: vec![pin.clone()], + total: Some(1), + ..Default::default() + }, + }); + let commands = app.run_binding("open", 1); + let [TelegramCommand::LoadPinnedContext { request_id, .. }] = commands.as_slice() else { + panic!("pin context"); + }; + app.handle_network(NetworkEvent::MessagesDeleted { + channel_id: None, + message_ids: vec![20], + }); assert!( - app.handle_network(NetworkEvent::MessageLoaded { + app.handle_network(NetworkEvent::PinnedContext { chat_id: 1, - message_id: 99, - request_id: 2, - message: message(99, 2, "from beta", false), + message_id: 20, + request_id: *request_id, + messages: vec![pin] }) .is_empty() ); - assert_eq!(app.active_chat_id, Some(1)); - - let mut target = message(99, 3, "from gamma", false); - target.sender = "Gamma author".to_owned(); - app.handle_network(NetworkEvent::MessageLoaded { + assert_eq!(app.mode, Mode::PinnedMessages); + assert!(app.message_pins.error.as_ref().unwrap().contains("deleted")); + assert!(app.active_messages().iter().all(|message| message.id != 20)); + assert_eq!(app.active_draft().unwrap().value(), "unsent"); + let commands = app.run_binding("refresh", 1); + let [TelegramCommand::LoadPinnedMessages { request_id, .. }] = commands.as_slice() else { + panic!("refresh pins"); + }; + let mut pin = message(21, 1, "another", false); + pin.pinned = true; + app.handle_network(NetworkEvent::PinnedMessages { chat_id: 1, - message_id: 99, - request_id: 3, - message: target, + request_id: *request_id, + page: crate::pins::MessagePage { + messages: vec![pin.clone()], + total: Some(1), + ..Default::default() + }, }); - assert_eq!(app.active_chat_id, Some(3)); - let source_messages = app.messages.get(&1).unwrap(); - assert_eq!( - source_messages - .iter() - .find(|message| message.id == 19) - .and_then(|message| message.reply_to.as_ref()) - .and_then(|reply| reply.sender.as_deref()), - Some("Gamma author") + let commands = app.run_binding("open", 1); + let [TelegramCommand::LoadPinnedContext { request_id, .. }] = commands.as_slice() else { + panic!("pin context"); + }; + assert!( + app.handle_network(NetworkEvent::PinnedContext { + chat_id: 1, + message_id: 21, + request_id: *request_id, + messages: vec![pin] + }) + .is_empty() ); - assert_eq!( - source_messages + assert_eq!(app.selected_message, Some(21)); + assert!(app.message_scroll > 0); + assert_eq!(app.active_draft().unwrap().value(), "unsent"); + } + #[test] + fn pin_cache_invalidation_reloads_open_lists_and_rejects_old_context() { + for scope in [Some(1), None] { + let mut app = ready_app(); + app.active_chat_id = Some(1); + app.focus = Focus::Conversation; + app.draft_data_mut(1).input = TextInput::from_value("unsent"); + let mut pin = message(20, 1, "obsolete", false); + pin.pinned = true; + let commands = app.run_binding("pins", 1); + let [TelegramCommand::LoadPinnedMessages { request_id, .. }] = commands.as_slice() + else { + panic!("pin list"); + }; + let old_page = crate::pins::MessagePage { + messages: vec![pin.clone()], + total: Some(1), + ..Default::default() + }; + app.handle_network(NetworkEvent::PinnedMessages { + chat_id: 1, + request_id: *request_id, + page: old_page.clone(), + }); + app.handle_network(NetworkEvent::CacheInvalidated { chat_id: Some(2) }); + assert_eq!(app.message_pins.page.messages[0].id, 20); + let commands = app.run_binding("open", 1); + let [ + TelegramCommand::LoadPinnedContext { + request_id: old_request, + .. + }, + ] = commands.as_slice() + else { + panic!("pin context"); + }; + let commands = app.handle_network(NetworkEvent::CacheInvalidated { chat_id: scope }); + let request_id = commands .iter() - .find(|message| message.id == 20) - .and_then(|message| message.reply_to.as_ref()) - .and_then(|reply| reply.sender.as_deref()), - None - ); + .find_map(|command| match command { + TelegramCommand::LoadPinnedMessages { + request_id, + before: 0, + .. + } => Some(*request_id), + _ => None, + }) + .expect("reload the open pin list"); + assert_ne!(request_id, *old_request); + assert!(app.message_pins.page.messages.is_empty()); + assert!(app.pinned_summary().is_none()); + app.handle_network(NetworkEvent::PinnedContext { + chat_id: 1, + message_id: 20, + request_id: *old_request, + messages: vec![pin], + }); + app.handle_network(NetworkEvent::PinnedMessages { + chat_id: 1, + request_id: *old_request, + page: old_page, + }); + assert_eq!(app.mode, Mode::PinnedMessages); + assert!(app.active_messages().is_empty()); + assert!(app.message_pins.page.messages.is_empty()); + app.handle_network(NetworkEvent::PinnedMessages { + chat_id: 1, + request_id, + page: crate::pins::MessagePage { + total: Some(0), + ..Default::default() + }, + }); + assert!(!app.message_pins.loading); + assert_eq!(app.active_draft().unwrap().value(), "unsent"); + } } #[test] - fn reply_result_is_rejected_when_source_target_changed_or_peer_is_wrong() { + #[allow(clippy::too_many_lines)] + fn cloud_search_pages_keep_target_sender_and_drafts_without_reading_history() { let mut app = ready_app(); - open_first(&mut app); - let source = app.messages.get_mut(&1).unwrap().last_mut().unwrap(); - source.reply_to = Some(ReplyInfo { - message_id: 99, - chat_id: 2, - sender: None, + app.connection = crate::event::ConnectionStatus::Online; + app.active_chat_id = Some(1); + app.draft_data_mut(1).input = crate::input::TextInput::from_value("unsent"); + app.run_binding("command", 1); + app.commands + .input + .set_value("search --cloud --media invalid text"); + assert!(app.run_binding("open", 1).is_empty()); + assert_eq!(app.mode, Mode::Command); + app.commands + .input + .set_value("search --cloud --from @ada --media file release"); + let commands = app.run_binding("open", 1); + let [TelegramCommand::SearchCloud(request)] = commands.as_slice() else { + panic!("cloud request") + }; + assert_eq!( + (request.chat_id, request.query.as_str(), request.before_id), + (1, "release", 0) + ); + let first_id = request.id; + let page = crate::cloud_search::Page { + messages: vec![ + message(30, 1, "release 界🙂", false), + message(20, 1, "old release", false), + ], + next: Some(20), + total: 3, + sender: Some(99), + }; + app.handle_network(NetworkEvent::CloudSearchResults { + request_id: first_id, + chat_id: 1, + page: page.clone(), }); - app.selected_message = Some(20); - app.handle_action(KeyAction::Character('r')); - - // An edit that retargets the source makes the in-flight result stale. - app.messages - .get_mut(&1) - .unwrap() - .last_mut() - .unwrap() - .reply_to - .as_mut() - .unwrap() - .chat_id = 3; - assert!( - app.handle_network(NetworkEvent::MessageLoaded { - chat_id: 1, - message_id: 99, - request_id: 2, - message: message(99, 2, "stale beta", false), - }) - .is_empty() + for (width, height) in [(40, 12), (110, 24)] { + let mut terminal = + ratatui::Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap(); + terminal + .draw(|frame| crate::ui::render(frame, &mut app)) + .unwrap(); + let text = terminal + .backend() + .buffer() + .content + .iter() + .map(ratatui::buffer::Cell::symbol) + .collect::(); + assert!(text.contains("Telegram search")); + if width == 110 { + assert!(text.contains("from @ada · file")); + } + assert!(app.request_visible_read().is_empty()); + assert!( + app.handle_mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 1, + row: 3, + modifiers: Modifiers::empty() + }) + .is_empty() + ); + assert_eq!(app.mode, Mode::Search); + } + app.handle_network(NetworkEvent::MessageUpdated(message( + 30, + 1, + "revised release", + false, + ))); + assert_eq!( + app.search.page.as_ref().unwrap().messages()[0].text, + "revised release" ); - assert_eq!(app.active_chat_id, Some(1)); + app.handle_network(NetworkEvent::MessagesDeleted { + channel_id: None, + message_ids: vec![20], + }); + assert_eq!(app.search.page.as_ref().unwrap().messages().len(), 1); + app.active_chat_id = Some(2); + let commands = app.run_binding("search_more", 1); + let [TelegramCommand::SearchCloud(request)] = commands.as_slice() else { + panic!("next page") + }; + assert_eq!((request.chat_id, request.before_id), (1, 20)); + assert_eq!( + request.filters.sender, + Some(crate::cloud_search::Sender::Id(99)) + ); + app.handle_network(NetworkEvent::CloudSearchResults { + request_id: first_id, + chat_id: 1, + page: page.clone(), + }); assert!( - app.status_message - .as_deref() - .is_some_and(|status| status.contains("target changed")) + app.search.page.is_none(), + "a stale page must not replace an active query" ); - - // A fresh request records peer 3 and must reject the same message ID - // returned from peer 2. - app.handle_action(KeyAction::Character('r')); - app.handle_network(NetworkEvent::MessageLoaded { + app.handle_network(NetworkEvent::CloudSearchResults { + request_id: request.id, chat_id: 1, - message_id: 99, - request_id: 3, - message: message(99, 2, "wrong peer", false), + page: crate::cloud_search::Page { + messages: vec![message(10, 1, "earliest release", false)], + next: None, + total: 2, + sender: Some(99), + }, }); - assert_eq!(app.active_chat_id, Some(1)); + assert!(app.run_binding("search_more", 1).is_empty()); + let commands = app.run_binding("search_previous", 1); + let [TelegramCommand::SearchCloud(request)] = commands.as_slice() else { + panic!("previous page") + }; + assert_eq!(request.before_id, 0); + let current_id = request.id; + app.handle_network(NetworkEvent::CloudSearchResults { + request_id: current_id, + chat_id: 1, + page: page.clone(), + }); + let commands = app.run_binding("open", 1); + let [ + TelegramCommand::LoadCloudContext { + request_id, + chat_id: 1, + message_id: 30, + }, + ] = commands.as_slice() + else { + panic!("context") + }; assert!( - app.status_message - .as_deref() - .is_some_and(|status| status.contains("wrong reply target")) + app.run_binding("open", 1).is_empty(), + "do not queue duplicate context reads" ); + app.handle_network(NetworkEvent::CloudSearchContext { + request_id: *request_id, + chat_id: 2, + message_id: 30, + messages: vec![message(30, 2, "wrong peer", false)], + }); + assert_eq!(app.mode, Mode::Search); assert!( - !app.messages - .get(&2) - .is_some_and(|messages| messages.iter().any(|message| message.id == 99)) + app.handle_network(NetworkEvent::CloudSearchContext { + request_id: *request_id, + chat_id: 1, + message_id: 30, + messages: vec![ + message(29, 1, "context", false), + message(30, 1, "release", false) + ] + }) + .is_empty() ); - - // Leaving the source conversation while a valid request is in flight - // must not let its eventual response hijack the active chat. - app.handle_action(KeyAction::Character('r')); - app.active_chat_id = Some(2); - app.selected_message = None; - app.handle_network(NetworkEvent::MessageLoaded { + assert_eq!( + (app.active_chat_id, app.selected_message), + (Some(1), Some(30)) + ); + assert!(app.browsing_older); + assert_eq!(app.draft_for(1).unwrap().value(), "unsent"); + app.run_binding("search", 1); + assert!(app.search.is_cloud()); + app.handle_network(NetworkEvent::CacheInvalidated { chat_id: Some(1) }); + app.handle_network(NetworkEvent::CloudSearchResults { + request_id: current_id, chat_id: 1, - message_id: 99, - request_id: 4, - message: message(99, 3, "late target", false), + page, }); - assert_eq!(app.active_chat_id, Some(2)); + assert!(app.search.page.is_none()); + assert!(!app.search.loading); + app.run_binding("cancel", 1); + app.run_binding("command", 1); + app.commands.input.set_value(r"search \b中文\s+ "); + let commands = app.run_binding("open", 1); assert!( - !app.messages - .get(&3) - .is_some_and(|messages| messages.iter().any(|message| message.id == 99)) + matches!(commands.as_slice(), [TelegramCommand::SearchCached(request)] if request.pattern == "\\b中文\\s+ ") ); } #[test] - fn failed_attachment_remains_selectable_and_retries_exact_path() { + fn cloud_search_cancel_offline_and_empty_filter_remain_editable() { let mut app = ready_app(); - open_first(&mut app); - let path = std::env::temp_dir().join(format!("termgram-retry-{}.png", std::process::id())); - fs::write(&path, b"png").expect("create retry fixture"); - let command = app.update(AppEvent::Paste(path.display().to_string())); - let TelegramCommand::SendAttachment { - local_id, - path: sent_path, - .. - } = command.into_iter().next().expect("send command") - else { - panic!("expected attachment send"); + app.connection = crate::event::ConnectionStatus::Offline; + app.active_chat_id = Some(1); + let filters = crate::cloud_search::Filters { + media: crate::cloud_search::Media::Photo, + ..Default::default() }; - app.handle_network(NetworkEvent::AttachmentSendFailed { + app.start_cloud_search(1, String::new(), filters); + assert!(app.search.editing); + assert!(matches!( + app.run_binding("open", 1).as_slice(), + [TelegramCommand::CancelSearch] + )); + assert!(app.search.error.as_ref().unwrap().contains("Connect")); + app.connection = crate::event::ConnectionStatus::Online; + let commands = app.run_binding("open", 1); + let [TelegramCommand::SearchCloud(request)] = commands.as_slice() else { + panic!("filtered empty query") + }; + assert!(request.query.is_empty()); + assert_eq!(request.filters.media, crate::cloud_search::Media::Photo); + app.run_binding("cancel", 1); + app.handle_network(NetworkEvent::CloudSearchResults { + request_id: request.id, chat_id: 1, - local_id, - path: sent_path.clone(), - caption: String::new(), - as_photo: true, - reply_to: None, - error: "offline".to_owned(), + page: crate::cloud_search::Page { + messages: vec![], + next: None, + total: 0, + sender: None, + }, }); - assert_eq!(app.selected_message, Some(local_id)); - assert_eq!( - app.handle_action(KeyAction::Enter), - vec![TelegramCommand::SendAttachment { - chat_id: 1, - local_id, - path: sent_path, - caption: String::new(), - as_photo: true, - reply_to: None, - }] - ); - fs::remove_file(path).expect("remove fixture"); + assert!(app.search.page.is_none()); + app.run_binding("search", 1); + assert!(app.search.editing); + assert!(!app.search.loading); } #[test] - fn optimistic_attachments_reconcile_by_identity_not_empty_caption() { - let first = Message { - id: -1, - chat_id: 1, - sender: "You".to_owned(), - reply_to: None, - text: String::new(), - timestamp: Utc::now(), - outgoing: true, - delivery: Delivery::Pending, - attachment: Some(Attachment { - kind: AttachmentKind::File, - file_name: Some("first.txt".to_owned()), - mime_type: Some("text/plain".to_owned()), - size: Some(1), - fallback_emoji: None, - }), - links: Vec::new(), - buttons: Vec::new(), + fn local_search_ignores_stale_results_and_preserves_drafts_and_query() { + let mut app = ready_app(); + app.active_chat_id = Some(1); + app.draft_data_mut(1).input = TextInput::from_value("unsent"); + app.run_binding("search", 1); + app.update(AppEvent::Paste("error.*".to_owned())); + let commands = app.run_binding("open", 1); + let TelegramCommand::SearchCached(request) = &commands[0] else { + panic!("search request"); }; - let second = Message { - id: -2, - attachment: Some(Attachment { - file_name: Some("second.txt".to_owned()), - ..first.attachment.clone().unwrap() - }), - ..first.clone() + let page = crate::search::Page { + messages: vec![message(21, 1, "error!", false)], + next: None, + cached_messages: 30, + oldest: Some(1), + newest: Some(30), }; - let server = Message { - id: 42, - attachment: second.attachment.clone(), - delivery: Delivery::Sent, - ..second.clone() + app.handle_network(NetworkEvent::SearchResults { + request_id: request.id.wrapping_sub(1), + page: page.clone(), + }); + assert!(app.search.page.is_none()); + app.handle_network(NetworkEvent::SearchResults { + request_id: request.id, + page, + }); + let commands = app.run_binding("open", 1); + let TelegramCommand::LoadCachedContext { request_id, .. } = commands[0] else { + panic!("cached context request"); }; - let mut pending = vec![first, second]; - assert_eq!( - super::remove_matching_optimistic(&mut pending, &server), - Some(-2) + let commands = app.handle_network(NetworkEvent::CachedContext { + request_id, + chat_id: 1, + message_id: 21, + messages: vec![ + message(20, 1, "before", false), + message(21, 1, "error!", false), + message(22, 1, "after", false), + ], + }); + assert!( + commands.is_empty(), + "opening search context must not mark unseen history read" ); - assert_eq!(pending[0].id, -1); + assert_eq!(app.selected_message, Some(21)); + app.run_binding("search", 1); + assert_eq!(app.search.query.value(), "error.*"); + assert_eq!(app.draft_for(1).unwrap().value(), "unsent"); } - #[test] - fn optimistic_photo_reconciles_when_telegram_renames_it() { - let mut local = message(-1, 1, "", true); - local.timestamp = Utc::now(); - local.delivery = Delivery::Pending; - local.attachment = Some(Attachment { - kind: AttachmentKind::Photo, - file_name: Some("holiday.png".to_owned()), - mime_type: Some("image/png".to_owned()), - size: Some(10), - fallback_emoji: None, - }); - let mut server = local.clone(); - server.id = 42; - server.delivery = Delivery::Sent; - server.attachment.as_mut().unwrap().file_name = Some("photo.jpg".to_owned()); - let mut pending = vec![local]; - + fn colors_follow_configuration_and_isolate_accounts_and_cancel_edits() { + use crate::appearance::{Target, TerminalColor}; + use ratatui::style::Color; + let mut app = ready_app(); + app.handle_network(NetworkEvent::AccountIdentity { user_id: 101 }); + assert_eq!(app.color(Target::Chat(1)), Color::Reset); assert_eq!( - super::remove_matching_optimistic(&mut pending, &server), - Some(-1) + app.color(Target::Folder(7)), + TerminalColor::folder_default(7).color() ); - assert!(pending.is_empty()); + app.keymap = + crate::keymap::Keymap::parse("return { colors = { chats = { [1] = 'cyan' } } }") + .unwrap(); + assert_eq!(app.color(Target::Chat(1)), Color::Cyan); + app.run_binding("chat_color", 1); + app.color_picker.as_mut().unwrap().selection = 3; // Red, after follow/default/black. + app.handle_colors(KeyAction::Enter); + assert_eq!(app.color(Target::Chat(1)), Color::Red); + app.handle_network(NetworkEvent::AccountIdentity { user_id: 102 }); + assert_eq!(app.color(Target::Chat(1)), Color::Cyan); + app.handle_network(NetworkEvent::AccountIdentity { user_id: 101 }); + app.run_binding("chat_color", 1); + app.color_picker.as_mut().unwrap().selection = 0; + app.handle_colors(KeyAction::Escape); + assert_eq!(app.color(Target::Chat(1)), Color::Red); + app.run_binding("chat_color", 1); + app.color_picker.as_mut().unwrap().selection = 0; + app.handle_colors(KeyAction::Enter); + assert_eq!(app.color(Target::Chat(1)), Color::Cyan); } - #[test] - fn attachment_success_clears_status_and_stale_selection() { + fn reveal_downloads_once_and_rejects_stale_completion_after_media_edit() { + use yazi_term::event::{KeyCode, KeyEvent, KeyEventKind}; let mut app = ready_app(); open_first(&mut app); - let mut local = message(-1, 1, "", true); - local.delivery = Delivery::Pending; - local.attachment = Some(Attachment { - kind: AttachmentKind::File, - file_name: Some("notes.txt".to_owned()), - mime_type: Some("text/plain".to_owned()), - size: Some(10), + let mut attached = message(21, 1, "photo", false); + attached.attachment = Some(Attachment { + source_id: Some(55), + kind: AttachmentKind::Photo, + file_name: None, + mime_type: Some("image/jpeg".to_owned()), + size: Some(4), fallback_emoji: None, }); - app.messages.get_mut(&1).unwrap().push(local); - app.selected_message = Some(-1); - app.status_message = Some("Sending attachment…".to_owned()); - - app.handle_network(NetworkEvent::MessageAccepted { + app.messages.insert(1, vec![attached.clone()]); + app.selected_message = Some(21); + let mut key = KeyEvent { + code: KeyCode::Char('O'), + modifiers: Modifiers::SHIFT, + kind: KeyEventKind::Repeat, + ..KeyEvent::default() + }; + assert!(app.handle_key(&key).is_empty()); + key.kind = KeyEventKind::Press; + assert!(matches!( + app.handle_key(&key).as_slice(), + [TelegramCommand::DownloadAttachment { + request_id: 1, + media_id: Some(55), + .. + }] + )); + assert!(app.handle_key(&key).is_empty()); + attached.text = "edited caption".to_owned(); + app.handle_network(NetworkEvent::MessageUpdated(attached.clone())); + assert_eq!(app.downloading_attachments.get(&(1, 21)), Some(&1)); + attached.attachment.as_mut().unwrap().source_id = Some(56); + app.handle_network(NetworkEvent::MessageUpdated(attached)); + assert!(!app.reveal_after_download.contains(&(1, 21))); + assert!(matches!( + app.handle_key(&key).as_slice(), + [TelegramCommand::DownloadAttachment { + request_id: 2, + media_id: Some(56), + .. + }] + )); + let absent = std::env::temp_dir() + .join(format!("termgram-missing-{}", std::process::id())) + .join("photo.jpg"); + app.handle_network(NetworkEvent::AttachmentDownloaded { + request_id: 1, chat_id: 1, - local_id: -1, - }); - assert_eq!(app.selected_message, None); - assert_eq!(app.status_message, None); - } - - #[test] - fn edited_attachment_invalidates_downloaded_state() { - let mut app = ready_app(); - open_first(&mut app); - let mut original = message(21, 1, "", false); - original.attachment = Some(Attachment { - kind: AttachmentKind::File, - file_name: Some("before.txt".to_owned()), - mime_type: Some("text/plain".to_owned()), - size: Some(1), - fallback_emoji: None, + message_id: 21, + path: absent.clone(), }); - app.handle_network(NetworkEvent::NewMessage(original.clone())); + assert_eq!(app.downloading_attachments.get(&(1, 21)), Some(&2)); + assert!(!app.downloaded_attachments.contains_key(&(1, 21))); app.handle_network(NetworkEvent::AttachmentDownloaded { + request_id: 2, chat_id: 1, message_id: 21, - path: std::env::temp_dir().join("termgram-edited-fixture"), + path: absent, }); - assert_eq!(app.attachment_state(1, 21), AttachmentState::Downloaded); - - original.attachment.as_mut().unwrap().file_name = Some("after.txt".to_owned()); - app.handle_network(NetworkEvent::MessageUpdated(original)); - assert_eq!(app.attachment_state(1, 21), AttachmentState::Ready); + assert!( + app.status_message + .as_deref() + .unwrap() + .starts_with("Could not reveal attachment") + ); + assert!(matches!( + app.handle_key(&key).as_slice(), + [TelegramCommand::DownloadAttachment { request_id: 3, .. }] + )); + app.handle_network(NetworkEvent::MessagesDeleted { + channel_id: None, + message_ids: vec![21], + }); + assert!(app.reveal_after_download.is_empty()); + assert!(app.downloading_attachments.is_empty()); } - #[test] - fn inline_media_downloads_are_bounded_and_ignore_superseded_messages() { + fn old_message_edits_and_latest_deletions_do_not_leave_stale_dialog_previews() { + let mut app = ready_app(); + app.chats[0].last_message_id = Some(100); + app.chats[0].last_activity = Some(Utc.timestamp_opt(100, 0).unwrap()); + app.chats[0].last_message = "latest".to_owned(); + app.messages.insert(1, vec![message(21, 1, "old", false)]); + app.handle_network(NetworkEvent::MessageUpdated(message( + 21, + 1, + "edited old", + false, + ))); + assert_eq!(app.chats[0].last_message, "latest"); + app.handle_network(NetworkEvent::DialogsLoading); + app.handle_network(NetworkEvent::MessagesDeleted { + channel_id: None, + message_ids: vec![100], + }); + assert!(app.chats[0].last_message.is_empty()); + let mut stale = app.chats[0].clone(); + stale.last_message_id = Some(100); + stale.last_message = "deleted".to_owned(); + app.handle_network(NetworkEvent::Dialogs(vec![stale])); + assert!(app.chats[0].last_message.is_empty()); + app.handle_network(NetworkEvent::DialogsLoading); + let mut fresh = app.chats[0].clone(); + fresh.last_message_id = Some(99); + fresh.last_message = "previous live message".to_owned(); + app.handle_network(NetworkEvent::Dialogs(vec![fresh])); + assert_eq!(app.chats[0].last_message, "previous live message"); + } + #[test] + fn editing_keeps_normal_draft_and_recovers_failure_without_sending_a_new_message() { let mut app = ready_app(); + app.account_user_id = Some(100); open_first(&mut app); - for id in 21..=23 { - let mut photo = message(id, 1, "caption", false); - photo.attachment = Some(Attachment { - kind: AttachmentKind::Sticker, - file_name: Some("sticker.tgs".to_owned()), - mime_type: Some("application/x-tgsticker".to_owned()), - size: None, - fallback_emoji: Some("🙂".to_owned()), - }); - app.handle_network(NetworkEvent::NewMessage(photo)); - app.media_slots.push(crate::media::MediaSlot { - chat_id: 1, - message_id: id, - viewport: ratatui::layout::Rect::new(0, 0, 24, 30), - offset: 0, - size: ratatui::layout::Size::new(24, 6), - }); + app.selected_message = Some(20); + app.draft_data_mut(1).input.set_value("unsent draft"); + let commands = app.run_binding("edit_message", 1); + let [TelegramCommand::LoadEdit { request_id, .. }] = commands.as_slice() else { + panic!("load original") + }; + app.handle_network(NetworkEvent::EditLoaded { + chat_id: 1, + request_id: *request_id, + result: Ok(crate::editing::Source { + message_id: 20, + text: "old caption".to_owned(), + revision: [1; 32], + caption: true, + }), + }); + app.handle_action(KeyAction::Clear); + app.update(AppEvent::Paste("new 界🙂".to_owned())); + assert_eq!(app.active_draft().unwrap().value(), "unsent draft"); + for width in [35, 110] { + let mut terminal = + ratatui::Terminal::new(ratatui::backend::TestBackend::new(width, 18)).unwrap(); + terminal + .draw(|frame| crate::ui::render(frame, &mut app)) + .unwrap(); + assert!( + app.handle_mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 1, + row: 3, + modifiers: Modifiers::empty() + }) + .is_empty() + ); + assert_eq!(app.mode, Mode::Edit); + assert_eq!(app.selected_message, Some(20)); + assert!( + app.request_visible_read().is_empty(), + "editor covers the timeline" + ); } - let requests = app.request_visible_media(); - assert_eq!(requests.len(), 2); - assert!(requests.iter().all(|c| matches!( - c, - TelegramCommand::DownloadPreview { - thumbnail: true, + let commands = app.run_binding("send", 1); + let [ + TelegramCommand::EditMessage { + chat_id: 1, + message_id: 20, + request_id, + text, .. - } - ))); - assert!(app.request_visible_media().is_empty()); - app.handle_action(KeyAction::Character('i')); - assert_eq!(app.mode, Mode::Compose); - let mut edited = app.messages[&1] - .iter() - .find(|m| m.id == 21) - .unwrap() - .clone(); - edited.text = "new caption".to_owned(); - app.handle_network(NetworkEvent::MessageUpdated(edited)); - assert_eq!(app.request_visible_media().len(), 1); - app.handle_network(NetworkEvent::PreviewDownloaded { + }, + ] = commands.as_slice() + else { + panic!("edit only") + }; + assert_eq!(text, "new 界🙂"); + app.handle_network(NetworkEvent::EditFinished { chat_id: 1, - message_id: 21, - request_id: 1, - path: "stale.webp".into(), + request_id: *request_id, + error: Some("MESSAGE_EDIT_TIME_EXPIRED".to_owned()), }); - assert!(app.media_previews[&(1, 21)].path.is_none()); - app.handle_network(NetworkEvent::PreviewDownloaded { + assert_eq!(app.message_edit().unwrap().input.value(), "new 界🙂"); + app.run_binding("cancel", 1); + assert_eq!(app.mode, Mode::Navigate); + app.run_binding("edit_message", 1); + let commands = app.run_binding("send", 1); + let [TelegramCommand::EditMessage { request_id, .. }] = commands.as_slice() else { + panic!("retry edit") + }; + app.run_binding("cancel", 1); + app.active_chat_id = Some(2); + app.handle_network(NetworkEvent::EditFinished { chat_id: 1, - message_id: 21, - request_id: 3, - path: "current.webp".into(), + request_id: *request_id, + error: None, + }); + assert!(app.draft_data(1).unwrap().edit.is_none()); + assert_eq!(app.draft_data(1).unwrap().input.value(), "unsent draft"); + assert!( + !app.messages + .values() + .flatten() + .any(|message| message.id < 0) + ); + } + + #[test] + fn cancelled_original_load_does_not_open_or_replace_an_edit() { + let mut app = ready_app(); + open_first(&mut app); + app.selected_message = Some(20); + let commands = app.run_binding("edit_message", 1); + let [TelegramCommand::LoadEdit { request_id, .. }] = commands.as_slice() else { + panic!("load") + }; + app.run_binding("cancel", 1); + app.handle_network(NetworkEvent::EditLoaded { + chat_id: 1, + request_id: *request_id, + result: Ok(crate::editing::Source { + message_id: 20, + text: "late".to_owned(), + revision: [1; 32], + caption: false, + }), }); + assert_eq!(app.mode, Mode::Navigate); + assert!(app.message_edit().is_none()); + } + #[test] + fn deletion_requires_explicit_scope_and_preserves_messages_until_synced() { + use crate::deletion::{Plan, Scope}; + let mut app = ready_app(); + open_first(&mut app); + app.selected_message = Some(20); + let load = |app: &mut App| { + let commands = app.run_binding("delete_message", 1); + let [TelegramCommand::ReviewDeletion { request_id, .. }] = commands.as_slice() else { + panic!("review deletion") + }; + app.handle_network(NetworkEvent::DeletionReady { + chat_id: 1, + message_id: 20, + request_id: *request_id, + result: Ok(Plan { + message: message(20, 1, "target 界🙂", false), + revision: [2; 32], + scopes: vec![Scope::OnlyMe, Scope::Everyone], + }), + }); + }; + load(&mut app); assert!( - app.media_previews[&(1, 21)] - .status - .contains("static preview") + app.run_binding("open", 1).is_empty(), + "Enter defaults to cancel" ); - assert!(!app.downloaded_attachments.contains_key(&(1, 21))); + assert_eq!(app.mode, Mode::Navigate); + load(&mut app); + for (width, height) in [(35, 12), (110, 22)] { + let mut terminal = + ratatui::Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap(); + terminal + .draw(|frame| crate::ui::render(frame, &mut app)) + .unwrap(); + assert!( + app.handle_mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 1, + row: 3, + modifiers: Modifiers::empty(), + }) + .is_empty() + ); + assert_eq!(app.selected_message, Some(20)); + assert_eq!(app.mode, Mode::DeletePrompt); + assert!(app.request_visible_read().is_empty()); + } + app.run_binding("down", 2); + let commands = app.run_binding("open", 1); + let [ + TelegramCommand::DeleteMessage { + chat_id: 1, + message_id: 20, + scope: Scope::Everyone, + request_id, + .. + }, + ] = commands.as_slice() + else { + panic!("explicit scope only") + }; + assert!(app.active_messages().iter().any(|message| message.id == 20)); + app.handle_network(NetworkEvent::DeleteFinished { + chat_id: 1, + message_id: 20, + request_id: *request_id, + error: Some("MESSAGE_DELETE_FORBIDDEN".to_owned()), + }); + assert_eq!(app.deletion.prompt.as_ref().unwrap().selected, 0); + assert!(app.active_messages().iter().any(|message| message.id == 20)); + app.run_binding("down", 1); + let commands = app.run_binding("open", 1); + let [ + TelegramCommand::DeleteMessage { + scope: Scope::OnlyMe, + request_id, + .. + }, + ] = commands.as_slice() + else { + panic!("retry scope") + }; + app.run_binding("cancel", 1); + app.active_chat_id = Some(2); app.handle_network(NetworkEvent::MessagesDeleted { channel_id: None, - message_ids: vec![21, 22, 23], + message_ids: vec![20], }); - app.handle_network(NetworkEvent::PreviewDownloaded { + app.handle_network(NetworkEvent::DeleteFinished { chat_id: 1, - message_id: 22, - request_id: 2, - path: "deleted.webp".into(), + message_id: 20, + request_id: *request_id, + error: None, }); - assert!(app.media_previews.is_empty()); - assert!(app.request_visible_media().is_empty()); + assert_eq!(app.active_chat_id, Some(2)); + assert!(app.deletion.prompt.is_none()); + assert!(!app.messages[&1].iter().any(|message| message.id == 20)); } #[test] - fn right_clicking_a_message_starts_a_reply_without_activating_its_actions() { + fn cancelled_or_changed_deletion_review_cannot_delete_a_stale_target() { + use crate::deletion::{Plan, Scope}; + let mut app = ready_app(); + open_first(&mut app); + app.selected_message = Some(20); + let commands = app.run_binding("delete_message", 1); + let [TelegramCommand::ReviewDeletion { request_id, .. }] = commands.as_slice() else { + panic!("review") + }; + let ready = NetworkEvent::DeletionReady { + chat_id: 1, + message_id: 20, + request_id: *request_id, + result: Ok(Plan { + message: message(20, 1, "original", false), + revision: [2; 32], + scopes: vec![Scope::Everyone], + }), + }; + app.run_binding("cancel", 1); + app.handle_network(ready.clone()); + assert!(app.deletion.prompt.is_none()); + let commands = app.run_binding("delete_message", 1); + let [TelegramCommand::ReviewDeletion { request_id, .. }] = commands.as_slice() else { + panic!("new review") + }; + app.handle_network(NetworkEvent::MessageUpdated(message( + 20, 1, "changed", false, + ))); + let NetworkEvent::DeletionReady { result, .. } = ready else { + unreachable!() + }; + app.handle_network(NetworkEvent::DeletionReady { + chat_id: 1, + message_id: 20, + request_id: *request_id, + result, + }); + app.run_binding("down", 1); + assert_eq!(app.deletion.prompt.as_ref().unwrap().selected, 0); + assert!(app.run_binding("open", 1).is_empty()); + assert_eq!(app.mode, Mode::Navigate); + } + #[test] + fn copy_captures_the_selected_message_and_only_accepts_its_own_result() { let mut app = ready_app(); open_first(&mut app); - app.set_message_hit_regions(vec![(10, 70, 8, (20, None))]); - assert!( - app.update(AppEvent::Mouse(MouseEvent { - kind: MouseEventKind::Down(MouseButton::Right), - column: 20, - row: 8, - modifiers: Modifiers::empty(), - })) + app.run_binding("copy_text", 1).is_empty(), + "selection is explicit" + ); + app.selected_message = Some(20); + app.run_binding("command", 1); + app.update(AppEvent::Paste("copy link".to_owned())); + app.selected_message = Some(19); + let commands = app.run_binding("open", 1); + let [ + TelegramCommand::CopyMessage { + chat_id: 1, + message_id: 20, + link: true, + request_id, + }, + ] = commands.as_slice() + else { + panic!("command must retain its original target") + }; + assert!( + app.run_binding("copy_text", 1).is_empty(), + "only one preparation in flight" + ); + assert!( + app.handle_network(NetworkEvent::MessageCopyReady { + request_id: request_id + 1, + result: Ok("wrong result".to_owned()), + }) .is_empty() ); - assert_eq!(app.mode, Mode::Compose); - assert_eq!(app.selected_message, Some(20)); + assert!( + app.handle_network(NetworkEvent::MessageCopyReady { + request_id: *request_id, + result: Err("Content is protected".to_owned()), + }) + .is_empty() + ); + let commands = app.run_binding("copy_text", 1); + let [ + TelegramCommand::CopyMessage { + request_id, + link: false, + .. + }, + ] = commands.as_slice() + else { + panic!("retry") + }; + app.active_chat_id = Some(2); + let result = app.handle_network(NetworkEvent::MessageCopyReady { + request_id: *request_id, + result: Ok("original 界🙂\ncaption".to_owned()), + }); assert_eq!( - app.active_reply_target().map(|reply| reply.message_id), - Some(20) + result, + [TelegramCommand::CopyText( + "original 界🙂\ncaption".to_owned() + )] + ); + assert_eq!(app.active_chat_id, Some(2)); + assert!(!format!("{:?}", result[0]).contains("caption")); + assert!( + app.handle_network(NetworkEvent::MessageCopyReady { + request_id: *request_id, + result: Ok("duplicate".to_owned()), + }) + .is_empty() + ); + } + #[test] + #[allow(clippy::too_many_lines)] + fn forward_review_keeps_target_drafts_and_deduplication_id_on_retry() { + let mut app = ready_app(); + app.account_user_id = Some(100); + open_first(&mut app); + app.selected_message = Some(20); + app.draft_data_mut(1).input.set_value("source draft"); + app.draft_data_mut(2).input.set_value("destination draft"); + app.run_binding("forward_message", 1); + assert_eq!(app.mode, Mode::Command); + app.update(AppEvent::Paste("Beta".to_owned())); + app.selected_message = Some(19); + let commands = app.run_binding("open", 1); + let [ + TelegramCommand::ReviewForward { + chat_id: 1, + message_id: 20, + destination: 2, + request_id, + }, + ] = commands.as_slice() + else { + panic!("captured forward target") + }; + assert!(app.run_binding("send", 1).is_empty(), "wait for review"); + app.handle_network(NetworkEvent::ForwardReady { + request_id: *request_id, + result: Ok(crate::forwarding::Plan { + message: message(20, 1, "Forward me 界🙂", false), + destination: chat(2, "Beta 最新名字"), + revision: [4; 32], + random_id: 9876, + }), + }); + for (width, height) in [(35, 12), (110, 22)] { + let mut terminal = + ratatui::Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap(); + terminal + .draw(|frame| crate::ui::render(frame, &mut app)) + .unwrap(); + assert!( + app.handle_mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 1, + row: 3, + modifiers: Modifiers::empty() + }) + .is_empty() + ); + assert_eq!(app.mode, Mode::ForwardPrompt); + assert!(app.request_visible_read().is_empty()); + } + let mut repeated = KeyEvent::new(yazi_term::event::KeyCode::Enter, Modifiers::empty()); + repeated.kind = yazi_term::event::KeyEventKind::Repeat; + assert!( + app.handle_key(&repeated).is_empty(), + "held Enter cannot submit a new preview" + ); + let commands = app.run_binding("send", 1); + let [ + TelegramCommand::ForwardMessage { + chat_id: 1, + message_id: 20, + destination: 2, + random_id: 9876, + request_id, + .. + }, + ] = commands.as_slice() + else { + panic!("send reviewed forward") + }; + assert!( + app.run_binding("send", 1).is_empty(), + "no duplicate submission" + ); + app.handle_network(NetworkEvent::ForwardFinished { + request_id: *request_id, + error: Some("Network interrupted".to_owned()), + }); + let commands = app.run_binding("send", 1); + let [ + TelegramCommand::ForwardMessage { + random_id: 9876, + request_id, + .. + }, + ] = commands.as_slice() + else { + panic!("retry must reuse its ID") + }; + app.run_binding("cancel", 1); + app.active_chat_id = Some(3); + app.handle_network(NetworkEvent::ForwardFinished { + request_id: *request_id, + error: None, + }); + assert_eq!(app.mode, Mode::Navigate); + assert_eq!(app.active_chat_id, Some(3)); + assert_eq!(app.draft_data(1).unwrap().input.value(), "source draft"); + assert_eq!( + app.draft_data(2).unwrap().input.value(), + "destination draft" ); + assert!(app.forwarding.review.is_none()); } #[test] - fn mouse_clicks_open_chats_and_activate_overlay_rows() { - let click = |column, row| { - AppEvent::Mouse(MouseEvent { - kind: MouseEventKind::Down(MouseButton::Left), - column, - row, - modifiers: Modifiers::empty(), - }) + fn cancelled_or_changed_forward_previews_never_send() { + let mut app = ready_app(); + app.account_user_id = Some(100); + open_first(&mut app); + app.selected_message = Some(20); + let commands = app.run_binding("save_message", 1); + let [ + TelegramCommand::ReviewForward { + destination: 100, + request_id, + .. + }, + ] = commands.as_slice() + else { + panic!("save to this account") + }; + app.run_binding("cancel", 1); + let plan = crate::forwarding::Plan { + message: message(20, 1, "old", false), + destination: chat(100, "Saved Messages"), + revision: [1; 32], + random_id: 42, + }; + app.handle_network(NetworkEvent::ForwardReady { + request_id: *request_id, + result: Ok(plan.clone()), + }); + assert!(app.forwarding.review.is_none()); + let commands = app.run_binding("save_message", 1); + let [TelegramCommand::ReviewForward { request_id, .. }] = commands.as_slice() else { + panic!("new review") }; + app.handle_network(NetworkEvent::ForwardReady { + request_id: *request_id, + result: Ok(plan), + }); + app.handle_network(NetworkEvent::MessageUpdated(message( + 20, + 1, + "new content", + false, + ))); + assert!(app.run_binding("send", 1).is_empty()); + assert!( + app.forwarding + .review + .as_ref() + .unwrap() + .error + .as_ref() + .unwrap() + .contains("changed") + ); + } + #[test] + fn saved_messages_uses_account_identity_even_without_a_cached_dialog() { let mut app = ready_app(); - app.set_chat_hit_regions(vec![(1, 30, 4, 1)]); - let commands = app.update(click(5, 4)); - assert_eq!(app.active_chat_id, Some(2)); + app.account_user_id = Some(100); + let commands = app.run_binding("saved_messages", 1); + let [TelegramCommand::OpenSaved { request_id }] = commands.as_slice() else { + panic!("resolve own peer") + }; + assert!( + app.handle_network(NetworkEvent::SavedReady { + request_id: request_id + 1, + result: Ok(chat(101, "Other account")) + }) + .is_empty() + ); + let commands = app.handle_network(NetworkEvent::SavedReady { + request_id: *request_id, + result: Ok(chat(100, "Saved Messages")), + }); assert!(matches!( - commands.first(), - Some(TelegramCommand::LoadHistory { chat_id: 2, .. }) + commands.as_slice(), + [TelegramCommand::LoadHistory { chat_id: 100, .. }] )); - - app.mode = Mode::Settings; - app.set_settings_hit_regions(vec![(20, 70, 8, 0)]); - let before = app.settings.automatic_update_checks; - assert!(app.update(click(25, 8)).is_empty()); - assert_eq!(app.settings.automatic_update_checks, !before); - - app.mode = Mode::Accounts; - app.settings.active_account = 1; - app.settings.account_count = 2; - app.set_account_hit_regions(vec![(20, 70, 10, 2)]); - assert_eq!( - app.update(click(25, 10)), - vec![TelegramCommand::SwitchAccount { account: 3 }] + assert_eq!(app.active_chat_id, Some(100)); + app.connection = crate::event::ConnectionStatus::Offline; + let commands = app.run_binding("saved_messages", 1); + assert!( + !commands + .iter() + .any(|command| matches!(command, TelegramCommand::OpenSaved { .. })), + "cached dialog opens offline" ); - assert_eq!(app.active_account(), 3); } - #[test] - fn mouse_wheel_routes_by_pane_instead_of_keyboard_focus() { + #[allow(clippy::too_many_lines)] + fn unread_mentions_keep_target_drafts_and_refresh_after_other_client_reads() { let mut app = ready_app(); - app.focus = Focus::Conversation; - app.selected_chat = 2; - app.set_chat_pane_region((0, 30, 1, 20)); - app.set_conversation_pane_region((30, 100, 1, 20)); - - app.handle_mouse(MouseEvent { - kind: MouseEventKind::ScrollUp, - column: 10, - row: 5, - modifiers: Modifiers::empty(), + app.connection = crate::event::ConnectionStatus::Online; + app.active_chat_id = Some(2); + app.draft_data_mut(2).input = crate::input::TextInput::from_value("unsent"); + app.focus = Focus::Chats; + app.run_binding("command", 1); + app.commands.input.set_value("mentions"); + let commands = app.run_binding("open", 1); + let [ + TelegramCommand::SearchMentions { + chat_id: 1, + before_id: 0, + request_id, + }, + ] = commands.as_slice() + else { + panic!("selected chat, not open chat") + }; + let mut mention = message(30, 1, "reply to you", false); + mention.mention = Some(crate::model::Mention { + unread: true, + requires_playback: false, }); - assert_eq!(app.focus, Focus::Chats); - assert_eq!(app.selected_chat, 0); - - app.handle_mouse(MouseEvent { - kind: MouseEventKind::ScrollUp, - column: 50, - row: 5, - modifiers: Modifiers::empty(), + app.handle_network(NetworkEvent::CloudSearchResults { + request_id: *request_id, + chat_id: 1, + page: crate::cloud_search::Page { + messages: vec![mention.clone()], + next: Some(30), + total: 2, + sender: None, + }, }); - assert_eq!(app.focus, Focus::Conversation); + for width in [40, 110] { + let mut terminal = + ratatui::Terminal::new(ratatui::backend::TestBackend::new(width, 24)).unwrap(); + terminal + .draw(|frame| crate::ui::render(frame, &mut app)) + .unwrap(); + let text = terminal + .backend() + .buffer() + .content + .iter() + .map(ratatui::buffer::Cell::symbol) + .collect::(); + assert!(text.contains("Unread mentions")); + assert!(!text.contains("Pattern")); + assert!(app.request_visible_read().is_empty()); + } + app.handle_network(NetworkEvent::MessageContentsRead { + channel_id: Some(-1_000_000_000_001), + message_ids: vec![30], + }); + assert_eq!( + app.search.page.as_ref().unwrap().messages().len(), + 1, + "channel IDs cannot clear private mentions" + ); + app.handle_network(NetworkEvent::MessageContentsRead { + channel_id: None, + message_ids: vec![30], + }); + assert!(app.search.page.as_ref().unwrap().messages().is_empty()); + let commands = app.run_binding("search_more", 1); + let [ + TelegramCommand::SearchMentions { + chat_id: 1, + before_id: 30, + request_id, + }, + ] = commands.as_slice() + else { + panic!("stable chat and next ID") + }; + let stale = *request_id; + let commands = app.run_binding("refresh", 1); + let [ + TelegramCommand::SearchMentions { + chat_id: 1, + before_id: 0, + request_id, + }, + ] = commands.as_slice() + else { + panic!("refresh starts at newest") + }; + app.handle_network(NetworkEvent::SearchFailed { + request_id: stale, + error: "stale".into(), + }); + assert!(app.search.loading); + app.handle_network(NetworkEvent::CloudSearchResults { + request_id: *request_id, + chat_id: 1, + page: crate::cloud_search::Page { + messages: vec![mention.clone()], + next: None, + total: 1, + sender: None, + }, + }); + app.run_binding("search_query", 1); + assert!(!app.search.editing); + let commands = app.run_binding("open", 1); + let [ + TelegramCommand::LoadCloudContext { + chat_id: 1, + message_id: 30, + request_id, + }, + ] = commands.as_slice() + else { + panic!("open original context") + }; + app.handle_network(NetworkEvent::CloudSearchContext { + request_id: *request_id, + chat_id: 1, + message_id: 30, + messages: vec![mention], + }); + assert_eq!(app.mode, Mode::Navigate); + assert_eq!(app.selected_message, Some(30)); + assert_eq!(app.active_chat_id, Some(1)); + assert_eq!(app.draft_data(2).unwrap().input.value(), "unsent"); + app.connection = crate::event::ConnectionStatus::Offline; + app.run_binding("mentions", 1); + assert!(app.search.error.is_some()); + assert!(!app.search.editing); + assert_eq!(app.run_binding("open", 1), [TelegramCommand::CancelSearch]); } - - use crate::input::TextInput; } diff --git a/src/app/alerts.rs b/src/app/alerts.rs new file mode 100644 index 0000000..1c5b5df --- /dev/null +++ b/src/app/alerts.rs @@ -0,0 +1,778 @@ +//! Notification policy and coalescing; platform delivery belongs to the runtime. +use super::{App, Mode, Screen, TelegramCommand}; +use crate::{ + event::{ConnectionStatus, NetworkEvent}, + model::{ChatId, Message}, + notifications::{Alert, Resolved, SettingsKey, When}, +}; +use std::{ + collections::{BTreeMap, BTreeSet}, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + time::{Duration, Instant}, +}; + +const MAX_CHATS: usize = 32; +const MAX_MESSAGES: usize = 32; +const MAX_AGE: i64 = 30; + +#[derive(Clone)] +struct Group { + messages: Vec, + due: Instant, + expires: Instant, +} + +#[derive(Clone)] +struct Settings { + request: u64, + loading: bool, + value: Option, + expires: Instant, +} + +#[derive(Clone)] +struct Submitted { + ids: Vec, + valid: Arc, + expires: Instant, +} + +#[derive(Clone)] +pub(super) struct State { + online: bool, + after: i64, + high_water: BTreeMap, + groups: BTreeMap, + settings: BTreeMap, + submitted: BTreeMap<(ChatId, i32), Submitted>, + next_request: u64, +} + +impl Default for State { + fn default() -> Self { + Self { + online: false, + after: chrono::Utc::now().timestamp(), + high_water: BTreeMap::new(), + groups: BTreeMap::new(), + settings: BTreeMap::new(), + submitted: BTreeMap::new(), + next_request: 0, + } + } +} + +fn key(message: &Message) -> SettingsKey { + SettingsKey { + chat: message.chat_id, + sender: message + .mention + .as_ref() + .filter(|mention| mention.unread) + .and_then(|_| message.notification.as_ref()?.sender), + } +} + +fn fresh(message: &Message, after: i64, now: i64) -> bool { + let date = message.timestamp.timestamp(); + message.id > 0 + && !message.outgoing + && message.notification.is_some() + && date > after + && date <= now.saturating_add(5) + && now.saturating_sub(date) <= MAX_AGE + && message + .mention + .as_ref() + .is_none_or(|mention| mention.unread) +} + +fn remove_matching(state: &mut State, mut affected: impl FnMut(ChatId, i32) -> bool) { + for (chat, group) in &mut state.groups { + group + .messages + .retain(|message| !affected(*chat, message.id)); + } + state.groups.retain(|_, group| !group.messages.is_empty()); + state.submitted.retain(|(chat, _), sent| { + let keep = !sent.ids.iter().any(|id| affected(*chat, *id)); + if !keep { + sent.valid.store(false, Ordering::Release); + } + keep + }); +} + +impl App { + pub fn cancel_alerts(&mut self) { + self.alerts.groups.clear(); + for sent in self.alerts.submitted.values() { + sent.valid.store(false, Ordering::Release); + } + self.alerts.submitted.clear(); + } + + fn invalidate_alert_settings(&mut self) { + self.alerts.settings.clear(); + for sent in self.alerts.submitted.values() { + sent.valid.store(false, Ordering::Release); + } + self.alerts.submitted.clear(); + } + + pub(super) fn observe_alerts(&mut self, event: &NetworkEvent) { + self.observe_alerts_at(event, chrono::Utc::now().timestamp(), Instant::now()); + } + + fn observe_alerts_at(&mut self, event: &NetworkEvent, now: i64, instant: Instant) { + match event { + NetworkEvent::Ready { .. } | NetworkEvent::Status(ConnectionStatus::Online) => { + if !self.alerts.online { + self.alerts.after = now; + self.alerts.online = true; + } + } + NetworkEvent::Status(_) | NetworkEvent::Auth(_) | NetworkEvent::Fatal(_) => { + self.alerts.online = false; + self.cancel_alerts(); + self.alerts.settings.clear(); + } + NetworkEvent::CachedSnapshot { chats, .. } => { + for chat in chats.iter().take(4096) { + if let Some(id) = chat.last_message_id { + self.alerts.high_water.insert(chat.id, id); + } + } + } + NetworkEvent::NewMessage(message) => self.queue_alert(message, now, instant), + NetworkEvent::MessageUpdated(message) => { + if let Some(group) = self.alerts.groups.get_mut(&message.chat_id) + && let Some(current) = group + .messages + .iter_mut() + .find(|current| current.id == message.id) + { + *current = message.clone(); + } + // Already queued native text cannot be rewritten after an edit. + for ((chat, _), sent) in &self.alerts.submitted { + if *chat == message.chat_id && sent.ids.contains(&message.id) { + sent.valid.store(false, Ordering::Release); + } + } + } + NetworkEvent::MessagesDeleted { + channel_id, + message_ids, + } + | NetworkEvent::MessageContentsRead { + channel_id, + message_ids, + } => { + remove_matching(&mut self.alerts, |chat, id| { + channel_id.map_or(chat > -1_000_000_000_000, |channel| chat == channel) + && message_ids.contains(&id) + }); + } + NetworkEvent::ReadMarked { + chat_id, max_id, .. + } + | NetworkEvent::UnreadChanged { + chat_id, max_id, .. + } => { + remove_matching(&mut self.alerts, |chat, id| { + chat == *chat_id && id <= *max_id + }); + } + NetworkEvent::CacheInvalidated { chat_id } => { + remove_matching(&mut self.alerts, |chat, _| { + chat_id.is_none_or(|id| id == chat) + }); + } + NetworkEvent::NotificationSettingsChanged + | NetworkEvent::ChatMuteChanged { .. } + | NetworkEvent::ChatMuteFinished { result: Ok(_), .. } => { + self.invalidate_alert_settings(); + } + NetworkEvent::AlertSettingsReady { + key, + request_id, + result, + } => { + if let Some(settings) = self.alerts.settings.get_mut(key) + && settings.request == *request_id + { + settings.loading = false; + settings.value = result.as_ref().ok().copied(); + settings.expires = + instant + Duration::from_secs(if result.is_ok() { 60 } else { 5 }); + } + } + _ => {} + } + } + + fn queue_alert(&mut self, message: &Message, now: i64, instant: Instant) { + let previous = self.alerts.high_water.entry(message.chat_id).or_default(); + let new = message.id > *previous; + *previous = (*previous).max(message.id); + while self.alerts.high_water.len() > 4096 { + self.alerts.high_water.pop_first(); + } + if !new + || !self.alerts.online + || !self.keymap.notifications.enabled + || !fresh(message, self.alerts.after, now) + || self.account_user_id == Some(message.chat_id) + { + return; + } + if !self.alerts.groups.contains_key(&message.chat_id) + && self.alerts.groups.len() >= MAX_CHATS + { + return; + } + let group = self + .alerts + .groups + .entry(message.chat_id) + .or_insert_with(|| Group { + messages: Vec::new(), + due: instant + Duration::from_millis(self.keymap.notifications.group_delay_ms), + expires: instant + Duration::from_secs(30), + }); + if group.messages.len() == MAX_MESSAGES { + group.messages.remove(0); + } + group.messages.push(message.clone()); + } + + /// Evaluate after drawing so a freshly arrived visible message is silent. + fn prune_alerts(&mut self, now: i64, instant: Instant) { + if !self.keymap.notifications.enabled + || !self.alerts.online + || self.should_quit + || self.screen != Screen::Main + || self.account_user_id.is_none() + { + self.cancel_alerts(); + return; + } + let visible = + if self.terminal_focused && matches!(self.mode, Mode::Navigate | Mode::Compose) { + self.reads.visible + } else { + None + }; + let suppress_all = + self.terminal_focused && self.keymap.notifications.when == When::Unfocused; + let seen = |chat: ChatId, id: i32| { + suppress_all + || visible.is_some_and(|(visible_chat, max)| visible_chat == chat && id <= max) + || self + .chats + .iter() + .find(|item| item.id == chat) + .and_then(|item| item.read_inbox_max_id) + .is_some_and(|max| id <= max) + }; + remove_matching(&mut self.alerts, seen); + for group in self.alerts.groups.values_mut() { + group + .messages + .retain(|message| fresh(message, self.alerts.after, now)); + } + self.alerts + .groups + .retain(|_, group| !group.messages.is_empty() && group.expires > instant); + self.alerts.submitted.retain(|_, sent| { + if sent.expires <= instant { + sent.valid.store(false, Ordering::Release); + false + } else { + sent.valid.load(Ordering::Acquire) + } + }); + self.alerts + .settings + .retain(|_, settings| settings.expires > instant); + } + + pub fn request_alert_settings(&mut self) -> Vec { + self.request_alert_settings_at(chrono::Utc::now().timestamp(), Instant::now()) + } + + fn request_alert_settings_at(&mut self, now: i64, instant: Instant) -> Vec { + self.prune_alerts(now, instant); + let wanted: BTreeSet<_> = self + .alerts + .groups + .values() + .flat_map(|group| group.messages.iter().map(key)) + .collect(); + let capacity = 2usize.saturating_sub( + self.alerts + .settings + .values() + .filter(|settings| settings.loading) + .count(), + ); + if capacity == 0 { + return Vec::new(); + } + let mut commands = Vec::new(); + for key in wanted { + if self.alerts.settings.contains_key(&key) { + continue; + } + if self.alerts.settings.len() >= 128 { + if let Some(old) = self + .alerts + .settings + .iter() + .filter(|(_, entry)| !entry.loading) + .min_by_key(|(_, entry)| entry.expires) + .map(|(key, _)| *key) + { + self.alerts.settings.remove(&old); + } else { + break; + } + } + self.alerts.next_request = self.alerts.next_request.wrapping_add(1); + let request_id = self.alerts.next_request; + self.alerts.settings.insert( + key, + Settings { + request: request_id, + loading: true, + value: None, + expires: instant + Duration::from_secs(30), + }, + ); + commands.push(TelegramCommand::ResolveAlertSettings { key, request_id }); + if commands.len() == capacity { + break; + } + } + commands + } + + #[must_use] + pub fn next_alert_deadline(&self) -> Option { + let now = Instant::now(); + self.alerts + .groups + .values() + .flat_map(|group| [group.due, group.expires]) + .chain( + self.alerts + .settings + .values() + .filter(|settings| !settings.loading) + .map(|settings| settings.expires), + ) + .chain(self.alerts.submitted.values().map(|sent| sent.expires)) + .filter(|time| *time > now) + .min() + } + + pub fn take_alerts(&mut self) -> Vec { + self.take_alerts_at(chrono::Utc::now().timestamp(), Instant::now()) + } + + fn take_alerts_at(&mut self, now: i64, instant: Instant) -> Vec { + self.prune_alerts(now, instant); + let mut ready = Vec::new(); + let mut waiting = BTreeMap::new(); + for (chat, group) in std::mem::take(&mut self.alerts.groups) { + if group.due > instant + || group.messages.iter().any(|message| { + self.alerts + .settings + .get(&key(message)) + .is_none_or(|settings| settings.value.is_none()) + }) + { + waiting.insert(chat, group); + continue; + } + let eligible: Vec<_> = group + .messages + .iter() + .filter_map(|message| { + let settings = self.alerts.settings.get(&key(message))?.value?; + (settings.chat.mute_until <= now + || settings.sender_mute_until.is_some_and(|until| until <= now)) + .then_some((message, settings)) + }) + .collect(); + let Some((last, settings)) = eligible.last() else { + continue; + }; + let metadata = last.notification.as_ref().expect("live candidate metadata"); + let show_preview = + self.keymap.notifications.previews && settings.chat.previews && !metadata.protected; + let excerpt = last.preview_text(); + let body = if show_preview { + format!("{}: {}", last.sender, excerpt) + } else { + "New message".to_owned() + }; + let body = if eligible.len() > 1 { + format!("{} messages · {body}", eligible.len()) + } else { + body + }; + let title = self + .chats + .iter() + .find(|item| item.id == chat) + .map_or_else(|| "Telegram".to_owned(), |item| item.title.clone()); + let title = format!( + "Termgram · {} · {title}", + self.user_name.as_deref().unwrap_or("Account") + ); + let sound = self.keymap.notifications.sound + && eligible.iter().any(|(message, settings)| { + settings.chat.sound + && message + .notification + .as_ref() + .is_some_and(|meta| !meta.silent) + }); + let valid = Arc::new(AtomicBool::new(true)); + let ids = eligible + .iter() + .map(|(message, _)| message.id) + .collect::>(); + self.alerts.submitted.insert( + (chat, last.id), + Submitted { + ids: ids.clone(), + valid: valid.clone(), + expires: group.expires, + }, + ); + ready.push(Alert { + account: self.account_user_id.expect("known account"), + chat, + ids, + title: crate::model::sanitize_terminal_line(&title) + .chars() + .take(160) + .collect(), + body: crate::model::sanitize_terminal_line(&body) + .chars() + .take(320) + .collect(), + sound, + valid, + expires: group.expires, + }); + } + self.alerts.groups = waiting; + ready + } + + pub fn alert_finished(&mut self, alert: &Alert) { + if let Some(id) = alert.ids.last() + && self + .alerts + .submitted + .get(&(alert.chat, *id)) + .is_some_and(|sent| Arc::ptr_eq(&sent.valid, &alert.valid)) + { + self.alerts.submitted.remove(&(alert.chat, *id)); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + model::{Chat, ChatKind, Delivery, Mention}, + notifications::{Metadata, Preferences}, + }; + use chrono::{TimeZone, Utc}; + + fn app(instant: Instant) -> App { + let mut app = App::with_ephemeral_settings(crate::config::Settings::default()); + app.screen = Screen::Main; + app.connection = ConnectionStatus::Online; + app.account_user_id = Some(7); + app.user_name = Some("Ada".into()); + app.terminal_focused = false; + app.chats.push(Chat { + id: 42, + title: "Group".into(), + kind: ChatKind::Group, + unread: 1, + read_inbox_max_id: Some(0), + last_message_id: None, + last_message: String::new(), + last_activity: None, + membership: crate::folders::ChatMembership::default(), + }); + app.observe_alerts_at( + &NetworkEvent::Ready { + user_name: "Ada".into(), + }, + 1000, + instant, + ); + app + } + + fn message(id: i32, date: i64) -> Message { + Message { + id, + chat_id: 42, + sender: "Sender".into(), + text: "private text".into(), + timestamp: Utc.timestamp_opt(date, 0).unwrap(), + outgoing: false, + delivery: Delivery::Sent, + mention: None, + reactions: None, + poll: None, + entities: Vec::new(), + notification: Some(Metadata { + sender: Some(90), + silent: false, + protected: false, + }), + pinned: false, + reply_to: None, + edited_at: None, + attachment: None, + links: Vec::new(), + buttons: Vec::new(), + } + } + + #[test] + fn replay_duplicates_and_visible_or_remotely_read_messages_stay_silent() { + let instant = Instant::now(); + let mut app = app(instant); + app.observe_alerts_at(&NetworkEvent::NewMessage(message(1, 999)), 1010, instant); + assert!(app.alerts.groups.is_empty(), "startup catch-up is silent"); + let live = message(2, 1010); + app.observe_alerts_at(&NetworkEvent::NewMessage(live.clone()), 1010, instant); + app.observe_alerts_at(&NetworkEvent::NewMessage(live), 1010, instant); + assert_eq!(app.alerts.groups[&42].messages.len(), 1); + let cached: Message = + serde_json::from_str(&serde_json::to_string(&message(3, 1010)).unwrap()).unwrap(); + assert!( + cached.notification.is_none(), + "delivery metadata is never replayed from disk" + ); + app.observe_alerts_at(&NetworkEvent::NewMessage(cached), 1010, instant); + assert_eq!(app.alerts.groups[&42].messages.len(), 1); + app.observe_alerts_at( + &NetworkEvent::UnreadChanged { + chat_id: 42, + max_id: 2, + unread: 0, + }, + 1010, + instant, + ); + assert!(app.request_alert_settings_at(1010, instant).is_empty()); + app.observe_alerts_at( + &NetworkEvent::Status(ConnectionStatus::Reconnecting), + 1011, + instant, + ); + app.observe_alerts_at(&NetworkEvent::NewMessage(message(4, 1012)), 1012, instant); + app.observe_alerts_at( + &NetworkEvent::Status(ConnectionStatus::Online), + 1013, + instant, + ); + app.observe_alerts_at(&NetworkEvent::NewMessage(message(5, 1012)), 1014, instant); + assert!( + app.alerts.groups.is_empty(), + "messages missed while offline do not replay as alerts" + ); + app.observe_alerts_at(&NetworkEvent::NewMessage(message(6, 1014)), 1014, instant); + app.terminal_focused = true; + app.active_chat_id = Some(42); + app.set_visible_read_boundary(42, Some(6)); + assert!(app.request_alert_settings_at(1014, instant).is_empty()); + assert!( + app.alerts.groups.is_empty(), + "drawn messages are silent even before their read RPC completes" + ); + app.observe_alerts_at(&NetworkEvent::NewMessage(message(7, 1014)), 1014, instant); + app.keymap.notifications.when = When::Unfocused; + assert!(app.request_alert_settings_at(1014, instant).is_empty()); + } + + #[test] + #[allow(clippy::too_many_lines)] + fn grouped_mentions_obey_fresh_settings_preview_sound_and_cancellation() { + let instant = Instant::now(); + let mut app = app(instant); + let mut first = message(10, 1010); + first.mention = Some(Mention { + unread: true, + requires_playback: false, + }); + first.notification.as_mut().unwrap().silent = true; + let mut second = first.clone(); + second.id = 11; + app.observe_alerts_at(&NetworkEvent::NewMessage(first), 1010, instant); + app.observe_alerts_at(&NetworkEvent::NewMessage(second), 1010, instant); + let commands = app.request_alert_settings_at(1010, instant); + let [TelegramCommand::ResolveAlertSettings { key, request_id }] = commands.as_slice() + else { + panic!("one shared settings query") + }; + assert_eq!(key.sender, Some(90)); + let key = *key; + let settings = Resolved { + chat: Preferences { + mute_until: i64::MAX, + previews: false, + sound: true, + }, + sender_mute_until: Some(0), + }; + app.observe_alerts_at(&NetworkEvent::NotificationSettingsChanged, 1010, instant); + app.observe_alerts_at( + &NetworkEvent::AlertSettingsReady { + key, + request_id: *request_id, + result: Ok(settings), + }, + 1010, + instant, + ); + assert!( + app.alerts.settings.is_empty(), + "stale RPC cannot restore notification preferences" + ); + let commands = app.request_alert_settings_at(1010, instant); + let [TelegramCommand::ResolveAlertSettings { request_id, .. }] = commands.as_slice() else { + panic!("retry fresh settings") + }; + app.observe_alerts_at( + &NetworkEvent::AlertSettingsReady { + key, + request_id: *request_id, + result: Ok(settings), + }, + 1010, + instant, + ); + assert!( + app.take_alerts_at(1010, instant + Duration::from_millis(799)) + .is_empty() + ); + let alerts = app.take_alerts_at(1011, instant + Duration::from_millis(801)); + let [alert] = alerts.as_slice() else { + panic!("one grouped alert") + }; + assert_eq!(alert.ids, [10, 11]); + assert!(alert.body.contains("2 messages")); + assert!(!alert.body.contains("private text")); + assert!( + !alert.sound, + "silent messages never gain sound while grouping" + ); + app.observe_alerts_at( + &NetworkEvent::MessagesDeleted { + channel_id: Some(-1_000_000_000_042), + message_ids: vec![11], + }, + 1011, + instant, + ); + assert!(alert.valid.load(Ordering::Acquire)); + app.observe_alerts_at( + &NetworkEvent::MessagesDeleted { + channel_id: None, + message_ids: vec![11], + }, + 1011, + instant, + ); + assert!( + !alert.valid.load(Ordering::Acquire), + "queued native alert is cancellable" + ); + app.observe_alerts_at(&NetworkEvent::NewMessage(message(12, 1012)), 1012, instant); + let commands = app.request_alert_settings_at(1012, instant); + let [TelegramCommand::ResolveAlertSettings { key, request_id }] = commands.as_slice() + else { + panic!("normal group settings") + }; + app.observe_alerts_at( + &NetworkEvent::AlertSettingsReady { + key: *key, + request_id: *request_id, + result: Ok(Resolved { + sender_mute_until: None, + ..settings + }), + }, + 1012, + instant, + ); + assert!( + app.take_alerts_at(1013, instant + Duration::from_secs(1)) + .is_empty(), + "ordinary messages in a muted chat stay silent" + ); + assert!(app.alerts.groups.is_empty()); + app.observe_alerts_at(&NetworkEvent::NotificationSettingsChanged, 1014, instant); + let mut protected = message(13, 1014); + protected.notification.as_mut().unwrap().protected = true; + app.observe_alerts_at(&NetworkEvent::NewMessage(protected), 1014, instant); + let commands = app.request_alert_settings_at(1014, instant); + let [TelegramCommand::ResolveAlertSettings { key, request_id }] = commands.as_slice() + else { + panic!("reload after settings change") + }; + app.observe_alerts_at( + &NetworkEvent::AlertSettingsReady { + key: *key, + request_id: *request_id, + result: Ok(Resolved { + chat: Preferences { + mute_until: 0, + previews: true, + sound: true, + }, + sender_mute_until: None, + }), + }, + 1014, + instant, + ); + let alerts = app.take_alerts_at(1015, instant + Duration::from_secs(1)); + let [alert] = alerts.as_slice() else { + panic!("new unmuted alert") + }; + assert!( + !alert.body.contains("private text"), + "protected content ignores enabled previews" + ); + assert!(alert.valid.load(Ordering::Acquire)); + app.reset_for_account_switch(2); + assert!( + !alert.valid.load(Ordering::Acquire), + "old account work cannot deliver after a switch" + ); + assert!( + crate::keymap::Keymap::parse("return { notifications = { group_delay_ms = 0 } }") + .is_err() + ); + assert!(crate::keymap::Keymap::parse("return { notifications = { backend = 'osc9', when = 'unfocused', previews = false } }").is_ok()); + } +} diff --git a/src/app/appearance.rs b/src/app/appearance.rs new file mode 100644 index 0000000..9fe15f8 --- /dev/null +++ b/src/app/appearance.rs @@ -0,0 +1,145 @@ +use super::{App, Focus, KeyAction, Mode, TelegramCommand}; +use crate::appearance::{Preferences, Target, TerminalColor}; +use anyhow::Result; + +#[derive(Clone)] +pub struct Picker { + pub target: Target, + pub label: String, + pub selection: usize, + previous_mode: Mode, +} + +impl App { + /// # Errors + /// Leaves existing managed preferences untouched when they cannot be read. + pub fn load_appearance(&mut self) -> Result<()> { + if let Some(path) = &self.settings_path { + self.appearance = Preferences::load(&path.with_file_name("appearance.json"))?; + } + Ok(()) + } + #[must_use] + pub fn color(&self, target: Target) -> ratatui::style::Color { + self.account_user_id + .and_then(|account| self.appearance.accounts.get(&account)) + .and_then(|colors| colors.get(target)) + .or_else(|| self.keymap.colors.get(target)) + .unwrap_or_else(|| match target { + Target::Chat(_) => TerminalColor::Default, + Target::Folder(id) => TerminalColor::folder_default(id), + }) + .color() + } + pub(super) fn begin_color_picker(&mut self, folder: bool) -> Vec { + let target = if folder { + self.folders + .iter() + .find(|folder| folder.id == self.folder_id) + .map(|folder| { + ( + Target::Folder(folder.id), + format!("Folder: {}", folder.title), + ) + }) + } else { + (if self.focus == Focus::Chats { + self.selected_chat_entry() + } else { + self.active_chat() + }) + .map(|chat| (Target::Chat(chat.id), format!("Chat: {}", chat.title))) + }; + if let Some((target, label)) = target { + self.begin_color_picker_for(target, label); + } + Vec::new() + } + + pub(super) fn begin_color_picker_for(&mut self, target: Target, label: String) { + let selected = self + .account_user_id + .and_then(|account| self.appearance.accounts.get(&account)) + .and_then(|colors| colors.get(target)); + let selection = selected + .and_then(|color| { + TerminalColor::ALL + .iter() + .position(|candidate| *candidate == color) + }) + .map_or(0, |index| index + 1); + self.status_message = None; + self.color_picker = Some(Picker { + target, + label, + selection, + previous_mode: self.mode, + }); + self.mode = Mode::Colors; + } + pub(super) fn handle_colors(&mut self, action: KeyAction) -> Vec { + if action == KeyAction::Escape { + self.mode = self + .color_picker + .as_ref() + .map_or(Mode::Navigate, |picker| picker.previous_mode); + self.color_picker = None; + return Vec::new(); + } + let Some(picker) = &mut self.color_picker else { + return Vec::new(); + }; + match action { + KeyAction::Up => picker.selection = picker.selection.saturating_sub(1), + KeyAction::Down => { + picker.selection = picker + .selection + .saturating_add(1) + .min(TerminalColor::ALL.len()); + } + KeyAction::Enter => { + let target = picker.target; + let previous_mode = picker.previous_mode; + let value = picker + .selection + .checked_sub(1) + .map(|index| TerminalColor::ALL[index]); + match self.save_color(target, value) { + Ok(()) => { + self.color_picker = None; + self.mode = previous_mode; + self.status_message = None; + } + Err(error) => { + self.status_message = Some(format!("Color was not saved: {error:#}")); + } + } + } + _ => {} + } + Vec::new() + } + fn save_color(&mut self, target: Target, value: Option) -> Result<()> { + let account = self + .account_user_id + .ok_or_else(|| anyhow::anyhow!("account identity is not available yet"))?; + let path = self + .settings_path + .as_ref() + .map(|path| path.with_file_name("appearance.json")); + let mut preferences = path.as_ref().map_or_else( + || Ok(self.appearance.clone()), + |path| Preferences::load(path), + )?; + preferences + .accounts + .entry(account) + .or_default() + .set(target, value); + if let Some(path) = path { + preferences.save(&path)?; + } + self.appearance = preferences; + Ok(()) + } +} diff --git a/src/app/attachments.rs b/src/app/attachments.rs new file mode 100644 index 0000000..11209f9 --- /dev/null +++ b/src/app/attachments.rs @@ -0,0 +1,100 @@ +use super::{App, Path, PathBuf, TelegramCommand, reveal_path, sanitize_terminal_line}; +use crate::model::ChatId; + +impl App { + pub(super) fn preview_selected_media(&mut self) -> Vec { + let message = self.selected_message.and_then(|id| { + self.active_messages() + .iter() + .find(|message| message.id == id) + .cloned() + }); + let Some(message) = message.filter(|message| { + message.id > 0 + && message + .attachment + .as_ref() + .is_some_and(crate::model::Attachment::supports_preview) + }) else { + self.status_message = Some("Select an image or sticker to preview".to_owned()); + return Vec::new(); + }; + self.activate_attachment(message.chat_id, message.id, &message) + } + + pub(super) fn queue_download( + &mut self, + chat_id: ChatId, + message_id: i32, + media_id: Option, + ) -> Vec { + if self + .downloading_attachments + .contains_key(&(chat_id, message_id)) + { + self.status_message = Some("Attachment is downloading…".to_owned()); + return Vec::new(); + } + let request_id = self.next_download_request_id; + self.next_download_request_id = request_id.wrapping_add(1).max(1); + self.downloading_attachments + .insert((chat_id, message_id), request_id); + self.status_message = Some("Downloading attachment…".to_owned()); + vec![TelegramCommand::DownloadAttachment { + chat_id, + message_id, + request_id, + media_id, + }] + } + + pub(super) fn reveal_selected_attachment(&mut self) -> Vec { + let message = self + .active_messages() + .iter() + .find(|message| Some(message.id) == self.selected_message) + .cloned(); + let Some(message) = + message.filter(|message| message.id > 0 && message.attachment.is_some()) + else { + self.status_message = + Some("Select a downloaded or downloadable attachment first".to_owned()); + return Vec::new(); + }; + let key = (message.chat_id, message.id); + let path: Option = self.downloaded_attachments.get(&key).cloned().or_else(|| { + message + .attachment + .as_ref() + .filter(|attachment| !attachment.preview_uses_thumbnail()) + .and_then(|_| self.media_previews.get(&key)) + .and_then(|preview| preview.path.clone()) + }); + if let Some(path) = path.filter(|path| path.is_file()) { + self.reveal_download(&path); + return Vec::new(); + } + self.downloaded_attachments.remove(&key); + self.reveal_after_download.insert(key); + self.queue_download( + message.chat_id, + message.id, + message + .attachment + .and_then(|attachment| attachment.source_id), + ) + } + + pub(super) fn reveal_download(&mut self, path: &Path) { + self.status_message = Some(match reveal_path(path) { + Ok(()) => format!( + "Revealed {}", + sanitize_terminal_line(&path.display().to_string()) + ), + Err(error) => format!( + "Could not reveal attachment: {}", + sanitize_terminal_line(&error.to_string()) + ), + }); + } +} diff --git a/src/app/chat_info.rs b/src/app/chat_info.rs new file mode 100644 index 0000000..f1a8a25 --- /dev/null +++ b/src/app/chat_info.rs @@ -0,0 +1,268 @@ +use super::{App, Mode, Screen, TelegramCommand}; +use crate::{ + actions::Action, + chat_info::{Content, Info}, + event::{ConnectionStatus, NetworkEvent}, + model::{sanitize_terminal_line, sanitize_terminal_text}, +}; +use std::{ + collections::BTreeMap, + time::{Duration, Instant}, +}; +const REFRESH: Duration = Duration::from_secs(60); + +#[derive(Clone)] +pub struct Entry { + pub info: Option, + pub error: Option, + pub at: Instant, + pub stale: bool, +} + +#[derive(Clone, Default)] +pub struct State { + pub entries: BTreeMap, + pub view: Option, + pub scroll: usize, + next_request: u64, + pending: Option<(i64, u64, bool)>, +} + +impl App { + pub(super) fn begin_chat_info(&mut self, chat_id: i64) -> Vec { + self.chat_info.view = Some(chat_id); + self.chat_info.scroll = 0; + self.mode = Mode::ChatInfo; + self.refresh_chat_info(chat_id) + } + + fn refresh_chat_info(&mut self, chat_id: i64) -> Vec { + if let Some(entry) = self.chat_info.entries.get_mut(&chat_id) { + entry.stale = true; + } + self.request_visible_chat_info() + } + + fn info_target(&self) -> Option { + if self.screen != Screen::Main + || self.connection != ConnectionStatus::Online + || !self.terminal_focused + { + return None; + } + if self.mode == Mode::ChatInfo { + self.chat_info.view + } else if self.focus == super::Focus::Conversation + && matches!(self.mode, Mode::Navigate | Mode::Compose) + { + self.active_chat_id + } else { + None + } + } + + pub fn request_visible_chat_info(&mut self) -> Vec { + let Some(chat_id) = self.info_target() else { + return Vec::new(); + }; + if self.chat_info.pending.is_some() + || self + .chat_info + .entries + .get(&chat_id) + .is_some_and(|e| !e.stale && e.at.elapsed() < REFRESH) + { + return Vec::new(); + } + self.chat_info.next_request += 1; + let request_id = self.chat_info.next_request; + self.chat_info.pending = Some((chat_id, request_id, true)); + vec![TelegramCommand::LoadChatInfo { + chat_id, + request_id, + }] + } + + #[must_use] + pub fn next_chat_info_deadline(&self) -> Option { + let target = self.info_target()?; + if self.chat_info.pending.is_some() { + return None; + } + let entry = self.chat_info.entries.get(&target)?; + let refresh = if entry.stale { + Instant::now() + } else { + entry.at + REFRESH + }; + Some( + if entry + .info + .as_ref() + .is_some_and(|i| i.next_send_at > chrono::Utc::now().timestamp()) + { + refresh.min(Instant::now() + Duration::from_secs(1)) + } else { + refresh + }, + ) + } + + pub(super) fn chat_info_binding( + &mut self, + action: &Action, + count: usize, + ) -> Option> { + if self.mode != Mode::ChatInfo { + return None; + } + match action { + Action::Cancel => { + self.mode = Mode::Navigate; + self.force_redraw = true; + } + Action::Up => self.chat_info.scroll = self.chat_info.scroll.saturating_sub(count), + Action::Down => self.chat_info.scroll = self.chat_info.scroll.saturating_add(count), + Action::Refresh => { + return Some( + self.chat_info + .view + .map_or_else(Vec::new, |id| self.refresh_chat_info(id)), + ); + } + Action::Quit | Action::NextAccount | Action::AddAccount | Action::Redraw => { + return None; + } + _ => {} + } + Some(Vec::new()) + } + + pub(super) fn observe_chat_info(&mut self, event: &NetworkEvent) { + match event { + NetworkEvent::ChatInfoReady { + chat_id, + request_id, + result, + } => { + let Some((chat, request, valid)) = self.chat_info.pending else { + return; + }; + if chat != *chat_id || request != *request_id { + return; + } + self.chat_info.pending = None; + if !valid { + return; + } + let (info, error) = match result { + Ok(info) => { + let mut info = info.clone(); + info.title = sanitize_terminal_line(&info.title); + info.about = sanitize_terminal_text(&info.about); + info.username = info.username.map(|name| sanitize_terminal_line(&name)); + (Some(info), None) + } + Err(error) => (None, Some(sanitize_terminal_line(error))), + }; + if self.chat_info.entries.len() >= 64 + && let Some(oldest) = self + .chat_info + .entries + .iter() + .min_by_key(|(_, e)| e.at) + .map(|(&id, _)| id) + { + self.chat_info.entries.remove(&oldest); + } + self.chat_info.entries.insert( + *chat_id, + Entry { + info, + error, + at: Instant::now(), + stale: false, + }, + ); + } + NetworkEvent::ChatInfoInvalidated { chat_id } + | NetworkEvent::SendFailed { chat_id, .. } + | NetworkEvent::AttachmentSendFailed { chat_id, .. } => { + self.invalidate_chat_info(*chat_id); + } + NetworkEvent::Status(status) if *status != ConnectionStatus::Online => { + for entry in self.chat_info.entries.values_mut() { + entry.stale = true; + } + if let Some(pending) = &mut self.chat_info.pending { + pending.2 = false; + } + } + NetworkEvent::MessageSent { message, .. } | NetworkEvent::NewMessage(message) + if message.outgoing && message.id > 0 => + { + if let Some(info) = self + .chat_info + .entries + .get_mut(&message.chat_id) + .and_then(|e| e.info.as_mut()) + { + info.next_send_at = info + .next_send_at + .max(message.timestamp.timestamp() + i64::from(info.slow_seconds)); + } + } + _ => {} + } + } + + fn invalidate_chat_info(&mut self, chat_id: i64) { + self.chat_info.entries.remove(&chat_id); + if let Some(pending) = &mut self.chat_info.pending + && pending.0 == chat_id + { + pending.2 = false; + } + } + + #[must_use] + pub fn draft_restriction(&self, chat_id: i64) -> Option { + let info = self + .chat_info + .entries + .get(&chat_id) + .filter(|e| e.error.is_none() && !e.stale && e.at.elapsed() < REFRESH)? + .info + .as_ref()?; + let now = chrono::Utc::now().timestamp(); + let attachments = self + .draft_data(chat_id) + .map(|d| d.attachments.as_slice()) + .unwrap_or_default(); + if info.slow_seconds > 0 { + if attachments.len() > 1 { + return Some("Slow mode: send one attachment at a time".to_owned()); + } + if self.messages.get(&chat_id).is_some_and(|messages| { + messages + .iter() + .any(|m| m.outgoing && m.delivery == crate::model::Delivery::Pending) + }) { + return Some("Slow mode: wait for the pending message to finish".to_owned()); + } + } + if attachments.is_empty() { + return info.restriction(Content::Text, now); + } + attachments.iter().find_map(|file| { + info.restriction( + if file.as_photo { + Content::Photo + } else { + Content::File + }, + now, + ) + }) + } +} diff --git a/src/app/commands.rs b/src/app/commands.rs new file mode 100644 index 0000000..e62adc5 --- /dev/null +++ b/src/app/commands.rs @@ -0,0 +1,920 @@ +//! Command editing reuses the normal `TextInput`, keymap and action dispatch. + +use std::collections::{BTreeMap, VecDeque}; + +use super::{App, Focus, KeyAction, Mode, Screen, TelegramCommand, TextInput}; +use crate::{ + actions::Action, + commands::{self, COMMANDS, Kind, Spec, Target}, + event::ConnectionStatus, + keymap::Context, + model::{ChatId, sanitize_terminal_text}, + pins::{DialogAction, DialogScope}, +}; + +const HISTORY_LIMIT: usize = 64; + +#[derive(Clone)] +struct Origin { + account: (u8, Option), + focus: Focus, + folder: i32, + chat: Option, + conversation: Option, + message: Option, + action: usize, + sidebar_visible: bool, +} + +#[derive(Clone)] +pub struct Candidate { + pub input: String, + pub label: String, + pub description: String, + pub shortcut: String, + pub unavailable: bool, +} + +#[derive(Clone, Default)] +pub struct State { + pub input: TextInput, + pub notice: Option, + pub selected: Option, + pub hit_regions: Vec<(u16, u16, u16, usize)>, + origin: Option, + completion: Option>, + history: BTreeMap>, + history_seed: Option, + history_index: Option, +} + +impl State { + pub(super) fn reset_session(&mut self) { + let history = std::mem::take(&mut self.history); + *self = Self { + history, + ..Self::default() + }; + } + + pub(super) fn invalidate_completion(&mut self) { + self.edited(); + } + + fn edited(&mut self) { + self.completion = None; + self.selected = None; + self.history_seed = None; + self.history_index = None; + self.notice = None; + } +} + +impl App { + pub(super) fn begin_command(&mut self) -> Vec { + if self.screen != Screen::Main || self.mode != Mode::Navigate { + return Vec::new(); + } + self.commands.reset_session(); + self.commands.origin = Some(Origin { + account: (self.active_account(), self.account_user_id), + focus: self.focus, + folder: self.folder_id, + chat: if self.focus == Focus::Chats { + self.selected_chat_entry().map(|chat| chat.id) + } else { + self.active_chat_id + }, + conversation: self.active_chat_id, + message: (self.focus == Focus::Conversation) + .then_some(self.selected_message) + .flatten(), + action: self.selected_action, + sidebar_visible: self.chat_pane_region.is_some(), + }); + self.mode = Mode::Command; + self.keymap.reset(); + self.status_message = None; + Vec::new() + } + + fn cancel_command(&mut self) { + if let Some(origin) = &self.commands.origin { + self.focus = origin.focus; + } + self.mode = Mode::Navigate; + self.commands.reset_session(); + self.force_redraw = true; + } + + pub(super) fn command_binding(&mut self, action: &Action) -> Option> { + if self.mode != Mode::Command { + return None; + } + match action { + Action::Cancel => self.cancel_command(), + Action::Open => return Some(self.submit_command()), + Action::CompleteNext => self.complete_command(true), + Action::CompletePrevious => self.complete_command(false), + Action::HistoryPrevious => self.command_history(true), + Action::HistoryNext => self.command_history(false), + Action::Quit + | Action::NextAccount + | Action::AddAccount + | Action::Redraw + | Action::Left + | Action::Right + | Action::Home + | Action::End + | Action::Backspace + | Action::Delete + | Action::Clear + | Action::DeleteWord => return None, + _ => {} + } + Some(Vec::new()) + } + + pub(super) fn edit_command(&mut self, action: KeyAction) -> Vec { + match action { + KeyAction::Escape => self.cancel_command(), + KeyAction::Enter => return self.submit_command(), + KeyAction::Character(character) => self.commands.input.insert(character), + KeyAction::Backspace => { + self.commands.input.backspace(); + } + KeyAction::Delete => { + self.commands.input.delete(); + } + KeyAction::Left => { + self.commands.input.move_left(); + } + KeyAction::Right => { + self.commands.input.move_right(); + } + KeyAction::Home => self.commands.input.move_home(), + KeyAction::End => self.commands.input.move_end(), + KeyAction::Clear => self.commands.input.clear(), + KeyAction::DeleteWord => { + self.commands.input.delete_word_before(); + } + _ => return Vec::new(), + } + self.commands.edited(); + Vec::new() + } + + pub(super) fn paste_command(&mut self, text: &str) { + self.commands + .input + .insert_str(&sanitize_terminal_text(text).replace(['\n', '\r'], " ")); + self.commands.edited(); + } + + #[must_use] + pub fn command_target_label(&self) -> String { + let Some(origin) = &self.commands.origin else { + return String::new(); + }; + let (name, _) = commands::split(self.commands.input.value()); + let target = if commands::find(name).is_some_and(|spec| { + matches!( + spec.target, + Target::Conversation | Target::Message | Target::Attachment | Target::Preview + ) + }) { + origin.conversation + } else { + origin.chat + }; + let chat = target.and_then(|id| self.chats.iter().find(|chat| chat.id == id)); + format!( + "Account {} · {}{}", + origin.account.0, + chat.map_or("No chat selected", |chat| chat.title.as_str()), + origin + .message + .map_or_else(String::new, |id| format!(" · #{id}")) + ) + } + + fn target_error(&self, target: Target) -> Option { + let origin = self.commands.origin.as_ref()?; + let chat = origin + .chat + .and_then(|id| self.chats.iter().find(|chat| chat.id == id)); + let message = origin + .conversation + .and_then(|id| self.messages.get(&id)) + .and_then(|messages| { + messages + .iter() + .find(|message| Some(message.id) == origin.message) + }); + let error = match target { + Target::None => return None, + Target::Chat if chat.is_some() => return None, + Target::Chat => "Select a chat first", + Target::Conversation + if origin.conversation.is_some() && origin.conversation == self.active_chat_id => + { + return None; + } + Target::Conversation => "Open a conversation first", + Target::Message if message.is_some() => return None, + Target::Message => "Select a message that is still available", + Target::Attachment if message.is_some_and(|message| message.attachment.is_some()) => { + return None; + } + Target::Attachment => "Select a message with an attachment", + Target::Preview + if message.is_some_and(|message| { + message + .attachment + .as_ref() + .is_some_and(crate::model::Attachment::supports_preview) + }) => + { + return None; + } + Target::Preview => "Select an image or sticker", + }; + Some(error.to_owned()) + } + + fn command_unavailable(&self, spec: &Spec) -> Option { + self.target_error(spec.target).or_else(|| { + (matches!( + spec.kind, + Kind::Pin(_) + | Kind::Archive(_) + | Kind::Read(_) + | Kind::Copy + | Kind::Forward + | Kind::Mute(_) + | Kind::Mentions + | Kind::Open + | Kind::Join + ) && self.connection != ConnectionStatus::Online) + .then(|| "Connect to Telegram first".to_owned()) + }) + } + + #[must_use] + pub fn command_candidates(&self) -> Vec { + if let Some(candidates) = &self.commands.completion { + return candidates.clone(); + } + let (name, argument) = commands::split(self.commands.input.value()); + let Some(argument) = argument else { + let context = if self + .commands + .origin + .as_ref() + .is_some_and(|origin| origin.focus == Focus::Chats) + { + Context::Chats + } else { + Context::Conversation + }; + let mut candidates: Vec<_> = COMMANDS + .iter() + .filter(|spec| spec.name.starts_with(name) || spec.alias == Some(name)) + .map(|spec| { + let reason = self.command_unavailable(spec); + Candidate { + input: format!( + "{}{}", + spec.name, + if spec.arguments.is_empty() { "" } else { " " } + ), + label: spec.usage(), + description: reason + .clone() + .unwrap_or_else(|| spec.description().to_owned()), + shortcut: spec.action().map_or_else(String::new, |action| { + self.keymap.hint(context, action.name()) + }), + unavailable: reason.is_some(), + } + }) + .collect(); + candidates + .sort_by_key(|candidate| (candidate.unavailable, !candidate.input.trim().eq(name))); + return candidates; + }; + let Some(spec) = commands::find(name) else { + return Vec::new(); + }; + self.command_argument_candidates(spec, argument) + } + + #[allow(clippy::too_many_lines)] + fn command_argument_candidates(&self, spec: &Spec, argument: &str) -> Vec { + let query = argument.trim().to_lowercase(); + let mut candidates = Vec::new(); + let mut add = |value: String, label: String, description: String| { + if value.to_lowercase().contains(&query) || label.to_lowercase().contains(&query) { + candidates.push(Candidate { + input: format!("{} {value}", spec.name), + label, + description, + shortcut: String::new(), + unavailable: false, + }); + } + }; + match spec.kind { + Kind::Config => add( + "reload".to_owned(), + "reload".to_owned(), + "Apply a validated Lua configuration".to_owned(), + ), + Kind::Search => Self::add_command_search(&mut add), + Kind::Forward => { + add( + "saved".to_owned(), + "Saved Messages".to_owned(), + "This account's personal storage".to_owned(), + ); + self.add_command_chats(&mut add); + } + Kind::Chat => self.add_command_chats(&mut add), + Kind::Folder => { + for folder in &self.folders { + add( + folder.id.to_string(), + folder.title.clone(), + format!("Folder {}", folder.id), + ); + } + } + Kind::Account => { + for slot in 1..=self.settings.account_count { + add( + slot.to_string(), + format!("Account {slot}"), + if slot == self.active_account() { + "Current account" + } else { + "Switch account" + } + .to_owned(), + ); + } + } + Kind::Pin(_) | Kind::Color => { + add( + "chat".to_owned(), + "chat".to_owned(), + "Selected chat".to_owned(), + ); + let (value, description) = if matches!(spec.kind, Kind::Color) { + ("folder", "Current folder") + } else { + ("message", "Selected message") + }; + add(value.to_owned(), value.to_owned(), description.to_owned()); + } + Kind::Mute(true) => { + for value in ["1h", "8h", "2d", "forever"] { + add( + value.to_owned(), + value.to_owned(), + "Mute this chat on Telegram".to_owned(), + ); + } + } + Kind::Copy => { + for value in ["text", "link"] { + add( + value.to_owned(), + value.to_owned(), + "Copy selected message".to_owned(), + ); + } + } + Kind::Sidebar => { + for value in ["show", "hide", "toggle"] { + add( + value.to_owned(), + value.to_owned(), + "Sidebar visibility".to_owned(), + ); + } + } + Kind::Help => { + for spec in COMMANDS { + add( + spec.name.to_owned(), + spec.usage(), + spec.description().to_owned(), + ); + } + } + _ => {} + } + candidates + } + + fn add_command_search(add: &mut impl FnMut(String, String, String)) { + add( + "--cloud".to_owned(), + "Telegram search".to_owned(), + "Current chat · sender, UTC date and media filters · requires connection".to_owned(), + ); + add( + "--cloud --from me".to_owned(), + "Messages from me".to_owned(), + "Search this chat for your messages".to_owned(), + ); + for media in ::value_variants() { + let value = clap::ValueEnum::to_possible_value(media).expect("media value"); + let name = value.get_name(); + add( + format!("--cloud --media {name}"), + format!("Media: {name}"), + "Filter Telegram history; enter optional search text".to_owned(), + ); + } + } + + fn add_command_chats(&self, add: &mut impl FnMut(String, String, String)) { + for (alias, id) in &self.keymap.chats { + if let Some(chat) = self.chats.iter().find(|chat| chat.id == *id) { + add(alias.clone(), alias.clone(), chat.title.clone()); + } + } + for chat in &self.chats { + add( + chat.id.to_string(), + chat.title.clone(), + format!( + "Chat {}{}", + chat.id, + if chat.membership.archived { + " · Archive" + } else { + "" + } + ), + ); + } + } + + fn complete_command(&mut self, forward: bool) { + let candidates = self.command_candidates(); + if candidates.is_empty() { + return; + } + let index = self.commands.selected.map_or( + if forward { 0 } else { candidates.len() - 1 }, + |index| { + if forward { + (index + 1) % candidates.len() + } else { + (index + candidates.len() - 1) % candidates.len() + } + }, + ); + self.commands + .input + .set_value(candidates[index].input.clone()); + self.commands.selected = Some(index); + self.commands.completion = Some(candidates); + self.commands.notice = None; + self.commands.history_seed = None; + self.commands.history_index = None; + } + + pub(super) fn click_command(&mut self, index: usize) { + let candidates = self.command_candidates(); + if let Some(candidate) = candidates.get(index) { + self.commands.input.set_value(candidate.input.clone()); + self.commands.edited(); + } + } + + fn command_history(&mut self, older: bool) { + let account = self + .account_user_id + .unwrap_or(-i64::from(self.active_account())); + let state = &mut self.commands; + let seed = state + .history_seed + .get_or_insert_with(|| state.input.value().to_owned()); + let history: Vec<_> = state + .history + .get(&account) + .into_iter() + .flatten() + .filter(|line| line.starts_with(seed.as_str())) + .rev() + .collect(); + let index = if older { + Some(state.history_index.map_or(0, |index| index + 1)) + } else { + state.history_index.and_then(|index| index.checked_sub(1)) + }; + if let Some(index) = index { + let Some(line) = history.get(index) else { + return; + }; + state.input.set_value((*line).clone()); + } else { + state.input.set_value(seed.clone()); + } + state.history_index = index; + state.completion = None; + state.selected = None; + state.notice = None; + } + + fn command_error(&mut self, error: impl Into) -> Vec { + self.commands.completion = None; + self.commands.selected = None; + self.commands.notice = Some(error.into()); + self.mode = Mode::Command; + Vec::new() + } + + fn resolve_command_chat(&self, value: &str) -> Result { + if let Some(&id) = self.keymap.chats.get(value) + && self.chats.iter().any(|chat| chat.id == id) + { + return Ok(id); + } + if let Ok(id) = value.parse::() + && self.chats.iter().any(|chat| chat.id == id) + { + return Ok(id); + } + let titles: Vec<_> = self + .chats + .iter() + .filter(|chat| chat.title.eq_ignore_ascii_case(value)) + .collect(); + if let [chat] = titles.as_slice() { + return Ok(chat.id); + } + Err( + "Choose a chat with Tab; Enter accepts an exact alias, ID or unique full title" + .to_owned(), + ) + } + + #[allow(clippy::too_many_lines)] + fn submit_command(&mut self) -> Vec { + let input = self.commands.input.value().to_owned(); + let (name, raw_argument) = commands::split(&input); + if name.is_empty() { + self.cancel_command(); + return Vec::new(); + } + let Some(spec) = commands::find(name) else { + return self.command_error("Unknown command · Tab to complete · help to browse"); + }; + let Some(origin) = self.commands.origin.clone() else { + return Vec::new(); + }; + if origin.account != (self.active_account(), self.account_user_id) { + return self.command_error("The account changed; close and reopen commands"); + } + let argument = raw_argument.unwrap_or_default().trim(); + if spec.arguments.starts_with('<') && argument.is_empty() { + self.commands.input.set_value(format!("{} ", spec.name)); + return self.command_error(format!("Usage: :{} · Tab to choose", spec.usage())); + } + if spec.arguments.is_empty() && !argument.is_empty() { + return self.command_error(format!("Usage: :{}", spec.usage())); + } + if let Some(error) = self.command_unavailable(spec) { + return self.command_error(error); + } + if matches!(spec.kind, Kind::Help) { + let help = if argument.is_empty() { + None + } else { + commands::find(argument) + }; + if !argument.is_empty() && help.is_none() { + return self.command_error("Unknown command in help"); + } + self.commands + .input + .set_value(help.map_or_else(String::new, |spec| format!("{} ", spec.name))); + self.commands.edited(); + self.commands.notice = Some(help.map_or_else( + || "Tab completes · Enter runs · ↑/↓ history · Esc returns · ? opens keyboard help outside commands".to_owned(), + |spec| format!(":{} · {}{}", spec.usage(), spec.description(), self.command_unavailable(spec).map_or_else(String::new, |reason| format!(" · {reason}"))))); + return Vec::new(); + } + // Resolve and validate arguments before leaving COMMAND or changing focus. + let invite = + if matches!(spec.kind, Kind::Join) { + match crate::invites::hash(argument) { + Some(hash) => Some(hash), + None => return self.command_error( + "Use :join https://t.me/+hash, t.me/joinchat/hash or tg://join?invite=hash", + ), + } + } else { + None + }; + let open_target = if matches!(spec.kind, Kind::Open) { + match crate::chat_discovery::target(argument) { + Ok(target) => Some(target), + Err(error) => return self.command_error(error), + } + } else { + None + }; + let mute = match spec.kind { + Kind::Mute(true) => match crate::notifications::Mute::parse(argument) { + Some(mute) => mute, + None => return self.command_error("Use :mute 1h, 8h, 2d or forever"), + }, + _ => crate::notifications::Mute::Off, + }; + let cloud_search = if matches!(spec.kind, Kind::Search) { + if let Some(value) = argument + .strip_prefix("--cloud") + .filter(|rest| rest.is_empty() || rest.starts_with(char::is_whitespace)) + { + let Some(chat_id) = origin.conversation else { + return self.command_error("Open a chat before using :search --cloud"); + }; + match crate::cloud_search::parse(value) { + Ok((query, filters)) => Some((chat_id, query, filters)), + Err(error) => return self.command_error(error.to_string()), + } + } else { + None + } + } else { + None + }; + let chat = if matches!(spec.kind, Kind::Chat | Kind::Forward) { + let target = if matches!(spec.kind, Kind::Forward) && argument == "saved" { + self.account_user_id + .ok_or_else(|| "Account identity is not ready".to_owned()) + } else { + self.resolve_command_chat(argument) + }; + match target { + Ok(id) => Some(id), + Err(error) => return self.command_error(error), + } + } else { + None + }; + let folder = if matches!(spec.kind, Kind::Folder) { + let matches: Vec<_> = self + .folders + .iter() + .filter(|folder| { + argument == folder.id.to_string() || folder.title.eq_ignore_ascii_case(argument) + }) + .map(|folder| folder.id) + .collect(); + if let [id] = matches.as_slice() { + Some(*id) + } else { + return self + .command_error("Choose a folder with Tab or enter its ID or unique full name"); + } + } else { + None + }; + let account = if matches!(spec.kind, Kind::Account) && !argument.is_empty() { + match argument.parse::() { + Ok(slot) if (1..=self.settings.account_count).contains(&slot) => Some(slot), + _ => return self.command_error("Choose an existing account slot"), + } + } else { + None + }; + match spec.kind { + Kind::Config if argument != "reload" => { + return self.command_error("Usage: :config reload"); + } + Kind::Copy if !matches!(argument, "" | "text" | "link") => { + return self.command_error(format!("Usage: :{}", spec.usage())); + } + Kind::Pin(_) if !matches!(argument, "chat" | "message") => { + return self.command_error(format!("Usage: :{}", spec.usage())); + } + Kind::Color if !matches!(argument, "chat" | "folder") => { + return self.command_error(format!("Usage: :{}", spec.usage())); + } + Kind::Sidebar if !matches!(argument, "" | "show" | "hide" | "toggle") => { + return self.command_error(format!("Usage: :{}", spec.usage())); + } + _ => {} + } + if matches!(spec.kind, Kind::Pin(_)) + && argument == "message" + && let Some(error) = self.target_error(Target::Message) + { + return self.command_error(error); + } + if (matches!(spec.kind, Kind::Archive(_)) + || (argument == "chat" && matches!(spec.kind, Kind::Pin(_) | Kind::Color))) + && let Some(error) = self.target_error(Target::Chat) + { + return self.command_error(error); + } + if ((matches!(spec.kind, Kind::Pin(_)) && argument == "chat") + || (matches!(spec.kind, Kind::Color) && argument == "folder")) + && !self.folders.iter().any(|folder| folder.id == origin.folder) + { + return self.command_error("The folder was removed; select a folder again"); + } + self.focus = origin.focus; + if matches!( + spec.target, + Target::Message | Target::Attachment | Target::Preview + ) || matches!(spec.kind, Kind::Action(Action::EditMessage)) + || (matches!(spec.kind, Kind::Pin(_)) && argument == "message") + { + if origin.conversation != self.active_chat_id { + return self.command_error("The conversation changed; select the message again"); + } + self.selected_message = origin.message; + self.selected_action = origin.action; + self.focus = Focus::Conversation; + } + if spec.target == Target::Conversation { + self.focus = Focus::Conversation; + } + let history = self + .commands + .history + .entry(origin.account.1.unwrap_or(-i64::from(origin.account.0))) + .or_default(); + history.retain(|line| line != &input); + history.push_back(input.clone()); + if history.len() > HISTORY_LIMIT { + history.pop_front(); + } + self.mode = Mode::Navigate; + self.status_message = None; + let outgoing = match &spec.kind { + Kind::Forward => self.review_forward(chat.expect("validated destination")), + Kind::Copy => self.copy_message(argument == "link"), + Kind::Read(unread) => { + self.set_chat_unread(origin.chat.expect("validated chat"), *unread) + } + Kind::Action(action) => self.run_action(action, 1), + Kind::Attach | Kind::Paste => { + let id = origin.chat.expect("validated chat target"); + let mut commands = if self.active_chat_id == Some(id) { + Vec::new() + } else { + self.open_chat_by_id(id) + }; + commands.extend(if matches!(spec.kind, Kind::Paste) { + self.paste_clipboard() + } else { + self.prepare_attachments(raw_argument.unwrap_or_default().to_owned(), false) + }); + commands + } + Kind::Config => self.run_action(&Action::ReloadConfig, 1), + Kind::Info => self.begin_chat_info(origin.chat.expect("validated target")), + Kind::Join => self.begin_invite(invite.expect("validated invite")), + Kind::Chat => self.open_chat_by_id(chat.expect("validated chat")), + Kind::Open => self.activate_url(open_target.as_deref().expect("validated target")), + Kind::Folder => { + self.folder_id = folder.expect("validated folder"); + self.filter.clear(); + self.selected_chat = 0; + self.focus = Focus::Chats; + self.sidebar_hidden = false; + self.narrow_conversation = false; + Vec::new() + } + Kind::Account => { + if let Some(account) = account { + self.activate_account(account, self.settings.account_count) + } else { + self.run_action(&Action::Accounts, 1) + } + } + Kind::Mentions => origin + .chat + .map_or_else(Vec::new, |id| self.start_mentions(id)), + Kind::Mute(_) => origin + .chat + .map_or_else(Vec::new, |id| self.set_chat_mute(id, mute)), + Kind::Search => { + if let Some((chat_id, query, filters)) = cloud_search { + self.start_cloud_search(chat_id, query, filters) + } else { + self.start_local_search(raw_argument) + } + } + Kind::Pin(pinned) => { + let current = if argument == "chat" { + origin + .chat + .is_some_and(|id| self.chat_pin_position_in(origin.folder, id).is_some()) + } else { + self.active_messages() + .iter() + .any(|message| Some(message.id) == origin.message && message.pinned) + }; + if current == *pinned { + self.status_message = Some( + if *pinned { + "Already pinned" + } else { + "Already unpinned" + } + .to_owned(), + ); + Vec::new() + } else if argument == "chat" { + let scope = match origin.folder { + 0 => DialogScope::Main, + 1 => DialogScope::Archive, + id => DialogScope::Filter(id), + }; + self.request_chat_pin( + origin.chat.expect("validated chat"), + scope, + DialogAction::Set(*pinned), + ) + } else { + self.run_action(&Action::Pin, 1) + } + } + Kind::Archive(archived) => { + if self + .chats + .iter() + .find(|chat| Some(chat.id) == origin.chat) + .is_some_and(|chat| chat.membership.archived == *archived) + { + self.status_message = Some( + if *archived { + "Already archived" + } else { + "Already outside Archive" + } + .to_owned(), + ); + Vec::new() + } else { + self.set_chat_archived(origin.chat.expect("validated chat"), *archived) + } + } + Kind::Sidebar => { + let show = match argument { + "show" => true, + "hide" => false, + _ => !origin.sidebar_visible, + }; + if show == origin.sidebar_visible { + Vec::new() + } else { + self.run_action(&Action::ToggleSidebar, 1) + } + } + Kind::Color => { + let (target, label) = if argument == "folder" { + let folder = self + .folders + .iter() + .find(|folder| folder.id == origin.folder) + .expect("validated folder"); + ( + crate::appearance::Target::Folder(folder.id), + format!("Folder: {}", folder.title), + ) + } else { + let chat = self + .chats + .iter() + .find(|chat| Some(chat.id) == origin.chat) + .expect("validated chat"); + ( + crate::appearance::Target::Chat(chat.id), + format!("Chat: {}", chat.title), + ) + }; + self.begin_color_picker_for(target, label); + Vec::new() + } + Kind::Status => { + self.configuration.status_scroll = 0; + self.mode = Mode::Status; + Vec::new() + } + Kind::Help => unreachable!("help stays in command entry"), + }; + self.commands.reset_session(); + self.force_redraw = true; + outgoing + } +} diff --git a/src/app/composition.rs b/src/app/composition.rs new file mode 100644 index 0000000..d57b8a9 --- /dev/null +++ b/src/app/composition.rs @@ -0,0 +1,112 @@ +//! Shared keyboard and pointer transitions into a per-chat draft. +use std::collections::BTreeMap; + +use anyhow::Result; +use serde::{Deserialize, Serialize}; + +use super::{App, ChatId, Focus, Mode, TelegramCommand}; +use crate::config::{read_preferences, write_preferences}; + +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(default, deny_unknown_fields)] +pub(super) struct Navigation { + /// Telegram user IDs, not local account-slot indices. + accounts: BTreeMap, +} + +impl App { + /// Resolve explicit navigation through the current folder ordering, rather + /// than treating a raw chat index as a filtered position. + pub(super) fn open_chat_by_id(&mut self, id: ChatId) -> Vec { + let Some(chat) = self.chats.iter().find(|chat| chat.id == id) else { + return Vec::new(); + }; + self.filter.clear(); + self.folder_id = i32::from(chat.membership.archived); + if self.folder_id == 1 && !self.folders.iter().any(|folder| folder.id == 1) { + self.folders.push(crate::folders::Folder::archive()); + } + self.mode = Mode::Navigate; + self.preserve_chat_selection(Some(id)); + self.open_selected_chat() + } + + /// Load navigation preferences without replacing malformed files. + /// + /// # Errors + /// Returns read or format errors from the managed preferences file. + pub fn load_navigation(&mut self) -> Result<()> { + if let Some(path) = &self.settings_path { + self.navigation = read_preferences(&path.with_file_name("navigation.json"))?; + } + Ok(()) + } + + pub(super) fn remember_chat(&mut self, chat_id: ChatId) { + let Some(account) = self.account_user_id else { + return; + }; + if self.navigation.accounts.insert(account, chat_id) == Some(chat_id) { + return; + } + let result = (|| -> Result<()> { + let Some(path) = &self.settings_path else { + return Ok(()); + }; + let path = path.with_file_name("navigation.json"); + let mut preferences: Navigation = read_preferences(&path)?; + preferences.accounts.insert(account, chat_id); + write_preferences(&path, &serde_json::to_vec_pretty(&preferences)?)?; + self.navigation = preferences; + Ok(()) + })(); + if let Err(error) = result { + self.status_message = Some(format!("Could not remember this chat: {error:#}")); + } + } + + pub(super) fn compose_or_reply(&mut self) -> Vec { + if self.focus == Focus::Conversation && self.selected_message.is_some() { + self.start_replying_to_selected() + } else { + self.start_composing() + } + } + + pub(super) fn start_composing(&mut self) -> Vec { + self.pending_telegram_link = None; + let mut commands = Vec::new(); + if self.active_chat_id.is_none() { + let remembered = self + .account_user_id + .and_then(|account| self.navigation.accounts.get(&account)) + .copied() + .filter(|id| self.chats.iter().any(|chat| chat.id == *id)); + if let Some(id) = remembered { + self.filter.clear(); + self.folder_id = self + .chats + .iter() + .find(|chat| chat.id == id) + .map_or(0, |chat| i32::from(chat.membership.archived)); + if self.folder_id == 1 && !self.folders.iter().any(|folder| folder.id == 1) { + self.folders.push(crate::folders::Folder::archive()); + } + self.preserve_chat_selection(Some(id)); + } + commands.extend(self.open_selected_chat()); + } + let Some(chat_id) = self.active_chat_id else { + self.status_message = + Some("No conversation available · open a chat after synchronization".to_owned()); + return commands; + }; + let key = self.draft_key(chat_id); + self.drafts.entry(key).or_default(); + self.mode = Mode::Compose; + self.focus = Focus::Conversation; + self.narrow_conversation = true; + self.selected_message = None; + commands + } +} diff --git a/src/app/configuration.rs b/src/app/configuration.rs new file mode 100644 index 0000000..1434f7e --- /dev/null +++ b/src/app/configuration.rs @@ -0,0 +1,81 @@ +//! One validated configuration snapshot is swapped between frames. +use super::App; +use crate::{keymap::Keymap, model::sanitize_terminal_line}; +use std::{path::PathBuf, time::Instant}; + +#[derive(Clone, Default)] +pub struct State { + pub path: Option, + pub loading: bool, + pub revision: u64, + pub error: Option, + pub loaded_at: Option, + pub terminal_clipboard: bool, + pub status_scroll: usize, + requested: bool, +} + +impl App { + pub(super) fn request_config_reload(&mut self) { + if self.configuration.loading { + return; + } + if self.configuration.path.is_none() { + self.configuration_failed("No Lua configuration path is available"); + return; + } + self.configuration.requested = true; + self.configuration.loading = true; + self.status_message = Some("Reloading Lua configuration…".to_owned()); + } + + pub fn take_config_reload(&mut self) -> Option { + if !std::mem::take(&mut self.configuration.requested) { + return None; + } + self.configuration.path.clone() + } + + /// The runtime prepares terminal changes before installing this snapshot. + pub fn install_configuration(&mut self, mut keymap: Keymap) { + self.cancel_alerts(); + keymap.reset(); + self.keymap = keymap; + self.metrics.latency = None; + self.metrics.reset_at = Instant::now(); + self.help_scroll = 0; + self.commands.invalidate_completion(); + self.clear_message_hit_regions(); + self.configuration.loading = false; + self.configuration.requested = false; + self.configuration.error = None; + self.configuration.loaded_at = Some(Instant::now()); + self.configuration.revision += 1; + self.force_redraw = true; + self.status_message = Some("Lua configuration reloaded".to_owned()); + } + + pub fn configuration_failed(&mut self, error: &str) { + let error = sanitize_terminal_line(error); + self.configuration.loading = false; + self.configuration.requested = false; + self.configuration.error = Some(error.clone()); + self.status_message = Some(format!("Configuration unchanged: {error}")); + } + + #[must_use] + pub fn help_lines(&self) -> Vec { + let mut lines = vec!["Effective shortcuts (including Lua overrides)".to_owned()]; + lines.extend(self.keymap.help()); + lines.extend([ + String::new(), + "Commands · type : to browse and complete".to_owned(), + ]); + lines.extend( + crate::commands::COMMANDS + .iter() + .map(|spec| format!(":{} — {}", spec.usage(), spec.description())), + ); + lines + } +} diff --git a/src/app/deletion.rs b/src/app/deletion.rs new file mode 100644 index 0000000..fcb841c --- /dev/null +++ b/src/app/deletion.rs @@ -0,0 +1,258 @@ +use super::{App, Focus, Mode, Screen, TelegramCommand}; +use crate::{ + actions::Action, + deletion::Plan, + event::{ConnectionStatus, NetworkEvent}, + model::sanitize_terminal_line, +}; + +#[derive(Clone, Default)] +pub struct State { + pub prompt: Option, + next_request: u64, + pending: Option<(u64, bool)>, +} + +#[derive(Clone)] +pub struct Prompt { + pub chat: i64, + pub message: i32, + pub title: String, + pub plan: Option, + pub selected: usize, + pub error: Option, + invalid: bool, +} + +impl App { + pub(super) fn begin_deletion(&mut self) -> Vec { + if self.screen != Screen::Main + || self.mode != Mode::Navigate + || self.focus != Focus::Conversation + || self.deletion.pending.is_some() + { + return Vec::new(); + } + let Some(chat) = self.active_chat_id else { + return Vec::new(); + }; + let Some(message) = self.selected_message.filter(|id| *id > 0) else { + self.status_message = Some("Select a delivered message to delete".to_owned()); + return Vec::new(); + }; + if self.connection != ConnectionStatus::Online { + self.status_message = + Some("Connect to Telegram to review deletion permissions".to_owned()); + return Vec::new(); + } + let title = self + .active_chat() + .map_or_else(|| "Conversation".to_owned(), |chat| chat.title.clone()); + self.deletion.prompt = Some(Prompt { + chat, + message, + title, + plan: None, + selected: 0, + error: None, + invalid: false, + }); + self.deletion.next_request += 1; + let request_id = self.deletion.next_request; + self.deletion.pending = Some((request_id, false)); + self.mode = Mode::DeletePrompt; + self.status_message = None; + vec![TelegramCommand::ReviewDeletion { + chat_id: chat, + message_id: message, + request_id, + }] + } + + #[must_use] + pub fn deletion_pending(&self) -> bool { + self.deletion.pending.is_some() + } + + pub(super) fn deletion_ready( + &mut self, + chat: i64, + message: i32, + request_id: u64, + result: Result, + ) { + if self.deletion.pending != Some((request_id, false)) { + return; + } + let Some(prompt) = self + .deletion + .prompt + .as_mut() + .filter(|prompt| prompt.chat == chat && prompt.message == message) + else { + return; + }; + self.deletion.pending = None; + if prompt.invalid { + return; + } + match result { + Ok(plan) => { + prompt.plan = Some(plan); + prompt.selected = 0; + } + Err(error) => prompt.error = Some(sanitize_terminal_line(&error)), + } + } + + pub(super) fn deletion_finished( + &mut self, + chat: i64, + message: i32, + request_id: u64, + error: Option, + ) { + if self.deletion.pending != Some((request_id, true)) { + return; + } + let Some(prompt) = self + .deletion + .prompt + .as_mut() + .filter(|prompt| prompt.chat == chat && prompt.message == message) + else { + return; + }; + self.deletion.pending = None; + if let Some(error) = error { + let error = sanitize_terminal_line(&error); + prompt.error = Some(error.clone()); + prompt.selected = 0; + self.status_message = Some(error); + } else { + self.deletion.prompt = None; + if self.mode == Mode::DeletePrompt { + self.mode = Mode::Navigate; + } + self.status_message = Some("Message deleted".to_owned()); + } + } + + fn close_deletion(&mut self) { + self.mode = Mode::Navigate; + if self.deletion.pending.is_some_and(|(_, saving)| saving) { + self.status_message = Some("Delete request is running in the background".to_owned()); + } else { + self.deletion.pending = None; + self.deletion.prompt = None; + self.status_message = None; + } + } + + fn confirm_deletion(&mut self) -> Vec { + if self.deletion.pending.is_some() { + return Vec::new(); + } + let Some(prompt) = &self.deletion.prompt else { + return Vec::new(); + }; + if prompt.selected == 0 { + self.close_deletion(); + return Vec::new(); + } + if prompt.invalid { + return Vec::new(); + } + let Some(plan) = &prompt.plan else { + return Vec::new(); + }; + let Some(&scope) = plan.scopes.get(prompt.selected - 1) else { + return Vec::new(); + }; + if self.connection != ConnectionStatus::Online { + self.status_message = Some("Connect to Telegram before confirming deletion".to_owned()); + return Vec::new(); + } + self.deletion.next_request += 1; + let request_id = self.deletion.next_request; + self.deletion.pending = Some((request_id, true)); + self.status_message = Some("Deleting message…".to_owned()); + vec![TelegramCommand::DeleteMessage { + chat_id: prompt.chat, + message_id: prompt.message, + revision: plan.revision, + request_id, + scope, + }] + } + + pub(super) fn deletion_binding( + &mut self, + action: &Action, + count: usize, + ) -> Option> { + if *action == Action::DeleteMessage { + return Some(self.begin_deletion()); + } + if self.mode != Mode::DeletePrompt { + return None; + } + match action { + Action::Cancel => self.close_deletion(), + Action::Open => return Some(self.confirm_deletion()), + Action::Up | Action::Down if !self.deletion_pending() => { + if let Some(prompt) = &mut self.deletion.prompt { + let maximum = prompt + .plan + .as_ref() + .filter(|_| !prompt.invalid) + .map_or(0, |plan| plan.scopes.len()); + prompt.selected = if *action == Action::Up { + prompt.selected.saturating_sub(count) + } else { + prompt.selected.saturating_add(count).min(maximum) + }; + } + } + Action::Quit | Action::Redraw | Action::NextAccount | Action::AddAccount => { + return None; + } + _ => {} + } + Some(Vec::new()) + } + + pub(super) fn observe_deletion(&mut self, event: &NetworkEvent) { + let Some(prompt) = &mut self.deletion.prompt else { + return; + }; + let changed = match event { + NetworkEvent::MessageUpdated(message) => { + message.chat_id == prompt.chat + && message.id == prompt.message + && prompt.plan.as_ref().is_none_or(|plan| { + message.text != plan.message.text + || message.edited_at != plan.message.edited_at + || message.attachment != plan.message.attachment + }) + } + NetworkEvent::MessagesDeleted { + channel_id, + message_ids, + } => { + channel_id.map_or(prompt.chat > -1_000_000_000_000, |chat| chat == prompt.chat) + && message_ids.contains(&prompt.message) + } + NetworkEvent::CacheInvalidated { chat_id } => { + chat_id.is_none_or(|chat| chat == prompt.chat) + } + _ => false, + }; + if changed { + prompt.invalid = true; + prompt.selected = 0; + prompt.error = + Some("Message changed or was removed; close and reopen deletion".to_owned()); + } + } +} diff --git a/src/app/drafts.rs b/src/app/drafts.rs new file mode 100644 index 0000000..41ee182 --- /dev/null +++ b/src/app/drafts.rs @@ -0,0 +1,67 @@ +use super::{App, ChatId}; +use crate::drafts::{Draft, Key, Snapshot, Stored}; + +impl App { + pub(super) fn draft_key(&self, chat: ChatId) -> Key { + Key { + account: self.account_user_id.unwrap_or(0), + chat, + topic: 0, + } + } + + pub(super) fn draft_data(&self, chat: ChatId) -> Option<&Draft> { + self.drafts.get(&self.draft_key(chat)) + } + + pub(super) fn draft_data_mut(&mut self, chat: ChatId) -> &mut Draft { + let key = self.draft_key(chat); + self.draft_at_mut(key) + } + + pub(super) fn draft_at_mut(&mut self, key: Key) -> &mut Draft { + self.drafts_dirty = true; + if key.account > 0 { + self.draft_modified_accounts.insert(key.account); + } + self.drafts.entry(key).or_default() + } + + pub(super) fn restore_drafts(&mut self, account: i64, drafts: Vec) { + // A second login/bootstrap can race with a newer, unflushed edit in + // this process. Already-loaded accounts retain their in-memory state. + if !self.draft_accounts.insert(account) { + return; + } + for draft in drafts { + self.drafts + .entry(Key { + account, + chat: draft.chat, + topic: draft.topic, + }) + .or_insert_with(|| draft.draft()); + } + } + + /// Coalesced, account-scoped persistence input. Empty loaded accounts are + /// included so clearing the final draft is durable; unknown ones are absent. + pub fn take_draft_snapshot(&mut self) -> Option { + if !std::mem::take(&mut self.drafts_dirty) { + return None; + } + let mut snapshot: Snapshot = self + .draft_accounts + .intersection(&self.draft_modified_accounts) + .map(|&account| (account, Vec::new())) + .collect(); + for (key, draft) in &self.drafts { + if !draft.is_empty() + && let Some(drafts) = snapshot.get_mut(&key.account) + { + drafts.push(draft.stored(key.chat, key.topic)); + } + } + Some(snapshot) + } +} diff --git a/src/app/editing.rs b/src/app/editing.rs new file mode 100644 index 0000000..b4e6f4c --- /dev/null +++ b/src/app/editing.rs @@ -0,0 +1,242 @@ +use super::{App, Focus, KeyAction, Mode, Screen, TelegramCommand, TextInput}; +use crate::{ + actions::Action, + drafts::Key, + editing::{Draft, Source}, + event::ConnectionStatus, + model::sanitize_terminal_line, +}; + +#[derive(Clone, Default)] +pub struct State { + pending: Option<(Key, u64, bool)>, + next_request: u64, +} + +impl App { + #[must_use] + pub fn message_edit(&self) -> Option<&Draft> { + self.active_chat_id + .and_then(|chat| self.draft_data(chat))? + .edit + .as_ref() + } + + #[must_use] + pub fn message_edit_pending(&self) -> bool { + self.editing.pending.is_some() + } + + fn begin_message_edit(&mut self) -> Vec { + if self.screen != Screen::Main + || self.mode != Mode::Navigate + || self.focus != Focus::Conversation + { + return Vec::new(); + } + let Some(chat_id) = self.active_chat_id else { + return Vec::new(); + }; + if self.message_edit().is_some() { + self.mode = Mode::Edit; + self.status_message = None; + return Vec::new(); + } + let Some(message_id) = self.selected_message.filter(|id| *id > 0) else { + self.status_message = Some("Select a delivered message to edit".to_owned()); + return Vec::new(); + }; + if self.editing.pending.is_some() { + return Vec::new(); + } + if self.connection != ConnectionStatus::Online { + self.status_message = + Some("Connect to Telegram to load the original message".to_owned()); + return Vec::new(); + } + self.editing.next_request += 1; + let request_id = self.editing.next_request; + self.editing.pending = Some((self.draft_key(chat_id), request_id, false)); + self.mode = Mode::Edit; + self.status_message = Some("Loading the original message…".to_owned()); + vec![TelegramCommand::LoadEdit { + chat_id, + message_id, + request_id, + }] + } + + pub(super) fn edit_loaded( + &mut self, + chat_id: i64, + request_id: u64, + result: Result, + ) { + let Some((key, id, false)) = self.editing.pending else { + return; + }; + if key != self.draft_key(chat_id) || id != request_id { + return; + } + self.editing.pending = None; + match result { + Ok(source) => { + let input = TextInput::from_value(&source.text); + self.draft_at_mut(key).edit = Some(Draft { source, input }); + self.status_message = None; + } + Err(error) => { + self.status_message = Some(sanitize_terminal_line(&error)); + if self.mode == Mode::Edit { + self.mode = Mode::Navigate; + } + } + } + } + + fn save_message_edit(&mut self) -> Vec { + if self.editing.pending.is_some() { + return Vec::new(); + } + let Some(chat_id) = self.active_chat_id else { + return Vec::new(); + }; + let Some(edit) = self.message_edit() else { + return Vec::new(); + }; + if edit.input.value() == edit.source.text { + self.draft_data_mut(chat_id).edit = None; + self.mode = Mode::Navigate; + self.status_message = Some("Message unchanged".to_owned()); + return Vec::new(); + } + if !edit.source.caption && edit.input.value().trim().is_empty() { + self.status_message = + Some("A text message cannot be empty; use :delete to remove it".to_owned()); + return Vec::new(); + } + if self.connection != ConnectionStatus::Online { + self.status_message = + Some("Connect to Telegram to save; your edit is kept locally".to_owned()); + return Vec::new(); + } + let (message_id, revision, text) = ( + edit.source.message_id, + edit.source.revision, + edit.input.value().to_owned(), + ); + self.editing.next_request += 1; + let request_id = self.editing.next_request; + self.editing.pending = Some((self.draft_key(chat_id), request_id, true)); + self.status_message = Some("Saving message edit…".to_owned()); + vec![TelegramCommand::EditMessage { + chat_id, + message_id, + request_id, + revision, + text, + }] + } + + pub(super) fn edit_finished(&mut self, chat_id: i64, request_id: u64, error: Option) { + let Some((key, id, true)) = self.editing.pending else { + return; + }; + if key != self.draft_key(chat_id) || id != request_id { + return; + } + self.editing.pending = None; + if let Some(error) = error { + self.status_message = Some(format!( + "{} · edit kept locally", + sanitize_terminal_line(&error) + )); + } else { + self.draft_at_mut(key).edit = None; + if self.active_chat_id == Some(chat_id) && self.mode == Mode::Edit { + self.mode = Mode::Navigate; + } + self.status_message = Some("Message edited".to_owned()); + } + } + + pub(super) fn editing_binding(&mut self, action: &Action) -> Option> { + if *action == Action::EditMessage { + return Some(self.begin_message_edit()); + } + if *action == Action::DiscardEdit && matches!(self.mode, Mode::Navigate | Mode::Edit) { + if self.editing.pending.is_some() { + self.status_message = Some("Wait for the pending edit to finish".to_owned()); + } else if let Some(chat) = self.active_chat_id { + self.draft_data_mut(chat).edit = None; + self.mode = Mode::Navigate; + self.status_message = Some("Local edit discarded".to_owned()); + } + return Some(Vec::new()); + } + if self.mode != Mode::Edit { + return None; + } + match action { + Action::Open | Action::Send => Some(self.save_message_edit()), + Action::Cancel => Some(self.edit_message_input(KeyAction::Escape)), + Action::Quit + | Action::NextAccount + | Action::AddAccount + | Action::Redraw + | Action::Newline + | Action::Left + | Action::Right + | Action::Home + | Action::End + | Action::Backspace + | Action::Delete + | Action::Clear + | Action::DeleteWord => None, + _ => Some(Vec::new()), + } + } + + pub(super) fn edit_message_input(&mut self, action: KeyAction) -> Vec { + if action == KeyAction::Escape { + if self.editing.pending.is_some_and(|(_, _, saving)| !saving) { + self.editing.pending = None; + } + self.mode = Mode::Navigate; + self.status_message = self + .message_edit() + .map(|_| "Edit kept locally · e or :edit to resume".to_owned()); + return Vec::new(); + } + if action == KeyAction::Enter { + return self.save_message_edit(); + } + if action == KeyAction::Redraw { + self.force_redraw = true; + } + if self.editing.pending.is_some() { + return Vec::new(); + } + let Some(chat) = self.active_chat_id else { + return Vec::new(); + }; + let Some(edit) = &mut self.draft_data_mut(chat).edit else { + return Vec::new(); + }; + let input = &mut edit.input; + match action { + KeyAction::Character(c) => input.insert(c), + KeyAction::Newline => input.insert('\n'), + KeyAction::Backspace => _ = input.backspace(), + KeyAction::Delete => _ = input.delete(), + KeyAction::Left => _ = input.move_left(), + KeyAction::Right => _ = input.move_right(), + KeyAction::Home => input.move_home(), + KeyAction::End => input.move_end(), + KeyAction::Clear => input.clear(), + KeyAction::DeleteWord => _ = input.delete_word_before(), + _ => {} + } + Vec::new() + } +} diff --git a/src/app/entities.rs b/src/app/entities.rs new file mode 100644 index 0000000..a8ff0a4 --- /dev/null +++ b/src/app/entities.rs @@ -0,0 +1,79 @@ +use super::AppState; +use crate::{entities::Kind, model::Message}; +use std::hash::{DefaultHasher, Hash, Hasher}; + +#[derive(Clone, Default)] +pub(super) struct Visibility { + revision: u64, + spoilers: bool, + quotes: bool, +} + +fn revision(message: &Message) -> u64 { + let mut hash = DefaultHasher::new(); + message.text.hash(&mut hash); + message.entities.hash(&mut hash); + if let Some(poll) = &message.poll { + poll.definition.hash(&mut hash); + } + hash.finish() +} + +impl AppState { + #[must_use] + pub fn spoilers_revealed(&self, message: &Message) -> bool { + self.text_visibility + .get(&(message.chat_id, message.id)) + .is_some_and(|state| state.spoilers && state.revision == revision(message)) + } + + #[must_use] + pub fn quotes_expanded(&self, message: &Message) -> bool { + self.text_visibility + .get(&(message.chat_id, message.id)) + .is_some_and(|state| state.quotes && state.revision == revision(message)) + } + + pub(super) fn toggle_text_details(&mut self, spoiler: bool) { + let Some(message) = self.inspected_message() else { + return; + }; + let available = if spoiler { + message.has_spoilers() + } else { + message.entities.iter().any(|entity| { + matches!(entity.kind, Kind::Quote { collapsed: true }) + && entity.valid_for(&message.text) + }) + }; + if !available { + self.status_message = Some( + if spoiler { + "Selected message has no spoiler" + } else { + "Selected message has no expandable quote" + } + .to_owned(), + ); + return; + } + let key = (message.chat_id, message.id); + let revision = revision(message); + if self.text_visibility.len() >= 256 && !self.text_visibility.contains_key(&key) { + self.text_visibility.pop_first(); + } + let state = self.text_visibility.entry(key).or_default(); + if state.revision != revision { + *state = Visibility { + revision, + ..Visibility::default() + }; + } + if spoiler { + state.spoilers = !state.spoilers; + } else { + state.quotes = !state.quotes; + } + self.status_message = None; + } +} diff --git a/src/app/forwarding.rs b/src/app/forwarding.rs new file mode 100644 index 0000000..ff15d1b --- /dev/null +++ b/src/app/forwarding.rs @@ -0,0 +1,299 @@ +use super::{App, Focus, Mode, Screen, TelegramCommand}; +use crate::{ + actions::Action, + event::{ConnectionStatus, NetworkEvent}, + forwarding::Plan, + model::{Chat, sanitize_terminal_line}, +}; + +#[derive(Clone, Default)] +pub struct State { + pub review: Option, + next_request: u64, + pending: Option<(u64, bool)>, + saved: Option, +} + +#[derive(Clone)] +pub struct Review { + pub source: i64, + pub message: i32, + pub target: i64, + pub source_title: String, + pub target_title: String, + pub plan: Option, + pub error: Option, + invalid: bool, +} + +impl App { + pub(super) fn open_saved(&mut self) -> Vec { + if self.screen != Screen::Main || self.mode != Mode::Navigate { + return Vec::new(); + } + if let Some(id) = self + .account_user_id + .filter(|id| self.chats.iter().any(|chat| chat.id == *id)) + { + return self.open_chat_by_id(id); + } + if self.connection != ConnectionStatus::Online { + self.status_message = Some("Connect to Telegram to open Saved Messages".to_owned()); + return Vec::new(); + } + if self.forwarding.saved.is_some() { + return Vec::new(); + } + self.forwarding.next_request += 1; + let request_id = self.forwarding.next_request; + self.forwarding.saved = Some(request_id); + self.status_message = Some("Opening Saved Messages…".to_owned()); + vec![TelegramCommand::OpenSaved { request_id }] + } + + pub(super) fn saved_ready( + &mut self, + request_id: u64, + result: Result, + ) -> Vec { + if self.forwarding.saved != Some(request_id) { + return Vec::new(); + } + self.forwarding.saved = None; + match result { + Ok(chat) if Some(chat.id) == self.account_user_id => { + self.open_resolved_link(chat, None) + } + Ok(_) => { + self.status_message = + Some("Saved Messages belongs to a different account".to_owned()); + Vec::new() + } + Err(error) => { + self.status_message = Some(sanitize_terminal_line(&error)); + Vec::new() + } + } + } + + pub(super) fn review_forward(&mut self, target: i64) -> Vec { + if self.screen != Screen::Main + || self.mode != Mode::Navigate + || self.focus != Focus::Conversation + { + return Vec::new(); + } + if self.forwarding.pending.is_some() { + self.status_message = Some("A forward request is still in progress".to_owned()); + return Vec::new(); + } + let Some(source) = self.active_chat_id else { + return Vec::new(); + }; + let Some(message) = self.selected_message.filter(|id| *id > 0) else { + self.status_message = Some("Select a delivered message to forward".to_owned()); + return Vec::new(); + }; + if self.connection != ConnectionStatus::Online { + self.status_message = Some("Connect to Telegram to forward messages".to_owned()); + return Vec::new(); + } + let source_title = self + .active_chat() + .map_or_else(|| "Conversation".to_owned(), |chat| chat.title.clone()); + let target_title = if Some(target) == self.account_user_id { + "Saved Messages".to_owned() + } else { + self.chats + .iter() + .find(|chat| chat.id == target) + .map_or_else(|| target.to_string(), |chat| chat.title.clone()) + }; + self.forwarding.review = Some(Review { + source, + message, + target, + source_title, + target_title, + plan: None, + error: None, + invalid: false, + }); + self.forwarding.next_request += 1; + let request_id = self.forwarding.next_request; + self.forwarding.pending = Some((request_id, false)); + self.mode = Mode::ForwardPrompt; + self.status_message = None; + vec![TelegramCommand::ReviewForward { + chat_id: source, + message_id: message, + destination: target, + request_id, + }] + } + + pub(super) fn forward_ready(&mut self, request_id: u64, result: Result) { + if self.forwarding.pending != Some((request_id, false)) { + return; + } + self.forwarding.pending = None; + let Some(review) = &mut self.forwarding.review else { + return; + }; + if review.invalid { + return; + } + match result { + Ok(plan) + if plan.message.chat_id == review.source + && plan.message.id == review.message + && plan.destination.id == review.target => + { + review.target_title.clone_from(&plan.destination.title); + review.plan = Some(plan); + } + Ok(_) => review.error = Some("Forward target changed; reopen the preview".to_owned()), + Err(error) => review.error = Some(sanitize_terminal_line(&error)), + } + } + + fn send_forward(&mut self) -> Vec { + if self.forwarding.pending.is_some() { + return Vec::new(); + } + let Some(review) = &self.forwarding.review else { + return Vec::new(); + }; + let Some(plan) = &review.plan else { + return Vec::new(); + }; + if review.invalid { + return Vec::new(); + } + if self.connection != ConnectionStatus::Online { + self.status_message = Some("Connect to Telegram before forwarding".to_owned()); + return Vec::new(); + } + self.forwarding.next_request += 1; + let request_id = self.forwarding.next_request; + self.forwarding.pending = Some((request_id, true)); + self.status_message = Some("Forwarding…".to_owned()); + vec![TelegramCommand::ForwardMessage { + chat_id: review.source, + message_id: review.message, + destination: review.target, + revision: plan.revision, + random_id: plan.random_id, + request_id, + }] + } + + pub(super) fn forward_finished(&mut self, request_id: u64, error: Option) { + if self.forwarding.pending != Some((request_id, true)) { + return; + } + self.forwarding.pending = None; + let Some(review) = &mut self.forwarding.review else { + return; + }; + if let Some(error) = error { + let error = sanitize_terminal_line(&error); + review.error = Some(error.clone()); + self.status_message = Some(error); + } else { + self.status_message = Some(format!("Forwarded to {}", review.target_title)); + self.forwarding.review = None; + if self.mode == Mode::ForwardPrompt { + self.mode = Mode::Navigate; + } + } + } + + #[must_use] + pub fn forward_pending(&self) -> bool { + self.forwarding.pending.is_some() + } + + pub(super) fn forwarding_binding(&mut self, action: &Action) -> Option> { + if self.mode == Mode::ForwardPrompt { + match action { + Action::Send => return Some(self.send_forward()), + Action::Cancel => { + self.mode = Mode::Navigate; + if self.forwarding.pending.is_some_and(|(_, sending)| sending) { + self.status_message = + Some("Forward request is running in the background".to_owned()); + } else { + self.forwarding.pending = None; + self.forwarding.review = None; + self.status_message = None; + } + } + Action::Quit | Action::Redraw | Action::NextAccount | Action::AddAccount => { + return None; + } + _ => {} + } + return Some(Vec::new()); + } + match action { + Action::SavedMessages => Some(self.open_saved()), + Action::SaveMessage => Some( + self.account_user_id + .map_or_else(Vec::new, |id| self.review_forward(id)), + ), + Action::ForwardMessage => { + if self.mode != Mode::Navigate || self.focus != Focus::Conversation { + return Some(Vec::new()); + } + if self + .forwarding + .review + .as_ref() + .is_some_and(|review| review.error.is_some()) + { + self.mode = Mode::ForwardPrompt; + return Some(Vec::new()); + } + self.begin_command(); + self.commands.input.set_value("forward "); + Some(Vec::new()) + } + _ => None, + } + } + + pub(super) fn observe_forward(&mut self, event: &NetworkEvent) { + let Some(review) = &mut self.forwarding.review else { + return; + }; + let changed = match event { + NetworkEvent::MessageUpdated(message) => { + message.chat_id == review.source + && message.id == review.message + && review.plan.as_ref().is_none_or(|plan| { + message.text != plan.message.text + || message.edited_at != plan.message.edited_at + || message.attachment != plan.message.attachment + }) + } + NetworkEvent::MessagesDeleted { + channel_id, + message_ids, + } => { + channel_id.map_or(review.source > -1_000_000_000_000, |chat| { + chat == review.source + }) && message_ids.contains(&review.message) + } + NetworkEvent::CacheInvalidated { chat_id } => { + chat_id.is_none_or(|chat| chat == review.source) + } + _ => false, + }; + if changed { + review.invalid = true; + review.error = + Some("Original changed or was removed; close and reopen forwarding".to_owned()); + } + } +} diff --git a/src/app/invites.rs b/src/app/invites.rs new file mode 100644 index 0000000..e974228 --- /dev/null +++ b/src/app/invites.rs @@ -0,0 +1,170 @@ +use super::{App, Mode, Screen, TelegramCommand}; +use crate::{ + actions::Action, + event::{ConnectionStatus, NetworkEvent}, + invites::{Outcome, Preview}, + model::{sanitize_terminal_line, sanitize_terminal_text}, +}; + +#[derive(Clone, Default)] +pub struct State { + pub preview: Option, + pub error: Option, + pub selected: usize, + pub hit_regions: Vec<(u16, u16, u16, usize)>, + hash: String, + next_request: u64, + pending: Option<(u64, bool)>, + settled: bool, +} + +impl App { + pub(super) fn begin_invite(&mut self, hash: String) -> Vec { + if self.screen != Screen::Main || self.connection != ConnectionStatus::Online { + self.status_message = Some("Connect to Telegram to preview this invite".to_owned()); + return Vec::new(); + } + if self.invites.pending.is_some_and(|(_, joining)| joining) { + self.status_message = Some("A join request is still running".to_owned()); + return Vec::new(); + } + let request_id = self.invites.next_request + 1; + self.invites = State { + hash: hash.clone(), + next_request: request_id, + pending: Some((request_id, false)), + ..State::default() + }; + self.pending_telegram_link = None; + self.mode = Mode::Invite; + self.status_message = None; + vec![TelegramCommand::PreviewInvite { hash, request_id }] + } + + #[must_use] + pub fn invite_action(&self) -> Option<&'static str> { + if self.invites.settled { + return None; + } + let preview = self + .invites + .preview + .as_ref() + .filter(|p| p.blocked.is_none())?; + Some(if preview.joined.is_some() { + "Open chat" + } else if preview.request_needed { + "Request to join" + } else { + "Join group" + }) + } + + #[must_use] + pub fn invite_pending(&self) -> bool { + self.invites.pending.is_some() + } + + pub(super) fn invite_binding(&mut self, action: &Action) -> Option> { + if self.mode != Mode::Invite { + return None; + } + match action { + Action::Cancel => self.close_invite(), + Action::Up => self.invites.selected = 0, + Action::Down if self.invite_action().is_some() => self.invites.selected = 1, + Action::Open if self.invites.selected == 0 => self.close_invite(), + Action::Open if !self.invite_pending() => { + if self.invite_action().is_none() { + return Some(Vec::new()); + } + let preview = self.invites.preview.as_ref().expect("available action"); + if let Some(chat) = preview.joined.clone() { + self.close_invite(); + return Some(self.open_resolved_link(chat, None)); + } + if self.connection != ConnectionStatus::Online { + self.invites.error = Some("Connect to Telegram before joining".to_owned()); + return Some(Vec::new()); + } + self.invites.next_request += 1; + let request_id = self.invites.next_request; + self.invites.pending = Some((request_id, true)); + self.invites.error = None; + return Some(vec![TelegramCommand::JoinInvite { + hash: self.invites.hash.clone(), + title: preview.title.clone(), + request_needed: preview.request_needed, + request_id, + }]); + } + Action::Quit | Action::NextAccount | Action::AddAccount | Action::Redraw => { + return None; + } + _ => {} + } + Some(Vec::new()) + } + + fn close_invite(&mut self) { + self.mode = Mode::Navigate; + self.force_redraw = true; + if self.invites.pending.is_some_and(|(_, joining)| joining) { + self.status_message = Some("Join request continues in the background".to_owned()); + } else { + self.invites = State { + next_request: self.invites.next_request, + ..State::default() + }; + } + } + + pub(super) fn observe_invite(&mut self, event: NetworkEvent) -> Vec { + match event { + NetworkEvent::InviteReady { request_id, result } + if self.invites.pending == Some((request_id, false)) => + { + self.invites.pending = None; + match result { + Ok(mut preview) => { + preview.title = sanitize_terminal_line(&preview.title); + preview.about = sanitize_terminal_text(&preview.about); + if let Some(chat) = &mut preview.joined { + chat.title = sanitize_terminal_line(&chat.title); + } + self.invites.preview = Some(preview); + } + Err(error) => self.invites.error = Some(sanitize_terminal_line(&error)), + } + } + NetworkEvent::InviteJoined { request_id, result } + if self.invites.pending == Some((request_id, true)) => + { + self.invites.pending = None; + self.invites.selected = 0; + self.invites.settled = true; + let notice = match result { + Ok(Outcome::Joined(chat)) => { + if self.mode == Mode::Invite { + self.close_invite(); + return self.open_resolved_link(chat, None); + } + "Joined group; open it from your chats".to_owned() + } + Ok(Outcome::Requested) => { + "Join request submitted; waiting for an administrator".to_owned() + } + Ok(Outcome::Verification) => { + "Complete the required verification in the official Telegram client" + .to_owned() + } + Err(error) => sanitize_terminal_line(&error), + }; + self.invites.error = Some(notice.clone()); + self.status_message = Some(notice); + } + _ => {} + } + Vec::new() + } +} diff --git a/src/app/message_pins.rs b/src/app/message_pins.rs new file mode 100644 index 0000000..513cd23 --- /dev/null +++ b/src/app/message_pins.rs @@ -0,0 +1,568 @@ +use super::{App, Focus, Mode, TelegramCommand}; +use crate::{ + event::ConnectionStatus, + model::{ChatId, ChatKind, Message, sanitize_terminal_line}, + pins::{MessageAction, MessagePage}, +}; + +#[derive(Clone, Default)] +pub struct State { + pub chat: Option, + pub page: MessagePage, + pub selected: usize, + pub loading: bool, + pub error: Option, + pub prompt: Option, + pub head: Option<(ChatId, MessagePage)>, + pub starts: Vec, + request_id: u64, + next_request: u64, + pending_mutation: Option<(ChatId, u64)>, + dirty: bool, + opening: Option, +} + +#[derive(Clone)] +pub struct Prompt { + pub chat_id: ChatId, + pub message_id: i32, + pub title: String, + pub preview: String, + pub options: Vec<(String, MessageAction)>, + pub selection: usize, + previous_mode: Mode, +} + +impl App { + pub(super) fn invalidate_message_pins( + &mut self, + chat_id: Option, + ) -> Vec { + let affected = |id: ChatId| chat_id.is_none_or(|chat| chat == id); + let state = &mut self.message_pins; + if state.head.as_ref().is_some_and(|(id, _)| affected(*id)) { + state.head = None; + } + if let Some(prompt) = &state.prompt + && affected(prompt.chat_id) + { + if self.mode == Mode::PinPrompt { + self.mode = prompt.previous_mode; + } + state.prompt = None; + } + if !state.chat.is_some_and(affected) { + return Vec::new(); + } + // In-flight mutations still complete, but old reads must not restore + // invalidated messages. Keep next_request monotonic across the reset. + state.request_id = 0; + state.page = MessagePage::default(); + state.starts = vec![0]; + state.selected = 0; + state.opening = None; + state.loading = false; + state.dirty = false; + state.error = None; + if self.mode == Mode::PinnedMessages { + self.load_pin_page(0) + } else { + Vec::new() + } + } + + pub(super) fn update_pin_preview(&mut self, message: &Message) { + let state = &mut self.message_pins; + if let Some((chat_id, page)) = &mut state.head + && *chat_id == message.chat_id + { + for current in &mut page.messages { + if current.id == message.id { + *current = message.clone(); + } + } + } + if state.chat == Some(message.chat_id) { + for current in &mut state.page.messages { + if current.id == message.id { + *current = message.clone(); + } + } + state.dirty |= state.loading && message.pinned; + } + } + pub(super) fn pin_context_chat(&self) -> Option { + (self.mode == Mode::PinnedMessages && self.message_pins.opening.is_some()) + .then_some(self.message_pins.chat) + .flatten() + } + pub(super) fn fail_pinned_messages(&mut self, chat_id: ChatId, request_id: u64, error: &str) { + if request_id != 0 + && self.message_pins.chat == Some(chat_id) + && self.message_pins.request_id == request_id + { + self.message_pins.loading = false; + self.message_pins.opening = None; + self.message_pins.error = Some(sanitize_terminal_line(error)); + } + } + + #[must_use] + pub fn pinned_summary(&self) -> Option<(&Message, Option)> { + if let Some((chat, page)) = &self.message_pins.head + && Some(*chat) == self.active_chat_id + { + return page.messages.first().map(|message| (message, page.total)); + } + self.active_messages() + .iter() + .rev() + .find(|message| message.pinned) + .map(|message| (message, None)) + } + pub(super) fn open_pinned_messages(&mut self) -> Vec { + let Some(chat_id) = self.active_chat_id else { + return Vec::new(); + }; + self.message_pins.chat = Some(chat_id); + self.message_pins.starts = vec![0]; + self.message_pins.page = MessagePage::default(); + self.mode = Mode::PinnedMessages; + self.load_pin_page(0) + } + + fn load_pin_page(&mut self, before: i32) -> Vec { + let Some(chat_id) = self.message_pins.chat else { + return Vec::new(); + }; + let state = &mut self.message_pins; + state.next_request = state.next_request.wrapping_add(1).max(1); + state.request_id = state.next_request; + state.loading = true; + state.dirty = false; + state.error = None; + state.opening = None; + state.selected = 0; + vec![TelegramCommand::LoadPinnedMessages { + chat_id, + before, + request_id: state.request_id, + }] + } + + pub(super) fn pin_binding( + &mut self, + action: &str, + count: usize, + ) -> Option> { + if self.mode == Mode::PinPrompt { + return self.pin_prompt_binding(action); + } + if self.mode != Mode::PinnedMessages { + return None; + } + match action { + "cancel" => { + self.mode = Mode::Navigate; + self.message_pins.request_id = 0; + self.message_pins.loading = false; + self.message_pins.opening = None; + } + "up" | "down" | "page_up" | "page_down" => { + let state = &mut self.message_pins; + let step = if action.starts_with("page_") { + count.saturating_mul(10) + } else { + count + }; + state.selected = if action.ends_with("up") { + state.selected.saturating_sub(step) + } else { + state + .selected + .saturating_add(step) + .min(state.page.messages.len().saturating_sub(1)) + }; + } + "pins_more" if !self.message_pins.loading => { + if let Some(next) = self.message_pins.page.next { + self.message_pins.starts.push(next); + return Some(self.load_pin_page(next)); + } + } + "pins_previous" if !self.message_pins.loading && self.message_pins.starts.len() > 1 => { + self.message_pins.starts.pop(); + return Some(self.load_pin_page(*self.message_pins.starts.last().unwrap_or(&0))); + } + "refresh" => { + return Some(self.load_pin_page(*self.message_pins.starts.last().unwrap_or(&0))); + } + "pin" => return Some(self.begin_message_pin(false)), + "unpin_all" => return Some(self.begin_message_pin(true)), + "open" => { + if let Some(message) = self + .message_pins + .page + .messages + .get(self.message_pins.selected) + { + let (chat_id, message_id) = (message.chat_id, message.id); + self.message_pins.opening = Some(message_id); + self.active_history_request = None; + self.history_changes.clear(); + self.history_read_max = 0; + self.message_pins.next_request = + self.message_pins.next_request.wrapping_add(1).max(1); + self.message_pins.request_id = self.message_pins.next_request; + self.message_pins.loading = true; + self.message_pins.error = None; + return Some(vec![TelegramCommand::LoadPinnedContext { + chat_id, + message_id, + request_id: self.message_pins.request_id, + }]); + } + } + _ => return None, + } + Some(Vec::new()) + } + + #[allow(clippy::too_many_lines)] + pub(super) fn begin_message_pin(&mut self, all: bool) -> Vec { + if self.connection != ConnectionStatus::Online { + self.status_message = Some("Connect to Telegram to change message pins".to_owned()); + return Vec::new(); + } + if self.message_pins.pending_mutation.is_some() { + return Vec::new(); + } + let message = if self.mode == Mode::PinnedMessages { + self.message_pins + .page + .messages + .get(self.message_pins.selected) + } else if self.mode == Mode::Navigate && self.focus == Focus::Conversation { + self.active_messages() + .iter() + .find(|message| Some(message.id) == self.selected_message) + } else { + return Vec::new(); + }; + let Some(message) = message.filter(|message| message.id > 0) else { + self.status_message = Some("Select a sent message first".to_owned()); + return Vec::new(); + }; + let private = self + .active_chat() + .is_some_and(|chat| chat.kind == ChatKind::Direct); + let own = self.account_user_id == Some(message.chat_id); + let old = self + .message_pins + .head + .as_ref() + .filter(|(chat, _)| *chat == message.chat_id) + .and_then(|(_, page)| page.messages.first()) + .is_some_and(|head| head.id > message.id); + let (title, options) = if all { + ( + "Unpin all messages?", + vec![("Unpin all messages".to_owned(), MessageAction::UnpinAll)], + ) + } else if message.pinned { + ( + "Unpin this message?", + vec![("Unpin message".to_owned(), MessageAction::Unpin)], + ) + } else if private && !own { + ( + "Pin this message?", + vec![ + ( + "Pin only for me".to_owned(), + MessageAction::Pin { + notify: false, + only_self: true, + }, + ), + ( + "Pin for both participants".to_owned(), + MessageAction::Pin { + notify: false, + only_self: false, + }, + ), + ], + ) + } else if !private && !old { + ( + "Pin this message?", + vec![ + ( + "Pin and notify members".to_owned(), + MessageAction::Pin { + notify: true, + only_self: false, + }, + ), + ( + "Pin silently".to_owned(), + MessageAction::Pin { + notify: false, + only_self: false, + }, + ), + ], + ) + } else { + ( + if old { + "Pin this older message?" + } else { + "Pin this message?" + }, + vec![( + "Pin message".to_owned(), + MessageAction::Pin { + notify: false, + only_self: false, + }, + )], + ) + }; + self.message_pins.prompt = Some(Prompt { + chat_id: message.chat_id, + message_id: message.id, + title: title.to_owned(), + preview: sanitize_terminal_line(&format!( + "#{} {}: {}", + message.id, message.sender, message.text + )), + options, + selection: 0, + previous_mode: self.mode, + }); + self.message_pins.error = None; + self.mode = Mode::PinPrompt; + Vec::new() + } + + fn pin_prompt_binding(&mut self, action: &str) -> Option> { + if action == "cancel" { + if let Some(prompt) = self.message_pins.prompt.take() { + self.mode = prompt.previous_mode; + } + return Some(Vec::new()); + } + let Some(prompt) = self.message_pins.prompt.as_mut() else { + return Some(Vec::new()); + }; + if self.message_pins.pending_mutation.is_some() { + return Some(Vec::new()); + } + match action { + "up" => prompt.selection = prompt.selection.saturating_sub(1), + "down" => { + prompt.selection = + (prompt.selection + 1).min(prompt.options.len().saturating_sub(1)); + } + "open" => { + self.message_pins.next_request = + self.message_pins.next_request.wrapping_add(1).max(1); + let request_id = self.message_pins.next_request; + self.message_pins.pending_mutation = Some((prompt.chat_id, request_id)); + self.message_pins.error = None; + self.status_message = Some("Updating pinned messages…".to_owned()); + return Some(vec![TelegramCommand::ChangeMessagePin { + chat_id: prompt.chat_id, + message_id: prompt.message_id, + action: prompt.options[prompt.selection].1, + request_id, + }]); + } + _ => return None, + } + Some(Vec::new()) + } + + pub(super) fn finish_message_pin( + &mut self, + chat_id: ChatId, + request_id: u64, + error: Option, + ) -> Vec { + if self.message_pins.pending_mutation != Some((chat_id, request_id)) { + return Vec::new(); + } + self.message_pins.pending_mutation = None; + if let Some(error) = error { + let error = sanitize_terminal_line(&error); + self.message_pins.error = Some(error.clone()); + self.status_message = Some(error); + return Vec::new(); + } + if let Some(prompt) = self.message_pins.prompt.take() + && self.mode == Mode::PinPrompt + { + self.mode = prompt.previous_mode; + } + self.status_message = Some("Pinned messages updated".to_owned()); + if self.mode == Mode::PinnedMessages { + self.message_pins.starts = vec![0]; + return self.load_pin_page(0); + } + Vec::new() + } + + pub(super) fn finish_pinned_messages( + &mut self, + chat_id: ChatId, + request_id: u64, + page: MessagePage, + ) -> Vec { + if !self.message_pins.dirty + && page.before == 0 + && self.active_chat_id == Some(chat_id) + && (request_id == 0 + || (self.mode == Mode::PinnedMessages + && self.message_pins.request_id == request_id)) + { + self.message_pins.head = Some((chat_id, page.clone())); + } + if request_id == 0 + || self.message_pins.chat != Some(chat_id) + || self.message_pins.request_id != request_id + { + return Vec::new(); + } + if self.message_pins.dirty && page.total.is_some() { + self.message_pins.starts = vec![0]; + return self.load_pin_page(0); + } + self.message_pins.loading = + page.total.is_none() && self.connection == ConnectionStatus::Online; + self.message_pins.page = page; + self.message_pins.selected = self + .message_pins + .selected + .min(self.message_pins.page.messages.len().saturating_sub(1)); + Vec::new() + } + + pub(super) fn pinned_messages_deleted( + &mut self, + channel_id: Option, + ids: &[i32], + ) -> Vec { + let mut commands = Vec::new(); + let mut chats = self + .message_pins + .chat + .into_iter() + .chain(self.message_pins.head.as_ref().map(|(id, _)| *id)) + .collect::>(); + chats.sort_unstable(); + chats.dedup(); + for chat_id in chats { + if channel_id.map_or(chat_id > -1_000_000_000_000, |id| id == chat_id) { + commands.extend(self.pin_messages_changed(chat_id, Some(ids), false)); + } + } + commands + } + + pub(super) fn pin_messages_changed( + &mut self, + chat_id: ChatId, + ids: Option<&[i32]>, + pinned: bool, + ) -> Vec { + let affected = |id: i32| ids.is_none_or(|ids| ids.contains(&id)); + let changed: Vec<_> = self + .messages + .get_mut(&chat_id) + .into_iter() + .flatten() + .filter(|message| affected(message.id)) + .map(|message| { + message.pinned = pinned; + message.clone() + }) + .collect(); + for message in changed { + self.track_snapshot_changes(&crate::event::NetworkEvent::MessageUpdated(message)); + } + if let Some((chat, page)) = &mut self.message_pins.head + && *chat == chat_id + && !pinned + { + page.messages.retain(|message| !affected(message.id)); + } + if self.message_pins.chat == Some(chat_id) { + if !pinned { + self.message_pins + .page + .messages + .retain(|message| !affected(message.id)); + } + if self.message_pins.loading { + self.message_pins.dirty = true; + } else if self.mode == Mode::PinnedMessages { + self.message_pins.starts = vec![0]; + return self.load_pin_page(0); + } + } + Vec::new() + } + + pub(super) fn finish_pinned_context( + &mut self, + chat_id: ChatId, + message_id: i32, + request_id: u64, + mut messages: Vec, + ) { + if self.mode != Mode::PinnedMessages + || self.message_pins.chat != Some(chat_id) + || self.message_pins.request_id != request_id + { + return; + } + messages.retain(|message| !self.history_changes.contains_key(&message.id)); + messages.extend( + self.history_changes + .values() + .flatten() + .filter(|message| message.id <= message_id) + .cloned(), + ); + if !messages.iter().any(|message| message.id == message_id) { + self.fail_pinned_messages(chat_id, request_id, "Pinned message was deleted"); + return; + } + self.message_pins.opening = None; + self.message_pins.loading = false; + self.mode = Mode::Navigate; + self.clear_unread_navigation(); + self.focus = Focus::Conversation; + self.narrow_conversation = true; + self.active_history_request = None; + self.older_motion = None; + self.loading_history = false; + self.history_target_message = None; + self.history_changes.clear(); + self.merge_history(chat_id, messages, None); + self.browsing_older = true; + self.new_messages_while_scrolled = 0; + self.new_messages_to_anchor = 0; + self.select_message_id(message_id, true); + self.message_scroll = self.message_scroll.max(1); + self.status_message = Some(format!( + "Pinned message · {} latest · {} pins", + self.keymap + .hint(crate::keymap::Context::Conversation, "latest"), + self.keymap + .hint(crate::keymap::Context::Conversation, "pins") + )); + } +} diff --git a/src/app/message_views.rs b/src/app/message_views.rs new file mode 100644 index 0000000..4f560ba --- /dev/null +++ b/src/app/message_views.rs @@ -0,0 +1,54 @@ +use super::App; +use crate::model::Message; + +impl App { + /// Partial content updates address one Telegram identity in every loaded + /// view, including replies and in-flight history overlays. + pub(super) fn visit_messages(&mut self, mut visit: impl FnMut(&mut Message)) { + for message in self.messages.values_mut().flatten() { + visit(message); + } + for message in self.history_changes.values_mut().flatten() { + visit(message); + } + for message in &mut self.message_pins.page.messages { + visit(message); + } + if let Some((_, page)) = &mut self.message_pins.head { + for message in &mut page.messages { + visit(message); + } + } + if let Some(page) = &mut self.search.page { + for message in page.messages_mut() { + visit(message); + } + } + self.replies.visit_messages(&mut visit); + if let Some(plan) = self + .forwarding + .review + .as_mut() + .and_then(|review| review.plan.as_mut()) + { + visit(&mut plan.message); + } + if let Some(plan) = self + .deletion + .prompt + .as_mut() + .and_then(|prompt| prompt.plan.as_mut()) + { + visit(&mut plan.message); + } + for chat in &mut self.chats { + if let Some(message) = self.messages.get(&chat.id).and_then(|messages| { + messages + .iter() + .find(|message| Some(message.id) == chat.last_message_id) + }) { + chat.last_message = message.preview_text(); + } + } + } +} diff --git a/src/app/notifications.rs b/src/app/notifications.rs new file mode 100644 index 0000000..3d8a071 --- /dev/null +++ b/src/app/notifications.rs @@ -0,0 +1,99 @@ +use super::{App, Focus, Mode, Screen, TelegramCommand}; +use crate::{event::ConnectionStatus, model::ChatId, notifications::Mute}; +use std::collections::BTreeMap; + +#[derive(Clone, Default)] +pub struct State { + next_request: u64, + pending: BTreeMap, +} + +impl App { + pub(super) fn mute_focused_chat(&mut self, mute: Mute) -> Vec { + if self.screen != Screen::Main || self.mode != Mode::Navigate { + return Vec::new(); + } + let id = if self.focus == Focus::Chats { + self.selected_chat_entry().map(|chat| chat.id) + } else { + self.active_chat_id + }; + id.map_or_else(Vec::new, |id| self.set_chat_mute(id, mute)) + } + + pub(super) fn set_chat_mute(&mut self, chat_id: ChatId, mute: Mute) -> Vec { + if self.connection != ConnectionStatus::Online { + self.status_message = Some("Connect to Telegram first".to_owned()); + return Vec::new(); + } + if self.notifications.pending.contains_key(&chat_id) { + self.status_message = + Some("This chat's notification settings are still updating".to_owned()); + return Vec::new(); + } + self.notifications.next_request = self.notifications.next_request.wrapping_add(1); + let request_id = self.notifications.next_request; + self.notifications.pending.insert(chat_id, request_id); + self.status_message = Some("Saving Telegram notification settings…".to_owned()); + vec![TelegramCommand::SetChatMute { + chat_id, + mute, + request_id, + }] + } + + pub(super) fn finish_chat_mute( + &mut self, + chat_id: ChatId, + request_id: u64, + result: Result, String>, + ) { + if self.notifications.pending.get(&chat_id) != Some(&request_id) { + return; + } + self.notifications.pending.remove(&chat_id); + match result { + Ok(until) => { + if let Some(until) = until { + self.apply_chat_mute(chat_id, until); + } + let title = self + .chats + .iter() + .find(|chat| chat.id == chat_id) + .map_or_else(|| chat_id.to_string(), |chat| chat.title.clone()); + self.status_message = Some(format!( + "{title} · {}", + until.map_or_else( + || "Notification setting saved; syncing…".to_owned(), + |until| crate::notifications::mute_label( + until, + chrono::Utc::now().timestamp() + ) + ) + )); + } + Err(error) => self.status_message = Some(crate::model::sanitize_terminal_line(&error)), + } + } + + pub(super) fn apply_chat_mute(&mut self, chat_id: ChatId, until: i64) { + let selected = self.selected_chat_entry().map(|chat| chat.id); + if let Some(chat) = self.chats.iter_mut().find(|chat| chat.id == chat_id) { + chat.membership.mute_until = until; + } + self.preserve_chat_selection(selected); + } + + #[must_use] + pub fn focused_mute_label(&self) -> Option { + let chat = if self.focus == Focus::Chats { + self.selected_chat_entry() + } else { + self.active_chat() + }?; + let now = chrono::Utc::now().timestamp(); + (chat.membership.mute_until > now) + .then(|| crate::notifications::mute_label(chat.membership.mute_until, now)) + } +} diff --git a/src/app/pins.rs b/src/app/pins.rs new file mode 100644 index 0000000..abccaac --- /dev/null +++ b/src/app/pins.rs @@ -0,0 +1,148 @@ +use super::{App, Focus, Mode, TelegramCommand}; +use crate::{ + event::ConnectionStatus, + pins::{DialogAction, DialogPins, DialogScope}, +}; + +#[derive(Clone, Default)] +pub struct State { + pub dialogs: DialogPins, + pending_dialog: Option, + next_request: u64, + archived_action: bool, +} + +impl App { + pub(super) fn toggle_archive(&mut self) -> Vec { + if self.focus != Focus::Chats || self.mode != Mode::Navigate { + return Vec::new(); + } + let Some(chat) = self.selected_chat_entry() else { + return Vec::new(); + }; + let (chat_id, archived) = (chat.id, !chat.membership.archived); + self.set_chat_archived(chat_id, archived) + } + + pub(super) fn set_chat_archived( + &mut self, + chat_id: i64, + archived: bool, + ) -> Vec { + if self.connection != ConnectionStatus::Online { + self.status_message = Some("Connect to Telegram to change the archive".to_owned()); + return Vec::new(); + } + if self.pins.pending_dialog.is_some() { + return Vec::new(); + } + self.pins.next_request += 1; + let request_id = self.pins.next_request; + self.pins.pending_dialog = Some(request_id); + self.pins.archived_action = true; + self.status_message = Some("Updating Telegram archive…".to_owned()); + vec![TelegramCommand::SetArchived { + chat_id, + archived, + request_id, + }] + } + #[must_use] + pub fn chat_pin_position(&self, chat_id: i64) -> Option { + self.chat_pin_position_in(self.folder_id, chat_id) + } + + pub(super) fn chat_pin_position_in(&self, folder: i32, chat_id: i64) -> Option { + let order = match folder { + 0 => &self.pins.dialogs.main, + 1 => &self.pins.dialogs.archive, + id => &self.folders.iter().find(|folder| folder.id == id)?.pinned, + }; + order.iter().position(|id| *id == chat_id) + } + + pub(super) fn change_chat_pin(&mut self, run: &str) -> Vec { + if self.focus != Focus::Chats || self.mode != Mode::Navigate { + return Vec::new(); + } + let Some(chat) = self.selected_chat_entry() else { + return Vec::new(); + }; + let chat_id = chat.id; + let pinned = self.chat_pin_position(chat_id).is_some(); + let action = match run { + "pin" => DialogAction::Set(!pinned), + "pin_up" if pinned => DialogAction::MoveUp, + "pin_down" if pinned => DialogAction::MoveDown, + _ => { + self.status_message = Some("Pin the chat before reordering it".to_owned()); + return Vec::new(); + } + }; + let scope = match self.folder_id { + 0 => DialogScope::Main, + 1 => DialogScope::Archive, + id => DialogScope::Filter(id), + }; + self.request_chat_pin(chat_id, scope, action) + } + + pub(super) fn request_chat_pin( + &mut self, + chat_id: i64, + scope: DialogScope, + action: DialogAction, + ) -> Vec { + if self.connection != ConnectionStatus::Online { + self.status_message = Some("Connect to Telegram to change pins".to_owned()); + return Vec::new(); + } + if self.pins.pending_dialog.is_some() { + return Vec::new(); + } + self.pins.next_request += 1; + let request_id = self.pins.next_request; + self.pins.pending_dialog = Some(request_id); + self.pins.archived_action = false; + self.status_message = Some("Updating Telegram pins…".to_owned()); + vec![TelegramCommand::ChangeDialogPin { + chat_id, + scope, + action, + request_id, + }] + } + + pub(super) fn finish_dialog_pin(&mut self, request_id: u64, error: Option) { + if self.pins.pending_dialog == Some(request_id) { + self.pins.pending_dialog = None; + let subject = if self.pins.archived_action { + "archive" + } else { + "pins" + }; + self.status_message = Some(error.map_or_else( + || format!("Telegram {subject} updated"), + |error| { + format!( + "Could not update {subject}: {}", + crate::model::sanitize_terminal_line(&error) + ) + }, + )); + } + } + + pub(super) fn preserve_chat_selection(&mut self, selected_id: Option) { + self.selected_chat = selected_id + .and_then(|id| { + self.filtered_chat_indices() + .iter() + .position(|&index| self.chats[index].id == id) + }) + .unwrap_or_else(|| { + self.selected_chat + .min(self.filtered_chat_indices().len().saturating_sub(1)) + }); + } +} diff --git a/src/app/polls.rs b/src/app/polls.rs new file mode 100644 index 0000000..6b60ff4 --- /dev/null +++ b/src/app/polls.rs @@ -0,0 +1,593 @@ +use super::{App, Focus, Mode, Screen, TelegramCommand}; +use crate::{ + actions::Action, + event::{ConnectionStatus, NetworkEvent}, + model::ChatId, + polls::{Poll, Update}, +}; +use std::{ + collections::{BTreeMap, BTreeSet}, + time::{Duration, Instant}, +}; + +type Key = (ChatId, i32); + +#[derive(Clone)] +struct Refresh { + request: Option, + due: Option, +} + +#[derive(Clone, Default)] +pub struct State { + pub review: Option, + pub hit_rows: Vec<(u16, u16, u16, usize)>, + visible: Vec, + refresh: BTreeMap, + next_request: u64, +} + +#[derive(Clone)] +pub struct Review { + pub chat: ChatId, + pub message: i32, + pub title: String, + pub poll: Poll, + pub selected: usize, + pub top: usize, + pub follow: bool, + pub choices: BTreeSet>, + pub dirty: bool, + pub ready: bool, + pub error: Option, + pub loading: Option, + pub sending: Option, +} + +impl Review { + fn fail(&mut self, error: impl Into) { + self.error = Some(crate::model::sanitize_terminal_line(&error.into())); + self.top = 0; + self.follow = false; + } + + fn update(&mut self, poll: Poll, loaded: bool) { + let changed = self.poll.definition != poll.definition; + self.poll = poll; + self.selected = self + .selected + .min(self.poll.definition.answers.len().saturating_sub(1)); + if changed || loaded || !self.dirty { + self.choices = self + .poll + .results + .counts + .iter() + .filter(|count| count.chosen) + .map(|count| count.option.clone()) + .collect(); + self.dirty = false; + } + if loaded { + self.ready = !self.poll.stale; + self.error = None; + } else if changed { + self.ready = false; + self.fail("Poll options changed; refresh before voting"); + } + } +} + +impl App { + fn next_poll_request(&mut self) -> u64 { + self.polls.next_request = self.polls.next_request.wrapping_add(1).max(1); + self.polls.next_request + } + + pub(super) fn begin_poll(&mut self) -> Vec { + if self.screen != Screen::Main + || self.mode != Mode::Navigate + || self.focus != Focus::Conversation + { + return Vec::new(); + } + if self + .polls + .review + .as_ref() + .is_some_and(|review| review.sending.is_some()) + { + self.status_message = Some("Wait for the pending vote to finish".to_owned()); + return Vec::new(); + } + let Some(message) = self.inspected_message().filter(|message| message.id > 0) else { + return Vec::new(); + }; + let Some(poll) = message.poll.clone() else { + self.status_message = Some("Select a poll first".to_owned()); + return Vec::new(); + }; + let choices = poll + .results + .counts + .iter() + .filter(|count| count.chosen) + .map(|count| count.option.clone()) + .collect(); + self.polls.review = Some(Review { + chat: message.chat_id, + message: message.id, + title: self + .active_chat() + .map_or_else(|| "Poll".to_owned(), |chat| chat.title.clone()), + poll, + choices, + selected: 0, + top: 0, + follow: false, + dirty: false, + ready: false, + error: None, + loading: None, + sending: None, + }); + self.mode = Mode::Poll; + self.status_message = None; + self.load_poll_review() + } + + fn load_poll_review(&mut self) -> Vec { + if self.connection != ConnectionStatus::Online { + if let Some(review) = &mut self.polls.review { + review.fail("Cached poll · connect to refresh or vote"); + } + return Vec::new(); + } + let request_id = self.next_poll_request(); + let Some(review) = &mut self.polls.review else { + return Vec::new(); + }; + if review.loading.is_some() || review.sending.is_some() { + return Vec::new(); + } + review.ready = false; + review.loading = Some(request_id); + review.error = None; + vec![TelegramCommand::LoadPoll { + chat_id: review.chat, + message_id: review.message, + request_id, + }] + } + + pub(super) fn poll_binding( + &mut self, + action: &Action, + count: usize, + ) -> Option> { + if *action == Action::Poll { + return Some(self.begin_poll()); + } + if self.mode != Mode::Poll { + return None; + } + let mut commands = Vec::new(); + match action { + Action::Cancel => { + self.mode = Mode::Navigate; + if self + .polls + .review + .as_ref() + .is_none_or(|review| review.sending.is_none()) + { + self.polls.review = None; + } + } + Action::Refresh => commands = self.load_poll_review(), + Action::Send => commands = self.submit_vote(), + Action::Open | Action::TogglePollAnswer => self.toggle_poll_answer(), + Action::RetractVote => { + if let Some(review) = &mut self.polls.review + && review.ready + && review.sending.is_none() + { + if let Some(reason) = + review.poll.read_only_reason(chrono::Utc::now().timestamp()) + { + review.fail(reason); + } else if review.poll.voted() { + review.choices.clear(); + review.dirty = true; + review.error = None; + } + } + } + Action::PageUp | Action::PageDown | Action::Home | Action::End => { + if let Some(review) = &mut self.polls.review { + review.follow = false; + review.top = match action { + Action::PageUp => review.top.saturating_sub(8), + Action::PageDown => review.top.saturating_add(8), + Action::Home => 0, + _ => usize::MAX, + }; + } + } + Action::Up | Action::Down => { + if let Some(review) = &mut self.polls.review { + review.follow = true; + if *action == Action::Up { + review.selected = review.selected.saturating_sub(count); + } else { + review.selected = review + .selected + .saturating_add(count) + .min(review.poll.definition.answers.len().saturating_sub(1)); + } + } + } + Action::Spoilers => { + self.toggle_text_details(true); + } + Action::Quit | Action::Redraw => return None, + _ => {} + } + Some(commands) + } + + pub(super) fn toggle_poll_answer(&mut self) { + if self + .inspected_message() + .is_some_and(|message| message.has_spoilers() && !self.spoilers_revealed(message)) + { + self.toggle_text_details(true); + return; + } + let Some(review) = &mut self.polls.review else { + return; + }; + if !review.ready || review.loading.is_some() || review.sending.is_some() { + return; + } + if let Some(reason) = review.poll.read_only_reason(chrono::Utc::now().timestamp()) { + review.fail(reason); + return; + } + if !self + .polls + .hit_rows + .iter() + .any(|row| row.3 == review.selected) + { + review.follow = true; + return; + } + let Some(answer) = review.poll.definition.answers.get(review.selected) else { + return; + }; + if !review.choices.remove(&answer.option) { + if !review.poll.definition.multiple_choice { + review.choices.clear(); + } + review.choices.insert(answer.option.clone()); + } + review.dirty = true; + review.error = None; + } + + fn submit_vote(&mut self) -> Vec { + if self.connection != ConnectionStatus::Online { + if let Some(review) = &mut self.polls.review { + review.fail("Connect to Telegram before voting"); + } + return Vec::new(); + } + let request_id = self.next_poll_request(); + let Some(review) = &mut self.polls.review else { + return Vec::new(); + }; + if !review.ready || review.loading.is_some() || review.sending.is_some() || !review.dirty { + return Vec::new(); + } + let options: Vec<_> = review.choices.iter().cloned().collect(); + let revision = review.poll.definition.revision(); + if let Err(error) = + review + .poll + .validate_vote(revision, &options, chrono::Utc::now().timestamp()) + { + review.fail(error); + return Vec::new(); + } + review.sending = Some(request_id); + review.error = None; + vec![TelegramCommand::VotePoll { + chat_id: review.chat, + message_id: review.message, + poll_id: review.poll.definition.id, + revision, + options, + request_id, + }] + } + + #[allow(clippy::too_many_lines)] + pub(super) fn observe_polls(&mut self, event: &NetworkEvent) -> Option> { + match event { + NetworkEvent::PollChanged(update) => { + self.apply_poll_update(update); + return Some(Vec::new()); + } + NetworkEvent::PollLoading { .. } => return Some(Vec::new()), + NetworkEvent::PollLoaded { + chat_id, + message_id, + request_id, + result, + } => { + if let Ok(poll) = result { + self.replace_poll(poll); + } + self.finish_poll_refresh( + (*chat_id, *message_id), + *request_id, + result.as_ref().err(), + ); + if let Some(review) = &mut self.polls.review + && review.loading == Some(*request_id) + && (review.chat, review.message) == (*chat_id, *message_id) + { + review.loading = None; + match result { + Ok(poll) if poll.definition.id == review.poll.definition.id => { + review.update(poll.clone(), true); + } + Ok(_) => { + review.ready = false; + review.fail("The original poll is no longer available"); + } + Err(error) => { + review.fail(error); + } + } + } + return Some(Vec::new()); + } + NetworkEvent::PollFinished { + chat_id, + message_id, + request_id, + voting, + error, + } => { + if !voting { + self.finish_poll_refresh((*chat_id, *message_id), *request_id, error.as_ref()); + } else if let Some(review) = &mut self.polls.review + && review.sending == Some(*request_id) + && (review.chat, review.message) == (*chat_id, *message_id) + { + review.sending = None; + if let Some(error) = error { + review.ready = false; + review.fail(format!("{error} · refresh before retrying")); + if self.mode != Mode::Poll { + self.status_message.clone_from(&review.error); + } + } else { + review.dirty = false; + self.status_message = Some("Vote saved".to_owned()); + if self.mode == Mode::Poll { + return Some(self.load_poll_review()); + } + self.polls.review = None; + } + } + return Some(Vec::new()); + } + NetworkEvent::MessageUpdated(message) => { + if let Some(review) = &mut self.polls.review + && (review.chat, review.message) == (message.chat_id, message.id) + { + if let Some(poll) = &message.poll { + review.update(poll.clone(), false); + } else { + review.ready = false; + review.fail("The poll was removed"); + } + } + } + NetworkEvent::MessagesDeleted { + channel_id, + message_ids, + } => { + if let Some(review) = &mut self.polls.review + && channel_id.map_or(review.chat > -1_000_000_000_000, |id| id == review.chat) + && message_ids.contains(&review.message) + { + review.ready = false; + review.loading = None; + review.fail("The poll message was deleted"); + } + } + NetworkEvent::CacheInvalidated { chat_id } => { + if let Some(review) = &mut self.polls.review + && chat_id.is_none_or(|id| id == review.chat) + { + review.ready = false; + review.loading = None; + review.fail("History changed; reopen this poll"); + } + } + _ => {} + } + None + } + + fn apply_poll_update(&mut self, update: &Update) { + self.visit_messages(|message| { + if let Some(poll) = &mut message.poll { + poll.apply(update); + } + }); + if let Some(review) = &mut self.polls.review + && review.poll.definition.id == update.id + { + let mut poll = review.poll.clone(); + poll.apply(update); + review.update(poll, false); + } + } + + fn replace_poll(&mut self, fresh: &Poll) { + self.visit_messages(|message| { + if message + .poll + .as_ref() + .is_some_and(|poll| poll.definition.id == fresh.definition.id) + { + message.poll = Some(fresh.clone()); + } + }); + } + + pub fn set_visible_polls(&mut self, ids: Vec) { + self.polls.visible = ids; + } + + fn polls_visible(&self) -> bool { + self.screen == Screen::Main + && self.terminal_focused + && self.connection == ConnectionStatus::Online + && matches!(self.mode, Mode::Navigate | Mode::Compose | Mode::Poll) + } + + pub fn request_visible_polls(&mut self) -> Vec { + if !self.polls_visible() { + self.polls.refresh.retain(|_, slot| slot.request.is_some()); + return Vec::new(); + } + let mut visible = if self.mode == Mode::Poll { + self.polls + .review + .as_ref() + .map(|review| vec![(review.chat, review.message)]) + .unwrap_or_default() + } else { + self.polls.visible.clone() + }; + visible.truncate(32); + self.polls + .refresh + .retain(|key, slot| visible.contains(key) || slot.request.is_some()); + let now = Instant::now(); + let mut in_flight = self + .polls + .refresh + .values() + .filter(|slot| slot.request.is_some()) + .count(); + let mut commands = Vec::new(); + for key in visible { + if self.polls.review.as_ref().is_some_and(|review| { + (review.chat, review.message) == key + && (review.loading.is_some() || review.sending.is_some()) + }) { + continue; + } + let Some(poll) = self + .messages + .get(&key.0) + .and_then(|messages| messages.iter().find(|message| message.id == key.1)) + .and_then(|message| message.poll.as_ref()) + else { + continue; + }; + let hash = if poll.stale { 0 } else { poll.definition.hash }; + let stale = poll.stale; + let slot = self.polls.refresh.entry(key).or_insert(Refresh { + request: None, + due: Some(now), + }); + if in_flight >= 2 || slot.request.is_some() || slot.due.is_none_or(|due| due > now) { + continue; + } + self.polls.next_request = self.polls.next_request.wrapping_add(1).max(1); + let request_id = self.polls.next_request; + slot.request = Some(request_id); + commands.push(if stale { + TelegramCommand::LoadPoll { + chat_id: key.0, + message_id: key.1, + request_id, + } + } else { + TelegramCommand::RefreshPoll { + chat_id: key.0, + message_id: key.1, + hash, + request_id, + } + }); + in_flight += 1; + } + commands + } + + fn finish_poll_refresh(&mut self, key: Key, request: u64, error: Option<&String>) { + let poll = self + .messages + .get(&key.0) + .and_then(|messages| messages.iter().find(|message| message.id == key.1)) + .and_then(|message| message.poll.as_ref()); + let Some(slot) = self + .polls + .refresh + .get_mut(&key) + .filter(|slot| slot.request == Some(request)) + else { + return; + }; + slot.request = None; + let now = chrono::Utc::now().timestamp(); + slot.due = if error.is_none() && poll.is_some_and(|poll| poll.closed(now) && !poll.stale) { + None + } else { + Some( + Instant::now() + + Duration::from_secs( + poll.and_then(|poll| poll.definition.close_date) + .filter(|date| *date > now) + .map_or(30, |date| u64::try_from(date - now).unwrap_or(30).min(30)), + ), + ) + }; + } + + #[must_use] + pub fn next_poll_deadline(&self) -> Option { + if !self.polls_visible() + || self + .polls + .refresh + .values() + .filter(|slot| slot.request.is_some()) + .count() + >= 2 + { + return None; + } + self.polls + .refresh + .iter() + .filter(|(key, slot)| { + slot.request.is_none() + && !self.polls.review.as_ref().is_some_and(|review| { + (review.chat, review.message) == **key + && (review.loading.is_some() || review.sending.is_some()) + }) + }) + .filter_map(|(_, slot)| slot.due) + .min() + } +} diff --git a/src/app/reactions.rs b/src/app/reactions.rs new file mode 100644 index 0000000..91bb98b --- /dev/null +++ b/src/app/reactions.rs @@ -0,0 +1,413 @@ +use super::{App, Focus, Mode, Screen}; +use crate::{ + actions::Action, + event::{ConnectionStatus, NetworkEvent, TelegramCommand}, + model::ChatId, + reactions::{Choice, Kind, Review, Summary, Update}, +}; +use std::time::{Duration, Instant}; + +#[derive(Clone, Default)] +pub struct State { + pub panel: Option, + pub hit_rows: Vec<(u16, u16, u16, usize)>, + visible: Vec<(ChatId, i32)>, + refreshed: Vec<(ChatId, i32)>, + refresh: Option<(ChatId, u64)>, + due: Option, + next: u64, +} + +#[derive(Clone)] +pub struct Panel { + pub chat: ChatId, + pub message: i32, + pub title: String, + pub review: Option, + pub selected: usize, + pub choices: Vec, + pub top: usize, + pub loading: Option, + pub sending: Option, + pub ready: bool, + pub error: Option, +} + +impl Panel { + fn loaded(&mut self, review: Review) { + self.choices.clone_from(&review.choices); + for kind in review.summary.chosen() { + if let Kind::Emoji(emoji) = kind + && !self.choices.iter().any(|choice| choice.emoji == emoji) + { + self.choices.push(Choice { + emoji, + title: "Selected · remove only".to_owned(), + }); + } + } + self.ready = !review.summary.stale && review.summary.choices_known; + self.selected = self.selected.min(self.choices.len().saturating_sub(1)); + self.review = Some(review); + self.error = None; + } +} + +impl App { + fn next_reaction_request(&mut self) -> u64 { + self.reactions.next = self.reactions.next.wrapping_add(1).max(1); + self.reactions.next + } + + pub(super) fn begin_reactions(&mut self) -> Vec { + if self.screen != Screen::Main + || self.mode != Mode::Navigate + || self.focus != Focus::Conversation + { + return Vec::new(); + } + if self + .reactions + .panel + .as_ref() + .is_some_and(|panel| panel.sending.is_some()) + { + self.status_message = Some("Wait for the pending reaction to finish".to_owned()); + return Vec::new(); + } + let Some(message) = self.inspected_message().filter(|message| message.id > 0) else { + return Vec::new(); + }; + self.reactions.panel = Some(Panel { + chat: message.chat_id, + message: message.id, + title: self + .active_chat() + .map_or_else(|| "Reactions".to_owned(), |chat| chat.title.clone()), + review: None, + choices: Vec::new(), + selected: 0, + top: 0, + loading: None, + sending: None, + ready: false, + error: None, + }); + self.mode = Mode::Reactions; + self.status_message = None; + self.load_reactions() + } + + fn load_reactions(&mut self) -> Vec { + let request_id = self.next_reaction_request(); + let Some(panel) = &mut self.reactions.panel else { + return Vec::new(); + }; + if panel.loading.is_some() || panel.sending.is_some() { + return Vec::new(); + } + panel.ready = false; + if self.connection != ConnectionStatus::Online { + panel.error = Some("Connect to Telegram to choose reactions".to_owned()); + return Vec::new(); + } + panel.loading = Some(request_id); + panel.error = None; + vec![TelegramCommand::LoadReactions { + chat_id: panel.chat, + message_id: panel.message, + request_id, + }] + } + + pub(super) fn reaction_binding( + &mut self, + action: &Action, + count: usize, + ) -> Option> { + if *action == Action::Reactions { + return Some(self.begin_reactions()); + } + if self.mode != Mode::Reactions { + return None; + } + let mut commands = Vec::new(); + match action { + Action::Cancel => { + self.mode = Mode::Navigate; + if self + .reactions + .panel + .as_ref() + .is_none_or(|panel| panel.sending.is_none()) + { + self.reactions.panel = None; + } + } + Action::Refresh => commands = self.load_reactions(), + Action::Open | Action::Send => commands = self.change_reaction(false), + Action::ClearReactions => commands = self.change_reaction(true), + Action::Up + | Action::Down + | Action::PageUp + | Action::PageDown + | Action::Home + | Action::End => { + if let Some(panel) = &mut self.reactions.panel { + let step = if matches!(action, Action::PageUp | Action::PageDown) { + count.saturating_mul(10) + } else { + count + }; + panel.selected = match action { + Action::Up | Action::PageUp => panel.selected.saturating_sub(step), + Action::Home => 0, + Action::End => panel.choices.len().saturating_sub(1), + _ => panel + .selected + .saturating_add(step) + .min(panel.choices.len().saturating_sub(1)), + }; + } + } + Action::Quit | Action::Redraw => return None, + _ => {} + } + Some(commands) + } + + fn change_reaction(&mut self, clear: bool) -> Vec { + let request_id = self.next_reaction_request(); + let Some(panel) = &mut self.reactions.panel else { + return Vec::new(); + }; + if !panel.ready || panel.loading.is_some() || panel.sending.is_some() { + return Vec::new(); + } + if self.connection != ConnectionStatus::Online { + panel.error = Some("Connect to Telegram before changing reactions".to_owned()); + return Vec::new(); + } + let Some(review) = &panel.review else { + return Vec::new(); + }; + let emoji = if clear { + None + } else { + let Some(choice) = panel.choices.get(panel.selected) else { + return Vec::new(); + }; + Some(choice.emoji.clone()) + }; + if let Err(error) = review.toggle(emoji.as_deref()) { + panel.error = Some(error); + return Vec::new(); + } + let expected = review.summary.chosen(); + if clear && expected.is_empty() { + return Vec::new(); + } + panel.sending = Some(request_id); + panel.error = None; + vec![TelegramCommand::ChangeReaction { + chat_id: panel.chat, + message_id: panel.message, + request_id, + expected, + emoji, + }] + } + + fn apply_reactions(&mut self, update: &Update) { + self.visit_messages(|message| { + if (message.chat_id, message.id) == (update.chat, update.message) { + message + .reactions + .get_or_insert_with(Summary::default) + .apply(&update.summary); + } + }); + if let Some(panel) = &mut self.reactions.panel + && (panel.chat, panel.message) == (update.chat, update.message) + && let Some(review) = &mut panel.review + { + review.summary.apply(&update.summary); + } + } + + #[allow(clippy::too_many_lines)] + pub(super) fn observe_reactions( + &mut self, + event: &NetworkEvent, + ) -> Option> { + match event { + NetworkEvent::ReactionsChanged(update) => { + self.apply_reactions(update); + return Some(Vec::new()); + } + NetworkEvent::ReactionsLoading { .. } => return Some(Vec::new()), + NetworkEvent::ReactionsLoaded { + chat_id, + message_id, + request_id, + result, + } => { + if let Ok(review) = result { + self.apply_reactions(&Update { + chat: *chat_id, + message: *message_id, + summary: review.summary.clone(), + }); + } + if let Some(panel) = &mut self.reactions.panel + && panel.loading == Some(*request_id) + && (panel.chat, panel.message) == (*chat_id, *message_id) + { + panel.loading = None; + match result { + Ok(review) => panel.loaded(review.clone()), + Err(error) => { + panel.error = Some(crate::model::sanitize_terminal_line(error)); + } + } + } + return Some(Vec::new()); + } + NetworkEvent::ReactionsFinished { + chat_id, + message_id, + request_id, + error, + } => { + if message_id.is_none() && self.reactions.refresh == Some((*chat_id, *request_id)) { + self.reactions.refresh = None; + self.reactions.due = Some(Instant::now() + Duration::from_secs(30)); + } + if let Some(panel) = &mut self.reactions.panel + && panel.sending == Some(*request_id) + && panel.chat == *chat_id + && Some(panel.message) == *message_id + { + panel.sending = None; + panel.ready = false; + if let Some(error) = error { + panel.error = Some(format!( + "{} · refresh before retrying", + crate::model::sanitize_terminal_line(error) + )); + if self.mode != Mode::Reactions { + self.status_message.clone_from(&panel.error); + } + } else if self.mode == Mode::Reactions { + return Some(self.load_reactions()); + } else { + self.reactions.panel = None; + self.status_message = Some("Reactions saved".to_owned()); + } + } + return Some(Vec::new()); + } + NetworkEvent::MessageUpdated(message) => { + if let Some(summary) = &message.reactions { + self.apply_reactions(&Update { + chat: message.chat_id, + message: message.id, + summary: summary.clone(), + }); + } + } + NetworkEvent::MessagesDeleted { + channel_id, + message_ids, + } => { + if let Some(panel) = &mut self.reactions.panel + && channel_id.map_or(panel.chat > -1_000_000_000_000, |chat| chat == panel.chat) + && message_ids.contains(&panel.message) + { + panel.ready = false; + panel.loading = None; + panel.error = Some("This message was deleted".to_owned()); + } + } + NetworkEvent::CacheInvalidated { chat_id } => { + if let Some(panel) = &mut self.reactions.panel + && chat_id.is_none_or(|chat| chat == panel.chat) + { + panel.ready = false; + panel.loading = None; + panel.error = Some("History changed; reopen reactions".to_owned()); + } + } + _ => {} + } + None + } + + pub fn set_visible_reactions(&mut self, ids: Vec<(ChatId, i32)>) { + self.reactions.visible = ids; + } + + fn reactions_visible(&self) -> bool { + self.screen == Screen::Main + && self.terminal_focused + && self.connection == ConnectionStatus::Online + && matches!(self.mode, Mode::Navigate | Mode::Compose | Mode::Reactions) + && !self.reactions.visible.is_empty() + } + + pub fn request_visible_reactions(&mut self) -> Vec { + if !self.reactions_visible() + || self.reactions.refresh.is_some() + || (self.reactions.refreshed == self.reactions.visible + && self.reactions.due.is_none_or(|due| due > Instant::now())) + || self + .reactions + .panel + .as_ref() + .is_some_and(|panel| panel.loading.is_some() || panel.sending.is_some()) + { + return Vec::new(); + } + let Some(chat_id) = self.active_chat_id else { + return Vec::new(); + }; + let message_ids: Vec<_> = self + .reactions + .visible + .iter() + .filter(|(chat, _)| *chat == chat_id) + .map(|(_, id)| *id) + .take(100) + .collect(); + if message_ids.is_empty() { + return Vec::new(); + } + let request_id = self.next_reaction_request(); + self.reactions.refresh = Some((chat_id, request_id)); + self.reactions.refreshed.clone_from(&self.reactions.visible); + vec![TelegramCommand::RefreshReactions { + chat_id, + message_ids, + request_id, + }] + } + + #[must_use] + pub fn next_reaction_deadline(&self) -> Option { + if !self.reactions_visible() + || self.reactions.refresh.is_some() + || self + .reactions + .panel + .as_ref() + .is_some_and(|panel| panel.loading.is_some() || panel.sending.is_some()) + { + return None; + } + if self.reactions.refreshed == self.reactions.visible { + self.reactions.due + } else { + Some(Instant::now()) + } + } +} diff --git a/src/app/reads.rs b/src/app/reads.rs new file mode 100644 index 0000000..178714c --- /dev/null +++ b/src/app/reads.rs @@ -0,0 +1,402 @@ +use super::{App, ChatId, Focus, Mode, Screen, TelegramCommand}; +use crate::event::ConnectionStatus; +use std::{ + collections::BTreeMap, + time::{Duration, Instant}, +}; + +#[derive(Clone, Default)] +pub(super) struct State { + pub visible: Option<(ChatId, i32)>, + pending: BTreeMap, + pub visible_mentions: Vec, + mentions_pending: BTreeMap>, + mentions_retry: BTreeMap, + retry_after: BTreeMap, + manual_pending: BTreeMap, + clear_marker: Option, + pub entry: Option, + pub forward: Option<(ChatId, i32)>, + pub paging: bool, +} + +#[derive(Clone)] +pub(super) struct Entry { + pub chat_id: ChatId, + pub boundary: i32, + pub message: Option, + pub confirmed: bool, +} + +impl App { + /// Called after a successful frame. Merely loading or selecting a chat is + /// not evidence that its messages were displayed. + pub fn request_visible_read(&mut self) -> Vec { + if self.screen != Screen::Main + || !self.terminal_focused + || self.connection != ConnectionStatus::Online + || !matches!(self.mode, Mode::Navigate | Mode::Compose) + { + return Vec::new(); + } + let Some((chat_id, max_id)) = self + .reads + .visible + .filter(|(id, _)| Some(*id) == self.active_chat_id) + else { + return Vec::new(); + }; + if self + .reads + .entry + .as_ref() + .is_some_and(|entry| entry.chat_id == chat_id && !entry.confirmed) + { + return Vec::new(); + } + let mut commands = self.request_mention_read(chat_id); + if self.reads.clear_marker == Some(chat_id) + && !self.reads.manual_pending.contains_key(&chat_id) + { + self.reads.clear_marker = None; + commands.extend(self.set_chat_unread_inner(chat_id, false, false)); + } + let Some(previous) = self + .chats + .iter() + .find(|chat| chat.id == chat_id) + .and_then(|chat| chat.read_inbox_max_id) + else { + return commands; + }; + if max_id <= previous + || max_id <= 0 + || self.reads.pending.contains_key(&chat_id) + || self + .reads + .retry_after + .get(&chat_id) + .is_some_and(|until| *until > Instant::now()) + { + return commands; + } + self.reads.pending.insert(chat_id, max_id); + commands.push(TelegramCommand::MarkRead { chat_id, max_id }); + commands + } + + pub fn set_visible_mentions(&mut self, ids: Vec) { + self.reads.visible_mentions = ids; + } + + fn request_mention_read(&mut self, chat_id: ChatId) -> Vec { + if self.reads.mentions_pending.contains_key(&chat_id) + || self + .reads + .mentions_retry + .get(&chat_id) + .is_some_and(|until| *until > Instant::now()) + { + return Vec::new(); + } + let message_ids = self + .active_messages() + .iter() + .filter(|message| { + message.id > 0 + && !message.outgoing + && self.reads.visible_mentions.contains(&message.id) + && message + .mention + .as_ref() + .is_some_and(|mention| mention.unread && !mention.requires_playback) + }) + .map(|message| message.id) + .take(100) + .collect::>(); + if message_ids.is_empty() { + return Vec::new(); + } + self.reads + .mentions_pending + .insert(chat_id, message_ids.clone()); + vec![TelegramCommand::ReadMentions { + chat_id, + message_ids, + }] + } + + pub(super) fn mentions_read_finished( + &mut self, + chat_id: ChatId, + ids: &[i32], + error: Option, + ) { + if self + .reads + .mentions_pending + .get(&chat_id) + .is_some_and(|pending| pending == ids) + { + self.reads.mentions_pending.remove(&chat_id); + // The common PTS receipt may still be behind its RPC completion. + self.reads + .mentions_retry + .insert(chat_id, Instant::now() + Duration::from_secs(2)); + } + if let Some(error) = error { + self.status_message = Some(crate::model::sanitize_terminal_line(&error)); + } + } + + pub(super) fn contents_read(&mut self, channel_id: Option, ids: &[i32]) { + let messages = self + .messages + .values_mut() + .flatten() + .chain(self.history_changes.values_mut().filter_map(Option::as_mut)) + .chain(self.message_pins.page.messages.iter_mut()) + .chain( + self.message_pins + .head + .iter_mut() + .flat_map(|(_, page)| &mut page.messages), + ); + for message in messages { + message.acknowledge_contents(channel_id, ids); + } + } + + pub(super) fn read_finished(&mut self, chat_id: ChatId, max_id: i32, failed: bool) { + if self.reads.pending.get(&chat_id) == Some(&max_id) { + self.reads.pending.remove(&chat_id); + } + if failed { + self.reads + .retry_after + .insert(chat_id, Instant::now() + Duration::from_secs(2)); + } else { + self.reads.retry_after.remove(&chat_id); + } + } + + pub fn set_visible_read_boundary(&mut self, chat_id: ChatId, max_id: Option) { + self.reads.visible = max_id.map(|id| (chat_id, id)); + } + + pub(super) fn set_chat_unread( + &mut self, + chat_id: ChatId, + unread: bool, + ) -> Vec { + self.reads.clear_marker = None; + self.set_chat_unread_inner(chat_id, unread, !unread) + } + + fn set_chat_unread_inner( + &mut self, + chat_id: ChatId, + unread: bool, + read_history: bool, + ) -> Vec { + if self.connection != ConnectionStatus::Online { + self.status_message = Some("Connect to Telegram first".to_owned()); + return Vec::new(); + } + if self.reads.manual_pending.contains_key(&chat_id) { + self.status_message = Some("This chat's read state is still updating".to_owned()); + return Vec::new(); + } + let request_id = self.next_history_request_id; + self.next_history_request_id = request_id.wrapping_add(1).max(1); + self.reads.manual_pending.insert(chat_id, request_id); + vec![TelegramCommand::SetChatUnread { + chat_id, + unread, + read_history, + request_id, + }] + } + + pub(super) fn finish_chat_unread( + &mut self, + chat_id: ChatId, + request_id: u64, + unread: bool, + error: Option, + ) { + if self.reads.manual_pending.get(&chat_id) != Some(&request_id) { + return; + } + self.reads.manual_pending.remove(&chat_id); + self.status_message = Some(error.unwrap_or_else(|| { + if unread { + "Chat marked unread".to_owned() + } else { + "Read state updated".to_owned() + } + })); + self.clamp_chat_selection(); + } + + pub(super) fn mark_focused_chat(&mut self, unread: bool) -> Vec { + let id = if self.focus == Focus::Chats { + self.selected_chat_entry().map(|chat| chat.id) + } else { + self.active_chat_id + }; + id.map_or_else(Vec::new, |id| self.set_chat_unread(id, unread)) + } + + pub(super) fn prepare_unread_entry(&mut self, chat_id: ChatId, resume: bool) -> Option { + self.reads.entry = None; + self.reads.forward = None; + self.reads.paging = false; + self.reads.visible = None; + self.reads.visible_mentions.clear(); + self.reads.clear_marker = self + .chats + .iter() + .find(|chat| chat.id == chat_id && chat.membership.unread_mark) + .map(|chat| chat.id); + let boundary = self + .chats + .iter() + .find(|chat| chat.id == chat_id && resume && chat.unread > 0) + .and_then(|chat| chat.read_inbox_max_id) + .filter(|boundary| { + self.chats + .iter() + .find(|chat| chat.id == chat_id) + .and_then(|chat| chat.last_message_id) + .is_none_or(|last| last > *boundary) + })?; + self.reads.entry = Some(Entry { + chat_id, + boundary, + message: None, + confirmed: false, + }); + self.message_scroll = 1; + Some(boundary) + } + + pub(super) fn position_unread_entry(&mut self, chat_id: ChatId, confirmed: bool) { + let Some(entry) = self + .reads + .entry + .as_ref() + .filter(|entry| entry.chat_id == chat_id && !entry.confirmed) + else { + return; + }; + let target = self + .messages + .get(&chat_id) + .into_iter() + .flatten() + .find(|message| message.id > entry.boundary && !message.outgoing) + .map(|message| message.id); + let entry = self.reads.entry.as_mut().expect("entry checked"); + entry.message = target; + entry.confirmed = confirmed; + if let Some(target) = target { + self.viewport_anchor_message = Some(target); + self.viewport_anchor_row = 0; + self.message_scroll = 1; + } + } + + #[must_use] + pub fn unread_separator(&self) -> Option { + self.reads + .entry + .as_ref() + .filter(|entry| Some(entry.chat_id) == self.active_chat_id) + .and_then(|entry| entry.message) + } + + #[must_use] + pub fn has_newer_history(&self) -> bool { + self.reads + .forward + .is_some_and(|(chat, _)| Some(chat) == self.active_chat_id) + } + + pub(super) fn clear_unread_navigation(&mut self) { + if self.reads.paging + || self + .reads + .entry + .as_ref() + .is_some_and(|entry| !entry.confirmed) + { + self.active_history_request = None; + self.loading_history = false; + } + self.reads.entry = None; + self.reads.forward = None; + self.reads.paging = false; + self.reads.visible = None; + self.reads.visible_mentions.clear(); + self.reads.clear_marker = None; + } + + pub(super) fn first_unread(&mut self) -> Vec { + let Some(chat_id) = self.active_chat_id else { + return Vec::new(); + }; + if self.active_chat().is_none_or(|chat| chat.unread == 0) { + self.status_message = Some("No unread messages in this chat".to_owned()); + return Vec::new(); + } + self.open_chat_by_id(chat_id) + } + + pub(super) fn latest_history(&mut self) -> Vec { + let Some(chat_id) = self.active_chat_id else { + return Vec::new(); + }; + let mut commands = self.open_chat_by_id(chat_id); + for command in &mut commands { + if let TelegramCommand::LoadHistory { after_id, .. } = command { + *after_id = None; + } + } + self.prepare_unread_entry(chat_id, false); + self.clear_viewport_anchor(); + self.message_scroll = 0; + commands + } + + /// Continue a confirmed forward page using the same generation and cache + /// machinery as opening history. Preserve the physical reading anchor. + pub(super) fn next_unread_page(&mut self) -> Vec { + let Some((chat_id, after_id)) = self + .reads + .forward + .filter(|(chat, _)| Some(*chat) == self.active_chat_id) + else { + return Vec::new(); + }; + if self.loading_history || self.connection != ConnectionStatus::Online { + return Vec::new(); + } + let request_id = self.next_history_request_id; + self.next_history_request_id = request_id.wrapping_add(1).max(1); + self.active_history_request = Some((chat_id, request_id)); + self.history_target_message = self.viewport_anchor_message.or(Some(after_id)); + self.viewport_anchor_message = self.history_target_message; + self.viewport_anchor_row = 0; + self.message_scroll = self.message_scroll.max(1); + self.reads.paging = true; + self.loading_history = true; + self.history_changes.clear(); + self.history_read_max = 0; + vec![TelegramCommand::LoadHistory { + chat_id, + request_id, + after_id: Some(after_id), + }] + } +} diff --git a/src/app/replies.rs b/src/app/replies.rs new file mode 100644 index 0000000..d63fc3a --- /dev/null +++ b/src/app/replies.rs @@ -0,0 +1,301 @@ +//! Bounded, lazy reply excerpts. Targets never become new timeline arrivals. +use std::collections::{BTreeMap, BTreeSet}; + +use super::{ + App, ChatId, Message, Mode, NetworkEvent, ReplyInfo, TelegramCommand, sanitize_message, +}; + +const BATCH_SIZE: usize = 32; +const CACHE_LIMIT: usize = 512; + +#[derive(Clone, Debug)] +enum Preview { + Loading, + Ready(Box), + Unavailable, + Failed, +} + +#[derive(Clone, Debug)] +struct Entry { + request_id: u64, + preview: Preview, +} + +#[derive(Clone, Default)] +pub(super) struct State { + entries: BTreeMap<(ChatId, i32), Entry>, + pending: Option<(ChatId, u64)>, +} + +impl State { + pub(super) fn visit_messages(&mut self, visit: &mut impl FnMut(&mut Message)) { + for entry in self.entries.values_mut() { + if let Preview::Ready(message) = &mut entry.preview { + visit(message); + } + } + } +} + +impl App { + #[must_use] + pub fn reply_message(&self, reply: &ReplyInfo) -> Option<&Message> { + if self.reply_is_unavailable(reply) { + return None; + } + self.messages + .get(&reply.chat_id) + .and_then(|messages| { + messages + .iter() + .find(|message| message.id == reply.message_id) + }) + .or_else(|| { + match &self + .replies + .entries + .get(&(reply.chat_id, reply.message_id))? + .preview + { + Preview::Ready(message) => Some(message.as_ref()), + _ => None, + } + }) + } + + pub(super) fn reply_is_unavailable(&self, reply: &ReplyInfo) -> bool { + self.replies + .entries + .get(&(reply.chat_id, reply.message_id)) + .is_some_and(|entry| matches!(entry.preview, Preview::Unavailable)) + } + + #[must_use] + pub fn reply_preview_status(&self, reply: &ReplyInfo) -> &'static str { + if self.reply_message(reply).is_some() { + return ""; + } + match self + .replies + .entries + .get(&(reply.chat_id, reply.message_id)) + .map(|entry| &entry.preview) + { + Some(Preview::Unavailable) => "Original message unavailable", + Some(Preview::Failed) => "Original not loaded · open to retry", + _ => "Loading original message…", + } + } + + /// Called after drawing. One batch is in flight; repeated frames deduplicate + /// by stable target identity, independently of selection and list order. + pub fn request_visible_replies(&mut self) -> Vec { + if self.replies.pending.is_some() || !matches!(self.mode, Mode::Navigate | Mode::Compose) { + return Vec::new(); + } + let visible: BTreeSet<_> = self + .message_hit_regions + .iter() + .map(|region| region.3.0) + .collect(); + let targets: BTreeSet<_> = self + .active_messages() + .iter() + .filter(|message| visible.contains(&message.id)) + .filter_map(|message| message.reply_to.as_ref()) + .filter(|reply| reply.message_id > 0) + .map(|reply| (reply.chat_id, reply.message_id)) + .collect(); + while self.replies.entries.len() > CACHE_LIMIT.saturating_sub(BATCH_SIZE) { + let Some(key) = self + .replies + .entries + .keys() + .find(|key| !targets.contains(key)) + .copied() + else { + break; + }; + self.replies.entries.remove(&key); + } + let missing = |target: &&(ChatId, i32)| { + !self.replies.entries.contains_key(*target) + && !self + .messages + .get(&target.0) + .is_some_and(|messages| messages.iter().any(|message| message.id == target.1)) + }; + let Some(&(chat_id, _)) = targets.iter().find(missing) else { + return Vec::new(); + }; + let message_ids: Vec<_> = targets + .iter() + .filter(missing) + .filter(|target| target.0 == chat_id) + .map(|target| target.1) + .take(BATCH_SIZE.min(CACHE_LIMIT.saturating_sub(self.replies.entries.len()))) + .collect(); + if message_ids.is_empty() { + return Vec::new(); + } + let request_id = self.next_history_request_id; + self.next_history_request_id = request_id.wrapping_add(1).max(1); + for id in &message_ids { + self.replies.entries.insert( + (chat_id, *id), + Entry { + request_id, + preview: Preview::Loading, + }, + ); + } + self.replies.pending = Some((chat_id, request_id)); + vec![TelegramCommand::LoadReplyPreviews { + chat_id, + message_ids, + request_id, + }] + } + + pub(super) fn update_reply_previews(&mut self, event: &NetworkEvent) { + match event { + NetworkEvent::ReplyPreviews { + chat_id, + request_id, + messages, + unavailable, + complete, + } => { + if self.replies.pending != Some((*chat_id, *request_id)) { + return; + } + self.finish_reply_previews(*chat_id, *request_id, messages, unavailable, *complete); + } + NetworkEvent::ReplyPreviewsFailed { + chat_id, + request_id, + .. + } => { + if self.replies.pending != Some((*chat_id, *request_id)) { + return; + } + self.replies.pending = None; + for entry in self + .replies + .entries + .values_mut() + .filter(|entry| entry.request_id == *request_id) + { + if matches!(entry.preview, Preview::Loading) { + entry.preview = Preview::Failed; + } + } + } + NetworkEvent::NewMessage(message) + | NetworkEvent::MessageUpdated(message) + | NetworkEvent::MessageLoaded { message, .. } => { + if let Some(entry) = self.replies.entries.get_mut(&(message.chat_id, message.id)) + && !matches!(entry.preview, Preview::Unavailable) + { + let mut message = message.clone(); + sanitize_message(&mut message); + // A live edit wins over a batch begun before that edit. + *entry = Entry { + request_id: 0, + preview: Preview::Ready(Box::new(message)), + }; + } + } + NetworkEvent::MessageContentsRead { + channel_id, + message_ids, + } => { + for entry in self.replies.entries.values_mut() { + if let Preview::Ready(message) = &mut entry.preview { + message.acknowledge_contents(*channel_id, message_ids); + } + } + } + NetworkEvent::MessagesDeleted { + channel_id, + message_ids, + } => { + self.delete_reply_previews(*channel_id, message_ids); + } + NetworkEvent::CacheInvalidated { chat_id } => { + self.replies + .entries + .retain(|(id, _), _| chat_id.is_some_and(|chat| chat != *id)); + if self + .replies + .pending + .is_some_and(|(id, _)| chat_id.is_none_or(|chat| chat == id)) + { + self.replies.pending = None; + } + } + NetworkEvent::Status(crate::event::ConnectionStatus::Online) => { + self.replies + .entries + .retain(|_, entry| !matches!(entry.preview, Preview::Failed)); + } + _ => {} + } + } + + fn finish_reply_previews( + &mut self, + chat_id: ChatId, + request_id: u64, + messages: &[Message], + unavailable: &[i32], + complete: bool, + ) { + for message in messages.iter().filter(|message| message.chat_id == chat_id) { + if let Some(entry) = self.replies.entries.get_mut(&(chat_id, message.id)) + && entry.request_id == request_id + { + let mut message = message.clone(); + sanitize_message(&mut message); + entry.preview = Preview::Ready(Box::new(message)); + } + } + for id in unavailable { + if let Some(entry) = self.replies.entries.get_mut(&(chat_id, *id)) + && entry.request_id == request_id + { + entry.preview = Preview::Unavailable; + } + } + if complete { + self.replies.pending = None; + } + } + + fn delete_reply_previews(&mut self, channel_id: Option, message_ids: &[i32]) { + // Record referenced targets even if they were never loaded, so + // an excerpt or a later history page cannot resurrect them. + let targets: BTreeSet<_> = self + .messages + .values() + .flatten() + .filter_map(|message| message.reply_to.as_ref()) + .map(|reply| (reply.chat_id, reply.message_id)) + .chain(self.replies.entries.keys().copied()) + .collect(); + for key in targets { + if channel_id.map_or(key.0 > -1_000_000_000_000, |id| id == key.0) + && message_ids.contains(&key.1) + { + self.replies.entries.insert( + key, + Entry { + request_id: 0, + preview: Preview::Unavailable, + }, + ); + } + } + } +} diff --git a/src/app/search.rs b/src/app/search.rs new file mode 100644 index 0000000..86357b3 --- /dev/null +++ b/src/app/search.rs @@ -0,0 +1,581 @@ +use super::{App, Focus, KeyAction, Mode, TelegramCommand, TextInput}; +use crate::{ + cloud_search, + event::{ConnectionStatus, NetworkEvent}, + model::{ChatId, Message}, + search::{Cursor as LocalCursor, Request}, +}; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum Scope { + #[default] + Chat, + Folder, + Account, +} + +#[derive(Clone, Copy)] +enum Cursor { + Local(LocalCursor), + Cloud(i32), +} + +#[derive(Clone, Default)] +pub enum Source { + #[default] + Local, + Mentions(ChatId), + Cloud { + chat_id: ChatId, + filters: cloud_search::Filters, + sender: Option, + }, +} + +#[derive(Clone)] +pub enum Results { + Local(crate::search::Page), + Cloud(cloud_search::Page), +} + +impl Results { + pub fn messages(&self) -> &[Message] { + match self { + Self::Local(page) => &page.messages, + Self::Cloud(page) => &page.messages, + } + } + pub(super) fn messages_mut(&mut self) -> &mut Vec { + match self { + Self::Local(page) => &mut page.messages, + Self::Cloud(page) => &mut page.messages, + } + } + fn next(&self) -> Option { + match self { + Self::Local(page) => page.next.map(Cursor::Local), + Self::Cloud(page) => page.next.map(Cursor::Cloud), + } + } +} + +#[derive(Clone, Default)] +pub struct State { + pub query: TextInput, + pub editing: bool, + pub scope: Scope, + pub scope_label: String, + pub source: Source, + pub page: Option, + pub selected: usize, + pub loading: bool, + pub error: Option, + pub page_number: usize, + request_id: u64, + chats: Option>, + starts: Vec>, + target: Option<(ChatId, i32)>, +} + +impl State { + pub fn is_cloud(&self) -> bool { + !matches!(self.source, Source::Local) + } + + pub fn is_mentions(&self) -> bool { + matches!(self.source, Source::Mentions(_)) + } +} + +impl App { + pub(super) fn focused_mentions(&mut self) -> Vec { + if self.screen != super::Screen::Main || self.mode != Mode::Navigate { + return Vec::new(); + } + let id = if self.focus == Focus::Chats { + self.selected_chat_entry().map(|chat| chat.id) + } else { + self.active_chat_id + }; + id.map_or_else(Vec::new, |id| self.start_mentions(id)) + } + + pub(super) fn start_mentions(&mut self, chat_id: ChatId) -> Vec { + self.search.source = Source::Mentions(chat_id); + self.search.query.clear(); + self.mode = Mode::Search; + self.search_submit(None, true) + } + + pub(super) fn start_cloud_search( + &mut self, + chat_id: ChatId, + query: String, + filters: cloud_search::Filters, + ) -> Vec { + self.search.request_id = self.search.request_id.wrapping_add(1); + self.search.source = Source::Cloud { + chat_id, + filters, + sender: None, + }; + self.search.query.set_value(query); + self.search.page = None; + self.search.target = None; + self.search.loading = false; + self.search.error = None; + self.search.editing = true; + self.mode = Mode::Search; + self.update_search_scope(); + if self.search.query.is_empty() { + vec![TelegramCommand::CancelSearch] + } else { + self.search_submit(None, true) + } + } + + pub(super) fn start_local_search(&mut self, query: Option<&str>) -> Vec { + if self.search.is_cloud() { + self.search.source = Source::Local; + self.search.query.clear(); + self.search.page = None; + } + self.open_search(); + self.search.editing = true; + if let Some(query) = query.filter(|query| !query.is_empty()) { + self.search.query.set_value(query); + self.search_submit(None, true) + } else { + self.search_edited() + } + } + + pub(super) fn open_search(&mut self) -> Vec { + if self.search.query.is_empty() && !self.search.is_mentions() { + self.search.editing = true; + self.search.scope = if self.active_chat_id.is_some() { + Scope::Chat + } else { + Scope::Folder + }; + } + self.mode = Mode::Search; + if self.search.page.is_none() { + self.search.editing = !self.search.is_mentions(); + self.update_search_scope(); + } + Vec::new() + } + + fn update_search_scope(&mut self) { + if let Source::Cloud { chat_id, .. } | Source::Mentions(chat_id) = &self.search.source { + self.search.scope_label = self + .chats + .iter() + .find(|chat| chat.id == *chat_id) + .map_or_else(|| format!("Chat {chat_id}"), |chat| chat.title.clone()); + return; + } + (self.search.chats, self.search.scope_label) = match self.search.scope { + Scope::Chat => ( + Some(self.active_chat_id.into_iter().collect()), + self.active_chat().map_or_else( + || "No open chat".to_owned(), + |chat| format!("Chat: {}", chat.title), + ), + ), + Scope::Folder => { + let folder = self + .folders + .iter() + .find(|folder| folder.id == self.folder_id); + ( + Some( + self.chats + .iter() + .filter(|chat| { + folder.is_none_or(|folder| { + folder.contains(chat, chrono::Utc::now().timestamp()) + }) + }) + .map(|chat| chat.id) + .collect(), + ), + format!( + "Folder: {}", + folder.map_or("All chats", |folder| folder.title.as_str()) + ), + ) + } + Scope::Account => (None, "This account".to_owned()), + }; + } + + pub(super) fn search_binding( + &mut self, + action: &str, + count: usize, + ) -> Option> { + match action { + "cancel" => { + self.mode = Mode::Navigate; + self.search.loading = false; + self.search.request_id = self.search.request_id.wrapping_add(1); + Some(vec![TelegramCommand::CancelSearch]) + } + "open" if self.search.loading => Some(Vec::new()), + "open" + if self.search.editing + || (self.search.is_mentions() && self.search.page.is_none()) => + { + Some(self.search_submit(None, true)) + } + "refresh" => Some(self.search_submit(None, true)), + "open" => self.open_search_result(), + "search_scope" if self.search.is_cloud() => Some(Vec::new()), + "search_scope" => { + self.search.scope = match self.search.scope { + Scope::Chat => Scope::Folder, + Scope::Folder => Scope::Account, + Scope::Account => Scope::Chat, + }; + self.update_search_scope(); + self.search.page = None; + self.search.editing = true; + self.search.loading = false; + self.search.request_id = self.search.request_id.wrapping_add(1); + Some(vec![TelegramCommand::CancelSearch]) + } + "search_query" if self.search.is_mentions() => Some(Vec::new()), + "search_query" => { + self.search.editing = true; + Some(Vec::new()) + } + "search_more" if !self.search.loading => { + if let Some(next) = self.search.page.as_ref().and_then(Results::next) { + self.search.starts.push(Some(next)); + self.search.page_number += 1; + Some(self.search_submit(Some(next), false)) + } else { + Some(Vec::new()) + } + } + "search_previous" if !self.search.loading && self.search.page_number > 1 => { + self.search.starts.pop(); + self.search.page_number -= 1; + Some(self.search_submit(self.search.starts.last().copied().flatten(), false)) + } + "up" | "down" | "page_up" | "page_down" => { + let length = self + .search + .page + .as_ref() + .map_or(0, |page| page.messages().len()); + if length > 0 { + self.search.editing = false; + let amount = if action.starts_with("page_") { + count.saturating_mul(10) + } else { + count + }; + self.search.selected = if action.ends_with("up") { + self.search.selected.saturating_sub(amount) + } else { + self.search.selected.saturating_add(amount).min(length - 1) + }; + } + Some(Vec::new()) + } + _ => None, + } + } + + fn open_search_result(&mut self) -> Option> { + let message = self + .search + .page + .as_ref()? + .messages() + .get(self.search.selected)?; + let (chat_id, message_id) = (message.chat_id, message.id); + if self.search.is_cloud() && self.connection != ConnectionStatus::Online { + self.search.error = Some("Connect to Telegram to open this cloud result".to_owned()); + return Some(Vec::new()); + } + self.search.request_id = self.search.request_id.wrapping_add(1); + let request_id = self.search.request_id; + self.search.loading = true; + self.search.error = None; + self.search.target = Some((chat_id, message_id)); + Some(vec![if self.search.is_cloud() { + TelegramCommand::LoadCloudContext { + chat_id, + message_id, + request_id, + } + } else { + TelegramCommand::LoadCachedContext { + chat_id, + message_id, + request_id, + } + }]) + } + + fn search_submit(&mut self, before: Option, fresh: bool) -> Vec { + self.search.request_id = self.search.request_id.wrapping_add(1); + if fresh { + self.search.starts = vec![None]; + self.search.page_number = 1; + self.search.page = None; + self.update_search_scope(); + } + self.search.selected = 0; + self.search.loading = true; + self.search.editing = false; + self.search.error = None; + self.search.target = None; + self.search.page = None; + if self.search.is_cloud() && self.connection != ConnectionStatus::Online { + self.search.loading = false; + self.search.editing = !self.search.is_mentions(); + self.search.error = Some( + "Connect to Telegram for cloud search and mentions; :search uses the offline cache" + .to_owned(), + ); + return vec![TelegramCommand::CancelSearch]; + } + match &self.search.source { + Source::Mentions(chat_id) => vec![TelegramCommand::SearchMentions { + chat_id: *chat_id, + request_id: self.search.request_id, + before_id: match before { + Some(Cursor::Cloud(id)) => id, + _ => 0, + }, + }], + Source::Cloud { + chat_id, + filters, + sender, + } => { + let mut filters = filters.clone(); + if let Some(id) = sender { + filters.sender = Some(cloud_search::Sender::Id(*id)); + } + vec![TelegramCommand::SearchCloud(cloud_search::Request { + id: self.search.request_id, + chat_id: *chat_id, + query: self.search.query.value().to_owned(), + filters, + before_id: match before { + Some(Cursor::Cloud(id)) => id, + _ => 0, + }, + })] + } + Source::Local => vec![TelegramCommand::SearchCached(Request { + id: self.search.request_id, + pattern: self.search.query.value().to_owned(), + chats: self.search.chats.clone(), + before: match before { + Some(Cursor::Local(cursor)) => Some(cursor), + _ => None, + }, + })], + } + } + + pub(super) fn edit_search(&mut self, action: KeyAction) -> Vec { + if !self.search.editing { + return Vec::new(); + } + let before = self.search.query.value().to_owned(); + let query = &mut self.search.query; + match action { + KeyAction::Character(c) => query.insert(c), + KeyAction::Backspace => _ = query.backspace(), + KeyAction::Delete => _ = query.delete(), + KeyAction::Left => _ = query.move_left(), + KeyAction::Right => _ = query.move_right(), + KeyAction::Home => query.move_home(), + KeyAction::End => query.move_end(), + KeyAction::Clear => query.clear(), + KeyAction::DeleteWord => _ = query.delete_word_before(), + _ => {} + } + if before == query.value() { + Vec::new() + } else { + self.search_edited() + } + } + + pub(super) fn search_edited(&mut self) -> Vec { + self.search.page = None; + self.search.error = None; + self.search.loading = false; + self.search.request_id = self.search.request_id.wrapping_add(1); + vec![TelegramCommand::CancelSearch] + } + + pub(super) fn finish_search(&mut self, request_id: u64, result: Result) { + if self.search.request_id != request_id || self.mode != Mode::Search { + return; + } + self.search.loading = false; + match result { + Ok(mut page) => { + if self.search.is_mentions() { + page.messages_mut().retain(|message| { + message + .mention + .as_ref() + .is_some_and(|mention| mention.unread) + }); + } + if let (Source::Cloud { sender, .. }, Results::Cloud(page)) = + (&mut self.search.source, &page) + { + *sender = page.sender; + } + self.search.page = Some(page); + self.search.error = None; + } + Err(error) => { + self.search.error = Some(crate::model::sanitize_terminal_text(&error)); + self.search.editing = !self.search.is_mentions(); + } + } + } + + pub(super) fn open_search_context( + &mut self, + request_id: u64, + chat_id: ChatId, + message_id: i32, + messages: Vec, + ) { + if self.search.request_id != request_id || self.mode != Mode::Search { + return; + } + if self.search.target != Some((chat_id, message_id)) { + return; + } + self.search.loading = false; + self.search.target = None; + if !messages + .iter() + .any(|message| message.id == message_id && message.chat_id == chat_id) + { + self.search.error = + Some("Search result is no longer available; run the search again".to_owned()); + return; + } + self.mode = Mode::Navigate; + self.clear_unread_navigation(); + self.focus = Focus::Conversation; + self.narrow_conversation = true; + self.active_history_request = None; + self.older_motion = None; + self.loading_history = false; + self.active_chat_id = Some(chat_id); + self.history_target_message = None; + self.merge_history(chat_id, messages, None); + self.browsing_older = true; + self.new_messages_while_scrolled = 0; + self.new_messages_to_anchor = 0; + self.select_message_id(message_id, true); + self.message_scroll = self.message_scroll.max(1); + self.status_message = Some(format!( + "{} · {} latest · {} results", + if self.search.is_mentions() { + "Mention" + } else if self.search.is_cloud() { + "Cloud search result" + } else { + "Cached search result" + }, + self.keymap + .hint(crate::keymap::Context::Conversation, "latest"), + self.keymap + .hint(crate::keymap::Context::Conversation, "search") + )); + } + + pub(super) fn observe_search(&mut self, event: &NetworkEvent) { + if let NetworkEvent::CacheInvalidated { chat_id } = event { + let affected = match &self.search.source { + Source::Cloud { chat_id: id, .. } | Source::Mentions(id) => { + chat_id.is_none_or(|chat| chat == *id) + } + Source::Local => chat_id.is_none_or(|chat| { + self.search + .chats + .as_ref() + .is_none_or(|chats| chats.contains(&chat)) + }), + }; + if affected { + self.search.request_id = self.search.request_id.wrapping_add(1); + self.search.loading = false; + self.search.page = None; + self.search.target = None; + self.search.editing = !self.search.is_mentions(); + self.search.error = Some("History is resyncing; run the search again".to_owned()); + } + } + let mentions = self.search.is_mentions(); + let Some(page) = self.search.page.as_mut() else { + return; + }; + match event { + NetworkEvent::MessageUpdated(message) | NetworkEvent::NewMessage(message) => { + if let Some(current) = page + .messages_mut() + .iter_mut() + .find(|current| current.chat_id == message.chat_id && current.id == message.id) + { + *current = message.clone(); + super::sanitize_message(current); + } + } + NetworkEvent::MessageContentsRead { + channel_id, + message_ids, + } => { + for message in page.messages_mut() { + message.acknowledge_contents(*channel_id, message_ids); + } + } + NetworkEvent::MessagesDeleted { + channel_id, + message_ids, + } => { + page.messages_mut().retain(|message| { + let affected = channel_id.map_or(message.chat_id > -1_000_000_000_000, |id| { + message.chat_id == id + }); + !affected || !message_ids.contains(&message.id) + }); + self.search.selected = self + .search + .selected + .min(page.messages().len().saturating_sub(1)); + } + _ => {} + } + if mentions { + page.messages_mut().retain(|message| { + message + .mention + .as_ref() + .is_some_and(|mention| mention.unread) + }); + self.search.selected = self + .search + .selected + .min(page.messages().len().saturating_sub(1)); + } + } +} diff --git a/src/app/sharing.rs b/src/app/sharing.rs new file mode 100644 index 0000000..0d56699 --- /dev/null +++ b/src/app/sharing.rs @@ -0,0 +1,70 @@ +use super::{App, Focus, Mode, Screen, TelegramCommand}; +use crate::{event::ConnectionStatus, model::sanitize_terminal_line}; + +#[derive(Clone, Default)] +pub struct State { + next_request: u64, + copy: Option, +} + +impl App { + pub(super) fn copy_message(&mut self, link: bool) -> Vec { + if self.screen != Screen::Main + || self.mode != Mode::Navigate + || self.focus != Focus::Conversation + { + return Vec::new(); + } + let Some(chat_id) = self.active_chat_id else { + return Vec::new(); + }; + let Some(message_id) = self.selected_message.filter(|id| *id > 0) else { + self.status_message = Some("Select a delivered message to copy".to_owned()); + return Vec::new(); + }; + if self.connection != ConnectionStatus::Online { + self.status_message = + Some("Connect to Telegram to check the message's sharing permissions".to_owned()); + return Vec::new(); + } + if self.sharing.copy.is_some() { + self.status_message = Some("A message copy is still being prepared".to_owned()); + return Vec::new(); + } + self.sharing.next_request += 1; + let request_id = self.sharing.next_request; + self.sharing.copy = Some(request_id); + self.status_message = Some( + if link { + "Loading message link…" + } else { + "Preparing message text…" + } + .to_owned(), + ); + vec![TelegramCommand::CopyMessage { + chat_id, + message_id, + link, + request_id, + }] + } + + pub(super) fn message_copy_ready( + &mut self, + request_id: u64, + result: Result, + ) -> Vec { + if self.sharing.copy != Some(request_id) { + return Vec::new(); + } + self.sharing.copy = None; + match result { + Ok(text) => vec![TelegramCommand::CopyText(text)], + Err(error) => { + self.status_message = Some(sanitize_terminal_line(&error)); + Vec::new() + } + } + } +} diff --git a/src/app/staging.rs b/src/app/staging.rs new file mode 100644 index 0000000..8759527 --- /dev/null +++ b/src/app/staging.rs @@ -0,0 +1,263 @@ +use super::{App, Focus, KeyAction, Mode, TelegramCommand}; +use crate::{ + actions::Action, + drafts::Key, + staging::{Input, MAX_BYTES, MAX_FILES, Prepared, Request}, +}; + +#[derive(Clone, Default)] +pub struct State { + pending: Option<(Key, u64, Option)>, + next_request: u64, + pub selected: usize, + pub preview: bool, + pub hit_regions: Vec<(u16, u16, u16, usize)>, + previous_mode: Mode, +} + +impl App { + pub(super) fn prepare_attachments( + &mut self, + paths: String, + fallback_text: bool, + ) -> Vec { + let fallback = fallback_text.then(|| paths.clone()); + self.prepare_attachment_input(Input::Paths(paths), fallback) + } + + pub(super) fn paste_clipboard(&mut self) -> Vec { + self.prepare_attachment_input(Input::Clipboard, None) + } + + /// Reserve the same captured draft destination used by a native read, + /// before the terminal asynchronously supplies its MIME payload. + pub fn reserve_terminal_paste(&mut self) -> Option { + if self.screen != super::Screen::Main + || self.active_chat_id.is_none() + || !(matches!(self.mode, Mode::Navigate | Mode::Compose) + && self.focus == Focus::Conversation + || self.mode == Mode::Attachments) + { + return None; + } + self.prepare_attachment_input(Input::Clipboard, None) + .into_iter() + .find_map(|command| { + if let TelegramCommand::PrepareAttachments(request) = command { + Some(request) + } else { + None + } + }) + } + + fn prepare_attachment_input( + &mut self, + input: Input, + fallback: Option, + ) -> Vec { + let Some(chat) = self.active_chat_id else { + self.status_message = Some("Open a chat before attaching files".to_owned()); + return Vec::new(); + }; + if self.attachment_draft.pending.is_some() { + self.status_message = Some("An attachment is still being prepared".to_owned()); + return Vec::new(); + } + let key = self.draft_key(chat); + let attachments = self.draft_attachments(); + let available_files = MAX_FILES.saturating_sub(attachments.len()); + let available_bytes = + MAX_BYTES.saturating_sub(attachments.iter().map(|file| file.size).sum()); + self.attachment_draft.next_request = + self.attachment_draft.next_request.wrapping_add(1).max(1); + let id = self.attachment_draft.next_request; + self.attachment_draft.pending = Some((key, id, fallback)); + self.mode = Mode::Compose; + self.focus = Focus::Conversation; + self.status_message = Some("Preparing attachments…".to_owned()); + vec![TelegramCommand::PrepareAttachments(Request { + key, + id, + as_photo: !matches!(&input, Input::Clipboard) + || self.keymap.attachments.clipboard_as_photo, + input, + available_files, + available_bytes, + })] + } + + #[must_use] + pub fn draft_attachments(&self) -> &[crate::staging::Attachment] { + self.active_chat_id + .and_then(|chat| self.draft_data(chat)) + .map_or(&[], |draft| draft.attachments.as_slice()) + } + + #[must_use] + pub fn preparing_attachments(&self) -> bool { + self.active_chat_id.is_some_and(|chat| { + self.attachment_draft + .pending + .as_ref() + .is_some_and(|(key, _, _)| *key == self.draft_key(chat)) + }) + } + + pub(super) fn attachments_prepared( + &mut self, + key: Key, + request_id: u64, + result: Result, + ) { + if !self + .attachment_draft + .pending + .as_ref() + .is_some_and(|(current, id, _)| *current == key && *id == request_id) + { + return; + } + let fallback = self + .attachment_draft + .pending + .take() + .and_then(|(_, _, text)| text); + let prepared = match result { + Ok(prepared) => prepared, + Err(error) => Prepared { + attachments: Vec::new(), + errors: vec![error], + text: None, + }, + }; + if let Some(text) = prepared.text { + self.draft_at_mut(key) + .input + .insert_str(&crate::model::sanitize_terminal_text( + &text.replace("\r\n", "\n").replace('\r', "\n"), + )); + self.status_message = None; + return; + } + for attachment in &prepared.attachments { + attachment.retain_owned(); + } + if prepared.attachments.is_empty() + && let Some(text) = fallback + { + self.draft_at_mut(key).input.insert_str(&text); + self.status_message = None; + return; + } + let count = prepared.attachments.len(); + self.draft_at_mut(key) + .attachments + .extend(prepared.attachments); + self.attachment_draft.selected = 0; + let title = self + .chats + .iter() + .find(|chat| chat.id == key.chat) + .map_or_else(|| key.chat.to_string(), |chat| chat.title.clone()); + self.status_message = Some(format!( + "{count} attachment(s) added to {title}{}", + if prepared.errors.is_empty() { + String::new() + } else { + format!(" · {}", prepared.errors.join(" · ")) + } + )); + } + + pub(super) fn open_attachments(&mut self) -> Vec { + if self.active_chat_id.is_none() { + return Vec::new(); + } + self.attachment_draft.previous_mode = self.mode; + self.attachment_draft.selected = 0; + self.attachment_draft.preview = false; + self.mode = Mode::Attachments; + self.status_message = None; + Vec::new() + } + + pub(super) fn attachment_binding(&mut self, action: &Action) -> Option> { + if self.mode != Mode::Attachments { + return None; + } + let count = self.draft_attachments().len(); + match action { + Action::Cancel if self.attachment_draft.preview => { + self.attachment_draft.preview = false; + } + Action::Cancel => self.mode = self.attachment_draft.previous_mode, + Action::Compose => { + self.mode = Mode::Compose; + self.focus = Focus::Conversation; + } + Action::Up => { + self.attachment_draft.selected = self.attachment_draft.selected.saturating_sub(1); + } + Action::Down => { + self.attachment_draft.selected = self + .attachment_draft + .selected + .saturating_add(1) + .min(count.saturating_sub(1)); + } + Action::RemoveAttachment => { + if self.preparing_attachments() { + self.attachment_draft.pending = None; + self.status_message = Some("Attachment preparation cancelled".to_owned()); + } else if let Some(chat) = self.active_chat_id { + let index = self.attachment_draft.selected; + let attachments = &mut self.draft_data_mut(chat).attachments; + if index < attachments.len() { + attachments.remove(index).discard_owned(); + } + self.attachment_draft.selected = index.min(attachments.len().saturating_sub(1)); + } + self.attachment_draft.preview = false; + } + Action::AttachmentFormat => { + if let Some(chat) = self.active_chat_id { + let index = self.attachment_draft.selected; + if let Some(attachment) = self.draft_data_mut(chat).attachments.get_mut(index) + && attachment.photo_supported + { + attachment.as_photo = !attachment.as_photo; + } + } + } + Action::Open | Action::Preview => { + self.attachment_draft.preview = self + .draft_attachments() + .get(self.attachment_draft.selected) + .is_some_and(|attachment| attachment.photo_supported); + } + Action::Reveal => { + if let Some(file) = self + .draft_attachments() + .get(self.attachment_draft.selected) + .cloned() + { + self.reveal_download(&file.path); + } + } + Action::PasteClipboard => return Some(self.paste_clipboard()), + Action::Attach => { + self.mode = Mode::Navigate; + self.begin_command(); + self.commands.input.set_value("attach "); + } + Action::Help | Action::Quit | Action::NextAccount | Action::AddAccount => return None, + Action::Redraw => { + self.handle_action(KeyAction::Redraw); + } + _ => {} + } + self.force_redraw = true; + Some(Vec::new()) + } +} diff --git a/src/appearance.rs b/src/appearance.rs new file mode 100644 index 0000000..1effc05 --- /dev/null +++ b/src/appearance.rs @@ -0,0 +1,205 @@ +//! Account-specific color overrides, separate from discardable caches and Lua. +use crate::model::ChatId; +use anyhow::Result; +use ratatui::style::Color; +use serde::{Deserialize, Serialize}; +use std::{collections::BTreeMap, path::Path}; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TerminalColor { + #[default] + Default, + Black, + Red, + Green, + Yellow, + Blue, + Magenta, + Cyan, + Gray, + DarkGray, + LightRed, + LightGreen, + LightYellow, + LightBlue, + LightMagenta, + LightCyan, + White, +} + +impl TerminalColor { + pub const ALL: [Self; 17] = [ + Self::Default, + Self::Black, + Self::Red, + Self::Green, + Self::Yellow, + Self::Blue, + Self::Magenta, + Self::Cyan, + Self::Gray, + Self::DarkGray, + Self::LightRed, + Self::LightGreen, + Self::LightYellow, + Self::LightBlue, + Self::LightMagenta, + Self::LightCyan, + Self::White, + ]; + #[must_use] + pub const fn label(self) -> &'static str { + match self { + Self::Default => "Terminal default", + Self::Black => "Black", + Self::Red => "Red", + Self::Green => "Green", + Self::Yellow => "Yellow", + Self::Blue => "Blue", + Self::Magenta => "Magenta", + Self::Cyan => "Cyan", + Self::Gray => "Gray", + Self::DarkGray => "Dark gray", + Self::LightRed => "Light red", + Self::LightGreen => "Light green", + Self::LightYellow => "Light yellow", + Self::LightBlue => "Light blue", + Self::LightMagenta => "Light magenta", + Self::LightCyan => "Light cyan", + Self::White => "White", + } + } + #[must_use] + pub const fn color(self) -> Color { + match self { + Self::Default => Color::Reset, + Self::Black => Color::Black, + Self::Red => Color::Red, + Self::Green => Color::Green, + Self::Yellow => Color::Yellow, + Self::Blue => Color::Blue, + Self::Magenta => Color::Magenta, + Self::Cyan => Color::Cyan, + Self::Gray => Color::Gray, + Self::DarkGray => Color::DarkGray, + Self::LightRed => Color::LightRed, + Self::LightGreen => Color::LightGreen, + Self::LightYellow => Color::LightYellow, + Self::LightBlue => Color::LightBlue, + Self::LightMagenta => Color::LightMagenta, + Self::LightCyan => Color::LightCyan, + Self::White => Color::White, + } + } + #[must_use] + pub fn folder_default(id: i32) -> Self { + const COLORS: [TerminalColor; 6] = [ + TerminalColor::Cyan, + TerminalColor::Yellow, + TerminalColor::Magenta, + TerminalColor::Green, + TerminalColor::Blue, + TerminalColor::Red, + ]; + COLORS[usize::try_from(id.rem_euclid(6)).unwrap_or_default()] + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Colors { + pub chats: BTreeMap, + pub folders: BTreeMap, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Target { + Chat(ChatId), + Folder(i32), +} +impl Colors { + #[must_use] + pub fn get(&self, target: Target) -> Option { + match target { + Target::Chat(id) => self.chats.get(&id), + Target::Folder(id) => self.folders.get(&id), + } + .copied() + } + pub fn set(&mut self, target: Target, value: Option) { + match target { + Target::Chat(id) => { + if let Some(value) = value { + self.chats.insert(id, value); + } else { + self.chats.remove(&id); + } + } + Target::Folder(id) => { + if let Some(value) = value { + self.folders.insert(id, value); + } else { + self.folders.remove(&id); + } + } + } + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Preferences { + pub accounts: BTreeMap, +} +impl Preferences { + /// # Errors + /// Returns read or format errors without replacing the existing preferences. + pub fn load(path: &Path) -> Result { + crate::config::read_preferences(path) + } + /// # Errors + /// Returns write errors; uses the existing atomic settings writer. + pub fn save(&self, path: &Path) -> Result<()> { + crate::config::write_preferences(path, &serde_json::to_vec_pretty(self)?) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn appearance_roundtrip_uses_durable_account_ids_and_keeps_bad_files() { + let dir = std::env::temp_dir().join(format!("termgram-appearance-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("appearance.json"); + let mut preferences = Preferences::default(); + preferences + .accounts + .entry(101) + .or_default() + .set(Target::Chat(5), Some(TerminalColor::Magenta)); + preferences + .accounts + .entry(102) + .or_default() + .set(Target::Folder(5), Some(TerminalColor::Cyan)); + preferences.save(&path).unwrap(); + assert_eq!(Preferences::load(&path).unwrap(), preferences); + preferences + .accounts + .get_mut(&101) + .unwrap() + .set(Target::Chat(5), None); + preferences.save(&path).unwrap(); + assert!( + Preferences::load(&path).unwrap().accounts[&101] + .chats + .is_empty() + ); + std::fs::write(&path, "invalid").unwrap(); + assert!(Preferences::load(&path).is_err()); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "invalid"); + std::fs::remove_dir_all(dir).unwrap(); + } +} diff --git a/src/cache.rs b/src/cache.rs new file mode 100644 index 0000000..0b61d77 --- /dev/null +++ b/src/cache.rs @@ -0,0 +1,2176 @@ +//! Account-local, discardable message storage. Authentication stays in the +//! Telegram session; user preferences do not belong in this database. +mod contents; +mod journal; +mod polls; +mod reactions; +mod search; + +use std::{collections::BTreeMap, path::Path, time::Duration}; + +use anyhow::{Context, Result, bail}; +use libsql::{Connection, Database, TransactionBehavior, params}; +use serde::{Deserialize, Serialize}; + +use crate::{ + event::NetworkEvent, + model::{Chat, ChatId, Delivery, Message}, +}; + +const SCHEMA_VERSION: i64 = 6; +const MAX_MESSAGES: i64 = 100_000; +const MAX_CHAT_MESSAGES: i64 = 5_000; + +/// SDK-independent cursor. Only complete update batches may advance it. +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct SyncCursor { + pub pts: i32, + pub qts: i32, + pub date: i32, + pub seq: i32, + pub channels: Vec<(i64, i32)>, +} + +pub struct Store { + _database: Database, + connection: Connection, + revision: i64, + dialog_revision: i64, + history_revisions: BTreeMap<(ChatId, u64), i64>, + pin_revisions: BTreeMap<(ChatId, u64), i64>, + search_revision: Option<(u64, ChatId, i64)>, + contents_read: contents::Receipts, + poll_updates: polls::Journal, + reaction_updates: reactions::Journal, + poll_revisions: BTreeMap, + reaction_revisions: BTreeMap, + downloads: BTreeMap<(ChatId, i32), (u64, Option)>, + owner: std::sync::Arc, +} + +impl Store { + /// # Errors + /// Returns filesystem, migration or SQLite errors; never discards a cache + /// merely because it could not be read. + pub async fn open(path: &Path) -> Result { + let mut lock_path = path.as_os_str().to_os_string(); + lock_path.push("-lock"); + crate::config::prepare_private_file(Path::new(&lock_path))?; + let owner = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(Path::new(&lock_path))?; + owner + .try_lock() + .context("this account cache is already open in another Termgram process")?; + crate::config::prepare_private_file(path)?; + let database = libsql::Builder::new_local(path).build().await?; + let connection = database.connect()?; + connection.busy_timeout(Duration::from_secs(5))?; + let transaction = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .await?; + let version = transaction + .query("PRAGMA user_version", ()) + .await? + .next() + .await? + .context("missing schema version")? + .get::(0)?; + if version > SCHEMA_VERSION { + bail!("message cache was created by a newer Termgram version"); + } + if version == 0 { + transaction.execute_batch( + "CREATE TABLE metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL); + CREATE TABLE chats (id INTEGER PRIMARY KEY, data TEXT NOT NULL, revision INTEGER NOT NULL); + CREATE TABLE messages (chat_id INTEGER NOT NULL, id INTEGER NOT NULL, + data TEXT, timestamp INTEGER NOT NULL, revision INTEGER NOT NULL, + PRIMARY KEY (chat_id, id)); + CREATE INDEX messages_recent ON messages(timestamp DESC); + CREATE TABLE global_deletions (id INTEGER PRIMARY KEY, revision INTEGER NOT NULL); + CREATE TABLE attachments (chat_id INTEGER NOT NULL, message_id INTEGER NOT NULL, + path TEXT NOT NULL, PRIMARY KEY(chat_id, message_id)); + PRAGMA user_version = 1;" + ).await?; + } + if version < 2 { + transaction.execute_batch("CREATE TABLE history_pages (chat_id INTEGER NOT NULL, before_id INTEGER NOT NULL, oldest_id INTEGER NOT NULL, PRIMARY KEY(chat_id,before_id)); PRAGMA user_version=2;").await?; + } + if version < 3 { + transaction.execute_batch("CREATE INDEX messages_search ON messages(timestamp DESC,chat_id DESC,id DESC); PRAGMA user_version=3;").await?; + } + if version < 4 { + transaction.execute_batch("ALTER TABLE attachments ADD COLUMN source_id INTEGER; DELETE FROM attachments; PRAGMA user_version=4;").await?; + } + if version < 5 { + transaction.execute_batch("ALTER TABLE chats ADD COLUMN last_message_id INTEGER; CREATE INDEX chats_last_message ON chats(last_message_id); PRAGMA user_version=5;").await?; + } + if version < 6 { + transaction.execute_batch("CREATE INDEX messages_poll ON messages(json_extract(data,'$.poll.definition.id')); PRAGMA user_version=6;").await?; + } + transaction.commit().await?; + let revision = metadata(&connection, "revision") + .await? + .and_then(|value| value.parse().ok()) + .unwrap_or(0); + Ok(Self { + _database: database, + owner: std::sync::Arc::new(owner), + connection, + revision, + dialog_revision: revision, + history_revisions: BTreeMap::new(), + pin_revisions: BTreeMap::new(), + search_revision: None, + contents_read: contents::Receipts::default(), + poll_updates: polls::Journal::default(), + reaction_updates: reactions::Journal::default(), + poll_revisions: BTreeMap::new(), + reaction_revisions: BTreeMap::new(), + downloads: BTreeMap::new(), + }) + } + + pub(crate) fn owner(&self) -> std::sync::Arc { + self.owner.clone() + } + + /// Exact cached reply targets, including deletion tombstones. + /// # Errors + /// Returns database or decoding errors. + pub async fn reply_previews( + &self, + chat_id: ChatId, + ids: &[i32], + ) -> Result<(Vec, Vec)> { + anyhow::ensure!(ids.len() <= 32, "reply preview batch is too large"); + let mut rows = self.connection.query( + "SELECT wanted.value, messages.data, messages.id IS NOT NULL OR + (?1>-1000000000000 AND EXISTS(SELECT 1 FROM global_deletions WHERE id=wanted.value)) + FROM json_each(?2) AS wanted LEFT JOIN messages ON messages.chat_id=?1 AND messages.id=wanted.value", + params![chat_id, serde_json::to_string(ids)?], + ).await?; + let mut messages = Vec::new(); + let mut unavailable = Vec::new(); + while let Some(row) = rows.next().await? { + if let Some(data) = row.get::>(1)? { + messages.push(serde_json::from_str(&data)?); + } else if row.get::(2)? != 0 { + unavailable.push(row.get(0)?); + } + } + Ok((messages, unavailable)) + } + + /// # Errors + /// Returns database or decoding errors. + pub async fn snapshot(&self) -> Result<(Option, Vec)> { + let name = metadata(&self.connection, "user_name").await?; + let mut rows = self.connection.query("SELECT data FROM chats", ()).await?; + let mut chats: Vec = Vec::new(); + while let Some(row) = rows.next().await? { + chats.push(serde_json::from_str(&row.get::(0)?)?); + } + chats.sort_by_key(|chat| std::cmp::Reverse(chat.last_activity)); + Ok((name, chats)) + } + + /// # Errors + /// Returns database or decoding errors. + pub async fn folders(&self) -> Result> { + Ok(metadata(&self.connection, "folders") + .await? + .map(|value| serde_json::from_str(&value)) + .transpose()? + .unwrap_or_else(|| vec![crate::folders::Folder::all()])) + } + + /// # Errors + /// Returns database or decoding errors. + pub async fn dialog_pins(&self) -> Result { + Ok(metadata(&self.connection, "dialog_pins") + .await? + .map(|value| serde_json::from_str(&value)) + .transpose()? + .unwrap_or_default()) + } + + /// # Errors + /// Returns database or message decoding errors. + pub async fn pinned_messages( + &self, + chat_id: ChatId, + before: i32, + ) -> Result { + let mut rows = self + .connection + .query( + "SELECT data FROM messages WHERE chat_id=?1 AND data IS NOT NULL + AND json_extract(data,'$.pinned')=1 AND (?2=0 OR id = Vec::new(); + while let Some(row) = rows.next().await? { + messages.push(serde_json::from_str(&row.get::(0)?)?); + } + let next = messages + .last() + .filter(|_| messages.len() == crate::pins::PAGE_SIZE) + .map(|message| message.id); + Ok(crate::pins::MessagePage { + messages, + before, + next, + total: None, + }) + } + + /// # Errors + /// Returns database errors. Missing files are treated as cache misses. + pub async fn attachment( + &self, + chat_id: ChatId, + message_id: i32, + media_id: Option, + ) -> Result> { + let row = self.connection.query("SELECT path FROM attachments WHERE chat_id=?1 AND message_id=?2 AND source_id IS ?3", params![chat_id, message_id, media_id]).await?.next().await?; + let path = row + .map(|row| row.get::(0).map(std::path::PathBuf::from)) + .transpose()?; + Ok(path.filter(|path| path.is_file())) + } + + /// Cached neighbors for a search hit, without triggering network reads. + /// # Errors + /// Returns an error when the hit was deleted or evicted since the search. + pub async fn context(&self, chat_id: ChatId, message_id: i32) -> Result> { + let mut messages = self + .history(chat_id, Some(message_id.saturating_add(1)), 40) + .await?; + if !messages.iter().any(|message| message.id == message_id) { + bail!("Search result is no longer cached; run the search again"); + } + let mut rows = self.connection.query("SELECT data FROM messages WHERE chat_id=?1 AND id>?2 AND data IS NOT NULL ORDER BY id LIMIT 40", params![chat_id, message_id]).await?; + while let Some(row) = rows.next().await? { + messages.push(serde_json::from_str(&row.get::(0)?)?); + } + Ok(messages) + } + + /// # Errors + /// Returns database or decoding errors. + pub async fn cursor(&self) -> Result { + metadata(&self.connection, "cursor") + .await? + .map(|value| serde_json::from_str(&value)) + .transpose() + .map(Option::unwrap_or_default) + .map_err(Into::into) + } + + /// # Errors + /// Returns a database error. + pub async fn account_id(&self) -> Result> { + Ok(metadata(&self.connection, "account_id") + .await? + .and_then(|value| value.parse().ok())) + } + + /// Read a bounded page without any network dependency. + /// # Errors + /// Returns database or decoding errors. + pub async fn history( + &self, + chat_id: ChatId, + before: Option, + limit: usize, + ) -> Result> { + let limit = i64::try_from(limit.min(500)).unwrap_or(500); + let mut rows = self.connection.query( + "SELECT data FROM messages WHERE chat_id=?1 AND id(0)?)?); + } + messages.reverse(); + Ok(messages) + } + + /// A provisional ascending page; the server still verifies its boundary. + /// # Errors + /// Returns database or decoding errors. + pub async fn history_after( + &self, + chat_id: ChatId, + after: i32, + limit: usize, + ) -> Result> { + let mut rows = self.connection.query( + "SELECT data FROM messages WHERE chat_id=?1 AND id>?2 AND data IS NOT NULL ORDER BY id LIMIT ?3", + params![chat_id, after, i64::try_from(limit.min(500)).unwrap_or(500)], + ).await?; + let mut messages = Vec::new(); + while let Some(row) = rows.next().await? { + messages.push(serde_json::from_str(&row.get::(0)?)?); + } + Ok(messages) + } + + /// A cache page is reusable only when this boundary was fetched completely. + /// # Errors + /// Returns database or decoding errors. + pub async fn older_page( + &self, + chat_id: ChatId, + before_id: i32, + ) -> Result>> { + let row = self + .connection + .query( + "SELECT oldest_id FROM history_pages WHERE chat_id=?1 AND before_id=?2", + params![chat_id, before_id], + ) + .await? + .next() + .await?; + let Some(row) = row else { + return Ok(None); + }; + let oldest_id: i32 = row.get(0)?; + if oldest_id == 0 { + return Ok(Some(Vec::new())); + } + let messages = self.history(chat_id, Some(before_id), 80).await?; + if messages + .first() + .is_none_or(|message| message.id != oldest_id) + { + return Ok(None); + } + Ok(Some(messages)) + } + + /// Commit events and any covered cursor together. Replay after an interrupted + /// commit is idempotent, and snapshot writes cannot overwrite later updates. + /// # Errors + /// Returns database or encoding errors; the transaction is rolled back. + #[allow(clippy::too_many_lines)] + pub async fn apply(&mut self, events: &[NetworkEvent]) -> Result<()> { + if events.is_empty() { + return Ok(()); + } + let transaction = self + .connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .await?; + for event in events { + self.revision += 1; + let revision = self.revision; + match event { + NetworkEvent::ReactionsLoading { request_id } => { + self.reaction_revisions.insert(*request_id, revision); + } + NetworkEvent::ReactionsLoaded { + chat_id, + message_id, + request_id, + result, + } => { + if let Some(started) = self.reaction_revisions.remove(request_id) + && let Ok(review) = result + { + let mut summary = review.summary.clone(); + reactions::merge_summary(&transaction, *chat_id, *message_id, &mut summary) + .await?; + reactions::reconcile_summary( + &self.reaction_updates, + *chat_id, + *message_id, + &mut summary, + started, + ); + let update = crate::reactions::Update { + chat: *chat_id, + message: *message_id, + summary, + }; + reactions::apply(&transaction, &update, revision).await?; + self.reaction_updates.record(revision, update); + } + } + NetworkEvent::ReactionsChanged(update) => { + reactions::apply(&transaction, update, revision).await?; + self.reaction_updates.record(revision, update.clone()); + } + NetworkEvent::PollLoading { request_id } => { + self.poll_revisions.insert(*request_id, revision); + } + NetworkEvent::PollLoaded { + request_id, result, .. + } => { + if let Some(started) = self.poll_revisions.remove(request_id) + && let Ok(poll) = result + { + let mut poll = poll.clone(); + polls::merge_cached(&transaction, &mut poll).await?; + self.poll_updates.reconcile(&mut poll, started); + let update = polls::snapshot(&poll); + polls::apply(&transaction, &update, revision).await?; + self.poll_updates.record(revision, update); + } + } + NetworkEvent::PollChanged(update) => { + polls::apply(&transaction, update, revision).await?; + self.poll_updates.record(revision, update.clone()); + } + + NetworkEvent::CloudSearchLoading { + chat_id, + request_id, + } => { + self.search_revision = Some((*request_id, *chat_id, revision)); + } + NetworkEvent::ChatMuteChanged { chat_id, until } + | NetworkEvent::ChatMuteFinished { + chat_id, + result: Ok(Some(until)), + .. + } => { + if let Some((mut chat, _)) = load_chat(&transaction, *chat_id).await? { + chat.membership.mute_until = *until; + write_chat(&transaction, &chat, revision).await?; + } + } + NetworkEvent::SearchFailed { request_id, .. } + | NetworkEvent::CloudSearchCancelled { request_id } => { + if self + .search_revision + .is_some_and(|(id, _, _)| id == *request_id) + { + self.search_revision = None; + } + } + NetworkEvent::CloudSearchResults { + request_id, + chat_id, + page: crate::cloud_search::Page { messages, .. }, + } + | NetworkEvent::CloudSearchContext { + request_id, + chat_id, + messages, + .. + } => { + let Some((_, _, started)) = self + .search_revision + .filter(|(id, chat, _)| *id == *request_id && *chat == *chat_id) + else { + continue; + }; + self.search_revision = None; + for message in messages { + if message.chat_id == *chat_id { + write_message( + &transaction, + message, + revision, + started, + &self.contents_read, + &self.poll_updates, + &self.reaction_updates, + ) + .await?; + } + } + prune_chat_messages(&transaction, *chat_id).await?; + } + NetworkEvent::ReplyPreviews { + chat_id, + request_id, + messages, + complete: true, + .. + } => { + let Some(started) = self.history_revisions.remove(&(*chat_id, *request_id)) + else { + continue; + }; + for message in messages { + write_message( + &transaction, + message, + revision, + started, + &self.contents_read, + &self.poll_updates, + &self.reaction_updates, + ) + .await?; + } + prune_chat_messages(&transaction, *chat_id).await?; + } + NetworkEvent::PinnedMessagesLoading { + chat_id, + request_id, + } => { + self.pin_revisions.insert((*chat_id, *request_id), revision); + } + NetworkEvent::PinnedMessages { + chat_id, + request_id, + page, + } if page.total.is_some() => { + let Some(started) = self.pin_revisions.remove(&(*chat_id, *request_id)) else { + continue; + }; + for message in &page.messages { + write_message( + &transaction, + message, + revision, + started, + &self.contents_read, + &self.poll_updates, + &self.reaction_updates, + ) + .await?; + } + transaction.execute( + "UPDATE messages SET data=json_set(data,'$.pinned',json('false')), revision=?1 + WHERE chat_id=?2 AND data IS NOT NULL AND json_extract(data,'$.pinned')=1 + AND revision<=?3 AND (?4=0 OR id=?5", + params![revision, *chat_id, started, page.before, page.next.unwrap_or(0)], + ).await?; + prune_chat_messages(&transaction, *chat_id).await?; + } + NetworkEvent::PinnedMessagesFailed { + chat_id, + request_id, + .. + } => { + self.pin_revisions.remove(&(*chat_id, *request_id)); + } + NetworkEvent::MessagePinsChanged { + chat_id, + message_ids, + pinned, + } => { + for id in message_ids { + transaction.execute( + "UPDATE messages SET data=json_set(data,'$.pinned',json(?1)), revision=?2 + WHERE chat_id=?3 AND id=?4 AND data IS NOT NULL", + params![if *pinned { "true" } else { "false" }, revision, *chat_id, *id], + ).await?; + } + } + NetworkEvent::MessagePinsCleared { chat_id } => { + transaction.execute("UPDATE messages SET data=json_set(data,'$.pinned',json('false')), revision=?1 WHERE chat_id=?2 AND data IS NOT NULL", params![revision, *chat_id]).await?; + } + NetworkEvent::PinnedContext { + chat_id, + request_id, + messages, + .. + } => { + let Some(started) = self.pin_revisions.remove(&(*chat_id, *request_id)) else { + continue; + }; + for message in messages { + write_message( + &transaction, + message, + revision, + started, + &self.contents_read, + &self.poll_updates, + &self.reaction_updates, + ) + .await?; + } + prune_chat_messages(&transaction, *chat_id).await?; + } + NetworkEvent::DialogPins(pins) => { + set_metadata(&transaction, "dialog_pins", &serde_json::to_string(pins)?) + .await?; + } + NetworkEvent::ArchiveChanged { chat_id, archived } => { + if let Some((mut chat, _)) = load_chat(&transaction, *chat_id).await? { + chat.membership.archived = *archived; + write_chat(&transaction, &chat, revision).await?; + } + } + NetworkEvent::CacheAccountReset { user_id } => { + self.history_revisions.clear(); + self.pin_revisions.clear(); + self.poll_revisions.clear(); + self.reaction_revisions.clear(); + self.poll_updates = polls::Journal::default(); + self.reaction_updates = reactions::Journal::default(); + self.search_revision = None; + self.contents_read = contents::Receipts::default(); + self.downloads.clear(); + transaction.execute_batch("DELETE FROM chats; DELETE FROM messages; DELETE FROM metadata; DELETE FROM attachments; DELETE FROM global_deletions; DELETE FROM history_pages;").await?; + set_metadata(&transaction, "account_id", &user_id.to_string()).await?; + } + NetworkEvent::Folders(folders) => { + set_metadata(&transaction, "folders", &serde_json::to_string(folders)?).await?; + } + NetworkEvent::UnreadChanged { + chat_id, + max_id, + unread, + } => { + if let Some((mut chat, _)) = load_chat(&transaction, *chat_id).await? { + crate::read_state::inbox_update(&mut chat, *max_id, *unread); + write_chat(&transaction, &chat, revision).await?; + } + } + NetworkEvent::AccountIdentity { user_id } => { + set_metadata(&transaction, "account_id", &user_id.to_string()).await?; + } + NetworkEvent::Ready { user_name } => { + set_metadata(&transaction, "user_name", user_name).await?; + } + NetworkEvent::LinkResolved { chat, .. } + | NetworkEvent::InviteJoined { + result: Ok(crate::invites::Outcome::Joined(chat)), + .. + } => { + if load_chat(&transaction, chat.id).await?.is_none() { + write_chat(&transaction, chat, revision).await?; + } + } + NetworkEvent::SyncCheckpoint(cursor) => { + set_metadata(&transaction, "cursor", &serde_json::to_string(cursor)?).await?; + } + NetworkEvent::DialogsLoading => self.dialog_revision = revision, + NetworkEvent::HistoryLoading { + chat_id, + request_id, + } + | NetworkEvent::ReplyPreviewsLoading { + chat_id, + request_id, + } => { + self.history_revisions + .insert((*chat_id, *request_id), revision); + } + NetworkEvent::Dialogs(chats) => { + for chat in chats { + let mut chat = chat.clone(); + if let Some((current, updated)) = load_chat(&transaction, chat.id).await? + && updated > self.dialog_revision + { + chat.last_message = current.last_message; + chat.last_activity = current.last_activity; + chat.last_message_id = current.last_message_id; + chat.unread = current.unread; + chat.membership.unread_mark = current.membership.unread_mark; + chat.read_inbox_max_id = + chat.read_inbox_max_id.max(current.read_inbox_max_id); + } + write_chat(&transaction, &chat, revision).await?; + } + transaction + .execute( + "DELETE FROM chats WHERE revision<=?1", + [self.dialog_revision], + ) + .await?; + } + NetworkEvent::History { + chat_id, + request_id, + messages, + } + | NetworkEvent::OlderHistory { + chat_id, + request_id, + messages, + .. + } => { + let Some(started) = self.history_revisions.remove(&(*chat_id, *request_id)) + else { + continue; + }; + if let NetworkEvent::OlderHistory { before_id, .. } = event { + transaction + .execute( + "INSERT OR REPLACE INTO history_pages VALUES(?1,?2,?3)", + params![ + *chat_id, + *before_id, + messages.first().map_or(0, |message| message.id) + ], + ) + .await?; + } + for message in messages { + write_message( + &transaction, + message, + revision, + started, + &self.contents_read, + &self.poll_updates, + &self.reaction_updates, + ) + .await?; + } + prune_chat_messages(&transaction, *chat_id).await?; + } + NetworkEvent::HistoryFailed { + chat_id, + request_id, + .. + } + | NetworkEvent::ReplyPreviewsFailed { + chat_id, + request_id, + .. + } + | NetworkEvent::MessageLoadFailed { + chat_id, + request_id, + .. + } + | NetworkEvent::OlderHistoryFailed { + chat_id, + request_id, + .. + } => { + self.history_revisions.remove(&(*chat_id, *request_id)); + } + NetworkEvent::NewMessage(message) + | NetworkEvent::CacheMessage(message) + | NetworkEvent::MessageUpdated(message) + | NetworkEvent::MessageSent { message, .. } + | NetworkEvent::MessageLoaded { message, .. } => { + if matches!(event, NetworkEvent::MessageUpdated(_)) { + let media_id = message + .attachment + .as_ref() + .and_then(|attachment| attachment.source_id); + if media_id.is_none() + || self + .downloads + .get(&(message.chat_id, message.id)) + .is_some_and(|(_, expected)| *expected != media_id) + { + self.downloads.remove(&(message.chat_id, message.id)); + } + if media_id.is_none() { + transaction + .execute( + "DELETE FROM attachments WHERE chat_id=?1 AND message_id=?2", + params![message.chat_id, message.id], + ) + .await?; + } + } + let existed = transaction + .query( + "SELECT 1 FROM messages WHERE chat_id=?1 AND id=?2", + params![message.chat_id, message.id], + ) + .await? + .next() + .await? + .is_some(); + let started = if let NetworkEvent::MessageLoaded { + chat_id, + request_id, + .. + } = event + { + self.history_revisions + .remove(&(*chat_id, *request_id)) + .unwrap_or(revision) + } else { + self.contents_read.supersede(message); + revision + }; + write_message( + &transaction, + message, + revision, + started, + &self.contents_read, + &self.poll_updates, + &self.reaction_updates, + ) + .await?; + if let Some((mut chat, _)) = load_chat(&transaction, message.chat_id).await? { + let latest = chat.last_message_id.map_or_else( + || { + chat.last_activity + .is_none_or(|date| message.timestamp >= date) + }, + |id| message.id >= id, + ); + if matches!(event, NetworkEvent::NewMessage(_)) + && !message.outgoing + && !existed + && latest + && chat.read_inbox_max_id.is_none_or(|seen| message.id > seen) + { + chat.unread = chat.unread.saturating_add(1); + } + if latest { + chat.last_message = message.preview_text(); + chat.last_message_id = Some(message.id); + chat.last_activity = Some(message.timestamp); + } + write_chat(&transaction, &chat, revision).await?; + } + } + + NetworkEvent::MessageContentsRead { + channel_id, + message_ids, + } => { + self.contents_read + .record(*channel_id, message_ids, revision); + transaction.execute( + "UPDATE messages SET data=json_set(data,'$.mention.unread',json('false')),revision=?1 + WHERE data IS NOT NULL AND json_extract(data,'$.mention.unread')=1 + AND ((?2 IS NULL AND chat_id>-1000000000000) OR chat_id=?2) + AND id IN (SELECT value FROM json_each(?3))", + params![revision, *channel_id, serde_json::to_string(message_ids)?], + ).await?; + } + NetworkEvent::MessagesDeleted { + channel_id, + message_ids, + } => { + for message_id in message_ids { + let mut rows = transaction.query("SELECT data FROM chats WHERE last_message_id=?1 AND ((?2 IS NULL AND id>-1000000000000) OR id=?2)", params![*message_id, *channel_id]).await?; + let mut affected = Vec::new(); + while let Some(row) = rows.next().await? { + let mut chat: Chat = serde_json::from_str(&row.get::(0)?)?; + chat.last_message.clear(); + chat.last_message_id = None; + affected.push(chat); + } + drop(rows); + for chat in affected { + write_chat(&transaction, &chat, revision).await?; + } + } + self.downloads.retain(|(chat_id, id), _| { + !(message_ids.contains(id) + && channel_id.map_or(*chat_id > -1_000_000_000_000, |channel| { + channel == *chat_id + })) + }); + for id in message_ids { + if let Some(chat_id) = channel_id { + transaction.execute( + "INSERT INTO messages VALUES(?1,?2,NULL,unixepoch(),?3) ON CONFLICT(chat_id,id) + DO UPDATE SET data=NULL,revision=excluded.revision", + params![*chat_id, *id, revision], + ).await?; + } else { + transaction + .execute( + "INSERT OR REPLACE INTO global_deletions VALUES(?1,?2)", + params![*id, revision], + ) + .await?; + transaction.execute("UPDATE messages SET data=NULL,revision=?2 WHERE id=?1 AND chat_id>-1000000000000", params![*id, revision]).await?; + } + } + } + NetworkEvent::MessagesRead { chat_id, max_id } => { + let mut rows = transaction.query("SELECT data FROM messages WHERE chat_id=?1 AND id<=?2 AND data IS NOT NULL", params![*chat_id, *max_id]).await?; + let mut messages: Vec = Vec::new(); + while let Some(row) = rows.next().await? { + messages.push(serde_json::from_str(&row.get::(0)?)?); + } + drop(rows); + for mut message in messages { + if message.outgoing { + message.delivery = Delivery::Read; + write_message( + &transaction, + &message, + revision, + revision, + &self.contents_read, + &self.poll_updates, + &self.reaction_updates, + ) + .await?; + } + } + } + NetworkEvent::ChatUnreadChanged { chat_id, unread } => { + if let Some((mut chat, _)) = load_chat(&transaction, *chat_id).await? { + crate::read_state::unread_mark(&mut chat, *unread); + write_chat(&transaction, &chat, revision).await?; + } + } + NetworkEvent::ChatUnreadFinished { + chat_id, + unread, + snapshot, + error: None, + .. + } => { + if let Some((mut chat, _)) = load_chat(&transaction, *chat_id).await? { + crate::read_state::unread_mark(&mut chat, *unread); + if let Some(snapshot) = snapshot { + crate::read_state::acknowledge( + &mut chat, + snapshot.max_id, + Some(snapshot), + ); + } + write_chat(&transaction, &chat, revision).await?; + } + } + NetworkEvent::ReadMarked { + chat_id, + max_id, + snapshot, + } => { + if let Some((mut chat, _)) = load_chat(&transaction, *chat_id).await? { + crate::read_state::acknowledge(&mut chat, *max_id, snapshot.as_ref()); + write_chat(&transaction, &chat, revision).await?; + } + } + NetworkEvent::AttachmentDownloadStarted { + chat_id, + message_id, + request_id, + media_id, + } => { + self.downloads + .insert((*chat_id, *message_id), (*request_id, *media_id)); + } + NetworkEvent::AttachmentDownloadFailed { + chat_id, + message_id, + request_id, + .. + } => { + if self + .downloads + .get(&(*chat_id, *message_id)) + .is_some_and(|(id, _)| *id == *request_id) + { + self.downloads.remove(&(*chat_id, *message_id)); + } + } + NetworkEvent::AttachmentDownloaded { + request_id, + chat_id, + message_id, + path, + } => { + let Some((id, media_id)) = + self.downloads.get(&(*chat_id, *message_id)).copied() + else { + continue; + }; + if id != *request_id { + continue; + } + self.downloads.remove(&(*chat_id, *message_id)); + transaction + .execute( + "INSERT OR REPLACE INTO attachments VALUES(?1,?2,?3,?4)", + params![ + *chat_id, + *message_id, + path.to_string_lossy().as_ref(), + media_id + ], + ) + .await?; + } + NetworkEvent::CacheInvalidated { chat_id } => { + self.poll_revisions.clear(); + self.reaction_revisions.clear(); + if self + .search_revision + .is_some_and(|(_, id, _)| chat_id.is_none_or(|chat| chat == id)) + { + self.search_revision = None; + } + self.downloads + .retain(|(id, _), _| chat_id.is_some_and(|chat_id| *id != chat_id)); + self.history_revisions + .retain(|(id, _), _| chat_id.is_some_and(|chat_id| *id != chat_id)); + self.pin_revisions + .retain(|(id, _), _| chat_id.is_some_and(|chat_id| *id != chat_id)); + if let Some(chat_id) = chat_id { + transaction + .execute("DELETE FROM history_pages WHERE chat_id=?1", [*chat_id]) + .await?; + transaction + .execute("DELETE FROM messages WHERE chat_id=?1", [*chat_id]) + .await?; + } else { + transaction.execute("DELETE FROM history_pages", ()).await?; + transaction.execute("DELETE FROM messages", ()).await?; + } + } + _ => {} + } + } + transaction.execute("DELETE FROM messages WHERE (chat_id,id) IN (SELECT chat_id,id FROM messages ORDER BY timestamp DESC LIMIT -1 OFFSET ?1)", [MAX_MESSAGES]).await?; + transaction.execute_batch("DELETE FROM attachments WHERE NOT EXISTS(SELECT 1 FROM messages WHERE messages.chat_id=attachments.chat_id AND messages.id=attachments.message_id AND messages.data IS NOT NULL); + DELETE FROM history_pages WHERE oldest_id>0 AND NOT EXISTS(SELECT 1 FROM messages WHERE messages.chat_id=history_pages.chat_id AND messages.id=history_pages.oldest_id);").await?; + transaction.execute("DELETE FROM history_pages WHERE rowid IN (SELECT rowid FROM history_pages ORDER BY rowid DESC LIMIT -1 OFFSET ?1)", [MAX_MESSAGES]).await?; + let oldest_in_flight = self + .history_revisions + .values() + .chain(self.pin_revisions.values()) + .chain(self.poll_revisions.values()) + .chain(self.reaction_revisions.values()) + .copied() + .chain(self.search_revision.map(|(_, _, revision)| revision)) + .min() + .unwrap_or(self.revision); + transaction.execute("DELETE FROM global_deletions WHERE revision Result<()> { + connection + .execute( + "DELETE FROM messages WHERE chat_id=?1 AND id NOT IN + (SELECT id FROM messages WHERE chat_id=?1 ORDER BY id DESC LIMIT ?2)", + params![chat_id, MAX_CHAT_MESSAGES], + ) + .await?; + Ok(()) +} + +async fn write_message( + connection: &Connection, + message: &Message, + revision: i64, + started: i64, + receipts: &contents::Receipts, + polls: &polls::Journal, + reactions: &reactions::Journal, +) -> Result<()> { + let mut message = receipts.reconcile(message, started); + if message.poll.is_some() { + let poll = message.to_mut().poll.as_mut().expect("poll present"); + polls::merge_cached(connection, poll).await?; + polls.reconcile(poll, started); + } + if message + .reactions + .as_ref() + .is_some_and(|summary| !summary.choices_known) + { + reactions::merge_cached(connection, message.to_mut()).await?; + } + if reactions.stale(started) || reactions.since(started).next().is_some() { + reactions::reconcile(reactions, message.to_mut(), started); + } + if message.id <= 0 { + return Ok(()); + } + connection + .execute( + "DELETE FROM attachments WHERE chat_id=?1 AND message_id=?2 AND source_id IS NOT ?3", + params![ + message.chat_id, + message.id, + message + .attachment + .as_ref() + .and_then(|attachment| attachment.source_id) + ], + ) + .await?; + connection.execute( + "INSERT INTO messages(chat_id,id,data,timestamp,revision) + SELECT ?1,?2,?3,?4,?5 WHERE NOT EXISTS + (SELECT 1 FROM global_deletions WHERE id=?2 AND ?1>-1000000000000) + ON CONFLICT(chat_id,id) DO UPDATE SET data=excluded.data,timestamp=excluded.timestamp,revision=excluded.revision + WHERE messages.data IS NOT NULL AND messages.revision<=?6", + params![message.chat_id, message.id, serde_json::to_string(message.as_ref())?, message.timestamp.timestamp(), revision, started], + ).await?; + Ok(()) +} + +async fn load_chat(connection: &Connection, id: ChatId) -> Result> { + let row = connection + .query("SELECT data,revision FROM chats WHERE id=?1", [id]) + .await? + .next() + .await?; + row.map(|row| Ok((serde_json::from_str(&row.get::(0)?)?, row.get(1)?))) + .transpose() +} + +async fn write_chat(connection: &Connection, chat: &Chat, revision: i64) -> Result<()> { + connection + .execute( + "INSERT OR REPLACE INTO chats VALUES(?1,?2,?3,?4)", + params![ + chat.id, + serde_json::to_string(chat)?, + revision, + chat.last_message_id + ], + ) + .await?; + Ok(()) +} + +async fn metadata(connection: &Connection, key: &str) -> Result> { + connection + .query("SELECT value FROM metadata WHERE key=?1", [key]) + .await? + .next() + .await? + .map(|row| row.get(0)) + .transpose() + .map_err(Into::into) +} + +async fn set_metadata(connection: &Connection, key: &str, value: &str) -> Result<()> { + connection + .execute( + "INSERT OR REPLACE INTO metadata VALUES(?1,?2)", + params![key, value], + ) + .await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::ChatKind; + use chrono::{TimeZone, Utc}; + + fn message(id: i32, text: &str) -> Message { + Message { + reactions: None, + poll: None, + entities: Vec::new(), + notification: None, + mention: None, + edited_at: None, + pinned: false, + id, + chat_id: 42, + sender: "Ada".to_owned(), + reply_to: None, + text: text.to_owned(), + timestamp: Utc.timestamp_opt(100 + i64::from(id), 0).unwrap(), + outgoing: false, + delivery: Delivery::Read, + attachment: None, + links: Vec::new(), + buttons: Vec::new(), + } + } + + #[tokio::test] + #[allow(clippy::too_many_lines)] + async fn reactions_reconcile_late_and_minimal_messages_without_crossing_accounts() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("cache.db"); + let mut store = Store::open(&path).await.unwrap(); + let mut original = message(10, "reaction target"); + original.reactions = Some(crate::reactions::example().summary); + let mut latest = original.reactions.clone().unwrap(); + latest.counts[0].count = 5; + store + .apply(&[ + NetworkEvent::HistoryLoading { + chat_id: 42, + request_id: 1, + }, + NetworkEvent::ReactionsLoading { request_id: 2 }, + NetworkEvent::ReactionsChanged(crate::reactions::Update { + chat: 42, + message: 10, + summary: latest.clone(), + }), + ]) + .await + .unwrap(); + let mut page = NetworkEvent::History { + chat_id: 42, + request_id: 1, + messages: vec![original.clone()], + }; + store.reconcile_snapshots(&mut page).await.unwrap(); + let NetworkEvent::History { messages, .. } = &page else { + unreachable!() + }; + assert_eq!(messages[0].reactions.as_ref(), Some(&latest)); + store.apply(&[page]).await.unwrap(); + let mut loaded = NetworkEvent::ReactionsLoaded { + chat_id: 42, + message_id: 10, + request_id: 2, + result: Ok(crate::reactions::example()), + }; + store.reconcile_snapshots(&mut loaded).await.unwrap(); + let NetworkEvent::ReactionsLoaded { + result: Ok(review), .. + } = &loaded + else { + unreachable!() + }; + assert_eq!(review.summary, latest); + store.apply(&[loaded]).await.unwrap(); + // A minimal live edit preserves private choices in both the event + // shown by the application and the persisted cache. + let minimal = original.reactions.as_mut().unwrap(); + minimal.choices_known = false; + for count in &mut minimal.counts { + count.chosen = None; + } + minimal.counts[0].count = 6; + let mut edited = NetworkEvent::MessageUpdated(original); + assert!(Store::has_message_snapshot(&edited)); + store.reconcile_snapshots(&mut edited).await.unwrap(); + let NetworkEvent::MessageUpdated(message) = &edited else { + unreachable!() + }; + assert_eq!( + message.reactions.as_ref().unwrap().chosen(), + latest.chosen() + ); + store.apply(&[edited]).await.unwrap(); + drop(store); + let mut store = Store::open(&path).await.unwrap(); + let messages = store.history(42, None, 10).await.unwrap(); + let summary = messages[0].reactions.as_ref().unwrap(); + assert_eq!(summary.counts[0].count, 6); + assert_eq!(summary.chosen(), latest.chosen()); + store + .apply(&[ + NetworkEvent::ReactionsLoading { request_id: 3 }, + NetworkEvent::ReactionsChanged(crate::reactions::Update { + chat: 42, + message: 10, + summary: latest, + }), + NetworkEvent::CacheAccountReset { user_id: 99 }, + ]) + .await + .unwrap(); + let mut late = NetworkEvent::ReactionsLoaded { + chat_id: 42, + message_id: 10, + request_id: 3, + result: Ok(crate::reactions::example()), + }; + store.reconcile_snapshots(&mut late).await.unwrap(); + assert!(matches!( + late, + NetworkEvent::ReactionsLoaded { result: Err(_), .. } + )); + assert_eq!(store.reaction_updates.since(0).count(), 0); + assert!(store.history(42, None, 10).await.unwrap().is_empty()); + } + + #[tokio::test] + #[allow(clippy::too_many_lines)] + async fn poll_snapshots_keep_live_results_and_choices_across_copies_and_restart() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("cache.db"); + let mut store = Store::open(&path).await.unwrap(); + let mut original = message(10, ""); + original.poll = Some(crate::polls::example()); + let mut copy = original.clone(); + copy.id = 11; + let mut unseen = original.clone(); + unseen.id = 12; + unseen.poll.as_mut().unwrap().definition.id = 124; + let result = crate::polls::ResultsPatch { + min: false, + counts: Some(vec![crate::polls::Count { + option: vec![0, 255], + voters: Some(7), + chosen: true, + correct: false, + }]), + total: Some(7), + solution: None, + }; + store + .apply(&[ + NetworkEvent::CacheMessage(original.clone()), + NetworkEvent::CacheMessage(copy.clone()), + NetworkEvent::HistoryLoading { + chat_id: 42, + request_id: 1, + }, + NetworkEvent::PollLoading { request_id: 2 }, + NetworkEvent::PollChanged(crate::polls::Update { + id: 123, + stale: false, + definition: None, + results: result.clone(), + }), + NetworkEvent::PollChanged(crate::polls::Update { + id: 124, + stale: false, + definition: None, + results: result, + }), + NetworkEvent::MessagesDeleted { + channel_id: None, + message_ids: vec![11], + }, + ]) + .await + .unwrap(); + let mut history = NetworkEvent::History { + chat_id: 42, + request_id: 1, + messages: vec![original.clone(), copy, unseen], + }; + store.reconcile_snapshots(&mut history).await.unwrap(); + let NetworkEvent::History { messages, .. } = &history else { + unreachable!() + }; + for message in messages { + let poll = message.poll.as_ref().unwrap(); + assert!(poll.voted()); + assert_eq!(poll.results.total, Some(7)); + } + store.apply(&[history]).await.unwrap(); + let mut loaded = NetworkEvent::PollLoaded { + chat_id: 42, + message_id: 10, + request_id: 2, + result: Ok(original.poll.clone().unwrap()), + }; + store.reconcile_snapshots(&mut loaded).await.unwrap(); + let NetworkEvent::PollLoaded { + result: Ok(poll), .. + } = &loaded + else { + unreachable!() + }; + assert!(poll.voted()); + store.apply(&[loaded]).await.unwrap(); + // A later minimal history has no new patches to replay. It still must + // inherit this account's known choice from the durable poll copy. + original.id = 13; + let poll = original.poll.as_mut().unwrap(); + poll.results = crate::polls::Results { + total: Some(9), + ..Default::default() + }; + store + .apply(&[NetworkEvent::HistoryLoading { + chat_id: 42, + request_id: 3, + }]) + .await + .unwrap(); + let mut page = NetworkEvent::History { + chat_id: 42, + request_id: 3, + messages: vec![original], + }; + store.reconcile_snapshots(&mut page).await.unwrap(); + let NetworkEvent::History { messages, .. } = &page else { + unreachable!() + }; + assert!(messages[0].poll.as_ref().unwrap().voted()); + store + .apply(&[ + page, + NetworkEvent::SyncCheckpoint(SyncCursor { + pts: 88, + ..Default::default() + }), + ]) + .await + .unwrap(); + drop(store); + let store = Store::open(&path).await.unwrap(); + let messages = store.history(42, None, 10).await.unwrap(); + assert_eq!(messages.len(), 3); + assert!(!messages.iter().any(|message| message.id == 11)); + assert!( + messages + .iter() + .all(|message| message.poll.as_ref().unwrap().voted()) + ); + assert_eq!( + messages + .iter() + .find(|message| message.id == 13) + .unwrap() + .poll + .as_ref() + .unwrap() + .results + .total, + Some(9) + ); + assert_eq!(store.cursor().await.unwrap().pts, 88); + } + + #[tokio::test] + async fn cloud_search_snapshot_keeps_live_edits_tombstones_and_invalidation() { + let directory = tempfile::tempdir().unwrap(); + let mut store = Store::open(&directory.path().join("cache.db")) + .await + .unwrap(); + store + .apply(&[ + NetworkEvent::CloudSearchLoading { + chat_id: 42, + request_id: 1, + }, + NetworkEvent::MessageUpdated(message(10, "live edit")), + NetworkEvent::MessagesDeleted { + channel_id: None, + message_ids: vec![11], + }, + ]) + .await + .unwrap(); + let mut event = NetworkEvent::CloudSearchResults { + request_id: 1, + chat_id: 42, + page: crate::cloud_search::Page { + messages: vec![ + message(12, "new hit"), + message(11, "deleted"), + message(10, "old snapshot"), + ], + next: Some(10), + total: 3, + sender: None, + }, + }; + store.reconcile_cloud_search(&mut event).await.unwrap(); + let NetworkEvent::CloudSearchResults { page, .. } = &event else { + panic!("merged results") + }; + assert_eq!( + page.messages + .iter() + .map(|message| message.text.as_str()) + .collect::>(), + vec!["new hit", "live edit"] + ); + assert_eq!(page.next, Some(10)); + store.apply(&[event]).await.unwrap(); + assert_eq!(store.history(42, None, 10).await.unwrap().len(), 2); + store + .apply(&[ + NetworkEvent::CloudSearchLoading { + chat_id: 42, + request_id: 2, + }, + NetworkEvent::CacheInvalidated { chat_id: Some(42) }, + ]) + .await + .unwrap(); + let mut event = NetworkEvent::CloudSearchContext { + request_id: 2, + chat_id: 42, + message_id: 10, + messages: vec![message(10, "stale context")], + }; + store.reconcile_cloud_search(&mut event).await.unwrap(); + assert!(matches!( + event, + NetworkEvent::SearchFailed { request_id: 2, .. } + )); + store.apply(&[event]).await.unwrap(); + assert!(store.history(42, None, 10).await.unwrap().is_empty()); + assert!(store.search_revision.is_none()); + } + + #[tokio::test] + async fn reply_snapshot_keeps_live_edits_and_deleted_targets_across_restart() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("reply-cache.sqlite3"); + let mut store = Store::open(&path).await.unwrap(); + store + .apply(&[ + NetworkEvent::ReplyPreviewsLoading { + chat_id: 42, + request_id: 1, + }, + NetworkEvent::MessageUpdated(message(10, "edited")), + NetworkEvent::MessagesDeleted { + channel_id: None, + message_ids: vec![11], + }, + NetworkEvent::ReplyPreviews { + chat_id: 42, + request_id: 1, + messages: vec![ + message(10, "stale"), + message(11, "deleted"), + message(12, "original"), + ], + unavailable: Vec::new(), + complete: true, + }, + ]) + .await + .unwrap(); + drop(store); + let store = Store::open(&path).await.unwrap(); + let (messages, deleted) = store.reply_previews(42, &[10, 11, 12, 13]).await.unwrap(); + assert_eq!( + messages + .iter() + .map(|message| (message.id, message.text.as_str())) + .collect::>(), + vec![(10, "edited"), (12, "original")] + ); + assert_eq!(deleted, vec![11]); + assert!( + store + .reply_previews(43, &[10, 12]) + .await + .unwrap() + .0 + .is_empty() + ); + } + + #[tokio::test] + #[allow(clippy::too_many_lines)] + async fn restart_keeps_live_changes_and_their_cursor_and_isolates_accounts() { + let directory = std::env::temp_dir().join(format!("termgram-cache-{}", std::process::id())); + std::fs::create_dir_all(&directory).unwrap(); + let path = directory.join("one.sqlite3"); + let mut store = Store::open(&path).await.unwrap(); + let chat = Chat { + read_inbox_max_id: Some(0), + membership: crate::folders::ChatMembership::default(), + id: 42, + title: "Group".to_owned(), + kind: ChatKind::Group, + unread: 0, + last_message_id: None, + last_message: String::new(), + last_activity: None, + }; + let cursor = SyncCursor { + pts: 12, + date: 123, + seq: 4, + ..SyncCursor::default() + }; + store + .apply(&[ + NetworkEvent::AccountIdentity { user_id: 7 }, + NetworkEvent::Ready { + user_name: "Ada".to_owned(), + }, + NetworkEvent::DialogsLoading, + NetworkEvent::Dialogs(vec![chat]), + NetworkEvent::ArchiveChanged { + chat_id: 42, + archived: true, + }, + NetworkEvent::DialogPins(crate::pins::DialogPins { + main: vec![7], + archive: vec![42], + }), + NetworkEvent::HistoryLoading { + chat_id: 42, + request_id: 1, + }, + NetworkEvent::MessageUpdated(message(1, "edited")), + NetworkEvent::MessagesDeleted { + channel_id: None, + message_ids: vec![2], + }, + NetworkEvent::NewMessage(message(3, "live")), + NetworkEvent::History { + chat_id: 42, + request_id: 1, + messages: vec![message(1, "old"), message(2, "deleted")], + }, + NetworkEvent::SyncCheckpoint(cursor.clone()), + ]) + .await + .unwrap(); + drop(store); + let mut store = Store::open(&path).await.unwrap(); + let messages = store.history(42, None, 80).await.unwrap(); + assert_eq!( + messages + .iter() + .map(|message| message.text.as_str()) + .collect::>(), + vec!["edited", "live"] + ); + assert_eq!(store.cursor().await.unwrap(), cursor); + assert_eq!(store.account_id().await.unwrap(), Some(7)); + assert!(store.snapshot().await.unwrap().1[0].membership.archived); + assert_eq!(store.dialog_pins().await.unwrap().archive, vec![42]); + assert_eq!(store.snapshot().await.unwrap().0.as_deref(), Some("Ada")); + let other = Store::open(&directory.join("two.sqlite3")).await.unwrap(); + assert!(other.history(42, None, 80).await.unwrap().is_empty()); + assert!(other.snapshot().await.unwrap().1.is_empty()); + drop(other); + + // A failed batch must roll back both its content and its checkpoint. + store.connection.execute_batch("CREATE TRIGGER reject_message BEFORE INSERT ON messages WHEN NEW.id=9 BEGIN SELECT RAISE(ABORT,'simulated write failure'); END;").await.unwrap(); + assert!( + store + .apply(&[ + NetworkEvent::SyncCheckpoint(SyncCursor { + pts: 99, + ..cursor.clone() + }), + NetworkEvent::NewMessage(message(9, "uncommitted")) + ]) + .await + .is_err() + ); + assert_eq!(store.cursor().await.unwrap(), cursor); + store + .apply(&[NetworkEvent::CacheAccountReset { user_id: 8 }]) + .await + .unwrap(); + assert!(store.history(42, None, 80).await.unwrap().is_empty()); + assert!(store.snapshot().await.unwrap().1.is_empty()); + assert_eq!(store.cursor().await.unwrap(), SyncCursor::default()); + assert_eq!( + store.dialog_pins().await.unwrap(), + crate::pins::DialogPins::default() + ); + drop(store); + std::fs::remove_dir_all(directory).unwrap(); + } + #[tokio::test] + async fn cached_paging_requires_a_complete_boundary_and_keeps_tombstones() { + let directory = + std::env::temp_dir().join(format!("termgram-paging-{}", std::process::id())); + std::fs::create_dir_all(&directory).unwrap(); + let mut store = Store::open(&directory.join("cache.sqlite3")).await.unwrap(); + store + .apply(&[ + NetworkEvent::NewMessage(message(3, "live")), + NetworkEvent::MessagesDeleted { + channel_id: None, + message_ids: vec![2], + }, + ]) + .await + .unwrap(); + assert!( + store.older_page(42, 4).await.unwrap().is_none(), + "isolated cached messages are not a complete history page" + ); + store + .apply(&[ + NetworkEvent::HistoryLoading { + chat_id: 42, + request_id: 2, + }, + NetworkEvent::OlderHistory { + chat_id: 42, + request_id: 2, + before_id: 4, + messages: vec![ + message(1, "edited"), + message(2, "deleted"), + message(3, "live"), + ], + }, + ]) + .await + .unwrap(); + assert_eq!(store.older_page(42, 4).await.unwrap().unwrap().len(), 2); + + drop(store); + std::fs::remove_dir_all(directory).unwrap(); + } + #[tokio::test] + async fn attachment_mapping_survives_restart_but_not_media_edits() { + let directory = tempfile::tempdir().unwrap(); + let database = directory.path().join("cache.sqlite3"); + let path = directory.path().join("file.txt"); + std::fs::write(&path, "file").unwrap(); + let mut store = Store::open(&database).await.unwrap(); + let mut media = message(1, "file"); + media.attachment = Some(crate::model::Attachment { + source_id: Some(55), + kind: crate::model::AttachmentKind::File, + file_name: Some("file.txt".to_owned()), + mime_type: None, + size: Some(4), + fallback_emoji: None, + }); + store + .apply(&[ + NetworkEvent::NewMessage(media.clone()), + NetworkEvent::AttachmentDownloadStarted { + chat_id: 42, + message_id: 1, + request_id: 1, + media_id: Some(55), + }, + NetworkEvent::AttachmentDownloaded { + chat_id: 42, + message_id: 1, + request_id: 1, + path: path.clone(), + }, + ]) + .await + .unwrap(); + drop(store); + let mut store = Store::open(&database).await.unwrap(); + assert_eq!( + store.attachment(42, 1, Some(55)).await.unwrap(), + Some(path.clone()) + ); + assert!(store.attachment(42, 1, Some(56)).await.unwrap().is_none()); + std::fs::remove_file(&path).unwrap(); + assert!(store.attachment(42, 1, Some(55)).await.unwrap().is_none()); + std::fs::write(&path, "file").unwrap(); + media.attachment.as_mut().unwrap().source_id = Some(56); + store + .apply(&[ + NetworkEvent::AttachmentDownloadStarted { + chat_id: 42, + message_id: 1, + request_id: 2, + media_id: Some(55), + }, + NetworkEvent::MessageUpdated(media), + NetworkEvent::AttachmentDownloadStarted { + chat_id: 42, + message_id: 1, + request_id: 3, + media_id: Some(56), + }, + NetworkEvent::AttachmentDownloaded { + chat_id: 42, + message_id: 1, + request_id: 2, + path: path.clone(), + }, + ]) + .await + .unwrap(); + assert!(store.attachment(42, 1, Some(55)).await.unwrap().is_none()); + assert!(store.attachment(42, 1, Some(56)).await.unwrap().is_none()); + store + .apply(&[NetworkEvent::AttachmentDownloaded { + chat_id: 42, + message_id: 1, + request_id: 3, + path: path.clone(), + }]) + .await + .unwrap(); + assert_eq!(store.attachment(42, 1, Some(56)).await.unwrap(), Some(path)); + } + + #[tokio::test] + async fn account_cache_has_one_owner_until_transfers_release_the_lock() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("cache.sqlite3"); + let store = Store::open(&path).await.unwrap(); + let transfer_owner = store.owner(); + assert!(Store::open(&path).await.is_err()); + drop(store); + assert!(Store::open(&path).await.is_err()); + drop(transfer_owner); + assert!(Store::open(&path).await.is_ok()); + } + + #[tokio::test] + async fn pinned_pages_persist_without_undoing_live_unpins_or_deletions() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("pins.sqlite3"); + let mut store = Store::open(&path).await.unwrap(); + let mut pin = message(4, "pinned"); + pin.pinned = true; + let mut removed = message(5, "deleted"); + removed.pinned = true; + store + .apply(&[ + NetworkEvent::CacheMessage(pin.clone()), + NetworkEvent::CacheMessage(removed.clone()), + NetworkEvent::PinnedMessagesLoading { + chat_id: 42, + request_id: 1, + }, + NetworkEvent::MessagePinsChanged { + chat_id: 42, + message_ids: vec![4], + pinned: false, + }, + NetworkEvent::MessagesDeleted { + channel_id: None, + message_ids: vec![5], + }, + NetworkEvent::PinnedMessages { + chat_id: 42, + request_id: 1, + page: crate::pins::MessagePage { + messages: vec![removed, pin.clone()], + total: Some(2), + ..Default::default() + }, + }, + ]) + .await + .unwrap(); + assert!( + store + .pinned_messages(42, 0) + .await + .unwrap() + .messages + .is_empty() + ); + store + .apply(&[ + NetworkEvent::PinnedMessagesLoading { + chat_id: 42, + request_id: 2, + }, + NetworkEvent::PinnedMessages { + chat_id: 42, + request_id: 2, + page: crate::pins::MessagePage { + messages: vec![pin], + total: Some(1), + ..Default::default() + }, + }, + ]) + .await + .unwrap(); + drop(store); + let mut store = Store::open(&path).await.unwrap(); + assert_eq!( + store.pinned_messages(42, 0).await.unwrap().messages[0].id, + 4 + ); + store + .apply(&[NetworkEvent::MessagePinsCleared { chat_id: 42 }]) + .await + .unwrap(); + assert!( + store + .pinned_messages(42, 0) + .await + .unwrap() + .messages + .is_empty() + ); + assert_eq!(store.history(42, None, 20).await.unwrap().len(), 1); + } + #[tokio::test] + async fn invalidated_pin_reads_cannot_repopulate_the_cache() { + for scope in [Some(42), None] { + let directory = tempfile::tempdir().unwrap(); + let mut store = Store::open(&directory.path().join("pins.sqlite3")) + .await + .unwrap(); + let mut pin = message(4, "obsolete pin"); + pin.pinned = true; + let mut other = pin.clone(); + other.chat_id = 99; + store + .apply(&[ + NetworkEvent::CacheMessage(pin.clone()), + NetworkEvent::CacheMessage(other), + NetworkEvent::PinnedMessagesLoading { + chat_id: 42, + request_id: 1, + }, + NetworkEvent::PinnedMessagesLoading { + chat_id: 42, + request_id: 2, + }, + NetworkEvent::CacheInvalidated { chat_id: scope }, + NetworkEvent::PinnedMessages { + chat_id: 42, + request_id: 1, + page: crate::pins::MessagePage { + messages: vec![pin.clone()], + total: Some(1), + ..Default::default() + }, + }, + NetworkEvent::PinnedContext { + chat_id: 42, + message_id: 4, + request_id: 2, + messages: vec![pin.clone()], + }, + ]) + .await + .unwrap(); + assert!(store.history(42, None, 20).await.unwrap().is_empty()); + assert_eq!( + store.history(99, None, 20).await.unwrap().is_empty(), + scope.is_none() + ); + pin.id = 6; + store + .apply(&[ + NetworkEvent::PinnedMessagesLoading { + chat_id: 42, + request_id: 3, + }, + NetworkEvent::PinnedMessages { + chat_id: 42, + request_id: 3, + page: crate::pins::MessagePage { + messages: vec![pin], + total: Some(1), + ..Default::default() + }, + }, + ]) + .await + .unwrap(); + let pins = store.pinned_messages(42, 0).await.unwrap(); + assert_eq!(pins.messages.len(), 1); + assert_eq!(pins.messages[0].id, 6); + } + } + + #[tokio::test] + async fn cached_dialogs_count_new_messages_once_and_clear_deleted_previews() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("cache.sqlite3"); + let mut store = Store::open(&path).await.unwrap(); + let chat = Chat { + id: 42, + title: "Group".to_owned(), + kind: ChatKind::Group, + unread: 0, + read_inbox_max_id: Some(0), + membership: crate::folders::ChatMembership::default(), + last_message: "first".to_owned(), + last_message_id: Some(1), + last_activity: Some(message(1, "first").timestamp), + }; + store + .apply(&[ + NetworkEvent::DialogsLoading, + NetworkEvent::Dialogs(vec![chat]), + NetworkEvent::NewMessage(message(2, "second")), + NetworkEvent::NewMessage(message(2, "second")), + NetworkEvent::MessageUpdated(message(1, "edited first")), + ]) + .await + .unwrap(); + let chats = store.snapshot().await.unwrap().1; + assert_eq!(chats[0].unread, 1); + assert_eq!(chats[0].last_message_id, Some(2)); + assert_eq!(chats[0].last_message, "second"); + assert_eq!( + store + .history_after(42, 1, 80) + .await + .unwrap() + .iter() + .map(|message| message.id) + .collect::>(), + vec![2] + ); + let mut spoiler = message(2, "second"); + spoiler.entities.push(crate::entities::Entity { + range: 0..6, + kind: crate::entities::Kind::Spoiler, + }); + store + .apply(&[NetworkEvent::MessageUpdated(spoiler.clone())]) + .await + .unwrap(); + drop(store); + let mut store = Store::open(&path).await.unwrap(); + assert_eq!(store.snapshot().await.unwrap().1[0].last_message, "▨▨▨▨▨▨"); + let restored = store.history_after(42, 1, 80).await.unwrap(); + assert_eq!(restored[0].entities, spoiler.entities); + assert_eq!(restored[0].text, "second"); + store + .apply(&[NetworkEvent::ReadMarked { + chat_id: 42, + max_id: 1, + snapshot: Some(crate::read_state::Snapshot { + max_id: 1, + unread: 0, + top_message: 1, + }), + }]) + .await + .unwrap(); + let chats = store.snapshot().await.unwrap().1; + assert_eq!(chats[0].unread, 1); + assert_eq!(chats[0].read_inbox_max_id, Some(1)); + store + .apply(&[ + NetworkEvent::MessagesDeleted { + channel_id: None, + message_ids: vec![2], + }, + NetworkEvent::UnreadChanged { + chat_id: 42, + max_id: 2, + unread: 0, + }, + NetworkEvent::ChatUnreadChanged { + chat_id: 42, + unread: true, + }, + ]) + .await + .unwrap(); + drop(store); + let store = Store::open(&path).await.unwrap(); + let chats = store.snapshot().await.unwrap().1; + assert!(chats[0].last_message.is_empty()); + assert_eq!(chats[0].unread, 0); + assert_eq!(chats[0].last_message_id, None); + assert_eq!(chats[0].read_inbox_max_id, Some(2)); + assert!(chats[0].membership.unread_mark); + } + #[tokio::test] + #[allow(clippy::too_many_lines)] + async fn content_receipts_protect_cached_and_uncached_snapshots_without_crossing_id_scopes() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("mentions.db"); + let mut store = Store::open(&path).await.unwrap(); + let mention = |id, chat_id| { + let mut message = message(id, "mention"); + message.chat_id = chat_id; + message.mention = Some(crate::model::Mention { + unread: true, + requires_playback: false, + }); + message + }; + let channel = -1_000_000_000_042; + store + .apply(&[ + NetworkEvent::MessageUpdated(mention(10, 42)), + NetworkEvent::MessageUpdated(mention(10, channel)), + NetworkEvent::HistoryLoading { + chat_id: 42, + request_id: 1, + }, + NetworkEvent::CloudSearchLoading { + chat_id: channel, + request_id: 2, + }, + NetworkEvent::MessageContentsRead { + channel_id: None, + message_ids: vec![10, 11], + }, + NetworkEvent::MessageContentsRead { + channel_id: Some(channel), + message_ids: vec![12], + }, + NetworkEvent::SyncCheckpoint(SyncCursor { + pts: 123, + ..Default::default() + }), + ]) + .await + .unwrap(); + let mut page = NetworkEvent::History { + chat_id: 42, + request_id: 1, + messages: vec![mention(10, 42), mention(11, 42), mention(12, 42)], + }; + store.reconcile_snapshots(&mut page).await.unwrap(); + let NetworkEvent::History { messages, .. } = &page else { + unreachable!() + }; + assert_eq!( + messages + .iter() + .map(|m| m.mention.as_ref().unwrap().unread) + .collect::>(), + [false, false, true] + ); + // Apply the unmerged version too: all Store writes have the same guard. + store + .apply(&[NetworkEvent::History { + chat_id: 42, + request_id: 1, + messages: vec![mention(10, 42), mention(11, 42), mention(12, 42)], + }]) + .await + .unwrap(); + let mut page = NetworkEvent::CloudSearchResults { + chat_id: channel, + request_id: 2, + page: crate::cloud_search::Page { + messages: vec![ + mention(10, channel), + mention(11, channel), + mention(12, channel), + ], + total: 3, + next: None, + sender: None, + }, + }; + store.reconcile_cloud_search(&mut page).await.unwrap(); + store.reconcile_snapshots(&mut page).await.unwrap(); + let NetworkEvent::CloudSearchResults { page: result, .. } = &page else { + unreachable!() + }; + assert_eq!( + result + .messages + .iter() + .map(|m| m.mention.as_ref().unwrap().unread) + .collect::>(), + [true, true, false] + ); + store.apply(&[page]).await.unwrap(); + + drop(store); + let store = Store::open(&path).await.unwrap(); + assert_eq!(store.cursor().await.unwrap().pts, 123); + let messages = store.history(42, None, 10).await.unwrap(); + assert_eq!( + messages + .iter() + .map(|m| m.mention.as_ref().unwrap().unread) + .collect::>(), + [false, false, true] + ); + let messages = store.history(channel, None, 10).await.unwrap(); + assert_eq!( + messages + .iter() + .map(|m| m.mention.as_ref().unwrap().unread) + .collect::>(), + [true, true, false] + ); + } +} diff --git a/src/cache/contents.rs b/src/cache/contents.rs new file mode 100644 index 0000000..4f64ba1 --- /dev/null +++ b/src/cache/contents.rs @@ -0,0 +1,217 @@ +//! Receipts protect RPC snapshots begun before a content-read update. The +//! durable message flag and PTS commit together; only in-flight RPCs need IDs +//! for messages that were not cached when the receipt arrived. +use super::{ChatId, Message, NetworkEvent, Store}; +use std::{borrow::Cow, collections::BTreeMap}; + +#[derive(Default)] +pub(super) struct Receipts(BTreeMap<(ChatId, i32), i64>); + +fn scope(chat: ChatId) -> ChatId { + if chat > -1_000_000_000_000 { 0 } else { chat } +} + +impl Receipts { + pub fn record(&mut self, channel: Option, ids: &[i32], revision: i64) { + self.0.extend( + ids.iter() + .filter(|id| **id > 0) + .map(|id| ((channel.unwrap_or(0), *id), revision)), + ); + } + + pub fn supersede(&mut self, message: &Message) { + self.0.remove(&(scope(message.chat_id), message.id)); + } + + pub fn prune(&mut self, oldest: i64) { + self.0.retain(|_, revision| *revision > oldest); + } + + pub fn reconcile<'a>(&self, message: &'a Message, started: i64) -> Cow<'a, Message> { + let mut message = Cow::Borrowed(message); + if message + .mention + .as_ref() + .is_some_and(|mention| mention.unread) + && self + .0 + .get(&(scope(message.chat_id), message.id)) + .is_some_and(|revision| *revision > started) + && let Some(mention) = &mut message.to_mut().mention + { + mention.unread = false; + } + message + } +} + +impl Store { + pub(crate) fn has_message_snapshot(event: &NetworkEvent) -> bool { + matches!(event, NetworkEvent::NewMessage(message) | NetworkEvent::MessageUpdated(message) | NetworkEvent::CacheMessage(message) | NetworkEvent::MessageSent { message, .. } + if message.poll.is_some() || message.reactions.is_some()) + || matches!( + event, + NetworkEvent::ReactionsLoaded { .. } + | NetworkEvent::PollLoaded { .. } + | NetworkEvent::History { .. } + | NetworkEvent::OlderHistory { .. } + | NetworkEvent::ReplyPreviews { complete: true, .. } + | NetworkEvent::PinnedMessages { .. } + | NetworkEvent::PinnedContext { .. } + | NetworkEvent::MessageLoaded { .. } + | NetworkEvent::CloudSearchResults { .. } + | NetworkEvent::CloudSearchContext { .. } + ) + } + + /// Called before the snapshot is applied and its generation is retired. + #[allow(clippy::too_many_lines)] + pub(crate) async fn reconcile_snapshots(&self, event: &mut NetworkEvent) -> anyhow::Result<()> { + if let NetworkEvent::NewMessage(message) + | NetworkEvent::MessageUpdated(message) + | NetworkEvent::CacheMessage(message) + | NetworkEvent::MessageSent { message, .. } = event + { + // Live message edits can also contain minimal media/reaction data. + // They supersede old snapshots but preserve omitted private choices. + super::reactions::merge_cached(&self.connection, message).await?; + if let Some(poll) = &mut message.poll { + super::polls::merge_cached(&self.connection, poll).await?; + } + return Ok(()); + } + if let NetworkEvent::ReactionsLoaded { + chat_id, + message_id, + request_id, + result, + } = event + { + if let Some(started) = self.reaction_revisions.get(request_id) { + if let Ok(review) = result { + super::reactions::merge_summary( + &self.connection, + *chat_id, + *message_id, + &mut review.summary, + ) + .await?; + super::reactions::reconcile_summary( + &self.reaction_updates, + *chat_id, + *message_id, + &mut review.summary, + *started, + ); + } + } else if result.is_ok() { + *result = Err("History changed during reaction lookup; reopen it".to_owned()); + } + return Ok(()); + } + if let NetworkEvent::PollLoaded { + request_id, result, .. + } = event + { + if let Some(started) = self.poll_revisions.get(request_id) { + if let Ok(poll) = result { + super::polls::merge_cached(&self.connection, poll).await?; + self.poll_updates.reconcile(poll, *started); + } + } else if result.is_ok() { + *result = Err("History changed during the poll lookup; reopen it".to_owned()); + } + return Ok(()); + } + let (messages, started): (&mut [Message], Option) = match event { + NetworkEvent::History { + chat_id, + request_id, + messages, + } + | NetworkEvent::OlderHistory { + chat_id, + request_id, + messages, + .. + } + | NetworkEvent::ReplyPreviews { + chat_id, + request_id, + messages, + complete: true, + .. + } => ( + messages, + self.history_revisions + .get(&(*chat_id, *request_id)) + .copied(), + ), + NetworkEvent::MessageLoaded { + chat_id, + request_id, + message, + .. + } => ( + std::slice::from_mut(message), + self.history_revisions + .get(&(*chat_id, *request_id)) + .copied(), + ), + NetworkEvent::PinnedMessages { + chat_id, + request_id, + page, + } => ( + &mut page.messages, + self.pin_revisions.get(&(*chat_id, *request_id)).copied(), + ), + NetworkEvent::PinnedContext { + chat_id, + request_id, + messages, + .. + } => ( + messages, + self.pin_revisions.get(&(*chat_id, *request_id)).copied(), + ), + NetworkEvent::CloudSearchResults { + chat_id, + request_id, + page, + } => ( + &mut page.messages, + self.search_revision + .filter(|(id, chat, _)| *id == *request_id && *chat == *chat_id) + .map(|(_, _, revision)| revision), + ), + NetworkEvent::CloudSearchContext { + chat_id, + request_id, + messages, + .. + } => ( + messages, + self.search_revision + .filter(|(id, chat, _)| *id == *request_id && *chat == *chat_id) + .map(|(_, _, revision)| revision), + ), + _ => return Ok(()), + }; + if let Some(started) = started { + for message in messages { + super::reactions::merge_cached(&self.connection, message).await?; + super::reactions::reconcile(&self.reaction_updates, message, started); + if let Some(poll) = &mut message.poll { + super::polls::merge_cached(&self.connection, poll).await?; + self.poll_updates.reconcile(poll, started); + } + if let Cow::Owned(merged) = self.contents_read.reconcile(message, started) { + *message = merged; + } + } + } + Ok(()) + } +} diff --git a/src/cache/journal.rs b/src/cache/journal.rs new file mode 100644 index 0000000..78dc231 --- /dev/null +++ b/src/cache/journal.rs @@ -0,0 +1,43 @@ +//! Bounded patches retained only while message snapshots are in flight. +use std::collections::VecDeque; + +pub(super) struct Journal { + updates: VecDeque<(i64, T)>, + overflow: i64, +} + +impl Default for Journal { + fn default() -> Self { + Self { + updates: VecDeque::new(), + overflow: 0, + } + } +} + +impl Journal { + pub fn record(&mut self, revision: i64, update: T) { + self.updates.push_back((revision, update)); + if self.updates.len() > 512 { + self.overflow = self.updates.pop_front().expect("over limit").0; + } + } + + pub fn since(&self, started: i64) -> impl Iterator { + self.updates + .iter() + .filter(move |(revision, _)| *revision > started) + .map(|(_, update)| update) + } + + pub fn stale(&self, started: i64) -> bool { + started < self.overflow + } + + pub fn prune(&mut self, oldest: i64) { + self.updates.retain(|(revision, _)| *revision > oldest); + if oldest >= self.overflow { + self.overflow = 0; + } + } +} diff --git a/src/cache/polls.rs b/src/cache/polls.rs new file mode 100644 index 0000000..4e8d132 --- /dev/null +++ b/src/cache/polls.rs @@ -0,0 +1,107 @@ +//! Small revision journal for poll patches arriving during history RPCs. +//! Durable messages and the covered sync cursor still commit in one transaction. +use crate::{ + model::Message, + polls::{Poll, ResultsPatch, Update}, +}; +use libsql::{Connection, params}; +pub(super) type Journal = super::journal::Journal; + +impl Journal { + pub fn reconcile(&self, poll: &mut Poll, started: i64) { + for update in self.since(started) { + poll.apply(update); + } + // Dropped patches require a fresh lookup before choices can be trusted. + if self.stale(started) { + poll.stale = true; + } + } +} + +pub(super) fn snapshot(poll: &Poll) -> Update { + Update { + id: poll.definition.id, + stale: poll.stale, + definition: Some(poll.definition.clone()), + results: ResultsPatch { + min: !poll.results.choice_known, + counts: (!poll.results.counts.is_empty()).then(|| poll.results.counts.clone()), + total: poll.results.total, + solution: poll.results.solution.clone(), + }, + } +} + +/// Minimal snapshots omit the current user's choices. Preserve those known +/// fields from any cached copy of this globally identified poll before replaying +/// updates that arrived after the RPC began. +pub(super) async fn merge_cached(connection: &Connection, poll: &mut Poll) -> anyhow::Result<()> { + let row = connection.query("SELECT data FROM messages WHERE json_extract(data,'$.poll.definition.id')=?1 ORDER BY revision DESC LIMIT 1", [poll.definition.id]).await?.next().await?; + if let Some(row) = row { + let message: Message = serde_json::from_str(&row.get::(0)?)?; + if let Some(mut previous) = message.poll { + previous.apply(&snapshot(poll)); + *poll = previous; + } + } + Ok(()) +} + +pub(super) async fn apply( + connection: &Connection, + update: &Update, + revision: i64, +) -> anyhow::Result<()> { + let mut rows = connection + .query( + "SELECT data FROM messages WHERE json_extract(data,'$.poll.definition.id')=?1", + [update.id], + ) + .await?; + let mut messages = Vec::new(); + while let Some(row) = rows.next().await? { + let mut message: Message = serde_json::from_str(&row.get::(0)?)?; + if let Some(poll) = &mut message.poll { + poll.apply(update); + } + messages.push(message); + } + drop(rows); + for message in messages { + connection.execute("UPDATE messages SET data=?1, revision=?2 WHERE chat_id=?3 AND id=?4 AND data IS NOT NULL", params![serde_json::to_string(&message)?, revision, message.chat_id, message.id]).await?; + if let Some((mut chat, _)) = super::load_chat(connection, message.chat_id).await? + && chat.last_message_id == Some(message.id) + { + chat.last_message = message.preview_text(); + super::write_chat(connection, &chat, revision).await?; + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn poll_journal_overflow_requires_a_fresh_snapshot() { + let mut journal = Journal::default(); + let mut poll = crate::polls::example(); + for revision in 1..=514 { + poll.results.total = Some(u32::try_from(revision).unwrap()); + journal.record(revision, snapshot(&poll)); + } + let mut late = crate::polls::example(); + journal.reconcile(&mut late, 1); + assert!(late.stale); + assert_eq!(late.results.total, Some(514)); + assert!(late.read_only_reason(0).is_some()); + let mut fresh = crate::polls::example(); + journal.reconcile(&mut fresh, 513); + assert!(!fresh.stale); + assert_eq!(fresh.results.total, Some(514)); + journal.prune(514); + assert_eq!(journal.since(0).count(), 0); + } +} diff --git a/src/cache/reactions.rs b/src/cache/reactions.rs new file mode 100644 index 0000000..348fd04 --- /dev/null +++ b/src/cache/reactions.rs @@ -0,0 +1,99 @@ +use crate::{ + model::Message, + reactions::{Summary, Update}, +}; +use libsql::{Connection, params}; + +pub(super) type Journal = super::journal::Journal; + +pub(super) fn reconcile(journal: &Journal, message: &mut Message, started: i64) { + for update in journal + .since(started) + .filter(|update| (update.chat, update.message) == (message.chat_id, message.id)) + { + message + .reactions + .get_or_insert_with(Summary::default) + .apply(&update.summary); + } + if journal.stale(started) { + message.reactions.get_or_insert_with(Summary::default).stale = true; + } +} + +pub(super) fn reconcile_summary( + journal: &Journal, + chat: i64, + id: i32, + summary: &mut Summary, + started: i64, +) { + for update in journal + .since(started) + .filter(|update| (update.chat, update.message) == (chat, id)) + { + summary.apply(&update.summary); + } + summary.stale |= journal.stale(started); +} + +pub(super) async fn merge_cached( + connection: &Connection, + message: &mut Message, +) -> anyhow::Result<()> { + if let Some(summary) = &mut message.reactions { + merge_summary(connection, message.chat_id, message.id, summary).await?; + } + Ok(()) +} + +pub(super) async fn merge_summary( + connection: &Connection, + chat: i64, + id: i32, + fresh: &mut Summary, +) -> anyhow::Result<()> { + if fresh.choices_known { + return Ok(()); + } + let row = connection + .query( + "SELECT data FROM messages WHERE chat_id=?1 AND id=?2 AND data IS NOT NULL", + params![chat, id], + ) + .await? + .next() + .await?; + if let Some(row) = row { + let cached: Message = serde_json::from_str(&row.get::(0)?)?; + if let Some(mut summary) = cached.reactions { + summary.apply(fresh); + *fresh = summary; + } + } + Ok(()) +} + +pub(super) async fn apply( + connection: &Connection, + update: &Update, + revision: i64, +) -> anyhow::Result<()> { + let row = connection + .query( + "SELECT data FROM messages WHERE chat_id=?1 AND id=?2 AND data IS NOT NULL", + params![update.chat, update.message], + ) + .await? + .next() + .await?; + if let Some(row) = row { + let mut message: Message = serde_json::from_str(&row.get::(0)?)?; + message + .reactions + .get_or_insert_with(Summary::default) + .apply(&update.summary); + connection.execute("UPDATE messages SET data=?1,revision=?2 WHERE chat_id=?3 AND id=?4 AND data IS NOT NULL", params![serde_json::to_string(&message)?, revision, update.chat, update.message]).await?; + } + Ok(()) +} diff --git a/src/cache/search.rs b/src/cache/search.rs new file mode 100644 index 0000000..29e145d --- /dev/null +++ b/src/cache/search.rs @@ -0,0 +1,71 @@ +//! Merge a cloud snapshot with edits and tombstones received during its RPC. +use super::{NetworkEvent, Result, Store, params}; + +impl Store { + pub(crate) async fn reconcile_cloud_search(&self, event: &mut NetworkEvent) -> Result<()> { + let (request_id, chat_id, messages) = match event { + NetworkEvent::CloudSearchResults { + request_id, + chat_id, + page, + } => (*request_id, *chat_id, &mut page.messages), + NetworkEvent::CloudSearchContext { + request_id, + chat_id, + messages, + .. + } => (*request_id, *chat_id, messages), + _ => return Ok(()), + }; + let Some((_, _, started)) = self + .search_revision + .filter(|(id, chat, _)| *id == request_id && *chat == chat_id) + else { + *event = NetworkEvent::SearchFailed { + request_id, + error: "History changed during search; run the search again".to_owned(), + }; + return Ok(()); + }; + let mut merged = Vec::with_capacity(messages.len()); + for message in messages.drain(..) { + if message.chat_id != chat_id || message.id <= 0 { + continue; + } + if self + .connection + .query( + "SELECT 1 FROM global_deletions WHERE id=?1 AND ?2>-1000000000000", + params![message.id, chat_id], + ) + .await? + .next() + .await? + .is_some() + { + continue; + } + let row = self + .connection + .query( + "SELECT data,revision FROM messages WHERE chat_id=?1 AND id=?2", + params![chat_id, message.id], + ) + .await? + .next() + .await?; + if let Some(row) = row { + let Some(data) = row.get::>(0)? else { + continue; + }; + if row.get::(1)? > started { + merged.push(serde_json::from_str(&data)?); + continue; + } + } + merged.push(message); + } + *messages = merged; + Ok(()) + } +} diff --git a/src/chat_discovery.rs b/src/chat_discovery.rs new file mode 100644 index 0000000..886bb93 --- /dev/null +++ b/src/chat_discovery.rs @@ -0,0 +1,26 @@ +//! Explicit chat lookup accepts Telegram identities, never external commands. + +/// # Errors +/// Rejects empty names, whitespace and targets outside Telegram. +pub fn target(value: &str) -> Result { + let value = value.trim(); + if let Some(username) = value.strip_prefix('@') { + if username.is_empty() + || username.len() > 64 + || !username + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_') + { + return Err( + "Enter a Telegram @username using letters, numbers or underscores".to_owned(), + ); + } + return Ok(format!("https://t.me/{username}")); + } + if value.is_empty() || value.chars().any(char::is_whitespace) { + return Err("Use :open @username or a Telegram chat/message link".to_owned()); + } + crate::app::telegram_link(value).ok_or_else(|| { + "Use a Telegram @username or t.me link; :chat opens cached names and IDs".to_owned() + }) +} diff --git a/src/chat_info.rs b/src/chat_info.rs new file mode 100644 index 0000000..3661e0b --- /dev/null +++ b/src/chat_info.rs @@ -0,0 +1,87 @@ +//! Read-only chat details and advisory send restrictions; Telegram is authoritative. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct Info { + pub title: String, + pub username: Option, + pub about: String, + pub members: Option, + pub role: String, + pub restrictions: Vec, + pub slow_seconds: u32, + pub next_send_at: i64, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Content { + Text, + Photo, + File, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Restriction { + pub content: Option, + pub reason: String, + /// Zero is permanent. Server-provided restrictions may expire without an update. + pub until: i64, +} + +impl Info { + #[must_use] + pub fn restriction(&self, content: Content, now: i64) -> Option { + if let Some(rule) = self.restrictions.iter().find(|rule| { + (rule.content.is_none() || rule.content == Some(content)) + && (rule.until == 0 || rule.until > now) + }) { + return Some(if rule.until > 0 { + format!( + "{} · until {}", + rule.reason, + chrono::DateTime::from_timestamp(rule.until, 0).map_or_else( + || rule.until.to_string(), + |d| d.format("%Y-%m-%d %H:%M UTC").to_string() + ) + ) + } else { + rule.reason.clone() + }); + } + (self.next_send_at > now).then(|| format!("Slow mode · wait {} s", self.next_send_at - now)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn content_restrictions_expire_independently_of_slow_mode() { + let mut info = Info { + next_send_at: 120, + restrictions: vec![Restriction { + content: Some(Content::Text), + reason: "Restricted".to_owned(), + until: 110, + }], + ..Info::default() + }; + assert!( + info.restriction(Content::Text, 100) + .unwrap() + .starts_with("Restricted") + ); + assert_eq!( + info.restriction(Content::Photo, 100).as_deref(), + Some("Slow mode · wait 20 s") + ); + assert_eq!( + info.restriction(Content::Text, 115).as_deref(), + Some("Slow mode · wait 5 s") + ); + assert_eq!(info.restriction(Content::Text, 120), None); + info.restrictions[0].until = 0; + assert_eq!( + info.restriction(Content::Text, 130).as_deref(), + Some("Restricted") + ); + } +} diff --git a/src/clipboard.rs b/src/clipboard.rs new file mode 100644 index 0000000..c181518 --- /dev/null +++ b/src/clipboard.rs @@ -0,0 +1,504 @@ +//! Native clipboard adapter. File lists keep their originals; bitmaps become +//! draft-owned PNG assets. Clipboard reads never run on the terminal thread. +mod wsl; + +pub mod copy; + +use crate::staging::{Attachment, Prepared, Request}; +use anyhow::{Context, Result, ensure}; +use image::{ImageDecoder, ImageEncoder}; +use std::{ + collections::{HashMap, HashSet}, + path::{Path, PathBuf}, + sync::{ + Arc, Mutex, OnceLock, Weak, + atomic::{AtomicBool, Ordering}, + }, +}; + +// Arboard operations must not overlap on Windows. Both background adapters +// share this guard; no clipboard access or lock wait runs on the terminal thread. +static NATIVE_ACCESS: Mutex<()> = Mutex::new(()); + +static LIVE_ASSETS: OnceLock>>> = OnceLock::new(); + +fn live_assets() -> std::sync::MutexGuard<'static, HashMap>> { + LIVE_ASSETS + .get_or_init(Mutex::default) + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +const MAX_RGBA: usize = 128 * 1024 * 1024; +const MAX_ENCODED: u64 = 16 * 1024 * 1024; +const MAX_TEXT: usize = 1024 * 1024; + +#[derive(Clone, Eq, PartialEq)] +pub struct Payload { + pub mime: String, + pub bytes: Arc<[u8]>, + pub remote: bool, +} + +impl std::fmt::Debug for Payload { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ClipboardPayload") + .field("mime", &self.mime) + .field("bytes", &self.bytes.len()) + .field("remote", &self.remote) + .finish() + } +} + +pub(crate) fn prepare_payload( + state: &Path, + request: &Request, + payload: &Payload, +) -> Result { + ensure!( + u64::try_from(payload.bytes.len())? <= MAX_ENCODED, + "Terminal clipboard exceeds 16 MiB" + ); + if matches!( + payload.mime.as_str(), + "text/plain" | "text/plain;charset=utf-8" + ) { + ensure!( + payload.bytes.len() <= MAX_TEXT, + "Clipboard text exceeds 1 MiB" + ); + return Ok(Prepared { + text: Some(std::str::from_utf8(&payload.bytes)?.to_owned()), + ..Prepared::default() + }); + } + if payload.mime == "text/uri-list" { + ensure!( + !payload.remote, + "Clipboard file paths belong to the terminal host; use :attach with a file readable on this machine" + ); + ensure!( + payload.bytes.len() <= MAX_TEXT, + "Clipboard file list exceeds 1 MiB" + ); + let text = std::str::from_utf8(&payload.bytes)?; + let mut paths = Vec::new(); + let mut errors = Vec::new(); + for line in text + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with('#')) + { + ensure!( + paths.len() + errors.len() < crate::staging::MAX_FILES, + "Clipboard contains more than eight files" + ); + match url::Url::parse(line) + .ok() + .filter(|url| url.scheme() == "file") + .and_then(|url| url.to_file_path().ok()) + { + Some(path) => paths.push(path), + None => errors.push( + "Clipboard contains a file URL that is not local to this machine".to_owned(), + ), + } + } + ensure!( + !paths.is_empty() || !errors.is_empty(), + "Clipboard file list is empty" + ); + let mut prepared = crate::staging::prepare_paths(paths, request); + prepared.errors.extend(errors); + return Ok(prepared); + } + let (format, suffix) = match payload.mime.as_str() { + "image/png" => (image::ImageFormat::Png, ".png"), + "image/jpeg" => (image::ImageFormat::Jpeg, ".jpg"), + "image/webp" => (image::ImageFormat::WebP, ".webp"), + "image/gif" => (image::ImageFormat::Gif, ".gif"), + _ => anyhow::bail!("Unsupported clipboard MIME type"), + }; + ensure!( + image::guess_format(&payload.bytes)? == format, + "Clipboard image does not match its MIME type" + ); + ensure!( + request.available_files > 0, + "Remove an attachment before adding another" + ); + let mut reader = image::ImageReader::with_format(std::io::Cursor::new(&payload.bytes), format); + let mut limits = image::Limits::default(); + limits.max_alloc = Some(MAX_RGBA as u64); + limits.max_image_width = Some(8192); + limits.max_image_height = Some(8192); + reader.limits(limits); + let decoder = reader.into_decoder()?; + let (width, height) = decoder.dimensions(); + ensure!( + width > 0 && height > 0 && decoder.total_bytes() <= MAX_RGBA as u64, + "Clipboard image exceeds the 128 MiB decoded size limit" + ); + let directory = prepare_root(state, request.key.account)?; + let mut file = tempfile::Builder::new() + .prefix("clipboard-") + .suffix(suffix) + .tempfile_in(directory)?; + std::io::Write::write_all(file.as_file_mut(), &payload.bytes)?; + finish_asset(file, request) +} + +#[derive(Debug)] +pub(crate) struct Asset { + path: PathBuf, + retained: AtomicBool, +} + +impl PartialEq for Asset { + fn eq(&self, other: &Self) -> bool { + self.path == other.path + } +} +impl Eq for Asset {} + +impl Asset { + fn new(path: PathBuf, retained: bool) -> Arc { + let mut live = live_assets(); + live.retain(|_, lease| lease.strong_count() > 0); + if let Some(existing) = live.get(&path).and_then(Weak::upgrade) { + return existing; + } + let lease = Arc::new(Self { + path: path.clone(), + retained: AtomicBool::new(retained), + }); + live.insert(path, Arc::downgrade(&lease)); + lease + } + pub(crate) fn retain(&self) { + self.retained.store(true, Ordering::Release); + } + pub(crate) fn discard(&self) { + self.retained.store(false, Ordering::Release); + } +} + +impl Drop for Asset { + fn drop(&mut self) { + if !self.retained.load(Ordering::Acquire) { + // Only paths created here or validated under the account's managed + // root acquire a lease. Original files can never enter this branch. + let _ = std::fs::remove_file(&self.path); + } + } +} + +fn root(state: &Path, account: i64) -> PathBuf { + let mut path = state.as_os_str().to_owned(); + path.push(".attachments"); + PathBuf::from(path).join(account.to_string()) +} + +fn prepare_root(state: &Path, account: i64) -> Result { + ensure!( + account > 0, + "Wait for the Telegram account identity before pasting media" + ); + let path = root(state, account); + let mut builder = std::fs::DirBuilder::new(); + builder.recursive(true); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + builder.create(&path)?; + ensure!( + std::fs::symlink_metadata(&path)?.is_dir(), + "draft asset storage must be a directory" + ); + std::fs::canonicalize(path).context("cannot resolve draft asset storage") +} + +/// Called while loading this account's drafts, under its existing cache lock. +/// Unreferenced crash remnants are separate from the downloaded-media cache. +pub(crate) fn restore( + state: &Path, + account: i64, + drafts: &mut [crate::drafts::Stored], +) -> Result<()> { + let path = root(state, account); + if !path.exists() { + return Ok(()); + } + ensure!( + std::fs::symlink_metadata(&path)?.is_dir(), + "draft asset storage must be a directory" + ); + let directory = std::fs::canonicalize(path)?; + let mut referenced: HashSet<_> = drafts + .iter() + .flat_map(|draft| &draft.attachments) + .map(|file| file.path.clone()) + .collect(); + // A quick account switch can precede the coalesced draft commit. Protect + // every in-process lease as well as persisted references during recovery. + referenced.extend( + live_assets() + .iter() + .filter(|(_, lease)| lease.strong_count() > 0) + .map(|(path, _)| path.clone()), + ); + for draft in drafts { + for file in &mut draft.attachments { + if file.owned && managed_path(&directory, &file.path) { + file.lease = Some(Asset::new(file.path.clone(), true)); + } + } + } + for entry in std::fs::read_dir(&directory)? { + let entry = entry?; + let path = entry.path(); + if managed_path(&directory, &path) + && entry.file_type()?.is_file() + && !referenced.contains(&path) + { + let _ = std::fs::remove_file(path); + } + } + Ok(()) +} + +// Managed filenames are minted here with an exact lowercase suffix. +#[allow(clippy::case_sensitive_file_extension_comparisons)] +fn managed_path(directory: &Path, path: &Path) -> bool { + path.parent() == Some(directory) + && path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| { + name.starts_with("clipboard-") + && [".png", ".jpg", ".webp", ".gif"] + .iter() + .any(|suffix| name.ends_with(suffix)) + }) +} + +pub(crate) fn read(state: &Path, request: &Request) -> Result { + match read_native(state, request) { + Ok(prepared) => Ok(prepared), + Err(native_error) if cfg!(target_os = "linux") && wsl::available() => { + wsl::read(state, request).with_context(|| { + format!("Native clipboard: {native_error:#}; Windows clipboard fallback failed") + }) + } + Err(error) => Err(error), + } +} + +fn read_native(state: &Path, request: &Request) -> Result { + let _access = NATIVE_ACCESS + .lock() + .map_err(|_| anyhow::anyhow!("Native clipboard lock failed"))?; + let mut clipboard = arboard::Clipboard::new() + .context("System clipboard unavailable; use :attach for a local file")?; + if let Ok(files) = clipboard.get().file_list() + && !files.is_empty() + { + return Ok(crate::staging::prepare_paths(files, request)); + } + if let Ok(image) = clipboard.get_image() { + return bitmap(state, request, &image); + } + let text = clipboard + .get_text() + .context("Clipboard has no supported files, image or text")?; + ensure!(text.len() <= MAX_TEXT, "Clipboard text exceeds 1 MiB"); + Ok(Prepared { + text: Some(text), + ..Prepared::default() + }) +} + +fn bitmap(state: &Path, request: &Request, image: &arboard::ImageData<'_>) -> Result { + ensure!( + request.available_files > 0, + "Remove an attachment before adding another" + ); + let expected = image + .width + .checked_mul(image.height) + .and_then(|size| size.checked_mul(4)); + ensure!( + image.width > 0 + && image.height > 0 + && expected == Some(image.bytes.len()) + && image.bytes.len() <= MAX_RGBA, + "Clipboard bitmap is invalid or exceeds 128 MiB RGBA" + ); + let width = u32::try_from(image.width)?; + let height = u32::try_from(image.height)?; + let directory = prepare_root(state, request.key.account)?; + let mut file = tempfile::Builder::new() + .prefix("clipboard-") + .suffix(".png") + .tempfile_in(directory)?; + image::codecs::png::PngEncoder::new(file.as_file_mut()).write_image( + &image.bytes, + width, + height, + image::ExtendedColorType::Rgba8, + )?; + finish_asset(file, request) +} + +fn finish_asset(file: tempfile::NamedTempFile, request: &Request) -> Result { + file.as_file().sync_all()?; + ensure!( + file.as_file().metadata()?.len() <= MAX_ENCODED.min(request.available_bytes), + "Clipboard image exceeds 16 MiB or the draft budget" + ); + let mut prepared = crate::staging::prepare_paths(vec![file.path().to_owned()], request); + let attachment: &mut Attachment = prepared + .attachments + .first_mut() + .context("could not prepare clipboard image")?; + let (_, path) = file.keep()?; + attachment.owned = true; + attachment.lease = Some(Asset::new(path, false)); + Ok(prepared) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + drafts::{Draft, Key}, + staging::{Input, MAX_BYTES, MAX_FILES}, + }; + use std::borrow::Cow; + + #[test] + fn terminal_images_keep_encoded_bytes_and_reject_remote_file_paths() { + let directory = tempfile::tempdir().unwrap(); + let state = directory.path().join("state.sqlite3"); + let request = Request { + key: Key { + account: 100, + chat: 7, + topic: 0, + }, + id: 1, + input: Input::Clipboard, + as_photo: false, + available_files: MAX_FILES, + available_bytes: MAX_BYTES, + }; + let mut encoded = Vec::new(); + image::codecs::png::PngEncoder::new(&mut encoded) + .write_image(&[120_u8; 16], 2, 2, image::ExtendedColorType::Rgba8) + .unwrap(); + let mut payload = Payload { + mime: "image/png".to_owned(), + bytes: encoded.clone().into(), + remote: true, + }; + let prepared = prepare_payload(&state, &request, &payload).unwrap(); + let path = prepared.attachments[0].path.clone(); + assert_eq!(std::fs::read(&path).unwrap(), encoded); + assert!(!prepared.attachments[0].as_photo); + drop(prepared); + assert!(!path.exists()); + payload.mime = "image/jpeg".to_owned(); + assert!(prepare_payload(&state, &request, &payload).is_err()); + payload.mime = "text/uri-list".to_owned(); + payload.bytes = b"file:///a/local/desktop/file".as_slice().into(); + assert!( + prepare_payload(&state, &request, &payload) + .unwrap_err() + .to_string() + .contains("terminal host") + ); + payload.mime = "text/plain".to_owned(); + assert_eq!( + prepare_payload(&state, &request, &payload) + .unwrap() + .text + .as_deref(), + Some("file:///a/local/desktop/file") + ); + } + + #[test] + fn bitmap_assets_survive_draft_restore_and_clean_up_only_when_released() { + let directory = tempfile::tempdir().unwrap(); + let state = directory.path().join("state.sqlite3"); + let request = Request { + key: Key { + account: 100, + chat: 7, + topic: 0, + }, + id: 1, + input: Input::Clipboard, + as_photo: false, + available_files: MAX_FILES, + available_bytes: MAX_BYTES, + }; + let pixels = [120_u8; 16]; + let image = arboard::ImageData { + width: 2, + height: 2, + bytes: Cow::Borrowed(&pixels), + }; + let pending = bitmap(&state, &request, &image).unwrap(); + let abandoned = pending.attachments[0].path.clone(); + drop(pending); + assert!( + !abandoned.exists(), + "unaccepted clipboard results clean up their own files" + ); + let prepared = bitmap(&state, &request, &image).unwrap(); + let file = &prepared.attachments[0]; + assert!(!file.as_photo); + file.retain_owned(); + let path = file.path.clone(); + restore(&state, 100, &mut []).unwrap(); + assert!( + path.exists(), + "a quick account switch cannot collect an unflushed draft" + ); + let draft = Draft { + attachments: prepared.attachments, + ..Draft::default() + }; + let serialized = serde_json::to_string(&draft.stored(7, 0)).unwrap(); + drop(draft); + assert!(path.exists()); + let orphan = path.parent().unwrap().join("clipboard-orphan.png"); + let unrelated = path.parent().unwrap().join("personal.txt"); + std::fs::write(&orphan, b"orphan").unwrap(); + std::fs::write(&unrelated, b"keep").unwrap(); + let mut restored = vec![serde_json::from_str(&serialized).unwrap()]; + restore(&state, 100, &mut restored).unwrap(); + assert!(path.exists()); + assert!(!orphan.exists()); + assert!(unrelated.exists()); + let held_by_transfer = restored[0].attachments[0].clone(); + restored[0].attachments[0].discard_owned(); + drop(restored); + assert!( + path.exists(), + "a transfer keeps its file until its final lease drops" + ); + drop(held_by_transfer); + assert!(!path.exists()); + let invalid = arboard::ImageData { + width: usize::MAX, + height: 2, + bytes: Cow::Borrowed(&pixels), + }; + assert!(bitmap(&state, &request, &invalid).is_err()); + } +} diff --git a/src/clipboard/copy.rs b/src/clipboard/copy.rs new file mode 100644 index 0000000..b7a7fd8 --- /dev/null +++ b/src/clipboard/copy.rs @@ -0,0 +1,114 @@ +//! One bounded native writer, retaining arboard's Linux clipboard owner. +//! Terminal output stays with the existing main-thread broker. +use std::sync::mpsc::{SyncSender, sync_channel}; +use tokio::sync::mpsc::{UnboundedReceiver, unbounded_channel}; + +pub(crate) const MAX_TEXT: usize = 100_000; + +pub(crate) struct Job { + pub account: (u8, Option), + pub text: String, + pub remote: bool, + pub multiplexed: bool, +} + +pub struct Completion { + pub(crate) account: (u8, Option), + pub(crate) terminal: Option, + pub(crate) native: Result<(), String>, +} + +pub(crate) struct Writer { + sender: SyncSender, + receiver: UnboundedReceiver, + busy: bool, +} + +impl Writer { + pub fn start() -> std::io::Result { + let (sender, receiver) = sync_channel::(1); + let (completed, results) = unbounded_channel(); + std::thread::Builder::new() + .name("clipboard-copy".to_owned()) + .spawn(move || { + // On X11 and some Wayland compositors, dropping this handle makes + // copied content disappear. Keep it across operations/accounts. + let mut owner: Option = None; + while let Ok(job) = receiver.recv() { + let native = if job.remote { + Err("Remote sessions use the attached terminal clipboard".to_owned()) + } else { + native_copy(&mut owner, &job.text).or_else(|error| { + if cfg!(target_os = "linux") && super::wsl::available() { + super::wsl::copy(&job.text) + .map_err(|fallback| format!("{error}; WSL: {fallback:#}")) + } else { + Err(error) + } + }) + }; + let terminal = + (job.remote || job.multiplexed || native.is_err()).then_some(job.text); + if completed + .send(Completion { + account: job.account, + terminal, + native, + }) + .is_err() + { + break; + } + } + })?; + Ok(Self { + sender, + receiver: results, + busy: false, + }) + } + + pub fn submit(&mut self, job: Job) -> Result<(), String> { + if self.busy { + return Err("A clipboard write is still in progress".to_owned()); + } + if job.text.is_empty() { + return Err("Nothing to copy".to_owned()); + } + if job.text.len() > MAX_TEXT { + return Err("Clipboard text exceeds 100,000 bytes".to_owned()); + } + self.sender + .try_send(job) + .map_err(|_| "Clipboard writer unavailable".to_owned())?; + self.busy = true; + Ok(()) + } + + pub async fn receive(&mut self) -> Completion { + if !self.busy { + return std::future::pending().await; + } + let result = self.receiver.recv().await.unwrap_or_else(|| Completion { + account: (0, None), + terminal: None, + native: Err("Clipboard writer stopped".to_owned()), + }); + self.busy = false; + result + } +} + +fn native_copy(owner: &mut Option, text: &str) -> Result<(), String> { + let _access = super::NATIVE_ACCESS + .lock() + .map_err(|_| "Native clipboard lock failed".to_owned())?; + if owner.is_none() { + *owner = Some(arboard::Clipboard::new().map_err(|error| error.to_string())?); + } + owner + .as_mut() + .expect("initialized clipboard") + .set_text(text) + .map_err(|error| error.to_string()) +} diff --git a/src/clipboard/wsl.rs b/src/clipboard/wsl.rs new file mode 100644 index 0000000..a8aa56e --- /dev/null +++ b/src/clipboard/wsl.rs @@ -0,0 +1,175 @@ +//! Bounded Windows clipboard fallback for WSL, following Codex's platform +//! approach while retaining every file and owning any exported bitmap. +use super::{MAX_ENCODED, MAX_TEXT, Prepared, Request}; +use anyhow::{Context, Result, ensure}; +use base64::Engine; +use serde::Deserialize; +use std::{ + io::Write, + path::{Path, PathBuf}, + process::Stdio, + time::Duration, +}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +// Clipboard values are data in JSON, never interpolated into shell source. +const SCRIPT: &str = r" +$ErrorActionPreference = 'Stop' +[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false) +Add-Type -AssemblyName System.Windows.Forms +if ([System.Windows.Forms.Clipboard]::ContainsFileDropList()) { + $files = @([System.Windows.Forms.Clipboard]::GetFileDropList()) + @{kind='files'; files=$files} | ConvertTo-Json -Compress +} elseif ([System.Windows.Forms.Clipboard]::ContainsImage()) { + $image = [System.Windows.Forms.Clipboard]::GetImage() + $stream = New-Object System.IO.MemoryStream + try { + if ([long]$image.Width * $image.Height * 4 -gt 134217728) { throw 'Bitmap exceeds 128 MiB' } + $image.Save($stream, [System.Drawing.Imaging.ImageFormat]::Png) + if ($stream.Length -gt 16777216) { throw 'PNG exceeds 16 MiB' } + @{kind='image'; png=[Convert]::ToBase64String($stream.ToArray())} | ConvertTo-Json -Compress + } finally { $image.Dispose(); $stream.Dispose() } +} elseif ([System.Windows.Forms.Clipboard]::ContainsText()) { + $text = [System.Windows.Forms.Clipboard]::GetText() + if ($text.Length -gt 1048576) { throw 'Text exceeds limit' } + @{kind='text'; text=$text} | ConvertTo-Json -Compress +} else { throw 'No supported clipboard content' } +"; + +#[derive(Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase", deny_unknown_fields)] +enum Payload { + Files { files: Vec }, + Image { png: String }, + Text { text: String }, +} + +pub(super) fn available() -> bool { + std::env::var_os("WSL_INTEROP").is_some() || std::env::var_os("WSL_DISTRO_NAME").is_some() +} + +pub(super) fn read(state: &Path, request: &Request) -> Result { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()? + .block_on(async { + let bytes = output( + "powershell.exe", + &["-NoProfile", "-NonInteractive", "-STA", "-Command", SCRIPT], + 24 * 1024 * 1024, + None, + ) + .await?; + match serde_json::from_slice::(&bytes)? { + Payload::Files { files } => { + ensure!( + files.len() <= crate::staging::MAX_FILES, + "Clipboard has more than eight files; copy a smaller selection" + ); + let mut paths = Vec::new(); + for file in files { + let bytes = output("wslpath", &["-u", &file], 16 * 1024, None).await?; + let value = String::from_utf8(bytes)?; + // Remove only the tool's line ending, preserving spaces in names. + paths.push(PathBuf::from(value.trim_end_matches(['\r', '\n']))); + } + Ok(crate::staging::prepare_paths(paths, request)) + } + Payload::Image { png } => { + let bytes = base64::engine::general_purpose::STANDARD.decode(png)?; + ensure!( + bytes.len() as u64 <= MAX_ENCODED, + "Clipboard PNG exceeds 16 MiB" + ); + let root = super::prepare_root(state, request.key.account)?; + let mut file = tempfile::Builder::new() + .prefix("clipboard-") + .suffix(".png") + .tempfile_in(root)?; + file.write_all(&bytes)?; + super::finish_asset(file, request) + } + Payload::Text { text } => { + ensure!(text.len() <= MAX_TEXT, "Clipboard text exceeds 1 MiB"); + Ok(Prepared { + text: Some(text), + ..Prepared::default() + }) + } + } + }) +} + +pub(super) fn copy(text: &str) -> Result<()> { + const COPY: &str = r" +$ErrorActionPreference = 'Stop' +[Console]::InputEncoding = New-Object System.Text.UTF8Encoding($false) +Add-Type -AssemblyName System.Windows.Forms +[System.Windows.Forms.Clipboard]::SetText([Console]::In.ReadToEnd()) +"; + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()? + .block_on(async { + output( + "powershell.exe", + &["-NoProfile", "-NonInteractive", "-STA", "-Command", COPY], + 1024, + Some(text.as_bytes()), + ) + .await?; + Ok(()) + }) +} + +async fn output( + program: &str, + arguments: &[&str], + limit: usize, + input: Option<&[u8]>, +) -> Result> { + let mut child = tokio::process::Command::new(program) + .args(arguments) + .stdin(if input.is_some() { + Stdio::piped() + } else { + Stdio::null() + }) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .spawn() + .with_context(|| format!("Cannot start {program}"))?; + let mut stdout = child + .stdout + .take() + .context("missing clipboard tool output")? + .take((limit + 1) as u64); + let result = tokio::time::timeout(Duration::from_secs(4), async { + if let Some(input) = input { + let mut stdin = child.stdin.take().context("missing clipboard tool input")?; + stdin.write_all(input).await?; + } + let mut bytes = Vec::new(); + stdout.read_to_end(&mut bytes).await?; + ensure!( + bytes.len() <= limit, + "Clipboard tool output exceeds its limit" + ); + ensure!( + child.wait().await?.success(), + "Clipboard tool failed or clipboard is unavailable" + ); + Ok(bytes) + }) + .await; + match result { + Ok(Ok(bytes)) => Ok(bytes), + other => { + // Kill and reap the child, including on output-limit failures. + let _ = child.kill().await; + let _ = child.wait().await; + other.context("Clipboard tool timed out")? + } + } +} diff --git a/src/cloud_search.rs b/src/cloud_search.rs new file mode 100644 index 0000000..a99c801 --- /dev/null +++ b/src/cloud_search.rs @@ -0,0 +1,196 @@ +//! Cloud search arguments are separate from the verbatim local regex syntax. +use anyhow::{Result, ensure}; +use chrono::NaiveDate; +use clap::{Parser, ValueEnum}; + +use crate::model::{ChatId, Message}; + +pub const PAGE_SIZE: usize = 100; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum Sender { + Me, + Username(String), + Id(ChatId), +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum)] +pub enum Media { + #[default] + All, + Photo, + Video, + File, + Music, + Voice, + Round, + Gif, + Link, + Poll, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct Filters { + pub sender: Option, + pub after: Option, + pub before: Option, + pub media: Media, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Request { + pub id: u64, + pub chat_id: ChatId, + pub query: String, + pub filters: Filters, + pub before_id: i32, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Page { + pub messages: Vec, + pub next: Option, + pub total: usize, + /// Resolve a username once, then retain this identity while paging. + pub sender: Option, +} + +#[derive(Parser)] +#[command( + name = ":search --cloud", + disable_help_flag = true, + disable_version_flag = true +)] +struct Arguments { + #[arg(long, value_parser = parse_sender, value_name = "me|@username|ID", allow_negative_numbers = true)] + from: Option, + #[arg(long, value_parser = parse_date, value_name = "YYYY-MM-DD")] + after: Option, + #[arg(long, value_parser = parse_date, value_name = "YYYY-MM-DD")] + before: Option, + #[arg(long, value_enum, default_value = "all")] + media: Media, + #[arg(trailing_var_arg = true)] + text: Vec, +} + +/// Parse only the arguments after `--cloud`; never interpret a local regex. +/// # Errors +/// Returns invalid quoting, option, sender or date errors for the command UI. +pub fn parse(value: &str) -> Result<(String, Filters)> { + #[cfg(unix)] + let words = yazi_shared::shell::unix::split(value, false)?.0; + #[cfg(windows)] + let words = yazi_shared::shell::windows::split(&format!("search {value}"))? + .into_iter() + .skip(1) + .collect::>(); + let args = Arguments::try_parse_from(std::iter::once("search".to_owned()).chain(words))?; + ensure!( + !matches!((args.after, args.before), (Some(after), Some(before)) if after >= before), + "--after must be earlier than --before (UTC)" + ); + let query = args.text.join(" "); + ensure!(query.len() <= 4096, "Search text is longer than 4096 bytes"); + Ok(( + query, + Filters { + sender: args.from, + after: args.after, + before: args.before, + media: args.media, + }, + )) +} + +fn parse_sender(value: &str) -> Result { + if value == "me" { + Ok(Sender::Me) + } else if let Some(name) = value.strip_prefix('@').filter(|name| { + !name.is_empty() && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') + }) { + Ok(Sender::Username(name.to_owned())) + } else if let Ok(id) = value.parse::() + && id != 0 + { + Ok(Sender::Id(id)) + } else { + Err("Use me, @username, or a known Telegram peer ID".to_owned()) + } +} + +fn parse_date(value: &str) -> Result { + let date = NaiveDate::parse_from_str(value, "%Y-%m-%d") + .map_err(|_| "Use a date in YYYY-MM-DD format".to_owned())?; + let timestamp = date + .and_hms_opt(0, 0, 0) + .expect("midnight") + .and_utc() + .timestamp(); + if date.format("%Y-%m-%d").to_string() != value + || !(1..=i64::from(i32::MAX)).contains(×tamp) + { + return Err("Date must be YYYY-MM-DD between 1970-01-02 and 2038-01-19".to_owned()); + } + Ok(date) +} + +impl Filters { + #[must_use] + pub fn label(&self) -> String { + let mut labels = Vec::new(); + if let Some(sender) = &self.sender { + labels.push(match sender { + Sender::Me => "from me".to_owned(), + Sender::Username(name) => format!("from @{name}"), + Sender::Id(id) => format!("from {id}"), + }); + } + if let Some(date) = self.after { + labels.push(format!("after {date} UTC")); + } + if let Some(date) = self.before { + labels.push(format!("before {date} UTC")); + } + if self.media != Media::All + && let Some(value) = self.media.to_possible_value() + { + labels.push(value.get_name().to_owned()); + } + labels.join(" · ") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cloud_arguments_preserve_quoted_text_and_validate_filters() { + let (text, filters) = parse("--from @ada --after 2026-09-01 --before 2026-09-20 --media file \"release notes\" 中文").unwrap(); + assert_eq!(text, "release notes 中文"); + assert_eq!(filters.sender, Some(Sender::Username("ada".to_owned()))); + assert_eq!(filters.media, Media::File); + assert!(filters.label().contains("after 2026-09-01 UTC")); + assert_eq!(parse("-- --from literal").unwrap().0, "--from literal"); + assert_eq!( + parse("--from -1001234567890").unwrap().1.sender, + Some(Sender::Id(-1_001_234_567_890)) + ); + assert_eq!(parse("word --media photo").unwrap().0, "word --media photo"); + assert!(parse("--from me --media poll").unwrap().0.is_empty()); + for bad in [ + "--from nobody", + "--from 0", + "--media invalid", + "--after 2026-02-30", + "--after 2026-9-01", + "--after 2038-01-20", + "--before 1970-01-01", + "--after 2026-09-20 --before 2026-09-01", + "--unknown", + ] { + assert!(parse(bad).is_err(), "{bad}"); + } + } +} diff --git a/src/commands.rs b/src/commands.rs new file mode 100644 index 0000000..86994a5 --- /dev/null +++ b/src/commands.rs @@ -0,0 +1,464 @@ +//! Finite application commands, with explicit aliases and argument contracts. +//! This is an adapter to actions, not an Ex or shell interpreter. + +use crate::actions::Action; + +#[derive(Clone, Debug)] +pub enum Kind { + Action(Action), + Help, + Chat, + Open, + Join, + Info, + Config, + Folder, + Account, + Search, + Pin(bool), + Archive(bool), + Sidebar, + Color, + Status, + Attach, + Paste, + Read(bool), + Copy, + Forward, + Mute(bool), + Mentions, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Target { + None, + Chat, + Conversation, + Message, + Attachment, + Preview, +} + +pub struct Spec { + pub name: &'static str, + pub alias: Option<&'static str>, + pub arguments: &'static str, + pub kind: Kind, + pub target: Target, + description: &'static str, +} + +impl Spec { + #[must_use] + pub fn description(&self) -> &str { + if let Kind::Action(action) = &self.kind { + action.description() + } else { + self.description + } + } + + #[must_use] + pub fn usage(&self) -> String { + format!( + "{}{}{}", + self.name, + if self.arguments.is_empty() { "" } else { " " }, + self.arguments + ) + } + + #[must_use] + pub fn action(&self) -> Option { + match &self.kind { + Kind::Action(action) => Some(action.clone()), + Kind::Chat | Kind::Open | Kind::Join | Kind::Info | Kind::Status => None, + Kind::Config => Some(Action::ReloadConfig), + Kind::Help => Some(Action::CommandLine), + Kind::Folder => Some(Action::FolderNext), + Kind::Account => Some(Action::Accounts), + Kind::Search => Some(Action::Search), + Kind::Pin(_) => Some(Action::Pin), + Kind::Archive(_) => Some(Action::Archive), + Kind::Sidebar => Some(Action::ToggleSidebar), + Kind::Color => Some(Action::ChatColor), + Kind::Attach => Some(Action::Attach), + Kind::Paste => Some(Action::PasteClipboard), + Kind::Forward => Some(Action::ForwardMessage), + Kind::Copy => Some(Action::CopyText), + Kind::Read(true) => Some(Action::MarkUnread), + Kind::Read(false) => Some(Action::MarkRead), + Kind::Mute(true) => Some(Action::MuteChat), + Kind::Mute(false) => Some(Action::UnmuteChat), + Kind::Mentions => Some(Action::Mentions), + } + } +} + +macro_rules! command { + ($name:literal, $alias:expr, $arguments:literal, $kind:expr, $target:ident, $description:literal) => { + Spec { + name: $name, + alias: $alias, + arguments: $arguments, + kind: $kind, + target: Target::$target, + description: $description, + } + }; +} + +pub static COMMANDS: &[Spec] = &[ + command!( + "config", + None, + "reload", + Kind::Config, + None, + "Validate and reload Lua settings; keep the previous configuration on error" + ), + command!( + "reload", + None, + "", + Kind::Action(Action::ReloadConfig), + None, + "" + ), + command!( + "info", + None, + "", + Kind::Info, + Chat, + "Inspect chat details, permissions and slow mode" + ), + command!( + "join", + None, + "", + Kind::Join, + None, + "Preview an invitation before joining or requesting approval" + ), + command!( + "react", + None, + "", + Kind::Action(Action::Reactions), + Message, + "" + ), + command!("poll", None, "", Kind::Action(Action::Poll), Message, ""), + command!( + "spoiler", + None, + "", + Kind::Action(Action::Spoilers), + Message, + "" + ), + command!( + "quote", + None, + "", + Kind::Action(Action::ExpandQuote), + Message, + "" + ), + command!( + "forward", + None, + "", + Kind::Forward, + Message, + "Review and forward the selected message to a chat" + ), + command!( + "save", + None, + "", + Kind::Action(Action::SaveMessage), + Message, + "" + ), + command!( + "saved", + None, + "", + Kind::Action(Action::SavedMessages), + None, + "" + ), + command!( + "copy", + None, + "[text, link]", + Kind::Copy, + Message, + "Copy message text or a Telegram message link" + ), + command!( + "delete", + None, + "", + Kind::Action(Action::DeleteMessage), + Message, + "" + ), + command!( + "edit", + None, + "", + Kind::Action(Action::EditMessage), + Conversation, + "" + ), + command!( + "edit-discard", + None, + "", + Kind::Action(Action::DiscardEdit), + Conversation, + "" + ), + command!( + "unread", + None, + "", + Kind::Action(Action::FirstUnread), + Conversation, + "" + ), + command!( + "read", + None, + "", + Kind::Read(false), + Chat, + "Mark this entire chat read" + ), + command!( + "mark-unread", + None, + "", + Kind::Read(true), + Chat, + "Mark this chat unread as a reminder" + ), + command!( + "paste", + None, + "", + Kind::Paste, + Chat, + "Paste clipboard files, an image or text into this chat's draft" + ), + command!( + "attach", + None, + "", + Kind::Attach, + Chat, + "Add files to this chat's draft without sending" + ), + command!( + "attachments", + None, + "", + Kind::Action(Action::Attachments), + Conversation, + "" + ), + command!( + "help", + Some("h"), + "[command]", + Kind::Help, + None, + "Browse commands, usage and availability" + ), + command!( + "chat", + None, + "", + Kind::Chat, + None, + "Open a cached chat in this account" + ), + command!( + "open", + None, + "<@username or Telegram link>", + Kind::Open, + None, + "Look up and open a Telegram user or group" + ), + command!( + "folder", + None, + "", + Kind::Folder, + None, + "Switch Telegram folders, including Archive" + ), + command!( + "account", + None, + "[slot]", + Kind::Account, + None, + "Choose or switch Telegram accounts" + ), + command!( + "search", + None, + "[regex | --cloud [--from me|@name|ID] [--after DATE] [--before DATE] [--media TYPE] [text]]", + Kind::Search, + None, + "Offline regex, or Telegram text search with sender/date/media filters" + ), + command!( + "mentions", + None, + "", + Kind::Mentions, + Chat, + "Browse unread mentions and replies to you in the captured chat" + ), + command!( + "mute", + None, + "[1h|8h|2d|forever]", + Kind::Mute(true), + Chat, + "Mute the captured chat on Telegram; default forever" + ), + command!( + "unmute", + None, + "", + Kind::Mute(false), + Chat, + "Enable the captured chat's Telegram notifications" + ), + command!( + "latest", + None, + "", + Kind::Action(Action::Latest), + Conversation, + "" + ), + command!("reply", None, "", Kind::Action(Action::Reply), Message, ""), + command!( + "preview", + None, + "", + Kind::Action(Action::Preview), + Preview, + "" + ), + command!( + "reveal", + None, + "", + Kind::Action(Action::Reveal), + Attachment, + "" + ), + command!( + "pins", + None, + "", + Kind::Action(Action::Pins), + Conversation, + "" + ), + command!( + "pin", + None, + "", + Kind::Pin(true), + Chat, + "Pin the specified kind of target" + ), + command!( + "unpin", + None, + "", + Kind::Pin(false), + Chat, + "Unpin the specified kind of target" + ), + command!( + "archive", + None, + "", + Kind::Archive(true), + Chat, + "Move this chat to Archive" + ), + command!( + "unarchive", + None, + "", + Kind::Archive(false), + Chat, + "Move this chat out of Archive" + ), + command!( + "sidebar", + None, + "[show, hide or toggle]", + Kind::Sidebar, + None, + "Control sidebar visibility" + ), + command!( + "color", + None, + "", + Kind::Color, + None, + "Choose the target's color" + ), + command!( + "settings", + None, + "", + Kind::Action(Action::Settings), + None, + "" + ), + command!( + "status", + None, + "", + Kind::Status, + None, + "Inspect account, connection, DC and synchronization" + ), + command!("refresh", None, "", Kind::Action(Action::Refresh), None, ""), + command!("quit", Some("q"), "", Kind::Action(Action::Quit), None, ""), +]; + +#[must_use] +pub fn find(name: &str) -> Option<&'static Spec> { + COMMANDS + .iter() + .find(|spec| spec.name == name || spec.alias == Some(name)) +} + +/// Only the first whitespace separates a name from its raw argument. In +/// particular, regex backslashes and trailing spaces are never shell-decoded. +#[must_use] +pub fn split(input: &str) -> (&str, Option<&str>) { + let input = input.trim_start(); + input + .char_indices() + .find(|(_, character)| character.is_whitespace()) + .map_or((input, None), |(end, separator)| { + (&input[..end], Some(&input[end + separator.len_utf8()..])) + }) +} diff --git a/src/config.rs b/src/config.rs index 9456c87..440036c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -48,9 +48,9 @@ impl ReleaseChannel { #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub enum DownloadBehavior { - /// Download to Termgram's private temporary directory, but never ask the - /// operating system to reveal the file. - TempOnly, + /// Enter keeps the file in Termgram's managed cache. The separate + /// explicit reveal action remains available. + CacheOnly, /// A second explicit activation reveals the containing folder or selects /// the file; downloaded content is never executed directly. #[default] @@ -61,7 +61,7 @@ impl DownloadBehavior { #[must_use] pub const fn label(self) -> &'static str { match self { - Self::TempOnly => "Temp only", + Self::CacheOnly => "Keep in cache", Self::RevealOnActivation => "Reveal on activation", } } @@ -69,14 +69,14 @@ impl DownloadBehavior { #[must_use] pub const fn toggled(self) -> Self { match self { - Self::TempOnly => Self::RevealOnActivation, - Self::RevealOnActivation => Self::TempOnly, + Self::CacheOnly => Self::RevealOnActivation, + Self::RevealOnActivation => Self::CacheOnly, } } const fn persisted(self) -> &'static str { match self { - Self::TempOnly => "temp_only", + Self::CacheOnly => "cache_only", Self::RevealOnActivation => "reveal_on_activation", } } @@ -89,8 +89,8 @@ pub struct Settings { pub automatic_update_checks: bool, pub release_channel: ReleaseChannel, pub download_behavior: DownloadBehavior, - /// Reserve a compact, right-aligned message identifier column in the - /// conversation pane. Reply headers always show their target identifier + /// Show the inspected message identifier in the bottom message details. + /// Reply details always show their target identifier /// regardless of this preference. pub show_message_ids: bool, /// One-based local session slot currently selected for Telegram. @@ -183,73 +183,7 @@ impl Settings { /// Returns an error if the directory is unsafe, the destination is not a /// regular file, or an atomic write cannot be completed. pub fn save_to(self, path: &Path) -> Result<()> { - let parent = path - .parent() - .filter(|parent| !parent.as_os_str().is_empty()) - .unwrap_or_else(|| Path::new(".")); - let parent_created = !parent.exists(); - fs::create_dir_all(parent) - .with_context(|| format!("failed to create {}", parent.display()))?; - let parent_metadata = fs::symlink_metadata(parent) - .with_context(|| format!("failed to inspect {}", parent.display()))?; - if parent_metadata.file_type().is_symlink() || !parent_metadata.is_dir() { - bail!("settings directory must be a real directory"); - } - protect_settings_directory(parent, parent_created)?; - - match fs::symlink_metadata(path) { - Ok(metadata) if metadata.file_type().is_symlink() => { - bail!("refusing to replace settings through a symbolic link"); - } - Ok(metadata) if !metadata.is_file() => { - bail!("settings path is not a regular file"); - } - Ok(_) => {} - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => { - return Err(error).with_context(|| format!("failed to inspect {}", path.display())); - } - } - - let nonce = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_nanos(); - let file_name = path - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or(SETTINGS_FILE_NAME); - let temporary = parent.join(format!(".{file_name}.tmp-{}-{nonce}", std::process::id())); - let result = (|| -> Result<()> { - let mut options = OpenOptions::new(); - options.write(true).create_new(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.mode(0o600); - } - let mut file = options - .open(&temporary) - .with_context(|| format!("failed to create {}", temporary.display()))?; - file.write_all(self.serialize().as_bytes()) - .with_context(|| format!("failed to write {}", temporary.display()))?; - file.sync_all() - .with_context(|| format!("failed to sync {}", temporary.display()))?; - drop(file); - replace_settings_file(&temporary, path, nonce)?; - protect_settings_file(path)?; - #[cfg(unix)] - OpenOptions::new() - .read(true) - .open(parent) - .and_then(|directory| directory.sync_all()) - .with_context(|| format!("failed to sync {}", parent.display()))?; - Ok(()) - })(); - if result.is_err() { - drop(fs::remove_file(&temporary)); - } - result + write_preferences(path, self.serialize().as_bytes()) } fn serialize(self) -> String { @@ -265,6 +199,93 @@ impl Settings { } } +/// Read bounded JSON preferences, using defaults only when the file is absent. +/// +/// # Errors +/// Returns read or format errors without replacing an existing file. +pub(crate) fn read_preferences(path: &Path) -> Result { + let text = match fs::read_to_string(path) { + Ok(text) => text, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(T::default()), + Err(error) => return Err(error.into()), + }; + if text.len() > 1024 * 1024 { + bail!("preferences exceed 1 MiB"); + } + Ok(serde_json::from_str(&text)?) +} + +/// Shared atomic writer for managed preferences, with the same platform cleanup +/// and path protections as settings.conf. +pub(crate) fn write_preferences(path: &Path, contents: &[u8]) -> Result<()> { + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + let parent_created = !parent.exists(); + fs::create_dir_all(parent).with_context(|| format!("failed to create {}", parent.display()))?; + let parent_metadata = fs::symlink_metadata(parent) + .with_context(|| format!("failed to inspect {}", parent.display()))?; + if parent_metadata.file_type().is_symlink() || !parent_metadata.is_dir() { + bail!("settings directory must be a real directory"); + } + protect_settings_directory(parent, parent_created)?; + + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_symlink() => { + bail!("refusing to replace settings through a symbolic link"); + } + Ok(metadata) if !metadata.is_file() => { + bail!("settings path is not a regular file"); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(error).with_context(|| format!("failed to inspect {}", path.display())); + } + } + + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(SETTINGS_FILE_NAME); + let temporary = parent.join(format!(".{file_name}.tmp-{}-{nonce}", std::process::id())); + let result = (|| -> Result<()> { + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options + .open(&temporary) + .with_context(|| format!("failed to create {}", temporary.display()))?; + file.write_all(contents) + .with_context(|| format!("failed to write {}", temporary.display()))?; + file.sync_all() + .with_context(|| format!("failed to sync {}", temporary.display()))?; + drop(file); + replace_settings_file(&temporary, path, nonce)?; + protect_settings_file(path)?; + #[cfg(unix)] + OpenOptions::new() + .read(true) + .open(parent) + .and_then(|directory| directory.sync_all()) + .with_context(|| format!("failed to sync {}", parent.display()))?; + Ok(()) + })(); + if result.is_err() { + drop(fs::remove_file(&temporary)); + } + result +} + fn parse_settings(text: &str) -> Result { let mut settings = Settings::default(); for (index, line) in text.lines().enumerate() { @@ -294,7 +315,7 @@ fn parse_settings(text: &str) -> Result { } "download_behavior" => { settings.download_behavior = match value.trim() { - "temp_only" => DownloadBehavior::TempOnly, + "cache_only" | "temp_only" => DownloadBehavior::CacheOnly, "reveal_on_activation" => DownloadBehavior::RevealOnActivation, _ => bail!("download_behavior has an unsupported value"), }; @@ -397,6 +418,10 @@ pub struct Config { pub api_id: i32, pub api_hash: String, pub session_path: PathBuf, + /// Durable drafts and later user-authored state shared across account slots. + pub state_path: PathBuf, + /// Enable bounded background Ping observations for the Lua statusline. + pub measure_latency: bool, } impl std::fmt::Debug for Config { @@ -406,6 +431,8 @@ impl std::fmt::Debug for Config { .field("api_id", &self.api_id) .field("api_hash", &"[redacted]") .field("session_path", &self.session_path) + .field("state_path", &self.state_path) + .field("measure_latency", &self.measure_latency) .finish() } } @@ -455,10 +482,14 @@ impl Config { choose_default_session_path(current, legacy) }; + let mut state_path = session_path.as_os_str().to_os_string(); + state_path.push(".state.sqlite3"); Ok(Self { api_id, api_hash, + state_path: PathBuf::from(state_path), session_path, + measure_latency: false, }) } @@ -493,7 +524,9 @@ impl Config { Ok(Self { api_id: self.api_id, api_hash: self.api_hash.clone(), + state_path: self.state_path.clone(), session_path, + measure_latency: self.measure_latency, }) } @@ -614,6 +647,41 @@ fn choose_default_session_path(current: PathBuf, legacy: Option) -> Pat } } +pub(crate) fn prepare_private_file(path: &Path) -> Result<()> { + match std::fs::symlink_metadata(path) { + Ok(metadata) if !metadata.is_file() || metadata.file_type().is_symlink() => { + bail!("local storage must be a regular file") + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + match options.open(path) { + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + let metadata = std::fs::symlink_metadata(path)?; + if !metadata.is_file() || metadata.file_type().is_symlink() { + bail!("local storage must be a regular file"); + } + } + Err(error) => return Err(error.into()), + } + } + Err(error) => return Err(error.into()), + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; + } + Ok(()) +} + #[cfg(test)] mod tests { use std::path::PathBuf; @@ -691,7 +759,7 @@ mod tests { let second = Settings { automatic_update_checks: false, release_channel: ReleaseChannel::Prerelease, - download_behavior: DownloadBehavior::TempOnly, + download_behavior: DownloadBehavior::CacheOnly, show_message_ids: true, active_account: 2, account_count: 3, @@ -765,6 +833,8 @@ mod tests { #[test] fn debug_output_redacts_the_api_hash() { let config = Config { + state_path: PathBuf::from("unused-state"), + measure_latency: false, api_id: 42, api_hash: "super-secret".to_owned(), session_path: PathBuf::from("session.db"), @@ -777,6 +847,8 @@ mod tests { #[test] fn additional_accounts_use_distinct_sibling_session_files() { let base = Config { + state_path: PathBuf::from("unused-state"), + measure_latency: false, api_id: 42, api_hash: "secret".to_owned(), session_path: PathBuf::from("state/custom.session"), @@ -837,6 +909,8 @@ mod tests { std::fs::create_dir_all(&root).expect("temporary directory"); let session_path = root.join("session.db"); let config = Config { + state_path: PathBuf::from("unused-state"), + measure_latency: false, api_id: 42, api_hash: "secret".to_owned(), session_path: session_path.clone(), @@ -876,6 +950,7 @@ mod tests { let symlink_path = root.join("symlink.session"); symlink(&symlink_target, &symlink_path).expect("create session symlink"); let unsafe_config = Config { + state_path: PathBuf::from("unused-state"), session_path: symlink_path.clone(), ..config.clone() }; diff --git a/src/deletion.rs b/src/deletion.rs new file mode 100644 index 0000000..6bfe437 --- /dev/null +++ b/src/deletion.rs @@ -0,0 +1,25 @@ +//! Explicit Telegram deletion scope, reviewed against a fresh server snapshot. +use crate::model::Message; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Scope { + OnlyMe, + Everyone, +} + +impl Scope { + #[must_use] + pub const fn label(self) -> &'static str { + match self { + Self::OnlyMe => "Delete only for me", + Self::Everyone => "Delete for everyone", + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Plan { + pub message: Message, + pub revision: [u8; 32], + pub scopes: Vec, +} diff --git a/src/drafts.rs b/src/drafts.rs new file mode 100644 index 0000000..a7e9ce8 --- /dev/null +++ b/src/drafts.rs @@ -0,0 +1,388 @@ +//! Durable user-authored state, separate from the discardable message cache. +//! SQLite owns atomic replacement; the writer coalesces snapshots off the UI thread. + +use std::{collections::BTreeMap, path::Path, time::Duration}; + +use anyhow::{Context, Result, ensure}; +use libsql::{Connection, Database, TransactionBehavior, params}; +use serde::{Deserialize, Serialize}; +use tokio::sync::watch; + +use crate::{ + config::Config, + input::TextInput, + model::{ChatId, ReplyInfo, sanitize_terminal_text}, +}; + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub struct Key { + pub account: i64, + pub chat: ChatId, + /// Zero represents the main conversation; positive IDs identify topics. + pub topic: i32, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct Draft { + pub input: TextInput, + pub edit: Option, + pub reply: Option, + pub attachments: Vec, +} + +impl Draft { + #[must_use] + pub fn is_empty(&self) -> bool { + self.input.is_empty() + && self.reply.is_none() + && self.attachments.is_empty() + && self.edit.is_none() + } + + #[must_use] + pub fn stored(&self, chat: ChatId, topic: i32) -> Stored { + Stored { + chat, + topic, + text: self.input.value().to_owned(), + cursor: self.input.cursor(), + edit: self.edit.as_ref().map(crate::editing::Draft::stored), + reply: self.reply.clone(), + attachments: self.attachments.clone(), + } + } +} + +#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)] +#[serde(deny_unknown_fields)] +pub struct Stored { + pub chat: ChatId, + pub topic: i32, + pub text: String, + pub cursor: usize, + #[serde(default)] + pub edit: Option, + pub reply: Option, + #[serde(default)] + pub attachments: Vec, +} + +impl Stored { + #[must_use] + pub fn draft(&self) -> Draft { + Draft { + input: TextInput::with_cursor(sanitize_terminal_text(&self.text), self.cursor), + edit: self.edit.as_ref().map(crate::editing::Stored::draft), + reply: self.reply.clone(), + attachments: self.attachments.clone(), + } + } +} + +/// Only loaded accounts are present, including those with no remaining drafts. +/// Unknown accounts must never be interpreted as a request to erase their data. +pub type Snapshot = BTreeMap>; + +/// Load before exposing an account's cached conversations, without making a +/// SQLite lock wait block terminal input on the current-thread UI runtime. +/// # Errors +/// Returns the original storage/decoding failure or a reader-task failure. +pub async fn load(path: std::path::PathBuf, account: i64) -> Result> { + tokio::task::spawn_blocking(move || { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()? + .block_on(async { + let mut drafts = Store::open(&path).await?.load(account).await?; + crate::clipboard::restore(&path, account, &mut drafts)?; + Ok(drafts) + }) + }) + .await + .context("draft reader stopped unexpectedly")? +} + +pub struct Store { + _database: Database, + connection: Connection, +} + +impl Store { + /// # Errors + /// Returns storage/migration errors without discarding existing drafts. + pub async fn open(path: &Path) -> Result { + crate::config::prepare_private_file(path)?; + let database = libsql::Builder::new_local(path).build().await?; + let connection = database.connect()?; + connection.busy_timeout(Duration::from_secs(5))?; + let transaction = connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .await?; + let version = transaction + .query("PRAGMA user_version", ()) + .await? + .next() + .await? + .context("missing draft schema version")? + .get::(0)?; + ensure!( + version <= 3, + "drafts were created by a newer Termgram version" + ); + if version == 0 { + transaction.execute_batch("CREATE TABLE drafts(account_id INTEGER NOT NULL, chat_id INTEGER NOT NULL, topic_id INTEGER NOT NULL, data TEXT NOT NULL, PRIMARY KEY(account_id,chat_id,topic_id)); PRAGMA user_version=1;").await?; + } + if version < 2 { + transaction.execute_batch("PRAGMA user_version=2;").await?; + } + if version < 3 { + transaction.execute_batch("PRAGMA user_version=3;").await?; + } + transaction.commit().await?; + Ok(Self { + _database: database, + connection, + }) + } + + /// # Errors + /// Returns a database or draft decoding error. + pub async fn load(&self, account: i64) -> Result> { + let mut rows = self + .connection + .query( + "SELECT data FROM drafts WHERE account_id=?1 ORDER BY chat_id,topic_id", + [account], + ) + .await?; + let mut drafts = Vec::new(); + while let Some(row) = rows.next().await? { + drafts.push(serde_json::from_str(&row.get::(0)?)?); + } + Ok(drafts) + } + + /// # Errors + /// A failed transaction leaves the previous account drafts intact. + pub async fn save(&self, snapshot: &Snapshot, previous: &Snapshot) -> Result<()> { + let transaction = self + .connection + .transaction_with_behavior(TransactionBehavior::Immediate) + .await?; + for (&account, drafts) in snapshot { + if previous.get(&account) == Some(drafts) { + continue; + } + ensure!(account > 0, "drafts require a Telegram user identity"); + transaction + .execute("DELETE FROM drafts WHERE account_id=?1", [account]) + .await?; + for draft in drafts { + transaction + .execute( + "INSERT INTO drafts VALUES(?1,?2,?3,?4)", + params![ + account, + draft.chat, + draft.topic, + serde_json::to_string(draft)? + ], + ) + .await?; + } + } + transaction.commit().await?; + Ok(()) + } +} + +pub struct Writer { + snapshots: watch::Sender, + errors: watch::Receiver>, + task: tokio::task::JoinHandle>, +} + +impl Writer { + #[must_use] + pub fn spawn(config: Config) -> Self { + let (snapshots, receiver) = watch::channel(Snapshot::new()); + let (errors_tx, errors) = watch::channel(None); + let task = tokio::task::spawn_blocking(move || { + let result = (|| -> Result<()> { + config.prepare_session_dir()?; + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()? + .block_on(write_loop(&config.state_path, receiver, &errors_tx)) + })(); + if let Err(error) = &result { + errors_tx.send_replace(Some(format!("Draft storage: {error:#}"))); + } + result + }); + Self { + snapshots, + errors, + task, + } + } + + pub fn queue(&self, snapshot: Snapshot) { + self.snapshots.send_replace(snapshot); + } + + pub async fn next_error(&mut self) -> String { + loop { + if self.errors.changed().await.is_err() { + return std::future::pending().await; + } + if let Some(error) = self.errors.borrow_and_update().clone() { + return error; + } + } + } + + /// Flush the last coalesced snapshot before completing normal shutdown. + /// # Errors + /// Returns the final write failure instead of claiming the draft was saved. + pub async fn finish(self) -> Result<()> { + drop(self.snapshots); + self.task + .await + .context("draft writer stopped unexpectedly")? + } +} + +async fn write_loop( + path: &Path, + mut snapshots: watch::Receiver, + errors: &watch::Sender>, +) -> Result<()> { + let store = Store::open(path).await?; + let mut previous = Snapshot::new(); + while snapshots.changed().await.is_ok() { + // A fixed coalescing window also bounds saves during continuous typing. + tokio::time::sleep(Duration::from_millis(400)).await; + loop { + let snapshot = snapshots.borrow_and_update().clone(); + match store.save(&snapshot, &previous).await { + Ok(()) => { + previous = snapshot; + break; + } + Err(error) => { + errors.send_replace(Some(format!("Draft not saved; retrying: {error:#}"))); + if snapshots.has_changed().is_err() { + return Err(error); + } + tokio::time::sleep(Duration::from_secs(2)).await; + } + } + } + } + let last = snapshots.borrow_and_update().clone(); + store.save(&last, &previous).await +} + +#[cfg(test)] +mod tests { + use super::*; + + fn stored(chat: ChatId, topic: i32, text: &str) -> Stored { + Stored { + edit: None, + attachments: Vec::new(), + chat, + topic, + text: text.to_owned(), + cursor: 1, + reply: Some(ReplyInfo { + chat_id: chat, + message_id: 77, + sender: Some("Full sender name".to_owned()), + }), + } + } + + #[tokio::test] + async fn drafts_survive_restart_and_failed_replacement_without_crossing_accounts() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("state.sqlite3"); + let store = Store::open(&path).await.unwrap(); + let mut initial = Snapshot::from([ + ( + 100, + vec![stored(7, 0, "a界🙂"), stored(7, 44, "Topic draft")], + ), + (200, vec![stored(7, 0, "Other account")]), + ]); + initial.get_mut(&100).unwrap()[0] + .attachments + .push(crate::staging::Attachment { + path: directory.path().join("attachment.txt"), + size: 12, + digest: [7; 32], + as_photo: false, + photo_supported: false, + owned: false, + lease: None, + }); + initial.get_mut(&100).unwrap()[0].edit = Some(crate::editing::Stored { + source: crate::editing::Source { + message_id: 41, + text: "original".to_owned(), + revision: [1; 32], + caption: true, + }, + text: "modified caption 界🙂".to_owned(), + cursor: 18, + }); + store.save(&initial, &Snapshot::new()).await.unwrap(); + let duplicate = stored(7, 0, "duplicate"); + assert!( + store + .save( + &Snapshot::from([(100, vec![duplicate.clone(), duplicate])]), + &initial + ) + .await + .is_err() + ); + assert_eq!(store.load(100).await.unwrap(), initial[&100]); + drop(store); + let restored = load(path.clone(), 100).await.unwrap(); + assert_eq!(restored, initial[&100]); + assert_eq!(restored[0].draft().input.cursor(), 1); + assert_eq!(TextInput::with_cursor("a界🙂", 2).cursor(), 1); + let store = Store::open(&path).await.unwrap(); + store + .save(&Snapshot::from([(100, Vec::new())]), &initial) + .await + .unwrap(); + assert!(store.load(100).await.unwrap().is_empty()); + assert_eq!(store.load(200).await.unwrap(), initial[&200]); + } + + #[tokio::test] + async fn normal_shutdown_flushes_the_latest_coalesced_snapshot() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("state.sqlite3"); + let writer = Writer::spawn(Config { + api_id: 1, + api_hash: "unused".to_owned(), + measure_latency: false, + session_path: directory.path().join("unused.session"), + state_path: path.clone(), + }); + writer.queue(Snapshot::from([(100, vec![stored(7, 0, "first")])])); + let final_snapshot = Snapshot::from([ + (100, vec![stored(7, 0, "latest")]), + (200, vec![stored(7, 0, "separate")]), + ]); + writer.queue(final_snapshot.clone()); + writer.finish().await.unwrap(); + let store = Store::open(&path).await.unwrap(); + assert_eq!(store.load(100).await.unwrap(), final_snapshot[&100]); + assert_eq!(store.load(200).await.unwrap(), final_snapshot[&200]); + } +} diff --git a/src/editing.rs b/src/editing.rs new file mode 100644 index 0000000..0bfa190 --- /dev/null +++ b/src/editing.rs @@ -0,0 +1,47 @@ +//! Edit drafts retain their server revision separately from ordinary composition. +use crate::{input::TextInput, model::sanitize_terminal_text}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)] +pub struct Source { + pub message_id: i32, + pub text: String, + pub revision: [u8; 32], + pub caption: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Draft { + pub source: Source, + pub input: TextInput, +} + +#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)] +pub struct Stored { + pub source: Source, + pub text: String, + pub cursor: usize, +} + +impl Draft { + #[must_use] + pub fn stored(&self) -> Stored { + Stored { + source: self.source.clone(), + text: self.input.value().to_owned(), + cursor: self.input.cursor(), + } + } +} + +impl Stored { + #[must_use] + pub fn draft(&self) -> Draft { + let mut source = self.source.clone(); + source.text = sanitize_terminal_text(&source.text); + Draft { + source, + input: TextInput::with_cursor(sanitize_terminal_text(&self.text), self.cursor), + } + } +} diff --git a/src/entities.rs b/src/entities.rs new file mode 100644 index 0000000..1301542 --- /dev/null +++ b/src/entities.rs @@ -0,0 +1,67 @@ +//! Telegram text formatting in terminal-safe UTF-8 byte ranges. +//! +//! Protocol UTF-16 offsets are converted once in the network adapter. Cache and +//! presentation share these ranges; unknown entities leave their text readable. +use serde::{Deserialize, Serialize}; +use std::{borrow::Cow, ops::Range}; +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; + +#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, Hash)] +pub struct Entity { + pub range: Range, + pub kind: Kind, +} + +#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, Hash)] +pub enum Kind { + Bold, + Italic, + Underline, + Strike, + Code, + Pre { language: String }, + Quote { collapsed: bool }, + Spoiler, + Link, + Mention, + Tag, +} + +impl Entity { + #[must_use] + pub fn valid_for(&self, text: &str) -> bool { + self.range.start < self.range.end && text.get(self.range.clone()).is_some() + } + + #[must_use] + pub const fn overlaps(&self, start: usize, end: usize) -> bool { + self.range.start < end && self.range.end > start + } +} + +/// Hide complete graphemes, including when an entity covers only a combining +/// mark. The returned text contains no hidden original bytes or ANSI conceal. +#[must_use] +pub fn conceal<'a>(text: &'a str, entities: &[Entity]) -> Cow<'a, str> { + let spoilers: Vec<_> = entities + .iter() + .filter(|entity| entity.kind == Kind::Spoiler && entity.valid_for(text)) + .collect(); + if spoilers.is_empty() { + return Cow::Borrowed(text); + } + let mut output = String::with_capacity(text.len()); + for (start, grapheme) in text.grapheme_indices(true) { + if grapheme != "\n" + && spoilers + .iter() + .any(|entity| entity.overlaps(start, start + grapheme.len())) + { + output.push_str(&"▨".repeat(grapheme.width())); + } else { + output.push_str(grapheme); + } + } + Cow::Owned(output) +} diff --git a/src/event.rs b/src/event.rs index 12a6276..32c7a39 100644 --- a/src/event.rs +++ b/src/event.rs @@ -60,6 +60,56 @@ pub enum ConnectionStatus { /// Commands sent from the application to the Telegram worker. #[derive(Clone, Eq, PartialEq)] pub enum TelegramCommand { + LoadChatInfo { + chat_id: ChatId, + request_id: u64, + }, + PreviewInvite { + hash: String, + request_id: u64, + }, + JoinInvite { + hash: String, + title: String, + request_needed: bool, + request_id: u64, + }, + LoadReactions { + chat_id: ChatId, + message_id: i32, + request_id: u64, + }, + ChangeReaction { + chat_id: ChatId, + message_id: i32, + request_id: u64, + expected: Vec, + emoji: Option, + }, + RefreshReactions { + chat_id: ChatId, + message_ids: Vec, + request_id: u64, + }, + LoadPoll { + chat_id: ChatId, + message_id: i32, + request_id: u64, + }, + RefreshPoll { + chat_id: ChatId, + message_id: i32, + hash: i64, + request_id: u64, + }, + VotePoll { + chat_id: ChatId, + message_id: i32, + poll_id: i64, + revision: u64, + options: Vec>, + request_id: u64, + }, /// Start a short-lived QR-code login flow from the phone prompt. StartQrAuth, SubmitPhone(String), @@ -67,9 +117,16 @@ pub enum TelegramCommand { SubmitPassword(String), /// Abandon the current login token and request a fresh phone/code flow. RestartAuth, + LoadOlder { + chat_id: ChatId, + request_id: u64, + before_id: i32, + }, LoadHistory { chat_id: ChatId, request_id: u64, + /// Oldest-to-newest page strictly after this incoming read boundary. + after_id: Option, }, /// Fetch one message for reply navigation when it is outside the bounded /// in-memory history window. @@ -79,6 +136,12 @@ pub enum TelegramCommand { message_id: i32, request_id: u64, }, + /// Resolve the visible replies in one request per target conversation. + LoadReplyPreviews { + chat_id: ChatId, + message_ids: Vec, + request_id: u64, + }, SendMessage { chat_id: ChatId, local_id: i32, @@ -87,22 +150,70 @@ pub enum TelegramCommand { /// native Telegram reply. reply_to: Option, }, - /// Upload a local path and send it as Telegram media. + OpenSaved { + request_id: u64, + }, + ReviewForward { + chat_id: ChatId, + message_id: i32, + destination: ChatId, + request_id: u64, + }, + ForwardMessage { + chat_id: ChatId, + message_id: i32, + destination: ChatId, + revision: [u8; 32], + random_id: i64, + request_id: u64, + }, + CopyText(String), + CopyMessage { + chat_id: ChatId, + message_id: i32, + link: bool, + request_id: u64, + }, + ReviewDeletion { + chat_id: ChatId, + message_id: i32, + request_id: u64, + }, + DeleteMessage { + chat_id: ChatId, + message_id: i32, + request_id: u64, + revision: [u8; 32], + scope: crate::deletion::Scope, + }, + LoadEdit { + chat_id: ChatId, + message_id: i32, + request_id: u64, + }, + EditMessage { + chat_id: ChatId, + message_id: i32, + request_id: u64, + revision: [u8; 32], + text: String, + }, + PrepareAttachments(crate::staging::Request), + /// Upload a reviewed local file and send it as Telegram media. SendAttachment { chat_id: ChatId, local_id: i32, - path: PathBuf, + attachment: crate::staging::Attachment, caption: String, - /// Compress image input as a Telegram photo; otherwise preserve it as - /// a document. - as_photo: bool, /// Positive message identifier in this conversation when the media is /// sent as a reply. reply_to: Option, }, - /// Lazily download media from a known message into Termgram's temporary - /// download directory. + /// Lazily download media from a known message into Termgram's managed + /// media cache. DownloadAttachment { + request_id: u64, + media_id: Option, chat_id: ChatId, message_id: i32, }, @@ -127,8 +238,75 @@ pub enum TelegramCommand { }, MarkRead { chat_id: ChatId, + max_id: i32, + }, + ReadMentions { + chat_id: ChatId, + message_ids: Vec, + }, + SetChatUnread { + chat_id: ChatId, + unread: bool, + read_history: bool, + request_id: u64, }, RefreshDialogs, + ResolveAlertSettings { + key: crate::notifications::SettingsKey, + request_id: u64, + }, + SetChatMute { + chat_id: ChatId, + mute: crate::notifications::Mute, + request_id: u64, + }, + RefreshDialogPins, + LoadPinnedMessages { + chat_id: ChatId, + before: i32, + request_id: u64, + }, + LoadPinnedContext { + chat_id: ChatId, + message_id: i32, + request_id: u64, + }, + ChangeMessagePin { + chat_id: ChatId, + message_id: i32, + action: crate::pins::MessageAction, + request_id: u64, + }, + SetArchived { + chat_id: ChatId, + archived: bool, + request_id: u64, + }, + ChangeDialogPin { + chat_id: ChatId, + scope: crate::pins::DialogScope, + action: crate::pins::DialogAction, + request_id: u64, + }, + RefreshFolders, + SearchCached(crate::search::Request), + SearchCloud(crate::cloud_search::Request), + SearchMentions { + chat_id: ChatId, + before_id: i32, + request_id: u64, + }, + CancelSearch, + LoadCloudContext { + chat_id: ChatId, + message_id: i32, + request_id: u64, + }, + LoadCachedContext { + chat_id: ChatId, + message_id: i32, + request_id: u64, + }, /// Select a different local session slot. The runtime intercepts this /// command and replaces the single active Telegram worker. SwitchAccount { @@ -137,10 +315,537 @@ pub enum TelegramCommand { Shutdown, } +impl TelegramCommand { + pub(crate) fn cloud_search_id(&self) -> Option { + match self { + Self::SearchCloud(request) => Some(request.id), + Self::LoadCloudContext { request_id, .. } | Self::SearchMentions { request_id, .. } => { + Some(*request_id) + } + _ => None, + } + } + + /// Return the matching failure event so pending UI operations always settle. + #[must_use] + #[allow(clippy::too_many_lines)] + pub fn failure(self, error: String) -> Option { + let event = match self { + Self::LoadChatInfo { + chat_id, + request_id, + } => NetworkEvent::ChatInfoReady { + chat_id, + request_id, + result: Err(error), + }, + Self::PreviewInvite { request_id, .. } => NetworkEvent::InviteReady { + request_id, + result: Err(error), + }, + Self::JoinInvite { request_id, .. } => NetworkEvent::InviteJoined { + request_id, + result: Err(error), + }, + Self::LoadReactions { + chat_id, + message_id, + request_id, + } => NetworkEvent::ReactionsLoaded { + chat_id, + message_id, + request_id, + result: Err(error), + }, + Self::ChangeReaction { + chat_id, + message_id, + request_id, + .. + } => NetworkEvent::ReactionsFinished { + chat_id, + message_id: Some(message_id), + request_id, + error: Some(error), + }, + Self::RefreshReactions { + chat_id, + request_id, + .. + } => NetworkEvent::ReactionsFinished { + chat_id, + message_id: None, + request_id, + error: Some(error), + }, + Self::LoadPoll { + chat_id, + message_id, + request_id, + } => NetworkEvent::PollLoaded { + chat_id, + message_id, + request_id, + result: Err(error), + }, + Self::RefreshPoll { + chat_id, + message_id, + request_id, + .. + } => NetworkEvent::PollFinished { + chat_id, + message_id, + request_id, + voting: false, + error: Some(error), + }, + Self::VotePoll { + chat_id, + message_id, + request_id, + .. + } => NetworkEvent::PollFinished { + chat_id, + message_id, + request_id, + voting: true, + error: Some(error), + }, + Self::ResolveAlertSettings { key, request_id } => NetworkEvent::AlertSettingsReady { + key, + request_id, + result: Err(error), + }, + Self::OpenSaved { request_id } => NetworkEvent::SavedReady { + request_id, + result: Err(error), + }, + Self::ReviewForward { request_id, .. } => NetworkEvent::ForwardReady { + request_id, + result: Err(error), + }, + Self::ForwardMessage { request_id, .. } => NetworkEvent::ForwardFinished { + request_id, + error: Some(error), + }, + + Self::CopyMessage { request_id, .. } => NetworkEvent::MessageCopyReady { + request_id, + result: Err(error), + }, + Self::ReviewDeletion { + chat_id, + message_id, + request_id, + } => NetworkEvent::DeletionReady { + chat_id, + message_id, + request_id, + result: Err(error), + }, + Self::DeleteMessage { + chat_id, + message_id, + request_id, + .. + } => NetworkEvent::DeleteFinished { + chat_id, + message_id, + request_id, + error: Some(error), + }, + Self::LoadEdit { + chat_id, + request_id, + .. + } => NetworkEvent::EditLoaded { + chat_id, + request_id, + result: Err(error), + }, + Self::EditMessage { + chat_id, + request_id, + .. + } => NetworkEvent::EditFinished { + chat_id, + request_id, + error: Some(error), + }, + Self::SetChatUnread { + chat_id, + unread, + request_id, + .. + } => NetworkEvent::ChatUnreadFinished { + chat_id, + unread, + request_id, + snapshot: None, + error: Some(error), + }, + Self::LoadPinnedMessages { + chat_id, + request_id, + .. + } + | Self::LoadPinnedContext { + chat_id, + request_id, + .. + } => NetworkEvent::PinnedMessagesFailed { + chat_id, + request_id, + error, + }, + Self::ChangeMessagePin { + chat_id, + request_id, + .. + } => NetworkEvent::MessagePinFinished { + chat_id, + request_id, + error: Some(error), + }, + Self::ChangeDialogPin { request_id, .. } | Self::SetArchived { request_id, .. } => { + NetworkEvent::DialogPinFinished { + request_id, + error: Some(error), + } + } + Self::LoadOlder { + chat_id, + request_id, + .. + } => NetworkEvent::OlderHistoryFailed { + chat_id, + request_id, + error, + }, + TelegramCommand::PrepareAttachments(request) => NetworkEvent::AttachmentsPrepared { + key: request.key, + request_id: request.id, + result: Err(error), + }, + TelegramCommand::SendMessage { + chat_id, + local_id, + text, + reply_to, + } => NetworkEvent::SendFailed { + chat_id, + local_id, + text, + reply_to, + error, + }, + TelegramCommand::SendAttachment { + chat_id, + local_id, + attachment, + caption, + reply_to, + } => NetworkEvent::AttachmentSendFailed { + chat_id, + local_id, + attachment, + caption, + reply_to, + error, + }, + TelegramCommand::DownloadAttachment { + chat_id, + message_id, + request_id, + .. + } => NetworkEvent::AttachmentDownloadFailed { + request_id, + chat_id, + message_id, + error, + }, + TelegramCommand::DownloadPreview { + chat_id, + message_id, + request_id, + .. + } => NetworkEvent::PreviewDownloadFailed { + chat_id, + message_id, + request_id, + error, + }, + TelegramCommand::ResolveTelegramLink { url } => NetworkEvent::LinkFailed { url, error }, + TelegramCommand::ActivateButton { + chat_id, + message_id, + button_index: _, + } => NetworkEvent::ButtonFailed { + chat_id, + message_id, + error, + }, + TelegramCommand::LoadHistory { + chat_id, + request_id, + .. + } => NetworkEvent::HistoryFailed { + chat_id, + request_id, + error, + }, + TelegramCommand::LoadMessage { + chat_id, + source_message_id: _, + message_id, + request_id, + } => NetworkEvent::MessageLoadFailed { + chat_id, + message_id, + request_id, + error, + }, + TelegramCommand::MarkRead { chat_id, max_id } => NetworkEvent::ReadMarkFailed { + chat_id, + max_id, + error, + }, + Self::ReadMentions { + chat_id, + message_ids, + } => NetworkEvent::MentionsReadFinished { + chat_id, + message_ids, + error: Some(error), + }, + TelegramCommand::LoadReplyPreviews { + chat_id, + request_id, + .. + } => NetworkEvent::ReplyPreviewsFailed { + chat_id, + request_id, + error, + }, + TelegramCommand::SetChatMute { + chat_id, + request_id, + .. + } => NetworkEvent::ChatMuteFinished { + chat_id, + request_id, + result: Err(error), + }, + TelegramCommand::SearchCached(request) => NetworkEvent::SearchFailed { + request_id: request.id, + error, + }, + TelegramCommand::SearchCloud(request) => NetworkEvent::SearchFailed { + request_id: request.id, + error, + }, + TelegramCommand::LoadCloudContext { request_id, .. } + | Self::SearchMentions { request_id, .. } + | TelegramCommand::LoadCachedContext { request_id, .. } => { + NetworkEvent::SearchFailed { request_id, error } + } + Self::CopyText(_) | TelegramCommand::RefreshFolders | Self::RefreshDialogPins => { + NetworkEvent::Error(error) + } + TelegramCommand::RefreshDialogs => NetworkEvent::DialogsFailed(error), + TelegramCommand::CancelSearch + | TelegramCommand::SwitchAccount { .. } + | TelegramCommand::Shutdown => return None, + TelegramCommand::StartQrAuth + | TelegramCommand::SubmitPhone(_) + | TelegramCommand::SubmitCode(_) + | TelegramCommand::SubmitPassword(_) + | TelegramCommand::RestartAuth => NetworkEvent::Fatal(error), + }; + Some(event) + } +} + impl fmt::Debug for TelegramCommand { #[allow(clippy::too_many_lines)] fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { + Self::LoadChatInfo { + chat_id, + request_id, + } => formatter + .debug_struct("LoadChatInfo") + .field("chat_id", chat_id) + .field("request_id", request_id) + .finish(), + Self::PreviewInvite { request_id, .. } | Self::JoinInvite { request_id, .. } => { + formatter + .debug_struct(if matches!(self, Self::PreviewInvite { .. }) { + "PreviewInvite" + } else { + "JoinInvite" + }) + .field("request_id", request_id) + .finish_non_exhaustive() + } + Self::LoadReactions { + chat_id, + request_id, + .. + } + | Self::ChangeReaction { + chat_id, + request_id, + .. + } + | Self::RefreshReactions { + chat_id, + request_id, + .. + } => formatter + .debug_struct(match self { + Self::LoadReactions { .. } => "LoadReactions", + Self::ChangeReaction { .. } => "ChangeReaction", + _ => "RefreshReactions", + }) + .field("chat_id", chat_id) + .field("request_id", request_id) + .finish_non_exhaustive(), + Self::LoadPoll { + chat_id, + message_id, + request_id, + } + | Self::RefreshPoll { + chat_id, + message_id, + request_id, + .. + } + | Self::VotePoll { + chat_id, + message_id, + request_id, + .. + } => formatter + .debug_struct(match self { + Self::LoadPoll { .. } => "LoadPoll", + Self::RefreshPoll { .. } => "RefreshPoll", + _ => "VotePoll", + }) + .field("chat_id", chat_id) + .field("message_id", message_id) + .field("request_id", request_id) + .finish_non_exhaustive(), + Self::SetChatUnread { + chat_id, + unread, + request_id, + read_history, + } => formatter + .debug_struct("SetChatUnread") + .field("chat_id", chat_id) + .field("unread", unread) + .field("read_history", read_history) + .field("request_id", request_id) + .finish(), + Self::LoadOlder { + chat_id, + request_id, + before_id, + } => formatter + .debug_struct("LoadOlder") + .field("chat_id", chat_id) + .field("request_id", request_id) + .field("before_id", before_id) + .finish(), + Self::LoadEdit { + chat_id, + message_id, + request_id, + } => formatter + .debug_struct("LoadEdit") + .field("chat_id", chat_id) + .field("message_id", message_id) + .field("request_id", request_id) + .finish(), + Self::EditMessage { + chat_id, + message_id, + request_id, + .. + } => formatter + .debug_struct("EditMessage") + .field("chat_id", chat_id) + .field("message_id", message_id) + .field("request_id", request_id) + .finish_non_exhaustive(), + Self::OpenSaved { request_id } => formatter + .debug_struct("OpenSaved") + .field("request_id", request_id) + .finish(), + Self::ReviewForward { + chat_id, + message_id, + destination, + request_id, + } => formatter + .debug_struct("ReviewForward") + .field("chat_id", chat_id) + .field("message_id", message_id) + .field("destination", destination) + .field("request_id", request_id) + .finish(), + Self::ForwardMessage { + chat_id, + message_id, + destination, + request_id, + .. + } => formatter + .debug_struct("ForwardMessage") + .field("chat_id", chat_id) + .field("message_id", message_id) + .field("destination", destination) + .field("request_id", request_id) + .finish_non_exhaustive(), + Self::CopyText(_) => formatter.write_str("CopyText()"), + Self::CopyMessage { + chat_id, + message_id, + link, + request_id, + } => formatter + .debug_struct("CopyMessage") + .field("chat_id", chat_id) + .field("message_id", message_id) + .field("link", link) + .field("request_id", request_id) + .finish(), + Self::ReviewDeletion { + chat_id, + message_id, + request_id, + } => formatter + .debug_struct("ReviewDeletion") + .field("chat_id", chat_id) + .field("message_id", message_id) + .field("request_id", request_id) + .finish(), + Self::DeleteMessage { + chat_id, + message_id, + request_id, + scope, + .. + } => formatter + .debug_struct("DeleteMessage") + .field("chat_id", chat_id) + .field("message_id", message_id) + .field("request_id", request_id) + .field("scope", scope) + .finish_non_exhaustive(), Self::StartQrAuth => formatter.write_str("StartQrAuth"), Self::SubmitPhone(_) => formatter .debug_tuple("SubmitPhone") @@ -158,8 +863,10 @@ impl fmt::Debug for TelegramCommand { Self::LoadHistory { chat_id, request_id, + after_id, } => formatter .debug_struct("LoadHistory") + .field("after_id", after_id) .field("chat_id", chat_id) .field("request_id", request_id) .finish(), @@ -175,6 +882,16 @@ impl fmt::Debug for TelegramCommand { .field("message_id", message_id) .field("request_id", request_id) .finish(), + Self::LoadReplyPreviews { + chat_id, + message_ids, + request_id, + } => formatter + .debug_struct("LoadReplyPreviews") + .field("chat_id", chat_id) + .field("message_ids", message_ids) + .field("request_id", request_id) + .finish(), Self::SendMessage { chat_id, local_id, @@ -187,25 +904,28 @@ impl fmt::Debug for TelegramCommand { .field("text", text) .field("reply_to", reply_to) .finish(), + Self::PrepareAttachments(request) => formatter + .debug_tuple("PrepareAttachments") + .field(request) + .finish(), Self::SendAttachment { chat_id, local_id, - path, + attachment, caption, - as_photo, reply_to, } => formatter .debug_struct("SendAttachment") .field("chat_id", chat_id) .field("local_id", local_id) - .field("path", path) + .field("attachment", attachment) .field("caption", caption) - .field("as_photo", as_photo) .field("reply_to", reply_to) .finish(), Self::DownloadAttachment { chat_id, message_id, + .. } => formatter .debug_struct("DownloadAttachment") .field("chat_id", chat_id) @@ -237,11 +957,131 @@ impl fmt::Debug for TelegramCommand { .field("message_id", message_id) .field("button_index", button_index) .finish(), - Self::MarkRead { chat_id } => formatter + Self::MarkRead { chat_id, max_id } => formatter .debug_struct("MarkRead") .field("chat_id", chat_id) + .field("max_id", max_id) + .finish(), + Self::ReadMentions { + chat_id, + message_ids, + } => formatter + .debug_struct("ReadMentions") + .field("chat_id", chat_id) + .field("message_ids", message_ids) + .finish(), + Self::SearchMentions { + chat_id, + before_id, + request_id, + } => formatter + .debug_struct("SearchMentions") + .field("chat_id", chat_id) + .field("before_id", before_id) + .field("request_id", request_id) + .finish(), + Self::SearchCached(request) => formatter + .debug_struct("SearchCached") + .field("request_id", &request.id) + .finish_non_exhaustive(), + Self::SearchCloud(request) => formatter + .debug_struct("SearchCloud") + .field("request_id", &request.id) + .field("chat_id", &request.chat_id) + .finish_non_exhaustive(), + Self::LoadCloudContext { + chat_id, + message_id, + request_id, + } => formatter + .debug_struct("LoadCloudContext") + .field("chat_id", chat_id) + .field("message_id", message_id) + .field("request_id", request_id) + .finish(), + Self::LoadCachedContext { + chat_id, + message_id, + request_id, + } => formatter + .debug_struct("LoadCachedContext") + .field("chat_id", chat_id) + .field("message_id", message_id) + .field("request_id", request_id) + .finish(), + Self::ResolveAlertSettings { key, request_id } => formatter + .debug_struct("ResolveAlertSettings") + .field("key", key) + .field("request_id", request_id) + .finish(), + Self::SetChatMute { + chat_id, + mute, + request_id, + } => formatter + .debug_struct("SetChatMute") + .field("chat_id", chat_id) + .field("mute", mute) + .field("request_id", request_id) .finish(), + Self::CancelSearch => formatter.write_str("CancelSearch"), + Self::RefreshFolders => formatter.write_str("RefreshFolders"), Self::RefreshDialogs => formatter.write_str("RefreshDialogs"), + Self::RefreshDialogPins => formatter.write_str("RefreshDialogPins"), + Self::LoadPinnedMessages { + chat_id, + before, + request_id, + } => formatter + .debug_struct("LoadPinnedMessages") + .field("chat_id", chat_id) + .field("before", before) + .field("request_id", request_id) + .finish(), + Self::LoadPinnedContext { + chat_id, + message_id, + request_id, + } => formatter + .debug_struct("LoadPinnedContext") + .field("chat_id", chat_id) + .field("message_id", message_id) + .field("request_id", request_id) + .finish(), + Self::ChangeMessagePin { + chat_id, + message_id, + action, + request_id, + } => formatter + .debug_struct("ChangeMessagePin") + .field("chat_id", chat_id) + .field("message_id", message_id) + .field("action", action) + .field("request_id", request_id) + .finish(), + Self::SetArchived { + chat_id, + archived, + request_id, + } => formatter + .debug_struct("SetArchived") + .field("chat_id", chat_id) + .field("archived", archived) + .field("request_id", request_id) + .finish(), + Self::ChangeDialogPin { + chat_id, + scope, + action, + request_id, + } => formatter + .debug_struct("ChangeDialogPin") + .field("chat_id", chat_id) + .field("scope", scope) + .field("action", action) + .field("request_id", request_id) + .finish(), Self::SwitchAccount { account } => formatter .debug_struct("SwitchAccount") .field("account", account) @@ -254,10 +1094,237 @@ impl fmt::Debug for TelegramCommand { /// SDK-independent updates sent from the Telegram worker to the application. #[derive(Clone, Debug, Eq, PartialEq)] pub enum NetworkEvent { + ChatInfoReady { + chat_id: ChatId, + request_id: u64, + result: Result, + }, + ChatInfoInvalidated { + chat_id: ChatId, + }, + InviteReady { + request_id: u64, + result: Result, + }, + InviteJoined { + request_id: u64, + result: Result, + }, + ReactionsChanged(crate::reactions::Update), + ReactionsLoading { + request_id: u64, + }, + ReactionsLoaded { + chat_id: ChatId, + message_id: i32, + request_id: u64, + result: Result, + }, + ReactionsFinished { + chat_id: ChatId, + message_id: Option, + request_id: u64, + error: Option, + }, + PollChanged(crate::polls::Update), + PollLoading { + request_id: u64, + }, + PollLoaded { + chat_id: ChatId, + message_id: i32, + request_id: u64, + result: Result, + }, + PollFinished { + chat_id: ChatId, + message_id: i32, + request_id: u64, + voting: bool, + error: Option, + }, + ChatUnreadChanged { + chat_id: ChatId, + unread: bool, + }, + ChatUnreadFinished { + chat_id: ChatId, + unread: bool, + request_id: u64, + snapshot: Option, + error: Option, + }, + ReplyPreviewsLoading { + chat_id: ChatId, + request_id: u64, + }, + ReplyPreviews { + chat_id: ChatId, + request_id: u64, + messages: Vec, + unavailable: Vec, + complete: bool, + }, + ReplyPreviewsFailed { + chat_id: ChatId, + request_id: u64, + error: String, + }, + /// A primary-DC observation, scoped to this worker's account and lifetime. + Telemetry { + dc_id: Option, + latency: Option, + }, + PinnedMessagesLoading { + chat_id: ChatId, + request_id: u64, + }, + PinnedMessages { + chat_id: ChatId, + request_id: u64, + page: crate::pins::MessagePage, + }, + PinnedMessagesFailed { + chat_id: ChatId, + request_id: u64, + error: String, + }, + PinnedContext { + chat_id: ChatId, + message_id: i32, + request_id: u64, + messages: Vec, + }, + MessagePinsChanged { + chat_id: ChatId, + message_ids: Vec, + pinned: bool, + }, + MessagePinsCleared { + chat_id: ChatId, + }, + MessagePinFinished { + chat_id: ChatId, + request_id: u64, + error: Option, + }, + DialogPins(crate::pins::DialogPins), + ArchiveChanged { + chat_id: ChatId, + archived: bool, + }, + DialogPinFinished { + request_id: u64, + error: Option, + }, + AttachmentDownloadStarted { + chat_id: ChatId, + message_id: i32, + request_id: u64, + media_id: Option, + }, + CachedContext { + request_id: u64, + chat_id: ChatId, + message_id: i32, + messages: Vec, + }, + SearchResults { + request_id: u64, + page: crate::search::Page, + }, + CloudSearchLoading { + chat_id: ChatId, + request_id: u64, + }, + CloudSearchCancelled { + request_id: u64, + }, + CloudSearchResults { + request_id: u64, + chat_id: ChatId, + page: crate::cloud_search::Page, + }, + CloudSearchContext { + request_id: u64, + chat_id: ChatId, + message_id: i32, + messages: Vec, + }, + SearchFailed { + request_id: u64, + error: String, + }, + Folders(Vec), + NotificationSettingsChanged, + AlertSettingsReady { + key: crate::notifications::SettingsKey, + request_id: u64, + result: Result, + }, + ChatMuteChanged { + chat_id: ChatId, + until: i64, + }, + ChatMuteFinished { + chat_id: ChatId, + request_id: u64, + result: Result, String>, + }, + UnreadChanged { + chat_id: ChatId, + max_id: i32, + unread: u32, + }, + OlderHistory { + chat_id: ChatId, + request_id: u64, + before_id: i32, + messages: Vec, + }, + OlderHistoryFailed { + chat_id: ChatId, + request_id: u64, + error: String, + }, + CachedSnapshot { + user_name: Option, + chats: Vec, + }, + AttachmentsPrepared { + key: crate::drafts::Key, + request_id: u64, + result: Result, + }, + CachedHistory { + chat_id: ChatId, + request_id: u64, + messages: Vec, + }, + HistoryLoading { + chat_id: ChatId, + request_id: u64, + }, + SyncCheckpoint(crate::cache::SyncCursor), + CacheAccountReset { + user_id: i64, + }, + AccountIdentity { + user_id: i64, + }, + LocalDrafts { + user_id: i64, + drafts: Vec, + }, + CacheMessage(Message), + CacheInvalidated { + chat_id: Option, + }, Auth(AuthPrompt), Ready { user_name: String, }, + DialogsLoading, Dialogs(Vec), History { chat_id: ChatId, @@ -285,6 +1352,44 @@ pub enum NetworkEvent { NewMessage(Message), /// A replayed or edited message that must not change unread state. MessageUpdated(Message), + SavedReady { + request_id: u64, + result: Result, + }, + ForwardReady { + request_id: u64, + result: Result, + }, + ForwardFinished { + request_id: u64, + error: Option, + }, + MessageCopyReady { + request_id: u64, + result: Result, + }, + DeletionReady { + chat_id: ChatId, + message_id: i32, + request_id: u64, + result: Result, + }, + DeleteFinished { + chat_id: ChatId, + message_id: i32, + request_id: u64, + error: Option, + }, + EditLoaded { + chat_id: ChatId, + request_id: u64, + result: Result, + }, + EditFinished { + chat_id: ChatId, + request_id: u64, + error: Option, + }, MessageSent { local_id: i32, message: Message, @@ -297,11 +1402,24 @@ pub enum NetworkEvent { }, ReadMarked { chat_id: ChatId, + max_id: i32, + snapshot: Option, }, ReadMarkFailed { chat_id: ChatId, + max_id: i32, error: String, }, + /// Content receipts have the same account/channel ID scopes as deletion. + MessageContentsRead { + channel_id: Option, + message_ids: Vec, + }, + MentionsReadFinished { + chat_id: ChatId, + message_ids: Vec, + error: Option, + }, MessagesRead { chat_id: ChatId, max_id: i32, @@ -318,9 +1436,8 @@ pub enum NetworkEvent { AttachmentSendFailed { chat_id: ChatId, local_id: i32, - path: PathBuf, + attachment: crate::staging::Attachment, caption: String, - as_photo: bool, reply_to: Option, error: String, }, @@ -342,16 +1459,19 @@ pub enum NetworkEvent { message_ids: Vec, }, AttachmentDownloaded { + request_id: u64, chat_id: ChatId, message_id: i32, path: PathBuf, }, AttachmentDownloadFailed { + request_id: u64, chat_id: ChatId, message_id: i32, error: String, }, LinkResolved { + url: String, chat: Chat, message: Option, }, diff --git a/src/folders.rs b/src/folders.rs new file mode 100644 index 0000000..acbd9f4 --- /dev/null +++ b/src/folders.rs @@ -0,0 +1,138 @@ +//! Native Telegram dialog filters, independent of SDK and terminal types. + +use crate::model::{Chat, ChatId, ChatKind}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(default)] +#[allow(clippy::struct_excessive_bools)] +pub struct ChatMembership { + pub contact: bool, + pub bot: bool, + pub archived: bool, + pub unread_mark: bool, + pub mentions: bool, + pub mute_until: i64, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(default)] +#[allow(clippy::struct_excessive_bools)] +pub struct Folder { + pub id: i32, + pub title: String, + pub pinned: Vec, + pub include: Vec, + pub exclude: Vec, + pub contacts: bool, + pub non_contacts: bool, + pub bots: bool, + pub groups: bool, + pub broadcasts: bool, + pub exclude_muted: bool, + pub exclude_read: bool, + pub exclude_archived: bool, +} + +impl Folder { + #[must_use] + pub fn all() -> Self { + Self { + title: "All chats".to_owned(), + ..Self::default() + } + } + + #[must_use] + pub fn archive() -> Self { + Self { + id: 1, + title: "Archive".to_owned(), + ..Self::default() + } + } + + #[must_use] + pub fn contains(&self, chat: &Chat, now: i64) -> bool { + if self.id == 0 { + return !chat.membership.archived; + } + if self.id == 1 { + return chat.membership.archived; + } + if self.exclude.contains(&chat.id) { + return false; + } + if self.include.contains(&chat.id) || self.pinned.contains(&chat.id) { + return true; + } + let membership = &chat.membership; + let kind = match chat.kind { + ChatKind::Direct if membership.bot => self.bots, + ChatKind::Direct if membership.contact => self.contacts, + ChatKind::Direct => self.non_contacts, + ChatKind::Group => self.groups, + ChatKind::Channel => self.broadcasts, + }; + kind && (!self.exclude_archived || !membership.archived) + && (!self.exclude_read + || chat.unread > 0 + || membership.mentions + || membership.unread_mark) + && (!self.exclude_muted + || membership.mute_until <= now + || (membership.mentions && !membership.archived)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn explicit_membership_wins_and_muted_mentions_follow_desktop_rules() { + let mut chat = Chat { + id: 1, + title: String::new(), + kind: ChatKind::Group, + unread: 0, + last_message_id: None, + last_message: String::new(), + last_activity: None, + read_inbox_max_id: Some(0), + membership: ChatMembership { + mute_until: 500, + ..ChatMembership::default() + }, + }; + let mut folder = Folder { + id: 2, + groups: true, + exclude_muted: true, + exclude_read: true, + ..Folder::default() + }; + assert!(!folder.contains(&chat, 100)); + chat.membership.mentions = true; + assert!(folder.contains(&chat, 100)); + chat.membership.archived = true; + assert!(!folder.contains(&chat, 100)); + folder.include.push(1); + assert!(folder.contains(&chat, 100)); + folder.exclude.push(1); + assert!(!folder.contains(&chat, 100)); + folder.exclude.clear(); + folder.include.clear(); + folder.exclude_read = false; + assert!(folder.contains(&chat, 600)); + folder.exclude_archived = true; + assert!(!folder.contains(&chat, 600)); + chat.kind = ChatKind::Direct; + chat.membership.bot = true; + chat.membership.contact = true; + folder.contacts = true; + folder.exclude_archived = false; + assert!(!folder.contains(&chat, 600)); + folder.bots = true; + assert!(folder.contains(&chat, 600)); + } +} diff --git a/src/forwarding.rs b/src/forwarding.rs new file mode 100644 index 0000000..0a4e08d --- /dev/null +++ b/src/forwarding.rs @@ -0,0 +1,10 @@ +//! A reviewed, single-message native forward. Retries retain the same random ID. +use crate::model::{Chat, Message}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Plan { + pub message: Message, + pub destination: Chat, + pub revision: [u8; 32], + pub random_id: i64, +} diff --git a/src/input.rs b/src/input.rs index af542ab..dcade3a 100644 --- a/src/input.rs +++ b/src/input.rs @@ -2,7 +2,6 @@ use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; -use yazi_term::event::{KeyCode, KeyEvent, KeyEventKind, Modifiers}; const MAX_INPUT_BYTES: usize = 16 * 1024; @@ -36,64 +35,6 @@ pub enum KeyAction { AddAccount, } -/// Convert a Yazi key event into an action understood by the app. -/// -/// Key releases and modified characters (except Ctrl-C) are deliberately -/// ignored. This prevents shortcuts such as Ctrl-Q from unexpectedly entering -/// text or triggering the plain `q` navigation shortcut. -#[must_use] -pub fn key_action(event: &KeyEvent) -> Option { - if event.kind == KeyEventKind::Release { - return None; - } - - if event.modifiers.contains(Modifiers::CONTROL) - && matches!(event.code, KeyCode::Char('c' | 'C')) - { - return Some(KeyAction::Quit); - } - - if event.modifiers.contains(Modifiers::CONTROL) { - return match event.code { - KeyCode::Char('j' | 'J') => Some(KeyAction::Newline), - KeyCode::Char('a' | 'A') => Some(KeyAction::Home), - KeyCode::Char('e' | 'E') => Some(KeyAction::End), - KeyCode::Char('u' | 'U') => Some(KeyAction::Clear), - KeyCode::Char('w' | 'W') => Some(KeyAction::DeleteWord), - KeyCode::Char('l' | 'L') => Some(KeyAction::Redraw), - _ => None, - }; - } - - let unsupported_modifiers = - Modifiers::CONTROL | Modifiers::ALT | Modifiers::SUPER | Modifiers::HYPER | Modifiers::META; - if event.modifiers.intersects(unsupported_modifiers) { - return None; - } - - match event.shifted_code() { - KeyCode::Char(character) => Some(KeyAction::Character(character)), - KeyCode::Enter if event.modifiers.contains(Modifiers::SHIFT) => Some(KeyAction::Newline), - KeyCode::Enter => Some(KeyAction::Enter), - KeyCode::Escape => Some(KeyAction::Escape), - KeyCode::Tab if event.modifiers.contains(Modifiers::SHIFT) => Some(KeyAction::BackTab), - KeyCode::Tab => Some(KeyAction::Tab), - KeyCode::Backspace => Some(KeyAction::Backspace), - KeyCode::Delete => Some(KeyAction::Delete), - KeyCode::Left => Some(KeyAction::Left), - KeyCode::Right => Some(KeyAction::Right), - KeyCode::Up => Some(KeyAction::Up), - KeyCode::Down => Some(KeyAction::Down), - KeyCode::PageUp => Some(KeyAction::PageUp), - KeyCode::PageDown => Some(KeyAction::PageDown), - KeyCode::Home => Some(KeyAction::Home), - KeyCode::End => Some(KeyAction::End), - KeyCode::Fn(2) => Some(KeyAction::NextAccount), - KeyCode::Fn(3) => Some(KeyAction::AddAccount), - _ => None, - } -} - /// A UTF-8 text buffer whose cursor always rests on a grapheme boundary. /// /// `cursor()` is a byte offset, which makes slicing the returned `value()` @@ -133,6 +74,22 @@ impl TextInput { Self { value, cursor } } + /// Restore a saved byte cursor, clamping malformed offsets to a complete + /// grapheme boundary inside the bounded input. + #[must_use] + pub fn with_cursor(value: impl Into, cursor: usize) -> Self { + let mut input = Self::from_value(value); + input.cursor = input + .value + .grapheme_indices(true) + .map(|(index, _)| index) + .chain(std::iter::once(input.value.len())) + .take_while(|&index| index <= cursor) + .last() + .unwrap_or(0); + input + } + #[must_use] pub fn value(&self) -> &str { &self.value @@ -310,68 +267,7 @@ fn boundary_at_or_after(value: &str, offset: usize) -> usize { #[cfg(test)] mod tests { - use yazi_term::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, Modifiers}; - - use super::{KeyAction, TextInput, key_action}; - - #[test] - fn ctrl_c_is_quit_and_plain_q_remains_a_character() { - assert_eq!( - key_action(&KeyEvent::new(KeyCode::Char('c'), Modifiers::CONTROL)), - Some(KeyAction::Quit) - ); - assert_eq!( - key_action(&KeyEvent::new(KeyCode::Char('q'), Modifiers::empty())), - Some(KeyAction::Character('q')) - ); - } - - #[test] - fn function_keys_select_account_actions() { - assert_eq!( - key_action(&KeyEvent::new(KeyCode::Fn(2), Modifiers::empty())), - Some(KeyAction::NextAccount) - ); - assert_eq!( - key_action(&KeyEvent::new(KeyCode::Fn(3), Modifiers::empty())), - Some(KeyAction::AddAccount) - ); - } - - #[test] - fn enhanced_keys_preserve_shifted_shortcuts_and_newlines() { - let key = KeyEvent { - code: KeyCode::Char('/'), - shifted: Some('?'), - modifiers: Modifiers::SHIFT, - ..KeyEvent::default() - }; - assert_eq!(key_action(&key), Some(KeyAction::Character('?'))); - assert_eq!( - key_action(&KeyEvent::new(KeyCode::Tab, Modifiers::SHIFT)), - Some(KeyAction::BackTab) - ); - assert_eq!( - key_action(&KeyEvent::new(KeyCode::Enter, Modifiers::SHIFT)), - Some(KeyAction::Newline) - ); - } - - #[test] - fn releases_and_other_control_combinations_are_ignored() { - let release = KeyEvent { - code: KeyCode::Enter, - modifiers: Modifiers::empty(), - kind: KeyEventKind::Release, - state: KeyEventState::empty(), - ..KeyEvent::default() - }; - assert_eq!(key_action(&release), None); - assert_eq!( - key_action(&KeyEvent::new(KeyCode::Char('x'), Modifiers::CONTROL)), - None - ); - } + use super::TextInput; #[test] fn edits_combining_graphemes_as_one_character() { @@ -424,18 +320,6 @@ mod tests { assert_eq!(input.cursor(), input.value().len()); } - #[test] - fn control_bindings_are_normalized() { - assert_eq!( - key_action(&KeyEvent::new(KeyCode::Char('j'), Modifiers::CONTROL)), - Some(KeyAction::Newline) - ); - assert_eq!( - key_action(&KeyEvent::new(KeyCode::Char('w'), Modifiers::CONTROL)), - Some(KeyAction::DeleteWord) - ); - } - #[test] fn deletes_a_unicode_word_and_preceding_space() { let mut input = TextInput::from_value("hello 世界 "); diff --git a/src/invites.rs b/src/invites.rs new file mode 100644 index 0000000..fbfb2ad --- /dev/null +++ b/src/invites.rs @@ -0,0 +1,88 @@ +//! Invite links are capabilities. Keep hashes out of logs and persisted UI state. +use crate::model::Chat; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Preview { + pub title: String, + pub about: String, + pub participants: Option, + pub request_needed: bool, + pub warning: Option, + pub blocked: Option, + pub joined: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum Outcome { + Joined(Chat), + Requested, + Verification, +} + +/// Recognize official web and tg invite forms using the shared URL parser. +#[must_use] +pub fn hash(value: &str) -> Option { + let value = value.trim(); + let normalized = if value.starts_with("t.me/") || value.starts_with("telegram.me/") { + format!("https://{value}") + } else { + value.to_owned() + }; + let url = url::Url::parse(&normalized).ok()?; + if !url.username().is_empty() || url.password().is_some() || url.port().is_some() { + return None; + } + let hash = if url.scheme() == "tg" && url.host_str() == Some("join") { + let values = url + .query_pairs() + .filter(|(key, _)| key == "invite") + .collect::>(); + if values.len() != 1 { + return None; + } + values[0].1.to_string() + } else if matches!(url.scheme(), "http" | "https") + && matches!( + url.host_str(), + Some("t.me" | "www.t.me" | "telegram.me" | "www.telegram.me") + ) + { + url.path() + .strip_prefix("/+") + .or_else(|| url.path().strip_prefix("/joinchat/"))? + .to_owned() + } else { + return None; + }; + (!hash.is_empty() + && hash.len() <= 256 + && hash + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')) + .then_some(hash) +} + +#[cfg(test)] +mod tests { + #[test] + fn invite_links_accept_official_forms_and_reject_ambiguous_targets() { + for value in [ + "https://t.me/+Ab_c-09", + "t.me/joinchat/Ab_c-09", + "tg://join?invite=Ab_c-09", + ] { + assert_eq!(super::hash(value).as_deref(), Some("Ab_c-09")); + } + for value in [ + "https://t.me.evil/+Ab", + "https://t.me@evil/+Ab", + "https://t.me/+Ab/more", + "tg://join?invite=a&invite=b", + "https://t.me/+", + "https://t.me/joinchat-extra/Ab", + "https://t.me/%2bAb", + ] { + assert_eq!(super::hash(value), None, "{value}"); + } + } +} diff --git a/src/keymap.rs b/src/keymap.rs new file mode 100644 index 0000000..4e8f3ba --- /dev/null +++ b/src/keymap.rs @@ -0,0 +1,902 @@ +//! Declarative Lua configuration and contextual chords. Yazi owns terminal key +//! parsing/normalization; this module only maps those keys to Termgram actions. + +use crate::actions::Action; + +use anyhow::{Context as _, Result, bail}; +use mlua::{HookTriggers, Lua, LuaOptions, LuaSerdeExt, StdLib, VmState}; +use serde::Deserialize; +use std::{ + collections::BTreeMap, + path::Path, + str::FromStr, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + time::{Duration, Instant}, +}; +use yazi_config::keymap::Key; +use yazi_term::event::{KeyEvent, KeyEventKind}; + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum Context { + Global, + Chats, + Conversation, + Compose, + Edit, + Forward, + Poll, + Reactions, + Command, + Attachments, + Input, + Overlay, + Preview, + Search, + Pins, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct BindingSpec { + context: Context, + on: Vec, + run: String, + #[serde(default = "default_count")] + count: usize, + #[serde(default)] + desc: Option, +} + +const fn default_count() -> usize { + 1 +} + +#[derive(Default, Deserialize)] +#[serde(default, deny_unknown_fields)] +struct Configuration { + keymap: Vec, + chats: BTreeMap, + ghost_text: Option, + nerd_font: bool, + colors: crate::appearance::Colors, + statusline: crate::statusline::Configuration, + sidebar: crate::sidebar::Configuration, + messages: crate::transcript::Configuration, + attachments: crate::staging::Configuration, + notifications: crate::notifications::Configuration, +} + +#[derive(Clone, Debug)] +struct Binding { + context: Context, + keys: Vec, + run: Action, + count: usize, + description: String, +} + +#[derive(Clone, Debug)] +pub struct Keymap { + bindings: Vec, + pub chats: BTreeMap, + pub ghost_text: String, + pub nerd_font: bool, + pub colors: crate::appearance::Colors, + pub statusline: crate::statusline::Configuration, + pub sidebar: crate::sidebar::Configuration, + pub messages: crate::transcript::Configuration, + pub attachments: crate::staging::Configuration, + pub notifications: crate::notifications::Configuration, + pending: Vec, + count: usize, + context: Option, + last_key: Option, +} + +#[derive(Debug, Eq, PartialEq)] +pub enum Resolution { + Action { run: Action, count: usize }, + Pending(String), + Unbound, +} + +impl Default for Keymap { + #[allow(clippy::too_many_lines)] + fn default() -> Self { + let mut result = Self { + bindings: Vec::new(), + chats: BTreeMap::new(), + colors: crate::appearance::Colors::default(), + statusline: crate::statusline::Configuration::default(), + sidebar: crate::sidebar::Configuration::default(), + messages: crate::transcript::Configuration::default(), + attachments: crate::staging::Configuration::default(), + notifications: crate::notifications::Configuration::default(), + ghost_text: "{send} to send".to_owned(), + nerd_font: false, + pending: Vec::new(), + count: 0, + context: None, + last_key: None, + }; + for (context, definitions) in [ + ( + Context::Global, + &[ + ("", "quit"), + ("", "redraw"), + ("", "next_account"), + ("", "add_account"), + ("", "toggle_sidebar"), + ][..], + ), + ( + Context::Chats, + &[ + (":", "command"), + ("j", "down"), + ("k", "up"), + ("", "down"), + ("", "up"), + ("", "open"), + ("", "open"), + ("", "focus"), + ("", "focus"), + ("i", "compose"), + ("c", "chat_color"), + ("p", "pin"), + ("e", "archive"), + ("", "pin_up"), + ("", "pin_down"), + ("C", "folder_color"), + ("/", "filter"), + ("", "search"), + ("]", "folder_next"), + ("[", "folder_previous"), + ("q", "quit"), + ("?", "help"), + ("s", "settings"), + ("a", "accounts"), + ("", "page_up"), + ("", "page_down"), + ("G", "latest"), + ("g g", "oldest"), + ("g i", "chat_info"), + ("g s", "saved_messages"), + ("g m", "mentions"), + ("g r", "mark_read"), + ("g U", "mark_unread"), + ("", "refresh"), + ][..], + ), + ( + Context::Conversation, + &[ + (":", "command"), + ("e", "edit_message"), + ("d", "delete_message"), + ("f", "forward_message"), + ("v", "poll"), + ("g e", "reactions"), + ("g S", "save_message"), + ("y", "copy_text"), + ("Y", "copy_link"), + ("p", "pin"), + ("P", "pins"), + ("c", "chat_color"), + ("/", "search"), + ("j", "message_down"), + ("k", "message_up"), + ("[", "message_up"), + ("]", "message_down"), + ("", "down"), + ("", "up"), + ("", "page_up"), + ("", "page_down"), + ("G", "latest"), + ("", "latest"), + ("g g", "oldest"), + ("g i", "chat_info"), + ("g s", "saved_messages"), + ("g m", "mentions"), + ("", "oldest"), + ("g u", "first_unread"), + ("g r", "mark_read"), + ("g U", "mark_unread"), + ("i", "compose"), + ("", "open"), + ("", "cancel"), + ("", "cancel"), + ("", "focus"), + ("", "focus"), + ("q", "quit"), + ("?", "help"), + ("s", "settings"), + ("a", "accounts"), + ("o", "preview"), + ("g n", "next_action"), + ("g p", "previous_action"), + ("O", "reveal"), + ("R", "reply"), + ("r", "reply_target"), + ("l", "open_link"), + ("", "refresh"), + ][..], + ), + ( + Context::Preview, + &[ + ("", "cancel"), + ("q", "cancel"), + ("i", "reply"), + ("O", "reveal"), + ("o", "preview"), + ][..], + ), + ( + Context::Forward, + &[ + ("", "send"), + ("", "cancel"), + ("", "cancel"), + ][..], + ), + ( + Context::Edit, + &[ + ("", "send"), + ("", "newline"), + ("", "newline"), + ("", "cancel"), + ("", "cancel"), + ("", "discard_edit"), + ][..], + ), + ( + Context::Compose, + &[ + ("", "attachments"), + ("", "send"), + ("", "newline"), + ("", "newline"), + ("", "cancel"), + ][..], + ), + ( + Context::Input, + &[ + ("", "open"), + ("", "cancel"), + ("", "focus"), + ("", "focus"), + ("", "up"), + ("", "down"), + ][..], + ), + ( + Context::Reactions, + &[ + ("", "cancel"), + ("q", "cancel"), + ("", "open"), + ("", "open"), + ("j", "down"), + ("k", "up"), + ("", "up"), + ("", "down"), + ("", "page_up"), + ("", "page_down"), + ("", "home"), + ("", "end"), + ("u", "clear_reactions"), + ("", "refresh"), + ][..], + ), + ( + Context::Poll, + &[ + ("", "cancel"), + ("q", "cancel"), + ("j", "down"), + ("k", "up"), + ("", "up"), + ("", "down"), + ("", "page_up"), + ("", "page_down"), + ("", "home"), + ("", "end"), + ("", "toggle_poll_answer"), + ("", "toggle_poll_answer"), + ("", "send"), + ("u", "retract_vote"), + ("", "refresh"), + ("s", "spoilers"), + ][..], + ), + ( + Context::Overlay, + &[ + ("", "refresh"), + ("", "open"), + ("", "open"), + ("", "cancel"), + ("?", "cancel"), + ("j", "down"), + ("k", "up"), + ("", "up"), + ("", "down"), + ][..], + ), + ( + Context::Search, + &[ + ("", "open"), + ("", "cancel"), + ("", "search_scope"), + ("", "search_more"), + ("", "search_previous"), + ("", "search_query"), + ("", "refresh"), + ("", "up"), + ("", "down"), + ("", "page_up"), + ("", "page_down"), + ][..], + ), + ( + Context::Pins, + &[ + ("", "open"), + ("", "cancel"), + ("?", "cancel"), + ("j", "down"), + ("k", "up"), + ("", "down"), + ("", "up"), + ("", "page_up"), + ("", "page_down"), + ("", "pins_more"), + ("", "pins_previous"), + ("", "refresh"), + ("p", "pin"), + ("U", "unpin_all"), + ][..], + ), + ] { + for &(keys, run) in definitions { + result + .insert(BindingSpec { + context, + on: keys.split(' ').map(str::to_owned).collect(), + run: run.to_owned(), + count: 1, + desc: None, + }) + .expect("valid builtin binding"); + } + } + for (key, run) in [ + ("", "cancel"), + ("", "cancel"), + ("", "open"), + ("", "complete_next"), + ("", "complete_next"), + ("", "complete_previous"), + ("", "complete_previous"), + ("", "history_previous"), + ("", "history_next"), + ] { + result + .insert(BindingSpec { + context: Context::Command, + on: vec![key.to_owned()], + run: run.to_owned(), + count: 1, + desc: None, + }) + .expect("valid command binding"); + } + for (key, run) in [ + ("", "cancel"), + ("q", "cancel"), + ("?", "help"), + ("i", "compose"), + ("j", "down"), + ("k", "up"), + ("", "up"), + ("", "down"), + ("a", "attach"), + ("d", "remove_attachment"), + ("", "remove_attachment"), + ("p", "attachment_format"), + ("o", "preview"), + ("", "preview"), + ("O", "reveal"), + ] { + result + .insert(BindingSpec { + context: Context::Attachments, + on: vec![key.to_owned()], + run: run.to_owned(), + count: 1, + desc: None, + }) + .expect("valid attachment binding"); + } + for context in [ + Context::Compose, + Context::Conversation, + Context::Attachments, + ] { + for key in ["", ""] { + result + .insert(BindingSpec { + context, + on: vec![key.to_owned()], + run: "paste_clipboard".to_owned(), + count: 1, + desc: None, + }) + .expect("valid clipboard binding"); + } + } + for context in [ + Context::Compose, + Context::Edit, + Context::Input, + Context::Search, + Context::Command, + ] { + for (key, run) in [ + ("", "home"), + ("", "end"), + ("", "clear"), + ("", "delete_word"), + ("", "home"), + ("", "end"), + ("", "left"), + ("", "right"), + ("", "backspace"), + ("", "delete"), + ] { + result + .insert(BindingSpec { + context, + on: vec![key.to_owned()], + run: run.to_owned(), + count: 1, + desc: None, + }) + .expect("valid editor binding"); + } + } + result + } +} + +impl Keymap { + /// # Errors + /// Returns a recoverable configuration error. No partially loaded bindings + /// are installed, and execution/memory limits keep startup responsive. + pub fn load(path: &Path) -> Result { + match Self::reload(path) { + Err(error) + if error + .downcast_ref::() + .is_some_and(|e| e.kind() == std::io::ErrorKind::NotFound) => + { + Ok(Self::default()) + } + result => result, + } + } + + /// # Errors + /// Reload is strict: missing, unreadable or invalid files preserve the old snapshot. + pub fn reload(path: &Path) -> Result { + use std::io::Read as _; + anyhow::ensure!( + std::fs::metadata(path)?.is_file(), + "Lua configuration must be a regular file" + ); + let file = std::fs::File::open(path) + .with_context(|| format!("could not open {}", path.display()))?; + let mut source = String::new(); + file.take(64 * 1024 + 1).read_to_string(&mut source)?; + Self::parse(&source) + .with_context(|| format!("invalid Lua configuration at {}", path.display())) + } + + /// # Errors + /// Returns Lua, action, key or chord-conflict errors. + pub fn parse(source: &str) -> Result { + if source.len() > 64 * 1024 { + bail!("Lua configuration exceeds 64 KiB"); + } + let lua = Lua::new_with( + StdLib::TABLE | StdLib::STRING | StdLib::MATH | StdLib::UTF8, + LuaOptions::default(), + )?; + lua.set_memory_limit(8 * 1024 * 1024)?; + let remaining = Arc::new(AtomicUsize::new(100)); + lua.set_hook( + HookTriggers::new().every_nth_instruction(10_000), + move |_, _| { + if remaining.fetch_sub(1, Ordering::Relaxed) == 0 { + return Err(mlua::Error::runtime( + "configuration instruction limit exceeded", + )); + } + Ok(VmState::Continue) + }, + )?; + let configuration: Configuration = + lua.from_value(lua.load(source).set_name("config.lua").eval()?)?; + configuration.statusline.validate()?; + configuration.sidebar.validate()?; + configuration.messages.validate()?; + configuration.notifications.validate()?; + let mut keymap = Self { + chats: configuration.chats, + colors: configuration.colors, + nerd_font: configuration.nerd_font, + statusline: configuration.statusline, + sidebar: configuration.sidebar, + messages: configuration.messages, + attachments: configuration.attachments, + notifications: configuration.notifications, + ..Self::default() + }; + if let Some(text) = configuration.ghost_text { + keymap.ghost_text = text; + } + for binding in configuration.keymap { + keymap.insert(binding)?; + } + for (index, binding) in keymap.bindings.iter().enumerate() { + for other in &keymap.bindings[index + 1..] { + if binding.context == other.context + && (binding.keys.starts_with(&other.keys) + || other.keys.starts_with(&binding.keys)) + { + bail!( + "ambiguous key prefix: {} / {}", + display_keys(&binding.keys), + display_keys(&other.keys) + ); + } + } + } + Ok(keymap) + } + + fn insert(&mut self, spec: BindingSpec) -> Result<()> { + if spec.on.is_empty() || spec.on.len() > 4 { + bail!("a binding must contain one to four keys"); + } + if !(1..=9999).contains(&spec.count) { + bail!("binding count must be between 1 and 9999"); + } + if spec.count != 1 + && !matches!( + spec.run.as_str(), + "up" | "down" | "message_up" | "message_down" | "page_up" | "page_down" + ) + { + bail!("count is only supported for navigation actions"); + } + let keys = spec + .on + .iter() + .map(|key| { + if !key.starts_with('<') && key.chars().count() != 1 { + bail!("use separate keys for a chord: {key}"); + } + Key::from_str(key) + }) + .collect::>>()?; + let action = Action::parse(&spec.run) + .ok_or_else(|| anyhow::anyhow!("unknown action: {}", spec.run))?; + if let Action::Jump(alias) = &action + && !self.chats.contains_key(alias) + { + bail!("unknown chat alias: {alias}"); + } + self.bindings + .retain(|binding| !(binding.context == spec.context && binding.keys == keys)); + if action == Action::Noop { + return Ok(()); + } + self.bindings.push(Binding { + context: spec.context, + keys, + description: spec.desc.unwrap_or_else(|| { + let action = action.description().to_owned(); + if spec.count == 1 { + action + } else { + format!("{action} ×{}", spec.count) + } + }), + run: action, + count: spec.count, + }); + Ok(()) + } + + pub fn reset(&mut self) { + self.pending.clear(); + self.count = 0; + self.last_key = None; + } + + #[must_use] + pub fn pending(&self) -> bool { + self.last_key.is_some() + } + + pub fn expire(&mut self) -> bool { + if self + .last_key + .is_some_and(|last| last.elapsed() > Duration::from_secs(1)) + { + self.reset(); + true + } else { + false + } + } + + pub fn feed(&mut self, context: Context, event: &KeyEvent) -> Resolution { + if event.kind == KeyEventKind::Release { + return Resolution::Unbound; + } + self.expire(); + if self.context != Some(context) { + self.reset(); + self.context = Some(context); + } + let Ok(key) = Key::try_from(event.clone()) else { + return Resolution::Unbound; + }; + if key.code == yazi_term::event::KeyCode::Escape && self.pending() { + self.reset(); + return Resolution::Pending(String::new()); + } + if matches!(context, Context::Chats | Context::Conversation) + && self.pending.is_empty() + && let Ok(digit) = key.to_string().parse::() + && (digit != 0 || self.count != 0) + && (self.count != 0 + || !self.bindings.iter().any(|binding| { + (binding.context == Context::Global || binding.context == context) + && binding.keys == [key] + })) + { + self.count = (self.count * 10 + digit).min(9999); + self.last_key = Some(Instant::now()); + return Resolution::Pending(self.count.to_string()); + } + self.pending.push(key); + for priority in [context, Context::Global] { + if let Some(binding) = self + .bindings + .iter() + .find(|binding| binding.context == priority && binding.keys == self.pending) + { + let result = Resolution::Action { + run: binding.run.clone(), + count: self.count.max(1).saturating_mul(binding.count).min(9999), + }; + self.reset(); + return result; + } + let candidates = self + .bindings + .iter() + .filter(|binding| { + binding.context == priority && binding.keys.starts_with(&self.pending) + }) + .collect::>(); + if !candidates.is_empty() { + self.last_key = Some(Instant::now()); + return Resolution::Pending( + candidates + .iter() + .map(|binding| { + format!("{} {}", display_keys(&binding.keys), binding.description) + }) + .collect::>() + .join(" · "), + ); + } + } + self.reset(); + Resolution::Unbound + } + + #[must_use] + pub fn hint(&self, context: Context, run: &str) -> String { + let Some(action) = Action::parse(run) else { + return "unbound".to_owned(); + }; + self.bindings + .iter() + .find(|binding| binding.context == context && binding.run == action) + .or_else(|| { + self.bindings.iter().find(|binding| { + binding.context == Context::Global + && binding.run == action + && !self.bindings.iter().any(|local| { + local.context == context + && (local.keys.starts_with(&binding.keys) + || binding.keys.starts_with(&local.keys)) + }) + }) + }) + .map_or_else( + || "unbound".to_owned(), + |binding| display_keys(&binding.keys), + ) + } + + #[must_use] + pub fn help(&self) -> Vec { + self.bindings + .iter() + .filter(|binding| binding.run != Action::Noop) + .map(|binding| { + format!( + "{:?} {:18} {}", + binding.context, + display_keys(&binding.keys), + binding.description + ) + }) + .collect() + } +} + +fn display_keys(keys: &[Key]) -> String { + keys.iter() + .map(ToString::to_string) + .collect::>() + .join(" ") +} + +#[cfg(test)] +mod tests { + use super::{Action, Context, Keymap, Resolution}; + use yazi_term::event::{KeyCode, KeyEvent, Modifiers}; + + #[test] + fn lua_overrides_hints_and_keeps_counts_out_of_the_editor() { + let mut map = Keymap::parse( + r"return { + chats = { work = -1000000000042 }, + keymap = { + { context='compose', on={''}, run='newline' }, + { context='compose', on={''}, run='send' }, + { context='conversation', on={'g','w'}, run='jump work' }, + } + }", + ) + .unwrap(); + assert_eq!(map.hint(Context::Compose, "send"), ""); + let key = |c| KeyEvent::new(KeyCode::Char(c), Modifiers::empty()); + assert!(matches!( + map.feed(Context::Conversation, &key('2')), + Resolution::Pending(_) + )); + assert!(matches!( + map.feed(Context::Conversation, &key('0')), + Resolution::Pending(_) + )); + assert_eq!( + map.feed(Context::Conversation, &key('k')), + Resolution::Action { + run: Action::MessageUp, + count: 20 + } + ); + assert_eq!(map.feed(Context::Compose, &key('2')), Resolution::Unbound); + assert!(matches!( + map.feed(Context::Conversation, &key('g')), + Resolution::Pending(_) + )); + assert_eq!( + map.feed(Context::Conversation, &key('w')), + Resolution::Action { + run: Action::Jump("work".to_owned()), + count: 1 + } + ); + assert!( + map.help() + .iter() + .any(|line| line.contains("") && line.ends_with("Send the current draft")) + ); + } + + #[test] + fn invalid_configuration_is_rejected_without_hanging() { + assert!(Keymap::parse("return {sidebar={width=0}}").is_err()); + assert!(Keymap::parse("return {sidebar={time_color='invisible'}}").is_err()); + assert!(Keymap::parse("return {statusline={right={'mode','mode'}}}").is_err()); + assert!(Keymap::parse("return {statusline={right={'imaginary'}}}").is_err()); + assert!( + !Keymap::parse("return {statusline={enabled=false}} ") + .unwrap() + .statusline + .measures_latency() + ); + assert!( + !Keymap::parse("return {statusline={left={'mode'},right={'dc'}}}") + .unwrap() + .statusline + .measures_latency() + ); + assert!(Keymap::parse("while true do end").is_err()); + assert!( + Keymap::parse("return { keymap={{context='conversation',on={'g'},run='latest'}} }") + .is_err() + ); + assert!( + Keymap::parse("return { keymap={{context='conversation',on={'x'},run='unknown'}} }") + .is_err() + ); + for count in [0, 10_000] { + assert!(Keymap::parse(&format!( + "return {{keymap={{{{context='conversation',on={{''}},run='message_up',count={count}}}}}}}" + )).is_err()); + } + } + + #[test] + fn fixed_counts_and_global_hints_follow_context_precedence() { + let mut map = Keymap::parse( + r"return {keymap={ + {context='conversation',on={''},run='message_up',count=20}, + {context='global',on={''},run='send'}, + {context='global',on={'g','w'},run='settings'}, + {context='compose',on={''},run='newline'}, + {context='input',on={''},run='clear'}, + }}", + ) + .unwrap(); + assert_eq!(map.hint(Context::Compose, "send"), ""); + assert_eq!(map.hint(Context::Input, "send"), "unbound"); + assert_eq!(map.hint(Context::Conversation, "settings"), "s"); + assert!(matches!( + map.feed( + Context::Conversation, + &KeyEvent::new(KeyCode::Char('2'), Modifiers::empty()) + ), + Resolution::Pending(_) + )); + assert_eq!( + map.feed( + Context::Conversation, + &KeyEvent::new(KeyCode::Char('u'), Modifiers::CONTROL) + ), + Resolution::Action { + run: Action::MessageUp, + count: 40 + } + ); + assert_eq!( + map.feed( + Context::Conversation, + &KeyEvent::new(KeyCode::Char('u'), Modifiers::CONTROL) + ), + Resolution::Action { + run: Action::MessageUp, + count: 20 + } + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index 9f1a465..87a8fd5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,19 +1,43 @@ +pub mod actions; pub mod app; +pub mod appearance; +pub mod cache; +pub mod chat_discovery; +pub mod chat_info; +pub mod cloud_search; +pub mod commands; pub mod config; +pub mod deletion; +pub mod drafts; +pub mod editing; +pub mod entities; pub mod event; +pub mod folders; +pub mod forwarding; pub mod input; +pub mod invites; +pub mod keymap; pub mod media; pub mod model; +pub mod notifications; +pub mod pins; +pub mod polls; +pub mod reactions; +pub mod search; +pub mod sidebar; +pub mod staging; +pub mod statusline; pub mod telegram; pub mod terminal; +pub mod transcript; pub mod ui; pub mod update; -/// Version embedded into distributable binaries by CI. Source builds fall -/// back to the package's base development version. +/// CI can override the version. Git source installs use the same commit-height +/// versioning; archives without Git metadata use the package development version. pub const VERSION: &str = match option_env!("TERMGRAM_BUILD_VERSION") { Some(version) => version, - None => env!("CARGO_PKG_VERSION"), + None => env!("TERMGRAM_SOURCE_VERSION"), }; /// Human-readable build identity, separate from the updater's semantic version. @@ -73,3 +97,7 @@ mod tests { ); } } + +mod clipboard; + +pub mod read_state; diff --git a/src/main.rs b/src/main.rs index 3c81dc9..09f453c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,6 +17,7 @@ use tokio::time::{sleep, timeout}; use yazi_term::event::Event; enum RuntimeEvent { + Configuration(Result), Terminal(Option>), Network(Box>), UpdateCheck { @@ -25,6 +26,9 @@ enum RuntimeEvent { }, ShutdownSignal(io::Result<()>), Preview(Result>), + DraftError(String), + Clipboard(termgram::terminal::clipboard::Activity), + Notification(termgram::notifications::delivery::Activity), Tick, } @@ -82,39 +86,112 @@ async fn main() -> Result<()> { let mut preview = PreviewRenderer::new(); let (mut app, settings) = load_app_settings(); let mut startup_warning = replacement_warning; + let lua_path = std::env::var_os("TERMGRAM_CONFIG") + .map(std::path::PathBuf::from) + .or_else(|| { + Settings::path() + .ok() + .map(|path| path.with_file_name("config.lua")) + }); + app.configuration.path.clone_from(&lua_path); + if let Some(path) = lua_path { + match termgram::keymap::Keymap::load(&path) { + Ok(keymap) => app.keymap = keymap, + Err(error) => { + app.configuration.error = Some(termgram::model::sanitize_terminal_line(&format!( + "{error:#}" + ))); + startup_warning = Some(format!("{error:#}; using default bindings")); + } + } + } + if let Err(error) = app.load_appearance() { + startup_warning = Some(format!("Could not load appearance preferences: {error:#}")); + } + if let Err(error) = app.load_navigation() { + startup_warning = Some(format!("Could not load navigation preferences: {error:#}")); + } if let Some(warning) = &startup_warning { app.handle_network(NetworkEvent::Error(warning.clone())); } let mut update_check = spawn_update_check(settings); let mut update_preferences = settings; - let (mut commands, mut events, mut worker, base_config) = match Config::load() { - Ok(config) => match config.for_account(settings.active_account) { - Ok(selected) => { - let TelegramHandle { - commands, - events, - task, - } = telegram::spawn(selected); - (Some(commands), Some(events), Some(task), Some(config)) - } + let mut measurement = None; + let (mut commands, mut events, mut worker, mut base_config, mut active_chat) = + match Config::load().map(|mut config| { + config.measure_latency = app.keymap.statusline.measures_latency(); + config + }) { + Ok(config) => match config.for_account(settings.active_account) { + Ok(selected) => { + let TelegramHandle { + measure_latency, + commands, + events, + task, + active_chat, + } = telegram::spawn(selected); + measurement = Some(measure_latency); + ( + Some(commands), + Some(events), + Some(task), + Some(config), + Some(active_chat), + ) + } + Err(error) => { + app.handle_network(NetworkEvent::Fatal(format!("{error:#}"))); + (None, None, None, Some(config), None) + } + }, Err(error) => { app.handle_network(NetworkEvent::Fatal(format!("{error:#}"))); - (None, None, None, Some(config)) + (None, None, None, None, None) } - }, - Err(error) => { - app.handle_network(NetworkEvent::Fatal(format!("{error:#}"))); - (None, None, None, None) - } - }; + }; + let mut draft_writer = base_config.clone().map(termgram::drafts::Writer::spawn); let mut shutdown_signal = Box::pin(wait_for_shutdown_signal()); let mut pending_commands = VecDeque::new(); + let mut clipboard = termgram::terminal::clipboard::Broker::default(); + let mut notifications = termgram::notifications::delivery::Dispatcher::default(); let mut redraw = true; let mut runtime_error: Option = None; + let mut configuration_load = None; loop { + if let Some(path) = app.take_config_reload() { + configuration_load = Some(tokio::task::spawn_blocking(move || { + termgram::keymap::Keymap::reload(&path).map_err(|e| format!("{e:#}")) + })); + } + if let Some(measurement) = &measurement { + let enabled = app.keymap.statusline.measures_latency(); + measurement.send_if_modified(|current| { + let changed = *current != enabled; + *current = enabled; + changed + }); + } + if let Some(config) = &mut base_config { + config.measure_latency = app.keymap.statusline.measures_latency(); + } + if let Err(error) = + terminal.set_clipboard_enabled(app.keymap.attachments.terminal_clipboard) + { + runtime_error = + Some(anyhow::Error::from(error).context("terminal clipboard mode failed")); + break; + } + app.configuration.terminal_clipboard = terminal.clipboard_supported(); + clipboard.configure(terminal.clipboard_supported(), &mut app); + if let Some(writer) = &draft_writer + && let Some(snapshot) = app.take_draft_snapshot() + { + writer.queue(snapshot); + } if redraw && let Err(error) = draw_interface(&mut terminal, &mut preview, &mut app) { runtime_error = Some(error); break; @@ -127,10 +204,25 @@ async fn main() -> Result<()> { let missing_previews = app .media_slots .iter() - .map(|slot| (slot.chat_id, slot.message_id)) + .filter_map(|slot| match slot.source { + termgram::media::MediaSource::Message { + chat_id, + message_id, + } => Some((chat_id, message_id)), + termgram::media::MediaSource::File(_) => None, + }) .filter(|key| !app.media_previews.contains_key(key)) .collect::>(); - let outgoing = app.request_visible_media(); + let mut outgoing = app.request_visible_media(); + outgoing.extend(app.request_visible_replies()); + outgoing.extend(app.request_visible_read()); + outgoing.extend(app.request_visible_polls()); + outgoing.extend(app.request_visible_reactions()); + outgoing.extend(app.request_visible_chat_info()); + outgoing.extend(app.request_alert_settings()); + notifications.flush(&mut app); + let outgoing = clipboard.route(&mut app, outgoing); + clipboard.flush(&mut app); dispatch(&mut app, &mut commands, &mut pending_commands, outgoing); if missing_previews .iter() @@ -144,7 +236,11 @@ async fn main() -> Result<()> { let runtime_event = if let Some(network) = events.as_mut() { tokio::select! { event = terminal.next_event() => RuntimeEvent::Terminal(event), + result = wait_for_configuration(&mut configuration_load) => RuntimeEvent::Configuration(result), result = preview.finished() => RuntimeEvent::Preview(result), + error = wait_for_draft_error(&mut draft_writer) => RuntimeEvent::DraftError(error), + activity = clipboard.next_activity() => RuntimeEvent::Clipboard(activity), + activity = notifications.next_activity(app.next_alert_deadline().into_iter().chain(app.next_poll_deadline()).chain(app.next_reaction_deadline()).chain(app.next_chat_info_deadline()).min()) => RuntimeEvent::Notification(activity), event = network.recv() => RuntimeEvent::Network(Box::new(event)), (channel, result) = wait_for_update_check(&mut update_check) => RuntimeEvent::UpdateCheck { channel, result }, result = &mut shutdown_signal => RuntimeEvent::ShutdownSignal(result), @@ -153,7 +249,11 @@ async fn main() -> Result<()> { } else { tokio::select! { event = terminal.next_event() => RuntimeEvent::Terminal(event), + result = wait_for_configuration(&mut configuration_load) => RuntimeEvent::Configuration(result), result = preview.finished() => RuntimeEvent::Preview(result), + error = wait_for_draft_error(&mut draft_writer) => RuntimeEvent::DraftError(error), + activity = clipboard.next_activity() => RuntimeEvent::Clipboard(activity), + activity = notifications.next_activity(app.next_alert_deadline().into_iter().chain(app.next_poll_deadline()).chain(app.next_reaction_deadline()).chain(app.next_chat_info_deadline()).min()) => RuntimeEvent::Notification(activity), (channel, result) = wait_for_update_check(&mut update_check) => RuntimeEvent::UpdateCheck { channel, result }, result = &mut shutdown_signal => RuntimeEvent::ShutdownSignal(result), () = animation_tick => RuntimeEvent::Tick, @@ -169,6 +269,9 @@ async fn main() -> Result<()> { RuntimeEvent::Terminal(Some(Ok(Event::Paste(text)))) => { app.update(AppEvent::Paste(text)) } + RuntimeEvent::Terminal(Some(Ok(Event::Clipboard(event)))) => { + clipboard.event(&mut app, event) + } RuntimeEvent::Terminal(Some(Ok(Event::FocusIn))) => { app.update(AppEvent::TerminalFocus(true)) } @@ -179,6 +282,12 @@ async fn main() -> Result<()> { preview.invalidate(); app.handle_action(termgram::input::KeyAction::Redraw) } + RuntimeEvent::Terminal(Some(Ok(Event::Report( + yazi_term::event::Report::BackgroundColor(rgb), + )))) => { + app.terminal_background = Some(rgb.map(|channel| (channel >> 8) as u8)); + Vec::new() + } RuntimeEvent::Terminal(Some(Ok(_))) => Vec::new(), RuntimeEvent::Terminal(Some(Err(error))) => { runtime_error = Some(anyhow::Error::from(error).context("terminal input failed")); @@ -242,19 +351,70 @@ async fn main() -> Result<()> { RuntimeEvent::Preview(result) => { match result { Ok(failures) => { - for (chat_id, message_id, error) in failures { - app.media_preview_failed(chat_id, message_id, &error); + for failure in failures { + match failure.source { + termgram::media::MediaSource::Message { + chat_id, + message_id, + } => { + app.media_preview_failed(chat_id, message_id, &failure.error); + } + termgram::media::MediaSource::File(path) => { + app.attachment_draft.preview = false; + app.status_message = Some(format!( + "Preview {}: {}", + path.display(), + failure.error + )); + } + } } } Err(error) => app.status_message = Some(format!("{error:#}")), } Vec::new() } + RuntimeEvent::DraftError(error) => { + app.status_message = Some(error); + Vec::new() + } + RuntimeEvent::Configuration(result) => { + configuration_load = None; + match result { + Ok(keymap) => { + if let Err(error) = + terminal.set_clipboard_enabled(keymap.attachments.terminal_clipboard) + { + let _ = terminal + .set_clipboard_enabled(app.keymap.attachments.terminal_clipboard); + app.configuration_failed(&format!( + "Could not apply terminal clipboard setting: {error}" + )); + } else { + clipboard.configuration_changed(&mut app); + app.install_configuration(keymap); + } + } + Err(error) => app.configuration_failed(&error), + } + Vec::new() + } RuntimeEvent::Tick => app.update(AppEvent::Tick), + RuntimeEvent::Notification(activity) => { + notifications.activity(activity, &mut app); + Vec::new() + } + RuntimeEvent::Clipboard(activity) => { + clipboard.activity(&mut app, activity); + Vec::new() + } }; + let outgoing = clipboard.route(&mut app, outgoing); + clipboard.flush(&mut app); let account_switch = dispatch(&mut app, &mut commands, &mut pending_commands, outgoing); if let Some(account) = account_switch { + clipboard.cancel(); if let Err(error) = draw_interface(&mut terminal, &mut preview, &mut app) .context("failed to draw account-switch feedback") { @@ -274,6 +434,8 @@ async fn main() -> Result<()> { &mut events, &mut worker, &mut pending_commands, + &mut active_chat, + &mut measurement, ) .await { @@ -282,11 +444,31 @@ async fn main() -> Result<()> { ))); } } + if let Some(active_chat) = &active_chat { + let target = app.sync_target(); + active_chat.send_if_modified(|current| { + if *current == target { + false + } else { + *current = target; + true + } + }); + } synchronize_update_preferences(&mut app, &mut update_preferences, &mut update_check); } + app.cancel_alerts(); drop(preview); drop(terminal); + if let Some(writer) = draft_writer { + if let Some(snapshot) = app.take_draft_snapshot() { + writer.queue(snapshot); + } + if let Err(error) = writer.finish().await { + runtime_error = Some(error.context("could not save drafts before exit")); + } + } drop(commands.take()); // Stop back-pressuring a worker that may be trying to report its final // status while the UI is no longer consuming network events. @@ -483,6 +665,24 @@ async fn wait_for_animation_tick(enabled: bool) { } } +async fn wait_for_configuration( + task: &mut Option>>, +) -> Result { + match task { + Some(task) => task + .await + .map_err(|e| format!("Configuration loader failed: {e}"))?, + None => std::future::pending().await, + } +} + +async fn wait_for_draft_error(writer: &mut Option) -> String { + match writer { + Some(writer) => writer.next_error().await, + None => std::future::pending().await, + } +} + fn dispatch( app: &mut AppState, commands: &mut Option>, @@ -533,105 +733,12 @@ fn dispatch( } fn reject_overflow(app: &mut AppState, command: TelegramCommand) { - let event = match command { - TelegramCommand::SendMessage { - chat_id, - local_id, - text, - reply_to, - } => NetworkEvent::SendFailed { - chat_id, - local_id, - text, - reply_to, - error: "Telegram command queue is busy".to_owned(), - }, - TelegramCommand::SendAttachment { - chat_id, - local_id, - path, - caption, - as_photo, - reply_to, - } => NetworkEvent::AttachmentSendFailed { - chat_id, - local_id, - path, - caption, - as_photo, - reply_to, - error: "Telegram command queue is busy".to_owned(), - }, - TelegramCommand::DownloadAttachment { - chat_id, - message_id, - } => NetworkEvent::AttachmentDownloadFailed { - chat_id, - message_id, - error: "Telegram command queue is busy".to_owned(), - }, - TelegramCommand::DownloadPreview { - chat_id, - message_id, - request_id, - .. - } => NetworkEvent::PreviewDownloadFailed { - chat_id, - message_id, - request_id, - error: "Telegram command queue is busy".to_owned(), - }, - TelegramCommand::ResolveTelegramLink { url } => NetworkEvent::LinkFailed { - url, - error: "Telegram command queue is busy".to_owned(), - }, - TelegramCommand::ActivateButton { - chat_id, - message_id, - button_index: _, - } => NetworkEvent::ButtonFailed { - chat_id, - message_id, - error: "Telegram command queue is busy".to_owned(), - }, - TelegramCommand::LoadHistory { - chat_id, - request_id, - } => NetworkEvent::HistoryFailed { - chat_id, - request_id, - error: "Telegram command queue is busy".to_owned(), - }, - TelegramCommand::LoadMessage { - chat_id, - source_message_id: _, - message_id, - request_id, - } => NetworkEvent::MessageLoadFailed { - chat_id, - message_id, - request_id, - error: "Telegram command queue is busy".to_owned(), - }, - TelegramCommand::MarkRead { chat_id } => NetworkEvent::ReadMarkFailed { - chat_id, - error: "Telegram command queue is busy".to_owned(), - }, - TelegramCommand::RefreshDialogs => { - NetworkEvent::DialogsFailed("Telegram command queue is busy".to_owned()) - } - TelegramCommand::SwitchAccount { .. } | TelegramCommand::Shutdown => return, - TelegramCommand::StartQrAuth - | TelegramCommand::SubmitPhone(_) - | TelegramCommand::SubmitCode(_) - | TelegramCommand::SubmitPassword(_) - | TelegramCommand::RestartAuth => { - NetworkEvent::Fatal("Telegram command queue is busy; restart sign-in".to_owned()) - } - }; - app.handle_network(event); + if let Some(event) = command.failure("Telegram command queue is busy".to_owned()) { + app.handle_network(event); + } } +#[allow(clippy::too_many_arguments)] async fn restart_telegram_worker( base_config: &Config, account: u8, @@ -639,6 +746,8 @@ async fn restart_telegram_worker( events: &mut Option>, worker: &mut Option>, pending: &mut VecDeque, + active_chat: &mut Option>>, + measurement: &mut Option>, ) -> Result<()> { pending.clear(); if let Some(sender) = commands.take() { @@ -654,10 +763,14 @@ async fn restart_telegram_worker( let selected = base_config.for_account(account)?; let TelegramHandle { + measure_latency, commands: next_commands, events: next_events, task, + active_chat: next_active, } = telegram::spawn(selected); + *measurement = Some(measure_latency); + *active_chat = Some(next_active); *commands = Some(next_commands); *events = Some(next_events); *worker = Some(task); diff --git a/src/media.rs b/src/media.rs index 9eafe64..cb17351 100644 --- a/src/media.rs +++ b/src/media.rs @@ -33,10 +33,15 @@ const IMAGE_ALLOC: u64 = 128 * 1024 * 1024; const IMAGE_BOUND: u32 = 8192; static KITTY_USED: AtomicBool = AtomicBool::new(false); +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum MediaSource { + Message { chat_id: ChatId, message_id: i32 }, + File(PathBuf), +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct MediaSlot { - pub chat_id: ChatId, - pub message_id: i32, + pub source: MediaSource, pub viewport: Rect, pub offset: i16, pub size: Size, @@ -52,7 +57,10 @@ struct ImageKey { } type Encoded = Vec<(ImageKey, Result)>; -pub type MediaFailure = (ChatId, i32, String); +pub struct MediaFailure { + pub source: MediaSource, + pub error: String, +} #[derive(Default)] pub struct PreviewRenderer { @@ -75,12 +83,21 @@ impl PreviewRenderer { .media_slots .iter() .filter_map(|slot| { - let preview = app.media_previews.get(&(slot.chat_id, slot.message_id))?; + let (path, request_id) = match &slot.source { + MediaSource::Message { + chat_id, + message_id, + } => { + let preview = app.media_previews.get(&(*chat_id, *message_id))?; + (preview.path.clone()?, preview.request_id) + } + MediaSource::File(path) => (path.clone(), 0), + }; Some(( slot.clone(), ImageKey { - path: preview.path.clone()?, - request_id: preview.request_id, + path, + request_id, width: slot.size.width, height: slot.size.height, generation: self.generation, @@ -157,7 +174,10 @@ impl PreviewRenderer { return Ok(self .targets .iter() - .map(|(slot, _)| (slot.chat_id, slot.message_id, format!("{error:#}"))) + .map(|(slot, _)| MediaFailure { + source: slot.source.clone(), + error: format!("{error:#}"), + }) .collect()); } }; @@ -170,7 +190,10 @@ impl PreviewRenderer { Ok(protocol) => { self.ready.insert(key, protocol); } - Err(error) => failures.push((slot.chat_id, slot.message_id, format!("{error:#}"))), + Err(error) => failures.push(MediaFailure { + source: slot.source.clone(), + error: format!("{error:#}"), + }), } } Ok(failures) diff --git a/src/model.rs b/src/model.rs index e73763d..9b266bf 100644 --- a/src/model.rs +++ b/src/model.rs @@ -1,19 +1,28 @@ +use serde::{Deserialize, Serialize}; + use chrono::{DateTime, Local, TimeZone, Utc}; use std::path::Path; pub type ChatId = i64; -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)] pub struct Chat { pub id: ChatId, pub title: String, pub kind: ChatKind, + #[serde(default)] + pub membership: crate::folders::ChatMembership, pub unread: u32, + /// None means an older cache without a server read boundary. + #[serde(default)] + pub read_inbox_max_id: Option, pub last_message: String, + #[serde(default)] + pub last_message_id: Option, pub last_activity: Option>, } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)] pub enum ChatKind { Direct, Group, @@ -24,8 +33,10 @@ pub enum ChatKind { /// /// This deliberately contains metadata only. Telegram's file reference stays /// in the network layer and is refreshed on demand before a download. -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)] pub struct Attachment { + #[serde(default)] + pub source_id: Option, pub kind: AttachmentKind, pub file_name: Option, pub mime_type: Option, @@ -34,7 +45,7 @@ pub struct Attachment { pub fallback_emoji: Option, } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)] pub enum AttachmentKind { Photo, File, @@ -91,8 +102,27 @@ impl Attachment { } } -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)] +pub struct Mention { + pub unread: bool, + /// Voice, round-video or self-destructing content needs explicit consumption. + pub requires_playback: bool, +} + +#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)] pub struct Message { + #[serde(default)] + pub reactions: Option, + #[serde(default)] + pub poll: Option, + #[serde(default)] + pub entities: Vec, + #[serde(skip)] + pub notification: Option, + #[serde(default)] + pub mention: Option, + #[serde(default)] + pub pinned: bool, pub id: i32, pub chat_id: ChatId, pub sender: String, @@ -103,6 +133,8 @@ pub struct Message { pub reply_to: Option, pub text: String, pub timestamp: DateTime, + #[serde(default)] + pub edited_at: Option>, pub outgoing: bool, pub delivery: Delivery, pub attachment: Option, @@ -114,20 +146,20 @@ pub struct Message { pub buttons: Vec, } -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)] pub struct MessageLink { pub label: String, pub url: String, } -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)] pub struct MessageButton { pub label: String, pub index: u16, pub kind: MessageButtonKind, } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)] pub enum MessageButtonKind { Url, Callback, @@ -143,7 +175,7 @@ impl MessageButtonKind { } /// Lightweight metadata used to render and navigate a reply. -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)] pub struct ReplyInfo { pub message_id: i32, /// Stable dialog identifier for the target. Telegram permits replies to a @@ -154,7 +186,7 @@ pub struct ReplyInfo { pub sender: Option, } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)] pub enum Delivery { Pending, Sent, @@ -178,6 +210,49 @@ impl Chat { } impl Message { + #[must_use] + pub fn has_spoilers(&self) -> bool { + self.poll.as_ref().is_some_and(|poll| { + poll.texts().any(|text| { + text.entities.iter().any(|entity| { + entity.kind == crate::entities::Kind::Spoiler && entity.valid_for(&text.text) + }) + }) + }) || self.entities.iter().any(|entity| { + entity.kind == crate::entities::Kind::Spoiler && entity.valid_for(&self.text) + }) + } + + /// Summary surfaces never reveal spoilers, even after an explicit reveal + /// in the transcript. Copy and edit intentionally use the complete text. + #[must_use] + pub fn preview_text(&self) -> String { + if self.text.is_empty() { + if let Some(poll) = &self.poll { + return format!( + "[{}] {}", + if poll.definition.quiz { "quiz" } else { "poll" }, + poll.definition.question.preview() + ); + } + self.attachment + .as_ref() + .map_or("Message", Attachment::display_name) + .to_owned() + } else { + crate::entities::conceal(&self.text, &self.entities).into_owned() + } + } + + pub(crate) fn acknowledge_contents(&mut self, channel: Option, ids: &[i32]) { + if channel.map_or(self.chat_id > -1_000_000_000_000, |id| self.chat_id == id) + && ids.contains(&self.id) + && let Some(mention) = &mut self.mention + { + mention.unread = false; + } + } + #[must_use] pub fn timestamp_from_unix(timestamp: i64) -> DateTime { Utc.timestamp_opt(timestamp, 0) @@ -188,13 +263,30 @@ impl Message { #[must_use] pub fn sanitize_terminal_text(value: &str) -> String { + sanitize_text_with_offsets(value, |_, _| {}) +} + +/// Report valid raw UTF-16 boundaries and their sanitized UTF-8 positions. A +/// surrogate's midpoint is never reported; stripped controls have zero width. +pub(crate) fn sanitize_text_with_offsets( + value: &str, + mut boundary: impl FnMut(usize, usize), +) -> String { let mut clean = String::with_capacity(value.len()); let mut chars = value.chars().peekable(); + let mut units = 0; + boundary(0, 0); while let Some(character) = chars.next() { + units += character.len_utf16(); if character == '\u{1b}' { + boundary(units, clean.len()); if chars.peek() == Some(&'[') { chars.next(); + units += 1; + boundary(units, clean.len()); for next in chars.by_ref() { + units += next.len_utf16(); + boundary(units, clean.len()); if ('@'..='~').contains(&next) { break; } @@ -208,6 +300,7 @@ pub fn sanitize_terminal_text(value: &str) -> String { character if character.is_control() => {} character => clean.push(character), } + boundary(units, clean.len()); } clean } diff --git a/src/notifications.rs b/src/notifications.rs new file mode 100644 index 0000000..cdcaadc --- /dev/null +++ b/src/notifications.rs @@ -0,0 +1,144 @@ +//! Notification preferences shared by commands, Telegram and status rendering. +pub mod delivery; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Mute { + Off, + Hour, + EightHours, + TwoDays, + Forever, +} + +impl Mute { + #[must_use] + pub fn parse(value: &str) -> Option { + match value { + "1h" => Some(Self::Hour), + "8h" => Some(Self::EightHours), + "2d" => Some(Self::TwoDays), + "" | "forever" => Some(Self::Forever), + _ => None, + } + } + + pub(crate) fn until(self, now: i64) -> i32 { + let seconds = match self { + Self::Off => return 0, + Self::Forever => return i32::MAX, + Self::Hour => 3600, + Self::EightHours => 8 * 3600, + Self::TwoDays => 2 * 24 * 3600, + }; + i32::try_from(now.saturating_add(seconds)).unwrap_or(i32::MAX) + } +} + +#[must_use] +pub fn mute_label(until: i64, now: i64) -> String { + if until <= now { + "Notifications on".to_owned() + } else if until >= i64::from(i32::MAX) { + "Muted forever".to_owned() + } else { + chrono::DateTime::from_timestamp(until, 0).map_or_else( + || "Muted".to_owned(), + |date| { + format!( + "Muted until {}", + date.with_timezone(&chrono::Local).format("%Y-%m-%d %H:%M") + ) + }, + ) + } +} + +#[derive(Clone, Copy, Debug, Default, serde::Deserialize, Eq, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum Backend { + #[default] + Auto, + Native, + Osc9, + Bell, +} + +#[derive(Clone, Copy, Debug, Default, serde::Deserialize, Eq, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum When { + #[default] + Unseen, + Unfocused, +} + +#[derive(Clone, Debug, serde::Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Configuration { + pub enabled: bool, + pub backend: Backend, + pub previews: bool, + pub sound: bool, + pub when: When, + pub group_delay_ms: u64, +} + +impl Default for Configuration { + fn default() -> Self { + Self { + enabled: true, + backend: Backend::Auto, + previews: true, + sound: true, + when: When::Unseen, + group_delay_ms: 800, + } + } +} + +impl Configuration { + pub(crate) fn validate(&self) -> anyhow::Result<()> { + anyhow::ensure!( + (100..=5000).contains(&self.group_delay_ms), + "notifications.group_delay_ms must be 100–5000" + ); + Ok(()) + } +} + +/// Transient delivery metadata. Cached history never produces notification intents. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Metadata { + pub sender: Option, + pub silent: bool, + pub protected: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)] +pub struct SettingsKey { + pub chat: crate::model::ChatId, + pub sender: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Preferences { + pub mute_until: i64, + pub previews: bool, + pub sound: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Resolved { + pub chat: Preferences, + pub sender_mute_until: Option, +} + +pub struct Alert { + pub account: i64, + pub chat: crate::model::ChatId, + pub ids: Vec, + pub title: String, + pub body: String, + pub sound: bool, + pub valid: std::sync::Arc, + pub expires: std::time::Instant, +} diff --git a/src/notifications/delivery.rs b/src/notifications/delivery.rs new file mode 100644 index 0000000..351a6be --- /dev/null +++ b/src/notifications/delivery.rs @@ -0,0 +1,231 @@ +//! Bounded native delivery and terminal fallbacks. All TTY writes stay on the +//! main event-loop thread; the platform worker never touches the terminal. +use super::{Alert, Backend}; +use crate::app::AppState; +use std::{ + io::Write, + sync::{atomic::Ordering, mpsc}, + time::{Duration, Instant}, +}; +use yazi_emulator::{Brand, EMULATOR}; +use yazi_tty::{TTY, sequence::TmuxPassthrough}; + +struct Job { + alert: Alert, + fallback: bool, +} +pub struct Completion { + job: Job, + result: Result<(), String>, +} +pub enum Activity { + Deadline, + Delivered(Box), +} + +pub struct Dispatcher { + jobs: Option>, + done: tokio::sync::mpsc::Sender, + results: tokio::sync::mpsc::Receiver, + remote: bool, + last_error: Option, +} + +impl Default for Dispatcher { + fn default() -> Self { + let (done, results) = tokio::sync::mpsc::channel(8); + Self { + jobs: None, + done, + results, + remote: std::env::var_os("SSH_CONNECTION").is_some() + || std::env::var_os("SSH_TTY").is_some(), + last_error: None, + } + } +} + +impl Dispatcher { + pub fn flush(&mut self, app: &mut AppState) { + let backend = app.keymap.notifications.backend; + for alert in app.take_alerts() { + let terminal = match backend { + Backend::Osc9 => Some(true), + Backend::Bell => Some(false), + Backend::Auto if self.remote => Some(supports_osc9()), + Backend::Auto | Backend::Native => None, + }; + if let Some(osc9) = terminal { + if let Err(error) = post_terminal(&alert, osc9) { + self.error(app, &error.to_string()); + } + app.alert_finished(&alert); + continue; + } + let fallback = backend == Backend::Auto; + match self.worker() { + Ok(sender) => match sender.try_send(Job { alert, fallback }) { + Ok(()) => {} + Err(error) => { + let job = match error { + mpsc::TrySendError::Full(job) + | mpsc::TrySendError::Disconnected(job) => job, + }; + app.alert_finished(&job.alert); + self.error(app, "notification queue is unavailable"); + } + }, + Err(error) => { + if fallback { + let _ = post_terminal(&alert, supports_osc9()); + } + app.alert_finished(&alert); + self.error(app, &error.to_string()); + } + } + } + } + + fn worker(&mut self) -> std::io::Result<&mpsc::SyncSender> { + if self.jobs.is_none() { + let (sender, receiver) = mpsc::sync_channel::(8); + let done = self.done.clone(); + std::thread::Builder::new() + .name("termgram-notifications".to_owned()) + .spawn(move || { + while let Ok(job) = receiver.recv() { + let result = if job.alert.valid.load(Ordering::Acquire) + && job.alert.expires > Instant::now() + { + post_native(&job.alert).map_err(|error| error.to_string()) + } else { + Ok(()) + }; + if done.blocking_send(Completion { job, result }).is_err() { + break; + } + } + })?; + self.jobs = Some(sender); + } + Ok(self.jobs.as_ref().expect("notification worker started")) + } + + pub async fn next_activity(&mut self, deadline: Option) -> Activity { + let timer = async { + if let Some(deadline) = deadline { + tokio::time::sleep_until(deadline.into()).await; + } else { + std::future::pending::<()>().await; + } + }; + tokio::select! { + () = timer => Activity::Deadline, + result = self.results.recv() => match result { + Some(result) => Activity::Delivered(Box::new(result)), + None => std::future::pending().await, + } + } + } + + pub fn activity(&mut self, activity: Activity, app: &mut AppState) { + let Activity::Delivered(completion) = activity else { + return; + }; + let Completion { job, result } = *completion; + if job.alert.valid.load(Ordering::Acquire) + && app.account_user_id == Some(job.alert.account) + && let Err(error) = result + { + if job.fallback + && app.keymap.notifications.backend == Backend::Auto + && let Err(fallback) = post_terminal(&job.alert, supports_osc9()) + { + self.error(app, &fallback.to_string()); + } + self.error(app, &error); + } + app.alert_finished(&job.alert); + } + + fn error(&mut self, app: &mut AppState, error: &str) { + let now = Instant::now(); + if self + .last_error + .is_none_or(|last| now.duration_since(last) >= Duration::from_secs(60)) + { + self.last_error = Some(now); + app.status_message = Some(format!( + "Desktop notification: {}", + crate::model::sanitize_terminal_line(error) + )); + } + } +} + +// Same terminal capability allowlist as Codex; detection remains Yazi's owner. +fn supports_osc9() -> bool { + matches!( + EMULATOR.brand.get(), + Brand::Ghostty | Brand::Iterm2 | Brand::Kitty | Brand::Warp | Brand::WezTerm + ) +} + +fn post_terminal(alert: &Alert, osc9: bool) -> std::io::Result<()> { + // Neither OSC 9 nor BEL can request a silent alert. Preserve Telegram's + // silent-message and sound-none settings rather than sounding a fallback. + if !alert.sound || !alert.valid.load(Ordering::Acquire) || alert.expires <= Instant::now() { + return Ok(()); + } + let mut tty = TTY.writer(); + if osc9 { + let sequence = format!("\x1b]9;{} · {}\x07", alert.title, alert.body); + write!( + tty, + "{}", + TmuxPassthrough(sequence, EMULATOR.mux.get().is_some()) + )?; + } else { + tty.write_all(b"\x07")?; + } + tty.flush() +} + +fn post_native(alert: &Alert) -> anyhow::Result<()> { + let mut notification = notify_rust::Notification::new(); + notification + .appname("Termgram") + .summary(&alert.title) + .timeout(8000); + #[cfg(all(unix, not(target_os = "macos")))] + { + // XDG bodies support markup; message text stays literal. + notification.body(&quick_xml::escape::partial_escape(alert.body.as_str())); + notification.hint(notify_rust::Hint::Category("im.received".to_owned())); + notification.hint(notify_rust::Hint::SuppressSound(!alert.sound)); + } + #[cfg(any(target_os = "windows", target_os = "macos"))] + notification.body(&alert.body); + #[cfg(target_os = "macos")] + { + if alert.sound { + notification.sound_name("default"); + } + // Legacy show() defers to Drop and hides delivery errors. schedule_raw + // sends immediately, returns errors, and leaves an empty handle. + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_secs_f64(); + notification.schedule_raw(now)?; + } + #[cfg(target_os = "windows")] + { + if alert.sound { + notification.sound_name("Default"); + } + notification.show()?; + } + #[cfg(all(unix, not(target_os = "macos")))] + notification.show()?; + Ok(()) +} diff --git a/src/pins.rs b/src/pins.rs new file mode 100644 index 0000000..2bdeede --- /dev/null +++ b/src/pins.rs @@ -0,0 +1,54 @@ +//! Telegram pin identities and ordering, independent of protocol and UI types. + +use crate::model::ChatId; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct DialogPins { + pub main: Vec, + pub archive: Vec, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DialogScope { + Main, + Archive, + Filter(i32), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DialogAction { + Set(bool), + MoveUp, + MoveDown, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MessageAction { + Pin { notify: bool, only_self: bool }, + Unpin, + UnpinAll, +} + +pub const PAGE_SIZE: usize = 30; + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct MessagePage { + pub messages: Vec, + pub total: Option, + pub before: i32, + pub next: Option, +} + +/// Move within the complete server order, including peers hidden by the UI. +pub(crate) fn move_pin(order: &mut [T], position: usize, action: DialogAction) { + let target = match action { + DialogAction::MoveUp => position.checked_sub(1), + DialogAction::MoveDown => position.checked_add(1).filter(|index| *index < order.len()), + DialogAction::Set(_) => None, + }; + if let Some(target) = target { + order.swap(position, target); + } +} diff --git a/src/polls.rs b/src/polls.rs new file mode 100644 index 0000000..44fa7c6 --- /dev/null +++ b/src/polls.rs @@ -0,0 +1,376 @@ +//! Poll identity, partial Telegram results and voting rules, without SDK/UI types. +use crate::entities::Entity; +use serde::{Deserialize, Serialize}; +use std::hash::{DefaultHasher, Hash, Hasher}; + +#[derive(Clone, Debug, Default, Eq, PartialEq, Hash, Serialize, Deserialize)] +pub struct Text { + pub text: String, + pub entities: Vec, +} + +impl Text { + #[must_use] + pub fn preview(&self) -> String { + crate::entities::conceal(&self.text, &self.entities).into_owned() + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)] +pub struct Answer { + pub option: Vec, + pub text: Text, + pub has_media: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)] +// Independent Telegram capabilities, not mutually exclusive UI states. +#[allow(clippy::struct_excessive_bools)] +pub struct Definition { + pub id: i64, + pub hash: i64, + pub question: Text, + pub answers: Vec, + pub closed: bool, + pub public_voters: bool, + pub multiple_choice: bool, + pub quiz: bool, + pub revoting_disabled: bool, + pub hide_results_until_close: bool, + pub subscribers_only: bool, + pub countries: Vec, + pub close_date: Option, + pub has_media: bool, +} + +impl Definition { + #[must_use] + pub fn revision(&self) -> u64 { + let mut hash = DefaultHasher::new(); + self.hash(&mut hash); + hash.finish() + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct Count { + pub option: Vec, + pub voters: Option, + pub chosen: bool, + pub correct: bool, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct Results { + pub counts: Vec, + pub total: Option, + pub choice_known: bool, + pub solution: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct Poll { + pub definition: Definition, + pub results: Results, + #[serde(default)] + pub stale: bool, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct ResultsPatch { + pub min: bool, + pub counts: Option>, + pub total: Option, + pub solution: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Update { + pub id: i64, + pub stale: bool, + pub definition: Option, + pub results: ResultsPatch, +} + +impl Poll { + pub(crate) fn apply(&mut self, update: &Update) { + if self.definition.id != update.id { + return; + } + if let Some(definition) = &update.definition { + let has_media = self.definition.has_media; + self.definition.clone_from(definition); + // updateMessagePoll does not carry the message's attached media. + self.definition.has_media |= has_media; + self.results.counts.retain(|count| { + definition + .answers + .iter() + .any(|answer| answer.option == count.option) + }); + self.stale = false; + } + let patch = &update.results; + if let Some(total) = patch.total { + self.results.total = Some(total); + } + if !patch.min { + self.results.choice_known = true; + } + if let Some(counts) = &patch.counts { + for new in counts { + if !self + .definition + .answers + .iter() + .any(|answer| answer.option == new.option) + { + continue; + } + let count = if let Some(index) = self + .results + .counts + .iter() + .position(|count| count.option == new.option) + { + &mut self.results.counts[index] + } else { + self.results.counts.push(Count { + option: new.option.clone(), + ..Count::default() + }); + self.results.counts.last_mut().expect("inserted count") + }; + if let Some(voters) = new.voters { + count.voters = Some(voters); + } + if !patch.min { + count.chosen = new.chosen; + } + count.correct |= new.correct; + } + } else if !patch.min && self.results.total == Some(0) { + for count in &mut self.results.counts { + count.chosen = false; + } + } + if let Some(solution) = &patch.solution { + self.results.solution = Some(solution.clone()); + } + self.stale |= update.stale; + } + + #[must_use] + pub fn voted(&self) -> bool { + self.results.counts.iter().any(|count| count.chosen) + } + + #[must_use] + pub fn count(&self, option: &[u8]) -> Option<&Count> { + self.results + .counts + .iter() + .find(|count| count.option == option) + } + + #[must_use] + pub fn closed(&self, now: i64) -> bool { + self.definition.closed || self.definition.close_date.is_some_and(|date| date <= now) + } + + #[must_use] + pub fn results_visible(&self, now: i64) -> bool { + !self.stale + && (self.closed(now) || (!self.definition.hide_results_until_close && self.voted())) + } + + #[must_use] + pub fn read_only_reason(&self, now: i64) -> Option<&'static str> { + if self.stale || !self.results.choice_known { + Some("Refresh this poll before voting") + } else if self.closed(now) { + Some("This poll is closed") + } else if self.voted() && (self.definition.quiz || self.definition.revoting_disabled) { + Some("This poll does not allow changing your vote") + } else if self.definition.has_media + || self + .definition + .answers + .iter() + .any(|answer| answer.has_media) + { + Some("This poll includes media; review and vote in an official client") + } else { + None + } + } + + /// # Errors + /// Returns a reason when the current server definition no longer permits + /// these stable option IDs. An empty set explicitly retracts an earlier vote. + pub fn validate_vote( + &self, + revision: u64, + options: &[Vec], + now: i64, + ) -> Result<(), String> { + if self.definition.revision() != revision { + return Err("Poll options changed; refresh and choose again".to_owned()); + } + if let Some(reason) = self.read_only_reason(now) { + return Err(reason.to_owned()); + } + if options.is_empty() && !self.voted() { + return Err("Choose an answer first".to_owned()); + } + if !self.definition.multiple_choice && options.len() > 1 { + return Err("Choose one answer".to_owned()); + } + for (index, option) in options.iter().enumerate() { + if !self + .definition + .answers + .iter() + .any(|answer| &answer.option == option) + || options[..index].contains(option) + { + return Err("Poll options changed; refresh and choose again".to_owned()); + } + } + Ok(()) + } + + pub fn texts(&self) -> impl Iterator { + std::iter::once(&self.definition.question) + .chain(self.definition.answers.iter().map(|answer| &answer.text)) + .chain(self.results.solution.iter()) + } +} + +#[cfg(test)] +pub(crate) fn example() -> Poll { + Poll { + definition: Definition { + id: 123, + hash: 456, + question: Text { + text: "Choose your drink".to_owned(), + entities: Vec::new(), + }, + answers: [([0, 255], "Tea 茶"), ([1, 128], "Coffee ☕")] + .into_iter() + .map(|(option, text)| Answer { + option: option.to_vec(), + text: Text { + text: text.to_owned(), + entities: Vec::new(), + }, + has_media: false, + }) + .collect(), + closed: false, + public_voters: false, + multiple_choice: true, + quiz: false, + revoting_disabled: false, + hide_results_until_close: false, + subscribers_only: false, + countries: Vec::new(), + close_date: None, + has_media: false, + }, + results: Results { + choice_known: true, + total: Some(0), + ..Results::default() + }, + stale: false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn poll_min_results_preserve_choice_correctness_and_omitted_counts() { + let mut poll = example(); + let mut update = Update { + id: poll.definition.id, + stale: false, + definition: None, + results: ResultsPatch { + min: false, + counts: Some(vec![Count { + option: vec![0, 255], + voters: Some(4), + chosen: true, + correct: true, + }]), + total: Some(5), + solution: None, + }, + }; + poll.apply(&update); + update.results.min = true; + update.results.counts = Some(vec![Count { + option: vec![0, 255], + voters: None, + chosen: false, + correct: false, + }]); + update.results.total = Some(6); + poll.apply(&update); + assert!(poll.voted()); + assert!(poll.results.counts[0].correct); + assert_eq!(poll.results.counts[0].voters, Some(4)); + assert_eq!(poll.results.total, Some(6)); + assert!(poll.results_visible(0)); + update.results.min = false; + poll.apply(&update); + assert!(!poll.voted()); + assert!(!poll.results_visible(0)); + assert!(poll.results.counts[0].correct); + } + + #[test] + fn poll_votes_use_stable_bytes_and_enforce_changed_closed_and_quiz_rules() { + let mut poll = example(); + let revision = poll.definition.revision(); + assert!( + poll.validate_vote(revision, &[vec![0, 255], vec![1, 128]], 0) + .is_ok() + ); + assert!(poll.validate_vote(revision, &[], 0).is_err()); + assert!( + poll.validate_vote(revision, &[vec![0, 255], vec![0, 255]], 0) + .is_err() + ); + assert!(poll.validate_vote(revision, &[vec![0]], 0).is_err()); + poll.results.counts.push(Count { + option: vec![0, 255], + chosen: true, + ..Count::default() + }); + assert!(poll.validate_vote(revision, &[], 0).is_ok()); + poll.definition.quiz = true; + assert!( + poll.validate_vote(poll.definition.revision(), &[vec![1, 128]], 0) + .is_err() + ); + poll.definition.quiz = false; + poll.definition.hide_results_until_close = true; + assert!(!poll.results_visible(0)); + poll.definition.close_date = Some(10); + assert!(poll.results_visible(10)); + assert!( + poll.validate_vote(poll.definition.revision(), &[vec![0, 255]], 10) + .is_err() + ); + poll.definition.close_date = None; + poll.definition.answers.swap(0, 1); + assert!(poll.validate_vote(revision, &[vec![0, 255]], 0).is_err()); + poll.definition.has_media = true; + assert!(poll.read_only_reason(0).unwrap().contains("official")); + } +} diff --git a/src/reactions.rs b/src/reactions.rs new file mode 100644 index 0000000..e73c38c --- /dev/null +++ b/src/reactions.rs @@ -0,0 +1,237 @@ +//! Telegram reaction identity and partial-result semantics. +use crate::model::ChatId; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)] +pub enum Kind { + Emoji(String), + Custom(i64), + Paid, +} + +impl Kind { + #[must_use] + pub fn label(&self) -> String { + match self { + Self::Emoji(emoji) => crate::model::sanitize_terminal_line(emoji), + Self::Custom(_) => "[custom emoji]".to_owned(), + Self::Paid => "★".to_owned(), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct Count { + pub kind: Kind, + pub count: u32, + pub chosen: Option, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +pub struct Summary { + pub counts: Vec, + pub choices_known: bool, + pub as_tags: bool, + pub stale: bool, +} + +impl Summary { + pub fn apply(&mut self, incoming: &Self) { + let mut counts = incoming.counts.clone(); + if !incoming.choices_known { + for count in &mut counts { + count.chosen = self + .counts + .iter() + .find(|old| old.kind == count.kind) + .and_then(|old| old.chosen); + } + } + self.counts = counts; + self.as_tags = incoming.as_tags; + self.stale = incoming.stale || (!incoming.choices_known && self.stale); + self.choices_known |= incoming.choices_known; + } + + #[must_use] + pub fn chosen(&self) -> Vec { + let mut chosen: Vec<_> = self + .counts + .iter() + .filter_map(|count| { + (count.kind != Kind::Paid) + .then_some(count.chosen) + .flatten() + .map(|order| (order, count.kind.clone())) + }) + .collect(); + chosen.sort_by_key(|(order, _)| *order); + chosen.into_iter().map(|(_, kind)| kind).collect() + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Update { + pub chat: ChatId, + pub message: i32, + pub summary: Summary, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Choice { + pub emoji: String, + pub title: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Review { + pub summary: Summary, + pub choices: Vec, + pub max_chosen: usize, + pub max_unique: usize, + pub read_only: Option, +} + +impl Review { + /// Compute a fresh desired set, preserving the order and identity of other + /// choices. Telegram replaces the oldest choice when the user's limit is hit. + /// # Errors + /// Rejects unavailable choices, unknown state and unique-reaction limits. + pub fn toggle(&self, emoji: Option<&str>) -> Result, String> { + if let Some(reason) = &self.read_only { + return Err(reason.clone()); + } + if self.summary.stale || !self.summary.choices_known { + return Err("Refresh reactions before changing them".to_owned()); + } + let Some(emoji) = emoji else { + return Ok(Vec::new()); + }; + let target = Kind::Emoji(emoji.to_owned()); + let mut chosen = self.summary.chosen(); + if chosen.contains(&target) { + chosen.retain(|kind| *kind != target); + return Ok(chosen); + } + if !self.choices.iter().any(|choice| choice.emoji == emoji) || self.max_chosen == 0 { + return Err("This reaction is not available in this chat".to_owned()); + } + while chosen.len() >= self.max_chosen { + chosen.remove(0); + } + let unique = self + .summary + .counts + .iter() + .filter(|count| { + count.kind != Kind::Paid + && count.count > 0 + && !(count.count == 1 + && count.chosen.is_some() + && !chosen.contains(&count.kind)) + }) + .count(); + if !self + .summary + .counts + .iter() + .any(|count| count.kind == target && count.count > 0) + && unique >= self.max_unique + { + return Err("This message has reached its unique reaction limit".to_owned()); + } + chosen.push(target); + Ok(chosen) + } +} + +#[cfg(test)] +pub(crate) fn example() -> Review { + Review { + summary: Summary { + counts: vec![ + Count { + kind: Kind::Emoji("👍".to_owned()), + count: 4, + chosen: Some(0), + }, + Count { + kind: Kind::Custom(7), + count: 1, + chosen: Some(1), + }, + ], + choices_known: true, + ..Summary::default() + }, + choices: [("👍", "Like"), ("❤", "Love"), ("🔥", "Fire")] + .into_iter() + .map(|(emoji, title)| Choice { + emoji: emoji.to_owned(), + title: title.to_owned(), + }) + .collect(), + max_chosen: 3, + max_unique: 11, + read_only: None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn minimal_reactions_keep_private_choices_but_drop_removed_counters() { + let mut summary = example().summary; + summary.apply(&Summary { + counts: vec![Count { + kind: Kind::Emoji("👍".to_owned()), + count: 5, + chosen: None, + }], + ..Summary::default() + }); + assert_eq!(summary.chosen(), [Kind::Emoji("👍".to_owned())]); + assert!(summary.choices_known); + assert_eq!(summary.counts[0].count, 5); + summary.apply(&Summary { + choices_known: true, + ..Summary::default() + }); + assert!(summary.counts.is_empty()); + } + + #[test] + fn changing_reactions_preserves_other_choices_and_telegram_limits() { + let mut review = example(); + assert_eq!(review.toggle(Some("👍")).unwrap(), [Kind::Custom(7)]); + assert_eq!( + review.toggle(Some("❤")).unwrap(), + [ + Kind::Emoji("👍".to_owned()), + Kind::Custom(7), + Kind::Emoji("❤".to_owned()) + ] + ); + review.max_chosen = 2; + assert_eq!( + review.toggle(Some("❤")).unwrap(), + [Kind::Custom(7), Kind::Emoji("❤".to_owned())] + ); + review.max_unique = 2; + assert!( + review.toggle(Some("❤")).is_err(), + "oldest choice still has other voters" + ); + review.summary.counts[0].count = 1; + assert!( + review.toggle(Some("❤")).is_ok(), + "replacing own exclusive choice frees a slot" + ); + assert!(review.toggle(Some("unavailable")).is_err()); + assert!(review.toggle(None).unwrap().is_empty()); + review.summary.stale = true; + assert!(review.toggle(None).is_err()); + } +} diff --git a/src/read_state.rs b/src/read_state.rs new file mode 100644 index 0000000..0179666 --- /dev/null +++ b/src/read_state.rs @@ -0,0 +1,37 @@ +//! Incoming read boundaries are monotonic. Counts from a slower dialog snapshot +//! must not erase arrivals beyond the snapshot's top message. +use crate::model::Chat; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Snapshot { + pub max_id: i32, + pub unread: u32, + pub top_message: i32, +} + +pub fn inbox_update(chat: &mut Chat, max_id: i32, unread: u32) { + if max_id < chat.read_inbox_max_id.unwrap_or(0) { + return; + } + chat.read_inbox_max_id = Some(max_id.max(0)); + chat.unread = unread; +} + +pub fn acknowledge(chat: &mut Chat, max_id: i32, snapshot: Option<&Snapshot>) { + let previous = chat.read_inbox_max_id.unwrap_or(0); + chat.read_inbox_max_id = Some(previous.max(max_id)); + if let Some(snapshot) = snapshot + && snapshot.max_id >= previous.max(max_id) + && chat + .last_message_id + .is_none_or(|id| id <= snapshot.top_message) + { + inbox_update(chat, snapshot.max_id, snapshot.unread); + } else if chat.last_message_id.is_some_and(|id| id <= max_id) { + chat.unread = 0; + } +} + +pub fn unread_mark(chat: &mut Chat, unread: bool) { + chat.membership.unread_mark = unread; +} diff --git a/src/search.rs b/src/search.rs new file mode 100644 index 0000000..b797cf9 --- /dev/null +++ b/src/search.rs @@ -0,0 +1,287 @@ +//! Bounded, cancellable regex searches over the account's local message cache. +use crate::{ + event::NetworkEvent, + model::{ChatId, Message}, +}; +use anyhow::{Result, bail}; +use libsql::{OpenFlags, params}; +use std::{ + collections::HashSet, + path::{Path, PathBuf}, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + time::Duration, +}; + +const PAGE_SIZE: usize = 100; +const SCAN_BATCH: i64 = 128; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Cursor(pub i64, pub ChatId, pub i32); + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Request { + pub id: u64, + pub pattern: String, + /// None means all cached conversations; Some(empty) is an empty scope. + pub chats: Option>, + pub before: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Page { + pub messages: Vec, + pub next: Option, + pub cached_messages: usize, + pub oldest: Option, + pub newest: Option, +} + +pub(crate) struct Worker { + path: PathBuf, + jobs: tokio::task::JoinSet<(u64, Arc, Result)>, + cancel: Arc, + pending: Option, +} + +impl Worker { + pub fn new(path: PathBuf) -> Self { + Self { + path, + jobs: tokio::task::JoinSet::new(), + cancel: Arc::default(), + pending: None, + } + } + pub fn queue(&mut self, request: Request) { + self.cancel(); + self.pending = Some(request); + } + pub fn cancel(&mut self) { + self.cancel.store(true, Ordering::Relaxed); + self.pending = None; + } + pub fn start_pending(&mut self) { + if !self.jobs.is_empty() { + return; + } + let Some(request) = self.pending.take() else { + return; + }; + self.cancel = Arc::default(); + let cancel = self.cancel.clone(); + let path = self.path.clone(); + let runtime = tokio::runtime::Handle::current(); + self.jobs.spawn_blocking(move || { + let result = runtime.block_on(scan(&path, &request, &cancel)); + (request.id, cancel, result) + }); + } + pub fn running(&self) -> bool { + !self.jobs.is_empty() + } + pub async fn next(&mut self) -> Option { + match self.jobs.join_next().await? { + Ok((_, cancel, _)) if cancel.load(Ordering::Relaxed) => None, + Ok((request_id, _, Ok(page))) => Some(NetworkEvent::SearchResults { request_id, page }), + Ok((request_id, _, Err(error))) => Some(NetworkEvent::SearchFailed { + request_id, + error: format!("{error:#}"), + }), + Err(error) => Some(NetworkEvent::Error(format!( + "Local search task failed: {error}" + ))), + } + } +} + +impl Drop for Worker { + fn drop(&mut self) { + self.cancel(); + } +} + +async fn scan(path: &Path, request: &Request, cancel: &AtomicBool) -> Result { + if request.pattern.len() > 4096 { + bail!("Regular expression is longer than 4096 bytes"); + } + let regex = regex::RegexBuilder::new(&request.pattern) + .size_limit(4 * 1024 * 1024) + .dfa_size_limit(2 * 1024 * 1024) + .build()?; + let database = libsql::Builder::new_local(path) + .flags(OpenFlags::SQLITE_OPEN_READ_ONLY) + .build() + .await?; + let connection = database.connect()?; + connection.busy_timeout(Duration::from_millis(250))?; + let scope = request + .chats + .as_ref() + .map(|ids| ids.iter().copied().collect::>()); + let mut page = Page { + messages: Vec::new(), + next: None, + cached_messages: 0, + oldest: None, + newest: None, + }; + let mut cursor = Cursor(i64::MAX, i64::MAX, i32::MAX); + let mut last_match = None; + let mut full = false; + loop { + if cancel.load(Ordering::Relaxed) { + bail!("Search cancelled"); + } + let mut rows = connection.query( + "SELECT m.timestamp,m.chat_id,m.id,m.data FROM messages m JOIN chats c ON c.id=m.chat_id WHERE m.data IS NOT NULL AND (m.timestamp,m.chat_id,m.id)<(?1,?2,?3) ORDER BY m.timestamp DESC,m.chat_id DESC,m.id DESC LIMIT ?4", + params![cursor.0, cursor.1, cursor.2, SCAN_BATCH]).await?; + let mut scanned = 0; + while let Some(row) = rows.next().await? { + if cancel.load(Ordering::Relaxed) { + bail!("Search cancelled"); + } + cursor = Cursor(row.get(0)?, row.get(1)?, row.get(2)?); + scanned += 1; + if scope.as_ref().is_some_and(|ids| !ids.contains(&cursor.1)) { + continue; + } + page.cached_messages += 1; + page.oldest = Some(cursor.0); + page.newest.get_or_insert(cursor.0); + if full + || request.before.is_some_and(|before| { + (cursor.0, cursor.1, cursor.2) >= (before.0, before.1, before.2) + }) + { + continue; + } + let message: Message = serde_json::from_str(&row.get::(3)?)?; + if regex.is_match(&message.text) { + if page.messages.len() == PAGE_SIZE { + page.next = last_match; + full = true; + } else { + page.messages.push(message); + last_match = Some(cursor); + } + } + } + if scanned < SCAN_BATCH { + break; + } + } + Ok(page) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + cache::Store, + model::{Chat, ChatKind, Delivery}, + }; + use chrono::TimeZone; + + #[tokio::test] + async fn regex_search_pages_scopes_tombstones_and_stale_context() { + let dir = std::env::temp_dir().join(format!("termgram-search-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("cache.sqlite3"); + let mut store = Store::open(&path).await.unwrap(); + let chat = |id| Chat { + id, + title: format!("Chat {id}"), + kind: ChatKind::Group, + unread: 0, + last_message_id: None, + last_message: String::new(), + last_activity: None, + read_inbox_max_id: Some(0), + membership: crate::folders::ChatMembership::default(), + }; + let mut events = vec![NetworkEvent::Dialogs(vec![chat(42), chat(43)])]; + for id in 1..=206 { + events.push(NetworkEvent::NewMessage(Message { + reactions: None, + poll: None, + entities: Vec::new(), + notification: None, + mention: None, + edited_at: None, + pinned: false, + id, + chat_id: if id == 206 { 43 } else { 42 }, + sender: "Ada".to_owned(), + reply_to: None, + text: format!("東京 error {id}"), + timestamp: chrono::Utc.timestamp_opt(i64::from(id), 0).unwrap(), + outgoing: false, + delivery: Delivery::Read, + attachment: None, + links: Vec::new(), + buttons: Vec::new(), + })); + } + events.push(NetworkEvent::MessagesDeleted { + channel_id: None, + message_ids: vec![11], + }); + store.apply(&events).await.unwrap(); + let mut request = Request { + id: 1, + pattern: r"東京.*error \d+$".to_owned(), + chats: Some(vec![42]), + before: None, + }; + let cancelled = AtomicBool::new(false); + let first = scan(&path, &request, &cancelled).await.unwrap(); + assert_eq!(first.cached_messages, 204); + assert_eq!(first.messages.len(), 100); + assert_eq!(first.messages[0].id, 205); + request.before = first.next; + let second = scan(&path, &request, &cancelled).await.unwrap(); + assert_eq!(second.messages.len(), 100); + assert!( + second + .messages + .iter() + .all(|message| !first.messages.iter().any(|other| other.id == message.id)) + ); + request.before = second.next; + let last = scan(&path, &request, &cancelled).await.unwrap(); + assert_eq!(last.messages.len(), 4); + assert!(last.next.is_none()); + assert!(last.messages.iter().all(|message| message.id != 11)); + request.before = None; + request.chats = Some(vec![43]); + assert_eq!( + scan(&path, &request, &cancelled) + .await + .unwrap() + .messages + .len(), + 1 + ); + let context = store.context(42, 50).await.unwrap(); + assert!(context.iter().any(|message| message.id == 49)); + assert!(context.iter().any(|message| message.id == 51)); + store + .apply(&[NetworkEvent::MessagesDeleted { + channel_id: None, + message_ids: vec![50], + }]) + .await + .unwrap(); + assert!(store.context(42, 50).await.is_err()); + request.pattern = "(".to_owned(); + assert!(scan(&path, &request, &cancelled).await.is_err()); + request.pattern = "error".to_owned(); + cancelled.store(true, Ordering::Relaxed); + assert!(scan(&path, &request, &cancelled).await.is_err()); + drop(store); + std::fs::remove_dir_all(dir).unwrap(); + } +} diff --git a/src/sidebar.rs b/src/sidebar.rs new file mode 100644 index 0000000..b722cf4 --- /dev/null +++ b/src/sidebar.rs @@ -0,0 +1,37 @@ +//! Sidebar dimensions and semantic colors. Terminal themes own the palette. +use crate::appearance::TerminalColor; +use serde::Deserialize; + +pub const MIN_SPLIT_WIDTH: u16 = 80; + +#[derive(Clone, Debug, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Configuration { + pub width: u16, + pub time_color: TerminalColor, + pub unread_color: TerminalColor, +} + +impl Default for Configuration { + fn default() -> Self { + Self { + width: 30, + time_color: TerminalColor::Cyan, + unread_color: TerminalColor::Yellow, + } + } +} + +impl Configuration { + pub(crate) fn validate(&self) -> anyhow::Result<()> { + anyhow::ensure!( + (24..=60).contains(&self.width), + "sidebar width must be between 24 and 60 terminal columns" + ); + Ok(()) + } + #[must_use] + pub fn width_for(&self, terminal_width: u16) -> u16 { + self.width.min(terminal_width.saturating_sub(48)) + } +} diff --git a/src/staging.rs b/src/staging.rs new file mode 100644 index 0000000..2d2e7e6 --- /dev/null +++ b/src/staging.rs @@ -0,0 +1,436 @@ +//! File preparation uses Yazi's platform argument parser and bounded background +//! work. Original files stay references until an explicit send is confirmed. + +use crate::{drafts::Key, event::NetworkEvent, model::sanitize_terminal_line}; +use anyhow::{Context, Result, ensure}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{ + fs::File, + io::{Read, Write}, + path::{Path, PathBuf}, +}; + +pub const MAX_FILES: usize = 8; +pub const MAX_BYTES: u64 = 2 * 1024 * 1024 * 1024; +static PREPARATION: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(1); + +#[derive(Clone, Debug, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Configuration { + /// Plain terminal paste remains text unless explicitly enabled. + pub auto_attach_paths: bool, + pub clipboard_as_photo: bool, + pub terminal_clipboard: bool, +} + +impl Default for Configuration { + fn default() -> Self { + Self { + auto_attach_paths: false, + clipboard_as_photo: true, + terminal_clipboard: true, + } + } +} + +#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)] +pub struct Attachment { + pub path: PathBuf, + pub size: u64, + pub digest: [u8; 32], + pub photo_supported: bool, + pub as_photo: bool, + #[serde(default)] + pub owned: bool, + #[serde(skip)] + pub(crate) lease: Option>, +} + +impl Attachment { + pub(crate) fn retain_owned(&self) { + if let Some(lease) = &self.lease { + lease.retain(); + } + } + + pub(crate) fn discard_owned(&self) { + if let Some(lease) = &self.lease { + lease.discard(); + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum Input { + Paths(String), + Clipboard, + Terminal(crate::clipboard::Payload), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Request { + pub key: Key, + pub id: u64, + pub input: Input, + pub as_photo: bool, + pub available_files: usize, + pub available_bytes: u64, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct Prepared { + pub attachments: Vec, + pub errors: Vec, + pub text: Option, +} + +/// Parse native file arguments; no shell expansion or execution takes place. +/// # Errors +/// Returns an argument or file-URL error. +pub fn paths(value: &str) -> Result> { + // A single existing path may contain spaces without shell quoting. This + // check runs on the preparation thread, never during a UI redraw. + let direct = Path::new(value.trim()); + if direct.is_file() { + return Ok(vec![direct.to_owned()]); + } + // A complete local URL has URL escaping, not shell escaping. In + // particular an apostrophe can remain literal inside a file URL. + if let Ok(url) = url::Url::parse(value.trim()) + && url.scheme() == "file" + && let Ok(path) = url.to_file_path() + && path.is_file() + { + return Ok(vec![path]); + } + #[cfg(unix)] + let words = yazi_shared::shell::unix::split(value, false)?.0; + #[cfg(windows)] + let words = yazi_shared::shell::windows::split(&format!("termgram {value}"))? + .into_iter() + .skip(1) + .collect::>(); + ensure!(!words.is_empty(), "Choose at least one file"); + words + .into_iter() + .map(|word| { + if word.starts_with("file:") { + url::Url::parse(&word)? + .to_file_path() + .map_err(|()| anyhow::anyhow!("File URL is not local to this machine")) + } else { + Ok(PathBuf::from(word)) + } + }) + .collect() +} + +fn hash_copy( + reader: &mut File, + mut output: Option<&mut File>, + limit: u64, +) -> Result<(u64, [u8; 32])> { + let mut hash = Sha256::new(); + let mut buffer = vec![0_u8; 64 * 1024]; + let mut total = 0_u64; + loop { + let count = reader.read(&mut buffer)?; + if count == 0 { + break; + } + total = total.saturating_add(count as u64); + ensure!( + total <= limit, + "Attachment exceeds the available staging budget" + ); + hash.update(&buffer[..count]); + if let Some(file) = &mut output { + file.write_all(&buffer[..count])?; + } + } + Ok((total, hash.finalize().into())) +} + +pub(crate) fn prepare(request: &Request) -> Result { + let Input::Paths(value) = &request.input else { + anyhow::bail!("native clipboard requires the preparation worker"); + }; + Ok(prepare_paths(paths(value)?, request)) +} + +pub(crate) fn prepare_paths(paths: Vec, request: &Request) -> Prepared { + let mut result = Prepared::default(); + let mut remaining = request.available_bytes; + for path in paths { + let item = (|| -> Result { + ensure!( + result.attachments.len() < request.available_files, + "At most {MAX_FILES} files per draft" + ); + let path = std::fs::canonicalize(&path)?; + ensure!(path.to_str().is_some(), "Choose a file with a Unicode name"); + let metadata = std::fs::metadata(&path)?; + ensure!(metadata.is_file(), "Only regular files can be attached"); + let mut file = File::open(&path)?; + let metadata = file.metadata()?; + ensure!(metadata.is_file(), "Only regular files can be attached"); + ensure!( + metadata.len() <= remaining, + "Draft attachments exceed the 2 GiB budget" + ); + let (size, digest) = hash_copy(&mut file, None, remaining)?; + let photo_supported = image::ImageReader::open(&path)? + .with_guessed_format()? + .format() + .is_some_and(|format| { + matches!( + format, + image::ImageFormat::Png + | image::ImageFormat::Jpeg + | image::ImageFormat::WebP + ) + }); + Ok(Attachment { + path, + size, + digest, + photo_supported, + as_photo: photo_supported && request.as_photo, + owned: false, + lease: None, + }) + })(); + match item { + Ok(attachment) => { + remaining = remaining.saturating_sub(attachment.size); + result.attachments.push(attachment); + } + Err(error) => result.errors.push(format!( + "{}: {error:#}", + sanitize_terminal_line(&path.display().to_string()) + )), + } + } + result +} + +/// A private immutable upload copy retains the reviewed bytes even if the +/// original file is replaced after confirmation. Its directory owns cleanup. +pub struct Upload { + _directory: tempfile::TempDir, + pub path: PathBuf, +} + +/// # Errors +/// Rejects missing/changed files before Telegram receives any bytes. +pub fn upload_copy(path: &Path, expected: [u8; 32]) -> Result { + ensure!( + std::fs::metadata(path) + .context("attachment is no longer available")? + .is_file(), + "attachment is no longer a regular file" + ); + let mut source = File::open(path).context("attachment is no longer available")?; + ensure!( + source.metadata()?.is_file(), + "attachment is no longer a regular file" + ); + let directory = tempfile::Builder::new() + .prefix("termgram-upload-") + .tempdir()?; + let destination = directory + .path() + .join(path.file_name().context("missing attachment name")?); + crate::config::prepare_private_file(&destination)?; + let mut output = File::options().write(true).open(&destination)?; + let (_, actual) = hash_copy(&mut source, Some(&mut output), MAX_BYTES)?; + ensure!( + actual == expected, + "Attachment changed since it was added; remove it and attach it again" + ); + output.flush()?; + Ok(Upload { + _directory: directory, + path: destination, + }) +} + +struct Work { + request: Request, + receiver: tokio::sync::oneshot::Receiver>, + deadline: Option, + expired: bool, +} + +pub(crate) struct Worker { + task: Option, + state: PathBuf, + owner: std::sync::Arc, +} + +impl Worker { + pub fn new(state: PathBuf, owner: std::sync::Arc) -> Self { + Self { + task: None, + state, + owner, + } + } + + pub fn start(&mut self, request: Request) -> Option { + let permit = PREPARATION.try_acquire(); + if self.task.is_some() || permit.is_err() { + return Some(request.failure( + "Another attachment or clipboard read is still being prepared".to_owned(), + )); + } + let state = self.state.clone(); + let owner = self.owner.clone(); + let work = request.clone(); + let (sender, receiver) = tokio::sync::oneshot::channel(); + // A hung OS clipboard call must neither accumulate workers nor keep + // Tokio's runtime shutdown waiting after the terminal has been restored. + if let Err(error) = std::thread::Builder::new() + .name("termgram-attachments".to_owned()) + .spawn(move || { + let _permit = permit; + let _owner = owner; + let result = match &work.input { + Input::Paths(_) => prepare(&work), + Input::Clipboard => crate::clipboard::read(&state, &work), + Input::Terminal(payload) => { + crate::clipboard::prepare_payload(&state, &work, payload) + } + }; + let _ = sender.send(result); + }) + { + return Some(request.failure(error.to_string())); + } + self.task = Some(Work { + deadline: matches!(request.input, Input::Clipboard) + .then(|| tokio::time::Instant::now() + std::time::Duration::from_secs(10)), + expired: false, + request, + receiver, + }); + None + } + + pub async fn next(&mut self) -> Option { + let Some(work) = &mut self.task else { + return std::future::pending().await; + }; + let result = tokio::select! { + result = &mut work.receiver => result, + () = async { + if let Some(deadline) = work.deadline { tokio::time::sleep_until(deadline).await; } + else { std::future::pending::<()>().await; } + } => { + work.expired = true; + work.deadline = None; + return Some(work.request.failure("Clipboard read timed out; its backend must finish before another read can start".to_owned())); + } + }; + let work = self.task.take().expect("active preparation"); + if work.expired { + return None; + } + Some(NetworkEvent::AttachmentsPrepared { + key: work.request.key, + request_id: work.request.id, + result: result + .map_err(anyhow::Error::from) + .and_then(std::convert::identity) + .map_err(|error| format!("{error:#}")), + }) + } +} + +impl Request { + pub(crate) fn failure(&self, error: String) -> NetworkEvent { + NetworkEvent::AttachmentsPrepared { + key: self.key, + request_id: self.id, + result: Err(error), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn expired_preparation_stays_bounded_and_discards_its_late_result() { + let file = tempfile::NamedTempFile::new().unwrap(); + let mut worker = Worker::new( + file.path().to_owned(), + std::sync::Arc::new(file.reopen().unwrap()), + ); + let request = Request { + key: Key { + account: 1, + chat: 2, + topic: 0, + }, + id: 1, + input: Input::Clipboard, + as_photo: true, + available_files: MAX_FILES, + available_bytes: MAX_BYTES, + }; + let (sender, receiver) = tokio::sync::oneshot::channel(); + worker.task = Some(Work { + request: request.clone(), + receiver, + deadline: Some(tokio::time::Instant::now()), + expired: false, + }); + assert!(matches!( + worker.next().await, + Some(NetworkEvent::AttachmentsPrepared { result: Err(_), .. }) + )); + assert!( + worker.start(request).is_some(), + "timeout does not free a still-running backend" + ); + sender.send(Ok(Prepared::default())).unwrap(); + assert!( + worker.next().await.is_none(), + "late completion never reaches the UI" + ); + assert!(worker.task.is_none()); + } + + #[test] + fn reviewed_bytes_are_copied_privately_and_changes_rejected() { + let root = tempfile::tempdir().unwrap(); + let original = root.path().join("a '猫' file.txt"); + std::fs::write(&original, b"reviewed bytes").unwrap(); + let prepared = prepare(&Request { + key: Key { + account: 1, + chat: 2, + topic: 0, + }, + id: 1, + input: Input::Paths(url::Url::from_file_path(&original).unwrap().to_string()), + as_photo: true, + available_files: MAX_FILES, + available_bytes: MAX_BYTES, + }) + .unwrap(); + assert!(prepared.errors.is_empty(), "{:?}", prepared.errors); + let file = &prepared.attachments[0]; + let upload = upload_copy(&file.path, file.digest).unwrap(); + let copy = upload.path.clone(); + std::fs::write(&original, b"changed bytes").unwrap(); + assert_eq!(std::fs::read(©).unwrap(), b"reviewed bytes"); + assert!(upload_copy(&file.path, file.digest).is_err()); + drop(upload); + assert!(!copy.exists()); + assert_eq!(std::fs::read(&original).unwrap(), b"changed bytes"); + } +} diff --git a/src/statusline.rs b/src/statusline.rs new file mode 100644 index 0000000..40da9ef --- /dev/null +++ b/src/statusline.rs @@ -0,0 +1,104 @@ +//! Declarative status items and ephemeral observations; no Lua runs while drawing. + +use serde::Deserialize; +use std::time::{Duration, Instant}; + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum Item { + Mode, + App, + Account, + Connection, + Latency, + Dc, + Position, + Message, + Notifications, + Context, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Configuration { + pub enabled: bool, + pub left: Vec, + pub right: Vec, +} + +impl Default for Configuration { + fn default() -> Self { + Self { + enabled: true, + left: vec![ + Item::Mode, + Item::App, + Item::Account, + Item::Message, + Item::Context, + ], + right: vec![ + Item::Notifications, + Item::Connection, + Item::Latency, + Item::Dc, + Item::Position, + ], + } + } +} + +impl Configuration { + pub(crate) fn validate(&self) -> anyhow::Result<()> { + let mut seen = Vec::new(); + for item in self.left.iter().chain(&self.right) { + anyhow::ensure!( + !seen.contains(item), + "statusline item {item:?} appears more than once" + ); + seen.push(*item); + } + Ok(()) + } + #[must_use] + pub fn measures_latency(&self) -> bool { + self.enabled + && self + .left + .iter() + .chain(&self.right) + .any(|item| *item == Item::Latency) + } +} + +/// Includes SDK scheduling/retries and network time, not message delivery latency. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Latency { + pub started: Instant, + pub elapsed: Duration, +} + +impl Latency { + #[must_use] + pub fn expired(self) -> bool { + self.started.elapsed() >= Duration::from_secs(90) + } +} + +#[derive(Clone, Debug)] +pub struct Metrics { + pub dc_id: Option, + pub latency: Option, + /// Reject measurements begun before a disconnect, including in-flight probes. + pub reset_at: Instant, +} + +impl Default for Metrics { + fn default() -> Self { + Self { + dc_id: None, + latency: None, + reset_at: Instant::now(), + } + } +} diff --git a/src/telegram/chat_info.rs b/src/telegram/chat_info.rs new file mode 100644 index 0000000..f037d31 --- /dev/null +++ b/src/telegram/chat_info.rs @@ -0,0 +1,242 @@ +use crate::chat_info::{Content, Info, Restriction}; +use anyhow::{Context, Result}; +use grammers_client::{Client, InvocationError, peer::Peer, tl}; +use grammers_session::types::{PeerKind, PeerRef}; + +pub(super) async fn load(client: &Client, peer: PeerRef) -> Result { + if peer.id.kind() == PeerKind::User { + user_info(client, peer).await + } else { + group_info(client, peer).await + } +} + +async fn user_info(client: &Client, peer: PeerRef) -> Result { + let tl::enums::users::UserFull::Full(full) = client + .invoke(&tl::functions::users::GetFullUser { id: peer.into() }) + .await?; + let raw = full + .users + .into_iter() + .find(|user| user.id() == peer.id.bare_id_unchecked()) + .context("User details did not include this user")?; + let resolved = Peer::User(grammers_client::peer::User::from_raw(client, raw)); + let tl::enums::UserFull::Full(full) = full.full_user; + let mut info = Info { + title: super::peer_display_name(&resolved), + username: resolved.username().map(str::to_owned), + about: full.about.unwrap_or_default(), + role: "Private conversation".to_owned(), + ..Info::default() + }; + if full.blocked { + block(&mut info, "Unblock this user in Telegram before sending"); + } + if full.send_paid_messages_stars.is_some_and(|n| n > 0) { + block( + &mut info, + "This user requires paid messages; use the official client", + ); + } + if let Peer::User(user) = resolved + && matches!(user.raw, tl::enums::User::User(u) if u.deleted) + { + block(&mut info, "This account was deleted"); + } + Ok(info) +} + +async fn group_info(client: &Client, peer: PeerRef) -> Result { + let tl::enums::messages::ChatFull::Full(full) = if peer.id.kind() == PeerKind::Channel { + client + .invoke(&tl::functions::channels::GetFullChannel { + channel: peer.into(), + }) + .await? + } else { + client + .invoke(&tl::functions::messages::GetFullChat { + chat_id: peer.id.bare_id_unchecked(), + }) + .await? + }; + let raw = full + .chats + .into_iter() + .find(|chat| chat.id() == peer.id.bare_id_unchecked()) + .context("Chat details did not include its current permissions")?; + let boosted = matches!(&full.full_chat, tl::enums::ChatFull::ChannelFull(full) if full.boosts_unrestrict.is_some_and(|required| required > 0 && full.boosts_applied.unwrap_or(0) >= required)); + let mut info = group_permissions(&raw, boosted); + match full.full_chat { + tl::enums::ChatFull::Full(full) => info.about = full.about, + tl::enums::ChatFull::ChannelFull(full) => { + info.about = full.about; + info.members = full.participants_count.and_then(|n| u32::try_from(n).ok()); + let exempt = matches!(&raw, tl::enums::Chat::Channel(c) if c.creator || c.admin_rights.is_some()) + || full.boosts_unrestrict.is_some_and(|required| { + required > 0 && full.boosts_applied.unwrap_or(0) >= required + }); + if !exempt { + info.slow_seconds = u32::try_from(full.slowmode_seconds.unwrap_or(0)).unwrap_or(0); + info.next_send_at = i64::from(full.slowmode_next_send_date.unwrap_or(0)); + } + if full.send_paid_messages_stars.is_some_and(|n| n > 0) { + block( + &mut info, + "This group requires paid messages; use the official client", + ); + } + } + } + Ok(info) +} + +fn group_permissions(raw: &tl::enums::Chat, boosted: bool) -> Info { + let mut info = Info::default(); + match raw { + tl::enums::Chat::Chat(chat) => { + info.title.clone_from(&chat.title); + info.members = u32::try_from(chat.participants_count).ok(); + let admin = chat.creator || chat.admin_rights.is_some(); + info.role = role(chat.creator, admin, chat.left); + if chat.left || chat.deactivated { + block( + &mut info, + "This group is no longer writable; reopen its current invite or migrated group", + ); + } + if !admin { + restrictions( + &mut info, + chat.default_banned_rights.as_ref(), + "Group permissions", + false, + ); + } + } + tl::enums::Chat::Channel(chat) => { + info.title.clone_from(&chat.title); + info.username.clone_from(&chat.username); + let admin = chat.creator || chat.admin_rights.is_some(); + info.role = role(chat.creator, admin, chat.left); + if chat.broadcast { + block(&mut info, "Broadcast channels are not supported"); + } + if chat.left && chat.join_to_send { + block(&mut info, "Join this group before sending"); + } + if chat.gigagroup && !admin { + block(&mut info, "Only administrators can send in this group"); + } + if !admin { + restrictions( + &mut info, + chat.banned_rights.as_ref(), + "Your permissions", + true, + ); + } + if !admin && !boosted { + restrictions( + &mut info, + chat.default_banned_rights.as_ref(), + "Group permissions", + false, + ); + } + } + _ => { + "Unavailable group".clone_into(&mut info.title); + block(&mut info, "You cannot access this group"); + } + } + info +} + +fn role(creator: bool, admin: bool, left: bool) -> String { + if creator { + "Owner" + } else if admin { + "Administrator" + } else if left { + "Not a member" + } else { + "Member" + } + .to_owned() +} + +fn block(info: &mut Info, reason: &str) { + info.restrictions.push(Restriction { + content: None, + reason: reason.to_owned(), + until: 0, + }); +} + +fn restrictions( + info: &mut Info, + rights: Option<&tl::enums::ChatBannedRights>, + source: &str, + personal: bool, +) { + let Some(tl::enums::ChatBannedRights::Rights(rights)) = rights else { + return; + }; + let until = if personal { + i64::from(rights.until_date).max(0) + } else { + 0 + }; + let all = rights.view_messages || rights.send_messages; + for (content, blocked, label) in [ + (Content::Text, all || rights.send_plain, "text messages"), + ( + Content::Photo, + all || rights.send_media || rights.send_photos, + "photos", + ), + ( + Content::File, + all || rights.send_media || rights.send_docs, + "files", + ), + ] { + if blocked { + info.restrictions.push(Restriction { + content: Some(content), + reason: format!("{source}: sending {label} is restricted"), + until, + }); + } + } +} + +/// Preserve raw RPC detail for unknown failures, but describe common write restrictions. +pub(super) fn send_error(error: &anyhow::Error) -> String { + let Some(InvocationError::Rpc(rpc)) = error.downcast_ref::() else { + return format!("{error:#}"); + }; + let hint = match rpc.name.as_str() { + "SLOWMODE_WAIT" => { + return format!( + "Slow mode: wait {} s before sending again", + rpc.value.unwrap_or(0) + ); + } + "CHAT_WRITE_FORBIDDEN" | "CHAT_SEND_PLAIN_FORBIDDEN" => { + "You cannot send text in this chat; use :info to check permissions" + } + "CHAT_SEND_MEDIA_FORBIDDEN" | "CHAT_SEND_PHOTOS_FORBIDDEN" | "CHAT_SEND_DOCS_FORBIDDEN" => { + "This media type is restricted in the chat; use :info to check permissions" + } + "USER_BANNED_IN_CHANNEL" => "You are restricted from sending in this group", + "USER_IS_BLOCKED" | "YOU_BLOCKED_USER" => { + "This user is blocked; check Telegram's block settings" + } + "CHAT_GUEST_SEND_FORBIDDEN" => "Join this group before sending", + "PRIVACY_PREMIUM_REQUIRED" => "This recipient requires Telegram Premium to contact them", + _ => return format!("{error:#}"), + }; + format!("{hint} ({})", rpc.name) +} diff --git a/src/telegram/deletion.rs b/src/telegram/deletion.rs new file mode 100644 index 0000000..172dbb4 --- /dev/null +++ b/src/telegram/deletion.rs @@ -0,0 +1,115 @@ +use super::message_actions::{fetch, revision}; +use crate::deletion::Scope; +use anyhow::{Result, ensure}; +use grammers_client::{Client, message::Message, peer::Peer, tl}; +use grammers_session::types::{PeerId, PeerKind, PeerRef}; + +pub(super) struct Review { + pub message: Message, + pub revision: [u8; 32], + pub scopes: Vec, +} + +pub(super) async fn review( + client: &Client, + peer: PeerRef, + id: i32, + self_id: i64, +) -> Result { + let message = fetch(client, peer, id).await?; + let tl::enums::Message::Message(raw) = &message.raw else { + anyhow::bail!("Service messages cannot be deleted here") + }; + let resolved = client.resolve_peer(peer).await?; + let channel = match &resolved { + Peer::Channel(channel) => Some(&channel.raw), + Peer::Group(group) => match &group.raw { + tl::enums::Chat::Channel(channel) => Some(channel), + _ => None, + }, + Peer::User(_) => None, + }; + let scopes = if peer.id.kind() == PeerKind::Channel { + ensure!(id != 1, "The first channel message cannot be deleted"); + let allowed = channel.is_some_and(|channel| { + let rights = channel.admin_rights.as_ref().map(|rights| { + let tl::enums::ChatAdminRights::Rights(rights) = rights; + rights + }); + channel.creator + || rights.is_some_and(|rights| rights.delete_messages) + || (raw.out && (!raw.post || rights.is_some_and(|rights| rights.post_messages))) + }); + ensure!( + allowed, + "You do not have permission to delete this message for everyone" + ); + vec![Scope::Everyone] + } else if peer.id == PeerId::user_unchecked(self_id) { + vec![Scope::OnlyMe] + } else { + let tl::enums::Config::Config(config) = + client.invoke(&tl::functions::help::GetConfig {}).await?; + let age = i64::from(config.date) - i64::from(raw.date); + let revoke = match &resolved { + Peer::User(user) => { + let accessible = matches!(&user.raw, tl::enums::User::User(user) if !user.deleted && (!user.bot || user.support)); + accessible + && age < i64::from(config.revoke_pm_time_limit) + && (raw.out || config.revoke_pm_inbox) + } + Peer::Group(group) => { + let admin = matches!(&group.raw, tl::enums::Chat::Chat(chat) if chat.creator || chat.admin_rights.as_ref().is_some_and(|rights| { let tl::enums::ChatAdminRights::Rights(rights) = rights; rights.delete_messages })); + age < i64::from(config.revoke_time_limit) && (raw.out || admin) + } + Peer::Channel(_) => false, + }; + let mut scopes = vec![Scope::OnlyMe]; + if revoke && !raw.post { + scopes.push(Scope::Everyone); + } + scopes + }; + Ok(Review { + revision: revision(&message), + message, + scopes, + }) +} + +pub(super) async fn delete( + client: &Client, + peer: PeerRef, + id: i32, + self_id: i64, + expected: [u8; 32], + scope: Scope, +) -> Result<()> { + let latest = review(client, peer, id, self_id).await?; + ensure!( + latest.revision == expected, + "Message changed; reopen deletion to review the latest message" + ); + ensure!( + latest.scopes.contains(&scope), + "Deletion permissions changed; reopen deletion to review the available scope" + ); + match scope { + Scope::OnlyMe => { + ensure!( + peer.id.kind() != PeerKind::Channel, + "Supergroups and channels only support deleting for everyone" + ); + client + .invoke(&tl::functions::messages::DeleteMessages { + revoke: false, + id: vec![id], + }) + .await?; + } + Scope::Everyone => { + client.delete_messages(peer, &[id]).await?; + } + } + Ok(()) +} diff --git a/src/telegram/editing.rs b/src/telegram/editing.rs new file mode 100644 index 0000000..c38e29c --- /dev/null +++ b/src/telegram/editing.rs @@ -0,0 +1,280 @@ +//! Existing Grammers edit RPC, with fresh permissions and a captured revision. +use super::message_actions::{fetch, revision}; +use crate::{editing::Source, model::sanitize_terminal_text}; +use anyhow::{Result, ensure}; +use grammers_client::{ + Client, + message::{InputMessage, Message}, + peer::Peer, + tl, +}; +use grammers_session::types::{PeerId, PeerRef}; + +async fn original(client: &Client, peer: PeerRef, id: i32, self_id: i64) -> Result { + let message = fetch(client, peer, id).await?; + let tl::enums::Message::Message(raw) = &message.raw else { + anyhow::bail!("Service messages cannot be edited") + }; + ensure!( + raw.fwd_from.is_none() && raw.via_bot_id.is_none(), + "Forwarded and inline-bot messages cannot be edited" + ); + let channel = match message.peer() { + Some(Peer::Channel(channel)) => Some(&channel.raw), + Some(Peer::Group(group)) => match &group.raw { + tl::enums::Chat::Channel(channel) => Some(channel), + _ => None, + }, + _ => None, + }; + let saved = peer.id == PeerId::user_unchecked(self_id); + let administer = channel.is_some_and(|channel| { + channel.creator + || channel.admin_rights.as_ref().is_some_and(|rights| { + let tl::enums::ChatAdminRights::Rights(rights) = rights; + rights.edit_messages + }) + }); + ensure!( + saved || raw.out || (raw.post && administer), + "Only the author or an authorized channel administrator can edit this message" + ); + let indefinite = saved + || channel.is_some_and(|channel| { + channel.creator + || channel.admin_rights.as_ref().is_some_and(|rights| { + let tl::enums::ChatAdminRights::Rights(rights) = rights; + if channel.megagroup { + rights.pin_messages + } else { + rights.edit_messages + } + }) + }); + if !indefinite { + let tl::enums::Config::Config(config) = + client.invoke(&tl::functions::help::GetConfig {}).await?; + ensure!( + i64::from(config.date) - i64::from(raw.date) < i64::from(config.edit_time_limit), + "Telegram's editing time limit has expired" + ); + } + match &raw.media { + None | Some(tl::enums::MessageMedia::Empty | tl::enums::MessageMedia::WebPage(_)) => {} + Some(tl::enums::MessageMedia::Photo(media)) => ensure!( + media.ttl_seconds.is_none(), + "Expiring photos cannot be edited here" + ), + Some(tl::enums::MessageMedia::Document(media)) => { + ensure!( + media.ttl_seconds.is_none(), + "Expiring media cannot be edited here" + ); + if let Some(tl::enums::Document::Document(document)) = &media.document { + ensure!( + !document.attributes.iter().any(|attribute| matches!( + attribute, + tl::enums::DocumentAttribute::Sticker(_) + | tl::enums::DocumentAttribute::Video( + tl::types::DocumentAttributeVideo { + round_message: true, + .. + } + ) + )), + "This media does not support an editable caption" + ); + } + } + _ => anyhow::bail!("This message type has no editable text or caption"), + } + Ok(message) +} + +pub(super) async fn load(client: &Client, peer: PeerRef, id: i32, self_id: i64) -> Result { + let message = original(client, peer, id, self_id).await?; + Ok(Source { + message_id: id, + text: sanitize_terminal_text(message.text()), + revision: revision(&message), + caption: matches!(&message.raw, tl::enums::Message::Message(raw) if matches!(raw.media, Some(tl::enums::MessageMedia::Photo(_) | tl::enums::MessageMedia::Document(_)))), + }) +} + +pub(super) async fn save( + client: &Client, + peer: PeerRef, + id: i32, + self_id: i64, + expected: [u8; 32], + text: &str, +) -> Result>> { + let message = original(client, peer, id, self_id).await?; + // A previous attempt may have succeeded just before disconnection. + if message.text() == text { + return Ok(Some(Box::new(message))); + } + ensure!( + revision(&message) == expected, + "Message changed in another client; keep your edit, or discard it and reopen the latest original" + ); + let entities = remap_entities( + message.text(), + text, + message.fmt_entities().cloned().unwrap_or_default(), + ); + let mut input = InputMessage::new().text(text).fmt_entities(entities); + if let tl::enums::Message::Message(raw) = &message.raw { + input = input.invert_media(raw.invert_media).link_preview(matches!( + raw.media, + Some(tl::enums::MessageMedia::WebPage(_)) + )); + } + client.edit_message(peer, id, input).await?; + // grammers-mtsender's process_own_update forwards the RPC's Updates to + // the existing ordered stream, including its PTS. Do not manufacture a + // second update from a later history snapshot or advance that cursor here. + Ok(None) +} + +fn remap_entities( + old: &str, + new: &str, + entities: Vec, +) -> Vec { + let prefix_chars = old + .chars() + .zip(new.chars()) + .take_while(|(a, b)| a == b) + .count(); + let prefix: i32 = old + .chars() + .take(prefix_chars) + .map(|c| i32::try_from(c.len_utf16()).unwrap_or(0)) + .sum(); + let old_rest: String = old.chars().skip(prefix_chars).collect(); + let new_rest: String = new.chars().skip(prefix_chars).collect(); + let suffix: i32 = old_rest + .chars() + .rev() + .zip(new_rest.chars().rev()) + .take_while(|(a, b)| a == b) + .map(|(c, _)| i32::try_from(c.len_utf16()).unwrap_or(0)) + .sum(); + let old_end = i32::try_from(old.encode_utf16().count()).unwrap_or(i32::MAX) - suffix; + let new_end = i32::try_from(new.encode_utf16().count()).unwrap_or(i32::MAX) - suffix; + let delta = new_end - old_end; + entities + .into_iter() + .filter_map(|mut entity| { + let (offset, length) = (entity.offset(), entity.length()); + let end = offset.checked_add(length)?; + let style = matches!( + entity, + tl::enums::MessageEntity::Bold(_) + | tl::enums::MessageEntity::Italic(_) + | tl::enums::MessageEntity::Underline(_) + | tl::enums::MessageEntity::Strike(_) + | tl::enums::MessageEntity::Code(_) + | tl::enums::MessageEntity::Pre(_) + | tl::enums::MessageEntity::Spoiler(_) + | tl::enums::MessageEntity::Blockquote(_) + ); + let (offset, length) = if end <= prefix { + (offset, length) + } else if offset >= old_end { + (offset + delta, length) + } else if style && offset <= prefix && end >= old_end { + (offset, length + delta) + } else { + return None; + }; + if offset < 0 || length <= 0 { + return None; + } + set_range(&mut entity, offset, length); + Some(entity) + }) + .collect() +} + +fn set_range(entity: &mut tl::enums::MessageEntity, offset: i32, length: i32) { + macro_rules! ranges { + ($($variant:ident),+) => { match entity { $(tl::enums::MessageEntity::$variant(value) => { value.offset = offset; value.length = length; }),+ } }; + } + ranges!( + Unknown, + Mention, + Hashtag, + BotCommand, + Url, + Email, + Bold, + Italic, + Code, + Pre, + TextUrl, + MentionName, + InputMessageEntityMentionName, + Phone, + Cashtag, + Underline, + Strike, + BankCard, + Spoiler, + CustomEmoji, + Blockquote, + FormattedDate, + DiffInsert, + DiffReplace, + DiffDelete + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unicode_edits_keep_untouched_entities_and_drop_changed_link_targets() { + let entities = vec![ + tl::types::MessageEntityBold { + offset: 0, + length: 10, + } + .into(), + tl::types::MessageEntityTextUrl { + offset: 4, + length: 2, + url: "https://example.com".to_owned(), + } + .into(), + tl::types::MessageEntityCustomEmoji { + offset: 8, + length: 2, + document_id: 42, + } + .into(), + ]; + let mapped = remap_entities("你好🙂abXY🙂", "你好🙂differentXY🙂", entities); + assert_eq!(mapped.len(), 2); + assert!( + matches!(&mapped[0], tl::enums::MessageEntity::Bold(value) if value.offset == 0 && value.length == 17) + ); + assert!( + matches!(&mapped[1], tl::enums::MessageEntity::CustomEmoji(value) if value.offset == 15 && value.length == 2 && value.document_id == 42) + ); + let after = remap_entities( + "🙂Hello", + "界🙂Hello", + vec![ + tl::types::MessageEntityItalic { + offset: 2, + length: 5, + } + .into(), + ], + ); + assert_eq!((after[0].offset(), after[0].length()), (3, 5)); + } +} diff --git a/src/telegram/entities.rs b/src/telegram/entities.rs new file mode 100644 index 0000000..6f3bf84 --- /dev/null +++ b/src/telegram/entities.rs @@ -0,0 +1,138 @@ +use crate::{ + entities::{Entity, Kind}, + model::sanitize_terminal_line, +}; +use grammers_client::tl; +use std::collections::BTreeMap; +use unicode_segmentation::UnicodeSegmentation; + +/// Telegram entities address the original text in UTF-16. Sanitize once while +/// recording boundaries so tabs and terminal-control removal cannot shift a +/// later entity onto unrelated text. Ignore malformed/split-surrogate ranges. +pub(super) fn map(text: &str, entities: &[tl::enums::MessageEntity]) -> (String, Vec) { + if entities.is_empty() { + return (crate::model::sanitize_terminal_text(text), Vec::new()); + } + let mut boundaries = BTreeMap::new(); + let clean = crate::model::sanitize_text_with_offsets(text, |units, byte| { + boundaries.insert(units, byte); + }); + let graphemes: Vec<_> = clean + .grapheme_indices(true) + .map(|(byte, _)| byte) + .chain(std::iter::once(clean.len())) + .collect(); + let entities = entities + .iter() + .filter_map(|entity| { + let start = usize::try_from(entity.offset()).ok()?; + let length = usize::try_from(entity.length()).ok()?; + let end = start.checked_add(length)?; + let mut range = *boundaries.get(&start)?..*boundaries.get(&end)?; + if range.is_empty() { + return None; + } + // A formatting boundary must never split a terminal grapheme. This + // also conceals a combining mark's base instead of revealing it. + range.start = graphemes[graphemes + .partition_point(|byte| *byte <= range.start) + .saturating_sub(1)]; + range.end = graphemes[graphemes.partition_point(|byte| *byte < range.end)]; + let kind = match entity { + tl::enums::MessageEntity::Bold(_) => Kind::Bold, + tl::enums::MessageEntity::Italic(_) => Kind::Italic, + tl::enums::MessageEntity::Underline(_) => Kind::Underline, + tl::enums::MessageEntity::Strike(_) => Kind::Strike, + tl::enums::MessageEntity::Code(_) => Kind::Code, + tl::enums::MessageEntity::Pre(value) => Kind::Pre { + language: sanitize_terminal_line(&value.language) + .chars() + .take(32) + .collect(), + }, + tl::enums::MessageEntity::Blockquote(value) => Kind::Quote { + collapsed: value.collapsed, + }, + tl::enums::MessageEntity::Spoiler(_) => Kind::Spoiler, + tl::enums::MessageEntity::TextUrl(_) + | tl::enums::MessageEntity::Url(_) + | tl::enums::MessageEntity::Email(_) + | tl::enums::MessageEntity::Phone(_) => Kind::Link, + tl::enums::MessageEntity::Mention(_) + | tl::enums::MessageEntity::MentionName(_) + | tl::enums::MessageEntity::InputMessageEntityMentionName(_) => Kind::Mention, + tl::enums::MessageEntity::Hashtag(_) + | tl::enums::MessageEntity::Cashtag(_) + | tl::enums::MessageEntity::BotCommand(_) => Kind::Tag, + // Custom emoji keep Telegram's Unicode fallback. Newer entity + // types remain readable without inventing unsupported actions. + _ => return None, + }; + Some(Entity { range, kind }) + }) + .collect(); + (clean, entities) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn utf16_entities_survive_controls_tabs_astral_text_and_combining_marks() { + let text = "🙂\t\u{1b}[31me\u{301} secret\u{1b}[0m"; + let (clean, entities) = map( + text, + &[ + tl::types::MessageEntityBold { + offset: 8, + length: 2, + } + .into(), + tl::types::MessageEntityItalic { + offset: 9, + length: 1, + } + .into(), + tl::types::MessageEntitySpoiler { + offset: 11, + length: 6, + } + .into(), + tl::types::MessageEntityUnderline { + offset: 1, + length: 1, + } + .into(), + tl::types::MessageEntityCode { + offset: -1, + length: 3, + } + .into(), + tl::types::MessageEntityCode { + offset: 100, + length: i32::MAX, + } + .into(), + ], + ); + assert_eq!(clean, "🙂 e\u{301} secret"); + assert_eq!(entities.len(), 3); + assert_eq!(&clean[entities[0].range.clone()], "e\u{301}"); + assert_eq!(entities[0].range, entities[1].range); + assert_eq!(&clean[entities[2].range.clone()], "secret"); + assert_eq!( + crate::entities::conceal(&clean, &entities), + "🙂 e\u{301} ▨▨▨▨▨▨" + ); + let (clean, entities) = map( + "x\u{200b}y", + &[tl::types::MessageEntitySpoiler { + offset: 1, + length: 1, + } + .into()], + ); + assert_eq!(crate::entities::conceal(&clean, &entities), "xy"); + } +} diff --git a/src/telegram/folders.rs b/src/telegram/folders.rs new file mode 100644 index 0000000..e67666f --- /dev/null +++ b/src/telegram/folders.rs @@ -0,0 +1,121 @@ +//! Small protocol adapter for native folders and inherited notification settings. +use crate::{ + folders::{ChatMembership, Folder}, + model::ChatId, + model::sanitize_terminal_line, +}; +use anyhow::Result; +use grammers_client::{Client, tl}; +use grammers_session::types::PeerId; + +pub(super) async fn load(client: &Client, self_id: i64) -> Result> { + let tl::enums::messages::DialogFilters::Filters(result) = client + .invoke(&tl::functions::messages::GetDialogFilters {}) + .await?; + let mut folders: Vec<_> = result + .filters + .into_iter() + .map(|filter| match filter { + tl::enums::DialogFilter::Default => Folder::all(), + tl::enums::DialogFilter::Filter(filter) => { + let tl::enums::TextWithEntities::Entities(title) = filter.title; + Folder { + id: filter.id, + title: sanitize_terminal_line(&title.text), + pinned: peer_ids(filter.pinned_peers, self_id), + include: peer_ids(filter.include_peers, self_id), + exclude: peer_ids(filter.exclude_peers, self_id), + contacts: filter.contacts, + non_contacts: filter.non_contacts, + bots: filter.bots, + groups: filter.groups, + broadcasts: filter.broadcasts, + exclude_muted: filter.exclude_muted, + exclude_read: filter.exclude_read, + exclude_archived: filter.exclude_archived, + } + } + tl::enums::DialogFilter::Chatlist(filter) => { + let tl::enums::TextWithEntities::Entities(title) = filter.title; + Folder { + id: filter.id, + title: sanitize_terminal_line(&title.text), + pinned: peer_ids(filter.pinned_peers, self_id), + include: peer_ids(filter.include_peers, self_id), + ..Folder::default() + } + } + }) + .collect(); + if !folders.iter().any(|folder| folder.id == 0) { + folders.insert(0, Folder::all()); + } + folders.push(Folder::archive()); + Ok(folders) +} + +fn peer_ids(peers: Vec, self_id: i64) -> Vec { + peers + .into_iter() + .filter_map(|peer| peer_id(&peer, self_id)) + .collect() +} + +pub(super) fn peer_id(peer: &tl::enums::InputPeer, self_id: i64) -> Option { + match peer { + tl::enums::InputPeer::User(peer) => PeerId::user(peer.user_id), + tl::enums::InputPeer::Chat(peer) => PeerId::chat(peer.chat_id), + tl::enums::InputPeer::Channel(peer) => PeerId::channel(peer.channel_id), + tl::enums::InputPeer::UserFromMessage(peer) => PeerId::user(peer.user_id), + tl::enums::InputPeer::ChannelFromMessage(peer) => PeerId::channel(peer.channel_id), + tl::enums::InputPeer::PeerSelf => PeerId::user(self_id), + tl::enums::InputPeer::Empty => None, + } + .and_then(PeerId::bot_api_dialog_id) +} + +pub(super) async fn default_mutes(client: &Client) -> Result<(i64, i64)> { + let (users, groups) = tokio::try_join!( + client.invoke(&tl::functions::account::GetNotifySettings { + peer: tl::enums::InputNotifyPeer::InputNotifyUsers + }), + client.invoke(&tl::functions::account::GetNotifySettings { + peer: tl::enums::InputNotifyPeer::InputNotifyChats + }), + )?; + let tl::enums::PeerNotifySettings::Settings(users) = users; + let tl::enums::PeerNotifySettings::Settings(groups) = groups; + Ok(( + i64::from(users.mute_until.unwrap_or(0)), + i64::from(groups.mute_until.unwrap_or(0)), + )) +} + +pub(super) fn membership( + dialog: &grammers_client::peer::Dialog, + defaults: (i64, i64), +) -> ChatMembership { + let tl::enums::Dialog::Dialog(raw) = &dialog.raw else { + return ChatMembership::default(); + }; + let user = match dialog.peer() { + grammers_client::peer::Peer::User(user) => Some(user), + _ => None, + }; + let tl::enums::PeerNotifySettings::Settings(notify) = &raw.notify_settings; + ChatMembership { + contact: user.is_some_and(grammers_client::peer::User::contact), + bot: user.is_some_and(grammers_client::peer::User::is_bot), + archived: raw.folder_id == Some(1), + unread_mark: raw.unread_mark, + mentions: raw.unread_mentions_count > 0, + mute_until: notify.mute_until.map_or( + if user.is_some() { + defaults.0 + } else { + defaults.1 + }, + i64::from, + ), + } +} diff --git a/src/telegram/forwarding.rs b/src/telegram/forwarding.rs new file mode 100644 index 0000000..71c37c3 --- /dev/null +++ b/src/telegram/forwarding.rs @@ -0,0 +1,121 @@ +use super::{ + message_actions::{fetch, revision}, + peer_display_name, + sharing::check_protection, +}; +use crate::model::{Chat, ChatKind}; +use anyhow::{Context, Result, ensure}; +use grammers_client::{Client, message::Message, peer::Peer, tl}; +use grammers_session::types::PeerRef; + +pub(super) struct Destination { + pub chat: Chat, + pub peer: PeerRef, + pub name: String, +} + +pub(super) struct Review { + pub original: Message, + pub destination: Destination, + pub revision: [u8; 32], + pub random_id: i64, +} + +pub(super) async fn destination( + client: &Client, + id: i64, + peer: Option, + self_id: i64, +) -> Result { + let resolved = if id == self_id { + Peer::User(client.get_me().await?) + } else { + client + .resolve_peer(peer.context("The target chat is no longer available")?) + .await? + }; + let peer = resolved + .to_ref() + .await + .map_err(anyhow::Error::from_boxed)? + .context("Target chat cannot be addressed")?; + ensure!( + peer.id.bot_api_dialog_id() == Some(id), + "Target chat identity changed" + ); + let name = peer_display_name(&resolved); + let chat = Chat { + id, + title: if id == self_id { + "Saved Messages".to_owned() + } else { + name.clone() + }, + kind: match resolved { + Peer::User(_) => ChatKind::Direct, + Peer::Group(_) => ChatKind::Group, + Peer::Channel(_) => ChatKind::Channel, + }, + membership: crate::folders::ChatMembership::default(), + unread: 0, + read_inbox_max_id: None, + last_message: String::new(), + last_message_id: None, + last_activity: None, + }; + Ok(Destination { chat, peer, name }) +} + +async fn original(client: &Client, peer: PeerRef, id: i32) -> Result { + let message = fetch(client, peer, id).await?; + check_protection(client, peer, &message).await?; + let tl::enums::Message::Message(raw) = &message.raw else { + unreachable!("protection rejects service messages") + }; + ensure!( + !matches!(&raw.media, + Some(tl::enums::MessageMedia::Photo(photo)) if photo.ttl_seconds.is_some()) + && !matches!(&raw.media, Some(tl::enums::MessageMedia::Document(document)) if document.ttl_seconds.is_some()), + "Expiring media cannot be forwarded" + ); + Ok(message) +} + +pub(super) async fn review( + client: &Client, + source: PeerRef, + id: i32, + target: Destination, +) -> Result { + let original = original(client, source, id).await?; + let random_id = i64::from_ne_bytes( + getrandom::u64() + .map_err(|error| anyhow::anyhow!("Cannot prepare Telegram request ID: {error}"))? + .to_ne_bytes(), + ); + Ok(Review { + revision: revision(&original), + original, + destination: target, + random_id, + }) +} + +pub(super) async fn send( + client: &Client, + source: PeerRef, + id: i32, + target: PeerRef, + expected: [u8; 32], + random_id: i64, +) -> Result<()> { + let message = original(client, source, id).await?; + ensure!( + revision(&message) == expected, + "Original message changed; close and reopen forwarding to review it again" + ); + client + .forward_messages_with_random_ids(target, &[id], source, &[random_id]) + .await?; + Ok(()) +} diff --git a/src/telegram/invites.rs b/src/telegram/invites.rs new file mode 100644 index 0000000..b3e680d --- /dev/null +++ b/src/telegram/invites.rs @@ -0,0 +1,144 @@ +//! Small typed-RPC adapter; Grammers delivers join updates on its one ordered stream. +use crate::{ + invites::{Outcome, Preview}, + model::{Chat, ChatKind}, +}; +use anyhow::{Context, Result, ensure}; +use grammers_client::{Client, peer::Peer, tl}; +use grammers_session::types::PeerRef; + +pub(super) async fn preview(client: &Client, hash: &str) -> Result<(Preview, Option)> { + match client + .invoke(&tl::functions::messages::CheckChatInvite { + hash: hash.to_owned(), + }) + .await? + { + tl::enums::ChatInvite::Invite(invite) => Ok(( + Preview { + title: invite.title, + about: invite.about.unwrap_or_default(), + participants: Some(u32::try_from(invite.participants_count).unwrap_or(0)), + request_needed: invite.request_needed, + warning: (invite.scam || invite.fake) + .then(|| "Telegram marks this chat as scam or fake".to_owned()), + blocked: if invite.subscription_pricing.is_some() + || invite.subscription_form_id.is_some() + { + Some("Subscription invites require the official Telegram client".to_owned()) + } else if invite.broadcast || (invite.channel && !invite.megagroup) { + Some("Broadcast channels are not supported".to_owned()) + } else { + None + }, + joined: None, + }, + None, + )), + tl::enums::ChatInvite::Already(invite) => from_chat(client, invite.chat, true).await, + tl::enums::ChatInvite::Peek(invite) => from_chat(client, invite.chat, false).await, + } +} + +async fn from_chat( + client: &Client, + raw: tl::enums::Chat, + joined: bool, +) -> Result<(Preview, Option)> { + let request_needed = matches!(&raw, tl::enums::Chat::Channel(c) if c.join_request); + let resolved = Peer::from_raw(client, raw); + let blocked = matches!(resolved, Peer::Channel(_)) + .then(|| "Broadcast channels are not supported".to_owned()); + let title = super::peer_display_name(&resolved); + let mut result = Preview { + title, + about: String::new(), + participants: None, + request_needed, + warning: None, + blocked, + joined: None, + }; + let peer = if joined && result.blocked.is_none() { + let (chat, peer) = chat(resolved).await?; + result.joined = Some(chat); + Some(peer) + } else { + None + }; + Ok((result, peer)) +} + +async fn chat(resolved: Peer) -> Result<(Chat, PeerRef)> { + ensure!( + !matches!(resolved, Peer::Channel(_)), + "Broadcast channels are not supported" + ); + let peer = resolved + .to_ref() + .await + .map_err(anyhow::Error::from_boxed)? + .context("Chat cannot be addressed")?; + Ok(( + Chat { + id: peer + .id + .bot_api_dialog_id() + .context("Chat has no stable identity")?, + title: super::peer_display_name(&resolved), + kind: ChatKind::Group, + membership: crate::folders::ChatMembership::default(), + unread: 0, + read_inbox_max_id: None, + last_message: String::new(), + last_message_id: None, + last_activity: None, + }, + peer, + )) +} + +pub(super) async fn join( + client: &Client, + hash: &str, + title: &str, + request_needed: bool, +) -> Result<(Outcome, Option)> { + let (fresh, peer) = preview(client, hash).await?; + ensure!( + fresh.blocked.is_none(), + "{}", + fresh.blocked.as_deref().unwrap_or_default() + ); + if let Some(chat) = fresh.joined { + return Ok((Outcome::Joined(chat), peer)); + } + ensure!( + crate::model::sanitize_terminal_line(&fresh.title) == title + && fresh.request_needed == request_needed, + "Invite changed; close and preview it again" + ); + let joined = match client + .invoke(&tl::functions::messages::ImportChatInvite { + hash: hash.to_owned(), + }) + .await + { + Ok(result) => result, + Err(error) if error.is("INVITE_REQUEST_SENT") => return Ok((Outcome::Requested, None)), + Err(error) => return Err(error.into()), + }; + match joined { + tl::enums::messages::ChatInviteJoinResult::WebView(_) => Ok((Outcome::Verification, None)), + tl::enums::messages::ChatInviteJoinResult::Ok(_) => { + // Recheck membership instead of assuming the first Updates chat is the joined peer. + let (fresh, peer) = preview(client, hash).await?; + Ok(( + Outcome::Joined(fresh.joined.context( + "Join returned without confirmed membership; reopen the invite to check", + )?), + peer, + )) + } + } +} diff --git a/src/telegram/local.rs b/src/telegram/local.rs new file mode 100644 index 0000000..7313304 --- /dev/null +++ b/src/telegram/local.rs @@ -0,0 +1,347 @@ +//! The local store remains available while Telegram connects or authenticates. + +use super::{ + COMMAND_QUEUE_CAPACITY, Config, EVENT_QUEUE_CAPACITY, NetworkEvent, Result, TelegramCommand, + VecDeque, mpsc, run, +}; +use crate::{ + cache::{Store, SyncCursor}, + model::Chat, +}; + +pub(super) struct Bootstrap { + pub cursor: SyncCursor, + pub chats: Vec, + pub account_id: Option, + pub cache_owner: std::sync::Arc, +} +use tokio::{ + task::JoinSet, + time::{Duration, MissedTickBehavior}, +}; + +#[allow(clippy::too_many_lines)] +pub(super) async fn serve( + config: Config, + mut commands: mpsc::Receiver, + events: mpsc::Sender, + active_chat: tokio::sync::watch::Receiver>, + measure_latency: tokio::sync::watch::Receiver, +) -> Result<()> { + config.prepare_session_dir()?; + let mut cache_path = config.session_path.as_os_str().to_os_string(); + cache_path.push(".cache.sqlite3"); + let mut store = Store::open(std::path::Path::new(&cache_path)).await?; + let state_path = config.state_path.clone(); + let mut loaded_drafts = std::collections::BTreeSet::new(); + let (user_name, chats) = store.snapshot().await?; + if let Some(user_id) = store.account_id().await? { + events + .send(NetworkEvent::LocalDrafts { + user_id, + drafts: crate::drafts::load(state_path.clone(), user_id).await?, + }) + .await?; + loaded_drafts.insert(user_id); + events + .send(NetworkEvent::AccountIdentity { user_id }) + .await?; + } + if !chats.is_empty() { + events + .send(NetworkEvent::CachedSnapshot { + user_name, + chats: chats.clone(), + }) + .await?; + } + events + .send(NetworkEvent::Folders(store.folders().await?)) + .await?; + events + .send(NetworkEvent::DialogPins(store.dialog_pins().await?)) + .await?; + let bootstrap = Bootstrap { + cache_owner: store.owner(), + cursor: store.cursor().await?, + chats, + account_id: store.account_id().await?, + }; + let (network_tx, network_commands) = mpsc::channel(COMMAND_QUEUE_CAPACITY); + let (network_events, mut network_rx) = mpsc::channel(EVENT_QUEUE_CAPACITY); + let mut tasks = JoinSet::new(); + tasks.spawn(run( + config, + network_commands, + network_events, + bootstrap, + active_chat, + measure_latency, + )); + let mut search = crate::search::Worker::new(std::path::PathBuf::from(cache_path)); + let mut pending = VecDeque::new(); + let mut preparation = crate::staging::Worker::new(state_path.clone(), store.owner()); + let mut changes = Vec::new(); + let mut ready = false; + let mut flush = tokio::time::interval(Duration::from_millis(100)); + flush.set_missed_tick_behavior(MissedTickBehavior::Skip); + + loop { + search.start_pending(); + tokio::select! { + event = preparation.next() => { if let Some(event) = event { events.send(event).await?; } }, + result = search.next(), if search.running() => { if let Some(event) = result { events.send(event).await?; } }, + command = commands.recv() => { + let Some(command) = command else { break; }; + if let TelegramCommand::PrepareAttachments(request) = command { + if let Some(event) = preparation.start(request) { events.send(event).await?; } + continue; + } + if command.cloud_search_id().is_some() || matches!(command, TelegramCommand::CancelSearch | TelegramCommand::SearchCached(_)) { + search.cancel(); + pending.retain(|queued: &TelegramCommand| queued.cloud_search_id().is_none() && !matches!(queued, TelegramCommand::CancelSearch)); + if matches!(command, TelegramCommand::SearchCached(_)) && ready { + pending.push_back(TelegramCommand::CancelSearch); + } + if matches!(command, TelegramCommand::CancelSearch) && !ready { continue; } + } + if serve_cached(&command, &mut store, &mut changes, &events, &mut search).await? { continue; } + if authentication_command(&command) { + pending.push_front(command); + } else if pending.len() < COMMAND_QUEUE_CAPACITY { + pending.push_back(command); + } else if let Some(event) = command.failure("Telegram command queue is busy".to_owned()) { + store.apply(std::slice::from_ref(&event)).await?; + events.send(event).await?; + } + } + permit = network_tx.reserve(), if pending.front().is_some_and(|command| ready || authentication_command(command)) => { + if let Ok(permit) = permit { + permit.send(pending.pop_front().expect("pending command")); + } else { break; } + } + event = network_rx.recv() => { + let Some(mut event) = event else { + store.apply(&changes).await?; + return tasks.join_next().await.transpose()?.unwrap_or(Ok(())); + }; + match &event { + NetworkEvent::AccountIdentity { user_id } if loaded_drafts.insert(*user_id) => { + events.send(NetworkEvent::LocalDrafts { user_id: *user_id, drafts: crate::drafts::load(state_path.clone(), *user_id).await? }).await?; + } + NetworkEvent::Ready { .. } => ready = true, + NetworkEvent::Auth(_) => ready = false, + NetworkEvent::CacheAccountReset { .. } => { pending.clear(); search.cancel(); }, + _ => {} + } + let cloud = matches!(event, NetworkEvent::CloudSearchResults { .. } | NetworkEvent::CloudSearchContext { .. }); + let snapshot = Store::has_message_snapshot(&event); + if snapshot { + store.apply(&changes).await?; + changes.clear(); + store.reconcile_cloud_search(&mut event).await?; + store.reconcile_snapshots(&mut event).await?; + } + let checkpoint = matches!(&event, NetworkEvent::SyncCheckpoint(_)); + changes.push(event.clone()); + if checkpoint || cloud || changes.len() >= 256 { + store.apply(&changes).await?; + changes.clear(); + } + events.send(event).await?; + } + _ = flush.tick(), if !changes.is_empty() => { + store.apply(&changes).await?; + changes.clear(); + } + } + } + store.apply(&changes).await?; + tasks.shutdown().await; + Ok(()) +} + +#[allow(clippy::too_many_lines)] +async fn serve_cached( + command: &TelegramCommand, + store: &mut Store, + changes: &mut Vec, + events: &mpsc::Sender, + search: &mut crate::search::Worker, +) -> Result { + if matches!( + command, + TelegramCommand::LoadHistory { .. } + | TelegramCommand::LoadReplyPreviews { .. } + | TelegramCommand::LoadPinnedMessages { .. } + | TelegramCommand::LoadPinnedContext { .. } + | TelegramCommand::LoadOlder { .. } + | TelegramCommand::LoadCachedContext { .. } + | TelegramCommand::DownloadAttachment { .. } + | TelegramCommand::SearchCached(_) + ) { + store.apply(changes).await?; + changes.clear(); + } + match command { + TelegramCommand::LoadReplyPreviews { + chat_id, + message_ids, + request_id, + } => { + let (messages, unavailable) = store.reply_previews(*chat_id, message_ids).await?; + let complete = messages.len() + unavailable.len() == message_ids.len(); + events + .send(NetworkEvent::ReplyPreviews { + chat_id: *chat_id, + request_id: *request_id, + messages, + unavailable, + complete, + }) + .await?; + return Ok(complete); + } + TelegramCommand::LoadPinnedMessages { + chat_id, + before, + request_id, + } => { + let page = store.pinned_messages(*chat_id, *before).await?; + events + .send(NetworkEvent::PinnedMessages { + chat_id: *chat_id, + request_id: *request_id, + page, + }) + .await?; + } + TelegramCommand::LoadPinnedContext { + chat_id, + message_id, + request_id, + } => { + let messages = store + .history( + *chat_id, + Some(message_id.saturating_add(1)), + super::HISTORY_LIMIT, + ) + .await?; + if messages.iter().any(|message| message.id == *message_id) { + events + .send(NetworkEvent::PinnedContext { + chat_id: *chat_id, + message_id: *message_id, + request_id: *request_id, + messages, + }) + .await?; + } + } + TelegramCommand::DownloadAttachment { + chat_id, + message_id, + request_id, + media_id, + } => { + if let Some(path) = store.attachment(*chat_id, *message_id, *media_id).await? { + events + .send(NetworkEvent::AttachmentDownloaded { + chat_id: *chat_id, + message_id: *message_id, + request_id: *request_id, + path, + }) + .await?; + return Ok(true); + } + store + .apply(&[NetworkEvent::AttachmentDownloadStarted { + chat_id: *chat_id, + message_id: *message_id, + request_id: *request_id, + media_id: *media_id, + }]) + .await?; + } + TelegramCommand::SearchCached(request) => { + search.queue(request.clone()); + return Ok(true); + } + TelegramCommand::CancelSearch => search.cancel(), + TelegramCommand::LoadCachedContext { + chat_id, + message_id, + request_id, + } => { + let event = match store.context(*chat_id, *message_id).await { + Ok(messages) => NetworkEvent::CachedContext { + request_id: *request_id, + chat_id: *chat_id, + message_id: *message_id, + messages, + }, + Err(error) => NetworkEvent::SearchFailed { + request_id: *request_id, + error: format!("{error:#}"), + }, + }; + events.send(event).await?; + return Ok(true); + } + TelegramCommand::LoadOlder { + chat_id, + request_id, + before_id, + } => { + if let Some(messages) = store.older_page(*chat_id, *before_id).await? { + events + .send(NetworkEvent::OlderHistory { + chat_id: *chat_id, + request_id: *request_id, + before_id: *before_id, + messages, + }) + .await?; + return Ok(true); + } + } + TelegramCommand::LoadHistory { + chat_id, + request_id, + after_id, + } => { + let messages = if let Some(after) = after_id { + store + .history_after(*chat_id, *after, super::HISTORY_LIMIT) + .await? + } else { + store.history(*chat_id, None, super::HISTORY_LIMIT).await? + }; + if !messages.is_empty() { + events + .send(NetworkEvent::CachedHistory { + chat_id: *chat_id, + request_id: *request_id, + messages, + }) + .await?; + } + } + _ => {} + } + Ok(false) +} + +fn authentication_command(command: &TelegramCommand) -> bool { + matches!( + command, + TelegramCommand::StartQrAuth + | TelegramCommand::SubmitPhone(_) + | TelegramCommand::SubmitCode(_) + | TelegramCommand::SubmitPassword(_) + | TelegramCommand::RestartAuth + | TelegramCommand::Shutdown + ) +} diff --git a/src/telegram/media_cache.rs b/src/telegram/media_cache.rs new file mode 100644 index 0000000..df20753 --- /dev/null +++ b/src/telegram/media_cache.rs @@ -0,0 +1,161 @@ +//! Managed media files. Temporary files are owned by tempfile until completion. +use crate::model::ChatId; +use anyhow::{Context, Result, bail}; +use std::{ + path::{Path, PathBuf}, + time::SystemTime, +}; +use tempfile::TempPath; + +const MAX_FILES: usize = 512; +const MAX_BYTES: u64 = 1024 * 1024 * 1024; + +pub(super) async fn prepare( + root: PathBuf, + account: i64, + owner: Option>, +) -> Result { + tokio::task::spawn_blocking(move || { + let _owner = owner; + let directory = root.join(account.to_string()); + for path in [&root, &directory] { + std::fs::create_dir_all(path)?; + let metadata = std::fs::symlink_metadata(path)?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + bail!("media cache must be a real directory"); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))?; + } + } + // The account cache lock guarantees these cannot belong to another + // running instance. Cleanup happens before the first transfer starts. + for entry in std::fs::read_dir(&directory)? { + let entry = entry?; + if entry.file_type()?.is_file() && is_partial(&entry.path()) { + std::fs::remove_file(entry.path())?; + } + } + prune(&directory, None, MAX_FILES, MAX_BYTES)?; + Ok(directory) + }) + .await? +} + +pub(super) fn temporary(directory: &Path) -> Result { + Ok(tempfile::Builder::new() + .prefix(".tg-download-") + .suffix(".part") + .tempfile_in(directory)? + .into_temp_path()) +} + +pub(super) async fn finish( + mut temporary: TempPath, + directory: PathBuf, + chat: ChatId, + message: i32, + name: &str, + owner: Option>, +) -> Result { + loop { + let path = super::unique_download_path(&directory, chat, message, name).await?; + match temporary.persist_noclobber(&path) { + Ok(()) => { + let keep = path.clone(); + tokio::task::spawn_blocking(move || { + let _owner = owner; + prune(&directory, Some(&keep), MAX_FILES, MAX_BYTES) + }) + .await??; + return Ok(path); + } + Err(error) if error.error.kind() == std::io::ErrorKind::AlreadyExists => { + temporary = error.path; + } + Err(error) => return Err(error.error).context("could not finish media cache file"), + } + } +} + +// This suffix is generated by this module, not a user-selected extension. +#[allow(clippy::case_sensitive_file_extension_comparisons)] +fn is_partial(path: &Path) -> bool { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with(".tg-download-") && name.ends_with(".part")) +} + +fn prune(directory: &Path, keep: Option<&Path>, max_files: usize, max_bytes: u64) -> Result<()> { + let mut files = Vec::new(); + let mut bytes = 0_u64; + for entry in std::fs::read_dir(directory)? { + let entry = entry?; + if !entry.file_type()?.is_file() || is_partial(&entry.path()) { + continue; + } + let metadata = entry.metadata()?; + bytes = bytes.saturating_add(metadata.len()); + files.push(( + metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH), + entry.path(), + metadata.len(), + )); + } + files.sort_by_key(|(time, _, _)| *time); + let mut count = files.len(); + for (_, path, size) in files { + if count <= max_files && bytes <= max_bytes { + break; + } + if keep == Some(path.as_path()) { + continue; + } + match std::fs::remove_file(path) { + Ok(()) => { + count = count.saturating_sub(1); + bytes = bytes.saturating_sub(size); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[tokio::test] + async fn retention_preserves_partial_and_current_files_without_overwriting() { + let root = tempfile::tempdir().unwrap(); + let directory = prepare(root.path().join("media"), 7, None).await.unwrap(); + let old = directory.join("old.txt"); + std::fs::write(&old, "old").unwrap(); + let active = temporary(&directory).unwrap(); + std::fs::write(&active, "partial").unwrap(); + let complete = temporary(&directory).unwrap(); + std::fs::write(&complete, "done").unwrap(); + let path = finish(complete, directory.clone(), 42, 1, "file.txt", None) + .await + .unwrap(); + let another = temporary(&directory).unwrap(); + std::fs::write(&another, "next").unwrap(); + let second = finish(another, directory.clone(), 42, 1, "file.txt", None) + .await + .unwrap(); + assert_ne!(path, second); + assert_eq!(std::fs::read_to_string(&path).unwrap(), "done"); + prune(&directory, Some(&second), 1, 4).unwrap(); + assert!(second.is_file()); + assert!(active.is_file()); + assert!(!old.exists()); + assert!(!path.exists()); + let interrupted = active.keep().unwrap(); + prepare(root.path().join("media"), 7, None).await.unwrap(); + assert!(!interrupted.exists()); + assert!(second.is_file()); + } +} diff --git a/src/telegram/message_actions.rs b/src/telegram/message_actions.rs new file mode 100644 index 0000000..9b00097 --- /dev/null +++ b/src/telegram/message_actions.rs @@ -0,0 +1,61 @@ +//! Exact-peer snapshot checks shared by destructive message operations. +use anyhow::{Context, Result, ensure}; +use grammers_client::{ + Client, + message::Message, + tl::{self, Serializable}, +}; +use grammers_session::types::PeerRef; +use sha2::{Digest, Sha256}; + +pub(super) async fn fetch(client: &Client, peer: PeerRef, id: i32) -> Result { + ensure!(id > 0, "Select a delivered message"); + let message = client + .get_messages_by_id(peer, &[id]) + .await? + .pop() + .flatten() + .context("Message is no longer available")?; + // messages.getMessages/deleteMessages do not accept a peer on non-channel + // chats. Checking only the caller's supplied chat would be insufficient. + ensure!( + message.peer_id() == peer.id, + "Message does not belong to the selected chat" + ); + Ok(message) +} + +pub(super) fn revision(message: &Message) -> [u8; 32] { + let mut hash = Sha256::new(); + hash.update(message.text().as_bytes()); + hash.update( + message + .edit_date() + .map_or(0, |date| date.timestamp()) + .to_le_bytes(), + ); + for entity in message.fmt_entities().into_iter().flatten() { + hash.update(entity.to_bytes()); + } + if let tl::enums::Message::Message(raw) = &message.raw { + let media_id = match &raw.media { + Some(tl::enums::MessageMedia::Photo(media)) => match &media.photo { + Some(tl::enums::Photo::Photo(photo)) => photo.id, + _ => 0, + }, + Some(tl::enums::MessageMedia::Document(media)) => match &media.document { + Some(tl::enums::Document::Document(document)) => document.id, + _ => 0, + }, + Some(tl::enums::MessageMedia::Poll(media)) => { + // Poll choices may change independently of the text. Counts + // intentionally do not invalidate a reviewed message action. + hash.update(media.poll.to_bytes()); + 0 + } + _ => 0, + }; + hash.update(media_id.to_le_bytes()); + } + hash.finalize().into() +} diff --git a/src/telegram/mod.rs b/src/telegram/mod.rs index 198c95e..04f4af5 100644 --- a/src/telegram/mod.rs +++ b/src/telegram/mod.rs @@ -1,3 +1,23 @@ +mod chat_info; +mod deletion; +mod editing; +mod entities; +mod folders; +mod forwarding; +mod invites; +mod local; +mod media_cache; +mod message_actions; +mod notifications; +mod pins; +mod polls; +mod reactions; +mod reads; +mod requests; +mod search; +mod sharing; +mod telemetry; + use std::collections::{HashMap, HashSet, VecDeque}; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -7,14 +27,16 @@ use anyhow::{Context, Result, anyhow, bail}; use grammers_client::client::{LoginToken, PasswordToken, UpdatesConfiguration}; use grammers_client::media::{Media, PhotoSize}; use grammers_client::message::{InputMessage, Message as TelegramMessage}; -use grammers_client::peer::{Peer, User}; +use grammers_client::peer::{Dialog, Peer, User}; use grammers_client::sender::SenderPoolHandle; use grammers_client::tl::{self, enums::Dialog as RawDialog}; use grammers_client::update::Update; use grammers_client::{Client, InvocationError, SenderPool, SignInError}; use grammers_session::Session; use grammers_session::storages::SqliteSession; -use grammers_session::types::{PeerId, PeerInfo, PeerKind, PeerRef, UpdateState, UpdatesState}; +use grammers_session::types::{ + ChannelKind, PeerId, PeerInfo, PeerKind, PeerRef, UpdateState, UpdatesState, +}; use grammers_session::updates::UpdatesLike; use tokio::sync::mpsc; use tokio::task::JoinSet; @@ -26,14 +48,14 @@ use crate::model::{ MessageButtonKind, MessageLink, ReplyInfo, sanitize_terminal_line, sanitize_terminal_text, }; -const HISTORY_LIMIT: usize = 80; +pub(crate) const HISTORY_LIMIT: usize = 80; const COMMAND_QUEUE_CAPACITY: usize = 32; const EVENT_QUEUE_CAPACITY: usize = 64; -const UPDATE_QUEUE_LIMIT: usize = 256; const TRANSIENT_SENDER_NAME_LIMIT: usize = 256; const MESSAGE_SENDER_CACHE_LIMIT: usize = 512; const UNRESOLVED_REFRESH_COOLDOWN: Duration = Duration::from_secs(5); const MAX_CONCURRENT_TRANSFERS: usize = 3; +const MAX_PENDING_TRANSFERS: usize = 32; const QR_MIGRATION_LIMIT: usize = 4; const MIN_QR_REFRESH_DELAY: Duration = Duration::from_millis(500); const DEFAULT_QR_REFRESH_DELAY: Duration = Duration::from_secs(30); @@ -41,13 +63,35 @@ const MAX_QR_REFRESH_DELAY: Duration = Duration::from_secs(120); const QR_RESTART_DELAY: Duration = Duration::from_secs(1); pub struct TelegramHandle { + pub measure_latency: tokio::sync::watch::Sender, pub commands: mpsc::Sender, pub events: mpsc::Receiver, pub task: tokio::task::JoinHandle<()>, + pub active_chat: tokio::sync::watch::Sender>, +} + +#[derive(Default)] +struct MetadataRefresh { + in_flight: bool, + dirty: bool, } #[derive(Default)] struct WorkerCache { + cloud_search: Option<(u64, tokio::task::AbortHandle)>, + search_sender: Option, + notify_revision: u64, + notification_peers: HashMap, + notification_peer_order: VecDeque, + transfer_slots: Option>, + upload_order: HashMap>, + dialogs: MetadataRefresh, + folders: MetadataRefresh, + dialog_pins: MetadataRefresh, + message_pins: MetadataRefresh, + active_chat: Option, + self_id: i64, + channel_pts: HashMap, peers: HashMap, /// Peers opened explicitly through supported Telegram links remain usable /// even when they are not part of the account's dialog snapshot. @@ -74,8 +118,10 @@ struct WorkerCache { /// triggering a complete dialog scan. Once it expires, any unresolved peer /// can retry the refresh. last_unresolved_refresh: Option, - /// Created lazily so text-only sessions never touch the temporary folder. + /// Created lazily so text-only sessions never create a media directory. download_dir: Option, + media_root: PathBuf, + cache_owner: Option>, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -112,9 +158,8 @@ enum TransferCompletion { Send { chat_id: ChatId, local_id: i32, - path: PathBuf, + attachment: crate::staging::Attachment, caption: String, - as_photo: bool, reply_to: Option, result: Box>, }, @@ -125,51 +170,37 @@ enum TransferCompletion { result: Result, }, Download { + request_id: u64, chat_id: ChatId, message_id: i32, result: Result, }, } -struct PartialDownload { - path: PathBuf, - complete: bool, -} - -impl PartialDownload { - fn new(path: PathBuf) -> Self { - Self { - path, - complete: false, - } - } - - fn finish(mut self) -> PathBuf { - self.complete = true; - self.path.clone() - } -} - -impl Drop for PartialDownload { - fn drop(&mut self) { - if !self.complete { - let _ = std::fs::remove_file(&self.path); - } - } -} - #[must_use] pub fn spawn(config: Config) -> TelegramHandle { + let (measure_tx, measure_rx) = tokio::sync::watch::channel(config.measure_latency); + let (active_tx, active_rx) = tokio::sync::watch::channel(None); let (command_tx, command_rx) = mpsc::channel(COMMAND_QUEUE_CAPACITY); let (event_tx, event_rx) = mpsc::channel(EVENT_QUEUE_CAPACITY); let task = tokio::spawn(async move { - if let Err(error) = Box::pin(run(config, command_rx, event_tx.clone())).await { + if let Err(error) = Box::pin(local::serve( + config, + command_rx, + event_tx.clone(), + active_rx, + measure_rx, + )) + .await + { let _ = event_tx .send(NetworkEvent::Fatal(describe_worker_error(&error))) .await; } }); TelegramHandle { + measure_latency: measure_tx, + active_chat: active_tx, commands: command_tx, events: event_rx, task, @@ -190,6 +221,9 @@ async fn run( config: Config, mut commands: mpsc::Receiver, events: mpsc::Sender, + mut bootstrap: local::Bootstrap, + mut active_chat: tokio::sync::watch::Receiver>, + measure_latency: tokio::sync::watch::Receiver, ) -> Result<()> { config.prepare_session_dir()?; events @@ -209,7 +243,11 @@ async fn run( mut updates, } = SenderPool::new(session.clone(), config.api_id); let client = Client::new(handle.clone()); - let pool_task = tokio::spawn(runner.run()); + // JoinSet aborts the sender on account switches and initialization failures, + // including when the owner itself is aborted during shutdown. + let mut pool_tasks = JoinSet::new(); + let mut observations = JoinSet::new(); + pool_tasks.spawn(runner.run()); let result: Result<()> = Box::pin(async { let initially_authorized = client.is_authorized().await?; @@ -244,11 +282,34 @@ async fn run( } } }; - let user_name = safe_name(me.first_name(), "You"); + let user_id = me.id().bare_id().context("Telegram account has no stable identifier")?; + if bootstrap.account_id.is_some_and(|previous| previous != user_id) { + bootstrap.cursor = crate::cache::SyncCursor::default(); + bootstrap.chats.clear(); + events.send(NetworkEvent::CacheAccountReset { user_id }).await?; + } + events.send(NetworkEvent::AccountIdentity { user_id }).await?; + let user_name = safe_name(Some(&me.full_name()), "You"); events.send(NetworkEvent::Ready { user_name }).await.ok(); - - let mut cache = WorkerCache::default(); - load_dialogs(&client, &mut cache, &events).await?; + observations.spawn(telemetry::observe(client.clone(), session.clone(), events.clone(), measure_latency)); + + let mut media_root = config.session_path.as_os_str().to_os_string(); + media_root.push(".media"); + let mut cache = WorkerCache { cache_owner: Some(bootstrap.cache_owner.clone()), media_root: PathBuf::from(media_root), self_id: user_id, folders: MetadataRefresh { dirty: true, ..MetadataRefresh::default() }, ..WorkerCache::default() }; + for chat in bootstrap.chats { + if let Some(id) = PeerId::from_bot_api_dialog_id(chat.id) { + if let Some(peer) = session.peer_ref(id).await? { cache.peers.insert(chat.id, peer); } + if let Some(top) = chat.last_message_id { cache.top_messages.insert(chat.id, top); } + cache.names.insert(id, chat.title); + if chat.kind == ChatKind::Group && id.kind() == PeerKind::Channel { + cache.visible_channel_groups.insert(chat.id); + } + } + } + let cursor = bootstrap.cursor; + for &(id, pts) in &cursor.channels { + cache.channel_pts.insert(PeerId::channel_unchecked(id).bot_api_dialog_id_unchecked(), pts); + } events .send(NetworkEvent::Status(ConnectionStatus::Online)) .await @@ -259,121 +320,158 @@ async fn run( updates, UpdatesConfiguration { catch_up: true, - update_queue_limit: Some(UPDATE_QUEUE_LIMIT), + // Never truncate catch-up batches; losing payloads while advancing + // PTS would leave the view permanently stale. + update_queue_limit: None, }, ) .await .map_err(anyhow::Error::from_boxed)?; + updates.restore_state(UpdatesState { + pts: cursor.pts, qts: cursor.qts, date: cursor.date, seq: cursor.seq, + channels: cursor.channels.into_iter().map(|(id, pts)| grammers_session::types::ChannelState { id, pts }).collect(), + }); + let active_channel = updates.active_channel(); + cache.active_chat = *active_chat.borrow(); + cache.message_pins.dirty = true; + signal_active_channel(&cache, *active_chat.borrow_and_update(), &active_channel); let mut recovering = false; let mut transfers = JoinSet::new(); - - loop { - tokio::select! { - command = commands.recv() => { - let Some(command) = command else { break; }; - match Box::pin(handle_command( - command, - &client, - &mut cache, - &events, - &mut transfers, - )).await { - Ok(true) => break, - Ok(false) => {} - Err(error) => { - let message = format!("Telegram request failed: {error:#}"); - events - .send(NetworkEvent::Error(message)) - .await - .ok(); - } + let mut requests = JoinSet::new(); + cache.dialog_pins.dirty = true; + requests::refresh_dialogs(&client, &mut cache, &events, &mut requests).await?; + requests::refresh_pending(&client, &mut cache, &events, &mut requests).await?; + + 'worker: loop { + requests::refresh_pending(&client, &mut cache, &events, &mut requests).await?; + // Grammers can change its message-box state before awaiting peer + // storage. Keep this future alive when commands or RPCs complete. + let next = updates.next_batch(); + tokio::pin!(next); + let update = loop { + tokio::select! { + update = &mut next => break update, + changed = active_chat.changed() => { + if changed.is_err() { break 'worker; } + cache.active_chat = *active_chat.borrow(); + cache.message_pins.dirty = true; + signal_active_channel(&cache, *active_chat.borrow_and_update(), &active_channel); } - } - update = updates.next() => { - if matches!(update, Err(InvocationError::Dropped)) { - bail!("Telegram update stream closed"); + command = commands.recv(), if requests.len() < requests::MAX_REQUESTS => { + let Some(command) = command else { break 'worker; }; + if handle_command(command, &client, &mut cache, &events, + &mut transfers, &mut requests).await? { + break 'worker; + } } - let update_failed = update.is_err(); - if let Err(error) = Box::pin(process_update( - update, - &client, - &mut cache, - &events, - &mut recovering, - )).await { - events - .send(NetworkEvent::Error(format!( - "Could not process a Telegram update: {error:#}" - ))) - .await - .ok(); + completion = requests.join_next(), if !requests.is_empty() => { + match completion { + Some(Ok(completion)) => { + requests::complete(completion, &mut cache, &events).await?; + signal_active_channel(&cache, *active_chat.borrow(), &active_channel); + } + Some(Err(error)) if error.is_cancelled() => {} + Some(Err(error)) => bail!("Telegram request task failed: {error}"), + None => {} + } } - if update_failed { - // Avoid a tight retry loop if difference recovery is temporarily failing. - tokio::time::sleep(Duration::from_millis(250)).await; + completion = transfers.join_next(), if !transfers.is_empty() => { + match completion { + Some(Ok(completion)) => process_transfer_completion(completion, &mut cache, &events).await?, + Some(Err(error)) => bail!("Telegram transfer task failed: {error}"), + None => {} + } } } - transfer = transfers.join_next(), if !transfers.is_empty() => { - match transfer { - Some(Ok(completion)) => { - Box::pin(process_transfer_completion( - completion, - &client, - &mut cache, - &events, - )).await?; - } - Some(Err(error)) if !error.is_cancelled() => { - events - .send(NetworkEvent::Error(format!( - "Telegram transfer task failed: {error}" - ))) - .await - .ok(); - } - Some(Err(_)) | None => {} + requests::refresh_pending(&client, &mut cache, &events, &mut requests).await?; + }; + if matches!(update, Err(InvocationError::Dropped)) { + bail!("Telegram update stream closed"); + } + match update { + Ok((batch, state)) => { + for update in batch { + Box::pin(process_update(Ok(update), &session, &client, &mut cache, + &events, &mut recovering, &mut requests)).await?; } + events.send(NetworkEvent::SyncCheckpoint(crate::cache::SyncCursor { + pts: state.pts, qts: state.qts, date: state.date, seq: state.seq, + channels: state.channels.into_iter().map(|channel| (channel.id, channel.pts)).collect(), + })).await?; + } + Err(error) => { + Box::pin(process_update(Err(error), &session, &client, &mut cache, + &events, &mut recovering, &mut requests)).await?; + tokio::time::sleep(Duration::from_millis(250)).await; } } } - transfers.abort_all(); - while transfers.join_next().await.is_some() {} + requests.shutdown().await; + transfers.shutdown().await; - updates - .sync_update_state() - .await - .map_err(anyhow::Error::from_boxed)?; Ok(()) }) .await; + observations.shutdown().await; handle.quit(); - let _ = pool_task.await; + let _ = pool_tasks.join_next().await; result } -#[allow(clippy::too_many_lines)] +fn signal_active_channel( + cache: &WorkerCache, + chat_id: Option, + sender: &tokio::sync::watch::Sender>, +) { + let value = chat_id.and_then(|id| { + let pts = *cache.channel_pts.get(&id)?; + let peer = cache.peers.get(&id)?; + (peer.id.kind() == PeerKind::Channel).then(|| (peer.id.bare_id_unchecked(), pts)) + }); + sender.send_if_modified(|current| { + if *current == value { + false + } else { + *current = value; + true + } + }); +} + +#[allow(clippy::too_many_lines, clippy::too_many_arguments)] async fn process_update( update: Result, + session: &SqliteSession, client: &Client, cache: &mut WorkerCache, events: &mpsc::Sender, recovering: &mut bool, + requests: &mut JoinSet, ) -> Result<()> { match update { Ok(Update::NewMessage(update)) => { restore_online_status(events, recovering).await; let message = update.into_inner(); - let Some(hidden) = is_hidden_broadcast(client, &message, cache).await? else { - if begin_unresolved_refresh(cache) { - load_dialogs(client, cache, events).await?; + if message.mentioned() { + cache.dialogs.dirty = true; + } + let Some(hidden) = is_hidden_broadcast(session, &message, cache).await? else { + if requests.len() < requests::MAX_REQUESTS && begin_unresolved_refresh(cache) { + requests::refresh_dialogs(client, cache, events, requests).await?; } + events + .send(NetworkEvent::CacheMessage(map_message(&message, cache)?)) + .await?; return Ok(()); }; if hidden { return Ok(()); } let chat_id = peer_id(&message)?; + if !cache.peers.contains_key(&chat_id) { + cache.dialogs.dirty = true; + } if let Some(peer) = message .peer_ref() .await @@ -381,9 +479,23 @@ async fn process_update( { cache.peers.insert(chat_id, peer); } + if message.mentioned() + && let Some(sender) = message.sender() + && let Ok(Some(peer)) = sender.to_ref().await + && let Some(id) = peer.id.bot_api_dialog_id().filter(|id| *id > 0) + { + if cache.notification_peers.insert(id, peer).is_none() { + cache.notification_peer_order.push_back(id); + } + while cache.notification_peer_order.len() > 256 { + if let Some(old) = cache.notification_peer_order.pop_front() { + cache.notification_peers.remove(&old); + } + } + } let message_id = message.id(); let after_dialog_snapshot = advance_dialog_watermark(cache, chat_id, message_id); - let message = Box::pin(map_message(client, &message, cache)).await?; + let message = map_message(&message, cache)?; let event = if after_dialog_snapshot { NetworkEvent::NewMessage(message) } else { @@ -394,23 +506,30 @@ async fn process_update( Ok(Update::MessageEdited(update)) => { restore_online_status(events, recovering).await; let message = update.into_inner(); - let Some(hidden) = is_hidden_broadcast(client, &message, cache).await? else { - if begin_unresolved_refresh(cache) { - load_dialogs(client, cache, events).await?; + cache.message_pins.dirty |= message.pinned(); + if message.mentioned() { + cache.dialogs.dirty = true; + } + let Some(hidden) = is_hidden_broadcast(session, &message, cache).await? else { + if requests.len() < requests::MAX_REQUESTS && begin_unresolved_refresh(cache) { + requests::refresh_dialogs(client, cache, events, requests).await?; } + events + .send(NetworkEvent::CacheMessage(map_message(&message, cache)?)) + .await?; return Ok(()); }; if hidden { return Ok(()); } events - .send(NetworkEvent::MessageUpdated( - Box::pin(map_message(client, &message, cache)).await?, - )) + .send(NetworkEvent::MessageUpdated(map_message(&message, cache)?)) .await .ok(); } Ok(Update::MessageDeleted(update)) => { + cache.dialogs.dirty = true; + cache.message_pins.dirty = true; restore_online_status(events, recovering).await; let channel_id = update .channel_id() @@ -427,6 +546,195 @@ async fn process_update( Ok(Update::Raw(update)) => { restore_online_status(events, recovering).await; match &update.raw { + tl::enums::Update::MessageReactions(update) => { + if let Some(chat) = PeerId::from(update.peer.clone()).bot_api_dialog_id() { + events + .send(NetworkEvent::ReactionsChanged(crate::reactions::Update { + chat, + message: update.msg_id, + summary: reactions::summary(&update.reactions), + })) + .await?; + } + } + tl::enums::Update::MessagePoll(update) => { + events + .send(NetworkEvent::PollChanged(polls::update(update))) + .await?; + } + tl::enums::Update::PinnedMessages(update) => { + if let Some(chat_id) = PeerId::from(update.peer.clone()).bot_api_dialog_id() { + events + .send(NetworkEvent::MessagePinsChanged { + chat_id, + message_ids: update.messages.clone(), + pinned: update.pinned, + }) + .await?; + cache.message_pins.dirty |= cache.active_chat == Some(chat_id); + } + } + tl::enums::Update::PinnedChannelMessages(update) => { + let chat_id = + PeerId::channel_unchecked(update.channel_id).bot_api_dialog_id_unchecked(); + events + .send(NetworkEvent::MessagePinsChanged { + chat_id, + message_ids: update.messages.clone(), + pinned: update.pinned, + }) + .await?; + cache.message_pins.dirty |= cache.active_chat == Some(chat_id); + } + tl::enums::Update::DialogPinned(_) | tl::enums::Update::PinnedDialogs(_) => { + cache.dialog_pins.dirty = true; + } + tl::enums::Update::DialogFilter(_) + | tl::enums::Update::DialogFilterOrder(_) + | tl::enums::Update::DialogFilters => { + cache.folders.dirty = true; + } + tl::enums::Update::Channel(update) => { + events + .send(NetworkEvent::ChatInfoInvalidated { + chat_id: PeerId::channel_unchecked(update.channel_id) + .bot_api_dialog_id_unchecked(), + }) + .await?; + cache.dialogs.dirty = true; + } + tl::enums::Update::Chat(update) => { + events + .send(NetworkEvent::ChatInfoInvalidated { + chat_id: PeerId::chat_unchecked(update.chat_id) + .bot_api_dialog_id_unchecked(), + }) + .await?; + cache.dialogs.dirty = true; + } + tl::enums::Update::ChatDefaultBannedRights(update) => { + if let Some(chat_id) = PeerId::from(update.peer.clone()).bot_api_dialog_id() { + events + .send(NetworkEvent::ChatInfoInvalidated { chat_id }) + .await?; + } + } + tl::enums::Update::NotifySettings(update) => { + cache.notify_revision = cache.notify_revision.wrapping_add(1); + cache.dialogs.dirty = true; + events + .send(NetworkEvent::NotificationSettingsChanged) + .await?; + if let tl::enums::NotifyPeer::Peer(target) = &update.peer + && let Some(chat_id) = PeerId::from(target.peer.clone()).bot_api_dialog_id() + && let tl::enums::PeerNotifySettings::Settings(settings) = + &update.notify_settings + && let Some(until) = settings.mute_until + { + events + .send(NetworkEvent::ChatMuteChanged { + chat_id, + until: i64::from(until), + }) + .await?; + } + } + tl::enums::Update::ReadMessagesContents(update) => { + cache.dialogs.dirty = true; + events + .send(NetworkEvent::MessageContentsRead { + channel_id: None, + message_ids: update.messages.clone(), + }) + .await?; + } + tl::enums::Update::ChannelReadMessagesContents(update) => { + cache.dialogs.dirty = true; + events + .send(NetworkEvent::MessageContentsRead { + channel_id: Some( + PeerId::channel_unchecked(update.channel_id) + .bot_api_dialog_id_unchecked(), + ), + message_ids: update.messages.clone(), + }) + .await?; + } + tl::enums::Update::PeerSettings(_) => { + cache.dialogs.dirty = true; + } + tl::enums::Update::DialogUnreadMark(update) => { + if update.saved_peer_id.is_none() + && let tl::enums::DialogPeer::Peer(peer) = &update.peer + && let Some(chat_id) = PeerId::from(peer.peer.clone()).bot_api_dialog_id() + { + events + .send(NetworkEvent::ChatUnreadChanged { + chat_id, + unread: update.unread, + }) + .await?; + } + cache.dialogs.dirty = true; + } + tl::enums::Update::FolderPeers(update) => { + cache.dialogs.dirty = true; + cache.dialog_pins.dirty = true; + for peer in &update.folder_peers { + let tl::enums::FolderPeer::Peer(peer) = peer; + if let Some(chat_id) = PeerId::from(peer.peer.clone()).bot_api_dialog_id() { + events + .send(NetworkEvent::ArchiveChanged { + chat_id, + archived: peer.folder_id == 1, + }) + .await?; + } + } + } + tl::enums::Update::ReadHistoryInbox(read) => { + if let Some(chat_id) = PeerId::from(read.peer.clone()).bot_api_dialog_id() { + events + .send(NetworkEvent::UnreadChanged { + chat_id, + max_id: read.max_id, + unread: u32::try_from(read.still_unread_count.max(0)) + .unwrap_or_default(), + }) + .await?; + } + } + tl::enums::Update::ReadChannelInbox(read) => { + events + .send(NetworkEvent::UnreadChanged { + max_id: read.max_id, + chat_id: PeerId::channel_unchecked(read.channel_id) + .bot_api_dialog_id_unchecked(), + unread: u32::try_from(read.still_unread_count.max(0)) + .unwrap_or_default(), + }) + .await?; + } + tl::enums::Update::PtsChanged => { + cache.dialogs.dirty = true; + cache.folders.dirty = true; + cache.dialog_pins.dirty = true; + cache.message_pins.dirty = true; + events + .send(NetworkEvent::CacheInvalidated { chat_id: None }) + .await?; + } + tl::enums::Update::ChannelTooLong(update) => { + let chat_id = + PeerId::channel_unchecked(update.channel_id).bot_api_dialog_id_unchecked(); + cache.dialogs.dirty = true; + cache.message_pins.dirty |= cache.active_chat == Some(chat_id); + events + .send(NetworkEvent::CacheInvalidated { + chat_id: Some(chat_id), + }) + .await?; + } grammers_client::tl::enums::Update::ReadHistoryOutbox(read) => { if let Some(chat_id) = PeerId::from(read.peer.clone()).bot_api_dialog_id() { cache @@ -1039,18 +1347,17 @@ async fn cancel_authorized_login(client: &Client) -> Result { Ok(CodeOutcome::Restart) } -async fn load_dialogs( - client: &Client, +fn apply_dialogs( + dialogs: Vec, + defaults: (i64, i64), cache: &mut WorkerCache, - events: &mpsc::Sender, -) -> Result<()> { - let mut iter = client.iter_dialogs(); +) -> Result> { let mut chats = Vec::new(); let mut visible_chat_ids = HashSet::new(); let mut dialog_name_ids = HashSet::new(); let mut hidden_broadcasts = HashSet::new(); let mut visible_channel_groups = HashSet::new(); - while let Some(dialog) = iter.next().await? { + for dialog in dialogs { if matches!(&dialog.raw, RawDialog::Folder(_)) { continue; } @@ -1073,13 +1380,15 @@ async fn load_dialogs( cache.peers.insert(id, dialog.peer_ref()); cache .names - .insert(dialog.peer_id(), safe_name(dialog.peer().name(), "Unknown")); + .insert(dialog.peer_id(), peer_display_name(dialog.peer())); let RawDialog::Dialog(raw) = &dialog.raw else { unreachable!("folder placeholders are filtered above") }; - let (unread, unread_mark, top_message, read_outbox) = ( + if let Some(pts) = raw.pts { + cache.channel_pts.insert(id, pts); + } + let (unread, top_message, read_outbox) = ( u32::try_from(raw.unread_count.max(0)).unwrap_or(u32::MAX), - raw.unread_mark, raw.top_message, raw.read_outbox_max_id, ); @@ -1095,14 +1404,21 @@ async fn load_dialogs( .or_insert(read_outbox); let last_message = dialog.last_message.as_ref(); chats.push(Chat { + read_inbox_max_id: Some(raw.read_inbox_max_id.max(0)), + membership: folders::membership(&dialog, defaults), id, - title: safe_name(dialog.peer().name(), "Unknown"), + title: if id == cache.self_id { + "Saved Messages".to_owned() + } else { + peer_display_name(dialog.peer()) + }, kind: match dialog.peer() { Peer::User(_) => ChatKind::Direct, Peer::Group(_) => ChatKind::Group, Peer::Channel(_) => unreachable!("broadcast channels are filtered above"), }, - unread: unread.max(u32::from(unread_mark)), + unread, + last_message_id: (top_message > 0).then_some(top_message), last_message: last_message.map(message_preview).unwrap_or_default(), last_activity: last_message.map(TelegramMessage::date), }); @@ -1114,8 +1430,7 @@ async fn load_dialogs( hidden_broadcasts, visible_channel_groups, ); - events.send(NetworkEvent::Dialogs(chats)).await?; - Ok(()) + Ok(chats) } fn reconcile_dialog_snapshot( @@ -1162,159 +1477,148 @@ async fn handle_command( cache: &mut WorkerCache, events: &mpsc::Sender, transfers: &mut JoinSet, + requests: &mut JoinSet, ) -> Result { + let slots = cache + .transfer_slots + .get_or_insert_with(|| Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_TRANSFERS))) + .clone(); match command { - TelegramCommand::LoadHistory { - chat_id, - request_id, - } => { - let result: Result> = async { - let peer = *cache - .peers - .get(&chat_id) - .context("selected chat is missing its Telegram peer reference")?; - let mut iter = client.iter_messages(peer).limit(HISTORY_LIMIT); - let mut messages = Vec::new(); - while let Some(message) = iter.next().await? { - messages.push(Box::pin(map_message(client, &message, cache)).await?); - } - messages.reverse(); - hydrate_reply_senders(&mut messages, cache); - Ok(messages) + command @ (TelegramCommand::SearchCloud(_) + | TelegramCommand::SearchMentions { .. } + | TelegramCommand::LoadCloudContext { .. } + | TelegramCommand::CancelSearch) => { + if let Some((request_id, handle)) = cache.cloud_search.take() { + handle.abort(); + events + .send(NetworkEvent::CloudSearchCancelled { request_id }) + .await?; } - .await; - match result { - Ok(messages) => { - events - .send(NetworkEvent::History { - chat_id, - request_id, - messages, - }) - .await?; - } - Err(error) => { - events - .send(NetworkEvent::HistoryFailed { - chat_id, - request_id, - error: format!("Could not load history: {error:#}"), - }) - .await?; - } + let chat_id = match &command { + TelegramCommand::SearchCloud(request) => Some(request.chat_id), + TelegramCommand::LoadCloudContext { chat_id, .. } + | TelegramCommand::SearchMentions { chat_id, .. } => Some(*chat_id), + _ => None, + }; + if let (Some(chat_id), Some(request_id)) = (chat_id, command.cloud_search_id()) { + events + .send(NetworkEvent::CloudSearchLoading { + chat_id, + request_id, + }) + .await?; + requests::spawn(command, client, cache, requests); } } - TelegramCommand::LoadMessage { - chat_id, - source_message_id, - message_id, - request_id, - } => { - let result: Result = async { - if source_message_id <= 0 || message_id <= 0 { - bail!("invalid Telegram message identifier") - } - let peer = *cache - .peers - .get(&chat_id) - .context("selected chat is missing its Telegram peer reference")?; - let mut messages = client - .get_messages_by_id(peer, &[source_message_id]) - .await - .context("could not retrieve the replying message")?; - let source = messages - .pop() - .flatten() - .context("replying message is unavailable")?; - if source.reply_to_message_id() != Some(message_id) { - bail!("reply relation changed before navigation") - } - let telegram_message = client - .get_reply_to_message(&source) - .await - .context("could not retrieve reply target")? - .context("reply target is unavailable")?; - let mut message = Box::pin(map_message(client, &telegram_message, cache)).await?; - hydrate_reply_sender(&mut message, cache); - Ok(message) + command @ (TelegramCommand::LoadChatInfo { .. } + | TelegramCommand::PreviewInvite { .. } + | TelegramCommand::JoinInvite { .. } + | TelegramCommand::LoadReactions { .. } + | TelegramCommand::ChangeReaction { .. } + | TelegramCommand::RefreshReactions { .. } + | TelegramCommand::LoadPoll { .. } + | TelegramCommand::VotePoll { .. } + | TelegramCommand::RefreshPoll { .. } + | TelegramCommand::ResolveAlertSettings { .. } + | TelegramCommand::ReviewForward { .. } + | TelegramCommand::ForwardMessage { .. } + | TelegramCommand::OpenSaved { .. } + | TelegramCommand::CopyMessage { .. } + | TelegramCommand::ReviewDeletion { .. } + | TelegramCommand::DeleteMessage { .. } + | TelegramCommand::LoadEdit { .. } + | TelegramCommand::EditMessage { .. } + | TelegramCommand::LoadOlder { .. } + | TelegramCommand::ChangeDialogPin { .. } + | TelegramCommand::SetArchived { .. } + | TelegramCommand::LoadPinnedMessages { .. } + | TelegramCommand::LoadPinnedContext { .. } + | TelegramCommand::ChangeMessagePin { .. } + | TelegramCommand::LoadHistory { .. } + | TelegramCommand::LoadMessage { .. } + | TelegramCommand::LoadReplyPreviews { .. } + | TelegramCommand::SendMessage { .. } + | TelegramCommand::ResolveTelegramLink { .. } + | TelegramCommand::ActivateButton { .. } + | TelegramCommand::SetChatUnread { .. } + | TelegramCommand::MarkRead { .. } + | TelegramCommand::ReadMentions { .. } + | TelegramCommand::SetChatMute { .. }) => { + if let TelegramCommand::LoadPinnedMessages { + chat_id, + request_id, + .. } - .await; - match result { - Ok(message) => { - events - .send(NetworkEvent::MessageLoaded { - chat_id, - message_id, - request_id, - message, - }) - .await?; - } - Err(error) => { - events - .send(NetworkEvent::MessageLoadFailed { - chat_id, - message_id, - request_id, - error: format!("Could not load message: {error:#}"), - }) - .await?; - } + | TelegramCommand::LoadPinnedContext { + chat_id, + request_id, + .. + } = &command + { + events + .send(NetworkEvent::PinnedMessagesLoading { + chat_id: *chat_id, + request_id: *request_id, + }) + .await?; } - } - TelegramCommand::SendMessage { - chat_id, - local_id, - text, - reply_to, - } => { - let Some(peer) = cache.peers.get(&chat_id).copied() else { + if let TelegramCommand::LoadHistory { + chat_id, + request_id, + .. + } + | TelegramCommand::LoadOlder { + chat_id, + request_id, + .. + } + | TelegramCommand::LoadMessage { + chat_id, + request_id, + .. + } = &command + { events - .send(NetworkEvent::SendFailed { - chat_id, - local_id, - text, - reply_to, - error: "conversation is missing its Telegram peer reference".to_owned(), + .send(NetworkEvent::HistoryLoading { + chat_id: *chat_id, + request_id: *request_id, + }) + .await?; + } + if let TelegramCommand::LoadReplyPreviews { + chat_id, + request_id, + .. + } = &command + { + events + .send(NetworkEvent::ReplyPreviewsLoading { + chat_id: *chat_id, + request_id: *request_id, + }) + .await?; + } + if let TelegramCommand::LoadReactions { request_id, .. } = &command { + events + .send(NetworkEvent::ReactionsLoading { + request_id: *request_id, }) .await?; - return Ok(false); - }; - let input = InputMessage::new().text(text.clone()).reply_to(reply_to); - match Box::pin(client.send_message(peer, input)).await { - Ok(message) => { - if message.id() <= 0 { - events - .send(NetworkEvent::MessageAccepted { chat_id, local_id }) - .await?; - } else { - events - .send(NetworkEvent::MessageSent { - local_id, - message: Box::pin(map_message(client, &message, cache)).await?, - }) - .await?; - } - } - Err(error) => { - events - .send(NetworkEvent::SendFailed { - chat_id, - local_id, - text, - reply_to, - error: error.to_string(), - }) - .await?; - } } + if let TelegramCommand::LoadPoll { request_id, .. } = &command { + events + .send(NetworkEvent::PollLoading { + request_id: *request_id, + }) + .await?; + } + requests::spawn(command, client, cache, requests); } TelegramCommand::SendAttachment { chat_id, local_id, - path, + attachment, caption, - as_photo, reply_to, } => { let Some(peer) = cache.peers.get(&chat_id).copied() else { @@ -1322,40 +1626,80 @@ async fn handle_command( .send(NetworkEvent::AttachmentSendFailed { chat_id, local_id, - path, + attachment, caption, - as_photo, reply_to, error: "conversation is missing its Telegram peer reference".to_owned(), }) .await?; return Ok(false); }; - if transfers.len() >= MAX_CONCURRENT_TRANSFERS { + if transfers.len() >= MAX_PENDING_TRANSFERS { events .send(NetworkEvent::AttachmentSendFailed { chat_id, local_id, - path, + attachment, caption, - as_photo, reply_to, - error: "too many Telegram transfers are already running".to_owned(), + error: "Telegram transfer queue is full".to_owned(), }) .await?; return Ok(false); } let client = client.clone(); + let cache_owner = cache.cache_owner.clone(); + cache.upload_order.retain(|_, previous| { + matches!( + previous.try_recv(), + Err(tokio::sync::oneshot::error::TryRecvError::Empty) + ) + }); + let (finished, gate) = tokio::sync::oneshot::channel::<()>(); + let after = cache.upload_order.insert(chat_id, gate); transfers.spawn(async move { - let result = upload_attachment(&client, peer, &path, &caption, as_photo, reply_to) + // Link tasks in admission order, before they are scheduled. + // Dropping the sender also releases the next item on cancellation. + let _finished = finished; + if let Some(previous) = after { + let _ = previous.await; + } + let permit = Arc::new( + slots + .acquire_owned() + .await + .expect("transfer queue is never closed"), + ); + let _cache_owner = cache_owner.clone(); + let result = async { + let original = attachment.path.clone(); + let digest = attachment.digest; + // Blocking validation retains its permit if this async task + // is cancelled; cancellation cannot create unbounded copies. + let copy_permit = permit.clone(); + let upload = tokio::task::spawn_blocking(move || { + let _permit = copy_permit; + crate::staging::upload_copy(&original, digest) + }) .await - .map_err(|error| format!("{error:#}")); + .context("attachment validation worker failed")??; + upload_attachment( + &client, + peer, + &upload.path, + &caption, + attachment.as_photo, + reply_to, + ) + .await + } + .await + .map_err(|error| chat_info::send_error(&error)); TransferCompletion::Send { chat_id, local_id, - path, + attachment, caption, - as_photo, reply_to, result: Box::new(result), } @@ -1373,8 +1717,8 @@ async fn handle_command( .get(&chat_id) .copied() .context("conversation is missing its Telegram peer reference")?; - if transfers.len() >= MAX_CONCURRENT_TRANSFERS { - bail!("too many Telegram transfers are already running; close and retry"); + if transfers.len() >= MAX_PENDING_TRANSFERS { + bail!("Telegram transfer queue is full; close and retry"); } let directory = ensure_download_dir(cache).await?; Ok::<_, anyhow::Error>((peer, directory)) @@ -1383,14 +1727,34 @@ async fn handle_command( match preparation { Ok((peer, directory)) => { let client = client.clone(); + let cache_owner = cache.cache_owner.clone(); transfers.spawn(async move { + let _permit = slots + .acquire_owned() + .await + .expect("transfer queue is never closed"); + let _cache_owner = cache_owner.clone(); let result = if thumbnail { download_preview_thumbnail( - &client, peer, chat_id, message_id, directory, + &client, + peer, + chat_id, + message_id, + directory, + cache_owner, ) .await } else { - download_attachment(&client, peer, chat_id, message_id, directory).await + download_attachment( + &client, + peer, + chat_id, + message_id, + directory, + None, + cache_owner, + ) + .await } .map_err(|error| format!("{error:#}")); TransferCompletion::Preview { @@ -1416,10 +1780,13 @@ async fn handle_command( TelegramCommand::DownloadAttachment { chat_id, message_id, + request_id, + media_id, } => { let Some(peer) = cache.peers.get(&chat_id).copied() else { events .send(NetworkEvent::AttachmentDownloadFailed { + request_id, chat_id, message_id, error: "conversation is missing its Telegram peer reference".to_owned(), @@ -1427,97 +1794,60 @@ async fn handle_command( .await?; return Ok(false); }; - if transfers.len() >= MAX_CONCURRENT_TRANSFERS { + if transfers.len() >= MAX_PENDING_TRANSFERS { events .send(NetworkEvent::AttachmentDownloadFailed { + request_id, chat_id, message_id, - error: "too many Telegram transfers are already running".to_owned(), + error: "Telegram transfer queue is full".to_owned(), }) .await?; return Ok(false); } let directory = ensure_download_dir(cache).await?; let client = client.clone(); + let cache_owner = cache.cache_owner.clone(); transfers.spawn(async move { - let result = download_attachment(&client, peer, chat_id, message_id, directory) + let _permit = slots + .acquire_owned() .await - .map_err(|error| format!("{error:#}")); + .expect("transfer queue is never closed"); + let _cache_owner = cache_owner.clone(); + let result = download_attachment( + &client, + peer, + chat_id, + message_id, + directory, + media_id, + cache_owner, + ) + .await + .map_err(|error| format!("{error:#}")); TransferCompletion::Download { + request_id, chat_id, message_id, result, } }); } - TelegramCommand::ResolveTelegramLink { url } => { - match Box::pin(resolve_telegram_link(client, cache, &url)).await { - Ok((chat, message)) => { - events - .send(NetworkEvent::LinkResolved { chat, message }) - .await?; - } - Err(error) => { - events - .send(NetworkEvent::LinkFailed { - url, - error: format!("Could not open Telegram link: {error:#}"), - }) - .await?; - } - } + TelegramCommand::PrepareAttachments(_) + | TelegramCommand::CopyText(_) + | TelegramCommand::SearchCached(_) + | TelegramCommand::LoadCachedContext { .. } => { + unreachable!("local searches never reach Telegram") } - TelegramCommand::ActivateButton { - chat_id, - message_id, - button_index, - } => match activate_inline_button(client, cache, chat_id, message_id, button_index).await { - Ok((message, url)) => { - events - .send(NetworkEvent::ButtonActivated { - chat_id, - message_id, - message, - url, - }) - .await?; - } - Err(error) => { - events - .send(NetworkEvent::ButtonFailed { - chat_id, - message_id, - error: format!("Could not activate button: {error:#}"), - }) - .await?; - } - }, - TelegramCommand::MarkRead { chat_id } => { - let result = match cache.peers.get(&chat_id).copied() { - Some(peer) => client.mark_as_read(peer).await, - None => Err(InvocationError::Dropped), - }; - match result { - Ok(()) => events.send(NetworkEvent::ReadMarked { chat_id }).await?, - Err(error) => { - events - .send(NetworkEvent::ReadMarkFailed { - chat_id, - error: format!("Could not mark conversation read: {error}"), - }) - .await?; - } - } + TelegramCommand::RefreshFolders => { + cache.folders.dirty = true; } TelegramCommand::RefreshDialogs => { - if let Err(error) = load_dialogs(client, cache, events).await { - events - .send(NetworkEvent::DialogsFailed(format!( - "Could not refresh conversations: {error:#}" - ))) - .await?; - } + cache.dialog_pins.dirty = true; + cache.message_pins.dirty = true; + requests::refresh_dialogs(client, cache, events, requests).await?; } + TelegramCommand::RefreshDialogPins => cache.dialog_pins.dirty = true, TelegramCommand::Shutdown => return Ok(true), TelegramCommand::StartQrAuth | TelegramCommand::SubmitPhone(_) @@ -1531,18 +1861,13 @@ async fn handle_command( async fn activate_inline_button( client: &Client, - cache: &WorkerCache, - chat_id: ChatId, + peer: PeerRef, message_id: i32, button_index: u16, ) -> Result<(Option, Option)> { if message_id <= 0 { bail!("invalid Telegram message identifier") } - let peer = *cache - .peers - .get(&chat_id) - .context("conversation is missing its Telegram peer reference")?; let mut messages = client .get_messages_by_id(peer, &[message_id]) .await @@ -1625,7 +1950,6 @@ async fn upload_attachment( async fn process_transfer_completion( completion: TransferCompletion, - client: &Client, cache: &mut WorkerCache, events: &mpsc::Sender, ) -> Result<()> { @@ -1656,22 +1980,23 @@ async fn process_transfer_completion( TransferCompletion::Send { chat_id, local_id, - path, + attachment, caption, - as_photo, reply_to, result, } => match *result { Ok(message) if message.id() <= 0 => { + attachment.discard_owned(); events .send(NetworkEvent::MessageAccepted { chat_id, local_id }) .await?; } Ok(message) => { + attachment.discard_owned(); events .send(NetworkEvent::MessageSent { local_id, - message: Box::pin(map_message(client, &message, cache)).await?, + message: map_message(&message, cache)?, }) .await?; } @@ -1680,9 +2005,8 @@ async fn process_transfer_completion( .send(NetworkEvent::AttachmentSendFailed { chat_id, local_id, - path, + attachment, caption, - as_photo, reply_to, error: format!("Could not send attachment: {error}"), }) @@ -1690,6 +2014,7 @@ async fn process_transfer_completion( } }, TransferCompletion::Download { + request_id, chat_id, message_id, result, @@ -1697,6 +2022,7 @@ async fn process_transfer_completion( Ok(path) => { events .send(NetworkEvent::AttachmentDownloaded { + request_id, chat_id, message_id, path, @@ -1706,6 +2032,7 @@ async fn process_transfer_completion( Err(error) => { events .send(NetworkEvent::AttachmentDownloadFailed { + request_id, chat_id, message_id, error: format!("Could not download attachment: {error}"), @@ -1723,6 +2050,8 @@ async fn download_attachment( chat_id: ChatId, message_id: i32, directory: PathBuf, + expected_media_id: Option, + cache_owner: Option>, ) -> Result { let mut messages = client .get_messages_by_id(peer, &[message_id]) @@ -1742,17 +2071,30 @@ async fn download_attachment( bail!("this type of Telegram media is not downloadable") } + if expected_media_id.is_some() + && attachment_from_media(&media).and_then(|attachment| attachment.source_id) + != expected_media_id + { + bail!("Attachment changed; refresh the message before opening it"); + } let suggested_name = attachment_from_media(&media).map_or_else( || "attachment".to_owned(), |attachment| attachment.display_name().to_owned(), ); - let path = unique_download_path(&directory, chat_id, message_id, &suggested_name).await?; - let partial = PartialDownload::new(path); + let partial = media_cache::temporary(&directory)?; client - .download_media(&media, &partial.path) + .download_media(&media, partial.as_ref() as &Path) .await .context("Telegram media transfer failed")?; - Ok(partial.finish()) + media_cache::finish( + partial, + directory, + chat_id, + message_id, + &suggested_name, + cache_owner, + ) + .await } /// Use the SDK's Downloadable thumbnail implementation, including cached and @@ -1763,6 +2105,7 @@ async fn download_preview_thumbnail( chat_id: ChatId, message_id: i32, directory: PathBuf, + cache_owner: Option>, ) -> Result { let message = client .get_messages_by_id(peer, &[message_id]) @@ -1783,46 +2126,36 @@ async fn download_preview_thumbnail( .filter(|thumb| thumb.size() > 0) .max_by_key(PhotoSize::size) .context("Telegram did not provide a static thumbnail for this sticker")?; - let path = unique_download_path(&directory, chat_id, message_id, "preview.jpg").await?; - let partial = PartialDownload::new(path); + let partial = media_cache::temporary(&directory)?; client - .download_media(&thumbnail, &partial.path) + .download_media(&thumbnail, partial.as_ref() as &Path) .await .context("Telegram preview transfer failed")?; - Ok(partial.finish()) + media_cache::finish( + partial, + directory, + chat_id, + message_id, + "preview.jpg", + cache_owner, + ) + .await } async fn ensure_download_dir(cache: &mut WorkerCache) -> Result { if let Some(path) = &cache.download_dir { return Ok(path.clone()); } - let nonce = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_nanos(); - let root = std::env::temp_dir().join(format!("termgram-{}-{nonce}", std::process::id())); - tokio::fs::create_dir(&root) - .await - .with_context(|| format!("could not create temporary directory {}", root.display()))?; - protect_download_dir(&root).await?; + let root = media_cache::prepare( + cache.media_root.clone(), + cache.self_id, + cache.cache_owner.clone(), + ) + .await?; cache.download_dir = Some(root.clone()); Ok(root) } -#[cfg(unix)] -async fn protect_download_dir(path: &Path) -> Result<()> { - use std::os::unix::fs::PermissionsExt; - - tokio::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)) - .await - .with_context(|| format!("could not protect temporary directory {}", path.display())) -} - -#[cfg(not(unix))] -async fn protect_download_dir(_path: &Path) -> Result<()> { - Ok(()) -} - async fn unique_download_path( directory: &Path, chat_id: ChatId, @@ -1930,9 +2263,9 @@ fn is_windows_reserved_base(base: &str) -> bool { async fn resolve_telegram_link( client: &Client, - cache: &mut WorkerCache, + private_link: Option<(PeerRef, String)>, url: &str, -) -> Result<(Chat, Option)> { +) -> Result<(Chat, PeerRef, Option)> { let target = parse_telegram_link(url)?; let (chat, peer, message_id) = match target { TelegramLink::Public { @@ -1949,7 +2282,6 @@ async fn resolve_telegram_link( .bot_api_dialog_id() .context("resolved chat has no stable identifier")?; if matches!(&resolved, Peer::Channel(_)) { - cache.hidden_broadcasts.insert(id); bail!("broadcast channels are outside Termgram's messaging scope") } let peer = resolved @@ -1957,21 +2289,18 @@ async fn resolve_telegram_link( .await .map_err(anyhow::Error::from_boxed)? .context("Telegram did not provide an addressable peer reference")?; - cache.peers.insert(id, peer); - cache.linked_peers.insert(id); - cache_sender_name(cache, resolved.id(), safe_name(resolved.name(), "Unknown")); - if matches!(&resolved, Peer::Group(_)) && resolved.id().kind() == PeerKind::Channel { - cache.visible_channel_groups.insert(id); - } let chat = Chat { + read_inbox_max_id: None, + membership: crate::folders::ChatMembership::default(), id, - title: safe_name(resolved.name(), "Unknown"), + title: peer_display_name(&resolved), kind: match &resolved { Peer::User(_) => ChatKind::Direct, Peer::Group(_) => ChatKind::Group, Peer::Channel(_) => unreachable!("broadcast channels are rejected above"), }, unread: 0, + last_message_id: None, last_message: String::new(), last_activity: None, }; @@ -1981,28 +2310,20 @@ async fn resolve_telegram_link( chat_id, message_id, } => { - let peer = *cache.peers.get(&chat_id).context( + let (peer, title) = private_link.context( "private message links can only open groups already present in your conversations", )?; - if cache.hidden_broadcasts.contains(&chat_id) - || !cache.visible_channel_groups.contains(&chat_id) - { - bail!("private link does not target a visible group conversation") - } - let peer_id = peer.id; let chat = Chat { + read_inbox_max_id: None, + membership: crate::folders::ChatMembership::default(), id: chat_id, - title: cache - .names - .get(&peer_id) - .cloned() - .unwrap_or_else(|| "Unknown".to_owned()), + title, kind: ChatKind::Group, unread: 0, + last_message_id: None, last_message: String::new(), last_activity: None, }; - cache.linked_peers.insert(chat_id); (chat, peer, Some(message_id)) } }; @@ -2016,11 +2337,11 @@ async fn resolve_telegram_link( .pop() .flatten() .context("linked message is unavailable")?; - Some(Box::pin(map_message(client, &telegram_message, cache)).await?) + Some(telegram_message) } else { None }; - Ok((chat, message)) + Ok((chat, peer, message)) } fn parse_telegram_link(value: &str) -> Result { @@ -2144,23 +2465,40 @@ fn private_chat_id(value: &str) -> Result { .context("private Telegram channel identifier is too large") } -async fn map_message( - client: &Client, - message: &TelegramMessage, - cache: &mut WorkerCache, -) -> Result { +fn map_message(message: &TelegramMessage, cache: &mut WorkerCache) -> Result { let chat_id = peer_id(message)?; let media = message.media(); + let (text, entities) = entities::map( + message.text(), + message.fmt_entities().map_or(&[], Vec::as_slice), + ); let (sender, reply_sender) = if message.outgoing() { let sender = "You".to_owned(); let reply_sender = username_or_sender(message.sender().and_then(Peer::username), &sender); (sender, reply_sender) } else { - resolve_sender(client, message, cache).await + resolve_sender(message, cache) }; let reply_to = reply_info(message, chat_id, cache); cache_message_sender(cache, chat_id, message.id(), reply_sender); Ok(Message { + reactions: reactions::map(message), + poll: polls::map(message), + entities, + notification: Some(crate::notifications::Metadata { + sender: message + .sender_id() + .and_then(PeerId::bot_api_dialog_id) + .filter(|id| *id > 0), + silent: message.silent(), + protected: matches!(&message.raw, tl::enums::Message::Message(raw) if raw.noforwards), + }), + mention: message.mentioned().then(|| crate::model::Mention { + unread: message.media_unread() && !message.outgoing(), + requires_playback: notifications::requires_playback(message), + }), + edited_at: message.edit_date(), + pinned: message.pinned(), id: message.id(), chat_id, sender, @@ -2168,7 +2506,7 @@ async fn map_message( // Telegram stores the Unicode fallback for custom-emoji entities in // the raw message string. Keep that text instead of trying to render // the custom document in a terminal. - text: sanitized_message_text(message), + text, timestamp: message.date(), outgoing: message.outgoing(), delivery: if message.outgoing() @@ -2246,16 +2584,12 @@ fn cache_message_sender(cache: &mut WorkerCache, chat_id: ChatId, message_id: i3 } } -async fn resolve_sender( - client: &Client, - message: &TelegramMessage, - cache: &mut WorkerCache, -) -> (String, String) { +fn resolve_sender(message: &TelegramMessage, cache: &mut WorkerCache) -> (String, String) { let Some(sender_id) = message.sender_id() else { return ("Unknown".to_owned(), "Unknown".to_owned()); }; if let Some(peer) = message.sender() { - let name = safe_name(peer.name(), "Unknown"); + let name = peer_display_name(peer); let reply_sender = username_or_sender(peer.username(), &name); cache_sender_name(cache, sender_id, name.clone()); return (name, reply_sender); @@ -2263,16 +2597,9 @@ async fn resolve_sender( if let Some(name) = cache.names.get(&sender_id) { return (name.clone(), name.clone()); } - let Ok(Some(sender)) = message.sender_ref().await else { - return ("Unknown".to_owned(), "Unknown".to_owned()); - }; - let Ok(peer) = client.resolve_peer(sender).await else { - return ("Unknown".to_owned(), "Unknown".to_owned()); - }; - let name = safe_name(peer.name(), "Unknown"); - let reply_sender = username_or_sender(peer.username(), &name); - cache_sender_name(cache, sender_id, name.clone()); - (name, reply_sender) + // Short updates may omit the sender object. Never make a network round + // trip on the update path just to obtain a display name. + ("Unknown".to_owned(), "Unknown".to_owned()) } fn username_or_sender(username: Option<&str>, sender: &str) -> String { @@ -2317,7 +2644,7 @@ fn begin_unresolved_refresh(cache: &mut WorkerCache) -> bool { } async fn is_hidden_broadcast( - client: &Client, + session: &SqliteSession, message: &TelegramMessage, cache: &mut WorkerCache, ) -> Result> { @@ -2348,29 +2675,20 @@ async fn is_hidden_broadcast( return Ok(Some(false)); } - let Some(peer_ref) = message - .peer_ref() - .await - .map_err(anyhow::Error::from_boxed)? - else { - // Unknown channel-shaped peers may be either broadcasts or megagroups. - // Drop this update safely, but do not poison either classification cache. - return Ok(None); - }; - match client.resolve_peer(peer_ref).await { - Ok(Peer::Group(group)) => { - cache.peers.insert(chat_id, peer_ref); - cache_sender_name(cache, group.id(), safe_name(group.title(), "Unknown")); - cache.visible_channel_groups.insert(chat_id); - Ok(Some(false)) - } - Ok(Peer::Channel(_)) => { + let info = session.peer(message.peer_id()).await?; + match info { + Some(PeerInfo::Channel { + kind: Some(ChannelKind::Broadcast), + .. + }) => { cache.hidden_broadcasts.insert(chat_id); - cache.visible_channel_groups.remove(&chat_id); Ok(Some(true)) } - Ok(Peer::User(_)) => Ok(Some(false)), - Err(_) => Ok(None), + Some(PeerInfo::Channel { kind: Some(_), .. }) => { + cache.visible_channel_groups.insert(chat_id); + Ok(Some(false)) + } + _ => Ok(None), } } @@ -2382,12 +2700,19 @@ fn peer_id(message: &TelegramMessage) -> Result { } fn message_preview(message: &TelegramMessage) -> String { + if let Some(poll) = polls::map(message) { + return format!( + "[{}] {}", + if poll.definition.quiz { "quiz" } else { "poll" }, + poll.definition.question.preview() + ); + } let media = message.media(); - message_preview_with_media(&sanitized_message_text(message), media.as_ref()) -} - -fn sanitized_message_text(message: &TelegramMessage) -> String { - sanitize_terminal_text(message.text()) + let (text, entities) = entities::map( + message.text(), + message.fmt_entities().map_or(&[], Vec::as_slice), + ); + message_preview_with_media(&crate::entities::conceal(&text, &entities), media.as_ref()) } fn message_links(message: &TelegramMessage) -> Vec { @@ -2538,6 +2863,7 @@ fn message_preview_with_media(text: &str, media: Option<&Media>) -> String { fn attachment_from_media(media: &Media) -> Option { match media { Media::Photo(photo) => Some(Attachment { + source_id: Some(photo.id()), kind: AttachmentKind::Photo, file_name: Some("photo.jpg".to_owned()), mime_type: Some("image/jpeg".to_owned()), @@ -2552,6 +2878,7 @@ fn attachment_from_media(media: &Media) -> Option { _ => AttachmentKind::File, }; Some(Attachment { + source_id: Some(document.id()), kind, file_name: safe_media_name(document.name()), mime_type, @@ -2560,6 +2887,7 @@ fn attachment_from_media(media: &Media) -> Option { }) } Media::Sticker(sticker) => Some(Attachment { + source_id: Some(sticker.document.id()), kind: AttachmentKind::Sticker, file_name: safe_media_name(sticker.document.name()) .or_else(|| Some(default_sticker_name(sticker.document.mime_type()).to_owned())), @@ -2600,6 +2928,20 @@ fn safe_name(value: Option<&str>, fallback: &str) -> String { } } +fn peer_display_name(peer: &Peer) -> String { + match peer { + Peer::User(user) => safe_name( + Some(&user.full_name()), + if user.deleted() { + "Deleted account" + } else { + "Unknown" + }, + ), + _ => safe_name(peer.name(), "Unknown"), + } +} + #[cfg(test)] mod tests { use std::collections::HashSet; @@ -2829,6 +3171,13 @@ mod tests { let mut cache = WorkerCache::default(); cache_message_sender(&mut cache, 7, 41, "Alice".to_owned()); let mut message = Message { + reactions: None, + poll: None, + entities: Vec::new(), + notification: None, + mention: None, + edited_at: None, + pinned: false, id: 42, chat_id: 7, sender: "Bob".to_owned(), @@ -2888,6 +3237,13 @@ mod tests { let mut cache = WorkerCache::default(); cache_message_sender(&mut cache, 8, 41, "Other chat".to_owned()); let mut message = Message { + reactions: None, + poll: None, + entities: Vec::new(), + notification: None, + mention: None, + edited_at: None, + pinned: false, id: 42, chat_id: 7, sender: "Bob".to_owned(), diff --git a/src/telegram/notifications.rs b/src/telegram/notifications.rs new file mode 100644 index 0000000..a971acf --- /dev/null +++ b/src/telegram/notifications.rs @@ -0,0 +1,208 @@ +use anyhow::{Result, ensure}; +use grammers_client::{Client, peer::Peer, tl}; +use grammers_session::types::PeerRef; + +pub(super) async fn set_mute( + client: &Client, + peer: PeerRef, + mute: crate::notifications::Mute, +) -> Result { + let target = tl::enums::InputNotifyPeer::Peer(tl::types::InputNotifyPeer { peer: peer.into() }); + let tl::enums::PeerNotifySettings::Settings(current) = client + .invoke(&tl::functions::account::GetNotifySettings { + peer: target.clone(), + }) + .await?; + let settings = with_mute(current, mute.until(chrono::Utc::now().timestamp())); + ensure!( + client + .invoke(&tl::functions::account::UpdateNotifySettings { + peer: target.clone(), + settings: settings.into() + }) + .await?, + "Telegram did not accept the notification setting" + ); + let tl::enums::PeerNotifySettings::Settings(current) = client + .invoke(&tl::functions::account::GetNotifySettings { peer: target }) + .await?; + if let Some(until) = current.mute_until { + return Ok(i64::from(until)); + } + // Another client may have restored inheritance while this request ran. + let scope = match client.resolve_peer(peer).await? { + Peer::User(_) => tl::enums::InputNotifyPeer::InputNotifyUsers, + Peer::Group(_) => tl::enums::InputNotifyPeer::InputNotifyChats, + Peer::Channel(_) => tl::enums::InputNotifyPeer::InputNotifyBroadcasts, + }; + let tl::enums::PeerNotifySettings::Settings(defaults) = client + .invoke(&tl::functions::account::GetNotifySettings { peer: scope }) + .await?; + Ok(i64::from(defaults.mute_until.unwrap_or(0))) +} + +fn with_mute( + current: tl::types::PeerNotifySettings, + until: i32, +) -> tl::types::InputPeerNotifySettings { + // Desktop serializes the whole peer override. Preserve preview, sounds, + // silent-post and story preferences instead of resetting them on mute. + tl::types::InputPeerNotifySettings { + show_previews: current.show_previews, + silent: current.silent, + mute_until: Some(until), + sound: current.other_sound, + stories_muted: current.stories_muted, + stories_hide_sender: current.stories_hide_sender, + stories_sound: current.stories_other_sound, + } +} + +pub(super) fn requires_playback(message: &grammers_client::message::Message) -> bool { + let tl::enums::Message::Message(message) = &message.raw else { + return false; + }; + match &message.media { + Some(tl::enums::MessageMedia::Photo(photo)) => photo.ttl_seconds.is_some(), + Some(tl::enums::MessageMedia::Document(media)) => { + media.ttl_seconds.is_some() + || match &media.document { + Some(tl::enums::Document::Document(document)) => { + document.attributes.iter().any(|attribute| match attribute { + tl::enums::DocumentAttribute::Audio(audio) => audio.voice, + tl::enums::DocumentAttribute::Video(video) => video.round_message, + _ => false, + }) + } + _ => false, + } + } + _ => false, + } +} + +pub(super) async fn read_mentions(client: &Client, peer: PeerRef, ids: &[i32]) -> Result<()> { + ensure!( + !ids.is_empty() && ids.len() <= 100 && ids.iter().all(|id| *id > 0), + "Mention receipts require 1–100 delivered message IDs" + ); + if peer.id.kind() == grammers_session::types::PeerKind::Channel { + ensure!( + client + .invoke(&tl::functions::channels::ReadMessageContents { + channel: peer.into(), + id: ids.to_vec() + }) + .await?, + "Telegram did not acknowledge the mentions" + ); + } else { + client + .invoke(&tl::functions::messages::ReadMessageContents { id: ids.to_vec() }) + .await?; + } + Ok(()) +} + +pub(super) async fn resolve_alert( + client: &Client, + peer: PeerRef, + sender_id: Option, + sender: Option, +) -> Result { + let chat = alert_preferences(client, peer).await?; + let sender_mute_until = if chat.mute_until > chrono::Utc::now().timestamp() { + if let Some(sender) = sender.filter(|peer| peer.id.bot_api_dialog_id() == sender_id) { + Some(alert_preferences(client, sender).await?.mute_until) + } else { + None + } + } else { + None + }; + Ok(crate::notifications::Resolved { + chat, + sender_mute_until, + }) +} + +async fn alert_preferences( + client: &Client, + peer: PeerRef, +) -> Result { + let tl::enums::PeerNotifySettings::Settings(settings) = client + .invoke(&tl::functions::account::GetNotifySettings { + peer: tl::types::InputNotifyPeer { peer: peer.into() }.into(), + }) + .await?; + // Every optional field inherits independently. The current dialog scope is + // users and groups; broadcast-specific defaults are added with channel UX. + let defaults = if settings.mute_until.is_none() + || settings.show_previews.is_none() + || settings.other_sound.is_none() + { + let tl::enums::PeerNotifySettings::Settings(defaults) = client + .invoke(&tl::functions::account::GetNotifySettings { + peer: if peer.id.kind() == grammers_session::types::PeerKind::User { + tl::enums::InputNotifyPeer::InputNotifyUsers + } else { + tl::enums::InputNotifyPeer::InputNotifyChats + }, + }) + .await?; + Some(defaults) + } else { + None + }; + Ok(crate::notifications::Preferences { + mute_until: i64::from( + settings + .mute_until + .or_else(|| defaults.as_ref().and_then(|default| default.mute_until)) + .unwrap_or(0), + ), + previews: settings + .show_previews + .or_else(|| defaults.as_ref().and_then(|default| default.show_previews)) + .unwrap_or(true), + sound: !matches!( + settings.other_sound.as_ref().or_else(|| defaults + .as_ref() + .and_then(|default| default.other_sound.as_ref())), + Some(tl::enums::NotificationSound::None) + ), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn changing_mute_preserves_other_notification_overrides() { + let old = tl::types::PeerNotifySettings { + show_previews: Some(false), + silent: Some(true), + mute_until: Some(0), + ios_sound: None, + android_sound: None, + other_sound: Some(tl::enums::NotificationSound::Ringtone( + tl::types::NotificationSoundRingtone { id: 123 }, + )), + stories_muted: Some(true), + stories_hide_sender: Some(true), + stories_ios_sound: None, + stories_android_sound: None, + stories_other_sound: Some(tl::enums::NotificationSound::None), + }; + let muted = with_mute(old.clone(), crate::notifications::Mute::Hour.until(1000)); + assert_eq!(muted.mute_until, Some(4600)); + assert_eq!(muted.show_previews, Some(false)); + assert_eq!(muted.silent, Some(true)); + assert_eq!(muted.sound, old.other_sound); + assert_eq!(muted.stories_sound, old.stories_other_sound); + assert_eq!(muted.stories_hide_sender, Some(true)); + assert_eq!(crate::notifications::Mute::Forever.until(1000), i32::MAX); + assert_eq!(crate::notifications::Mute::Off.until(1000), 0); + } +} diff --git a/src/telegram/pins.rs b/src/telegram/pins.rs new file mode 100644 index 0000000..6c9a772 --- /dev/null +++ b/src/telegram/pins.rs @@ -0,0 +1,292 @@ +//! Native pin RPC adapters. Filter edits start from fresh, complete server data. + +use anyhow::{Context, Result, ensure}; +use grammers_client::{ + Client, + peer::{Peer, User}, + tl, +}; +use grammers_session::types::{PeerId, PeerRef}; + +use crate::pins::{DialogAction, DialogPins, DialogScope, move_pin}; + +pub(super) async fn change_message( + client: &Client, + peer: PeerRef, + id: i32, + action: crate::pins::MessageAction, +) -> Result<()> { + use crate::pins::MessageAction; + match action { + MessageAction::Pin { notify, only_self } => { + ensure!(id > 0, "Only sent messages can be pinned"); + client + .invoke(&tl::functions::messages::UpdatePinnedMessage { + silent: !notify, + unpin: false, + pm_oneside: only_self, + peer: peer.into(), + id, + }) + .await?; + } + MessageAction::Unpin => { + client.unpin_message(peer, id).await?; + } + MessageAction::UnpinAll => { + // The server can split large unpin operations into several batches. + loop { + let tl::enums::messages::AffectedHistory::History(result) = client + .invoke(&tl::functions::messages::UnpinAllMessages { + peer: peer.into(), + top_msg_id: None, + saved_peer_id: None, + }) + .await?; + if result.offset == 0 { + break; + } + } + } + } + Ok(()) +} + +pub(super) async fn load_dialogs(client: &Client) -> Result { + let (main, archive) = tokio::try_join!(get_dialogs(client, 0), get_dialogs(client, 1))?; + Ok(DialogPins { + main: dialog_ids(&main), + archive: dialog_ids(&archive), + }) +} + +async fn get_dialogs(client: &Client, folder_id: i32) -> Result { + let tl::enums::messages::PeerDialogs::Dialogs(dialogs) = client + .invoke(&tl::functions::messages::GetPinnedDialogs { folder_id }) + .await?; + Ok(dialogs) +} + +fn dialog_ids(dialogs: &tl::types::messages::PeerDialogs) -> Vec { + dialogs + .dialogs + .iter() + .filter_map(|dialog| match dialog { + tl::enums::Dialog::Dialog(dialog) => { + PeerId::from(dialog.peer.clone()).bot_api_dialog_id() + } + tl::enums::Dialog::Folder(_) => None, + }) + .collect() +} + +pub(super) async fn change_dialog( + client: &Client, + peer: PeerRef, + scope: DialogScope, + action: DialogAction, + self_id: i64, +) -> Result<()> { + if let DialogScope::Filter(id) = scope { + return change_filter(client, peer, id, action, self_id).await; + } + if let DialogAction::Set(pinned) = action { + ensure!( + client + .invoke(&tl::functions::messages::ToggleDialogPin { + pinned, + peer: tl::types::InputDialogPeer { peer: peer.into() }.into(), + }) + .await?, + "Telegram did not change the chat pin" + ); + return Ok(()); + } + let folder_id = i32::from(scope == DialogScope::Archive); + let dialogs = get_dialogs(client, folder_id).await?; + let ids = dialog_ids(&dialogs); + let position = ids + .iter() + .position(|id| *id == peer.id.bot_api_dialog_id_unchecked()) + .context("This chat is no longer pinned; refresh and try again")?; + let peers = dialogs + .users + .into_iter() + .map(|user| Peer::User(User::from_raw(client, user))) + .chain( + dialogs + .chats + .into_iter() + .map(|chat| Peer::from_raw(client, chat)), + ) + .map(|peer| (peer.id(), peer)) + .collect::>(); + let mut order = Vec::new(); + for dialog in dialogs.dialogs { + match dialog { + tl::enums::Dialog::Dialog(dialog) => { + let id = PeerId::from(dialog.peer); + let reference = peers + .get(&id) + .context("Pinned peer is unavailable")? + .to_ref() + .await + .map_err(anyhow::Error::from_boxed)? + .context("Pinned peer has no access reference")?; + order.push( + tl::types::InputDialogPeer { + peer: reference.into(), + } + .into(), + ); + } + tl::enums::Dialog::Folder(_) => {} + } + } + move_pin(&mut order, position, action); + ensure!( + client + .invoke(&tl::functions::messages::ReorderPinnedDialogs { + force: false, + folder_id, + order, + }) + .await?, + "Telegram did not reorder pinned chats" + ); + Ok(()) +} + +async fn change_filter( + client: &Client, + peer: PeerRef, + id: i32, + action: DialogAction, + self_id: i64, +) -> Result<()> { + let tl::enums::messages::DialogFilters::Filters(filters) = client + .invoke(&tl::functions::messages::GetDialogFilters {}) + .await?; + let mut filter = filters + .filters + .into_iter() + .find(|filter| match filter { + tl::enums::DialogFilter::Filter(filter) => filter.id == id, + tl::enums::DialogFilter::Chatlist(filter) => filter.id == id, + tl::enums::DialogFilter::Default => false, + }) + .context("This Telegram folder no longer exists")?; + edit_filter_pins(&mut filter, peer.into(), action, self_id)?; + ensure!( + client + .invoke(&tl::functions::messages::UpdateDialogFilter { + id, + filter: Some(filter), + }) + .await?, + "Telegram did not update folder pins" + ); + Ok(()) +} + +fn edit_filter_pins( + filter: &mut tl::enums::DialogFilter, + peer: tl::enums::InputPeer, + action: DialogAction, + self_id: i64, +) -> Result<()> { + let (pinned, included) = match filter { + tl::enums::DialogFilter::Filter(filter) => { + (&mut filter.pinned_peers, &mut filter.include_peers) + } + tl::enums::DialogFilter::Chatlist(filter) => { + (&mut filter.pinned_peers, &mut filter.include_peers) + } + tl::enums::DialogFilter::Default => anyhow::bail!("Default folder pins use the dialog API"), + }; + let id = super::folders::peer_id(&peer, self_id); + let position = pinned + .iter() + .position(|candidate| super::folders::peer_id(candidate, self_id) == id); + // Desktop's `always` set is the union of include_peers and pinned_peers. + // Unpinning retains explicit membership; serialization excludes pins from + // include_peers (tdesktop 4d4da471, data/data_chat_filters.cpp). + for candidate in pinned.iter() { + let id = super::folders::peer_id(candidate, self_id); + if !included + .iter() + .any(|peer| super::folders::peer_id(peer, self_id) == id) + { + included.push(candidate.clone()); + } + } + match action { + DialogAction::Set(true) if position.is_none() => { + pinned.insert(0, peer.clone()); + if !included + .iter() + .any(|candidate| super::folders::peer_id(candidate, self_id) == id) + { + included.push(peer); + } + } + DialogAction::Set(false) => { + if let Some(position) = position { + pinned.remove(position); + } + } + DialogAction::Set(true) => {} + _ => move_pin( + pinned, + position.context("This chat is no longer pinned in this folder")?, + action, + ), + } + included.retain(|peer| { + !pinned.iter().any(|candidate| { + super::folders::peer_id(peer, self_id) == super::folders::peer_id(candidate, self_id) + }) + }); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unpin_keeps_explicit_membership_and_preserves_server_filter_fields() { + let peer = |id| tl::enums::InputPeer::Chat(tl::types::InputPeerChat { chat_id: id }); + let original = tl::types::DialogFilter { + contacts: false, + non_contacts: true, + groups: true, + broadcasts: true, + bots: false, + exclude_muted: true, + exclude_read: true, + exclude_archived: true, + title_noanimate: true, + id: 7, + title: tl::types::TextWithEntities { + text: "Work".into(), + entities: vec![], + } + .into(), + emoticon: Some("x".into()), + color: Some(4), + pinned_peers: vec![peer(1), peer(2)], + include_peers: vec![peer(3)], + exclude_peers: vec![peer(4)], + }; + let mut filter: tl::enums::DialogFilter = original.clone().into(); + edit_filter_pins(&mut filter, peer(2), DialogAction::Set(false), 10).unwrap(); + let mut expected = original.clone(); + expected.pinned_peers = vec![peer(1)]; + expected.include_peers = vec![peer(3), peer(2)]; + assert_eq!(filter, expected.into()); + edit_filter_pins(&mut filter, peer(2), DialogAction::Set(true), 10).unwrap(); + edit_filter_pins(&mut filter, peer(2), DialogAction::MoveDown, 10).unwrap(); + assert_eq!(filter, original.into()); + } +} diff --git a/src/telegram/polls.rs b/src/telegram/polls.rs new file mode 100644 index 0000000..dc75138 --- /dev/null +++ b/src/telegram/polls.rs @@ -0,0 +1,155 @@ +use super::entities; +use crate::polls::{Answer, Count, Definition, Poll, Results, ResultsPatch, Text, Update}; +use anyhow::{Context, Result, ensure}; +use grammers_client::{Client, message::Message, tl}; +use grammers_session::types::PeerRef; + +fn text(value: &tl::enums::TextWithEntities) -> Text { + let tl::enums::TextWithEntities::Entities(value) = value; + let (text, entities) = entities::map(&value.text, &value.entities); + Text { text, entities } +} + +fn definition(raw: &tl::types::Poll, has_media: bool) -> Definition { + Definition { + id: raw.id, + hash: raw.hash, + question: text(&raw.question), + answers: raw + .answers + .iter() + .filter_map(|answer| { + let tl::enums::PollAnswer::Answer(answer) = answer else { + return None; + }; + Some(Answer { + option: answer.option.clone(), + text: text(&answer.text), + has_media: answer.media.is_some(), + }) + }) + .collect(), + closed: raw.closed, + public_voters: raw.public_voters, + multiple_choice: raw.multiple_choice, + quiz: raw.quiz, + revoting_disabled: raw.revoting_disabled, + hide_results_until_close: raw.hide_results_until_close, + subscribers_only: raw.subscribers_only, + countries: raw + .countries_iso2 + .iter() + .flatten() + .map(|country| crate::model::sanitize_terminal_line(country)) + .collect(), + close_date: raw.close_date.map(i64::from), + has_media, + } +} + +fn results(raw: &tl::types::PollResults) -> ResultsPatch { + ResultsPatch { + min: raw.min, + counts: raw.results.as_ref().map(|counts| { + counts + .iter() + .map(|count| { + let tl::enums::PollAnswerVoters::Voters(count) = count; + Count { + option: count.option.clone(), + voters: count.voters.and_then(|n| u32::try_from(n).ok()), + chosen: count.chosen, + correct: count.correct, + } + }) + .collect() + }), + total: raw.total_voters.and_then(|n| u32::try_from(n).ok()), + solution: raw.solution.as_ref().map(|value| { + let (text, entities) = + entities::map(value, raw.solution_entities.as_deref().unwrap_or_default()); + Text { text, entities } + }), + } +} + +pub(super) fn map(message: &Message) -> Option { + let tl::enums::Message::Message(message) = &message.raw else { + return None; + }; + let tl::enums::MessageMedia::Poll(media) = message.media.as_ref()? else { + return None; + }; + let tl::enums::Poll::Poll(raw) = &media.poll; + let tl::enums::PollResults::Results(raw_results) = &media.results; + let mut poll = Poll { + definition: definition(raw, media.attached_media.is_some()), + results: Results::default(), + stale: false, + }; + poll.apply(&Update { + id: raw.id, + stale: false, + definition: None, + results: results(raw_results), + }); + Some(poll) +} + +pub(super) fn update(raw: &tl::types::UpdateMessagePoll) -> Update { + let tl::enums::PollResults::Results(raw_results) = &raw.results; + Update { + id: raw.poll_id, + stale: false, + definition: raw.poll.as_ref().map(|poll| { + let tl::enums::Poll::Poll(poll) = poll; + definition(poll, false) + }), + results: results(raw_results), + } +} + +pub(super) async fn load(client: &Client, peer: PeerRef, message_id: i32) -> Result { + ensure!(message_id > 0, "Select a delivered poll"); + let message = super::message_actions::fetch(client, peer, message_id).await?; + map(&message).context("The message is no longer an available poll") +} + +pub(super) async fn vote( + client: &Client, + peer: PeerRef, + message_id: i32, + poll_id: i64, + revision: u64, + options: &[Vec], +) -> Result<()> { + let poll = load(client, peer, message_id).await?; + ensure!(poll.definition.id == poll_id, "The poll changed; reopen it"); + poll.validate_vote(revision, options, chrono::Utc::now().timestamp()) + .map_err(anyhow::Error::msg)?; + client + .invoke(&tl::functions::messages::SendVote { + peer: peer.into(), + msg_id: message_id, + options: options.to_vec(), + }) + .await?; + // The SDK puts this RPC's Updates into the existing ordered update stream. + Ok(()) +} + +pub(super) async fn refresh( + client: &Client, + peer: PeerRef, + message_id: i32, + hash: i64, +) -> Result<()> { + client + .invoke(&tl::functions::messages::GetPollResults { + peer: peer.into(), + msg_id: message_id, + poll_hash: hash, + }) + .await?; + Ok(()) +} diff --git a/src/telegram/reactions.rs b/src/telegram/reactions.rs new file mode 100644 index 0000000..dfceac4 --- /dev/null +++ b/src/telegram/reactions.rs @@ -0,0 +1,271 @@ +use crate::reactions::{Choice, Count, Kind, Review, Summary}; +use anyhow::{Context, Result, ensure}; +use grammers_client::{ + Client, + message::{InputReactions, Message}, + tl, +}; +use grammers_session::types::{PeerId, PeerKind, PeerRef}; + +fn kind(raw: &tl::enums::Reaction) -> Option { + match raw { + tl::enums::Reaction::Emoji(value) => Some(Kind::Emoji(value.emoticon.clone())), + tl::enums::Reaction::CustomEmoji(value) => Some(Kind::Custom(value.document_id)), + tl::enums::Reaction::Paid => Some(Kind::Paid), + tl::enums::Reaction::Empty => None, + } +} + +pub(super) fn summary(raw: &tl::enums::MessageReactions) -> Summary { + let tl::enums::MessageReactions::Reactions(raw) = raw; + Summary { + counts: raw + .results + .iter() + .filter_map(|count| { + let tl::enums::ReactionCount::Count(count) = count; + Some(Count { + kind: kind(&count.reaction)?, + count: u32::try_from(count.count).ok()?, + chosen: if raw.min { None } else { count.chosen_order }, + }) + }) + .collect(), + choices_known: !raw.min, + as_tags: raw.reactions_as_tags, + stale: false, + } +} + +pub(super) fn map(message: &Message) -> Option { + match &message.raw { + tl::enums::Message::Message(message) => message.reactions.as_ref().map(summary), + tl::enums::Message::Service(message) => message.reactions.as_ref().map(summary), + tl::enums::Message::Empty(_) => None, + } +} + +async fn own_choices( + client: &Client, + peer: PeerRef, + id: i32, + mut current: Summary, +) -> Result { + if current.choices_known { + return Ok(current); + } + let updates = client + .invoke(&tl::functions::messages::GetMessagesReactions { + peer: peer.into(), + id: vec![id], + }) + .await?; + let updates = match updates { + tl::enums::Updates::Updates(updates) => updates.updates, + tl::enums::Updates::Combined(updates) => updates.updates, + tl::enums::Updates::UpdateShort(update) => vec![update.update], + _ => Vec::new(), + }; + for update in updates { + if let tl::enums::Update::MessageReactions(update) = update + && update.msg_id == id + && PeerId::from(update.peer) == peer.id + { + current.apply(&summary(&update.reactions)); + } + } + ensure!( + current.choices_known, + "Telegram did not return your reaction choices; refresh" + ); + Ok(current) +} + +async fn allowed( + client: &Client, + peer: PeerRef, +) -> Result<(tl::enums::ChatReactions, Option)> { + let full = match peer.id.kind() { + PeerKind::User => { + return Ok(( + tl::types::ChatReactionsAll { allow_custom: true }.into(), + None, + )); + } + PeerKind::Chat => { + client + .invoke(&tl::functions::messages::GetFullChat { + chat_id: peer.into(), + }) + .await? + } + PeerKind::Channel => { + client + .invoke(&tl::functions::channels::GetFullChannel { + channel: peer.into(), + }) + .await? + } + }; + let tl::enums::messages::ChatFull::Full(full) = full; + let (reactions, limit) = match full.full_chat { + tl::enums::ChatFull::Full(full) => (full.available_reactions, full.reactions_limit), + tl::enums::ChatFull::ChannelFull(full) => (full.available_reactions, full.reactions_limit), + }; + Ok((reactions.unwrap_or(tl::enums::ChatReactions::None), limit)) +} + +fn config_limit(config: &tl::enums::help::AppConfig, key: &str) -> Option { + let tl::enums::help::AppConfig::Config(config) = config else { + return None; + }; + let tl::enums::Jsonvalue::JsonObject(object) = &config.config else { + return None; + }; + object.value.iter().find_map(|entry| { + let tl::enums::JsonobjectValue::JsonObjectValue(entry) = entry; + if entry.key != key { + return None; + } + let tl::enums::Jsonvalue::JsonNumber(number) = &entry.value else { + return None; + }; + if !number.value.is_finite() || number.value.fract() != 0.0 { + return None; + } + // Parse rather than truncating an unbounded floating-point value. + number.value.to_string().parse().ok() + }) +} + +pub(super) async fn review( + client: &Client, + peer: PeerRef, + message_id: i32, + self_id: i64, +) -> Result { + let (message, me, available, config, (allowed, unique)) = tokio::try_join!( + super::message_actions::fetch(client, peer, message_id), + async { Ok::<_, anyhow::Error>(client.get_me().await?) }, + async { + Ok::<_, anyhow::Error>( + client + .invoke(&tl::functions::messages::GetAvailableReactions { hash: 0 }) + .await?, + ) + }, + async { + Ok::<_, anyhow::Error>( + client + .invoke(&tl::functions::help::GetAppConfig { hash: 0 }) + .await?, + ) + }, + allowed(client, peer), + )?; + let possible = match &message.raw { + tl::enums::Message::Message(_) => true, + tl::enums::Message::Service(message) => message.reactions_are_possible, + tl::enums::Message::Empty(_) => false, + }; + let summary = map(&message).unwrap_or(Summary { + choices_known: true, + ..Summary::default() + }); + let summary = own_choices(client, peer, message_id, summary).await?; + let premium = matches!(me.raw, tl::enums::User::User(user) if user.premium); + let max_chosen = config_limit( + &config, + if premium { + "reactions_user_max_premium" + } else { + "reactions_user_max_default" + }, + ) + .unwrap_or(if premium { 3 } else { 1 }); + let max_unique = unique + .and_then(|n| usize::try_from(n).ok()) + .or_else(|| config_limit(&config, "reactions_uniq_max")) + .unwrap_or(usize::MAX); + let tl::enums::messages::AvailableReactions::Reactions(available) = available else { + anyhow::bail!("Telegram did not return available reactions; refresh"); + }; + let choices = available + .reactions + .into_iter() + .filter_map(|reaction| { + let tl::enums::AvailableReaction::Reaction(reaction) = reaction; + let permitted = match &allowed { + tl::enums::ChatReactions::None => false, + tl::enums::ChatReactions::All(_) => true, + tl::enums::ChatReactions::Some(allowed) => allowed + .reactions + .iter() + .any(|item| kind(item) == Some(Kind::Emoji(reaction.reaction.clone()))), + }; + (permitted && !reaction.inactive && (!reaction.premium || premium)).then(|| Choice { + emoji: reaction.reaction, + title: crate::model::sanitize_terminal_line(&reaction.title), + }) + }) + .collect(); + let read_only = if peer.id == PeerId::user_unchecked(self_id) || summary.as_tags { + Some("Saved Messages use tags; manage them in an official client".to_owned()) + } else if !possible { + Some("This service message does not accept reactions".to_owned()) + } else { + None + }; + Ok(Review { + summary, + choices, + max_chosen, + max_unique, + read_only, + }) +} + +pub(super) async fn change( + client: &Client, + peer: PeerRef, + message_id: i32, + self_id: i64, + expected: &[Kind], + emoji: Option<&str>, +) -> Result<()> { + let latest = review(client, peer, message_id, self_id).await?; + ensure!( + latest.summary.chosen() == expected, + "Your reactions changed in another client; refresh before retrying" + ); + let desired = latest.toggle(emoji).map_err(anyhow::Error::msg)?; + let reactions: Vec = desired + .into_iter() + .filter_map(|kind| match kind { + Kind::Emoji(emoticon) => Some(tl::types::ReactionEmoji { emoticon }.into()), + Kind::Custom(document_id) => { + Some(tl::types::ReactionCustomEmoji { document_id }.into()) + } + Kind::Paid => None, + }) + .collect(); + client + .send_reactions( + peer, + message_id, + InputReactions::from(reactions).add_to_recent(), + ) + .await + .context("Could not change reactions")?; + Ok(()) +} + +pub(super) async fn refresh(client: &Client, peer: PeerRef, ids: Vec) -> Result<()> { + client + .invoke(&tl::functions::messages::GetMessagesReactions { + peer: peer.into(), + id: ids, + }) + .await?; + Ok(()) +} diff --git a/src/telegram/reads.rs b/src/telegram/reads.rs new file mode 100644 index 0000000..21e859a --- /dev/null +++ b/src/telegram/reads.rs @@ -0,0 +1,72 @@ +use crate::read_state::Snapshot; +use anyhow::{Result, ensure}; +use grammers_client::{Client, tl}; +use grammers_session::types::{PeerKind, PeerRef}; + +pub(super) async fn mark(client: &Client, peer: PeerRef, max_id: i32) -> Result> { + ensure!( + max_id > 0, + "Automatic read receipts require a positive visible message ID" + ); + // Same typed requests as Grammers Message::mark_as_read. The UI retains + // only message metadata, so no redundant getMessage RPC is needed first. + if peer.id.kind() == PeerKind::Channel { + client + .invoke(&tl::functions::channels::ReadHistory { + channel: peer.into(), + max_id, + }) + .await?; + } else { + client + .invoke(&tl::functions::messages::ReadHistory { + peer: peer.into(), + max_id, + }) + .await?; + } + Ok(snapshot(client, peer).await) +} + +pub(super) async fn set_unread( + client: &Client, + peer: PeerRef, + unread: bool, + read_history: bool, +) -> Result> { + if read_history { + // Only an explicit :read action may acknowledge the whole history. + client.mark_as_read(peer).await?; + } + client + .invoke(&tl::functions::messages::MarkDialogUnread { + unread, + parent_peer: None, + peer: tl::types::InputDialogPeer { peer: peer.into() }.into(), + }) + .await?; + Ok(snapshot(client, peer).await) +} + +async fn snapshot(client: &Client, peer: PeerRef) -> Option { + // Retrieve this peer's actual remaining count, without a full dialog scan. + // Count lookup failure does not undo a successful read acknowledgement. + let result = tokio::time::timeout( + std::time::Duration::from_secs(2), + client.invoke(&tl::functions::messages::GetPeerDialogs { + peers: vec![tl::types::InputDialogPeer { peer: peer.into() }.into()], + }), + ) + .await; + let Ok(Ok(tl::enums::messages::PeerDialogs::Dialogs(dialogs))) = result else { + return None; + }; + dialogs.dialogs.into_iter().find_map(|dialog| match dialog { + tl::enums::Dialog::Dialog(dialog) => Some(Snapshot { + max_id: dialog.read_inbox_max_id.max(0), + unread: u32::try_from(dialog.unread_count.max(0)).unwrap_or_default(), + top_message: dialog.top_message, + }), + tl::enums::Dialog::Folder(_) => None, + }) +} diff --git a/src/telegram/requests.rs b/src/telegram/requests.rs new file mode 100644 index 0000000..3557b2d --- /dev/null +++ b/src/telegram/requests.rs @@ -0,0 +1,1328 @@ +//! RPC tasks own their arguments, never the worker's mutable state. +//! Results are merged by the same coordinator that consumes live updates. + +use super::{ + HISTORY_LIMIT, TelegramLink, WorkerCache, activate_inline_button, apply_dialogs, + cache_sender_name, hydrate_reply_sender, hydrate_reply_senders, map_message, + parse_telegram_link, resolve_telegram_link, +}; +use crate::{ + event::{NetworkEvent, TelegramCommand}, + model::{Chat, ChatKind}, +}; +use anyhow::{Context, Result, bail}; +use grammers_client::peer::Dialog; +use grammers_client::{ + Client, + message::{InputMessage, Message as TelegramMessage}, +}; +use grammers_session::types::{PeerKind, PeerRef}; +use tokio::{sync::mpsc, task::JoinSet}; + +pub(super) const MAX_REQUESTS: usize = 8; + +pub(super) struct Completion { + command: TelegramCommand, + result: Result, + notify_revision: u64, +} + +pub(super) async fn refresh_dialogs( + client: &Client, + cache: &mut WorkerCache, + events: &mpsc::Sender, + requests: &mut JoinSet, +) -> Result<()> { + if cache.dialogs.in_flight { + cache.dialogs.dirty = true; + } else { + cache.dialogs.in_flight = true; + cache.dialogs.dirty = false; + events.send(NetworkEvent::DialogsLoading).await?; + spawn(TelegramCommand::RefreshDialogs, client, cache, requests); + } + Ok(()) +} + +pub(super) async fn refresh_pending( + client: &Client, + cache: &mut WorkerCache, + events: &mpsc::Sender, + requests: &mut JoinSet, +) -> Result<()> { + if cache.dialogs.dirty && !cache.dialogs.in_flight && requests.len() < MAX_REQUESTS { + refresh_dialogs(client, cache, events, requests).await?; + } + if cache.folders.dirty && !cache.folders.in_flight && requests.len() < MAX_REQUESTS { + cache.folders.dirty = false; + cache.folders.in_flight = true; + spawn(TelegramCommand::RefreshFolders, client, cache, requests); + } + if cache.dialog_pins.dirty && !cache.dialog_pins.in_flight && requests.len() < MAX_REQUESTS { + cache.dialog_pins.dirty = false; + cache.dialog_pins.in_flight = true; + spawn(TelegramCommand::RefreshDialogPins, client, cache, requests); + } + if cache.message_pins.dirty + && !cache.message_pins.in_flight + && requests.len() < MAX_REQUESTS + && let Some(chat_id) = cache.active_chat.filter(|id| cache.peers.contains_key(id)) + { + cache.message_pins.dirty = false; + cache.message_pins.in_flight = true; + events + .send(NetworkEvent::PinnedMessagesLoading { + chat_id, + request_id: 0, + }) + .await?; + spawn( + TelegramCommand::LoadPinnedMessages { + chat_id, + before: 0, + request_id: 0, + }, + client, + cache, + requests, + ); + } + Ok(()) +} + +enum Response { + ChatInfo(crate::chat_info::Info), + Invite(crate::invites::Preview, Option), + Joined(crate::invites::Outcome, Option), + Reactions(crate::reactions::Review), + Poll(crate::polls::Poll), + Search(super::search::Page), + Muted(i64), + AlertSettings(crate::notifications::Resolved), + Saved(super::forwarding::Destination), + ForwardReview(Box), + Copied(String), + Deletion(Box), + EditSource(crate::editing::Source), + Edited(Option>), + PinnedMessages(Vec, usize), + DialogPins(crate::pins::DialogPins), + Dialogs(Vec, (i64, i64)), + Folders(Vec), + History(Vec), + ReplyPreviews(Vec, Vec), + Message(Box), + Link(Chat, PeerRef, Option>), + Button(Option, Option), + Applied, + Read(Option), + ChatUnread(Option), +} + +/// Capture only the peer needed by this request. The complete cache stays with +/// the coordinator, including updates received while the RPC is running. +pub(super) fn spawn( + command: TelegramCommand, + client: &Client, + cache: &mut WorkerCache, + requests: &mut JoinSet, +) { + let search_id = command.cloud_search_id(); + let chat_id = match &command { + TelegramCommand::ResolveAlertSettings { key, .. } => Some(key.chat), + TelegramCommand::SearchCloud(request) => Some(request.chat_id), + TelegramCommand::LoadChatInfo { chat_id, .. } + | TelegramCommand::LoadReactions { chat_id, .. } + | TelegramCommand::ChangeReaction { chat_id, .. } + | TelegramCommand::RefreshReactions { chat_id, .. } + | TelegramCommand::LoadPoll { chat_id, .. } + | TelegramCommand::VotePoll { chat_id, .. } + | TelegramCommand::RefreshPoll { chat_id, .. } + | TelegramCommand::SearchMentions { chat_id, .. } + | TelegramCommand::ReadMentions { chat_id, .. } + | TelegramCommand::LoadCloudContext { chat_id, .. } + | TelegramCommand::ReviewForward { chat_id, .. } + | TelegramCommand::ForwardMessage { chat_id, .. } + | TelegramCommand::CopyMessage { chat_id, .. } + | TelegramCommand::ReviewDeletion { chat_id, .. } + | TelegramCommand::DeleteMessage { chat_id, .. } + | TelegramCommand::LoadEdit { chat_id, .. } + | TelegramCommand::EditMessage { chat_id, .. } + | TelegramCommand::LoadHistory { chat_id, .. } + | TelegramCommand::ChangeDialogPin { chat_id, .. } + | TelegramCommand::SetArchived { chat_id, .. } + | TelegramCommand::LoadPinnedMessages { chat_id, .. } + | TelegramCommand::LoadPinnedContext { chat_id, .. } + | TelegramCommand::ChangeMessagePin { chat_id, .. } + | TelegramCommand::LoadOlder { chat_id, .. } + | TelegramCommand::LoadMessage { chat_id, .. } + | TelegramCommand::LoadReplyPreviews { chat_id, .. } + | TelegramCommand::SendMessage { chat_id, .. } + | TelegramCommand::ActivateButton { chat_id, .. } + | TelegramCommand::MarkRead { chat_id, .. } + | TelegramCommand::SetChatUnread { chat_id, .. } + | TelegramCommand::SetChatMute { chat_id, .. } => Some(*chat_id), + _ => None, + }; + let peer = chat_id.and_then(|id| cache.peers.get(&id).copied()); + let private_link = match &command { + TelegramCommand::ResolveTelegramLink { url } => match parse_telegram_link(url) { + Ok(TelegramLink::Private { chat_id, .. }) + if cache.visible_channel_groups.contains(&chat_id) => + { + cache.peers.get(&chat_id).copied().map(|peer| { + let title = cache.names.get(&peer.id).cloned().unwrap_or_default(); + (peer, title) + }) + } + _ => None, + }, + _ => None, + }; + let other_peer = match &command { + TelegramCommand::ResolveAlertSettings { key, .. } => key.sender.and_then(|id| { + cache + .peers + .get(&id) + .or_else(|| cache.notification_peers.get(&id)) + .copied() + }), + TelegramCommand::SearchCloud(request) => match &request.filters.sender { + Some(crate::cloud_search::Sender::Id(id)) => { + cache.peers.get(id).copied().or_else(|| { + cache + .search_sender + .filter(|peer| peer.id.bot_api_dialog_id() == Some(*id)) + }) + } + _ => None, + }, + TelegramCommand::ReviewForward { destination, .. } + | TelegramCommand::ForwardMessage { destination, .. } => { + cache.peers.get(destination).copied() + } + _ => None, + }; + let notify_revision = cache.notify_revision; + let self_id = cache.self_id; + let client = client.clone(); + let handle = requests.spawn(async move { + let result = Box::pin(execute( + &command, + &client, + peer, + private_link, + self_id, + other_peer, + )) + .await; + Completion { + command, + result, + notify_revision, + } + }); + if let Some(id) = search_id { + cache.cloud_search = Some((id, handle)); + } +} + +#[allow(clippy::too_many_lines)] +async fn execute( + command: &TelegramCommand, + client: &Client, + peer: Option, + private_link: Option<(PeerRef, String)>, + self_id: i64, + other_peer: Option, +) -> Result { + let peer = || peer.context("conversation is missing its Telegram peer reference"); + match command { + TelegramCommand::LoadChatInfo { .. } => Ok(Response::ChatInfo( + tokio::time::timeout( + std::time::Duration::from_secs(15), + super::chat_info::load(client, peer()?), + ) + .await + .context("Chat information timed out")??, + )), + TelegramCommand::PreviewInvite { hash, .. } => { + let (preview, peer) = tokio::time::timeout( + std::time::Duration::from_secs(15), + super::invites::preview(client, hash), + ) + .await + .context("Invite preview timed out")??; + Ok(Response::Invite(preview, peer)) + } + TelegramCommand::JoinInvite { + hash, + title, + request_needed, + .. + } => { + let (result, peer) = tokio::time::timeout( + std::time::Duration::from_secs(30), + super::invites::join(client, hash, title, *request_needed), + ) + .await + .context("Join confirmation timed out; reopen the invite to check membership")??; + Ok(Response::Joined(result, peer)) + } + TelegramCommand::ResolveAlertSettings { key, .. } => Ok(Response::AlertSettings( + tokio::time::timeout( + std::time::Duration::from_secs(5), + super::notifications::resolve_alert(client, peer()?, key.sender, other_peer), + ) + .await + .context("Notification settings timed out")??, + )), + TelegramCommand::ReadMentions { message_ids, .. } => { + super::notifications::read_mentions(client, peer()?, message_ids).await?; + Ok(Response::Applied) + } + TelegramCommand::SearchMentions { before_id, .. } => Ok(Response::Search( + super::search::mentions(client, peer()?, *before_id).await?, + )), + TelegramCommand::SetChatMute { mute, .. } => Ok(Response::Muted( + super::notifications::set_mute(client, peer()?, *mute).await?, + )), + TelegramCommand::SearchCloud(request) => Ok(Response::Search( + super::search::search(client, peer()?, request, other_peer).await?, + )), + TelegramCommand::LoadCloudContext { message_id, .. } + | TelegramCommand::LoadPinnedContext { message_id, .. } => Ok(Response::History( + super::search::context(client, peer()?, *message_id).await?, + )), + TelegramCommand::OpenSaved { .. } => Ok(Response::Saved( + super::forwarding::destination(client, self_id, None, self_id).await?, + )), + TelegramCommand::ReviewForward { + message_id, + destination, + .. + } => { + let destination = + super::forwarding::destination(client, *destination, other_peer, self_id).await?; + Ok(Response::ForwardReview(Box::new( + super::forwarding::review(client, peer()?, *message_id, destination).await?, + ))) + } + TelegramCommand::ForwardMessage { + message_id, + revision, + random_id, + .. + } => { + super::forwarding::send( + client, + peer()?, + *message_id, + other_peer.context("Forward target is no longer available")?, + *revision, + *random_id, + ) + .await?; + Ok(Response::Applied) + } + TelegramCommand::CopyMessage { + message_id, link, .. + } => Ok(Response::Copied( + super::sharing::copy(client, peer()?, *message_id, *link).await?, + )), + TelegramCommand::ReviewDeletion { message_id, .. } => Ok(Response::Deletion(Box::new( + super::deletion::review(client, peer()?, *message_id, self_id).await?, + ))), + TelegramCommand::DeleteMessage { + message_id, + revision, + scope, + .. + } => { + super::deletion::delete(client, peer()?, *message_id, self_id, *revision, *scope) + .await?; + Ok(Response::Applied) + } + TelegramCommand::LoadReactions { message_id, .. } => Ok(Response::Reactions( + tokio::time::timeout( + std::time::Duration::from_secs(15), + super::reactions::review(client, peer()?, *message_id, self_id), + ) + .await + .context("Reaction lookup timed out")??, + )), + TelegramCommand::ChangeReaction { + message_id, + expected, + emoji, + .. + } => { + tokio::time::timeout( + std::time::Duration::from_secs(20), + super::reactions::change( + client, + peer()?, + *message_id, + self_id, + expected, + emoji.as_deref(), + ), + ) + .await + .context("Reaction status is uncertain; refresh before retrying")??; + Ok(Response::Applied) + } + TelegramCommand::RefreshReactions { message_ids, .. } => { + tokio::time::timeout( + std::time::Duration::from_secs(10), + super::reactions::refresh(client, peer()?, message_ids.clone()), + ) + .await + .context("Reaction refresh timed out")??; + Ok(Response::Applied) + } + TelegramCommand::LoadPoll { message_id, .. } => Ok(Response::Poll( + tokio::time::timeout( + std::time::Duration::from_secs(10), + super::polls::load(client, peer()?, *message_id), + ) + .await + .context("Poll lookup timed out")??, + )), + TelegramCommand::RefreshPoll { + message_id, hash, .. + } => { + tokio::time::timeout( + std::time::Duration::from_secs(10), + super::polls::refresh(client, peer()?, *message_id, *hash), + ) + .await + .context("Poll refresh timed out")??; + Ok(Response::Applied) + } + TelegramCommand::VotePoll { + message_id, + poll_id, + revision, + options, + .. + } => { + tokio::time::timeout( + std::time::Duration::from_secs(15), + super::polls::vote(client, peer()?, *message_id, *poll_id, *revision, options), + ) + .await + .context("Vote status is uncertain; refresh the poll before retrying")??; + Ok(Response::Applied) + } + TelegramCommand::LoadEdit { message_id, .. } => Ok(Response::EditSource( + super::editing::load(client, peer()?, *message_id, self_id).await?, + )), + TelegramCommand::EditMessage { + message_id, + revision, + text, + .. + } => Ok(Response::Edited( + super::editing::save(client, peer()?, *message_id, self_id, *revision, text).await?, + )), + TelegramCommand::LoadPinnedMessages { before, .. } => { + let mut iter = client + .search_messages(peer()?) + .filter(grammers_client::tl::enums::MessagesFilter::InputMessagesFilterPinned) + .offset_id(*before) + .limit(crate::pins::PAGE_SIZE); + let mut messages = Vec::new(); + while let Some(message) = iter.next().await? { + messages.push(message); + } + Ok(Response::PinnedMessages(messages, iter.total().await?)) + } + TelegramCommand::ChangeMessagePin { + message_id, action, .. + } => { + super::pins::change_message(client, peer()?, *message_id, *action).await?; + Ok(Response::Applied) + } + TelegramCommand::SetArchived { archived, .. } => { + client + .invoke(&grammers_client::tl::functions::folders::EditPeerFolders { + folder_peers: vec![ + grammers_client::tl::types::InputFolderPeer { + peer: peer()?.into(), + folder_id: i32::from(*archived), + } + .into(), + ], + }) + .await?; + Ok(Response::DialogPins( + super::pins::load_dialogs(client).await?, + )) + } + TelegramCommand::RefreshDialogPins => Ok(Response::DialogPins( + super::pins::load_dialogs(client).await?, + )), + TelegramCommand::ChangeDialogPin { scope, action, .. } => { + super::pins::change_dialog(client, peer()?, *scope, *action, self_id).await?; + if matches!(scope, crate::pins::DialogScope::Filter(_)) { + Ok(Response::Folders( + super::folders::load(client, self_id).await?, + )) + } else { + Ok(Response::DialogPins( + super::pins::load_dialogs(client).await?, + )) + } + } + TelegramCommand::RefreshFolders => Ok(Response::Folders( + super::folders::load(client, self_id).await?, + )), + TelegramCommand::RefreshDialogs => { + let mut iter = client.iter_dialogs(); + let mut dialogs = Vec::new(); + while let Some(dialog) = iter.next().await? { + dialogs.push(dialog); + } + Ok(Response::Dialogs( + dialogs, + super::folders::default_mutes(client).await?, + )) + } + TelegramCommand::LoadHistory { .. } | TelegramCommand::LoadOlder { .. } => { + let after = match command { + TelegramCommand::LoadHistory { after_id, .. } => *after_id, + _ => None, + }; + let before = match command { + TelegramCommand::LoadOlder { before_id, .. } => *before_id, + _ => 0, + }; + let mut iter = client + .iter_messages(peer()?) + .offset_id(after.unwrap_or(before)) + .reverse(after.is_some()) + .limit(HISTORY_LIMIT); + let mut messages = Vec::new(); + while let Some(message) = iter.next().await? { + messages.push(message); + } + if after.is_none() { + messages.reverse(); + } + Ok(Response::History(messages)) + } + TelegramCommand::LoadReplyPreviews { + chat_id, + message_ids, + .. + } => { + anyhow::ensure!( + message_ids.len() <= 32 && message_ids.iter().all(|id| *id > 0), + "invalid reply preview batch" + ); + let messages: Vec<_> = client + .get_messages_by_id(peer()?, message_ids) + .await? + .into_iter() + .flatten() + .filter(|message| super::peer_id(message).ok() == Some(*chat_id)) + .collect(); + let unavailable = message_ids + .iter() + .copied() + .filter(|id| !messages.iter().any(|message| message.id() == *id)) + .collect(); + Ok(Response::ReplyPreviews(messages, unavailable)) + } + TelegramCommand::LoadMessage { + source_message_id, + message_id, + .. + } => { + if *source_message_id <= 0 || *message_id <= 0 { + bail!("invalid Telegram message identifier"); + } + let source = client + .get_messages_by_id(peer()?, &[*source_message_id]) + .await? + .pop() + .flatten() + .context("replying message is unavailable")?; + if source.reply_to_message_id() != Some(*message_id) { + bail!("reply relation changed before navigation"); + } + let message = client + .get_reply_to_message(&source) + .await? + .context("reply target is unavailable")?; + Ok(Response::Message(Box::new(message))) + } + TelegramCommand::SendMessage { text, reply_to, .. } => { + let input = InputMessage::new().text(text.clone()).reply_to(*reply_to); + let message = Box::pin(client.send_message(peer()?, input)).await?; + Ok(Response::Message(Box::new(message))) + } + TelegramCommand::ResolveTelegramLink { url } => { + let (chat, peer, message) = tokio::time::timeout( + std::time::Duration::from_secs(15), + resolve_telegram_link(client, private_link, url), + ) + .await + .context("Telegram lookup timed out")??; + Ok(Response::Link(chat, peer, message.map(Box::new))) + } + TelegramCommand::ActivateButton { + message_id, + button_index, + .. + } => { + let (message, url) = + activate_inline_button(client, peer()?, *message_id, *button_index).await?; + Ok(Response::Button(message, url)) + } + TelegramCommand::MarkRead { max_id, .. } => Ok(Response::Read( + super::reads::mark(client, peer()?, *max_id).await?, + )), + TelegramCommand::SetChatUnread { + unread, + read_history, + .. + } => Ok(Response::ChatUnread( + super::reads::set_unread(client, peer()?, *unread, *read_history).await?, + )), + _ => unreachable!("only RPC commands are submitted to request tasks"), + } +} + +#[allow(clippy::too_many_lines)] +pub(super) async fn complete( + completion: Completion, + cache: &mut WorkerCache, + events: &mpsc::Sender, +) -> Result<()> { + let Completion { + command, + result, + notify_revision, + } = completion; + if let Some(id) = command.cloud_search_id() { + if cache + .cloud_search + .as_ref() + .is_none_or(|(current, _)| *current != id) + { + return Ok(()); + } + cache.cloud_search = None; + } + if matches!( + command, + TelegramCommand::SetChatUnread { .. } + | TelegramCommand::SetChatMute { .. } + | TelegramCommand::ReadMentions { .. } + ) { + // A failed second RPC may follow a successful first RPC. Refresh even + // on partial failure so the authoritative mark/count can settle. + cache.dialogs.dirty = true; + } + if matches!( + command, + TelegramCommand::LoadPinnedMessages { request_id: 0, .. } + ) { + cache.message_pins.in_flight = false; + if cache.message_pins.dirty { + if let Some(event) = command.failure("Pins changed during refresh".to_owned()) { + events.send(event).await?; + } + return Ok(()); + } + } + if matches!(command, TelegramCommand::RefreshDialogs) { + cache.dialogs.in_flight = false; + if cache.dialogs.dirty { + return Ok(()); + } + } + if matches!(command, TelegramCommand::RefreshFolders) { + cache.folders.in_flight = false; + if cache.folders.dirty { + return Ok(()); + } + } + if matches!(command, TelegramCommand::RefreshDialogPins) { + cache.dialog_pins.in_flight = false; + if cache.dialog_pins.dirty { + return Ok(()); + } + } + let response = match result { + Ok(response) => response, + Err(error) => { + let detail = if matches!( + command, + TelegramCommand::SendMessage { .. } | TelegramCommand::ForwardMessage { .. } + ) { + super::chat_info::send_error(&error) + } else { + format!("Telegram request failed: {error:#}") + }; + if let Some(event) = command.failure(detail) { + events.send(event).await?; + } + return Ok(()); + } + }; + let event = match (command, response) { + ( + TelegramCommand::LoadChatInfo { + chat_id, + request_id, + }, + Response::ChatInfo(info), + ) => NetworkEvent::ChatInfoReady { + chat_id, + request_id, + result: Ok(info), + }, + (TelegramCommand::PreviewInvite { request_id, .. }, Response::Invite(preview, peer)) => { + if let (Some(chat), Some(peer)) = (&preview.joined, peer) { + cache_invited_chat(cache, chat, peer); + } + NetworkEvent::InviteReady { + request_id, + result: Ok(preview), + } + } + (TelegramCommand::JoinInvite { request_id, .. }, Response::Joined(outcome, peer)) => { + if let (crate::invites::Outcome::Joined(chat), Some(peer)) = (&outcome, peer) { + cache_invited_chat(cache, chat, peer); + } + cache.dialogs.dirty = true; + NetworkEvent::InviteJoined { + request_id, + result: Ok(outcome), + } + } + ( + TelegramCommand::ResolveAlertSettings { key, request_id }, + Response::AlertSettings(settings), + ) => NetworkEvent::AlertSettingsReady { + key, + request_id, + result: if cache.notify_revision == notify_revision { + Ok(settings) + } else { + Err("Notification settings changed; retrying".to_owned()) + }, + }, + ( + TelegramCommand::SetChatMute { + chat_id, + request_id, + .. + }, + Response::Muted(until), + ) => NetworkEvent::ChatMuteFinished { + chat_id, + request_id, + result: Ok((cache.notify_revision == notify_revision).then_some(until)), + }, + ( + TelegramCommand::ReadMentions { + chat_id, + message_ids, + }, + Response::Applied, + ) => { + // Common receipts arrive through the SDK's ordered PTS update. The + // channel RPC returns only Bool, so its acknowledgement supplies IDs. + if chat_id <= -1_000_000_000_000 { + events + .send(NetworkEvent::MessageContentsRead { + channel_id: Some(chat_id), + message_ids: message_ids.clone(), + }) + .await?; + } + NetworkEvent::MentionsReadFinished { + chat_id, + message_ids, + error: None, + } + } + ( + TelegramCommand::SearchCloud(crate::cloud_search::Request { + id: request_id, + chat_id, + .. + }) + | TelegramCommand::SearchMentions { + request_id, + chat_id, + .. + }, + Response::Search(page), + ) => { + let mut messages = page + .messages + .iter() + .map(|message| map_message(message, cache)) + .collect::>>()?; + hydrate_reply_senders(&mut messages, cache); + cache.search_sender = page.sender; + NetworkEvent::CloudSearchResults { + request_id, + chat_id, + page: crate::cloud_search::Page { + messages, + next: page.next, + total: page.total, + sender: page.sender.and_then(|peer| peer.id.bot_api_dialog_id()), + }, + } + } + ( + TelegramCommand::LoadCloudContext { + chat_id, + message_id, + request_id, + }, + Response::History(raw), + ) => { + let mut messages = raw + .iter() + .map(|message| map_message(message, cache)) + .collect::>>()?; + hydrate_reply_senders(&mut messages, cache); + NetworkEvent::CloudSearchContext { + chat_id, + message_id, + request_id, + messages, + } + } + (TelegramCommand::OpenSaved { request_id }, Response::Saved(destination)) => { + cache.peers.insert(destination.chat.id, destination.peer); + cache.linked_peers.insert(destination.chat.id); + cache_sender_name(cache, destination.peer.id, destination.name); + NetworkEvent::SavedReady { + request_id, + result: Ok(destination.chat), + } + } + (TelegramCommand::ReviewForward { request_id, .. }, Response::ForwardReview(review)) => { + cache + .peers + .insert(review.destination.chat.id, review.destination.peer); + cache.linked_peers.insert(review.destination.chat.id); + cache_sender_name(cache, review.destination.peer.id, review.destination.name); + NetworkEvent::ForwardReady { + request_id, + result: Ok(crate::forwarding::Plan { + message: map_message(&review.original, cache)?, + destination: review.destination.chat, + revision: review.revision, + random_id: review.random_id, + }), + } + } + (TelegramCommand::ForwardMessage { request_id, .. }, Response::Applied) => { + cache.dialogs.dirty = true; + NetworkEvent::ForwardFinished { + request_id, + error: None, + } + } + + (TelegramCommand::CopyMessage { request_id, .. }, Response::Copied(text)) => { + NetworkEvent::MessageCopyReady { + request_id, + result: Ok(text), + } + } + ( + TelegramCommand::ReviewDeletion { + chat_id, + message_id, + request_id, + }, + Response::Deletion(review), + ) => NetworkEvent::DeletionReady { + chat_id, + message_id, + request_id, + result: Ok(crate::deletion::Plan { + message: map_message(&review.message, cache)?, + revision: review.revision, + scopes: review.scopes, + }), + }, + ( + TelegramCommand::DeleteMessage { + chat_id, + message_id, + request_id, + .. + }, + Response::Applied, + ) => { + cache.dialogs.dirty = true; + cache.message_pins.dirty = true; + // The SDK delivers deletion IDs through the ordered update stream, + // which persists the tombstone before the covered checkpoint. + NetworkEvent::DeleteFinished { + chat_id, + message_id, + request_id, + error: None, + } + } + ( + TelegramCommand::LoadReactions { + chat_id, + message_id, + request_id, + }, + Response::Reactions(review), + ) => NetworkEvent::ReactionsLoaded { + chat_id, + message_id, + request_id, + result: Ok(review), + }, + ( + TelegramCommand::ChangeReaction { + chat_id, + message_id, + request_id, + .. + }, + Response::Applied, + ) => NetworkEvent::ReactionsFinished { + chat_id, + message_id: Some(message_id), + request_id, + error: None, + }, + ( + TelegramCommand::RefreshReactions { + chat_id, + request_id, + .. + }, + Response::Applied, + ) => NetworkEvent::ReactionsFinished { + chat_id, + message_id: None, + request_id, + error: None, + }, + ( + TelegramCommand::LoadPoll { + chat_id, + message_id, + request_id, + }, + Response::Poll(poll), + ) => NetworkEvent::PollLoaded { + chat_id, + message_id, + request_id, + result: Ok(poll), + }, + ( + TelegramCommand::RefreshPoll { + chat_id, + message_id, + request_id, + .. + }, + Response::Applied, + ) => NetworkEvent::PollFinished { + chat_id, + message_id, + request_id, + voting: false, + error: None, + }, + ( + TelegramCommand::VotePoll { + chat_id, + message_id, + request_id, + .. + }, + Response::Applied, + ) => NetworkEvent::PollFinished { + chat_id, + message_id, + request_id, + voting: true, + error: None, + }, + ( + TelegramCommand::LoadEdit { + chat_id, + request_id, + .. + }, + Response::EditSource(source), + ) => NetworkEvent::EditLoaded { + chat_id, + request_id, + result: Ok(source), + }, + ( + TelegramCommand::EditMessage { + chat_id, + request_id, + .. + }, + Response::Edited(message), + ) => { + if let Some(message) = message { + events + .send(NetworkEvent::MessageUpdated(map_message(&message, cache)?)) + .await?; + } + NetworkEvent::EditFinished { + chat_id, + request_id, + error: None, + } + } + ( + TelegramCommand::LoadReplyPreviews { + chat_id, + request_id, + .. + }, + Response::ReplyPreviews(raw, unavailable), + ) => { + let mut messages = raw + .iter() + .map(|message| map_message(message, cache)) + .collect::>>()?; + hydrate_reply_senders(&mut messages, cache); + NetworkEvent::ReplyPreviews { + chat_id, + request_id, + messages, + unavailable, + complete: true, + } + } + ( + TelegramCommand::ChangeMessagePin { + chat_id, + message_id, + action, + request_id, + }, + Response::Applied, + ) => { + cache.message_pins.dirty |= cache.active_chat == Some(chat_id); + let event = match action { + crate::pins::MessageAction::UnpinAll => { + NetworkEvent::MessagePinsCleared { chat_id } + } + _ => NetworkEvent::MessagePinsChanged { + chat_id, + message_ids: vec![message_id], + pinned: matches!(action, crate::pins::MessageAction::Pin { .. }), + }, + }; + events.send(event).await?; + NetworkEvent::MessagePinFinished { + chat_id, + request_id, + error: None, + } + } + ( + TelegramCommand::LoadPinnedMessages { + chat_id, + before, + request_id, + }, + Response::PinnedMessages(raw, total), + ) => { + let mut messages = raw + .iter() + .map(|message| map_message(message, cache)) + .collect::>>()?; + hydrate_reply_senders(&mut messages, cache); + let next = (messages.len() == crate::pins::PAGE_SIZE) + .then(|| messages.last().expect("full page").id); + NetworkEvent::PinnedMessages { + chat_id, + request_id, + page: crate::pins::MessagePage { + messages, + total: Some(total), + before, + next, + }, + } + } + ( + TelegramCommand::LoadPinnedContext { + chat_id, + message_id, + request_id, + }, + Response::History(raw), + ) => { + let mut messages = raw + .iter() + .map(|message| map_message(message, cache)) + .collect::>>()?; + hydrate_reply_senders(&mut messages, cache); + NetworkEvent::PinnedContext { + chat_id, + message_id, + request_id, + messages, + } + } + ( + TelegramCommand::SetArchived { + chat_id, + archived, + request_id, + }, + Response::DialogPins(pins), + ) => { + cache.dialogs.dirty = true; + cache.dialog_pins.dirty = true; + events + .send(NetworkEvent::ArchiveChanged { chat_id, archived }) + .await?; + events.send(NetworkEvent::DialogPins(pins)).await?; + NetworkEvent::DialogPinFinished { + request_id, + error: None, + } + } + (TelegramCommand::RefreshDialogPins, Response::DialogPins(pins)) => { + NetworkEvent::DialogPins(pins) + } + (TelegramCommand::ChangeDialogPin { request_id, .. }, response) => { + cache.dialog_pins.dirty = true; + cache.folders.dirty = true; + match response { + Response::DialogPins(pins) => events.send(NetworkEvent::DialogPins(pins)).await?, + Response::Folders(folders) => events.send(NetworkEvent::Folders(folders)).await?, + _ => unreachable!("pin mutations return their authoritative server list"), + } + NetworkEvent::DialogPinFinished { + request_id, + error: None, + } + } + (TelegramCommand::RefreshFolders, Response::Folders(folders)) => { + NetworkEvent::Folders(folders) + } + (TelegramCommand::RefreshDialogs, Response::Dialogs(dialogs, defaults)) => { + NetworkEvent::Dialogs(apply_dialogs(dialogs, defaults, cache)?) + } + ( + TelegramCommand::LoadHistory { + chat_id, + request_id, + .. + }, + Response::History(raw), + ) => { + let mut messages = raw + .iter() + .map(|message| map_message(message, cache)) + .collect::>>()?; + hydrate_reply_senders(&mut messages, cache); + NetworkEvent::History { + chat_id, + request_id, + messages, + } + } + ( + TelegramCommand::LoadOlder { + chat_id, + request_id, + before_id, + }, + Response::History(raw), + ) => { + let mut messages = raw + .iter() + .map(|message| map_message(message, cache)) + .collect::>>()?; + hydrate_reply_senders(&mut messages, cache); + NetworkEvent::OlderHistory { + chat_id, + request_id, + before_id, + messages, + } + } + ( + TelegramCommand::LoadMessage { + chat_id, + message_id, + request_id, + .. + }, + Response::Message(raw), + ) => { + let mut message = map_message(&raw, cache)?; + hydrate_reply_sender(&mut message, cache); + NetworkEvent::MessageLoaded { + chat_id, + message_id, + request_id, + message, + } + } + ( + TelegramCommand::SendMessage { + chat_id, local_id, .. + }, + Response::Message(raw), + ) => { + if raw.id() <= 0 { + NetworkEvent::MessageAccepted { chat_id, local_id } + } else { + NetworkEvent::MessageSent { + local_id, + message: map_message(&raw, cache)?, + } + } + } + (TelegramCommand::ResolveTelegramLink { url }, Response::Link(chat, peer, raw)) => { + cache.peers.insert(chat.id, peer); + cache.linked_peers.insert(chat.id); + cache_sender_name(cache, peer.id, chat.title.clone()); + if chat.kind == ChatKind::Group && peer.id.kind() == PeerKind::Channel { + cache.visible_channel_groups.insert(chat.id); + } + let message = raw + .as_ref() + .map(|raw| map_message(raw, cache)) + .transpose()?; + NetworkEvent::LinkResolved { url, chat, message } + } + ( + TelegramCommand::ActivateButton { + chat_id, + message_id, + .. + }, + Response::Button(message, url), + ) => NetworkEvent::ButtonActivated { + chat_id, + message_id, + message, + url, + }, + ( + TelegramCommand::SetChatUnread { + chat_id, + unread, + request_id, + .. + }, + Response::ChatUnread(snapshot), + ) => { + cache.dialogs.dirty = true; + NetworkEvent::ChatUnreadFinished { + chat_id, + unread, + request_id, + snapshot, + error: None, + } + } + (TelegramCommand::MarkRead { chat_id, max_id }, Response::Read(snapshot)) => { + NetworkEvent::ReadMarked { + chat_id, + max_id, + snapshot, + } + } + _ => unreachable!("request and completion kinds always match"), + }; + events.send(event).await?; + Ok(()) +} + +fn cache_invited_chat(cache: &mut WorkerCache, chat: &Chat, peer: PeerRef) { + cache.peers.insert(chat.id, peer); + cache.linked_peers.insert(chat.id); + cache_sender_name(cache, peer.id, chat.title.clone()); + if peer.id.kind() == PeerKind::Channel { + cache.visible_channel_groups.insert(chat.id); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn mute_completion_never_overwrites_a_newer_notification_update() { + let (events, mut received) = mpsc::channel(2); + let mut cache = WorkerCache { + notify_revision: 2, + ..Default::default() + }; + complete( + Completion { + command: TelegramCommand::SetChatMute { + chat_id: 42, + mute: crate::notifications::Mute::Off, + request_id: 7, + }, + result: Ok(Response::Muted(0)), + notify_revision: 1, + }, + &mut cache, + &events, + ) + .await + .unwrap(); + assert!( + cache.dialogs.dirty, + "read the authoritative settings again after a race" + ); + assert!(matches!( + received.recv().await.unwrap(), + NetworkEvent::ChatMuteFinished { + result: Ok(None), + .. + } + )); + complete( + Completion { + command: TelegramCommand::SetChatMute { + chat_id: 42, + mute: crate::notifications::Mute::Hour, + request_id: 8, + }, + result: Ok(Response::Muted(2000)), + notify_revision: 2, + }, + &mut cache, + &events, + ) + .await + .unwrap(); + assert!(matches!( + received.recv().await.unwrap(), + NetworkEvent::ChatMuteFinished { + result: Ok(Some(2000)), + .. + } + )); + } +} diff --git a/src/telegram/search.rs b/src/telegram/search.rs new file mode 100644 index 0000000..db0aa69 --- /dev/null +++ b/src/telegram/search.rs @@ -0,0 +1,150 @@ +//! Bounded searches use the SDK iterator and the coordinator's single update stream. +use anyhow::{Context, Result, ensure}; +use grammers_client::{Client, message::Message, tl::enums::MessagesFilter}; +use grammers_session::types::PeerRef; + +use crate::cloud_search::{Media, PAGE_SIZE, Request, Sender}; + +pub(super) struct Page { + pub messages: Vec, + pub next: Option, + pub total: usize, + pub sender: Option, +} + +pub(super) async fn search( + client: &Client, + peer: PeerRef, + request: &Request, + known_sender: Option, +) -> Result { + ensure!( + request.query.len() <= 4096, + "Search text is longer than 4096 bytes" + ); + let mut iter = client + .search_messages(peer) + .query(&request.query) + .filter(filter(request.filters.media)) + .offset_id(request.before_id) + .limit(PAGE_SIZE + 1); + let sender = match &request.filters.sender { + Some(Sender::Me) => { + iter = iter.sent_by_self(); + None + } + Some(Sender::Username(name)) => { + let sender = client + .resolve_username(name) + .await? + .context("Sender username was not found")? + .to_ref() + .await + .map_err(anyhow::Error::from_boxed)? + .context("Sender cannot be addressed")?; + iter = iter.sent_by(sender); + Some(sender) + } + Some(Sender::Id(id)) => { + let sender = known_sender + .filter(|peer| peer.id.bot_api_dialog_id() == Some(*id)) + .context("Sender ID is not known; use @username or me")?; + iter = iter.sent_by(sender); + Some(sender) + } + None => None, + }; + if let Some(date) = request.filters.after { + iter = iter.min_date( + &date + .and_hms_opt(0, 0, 0) + .expect("midnight") + .and_utc() + .fixed_offset(), + ); + } + if let Some(date) = request.filters.before { + iter = iter.max_date( + &date + .and_hms_opt(0, 0, 0) + .expect("midnight") + .and_utc() + .fixed_offset(), + ); + } + let mut messages = Vec::new(); + while let Some(message) = iter.next().await? { + ensure!( + message.peer_id() == peer.id, + "Search returned a message from another chat" + ); + messages.push(message); + } + let total = iter.total().await?; + let more = messages.len() > PAGE_SIZE; + messages.truncate(PAGE_SIZE); + let next = more.then(|| messages.last().expect("full page").id()); + Ok(Page { + messages, + next, + total, + sender, + }) +} + +pub(super) async fn context(client: &Client, peer: PeerRef, id: i32) -> Result> { + let target = super::message_actions::fetch(client, peer, id).await?; + let mut iter = client + .iter_messages(peer) + .offset_id(id) + .limit(super::HISTORY_LIMIT - 1); + let mut messages = vec![target]; + while let Some(message) = iter.next().await? { + ensure!( + message.peer_id() == peer.id, + "History returned a message from another chat" + ); + messages.push(message); + } + messages.reverse(); + Ok(messages) +} + +pub(super) async fn mentions(client: &Client, peer: PeerRef, before_id: i32) -> Result { + let mut iter = client + .unread_mentions(peer) + .offset_id(before_id) + .limit(PAGE_SIZE + 1); + let mut messages = Vec::new(); + while let Some(message) = iter.next().await? { + ensure!( + message.peer_id() == peer.id, + "Mention search returned another chat" + ); + messages.push(message); + } + let total = iter.total().await?; + let more = messages.len() > PAGE_SIZE; + messages.truncate(PAGE_SIZE); + Ok(Page { + next: more.then(|| messages.last().expect("full page").id()), + messages, + total, + sender: None, + }) +} + +fn filter(media: Media) -> MessagesFilter { + match media { + Media::All => MessagesFilter::InputMessagesFilterEmpty, + Media::Photo => MessagesFilter::InputMessagesFilterPhotos, + Media::Video => MessagesFilter::InputMessagesFilterVideo, + Media::File => MessagesFilter::InputMessagesFilterDocument, + Media::Music => MessagesFilter::InputMessagesFilterMusic, + Media::Voice => MessagesFilter::InputMessagesFilterVoice, + Media::Round => MessagesFilter::InputMessagesFilterRoundVideo, + Media::Gif => MessagesFilter::InputMessagesFilterGif, + Media::Link => MessagesFilter::InputMessagesFilterUrl, + Media::Poll => MessagesFilter::InputMessagesFilterPoll, + } +} diff --git a/src/telegram/sharing.rs b/src/telegram/sharing.rs new file mode 100644 index 0000000..5b58791 --- /dev/null +++ b/src/telegram/sharing.rs @@ -0,0 +1,58 @@ +//! Native message actions, preserving Telegram content protection. +use super::message_actions::fetch; +use anyhow::{Result, ensure}; +use grammers_client::{Client, message::Message, peer::Peer, tl}; +use grammers_session::types::{PeerKind, PeerRef}; + +pub(super) async fn copy(client: &Client, peer: PeerRef, id: i32, link: bool) -> Result { + let message = fetch(client, peer, id).await?; + let tl::enums::Message::Message(_) = &message.raw else { + anyhow::bail!("Select a regular message to copy") + }; + if link { + ensure!( + peer.id.kind() == PeerKind::Channel, + "Telegram message links are available for supergroups and channels" + ); + let tl::enums::ExportedMessageLink::Link(exported) = client + .invoke(&tl::functions::channels::ExportMessageLink { + grouped: false, + thread: true, + channel: peer.into(), + id, + }) + .await?; + return Ok(exported.link); + } + check_protection(client, peer, &message).await?; + ensure!( + !message.text().is_empty(), + "This message has no text or caption to copy" + ); + Ok(message.text().to_owned()) +} + +pub(super) async fn check_protection( + client: &Client, + peer: PeerRef, + message: &Message, +) -> Result<()> { + let tl::enums::Message::Message(raw) = &message.raw else { + anyhow::bail!("Service messages cannot be shared") + }; + let resolved = client.resolve_peer(peer).await?; + let protected = match resolved { + Peer::User(_) => false, + Peer::Channel(channel) => channel.raw.noforwards, + Peer::Group(group) => match group.raw { + tl::enums::Chat::Chat(chat) => chat.noforwards, + tl::enums::Chat::Channel(channel) => channel.noforwards, + _ => true, + }, + }; + ensure!( + !raw.noforwards && !protected, + "Sharing is disabled by this chat's content protection" + ); + Ok(()) +} diff --git a/src/telegram/telemetry.rs b/src/telegram/telemetry.rs new file mode 100644 index 0000000..0950200 --- /dev/null +++ b/src/telegram/telemetry.rs @@ -0,0 +1,75 @@ +//! Optional observations on the existing primary connection. This task neither +//! consumes updates nor replaces Grammers' own keepalive/reconnection machinery. + +use super::{Client, NetworkEvent, Session, SqliteSession, mpsc, tl}; +use crate::statusline::Latency; +use std::{ + sync::Arc, + time::{Duration, Instant}, +}; + +pub(super) async fn observe( + client: Client, + session: Arc, + events: mpsc::Sender, + mut measure_latency: tokio::sync::watch::Receiver, +) { + let mut ping_id = 0_i64; + loop { + let enabled = *measure_latency.borrow_and_update(); + let dc_id = session.home_dc_id().ok(); + // Publish DC without waiting for the optional measurement. + if events + .send(NetworkEvent::Telemetry { + dc_id, + latency: None, + }) + .await + .is_err() + { + return; + } + if !enabled { + tokio::select! { + () = tokio::time::sleep(Duration::from_secs(60)) => {}, + changed = measure_latency.changed() => { if changed.is_err() { return; } }, + } + continue; + } + let started = Instant::now(); + ping_id = ping_id.wrapping_add(1); + let request = tl::functions::Ping { ping_id }; + let invoke = client.invoke(&request); + tokio::pin!(invoke); + let result = tokio::time::timeout(Duration::from_secs(5), &mut invoke).await; + let timed_out = result.is_err(); + let current_dc = session.home_dc_id().ok(); + let latency = (matches!(result, Ok(Ok(_))) && *measure_latency.borrow()) + .then_some(Latency { + started, + elapsed: started.elapsed(), + }) + .filter(|_| dc_id.is_some() && dc_id == current_dc); + if events + .send(NetworkEvent::Telemetry { + dc_id: current_dc, + latency, + }) + .await + .is_err() + { + return; + } + // Grammers cannot cancel an already-enqueued RPC when its future is + // dropped. Keep one probe alive after reporting it unavailable instead + // of accumulating requests during a half-open/offline connection. + // Worker shutdown owns both this task and the underlying sender pool. + if timed_out { + let _ = invoke.await; + } + tokio::select! { + () = tokio::time::sleep(Duration::from_secs(60)) => {}, + changed = measure_latency.changed() => { if changed.is_err() { return; } }, + } + } +} diff --git a/src/terminal.rs b/src/terminal.rs index 9c34c4e..e52f7ff 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -1,5 +1,6 @@ //! Yazi owns terminal input, capability detection, and platform restoration. //! Ratatui uses the same TTY writer exclusively for output; it never reads input. +pub mod clipboard; use std::io::{self, Write}; use std::sync::{ @@ -24,10 +25,11 @@ use yazi_term::{ use yazi_tty::{ TTY, TtyWriter, sequence::{ - DisableBracketedPaste, DisableColorSchemeUpdates, DisableFocusChange, DisableMouseCapture, - EnableBracketedPaste, EnableColorSchemeUpdates, EnableFocusChange, EnableMouseCapture, - EndSyncUpdate, EnterAlternateScreen, LeaveAlternateScreen, PopKeyboardFlags, - PushKeyboardFlags, RequestCellPixelSize, RestoreCursorStyle, ShowCursor, + DisableBracketedPaste, DisableClipboard, DisableColorSchemeUpdates, DisableFocusChange, + DisableMouseCapture, EnableBracketedPaste, EnableClipboard, EnableColorSchemeUpdates, + EnableFocusChange, EnableMouseCapture, EndSyncUpdate, EnterAlternateScreen, + LeaveAlternateScreen, PopKeyboardFlags, PushKeyboardFlags, RequestBgColor, + RequestCellPixelSize, RestoreCursorStyle, ShowCursor, }, }; @@ -35,6 +37,7 @@ pub type AppTerminal = Terminal>>; static INITIALIZED: OnceLock> = OnceLock::new(); static MODES_ACTIVE: AtomicBool = AtomicBool::new(false); +static CLIPBOARD_ACTIVE: AtomicBool = AtomicBool::new(false); const PROBE_TIMEOUT: Duration = Duration::from_secs(5); /// The reader is dropped before the restoration guard. This joins Yazi's @@ -45,6 +48,7 @@ pub struct TerminalGuard { passthrough: Option>, probe_deadline: Instant, allow_passthrough: bool, + clipboard_supported: bool, _modes: TerminalModes, } @@ -114,6 +118,7 @@ impl TerminalGuard { // policy explicit rather than hiding it inside capability detection. allow_passthrough: std::env::var_os("TERMGRAM_TMUX_PASSTHROUGH") .is_some_and(|value| value == "1"), + clipboard_supported: false, _modes: modes, }) } @@ -122,6 +127,30 @@ impl TerminalGuard { &mut self.terminal } + /// # Errors + /// Returns a TTY write error when changing the negotiated clipboard mode. + pub fn set_clipboard_enabled(&mut self, enabled: bool) -> io::Result<()> { + let enabled = enabled && self.clipboard_supported; + if CLIPBOARD_ACTIVE.load(Ordering::Acquire) != enabled { + let mut writer = TTY.writer(); + if enabled { + // Restore even when enabling partially writes and then fails. + CLIPBOARD_ACTIVE.store(true, Ordering::Release); + write!(writer, "{EnableClipboard}")?; + } else { + write!(writer, "{DisableClipboard}")?; + } + writer.flush()?; + CLIPBOARD_ACTIVE.store(enabled, Ordering::Release); + } + Ok(()) + } + + #[must_use] + pub fn clipboard_supported(&self) -> bool { + self.clipboard_supported + } + /// Consume protocol reports here so they can never become draft text. /// The receiver, deadline, and reprobe task survive select! cancellation. pub async fn next_event(&mut self) -> Option> { @@ -129,7 +158,18 @@ impl TerminalGuard { tokio::select! { event = self.input.next() => { match event { - Some(Ok(Event::Report(report))) => self.report(&report), + Some(Ok(Event::Report(report))) => { + self.report(&report); + // Forward parsed colors, never raw protocol text, so the UI + // can shade messages without starting another terminal reader. + if matches!(report, Report::BackgroundColor(_) | Report::Clipboard(_)) { + return Some(Ok(Event::Report(report))); + } + if matches!(report, Report::ColorScheme(_)) { + let _ = write!(TTY.writer(), "{RequestBgColor}"); + let _ = TTY.writer().flush(); + } + } Some(Ok(event @ Event::Resize(_))) => { // Font changes can change pixel dimensions without changing // the terminal brand or negotiated graphics protocol. @@ -172,6 +212,9 @@ impl TerminalGuard { // revision recorded in vendor/README.md. Keep the two-stage probe contract. fn report(&mut self, report: &Report) { EMULATOR.apply(report); + if let Report::Clipboard(supported) = report { + self.clipboard_supported = *supported; + } if !report.is_da_1() || EMULATOR.probe.pending().is_none() { return; } @@ -204,6 +247,9 @@ fn restore_modes() { return; } crate::media::cleanup(); + if CLIPBOARD_ACTIVE.swap(false, Ordering::AcqRel) { + let _ = write!(TTY.writer(), "{DisableClipboard}"); + } let cursor = RestoreCursorStyle { blink: EMULATOR.cursor_blink.get(), shape: EMULATOR.cursor_shape.get(), diff --git a/src/terminal/clipboard.rs b/src/terminal/clipboard.rs new file mode 100644 index 0000000..9823aa4 --- /dev/null +++ b/src/terminal/clipboard.rs @@ -0,0 +1,611 @@ +//! OSC 5522 application adapter. Yazi parses and formats the protocol; this +//! broker only selects a MIME type and retains the user's original destination. +use crate::{ + app::{AppState, Focus, Mode, Screen}, + clipboard::Payload, + event::{AppEvent, TelegramCommand}, + staging::{Input, Request}, +}; +use std::{collections::VecDeque, io::Write, time::Duration}; +use tokio::time::Instant; +use yazi_term::event::{ClipboardData, ClipboardEvent}; +use yazi_tty::{ + TTY, + sequence::{ReadClipboard, SetClipboard}, +}; + +const DEADLINE: Duration = Duration::from_secs(10); +const MAX_TEXT: usize = 1024 * 1024; + +#[derive(Clone, Eq, PartialEq)] +struct TextTarget { + account: u8, + user: Option, + chat: Option, + edit_message: Option, + screen: Screen, + mode: Mode, + focus: Focus, +} + +impl TextTarget { + fn capture(app: &AppState) -> Self { + Self { + account: app.active_account(), + user: app.account_user_id, + chat: app.active_chat_id, + edit_message: app.message_edit().map(|edit| edit.source.message_id), + screen: app.screen.clone(), + mode: app.mode, + focus: app.focus, + } + } +} + +enum Destination { + Draft(Request), + Text(TextTarget), +} + +struct Pending { + id: String, + mime: Option<&'static str>, + destination: Destination, + deadline: Instant, + primary: bool, + password: String, +} + +pub enum Activity { + PasteTimeout, + Copied(Box), +} + +#[derive(Default)] +pub struct Broker { + supported: bool, + next_id: u64, + pending: Option, + outbound: VecDeque, + writer: Option, +} + +impl Broker { + pub fn configure(&mut self, supported: bool, app: &mut AppState) { + self.supported = supported && app.keymap.attachments.terminal_clipboard; + if !self.supported && self.pending.is_some() { + self.fail(app, "Terminal clipboard is no longer available".to_owned()); + } + } + + /// Intercept native clipboard intents only after positive capability detection. + pub fn route( + &mut self, + app: &mut AppState, + outgoing: Vec, + ) -> Vec { + let mut remaining = Vec::new(); + for command in outgoing { + match command { + TelegramCommand::CopyText(text) => self.copy(app, text), + TelegramCommand::PrepareAttachments(request) + if self.supported && request.input == Input::Clipboard => + { + self.start(app, Destination::Draft(request), false, String::new(), None); + } + other => remaining.push(other), + } + } + remaining + } + + /// Flush alongside normal rendering on the main thread, through the same TTY. + pub fn flush(&mut self, app: &mut AppState) { + if self.outbound.is_empty() { + return; + } + let result = (|| -> std::io::Result<()> { + let mut writer = TTY.writer(); + while let Some(sequence) = self.outbound.pop_front() { + writer.write_all(sequence.as_bytes())?; + } + writer.flush() + })(); + if let Err(error) = result { + let error = format!("Terminal clipboard request failed: {error}"); + app.status_message = Some(error.clone()); + self.fail(app, error); + } + } + + /// Both clipboard directions remain events of the existing main loop. + pub async fn next_activity(&mut self) -> Activity { + let deadline = self.pending.as_ref().map(|pending| pending.deadline); + let paste = async move { + if let Some(deadline) = deadline { + tokio::time::sleep_until(deadline).await; + } else { + std::future::pending::<()>().await; + } + }; + let copy = async { + if let Some(writer) = &mut self.writer { + writer.receive().await + } else { + std::future::pending().await + } + }; + tokio::select! { + () = paste => Activity::PasteTimeout, + result = copy => Activity::Copied(Box::new(result)), + } + } + + fn copy(&mut self, app: &mut AppState, text: String) { + if self.writer.is_none() { + match crate::clipboard::copy::Writer::start() { + Ok(writer) => self.writer = Some(writer), + Err(error) => { + app.status_message = Some(format!("Clipboard writer could not start: {error}")); + return; + } + } + } + let result = + self.writer + .as_mut() + .expect("initialized writer") + .submit(crate::clipboard::copy::Job { + account: (app.active_account(), app.account_user_id), + text, + remote: remote(), + multiplexed: std::env::var_os("TMUX").is_some() + || std::env::var_os("TMUX_PANE").is_some(), + }); + app.status_message = Some(result.map_or_else(|error| error, |()| "Copying…".to_owned())); + } + + pub fn activity(&mut self, app: &mut AppState, activity: Activity) { + match activity { + Activity::PasteTimeout => self.timeout(app), + Activity::Copied(result) => { + let status = if let Some(text) = &result.terminal { + self.outbound + .push_back(SetClipboard(text.as_bytes()).to_string()); + if result.native.is_ok() { + "Copied · terminal forwarding requested" + } else { + "Clipboard request sent to terminal (OSC 52)" + } + .to_owned() + } else { + result + .native + .map_or_else(|error| error, |()| "Copied".to_owned()) + }; + if result.account == (app.active_account(), app.account_user_id) + || result.account.0 == 0 + { + app.status_message = Some(status); + } + } + } + } + + pub fn timeout(&mut self, app: &mut AppState) { + self.fail( + app, + "Terminal clipboard timed out; try again or use :attach".to_owned(), + ); + } + + pub fn configuration_changed(&mut self, app: &mut AppState) { + self.fail( + app, + "Terminal paste cancelled by configuration reload; paste again".to_owned(), + ); + self.outbound.clear(); + } + + pub fn cancel(&mut self) { + self.pending = None; + self.outbound.clear(); + // Keep the sequence counter across account switches. Late replies can + // never match a new operation, even when draft request IDs restart. + } + + pub fn event(&mut self, app: &mut AppState, event: ClipboardEvent) -> Vec { + match event { + ClipboardEvent::Read { + id, + primary, + pw, + data, + } if id.is_empty() && data.get(".").is_some() => { + if !self.supported { + return Vec::new(); + } + if self.pending.is_some() { + app.status_message = Some("A terminal paste is still in progress".to_owned()); + return Vec::new(); + } + let media = app.screen == Screen::Main + && ((matches!(app.mode, Mode::Navigate | Mode::Compose) + && app.focus == Focus::Conversation) + || app.mode == Mode::Attachments); + let destination = if media { + let Some(request) = app.reserve_terminal_paste() else { + return Vec::new(); + }; + Destination::Draft(request) + } else if matches!(app.screen, Screen::Auth(_)) + || (app.screen == Screen::Main + && matches!( + app.mode, + Mode::Command | Mode::Search | Mode::Filter | Mode::Edit + )) + { + Destination::Text(TextTarget::capture(app)) + } else { + app.status_message = + Some("Select an input or open a conversation to paste".to_owned()); + return Vec::new(); + }; + self.start(app, destination, primary, pw, data.get(".")); + } + ClipboardEvent::Read { id, data, .. } + if self + .pending + .as_ref() + .is_some_and(|pending| pending.id == id) => + { + if let Some(pending) = self.pending.take() { + return self.received(app, pending, data); + } + } + ClipboardEvent::ReadError { id, code } + if self + .pending + .as_ref() + .is_some_and(|pending| pending.id == id) => + { + self.fail( + app, + format!( + "Terminal clipboard: {}", + crate::model::sanitize_terminal_line( + &code.chars().take(80).collect::() + ) + ), + ); + } + _ => {} + } + Vec::new() + } + + fn received( + &mut self, + app: &mut AppState, + mut pending: Pending, + data: ClipboardData, + ) -> Vec { + let Some(mime) = pending.mime else { + let result = data + .get(".") + .ok_or("Terminal did not provide its clipboard MIME list") + .and_then(|types| { + select_mime(types, matches!(pending.destination, Destination::Draft(_))) + }); + match result { + Ok(mime) => { + pending.mime = Some(mime); + self.enqueue(pending); + } + Err(error) => { + Self::fail_destination(app, pending.destination, error.to_owned()); + } + } + return Vec::new(); + }; + let Some(bytes) = data.into_inner().remove(mime) else { + Self::fail_destination( + app, + pending.destination, + "Clipboard changed or its requested type is unavailable".to_owned(), + ); + return Vec::new(); + }; + match pending.destination { + Destination::Draft(mut request) => { + request.input = Input::Terminal(Payload { + mime: mime.to_owned(), + bytes: bytes.into(), + remote: remote(), + }); + return vec![TelegramCommand::PrepareAttachments(request)]; + } + Destination::Text(target) => { + if target != TextTarget::capture(app) { + app.status_message = + Some("Paste cancelled because the input destination changed".to_owned()); + } else if bytes.len() > MAX_TEXT { + app.status_message = Some("Clipboard text exceeds 1 MiB".to_owned()); + } else if let Ok(text) = String::from_utf8(bytes) { + return app.update(AppEvent::Paste(text)); + } else { + app.status_message = Some("Clipboard text is not valid UTF-8".to_owned()); + } + } + } + Vec::new() + } + + fn start( + &mut self, + app: &mut AppState, + destination: Destination, + primary: bool, + password: String, + types: Option<&[u8]>, + ) { + if self.pending.is_some() { + Self::fail_destination( + app, + destination, + "A terminal paste is still in progress".to_owned(), + ); + return; + } + if password.len() > 4096 { + Self::fail_destination( + app, + destination, + "Terminal clipboard token exceeds its limit".to_owned(), + ); + return; + } + let mime = match types + .map(|types| select_mime(types, matches!(destination, Destination::Draft(_)))) + .transpose() + { + Ok(mime) => mime, + Err(error) => { + Self::fail_destination(app, destination, error.to_owned()); + return; + } + }; + self.enqueue(Pending { + id: String::new(), + mime, + destination, + deadline: Instant::now() + DEADLINE, + primary, + password, + }); + } + + fn enqueue(&mut self, mut pending: Pending) { + self.next_id = self.next_id.wrapping_add(1).max(1); + pending.id = format!("termgram-{}-{}", std::process::id(), self.next_id); + pending.deadline = Instant::now() + DEADLINE; + self.outbound.push_back( + ReadClipboard::new( + [pending.mime.unwrap_or(".")], + &pending.password, + if pending.password.is_empty() { + "Termgram" + } else { + "Paste event" + }, + pending.primary, + ) + .with_id(&pending.id) + .to_string(), + ); + self.pending = Some(pending); + } + + fn fail(&mut self, app: &mut AppState, error: String) { + self.outbound.clear(); + if let Some(pending) = self.pending.take() { + Self::fail_destination(app, pending.destination, error); + } + } + + fn fail_destination(app: &mut AppState, destination: Destination, error: String) { + match destination { + Destination::Draft(request) => { + app.handle_network(request.failure(error)); + } + Destination::Text(_) => app.status_message = Some(error), + } + } +} + +fn select_mime(types: &[u8], media: bool) -> Result<&'static str, &'static str> { + if types.len() > 64 * 1024 { + return Err("Clipboard MIME list exceeds 64 KiB"); + } + let types = std::str::from_utf8(types).map_err(|_| "Clipboard MIME list is not UTF-8")?; + let allowed = if media { + &[ + "text/uri-list", + "image/png", + "image/jpeg", + "image/webp", + "image/gif", + "text/plain", + "text/plain;charset=utf-8", + ][..] + } else { + &["text/plain", "text/plain;charset=utf-8"][..] + }; + allowed + .iter() + .copied() + .find(|mime| types.split_whitespace().any(|candidate| candidate == *mime)) + .ok_or("Clipboard contains no supported image, file list or plain text") +} + +fn remote() -> bool { + ["SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"] + .iter() + .any(|name| std::env::var_os(name).is_some()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn read(id: &str, mime: &str, bytes: &[u8]) -> ClipboardEvent { + ClipboardEvent::Read { + id: id.to_owned(), + primary: false, + pw: String::new(), + data: [(mime.to_owned(), bytes.to_vec())].into_iter().collect(), + } + } + + #[test] + fn mime_paste_keeps_original_draft_and_rejects_late_phase_replies() { + let mut app = AppState::new(); + app.screen = Screen::Main; + app.account_user_id = Some(100); + app.active_chat_id = Some(7); + app.focus = Focus::Conversation; + let request = app.reserve_terminal_paste().unwrap(); + let original_key = request.key; + let mut broker = Broker::default(); + broker.configure(true, &mut app); + assert!( + broker + .route(&mut app, vec![TelegramCommand::PrepareAttachments(request)]) + .is_empty() + ); + let manifest_id = broker.pending.as_ref().unwrap().id.clone(); + assert!( + broker + .event(&mut app, read(&manifest_id, ".", b"image/png text/plain")) + .is_empty() + ); + let payload_id = broker.pending.as_ref().unwrap().id.clone(); + assert_ne!(manifest_id, payload_id); + app.active_chat_id = Some(8); + assert!( + broker + .event(&mut app, read(&manifest_id, ".", b"text/plain")) + .is_empty() + ); + let commands = broker.event(&mut app, read(&payload_id, "image/png", b"encoded image")); + let [TelegramCommand::PrepareAttachments(result)] = commands.as_slice() else { + panic!("prepare only") + }; + assert_eq!(result.key, original_key); + assert!( + matches!(&result.input, Input::Terminal(payload) if payload.mime == "image/png" && &*payload.bytes == b"encoded image") + ); + assert!(broker.pending.is_none()); + app = AppState::new(); + app.screen = Screen::Main; + app.account_user_id = Some(100); + app.active_chat_id = Some(3); + app.focus = Focus::Conversation; + app.mode = Mode::Compose; + broker.event(&mut app, read("", ".", b"image/png")); + let obsolete = broker.pending.as_ref().unwrap().id.clone(); + broker.configuration_changed(&mut app); + assert!(!app.preparing_attachments()); + assert!( + broker + .event(&mut app, read(&obsolete, "image/png", b"old")) + .is_empty() + ); + } + + #[test] + fn changed_text_context_and_cancelled_request_cannot_receive_old_paste() { + let mut app = AppState::new(); + app.screen = Screen::Main; + app.mode = Mode::Search; + let mut broker = Broker::default(); + broker.configure(true, &mut app); + broker.event(&mut app, read("", ".", b"text/plain image/png")); + let old_id = broker.pending.as_ref().unwrap().id.clone(); + app.mode = Mode::Filter; + assert!( + broker + .event(&mut app, read(&old_id, "text/plain", b"private text")) + .is_empty() + ); + assert!( + app.status_message + .as_deref() + .unwrap() + .contains("destination changed") + ); + assert!(app.filter.value().is_empty()); + broker.event(&mut app, read("", ".", b"text/plain")); + let cancelled = broker.pending.as_ref().unwrap().id.clone(); + broker.cancel(); + broker.event(&mut app, read("", ".", b"text/plain")); + let current = broker.pending.as_ref().unwrap().id.clone(); + assert_ne!(cancelled, current); + assert!( + broker + .event(&mut app, read(&cancelled, "text/plain", b"old text")) + .is_empty() + ); + assert_eq!(broker.pending.as_ref().unwrap().id, current); + broker.timeout(&mut app); + assert!( + broker + .event(&mut app, read(¤t, "text/plain", b"late text")) + .is_empty() + ); + assert!(app.filter.value().is_empty()); + } + #[tokio::test] + async fn remote_copy_is_bounded_and_uses_the_single_tty_output_queue() { + use crate::clipboard::copy::{Job, MAX_TEXT, Writer}; + let mut app = AppState::new(); + app.account_user_id = Some(100); + let account = (app.active_account(), app.account_user_id); + let job = |text: String| Job { + account, + text, + remote: true, + multiplexed: false, + }; + // remote=true deliberately avoids reading or modifying the real clipboard. + let mut writer = Writer::start().unwrap(); + assert!(writer.submit(job(String::new())).is_err()); + assert!(writer.submit(job("x".repeat(MAX_TEXT + 1))).is_err()); + writer.submit(job("界🙂\ntext".to_owned())).unwrap(); + assert!(writer.submit(job("duplicate".to_owned())).is_err()); + let mut broker = Broker { + writer: Some(writer), + ..Broker::default() + }; + let event = tokio::time::timeout(Duration::from_secs(2), broker.next_activity()) + .await + .unwrap(); + broker.activity(&mut app, event); + assert_eq!( + broker.outbound.pop_front().unwrap(), + SetClipboard("界🙂\ntext".as_bytes()).to_string() + ); + assert!( + app.status_message + .as_ref() + .unwrap() + .contains("request sent") + ); + broker.cancel(); + assert!(broker.outbound.is_empty()); + assert!( + broker.writer.is_some(), + "account changes retain the clipboard owner" + ); + } +} diff --git a/src/transcript.rs b/src/transcript.rs new file mode 100644 index 0000000..6952eb1 --- /dev/null +++ b/src/transcript.rs @@ -0,0 +1,59 @@ +//! Conversation density and background choices, independent of frame rendering. +use crate::appearance::TerminalColor; +use ratatui::style::Color; +use serde::Deserialize; + +#[derive(Clone, Debug, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Configuration { + pub spacing: u16, + pub alternating: bool, + /// None blends with the background reported by the existing Yazi reader. + pub alternate_background: Option, +} + +impl Default for Configuration { + fn default() -> Self { + Self { + spacing: 0, + alternating: true, + alternate_background: None, + } + } +} + +impl Configuration { + pub(crate) fn validate(&self) -> anyhow::Result<()> { + anyhow::ensure!( + self.spacing <= 2, + "messages.spacing must be between 0 and 2" + ); + Ok(()) + } + + /// Like Codex's message backgrounds, shade relative to the user's theme. + /// No terminal reads or palette guesses are made while drawing. + #[must_use] + pub fn background(&self, alternate: bool, terminal: Option<[u8; 3]>) -> Color { + if !self.alternating || !alternate { + return Color::Reset; + } + if let Some(color) = self.alternate_background { + return color.color(); + } + let Some(rgb) = terminal else { + return Color::Reset; + }; + let light = u32::from(rgb[0]) * 3 + u32::from(rgb[1]) * 6 + u32::from(rgb[2]) > 1_500; + let shade = rgb.map(|channel| { + let channel = u16::from(channel); + u8::try_from(if light { + channel * 94 / 100 + } else { + channel + (255 - channel) * 8 / 100 + }) + .unwrap_or_default() + }); + Color::Rgb(shade[0], shade[1], shade[2]) + } +} diff --git a/src/ui/appearance.rs b/src/ui/appearance.rs new file mode 100644 index 0000000..1c7c7e8 --- /dev/null +++ b/src/ui/appearance.rs @@ -0,0 +1,53 @@ +use super::{ + AppState, Clear, Constraint, DANGER, Frame, Layout, Line, List, ListItem, ListState, MUTED, + Modifier, Paragraph, Rect, Span, Style, Wrap, centered, pane_block, +}; +use crate::{appearance::TerminalColor, keymap::Context}; + +pub(super) fn render_colors(frame: &mut Frame<'_>, area: Rect, app: &AppState) { + let Some(picker) = &app.color_picker else { + return; + }; + let popup = centered( + area, + area.width.min(62), + area.height.saturating_sub(2).min(24), + ); + frame.render_widget(Clear, popup); + let block = pane_block(format!(" {} ", picker.label), true); + let inner = block.inner(popup); + frame.render_widget(block, popup); + let rows = Layout::vertical([Constraint::Min(1), Constraint::Length(2)]).split(inner); + let mut items = vec![ListItem::new("Follow configuration")]; + items.extend(TerminalColor::ALL.iter().map(|color| { + ListItem::new(Line::from(vec![ + Span::styled("■ ", Style::default().fg(color.color())), + Span::raw(color.label()), + ])) + })); + let mut state = ListState::default().with_selected(Some(picker.selection)); + frame.render_stateful_widget( + List::new(items) + .highlight_symbol("› ") + .highlight_style(Style::default().add_modifier(Modifier::REVERSED)), + rows[0], + &mut state, + ); + let hint = app.status_message.clone().unwrap_or_else(|| { + format!( + "{} apply · {} cancel\nLocal to this Telegram account", + app.keymap.hint(Context::Overlay, "open"), + app.keymap.hint(Context::Overlay, "cancel") + ) + }); + frame.render_widget( + Paragraph::new(hint) + .wrap(Wrap { trim: true }) + .style(Style::default().fg(if app.status_message.is_some() { + DANGER + } else { + MUTED + })), + rows[1], + ); +} diff --git a/src/ui/chat_info.rs b/src/ui/chat_info.rs new file mode 100644 index 0000000..4e4ab7e --- /dev/null +++ b/src/ui/chat_info.rs @@ -0,0 +1,96 @@ +use super::{ + AppState, Clear, Frame, MUTED, Paragraph, Rect, Style, centered, pane_block, wrap_cells, +}; +use crate::{chat_info::Content, event::ConnectionStatus}; +use ratatui::text::Line; + +pub(super) fn render(frame: &mut Frame<'_>, area: Rect, app: &mut AppState) { + let Some(id) = app.chat_info.view else { + return; + }; + let popup = centered( + area, + area.width.saturating_sub(2).min(86), + area.height.saturating_sub(3).min(25), + ); + frame.render_widget(Clear, popup); + let block = pane_block(" Chat information ".to_owned(), true); + let inner = block.inner(popup); + frame.render_widget(block, popup); + let mut lines = vec![format!("Chat ID: {id}")]; + let now = chrono::Utc::now().timestamp(); + if let Some(entry) = app.chat_info.entries.get(&id) { + if let Some(info) = &entry.info { + lines.push(info.title.clone()); + if let Some(username) = &info.username { + lines.push(format!("@{username}")); + } + lines.push(format!("Your role: {}", info.role)); + if let Some(members) = info.members { + lines.push(format!("Members: {members}")); + } + lines.push(info.about.clone()); + lines.push(String::new()); + for (label, content) in [ + ("Text", Content::Text), + ("Photos", Content::Photo), + ("Files", Content::File), + ] { + lines.push(format!( + "{label}: {}", + info.restriction(content, now) + .unwrap_or_else(|| "Allowed by known permissions".to_owned()) + )); + } + if info.slow_seconds > 0 { + lines.push(format!( + "Slow mode: {} s between messages", + info.slow_seconds + )); + } + lines.push(format!( + "Details fetched {} s ago{}", + entry.at.elapsed().as_secs(), + if app.connection == ConnectionStatus::Online { + "" + } else { + " · offline" + } + )); + } + if let Some(error) = &entry.error { + lines.push(error.clone()); + } + } else { + lines.push( + if app.connection == ConnectionStatus::Online { + "Loading chat details…" + } else { + "Connect to Telegram to fetch details" + } + .to_owned(), + ); + } + if let Some(chat) = app.chats.iter().find(|c| c.id == id) { + lines.push(crate::notifications::mute_label( + chat.membership.mute_until, + now, + )); + } + lines.push("Telegram checks current permissions when sending. Drafts are kept when sending is restricted.".to_owned()); + let wrapped: Vec> = lines + .iter() + .flat_map(|line| wrap_cells(line, usize::from(inner.width.max(1)))) + .map(Line::from) + .collect(); + app.chat_info.scroll = app + .chat_info + .scroll + .min(wrapped.len().saturating_sub(usize::from(inner.height))); + frame.render_widget( + Paragraph::new(wrapped) + .style(Style::default().fg(MUTED)) + .scroll((u16::try_from(app.chat_info.scroll).unwrap_or(u16::MAX), 0)), + inner, + ); +} diff --git a/src/ui/chats.rs b/src/ui/chats.rs new file mode 100644 index 0000000..24637a3 --- /dev/null +++ b/src/ui/chats.rs @@ -0,0 +1,161 @@ +//! Aligned semantic columns using Ratatui's Table; no custom table layout engine. +use super::{ACCENT, MUTED, clamp_u16, icons::Icons, pane_block, truncate_cells}; +use crate::{ + app::{AppState, Focus, Mode}, + appearance::Target, +}; +use chrono::Local; +use ratatui::{ + Frame, + layout::{Alignment, Constraint, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Cell, HighlightSpacing, Paragraph, Row, Table, TableState}, +}; +use unicode_width::UnicodeWidthStr; + +pub(super) fn render(frame: &mut Frame<'_>, area: Rect, app: &mut AppState) { + app.set_chat_pane_region((area.x, area.right(), area.y, area.bottom())); + let focused = app.focus == Focus::Chats && app.mode != Mode::Compose; + let icons = Icons(app.keymap.nerd_font); + let folder = app.folders.iter().find(|folder| folder.id == app.folder_id); + let title = if app.mode == Mode::Filter { + format!("Chats · /{}", app.filter.value()) + } else { + format!( + "{}{}", + icons.folder(app.folder_id), + folder.map_or("Chats", |folder| folder.title.as_str()) + ) + }; + let block = pane_block( + format!( + " {} ", + truncate_cells(&title, usize::from(area.width.saturating_sub(4))) + ), + focused, + ) + .title_style(Style::default().fg(app.color(Target::Folder(app.folder_id)))); + let visible = app.filtered_chat_indices(); + if visible.is_empty() { + app.set_chat_hit_regions(Vec::new()); + frame.render_widget( + Paragraph::new(if app.mode == Mode::Filter { + "No chats match" + } else { + "No conversations" + }) + .style(Style::default().fg(MUTED)) + .block(block), + area, + ); + return; + } + let viewport_height = usize::from(area.height.saturating_sub(2)); + let selected = app.selected_chat.min(visible.len().saturating_sub(1)); + let start = selected.saturating_add(1).saturating_sub(viewport_height); + let end = start.saturating_add(viewport_height).min(visible.len()); + // Borders (2), selection marker (2), time (5), unread (4), column gaps (2). + let title_width = usize::from(area.width.saturating_sub(15)); + let now = Local::now(); + let rows = visible[start..end] + .iter() + .enumerate() + .map(|(offset, &index)| { + row( + app, + &app.chats[index], + title_width, + focused, + start + offset == selected, + now, + ) + }) + .collect::>(); + let regions = (start..end) + .enumerate() + .map(|(offset, position)| { + ( + area.x.saturating_add(1), + area.right().saturating_sub(1), + area.y.saturating_add(1).saturating_add(clamp_u16(offset)), + position, + ) + }) + .collect(); + app.set_chat_hit_regions(regions); + let mut state = TableState::default().with_selected(Some(selected.saturating_sub(start))); + let table = Table::new( + rows, + [ + Constraint::Min(0), + Constraint::Length(5), + Constraint::Length(4), + ], + ) + .block(block) + .column_spacing(1) + .highlight_spacing(HighlightSpacing::Always) + .row_highlight_style(Style::default().bold()) + .highlight_symbol(Span::styled( + "› ", + Style::default().fg(if focused { ACCENT } else { MUTED }), + )); + frame.render_stateful_widget(table, area, &mut state); +} + +fn row( + app: &AppState, + chat: &crate::model::Chat, + title_width: usize, + focused: bool, + is_selected: bool, + now: chrono::DateTime, +) -> Row<'static> { + let icons = Icons(app.keymap.nerd_font); + let pin = if app.chat_pin_position(chat.id).is_some() { + format!("{} ", icons.pin()) + } else { + String::new() + }; + let icon = icons.chat(chat.kind); + let muted = if chat.membership.mute_until > now.timestamp() { + icons.muted() + } else { + "" + }; + let mention = if chat.membership.mentions { "@ " } else { "" }; + let title = truncate_cells( + &chat.title, + title_width.saturating_sub(icon.width() + pin.width() + muted.width() + mention.width()), + ); + let mut title_style = Style::default().fg(app.color(Target::Chat(chat.id))); + if chat.unread > 0 || chat.membership.unread_mark || is_selected { + title_style = title_style.bold(); + } + if is_selected && focused { + title_style = title_style.add_modifier(Modifier::UNDERLINED); + } + let count = match chat.unread { + 0 if chat.membership.unread_mark => "•".to_owned(), + 0 => String::new(), + 1..=999 => chat.unread.to_string(), + _ => "999+".to_owned(), + }; + Row::new([ + Cell::from(Line::from(vec![ + Span::styled(icon, Style::default().fg(MUTED)), + Span::styled(pin, Style::default().fg(ACCENT)), + Span::styled(muted, Style::default().fg(MUTED)), + Span::styled(mention, Style::default().fg(ACCENT).bold()), + Span::styled(title, title_style), + ])), + Cell::from(Line::from(chat.activity_label(now)).alignment(Alignment::Right)) + .style(Style::default().fg(app.keymap.sidebar.time_color.color())), + Cell::from(Line::from(count).alignment(Alignment::Right)).style( + Style::default() + .fg(app.keymap.sidebar.unread_color.color()) + .bold(), + ), + ]) +} diff --git a/src/ui/commands.rs b/src/ui/commands.rs new file mode 100644 index 0000000..2de5840 --- /dev/null +++ b/src/ui/commands.rs @@ -0,0 +1,238 @@ +use super::{ + ACCENT, AppState, Clear, DANGER, Frame, MUTED, Paragraph, Position, Rect, Style, centered, + clamp_u16, pane_block, truncate_cells, +}; +use crate::event::ConnectionStatus; +use ratatui::text::{Line, Span}; +use unicode_width::UnicodeWidthStr; + +pub(super) fn render(frame: &mut Frame<'_>, area: Rect, app: &mut AppState) { + let candidates = app.command_candidates(); + let notice = app.commands.notice.clone(); + let bottom = area + .bottom() + .saturating_sub(u16::from(app.keymap.statusline.enabled)); + let max_rows = area.height.saturating_sub(6).min(8); + let count = clamp_u16(candidates.len()).min(max_rows); + let height = 3 + count + u16::from(notice.is_some()); + let popup = Rect::new(area.x, bottom.saturating_sub(height), area.width, height); + frame.render_widget(Clear, popup); + let block = pane_block(format!(" : · {} ", app.command_target_label()), true); + let inner = block.inner(popup); + frame.render_widget(block, popup); + let start = app + .commands + .selected + .unwrap_or(0) + .saturating_add(1) + .saturating_sub(usize::from(count)); + for (offset, (index, candidate)) in candidates + .iter() + .enumerate() + .skip(start) + .take(usize::from(count)) + .enumerate() + { + let row = Rect::new(inner.x, inner.y + clamp_u16(offset), inner.width, 1); + let prefix = if app.commands.selected == Some(index) { + "› " + } else { + " " + }; + let width = usize::from(inner.width); + let label_width = (width / 3).clamp(8, 28); + let label = truncate_cells(&candidate.label, label_width); + let shortcut = if candidate.shortcut == "unbound" || candidate.shortcut.is_empty() { + String::new() + } else { + format!(" {}", candidate.shortcut) + }; + let description_width = width.saturating_sub(2 + label_width + 2 + shortcut.width()); + let description = truncate_cells(&candidate.description, description_width); + let mut style = Style::default(); + if app.commands.selected == Some(index) { + style = style.reversed(); + } + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled( + format!( + "{prefix}{label}{} ", + " ".repeat(label_width.saturating_sub(label.width())) + ), + Style::default().fg(ACCENT), + ), + Span::styled( + description, + Style::default().fg(if candidate.unavailable { DANGER } else { MUTED }), + ), + Span::styled(shortcut, Style::default().fg(MUTED)), + ])) + .style(style), + row, + ); + app.commands + .hit_regions + .push((row.x, row.right(), row.y, index)); + } + if let Some(notice) = notice { + frame.render_widget( + Paragraph::new(truncate_cells(¬ice, usize::from(inner.width))) + .style(Style::default().fg(DANGER)), + Rect::new(inner.x, inner.y + count, inner.width, 1), + ); + } + let input_row = Rect::new(inner.x, inner.bottom().saturating_sub(1), inner.width, 1); + frame.render_widget( + Paragraph::new(Span::styled(":", Style::default().fg(ACCENT).bold())), + Rect::new(input_row.x, input_row.y, 1, 1), + ); + let input = &app.commands.input; + let scroll = input + .cursor_display_width() + .saturating_sub(usize::from(input_row.width.saturating_sub(2))); + frame.render_widget( + Paragraph::new(input.value()).scroll((0, clamp_u16(scroll))), + Rect::new( + input_row.x + 1, + input_row.y, + input_row.width.saturating_sub(1), + 1, + ), + ); + frame.set_cursor_position(Position::new( + input_row.x + 1 + clamp_u16(input.cursor_display_width().saturating_sub(scroll)), + input_row.y, + )); +} + +pub(super) fn render_status(frame: &mut Frame<'_>, area: Rect, app: &mut AppState) { + let popup = centered( + area, + area.width.min(86), + area.height.saturating_sub(3).min(28), + ); + frame.render_widget(Clear, popup); + let online = app.connection == ConnectionStatus::Online; + let unavailable = "not available"; + let dc = app + .metrics + .dc_id + .filter(|_| online) + .map_or_else(|| unavailable.to_owned(), |dc| dc.to_string()); + let latency = app + .metrics + .latency + .filter(|sample| online && !sample.expired()) + .map_or_else( + || unavailable.to_owned(), + |sample| { + format!( + "{} ms · sampled {} s ago", + sample.elapsed.as_millis(), + sample.started.elapsed().as_secs() + ) + }, + ); + let mut lines = vec![ + format!( + "Account {} · {}", + app.active_account(), + app.user_name.as_deref().unwrap_or("Telegram") + ), + format!("Connection: {:?}", app.connection), + format!("DC: {dc}"), + format!("Ping: {latency}"), + "Ping includes SDK scheduling and retries; it is not delivery latency.".to_owned(), + String::new(), + format!( + "Dialogs in memory: {} · folders: {}", + app.chats.len(), + app.folders.len() + ), + format!( + "Messages in memory: {}", + app.messages.values().map(Vec::len).sum::() + ), + format!( + "Current history: {}", + if app.loading_history { + "loading" + } else { + "idle" + } + ), + "Local search reports the persisted cache's coverage separately.".to_owned(), + ]; + lines.extend(configuration_lines(app)); + let block = pane_block(" Status ".to_owned(), true); + let inner = block.inner(popup); + frame.render_widget(block, popup); + let wrapped = lines + .iter() + .flat_map(|line| super::wrap_cells(line, usize::from(inner.width.max(1)))) + .map(Line::from) + .collect::>(); + app.configuration.status_scroll = app + .configuration + .status_scroll + .min(wrapped.len().saturating_sub(usize::from(inner.height))); + frame.render_widget( + Paragraph::new(wrapped).scroll((clamp_u16(app.configuration.status_scroll), 0)), + inner, + ); +} + +fn configuration_lines(app: &AppState) -> Vec { + let state = &app.configuration; + let mut lines = vec![ + String::new(), + format!( + "Lua configuration: {}", + state.path.as_deref().map_or_else( + || "not available".to_owned(), + |p| crate::model::sanitize_terminal_line(&p.display().to_string()) + ) + ), + format!( + "Configuration revision: {}{}", + state.revision, + if state.loading { " · reloading" } else { "" } + ), + "Use :config reload or :reload to apply changes.".to_owned(), + format!("Nerd Font icons: {}", app.keymap.nerd_font), + format!( + "Latency probes: {}", + app.keymap.statusline.measures_latency() + ), + format!( + "Terminal media paste: configured {} · supported {}", + app.keymap.attachments.terminal_clipboard, state.terminal_clipboard + ), + format!( + "Desktop alerts: {} · {:?} · {:?}", + app.keymap.notifications.enabled, + app.keymap.notifications.backend, + app.keymap.notifications.when + ), + format!( + "Alert previews: {} · sound: {}", + app.keymap.notifications.previews, app.keymap.notifications.sound + ), + ]; + if let Some(error) = &state.error { + lines.push(format!("Last configuration error: {error}")); + } + if let Some(at) = state.loaded_at { + lines.push(format!("Last reload: {} s ago", at.elapsed().as_secs())); + } + if let Some(chat) = app.active_chat_id { + lines.push(format!("Open chat: {chat}")); + if let Some(reason) = app.draft_restriction(chat) { + lines.push(reason); + } + } + lines.push(String::new()); + lines.push(crate::version_description(false)); + lines +} diff --git a/src/ui/deletion.rs b/src/ui/deletion.rs new file mode 100644 index 0000000..c2795a2 --- /dev/null +++ b/src/ui/deletion.rs @@ -0,0 +1,80 @@ +use super::{ + AppState, Clear, Constraint, DANGER, Frame, Layout, List, ListItem, ListState, MUTED, + Paragraph, Rect, Style, Wrap, centered, pane_block, +}; +use ratatui::text::{Line, Span}; + +pub(super) fn render(frame: &mut Frame<'_>, area: Rect, app: &AppState) { + let Some(prompt) = &app.deletion.prompt else { + return; + }; + let popup = centered( + area, + area.width.saturating_sub(2).min(76), + area.height.saturating_sub(3).min(13), + ); + frame.render_widget(Clear, popup); + let block = pane_block( + format!(" Delete #{} · {} ", prompt.message, prompt.title), + true, + ); + let inner = block.inner(popup); + frame.render_widget(block, popup); + let rows = Layout::vertical([ + Constraint::Length(3), + Constraint::Min(1), + Constraint::Length(2), + ]) + .split(inner); + let preview = prompt.plan.as_ref().map_or_else( + || "Loading message and available scopes…".to_owned(), + |plan| { + let message = &plan.message; + let body = message.preview_text(); + format!( + "{} · {}\n{}", + message.sender, + message.timestamp.format("%Y-%m-%d %H:%M UTC"), + body + ) + }, + ); + frame.render_widget( + Paragraph::new(preview) + .wrap(Wrap { trim: false }) + .style(Style::default().fg(MUTED)), + rows[0], + ); + let mut options = vec![ListItem::new("Cancel")]; + if let Some(plan) = &prompt.plan { + options.extend( + plan.scopes + .iter() + .map(|scope| ListItem::new(scope.label()).style(Style::default().fg(DANGER))), + ); + } + let mut selection = ListState::default().with_selected(Some(prompt.selected)); + frame.render_stateful_widget( + List::new(options) + .highlight_symbol("› ") + .highlight_style(Style::default().reversed()), + rows[1], + &mut selection, + ); + let notice = prompt + .error + .as_deref() + .unwrap_or(if app.deletion_pending() { + "Waiting for Telegram…" + } else { + "Deletion cannot be undone. Choose the scope before confirming." + }); + frame.render_widget( + Paragraph::new(vec![Line::from(Span::styled( + notice, + Style::default().fg(MUTED), + ))]) + .wrap(Wrap { trim: true }), + rows[2], + ); +} diff --git a/src/ui/editing.rs b/src/ui/editing.rs new file mode 100644 index 0000000..c88f406 --- /dev/null +++ b/src/ui/editing.rs @@ -0,0 +1,56 @@ +use super::{ + AppState, Clear, Frame, MUTED, Paragraph, Position, Rect, Style, centered, editor_lines, + input_cursor, pane_block, +}; +use ratatui::text::{Line, Text}; + +pub(super) fn render(frame: &mut Frame<'_>, area: Rect, app: &AppState) { + let popup = centered( + area, + area.width.min(90), + area.height.saturating_sub(3).min(18), + ); + frame.render_widget(Clear, popup); + let title = app.message_edit().map_or_else( + || " Edit message ".to_owned(), + |edit| { + format!( + " Edit {} #{} · {} ", + if edit.source.caption { + "caption" + } else { + "message" + }, + edit.source.message_id, + app.active_chat() + .map_or("Conversation", |chat| chat.title.as_str()) + ) + }, + ); + let block = pane_block(title, true); + let inner = block.inner(popup); + frame.render_widget(block, popup); + let Some(edit) = app.message_edit() else { + frame.render_widget( + Paragraph::new("Loading original…").style(Style::default().fg(MUTED)), + inner, + ); + return; + }; + let input = &edit.input; + let (row, column) = input_cursor(input, inner.width.max(1)); + let scroll = row.saturating_sub(inner.height.saturating_sub(1)); + let text = Text::from( + editor_lines(input.value(), inner.width.max(1)) + .into_iter() + .map(Line::from) + .collect::>(), + ); + frame.render_widget(Paragraph::new(text).scroll((scroll, 0)), inner); + if !app.message_edit_pending() && inner.width > 0 && inner.height > 0 { + frame.set_cursor_position(Position::new( + inner.x + column.min(inner.width - 1), + inner.y + row.saturating_sub(scroll).min(inner.height - 1), + )); + } +} diff --git a/src/ui/entities.rs b/src/ui/entities.rs new file mode 100644 index 0000000..75587d0 --- /dev/null +++ b/src/ui/entities.rs @@ -0,0 +1,194 @@ +//! Adapt Telegram entities to Ratatui spans, then use the same source-range +//! wrapper as plain transcript text. No Markdown parser or second text engine. +use super::{ACCENT, MUTED, wrapping}; +use crate::{ + app::{AppState, MessageAction}, + entities::{Entity, Kind}, + model::Message, +}; +use ratatui::{ + style::{Color, Modifier, Style}, + text::{Line, Span}, +}; +use std::ops::Range; +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; + +pub(super) struct Row { + pub line: Line<'static>, + pub action: Option, +} + +fn block_boundaries(length: usize, entities: &[&Entity]) -> Vec { + let mut boundaries = vec![0, length]; + for entity in entities { + if matches!(entity.kind, Kind::Pre { .. } | Kind::Quote { .. }) { + boundaries.extend([entity.range.start, entity.range.end]); + } + } + boundaries.sort_unstable(); + boundaries.dedup(); + boundaries +} + +pub(super) fn render(message: &Message, width: usize, app: &AppState) -> Vec { + render_text( + &message.text, + &message.entities, + width, + app.spoilers_revealed(message), + app.quotes_expanded(message), + ) +} + +pub(super) fn render_text( + text: &str, + formatting: &[Entity], + width: usize, + revealed: bool, + expanded: bool, +) -> Vec { + let entities: Vec<_> = formatting + .iter() + .filter(|entity| entity.valid_for(text)) + .collect(); + let boundaries = block_boundaries(text.len(), &entities); + let mut result = Vec::new(); + for boundary in boundaries.windows(2) { + let range = boundary[0]..boundary[1]; + let block = entities.iter().find(|entity| { + entity.overlaps(range.start, range.end) + && matches!(entity.kind, Kind::Pre { .. } | Kind::Quote { .. }) + }); + let code = block.is_some_and(|entity| matches!(entity.kind, Kind::Pre { .. })); + let foldable = + block.is_some_and(|entity| matches!(entity.kind, Kind::Quote { collapsed: true })); + let indent = if block.is_some() && width > 2 { + "│ " + } else { + "" + }; + let available = width.saturating_sub(indent.width()).max(1); + let source = &text[range.clone()]; + // A newline adjoining a block is its row separator, not an extra + // empty paragraph. Keep interior blank lines and a final hard break. + let source = if range.end < text.len() { + source.strip_suffix('\n').unwrap_or(source) + } else { + source + }; + if source.is_empty() && range.end < text.len() { + continue; + } + if let Some(Entity { + kind: Kind::Pre { language }, + .. + }) = block.copied() + && !language.is_empty() + && width > 2 + { + result.push(Row { + line: Line::styled( + format!( + "┌ {}", + super::truncate_cells(language, width.saturating_sub(2)) + ), + Style::default().fg(MUTED), + ), + action: None, + }); + } + let rows = wrapping::ranges(source, available, !code); + let folded = foldable && !expanded && rows.len() > 3; + for row in rows.iter().take(if folded { 2 } else { rows.len() }) { + let span_range = range.start + row.start..range.start + row.end; + let spoiler = entities.iter().any(|entity| { + entity.kind == Kind::Spoiler && entity.overlaps(span_range.start, span_range.end) + }); + let action = if spoiler { + Some(MessageAction::Spoilers) + } else if foldable { + Some(MessageAction::ExpandQuote) + } else { + None + }; + let base = if code { + Style::default().fg(Color::Yellow) + } else { + Style::default() + }; + let mut spans = Vec::new(); + if !indent.is_empty() { + spans.push(Span::styled( + indent, + Style::default().fg(if code { MUTED } else { ACCENT }), + )); + } + spans.extend(styled(text, span_range, &entities, base, revealed)); + result.push(Row { + line: Line::from(spans), + action, + }); + } + if folded { + result.push(Row { + line: Line::styled(format!("{indent}…"), Style::default().fg(ACCENT)), + action: Some(MessageAction::ExpandQuote), + }); + } + } + if result.is_empty() { + result.push(Row { + line: Line::default(), + action: None, + }); + } + result +} + +fn styled( + text: &str, + range: Range, + entities: &[&Entity], + base: Style, + revealed: bool, +) -> Vec> { + if entities.is_empty() { + return vec![Span::styled(text[range].to_owned(), base)]; + } + let mut spans: Vec> = Vec::new(); + for (offset, grapheme) in text[range.clone()].grapheme_indices(true) { + let start = range.start + offset; + let end = start + grapheme.len(); + let mut style = base; + let mut hidden = false; + for entity in entities.iter().filter(|entity| entity.overlaps(start, end)) { + style = match entity.kind { + Kind::Bold => style.bold(), + Kind::Italic => style.italic(), + Kind::Underline => style.underlined(), + Kind::Strike => style.add_modifier(Modifier::CROSSED_OUT), + Kind::Code | Kind::Pre { .. } => style.fg(Color::Yellow), + Kind::Link => style.fg(ACCENT).underlined(), + Kind::Mention | Kind::Tag => style.fg(ACCENT), + Kind::Quote { .. } => style, + Kind::Spoiler => { + hidden = !revealed; + style + } + }; + } + let content = if hidden { + style = Style::default().fg(MUTED); + "▨".repeat(grapheme.width()) + } else { + grapheme.to_owned() + }; + if let Some(last) = spans.last_mut().filter(|last| last.style == style) { + last.content.to_mut().push_str(&content); + } else { + spans.push(Span::styled(content, style)); + } + } + spans +} diff --git a/src/ui/forwarding.rs b/src/ui/forwarding.rs new file mode 100644 index 0000000..81d341c --- /dev/null +++ b/src/ui/forwarding.rs @@ -0,0 +1,56 @@ +use super::{ + AppState, Clear, Constraint, Frame, Layout, MUTED, Paragraph, Rect, Style, Wrap, centered, + pane_block, render_notice, wrapped_height, +}; + +pub(super) fn render(frame: &mut Frame<'_>, area: Rect, app: &AppState) { + let Some(review) = &app.forwarding.review else { + return; + }; + let popup = centered( + area, + area.width.saturating_sub(2).min(76), + area.height.saturating_sub(3).min(12), + ); + frame.render_widget(Clear, popup); + let block = pane_block(" Forward message ".to_owned(), true); + let inner = block.inner(popup); + frame.render_widget(block, popup); + let target = format!( + "To: {} · {} · Account {}\nFrom: {} · #{}", + review.target, + review.target_title, + app.active_account(), + review.source_title, + review.message + ); + let rows = Layout::vertical([ + Constraint::Length( + wrapped_height(&target, inner.width).min(inner.height.saturating_sub(3)), + ), + Constraint::Min(1), + Constraint::Length(2), + ]) + .split(inner); + render_notice(frame, rows[0], &target, ratatui::style::Color::Reset); + let preview = review.plan.as_ref().map_or_else( + || "Loading original message…".to_owned(), + |plan| { + let message = &plan.message; + let body = message.preview_text(); + format!("{}\n{}", message.sender, body) + }, + ); + frame.render_widget(Paragraph::new(preview).wrap(Wrap { trim: false }), rows[1]); + let notice = review.error.as_deref().unwrap_or(if app.forward_pending() { + "Waiting for Telegram…" + } else { + "Forwards with original attribution. Your existing drafts are kept." + }); + frame.render_widget( + Paragraph::new(notice) + .wrap(Wrap { trim: true }) + .style(Style::default().fg(MUTED)), + rows[2], + ); +} diff --git a/src/ui/icons.rs b/src/ui/icons.rs new file mode 100644 index 0000000..92e09a8 --- /dev/null +++ b/src/ui/icons.rs @@ -0,0 +1,55 @@ +//! Opt-in glyph selection; Ratatui and unicode-width still own cell layout. +//! +//! Codepoints verified against Nerd Fonts v3.4.0 glyphnames.json: +//! +//! No font assets or rendering backend are embedded. Like Yazi's Icon and +//! Codex's terminal palettes, icons remain text with a readable fallback. + +use crate::model::{AttachmentKind, ChatKind}; + +#[derive(Clone, Copy)] +pub(super) struct Icons(pub bool); + +impl Icons { + pub(super) const fn muted(self) -> &'static str { + if self.0 { "\u{f1f6} " } else { "[m] " } // nf-fa-bell_slash + } + + pub(super) const fn pin(self) -> &'static str { + if self.0 { "\u{f08d}" } else { "^" } // nf-fa-thumbtack + } + + pub(super) const fn chat(self, kind: ChatKind) -> &'static str { + if !self.0 { + return ""; + } + match kind { + ChatKind::Direct => "\u{f075} ", // nf-fa-comment + ChatKind::Group => "\u{f0c0} ", // nf-fa-users + ChatKind::Channel => "\u{f0a1} ", // nf-fa-bullhorn + } + } + + pub(super) const fn folder(self, id: i32) -> &'static str { + if !self.0 { + "" + } else if id == 1 { + "\u{f187} " // nf-fa-box_archive + } else { + "\u{f07b} " // nf-fa-folder + } + } + + pub(super) const fn attachment(self, kind: AttachmentKind) -> &'static str { + if !self.0 { + return ""; + } + match kind { + AttachmentKind::Photo => "\u{f03e} ", // nf-fa-image + AttachmentKind::File | AttachmentKind::Other => "\u{f15b} ", // nf-fa-file + AttachmentKind::Video => "\u{f03d} ", // nf-fa-video + AttachmentKind::Audio => "\u{f001} ", // nf-fa-music + AttachmentKind::Sticker => "\u{f118} ", // nf-fa-face_smile + } + } +} diff --git a/src/ui/invites.rs b/src/ui/invites.rs new file mode 100644 index 0000000..f57f0fe --- /dev/null +++ b/src/ui/invites.rs @@ -0,0 +1,73 @@ +use super::{ + AppState, Clear, Constraint, Frame, Layout, List, ListItem, ListState, Paragraph, Rect, Style, + Wrap, centered, pane_block, +}; + +pub(super) fn render(frame: &mut Frame<'_>, area: Rect, app: &mut AppState) { + let popup = centered( + area, + area.width.saturating_sub(2).min(76), + area.height.saturating_sub(3).min(16), + ); + frame.render_widget(Clear, popup); + let block = pane_block(" Invitation ".to_owned(), true); + let inner = block.inner(popup); + frame.render_widget(block, popup); + let rows = Layout::vertical([ + Constraint::Min(2), + Constraint::Length(2), + Constraint::Length(3), + ]) + .split(inner); + let preview = app.invites.preview.as_ref().map_or_else( + || "Loading invitation…".to_owned(), + |p| { + format!( + "{}{}\n{}{}", + p.title, + p.participants + .map_or_else(String::new, |n| format!(" · {n} members")), + p.about, + p.warning + .as_ref() + .map_or_else(String::new, |w| format!("\n{w}")) + ) + }, + ); + frame.render_widget(Paragraph::new(preview).wrap(Wrap { trim: false }), rows[0]); + let mut choices = vec![ListItem::new("Cancel / close")]; + if let Some(action) = app.invite_action() { + choices.push(ListItem::new(action)); + } + for index in 0..choices.len().min(rows[1].height as usize) { + app.invites.hit_regions.push(( + rows[1].x, + rows[1].right(), + rows[1].y + u16::try_from(index).unwrap_or(0), + index, + )); + } + frame.render_stateful_widget( + List::new(choices) + .highlight_symbol("› ") + .highlight_style(Style::default().reversed()), + rows[1], + &mut ListState::default().with_selected(Some(app.invites.selected)), + ); + let notice = app + .invites + .error + .as_deref() + .or_else(|| { + app.invites + .preview + .as_ref() + .and_then(|p| p.blocked.as_deref()) + }) + .unwrap_or(if app.invite_pending() { + "Waiting for Telegram…" + } else { + "Select an action, then confirm." + }); + frame.render_widget(Paragraph::new(notice).wrap(Wrap { trim: true }), rows[2]); +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 01bddaa..e19c8d9 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,3 +1,22 @@ +mod appearance; +mod chat_info; +mod chats; +mod commands; +mod deletion; +mod editing; +mod entities; +mod forwarding; +mod icons; +mod invites; +mod pins; +mod polls; +mod preview; +mod reactions; +mod search; +mod staging; +mod statusline; +mod transcript; +mod wrapping; use chrono::Local; use qrcode::{Color as QrColor, QrCode}; use ratatui::Frame; @@ -5,25 +24,23 @@ use ratatui::layout::{Alignment, Constraint, Direction, Layout, Position, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span, Text}; use ratatui::widgets::{ - Block, BorderType, Borders, Clear, HighlightSpacing, List, ListItem, ListState, Paragraph, Wrap, + Block, BorderType, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap, }; use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; +use wrapping::wrap_cells; -use crate::app::{ - AppState, AttachmentState, AuthPhase, Focus, MessageAction, Mode, QrRenderMode, Screen, -}; +use crate::app::{AppState, AuthPhase, Focus, Mode, QrRenderMode, Screen}; use crate::config::DownloadBehavior; -use crate::event::ConnectionStatus; use crate::input::TextInput; -use crate::model::{AttachmentKind, Delivery, Message, MessageButtonKind}; +use crate::keymap::Context; +use crate::model::{AttachmentKind, Message}; const ACCENT: Color = Color::Rgb(216, 180, 254); const MUTED: Color = Color::DarkGray; const SUCCESS: Color = Color::Rgb(126, 211, 166); const WARNING: Color = Color::Rgb(245, 194, 107); const DANGER: Color = Color::Rgb(242, 139, 130); -const MESSAGE_ID_COLUMN_WIDTH: usize = 12; // Terminal QR renderers such as qr-cli use a compact two-module border. A // quiet zone is still required for reliable finder-pattern detection, but a // full four-module print margin makes short-lived login tokens unnecessarily @@ -31,6 +48,10 @@ const MESSAGE_ID_COLUMN_WIDTH: usize = 12; const QR_QUIET_ZONE: usize = 2; pub fn render(frame: &mut Frame<'_>, app: &mut AppState) { + app.set_visible_polls(Vec::new()); + app.set_visible_reactions(Vec::new()); + app.reactions.hit_rows.clear(); + app.polls.hit_rows.clear(); let area = frame.area(); app.clear_message_hit_regions(); if area.width < 40 || area.height < 10 { @@ -51,6 +72,27 @@ pub fn render(frame: &mut Frame<'_>, app: &mut AppState) { } } +fn account_controls(app: &AppState, context: Context) -> String { + let hint = |action| app.keymap.hint(context, action); + format!( + "{} quit · {} next · {} add account", + hint("quit"), + hint("next_account"), + hint("add_account") + ) +} + +fn overlay_controls(app: &AppState, action: &str) -> String { + let hint = |action| app.keymap.hint(Context::Overlay, action); + format!( + "{} {action} · {} close\n{}/{} select", + hint("open"), + hint("cancel"), + hint("up"), + hint("down") + ) +} + fn render_connecting(frame: &mut Frame<'_>, area: Rect, app: &AppState) { let spinner = spinner(app.tick); let mut lines = vec![ @@ -61,7 +103,7 @@ fn render_connecting(frame: &mut Frame<'_>, area: Rect, app: &AppState) { Line::from(""), Line::from(format!("{spinner} Connecting to Telegram…")), Line::from(Span::styled( - "F2 next account · F3 add account · Ctrl+C quits", + account_controls(app, Context::Global), Style::default().fg(MUTED), )), ]; @@ -74,6 +116,19 @@ fn render_connecting(frame: &mut Frame<'_>, area: Rect, app: &AppState) { render_centered_notice(frame, area, lines); } +fn auth_controls(app: &AppState, phase: &AuthPhase) -> String { + let hint = |action| app.keymap.hint(Context::Input, action); + format!( + "{} · {}", + if matches!(phase, AuthPhase::Phone) { + format!("{} QR", hint("focus")) + } else { + format!("{} starts over", hint("cancel")) + }, + account_controls(app, Context::Input) + ) +} + fn render_auth(frame: &mut Frame<'_>, area: Rect, app: &AppState, phase: &AuthPhase) { if let AuthPhase::Qr { url } = phase { render_qr_auth(frame, area, app, url); @@ -81,12 +136,9 @@ fn render_auth(frame: &mut Frame<'_>, area: Rect, app: &AppState, phase: &AuthPh } let width = area.width.min(72); let text_width = width.saturating_sub(4); - let footer = if matches!(phase, AuthPhase::Phone) { - "Tab QR · F2 next account · F3 add account · Ctrl+C quits" - } else { - "Esc starts over · F2 next account · F3 add account · Ctrl+C quits" - }; - let footer_height = wrapped_height(footer, text_width); + let hint = |action| app.keymap.hint(Context::Input, action); + let footer = auth_controls(app, phase); + let footer_height = wrapped_height(&footer, text_width); let status_height = app .status_message .as_deref() @@ -151,6 +203,7 @@ fn render_auth(frame: &mut Frame<'_>, area: Rect, app: &AppState, phase: &AuthPh .wrap(Wrap { trim: true }), chunks[0], ); + let submit_hint = format!("{} to continue", hint("open")); render_input( frame, chunks[1], @@ -159,7 +212,7 @@ fn render_auth(frame: &mut Frame<'_>, area: Rect, app: &AppState, phase: &AuthPh if app.auth_is_submitting() { "Submitted" } else { - "Enter to continue" + &submit_hint }, !app.auth_is_submitting(), ); @@ -173,15 +226,19 @@ fn render_auth(frame: &mut Frame<'_>, area: Rect, app: &AppState, phase: &AuthPh WARNING, ); } - render_notice(frame, chunks[3], footer, MUTED); + render_notice(frame, chunks[3], &footer, MUTED); } fn render_qr_auth(frame: &mut Frame<'_>, area: Rect, app: &AppState, url: &str) { + let display = app.keymap.hint(Context::Input, "focus"); + let cancel = app.keymap.hint(Context::Input, "cancel"); + let accounts = account_controls(app, Context::Input); let Ok(code) = QrCode::new(url.as_bytes()) else { render_qr_unavailable( frame, area, - "Could not render this QR code. Press Esc to use your phone number.", + app, + &format!("Could not render this QR code. {cancel} for phone sign-in."), ); return; }; @@ -196,19 +253,19 @@ fn render_qr_auth(frame: &mut Frame<'_>, area: Rect, app: &AppState, url: &str) QrRenderMode::Compatible => ("Full-cell", "compact"), }; let message = format!( - "{mode_name} QR needs at least {qr_width} × {required_height} terminal cells. Resize, press Tab for {alternative} mode, or Esc for phone sign-in." + "{mode_name} QR needs at least {qr_width} × {required_height} terminal cells. Resize, {display} for {alternative} mode, or {cancel} for phone sign-in." ); let message = app .status_message .as_deref() .map_or_else(|| message.clone(), |error| format!("{error}\n\n{message}")); - render_qr_unavailable(frame, area, &message); + render_qr_unavailable(frame, area, app, &message); return; } let (guidance, style) = if let Some(message) = &app.status_message { ( - format!("{message} · Tab display · F2 next · F3 add · Esc phone"), + format!("{message} · {display} display · {cancel} phone · {accounts}"), Style::default().fg(DANGER), ) } else { @@ -218,7 +275,7 @@ fn render_qr_auth(frame: &mut Frame<'_>, area: Rect, app: &AppState, url: &str) }; ( format!( - "{} Telegram: Devices→Link Desktop · Tab {alternative} · F2 next · F3 add · Esc phone", + "{} Telegram: Devices→Link Desktop · {display} {alternative} · {cancel} phone · {accounts}", spinner(app.tick), ), Style::default().fg(WARNING).bold(), @@ -230,8 +287,9 @@ fn render_qr_auth(frame: &mut Frame<'_>, area: Rect, app: &AppState, url: &str) render_qr_unavailable( frame, area, + app, &format!( - "{guidance}\n\nResize the terminal to show the QR code, or Esc for phone sign-in." + "{guidance}\n\nResize the terminal to show the QR code, or {cancel} for phone sign-in." ), ); return; @@ -260,7 +318,7 @@ fn render_qr_auth(frame: &mut Frame<'_>, area: Rect, app: &AppState, url: &str) ); } -fn render_qr_unavailable(frame: &mut Frame<'_>, area: Rect, message: &str) { +fn render_qr_unavailable(frame: &mut Frame<'_>, area: Rect, app: &AppState, message: &str) { let popup = centered(area, area.width.min(72), area.height); render_centered_notice( frame, @@ -273,7 +331,10 @@ fn render_qr_unavailable(frame: &mut Frame<'_>, area: Rect, message: &str) { Line::from(""), Line::from(message), Line::from(""), - Line::from(Span::styled("Ctrl+C quits", Style::default().fg(MUTED))), + Line::from(Span::styled( + format!("{} quit", app.keymap.hint(Context::Input, "quit")), + Style::default().fg(MUTED), + )), ], ); } @@ -371,169 +432,108 @@ const fn qr_pair_symbol(top: QrColor, bottom: QrColor) -> &'static str { } fn render_main(frame: &mut Frame<'_>, area: Rect, app: &mut AppState) { - let narrow = area.width < 96; - let show_conversation_only = narrow && app.narrow_conversation && app.active_chat_id.is_some(); - let composer_height = composer_height(app, area.width); - let footer_height = app.status_message.as_deref().map_or(1, |message| { + let narrow = area.width < crate::sidebar::MIN_SPLIT_WIDTH; + let conversation_only = + app.active_chat_id.is_some() && (app.sidebar_hidden || (narrow && app.narrow_conversation)); + let sidebar_width = app.keymap.sidebar.width_for(area.width); + let editor_width = if narrow || conversation_only { + area.width + } else { + area.width.saturating_sub(sidebar_width) + }; + let composer_height = composer_height(app, editor_width); + let notice_height = app.status_message.as_deref().map_or(0, |message| { wrapped_height(message, area.width) .min(area.height.saturating_sub(composer_height + 6).max(1)) }); let rows = Layout::vertical([ - Constraint::Length(1), Constraint::Min(5), - Constraint::Length(composer_height), - Constraint::Length(footer_height), + Constraint::Length(notice_height), + Constraint::Length(u16::from(app.keymap.statusline.enabled)), ]) .split(area); - render_header(frame, rows[0], app); - if show_conversation_only { - render_conversation(frame, rows[1], app); - } else if narrow { - render_chats(frame, rows[1], app); + let conversation_area = if !narrow && !conversation_only { + let panes = Layout::horizontal([Constraint::Length(sidebar_width), Constraint::Min(48)]) + .split(rows[0]); + chats::render(frame, panes[0], app); + panes[1] } else { - let panes = Layout::horizontal([Constraint::Percentage(32), Constraint::Percentage(68)]) - .split(rows[1]); - render_chats(frame, panes[0], app); - render_conversation(frame, panes[1], app); + rows[0] + }; + let content = Layout::vertical([Constraint::Min(3), Constraint::Length(composer_height)]) + .split(conversation_area); + if narrow && !conversation_only { + chats::render(frame, content[0], app); + } else { + render_conversation(frame, content[0], app); } - render_composer(frame, rows[2], app, show_conversation_only || !narrow); - render_footer(frame, rows[3], app, narrow); - - if app.mode == Mode::Help { - render_help(frame, area); + render_composer(frame, content[1], app, conversation_only || !narrow); + if let Some(message) = &app.status_message { + render_notice(frame, rows[1], message, WARNING); + } + statusline::render(frame, rows[2], app); + + if app.mode == Mode::ChatInfo { + chat_info::render(frame, area, app); + } else if app.mode == Mode::Invite { + invites::render(frame, area, app); + } else if app.mode == Mode::ForwardPrompt { + forwarding::render(frame, area, app); + } else if app.mode == Mode::DeletePrompt { + deletion::render(frame, area, app); + } else if app.mode == Mode::Reactions { + reactions::render(frame, area, app); + } else if app.mode == Mode::Poll { + polls::render(frame, area, app); + } else if app.mode == Mode::Edit { + editing::render(frame, area, app); + } else if app.mode == Mode::Command { + commands::render(frame, area, app); + } else if app.mode == Mode::Status { + commands::render_status(frame, area, app); + } else if app.mode == Mode::Attachments { + staging::render(frame, area, app); + } else if app.mode == Mode::Preview { + preview::render(frame, area, app); + } else if app.mode == Mode::Colors { + appearance::render_colors(frame, area, app); + } else if app.mode == Mode::PinnedMessages { + pins::render(frame, area, app); + } else if app.mode == Mode::PinPrompt { + pins::render_prompt(frame, area, app); + } else if app.mode == Mode::Search { + search::render_search(frame, area, app); + } else if app.mode == Mode::Help { + render_help(frame, area, app); } else if app.mode == Mode::Settings { render_settings(frame, area, app); } else if app.mode == Mode::Accounts { render_accounts(frame, area, app); } - if matches!(app.mode, Mode::Help | Mode::Settings | Mode::Accounts) { + if matches!( + app.mode, + Mode::ChatInfo + | Mode::Invite + | Mode::Search + | Mode::Poll + | Mode::Reactions + | Mode::Edit + | Mode::DeletePrompt + | Mode::ForwardPrompt + | Mode::Command + | Mode::Status + | Mode::Colors + | Mode::Help + | Mode::Settings + | Mode::Accounts + | Mode::PinnedMessages + | Mode::PinPrompt + ) { app.media_slots.clear(); } } -fn render_header(frame: &mut Frame<'_>, area: Rect, app: &AppState) { - let (label, color) = match app.connection { - ConnectionStatus::Connecting => (format!("{} connecting", spinner(app.tick)), WARNING), - ConnectionStatus::Online => ("● online".to_owned(), SUCCESS), - ConnectionStatus::Reconnecting => (format!("{} reconnecting", spinner(app.tick)), WARNING), - ConnectionStatus::Offline => ("● offline".to_owned(), DANGER), - }; - let user = app.user_name.as_deref().unwrap_or("Telegram"); - let right_width = clamp_u16(UnicodeWidthStr::width(label.as_str())); - let chunks = Layout::horizontal([ - Constraint::Min(1), - Constraint::Length(right_width.saturating_add(1)), - ]) - .split(area); - frame.render_widget( - Paragraph::new(Line::from(vec![ - Span::styled(" Termgram", Style::default().fg(ACCENT).bold()), - Span::styled( - format!(" · Account {}", app.active_account()), - Style::default().fg(MUTED), - ), - Span::styled(format!(" {user}"), Style::default().fg(MUTED)), - ])), - chunks[0], - ); - frame.render_widget( - Paragraph::new(label) - .alignment(Alignment::Right) - .style(Style::default().fg(color)), - chunks[1], - ); -} - -fn render_chats(frame: &mut Frame<'_>, area: Rect, app: &mut AppState) { - app.set_chat_pane_region((area.x, area.right(), area.y, area.bottom())); - let focused = app.focus == Focus::Chats && app.mode != Mode::Compose; - let title = match app.mode { - Mode::Filter => format!(" Chats · /{} ", app.filter.value()), - _ => " Chats ".to_owned(), - }; - let block = pane_block(title, focused); - let visible = app.filtered_chat_indices(); - let viewport_height = usize::from(area.height.saturating_sub(2)).max(1); - let selected = app.selected_chat.min(visible.len().saturating_sub(1)); - let viewport_start = selected.saturating_add(1).saturating_sub(viewport_height); - let viewport_end = viewport_start - .saturating_add(viewport_height) - .min(visible.len()); - let now = Local::now(); - let items = visible[viewport_start..viewport_end] - .iter() - .enumerate() - .map(|(offset, &index)| { - let chat = &app.chats[index]; - let selected = viewport_start.saturating_add(offset) == app.selected_chat; - let unread = if chat.unread > 0 { - format!(" {}", chat.unread) - } else { - String::new() - }; - let time = chat.activity_label(now); - let width = usize::from(area.width.saturating_sub(4)); - let suffix_width = - UnicodeWidthStr::width(time.as_str()) + UnicodeWidthStr::width(unread.as_str()); - let title = truncate_cells( - &chat.title, - width.saturating_sub(suffix_width).saturating_sub(1), - ); - let gap = width - .saturating_sub(UnicodeWidthStr::width(title.as_str())) - .saturating_sub(suffix_width) - .max(1); - let style = if selected { - Style::default().fg(Color::Black).bg(ACCENT).bold() - } else if chat.unread > 0 { - Style::default().bold() - } else { - Style::default() - }; - ListItem::new(Line::from(vec![ - Span::styled(title, style), - Span::styled(" ".repeat(gap), style), - Span::styled(time, style), - Span::styled(unread, style), - ])) - }); - let list = if visible.is_empty() { - List::new(vec![ListItem::new(Span::styled( - if app.mode == Mode::Filter { - " No chats match" - } else { - " No conversations" - }, - Style::default().fg(MUTED), - ))]) - } else { - List::new(items.collect::>()) - }; - let mut state = ListState::default() - .with_selected((!visible.is_empty()).then_some(selected.saturating_sub(viewport_start))); - let hit_regions = (viewport_start..viewport_end) - .enumerate() - .map(|(offset, position)| { - ( - area.x.saturating_add(1), - area.right().saturating_sub(1), - area.y.saturating_add(1).saturating_add(clamp_u16(offset)), - position, - ) - }) - .collect(); - app.set_chat_hit_regions(hit_regions); - frame.render_stateful_widget( - list.block(block) - .highlight_symbol("› ") - .highlight_spacing(HighlightSpacing::Always) - .highlight_style(Style::default().fg(Color::Black).bg(ACCENT).bold()), - area, - &mut state, - ); -} - #[allow(clippy::too_many_lines)] fn render_conversation(frame: &mut Frame<'_>, area: Rect, app: &mut AppState) { app.set_conversation_pane_region((area.x, area.right(), area.y, area.bottom())); @@ -544,19 +544,45 @@ fn render_conversation(frame: &mut Frame<'_>, area: Rect, app: &mut AppState) { let block = pane_block( title, app.focus == Focus::Conversation || app.mode == Mode::Compose, + ) + .title_style( + Style::default().fg(app.active_chat_id.map_or(Color::Reset, |id| { + app.color(crate::appearance::Target::Chat(id)) + })), ); - let inner = block.inner(area); + let mut inner = block.inner(area); frame.render_widget(block, area); let Some(chat_id) = app.active_chat_id else { app.set_message_hit_regions(Vec::new()); frame.render_widget( - Paragraph::new("Select a chat and press Enter") - .alignment(Alignment::Center) - .style(Style::default().fg(MUTED)), + Paragraph::new(format!( + "Select a chat · {} open", + app.keymap.hint(Context::Chats, "open") + )) + .alignment(Alignment::Center) + .style(Style::default().fg(MUTED)), vertically_centered(inner, 1), ); return; }; + if let Some((message, count)) = app.pinned_summary() + && inner.height > 1 + { + let label = format!( + "{} {} pins{} · {}", + icons::Icons(app.keymap.nerd_font).pin(), + app.keymap.hint(Context::Conversation, "pins"), + count.map_or(String::new(), |n| format!(" ({n})")), + crate::model::sanitize_terminal_line(&message.preview_text()), + ); + frame.render_widget( + Paragraph::new(truncate_cells(&label, usize::from(inner.width))) + .style(Style::default().fg(ACCENT)), + Rect { height: 1, ..inner }, + ); + inner.y = inner.y.saturating_add(1); + inner.height = inner.height.saturating_sub(1); + } let messages: &[Message] = app.messages.get(&chat_id).map_or(&[], Vec::as_slice); if messages.is_empty() { app.set_message_hit_regions(Vec::new()); @@ -566,7 +592,7 @@ fn render_conversation(frame: &mut Frame<'_>, area: Rect, app: &mut AppState) { let text = if app.loading_history { format!("{} Loading messages…", spinner(app.tick)) } else { - "No messages yet — press i to write one".to_owned() + "No messages yet".to_owned() }; frame.render_widget( Paragraph::new(text) @@ -577,31 +603,35 @@ fn render_conversation(frame: &mut Frame<'_>, area: Rect, app: &mut AppState) { return; } - let message_width = usize::from(inner.width.saturating_sub(2).max(1)); + let reflow = app.viewport_width != inner.width; + app.viewport_width = inner.width; + let message_width = usize::from(inner.width.max(1)); let mut lines = Vec::new(); let mut layouts = Vec::with_capacity(messages.len()); let mut media_rows = Vec::new(); - for message in messages { + for (index, message) in messages.iter().enumerate() { let start = lines.len(); - let actions = app.message_actions(message); - let rendered = message_lines( - message, - message_width, - app.attachment_state(chat_id, message.id), - app.settings().download_behavior, - app.selected_message == Some(message.id), - app.selected_action, - app.settings().show_message_ids, - &actions, - ); + if app.unread_separator() == Some(message.id) { + lines.push(Line::styled( + " ── Unread messages ──", + Style::default().fg(ACCENT), + )); + } + let content_start = lines.len(); + let rendered = transcript::render(message, message_width, app); let body_action = rendered.body_action; let body_height = rendered.body_height; - let mut hit_rows: Vec<_> = rendered - .action_rows - .into_iter() - .map(|(row, action)| (start.saturating_add(row), Some(action))) - .chain((0..body_height).map(move |row| (start.saturating_add(row), body_action))) + let mut hit_rows: Vec<_> = (0..body_height) + .map(|row| (content_start.saturating_add(row), None)) .collect(); + // Pointer lookup walks backward: specific action rows must win over + // the enclosing message (a reply quote may accompany an attachment). + hit_rows.extend( + rendered + .action_rows + .into_iter() + .map(|(row, action)| (content_start.saturating_add(row), Some(action))), + ); lines.extend(rendered.lines); if message.id > 0 && let Some(attachment) = message.attachment.as_ref().filter(|a| a.supports_preview()) @@ -614,23 +644,34 @@ fn render_conversation(frame: &mut Frame<'_>, area: Rect, app: &mut AppState) { } else { 10 }); - let width = - inner - .width - .saturating_sub(21) - .min(if attachment.kind == AttachmentKind::Sticker { - 24 - } else { - 48 - }); + let width = inner.width.saturating_sub(transcript::GUTTER_WIDTH).min( + if attachment.kind == AttachmentKind::Sticker { + 24 + } else { + 48 + }, + ); if height > 0 && width > 0 { media_rows.push((message.id, lines.len(), width, height)); hit_rows.extend( (lines.len()..lines.len() + usize::from(height)).map(|row| (row, body_action)), ); - lines.extend((0..height).map(|_| Line::from(""))); + lines.extend((0..height).map(|_| { + Line::from(transcript::gutter(app.selected_message == Some(message.id))) + })); } } + let background = app + .keymap + .messages + .background(index % 2 == 1, app.terminal_background); + for line in &mut lines[start..] { + line.style = line.style.bg(background); + line.spans.push(Span::raw( + " ".repeat(message_width.saturating_sub(line.width())), + )); + } + lines.extend((0..app.keymap.messages.spacing).map(|_| Line::default())); layouts.push(MessageLayout { id: message.id, start, @@ -656,14 +697,17 @@ fn render_conversation(frame: &mut Frame<'_>, area: Rect, app: &mut AppState) { .min(max_scroll); let mut scroll = max_scroll.saturating_sub(app.message_scroll); if let Some(anchor_id) = app.viewport_anchor_message { - if let Some(anchor_start) = layouts - .iter() - .find(|layout| layout.id == anchor_id) - .map(|layout| layout.start) - { - scroll = anchor_start - .saturating_add(app.viewport_anchor_row) - .min(max_scroll); + if let Some(layout) = layouts.iter().find(|layout| layout.id == anchor_id) { + let row = if app.viewport_anchor_row >= layout.height + || (reflow && app.viewport_anchor_row == layout.height.saturating_sub(1)) + { + // Reflow may remove the old physical row. Keep this message + // visible from its header instead of landing on its separator. + 0 + } else { + app.viewport_anchor_row + }; + scroll = layout.start.saturating_add(row).min(max_scroll); app.message_scroll = max_scroll.saturating_sub(scroll); } else { app.viewport_anchor_message = None; @@ -701,7 +745,7 @@ fn render_conversation(frame: &mut Frame<'_>, area: Rect, app: &mut AppState) { -i16::try_from(scroll - row).unwrap_or(i16::MAX) }; let viewport = Rect::new( - inner.x.saturating_add(21), + inner.x.saturating_add(transcript::GUTTER_WIDTH), inner.y, width, inner @@ -709,22 +753,24 @@ fn render_conversation(frame: &mut Frame<'_>, area: Rect, app: &mut AppState) { .saturating_sub(u16::from(app.new_messages_while_scrolled > 0)), ); app.media_slots.push(crate::media::MediaSlot { - chat_id, - message_id, + source: crate::media::MediaSource::Message { + chat_id, + message_id, + }, viewport, offset, size: ratatui::layout::Size::new(width, height), }); - let status = app + let pending = app .media_previews .get(&(chat_id, message_id)) - .map_or("Loading preview…", |p| p.status.as_str()); - if !status.is_empty() { + .is_none_or(|preview| preview.path.is_none()); + if pending { let y = inner .y .saturating_add(clamp_u16(row.saturating_sub(scroll))); frame.render_widget( - Paragraph::new(status).style(Style::default().fg(MUTED)), + Paragraph::new("▧").style(Style::default().fg(MUTED)), Rect::new(viewport.x, y, width, 1), ); } @@ -744,6 +790,68 @@ fn render_conversation(frame: &mut Frame<'_>, area: Rect, app: &mut AppState) { }) }) .collect(); + // The new-message badge covers the last physical row. It cannot count as + // displaying the end of a message underneath it. + let read_bottom = scroll + .saturating_add(available.saturating_sub(usize::from(app.new_messages_while_scrolled > 0))); + let visible = layouts + .iter() + .filter(|layout| { + layout.start < read_bottom + && layout.start.saturating_add(layout.height) > scroll + && layout.start.saturating_add(layout.height) <= read_bottom + }) + .filter_map(|layout| { + messages + .iter() + .find(|message| message.id == layout.id && message.id > 0 && !message.outgoing) + }) + .collect::>(); + let visible_polls = layouts + .iter() + .filter(|layout| { + layout.start < read_bottom && layout.start.saturating_add(layout.height) > scroll + }) + .filter_map(|layout| { + messages + .iter() + .find(|message| message.id == layout.id && message.poll.is_some() && message.id > 0) + }) + .map(|message| (message.chat_id, message.id)) + .take(32) + .collect(); + let visible_reactions = layouts + .iter() + .filter(|layout| { + layout.start < read_bottom && layout.start.saturating_add(layout.height) > scroll + }) + .filter_map(|layout| { + messages.iter().find(|message| { + message.id == layout.id + && message.id > 0 + && !message.outgoing + && message.reactions.is_some() + }) + }) + .map(|message| (message.chat_id, message.id)) + .take(100) + .collect(); + let visible_read = visible.iter().map(|message| message.id).max(); + let mentions = visible + .iter() + .filter(|message| { + message + .mention + .as_ref() + .is_some_and(|mention| mention.unread && !mention.requires_playback) + }) + .map(|message| message.id) + .take(100) + .collect(); + app.set_visible_polls(visible_polls); + app.set_visible_reactions(visible_reactions); + app.set_visible_mentions(mentions); + app.set_visible_read_boundary(chat_id, visible_read.or((available > 0).then_some(0))); app.set_message_hit_regions(hit_regions); render_new_message_badge(frame, inner, app.new_messages_while_scrolled); } @@ -773,25 +881,19 @@ fn render_new_message_badge(frame: &mut Frame<'_>, area: Rect, count: usize) { ); } -fn render_composer(frame: &mut Frame<'_>, area: Rect, app: &AppState, enabled: bool) { +fn render_composer(frame: &mut Frame<'_>, area: Rect, app: &mut AppState, enabled: bool) { + app.set_composer_region((area.x, area.right(), area.y, area.bottom())); let active = app.mode == Mode::Compose; - let title = if !enabled || app.active_chat_id.is_none() { - " Message · select a chat ".to_owned() - } else if let Some(reply) = app.active_reply_target() { - let sender = reply.sender.as_deref().unwrap_or("unknown"); - if active { - format!( - " Reply to #{} {sender} · Enter send · Esc cancel ", - reply.message_id - ) - } else { - format!(" Reply draft to #{} {sender} · i resume ", reply.message_id) - } - } else if active { - " Message · Enter send · Esc keep draft ".to_owned() - } else { - " Message · i to compose ".to_owned() - }; + let title = app.active_reply_target().map_or_else(String::new, |reply| { + format!( + " Reply to #{} {} ", + reply.message_id, + app.reply_message(reply) + .map(|message| message.sender.as_str()) + .or(reply.sender.as_deref()) + .unwrap_or("Original message") + ) + }); let block = pane_block(title, active); let inner = block.inner(area); frame.render_widget(block, area); @@ -803,13 +905,39 @@ fn render_composer(frame: &mut Frame<'_>, area: Rect, app: &AppState, enabled: b ); return; } + let inner = staging::composer_summary(frame, inner, app); let empty = TextInput::new(); let input = app.active_draft().unwrap_or(&empty); let (row, column) = input_cursor(input, inner.width.max(1)); let vertical_scroll = row.saturating_sub(inner.height.saturating_sub(1)); - let placeholder = input.is_empty() && !active; + let placeholder = input.is_empty(); let text = if placeholder { - Text::from("Write a message…") + use crate::keymap::Context; + let hint = if let Some(reason) = app.active_chat_id.and_then(|id| app.draft_restriction(id)) + { + reason + } else if app.keymap.ghost_text.is_empty() { + String::new() + } else if active { + app.keymap + .ghost_text + .replace("{send}", &app.keymap.hint(Context::Compose, "send")) + .replace("{newline}", &app.keymap.hint(Context::Compose, "newline")) + .replace("{cancel}", &app.keymap.hint(Context::Compose, "cancel")) + } else if app.focus == Focus::Chats { + format!("{} to compose", app.keymap.hint(Context::Chats, "compose")) + } else if app.selected_message.is_some() { + format!( + "{} to reply", + app.keymap.hint(Context::Conversation, "compose") + ) + } else { + format!( + "{} to compose", + app.keymap.hint(Context::Conversation, "compose") + ) + }; + Text::from(crate::model::sanitize_terminal_line(&hint)) } else { Text::from( editor_lines(input.value(), inner.width.max(1)) @@ -842,81 +970,33 @@ fn render_composer(frame: &mut Frame<'_>, area: Rect, app: &AppState, enabled: b } } -fn render_footer(frame: &mut Frame<'_>, area: Rect, app: &AppState, narrow: bool) { - if let Some(message) = &app.status_message { - render_notice(frame, area, message, WARNING); - return; - } - let content = if let Some(version) = app.available_update() { - Line::from(Span::styled( - format!(" Update {version} available · run tg update"), - Style::default().fg(SUCCESS), - )) - } else if app.mode == Mode::Compose { - let prefix = app.active_reply_target().map_or_else(String::new, |reply| { - format!(" Reply #{} · ", reply.message_id) - }); - Line::from(format!( - "{prefix} Enter send · drop files to attach · Ctrl+J newline · Esc" - )) - } else if app.mode == Mode::Settings { - Line::from(" ↑↓ select · Enter toggle · Esc close settings") - } else if app.mode == Mode::Accounts { - Line::from(" ↑↓ select · Enter switch/add · Esc close accounts") - } else if narrow && app.narrow_conversation { - Line::from(" [/ ] select · R reply · o/O action · r target · Esc chats") - } else { - Line::from(" ↑↓/jk move · [/] select · R reply · o/O action · r target · ? help · q quit") - }; - frame.render_widget( - Paragraph::new(content).style(Style::default().fg(MUTED)), - area, - ); -} - -fn render_help(frame: &mut Frame<'_>, area: Rect) { +fn render_help(frame: &mut Frame<'_>, area: Rect, app: &mut AppState) { let popup = centered(area, area.width.min(72), area.height.min(27)); frame.render_widget(Clear, popup); let block = Block::new() .borders(Borders::ALL) .border_type(BorderType::Rounded) .border_style(Style::default().fg(ACCENT)) - .title(" Keyboard shortcuts "); - let lines = vec![ - Line::from(vec![ - Span::styled("Navigation", Style::default().bold()), - Span::raw(" ↑↓ or j/k · Enter opens"), - ]), - Line::from("Tab switch chats / conversation"), - Line::from("PgUp/PgDn/Home/End scroll / oldest / latest"), - Line::from("o/O + Enter select and activate message actions"), - Line::from("[ / ] select older / newer message (latest first)"), - Line::from("R compose a reply to selected/latest message"), - Line::from("r jump to a selected reply's target"), - Line::from("Right click reply to a message under the pointer"), - Line::from("l follow selected/first link in a message"), - Line::from("/ filter chats (from chat list)"), - Line::from("s / a settings / accounts"), - Line::from("F2 / F3 next / add account"), - Line::from(""), - Line::from(vec![ - Span::styled("Writing", Style::default().bold()), - Span::raw(" i or Enter starts"), - ]), - Line::from("Enter / Ctrl+J send / insert a new line"), - Line::from("Ctrl+A/E/W/U start / end / delete word / clear"), - Line::from("Esc cancel reply, then keep draft and leave"), - Line::from("Drop / paste send existing local file paths"), - Line::from(""), - Line::from("? / Esc close help"), - Line::from("q / Ctrl+C quit"), - ]; + .title(format!( + " Shortcuts · {} close · {}/{} scroll ", + app.keymap.hint(Context::Overlay, "cancel"), + app.keymap.hint(Context::Overlay, "up"), + app.keymap.hint(Context::Overlay, "down") + )); + let inner = block.inner(popup); + let lines = app + .help_lines() + .iter() + .flat_map(|line| wrap_cells(line, usize::from(inner.width.max(1)))) + .map(Line::from) + .collect::>(); + app.help_scroll = app + .help_scroll + .min(lines.len().saturating_sub(usize::from(inner.height))); + frame.render_widget(block, popup); frame.render_widget( - Paragraph::new(lines) - .block(block) - .wrap(Wrap { trim: false }) - .style(Style::default().fg(Color::White)), - popup, + Paragraph::new(lines).scroll((clamp_u16(app.help_scroll), 0)), + inner, ); } @@ -944,7 +1024,7 @@ fn render_settings(frame: &mut Frame<'_>, area: Rect, app: &mut AppState) { ("Release channel", settings.release_channel.label()), ("Downloads", settings.download_behavior.label()), ( - "Message ID column", + "Message IDs", if settings.show_message_ids { "Shown" } else { @@ -984,11 +1064,9 @@ fn render_settings(frame: &mut Frame<'_>, area: Rect, app: &mut AppState) { if index == 2 { lines.push(Line::from(Span::styled( match settings.download_behavior { - DownloadBehavior::TempOnly => { - " Temp download only; Termgram never reveals files." - } + DownloadBehavior::CacheOnly => " Keep downloads for reuse.", DownloadBehavior::RevealOnActivation => { - " Second activation reveals; Termgram never executes files." + " Activate a downloaded file to reveal it." } }, Style::default().fg(MUTED), @@ -999,7 +1077,7 @@ fn render_settings(frame: &mut Frame<'_>, area: Rect, app: &mut AppState) { lines.extend([ Line::from(""), Line::from(Span::styled( - "↑↓/j/k select · Enter/Space toggle · Esc close", + overlay_controls(app, "toggle"), Style::default().fg(MUTED), )), ]); @@ -1065,7 +1143,7 @@ fn render_accounts(frame: &mut Frame<'_>, area: Rect, app: &mut AppState) { lines.extend([ Line::from(""), Line::from(Span::styled( - "↑↓/j/k select · Enter switch · 1-8 direct · Esc close", + overlay_controls(app, "switch"), Style::default().fg(MUTED), )), ]); @@ -1097,8 +1175,9 @@ fn render_fatal(frame: &mut Frame<'_>, area: Rect, app: &AppState, message: &str Line::from(""), Line::from(Span::styled( format!( - "Account {} · F2 next · F3 add · q/Ctrl+C quit", - app.active_account() + "Account {} · {}", + app.active_account(), + account_controls(app, Context::Chats) ), Style::default().fg(MUTED), )), @@ -1195,293 +1274,6 @@ fn render_input( } } -struct RenderedMessage { - lines: Vec>, - body_height: usize, - body_action: Option, - action_rows: Vec<(usize, usize)>, -} - -#[allow(clippy::too_many_arguments)] -fn message_lines( - message: &Message, - width: usize, - attachment_state: AttachmentState, - download_behavior: DownloadBehavior, - selected: bool, - selected_action: usize, - show_message_ids: bool, - actions: &[MessageAction], -) -> RenderedMessage { - let time = message - .timestamp - .with_timezone(&Local) - .format("%H:%M") - .to_string(); - let sender = pad_cells(&truncate_cells(&message.sender, 12), 12); - let prefix = format!("{time} {sender} │ "); - let prefix_width = UnicodeWidthStr::width(prefix.as_str()); - let delivery_width = if message.outgoing { 3 } else { 0 }; - let id_reserve = usize::from(show_message_ids) * MESSAGE_ID_COLUMN_WIDTH; - let body_width = width - .saturating_sub(prefix_width) - .saturating_sub(delivery_width) - .saturating_sub(id_reserve) - .max(8); - let mut body = message_body(message, attachment_state, download_behavior); - if let Some(reply) = &message.reply_to { - let sender = reply.sender.as_deref().unwrap_or("unknown"); - body = format!("↩ #{} {} {body}", reply.message_id, sender); - } - let wrapped = wrap_cells(&body, body_width); - let mut result = Vec::new(); - for (index, part) in wrapped.into_iter().enumerate() { - let line_prefix = if index == 0 { - prefix.clone() - } else { - format!("{}│ ", " ".repeat(prefix_width.saturating_sub(2))) - }; - let selection = selected.then_some(Color::DarkGray); - let mut body_style = if message.outgoing { - Style::default().fg(Color::Cyan) - } else { - Style::default() - }; - if let Some(background) = selection { - body_style = body_style.bg(background); - } - let mut prefix_style = Style::default().fg(MUTED); - if let Some(background) = selection { - prefix_style = prefix_style.bg(background); - } - let mut spans = vec![ - Span::styled(line_prefix, prefix_style), - Span::styled(part, body_style), - ]; - if index == 0 && message.outgoing { - let (mark, color) = match message.delivery { - Delivery::Pending => (" …", WARNING), - Delivery::Sent => (" ✓", MUTED), - Delivery::Read => (" ✓✓", SUCCESS), - Delivery::Failed => (" !", DANGER), - }; - let mut delivery_style = Style::default().fg(color); - if let Some(background) = selection { - delivery_style = delivery_style.bg(background); - } - spans.push(Span::styled(mark, delivery_style)); - } - let mut line = Line::from(spans); - if show_message_ids && index == 0 { - let label = format!("#{}", message.id); - let gap = width - .saturating_sub(line.width()) - .saturating_sub(UnicodeWidthStr::width(label.as_str())); - line.spans.push(Span::raw(" ".repeat(gap))); - line.spans.push(Span::styled(label, prefix_style)); - } - result.push(line); - } - let body_height = result.len(); - let body_action = actions - .iter() - .position(|action| matches!(action, MessageAction::Attachment | MessageAction::Reply)); - let continuation = format!("{}│ ", " ".repeat(prefix_width.saturating_sub(2))); - let action_rows = append_message_actions( - &mut result, - message, - actions, - selected, - selected_action, - body_width, - &continuation, - ); - RenderedMessage { - lines: result, - body_height, - body_action, - action_rows, - } -} - -#[allow(clippy::too_many_arguments)] -fn append_message_actions( - result: &mut Vec>, - message: &Message, - actions: &[MessageAction], - selected: bool, - selected_action: usize, - body_width: usize, - continuation: &str, -) -> Vec<(usize, usize)> { - let mut action_rows = Vec::new(); - for (link_index, link) in message.links.iter().enumerate() { - let Some(action_index) = actions - .iter() - .position(|action| *action == MessageAction::Link(link_index)) - else { - continue; - }; - let label = if link.label == link.url { - format!("↗ {}", link.url) - } else { - format!("↗ {} → {}", link.label, link.url) - }; - push_action_lines( - result, - &mut action_rows, - continuation, - &label, - body_width, - action_index, - selected && selected_action == action_index, - true, - ); - } - for (button_index, button) in message.buttons.iter().enumerate() { - let action_index = actions - .iter() - .position(|action| *action == MessageAction::Button(button_index)); - let (icon, suffix) = match button.kind { - MessageButtonKind::Url => ("↗", ""), - MessageButtonKind::Callback => ("●", ""), - MessageButtonKind::Game => ("▶", ""), - MessageButtonKind::Unsupported => ("×", " · graphical client required"), - }; - let label = format!("{icon} [ {} ]{suffix}", button.label); - if let Some(action_index) = action_index { - push_action_lines( - result, - &mut action_rows, - continuation, - &label, - body_width, - action_index, - selected && selected_action == action_index, - button.kind == MessageButtonKind::Url, - ); - } else { - result.push(Line::from(vec![ - Span::styled(continuation.to_owned(), Style::default().fg(MUTED)), - Span::styled(label, Style::default().fg(MUTED)), - ])); - } - } - action_rows -} - -#[allow(clippy::too_many_arguments)] -fn push_action_lines( - lines: &mut Vec>, - action_rows: &mut Vec<(usize, usize)>, - prefix: &str, - label: &str, - width: usize, - action_index: usize, - selected: bool, - underlined: bool, -) { - for part in wrap_cells(label, width) { - let row = lines.len(); - let background = selected.then_some(Color::DarkGray); - let mut prefix_style = Style::default().fg(MUTED); - let mut action_style = Style::default().fg(ACCENT); - if underlined { - action_style = action_style.add_modifier(Modifier::UNDERLINED); - } - if let Some(background) = background { - prefix_style = prefix_style.bg(background); - action_style = action_style.bg(background); - } - lines.push(Line::from(vec![ - Span::styled(prefix.to_owned(), prefix_style), - Span::styled(part, action_style), - ])); - action_rows.push((row, action_index)); - } -} - -fn message_body( - message: &Message, - state: AttachmentState, - download_behavior: DownloadBehavior, -) -> String { - let Some(attachment) = &message.attachment else { - return message.text.clone(); - }; - if attachment.kind == AttachmentKind::Sticker { - let fallback = attachment.fallback_emoji.as_deref().unwrap_or("◻"); - let label = if attachment.preview_uses_thumbnail() { - "[sticker · static preview]" - } else { - "[sticker]" - }; - return if message.text.is_empty() { - format!("{fallback} {label}") - } else { - format!("{fallback} {label} {}", message.text) - }; - } - - let kind = match attachment.kind { - AttachmentKind::Photo => "photo", - AttachmentKind::File => "file", - AttachmentKind::Video => "video", - AttachmentKind::Audio => "audio", - AttachmentKind::Sticker => "sticker", - AttachmentKind::Other => "attachment", - }; - let mut label = format!("[{kind}]"); - if let Some(name) = &attachment.file_name { - label.push(' '); - label.push_str(name); - } - if let Some(size) = attachment.size { - label.push_str(" · "); - label.push_str(&human_size(size)); - } - let action = if attachment.supports_preview() { - "inline preview" - } else { - match state { - AttachmentState::Ready => "click/Enter to download", - AttachmentState::Downloading => "downloading…", - AttachmentState::Downloaded => match download_behavior { - DownloadBehavior::TempOnly => "downloaded to temp", - DownloadBehavior::RevealOnActivation => "click/Enter to reveal", - }, - } - }; - label.push_str(" · "); - label.push_str(action); - if message.text.is_empty() { - label - } else { - format!("{label}\n{}", message.text) - } -} - -fn human_size(size: u64) -> String { - const KIB: u64 = 1024; - const MIB: u64 = KIB * 1024; - const GIB: u64 = MIB * 1024; - if size >= GIB { - decimal_size(size, GIB, "GiB") - } else if size >= MIB { - decimal_size(size, MIB, "MiB") - } else if size >= KIB { - decimal_size(size, KIB, "KiB") - } else { - format!("{size} B") - } -} - -fn decimal_size(size: u64, unit: u64, suffix: &str) -> String { - let whole = size / unit; - let decimal = size % unit * 10 / unit; - format!("{whole}.{decimal} {suffix}") -} - fn pane_block<'a>(title: String, focused: bool) -> Block<'a> { Block::new() .borders(Borders::ALL) @@ -1501,6 +1293,9 @@ fn composer_height(app: &AppState, width: u16) -> u16 { u16::try_from(lines.clamp(1, 4)) .unwrap_or(4) .saturating_add(2) + .saturating_add(u16::from( + app.preparing_attachments() || !app.draft_attachments().is_empty(), + )) } fn editor_lines(value: &str, width: u16) -> Vec { @@ -1579,47 +1374,6 @@ fn truncate_cells(value: &str, max: usize) -> String { result } -fn pad_cells(value: &str, width: usize) -> String { - let current = UnicodeWidthStr::width(value); - format!("{value}{}", " ".repeat(width.saturating_sub(current))) -} - -fn wrap_cells(value: &str, max: usize) -> Vec { - let mut lines = Vec::new(); - for source_line in value.split('\n') { - let mut current = String::new(); - let mut current_width = 0_usize; - for word in source_line.split_inclusive(char::is_whitespace) { - let word_width = UnicodeWidthStr::width(word); - if current_width.saturating_add(word_width) > max && !current.is_empty() { - lines.push(current.trim_end().to_owned()); - current.clear(); - current_width = 0; - } - if word_width > max { - for grapheme in word.graphemes(true) { - let grapheme_width = UnicodeWidthStr::width(grapheme); - if current_width.saturating_add(grapheme_width) > max && !current.is_empty() { - lines.push(current); - current = String::new(); - current_width = 0; - } - current.push_str(grapheme); - current_width = current_width.saturating_add(grapheme_width); - } - } else { - current.push_str(word); - current_width = current_width.saturating_add(word_width); - } - } - lines.push(current.trim_end().to_owned()); - } - if lines.is_empty() { - lines.push(String::new()); - } - lines -} - fn spinner(tick: u64) -> &'static str { const FRAMES: [&str; 8] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧"]; let index = usize::try_from(tick % u64::try_from(FRAMES.len()).unwrap_or(1)).unwrap_or(0); @@ -1682,16 +1436,602 @@ mod tests { populated_app_with_settings(Settings::default()) } + #[test] + #[allow(clippy::too_many_lines)] + fn entities_keep_styles_and_require_explicit_spoiler_and_quote_actions() { + use crate::{ + app::MessageAction, + entities::{Entity, Kind}, + event::NetworkEvent, + }; + use ratatui::style::Modifier; + use yazi_term::event::{Modifiers, MouseButton, MouseEvent, MouseEventKind}; + let mut app = populated_app(); + app.sidebar_hidden = true; + app.focus = Focus::Conversation; + app.narrow_conversation = true; + app.selected_message = Some(11); + let mut message = app.active_messages()[0].clone(); + message.text = + "Bold 🙂\n let answer = 42; \nquote-one\nquote-two\nquote-three\nquote-four\nSECRET" + .to_owned(); + let quote = message.text.find("quote-one").unwrap(); + let secret = message.text.find("SECRET").unwrap(); + let code = message.text.find(" let").unwrap(); + message.entities = vec![ + Entity { + range: 0..4, + kind: Kind::Bold, + }, + Entity { + range: 0..4, + kind: Kind::Italic, + }, + Entity { + range: code..quote, + kind: Kind::Pre { + language: "rust".to_owned(), + }, + }, + Entity { + range: quote..secret, + kind: Kind::Quote { collapsed: true }, + }, + Entity { + range: secret..message.text.len(), + kind: Kind::Spoiler, + }, + ]; + message.links = vec![MessageLink { + label: "SECRET".to_owned(), + url: "https://secret.example".to_owned(), + }]; + let encoded = serde_json::to_string(&message).unwrap(); + let restored: Message = serde_json::from_str(&encoded).unwrap(); + assert_eq!(restored.entities, message.entities); + assert!(!restored.preview_text().contains("SECRET")); + let mut old: serde_json::Value = serde_json::from_str(&encoded).unwrap(); + old.as_object_mut().unwrap().remove("entities"); + assert!( + serde_json::from_value::(old) + .unwrap() + .entities + .is_empty() + ); + app.handle_network(NetworkEvent::MessageUpdated(message.clone())); + assert!(!app.chats[0].last_message.contains("SECRET")); + for width in [40, 100] { + let mut terminal = Terminal::new(TestBackend::new(width, 32)).unwrap(); + terminal.draw(|frame| render(frame, &mut app)).unwrap(); + let buffer = terminal.backend().buffer(); + let text: String = buffer + .content() + .iter() + .map(ratatui::buffer::Cell::symbol) + .collect(); + assert!(!text.contains("SECRET") && !text.contains("secret.example")); + assert!(!text.contains("quote-three")); + assert!(text.contains("│ let answer = 42; ")); + let bold_row = (0..32) + .find(|&y| { + (0..width) + .map(|x| buffer[(x, y)].symbol()) + .collect::() + .contains("Bold") + }) + .unwrap(); + let bold = (0..width) + .find(|&x| buffer[(x, bold_row)].symbol() == "B") + .unwrap(); + assert!( + buffer[(bold, bold_row)] + .modifier + .contains(Modifier::BOLD | Modifier::ITALIC) + ); + let row = (0..32) + .find(|&y| (0..width).any(|x| buffer[(x, y)].symbol() == "▨")) + .unwrap(); + app.handle_mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 6, + row, + modifiers: Modifiers::empty(), + }); + assert!(app.spoilers_revealed(app.inspected_message().unwrap())); + assert!(render_text_mut(&mut app, width, 32).contains("SECRET")); + app.handle_action(KeyAction::Enter); + assert!(!app.spoilers_revealed(app.inspected_message().unwrap())); + } + app.selected_action = app + .message_actions(&message) + .iter() + .position(|action| *action == MessageAction::ExpandQuote) + .unwrap(); + app.handle_action(KeyAction::Enter); + assert!(render_text_mut(&mut app, 100, 32).contains("quote-four")); + app.handle_key(&yazi_term::event::KeyEvent::new( + yazi_term::event::KeyCode::Char(':'), + Modifiers::empty(), + )); + for ch in "spoiler".chars() { + app.handle_action(KeyAction::Character(ch)); + } + assert!(app.handle_action(KeyAction::Enter).is_empty()); + assert!(app.spoilers_revealed(app.inspected_message().unwrap())); + // A live edit cannot inherit permission to reveal different content. + message.text.push('!'); + app.handle_network(NetworkEvent::MessageUpdated(message)); + assert!(!app.spoilers_revealed(app.inspected_message().unwrap())); + assert!(!render_text_mut(&mut app, 100, 32).contains("SECRET")); + } + + #[test] + fn unread_separator_keeps_message_hit_rows_on_the_message() { + use crate::event::NetworkEvent; + use yazi_term::event::{Modifiers, MouseButton, MouseEvent, MouseEventKind}; + let mut app = populated_app(); + let messages = app.active_messages().to_vec(); + app.handle_action(KeyAction::Enter); + app.handle_network(NetworkEvent::History { + chat_id: 7, + request_id: 1, + messages, + }); + let text = render_text_mut(&mut app, 80, 24); + assert!(text.contains("Unread messages")); + assert_eq!(app.unread_separator(), Some(11)); + app.sidebar_hidden = true; + let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap(); + terminal.draw(|frame| render(frame, &mut app)).unwrap(); + let separator = (0..24) + .find(|&y| { + (0..80) + .map(|x| terminal.backend().buffer()[(x, y)].symbol()) + .collect::() + .contains("Unread messages") + }) + .unwrap(); + let click = |row| MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 8, + row, + modifiers: Modifiers::empty(), + }; + app.handle_mouse(click(separator)); + assert_eq!(app.selected_message, None); + app.handle_mouse(click(separator + 1)); + assert_eq!(app.selected_message, Some(11)); + } + + #[test] + fn receipts_follow_visible_rows_and_require_an_unobscured_focused_frame() { + use crate::event::{NetworkEvent, TelegramCommand}; + let mut app = populated_app(); + app.sidebar_hidden = true; + app.focus = Focus::Conversation; + let first = app.active_messages()[0].clone(); + let mut later = first.clone(); + later.id = 12; + later.text = "Long incoming message\n".repeat(40); + app.messages.insert(7, vec![first, later]); + app.viewport_anchor_message = Some(11); + assert!(app.request_visible_read().is_empty()); + + app.mode = Mode::Help; + render_text_mut(&mut app, 80, 18); + assert!(app.request_visible_read().is_empty()); + app.mode = Mode::Navigate; + app.terminal_focused = false; + render_text_mut(&mut app, 80, 18); + assert!(app.request_visible_read().is_empty()); + app.terminal_focused = true; + render_text_mut(&mut app, 80, 18); + assert_eq!( + app.request_visible_read(), + vec![TelegramCommand::MarkRead { + chat_id: 7, + max_id: 11, + }] + ); + assert!(app.request_visible_read().is_empty()); + app.handle_network(NetworkEvent::ReadMarked { + chat_id: 7, + max_id: 11, + snapshot: None, + }); + assert!(app.request_visible_read().is_empty()); + + // A smaller terminal replaces the transcript. Old frame evidence dies. + render_text_mut(&mut app, 10, 3); + assert!(app.request_visible_read().is_empty()); + } + + #[test] + fn command_popup_adapts_and_pointer_completion_does_not_execute() { + use yazi_term::event::{ + KeyCode, KeyEvent, Modifiers, MouseButton, MouseEvent, MouseEventKind, + }; + for (width, height) in [(40, 10), (80, 24), (160, 42)] { + let mut app = populated_app(); + app.handle_key(&KeyEvent::new(KeyCode::Char(':'), Modifiers::empty())); + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + terminal.draw(|frame| render(frame, &mut app)).unwrap(); + assert!(!app.commands.hit_regions.is_empty()); + let (x, _, y, _) = app.commands.hit_regions[0]; + assert!( + app.handle_mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: x, + row: y, + modifiers: Modifiers::empty() + }) + .is_empty() + ); + assert_eq!(app.mode, Mode::Command); + assert!(!app.commands.input.is_empty()); + app.commands.input.set_value("界".repeat(100)); + terminal.draw(|frame| render(frame, &mut app)).unwrap(); + app.handle_key(&KeyEvent::new(KeyCode::Escape, Modifiers::empty())); + terminal.draw(|frame| render(frame, &mut app)).unwrap(); + assert!(app.commands.hit_regions.is_empty()); + assert_eq!(app.mode, Mode::Navigate); + } + } + + #[test] + fn compact_messages_keep_full_authors_and_use_theme_relative_backgrounds() { + let mut app = populated_app(); + app.focus = Focus::Conversation; + app.sidebar_hidden = true; + let mut first = app.active_messages()[0].clone(); + first.sender = "Long author name with 中文 and a complete ApellidoFinal".to_owned(); + first.text = "First body".to_owned(); + let mut second = first.clone(); + second.id += 1; + second.sender = "Second author".to_owned(); + second.text = "Second body".to_owned(); + app.messages.insert(7, vec![first, second]); + for background in [[25, 28, 35], [240, 240, 240]] { + app.terminal_background = Some(background); + let mut terminal = Terminal::new(TestBackend::new(40, 20)).unwrap(); + terminal.draw(|frame| render(frame, &mut app)).unwrap(); + let buffer = terminal.backend().buffer(); + let rows: Vec<_> = (0..20) + .map(|y| (0..40).map(|x| buffer[(x, y)].symbol()).collect::()) + .collect(); + assert!(rows.iter().any(|line| line.contains("ApellidoFinal"))); + let first = rows + .iter() + .position(|line| line.contains("First body")) + .unwrap(); + assert!(rows[first + 1].contains("Second author")); + let y = u16::try_from(first + 1).unwrap(); + assert_ne!(buffer[(3, y)].bg, Color::Reset); + assert_eq!(buffer[(3, y)].bg, buffer[(38, y)].bg); + } + } + + #[test] + fn transcript_separates_metadata_reply_and_body_and_aligns_media() { + use crate::event::TelegramCommand; + use yazi_term::event::{Modifiers, MouseButton, MouseEvent, MouseEventKind}; + let mut app = populated_app_with_settings(Settings { + show_message_ids: true, + ..Settings::default() + }); + app.focus = Focus::Conversation; + app.sidebar_hidden = true; + app.selected_message = Some(11); + let message = &mut app.messages.get_mut(&7).unwrap()[0]; + message.outgoing = true; + message.sender = "Me".to_owned(); + message.text = "Readable body 你好".to_owned(); + message.reply_to = Some(ReplyInfo { + message_id: 42, + chat_id: 7, + sender: Some("Bob".to_owned()), + }); + message.attachment = Some(Attachment { + source_id: None, + kind: AttachmentKind::Photo, + file_name: None, + mime_type: None, + size: None, + fallback_emoji: None, + }); + for width in [40, 100] { + let mut terminal = Terminal::new(TestBackend::new(width, 24)).unwrap(); + terminal.draw(|frame| render(frame, &mut app)).unwrap(); + let buffer = terminal.backend().buffer(); + let row = |y| { + (0..width) + .map(|x| buffer[(x, y)].symbol()) + .collect::() + }; + assert!(row(1).contains("Me")); + assert!(!row(1).contains("#11")); + assert!(!row(1).contains("Readable body")); + assert!(row(2).contains("│ ↩ Bob")); + assert!(row(3).contains("Readable body")); + assert_eq!(buffer[(3, 3)].fg, Color::Reset); + assert_eq!(buffer[(1, 3)].symbol(), "▎"); + assert!(!row(3).contains("photo")); + assert_eq!(app.media_slots.len(), 1); + assert_eq!(app.media_slots[0].viewport.x, 3); + assert!(app.media_slots[0].viewport.right() < width); + } + let commands = app.handle_mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 8, + row: 2, + modifiers: Modifiers::empty(), + }); + assert_eq!(app.selected_action, 0); + assert!(render_text_mut(&mut app, 100, 24).contains("│ ↩ Bob")); + assert!(app.handle_action(KeyAction::Enter).is_empty()); + assert!(matches!( + commands.first(), + Some(TelegramCommand::LoadMessage { message_id: 42, .. }) + )); + } + + #[test] + fn loaded_reply_excerpt_is_two_rows_and_click_opens_its_exact_target() { + use crate::event::{NetworkEvent, TelegramCommand}; + use yazi_term::event::{Modifiers, MouseButton, MouseEvent, MouseEventKind}; + let mut app = populated_app(); + app.focus = Focus::Conversation; + app.sidebar_hidden = true; + let mut original = app.active_messages()[0].clone(); + original.id = 10; + original.sender = "Full original author".to_owned(); + original.text = "An excerpt with 中文 content ".repeat(50); + app.messages.get_mut(&7).unwrap()[0].reply_to = Some(ReplyInfo { + chat_id: 7, + message_id: 10, + sender: None, + }); + render_text_mut(&mut app, 70, 24); + let commands = app.request_visible_replies(); + let [TelegramCommand::LoadReplyPreviews { request_id, .. }] = commands.as_slice() else { + panic!("one batch expected") + }; + app.handle_network(NetworkEvent::ReplyPreviews { + chat_id: 7, + request_id: *request_id, + messages: vec![original], + unavailable: Vec::new(), + complete: true, + }); + let text = render_text_mut(&mut app, 70, 24); + assert!(text.contains("Full original author · An excerpt")); + assert_eq!(text.matches(" │ ").count(), 2); + assert!(!text.contains("unknown")); + assert_eq!(app.active_messages().len(), 1); + assert!( + app.handle_mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 8, + row: 2, + modifiers: Modifiers::empty() + }) + .is_empty() + ); + assert_eq!(app.selected_message, Some(10)); + assert_eq!(app.viewport_anchor_message, Some(10)); + } + + #[test] + fn reflow_clamps_the_anchor_to_its_message_when_wrapping_shrinks() { + let mut app = populated_app(); + app.narrow_conversation = true; + let mut message = app.active_messages()[0].clone(); + message.text = "long content ".repeat(300); + let mut messages = vec![message.clone()]; + for id in 12..32 { + let mut following = message.clone(); + following.id = id; + following.text = format!("message {id}"); + messages.push(following); + } + app.messages.insert(7, messages); + app.message_scroll = 1; + app.viewport_anchor_message = Some(11); + app.viewport_anchor_row = 70; + render_text_mut(&mut app, 40, 14); + assert_eq!(app.viewport_anchor_message, Some(11)); + assert!(render_text_mut(&mut app, 120, 14).contains("long content")); + assert_eq!(app.viewport_anchor_message, Some(11)); + assert_eq!(app.viewport_anchor_row, 0); + app.sidebar_hidden = true; + render_text_mut(&mut app, 120, 14); + assert_eq!(app.viewport_anchor_message, Some(11)); + } + + #[test] + fn sidebar_toggle_keeps_drafts_and_clears_hidden_pointer_targets() { + use yazi_term::event::{KeyCode, KeyEvent, KeyEventKind, Modifiers}; + let key = KeyEvent::new(KeyCode::Fn(4), Modifiers::empty()); + let mut app = populated_app(); + render_text_mut(&mut app, 100, 24); + app.handle_action(KeyAction::Character('i')); + app.update(crate::event::AppEvent::Paste("draft 你好".to_owned())); + app.handle_key(&key); + assert!(app.sidebar_hidden); + assert_eq!(app.mode, Mode::Compose); + assert_eq!(app.chat_hit_region_count(), 0); + render_text_mut(&mut app, 100, 24); + assert_eq!(app.chat_hit_region_count(), 0); + let mut repeat = key.clone(); + repeat.kind = KeyEventKind::Repeat; + app.handle_key(&repeat); + assert!(app.sidebar_hidden); + app.handle_key(&key); + render_text_mut(&mut app, 100, 24); + assert!(!app.sidebar_hidden); + assert_eq!(app.mode, Mode::Compose); + assert!(app.chat_hit_region_count() > 0); + render_text_mut(&mut app, 40, 10); + app.handle_key(&key); + assert_eq!(app.mode, Mode::Navigate); + assert_eq!(app.focus, Focus::Chats); + assert_eq!(app.active_draft().unwrap().value(), "draft 你好"); + render_text_mut(&mut app, 40, 10); + assert!(app.chat_hit_region_count() > 0); + app.handle_key(&key); + assert!(app.sidebar_hidden); + app.handle_action(KeyAction::Tab); + assert!(!app.sidebar_hidden); + } + + #[test] + fn chat_columns_keep_alignment_and_semantic_colors_with_unicode_and_icons() { + let mut app = populated_app(); + let mut second = app.chats[0].clone(); + second.id = 8; + second.title = "很长的群聊名称 with emoji 🙂".to_owned(); + second.unread = 10_000; + app.chats.push(second); + app.keymap + .colors + .chats + .insert(7, crate::appearance::TerminalColor::Red); + for nerd_font in [false, true] { + app.keymap.nerd_font = nerd_font; + let mut terminal = Terminal::new(TestBackend::new(100, 24)).unwrap(); + terminal.draw(|frame| render(frame, &mut app)).unwrap(); + let buffer = terminal.backend().buffer(); + let first_time = (19..24) + .map(|x| buffer[(x, 1)].symbol()) + .collect::(); + let second_time = (19..24) + .map(|x| buffer[(x, 2)].symbol()) + .collect::(); + assert_eq!(first_time, second_time); + assert!(first_time.contains(':')); + assert_eq!(buffer[(19, 1)].fg, Color::Cyan); + assert_eq!(buffer[(28, 1)].fg, Color::Yellow); + assert_eq!(buffer[(28, 1)].symbol(), "2"); + assert_eq!( + (25..29) + .map(|x| buffer[(x, 2)].symbol()) + .collect::(), + "999+" + ); + assert_eq!(buffer[(if nerd_font { 5 } else { 3 }, 1)].fg, Color::Red); + } + } + + #[test] + fn statusline_respects_lua_order_and_keeps_mode_and_selection_hints_when_narrow() { + let mut app = populated_app(); + app.keymap = crate::keymap::Keymap::parse( + "return {statusline={left={'mode','account'},right={'dc','connection'}}}", + ) + .unwrap(); + app.metrics.dc_id = Some(4); + let mut terminal = Terminal::new(TestBackend::new(100, 24)).unwrap(); + terminal.draw(|frame| render(frame, &mut app)).unwrap(); + let last_row = terminal.backend().buffer().content()[2300..] + .iter() + .map(ratatui::buffer::Cell::symbol) + .collect::(); + assert!(last_row.starts_with(" CHATS ")); + assert!(last_row.contains("1 · Me")); + assert!(last_row.find("DC 4").unwrap() < last_row.find("online").unwrap()); + app.keymap = crate::keymap::Keymap::default(); + app.focus = Focus::Conversation; + app.selected_message = Some(11); + app.messages.get_mut(&7).unwrap()[0].attachment = Some(Attachment { + source_id: None, + kind: AttachmentKind::Photo, + file_name: None, + mime_type: None, + size: None, + fallback_emoji: None, + }); + let narrow = render_text(&app, 40, 10); + assert!(narrow.contains("SELECT")); + assert!(narrow.contains("o preview")); + assert!( + narrow.contains("O Finder") + || narrow.contains("O Explorer") + || narrow.contains("O files") + ); + app.keymap.statusline.enabled = false; + app.status_message = Some("Failed to send".to_owned()); + let hidden = render_text(&app, 40, 10); + assert!(!hidden.contains("SELECT")); + assert!(hidden.contains("Failed to send")); + } + + #[test] + fn pinned_overlay_keeps_navigation_visible_in_a_small_terminal() { + let mut app = populated_app(); + app.mode = Mode::PinnedMessages; + app.message_pins.chat = Some(7); + app.message_pins.page.messages = app.active_messages().to_vec(); + let rendered = render_text(&app, 40, 10); + assert!(rendered.contains("Pinned messages")); + assert!(rendered.contains(" open")); + assert!(rendered.contains(" close")); + assert!(rendered.contains(" next")); + } + + #[test] + fn nerd_font_opt_in_preserves_labels_and_plain_font_fallback() { + let mut app = populated_app(); + app.folders.push(crate::folders::Folder::archive()); + app.folder_id = 1; + app.chats[0].membership.archived = true; + app.pins.dialogs.archive = vec![7]; + let message = &mut app.messages.get_mut(&7).unwrap()[0]; + message.pinned = true; + message.attachment = Some(Attachment { + source_id: None, + kind: AttachmentKind::File, + file_name: Some("notes.txt".to_owned()), + mime_type: None, + size: None, + fallback_emoji: None, + }); + assert!(!app.keymap.nerd_font); + let plain = render_text(&app, 100, 24); + assert!(plain.contains("Archive")); + assert!(plain.contains("notes.txt")); + assert!( + !plain + .chars() + .any(|ch| ('\u{e000}'..='\u{f8ff}').contains(&ch)) + ); + app.keymap = crate::keymap::Keymap::parse("return { nerd_font = true }").unwrap(); + let icons = render_text(&app, 100, 24); + for glyph in ['\u{f187}', '\u{f075}', '\u{f08d}', '\u{f15b}'] { + assert!(icons.contains(glyph)); + } + // TestBackend retains the blank continuation cell of each wide glyph. + assert!(icons.contains("Alice 東 京")); + assert!(icons.contains("notes.txt")); + assert!(icons.contains("P pins")); + assert!(render_text(&app, 40, 10).contains("Archive")); + app.keymap = crate::keymap::Keymap::parse("return { nerd_font = false }").unwrap(); + assert_eq!(render_text(&app, 100, 24), plain); + assert!(crate::keymap::Keymap::parse("return { nerd_font = 'yes' }").is_err()); + } + fn populated_app_with_settings(settings: Settings) -> AppState { let mut app = AppState::with_ephemeral_settings(settings); app.screen = Screen::Main; app.connection = ConnectionStatus::Online; app.user_name = Some("Me".to_owned()); app.chats.push(Chat { + read_inbox_max_id: Some(0), + membership: crate::folders::ChatMembership::default(), id: 7, title: "Alice 東京".to_owned(), kind: ChatKind::Direct, unread: 2, + last_message_id: None, last_message: "hello from the terminal".to_owned(), last_activity: Some(Utc::now()), }); @@ -1699,6 +2039,13 @@ mod tests { app.messages.insert( 7, vec![Message { + reactions: None, + poll: None, + entities: Vec::new(), + notification: None, + mention: None, + edited_at: None, + pinned: false, id: 11, chat_id: 7, sender: "Alice".to_owned(), @@ -1735,6 +2082,83 @@ mod tests { .collect() } + #[test] + fn visible_composer_click_enters_input_and_resets_with_the_frame() { + use yazi_term::event::{Modifiers, MouseButton, MouseEvent, MouseEventKind}; + + let mut app = populated_app(); + app.active_chat_id = None; + render_text_mut(&mut app, 100, 24); + app.handle_mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 70, + row: 21, + modifiers: Modifiers::empty(), + }); + assert_eq!(app.active_chat_id, Some(7)); + assert_eq!(app.mode, Mode::Compose); + app.update(crate::event::AppEvent::Paste("你好🙂".to_owned())); + assert_eq!(app.active_draft().unwrap().value(), "你好🙂"); + app.handle_action(KeyAction::Escape); + render_text_mut(&mut app, 30, 8); + app.handle_mouse(MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 70, + row: 21, + modifiers: Modifiers::empty(), + }); + assert_eq!(app.mode, Mode::Navigate); + } + + #[test] + fn expanded_preview_fits_small_windows_and_keeps_close_visible_on_error() { + let mut app = populated_app(); + app.messages.get_mut(&7).unwrap()[0].attachment = Some(Attachment { + source_id: None, + kind: AttachmentKind::Photo, + file_name: None, + mime_type: Some("image/jpeg".to_owned()), + size: None, + fallback_emoji: None, + }); + app.mode = Mode::Preview; + app.selected_message = Some(11); + app.status_message = Some("Cannot load image. ".repeat(20)); + for (width, height) in [(100, 24), (40, 10)] { + let output = render_text_mut(&mut app, width, height); + assert!(output.contains(" close")); + assert!(output.contains("Cannot load image")); + assert_eq!(app.media_slots.len(), 1); + let slot = &app.media_slots[0]; + assert!(slot.viewport.right() <= width); + assert!(slot.viewport.bottom() < height); + assert_eq!(slot.size.width, width - 2); + } + } + + #[test] + fn composer_hint_tracks_the_effective_send_binding() { + let mut app = populated_app(); + app.mode = Mode::Compose; + app.keymap = crate::keymap::Keymap::parse("return { keymap={{context='compose',on={''},run='newline'}, {context='compose',on={''},run='send'}} }").unwrap(); + let text = render_text(&app, 100, 24); + assert!(text.contains(" to send")); + assert!(!text.contains("Enter send")); + app.mode = Mode::Navigate; + app.focus = Focus::Chats; + assert!(render_text(&app, 100, 24).contains("i to compose")); + } + + #[test] + fn narrow_search_keeps_submit_and_close_controls_visible() { + let mut app = populated_app(); + app.mode = Mode::Search; + app.search.editing = true; + let output = render_text(&app, 40, 14); + assert!(output.contains(" search")); + assert!(output.contains(" close")); + } + #[test] fn login_errors_wrap_and_grow_for_all_phone_auth_phases() { let error = "Could not request a login code: request error: rpc error 500: AUTH_RESTART"; @@ -1754,16 +2178,16 @@ mod tests { output.contains("AUTH_RESTART"), "{width}x{height}: {output}" ); - assert!(output.contains("Ctrl+C quits")); - assert!(output.contains("Enter to continue")); + assert!(output.contains(" quit"), "{output}"); + assert!(output.contains(" to continue")); } app.status_message = Some(format!("{} END_OF_ERROR", "连接失败 retry ".repeat(30))); let output = render_text(&app, 72, 40); assert!(output.contains("END_OF_ERROR")); - assert!(output.contains("Ctrl+C quits")); + assert!(output.contains(" quit"), "{output}"); let small = render_text(&app, 40, 10); assert!(small.contains("Resize terminal for more")); - assert!(small.contains("Ctrl+C quits")); + assert!(small.contains(" quit")); } } @@ -1789,7 +2213,7 @@ mod tests { app.screen = Screen::Fatal(format!("{} END_OF_ERROR", "request failed ".repeat(30))); let output = render_text(&app, 40, 24); assert!(output.contains("END_OF_ERROR")); - assert!(output.contains("q/Ctrl+C")); + assert!(output.contains("q quit")); assert!(output.contains("quit")); assert!(render_text(&app, 40, 10).contains("Resize terminal for more")); } @@ -1865,7 +2289,7 @@ mod tests { let output = render_text(&app, 100, 24); assert!(output.contains("Reply to #11 Alice")); - assert!(output.contains("Reply #11")); + assert_eq!(output.matches("Reply to #11 Alice").count(), 1); } #[test] @@ -1876,11 +2300,11 @@ mod tests { assert!(output.contains("Termgram")); assert!(output.contains("Alice")); assert!(output.contains("hello from the terminal")); - assert!(output.contains("Message · i to compose")); + assert!(output.contains("i to compose")); } #[test] - fn settings_overlay_renders_defaults_and_safety_language() { + fn settings_overlay_renders_current_download_behavior() { let mut app = populated_app(); app.mode = Mode::Settings; let output = render_text(&app, 100, 30); @@ -1889,16 +2313,16 @@ mod tests { assert!(output.contains("Automatic update checks")); assert!(output.contains("Stable")); assert!(output.contains("Reveal on activation")); - assert!(output.contains("never executes files")); + assert!(output.contains("Activate a downloaded file to reveal it")); } #[test] - fn settings_overlay_reflects_prerelease_and_temp_only() { + fn settings_overlay_reflects_prerelease_and_cache_only() { let mut app = AppState::with_settings( Settings { automatic_update_checks: false, release_channel: ReleaseChannel::Prerelease, - download_behavior: DownloadBehavior::TempOnly, + download_behavior: DownloadBehavior::CacheOnly, show_message_ids: false, ..Settings::default() }, @@ -1910,8 +2334,8 @@ mod tests { assert!(output.contains("Off")); assert!(output.contains("Prerelease")); - assert!(output.contains("Temp only")); - assert!(output.contains("never reveals files")); + assert!(output.contains("Keep in cache")); + assert!(output.contains("Keep downloads for reuse")); } #[test] @@ -1930,11 +2354,11 @@ mod tests { assert!(output.contains("Account 2 · Me (active)")); assert!(output.contains("Account 3")); assert!(output.contains("+ Add account")); - assert!(output.contains("1-8 direct")); + assert!(output.contains(" switch")); } #[test] - fn replies_render_target_metadata_and_optional_right_id_column() { + fn replies_render_target_metadata_and_optional_statusline_ids() { let mut app = populated_app(); let message = app.messages.get_mut(&7).unwrap().first_mut().unwrap(); message.reply_to = Some(ReplyInfo { @@ -1944,7 +2368,7 @@ mod tests { }); let without_column = render_text(&app, 100, 30); - assert!(without_column.contains("↩ #42 Bob")); + assert!(without_column.contains("│ ↩ Bob")); assert!(!without_column.contains("#11")); let settings = Settings { @@ -1952,6 +2376,8 @@ mod tests { ..Settings::default() }; let mut app = populated_app_with_settings(settings); + app.focus = Focus::Conversation; + app.selected_message = Some(11); let message = app.messages.get_mut(&7).unwrap().first_mut().unwrap(); message.reply_to = Some(ReplyInfo { message_id: 42, @@ -1959,7 +2385,7 @@ mod tests { sender: Some("Bob".to_owned()), }); let with_column = render_text(&app, 100, 30); - assert!(with_column.contains("↩ #42 Bob")); + assert!(with_column.contains("│ ↩ Bob")); assert!(with_column.contains("#11")); } @@ -1981,6 +2407,13 @@ mod tests { let mut app = populated_app(); app.messages.get_mut(&7).unwrap().extend([ Message { + reactions: None, + poll: None, + entities: Vec::new(), + notification: None, + mention: None, + edited_at: None, + pinned: false, id: 12, chat_id: 7, sender: "Alice".to_owned(), @@ -1990,6 +2423,7 @@ mod tests { outgoing: false, delivery: Delivery::Read, attachment: Some(Attachment { + source_id: None, kind: AttachmentKind::Photo, file_name: Some("image.jpg".to_owned()), mime_type: Some("image/jpeg".to_owned()), @@ -2000,6 +2434,13 @@ mod tests { buttons: Vec::new(), }, Message { + reactions: None, + poll: None, + entities: Vec::new(), + notification: None, + mention: None, + edited_at: None, + pinned: false, id: 13, chat_id: 7, sender: "Alice".to_owned(), @@ -2009,6 +2450,7 @@ mod tests { outgoing: false, delivery: Delivery::Read, attachment: Some(Attachment { + source_id: None, kind: AttachmentKind::Sticker, file_name: None, mime_type: None, @@ -2021,11 +2463,8 @@ mod tests { ]); let output = render_text_mut(&mut app, 120, 36); - assert!(output.contains("[photo] image.jpg · 2.0 KiB · inline preview")); - // TestBackend retains the continuation cell for a wide emoji, so the - // exact amount of padding between these two tokens is backend-specific. - assert!(output.contains("🙂")); - assert!(output.contains("[sticker]")); + assert!(!output.contains("[photo]")); + assert!(!output.contains("[sticker]")); assert_eq!(app.media_slots.len(), 2); assert_eq!(app.request_visible_media().len(), 2); app.message_scroll = 3; @@ -2079,7 +2518,7 @@ mod tests { }); let output = render_text(&app, 72, 20); - assert!(output.contains("Esc starts over")); + assert!(output.contains(" starts over")); } #[test] @@ -2127,8 +2566,8 @@ mod tests { let output = render_text(&app, 80, 24); assert!(output.contains("Devices→Link Desktop")); - assert!(output.contains("Tab full")); - assert!(output.contains("Esc phone")); + assert!(output.contains(" full")); + assert!(output.contains(" phone")); assert!(output.contains('▀')); assert!(!output.contains(secret_url)); assert_eq!(qr_pair_symbol(QrColor::Dark, QrColor::Dark), "█"); @@ -2157,7 +2596,7 @@ mod tests { .iter() .map(ratatui::buffer::Cell::symbol) .collect(); - assert!(compatible_text.contains("Tab compact")); + assert!(compatible_text.contains(" compact")); assert!(!compatible_text.contains('▀')); assert!(!compatible_text.contains(secret_url)); assert!( @@ -2185,7 +2624,7 @@ mod tests { let conversation = render_text(&app, 70, 24); assert!(conversation.contains("Alice")); assert!(conversation.contains("hello from the terminal")); - assert!(conversation.contains("Esc chats")); + assert!(conversation.contains(" chats")); } #[test] @@ -2194,6 +2633,7 @@ mod tests { let mut photo = app.messages.get(&7).unwrap()[0].clone(); photo.id = 12; photo.attachment = Some(Attachment { + source_id: None, kind: AttachmentKind::Photo, file_name: Some("photo.jpg".to_owned()), mime_type: Some("image/jpeg".to_owned()), @@ -2220,10 +2660,13 @@ mod tests { let mut app = populated_app(); for index in 0_i64..30 { app.chats.push(Chat { + read_inbox_max_id: Some(0), + membership: crate::folders::ChatMembership::default(), id: 100 + index, title: format!("Overflow chat {index:02}"), kind: ChatKind::Direct, unread: 0, + last_message_id: None, last_message: String::new(), last_activity: None, }); @@ -2239,6 +2682,13 @@ mod tests { let mut app = populated_app(); let messages = (0_i32..30) .map(|id| Message { + reactions: None, + poll: None, + entities: Vec::new(), + notification: None, + mention: None, + edited_at: None, + pinned: false, id, chat_id: 7, sender: "Alice".to_owned(), @@ -2266,6 +2716,13 @@ mod tests { .get_mut(&7) .expect("active history") .push(Message { + reactions: None, + poll: None, + entities: Vec::new(), + notification: None, + mention: None, + edited_at: None, + pinned: false, id: 30, chat_id: 7, sender: "Alice".to_owned(), @@ -2285,7 +2742,7 @@ mod tests { assert!(after.contains(&format!("message-{anchor:02}"))); assert!(!after.contains("new-three")); assert!(after.contains("1 new")); - assert_eq!(app.message_scroll, 8); + assert_eq!(app.message_scroll, 9); // Header and three body lines, no separator. assert_eq!(app.new_messages_to_anchor, 0); let anchored_scroll = app.message_scroll; @@ -2301,6 +2758,13 @@ mod tests { app.messages.insert( 7, vec![Message { + reactions: None, + poll: None, + entities: Vec::new(), + notification: None, + mention: None, + edited_at: None, + pinned: false, id: 99, chat_id: 7, sender: "Alice".to_owned(), @@ -2324,4 +2788,60 @@ mod tests { assert!(top.contains("oldest")); assert!(!top.contains("newest")); } + #[test] + fn visible_mentions_require_a_focused_frame_and_do_not_consume_voice_messages() { + let mut app = populated_app(); + app.focus = Focus::Conversation; + app.chats[0].read_inbox_max_id = Some(100); + let mut text = app.active_messages()[0].clone(); + text.mention = Some(crate::model::Mention { + unread: true, + requires_playback: false, + }); + let mut voice = text.clone(); + voice.id = 12; + voice.text = "voice reply".into(); + voice.mention.as_mut().unwrap().requires_playback = true; + app.messages.insert(7, vec![text, voice]); + assert!( + app.request_visible_read().is_empty(), + "loading is not viewing" + ); + app.terminal_focused = false; + render_text_mut(&mut app, 100, 24); + assert!(app.request_visible_read().is_empty()); + app.terminal_focused = true; + app.mode = Mode::Search; + render_text_mut(&mut app, 100, 24); + assert!( + app.request_visible_read().is_empty(), + "covered conversation is not visible" + ); + app.mode = Mode::Navigate; + render_text_mut(&mut app, 100, 24); + assert_eq!( + app.request_visible_read(), + [crate::event::TelegramCommand::ReadMentions { + chat_id: 7, + message_ids: vec![11] + }] + ); + assert!( + app.request_visible_read().is_empty(), + "one receipt in flight per chat" + ); + app.handle_network(crate::event::NetworkEvent::MessageContentsRead { + channel_id: None, + message_ids: vec![11], + }); + assert!(!app.active_messages()[0].mention.as_ref().unwrap().unread); + assert!(app.active_messages()[1].mention.as_ref().unwrap().unread); + app.handle_network(crate::event::NetworkEvent::MentionsReadFinished { + chat_id: 7, + message_ids: vec![11], + error: None, + }); + render_text_mut(&mut app, 100, 24); + assert!(app.request_visible_read().is_empty()); + } } diff --git a/src/ui/pins.rs b/src/ui/pins.rs new file mode 100644 index 0000000..4050451 --- /dev/null +++ b/src/ui/pins.rs @@ -0,0 +1,136 @@ +use super::{ + AppState, Clear, Constraint, Frame, Layout, List, ListItem, ListState, MUTED, Modifier, + Paragraph, Rect, Style, Wrap, centered, pane_block, truncate_cells, +}; +use crate::{keymap::Context, model::sanitize_terminal_line}; + +pub(super) fn render(frame: &mut Frame<'_>, area: Rect, app: &AppState) { + let popup = centered( + area, + area.width.saturating_sub(4).min(90), + area.height.saturating_sub(2).min(24), + ); + frame.render_widget(Clear, popup); + let state = &app.message_pins; + let count = state + .page + .total + .map_or_else(|| "cached".to_owned(), |total| total.to_string()); + let block = pane_block( + format!( + " Pinned messages · {count} · page {} ", + state.starts.len().max(1) + ), + true, + ); + let inner = block.inner(popup); + frame.render_widget(block, popup); + let rows = Layout::vertical([Constraint::Min(1), Constraint::Length(3)]).split(inner); + if state.page.messages.is_empty() { + let label = if state.loading { + "Loading pinned messages…" + } else if state.page.total.is_none() { + "No cached pins · connect to synchronize" + } else { + "No pinned messages" + }; + frame.render_widget(Paragraph::new(label).wrap(Wrap { trim: true }), rows[0]); + } else { + let items = state + .page + .messages + .iter() + .map(|message| { + let text = message.preview_text(); + ListItem::new(truncate_cells( + &sanitize_terminal_line(&format!("#{} {}: {text}", message.id, message.sender)), + usize::from(rows[0].width.saturating_sub(2)), + )) + }) + .collect::>(); + let mut selection = ListState::default().with_selected(Some(state.selected)); + frame.render_stateful_widget( + List::new(items) + .highlight_symbol("› ") + .highlight_style(Style::default().add_modifier(Modifier::REVERSED)), + rows[0], + &mut selection, + ); + } + let hint = |action| app.keymap.hint(Context::Pins, action); + let status = state.error.clone().unwrap_or_else(|| { + format!( + "{} unpin · {} unpin all{}", + hint("pin"), + hint("unpin_all"), + if state.loading { " · syncing…" } else { "" } + ) + }); + frame.render_widget( + Paragraph::new(format!( + "{} open · {} close\n{} previous · {} next\n{}", + hint("open"), + hint("cancel"), + hint("pins_previous"), + hint("pins_more"), + truncate_cells(&status, usize::from(rows[1].width)) + )) + .style(Style::default().fg(MUTED)), + rows[1], + ); +} + +pub(super) fn render_prompt(frame: &mut Frame<'_>, area: Rect, app: &AppState) { + let Some(prompt) = &app.message_pins.prompt else { + return; + }; + let popup = centered( + area, + area.width.saturating_sub(4).min(72), + area.height.saturating_sub(2).min(12), + ); + frame.render_widget(Clear, popup); + let block = pane_block(format!(" {} ", prompt.title), true); + let inner = block.inner(popup); + frame.render_widget(block, popup); + let rows = Layout::vertical([ + Constraint::Length(1), + Constraint::Min(1), + Constraint::Length(2), + ]) + .split(inner); + frame.render_widget( + Paragraph::new(truncate_cells(&prompt.preview, usize::from(rows[0].width))) + .style(Style::default().fg(MUTED)), + rows[0], + ); + let items = prompt + .options + .iter() + .map(|(label, _)| ListItem::new(label.as_str())) + .collect::>(); + let mut state = ListState::default().with_selected(Some(prompt.selection)); + frame.render_stateful_widget( + List::new(items) + .highlight_symbol("› ") + .highlight_style(Style::default().add_modifier(Modifier::REVERSED)), + rows[1], + &mut state, + ); + let status = app + .message_pins + .error + .as_deref() + .or(app.status_message.as_deref()) + .unwrap_or("Changes apply to Telegram"); + frame.render_widget( + Paragraph::new(format!( + "{} confirm · {} cancel\n{}", + app.keymap.hint(Context::Overlay, "open"), + app.keymap.hint(Context::Overlay, "cancel"), + truncate_cells(status, usize::from(rows[2].width)) + )) + .style(Style::default().fg(MUTED)), + rows[2], + ); +} diff --git a/src/ui/polls.rs b/src/ui/polls.rs new file mode 100644 index 0000000..056ad43 --- /dev/null +++ b/src/ui/polls.rs @@ -0,0 +1,277 @@ +use super::{ACCENT, MUTED, centered, entities, pane_block}; +use crate::{ + app::{AppState, MessageAction}, + model::Message, + polls::Poll, +}; +use ratatui::{ + Frame, + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Clear, Paragraph}, +}; +use std::collections::BTreeSet; +use unicode_width::UnicodeWidthStr; + +pub(super) struct Row { + pub line: Line<'static>, + pub option: Option, + pub spoiler: bool, +} + +pub(super) fn lines( + poll: &Poll, + width: usize, + revealed: bool, + choices: Option<&BTreeSet>>, +) -> Vec { + let now = chrono::Utc::now().timestamp(); + let visible = poll.results_visible(now); + let mut rows = Vec::new(); + for mut row in entities::render_text( + &poll.definition.question.text, + &poll.definition.question.entities, + width, + revealed, + true, + ) { + row.line.style = row.line.style.bold(); + rows.push(Row { + line: row.line, + option: None, + spoiler: row.action == Some(MessageAction::Spoilers), + }); + } + for (index, answer) in poll.definition.answers.iter().enumerate() { + let count = poll.count(&answer.option); + let selected = choices.map_or_else( + || count.is_some_and(|count| count.chosen), + |choices| choices.contains(&answer.option), + ); + let correct = visible && count.is_some_and(|count| count.correct); + let mark = if correct { + "✓ " + } else if selected { + "● " + } else { + "○ " + }; + let color = if correct { + Color::Green + } else if selected { + ACCENT + } else { + MUTED + }; + let result = if visible { + count + .and_then(|count| count.voters) + .map_or_else(String::new, |votes| result_label(votes, poll.results.total)) + } else { + String::new() + }; + let available = width.saturating_sub(2 + result.width()).max(1); + let parts = entities::render_text( + &answer.text.text, + &answer.text.entities, + available, + revealed, + true, + ); + for (part, row) in parts.into_iter().enumerate() { + let mut line = row.line; + line.spans.insert( + 0, + Span::styled( + if part == 0 { mark } else { " " }, + Style::default().fg(color), + ), + ); + if part == 0 && !result.is_empty() { + line.spans.push(Span::raw( + " ".repeat(width.saturating_sub(line.width() + result.width())), + )); + line.spans + .push(Span::styled(result.clone(), Style::default().fg(MUTED))); + } + rows.push(Row { + line, + option: Some(index), + spoiler: row.action == Some(MessageAction::Spoilers), + }); + } + } + if visible && let Some(solution) = &poll.results.solution { + rows.push(Row { + line: Line::styled("Explanation", Style::default().fg(ACCENT)), + option: None, + spoiler: false, + }); + rows.extend( + entities::render_text(&solution.text, &solution.entities, width, revealed, true) + .into_iter() + .map(|row| Row { + line: row.line, + option: None, + spoiler: row.action == Some(MessageAction::Spoilers), + }), + ); + } + rows +} + +fn result_label(votes: u32, total: Option) -> String { + total.filter(|total| *total > 0).map_or_else( + || format!(" {votes}"), + |total| { + format!( + " {:>3}% · {votes}", + u64::from(votes) * 100 / u64::from(total) + ) + }, + ) +} + +pub(super) fn transcript(message: &Message, width: usize, app: &AppState) -> Vec { + message.poll.as_ref().map_or_else(Vec::new, |poll| { + lines(poll, width, app.spoilers_revealed(message), None) + }) +} + +pub(super) fn status(poll: &Poll) -> String { + let now = chrono::Utc::now().timestamp(); + format!( + "{} · {} · {}{}{}", + if poll.definition.quiz { "quiz" } else { "poll" }, + if poll.definition.public_voters { + "public votes" + } else { + "anonymous" + }, + poll.results.total.map_or_else( + || "votes unknown".to_owned(), + |count| format!("{count} voters") + ), + if poll.closed(now) { + " · closed" + } else if poll.voted() { + " · voted" + } else if poll.definition.multiple_choice { + " · multiple answers" + } else { + " · one answer" + }, + if poll.stale { " · refreshing" } else { "" } + ) +} + +pub(super) fn render(frame: &mut Frame<'_>, area: Rect, app: &mut AppState) { + let revealed = app + .inspected_message() + .is_some_and(|message| app.spoilers_revealed(message)); + let Some(review) = &mut app.polls.review else { + return; + }; + let popup = centered( + area, + area.width.saturating_sub(2).min(84), + area.height.saturating_sub(2).min(30), + ); + frame.render_widget(Clear, popup); + let block = pane_block(format!(" {} · #{} ", review.title, review.message), true); + let inner = block.inner(popup); + frame.render_widget(block, popup); + // All content scrolls, including wrapped errors and eligibility notices. + // Fixed-height footers otherwise hide the reason voting is unavailable. + let mut rows = info_rows(review, usize::from(inner.width.max(1))); + rows.extend(lines( + &review.poll, + usize::from(inner.width.max(1)), + revealed, + Some(&review.choices), + )); + if review.follow + && let Some(row) = rows + .iter() + .position(|row| row.option == Some(review.selected)) + { + if row < review.top { + review.top = row; + } else if row >= review.top.saturating_add(usize::from(inner.height)) { + review.top = row + .saturating_add(1) + .saturating_sub(usize::from(inner.height)); + } + } + review.follow = false; + review.top = review + .top + .min(rows.len().saturating_sub(usize::from(inner.height))); + let mut displayed = Vec::new(); + app.polls.hit_rows.clear(); + for (index, mut row) in rows + .into_iter() + .skip(review.top) + .take(usize::from(inner.height)) + .enumerate() + { + if let Some(option) = row.option { + app.polls.hit_rows.push(( + inner.x, + inner.right(), + inner.y + super::clamp_u16(index), + option, + )); + if option == review.selected { + row.line.style = row.line.style.add_modifier(Modifier::REVERSED); + } + } + displayed.push(row.line); + } + frame.render_widget(Paragraph::new(displayed), inner); +} + +fn info_rows(review: &crate::app::polls::Review, width: usize) -> Vec { + let notice = review.error.clone().unwrap_or_else(|| { + if review.loading.is_some() { + "Refreshing poll…".to_owned() + } else if review.sending.is_some() { + "Saving vote…".to_owned() + } else if review.dirty && review.choices.is_empty() { + "Ready to retract your vote".to_owned() + } else if review.dirty { + format!( + "{} answer(s) selected · vote is not sent yet", + review.choices.len() + ) + } else if let Some(reason) = review.poll.read_only_reason(chrono::Utc::now().timestamp()) { + reason.to_owned() + } else if review.poll.definition.subscribers_only { + "Eligible subscribers only · Telegram verifies your membership".to_owned() + } else if !review.poll.definition.countries.is_empty() { + format!( + "Voting limited to {}", + review.poll.definition.countries.join(", ") + ) + } else { + "Choose answers, then submit your vote".to_owned() + } + }); + let info = format!("{}\n{}", status(&review.poll), notice); + super::wrap_cells(&info, width) + .into_iter() + .map(|line| Row { + line: Line::styled( + line, + Style::default().fg(if review.error.is_some() { + Color::Yellow + } else { + MUTED + }), + ), + option: None, + spoiler: false, + }) + .collect() +} diff --git a/src/ui/preview.rs b/src/ui/preview.rs new file mode 100644 index 0000000..ca00035 --- /dev/null +++ b/src/ui/preview.rs @@ -0,0 +1,80 @@ +//! Expanded image view using the existing asynchronous media renderer. +use super::{ACCENT, MUTED, WARNING, clamp_u16}; +use crate::{app::AppState, keymap::Context, media::MediaSlot}; +use ratatui::{ + Frame, + layout::{Constraint, Layout, Rect, Size}, + style::Style, + widgets::{Block, Borders, Clear, Paragraph}, +}; +use unicode_width::UnicodeWidthStr; + +pub(super) fn render(frame: &mut Frame<'_>, area: Rect, app: &mut AppState) { + app.media_slots.clear(); + frame.render_widget(Clear, area); + let rows = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(area); + let title = app + .selected_message + .and_then(|id| { + app.active_messages() + .iter() + .find(|message| message.id == id) + }) + .and_then(|message| message.attachment.as_ref()) + .map_or_else( + || "Preview".to_owned(), + |attachment| crate::model::sanitize_terminal_line(attachment.display_name()), + ); + let block = Block::new() + .title(format!(" {title} ")) + .borders(Borders::ALL) + .border_style(Style::default().fg(ACCENT)); + let inner = block.inner(rows[0]); + frame.render_widget(block, rows[0]); + let status = + if let (Some(chat_id), Some(message_id)) = (app.active_chat_id, app.selected_message) { + app.media_slots.push(MediaSlot { + source: crate::media::MediaSource::Message { + chat_id, + message_id, + }, + viewport: inner, + offset: 0, + size: Size::new(inner.width, inner.height), + }); + app.media_previews + .get(&(chat_id, message_id)) + .map_or("Loading preview…", |preview| preview.status.as_str()) + } else { + "This media is no longer available" + }; + if !status.is_empty() { + frame.render_widget( + Paragraph::new(status).style(Style::default().fg(MUTED)), + inner, + ); + } + let hint = |action| app.keymap.hint(Context::Preview, action); + let close = format!("{} close ", hint("cancel")); + let footer = Layout::horizontal([ + Constraint::Min(0), + Constraint::Length(clamp_u16(close.width())), + ]) + .split(rows[1]); + let controls = app + .status_message + .clone() + .unwrap_or_else(|| format!(" {} reply · {} reveal file", hint("reply"), hint("reveal"))); + frame.render_widget( + Paragraph::new(controls).style(Style::default().fg(if app.status_message.is_some() { + WARNING + } else { + MUTED + })), + footer[0], + ); + frame.render_widget( + Paragraph::new(close).style(Style::default().fg(MUTED)), + footer[1], + ); +} diff --git a/src/ui/reactions.rs b/src/ui/reactions.rs new file mode 100644 index 0000000..6f67acc --- /dev/null +++ b/src/ui/reactions.rs @@ -0,0 +1,182 @@ +use super::{ACCENT, MUTED, centered, pane_block, truncate_cells, wrap_cells}; +use crate::{ + app::{AppState, MessageAction}, + model::Message, + reactions::{Kind, Summary}, +}; +use ratatui::{ + Frame, + layout::Rect, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Clear, Paragraph}, +}; + +pub(super) fn transcript(message: &Message, width: usize) -> Vec { + let Some(summary) = &message.reactions else { + return Vec::new(); + }; + let mut rows = Vec::new(); + let mut line = Line::default(); + for count in summary.counts.iter().filter(|count| count.count > 0) { + let mine = count.chosen.is_some(); + let label = format!( + "{}{} {} ", + if mine { "● " } else { "" }, + count.kind.label(), + count.count + ); + let label = truncate_cells(&label, width); + let span = Span::styled( + label, + if mine { + Style::default().fg(ACCENT).bold() + } else { + Style::default().fg(MUTED) + }, + ); + if line.width() + span.width() > width && !line.spans.is_empty() { + rows.push(super::entities::Row { + line, + action: Some(MessageAction::Reactions), + }); + line = Line::default(); + } + line.spans.push(span); + } + if !line.spans.is_empty() { + rows.push(super::entities::Row { + line, + action: Some(MessageAction::Reactions), + }); + } + rows +} + +pub(super) fn status(summary: &Summary) -> String { + format!( + "{} {}{}", + summary + .counts + .iter() + .map(|count| u64::from(count.count)) + .sum::(), + if summary.as_tags { "tags" } else { "reactions" }, + if summary.stale { " · refreshing" } else { "" } + ) +} + +pub(super) fn render(frame: &mut Frame<'_>, area: Rect, app: &mut AppState) { + let Some(panel) = &mut app.reactions.panel else { + return; + }; + let popup = centered( + area, + area.width.saturating_sub(2).min(70), + area.height.saturating_sub(2).min(24), + ); + frame.render_widget(Clear, popup); + let block = pane_block( + format!(" Reactions · {} · #{} ", panel.title, panel.message), + true, + ); + let inner = block.inner(popup); + frame.render_widget(block, popup); + let notice = notice(panel); + let width = usize::from(inner.width.max(1)); + let notice_height = inner + .height + .saturating_sub(2) + .min(super::clamp_u16(wrap_cells(¬ice, width).len())); + frame.render_widget( + Paragraph::new( + wrap_cells(¬ice, width) + .into_iter() + .map(|line| Line::styled(line, Style::default().fg(MUTED))) + .collect::>(), + ), + Rect { + height: notice_height, + ..inner + }, + ); + let body = Rect { + y: inner.y + notice_height, + height: inner.height.saturating_sub(notice_height), + ..inner + }; + if panel.selected < panel.top { + panel.top = panel.selected; + } + if panel.selected >= panel.top + usize::from(body.height) { + panel.top = (panel.selected + 1).saturating_sub(usize::from(body.height)); + } + app.reactions.hit_rows.clear(); + let lines: Vec<_> = panel + .choices + .iter() + .enumerate() + .skip(panel.top) + .take(usize::from(body.height)) + .map(|(index, choice)| { + let count = panel.review.as_ref().and_then(|review| { + review + .summary + .counts + .iter() + .find(|count| count.kind == Kind::Emoji(choice.emoji.clone())) + }); + let mine = count.is_some_and(|count| count.chosen.is_some()); + let label = format!( + "{} {} · {}{}", + if mine { "●" } else { "○" }, + crate::model::sanitize_terminal_line(&choice.emoji), + choice.title, + count.map_or_else(String::new, |count| format!(" · {}", count.count)) + ); + app.reactions.hit_rows.push(( + body.x, + body.right(), + body.y + super::clamp_u16(index - panel.top), + index, + )); + let style = if mine { + Style::default().fg(ACCENT).bold() + } else { + Style::default() + }; + Line::styled( + truncate_cells(&label, width), + if index == panel.selected { + style.add_modifier(Modifier::REVERSED) + } else { + style + }, + ) + }) + .collect(); + frame.render_widget(Paragraph::new(lines), body); +} + +fn notice(panel: &crate::app::reactions::Panel) -> String { + panel.error.clone().unwrap_or_else(|| { + if panel.loading.is_some() { + "Loading available reactions…".to_owned() + } else if panel.sending.is_some() { + "Saving reactions…".to_owned() + } else if let Some(review) = &panel.review { + review.read_only.clone().unwrap_or_else(|| { + if panel.choices.is_empty() { + "This chat has no available emoji reactions".to_owned() + } else { + format!( + "{} per message · oldest choice is replaced at the limit", + review.max_chosen + ) + } + }) + } else { + "Refresh to load reactions".to_owned() + } + }) +} diff --git a/src/ui/search.rs b/src/ui/search.rs new file mode 100644 index 0000000..9245729 --- /dev/null +++ b/src/ui/search.rs @@ -0,0 +1,234 @@ +use super::{ + AppState, Clear, Constraint, DANGER, Frame, Layout, List, ListItem, ListState, Local, MUTED, + Modifier, Paragraph, Position, Rect, Style, Wrap, centered, clamp_u16, pane_block, spinner, + truncate_cells, wrapped_height, +}; +use crate::app::search::{Results, Source}; +use crate::keymap::Context; + +pub(super) fn render_search(frame: &mut Frame<'_>, area: Rect, app: &AppState) { + let popup = centered( + area, + area.width.min(110), + area.height.saturating_sub(2).max(8), + ); + frame.render_widget(Clear, popup); + let source = if app.search.is_mentions() { + "Unread mentions" + } else if app.search.is_cloud() { + "Telegram search" + } else { + "Local regex" + }; + let block = pane_block(format!(" {source} · {} ", app.search.scope_label), true); + let inner = block.inner(popup); + frame.render_widget(block, popup); + let coverage = coverage_label(app); + let rows = Layout::vertical([ + Constraint::Length(if app.search.is_mentions() { 0 } else { 3 }), + Constraint::Length(wrapped_height(&coverage, inner.width).clamp(2, 4)), + Constraint::Min(1), + Constraint::Length(3), + ]) + .split(inner); + let input = &app.search.query; + let scroll = input + .cursor_display_width() + .saturating_sub(usize::from(rows[0].width.saturating_sub(3))); + if !app.search.is_mentions() { + frame.render_widget( + Paragraph::new(input.value()) + .scroll((0, clamp_u16(scroll))) + .block(pane_block( + if app.search.is_cloud() { + " Text " + } else { + " Pattern " + } + .to_owned(), + app.search.editing, + )), + rows[0], + ); + } + if app.search.editing { + frame.set_cursor_position(Position::new( + rows[0].x + 1 + clamp_u16(input.cursor_display_width().saturating_sub(scroll)), + rows[0].y + 1, + )); + } + frame.render_widget( + Paragraph::new(coverage) + .wrap(Wrap { trim: true }) + .style(Style::default().fg(if app.search.error.is_some() { + DANGER + } else { + MUTED + })), + rows[1], + ); + let height = usize::from(rows[2].height).max(1); + let start = app.search.selected.saturating_add(1).saturating_sub(height); + let messages = app.search.page.as_ref().map_or(&[][..], Results::messages); + let items: Vec<_> = messages + .iter() + .skip(start) + .take(height) + .map(|message| result_row(message, app, usize::from(rows[2].width.saturating_sub(2)))) + .collect(); + let items = if items.is_empty() && app.search.page.is_some() && !app.search.loading { + vec![ListItem::new(if app.search.is_mentions() { + "No unread mentions" + } else if app.search.is_cloud() { + "No Telegram matches" + } else { + "No matches in the cached range" + })] + } else { + items + }; + let mut state = ListState::default() + .with_selected((!messages.is_empty()).then_some(app.search.selected.saturating_sub(start))); + frame.render_stateful_widget( + List::new(items) + .highlight_symbol("› ") + .highlight_style(Style::default().add_modifier(Modifier::REVERSED)), + rows[2], + &mut state, + ); + render_hints(frame, rows[3], app); +} + +fn render_hints(frame: &mut Frame<'_>, area: Rect, app: &AppState) { + let hint = |action| app.keymap.hint(Context::Search, action); + if app.search.is_mentions() { + frame.render_widget( + Paragraph::new(format!( + "{} open · {} close\n{} refresh\n{} previous · {} next", + hint("open"), + hint("cancel"), + hint("refresh"), + hint("search_previous"), + hint("search_more"), + )) + .style(Style::default().fg(MUTED)), + area, + ); + return; + } + frame.render_widget( + Paragraph::new(format!( + "{} {} · {} close\n{} · {} edit\n{} previous · {} next", + hint("open"), + if app.search.editing { "search" } else { "open" }, + hint("cancel"), + if app.search.is_cloud() { + ":search --cloud to change filters".to_owned() + } else { + format!("{} scope", hint("search_scope")) + }, + hint("search_query"), + hint("search_previous"), + hint("search_more") + )) + .style(Style::default().fg(MUTED)), + area, + ); +} + +fn coverage_label(app: &AppState) -> String { + if let Some(error) = &app.search.error { + error.clone() + } else if app.search.loading { + format!( + "{} {}…", + spinner(app.tick), + if app.search.is_cloud() { + "Loading from Telegram" + } else { + "Searching cached messages" + } + ) + } else if let Some(Results::Local(page)) = &app.search.page { + let date = |time: Option| { + time.and_then(|time| chrono::DateTime::from_timestamp(time, 0)) + .map_or_else( + || "—".to_owned(), + |date| date.format("%Y-%m-%d").to_string(), + ) + }; + format!( + "{} cached messages · {} to {} UTC · page {} · {} hits{}", + page.cached_messages, + date(page.oldest), + date(page.newest), + app.search.page_number, + page.messages.len(), + if page.next.is_some() { + " · more available" + } else { + "" + } + ) + } else if app.search.is_mentions() { + app.search.page.as_ref().map_or_else( + || "Mentions and replies to you; refresh to load from Telegram.".to_owned(), + |page| { + let Results::Cloud(page) = page else { + unreachable!("mentions come from Telegram") + }; + format!( + "{} unread at search · page {} · {} shown", + page.total, + app.search.page_number, + page.messages.len() + ) + }, + ) + } else if let Source::Cloud { filters, .. } = &app.search.source { + let coverage = if let Some(Results::Cloud(page)) = &app.search.page { + format!( + "Telegram: {} matches · page {} · {} hits{}", + page.total, + app.search.page_number, + page.messages.len(), + if page.next.is_some() { + " · more available" + } else { + "" + } + ) + } else { + "Searches this chat on Telegram; text uses Telegram's search syntax.".to_owned() + }; + let filters = filters.label(); + if filters.is_empty() { + coverage + } else { + format!("{coverage}\n{filters}") + } + } else { + "Searches cached message text only; older uncached history is not included.".to_owned() + } +} + +fn result_row(message: &crate::model::Message, app: &AppState, width: usize) -> ListItem<'static> { + let title = app + .chats + .iter() + .find(|chat| chat.id == message.chat_id) + .map_or("Unknown", |chat| chat.title.as_str()); + ListItem::new(truncate_cells( + &format!( + "{} · {} · {} · {}", + title, + crate::model::sanitize_terminal_line(&message.sender), + message + .timestamp + .with_timezone(&Local) + .format("%m-%d %H:%M"), + crate::model::sanitize_terminal_line(&message.preview_text()) + ), + width, + )) +} diff --git a/src/ui/staging.rs b/src/ui/staging.rs new file mode 100644 index 0000000..4cbb442 --- /dev/null +++ b/src/ui/staging.rs @@ -0,0 +1,172 @@ +//! Draft review uses the same table and asynchronous image renderer as chats. +use super::{ + ACCENT, MUTED, WARNING, clamp_u16, pane_block, transcript::human_size, truncate_cells, +}; +use crate::{ + app::AppState, + keymap::Context, + media::{MediaSlot, MediaSource}, + model::sanitize_terminal_line, +}; +use ratatui::{ + Frame, + layout::{Alignment, Constraint, Layout, Rect, Size}, + style::Style, + text::Line, + widgets::{Cell, Clear, Paragraph, Row, Table, TableState}, +}; +use unicode_width::UnicodeWidthStr; + +#[allow(clippy::too_many_lines)] +pub(super) fn render(frame: &mut Frame<'_>, area: Rect, app: &mut AppState) { + app.media_slots.clear(); + frame.render_widget(Clear, area); + let rows = Layout::vertical([ + Constraint::Min(1), + Constraint::Length(u16::from(app.status_message.is_some())), + Constraint::Length(1), + ]) + .split(area); + let count = app.draft_attachments().len(); + let chat = app + .active_chat() + .map_or("Conversation", |chat| chat.title.as_str()); + let block = pane_block(format!(" {chat} · {count} attachments "), true); + let inner = block.inner(rows[0]); + frame.render_widget(block, rows[0]); + let selected = app.attachment_draft.selected.min(count.saturating_sub(1)); + if app.attachment_draft.preview { + if let Some(file) = app.draft_attachments().get(selected) { + let path = file.path.clone(); + app.media_slots.push(MediaSlot { + source: MediaSource::File(path), + viewport: inner, + offset: 0, + size: Size::new(inner.width, inner.height), + }); + } + } else if count == 0 { + frame.render_widget( + Paragraph::new(if app.preparing_attachments() { + "Preparing files…" + } else { + "No attachments. Add files, then review them before sending." + }) + .style(Style::default().fg(MUTED)), + inner, + ); + } else { + let start = selected + .saturating_add(1) + .saturating_sub(usize::from(inner.height)); + let end = count.min(start + usize::from(inner.height)); + let items = app.draft_attachments()[start..end] + .iter() + .map(|file| { + let name = file.path.file_name().unwrap_or_default().to_string_lossy(); + Row::new([ + Cell::from(truncate_cells( + &sanitize_terminal_line(&name), + usize::from(inner.width.saturating_sub(22)), + )), + Cell::from(if file.as_photo { "Photo" } else { "File" }) + .style(Style::default().fg(ACCENT)), + Cell::from(Line::from(human_size(file.size)).alignment(Alignment::Right)) + .style(Style::default().fg(MUTED)), + ]) + }) + .collect::>(); + let table = Table::new( + items, + [ + Constraint::Min(1), + Constraint::Length(5), + Constraint::Length(10), + ], + ) + .column_spacing(1) + .row_highlight_style(Style::default().reversed()) + .highlight_symbol("› "); + frame.render_stateful_widget( + table, + inner, + &mut TableState::default().with_selected(selected - start), + ); + app.attachment_draft.hit_regions = (start..end) + .map(|index| { + ( + inner.x, + inner.right(), + inner.y.saturating_add(clamp_u16(index - start)), + index, + ) + }) + .collect(); + } + if let Some(notice) = &app.status_message { + frame.render_widget( + Paragraph::new(notice.as_str()).style(Style::default().fg(WARNING)), + rows[1], + ); + } + let hint = |action| app.keymap.hint(Context::Attachments, action); + let close = format!("{} back", hint("cancel")); + let footer = Layout::horizontal([ + Constraint::Min(0), + Constraint::Length(clamp_u16(close.width() + 1)), + ]) + .split(rows[2]); + let controls = if app.attachment_draft.preview { + format!("{} reveal", hint("reveal")) + } else if area.width < 72 { + format!( + "{} caption · {} add · {} help", + hint("compose"), + hint("attach"), + hint("help") + ) + } else { + format!( + "{} add · {} paste · {} photo/file · {} remove · {} preview · {} caption · {} help", + hint("attach"), + hint("paste_clipboard"), + hint("attachment_format"), + hint("remove_attachment"), + hint("preview"), + hint("compose"), + hint("help") + ) + }; + frame.render_widget( + Paragraph::new(controls).style(Style::default().fg(MUTED)), + footer[0], + ); + frame.render_widget( + Paragraph::new(close) + .style(Style::default().fg(MUTED)) + .alignment(Alignment::Right), + footer[1], + ); +} + +pub(super) fn composer_summary(frame: &mut Frame<'_>, inner: Rect, app: &AppState) -> Rect { + if app.preparing_attachments() || !app.draft_attachments().is_empty() { + let rows = Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).split(inner); + let label = if app.preparing_attachments() { + "Preparing attachments…".to_owned() + } else { + format!( + "{} attachments · {} review", + app.draft_attachments().len(), + app.keymap.hint(Context::Compose, "attachments") + ) + }; + frame.render_widget( + Paragraph::new(label).style(Style::default().fg(ACCENT)), + rows[0], + ); + rows[1] + } else { + inner + } +} diff --git a/src/ui/statusline.rs b/src/ui/statusline.rs new file mode 100644 index 0000000..5509475 --- /dev/null +++ b/src/ui/statusline.rs @@ -0,0 +1,504 @@ +//! Small, ordered status segments inspired by Yazi's status.lua and Codex's +//! finite status-line items. Ratatui owns cell layout; no upstream code is copied. + +use super::{ACCENT, DANGER, MUTED, SUCCESS, WARNING, clamp_u16, truncate_cells}; +use crate::{ + app::{AppState, AttachmentState, Focus, MessageAction, Mode}, + event::ConnectionStatus, + keymap::Context, + model::Delivery, + statusline::Item, +}; +use ratatui::{ + Frame, + layout::{Constraint, Layout, Rect}, + style::{Color, Style}, + text::{Line, Span}, + widgets::Paragraph, +}; +use unicode_width::UnicodeWidthStr; + +struct Segment { + text: String, + style: Style, + priority: u8, + right: bool, +} + +pub(super) fn render(frame: &mut Frame<'_>, area: Rect, app: &AppState) { + if area.is_empty() || !app.keymap.statusline.enabled { + return; + } + let mut segments = app + .keymap + .statusline + .left + .iter() + .map(|item| (*item, false)) + .chain(app.keymap.statusline.right.iter().map(|item| (*item, true))) + .filter_map(|(item, right)| { + segment( + item, + right, + app, + area.width < crate::sidebar::MIN_SPLIT_WIDTH, + ) + }) + .collect::>(); + let width = usize::from(area.width); + while total_width(&segments) > width { + let Some((index, entry)) = segments + .iter() + .enumerate() + .min_by_key(|(_, entry)| entry.priority) + else { + break; + }; + if entry.priority >= 90 { + let excess = total_width(&segments).saturating_sub(width); + let remaining = entry.text.width().saturating_sub(excess); + if remaining == 0 { + segments.remove(index); + } else { + segments[index].text = truncate_cells(&entry.text, remaining); + } + continue; + } + segments.remove(index); + } + let right_width = segments + .iter() + .filter(|segment| segment.right) + .map(|segment| segment.text.width() + 1) + .sum::(); + let columns = Layout::horizontal([ + Constraint::Min(0), + Constraint::Length(clamp_u16(right_width.min(width))), + ]) + .split(area); + for (right, column) in [(false, columns[0]), (true, columns[1])] { + let mut remaining = usize::from(column.width); + let mut spans = Vec::new(); + for segment in segments.iter().filter(|segment| segment.right == right) { + if remaining == 0 { + break; + } + let text = truncate_cells(&segment.text, remaining.saturating_sub(1)); + remaining = remaining.saturating_sub(text.width() + 1); + spans.push(Span::styled(text, segment.style)); + spans.push(Span::raw(" ")); + } + frame.render_widget(Paragraph::new(Line::from(spans)), column); + } +} + +fn total_width(segments: &[Segment]) -> usize { + segments + .iter() + .map(|segment| segment.text.width() + 1) + .sum() +} + +fn segment(item: Item, right: bool, app: &AppState, narrow: bool) -> Option { + let (text, color, priority) = match item { + Item::Mode => ( + match app.mode { + Mode::Compose => " INSERT ", + Mode::Edit => " EDIT ", + Mode::ForwardPrompt => " FORWARD ", + Mode::Poll => " POLL ", + Mode::Reactions => " REACT ", + Mode::DeletePrompt => " DELETE ", + Mode::Command => " COMMAND ", + Mode::Filter | Mode::Search => " SEARCH ", + Mode::Navigate if app.focus == Focus::Chats => " CHATS ", + Mode::Navigate if app.selected_message.is_some() => " SELECT ", + Mode::Navigate => " NORMAL ", + _ => " VIEW ", + } + .to_owned(), + ACCENT, + 100, + ), + Item::App => ("Termgram".to_owned(), ACCENT, 10), + Item::Account => ( + format!( + "{} · {}", + app.active_account(), + app.user_name.as_deref().unwrap_or("Telegram") + ), + Color::Reset, + 60, + ), + Item::Connection => { + let (label, color) = match app.connection { + ConnectionStatus::Connecting => ("connecting", WARNING), + ConnectionStatus::Online => ("online", SUCCESS), + ConnectionStatus::Reconnecting => ("reconnecting", WARNING), + ConnectionStatus::Offline => ("offline", DANGER), + }; + (label.to_owned(), color, 80) + } + Item::Latency => ( + app.metrics + .latency + .filter(|sample| !sample.expired() && app.connection == ConnectionStatus::Online) + .map_or_else( + || "ping —".to_owned(), + |sample| format!("{} ms", sample.elapsed.as_millis()), + ), + MUTED, + 30, + ), + Item::Dc => ( + app.metrics + .dc_id + .filter(|_| app.connection == ConnectionStatus::Online) + .map_or_else(|| "DC —".to_owned(), |dc| format!("DC {dc}")), + MUTED, + 20, + ), + Item::Position => { + let text = if app.active_chat_id.is_none() { + String::new() + } else if app.message_scroll == 0 { + "live".to_owned() + } else if app.new_messages_while_scrolled > 0 { + format!("+{} new", app.new_messages_while_scrolled) + } else { + format!("↑{} rows", app.message_scroll) + }; + (text, MUTED, 40) + } + Item::Message => message_metadata(app)?, + Item::Notifications => (app.focused_mute_label()?, MUTED, 35), + Item::Context => (context(app, narrow), MUTED, 90), + }; + if text.is_empty() { + return None; + } + let style = if item == Item::Mode { + Style::default() + .bg(if app.mode == Mode::Compose { + Color::Green + } else { + Color::Blue + }) + .fg(Color::Black) + .bold() + } else { + Style::default().fg(color) + }; + Some(Segment { + text, + style, + priority, + right, + }) +} + +fn selected_context(app: &AppState) -> Option { + if app.mode == Mode::Navigate + && app.focus == Focus::Conversation + && app.selected_message.is_some() + { + let hint = |action| app.keymap.hint(Context::Conversation, action); + let message = app.inspected_message()?; + let mut hints = Vec::new(); + if app.has_newer_history() { + hints.push(format!("{} continue", hint("message_down"))); + hints.push(format!("{} latest", hint("latest"))); + } + if app.message_actions(message).get(app.selected_action) == Some(&MessageAction::Reply) { + hints.push(format!("{} original", hint("open"))); + } + match app.message_actions(message).get(app.selected_action) { + Some(MessageAction::Reactions) => hints.push(format!("{} react", hint("open"))), + Some(MessageAction::Poll) => hints.push(format!("{} poll", hint("open"))), + Some(MessageAction::Spoilers) => hints.push(format!( + "{} {} spoiler", + hint("open"), + if app.spoilers_revealed(message) { + "hide" + } else { + "reveal" + } + )), + Some(MessageAction::ExpandQuote) => hints.push(format!( + "{} {} quote", + hint("open"), + if app.quotes_expanded(message) { + "collapse" + } else { + "expand" + } + )), + _ => {} + } + if let Some(attachment) = &message.attachment { + if attachment.supports_preview() { + hints.push(format!("{} preview", hint("preview"))); + } + let manager = if cfg!(target_os = "macos") { + "Finder" + } else if cfg!(windows) { + "Explorer" + } else { + "files" + }; + hints.push(format!("{} {manager}", hint("reveal"))); + } + hints.push(format!("{} reply", hint("compose"))); + if message.id > 0 && !message.text.is_empty() { + hints.push(format!("{} copy", hint("copy_text"))); + } + if message.id > 0 { + hints.push(format!("{} forward", hint("forward_message"))); + } + if message.outgoing && message.id > 0 { + hints.push(format!("{} edit", hint("edit_message"))); + } + if hints.len() == 1 { + hints.push(format!("{} clear", hint("cancel"))); + } + return Some(hints.join(" · ")); + } + None +} + +#[allow(clippy::too_many_lines)] +fn context(app: &AppState, narrow: bool) -> String { + if app.mode == Mode::Reactions { + let hint = |action| app.keymap.hint(Context::Reactions, action); + if narrow { + return format!("{} toggle · {} close", hint("open"), hint("cancel")); + } + return format!( + "{} toggle · {} remove mine · {} refresh · {} close", + hint("open"), + hint("clear_reactions"), + hint("refresh"), + hint("cancel") + ); + } + + if app.mode == Mode::Poll { + let hint = |action| app.keymap.hint(Context::Poll, action); + if narrow { + return format!("{} vote · {} close", hint("send"), hint("cancel")); + } + return format!( + "{} choose · {} vote · {} retract · {} refresh · {} close", + hint("toggle_poll_answer"), + hint("send"), + hint("retract_vote"), + hint("refresh"), + hint("cancel") + ); + } + if app.mode == Mode::ForwardPrompt { + return format!( + "{} forward · {} cancel", + app.keymap.hint(Context::Forward, "send"), + app.keymap.hint(Context::Forward, "cancel") + ); + } + if app.mode == Mode::ChatInfo { + let hint = |action| app.keymap.hint(Context::Overlay, action); + return format!( + "{}/{} scroll · {} refresh · {} close", + hint("up"), + hint("down"), + hint("refresh"), + hint("cancel") + ); + } + if app.mode == Mode::Invite { + let hint = |action| app.keymap.hint(Context::Overlay, action); + return format!( + "{}/{} select · {} confirm · {} close", + hint("up"), + hint("down"), + hint("open"), + hint("cancel") + ); + } + if app.mode == Mode::DeletePrompt { + let hint = |action| app.keymap.hint(Context::Overlay, action); + return format!( + "{}/{} scope · {} confirm · {} close", + hint("up"), + hint("down"), + hint("open"), + hint("cancel") + ); + } + if app.mode == Mode::Edit { + let hint = |action| app.keymap.hint(Context::Edit, action); + return format!( + "{} save · {} keep/close · {} discard", + hint("send"), + hint("cancel"), + hint("discard_edit") + ); + } + if app.mode == Mode::Status { + return format!( + "{}/{} scroll · {} close", + app.keymap.hint(Context::Overlay, "up"), + app.keymap.hint(Context::Overlay, "down"), + app.keymap.hint(Context::Overlay, "cancel") + ); + } + if app.mode == Mode::Command { + let hint = |action| app.keymap.hint(Context::Command, action); + return format!( + "{} complete · {} run · {} cancel", + hint("complete_next"), + hint("open"), + hint("cancel") + ); + } + if app.mode == Mode::Compose + && let Some(reason) = app.active_chat_id.and_then(|id| app.draft_restriction(id)) + { + return format!("{reason} · :info"); + } + if app.status_message.is_some() { + return String::new(); + } + if let Some(hint) = selected_context(app) { + return hint; + } + if let Some(version) = app.available_update() { + return format!("Update {version} available · run tg update"); + } + if app.mode != Mode::Navigate { + return String::new(); + } + let context = if app.focus == Focus::Chats { + Context::Chats + } else { + Context::Conversation + }; + if app.focus == Focus::Conversation && app.has_newer_history() { + return format!( + "{} continue · {} latest · {} commands", + app.keymap.hint(context, "message_down"), + app.keymap.hint(context, "latest"), + app.keymap.hint(context, "command") + ); + } + let back = if app.sidebar_hidden { + format!( + "{} chats · ", + app.keymap.hint(Context::Conversation, "toggle_sidebar") + ) + } else if narrow && app.narrow_conversation { + format!( + "{} chats · ", + app.keymap.hint(Context::Conversation, "cancel") + ) + } else if app.focus == Focus::Chats && app.folders.len() > 1 { + format!( + "{} {} folders · ", + app.keymap.hint(Context::Chats, "folder_previous"), + app.keymap.hint(Context::Chats, "folder_next") + ) + } else { + String::new() + }; + format!( + "{back}{} commands · {} help", + app.keymap.hint(context, "command"), + app.keymap.hint(context, "help") + ) +} + +fn message_metadata(app: &AppState) -> Option<(String, Color, u8)> { + use chrono::{Datelike, Local}; + let message = app.inspected_message()?; + let timestamp = message.timestamp.with_timezone(&Local); + let now = Local::now(); + let mut parts = Vec::new(); + if app.settings().show_message_ids { + parts.push(format!("#{}", message.id)); + } + parts.push( + timestamp + .format(if timestamp.date_naive() == now.date_naive() { + "%H:%M" + } else if timestamp.year() == now.year() { + "%m-%d %H:%M" + } else { + "%Y-%m-%d %H:%M" + }) + .to_string(), + ); + if let Some(summary) = &message.reactions { + parts.push(super::reactions::status(summary)); + } + if let Some(poll) = &message.poll { + parts.push(super::polls::status(poll)); + } + let mut color = MUTED; + if message.outgoing { + let (state, tone) = match message.delivery { + Delivery::Pending => ("sending", WARNING), + Delivery::Sent => ("sent", MUTED), + Delivery::Read => ("read", SUCCESS), + Delivery::Failed => ("failed", DANGER), + }; + color = tone; + parts.push(state.to_owned()); + } + if message + .mention + .as_ref() + .is_some_and(|mention| mention.unread) + { + parts.push("@ unread mention".to_owned()); + color = ACCENT; + } + if let Some(edited) = message.edited_at { + parts.push(format!( + "edited {}", + edited.with_timezone(&Local).format("%H:%M") + )); + } + if message.pinned { + parts.push(format!( + "{} pinned", + super::icons::Icons(app.keymap.nerd_font).pin() + )); + } + if let Some(reply) = &message.reply_to { + parts.push(format!("reply #{}", reply.message_id)); + let status = app.reply_preview_status(reply); + if !status.is_empty() { + parts.push(status.to_owned()); + } + } + if let Some(attachment) = &message.attachment { + // Actual filenames matter; Telegram's generated photo.jpg does not. + if let Some(name) = &attachment.file_name + && !(attachment.kind == crate::model::AttachmentKind::Photo && name == "photo.jpg") + { + parts.push(name.clone()); + } + if let Some(size) = attachment.size { + parts.push(super::transcript::human_size(size)); + } + match app.attachment_state(message.chat_id, message.id) { + AttachmentState::Downloading => parts.push("downloading".to_owned()), + AttachmentState::Downloaded => parts.push("downloaded".to_owned()), + AttachmentState::Ready => {} + } + if let Some(preview) = app.media_previews.get(&(message.chat_id, message.id)) + && !preview.status.is_empty() + { + parts.push(preview.status.clone()); + } + } + Some((parts.join(" · "), color, 90)) +} diff --git a/src/ui/transcript.rs b/src/ui/transcript.rs new file mode 100644 index 0000000..47930ed --- /dev/null +++ b/src/ui/transcript.rs @@ -0,0 +1,338 @@ +//! Author/metadata, reply, body and media hierarchy inspired by Codex history +//! cells. Reuses Termgram's wrapping/actions and Ratatui spans; no copied runtime. +use super::{ACCENT, MUTED, icons::Icons, truncate_cells, wrap_cells}; +use crate::{ + app::{AppState, MessageAction}, + model::{Attachment, AttachmentKind, Message, MessageButtonKind}, +}; +use ratatui::{ + style::{Color, Modifier, Style}, + text::{Line, Span}, +}; +use std::hash::{DefaultHasher, Hash, Hasher}; + +pub(super) const GUTTER_WIDTH: u16 = 2; + +pub(super) struct RenderedMessage { + pub lines: Vec>, + pub body_height: usize, + pub body_action: Option, + pub action_rows: Vec<(usize, usize)>, +} + +pub(super) fn gutter(selected: bool) -> Span<'static> { + Span::styled( + if selected { "▎ " } else { " " }, + Style::default().fg(ACCENT), + ) +} + +pub(super) fn render(message: &Message, width: usize, app: &AppState) -> RenderedMessage { + let selected = app.selected_message == Some(message.id); + let icons = Icons(app.keymap.nerd_font); + let actions = app.message_actions(message); + let body_width = width.saturating_sub(usize::from(GUTTER_WIDTH)).max(1); + let mut lines = header(message, body_width, selected); + let mut action_rows = Vec::new(); + if let Some(reply) = &message.reply_to { + let original = app.reply_message(reply); + let author = original + .map(|message| message.sender.as_str()) + .or(reply.sender.as_deref()) + .filter(|sender| !sender.eq_ignore_ascii_case("unknown")) + .unwrap_or("Original message"); + let excerpt = original.map_or_else(String::new, |message| { + crate::model::sanitize_terminal_line(&message.preview_text()) + }); + let label = if excerpt.is_empty() { + format!("↩ {author}") + } else { + format!("↩ {author} · {excerpt}") + }; + let reply_action = actions + .iter() + .position(|action| *action == MessageAction::Reply); + let current = selected && reply_action == Some(app.selected_action); + let quote_width = body_width.saturating_sub(4).max(1); + let mut parts = wrap_cells(&label, quote_width); + if parts.len() > 2 { + parts[1] = format!( + "{}…", + truncate_cells(&parts[1], quote_width.saturating_sub(1)) + ); + parts.truncate(2); + } + for part in parts { + if let Some(action) = reply_action { + action_rows.push((lines.len(), action)); + } + lines.push(Line::from(vec![ + action_gutter(selected, current), + Span::styled(" │ ", Style::default().fg(ACCENT)), + Span::styled(part, action_style(current)), + ])); + } + } + if let Some(attachment) = &message.attachment + && !(message.id > 0 && attachment.supports_preview()) + { + let label = attachment_label(attachment, icons); + let current = + selected && actions.get(app.selected_action) == Some(&MessageAction::Attachment); + for part in wrap_cells(&label, body_width) { + if let Some(action) = actions + .iter() + .position(|action| *action == MessageAction::Attachment) + { + action_rows.push((lines.len(), action)); + } + lines.push(Line::from(vec![ + action_gutter(selected, current), + Span::styled(part, action_style(current)), + ])); + } + } + for mut row in body(message, body_width, app) { + let action = row + .action + .and_then(|action| actions.iter().position(|candidate| *candidate == action)); + if let Some(index) = action { + action_rows.push((lines.len(), index)); + } + row.line.spans.insert( + 0, + action_gutter(selected, selected && action == Some(app.selected_action)), + ); + lines.push(row.line); + } + let body_height = lines.len(); + let body_action = actions + .iter() + .position(|action| *action == MessageAction::Attachment); + action_rows.extend(append_message_actions( + &mut lines, + message, + &actions, + selected, + app.selected_action, + body_width, + if selected { "▎ " } else { " " }, + )); + RenderedMessage { + lines, + body_height, + body_action, + action_rows, + } +} + +fn body(message: &Message, width: usize, app: &AppState) -> Vec { + let mut body = + if !message.text.is_empty() || (message.attachment.is_none() && message.poll.is_none()) { + super::entities::render(message, width, app) + } else { + Vec::new() + }; + body.extend( + super::polls::transcript(message, width, app) + .into_iter() + .map(|row| super::entities::Row { + line: row.line, + action: Some(if row.spoiler { + MessageAction::Spoilers + } else { + MessageAction::Poll + }), + }), + ); + body.extend(super::reactions::transcript(message, width)); + body +} + +fn action_gutter(selected: bool, current: bool) -> Span<'static> { + if current { + Span::styled("› ", Style::default().fg(ACCENT).bold()) + } else { + gutter(selected) + } +} + +fn action_style(current: bool) -> Style { + let style = Style::default().fg(ACCENT); + if current { style.bold() } else { style } +} + +fn header(message: &Message, width: usize, selected: bool) -> Vec> { + wrap_cells(&message.sender, width) + .into_iter() + .map(|part| { + Line::from(vec![ + gutter(selected), + Span::styled(part, Style::default().fg(author_color(message)).bold()), + ]) + }) + .collect() +} + +fn author_color(message: &Message) -> Color { + const PALETTE: [Color; 6] = [ + Color::Cyan, + Color::Magenta, + Color::Yellow, + Color::Blue, + Color::LightCyan, + Color::LightMagenta, + ]; + if message.outgoing { + return Color::Green; + } + let mut hash = DefaultHasher::new(); + message.sender.hash(&mut hash); + PALETTE[usize::try_from(hash.finish() % 6).unwrap_or_default()] +} + +fn attachment_label(attachment: &Attachment, icons: Icons) -> String { + if attachment.kind == AttachmentKind::Sticker { + return format!( + "{}{} {}", + icons.attachment(attachment.kind), + attachment.fallback_emoji.as_deref().unwrap_or("◻"), + if attachment.preview_uses_thumbnail() { + "[sticker · static preview]" + } else { + "[sticker]" + } + ); + } + let kind = match attachment.kind { + AttachmentKind::Photo => "photo", + AttachmentKind::File => "file", + AttachmentKind::Video => "video", + AttachmentKind::Audio => "audio", + AttachmentKind::Sticker => "sticker", + AttachmentKind::Other => "attachment", + }; + let mut label = format!("{}[{kind}]", icons.attachment(attachment.kind)); + if let Some(name) = &attachment.file_name { + label.push(' '); + label.push_str(name); + } + label +} + +#[allow(clippy::too_many_arguments)] +fn append_message_actions( + result: &mut Vec>, + message: &Message, + actions: &[MessageAction], + selected: bool, + selected_action: usize, + body_width: usize, + continuation: &str, +) -> Vec<(usize, usize)> { + let mut action_rows = Vec::new(); + for (link_index, link) in message.links.iter().enumerate() { + let Some(action_index) = actions + .iter() + .position(|action| *action == MessageAction::Link(link_index)) + else { + continue; + }; + let label = if link.label == link.url { + format!("↗ {}", link.url) + } else { + format!("↗ {} → {}", link.label, link.url) + }; + push_action_lines( + result, + &mut action_rows, + continuation, + &label, + body_width, + action_index, + selected && selected_action == action_index, + true, + ); + } + for (button_index, button) in message.buttons.iter().enumerate() { + let action_index = actions + .iter() + .position(|action| *action == MessageAction::Button(button_index)); + let (icon, suffix) = match button.kind { + MessageButtonKind::Url => ("↗", ""), + MessageButtonKind::Callback => ("●", ""), + MessageButtonKind::Game => ("▶", ""), + MessageButtonKind::Unsupported => ("×", " · graphical client required"), + }; + let label = format!("{icon} [ {} ]{suffix}", button.label); + if let Some(action_index) = action_index { + push_action_lines( + result, + &mut action_rows, + continuation, + &label, + body_width, + action_index, + selected && selected_action == action_index, + button.kind == MessageButtonKind::Url, + ); + } else { + result.push(Line::from(vec![ + Span::styled(continuation.to_owned(), Style::default().fg(MUTED)), + Span::styled(label, Style::default().fg(MUTED)), + ])); + } + } + action_rows +} + +#[allow(clippy::too_many_arguments)] +fn push_action_lines( + lines: &mut Vec>, + action_rows: &mut Vec<(usize, usize)>, + prefix: &str, + label: &str, + width: usize, + action_index: usize, + selected: bool, + underlined: bool, +) { + for part in wrap_cells(label, width) { + let row = lines.len(); + let mut prefix_style = Style::default().fg(MUTED); + let mut action_style = Style::default().fg(ACCENT); + if underlined { + action_style = action_style.add_modifier(Modifier::UNDERLINED); + } + if selected { + prefix_style = prefix_style.fg(ACCENT).bold(); + action_style = action_style.bold(); + } + lines.push(Line::from(vec![ + Span::styled(prefix.to_owned(), prefix_style), + Span::styled(part, action_style), + ])); + action_rows.push((row, action_index)); + } +} + +pub(super) fn human_size(size: u64) -> String { + const KIB: u64 = 1024; + const MIB: u64 = KIB * 1024; + const GIB: u64 = MIB * 1024; + if size >= GIB { + decimal_size(size, GIB, "GiB") + } else if size >= MIB { + decimal_size(size, MIB, "MiB") + } else if size >= KIB { + decimal_size(size, KIB, "KiB") + } else { + format!("{size} B") + } +} + +fn decimal_size(size: u64, unit: u64, suffix: &str) -> String { + let whole = size / unit; + let decimal = size % unit * 10 / unit; + format!("{whole}.{decimal} {suffix}") +} diff --git a/src/ui/wrapping.rs b/src/ui/wrapping.rs new file mode 100644 index 0000000..f317e37 --- /dev/null +++ b/src/ui/wrapping.rs @@ -0,0 +1,57 @@ +//! Shared grapheme-aware wrapping for plain and styled transcript text. +//! Ranges retain source offsets; code preserves spaces, prose trims row ends. +use std::ops::Range; +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; + +pub(super) fn wrap_cells(value: &str, max: usize) -> Vec { + ranges(value, max, true) + .into_iter() + .map(|range| value[range].to_owned()) + .collect() +} + +pub(super) fn ranges(value: &str, max: usize, trim: bool) -> Vec> { + let mut lines = Vec::new(); + let mut offset = 0; + for source in value.split('\n') { + let mut start = 0; + let mut end = 0; + let mut width = 0_usize; + let mut push = |start, end| { + let text = &source[start..end]; + let length = if trim { + text.trim_end().len() + } else { + text.len() + }; + lines.push(offset + start..offset + start + length); + }; + for word in source.split_inclusive(char::is_whitespace) { + let word_width = word.width(); + if width.saturating_add(word_width) > max && end > start { + push(start, end); + start = end; + width = 0; + } + if word_width > max { + for grapheme in word.graphemes(true) { + let cells = grapheme.width(); + if width.saturating_add(cells) > max && end > start { + push(start, end); + start = end; + width = 0; + } + end += grapheme.len(); + width = width.saturating_add(cells); + } + } else { + end += word.len(); + width = width.saturating_add(word_width); + } + } + push(start, end); + offset += source.len() + 1; + } + lines +} diff --git a/tests/session_concurrency.rs b/tests/session_concurrency.rs index 3e6b4e6..52e68a2 100644 --- a/tests/session_concurrency.rs +++ b/tests/session_concurrency.rs @@ -8,27 +8,38 @@ fn concurrent_session_processes() { let directory = std::env::temp_dir().join(format!("termgram-session-{}", std::process::id())); std::fs::create_dir_all(&directory).unwrap(); let path = directory.join("shared.session"); - let mut children = (0..4) + let children = (0..4) .map(|_| { Command::new(std::env::current_exe().unwrap()) .args(["--exact", "session_writer", "--nocapture"]) .env("TERMGRAM_TEST_SESSION", &path) - .stdout(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) .spawn() .unwrap() }) .collect::>(); - let success = children - .iter_mut() - .all(|child| child.wait().unwrap().success()); - // Reap every child even if an earlier writer failed. - for child in &mut children { - let _ = child.wait(); - } + // Reap every child and retain its diagnostics, including native exit codes. + let failures = children + .into_iter() + .enumerate() + .filter_map(|(index, child)| { + let output = child.wait_with_output().unwrap(); + (!output.status.success()).then(|| { + format!( + "writer {index}: {}\nstdout:\n{}\nstderr:\n{}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ) + }) + }) + .collect::>(); std::fs::remove_dir_all(directory).unwrap(); assert!( - success, - "all processes must initialize and update the shared session" + failures.is_empty(), + "all processes must initialize and update the shared session:\n{}", + failures.join("\n") ); } diff --git a/tests/update_recovery.rs b/tests/update_recovery.rs new file mode 100644 index 0000000..f03341d --- /dev/null +++ b/tests/update_recovery.rs @@ -0,0 +1,159 @@ +use grammers_client::tl; +use grammers_session::{ + types::{ChannelState, UpdatesState}, + updates::{MessageBoxes, UpdatesLike}, +}; + +#[test] +fn startup_recovers_common_state_without_scanning_every_channel() { + let state = UpdatesState { + pts: 12, + qts: 0, + date: 123, + seq: 1, + channels: (1..=500).map(|id| ChannelState { id, pts: 20 }).collect(), + }; + let boxes = MessageBoxes::load(state); + assert!(boxes.get_difference().is_some()); + assert!(boxes.get_channel_difference().is_none()); + assert_eq!(boxes.session_state().channels.len(), 500); +} + +#[test] +fn channel_gap_recovers_from_the_local_cursor() { + let mut boxes = MessageBoxes::new(); + boxes.try_set_channel_state(42, 20); + boxes + .process_updates(UpdatesLike::Updates(tl::enums::Updates::UpdateShort( + tl::types::UpdateShort { + update: tl::types::UpdateChannelTooLong { + channel_id: 42, + pts: Some(30), + } + .into(), + date: 123, + }, + ))) + .unwrap(); + let request = boxes + .get_channel_difference() + .expect("channel gap must trigger recovery"); + assert_eq!(request.pts, 20); +} + +#[tokio::test] +async fn sdk_delivers_the_whole_batch_before_its_checkpoint() { + use grammers_client::{Client, SenderPool, client::UpdatesConfiguration}; + use grammers_session::storages::MemorySession; + use std::sync::Arc; + let pool = SenderPool::new(Arc::new(MemorySession::default()), 1); + let client = Client::new(pool.handle); + let (sender, receiver) = tokio::sync::mpsc::unbounded_channel(); + let mut stream = client + .stream_updates( + receiver, + UpdatesConfiguration { + catch_up: false, + update_queue_limit: None, + }, + ) + .await + .unwrap(); + sender + .send(UpdatesLike::Updates( + tl::types::Updates { + updates: vec![tl::enums::Update::PtsChanged, tl::enums::Update::PtsChanged], + users: Vec::new(), + chats: Vec::new(), + date: 123, + seq: 1, + } + .into(), + )) + .unwrap(); + let (updates, cursor) = Box::pin(stream.next_batch()).await.unwrap(); + assert_eq!(updates.len(), 2); + assert_eq!(cursor.seq, 1); + assert_eq!(cursor.date, 123); + + // Own delete RPCs use UpdateShort with NO_DATE and must carry their IDs in + // the very batch that advances the durable cursor, just like server pushes. + sender + .send(UpdatesLike::Updates( + tl::types::UpdateShort { + update: tl::types::UpdateDeleteMessages { + messages: vec![41, 42], + pts: 2, + pts_count: 2, + } + .into(), + date: 0, + } + .into(), + )) + .unwrap(); + let (updates, cursor) = Box::pin(stream.next_batch()).await.unwrap(); + let [grammers_client::update::Update::MessageDeleted(deleted)] = updates.as_slice() else { + panic!("deletion IDs must arrive with their checkpoint") + }; + assert_eq!(deleted.messages(), [41, 42]); + assert_eq!(cursor.pts, 2); + assert_eq!(cursor.date, 123, "NO_DATE retains the previous date"); + + sender + .send(UpdatesLike::Updates( + tl::types::UpdateShort { + update: tl::types::UpdateReadMessagesContents { + messages: vec![43], + pts: 3, + pts_count: 1, + date: None, + } + .into(), + date: 0, + } + .into(), + )) + .unwrap(); + let (updates, cursor) = Box::pin(stream.next_batch()).await.unwrap(); + let [grammers_client::update::Update::Raw(raw)] = updates.as_slice() else { + panic!("content receipt must arrive before its checkpoint") + }; + let tl::enums::Update::ReadMessagesContents(read) = &raw.raw else { + panic!("content receipt") + }; + assert_eq!(read.messages, [43]); + assert_eq!(cursor.pts, 3); + assert_eq!(cursor.date, 123); +} + +#[test] +fn active_group_uses_server_timeout_and_stops_when_closed() { + let mut boxes = MessageBoxes::new(); + boxes.try_set_channel_state(42, 20); + boxes.set_active_channel(Some((42, 99))); + assert_eq!(boxes.get_channel_difference().unwrap().pts, 20); + boxes.apply_channel_difference( + tl::types::updates::ChannelDifferenceEmpty { + r#final: true, + pts: 21, + timeout: Some(3), + } + .into(), + ); + assert!(boxes.get_channel_difference().is_none()); + let delay = boxes + .check_deadlines() + .duration_since(std::time::Instant::now()); + assert!(delay.as_secs() <= 3); + assert!(delay.as_secs() >= 2); + boxes.set_active_channel(None); + let delay = boxes + .check_deadlines() + .duration_since(std::time::Instant::now()); + assert!( + delay.as_secs() > 60, + "closing the group stops short polling" + ); + assert!(boxes.get_channel_difference().is_none()); +} diff --git a/vendor/README.md b/vendor/README.md index ed55552..919992e 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -1,14 +1,38 @@ # Upstream code +Command metadata and candidate layout in `src/commands.rs` and +`src/ui/commands.rs` follow the finite command registry in Codex's +`codex-rs/tui/src/slash_command.rs` and `bottom_pane/command_popup.rs` at +`78245b47af2a7aafcabe025828ceecca69db4df1`. These are original application +adapters: no Codex source or plugin runtime is copied. The existing Yazi key +parser, Termgram `TextInput` and Ratatui widgets retain input/rendering ownership. + +Conversation shading in `src/transcript.rs` follows the theme-relative design +of Codex `codex-rs/tui/src/style.rs` at +`78245b47af2a7aafcabe025828ceecca69db4df1` (https://github.com/openai/codex). +It is a small original adapter, not copied source: Yazi's existing background +report supplies the color, with no Codex probing code or additional input reader. + Yazi is pinned to `9203fd2604f867ab5ec18f24203b918975c4c00a` from -https://github.com/sxyazi/yazi. All Yazi crates except `yazi-term` are Git +https://github.com/sxyazi/yazi. All Yazi crates except `yazi-term` and `yazi-tty` are Git dependencies at that revision. `yazi-term/` copies the complete upstream `yazi-term/src` and its MIT license. Its standalone manifest resolves upstream workspace dependencies explicitly. -The only source changes expose mouse coordinates/modifiers, key state, and -`KeyEvent::new` to Rust consumers; upstream exposes some of these only to Lua. -Parsing, platform input, timeouts, and restoration code are unchanged. +Local source changes expose mouse coordinates/modifiers, key state, +`KeyEvent::new`, and clipboard payload access to Rust consumers. Clipboard reads +retain the OSC 5522 request ID in parsed success and error events, reject +oversized or inconsistent IDs, and include it in the existing aggregate budget. +The upstream parser, platform reader, size limits, timeouts, and restoration +lifecycle remain in use. + +`yazi-tty/` contains the complete upstream crate source and MIT license at the +same revision, with an explicit standalone manifest. `ReadClipboard::new` and +`with_id` provide a Rust construction path for the existing sequence formatter; +the optional ID is restricted to the protocol's character set and 128 bytes. +The Lua path retains its previous behavior with an empty ID. All other sequence +and TTY access code is unchanged. This small API patch avoids copying the +formatter or creating a separate parser or Lua host in Termgram. When updating, replace from one upstream revision, reapply these visibility changes, update all Yazi Git revisions together, and review the integration in @@ -45,3 +69,207 @@ WAL is not necessary for multi-process operation, and the bundled libSQL SQLite engine predates upstream's WAL-reset fix. Reapply and review these changes when upgrading the session crate. `tests/session_concurrency.rs` validates independent processes opening and writing one session, including initial schema creation. + +`src/message_box/mod.rs` also follows Telegram's documented startup recovery: +only common/secondary differences start immediately; channel differences are +requested when Telegram reports a gap, including `updateChannelTooLong`. +That notification preserves an existing local channel PTS instead of replacing +it with the remote PTS before recovery. The upstream connection-flow assertion +is adjusted; `tests/update_recovery.rs` checks both integration boundaries. +See https://core.telegram.org/api/updates#recovering-gaps. + +`grammers-client/` vendors crates.io 0.10.0 from the same Grammers revision +(`grammers-client/`). Original source, examples, tests and both licenses are +retained. `src/client/updates.rs` is adapted: + +- `next_batch` drains a complete update batch and returns its covered cursor; +- `restore_state` loads the application's durable cursor before polling; +- too-long differences emit a raw `PtsChanged` / `ChannelTooLong` invalidation + before replacement messages, allowing the local store to discard stale data. + +`src/telegram/local.rs` persists events before committing their cursor and reads +that cursor on restart. Telegram's session peer cache remains separate: dialog +iteration may advance its PTS without having cached any message content. + +Active-group polling uses the same message-box engine. `set_active_channel` +seeds only unknown PTS, recovers the viewed group immediately, and schedules its +next final difference using the server timeout (one second when absent). Leaving +the group restores the normal recovery deadline. A watch channel wakes the +existing update receive without cancellation or a second update consumer. +The deadline helper also accounts for deadlines moved earlier than the current +minimum. `tests/update_recovery.rs` covers activation, timeout and deactivation. + +`libsql/` vendors the published libSQL 0.9.30 Rust crate from +https://github.com/tursodatabase/libsql at +`0653c5788d77ef16a97c56ff3e9fdc11717a72d9` (`libsql/`), with the upstream +repository's `LICENSE.md`. The published standalone manifest, lockfile and original +source, examples and tests are retained; Cargo download metadata is omitted. An +empty workspace table allows the upstream tests to run independently of Termgram. +The root lockfile resolves the application dependency. + +The only source changes backport upstream PR #2282 at +`0070ff3331cd6d09425b812e1cd3ebe32e1d4206`, including its regression test: +https://github.com/tursodatabase/libsql/pull/2282. +`src/local/connection.rs` makes disconnect idempotent, and `src/local/impls.rs` +removes the redundant connection destructor. This prevents a second +`sqlite3_close_v2` call on freed memory, reported in upstream issue #2251 and +consistent with Termgram's Windows ARM64 session-writer access violations. +The native SQLite engine and session format are unchanged. Directly using that +upstream Git revision would also require its 0.10.0-pre.4 engine upgrade; this +backport keeps the fix bounded to connection ownership. Remove this patch when +a stable release includes the fix. Keep upstream formatting and use its focused +`drop_closes_the_handle_exactly_once` test to validate the ownership boundary. + +```sh +cargo test --locked --manifest-path vendor/libsql/Cargo.toml \ + --no-default-features --features core --lib \ + drop_closes_the_handle_exactly_once --target-dir target/libsql-regression +``` + +Attachment path arguments in `src/staging.rs` call the pinned `yazi-shared` +`shell/unix.rs` and `shell/windows.rs` implementations directly. Native Windows +quoting and Unix quoting remain upstream responsibilities. The former local +shell-like parser is removed. The file URL adapter uses the `url` crate; there +is no shell execution, variable expansion or copied argument parser. + +Native clipboard handling in `src/clipboard.rs` directly depends on arboard +3.6.1 (`image-data`, `wayland-data-control`). File-list priority and the WSL +PowerShell fallback follow the approach in Codex `clipboard_paste.rs` at +`78245b47af2a7aafcabe025828ceecca69db4df1`; no Codex source is copied. Termgram +preserves all original file references, owns PNG assets with persistent drafts, +and bounds blocking work and child output/time. The WSL adapter uses constant +PowerShell source and JSON, not interpolation of clipboard values into commands. +The Wayland dependency retains its default Rust backend; `native_lib` and `dlopen` +are not enabled, so the existing Linux musl builds do not require new Wayland +C development packages. Actual clipboard availability is desktop-dependent. + +Message editing in `src/telegram/editing.rs` calls Grammers 0.10.0 +`Client::edit_message` directly. `grammers-mtsender/src/sender.rs` already routes +RPC `OwnUpdate` results through the existing PTS-ordered update stream, so edits +need no second fetch/reader or SDK patch. Eligibility follows Desktop +`history/history_item.cpp` (`canBeEdited`, `isTooOldForEdit`) and +`data/data_peer.cpp` (`canEditMessagesIndefinitely`) at +`4d4da471fbee771c10e173a83c003ba1728989f1`; no Desktop source is copied. +The UTF-16 range adapter retains untouched entities and enclosing styles while +dropping partially replaced or changed semantic targets. + +`grammers-mtsender/` retains the complete published 0.10.0 crate and both licenses +from the same Grammers revision (`grammers-mtsender/`). An empty workspace table +keeps upstream regression tests independently runnable. The single source patch +in `src/sender.rs` retains the requested IDs for `messages.deleteMessages` when +routing its `AffectedMessages` RPC result. It emits the existing `UpdateShort` +/ `UpdateDeleteMessages` representation with the session adaptor's NO_DATE +sentinel, so application tombstones are committed before their covered PTS. +Request decoding now validates and consumes the TL constructor before reading +bare function fields, correcting the existing channel-delete/short-send decoder +as well. Content acknowledgements from `messages.readMessageContents` similarly retain +their IDs as `UpdateReadMessagesContents`. Other affected-message RPCs retain their +original path. The focused unit test checks both revoke scopes, channel PTS +ownership, content receipts and an unrelated RPC; +`tests/update_recovery.rs` verifies delivery together with the durable cursor. +Reapply this narrow patch until an upstream release retains deletion and content-receipt IDs. +Deletion eligibility in `src/telegram/deletion.rs` follows Desktop's `canDelete` +and `canDeleteForEveryone` at the revision recorded above; no source is copied. + +Message text copying calls the existing arboard 3.6.1 dependency. The persistent +Linux owner and native/terminal fallback behavior follow Codex +`codex-rs/tui/src/clipboard_copy.rs` at the Codex revision above; no source is +copied. The original adapter in `src/clipboard/copy.rs` runs one bounded writer, +serializes native reads/writes and passes WSL text through stdin with constant +PowerShell code. SSH writes only to the attached terminal. `src/terminal/clipboard.rs` +formats OSC 52 with Yazi's existing `SetClipboard`, following `yazi-widgets/src/clipboard.rs` +at the pinned revision, and flushes it through the same TTY on the main thread. +No clipboard reader, escape encoder or Linux selection implementation is copied. + +`grammers-client/src/client/messages.rs` exposes +`forward_messages_with_random_ids` while keeping `forward_messages` as the +existing random-ID-generating wrapper. The request builder, attribution flags, +RPC and result mapping are unchanged. Termgram supplies one OS-random ID for a +reviewed message and retains it across retries. This avoids duplicating the SDK's +forward request or generating a new delivery intent after a transient error. +`getrandom` 0.4.3, already present transitively, is now a direct dependency. + +`grammers-client/src/client/messages.rs` also exposes `SearchIter::sent_by(PeerRef)`, +a small setter for the existing `messages.search.from_id` field. Search query, +media/date filters, bounded iteration and pagination remain the SDK implementation +at the same pinned Grammers revision. Termgram's cloud command arguments reuse +the pinned Yazi platform tokenizer and Clap 4.6.7 (already a transitive dependency); +local regex queries never pass through either parser. + +Telegram mute changes follow the peer-override lifecycle in Desktop's +`Telegram/SourceFiles/data/notify/data_peer_notify_settings.cpp` at +`4d4da471fbee771c10e173a83c003ba1728989f1`: read the complete peer settings, +change mute time, preserve unrelated fields, and synchronize the returned state. +`src/telegram/notifications.rs` is an original typed-RPC adapter; no Desktop code +is copied. The shared RPC coordinator rejects stale mute snapshots after a +notification-settings update and refreshes authoritative dialogs. + +Unread-mention pagination adds `Client::unread_mentions` / `UnreadMentionIter` in +`grammers-client/src/client/messages.rs`. It reuses `IterBuffer`, `fill_buffer`, +peer mapping, limits and offset handling for `messages.getUnreadMentions`; no +second paging engine or update reader is added. Application navigation reuses +cloud-search cancellation and its existing result/context panel. The wrapper +currently queries the entire chat; topic scoping will be added with topics. + +Mention receipts follow Desktop's `history/history_widget.cpp` visibility rule +and `history/history_item.cpp` unread-media distinction at the Desktop revision +above. Text mentions use `messages.readMessageContents` (ordered PTS result) or +`channels.readMessageContents` (Bool acknowledgement). Voice, round-video and TTL +media require explicit consumption. This is an original adapter, not copied +Desktop source. Cache receipts update existing JSON and protect only snapshots +still in flight, including messages not yet cached when the receipt arrived. + +Desktop notification delivery depends on notify-rust 4.18.0 directly, with its +pure-Rust zbus backend for Unix desktops. Existing quick-xml 0.42.0 is a direct +Unix dependency for literal XML body escaping. Native delivery has one bounded +background worker; it does not implement platform notification protocols. +The legacy macOS adapter uses `schedule_raw` at the current instant because +`show` defers to Drop and hides send errors in this crate version. No native +callback waits or notification-center event loop are introduced. + +The terminal OSC 9/BEL choice follows Codex `codex-rs/tui/src/notifications/{mod, +osc9,bel}.rs` at `78245b47af2a7aafcabe025828ceecca69db4df1`. The original application +adapter uses existing Yazi `EMULATOR`, `TTY` and `TmuxPassthrough`; no Codex source, +terminal detection or input handling is copied. Quiet messages do not fall back +to protocols that cannot suppress sound. Grouping is implemented before delivery +because replacement IDs do not work uniformly across the native backends. + +Eligibility follows Desktop `notifications/notifications_manager.cpp` at the +Desktop revision above: unmuted chats notify, muted-chat mentions may use their +sender's notification state, and silent messages/sound-none remain quiet. Server +preview preferences apply independently. This is an original typed-RPC adapter, +not copied GPL source. Resolved settings expire and are invalidated by live +updates; notification-only metadata is never serialized into the message cache. + +## Message entity rendering references + +Telegram entity offsets use UTF-16 on the original text. Termgram converts them +while sanitizing to safe UTF-8, keeping this conversion separate from its cached +domain model and Ratatui renderer. The shared grapheme wrapper now returns source +ranges to retain styles and code whitespace. This is an extension of Termgram's +existing wrapper, not a copied Markdown renderer. + +Reviewed Codex `codex-rs/tui/src/wrapping.rs` and `markdown_render.rs` at +`78245b47af2a7aafcabe025828ceecca69db4df1` for source-offset projection, styled spans, +quote prefixes and code whitespace. No Codex code or runtime was copied. The +existing Ratatui and unicode-segmentation dependencies supply styles and grapheme +boundaries. Telegram's entity protocol supplies spoiler and collapsed-quote +semantics: https://core.telegram.org/api/entities. + +Polls use Grammers 0.10.0 typed TL requests and its existing ordered update +stream, with no SDK changes. Partial-result merging and visible-poll refresh +follow `Telegram/SourceFiles/data/data_poll.cpp` and +`Telegram/SourceFiles/api/api_polls.cpp` in Telegram Desktop at +`4d4da471fbee771c10e173a83c003ba1728989f1`; no Desktop source is copied. +The application preserves choices omitted by minimal updates and validates +stable answer bytes against a freshly fetched poll. The existing Ratatui entity +renderer, Yazi key resolver and single event loop own display and input. + +Ordinary reactions call Grammers `Client::send_reactions` with `InputReactions` +from a vector, preserving the identity and chosen order of existing reactions. +Available emoji, chat permissions and limits use typed TL requests at the same +SDK revision. Partial-result merging, oldest-choice replacement and batched +visible refresh follow Desktop `data/data_message_reactions.cpp` at +`4d4da471fbee771c10e173a83c003ba1728989f1`; no source is copied. Polls and reactions +share the bounded cache journal and the existing message-view visitor. Neither +adds a terminal reader or an independent Telegram update consumer. diff --git a/vendor/grammers-client/.cargo_vcs_info.json b/vendor/grammers-client/.cargo_vcs_info.json new file mode 100644 index 0000000..87015b7 --- /dev/null +++ b/vendor/grammers-client/.cargo_vcs_info.json @@ -0,0 +1,6 @@ +{ + "git": { + "sha1": "5c6d44ff30e02d6c9295bcf1fcb51403ad77c981" + }, + "path_in_vcs": "grammers-client" +} \ No newline at end of file diff --git a/vendor/grammers-client/Cargo.lock b/vendor/grammers-client/Cargo.lock new file mode 100644 index 0000000..ec27e6a --- /dev/null +++ b/vendor/grammers-client/Cargo.lock @@ -0,0 +1,2348 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aes" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138" +dependencies = [ + "cipher", + "cpubits", + "cpufeatures", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "cc" +version = "1.2.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer", + "crypto-common", + "inout", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "colored" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctr" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" +dependencies = [ + "cipher", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "ctutils", +] + +[[package]] +name = "dispatch2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +dependencies = [ + "bitflags", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core", +] + +[[package]] +name = "glass_pumpkin" +version = "2.0.0-rc0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec260059e5d59a4899d78dff8bf59abf81f8f5b3b44e9d3ba25c9dade1390a36" +dependencies = [ + "getrandom 0.4.3", + "num-bigint", + "num-integer", + "num-traits", + "once_cell", + "rand_core", +] + +[[package]] +name = "grammers-client" +version = "0.10.0" +dependencies = [ + "chrono", + "futures-util", + "grammers-crypto", + "grammers-mtsender", + "grammers-session", + "grammers-tl-types", + "html5ever", + "log", + "md5", + "mime_guess", + "pin-project-lite", + "pulldown-cmark", + "simple_logger", + "tokio", + "toml", + "url", +] + +[[package]] +name = "grammers-crypto" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23fe0fbd93bce6965d08248abdfec2d2544e11cb8d5c91dba3369c0a4295fd85" +dependencies = [ + "aes", + "ctr", + "getrandom 0.4.3", + "glass_pumpkin", + "hmac", + "num-bigint", + "num-traits", + "pbkdf2", + "sha1", + "sha2", +] + +[[package]] +name = "grammers-mtproto" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8d68ffa402c5f0707e22c29d44229eb01e968017dd0fb27e1adab953d49fc3" +dependencies = [ + "bytes", + "crc32fast", + "flate2", + "getrandom 0.4.3", + "grammers-crypto", + "grammers-tl-types", + "log", + "num-bigint", + "sha1", +] + +[[package]] +name = "grammers-mtsender" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f39b8556ddc94f7ec935135dcfbf84ca8628538e103779cdc701423b759b078e" +dependencies = [ + "bytes", + "grammers-crypto", + "grammers-mtproto", + "grammers-session", + "grammers-tl-types", + "hickory-resolver", + "locate-locale", + "log", + "os_info", + "tokio", + "tokio-socks", + "url", +] + +[[package]] +name = "grammers-session" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e4d5b9e434c6b9fc1e091fe82d1f201d8e19df5005d2c19e218b4977b4df506" +dependencies = [ + "grammers-tl-types", + "log", + "tokio", +] + +[[package]] +name = "grammers-tl-gen" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a37dda68c9e775dfbffb85b99826b5ac8144e9da40be3327e77cbcd8618fc30" +dependencies = [ + "grammers-tl-parser", +] + +[[package]] +name = "grammers-tl-parser" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "646fc5eeb27461ffcc94fcf9fda3ffd9b402a20250447ae4a65c39c1a905e381" +dependencies = [ + "crc32fast", +] + +[[package]] +name = "grammers-tl-types" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67d450e2f2588af535fe85a17b9a6df8338794ee8ad6bcb1b97d61a14d472fc5" +dependencies = [ + "grammers-tl-gen", + "grammers-tl-parser", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hickory-net" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2295ed2f9c31e471e1428a8f88a3f0e1f4b27c15049592138d1eebe9c35b183" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "futures-channel", + "futures-io", + "futures-util", + "hickory-proto", + "idna", + "ipnet", + "jni", + "rand", + "thiserror 2.0.18", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "hickory-proto" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni", + "once_cell", + "prefix-trie", + "rand", + "ring", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d58d28879ceecde6607729660c2667a081ccdc082e082675042793960f178c" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-net", + "hickory-proto", + "ipconfig", + "ipnet", + "jni", + "moka", + "ndk-context", + "once_cell", + "parking_lot", + "rand", + "resolv-conf", + "smallvec", + "system-configuration", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest", +] + +[[package]] +name = "html5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ipconfig" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f" +dependencies = [ + "socket2 0.5.10", + "widestring", + "windows-sys 0.48.0", + "winreg", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +dependencies = [ + "serde", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "js-sys" +version = "0.3.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93f0862381daaec758576dcc22eb7bbf4d7efd67328553f3b45a412a51a3fb21" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "locate-locale" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2835eaaed39a92511442aff277d4dca3d7674ca058df3bc45170661c2ccb4619" +dependencies = [ + "winapi", +] + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "markup5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "md5" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moka" +version = "0.12.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac832c50ced444ef6be0767a008b02c106a909ba79d1d830501e94b96f6b7e" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[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]] +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-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "objc2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] + +[[package]] +name = "os_info" +version = "3.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf20a545b305cf1da722b236b5155c9bb35f1d5ceb28c048bd96ca842f41b5b" +dependencies = [ + "android_system_properties", + "log", + "nix", + "objc2", + "objc2-foundation", + "objc2-ui-kit", + "windows-sys 0.61.2", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pbkdf2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" +dependencies = [ + "digest", + "hmac", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "prefix-trie" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7" +dependencies = [ + "either", + "ipnet", + "num-traits", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags", + "memchr", + "unicase", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simple_logger" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7038d0e96661bf9ce647e1a6f6ef6d6f3663f66d9bf741abf14ba4876071c17" +dependencies = [ + "colored", + "log", + "windows-sys 0.61.2", +] + +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "tendril" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" +dependencies = [ + "new_debug_unreachable", + "utf-8", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.4", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-socks" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7e2948f60dbe26b35f2c7fb74ac2854c1fddded0fe9d7548fcc674a246f7615" +dependencies = [ + "either", + "futures-util", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.110" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1de241cdc66a9d91bd84f097039eb140cdc6eec47e0cdbaf9d932a1dd6c35866" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.110" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e12fdf6649048f2e3de6d7d5ff3ced779cdedee0e0baffd7dff5cdfa3abc8a52" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.110" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e63d1795c565ac3462334c1e396fd46dbf481c40f51f5072c310717bc4fb309" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.110" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f9cdac23a5ce71f6bf9f8824898a501e511892791ea2a0c6b8568c68b9cb53" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web_atoms" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57a9779e9f04d2ac1ce317aee707aa2f6b773afba7b931222bff6983843b1576" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" + +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/vendor/grammers-client/Cargo.toml b/vendor/grammers-client/Cargo.toml new file mode 100644 index 0000000..33d72c2 --- /dev/null +++ b/vendor/grammers-client/Cargo.toml @@ -0,0 +1,145 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# If you are reading this file be aware that the original Cargo.toml +# will likely look very different (and much more reasonable). +# See Cargo.toml.orig for the original contents. + +[package] +edition = "2024" +name = "grammers-client" +version = "0.10.0" +authors = ["Lonami Exo "] +build = false +autolib = false +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = """ +A high level client to interact with Telegram's API. +""" +homepage = "https://codeberg.org/Lonami/grammers" +documentation = "https://docs.rs/grammers-client" +readme = "README.md" +keywords = [ + "mtproto", + "telegram", + "tl", +] +categories = ["api-bindings"] +license = "MIT OR Apache-2.0" +repository = "https://codeberg.org/Lonami/grammers" +resolver = "2" + +[features] +default = ["fs"] +fs = ["tokio/fs"] +html = ["html5ever"] +markdown = ["pulldown-cmark"] +parse_invite_link = ["url"] +proxy = ["grammers-mtsender/proxy"] + +[lib] +name = "grammers_client" +path = "src/lib.rs" + +[[example]] +name = "dialogs" +path = "examples/dialogs.rs" + +[[example]] +name = "downloader" +path = "examples/downloader.rs" + +[[example]] +name = "echo" +path = "examples/echo.rs" + +[[example]] +name = "inline-pagination" +path = "examples/inline-pagination.rs" + +[[example]] +name = "ping" +path = "examples/ping.rs" + +[[example]] +name = "send_album" +path = "examples/send_album.rs" + +[[test]] +name = "deps" +path = "tests/deps.rs" + +[dependencies.chrono] +version = "0.4.45" + +[dependencies.futures-util] +version = "0.3.32" +features = ["alloc"] +default-features = false + +[dependencies.grammers-crypto] +version = "0.10.0" + +[dependencies.grammers-mtsender] +version = "0.10.0" + +[dependencies.grammers-session] +version = "0.10.0" +default-features = false + +[dependencies.grammers-tl-types] +version = "0.10.0" +features = ["tl-mtproto"] + +[dependencies.html5ever] +version = "0.39.0" +optional = true + +[dependencies.log] +version = "0.4.33" + +[dependencies.md5] +version = "0.8.0" + +[dependencies.mime_guess] +version = "2.0.5" + +[dependencies.pin-project-lite] +version = "0.2" + +[dependencies.pulldown-cmark] +version = "0.13.4" +optional = true +default-features = false + +[dependencies.tokio] +version = "1.52.3" +features = [ + "rt", + "macros", +] +default-features = false + +[dependencies.url] +version = "2.5.8" +optional = true + +[dev-dependencies.simple_logger] +version = "5.2.0" +features = ["colors"] +default-features = false + +[dev-dependencies.tokio] +version = "1.52.3" +features = ["signal"] +default-features = false + +[dev-dependencies.toml] +version = "1.1.2" diff --git a/vendor/grammers-client/Cargo.toml.orig b/vendor/grammers-client/Cargo.toml.orig new file mode 100755 index 0000000..9c43261 --- /dev/null +++ b/vendor/grammers-client/Cargo.toml.orig @@ -0,0 +1,53 @@ +[package] +name = "grammers-client" +version = "0.10.0" +authors = ["Lonami Exo "] +license = "MIT OR Apache-2.0" +description = """ +A high level client to interact with Telegram's API. +""" +homepage = "https://codeberg.org/Lonami/grammers" +documentation = "https://docs.rs/grammers-client" +repository = "https://codeberg.org/Lonami/grammers" +keywords = ["mtproto", "telegram", "tl"] +categories = ["api-bindings"] +edition = "2024" + +[features] +markdown = ["pulldown-cmark"] +html = ["html5ever"] +proxy = ["grammers-mtsender/proxy"] +parse_invite_link = ["url"] +fs = ["tokio/fs"] +default = ["fs"] + +[dependencies] +chrono = "0.4.45" +futures-util = { version = "0.3.32", default-features = false, features = [ + "alloc" +] } +grammers-crypto = { path = "../grammers-crypto", version = "0.10.0" } +grammers-mtsender = { path = "../grammers-mtsender", version = "0.10.0" } +grammers-session = { path = "../grammers-session", version = "0.10.0", default-features = false } +grammers-tl-types = { path = "../grammers-tl-types", version = "0.10.0", features = [ + "tl-mtproto", +] } +html5ever = { version = "0.39.0", optional = true } +log = "0.4.33" +md5 = "0.8.0" +mime_guess = "2.0.5" +pin-project-lite = "0.2" +pulldown-cmark = { version = "0.13.4", default-features = false, optional = true } +tokio = { version = "1.52.3", default-features = false, features = [ + "rt", "macros" +] } +url = { version = "2.5.8", optional = true } + +[dev-dependencies] +tokio = { version = "1.52.3", default-features = false, features = [ + "signal", +] } +simple_logger = { version = "5.2.0", default-features = false, features = [ + "colors", +] } +toml = "1.1.2" diff --git a/vendor/grammers-client/DEPS.md b/vendor/grammers-client/DEPS.md new file mode 100755 index 0000000..18d9d0e --- /dev/null +++ b/vendor/grammers-client/DEPS.md @@ -0,0 +1,70 @@ +# Dependencies + +## grammers-crypto + +Used for supporting logging in to accounts with 2-factor authentication enabled. + +## grammers-mtsender + +Used to drive the network connection to Telegram. + +## grammers-session + +Used to load and store session data, such as authorization key or current user identifier. + +It also contains the logic needed to correctly process updates. + +## grammers-tl-types + +Used everywhere to invoke the "raw Telegram's API". It is the implementation of all the friendly +client methods. + +## pulldown-cmark + +Enables the user to use markdown text to send formatted messages. + +## html5ever + +Enables the user to use HTML text to send formatted messages. + +## tokio + +Used to coordinate the asynchronous methods of the client. + +## log + +Used to log the execution of the client to help debug issues. + +## md5 + +Needed when uploading files to Telegram. + +## mime_guess + +Used to guess the mime-type of uploaded files when sending media unless the user explicitly sets +the mime-type themselves. The mime-type is required by Telegram. + +## chrono + +Used for defining date types (for example, accessing the date of when a message was sent). + +## simple_logger + +Used by the examples to showcase how one may configure logging for more information. + +## toml + +Used to test that this file lists all dependencies from `Cargo.toml`. + +## pin-project-lite + +Used for return custom types that `impl Future` so that the requests can be further configured +without having to use `Box`. + +## futures-util + +Provides useful functions for working with futures/tasks. + +## url + +Used to parse certain URLs to offer features such as joining private chats via their invite link. diff --git a/vendor/grammers-client/LICENSE-APACHE b/vendor/grammers-client/LICENSE-APACHE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/vendor/grammers-client/LICENSE-APACHE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/vendor/grammers-client/LICENSE-MIT b/vendor/grammers-client/LICENSE-MIT new file mode 100644 index 0000000..5f4dc02 --- /dev/null +++ b/vendor/grammers-client/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Lonami + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/vendor/grammers-client/README.md b/vendor/grammers-client/README.md new file mode 100755 index 0000000..fcb4f9a --- /dev/null +++ b/vendor/grammers-client/README.md @@ -0,0 +1,28 @@ +# grammers-client + +This library is a higher-level interface to interact with Telegram's API. + +It contains the code necessary to create a client, connect to the API and +make Remote Procedure Calls (RPC) to it, such as signing in or sending a +message. + +The library is in development, but new releases are only cut rarely. +[Specifying the dependency from the git repository][dep-git] is recommended: + +```toml +grammers-client = { git = "https://codeberg.org/Lonami/grammers.git" } +``` + +Please note that traits across versions are not always compatible. +If you depend on other `grammers-` crates, be sure all of them use +a compatible version (e.g. all of them using `git`). + +Note that `grammers-tl-types` (needed to `client.invoke` "raw" functions) +is currently re-exported from within this crates, so it's easier to use the +re-export than to depend on the crate separatedly: + +```rust +use grammers_client::grammers_tl_types as tl; +``` + +[dep-git]: https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html#specifying-dependencies-from-git-repositories diff --git a/vendor/grammers-client/examples/README.md b/vendor/grammers-client/examples/README.md new file mode 100755 index 0000000..6f8af3e --- /dev/null +++ b/vendor/grammers-client/examples/README.md @@ -0,0 +1,42 @@ +# grammers-client examples + +This folder contains various simple, self-contained examples that you can use to base your +projects on. If you would like to see any other example that could be useful for others, or +you think these could be improved, feel free to contribute. + +For the time being, the examples are licensed in the same way as the libraries. + +## [ping.rs] + +Pings Telegram by using raw API. This is the simplest possible example, and requires no valid +API ID or hash, since it's not logging in to any account. + +## [echo.rs] + +Simple echo-bot. It will respond to plain text messages with the same content. The updates will +be processed concurrently to showcase how one might use multiple handles the same time. In a +real-world scenario, you would probably want to use some form of task pool to spawn tasks within +limits. Or you might not need this at all if you want to process updates in order. + +## [dialogs.rs] + +Logs in to a user account and prints the title and ID of all the dialogs (chats they have joined +to and private conversations). It shows how to spawn a task to handle the network in the +background and using a single client handle to actually perform remote calls. If the client was +not running and thus not handling the network, no response would ever arrive to the handles! + +This separation between client, which deals with all network events, and client handles, enables +you to have as many handles as you need, for as many tasks as you need (and enabling things like +iterators which keep their own handle). It requires some boilerplate to setup, but it's a very +powerful approach. + +## [downloader.rs] + +Downloads all media from a peer, including files located in datacenters different than your own. +After logging in, the example iterates all messages of the supplied peer name, printing in the console +the ID and the text for each one; videos, images, stickers and contacts are saved in the `target` folder. + +[ping.rs]: ping.rs +[echo.rs]: echo.rs +[dialogs.rs]: dialogs.rs +[downloader.rs]: downloader.rs diff --git a/vendor/grammers-client/examples/dialogs.rs b/vendor/grammers-client/examples/dialogs.rs new file mode 100755 index 0000000..5c52ca9 --- /dev/null +++ b/vendor/grammers-client/examples/dialogs.rs @@ -0,0 +1,97 @@ +//! Example to print the ID and title of all the dialogs. +//! +//! The `TG_ID` and `TG_HASH` environment variables must be set (learn how to do it for +//! [Windows](https://ss64.com/nt/set.html) or [Linux](https://ss64.com/bash/export.html)) +//! to Telegram's API ID and API hash respectively. +//! +//! Then, run it as: +//! +//! ```sh +//! cargo run --example dialogs +//! ``` + +use std::env; +use std::io::{self, BufRead as _, Write as _}; +use std::sync::Arc; + +use grammers_client::{Client, SignInError}; +use grammers_mtsender::SenderPool; +use grammers_session::storages::SqliteSession; +use simple_logger::SimpleLogger; +use tokio::runtime; + +type Result = std::result::Result>; + +const SESSION_FILE: &str = "dialogs.session"; + +fn prompt(message: &str) -> Result { + let stdout = io::stdout(); + let mut stdout = stdout.lock(); + stdout.write_all(message.as_bytes())?; + stdout.flush()?; + + let stdin = io::stdin(); + let mut stdin = stdin.lock(); + + let mut line = String::new(); + stdin.read_line(&mut line)?; + Ok(line) +} + +async fn async_main() -> Result<()> { + SimpleLogger::new() + .with_level(log::LevelFilter::Debug) + .init() + .unwrap(); + + let api_id = env!("TG_ID").parse().expect("TG_ID invalid"); + + let session = Arc::new(SqliteSession::open(SESSION_FILE).await?); + + let SenderPool { runner, handle, .. } = SenderPool::new(Arc::clone(&session), api_id); + let client = Client::new(handle); + let _ = tokio::spawn(runner.run()); + + if !client.is_authorized().await? { + println!("Signing in..."); + let phone = prompt("Enter your phone number (international format): ")?; + let token = client.request_login_code(&phone, env!("TG_HASH")).await?; + let code = prompt("Enter the code you received: ")?; + let signed_in = client.sign_in(&token, &code).await; + match signed_in { + Err(SignInError::PasswordRequired(password_token)) => { + // Note: this `prompt` method will echo the password in the console. + // Real code might want to use a better way to handle this. + let hint = password_token.hint().unwrap_or("None"); + let prompt_message = format!("Enter the password (hint {}): ", &hint); + let password = prompt(prompt_message.as_str())?; + + client + .check_password(password_token, password.trim()) + .await?; + } + Ok(_) => (), + Err(e) => panic!("{}", e), + }; + println!("Signed in!"); + } + + let mut dialogs = client.iter_dialogs(); + + println!("Showing up to {} dialogs:", dialogs.total().await?); + while let Some(dialog) = dialogs.next().await? { + let peer = dialog.peer(); + println!("- {: >10} {}", peer.id(), peer.name().unwrap_or_default()); + } + + // `runner.run()`'s task will be dropped (and disconnect occur) once the runtime exits. + Ok(()) +} + +fn main() -> Result<()> { + runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async_main()) +} diff --git a/vendor/grammers-client/examples/downloader.rs b/vendor/grammers-client/examples/downloader.rs new file mode 100644 index 0000000..78536f4 --- /dev/null +++ b/vendor/grammers-client/examples/downloader.rs @@ -0,0 +1,153 @@ +//! Example to download all messages and media from a peer. +//! +//! The `TG_ID` and `TG_HASH` environment variables must be set (learn how to do it for +//! [Windows](https://ss64.com/nt/set.html) or [Linux](https://ss64.com/bash/export.html)) +//! to Telegram's API ID and API hash respectively. +//! +//! Then, run it as: +//! +//! ```sh +//! cargo run --example downloader -- PEER_NAME +//! ``` +//! +//! Messages will be printed to stdout, and media will be saved in the `target/` folder locally, named +//! message-[MSG_ID].[EXT] + +use std::io::{BufRead, Write}; +use std::path::Path; +use std::sync::Arc; +use std::{env, io}; + +use grammers_client::media::Media; +use grammers_client::{Client, SignInError}; +use grammers_mtsender::SenderPool; +use grammers_session::storages::SqliteSession; +use mime::Mime; +use mime_guess::mime; +use simple_logger::SimpleLogger; +use tokio::runtime; + +type Result = std::result::Result>; + +const SESSION_FILE: &str = "downloader.session"; + +async fn async_main() -> Result<()> { + SimpleLogger::new() + .with_level(log::LevelFilter::Info) + .init() + .unwrap(); + + let api_id = env!("TG_ID").parse().expect("TG_ID invalid"); + let peer_name = env::args().nth(1).expect("peer name missing"); + + let session = Arc::new(SqliteSession::open(SESSION_FILE).await?); + + let SenderPool { runner, handle, .. } = SenderPool::new(Arc::clone(&session), api_id); + let client = Client::new(handle); + let _ = tokio::spawn(runner.run()); + + if !client.is_authorized().await? { + println!("Signing in..."); + let phone = prompt("Enter your phone number (international format): ")?; + let token = client.request_login_code(&phone, env!("TG_HASH")).await?; + let code = prompt("Enter the code you received: ")?; + let signed_in = client.sign_in(&token, &code).await; + match signed_in { + Err(SignInError::PasswordRequired(password_token)) => { + // Note: this `prompt` method will echo the password in the console. + // Real code might want to use a better way to handle this. + let hint = password_token.hint().unwrap(); + let prompt_message = format!("Enter the password (hint {}): ", &hint); + let password = prompt(prompt_message.as_str())?; + + client + .check_password(password_token, password.trim()) + .await?; + } + Ok(_) => (), + Err(e) => panic!("{}", e), + }; + println!("Signed in!"); + } + + let maybe_peer = client + .resolve_username(peer_name.as_str()) + .await? + .ok_or("no peer with username")? + .to_ref() + .await?; + + let peer = maybe_peer.unwrap_or_else(|| panic!("Peer {peer_name} could not be found")); + + let mut messages = client.iter_messages(peer); + + println!( + "Peer {} has {} total messages.", + peer_name, + messages.total().await.unwrap() + ); + + let mut counter = 0; + + while let Some(msg) = messages.next().await? { + counter += 1; + println!("Message {}:{}", msg.id(), msg.text()); + if let Some(media) = msg.media() { + let dest = format!( + "target/message-{}{}", + &msg.id().to_string(), + get_file_extension(&media) + ); + client + .download_media(&media, &Path::new(dest.as_str())) + .await + .expect("Error downloading message"); + } + } + + println!("Downloaded {counter} messages"); + + // `runner.run()`'s task will be dropped (and disconnect occur) once the runtime exits. + Ok(()) +} + +fn main() -> Result<()> { + runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async_main()) +} + +fn get_file_extension(media: &Media) -> String { + match media { + Media::Photo(_) => ".jpg".to_string(), + Media::Sticker(sticker) => get_mime_extension(sticker.document.mime_type()), + Media::Document(document) => get_mime_extension(document.mime_type()), + Media::Contact(_) => ".vcf".to_string(), + _ => String::new(), + } +} + +fn get_mime_extension(mime_type: Option<&str>) -> String { + mime_type + .map(|m| { + let mime: Mime = m.parse().unwrap(); + format!(".{}", mime.subtype()) + }) + .unwrap_or_default() +} + +fn prompt(message: &str) -> Result { + let stdout = io::stdout(); + let mut stdout = stdout.lock(); + stdout.write_all(message.as_bytes())?; + stdout.flush()?; + + let stdin = io::stdin(); + let mut stdin = stdin.lock(); + + let mut line = String::new(); + stdin.read_line(&mut line)?; + Ok(line) +} diff --git a/vendor/grammers-client/examples/echo.rs b/vendor/grammers-client/examples/echo.rs new file mode 100644 index 0000000..29d0899 --- /dev/null +++ b/vendor/grammers-client/examples/echo.rs @@ -0,0 +1,139 @@ +//! Example to echo user text messages. Updates are handled concurrently. +//! +//! The `TG_ID` and `TG_HASH` environment variables must be set (learn how to do it for +//! [Windows](https://ss64.com/nt/set.html) or [Linux](https://ss64.com/bash/export.html)) +//! to Telegram's API ID and API hash respectively. +//! +//! Then, run it as: +//! +//! ```sh +//! cargo run --example echo -- BOT_TOKEN +//! ``` + +use std::sync::Arc; +use std::{env, time::Duration}; + +use grammers_client::Client; +use grammers_client::client::UpdatesConfiguration; +use grammers_client::update::Update; +use grammers_mtsender::SenderPool; +use grammers_session::storages::SqliteSession; +use simple_logger::SimpleLogger; +use tokio::task::JoinSet; +use tokio::{runtime, time::sleep}; + +type Result = std::result::Result<(), Box>; + +const SESSION_FILE: &str = "echo.session"; + +async fn handle_update(client: Client, update: Update) { + match update { + Update::NewMessage(message) if !message.outgoing() => { + let peer = message.peer().unwrap(); + println!( + "Responding to {}", + peer.name().unwrap_or(&format!("id {}", message.peer_id())) + ); + if message.text() == "slow" { + sleep(Duration::from_secs(5)).await; + } + if let Err(e) = client + .send_message(peer.to_ref().await.unwrap().unwrap(), message.text()) + .await + { + println!("Failed to respond! {e}"); + }; + } + _ => {} + } +} + +async fn async_main() -> Result { + SimpleLogger::new() + .with_level(log::LevelFilter::Debug) + .init() + .unwrap(); + + let api_id = env!("TG_ID").parse().expect("TG_ID invalid"); + let token = env::args().nth(1).expect("token missing"); + + let session = Arc::new(SqliteSession::open(SESSION_FILE).await?); + + let SenderPool { + runner, + updates, + handle, + } = SenderPool::new(Arc::clone(&session), api_id); + let client = Client::new(handle.clone()); + let pool_task = tokio::spawn(runner.run()); + + if !client.is_authorized().await? { + println!("Signing in..."); + client.bot_sign_in(&token, env!("TG_HASH")).await?; + println!("Signed in!"); + } + + println!("Waiting for messages..."); + + // This example spawns a task to handle each update. + // To guarantee that all handlers run to completion, they're stored in this set. + // You can use `task::spawn` if you don't care about dropping unfinished handlers midway. + let mut handler_tasks = JoinSet::new(); + let mut updates = client + .stream_updates( + updates, + UpdatesConfiguration { + catch_up: true, + ..Default::default() + }, + ) + .await?; + loop { + // Empty finished handlers (you could look at their return value here too.) + while let Some(_) = handler_tasks.try_join_next() {} + + // This code uses `select` on Ctrl+C to gracefully stop the client and have a chance to + // save the session. You could have fancier logic to save the session if you wanted to + // (or even save it on every update). Or you could also ignore Ctrl+C and just use + // `let update = client.next_update().await?`. + tokio::select! { + _ = tokio::signal::ctrl_c() => break, + update = updates.next() => { + let update = update?; + let handle = client.clone(); + handler_tasks.spawn(handle_update(handle, update)); + } + } + } + + println!("Saving session file..."); + updates.sync_update_state().await?; // you usually want this before closing the session + + // Pool's `run()` won't finish until all handles are dropped or quit is called. + // Here there are at least three handles alive: `handle`, `client` and `updates` + // which contains a `client`. Any ongoing `handle_update` handlers have one client too. + // In this case, it's easier to call `handle.quit()` to close them all. + // + // You don't need to explicitly close the connection, but this is a way to do it gracefully. + // This also gives a chance to the handlers to finish their work by handling the `Dropped` + // error from any pending method calls (RPC invocations). + // + // You can try this graceful shutdown by sending a message saying "slow" and then pressing Ctrl+C. + println!("Gracefully closing connection to notify all pending handlers..."); + handle.quit(); + let _ = pool_task.await; + + // Give a chance to all on-going handlers to finish. + println!("Waiting for any slow handlers to finish..."); + while let Some(_) = handler_tasks.join_next().await {} + + Ok(()) +} + +fn main() -> Result { + runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async_main()) +} diff --git a/vendor/grammers-client/examples/inline-pagination.rs b/vendor/grammers-client/examples/inline-pagination.rs new file mode 100644 index 0000000..d8bd827 --- /dev/null +++ b/vendor/grammers-client/examples/inline-pagination.rs @@ -0,0 +1,168 @@ +//! Example to showcase how to achieve pagination with inline buttons. +//! +//! The `TG_ID` and `TG_HASH` environment variables must be set (learn how to do it for +//! [Windows](https://ss64.com/nt/set.html) or [Linux](https://ss64.com/bash/export.html)) +//! to Telegram's API ID and API hash respectively. +//! +//! Then, run it as: +//! +//! ```sh +//! cargo run --example echo -- BOT_TOKEN +//! ``` +//! +//! In order to achieve pagination, the button data must contain enough information for the code +//! to determine which "offset" should it use when querying additional data. +//! +//! For this example, the button contains the last two values of the fibonacci sequence where it +//! last left off, separated by a comma. This way, the code can resume. +//! +//! If there is no comma, then there is a single number, which we use to know what was clicked. +//! +//! If it's the special string "done", then we know we've reached the end (there is a limit to +//! how much data a button's payload can contain, and to keep it simple, we're storing it inline +//! in decimal, so the numbers can't get too large). + +use std::env; +use std::sync::Arc; + +use grammers_client::Client; +use grammers_client::message::{Button, InputMessage, ReplyMarkup}; +use grammers_client::update::Update; +use grammers_mtsender::SenderPool; +use grammers_session::storages::SqliteSession; +use simple_logger::SimpleLogger; +use tokio::{runtime, task}; + +type Result = std::result::Result<(), Box>; + +const SESSION_FILE: &str = "inline-pagination.session"; + +const NUMBERS_PER_PAGE: usize = 4; + +// https://core.telegram.org/bots/api#inlinekeyboardbutton +const MAX_PAYLOAD_DATA_LEN: usize = 64; + +/// Generate the inline keyboard reply markup with a few more numbers from the sequence. +fn fib_markup(mut a: u128, mut b: u128) -> ReplyMarkup { + let mut rows = Vec::with_capacity(NUMBERS_PER_PAGE + 1); + for _ in 0..NUMBERS_PER_PAGE { + let text = a.to_string(); + rows.push(vec![Button::data(&text, text.as_bytes())]); + + let bb = b; + b += a; + a = bb; + } + + let next = format!("{a},{b}"); + if next.len() > MAX_PAYLOAD_DATA_LEN { + rows.push(vec![Button::data("I'm satisfied!!", b"done".to_vec())]); + } else { + rows.push(vec![ + Button::data("Restart!", b"0,1".to_vec()), + Button::data("More!", format!("{a},{b}").into_bytes()), + ]); + } + ReplyMarkup::from_buttons(&rows) +} + +async fn handle_update(_client: Client, update: Update) -> Result { + match update { + Update::NewMessage(message) if message.text() == "/start" => { + message + .respond( + InputMessage::new() + .text("Here's a fibonacci") + .reply_markup(fib_markup(0, 1)), + ) + .await?; + } + Update::CallbackQuery(query) => { + let data = std::str::from_utf8(query.data()).unwrap(); + println!("Got callback query for {data}"); + + // First check special-case. + if data == "done" { + query.answer().edit("Glad you liked it 👍").await?; + return Ok(()); + } + + // Otherwise get the stored number(s). + let mut parts = data.split(','); + let a = parts.next().unwrap().parse::().unwrap(); + if let Some(b) = parts.next() { + let os = (0..b.len()).map(|_| 'o').collect::(); + let b = b.parse::().unwrap(); + query + .answer() + .edit( + InputMessage::from(format!("S{os} much fibonacci 🔢")) + .reply_markup(fib_markup(a, b)), + ) + .await?; + } else if a % 2 == 0 { + query.answer().text("Even that's a number!").send().await?; + } else { + query.answer().alert("That's odd…").send().await?; + } + } + _ => {} + } + + Ok(()) +} + +async fn async_main() -> Result { + SimpleLogger::new() + .with_level(log::LevelFilter::Debug) + .init() + .unwrap(); + + let api_id = env!("TG_ID").parse().expect("TG_ID invalid"); + let token = env::args().nth(1).expect("token missing"); + + let session = Arc::new(SqliteSession::open(SESSION_FILE).await?); + + let SenderPool { + runner, + handle, + updates, + } = SenderPool::new(Arc::clone(&session), api_id); + let client = Client::new(handle); + let _ = tokio::spawn(runner.run()); + + if !client.is_authorized().await? { + println!("Signing in..."); + client.bot_sign_in(&token, env!("TG_HASH")).await?; + println!("Signed in!"); + } + + println!("Waiting for messages..."); + let mut updates = client.stream_updates(updates, Default::default()).await?; + loop { + tokio::select! { + _ = tokio::signal::ctrl_c() => break, + update = updates.next() => { + let update = update?; + let handle = client.clone(); + task::spawn(async move { + match handle_update(handle, update).await { + Ok(_) => {} + Err(e) => eprintln!("Error handling updates!: {e}"), + } + }); + } + } + } + + // `runner.run()`'s task will be dropped (and disconnect occur) once the runtime exits. + Ok(()) +} + +fn main() -> Result { + runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async_main()) +} diff --git a/vendor/grammers-client/examples/ping.rs b/vendor/grammers-client/examples/ping.rs new file mode 100755 index 0000000..4bc026a --- /dev/null +++ b/vendor/grammers-client/examples/ping.rs @@ -0,0 +1,42 @@ +//! This example sends a ping to Telegram through raw API, and that's it. +//! +//! ```sh +//! cargo run --example ping +//! ``` + +use std::sync::Arc; + +use grammers_client::Client; +use grammers_mtsender::SenderPool; +use grammers_session::storages::SqliteSession; +use grammers_tl_types as tl; +use tokio::runtime; + +type Result = std::result::Result<(), Box>; + +async fn async_main() -> Result { + let session = Arc::new(SqliteSession::open("ping.session").await?); + let SenderPool { runner, handle, .. } = SenderPool::new(Arc::clone(&session), 1); + let client = Client::new(handle); + let pool_task = tokio::spawn(runner.run()); + + println!("Sending ping..."); + dbg!(client.invoke(&tl::functions::Ping { ping_id: 0 }).await?); + println!("Ping sent successfully!"); + + // Pool's `run()` won't finish until all handles are dropped or quit is called. + // + // You don't need to explicitly close the connection, but this is a way to do it gracefully. + drop(client); + let _ = pool_task.await; + + Ok(()) +} + +fn main() -> Result { + runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async_main()) +} diff --git a/vendor/grammers-client/examples/send_album.rs b/vendor/grammers-client/examples/send_album.rs new file mode 100644 index 0000000..884e347 --- /dev/null +++ b/vendor/grammers-client/examples/send_album.rs @@ -0,0 +1,125 @@ +use std::env; +use std::io; +use std::io::{BufRead, Write}; +use std::sync::Arc; + +use grammers_client::Client; +use grammers_client::SignInError; +use grammers_client::media::InputMedia; +use grammers_mtsender::SenderPool; +use grammers_session::storages::SqliteSession; +use grammers_session::types::{PeerAuth, PeerId, PeerRef}; +use grammers_tl_types as tl; +use simple_logger::SimpleLogger; +use tokio::runtime; + +type Result = std::result::Result>; + +const SESSION_FILE: &str = "me.session"; + +async fn async_main() -> Result<()> { + SimpleLogger::new() + .with_level(log::LevelFilter::Info) + .init() + .unwrap(); + + let api_id: i32 = env!("TG_ID").parse().expect("TG_ID invalid"); + let api_hash: String = env!("TG_HASH").to_string(); + + let session = Arc::new(SqliteSession::open(SESSION_FILE).await?); + + let SenderPool { runner, handle, .. } = SenderPool::new(Arc::clone(&session), api_id); + let client = Client::new(handle); + let _ = tokio::spawn(runner.run()); + + if !client.is_authorized().await? { + println!("Signing in..."); + let phone = prompt("Enter your phone number (international format): ")?; + let token = client.request_login_code(&phone, &api_hash).await?; + let code = prompt("Enter the code you received: ")?; + let signed_in = client.sign_in(&token, &code).await; + match signed_in { + Err(SignInError::PasswordRequired(password_token)) => { + // Note: this `prompt` method will echo the password in the console. + // Real code might want to use a better way to handle this. + let hint = password_token.hint().unwrap_or(""); + let prompt_message = format!("Enter the password (hint {}): ", &hint); + let password = prompt(prompt_message.as_str())?; + + client + .check_password(password_token, password.trim()) + .await?; + } + Ok(_) => (), + Err(e) => panic!("{}", e), + }; + println!("Signed in!"); + } + + let peer = client + .resolve_username("telegram") + .await? + .ok_or("no peer with username")? + .to_ref() + .await? + .unwrap(); + + let messages = client.get_messages_by_id(peer, &[437, 438]).await?; + let medias = messages + .into_iter() + .map(|message| { + let media = message + .unwrap() + .media() + .unwrap() + .to_raw_input_media() + .unwrap(); + let (id, video_cover, video_timestamp) = match media { + tl::enums::InputMedia::Document(x) => (x.id, x.video_cover, x.video_timestamp), + _ => panic!("must be document"), + }; + let media = tl::types::InputMediaDocument { + spoiler: true, + id, + video_cover, + video_timestamp, + ttl_seconds: None, + query: None, + }; + InputMedia::new().media(media) + }) + .collect(); + + let peer = PeerRef { + id: PeerId::self_user(), + auth: PeerAuth::default(), + }; + + let m = client.send_album(peer, medias).await?; + println!("{:?}", m); + + // `runner.run()`'s task will be dropped (and disconnect occur) once the runtime exits. + Ok(()) +} + +fn main() -> Result<()> { + runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async_main()) +} + +fn prompt(message: &str) -> Result { + let stdout = io::stdout(); + let mut stdout = stdout.lock(); + stdout.write_all(message.as_bytes())?; + stdout.flush()?; + + let stdin = io::stdin(); + let mut stdin = stdin.lock(); + + let mut line = String::new(); + stdin.read_line(&mut line)?; + Ok(line) +} diff --git a/vendor/grammers-client/src/client/auth.rs b/vendor/grammers-client/src/client/auth.rs new file mode 100644 index 0000000..d4d10ee --- /dev/null +++ b/vendor/grammers-client/src/client/auth.rs @@ -0,0 +1,497 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +use std::fmt; + +use grammers_crypto::two_factor_auth::{calculate_2fa, check_p_and_g}; +use grammers_mtsender::InvocationError; +use grammers_session::types::{PeerInfo, UpdateState, UpdatesState}; +use grammers_tl_types as tl; + +use super::Client; +use crate::peer::User; +use crate::utils; + +/// The error type which is returned when signing in fails. +#[derive(Debug)] +#[allow(clippy::large_enum_variant)] +pub enum SignInError { + /// Sign-up with an official client is required. + /// + /// Third-party applications, such as those developed with *grammers*, + /// cannot be used to register new accounts. For more details, see this + /// [comment regarding third-party app sign-ups](https://bugs.telegram.org/c/25410/1): + /// > \[…] if a user doesn’t have a Telegram account yet, + /// > they will need to create one first using an official mobile Telegram app. + SignUpRequired, + /// The account has 2FA enabled, and the password is required. + PasswordRequired(PasswordToken), + /// The code used to complete login was not valid. + InvalidCode, + /// The 2FA password used to complete login was not valid. + InvalidPassword(PasswordToken), + /// A generic invocation error occured. + Other(InvocationError), +} + +impl fmt::Display for SignInError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + use SignInError::*; + match self { + SignUpRequired => write!(f, "sign in error: sign up with official client required"), + PasswordRequired(_password) => write!(f, "2fa password required"), + InvalidCode => write!(f, "sign in error: invalid code"), + InvalidPassword(_password) => write!(f, "invalid password"), + Other(e) => write!(f, "sign in error: {e}"), + } + } +} + +impl std::error::Error for SignInError {} + +/// Login token needed to continue the login process after sending the code. +pub struct LoginToken { + pub(crate) phone: String, + pub(crate) phone_code_hash: String, +} + +/// Password token needed to complete a 2FA login. +#[derive(Debug)] +pub struct PasswordToken { + pub(crate) password: tl::types::account::Password, +} + +impl PasswordToken { + pub fn new(password: tl::types::account::Password) -> Self { + PasswordToken { password } + } + + pub fn hint(&self) -> Option<&str> { + self.password.hint.as_deref() + } +} + +/// Method implementations related with the authentication of the user into the API. +/// +/// Most requests to the API require the user to have authorized their key, stored in the session, +/// before being able to use them. +impl Client { + /// Returns `true` if the current account is authorized. Otherwise, + /// logging in will be required before being able to invoke requests. + /// + /// This will likely be the first method you want to call on a connected [`Client`]. After you + /// determine if the account is authorized or not, you will likely want to use either + /// [`Client::bot_sign_in`] or [`Client::request_login_code`]. + /// + /// # Examples + /// + /// ``` + /// # async fn f(client: grammers_client::Client) -> Result<(), Box> { + /// if client.is_authorized().await? { + /// println!("Client already authorized and ready to use!"); + /// } else { + /// println!("Client is not authorized, you will need to sign_in!"); + /// } + /// # Ok(()) + /// # } + /// ``` + pub async fn is_authorized(&self) -> Result { + match self.invoke(&tl::functions::updates::GetState {}).await { + Ok(_) => Ok(true), + Err(InvocationError::Rpc(e)) if e.code == 401 => Ok(false), + Err(err) => Err(err), + } + } + + async fn complete_login( + &self, + auth: tl::types::auth::Authorization, + ) -> Result { + // In the extremely rare case where `Err` happens, there's not much we can do. + // `message_box` will try to correct its state as updates arrive. + let update_state = self.invoke(&tl::functions::updates::GetState {}).await.ok(); + + let user = User::from_raw(self, auth.user); + let auth = user.to_ref().await?.unwrap().auth; + + self.0 + .session + .cache_peer(&PeerInfo::User { + id: user.id().bare_id_unchecked(), + auth: Some(auth), + bot: Some(user.is_bot()), + is_self: Some(true), + }) + .await?; + if let Some(tl::enums::updates::State::State(state)) = update_state { + self.0 + .session + .set_update_state(UpdateState::All(UpdatesState { + pts: state.pts, + qts: state.qts, + date: state.date, + seq: state.seq, + channels: Vec::new(), + })) + .await?; + } + + Ok(user) + } + + /// Signs in to the bot account associated with this token. + /// + /// This is the method you need to call to use the client under a bot account. + /// + /// It is recommended to save the session on successful login. Some session storages will do this + /// automatically. If saving fails, it is recommended to [`Client::sign_out`]. If the session is never + /// saved post-login, then the authorization will be "lost" in the list of logged-in clients, since it + /// is unaccessible. + /// + /// # Examples + /// + /// ``` + /// # async fn f(client: grammers_client::Client) -> Result<(), Box> { + /// // Note: these values are obviously fake. + /// // Obtain your own with the developer's phone at https://my.telegram.org. + /// const API_HASH: &str = "514727c32270b9eb8cc16daf17e21e57"; + /// // Obtain your own by talking to @BotFather via a Telegram app. + /// const TOKEN: &str = "776609994:AAFXAy5-PawQlnYywUlZ_b_GOXgarR3ah_yq"; + /// + /// let user = match client.bot_sign_in(TOKEN, API_HASH).await { + /// Ok(user) => user, + /// Err(err) => { + /// println!("Failed to sign in as a bot :(\n{}", err); + /// return Err(err.into()); + /// } + /// }; + /// + /// if let Some(first_name) = user.first_name() { + /// println!("Signed in as {}!", first_name); + /// } else { + /// println!("Signed in!"); + /// } + /// + /// # Ok(()) + /// # } + /// ``` + pub async fn bot_sign_in(&self, token: &str, api_hash: &str) -> Result { + let request = tl::functions::auth::ImportBotAuthorization { + flags: 0, + api_id: self.0.api_id, + api_hash: api_hash.to_string(), + bot_auth_token: token.to_string(), + }; + + let result = match self.invoke(&request).await { + Ok(x) => x, + Err(InvocationError::Rpc(err)) if err.code == 303 => { + let old_dc_id = self.0.session.home_dc_id()?; + let new_dc_id = err.value.unwrap() as i32; + // Disconnect from current DC to cull the now-unused connection. + // This also gives a chance for the new home DC to export its authorization + // if there's a need to connect back to the old DC after having logged in. + self.0.handle.disconnect_from_dc(old_dc_id); + self.0.session.set_home_dc_id(new_dc_id).await?; + self.invoke(&request).await? + } + Err(e) => return Err(e.into()), + }; + + match result { + tl::enums::auth::Authorization::Authorization(x) => { + self.complete_login(x).await.map_err(Into::into) + } + tl::enums::auth::Authorization::SignUpRequired(_) => { + panic!("API returned SignUpRequired even though we're logging in as a bot"); + } + } + } + + /// Requests the login code for the account associated to the given phone + /// number via another Telegram application or SMS. + /// + /// This is the method you need to call before being able to sign in to a user account. + /// After you obtain the code and it's inside your program (e.g. ask the user to enter it + /// via the console's standard input), you will need to [`Client::sign_in`] to complete the + /// process. + /// + /// # Examples + /// + /// ``` + /// # async fn f(client: grammers_client::Client) -> Result<(), Box> { + /// // Note: these values are obviously fake. + /// // Obtain your own with the developer's phone at https://my.telegram.org. + /// const API_HASH: &str = "514727c32270b9eb8cc16daf17e21e57"; + /// // The phone used here does NOT need to be the same as the one used by the developer + /// // to obtain the API ID and hash. + /// const PHONE: &str = "+1 415 555 0132"; + /// + /// if !client.is_authorized().await? { + /// // We're not logged in, so request the login code. + /// client.request_login_code(PHONE, API_HASH).await?; + /// } + /// # Ok(()) + /// # } + /// ``` + pub async fn request_login_code( + &self, + phone: &str, + api_hash: &str, + ) -> Result { + let request = tl::functions::auth::SendCode { + phone_number: phone.to_string(), + api_id: self.0.api_id, + api_hash: api_hash.to_string(), + settings: tl::types::CodeSettings { + allow_flashcall: false, + current_number: false, + allow_app_hash: false, + allow_missed_call: false, + allow_firebase: false, + logout_tokens: None, + token: None, + app_sandbox: None, + unknown_number: false, + } + .into(), + }; + + use tl::enums::auth::SentCode as SC; + + let sent_code: tl::types::auth::SentCode = match self.invoke(&request).await { + Ok(x) => match x { + SC::Code(code) => code, + SC::Success(_) => panic!("should not have logged in yet"), + SC::PaymentRequired(_) => unimplemented!(), + }, + Err(InvocationError::Rpc(err)) if err.code == 303 => { + let old_dc_id = self.0.session.home_dc_id()?; + let new_dc_id = err.value.unwrap() as i32; + // Disconnect from current DC to cull the now-unused connection. + // This also gives a chance for the new home DC to export its authorization + // if there's a need to connect back to the old DC after having logged in. + self.0.handle.disconnect_from_dc(old_dc_id); + self.0.session.set_home_dc_id(new_dc_id).await?; + match self.invoke(&request).await? { + SC::Code(code) => code, + SC::Success(_) => panic!("should not have logged in yet"), + SC::PaymentRequired(_) => unimplemented!(), + } + } + Err(e) => return Err(e.into()), + }; + + Ok(LoginToken { + phone: phone.to_string(), + phone_code_hash: sent_code.phone_code_hash, + }) + } + + /// Signs in to the user account. + /// + /// You must call [`Client::request_login_code`] before using this method in order to obtain + /// necessary login token, and also have asked the user for the login code. + /// + /// It is recommended to save the session on successful login. Some session storages will do this + /// automatically. If saving fails, it is recommended to [`Client::sign_out`]. If the session is never + /// saved post-login, then the authorization will be "lost" in the list of logged-in clients, since it + /// is unaccessible. + /// + /// # Examples + /// + /// ``` + /// # use grammers_client::SignInError; + /// + /// async fn f(client: grammers_client::Client) -> Result<(), Box> { + /// # const API_HASH: &str = ""; + /// # const PHONE: &str = ""; + /// fn ask_code_to_user() -> String { + /// unimplemented!() + /// } + /// + /// let token = client.request_login_code(PHONE, API_HASH).await?; + /// let code = ask_code_to_user(); + /// + /// let user = match client.sign_in(&token, &code).await { + /// Ok(user) => user, + /// Err(SignInError::PasswordRequired(_token)) => panic!("Please provide a password"), + /// Err(SignInError::SignUpRequired) => panic!("Sign up required"), + /// Err(err) => { + /// println!("Failed to sign in as a user :(\n{}", err); + /// return Err(err.into()); + /// } + /// }; + /// + /// if let Some(first_name) = user.first_name() { + /// println!("Signed in as {}!", first_name); + /// } else { + /// println!("Signed in!"); + /// } + /// # Ok(()) + /// # } + /// ``` + pub async fn sign_in(&self, token: &LoginToken, code: &str) -> Result { + match self + .invoke(&tl::functions::auth::SignIn { + phone_number: token.phone.clone(), + phone_code_hash: token.phone_code_hash.clone(), + phone_code: Some(code.to_string()), + email_verification: None, + }) + .await + { + Ok(tl::enums::auth::Authorization::Authorization(x)) => { + self.complete_login(x).await.map_err(SignInError::Other) + } + Ok(tl::enums::auth::Authorization::SignUpRequired(_)) => { + Err(SignInError::SignUpRequired) + } + Err(err) if err.is("SESSION_PASSWORD_NEEDED") => { + let password_token = self.get_password_information().await; + match password_token { + Ok(token) => Err(SignInError::PasswordRequired(token)), + Err(e) => Err(SignInError::Other(e)), + } + } + Err(err) if err.is("PHONE_CODE_*") => Err(SignInError::InvalidCode), + Err(error) => Err(SignInError::Other(error)), + } + } + + /// Extract information needed for the two-factor authentication + /// It's called automatically when we get SESSION_PASSWORD_NEEDED error during sign in. + async fn get_password_information(&self) -> Result { + let request = tl::functions::account::GetPassword {}; + + let password: tl::types::account::Password = self.invoke(&request).await?.into(); + + Ok(PasswordToken::new(password)) + } + + /// Sign in using two-factor authentication (user password). + /// + /// [`PasswordToken`] can be obtained from [`SignInError::PasswordRequired`] error after the + /// [`Client::sign_in`] method fails. + /// + /// # Examples + /// + /// ``` + /// use grammers_client::SignInError; + /// + /// # async fn f(client: grammers_client::Client) -> Result<(), Box> { + /// # const API_HASH: &str = ""; + /// # const PHONE: &str = ""; + /// fn get_user_password(hint: &str) -> Vec { + /// unimplemented!() + /// } + /// + /// # let token = client.request_login_code(PHONE, API_HASH).await?; + /// # let code = ""; + /// + /// // ... enter phone number, request login code ... + /// + /// let user = match client.sign_in(&token, &code).await { + /// Err(SignInError::PasswordRequired(password_token) ) => { + /// let mut password = get_user_password(password_token.hint().unwrap()); + /// + /// client + /// .check_password(password_token, password) + /// .await.unwrap() + /// } + /// Ok(user) => user, + /// Ok(_) => panic!("Sign in required"), + /// Err(err) => { + /// panic!("Failed to sign in as a user :(\n{err}"); + /// } + /// }; + /// # Ok(()) + /// # } + /// ``` + pub async fn check_password( + &self, + password_token: PasswordToken, + password: impl AsRef<[u8]>, + ) -> Result { + let mut password_info = password_token.password; + let current_algo = password_info.current_algo.clone().unwrap(); + let mut params = utils::extract_password_parameters(¤t_algo); + + // Telegram sent us incorrect parameters, trying to get them again + if !check_p_and_g(params.2, params.3) { + password_info = self + .get_password_information() + .await + .map_err(SignInError::Other)? + .password; + params = + utils::extract_password_parameters(password_info.current_algo.as_ref().unwrap()); + if !check_p_and_g(params.2, params.3) { + panic!("Failed to get correct password information from Telegram") + } + } + + let (salt1, salt2, p, g) = params; + + let g_b = password_info.srp_b.clone().unwrap(); + let a = password_info.secure_random.clone(); + + let (m1, g_a) = calculate_2fa(salt1, salt2, p, g, g_b, a, password); + + let check_password = tl::functions::auth::CheckPassword { + password: tl::enums::InputCheckPasswordSrp::Srp(tl::types::InputCheckPasswordSrp { + srp_id: password_info.srp_id.clone().unwrap(), + a: g_a.to_vec(), + m1: m1.to_vec(), + }), + }; + + match self.invoke(&check_password).await { + Ok(tl::enums::auth::Authorization::Authorization(x)) => { + self.complete_login(x).await.map_err(SignInError::Other) + } + Ok(tl::enums::auth::Authorization::SignUpRequired(_x)) => panic!("Unexpected result"), + Err(err) if err.is("PASSWORD_HASH_INVALID") => { + Err(SignInError::InvalidPassword(PasswordToken { + password: password_info, + })) + } + Err(error) => Err(SignInError::Other(error)), + } + } + + /// Signs out of the account authorized by this client's session. + /// + /// If the client was not logged in, this method returns false. + /// + /// The client is not disconnected after signing out. + /// + /// Note that after using this method you will have to sign in again. If all you want to do + /// is disconnect, simply [`drop`] the [`Client`] instance. + /// + /// # Examples + /// + /// ``` + /// # async fn f(client: grammers_client::Client) -> Result<(), Box> { + /// if client.sign_out().await.is_ok() { + /// println!("Signed out successfully!"); + /// } else { + /// println!("No user was signed in, so nothing has changed..."); + /// } + /// # Ok(()) + /// # } + /// ``` + pub async fn sign_out(&self) -> Result { + self.invoke(&tl::functions::auth::LogOut {}).await + } + + /// Signals all clients sharing the same sender pool to disconnect. + pub fn disconnect(&self) { + self.0.handle.quit(); + } +} diff --git a/vendor/grammers-client/src/client/bots.rs b/vendor/grammers-client/src/client/bots.rs new file mode 100644 index 0000000..2f9bdc0 --- /dev/null +++ b/vendor/grammers-client/src/client/bots.rs @@ -0,0 +1,205 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +use grammers_mtsender::InvocationError; +use grammers_session::types::PeerRef; +use grammers_tl_types as tl; + +use super::messages::parse_mention_entities; +use super::{Client, IterBuffer}; +use crate::message::InputMessage; +use crate::utils::generate_random_id; + +const MAX_LIMIT: usize = 50; + +/// Item returned by [`Client::inline_query`]. +pub struct InlineResult { + client: Client, + query_id: i64, + pub raw: tl::enums::BotInlineResult, +} + +/// Iterator returned by [`Client::inline_query`]. +pub type InlineResultIter = IterBuffer; + +impl InlineResult { + /// Send this inline result to the specified peer. + // TODO return the produced message + pub async fn send>(&self, peer: C) -> Result<(), InvocationError> { + self.client + .invoke(&tl::functions::messages::SendInlineBotResult { + silent: false, + background: false, + clear_draft: false, + hide_via: false, + peer: peer.into().into(), + reply_to: None, + random_id: generate_random_id(), + query_id: self.query_id, + id: self.id().to_string(), + schedule_date: None, + send_as: None, + quick_reply_shortcut: None, + allow_paid_stars: None, + }) + .await + .map(drop) + } + + /// The ID for this result. + pub fn id(&self) -> &str { + use tl::enums::BotInlineResult::*; + + match &self.raw { + Result(r) => &r.id, + BotInlineMediaResult(r) => &r.id, + } + } + + /// The title for this result, if any. + pub fn title(&self) -> Option<&String> { + use tl::enums::BotInlineResult::*; + + match &self.raw { + Result(r) => r.title.as_ref(), + BotInlineMediaResult(r) => r.title.as_ref(), + } + } +} + +impl InlineResultIter { + fn new(client: &Client, bot: PeerRef, query: &str) -> Self { + Self::from_request( + client, + MAX_LIMIT, + tl::functions::messages::GetInlineBotResults { + bot: bot.into(), + peer: tl::enums::InputPeer::Empty, + geo_point: None, + query: query.to_string(), + offset: String::new(), + }, + ) + } + + /// Indicate the bot the peer where this inline query will be sent to. + /// + /// Some bots use this information to return different results depending on the type of the + /// peer, and some even "need" it to give useful results. + pub fn peer>(mut self, peer: C) -> Self { + self.request.peer = peer.into().into(); + self + } + + /// Return the next `InlineResult` from the internal buffer, filling the buffer previously if + /// it's empty. + /// + /// Returns `None` if the `limit` is reached or there are no results left. + pub async fn next(&mut self) -> Result, InvocationError> { + if let Some(result) = self.next_raw() { + return result; + } + + let tl::enums::messages::BotResults::Results(tl::types::messages::BotResults { + query_id, + next_offset, + results, + .. + }) = self.client.invoke(&self.request).await?; + + if let Some(offset) = next_offset { + self.request.offset = offset; + } else { + self.last_chunk = true; + } + + let client = self.client.clone(); + self.buffer + .extend(results.into_iter().map(|r| InlineResult { + client: client.clone(), + query_id, + raw: r, + })); + + Ok(self.pop_item()) + } +} + +/// Method implementations related to dealing with bots. +impl Client { + /// Perform an inline query to the specified bot. + /// + /// The query text may be empty. + /// + /// Executing the query will fail if the input peer does not actually represent a bot account + /// supporting inline mode. + /// + /// # Examples + /// + /// ``` + /// # async fn f(bot: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// // This is equivalent to writing `@bot inline query` in a Telegram app. + /// let mut inline_results = client.inline_query(bot, "inline query"); + /// + /// while let Some(result) = inline_results.next().await? { + /// println!("{}", result.title().unwrap()); + /// } + /// # Ok(()) + /// # } + /// ``` + pub fn inline_query>(&self, bot: C, query: &str) -> InlineResultIter { + InlineResultIter::new(self, bot.into(), query) + } + + /// Edits an inline message sent by a bot. + /// + /// Similar to [`Client::send_message`], advanced formatting can be achieved with the + /// options offered by [`InputMessage`]. + pub(crate) async fn edit_inline_message( + &self, + message_id: tl::enums::InputBotInlineMessageId, + message: InputMessage, + ) -> Result { + let entities = parse_mention_entities(self, message.entities).await; + if message.media.as_ref().is_some_and(|media| { + !matches!( + media, + tl::enums::InputMedia::PhotoExternal(_) + | tl::enums::InputMedia::DocumentExternal(_), + ) + }) { + let dc_id = message_id.dc_id(); + self.invoke_in_dc( + dc_id, + &tl::functions::messages::EditInlineBotMessage { + id: message_id, + message: Some(message.text), + media: message.media, + entities, + no_webpage: !message.link_preview, + reply_markup: message.reply_markup, + invert_media: message.invert_media, + rich_message: None, + }, + ) + .await + } else { + self.invoke(&tl::functions::messages::EditInlineBotMessage { + id: message_id, + message: Some(message.text), + media: message.media, + entities, + no_webpage: !message.link_preview, + reply_markup: message.reply_markup, + invert_media: message.invert_media, + rich_message: None, + }) + .await + } + } +} diff --git a/vendor/grammers-client/src/client/chats.rs b/vendor/grammers-client/src/client/chats.rs new file mode 100644 index 0000000..cfc0368 --- /dev/null +++ b/vendor/grammers-client/src/client/chats.rs @@ -0,0 +1,1013 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +//! Methods related to users, groups and channels. + +use std::collections::HashMap; +use std::collections::VecDeque; +use std::future::Future; +use std::sync::Arc; +use std::time::Duration; + +use grammers_mtsender::{InvocationError, RpcError}; +use grammers_session::types::{PeerId, PeerKind, PeerRef}; +use grammers_tl_types as tl; + +use super::{Client, IterBuffer}; +use crate::media::Photo; +use crate::message::Message; +use crate::peer::ActionSender; +use crate::peer::AdminRightsBuilder; +use crate::peer::BannedRightsBuilder; +use crate::peer::Participant; +use crate::peer::Peer; +use crate::peer::PeerMap; +use crate::peer::User; +use crate::peer::chats::AdminRightsBuilderInner; +use crate::peer::chats::BannedRightsBuilderInner; + +const MAX_PARTICIPANT_LIMIT: usize = 200; +const MAX_PHOTO_LIMIT: usize = 100; +const KICK_BAN_DURATION: i32 = 60; // in seconds, in case the second request fails + +enum ParticipantIterInner { + Empty, + Chat { + client: Client, + chat_id: i64, + buffer: VecDeque, + total: Option, + }, + Channel(IterBuffer), +} + +/// Iterator returned by [`Client::iter_participants`]. +pub struct ParticipantIter(ParticipantIterInner); + +impl ParticipantIter { + fn new(client: &Client, peer: PeerRef) -> Self { + Self(if peer.id.kind() == PeerKind::Channel { + ParticipantIterInner::Channel(IterBuffer::from_request( + client, + MAX_PARTICIPANT_LIMIT, + tl::functions::channels::GetParticipants { + channel: peer.into(), + filter: tl::enums::ChannelParticipantsFilter::ChannelParticipantsRecent, + offset: 0, + limit: 0, + hash: 0, + }, + )) + } else if peer.id.kind() == PeerKind::Chat { + ParticipantIterInner::Chat { + client: client.clone(), + chat_id: peer.into(), + buffer: VecDeque::new(), + total: None, + } + } else { + ParticipantIterInner::Empty + }) + } + + /// Determines how many participants there are in total. + /// + /// This only performs a network call if `next` has not been called before. + pub async fn total(&mut self) -> Result { + match &self.0 { + ParticipantIterInner::Empty => Ok(0), + ParticipantIterInner::Chat { total, .. } => { + if let Some(total) = total { + Ok(*total) + } else { + self.fill_buffer().await + } + } + ParticipantIterInner::Channel(iter) => { + if let Some(total) = iter.total { + Ok(total) + } else { + self.fill_buffer().await + } + } + } + } + + /// Fills the buffer, and returns the total count. + async fn fill_buffer(&mut self) -> Result { + match &mut self.0 { + ParticipantIterInner::Empty => Ok(0), + ParticipantIterInner::Chat { + client, + chat_id, + buffer, + total, + } => { + assert!(buffer.is_empty()); + let tl::enums::messages::ChatFull::Full(full) = client + .invoke(&tl::functions::messages::GetFullChat { chat_id: *chat_id }) + .await?; + + let chat = match full.full_chat { + tl::enums::ChatFull::Full(chat) => chat, + tl::enums::ChatFull::ChannelFull(_) => panic!( + "API returned ChannelFull even though messages::GetFullChat was used" + ), + }; + + // Don't actually care for the chats, just the users. + let mut peers = client.build_peer_map(full.users, Vec::new()).await; + + let participants = match chat.participants { + tl::enums::ChatParticipants::Forbidden(c) => { + if let Some(p) = c.self_participant { + buffer.push_back(Participant::from_raw_chat(&mut peers, p)); + } + return Ok(buffer.len()); + } + tl::enums::ChatParticipants::Participants(c) => c.participants, + }; + + buffer.extend( + participants + .into_iter() + .map(|p| Participant::from_raw_chat(&mut peers, p)), + ); + + *total = Some(buffer.len()); + Ok(buffer.len()) + } + ParticipantIterInner::Channel(iter) => { + assert!(iter.buffer.is_empty()); + use tl::enums::channels::ChannelParticipants::*; + + iter.request.limit = iter.determine_limit(MAX_PARTICIPANT_LIMIT); + let (count, participants, _, users) = + match iter.client.invoke(&iter.request).await? { + Participants(p) => (p.count, p.participants, p.chats, p.users), + NotModified => { + panic!("API returned Dialogs::NotModified even though hash = 0") + } + }; + + // Telegram can return less participants than asked for but the count being higher + // (for example, count=4825, participants=199, users=200). The missing participant + // was an admin bot account, not sure why it's not included. + // + // In any case we pick whichever size is highest to avoid weird cases like this. + iter.last_chunk = + usize::max(participants.len(), users.len()) < iter.request.limit as usize; + iter.request.offset += participants.len() as i32; + + // Don't actually care for the chats, just the users. + let mut peers = iter.client.build_peer_map(users, Vec::new()).await; + + iter.buffer.extend( + participants + .into_iter() + .map(|p| Participant::from_raw_channel(&mut peers, p)), + ); + + iter.total = Some(count as usize); + Ok(count as usize) + } + } + } + + /// Return the next `Participant` from the internal buffer, filling the buffer previously if + /// it's empty. + /// + /// Returns `None` if the `limit` is reached or there are no participants left. + pub async fn next(&mut self) -> Result, InvocationError> { + // Need to split the `match` because `fill_buffer()` borrows mutably. + match &mut self.0 { + ParticipantIterInner::Empty => {} + ParticipantIterInner::Chat { buffer, .. } => { + if buffer.is_empty() { + self.fill_buffer().await?; + } + } + ParticipantIterInner::Channel(iter) => { + if let Some(result) = iter.next_raw() { + return result; + } + self.fill_buffer().await?; + } + } + + match &mut self.0 { + ParticipantIterInner::Empty => Ok(None), + ParticipantIterInner::Chat { buffer, .. } => { + let result = buffer.pop_front(); + if buffer.is_empty() { + self.0 = ParticipantIterInner::Empty; + } + Ok(result) + } + ParticipantIterInner::Channel(iter) => Ok(iter.pop_item()), + } + } + + /// apply a filter on fetched participants, note that this filter will apply only on large `Channel` and not small groups + pub fn filter(mut self, filter: tl::enums::ChannelParticipantsFilter) -> Self { + match self.0 { + ParticipantIterInner::Channel(ref mut c) => { + c.request.filter = filter; + self + } + _ => self, + } + } +} + +enum ProfilePhotoIterInner { + User(IterBuffer), + Chat(IterBuffer), +} + +/// Iterator returned by [`Client::iter_profile_photos`]. +pub struct ProfilePhotoIter(ProfilePhotoIterInner); + +impl ProfilePhotoIter { + fn new(client: &Client, peer: PeerRef) -> Self { + Self(if matches!(peer.id.kind(), PeerKind::User) { + ProfilePhotoIterInner::User(IterBuffer::from_request( + client, + MAX_PHOTO_LIMIT, + tl::functions::photos::GetUserPhotos { + user_id: peer.into(), + offset: 0, + max_id: 0, + limit: 0, + }, + )) + } else { + ProfilePhotoIterInner::Chat( + client + .search_messages(peer) + .filter(tl::enums::MessagesFilter::InputMessagesFilterChatPhotos), + ) + }) + } + + /// Determines how many profile photos there are in total. + /// + /// This only performs a network call if `next` has not been called before. + pub async fn total(&mut self) -> Result { + match &mut self.0 { + ProfilePhotoIterInner::User(iter) => { + if let Some(total) = iter.total { + Ok(total) + } else { + self.fill_buffer().await + } + } + ProfilePhotoIterInner::Chat(iter) => iter.total().await, + } + } + + /// Fills the buffer, and returns the total count. + async fn fill_buffer(&mut self) -> Result { + match &mut self.0 { + ProfilePhotoIterInner::User(iter) => { + use tl::enums::photos::Photos; + + iter.request.limit = iter.determine_limit(MAX_PHOTO_LIMIT); + let (total, photos) = match iter.client.invoke(&iter.request).await? { + Photos::Photos(p) => { + iter.last_chunk = true; + iter.total = Some(p.photos.len()); + (p.photos.len(), p.photos) + } + Photos::Slice(p) => { + iter.last_chunk = p.photos.len() < iter.request.limit as usize; + iter.total = Some(p.count as usize); + (p.count as usize, p.photos) + } + }; + + // Don't bother updating offsets if this is the last time stuff has to be fetched. + if !iter.last_chunk && !photos.is_empty() { + iter.request.offset += photos.len() as i32; + } + + iter.buffer.extend(photos.into_iter().map(Photo::from_raw)); + + Ok(total) + } + ProfilePhotoIterInner::Chat(_) => { + panic!("fill_buffer should not be called for Chat variant") + } + } + } + + /// Return the next photo from the internal buffer, filling the buffer previously if it's + /// empty. + /// + /// Returns `None` if the `limit` is reached or there are no photos left. + pub async fn next(&mut self) -> Result, InvocationError> { + // Need to split the `match` because `fill_buffer()` borrows mutably. + match &mut self.0 { + ProfilePhotoIterInner::User(iter) => { + if let Some(result) = iter.next_raw() { + return result; + } + self.fill_buffer().await?; + } + ProfilePhotoIterInner::Chat(iter) => { + while let Some(message) = iter.next().await? { + if let Some(tl::enums::MessageAction::ChatEditPhoto( + tl::types::MessageActionChatEditPhoto { photo }, + )) = message.action() + { + return Ok(Some(Photo::from_raw(photo.clone()))); + } else { + continue; + } + } + } + } + + match &mut self.0 { + ProfilePhotoIterInner::User(iter) => Ok(iter.pop_item()), + ProfilePhotoIterInner::Chat(_) => Ok(None), + } + } +} + +fn updates_to_chat(client: &Client, id: Option, updates: tl::enums::Updates) -> Option { + use tl::enums::Updates; + + let chats = match updates { + Updates::Combined(updates) => Some(updates.chats), + Updates::Updates(updates) => Some(updates.chats), + _ => None, + }; + + match chats { + Some(chats) => match id { + Some(id) => chats.into_iter().find(|chat| chat.id() == id), + None => chats.into_iter().next(), + }, + None => None, + } + .map(|chat| Peer::from_raw(client, chat)) +} + +/// Method implementations related to dealing with peers. +impl Client { + /// Resolves a username into the peer that owns it, if any. + /// + /// Note that this method is expensive to call, and can quickly cause long flood waits. + /// + /// # Examples + /// + /// ``` + /// # async fn f(client: grammers_client::Client) -> Result<(), Box> { + /// if let Some(peer) = client.resolve_username("username").await? { + /// println!("Found peer!: {:?}", peer.name()); + /// } + /// # Ok(()) + /// # } + /// ``` + pub async fn resolve_username(&self, username: &str) -> Result, InvocationError> { + let tl::types::contacts::ResolvedPeer { peer, users, chats } = match self + .invoke(&tl::functions::contacts::ResolveUsername { + username: username.into(), + referer: None, + }) + .await + { + Ok(tl::enums::contacts::ResolvedPeer::Peer(p)) => p, + Err(err) if err.is("USERNAME_NOT_OCCUPIED") => return Ok(None), + Err(err) => return Err(err), + }; + + let mut peers = self.build_peer_map(users, chats).await; + let peer_id = PeerId::from(peer); + + Ok(peers.take(peer_id)) + } + + /// Fetch full information about the currently logged-in user. + /// + /// Although this method is cheap to call, you might want to cache the results somewhere. + /// + /// # Examples + /// + /// ``` + /// # async fn f(client: grammers_client::Client) -> Result<(), Box> { + /// println!("Displaying full user information of the logged-in user:"); + /// dbg!(client.get_me().await?); + /// # Ok(()) + /// # } + /// ``` + pub async fn get_me(&self) -> Result { + let mut res = self + .invoke(&tl::functions::users::GetUsers { + id: vec![tl::enums::InputUser::UserSelf], + }) + .await?; + + if res.len() != 1 { + panic!("fetching only one user should exactly return one user"); + } + + Ok(User::from_raw(self, res.pop().unwrap())) + } + + /// Iterate over the participants of a chat. + /// + /// The participants are returned in no particular order. + /// + /// When used to iterate the participants of "user", the iterator won't produce values. + /// + /// # Examples + /// + /// ``` + /// # async fn f(chat: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// let mut participants = client.iter_participants(chat); + /// + /// while let Some(participant) = participants.next().await? { + /// println!( + /// "{} has role {:?}", + /// participant.user.first_name().unwrap_or(&participant.user.id().to_string()), + /// participant.role + /// ); + /// } + /// # Ok(()) + /// # } + /// ``` + pub fn iter_participants>(&self, chat: C) -> ParticipantIter { + ParticipantIter::new(self, chat.into()) + } + + /// Kicks the participant from the chat. + /// + /// This will fail if you do not have sufficient permissions to perform said operation, + /// or the target user is the logged-in account. Use [`Self::delete_dialog`] for the latter instead. + /// + /// The kicked user will be able to join after being kicked (they are not permanently banned). + /// + /// Kicking someone who was not in the chat prior to running this method will be able to join + /// after as well (effectively unbanning them). + /// + /// When used to kick users from "user" chat, nothing will be done. + /// + /// # Examples + /// + /// ``` + /// # async fn f(chat: grammers_session::types::PeerRef, user: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// match client.kick_participant(chat, user).await { + /// Ok(_) => println!("user is no more >:D"), + /// Err(_) => println!("Kick failed! Are you sure you're admin?"), + /// }; + /// # Ok(()) + /// # } + /// ``` + pub async fn kick_participant, U: Into>( + &self, + chat: C, + user: U, + ) -> Result<(), InvocationError> { + let chat = chat.into(); + let user = user.into(); + if chat.id.kind() == PeerKind::Channel { + self.set_banned_rights(chat, user) + .view_messages(false) + .duration(Duration::from_secs(KICK_BAN_DURATION as u64)) + .await?; + + self.set_banned_rights(chat, user).await + } else if chat.id.kind() == PeerKind::Chat { + self.invoke(&tl::functions::messages::DeleteChatUser { + chat_id: chat.into(), + user_id: user.into(), + revoke_history: false, + }) + .await + .map(drop) + } else { + Ok(()) + } + } + + /// Returns a builder to set the banned rights for a specific user. + /// + /// Nothing is done until it is awaited, at which point it might result in + /// error if you do not have sufficient permissions to ban the user in the input chat. + /// + /// By default, the user has all rights, and you need to revoke those you want to take away + /// from the user by setting the permissions to `false`. This means that not taking away any + /// permissions will effectively unban someone, granting them all default user permissions. + /// + /// By default, the ban is applied forever, but this can be changed to a shorter duration. + /// + /// The default group rights are respected, despite individual restrictions. + /// + /// # Example + /// + /// ``` + /// # async fn f(chat: grammers_session::types::PeerRef, user: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// // This user keeps spamming pepe stickers, take the sticker permission away from them + /// let res = client + /// .set_banned_rights(chat, user) + /// .send_stickers(false) + /// .await; + /// + /// match res { + /// Ok(_) => println!("No more sticker spam!"), + /// Err(_) => println!("Ban failed! Are you sure you're admin?"), + /// }; + /// # Ok(()) + /// # } + /// ``` + pub fn set_banned_rights, U: Into>( + &self, + channel: C, + user: U, + ) -> BannedRightsBuilder>> { + BannedRightsBuilder::new( + self.clone(), + channel.into(), + user.into(), + BannedRightsBuilderInner::invoke, + ) + } + + /// Returns a builder to set the administrator rights for a specific user. + /// + /// Nothing is done until it is awaited, at which point + /// it might result in error if you do not have sufficient permissions to grant those rights + /// to the other user. + /// + /// By default, no permissions are granted, and you need to specify those you want to grant by + /// setting the permissions to `true`. This means that not granting any permission will turn + /// the user into a normal user again, and they will no longer be an administrator. + /// + /// The change is applied forever and there is no way to set a specific duration. If the user + /// should only be an administrator for a set period of time, the administrator permissions + /// must be manually revoked at a later point in time. + /// + /// # Example + /// + /// ``` + /// # async fn f(chat: grammers_session::types::PeerRef, user: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// // Let the user pin messages and ban other people + /// let res = client.set_admin_rights(chat, user) + /// .load_current() + /// .await? + /// .pin_messages(true) + /// .ban_users(true) + /// .await?; + /// # Ok(()) + /// # } + /// ``` + pub fn set_admin_rights, U: Into>( + &self, + channel: C, + user: U, + ) -> AdminRightsBuilder>> { + AdminRightsBuilder::new( + self.clone(), + channel.into(), + user.into(), + AdminRightsBuilderInner::invoke, + ) + } + + /// Iterate over the history of profile photos for the given peer. + /// + /// Note that the current photo might not be present in the history, and to avoid doing more + /// work when it's generally not needed (the photo history tends to be complete but in some + /// cases it might not be), it's up to you to fetch this photo from the full channel. + /// + /// Note that you cannot use these photos to send them as messages directly. They must be + /// downloaded first, then uploaded, and finally sent. + /// + /// # Examples + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// let mut photos = client.iter_profile_photos(peer); + /// + /// while let Some(photo) = photos.next().await? { + /// println!("Did you know peer has a photo with ID {}?", photo.id()); + /// } + /// # Ok(()) + /// # } + /// ``` + pub fn iter_profile_photos>(&self, peer: C) -> ProfilePhotoIter { + ProfilePhotoIter::new(self, peer.into()) + } + + /// Convert a [`PeerRef`] back into a [`Peer`]. + /// + /// # Example + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// let peer = client.resolve_peer(peer).await?; + /// + /// println!("Found peer: {}", peer.name().unwrap_or(&peer.id().to_string())); + /// # Ok(()) + /// # } + /// ``` + pub async fn resolve_peer>(&self, peer: C) -> Result { + let peer = peer.into(); + let mut peers = match peer.id.kind() { + PeerKind::User => { + let res = self + .invoke(&tl::functions::users::GetUsers { + id: vec![peer.into()], + }) + .await?; + if res.len() > 1 { + panic!("fetching only one user should exactly return one user"); + } + self.build_peer_map(res, Vec::new()).await + } + PeerKind::Chat => { + let res = match self + .invoke(&tl::functions::messages::GetChats { + id: vec![peer.into()], + }) + .await? + { + tl::enums::messages::Chats::Chats(chats) => chats.chats, + tl::enums::messages::Chats::Slice(chat_slice) => chat_slice.chats, + }; + if res.len() > 1 { + panic!("fetching only one chat should exactly return one chat"); + } + self.build_peer_map(Vec::new(), res).await + } + PeerKind::Channel => { + let res = match self + .invoke(&tl::functions::channels::GetChannels { + id: vec![peer.into()], + }) + .await? + { + tl::enums::messages::Chats::Chats(chats) => chats.chats, + tl::enums::messages::Chats::Slice(chat_slice) => chat_slice.chats, + }; + if res.len() > 1 { + panic!("fetching only one channel should exactly return one chat"); + } + self.build_peer_map(Vec::new(), res).await + } + }; + peers.take(peer.id).ok_or(InvocationError::Dropped) + } + + /// Get permissions of participant `user` from chat `chat`. + /// + /// # Example + /// + /// ``` + /// # async fn f(chat: grammers_session::types::PeerRef, user: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// let permissions = client.get_permissions(chat, user).await?; + /// println!("The user {} an admin", if permissions.is_admin() { "is" } else { "is not" }); + /// # Ok(()) + /// # } + /// ``` + pub async fn get_permissions, U: Into>( + &self, + chat: C, + user: U, + ) -> Result { + let chat = chat.into(); + let user = user.into(); + + // Get by chat + if chat.id.kind() == PeerKind::Chat { + // Get user id + let user = user.into(); + let user_id = match user { + tl::enums::InputUser::User(user) => user.user_id, + tl::enums::InputUser::FromMessage(user) => user.user_id, + tl::enums::InputUser::UserSelf => { + let me = self.get_me().await?; + me.id().bare_id_unchecked() + } + tl::enums::InputUser::Empty => return Err(InvocationError::Dropped), + }; + + // Get chat and find user + let chat = self + .invoke(&tl::functions::messages::GetFullChat { + chat_id: chat.into(), + }) + .await?; + let tl::enums::messages::ChatFull::Full(chat) = chat; + if let tl::enums::ChatFull::Full(chat) = chat.full_chat { + if let tl::enums::ChatParticipants::Participants(participants) = chat.participants { + for participant in participants.participants { + if participant.user_id() == user_id { + return Ok(ParticipantPermissions(ParticipantPermissionsInner::Chat( + participant, + ))); + } + } + } + } + return Err(InvocationError::Rpc(RpcError { + code: 400, + name: "USER_NOT_PARTICIPANT".to_string(), + value: None, + caused_by: None, + })); + } + + // Get by channel + let participant = self + .invoke(&tl::functions::channels::GetParticipant { + channel: chat.into(), + participant: user.into(), + }) + .await?; + let tl::enums::channels::ChannelParticipant::Participant(participant) = participant; + Ok(ParticipantPermissions( + ParticipantPermissionsInner::Channel(participant.participant), + )) + } + + #[cfg(feature = "parse_invite_link")] + pub fn parse_invite_link(invite_link: &str) -> Option { + let url_parse_result = url::Url::parse(invite_link); + if url_parse_result.is_err() { + return None; + } + + let url_parse = url_parse_result.unwrap(); + let scheme = url_parse.scheme(); + let path = url_parse.path(); + if url_parse.host_str().is_none() || !vec!["https", "http"].contains(&scheme) { + return None; + } + let host = url_parse.host_str().unwrap(); + let hosts = [ + "t.me", + "telegram.me", + "telegram.dog", + "tg.dev", + "telegram.me", + "telesco.pe", + ]; + + if !hosts.contains(&host) { + return None; + } + let paths = path.split("/").skip(1).collect::>(); + + if paths.len() == 1 { + if paths[0].starts_with("+") { + return Some(paths[0].replace("+", "")); + } + return None; + } + + if paths.len() > 1 { + if paths[0].starts_with("joinchat") { + return Some(paths[1].to_string()); + } + if paths[0].starts_with("+") { + return Some(paths[0].replace("+", "")); + } + return None; + } + + None + } + + /// Accept an invite link to join the corresponding private chat. + /// + /// If the chat is public (has a public username), [`Client::join_chat`](Client::join_chat) should be used instead. + #[cfg(feature = "parse_invite_link")] + pub async fn accept_invite_link( + &self, + invite_link: &str, + ) -> Result, InvocationError> { + match Self::parse_invite_link(invite_link) { + Some(hash) => Ok( + if let tl::enums::messages::ChatInviteJoinResult::Ok(ok) = self + .invoke(&tl::functions::messages::ImportChatInvite { hash }) + .await? + { + updates_to_chat(self, None, ok.updates) + } else { + None + }, + ), + None => Err(InvocationError::Rpc(RpcError { + code: 400, + name: "INVITE_HASH_INVALID".to_string(), + value: None, + caused_by: None, + })), + } + } + + #[cfg_attr( + not(feature = "parse_invite_link"), + allow(rustdoc::broken_intra_doc_links) + )] + /// Join a public group or channel. + /// + /// A channel is public if it has a username. + /// To join private chats, [`Client::accept_invite_link`](Client::accept_invite_link) should be used instead. + pub async fn join_chat>( + &self, + chat: C, + ) -> Result, InvocationError> { + let chat: PeerRef = chat.into(); + let channel = chat.into(); + Ok( + if let tl::enums::messages::ChatInviteJoinResult::Ok(ok) = self + .invoke(&tl::functions::channels::JoinChannel { channel }) + .await? + { + updates_to_chat(self, chat.id.bare_id(), ok.updates) + } else { + None + }, + ) + } + + /// Send a message action (such as typing, uploading photo, or viewing an emoji interaction) + /// + /// # Examples + /// + /// **Do a one-shot pulse and let it fade away** + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// use grammers_tl_types::enums::SendMessageAction; + /// + /// client + /// .action(peer) + /// .oneshot(SendMessageAction::SendMessageTypingAction) + /// .await?; + /// # Ok(()) + /// # } + /// ``` + /// + /// **Repeat request until the future is done** + /// ``` + /// # use std::time::Duration; + /// + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// use grammers_tl_types as tl; + /// + /// let heavy_task = async { + /// tokio::time::sleep(Duration::from_secs(10)).await; + /// + /// 42 + /// }; + /// + /// tokio::pin!(heavy_task); + /// + /// let (task_result, _) = client + /// .action(peer) + /// .repeat( + /// // most clients doesn't actually show progress of an action + /// || tl::types::SendMessageUploadDocumentAction { progress: 0 }, + /// heavy_task + /// ) + /// .await; + /// + /// // Note: repeat function does not cancel actions automatically, they will just fade away + /// + /// assert_eq!(task_result, 42); + /// # Ok(()) + /// # } + /// ``` + /// + /// **Cancel any actions** + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// client.action(peer).cancel().await?; + /// # Ok(()) + /// # } + /// ``` + pub fn action>(&self, peer: C) -> ActionSender { + ActionSender::new(self, peer) + } + + pub(crate) async fn build_peer_map( + &self, + users: Vec, + chats: Vec, + ) -> PeerMap { + let map = users + .into_iter() + .map(|user| Peer::from_user(self, user)) + .chain(chats.into_iter().map(|chat| Peer::from_raw(self, chat))) + .map(|peer| (peer.id(), peer)) + .collect::>(); + + if self.0.configuration.auto_cache_peers { + for peer in map.values() { + if peer.auth().is_some() { + if let Err(e) = self.0.session.cache_peer(&peer.into()).await { + log::warn!("cache_peer fail: {:?}", e) + } + } + } + } + + PeerMap { + map: Arc::new(map), + session: Arc::clone(&self.0.session), + } + } + + pub(crate) fn empty_peer_map(&self) -> PeerMap { + PeerMap { + map: Arc::new(HashMap::new()), + session: Arc::clone(&self.0.session), + } + } +} + +#[derive(Debug, Clone)] +enum ParticipantPermissionsInner { + Channel(tl::enums::ChannelParticipant), + Chat(tl::enums::ChatParticipant), +} + +/// Permissions returned by [`Client::get_permissions`]. +#[derive(Debug, Clone)] +pub struct ParticipantPermissions(ParticipantPermissionsInner); + +impl ParticipantPermissions { + /// Whether the user is the creator of the chat or not. + pub fn is_creator(&self) -> bool { + matches!( + self.0, + ParticipantPermissionsInner::Channel(tl::enums::ChannelParticipant::Creator(_)) + | ParticipantPermissionsInner::Chat(tl::enums::ChatParticipant::Creator(_)) + ) + } + + /// Whether the user is an administrator of the chat or not. The creator also counts as begin an administrator, since they have all permissions. + pub fn is_admin(&self) -> bool { + self.is_creator() + || matches!( + self.0, + ParticipantPermissionsInner::Channel(tl::enums::ChannelParticipant::Admin(_)) + | ParticipantPermissionsInner::Chat(tl::enums::ChatParticipant::Admin(_)) + ) + } + + /// Whether the user is banned in the chat. + pub fn is_banned(&self) -> bool { + matches!( + self.0, + ParticipantPermissionsInner::Channel(tl::enums::ChannelParticipant::Banned(_)) + ) + } + + /// Whether the user left the chat. + pub fn has_left(&self) -> bool { + matches!( + self.0, + ParticipantPermissionsInner::Channel(tl::enums::ChannelParticipant::Left(_)) + ) + } + + /// Whether the user is a normal user of the chat (not administrator, but not banned either, and has no restrictions applied). + pub fn has_default_permissions(&self) -> bool { + matches!( + self.0, + ParticipantPermissionsInner::Channel(tl::enums::ChannelParticipant::Participant(_)) + | ParticipantPermissionsInner::Channel( + tl::enums::ChannelParticipant::ParticipantSelf(_) + ) + | ParticipantPermissionsInner::Chat(tl::enums::ChatParticipant::Participant(_)) + ) + } + + /// Whether the administrator can add new administrators with the same or less permissions than them. + pub fn can_add_admins(&self) -> bool { + if !self.is_admin() { + return false; + } + match &self.0 { + ParticipantPermissionsInner::Channel(tl::enums::ChannelParticipant::Admin( + participant, + )) => { + let tl::enums::ChatAdminRights::Rights(rights) = &participant.admin_rights; + rights.add_admins + } + ParticipantPermissionsInner::Channel(tl::enums::ChannelParticipant::Creator(_)) => true, + ParticipantPermissionsInner::Chat(_) => self.is_creator(), + _ => false, + } + } +} diff --git a/vendor/grammers-client/src/client/client.rs b/vendor/grammers-client/src/client/client.rs new file mode 100644 index 0000000..b16614b --- /dev/null +++ b/vendor/grammers-client/src/client/client.rs @@ -0,0 +1,105 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +use std::sync::Arc; + +use grammers_mtsender::SenderPoolHandle; +use grammers_session::ErasedSession; + +pub(crate) struct ClientInner { + pub(crate) session: Arc, + pub(crate) api_id: i32, + pub(crate) handle: SenderPoolHandle, + pub(crate) configuration: ClientConfiguration, + pub(crate) auth_copied_to_dcs: tokio::sync::Mutex>, +} + +/// Wrapper around [`SenderPool`] to facilitate interaction with Telegram's API. +/// +/// This structure is the "entry point" of the library, from which you can start using the rest. +/// +/// This structure owns all the necessary connections to Telegram, and has implementations for the +/// most basic methods, such as connecting, signing in, or processing network events. +/// +/// On drop, all state is synchronized to the session. The [`Session`] must be explicitly saved +/// to disk with its corresponding save method for persistence. +/// +/// [`SenderPool`]: grammers_mtsender::SenderPool +/// [`Session`]: grammers_session::Session +#[derive(Clone)] +pub struct Client(pub(crate) Arc); + +use std::time::Duration; + +/// Configuration that controls the [`Client`] behaviour when making requests. +pub struct ClientConfiguration { + /// The retry policy to use when encountering errors after invoking a request. + pub retry_policy: Box, + + /// Whether to call [`Session::cache_peer`] on all peer information that + /// the high-level methods receive as a response (e.g. [`Client::iter_dialogs`]). + /// + /// The cached peers are then usable by other methods such as [`Client::resolve_peer`] + /// for as long as the same persisted session is used. + pub auto_cache_peers: bool, +} + +/// Configuration that controls [`Client::stream_updates`]. +pub struct UpdatesConfiguration { + /// Should the client catch-up on updates sent to it while it was offline? + /// + /// By default, updates sent while the client was offline are ignored. + pub catch_up: bool, + + /// How many updates may be buffered by the client at any given time. + /// + /// Telegram passively sends updates to the client through the open connection, so they must + /// be buffered until the application has the capacity to consume them. + /// + /// Upon reaching this limit, updates will be dropped, and a warning log message will be + /// emitted (but not too often, to avoid spamming the log), in order to let the developer + /// know that they should either change how they handle updates or increase the limit. + /// + /// A limit of zero (`Some(0)`) indicates that updates should not be buffered. + /// They will be immediately dropped, and no warning will ever be emitted. + /// + /// A limit of `None` disables the upper bound for the buffer. This is not recommended, as it + /// could eventually lead to memory exhaustion. This option will also not emit any warnings. + /// + /// The default limit, which may change at any time, should be enough for user accounts, + /// although bot accounts may need to increase the limit depending on their capacity. + /// + /// When the limit is `Some`, a buffer to hold that many updates will be pre-allocated. + pub update_queue_limit: Option, +} + +impl Default for ClientConfiguration { + /// Returns an instance that with an [`AutoSleep::default`] retry policy, + /// where encountered peers are automatically passed to [`Session::cache_peer`]. + /// + /// [`AutoSleep::default`]: super::AutoSleep::default + fn default() -> Self { + Self { + retry_policy: Box::new(super::AutoSleep { + threshold: Duration::from_secs(60), + io_errors_as_flood_of: Some(Duration::from_secs(1)), + }), + auto_cache_peers: true, + } + } +} + +impl Default for UpdatesConfiguration { + /// Returns an instance that will not catch up, with a queue limit of 100 updates. + fn default() -> Self { + Self { + catch_up: false, + update_queue_limit: Some(100), + } + } +} diff --git a/vendor/grammers-client/src/client/dialogs.rs b/vendor/grammers-client/src/client/dialogs.rs new file mode 100644 index 0000000..dbc6df6 --- /dev/null +++ b/vendor/grammers-client/src/client/dialogs.rs @@ -0,0 +1,263 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +use grammers_mtsender::InvocationError; +use grammers_session::types::{PeerKind, PeerRef, UpdateState}; +use grammers_tl_types as tl; + +use super::{Client, IterBuffer}; +use crate::peer::Dialog; + +const MAX_LIMIT: usize = 100; + +/// Iterator returned by [`Client::iter_dialogs`]. +pub type DialogIter = IterBuffer; + +impl DialogIter { + fn new(client: &Client) -> Self { + // TODO let users tweak all the options from the request + Self::from_request( + client, + MAX_LIMIT, + tl::functions::messages::GetDialogs { + exclude_pinned: false, + folder_id: None, + offset_date: 0, + offset_id: 0, + offset_peer: tl::enums::InputPeer::Empty, + limit: 0, + hash: 0, + }, + ) + } + + /// Determines how many dialogs there are in total. + /// + /// This only performs a network call if `next` has not been called before. + pub async fn total(&mut self) -> Result { + if let Some(total) = self.total { + return Ok(total); + } + + use tl::enums::messages::Dialogs; + + self.request.limit = 1; + let total = match self.client.invoke(&self.request).await? { + Dialogs::Dialogs(dialogs) => dialogs.dialogs.len(), + Dialogs::Slice(dialogs) => dialogs.count as usize, + Dialogs::NotModified(dialogs) => dialogs.count as usize, + }; + self.total = Some(total); + Ok(total) + } + + /// Return the next `Dialog` from the internal buffer, filling the buffer previously if it's + /// empty. + /// + /// Returns `None` if the `limit` is reached or there are no dialogs left. + pub async fn next(&mut self) -> Result, InvocationError> { + if let Some(result) = self.next_raw() { + return result; + } + + use tl::enums::messages::Dialogs; + + self.request.limit = self.determine_limit(MAX_LIMIT); + let (dialogs, mut messages, users, chats) = match self.client.invoke(&self.request).await? { + Dialogs::Dialogs(d) => { + self.last_chunk = true; + self.total = Some(d.dialogs.len()); + (d.dialogs, d.messages, d.users, d.chats) + } + Dialogs::Slice(d) => { + self.last_chunk = d.dialogs.len() < self.request.limit as usize; + self.total = Some(d.count as usize); + (d.dialogs, d.messages, d.users, d.chats) + } + Dialogs::NotModified(_) => { + panic!("API returned Dialogs::NotModified even though hash = 0") + } + }; + + let peers = self.client.build_peer_map(users, chats).await; + + for dialog in dialogs.iter() { + if let tl::enums::Dialog::Dialog(tl::types::Dialog { + peer: tl::enums::Peer::Channel(channel), + pts: Some(pts), + .. + }) = &dialog + { + self.client + .0 + .session + .set_update_state(UpdateState::Channel { + id: channel.channel_id, + pts: *pts, + }) + .await?; + } + } + + self.buffer.extend( + dialogs + .into_iter() + .map(|dialog| Dialog::new(&self.client, dialog, &mut messages, peers.handle())), + ); + + // Don't bother updating offsets if this is the last time stuff has to be fetched. + if !self.last_chunk && !self.buffer.is_empty() { + self.request.exclude_pinned = true; + if let Some(last_message) = self + .buffer + .iter() + .rev() + .find_map(|dialog| dialog.last_message.as_ref()) + { + self.request.offset_date = last_message.date_timestamp(); + self.request.offset_id = last_message.id(); + } + self.request.offset_peer = self.buffer[self.buffer.len() - 1].peer_ref().into(); + } + + Ok(self.pop_item()) + } +} + +/// Method implementations related to open conversations. +impl Client { + /// Returns a new iterator over the dialogs. + /// + /// While iterating, the update state for any broadcast channel or megagroup will be set if it was unknown before. + /// When the update state is set for these peers, the library can actively check to make sure it's not missing any + /// updates from them (as long as the queue limit for updates is larger than zero). + /// + /// Bot accounts will receive an error response when using this method, as they do not have dialogs. + /// The closest a bot account can get to listing the peers it has interacted with is by querying the + /// session cache, but that approach can be misleading, as it often contains both additional peers, + /// and peers that have since blocked the bot ("deleted the dialog" with it). + /// + /// # Examples + /// + /// ``` + /// # async fn f(client: grammers_client::Client) -> Result<(), Box> { + /// let mut dialogs = client.iter_dialogs(); + /// + /// while let Some(dialog) = dialogs.next().await? { + /// let peer = dialog.peer(); + /// println!("{} ({})", peer.name().unwrap_or_default(), peer.id()); + /// } + /// # Ok(()) + /// # } + /// ``` + pub fn iter_dialogs(&self) -> DialogIter { + DialogIter::new(self) + } + + /// Deletes a dialog, effectively removing it from your list of open conversations. + /// + /// The dialog is only deleted for yourself. + /// + /// Deleting a dialog effectively clears the message history and "kicks" you from it. + /// + /// For groups and channels, this is the same as leaving said chat. This method does **not** + /// delete the chat itself (the chat still exists and the other members will remain inside). + /// + /// Bot accounts can use this method to leave a channel or group, but attempting + /// to leave the dialog with a user will fail, as bots do not actually have dialogs. + /// + /// # Examples + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// // Consider making a backup before, you will lose access to the messages in peer! + /// client.delete_dialog(peer).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn delete_dialog>(&self, peer: C) -> Result<(), InvocationError> { + let peer = peer.into(); + if peer.id.kind() == PeerKind::Channel { + self.invoke(&tl::functions::channels::LeaveChannel { + channel: peer.into(), + }) + .await + .map(drop) + } else if peer.id.kind() == PeerKind::Chat { + self.invoke(&tl::functions::messages::DeleteChatUser { + chat_id: peer.into(), + user_id: tl::enums::InputUser::UserSelf, + revoke_history: false, + }) + .await + .map(drop) + } else { + self.invoke(&tl::functions::messages::DeleteHistory { + just_clear: false, + revoke: false, + peer: peer.into(), + max_id: 0, + min_date: None, + max_date: None, + }) + .await + .map(drop) + } + } + + /// Mark a peer as read. + /// + /// If you want to get rid of all the mentions (for example, a voice note that you have not + /// listened to yet), you need to also use [`Client::clear_mentions`]. + /// + /// # Examples + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// client.mark_as_read(peer).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn mark_as_read>(&self, peer: C) -> Result<(), InvocationError> { + let peer = peer.into(); + if peer.id.kind() == PeerKind::Channel { + self.invoke(&tl::functions::channels::ReadHistory { + channel: peer.into(), + max_id: 0, + }) + .await + .map(drop) + } else { + self.invoke(&tl::functions::messages::ReadHistory { + peer: peer.into(), + max_id: 0, + }) + .await + .map(drop) + } + } + + /// Clears all pending mentions from a peer, marking them as read. + /// + /// # Examples + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// client.clear_mentions(peer).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn clear_mentions>(&self, peer: C) -> Result<(), InvocationError> { + self.invoke(&tl::functions::messages::ReadMentions { + peer: peer.into().into(), + top_msg_id: None, + }) + .await + .map(drop) + } +} diff --git a/vendor/grammers-client/src/client/files.rs b/vendor/grammers-client/src/client/files.rs new file mode 100644 index 0000000..f076b65 --- /dev/null +++ b/vendor/grammers-client/src/client/files.rs @@ -0,0 +1,578 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +use std::mem; +use std::sync::Arc; +#[cfg(feature = "fs")] +use std::{io::SeekFrom, path::Path}; + +use futures_util::stream::{FuturesUnordered, StreamExt as _}; +use grammers_mtsender::InvocationError; +use grammers_tl_types as tl; +use tokio::io::{self, AsyncRead, AsyncReadExt}; +use tokio::sync::Mutex as AsyncMutex; +#[cfg(feature = "fs")] +use tokio::{ + fs, + io::{AsyncSeekExt, AsyncWriteExt}, + sync::mpsc::unbounded_channel, +}; + +use super::Client; +use crate::media::{Downloadable, Uploaded}; +use crate::utils::generate_random_id; + +pub const MIN_CHUNK_SIZE: i32 = 4 * 1024; +pub const MAX_CHUNK_SIZE: i32 = 512 * 1024; +const FILE_MIGRATE_ERROR: i32 = 303; +const BIG_FILE_SIZE: usize = 10 * 1024 * 1024; +const WORKER_COUNT: usize = 4; + +/// Iterator returned by [`Client::iter_download`]. +pub struct DownloadIter { + client: Client, + done: bool, + size: Option, + variant: DownloadIterVariant, +} + +enum DownloadIterVariant { + Request(tl::functions::upload::GetFile), + PreDownloaded(Vec), + PreFailed(io::Error), + Empty, +} + +impl DownloadIter { + /// Changes the chunk size, in bytes, used to make requests. Useful if you only need to get a + /// small part of a file. By default, `MAX_CHUNK_SIZE` is used. + /// + /// # Panics + /// + /// Panics if `size` is not divisible by `MIN_CHUNK_SIZE`, or if `size` is not in contained in + /// the range `MIN_CHUNK_SIZE..=MAX_CHUNK_SIZE`. + pub fn chunk_size(mut self, size: i32) -> Self { + assert!((MIN_CHUNK_SIZE..=MAX_CHUNK_SIZE).contains(&size) && size % MIN_CHUNK_SIZE == 0); + match &mut self.variant { + DownloadIterVariant::Request(request) => request.limit = size, + _ => {} + } + self + } + + /// Skips `n` chunks to start downloading a different offset from the file. If you want to + /// skip less data, modify the `chunk_size` before calling this method, and then reset it to + /// any value you want. + pub fn skip_chunks(mut self, n: i32) -> Self { + match &mut self.variant { + DownloadIterVariant::Request(request) => { + request.offset += request.limit as i64 * (n as i64) + } + _ => {} + } + self + } + + /// Fetch and return the next chunk. + pub async fn next(&mut self) -> Result>, InvocationError> { + if self.done { + return Ok(None); + } + + let variant = mem::replace(&mut self.variant, DownloadIterVariant::Empty); + + let mut request = match variant { + DownloadIterVariant::Request(r) => r, + DownloadIterVariant::PreDownloaded(data) => { + self.done = true; + return Ok(Some(data.clone())); + } + DownloadIterVariant::PreFailed(error) => { + return Err(InvocationError::Io(error)); + } + DownloadIterVariant::Empty => return Ok(None), + }; + + use tl::enums::upload::File; + + // TODO handle maybe FILEREF_UPGRADE_NEEDED + let mut dc = self.client.0.session.home_dc_id()?; + loop { + break match self.client.invoke_in_dc(dc, &request).await { + Ok(File::File(f)) => { + let reached_known_size = self + .size + .is_some_and(|size| request.offset as usize + f.bytes.len() >= size); + + if reached_known_size || f.bytes.len() < request.limit as usize { + self.done = true; + if f.bytes.is_empty() { + break Ok(None); + } + } + + request.offset += request.limit as i64; + self.variant = DownloadIterVariant::Request(request); + + Ok(Some(f.bytes)) + } + Ok(File::CdnRedirect(_)) => { + panic!("API returned File::CdnRedirect even though cdn_supported = false"); + } + Err(InvocationError::Rpc(err)) if &err.name == "AUTH_KEY_UNREGISTERED" => { + match self.client.copy_auth_to_dc(dc).await { + Ok(_) => continue, + Err(e) => Err(e), + } + } + Err(InvocationError::Rpc(err)) if err.code == FILE_MIGRATE_ERROR => { + dc = err.value.unwrap() as _; + continue; + } + Err(e) => Err(e), + }; + } + } +} + +/// Method implementations related to uploading or downloading files. +impl Client { + /// Returns a new iterator over the contents of a media document that will be downloaded. + /// + /// # Examples + /// + /// ``` + /// # async fn f(downloadable: grammers_client::media::Media, client: grammers_client::Client) -> Result<(), Box> { + /// let mut file_bytes = Vec::new(); + /// let mut download = client.iter_download(&downloadable); + /// + /// while let Some(chunk) = download.next().await? { + /// file_bytes.extend(chunk); + /// } + /// + /// // The file is now downloaded in-memory, inside `file_bytes`! + /// # Ok(()) + /// # } + /// ``` + pub fn iter_download(&self, downloadable: &D) -> DownloadIter { + if let Some(data) = downloadable.to_data() { + DownloadIter { + client: self.clone(), + done: false, + size: Some(data.len()), + variant: DownloadIterVariant::PreDownloaded(data), + } + } else if let Some(location) = downloadable.to_raw_input_location() { + DownloadIter { + client: self.clone(), + done: false, + size: downloadable.size(), + variant: DownloadIterVariant::Request(tl::functions::upload::GetFile { + precise: false, + cdn_supported: false, + location, + offset: 0, + limit: MAX_CHUNK_SIZE, + }), + } + } else { + DownloadIter { + client: self.clone(), + done: false, + size: None, + variant: DownloadIterVariant::PreFailed(io::Error::new( + io::ErrorKind::Other, + "media not downloadable", + )), + } + } + } + + /// Downloads a media file into the specified path. + /// + /// If the file already exists, it will be overwritten. + /// + /// This is a small wrapper around [`Client::iter_download`] for the common case of + /// wanting to save the file locally. + /// + /// # Examples + /// + /// ``` + /// # async fn f(downloadable: grammers_client::media::Media, client: grammers_client::Client) -> Result<(), Box> { + /// client.download_media(&downloadable, "/home/username/photos/holidays.jpg").await?; + /// # Ok(()) + /// # } + /// ``` + #[cfg(feature = "fs")] + pub async fn download_media>( + &self, + downloadable: &D, + path: P, + ) -> Result<(), InvocationError> { + // Concurrent downloader + if let Some((location, size)) = downloadable + .to_raw_input_location() + .zip(downloadable.size()) + { + if size > BIG_FILE_SIZE { + return self + .download_media_concurrent(location, size, path, WORKER_COUNT) + .await; + } + } + + let mut download = self.iter_download(downloadable); + Ok(Client::load(path, &mut download).await?) + } + + #[cfg(feature = "fs")] + async fn load>(path: P, download: &mut DownloadIter) -> Result<(), io::Error> { + let mut file = fs::File::create(path).await?; + while let Some(chunk) = download.next().await.map_err(io::Error::other)? { + file.write_all(&chunk).await?; + } + + Ok(()) + } + + /// Downloads a `Document` to specified path using multiple connections + #[cfg(feature = "fs")] + async fn download_media_concurrent>( + &self, + location: tl::enums::InputFileLocation, + size: usize, + path: P, + workers: usize, + ) -> Result<(), InvocationError> { + // Allocate + let mut file = fs::File::create(path).await.map_err(InvocationError::Io)?; + file.set_len(size as u64) + .await + .map_err(InvocationError::Io)?; + file.seek(SeekFrom::Start(0)) + .await + .map_err(InvocationError::Io)?; + + // Start workers + let (tx, mut rx) = unbounded_channel(); + let part_index = Arc::new(tokio::sync::Mutex::::new(0)); + let mut tasks = vec![]; + let home_dc_id = self.0.session.home_dc_id()?; + for _ in 0..workers { + let location = location.clone(); + let tx = tx.clone(); + let part_index = part_index.clone(); + let client = self.clone(); + let task = tokio::task::spawn(async move { + let mut retry_offset = None; + let mut dc = home_dc_id; + loop { + // Calculate file offset + let offset: i64 = { + if let Some(offset) = retry_offset { + retry_offset = None; + offset + } else { + let mut i = part_index.lock().await; + *i += 1; + MAX_CHUNK_SIZE as i64 * (*i - 1) + } + }; + if offset >= size as i64 { + break; + } + // Fetch from telegram + let request = &tl::functions::upload::GetFile { + precise: true, + cdn_supported: false, + location: location.clone(), + offset, + limit: MAX_CHUNK_SIZE, + }; + match client.invoke_in_dc(dc, request).await { + Ok(tl::enums::upload::File::File(file)) => { + tx.send((offset as u64, file.bytes)).unwrap(); + } + Ok(tl::enums::upload::File::CdnRedirect(_)) => { + panic!( + "API returned File::CdnRedirect even though cdn_supported = false" + ); + } + Err(InvocationError::Rpc(err)) if &err.name == "AUTH_KEY_UNREGISTERED" => { + match client.copy_auth_to_dc(dc).await { + Ok(_) => { + retry_offset = Some(offset); + continue; + } + Err(e) => return Err(e), + } + } + Err(InvocationError::Rpc(err)) => { + if err.code == FILE_MIGRATE_ERROR { + dc = err.value.unwrap() as _; + retry_offset = Some(offset); + continue; + } + return Err(InvocationError::Rpc(err)); + } + Err(e) => return Err(e), + } + } + Ok::<(), InvocationError>(()) + }); + tasks.push(task); + } + drop(tx); + + // File write loop + let mut pos = 0; + while let Some((offset, data)) = rx.recv().await { + if offset != pos { + file.seek(SeekFrom::Start(offset)).await?; + } + file.write_all(&data).await?; + pos = offset + data.len() as u64; + } + + // Check if all tasks finished succesfully + for task in tasks { + let res = task.await.map_err(io::Error::other)?; + res?; + } + Ok(()) + } + + /// Uploads an async stream to Telegram servers. + /// + /// The file is not sent to any peer, but can be used as media when sending messages for a + /// certain period of time (less than a day). You can use this uploaded file multiple times. + /// + /// Refer to [`InputMessage`] to learn more uses for `uploaded_file`. + /// + /// The stream size must be known beforehand. If this is not possible, you might need to + /// process the entire async stream to determine its size, and then use the size and the + /// downloaded buffer. + /// + /// The stream size may be less or equal to the actual length of the stream, but not more. + /// If it's less, you may continue to read from the stream after the method returns. + /// If it's more, the method will fail because it does not have enough data to read. + /// + /// Note that Telegram uses the file name in certain methods, for example, to make sure the + /// file is an image when trying to use send the file as photo media, so it is important that + /// the file name at least uses the right extension, even if the name is a dummy value. + /// If the input file name is empty, the non-empty dummy value "a" will be used instead. + /// Because it has no extension, you may not be able to use the file in certain methods. + /// + /// # Examples + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client, some_vec: &[u8]) -> Result<(), Box> { + /// use grammers_client::message::InputMessage; + /// + /// // In-memory `Vec` buffers can be used as async streams + /// let size = some_vec.len(); + /// let mut stream = std::io::Cursor::new(some_vec); + /// let uploaded_file = client.upload_stream(&mut stream, size, "sleep.jpg".to_string()).await?; + /// + /// client.send_message(peer, InputMessage::new().text("Zzz...").photo(uploaded_file)).await?; + /// # Ok(()) + /// # } + /// ``` + /// + /// [`InputMessage`]: crate::message::InputMessage + pub async fn upload_stream( + &self, + stream: &mut S, + size: usize, + name: String, + ) -> Result { + let file_id = generate_random_id(); + let name = if name.is_empty() { + "a".to_string() + } else { + name + }; + + let big_file = size > BIG_FILE_SIZE; + let parts = PartStream::new(stream, size); + let total_parts = parts.total_parts(); + + if big_file { + let parts = Arc::new(parts); + let mut tasks = FuturesUnordered::new(); + for _ in 0..WORKER_COUNT { + let handle = self.clone(); + let parts = Arc::clone(&parts); + let task = async move { + while let Some((part, bytes)) = parts.next_part().await? { + let ok = handle + .invoke(&tl::functions::upload::SaveBigFilePart { + file_id, + file_part: part, + file_total_parts: total_parts, + bytes, + }) + .await + .map_err(io::Error::other)?; + + if !ok { + return Err(io::Error::new( + io::ErrorKind::Other, + "server failed to store uploaded data", + )); + } + } + Ok(()) + }; + tasks.push(task); + } + + while let Some(res) = tasks.next().await { + res?; + } + + Ok(Uploaded::from_raw( + tl::types::InputFileBig { + id: file_id, + parts: total_parts, + name, + } + .into(), + )) + } else { + let mut md5 = md5::Context::new(); + while let Some((part, bytes)) = parts.next_part().await? { + md5.consume(&bytes); + let ok = self + .invoke(&tl::functions::upload::SaveFilePart { + file_id, + file_part: part, + bytes, + }) + .await + .map_err(io::Error::other)?; + + if !ok { + return Err(io::Error::new( + io::ErrorKind::Other, + "server failed to store uploaded data", + )); + } + } + Ok(Uploaded::from_raw( + tl::types::InputFile { + id: file_id, + parts: total_parts, + name, + md5_checksum: format!("{:x}", md5.finalize()), + } + .into(), + )) + } + } + + /// Uploads a local file to Telegram servers. + /// + /// The file is not sent to any peer, but can be used as media when sending messages for a + /// certain period of time (less than a day). You can use this uploaded file multiple times. + /// + /// Refer to [`InputMessage`] to learn more uses for `uploaded_file`. + /// + /// If you need more control over the uploaded data, such as performing only a partial upload + /// or with a different name, use [`Client::upload_stream`] instead. + /// + /// # Examples + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// use grammers_client::message::InputMessage; + /// + /// let uploaded_file = client.upload_file("/home/username/photos/holidays.jpg").await?; + /// + /// client.send_message(peer, InputMessage::new().text("Check this out!").photo(uploaded_file)).await?; + /// # Ok(()) + /// # } + /// ``` + /// + /// [`InputMessage`]: crate::message::InputMessage + #[cfg(feature = "fs")] + pub async fn upload_file>(&self, path: P) -> Result { + let path = path.as_ref(); + + let mut file = fs::File::open(path).await?; + let size = file.seek(SeekFrom::End(0)).await? as usize; + file.seek(SeekFrom::Start(0)).await?; + + // File name will only be `None` for `..` path, and directories cannot be uploaded as + // files, so it's fine to unwrap. + let name = path.file_name().unwrap().to_string_lossy().to_string(); + + self.upload_stream(&mut file, size, name).await + } +} + +struct PartStreamInner<'a, S: AsyncRead + Unpin> { + stream: &'a mut S, + current_part: i32, +} + +struct PartStream<'a, S: AsyncRead + Unpin> { + inner: AsyncMutex>, + total_parts: i32, +} + +impl<'a, S: AsyncRead + Unpin> PartStream<'a, S> { + fn new(stream: &'a mut S, size: usize) -> Self { + let total_parts = ((size + MAX_CHUNK_SIZE as usize - 1) / MAX_CHUNK_SIZE as usize) as i32; + Self { + inner: AsyncMutex::new(PartStreamInner { + stream, + current_part: 0, + }), + total_parts, + } + } + + fn total_parts(&self) -> i32 { + self.total_parts + } + + async fn next_part(&self) -> Result)>, io::Error> { + let mut lock = self.inner.lock().await; + if lock.current_part >= self.total_parts { + return Ok(None); + } + let mut read = 0; + let mut buffer = vec![0; MAX_CHUNK_SIZE as usize]; + + while read != buffer.len() { + let n = lock.stream.read(&mut buffer[read..]).await?; + if n == 0 { + if lock.current_part == self.total_parts - 1 { + break; + } else { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "reached EOF before reaching the last file part", + )); + } + } + read += n; + } + + let bytes = if read == buffer.len() { + buffer + } else { + buffer[..read].to_vec() + }; + + let res = Ok(Some((lock.current_part, bytes))); + lock.current_part += 1; + res + } +} diff --git a/vendor/grammers-client/src/client/iter_buffer.rs b/vendor/grammers-client/src/client/iter_buffer.rs new file mode 100755 index 0000000..d729d71 --- /dev/null +++ b/vendor/grammers-client/src/client/iter_buffer.rs @@ -0,0 +1,95 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +use std::collections::VecDeque; + +use grammers_mtsender::InvocationError; + +use super::Client; + +/// Common parts to most requests that are used for creating iterators. +/// +/// Client methods will either return a type alias or a wrapper around this. +pub struct IterBuffer { + pub(crate) client: Client, + pub(crate) limit: Option, + pub(crate) fetched: usize, + pub(crate) buffer: VecDeque, + pub(crate) last_chunk: bool, + pub(crate) total: Option, + pub(crate) request: R, +} + +impl IterBuffer { + /// Create a new `IterBuffer` instance from a handle, capacity and request. + pub(crate) fn from_request(client: &Client, capacity: usize, request: R) -> Self { + Self { + client: client.clone(), + limit: None, + fetched: 0, + buffer: VecDeque::with_capacity(capacity), + last_chunk: false, + total: None, + request, + } + } + + /// Change how many items will be returned from the iterator. + /// + /// Using `limit` instead of `take` on the iterator is useful because outgoing requests can + /// ask for less items from the server to only fetch what's needed. + pub fn limit(mut self, n: usize) -> Self { + self.limit = Some(n); + self + } + + /// Checks whether the limit has been reached and no more items should be fetched. + fn limit_reached(&self) -> bool { + if let Some(limit) = self.limit { + self.fetched >= limit + } else { + false + } + } + + /// Return the next result item from the buffer unless more data needs to be fetched. + /// + /// Data does not need to be fetched if the limit is reached or the buffer is empty and the + /// last chunk was reached. + pub(crate) fn next_raw(&mut self) -> Option, InvocationError>> { + if self.limit_reached() || (self.buffer.is_empty() && self.last_chunk) { + Some(Ok(None)) + } else { + self.pop_item().map(|item| Ok(Some(item))) + } + } + + /// Determines the new "limit" for the request, so that no unnecessary items are fetched from + /// the network. + pub(crate) fn determine_limit(&self, max: usize) -> i32 { + if let Some(limit) = self.limit { + if self.fetched < limit { + (limit - self.fetched).min(max) as i32 + } else { + 1 // 0 would cause Telegram to send a default amount and not actually 0 + } + } else { + max as i32 + } + } + + /// Pop a buffered item from the queue, and increment the amount of items fetched (returned). + pub(crate) fn pop_item(&mut self) -> Option { + if let Some(item) = self.buffer.pop_front() { + self.fetched += 1; + Some(item) + } else { + None + } + } +} diff --git a/vendor/grammers-client/src/client/messages.rs b/vendor/grammers-client/src/client/messages.rs new file mode 100644 index 0000000..6a5ce1e --- /dev/null +++ b/vendor/grammers-client/src/client/messages.rs @@ -0,0 +1,1395 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +//! Methods related to sending messages. + +use std::collections::HashMap; + +use chrono::{DateTime, FixedOffset}; +use grammers_mtsender::InvocationError; +use grammers_session::types::{PeerAuth, PeerId, PeerKind, PeerRef}; +use grammers_tl_types::{self as tl, enums::InputPeer}; +use log::{Level, log_enabled, warn}; + +use super::{Client, IterBuffer}; +use crate::media::{InputMedia, Media}; +use crate::message::{InputMessage, InputReactions, Message}; +use crate::utils::{generate_random_id, generate_random_ids}; + +async fn map_random_ids_to_messages( + client: &Client, + fetched_in: PeerRef, + random_ids: &[i64], + updates: tl::enums::Updates, +) -> Result>, Box> { + match updates { + tl::enums::Updates::Updates(tl::types::Updates { + updates, + users, + chats, + date: _, + seq: _, + }) => { + let peers = client.build_peer_map(users, chats).await; + + let rnd_to_id = updates + .iter() + .filter_map(|update| match update { + tl::enums::Update::MessageId(u) => Some((u.random_id, u.id)), + _ => None, + }) + .collect::>(); + + // TODO ideally this would use the same UpdateIter mechanism to make sure we don't + // accidentally miss variants + let mut id_to_msg = updates + .into_iter() + .filter_map(|update| match update { + tl::enums::Update::NewMessage(tl::types::UpdateNewMessage { + message, .. + }) => Some(message), + tl::enums::Update::NewChannelMessage(tl::types::UpdateNewChannelMessage { + message, + .. + }) => Some(message), + tl::enums::Update::NewScheduledMessage( + tl::types::UpdateNewScheduledMessage { message, .. }, + ) => Some(message), + _ => None, + }) + .map(|message| { + Message::from_raw(client, message, Some(fetched_in.clone()), peers.handle()) + }) + .map(|message| (message.id(), message)) + .collect::>(); + + Ok(random_ids + .iter() + .map(|rnd| { + rnd_to_id + .get(rnd) + .and_then(|id| id_to_msg.remove(id)) + .or_else(|| { + if id_to_msg.len() == 1 { + // If there's no random_id to map from, in the common case a single message + // should've been produced regardless, so try to recover by returning that. + id_to_msg.drain().next().map(|(_, m)| m) + } else { + None + } + }) + }) + .collect()) + } + _ => panic!("API returned something other than Updates so messages can't be mapped"), + } +} + +pub(crate) async fn parse_mention_entities( + client: &Client, + mut entities: Vec, +) -> Option> { + if entities.is_empty() { + return None; + } + + if entities + .iter() + .any(|e| matches!(e, tl::enums::MessageEntity::MentionName(_))) + { + for entity in entities.iter_mut() { + if let tl::enums::MessageEntity::MentionName(mention_name) = entity { + *entity = tl::types::InputMessageEntityMentionName { + offset: mention_name.offset, + length: mention_name.length, + user_id: tl::enums::InputUser::User(tl::types::InputUser { + user_id: mention_name.user_id, + access_hash: client + .0 + .session + .peer(PeerId::user_unchecked(mention_name.user_id)) + .await + .unwrap_or_default() + .and_then(|peer| peer.auth()) + .unwrap_or_default() + .hash(), + }), + } + .into() + } + } + } + + Some(entities) +} + +const MAX_LIMIT: usize = 100; + +/// Build a reply-to a plain message by its ID, leaving all other fields unset. +fn input_reply_to(reply_to_msg_id: i32) -> tl::enums::InputReplyTo { + tl::types::InputReplyToMessage { + reply_to_msg_id, + top_msg_id: None, + reply_to_peer_id: None, + quote_text: None, + quote_entities: None, + quote_offset: None, + monoforum_peer_id: None, + todo_item_id: None, + poll_option: None, + } + .into() +} + +/// Flatten a `messages.Messages` response into its messages, users and chats. +/// +/// Panics on `NotModified`, which is only returned when a non-zero `hash` was sent. +fn unpack_messages( + res: tl::enums::messages::Messages, +) -> ( + Vec, + Vec, + Vec, +) { + use tl::enums::messages::Messages; + + match res { + Messages::Messages(m) => (m.messages, m.users, m.chats), + Messages::Slice(m) => (m.messages, m.users, m.chats), + Messages::ChannelMessages(m) => (m.messages, m.users, m.chats), + Messages::NotModified(_) => { + panic!("API returned Messages::NotModified even though GetMessages was used") + } + } +} + +impl> IterBuffer { + /// Fetches the total unless cached. + /// + /// The `request.limit` should be set to the right value before calling this method. + async fn get_total(&mut self) -> Result { + if let Some(total) = self.total { + return Ok(total); + } + + use tl::enums::messages::Messages; + + let total = match self.client.invoke(&self.request).await? { + Messages::Messages(messages) => messages.messages.len(), + Messages::Slice(messages) => messages.count as usize, + Messages::ChannelMessages(messages) => messages.count as usize, + Messages::NotModified(messages) => messages.count as usize, + }; + self.total = Some(total); + Ok(total) + } + + /// Performs the network call, fills the buffer, and returns the `offset_rate` if any. + /// + /// The `request.limit` should be set to the right value before calling this method. + async fn fill_buffer( + &mut self, + limit: i32, + reverse: bool, + peer: Option, + ) -> Result, InvocationError> { + use tl::enums::messages::Messages; + + let (mut messages, users, chats, rate) = match self.client.invoke(&self.request).await? { + Messages::Messages(m) => { + self.last_chunk = true; + self.total = Some(m.messages.len()); + (m.messages, m.users, m.chats, None) + } + Messages::Slice(m) => { + // Can't rely on `count(messages) < limit` as the stop condition. + // See https://t.me/tdlibchat/57257 for discussion. As an example: + // offset_id 132002, limit 4 => we get msg 131999 & 131998 + // offset_id 132002, limit 3 => we get msg 131999 + // offset_id 132002, limit 2 => we get msg 132000 + // offset_id 132002, limit 1 => we get msg 132001 + // + // When iterating newest-to-oldest, the highest fetched message ID is + // lower than or equal to the limit, there can't be more messages after + // (highest ID - limit), because the absolute lowest message ID is 1. + self.last_chunk = + m.messages.is_empty() || (!reverse && m.messages[0].id() <= limit); + + self.total = Some(m.count as usize); + + (m.messages, m.users, m.chats, m.next_rate) + } + Messages::ChannelMessages(m) => { + self.last_chunk = + m.messages.is_empty() || (!reverse && m.messages[0].id() <= limit); + self.total = Some(m.count as usize); + (m.messages, m.users, m.chats, None) + } + Messages::NotModified(_) => { + panic!("API returned Messages::NotModified even though hash = 0") + } + }; + + if reverse { + messages.reverse(); + } + + let peers = self.client.build_peer_map(users, chats).await; + + let client = self.client.clone(); + self.buffer.extend( + messages + .into_iter() + .map(|message| Message::from_raw(&client, message, peer, peers.handle())), + ); + + Ok(rate) + } +} + +/// Iterator returned by [`Client::iter_messages`]. +pub struct MessageIter { + inner: IterBuffer, + reverse: bool, +} + +impl MessageIter { + fn new(client: &Client, peer: PeerRef) -> Self { + let inner = IterBuffer::from_request( + client, + MAX_LIMIT, + tl::functions::messages::GetHistory { + peer: peer.into(), + offset_id: 0, + offset_date: 0, + add_offset: 0, + limit: 0, + max_id: 0, + min_id: 0, + hash: 0, + }, + ); + + Self { + inner, + reverse: false, + } + } + + /// Changes the starting message ID (exclusive). + pub fn offset_id(mut self, offset: i32) -> Self { + self.inner.request.offset_id = offset; + self + } + + /// Changes the starting message date. + pub fn offset_date(mut self, offset: i32) -> Self { + self.inner.request.offset_date = offset; + self + } + + /// Changes the order to oldest-to-newest. (default is newest-to-oldest) + pub fn reverse(mut self, reverse: bool) -> Self { + self.reverse = reverse; + self + } + + pub fn limit(mut self, limit: usize) -> Self { + self.inner = self.inner.limit(limit); + self + } + + /// Determines the total number of messages in the chat. + /// + /// This only performs a network call if `next` has not been called before. + pub async fn total(&mut self) -> Result { + self.inner.request.limit = 1; + self.inner.get_total().await + } + + /// Returns the next `Message` from the internal buffer, filling the buffer previously if it's + /// empty. + /// + /// Returns `None` if the `limit` is reached or there are no messages left. + pub async fn next(&mut self) -> Result, InvocationError> { + if let Some(result) = self.inner.next_raw() { + return result; + } + + self.inner.request.limit = self.inner.determine_limit(MAX_LIMIT); + let request = &mut self.inner.request; + if self.reverse { + request.add_offset = -request.limit; + request.min_id = request.offset_id; + if request.offset_id == 0 && request.offset_date == 0 { + // With no explicit offset, start from the oldest available page. + request.offset_id = 1; + request.add_offset = -request.limit + 1; // +1 to include the first message (ID=1) + } + } + self.inner + .fill_buffer( + self.inner.request.limit, + self.reverse, + Some(self.inner.request.peer.clone().into()), + ) + .await?; + + // Don't bother updating offsets if this is the last time stuff has to be fetched. + if !self.inner.last_chunk && !self.inner.buffer.is_empty() { + let last = &self.inner.buffer[self.inner.buffer.len() - 1]; + self.inner.request.offset_id = last.id(); + self.inner.request.offset_date = last.date_timestamp(); + } + + Ok(self.inner.pop_item()) + } +} + +/// Iterator over this peer's unread mentions and replies to the logged-in user. +pub type UnreadMentionIter = IterBuffer; + +impl UnreadMentionIter { + fn new(client: &Client, peer: PeerRef) -> Self { + Self::from_request(client, MAX_LIMIT, tl::functions::messages::GetUnreadMentions { + peer: peer.into(), top_msg_id: None, offset_id: 0, add_offset: 0, + limit: 0, max_id: 0, min_id: 0, + }) + } + + /// Continue with mentions older than this message ID (zero starts at newest). + pub fn offset_id(mut self, offset: i32) -> Self { + self.request.offset_id = offset; + self + } + + /// Return the unread count reported by Telegram for this request. + pub async fn total(&mut self) -> Result { + self.request.limit = 1; + self.get_total().await + } + + /// Fetch the next mention using the shared bounded message iterator. + pub async fn next(&mut self) -> Result, InvocationError> { + if let Some(result) = self.next_raw() { return result; } + self.request.limit = self.determine_limit(MAX_LIMIT); + self.fill_buffer(self.request.limit, false, Some(self.request.peer.clone().into())).await?; + if !self.last_chunk && let Some(last) = self.buffer.back() { + self.request.offset_id = last.id(); + } + Ok(self.pop_item()) + } +} + +/// Iterator returned by [`Client::search_messages`]. +pub type SearchIter = IterBuffer; + +impl SearchIter { + fn new(client: &Client, peer: PeerRef) -> Self { + // TODO let users tweak all the options from the request + Self::from_request( + client, + MAX_LIMIT, + tl::functions::messages::Search { + peer: peer.into(), + q: String::new(), + from_id: None, + saved_peer_id: None, + saved_reaction: None, + top_msg_id: None, + filter: tl::enums::MessagesFilter::InputMessagesFilterEmpty, + min_date: 0, + max_date: 0, + offset_id: 0, + add_offset: 0, + limit: 0, + max_id: 0, + min_id: 0, + hash: 0, + }, + ) + } + + /// Changes the message identifier upper bound. + pub fn offset_id(mut self, offset: i32) -> Self { + self.request.offset_id = offset; + self + } + + /// Changes the query of the search. Telegram servers perform a somewhat fuzzy search over + /// this query (so a word in singular may also return messages with the word in plural, for + /// example). + pub fn query(mut self, query: &str) -> Self { + self.request.q = query.to_string(); + self + } + + /// Restricts results to messages sent by the logged-in user + pub fn sent_by_self(mut self) -> Self { + self.request.from_id = Some(InputPeer::PeerSelf); + self + } + + /// Restricts results to messages sent by a specific peer. + pub fn sent_by(mut self, peer: impl Into) -> Self { + self.request.from_id = Some(peer.into().into()); + self + } + + /// Returns only messages with date bigger than date_time. + /// + /// ``` + /// use chrono::DateTime; + /// + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// // Search messages sent after Jan 1st, 2021 + /// let min_date = DateTime::parse_from_rfc3339("2021-01-01T00:00:00-00:00").unwrap(); + /// + /// let mut messages = client.search_messages(peer).min_date(&min_date); + /// + /// # Ok(()) + /// # } + /// ``` + pub fn min_date(mut self, date_time: &DateTime) -> Self { + self.request.min_date = date_time.timestamp() as i32; + self + } + + /// Returns only messages with date smaller than date_time + /// + /// ``` + /// use chrono::DateTime; + /// + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// // Search messages sent before Dec, 25th 2022 + /// let max_date = DateTime::parse_from_rfc3339("2022-12-25T00:00:00-00:00").unwrap(); + /// + /// let mut messages = client.search_messages(peer).max_date(&max_date); + /// + /// # Ok(()) + /// # } + /// ``` + pub fn max_date(mut self, date_time: &DateTime) -> Self { + self.request.max_date = date_time.timestamp() as i32; + self + } + + /// Changes the media filter. Only messages with this type of media will be fetched. + pub fn filter(mut self, filter: tl::enums::MessagesFilter) -> Self { + self.request.filter = filter; + self + } + + /// Determines how many messages there are in total. + /// + /// This only performs a network call if `next` has not been called before. + pub async fn total(&mut self) -> Result { + // Unlike most requests, a limit of 0 actually returns 0 and not a default amount + // (as of layer 120). + self.request.limit = 0; + self.get_total().await + } + + /// Return the next `Message` from the internal buffer, filling the buffer previously if it's + /// empty. + /// + /// Returns `None` if the `limit` is reached or there are no messages left. + pub async fn next(&mut self) -> Result, InvocationError> { + if let Some(result) = self.next_raw() { + return result; + } + + self.request.limit = self.determine_limit(MAX_LIMIT); + self.fill_buffer( + self.request.limit, + false, + Some(self.request.peer.clone().into()), + ) + .await?; + + // Don't bother updating offsets if this is the last time stuff has to be fetched. + if !self.last_chunk && !self.buffer.is_empty() { + let last = &self.buffer[self.buffer.len() - 1]; + self.request.offset_id = last.id(); + self.request.max_date = last.date_timestamp(); + } + + Ok(self.pop_item()) + } +} + +/// Iterator returned by [`Client::search_all_messages`]. +pub type GlobalSearchIter = IterBuffer; + +impl GlobalSearchIter { + fn new(client: &Client) -> Self { + // TODO let users tweak all the options from the request + Self::from_request( + client, + MAX_LIMIT, + tl::functions::messages::SearchGlobal { + folder_id: None, + q: String::new(), + filter: tl::enums::MessagesFilter::InputMessagesFilterEmpty, + min_date: 0, + max_date: 0, + offset_rate: 0, + offset_peer: tl::enums::InputPeer::Empty, + offset_id: 0, + limit: 0, + broadcasts_only: false, + groups_only: false, + users_only: false, + }, + ) + } + + /// Changes the message identifier upper bound. + pub fn offset_id(mut self, offset: i32) -> Self { + self.request.offset_id = offset; + self + } + + /// Changes the query of the search. Telegram servers perform a somewhat fuzzy search over + /// this query (so a word in singular may also return messages with the word in plural, for + /// example). + pub fn query(mut self, query: &str) -> Self { + self.request.q = query.to_string(); + self + } + + /// Changes the media filter. Only messages with this type of media will be fetched. + pub fn filter(mut self, filter: tl::enums::MessagesFilter) -> Self { + self.request.filter = filter; + self + } + + /// Determines how many messages there are in total. + /// + /// This only performs a network call if `next` has not been called before. + pub async fn total(&mut self) -> Result { + self.request.limit = 1; + self.get_total().await + } + + /// Return the next `Message` from the internal buffer, filling the buffer previously if it's + /// empty. + /// + /// Returns `None` if the `limit` is reached or there are no messages left. + pub async fn next(&mut self) -> Result, InvocationError> { + if let Some(result) = self.next_raw() { + return result; + } + + self.request.limit = self.determine_limit(MAX_LIMIT); + let offset_rate = self.fill_buffer(self.request.limit, false, None).await?; + + // Don't bother updating offsets if this is the last time stuff has to be fetched. + if !self.last_chunk && !self.buffer.is_empty() { + let last = &self.buffer[self.buffer.len() - 1]; + self.request.offset_rate = offset_rate.unwrap_or(0); + self.request.offset_peer = last + .peer_ref() + .await? + .map(|peer| peer.into()) + .unwrap_or(tl::enums::InputPeer::Empty); + self.request.offset_id = last.id(); + } + + Ok(self.pop_item()) + } +} + +/// Method implementations related to sending, modifying or getting messages. +impl Client { + /// Sends a message to the desired peer. + /// + /// This method can also be used to send media such as photos, videos, documents, polls, etc. + /// + /// If you want to send a local file as media, you will need to use + /// [`Client::upload_file`] first. + /// + /// Refer to [`InputMessage`] to learn more formatting options, such as using markdown or + /// adding buttons under your message (if you're logged in as a bot). + /// + /// See also: [`Message::respond`], [`Message::reply`]. + /// + /// # Examples + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// client.send_message(peer, "Boring text message :-(").await?; + /// + /// use grammers_client::message::InputMessage; + /// + /// client.send_message(peer, InputMessage::new().text("Sneaky message").silent(true)).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn send_message, M: Into>( + &self, + peer: C, + message: M, + ) -> Result { + let peer = peer.into(); + let message = message.into(); + let random_id = generate_random_id(); + let entities = parse_mention_entities(self, message.entities.clone()).await; + let updates = if let Some(media) = message.media.clone() { + self.invoke(&tl::functions::messages::SendMedia { + silent: message.silent, + background: message.background, + clear_draft: message.clear_draft, + peer: peer.into(), + reply_to: message.reply_to.map(input_reply_to), + media, + message: message.text.clone(), + random_id, + reply_markup: message.reply_markup.clone(), + entities, + schedule_date: message.schedule_date, + schedule_repeat_period: None, + send_as: None, + noforwards: false, + update_stickersets_order: false, + invert_media: message.invert_media, + quick_reply_shortcut: None, + effect: None, + allow_paid_floodskip: false, + allow_paid_stars: None, + suggested_post: None, + }) + .await + } else { + self.invoke(&tl::functions::messages::SendMessage { + no_webpage: !message.link_preview, + silent: message.silent, + background: message.background, + clear_draft: message.clear_draft, + peer: peer.into(), + reply_to: message.reply_to.map(input_reply_to), + message: message.text.clone(), + random_id, + reply_markup: message.reply_markup.clone(), + entities, + schedule_date: message.schedule_date, + schedule_repeat_period: None, + send_as: None, + noforwards: false, + update_stickersets_order: false, + invert_media: message.invert_media, + quick_reply_shortcut: None, + effect: None, + allow_paid_floodskip: false, + allow_paid_stars: None, + suggested_post: None, + rich_message: None, + }) + .await + }?; + + Ok(match updates { + tl::enums::Updates::UpdateShortSentMessage(updates) => { + let peer = if peer.id.bare_id().is_none() { + // from_raw_short_updates needs the peer ID + self.0.session.peer_ref(peer.id).await?.unwrap() + } else { + peer + }; + + Message::from_raw_short_updates(self, updates, message, peer) + } + updates => { + let updates_debug = if log_enabled!(Level::Warn) { + Some(updates.clone()) + } else { + None + }; + + match map_random_ids_to_messages(self, peer, &[random_id], updates) + .await? + .pop() + .flatten() + { + Some(message) => message, + None => { + if let Some(updates) = updates_debug { + warn!( + "failed to find just-sent message in response updates; please report this:" + ); + warn!("{:#?}", updates); + } + Message::from_raw( + self, + tl::enums::Message::Empty(tl::types::MessageEmpty { + id: 0, + peer_id: Some(peer.id.into()), + }), + Some(peer), + self.empty_peer_map(), + ) + } + } + } + }) + } + + /// Sends a album to the desired peer. + /// + /// This method can also be used to send a bunch of media such as photos, videos, documents, polls, etc. + /// + /// If you want to send a local file as media, you will need to use + /// [`Client::upload_file`] first. + /// + /// Refer to [`InputMedia`] to learn more formatting options, such as using markdown. + /// + /// See also: [`Message::respond_album`], [`Message::reply_album`]. + /// + /// # Examples + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// use grammers_client::media::InputMedia; + /// + /// client.send_album(peer, vec![InputMedia::new().caption("A album").photo_url("https://example.com/cat.jpg")]).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn send_album>( + &self, + peer: C, + mut medias: Vec, + ) -> Result>, InvocationError> { + let peer = peer.into(); + let random_ids = generate_random_ids(medias.len()); + + // Upload external files + for media in medias.iter_mut() { + let raw_media = media.media.clone().unwrap(); + + if matches!( + raw_media, + tl::enums::InputMedia::UploadedPhoto(_) + | tl::enums::InputMedia::PhotoExternal(_) + | tl::enums::InputMedia::UploadedDocument(_) + | tl::enums::InputMedia::DocumentExternal(_) + ) { + let uploaded = self + .invoke(&tl::functions::messages::UploadMedia { + business_connection_id: None, + peer: peer.into(), + media: raw_media, + }) + .await?; + media.media = Some( + Media::from_raw(uploaded) + .unwrap() + .to_raw_input_media() + .unwrap(), + ); + } + } + + let first_media_reply = medias.first().unwrap().reply_to; + + let mut multi_media = Vec::with_capacity(medias.len()); + for (input_media, random_id) in medias.into_iter().zip(random_ids.iter()) { + let entities = parse_mention_entities(self, input_media.entities).await; + let raw_media = input_media.media.unwrap(); + multi_media.push(tl::enums::InputSingleMedia::Media( + tl::types::InputSingleMedia { + media: raw_media, + random_id: *random_id, + message: input_media.caption, + entities, + }, + )) + } + + let updates = self + .invoke(&tl::functions::messages::SendMultiMedia { + silent: false, + background: false, + clear_draft: false, + peer: peer.into(), + reply_to: first_media_reply.map(input_reply_to), + schedule_date: None, + multi_media, + send_as: None, + noforwards: false, + update_stickersets_order: false, + invert_media: false, + quick_reply_shortcut: None, + effect: None, + allow_paid_floodskip: false, + allow_paid_stars: None, + }) + .await?; + + Ok(map_random_ids_to_messages(self, peer, &random_ids, updates).await?) + } + + /// Edits an existing message. + /// + /// Similar to [`Client::send_message`], advanced formatting can be achieved with the + /// options offered by [`InputMessage`]. + /// + /// See also: [`Message::edit`]. + /// + /// # Examples + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// let old_message_id = 123; + /// client.edit_message(peer, old_message_id, "New text message").await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn edit_message, M: Into>( + &self, + peer: C, + message_id: i32, + new_message: M, + ) -> Result<(), InvocationError> { + let new_message = new_message.into(); + let entities = parse_mention_entities(self, new_message.entities).await; + self.invoke(&tl::functions::messages::EditMessage { + no_webpage: !new_message.link_preview, + invert_media: new_message.invert_media, + peer: peer.into().into(), + id: message_id, + message: Some(new_message.text), + media: new_message.media, + reply_markup: new_message.reply_markup, + entities, + schedule_date: new_message.schedule_date, + schedule_repeat_period: None, + quick_reply_shortcut_id: None, + rich_message: None, + }) + .await?; + + Ok(()) + } + + /// Deletes up to 100 messages in a peer. + /// + ///
+ /// + /// When deleting messages from small group peers or private conversations, this + /// method cannot validate that the provided message IDs actually belong to the input peer due + /// to the way Telegram's API works. Make sure to pass correct [`Message::id`]'s. + /// + ///
+ /// + /// The messages are deleted for both ends. + /// + /// The amount of deleted messages is returned (it might be less than the amount of input + /// message IDs if some of them were already missing). It is not possible to find out which + /// messages were actually deleted, but if the request succeeds, none of the specified message + /// IDs will appear in the message history from that point on. + /// + /// See also: [`Message::delete`]. + /// + /// # Examples + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// let message_ids = [123, 456, 789]; + /// + /// // Careful, these messages will be gone after the method succeeds! + /// client.delete_messages(peer, &message_ids).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn delete_messages>( + &self, + peer: C, + message_ids: &[i32], + ) -> Result { + let peer = peer.into(); + let tl::enums::messages::AffectedMessages::Messages(affected) = + if peer.id.kind() == PeerKind::Channel { + self.invoke(&tl::functions::channels::DeleteMessages { + channel: peer.into(), + id: message_ids.to_vec(), + }) + .await + } else { + self.invoke(&tl::functions::messages::DeleteMessages { + revoke: true, + id: message_ids.to_vec(), + }) + .await + }?; + + Ok(affected.pts_count as usize) + } + + /// Forwards up to 100 messages from `source` into `destination`. + /// + /// For consistency with other methods, the peer upon which this request acts comes first + /// (destination), and then the source peer. + /// + /// Returns the new forwarded messages in a list. Those messages that could not be forwarded + /// will be `None`. The length of the resulting list is the same as the length of the input + /// message IDs, and the indices from the list of IDs map to the indices in the result so + /// you can find which messages were forwarded and which message they became. + /// + /// See also: [`Message::forward_to`]. + /// + /// # Examples + /// + /// ``` + /// # async fn f(destination: grammers_session::types::PeerRef, source: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// let message_ids = [123, 456, 789]; + /// + /// let messages = client.forward_messages(destination, &message_ids, source).await?; + /// let fwd_count = messages.into_iter().filter(Option::is_some).count(); + /// println!("Forwarded {} out of {} messages!", fwd_count, message_ids.len()); + /// # Ok(()) + /// # } + /// ``` + pub async fn forward_messages, S: Into>( + &self, + destination: C, + message_ids: &[i32], + source: S, + ) -> Result>, InvocationError> { + self.forward_messages_with_random_ids( + destination, + message_ids, + source, + &generate_random_ids(message_ids.len()), + ) + .await + } + + /// Forward messages with caller-owned deduplication IDs, retaining them on retry. + /// + /// # Panics + /// Panics if the message and random-ID slices differ in length. + pub async fn forward_messages_with_random_ids, S: Into>( + &self, + destination: C, + message_ids: &[i32], + source: S, + random_ids: &[i64], + ) -> Result>, InvocationError> { + assert_eq!(message_ids.len(), random_ids.len(), "one random ID per message"); + // TODO let user customize more options + let peer = destination.into(); + let request = tl::functions::messages::ForwardMessages { + silent: false, + background: false, + with_my_score: false, + drop_author: false, + drop_media_captions: false, + from_peer: source.into().into(), + id: message_ids.to_vec(), + random_id: random_ids.to_vec(), + to_peer: peer.into(), + top_msg_id: None, + reply_to: None, + schedule_date: None, + schedule_repeat_period: None, + send_as: None, + noforwards: false, + quick_reply_shortcut: None, + allow_paid_floodskip: false, + effect: None, + video_timestamp: None, + allow_paid_stars: None, + suggested_post: None, + }; + let result = self.invoke(&request).await?; + Ok(map_random_ids_to_messages(self, peer.into(), &request.random_id, result).await?) + } + + /// Gets the [`Message`] to which the input message is replying to. + /// + /// See also: [`Message::get_reply`]. + /// + /// # Examples + /// + /// ``` + /// # async fn f(message: grammers_client::message::Message, client: grammers_client::Client) -> Result<(), Box> { + /// if let Some(reply) = client.get_reply_to_message(&message).await? { + /// println!("The reply said: {}", reply.text()); + /// } + /// # Ok(()) + /// # } + /// ``` + pub async fn get_reply_to_message( + &self, + message: &Message, + ) -> Result, InvocationError> { + /// Helper method to fetch a single message by its input message. + async fn get_message( + client: &Client, + peer: PeerRef, + id: tl::enums::InputMessage, + ) -> Result<(tl::enums::messages::Messages, bool), InvocationError> { + if peer.id.kind() == PeerKind::Channel { + client + .invoke(&tl::functions::channels::GetMessages { + id: vec![id], + channel: peer.into(), + }) + .await + .map(|res| (res, false)) + } else { + client + .invoke(&tl::functions::messages::GetMessages { id: vec![id] }) + .await + .map(|res| (res, true)) + } + } + + // TODO shouldn't this method take in a message id anyway? + let peer_id = message.peer_id(); + let peer = match peer_id.kind() { + PeerKind::User | PeerKind::Chat => PeerRef { + id: peer_id, + auth: PeerAuth::default(), // unused, so no need to bother fetching it + }, + PeerKind::Channel => message.peer_ref().await?.ok_or(InvocationError::Dropped)?, + }; + let reply_to_message_id = match message.reply_to_message_id() { + Some(id) => id, + None => return Ok(None), + }; + + let input_id = + tl::enums::InputMessage::ReplyTo(tl::types::InputMessageReplyTo { id: message.id() }); + + let (res, filter_req) = match get_message(self, peer, input_id).await { + Ok(tup) => tup, + Err(_) => { + let input_id = tl::enums::InputMessage::Id(tl::types::InputMessageId { + id: reply_to_message_id, + }); + get_message(self, peer, input_id).await? + } + }; + + let (messages, users, chats) = unpack_messages(res); + + let peers = self.build_peer_map(users, chats).await; + Ok(messages + .into_iter() + .map(|m| Message::from_raw(self, m, Some(peer.into()), peers.handle())) + .next() + .filter(|m| !filter_req || m.peer_id() == message.peer_id())) + } + + /// Iterate over the message history of a peer, from most recent to oldest. + /// + /// # Examples + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// // Note we're setting a reasonable limit, or we'd print out ALL the messages in peer! + /// let mut messages = client.iter_messages(peer).limit(100); + /// + /// while let Some(message) = messages.next().await? { + /// println!("{}", message.text()); + /// } + /// # Ok(()) + /// # } + /// ``` + pub fn iter_messages>(&self, peer: C) -> MessageIter { + MessageIter::new(self, peer.into()) + } + + /// Iterate over the messages that match certain search criteria. + /// + /// This allows you to search by text within a peer or filter by media among other things. + /// + /// # Examples + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// // Let's print all the people who think grammers is cool. + /// let mut messages = client.search_messages(peer).query("grammers is cool"); + /// + /// while let Some(message) = messages.next().await? { + /// let sender = message.sender().unwrap(); + /// println!("{}", sender.name().unwrap_or(&sender.id().to_string())); + /// } + /// # Ok(()) + /// # } + /// ``` + pub fn search_messages>(&self, peer: C) -> SearchIter { + SearchIter::new(self, peer.into()) + } + + /// Iterate over a chat's unread mentions without marking them read. + pub fn unread_mentions>(&self, peer: C) -> UnreadMentionIter { + UnreadMentionIter::new(self, peer.into()) + } + + + /// Iterate over the messages that match certain search criteria, without being restricted to + /// searching in a specific peer. The downside is that this global search supports less filters. + /// + /// This allows you to search by text within a peer or filter by media among other things. + /// + /// # Examples + /// + /// ``` + /// # async fn f(client: grammers_client::Client) -> Result<(), Box> { + /// // Let's print all the peers were people think grammers is cool. + /// let mut messages = client.search_all_messages().query("grammers is cool"); + /// + /// while let Some(message) = messages.next().await? { + /// println!("{}", message.peer().unwrap().name().unwrap_or(&message.peer().unwrap().id().to_string())); + /// } + /// # Ok(()) + /// # } + /// ``` + pub fn search_all_messages(&self) -> GlobalSearchIter { + GlobalSearchIter::new(self) + } + + /// Get up to 100 messages using their ID. + /// + /// Returns the new retrieved messages in a list. Those messages that could not be retrieved + /// or do not belong to the input peer will be `None`. The length of the resulting list is the + /// same as the length of the input message IDs, and the indices from the list of IDs map to + /// the indices in the result so you can map them into the new list. + /// + /// # Examples + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// let message_ids = [123, 456, 789]; + /// + /// let messages = client.get_messages_by_id(peer, &message_ids).await?; + /// let count = messages.into_iter().filter(Option::is_some).count(); + /// println!("{} out of {} messages were deleted!", message_ids.len() - count, message_ids.len()); + /// # Ok(()) + /// # } + /// ``` + pub async fn get_messages_by_id>( + &self, + peer: C, + message_ids: &[i32], + ) -> Result>, InvocationError> { + let peer = peer.into(); + let id = message_ids + .iter() + .map(|&id| tl::enums::InputMessage::Id(tl::types::InputMessageId { id })) + .collect(); + + let result = if peer.id.kind() == PeerKind::Channel { + self.invoke(&tl::functions::channels::GetMessages { + channel: peer.into(), + id, + }) + .await + } else { + self.invoke(&tl::functions::messages::GetMessages { id }) + .await + }?; + + let (messages, users, chats) = unpack_messages(result); + + let peers = self.build_peer_map(users, chats).await; + let mut map = messages + .into_iter() + .map(|m| Message::from_raw(self, m, Some(peer.into()), peers.handle())) + .filter(|m| m.peer_id() == peer.id) + .map(|m| (m.id(), m)) + .collect::>(); + + Ok(message_ids.iter().map(|id| map.remove(id)).collect()) + } + + /// Get the latest pin from a peer. + /// + /// # Examples + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// if let Some(message) = client.get_pinned_message(peer).await? { + /// println!("There is a message pinned: {}", message.text()); + /// } else { + /// println!("There are no messages pinned"); + /// } + /// # Ok(()) + /// # } + /// ``` + pub async fn get_pinned_message>( + &self, + peer: C, + ) -> Result, InvocationError> { + let peer = peer.into(); + // TODO return types::Message and print its text in the example + let id = vec![tl::enums::InputMessage::Pinned]; + + let result = if peer.id.kind() == PeerKind::Channel { + self.invoke(&tl::functions::channels::GetMessages { + channel: peer.into(), + id, + }) + .await + } else { + self.invoke(&tl::functions::messages::GetMessages { id }) + .await + }?; + + let (messages, users, chats) = unpack_messages(result); + + let peers = self.build_peer_map(users, chats).await; + Ok(messages + .into_iter() + .map(|m| Message::from_raw(self, m, Some(peer.into()), peers.handle())) + .find(|m| m.peer_id() == peer.id)) + } + + /// Pin a message in the peer. This will not notify any users. + /// + /// # Examples + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// let message_id = 123; + /// client.pin_message(peer, message_id).await?; + /// # Ok(()) + /// # } + /// ``` + // TODO return produced Option + pub async fn pin_message>( + &self, + peer: C, + message_id: i32, + ) -> Result<(), InvocationError> { + self.update_pinned(peer.into(), message_id, true).await + } + + /// Unpin a message from the peer. + /// + /// # Examples + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// let message_id = 123; + /// client.unpin_message(peer, message_id).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn unpin_message>( + &self, + peer: C, + message_id: i32, + ) -> Result<(), InvocationError> { + self.update_pinned(peer.into(), message_id, false).await + } + + async fn update_pinned( + &self, + peer: PeerRef, + id: i32, + pin: bool, + ) -> Result<(), InvocationError> { + self.invoke(&tl::functions::messages::UpdatePinnedMessage { + silent: true, + unpin: !pin, + pm_oneside: false, + peer: peer.into(), + id, + }) + .await + .map(drop) + } + + /// Unpin all currently-pinned messages from the peer. + /// + /// # Examples + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// client.unpin_all_messages(peer).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn unpin_all_messages>( + &self, + peer: C, + ) -> Result<(), InvocationError> { + self.invoke(&tl::functions::messages::UnpinAllMessages { + peer: peer.into().into(), + top_msg_id: None, + saved_peer_id: None, + }) + .await?; + Ok(()) + } + + /// Send reaction. + /// + /// # Examples + /// + /// Via emoticon + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// let message_id = 123; + /// + /// client.send_reactions(peer, message_id, "👍").await?; + /// # Ok(()) + /// # } + /// ``` + /// + /// Make animation big & Add to recent + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// use grammers_client::message::InputReactions; + /// + /// let message_id = 123; + /// let reactions = InputReactions::emoticon("🤯").big().add_to_recent(); + /// + /// client.send_reactions(peer, message_id, reactions).await?; + /// # Ok(()) + /// # } + /// ``` + /// + /// Remove reactions + /// + /// ``` + /// # async fn f(peer: grammers_session::types::PeerRef, client: grammers_client::Client) -> Result<(), Box> { + /// use grammers_client::message::InputReactions; + /// + /// let message_id = 123; + /// + /// client.send_reactions(peer, message_id, InputReactions::remove()).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn send_reactions, R: Into>( + &self, + peer: C, + message_id: i32, + reactions: R, + ) -> Result<(), InvocationError> { + let reactions = reactions.into(); + + self.invoke(&tl::functions::messages::SendReaction { + big: reactions.big, + add_to_recent: reactions.add_to_recent, + peer: peer.into().into(), + msg_id: message_id, + reaction: Some(reactions.reactions), + }) + .await?; + + Ok(()) + } +} diff --git a/vendor/grammers-client/src/client/mod.rs b/vendor/grammers-client/src/client/mod.rs new file mode 100755 index 0000000..a96ce0e --- /dev/null +++ b/vendor/grammers-client/src/client/mod.rs @@ -0,0 +1,34 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +//! Contains client-specific configuration and types. + +mod auth; +mod bots; +mod chats; +#[allow(clippy::module_inception)] +mod client; +mod dialogs; +mod files; +mod iter_buffer; +mod messages; +mod net; +mod retry_policy; +mod updates; + +pub use auth::{LoginToken, PasswordToken, SignInError}; +pub use bots::{InlineResult, InlineResultIter}; +pub use chats::{ParticipantIter, ParticipantPermissions, ProfilePhotoIter}; +pub(crate) use client::ClientInner; +pub use client::{Client, ClientConfiguration, UpdatesConfiguration}; +pub use dialogs::DialogIter; +pub use files::DownloadIter; +pub use iter_buffer::IterBuffer; +pub use messages::{GlobalSearchIter, MessageIter, SearchIter, UnreadMentionIter}; +pub use retry_policy::{AutoSleep, NoRetries, RetryContext, RetryPolicy}; +pub use updates::UpdateStream; diff --git a/vendor/grammers-client/src/client/net.rs b/vendor/grammers-client/src/client/net.rs new file mode 100644 index 0000000..745ff79 --- /dev/null +++ b/vendor/grammers-client/src/client/net.rs @@ -0,0 +1,198 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +use std::num::NonZeroU32; +use std::ops::ControlFlow; +use std::sync::Arc; +use std::time::Duration; + +use grammers_mtsender::{InvocationError, SenderPoolFatHandle}; +use grammers_tl_types::{self as tl, Deserializable}; +use log::info; +use tokio::{sync::Mutex, time::sleep}; + +use super::{Client, ClientConfiguration, ClientInner, RetryContext}; + +/// Method implementations directly related with network connectivity. +impl Client { + /// Creates and returns a new client instance upon successful connection to Telegram. + /// + /// If the session in the configuration did not have an authorization key, a new one + /// will be created and the session will be saved with it. + /// + /// The connection will be initialized with the data from the input configuration. + /// + /// The [`grammers_mtsender::SenderPoolHandle`] does not keep a reference to the [`grammers_session::Session`] + /// or `api_id`, but the [`SenderPool`] itself does, so the latter is used as input to guarantee + /// that the values are correctly shared between the pool and the client handles. + /// + /// # Examples + /// + /// ``` + /// use std::sync::Arc; + /// use grammers_client::Client; + /// use grammers_session::storages::MemorySession; // avoid this storage outside tests! + /// use grammers_mtsender::SenderPool; + /// + /// // Note: these are example values and are not actually valid. + /// // Obtain your own with the developer's phone at https://my.telegram.org. + /// const API_ID: i32 = 932939; + /// + /// # async fn f() -> Result<(), Box> { + /// let session = Arc::new(MemorySession::default()); + /// let pool = SenderPool::new(Arc::clone(&session), API_ID); + /// let client = Client::new(pool.handle); + /// # Ok(()) + /// # } + /// ``` + pub fn new(sender_pool: SenderPoolFatHandle) -> Self { + Self::with_configuration(sender_pool, Default::default()) + } + + /// Like [`Self::new`] but with a custom [`ClientConfiguration`]. + pub fn with_configuration( + sender_pool: SenderPoolFatHandle, + configuration: ClientConfiguration, + ) -> Self { + // TODO Sender doesn't have a way to handle backpressure yet + Self(Arc::new(ClientInner { + session: sender_pool.session, + api_id: sender_pool.api_id, + handle: sender_pool.thin, + configuration, + auth_copied_to_dcs: Mutex::new(Vec::new()), + })) + } + + /// Invoke a raw API call. This directly sends the request to Telegram's servers. + /// + /// Using function definitions corresponding to a different layer is likely to cause the + /// responses to the request to not be understood. + /// + ///
+ /// + /// This method is **not** part of the stability guarantees of semantic + /// versioning. It **may** break during *minor* version changes (but not on patch version + /// changes). Use with care. + /// + ///
+ /// + /// # Examples + /// + /// ``` + /// # async fn f(client: grammers_client::Client) -> Result<(), Box> { + /// use grammers_tl_types as tl; + /// + /// dbg!(client.invoke(&tl::functions::Ping { ping_id: 0 }).await?); + /// # Ok(()) + /// # } + /// ``` + pub async fn invoke( + &self, + request: &R, + ) -> Result { + let dc_id = self.0.session.home_dc_id()?; + self.do_invoke_in_dc(dc_id, request.to_bytes()) + .await + .and_then(|body| R::Return::from_bytes(&body).map_err(|e| e.into())) + } + + /// Like [`Self::invoke`], but in the specified DC. + /// + ///
+ /// + /// This method is **not** part of the stability guarantees of semantic + /// versioning. It **may** break during *minor* version changes (but not on patch version + /// changes). Use with care. + /// + ///
+ pub async fn invoke_in_dc( + &self, + dc_id: i32, + request: &R, + ) -> Result { + self.do_invoke_in_dc(dc_id, request.to_bytes()) + .await + .and_then(|body| R::Return::from_bytes(&body).map_err(|e| e.into())) + } + + async fn do_invoke_in_dc( + &self, + dc_id: i32, + request_body: Vec, + ) -> Result, InvocationError> { + let mut retry_context = RetryContext { + fail_count: NonZeroU32::new(1).unwrap(), + slept_so_far: Duration::default(), + error: InvocationError::Dropped, + }; + + loop { + match self + .0 + .handle + .invoke_in_dc(dc_id, request_body.clone()) + .await + { + Ok(response) => break Ok(response), + Err(e) => { + retry_context.error = e; + match self + .0 + .configuration + .retry_policy + .should_retry(&retry_context) + { + ControlFlow::Continue(delay) => { + info!( + "sleeping on {} for {:?} before retrying", + retry_context.error, delay, + ); + sleep(delay).await; + retry_context.fail_count = retry_context.fail_count.saturating_add(1); + retry_context.slept_so_far += delay; + continue; + } + ControlFlow::Break(()) => break Err(retry_context.error), + } + } + } + } + } + + pub(crate) async fn copy_auth_to_dc(&self, target_dc_id: i32) -> Result<(), InvocationError> { + let mut auth_copied_to_dcs = self.0.auth_copied_to_dcs.lock().await; + if auth_copied_to_dcs.contains(&target_dc_id) { + return Ok(()); + } + + let home_dc_id = self.0.session.home_dc_id()?; + if target_dc_id == home_dc_id { + return Ok(()); + } + + let tl::enums::auth::ExportedAuthorization::Authorization(exported_auth) = self + .invoke(&tl::functions::auth::ExportAuthorization { + dc_id: target_dc_id, + }) + .await?; + + self.invoke_in_dc( + target_dc_id, + &tl::functions::auth::ImportAuthorization { + id: exported_auth.id, + bytes: exported_auth.bytes, + }, + ) + .await?; + + auth_copied_to_dcs.push(target_dc_id); + + Ok(()) + } +} diff --git a/vendor/grammers-client/src/client/retry_policy.rs b/vendor/grammers-client/src/client/retry_policy.rs new file mode 100755 index 0000000..2f5e5aa --- /dev/null +++ b/vendor/grammers-client/src/client/retry_policy.rs @@ -0,0 +1,94 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +//! Retry policy trait and built-in policies for use in [`ClientConfiguration`]. +//! +//! [`ClientConfiguration`]: crate::ClientConfiguration + +use std::num::NonZeroU32; +use std::ops::ControlFlow; +use std::time::Duration; + +use grammers_mtsender::{InvocationError, RpcError}; + +/// This trait controls how the [`Client`] should behave when +/// an invoked request fails with an [`InvocationError`]. +/// +/// [`Client`]: crate::Client +pub trait RetryPolicy: Send + Sync { + /// Determines whether the failing request should retry. + /// + /// If it should Continue, a sleep duration before retrying is included.\ + /// If it should Break, the context error will be propagated to the caller. + fn should_retry(&self, ctx: &RetryContext) -> ControlFlow<(), Duration>; +} + +/// Context passed to [`RetryPolicy::should_retry`]. +pub struct RetryContext { + /// Amount of times the instance of this request has failed. + pub fail_count: NonZeroU32, + /// Sum of the durations for all previous continuations (not total time elapsed since first failure). + pub slept_so_far: Duration, + /// The most recent error caused by the instance of the request. + pub error: InvocationError, +} + +/// Retry policy that will never retry. +pub struct NoRetries; + +impl RetryPolicy for NoRetries { + fn should_retry(&self, _: &RetryContext) -> ControlFlow<(), Duration> { + ControlFlow::Break(()) + } +} + +/// Retry policy that will retry *once* on flood-wait and slow mode wait errors. +/// +/// The library will sleep only if the duration to sleep for is below or equal to the threshold. +pub struct AutoSleep { + /// The (inclusive) threshold below which the library should automatically sleep. + pub threshold: Duration, + + /// `Some` if I/O errors should be treated as a flood error that would last the specified duration. + /// This duration will ignore the `threshold` and always be slept on on the first I/O error. + pub io_errors_as_flood_of: Option, +} + +impl RetryPolicy for AutoSleep { + fn should_retry(&self, ctx: &RetryContext) -> ControlFlow<(), Duration> { + match ctx.error { + InvocationError::Rpc(RpcError { + code: 420, + value: Some(seconds), + .. + }) if ctx.fail_count.get() == 1 && seconds as u64 <= self.threshold.as_secs() => { + ControlFlow::Continue(Duration::from_secs(seconds as _)) + } + InvocationError::Io(_) if ctx.fail_count.get() == 1 => { + if let Some(duration) = self.io_errors_as_flood_of { + ControlFlow::Continue(duration) + } else { + ControlFlow::Break(()) + } + } + _ => ControlFlow::Break(()), + } + } +} + +impl Default for AutoSleep { + /// Returns an instance with a threshold of 60 seconds. + /// + /// I/O errors will be treated as if they were a 1-second flood. + fn default() -> Self { + Self { + threshold: Duration::from_secs(60), + io_errors_as_flood_of: Some(Duration::from_secs(1)), + } + } +} diff --git a/vendor/grammers-client/src/client/updates.rs b/vendor/grammers-client/src/client/updates.rs new file mode 100644 index 0000000..ed79919 --- /dev/null +++ b/vendor/grammers-client/src/client/updates.rs @@ -0,0 +1,419 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +//! Methods to deal with and offer access to updates. + +use std::collections::VecDeque; +use std::time::{Duration, Instant}; + +use grammers_mtsender::InvocationError; +use grammers_session::ErasedSession; +use grammers_session::types::{PeerId, PeerInfo, UpdateState, UpdatesState}; +use grammers_session::updates::{MessageBoxes, PrematureEndReason, State, UpdatesLike}; +use grammers_tl_types as tl; +use log::{trace, warn}; +use tokio::sync::{mpsc, watch}; +use tokio::time::timeout_at; + +use super::{Client, UpdatesConfiguration}; +use crate::peer::PeerMap; +use crate::update::Update; + +/// How long to wait after warning the user that the updates limit was exceeded. +const UPDATE_LIMIT_EXCEEDED_LOG_COOLDOWN: Duration = Duration::from_secs(300); + +// See https://core.telegram.org/method/updates.getChannelDifference. +const BOT_CHANNEL_DIFF_LIMIT: i32 = 100000; +const USER_CHANNEL_DIFF_LIMIT: i32 = 100; + +async fn prepare_channel_difference( + mut request: tl::functions::updates::GetChannelDifference, + session: &ErasedSession, + message_box: &mut MessageBoxes, +) -> Result< + Option, + Box, +> { + let id = match &request.channel { + tl::enums::InputChannel::Channel(channel) => PeerId::channel_unchecked(channel.channel_id), + _ => unreachable!(), + }; + + if let Some(PeerInfo::Channel { + id, + auth: Some(auth), + .. + }) = session.peer(id).await? + { + request.channel = tl::enums::InputChannel::Channel(tl::types::InputChannel { + channel_id: id, + access_hash: auth.hash(), + }); + request.limit = if session + .peer(PeerId::self_user()) + .await? + .map(|user| match user { + PeerInfo::User { bot, .. } => bot.unwrap_or(false), + _ => false, + }) + .unwrap_or(false) + { + BOT_CHANNEL_DIFF_LIMIT + } else { + USER_CHANNEL_DIFF_LIMIT + }; + trace!("requesting {:?}", request); + Ok(Some(request)) + } else { + warn!( + "cannot getChannelDifference for {:?} as we're missing its hash", + id + ); + message_box.end_channel_difference(PrematureEndReason::Banned); + Ok(None) + } +} + +/// Iterator returned by [`Client::stream_updates`]. +pub struct UpdateStream { + client: Client, + message_box: MessageBoxes, + // When did we last warn the user that the update queue filled up? + // This is used to avoid spamming the log. + last_update_limit_warn: Option, + buffer: VecDeque<(tl::enums::Update, State, PeerMap)>, + updates: mpsc::UnboundedReceiver, + configuration: UpdatesConfiguration, + should_get_state: bool, + active_channel: watch::Sender>, + active_changes: watch::Receiver>, +} + +impl UpdateStream { + /// Latest viewed channel and its initial PTS. None stops active short polling. + /// Changing this handle does not cancel an in-flight update receive. + pub fn active_channel(&self) -> watch::Sender> { + self.active_channel.clone() + } + + /// Return one complete application batch and the cursor it covers. + /// Persist every update before committing this cursor. In particular, a + /// difference response can give several updates the same terminal PTS. + pub async fn next_batch(&mut self) -> Result<(Vec, UpdatesState), InvocationError> { + let first = self.next_raw().await?; + let raw = std::iter::once(first).chain(self.buffer.drain(..)); + let updates = raw.map(|(update, state, peers)| { + Update::from_raw(&self.client, update, state, peers) + }).collect(); + Ok((updates, self.message_box.session_state())) + } + + /// Restore the cursor owned by the application's durable message store. + /// Call before polling this stream. Session peer/dialog cursors may be ahead + /// of the messages persisted by an application and are not a checkpoint. + pub fn restore_state(&mut self, state: UpdatesState) { + assert!(self.buffer.is_empty(), "restore state before polling updates"); + self.message_box = MessageBoxes::load(state); + self.should_get_state = self.message_box.is_empty(); + } + + /// Pops an update from the queue, waiting for an update to arrive first if the queue is empty. + pub async fn next(&mut self) -> Result { + let (update, state, peers) = self.next_raw().await?; + Ok(Update::from_raw(&self.client, update, state, peers)) + } + + /// Pops an update from the queue, waiting for an update to arrive first if the queue is empty. + /// + /// Unlike [`Self::next`], the update is not wrapped at all, but it is still processed. + pub async fn next_raw( + &mut self, + ) -> Result<(tl::enums::Update, State, PeerMap), InvocationError> { + if self.should_get_state { + self.should_get_state = false; + match self + .client + .invoke(&tl::functions::updates::GetState {}) + .await + { + Ok(tl::enums::updates::State::State(state)) => { + self.client + .0 + .session + .set_update_state(UpdateState::All(UpdatesState { + pts: state.pts, + qts: state.qts, + date: state.date, + seq: state.seq, + channels: Vec::new(), + })) + .await?; + self.message_box.set_state(state); + // A new application cache needs this initial checkpoint + // even if no live message arrives during the session. + // Signal that older content has no replayable coverage. + let state = self.message_box.session_state(); + let peers = self.client.build_peer_map(Vec::new(), Vec::new()).await; + self.buffer.push_back((tl::enums::Update::PtsChanged, State { + date: state.date, seq: state.seq, message_box: None, + }, peers)); + } + Err(_err) => { + // The account may no longer actually be logged in, or it can rarely fail. + // `message_box` will try to correct its state as updates arrive. + } + } + } + + loop { + let (deadline, get_diff, get_channel_diff) = { + if let Some(update) = self.buffer.pop_front() { + return Ok(update); + } + ( + self.message_box.check_deadlines(), // first, as it might trigger differences + self.message_box.get_difference(), + match self.message_box.get_channel_difference() { + Some(gd) => { + prepare_channel_difference( + gd, + self.client.0.session.as_ref(), + &mut self.message_box, + ) + .await? + } + None => None, + }, + ) + }; + + if let Some(request) = get_diff { + let response = self.client.invoke(&request).await?; + let invalidated = matches!(&response, tl::enums::updates::Difference::TooLong(_)); + let (mut updates, users, chats) = self.message_box.apply_difference(response); + if invalidated { + let state = self.message_box.session_state(); + updates.insert(0, (tl::enums::Update::PtsChanged, State { + date: state.date, seq: state.seq, message_box: None, + })); + } + let peers = self.client.build_peer_map(users, chats).await; + self.extend_update_queue(updates, peers); + continue; + } + + if let Some(request) = get_channel_diff { + let maybe_response = self.client.invoke(&request).await; + + let response = match maybe_response { + Ok(r) => r, + Err(e) if e.is("PERSISTENT_TIMESTAMP_OUTDATED") => { + // According to Telegram's docs: + // "Channel internal replication issues, try again later (treat this like an RPC_CALL_FAIL)." + // We can treat this as "empty difference" and not update the local pts. + // Then this same call will be retried when another gap is detected or timeout expires. + // + // Another option would be to literally treat this like an RPC_CALL_FAIL and retry after a few + // seconds, but if Telegram is having issues it's probably best to wait for it to send another + // update (hinting it may be okay now) and retry then. + // + // This is a bit hacky because MessageBox doesn't really have a way to "not update" the pts. + // Instead we manually extract the previously-known pts and use that. + log::warn!( + "Getting difference for channel updates caused PersistentTimestampOutdated; ending getting difference prematurely until server issues are resolved" + ); + { + self.message_box + .end_channel_difference(PrematureEndReason::TemporaryServerIssues); + } + continue; + } + Err(e) if e.is("CHANNEL_PRIVATE") => { + log::info!( + "Account is now banned so we can no longer fetch updates with request: {:?}", + request + ); + { + self.message_box + .end_channel_difference(PrematureEndReason::Banned); + } + continue; + } + Err(InvocationError::Rpc(rpc_error)) if rpc_error.code == 500 => { + log::warn!("Telegram is having internal issues: {:#?}", rpc_error); + { + self.message_box + .end_channel_difference(PrematureEndReason::TemporaryServerIssues); + } + continue; + } + Err(e) => return Err(e), + }; + + let invalidated_channel = if matches!(&response, tl::enums::updates::ChannelDifference::TooLong(_)) { + match &request.channel { + tl::enums::InputChannel::Channel(channel) => Some(channel.channel_id), + _ => None, + } + } else { None }; + let (mut updates, users, chats) = self.message_box.apply_channel_difference(response); + if let Some(channel_id) = invalidated_channel { + let state = self.message_box.session_state(); + updates.insert(0, (tl::types::UpdateChannelTooLong { channel_id, pts: None }.into(), State { + date: state.date, seq: state.seq, message_box: None, + })); + } + + let peers = self.client.build_peer_map(users, chats).await; + self.extend_update_queue(updates, peers); + continue; + } + + tokio::select! { + _ = self.active_changes.changed() => { + let active = *self.active_changes.borrow_and_update(); + self.message_box.set_active_channel(active); + } + result = timeout_at(deadline.into(), self.updates.recv()) => match result { + Ok(Some(updates)) => self.process_socket_updates(updates).await?, + Ok(None) => break Err(InvocationError::Dropped), + Err(_) => {} + } + } + } + } + + pub(crate) async fn process_socket_updates( + &mut self, + updates: UpdatesLike, + ) -> Result<(), InvocationError> { + let mut result = Option::<(Vec<_>, Vec<_>, Vec<_>)>::None; + match self.message_box.process_updates(updates) { + Ok(tup) => { + if let Some(res) = result.as_mut() { + res.0.extend(tup.0); + res.1.extend(tup.1); + res.2.extend(tup.2); + } else { + result = Some(tup); + } + } + Err(_) => return Ok(()), + } + + if let Some((updates, users, chats)) = result { + let peers = self.client.build_peer_map(users, chats).await; + self.extend_update_queue(updates, peers); + } + + Ok(()) + } + + fn extend_update_queue( + &mut self, + mut updates: Vec<(tl::enums::Update, State)>, + peer_map: PeerMap, + ) { + if let Some(limit) = self.configuration.update_queue_limit { + if let Some(exceeds) = (self.buffer.len() + updates.len()).checked_sub(limit + 1) { + let exceeds = exceeds + 1; + let now = Instant::now(); + let notify = match self.last_update_limit_warn { + None => true, + Some(instant) => now - instant > UPDATE_LIMIT_EXCEEDED_LOG_COOLDOWN, + }; + + updates.truncate(updates.len() - exceeds); + if notify { + log::warn!( + "{} updates were dropped because the update_queue_limit was exceeded", + exceeds + ); + } + + self.last_update_limit_warn = Some(now); + } + } + + self.buffer + .extend(updates.into_iter().map(|(u, s)| (u, s, peer_map.handle()))); + } + + /// Synchronize the updates state to the session. + /// + /// This is **not** automatically done on drop. + pub async fn sync_update_state(&self) -> Result<(), Box> { + self.client + .0 + .session + .set_update_state(UpdateState::All(self.message_box.session_state())) + .await?; + Ok(()) + } +} + +impl Client { + /// Returns an asynchronous stream of processed updates. + /// + /// The updates are guaranteed to be in order, and any gaps will be resolved.\ + /// **Important** to note that for gaps to be resolved, the peers must have been + /// persisted in the session cache beforehand (i.e. be retrievable with [`Session::peer`]). + /// A good way to achieve this is to use [`Self::iter_dialogs`] at least once after login. + /// + /// The updates are wrapped in [`crate::update::Update`] to make them more convenient to use, + /// but their raw type is still accessible to bridge any missing functionality. + pub async fn stream_updates( + &self, + updates: mpsc::UnboundedReceiver, + configuration: UpdatesConfiguration, + ) -> Result> { + let message_box = if configuration.catch_up { + MessageBoxes::load(self.0.session.updates_state().await?) + } else { + // If the user doesn't want to bother with catching up on previous update, start with + // pristine state instead. + MessageBoxes::new() + }; + // Don't bother getting pristine update state if we're not logged in. + let should_get_state = + message_box.is_empty() && self.0.session.peer(PeerId::self_user()).await?.is_some(); + + let (active_channel, active_changes) = watch::channel(None); + Ok(UpdateStream { + active_channel, + active_changes, + client: self.clone(), + message_box, + last_update_limit_warn: None, + buffer: VecDeque::new(), + updates, + configuration, + should_get_state, + }) + } +} + +#[cfg(test)] +mod tests { + use core::future::Future; + + use super::*; + + fn get_update_stream() -> UpdateStream { + panic!() + } + + #[test] + fn ensure_next_update_future_impls_send() { + if false { + // We just want it to type-check, not actually run. + fn typeck(_: impl Future + Send) {} + typeck(get_update_stream().next()); + } + } +} diff --git a/vendor/grammers-client/src/lib.rs b/vendor/grammers-client/src/lib.rs new file mode 100755 index 0000000..d9a6bc6 --- /dev/null +++ b/vendor/grammers-client/src/lib.rs @@ -0,0 +1,104 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +//! # Brief on Telegram's API +//! +//! This library is a high-level implementation to access [Telegram's API], which essentially +//! lets you automate everything you can do with official Telegram clients and more from Rust, +//! or even control bot accounts, making it a viable alternative to using the [Telegram Bot API]. +//! +//! In order to create an application with the library for people to use, you will need to +//! [obtain a developer API ID] first. You can embed it as a constant in your binary and ship +//! that to users (anyone can login, including yourself and bots, with the developer API ID; +//! they do *not* need to provide their own API ID). +//! +//! Once that's ready, create a new [`Client`] instance with its [`Client::new`] +//! method and start making API calls. +//! +//! # Method cost +//! +//! When a method is said to be "expensive", this often means that calling it too much in a +//! certain period of time will result in the API returning "flood wait" errors, meaning that +//! the method cannot be called again for a certain amount of seconds (trying to do so will +//! continue to return the flood wait error for that duration). +//! +//! On the other hand, a "cheap" method can be called a lot and is unlikely to result in a flood +//! wait error. However, all methods can potentially cause a flood wait, so some care is still +//! required. +//! +//! There is nothing wrong with causing the API to return flood wait errors, but it is something +//! to be avoided. Because they are expected, the default [`ClientConfiguration`] will sleep on +//! small flood errors to prevent interruption of simple scripts. +//! +//! A flood wait error is different from a peer flood error. A peer flood error means the flood +//! limitation is applied account-wide, and its duration is undefined. This often means that the +//! account spammed, or a young account tried to contact too many peers. +//! +//! # Re-exports +//! +//! For convenience and to ease using compatible traits in lockstep, this +//! library re-exports several modules and types from its direct dependencies. +//! +//! ## grammers-mtsender as sender +//! +//! `grammers_client` is purely a friendlier abstraction on top of the [`SenderPool`], +//! which is responsible for abstracting the connection wholesale. The [`InvocationError`] +//! is also re-exported at the top level, as all methods that interact with the API +//! eventually need to invoke the request and may thus fail. You may also use the +//! [`Result`] alias, which has the `InvocationError` set as its error variant. +//! +//! If you need access to the rest of the crate (e.g. to configure the `SenderPool`), +//! it is re-exported under [`sender`]. +//! +//! ## grammers-session as session +//! +//! After login, the generated Authorization Key should be persisted and reused, +//! in order to avoid logging in again, as that is a very expensive method. +//! +//! Session storages are responsible for doing so, and you may even implement your +//! own by using the [`session::Session`] trait. +//! +//! ## grammers-tl-types as tl +//! +//! This crate is re-exported wholesale under the [`tl`] namespace (short for +//! [Type Language](https://core.telegram.org/mtproto/TL)). All Telegram types and functions +//! are automatically generated from their public schema, which is versioned by "layers". +//! For now and the foreseeable future, *grammers* will only offer one layer at a time. +//! +//! When the friendly API of `grammers_client` falls short, you can reach out for its +//! raw [`tl::types`] to access all the information Telegram returned on its responses. +//! Additionally, if the [`Client`] is missing a method to do what you need, you may +//! directly [`Client::invoke`] any of the [`tl::functions`] offered by Telegram's API. +//! +//! Using raw types is **discouraged**. Not only are they far clunkier to use, but they +//! are also **not part of the semantic versioning**. While patch versions won't change +//! the layer, minor versions will, which would traditionally be considered a breaking change. +//! +//! [Telegram's API]: https://core.telegram.org/#telegram-api +//! [Telegram Bot API]: https://core.telegram.org/bots/api +//! [obtain a developer API ID]: https://my.telegram.org/auth +//! [`ClientConfiguration`]: crate::client::ClientConfiguration + +#![deny(unsafe_code)] + +pub mod client; +pub mod media; +pub mod message; +#[cfg(any(feature = "markdown", feature = "html"))] +pub mod parsers; +pub mod peer; +pub mod update; +pub(crate) mod utils; + +pub use client::{Client, SignInError}; +pub use grammers_mtsender::{self as sender, InvocationError, SenderPool}; +pub use grammers_session as session; +pub use grammers_tl_types as tl; + +/// Alias for [`std::result::Result`] with the error set to [`InvocationError`]. +pub type Result = std::result::Result; diff --git a/vendor/grammers-client/src/media/attributes.rs b/vendor/grammers-client/src/media/attributes.rs new file mode 100755 index 0000000..c790eff --- /dev/null +++ b/vendor/grammers-client/src/media/attributes.rs @@ -0,0 +1,80 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +use std::convert::TryInto; +use std::time::Duration; + +use grammers_tl_types as tl; + +/// Document media attributes. +#[derive(Clone)] +pub enum Attribute { + Audio { + duration: Duration, + title: Option, + performer: Option, + }, + Voice { + duration: Duration, + waveform: Option>, + }, + Video { + round_message: bool, + supports_streaming: bool, + duration: Duration, + w: i32, + h: i32, + }, + FileName(String), +} + +impl From for tl::enums::DocumentAttribute { + fn from(attr: Attribute) -> Self { + use Attribute::*; + match attr { + Audio { + duration, + title, + performer, + } => Self::Audio(tl::types::DocumentAttributeAudio { + voice: false, + duration: duration.as_secs().try_into().unwrap(), + title, + performer, + waveform: None, + }), + Voice { duration, waveform } => Self::Audio(tl::types::DocumentAttributeAudio { + voice: true, + duration: duration.as_secs().try_into().unwrap(), + title: None, + performer: None, + waveform, + }), + Video { + round_message, + supports_streaming, + duration, + w, + h, + } => Self::Video(tl::types::DocumentAttributeVideo { + round_message, + supports_streaming, + nosound: false, + duration: duration.as_secs_f64(), + w, + h, + preload_prefix_size: None, + video_start_ts: None, + video_codec: None, + }), + FileName(file_name) => { + Self::Filename(tl::types::DocumentAttributeFilename { file_name }) + } + } + } +} diff --git a/vendor/grammers-client/src/media/downloadable.rs b/vendor/grammers-client/src/media/downloadable.rs new file mode 100755 index 0000000..8ec49f4 --- /dev/null +++ b/vendor/grammers-client/src/media/downloadable.rs @@ -0,0 +1,27 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +use grammers_tl_types as tl; + +/// Trait implemented by all types that may be downloaded. +pub trait Downloadable { + /// Converts this downloadable into the input type that may be used by raw requests to download it. + fn to_raw_input_location(&self) -> Option; + + /// Returns `Some` if the media has a tiny thumbnail embedded within. + /// + /// No network request would occur when attempting to download this downloadable. + fn to_data(&self) -> Option> { + None + } + + /// File size, in bytes. + fn size(&self) -> Option { + None + } +} diff --git a/vendor/grammers-client/src/media/input_media.rs b/vendor/grammers-client/src/media/input_media.rs new file mode 100644 index 0000000..227233d --- /dev/null +++ b/vendor/grammers-client/src/media/input_media.rs @@ -0,0 +1,305 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +use grammers_tl_types as tl; + +use super::{Attribute, Media, Uploaded}; + +/// Media item to be sent as part of an album. +#[derive(Default)] +pub struct InputMedia { + pub(crate) entities: Vec, + pub(crate) reply_to: Option, + pub(crate) caption: String, + pub(crate) media: Option, + media_ttl: Option, + mime_type: Option, +} + +impl InputMedia { + /// Creates a new empty media message for input. + pub fn new() -> Self { + Self::default() + } + + /// Replaces the plaintext in the message. + /// + /// The caller must ensure that formatting entities remain valid for the given text. + /// If you need to update formatting entities, call method [`InputMedia::fmt_entities`]. + /// + ///
+ /// Note that this method does not modify formatting entities, which may break + /// formatting or cause out-of-bounds errors if entities do not match the given text. + ///
+ pub fn caption(mut self, s: T) -> Self + where + T: Into, + { + self.caption = s.into(); + self + } + + /// The formatting entities within the caption (such as bold, italics, etc.). + pub fn fmt_entities(mut self, entities: I) -> Self + where + I: IntoIterator, + { + self.entities = entities.into_iter().collect(); + self + } + + /// Builds a new media from the given markdown-formatted string as the + /// caption contents and entities. + /// + /// Note that Telegram only supports a very limited subset of entities: + /// bold, italic, underline, strikethrough, code blocks, pre blocks and inline links (inline + /// links with this format `tg://user?id=12345678` will be replaced with inline mentions when + /// possible). + #[cfg(feature = "markdown")] + pub fn markdown(mut self, s: T) -> Self + where + T: AsRef, + { + let (caption, entities) = crate::parsers::parse_markdown_message(s.as_ref()); + self.caption = caption; + self.entities = entities; + self + } + + /// Builds a new media from the given HTML-formatted string as the + /// caption contents and entities. + /// + /// Note that Telegram only supports a very limited subset of entities: + /// bold, italic, underline, strikethrough, code blocks, pre blocks and inline links (inline + /// links with this format `tg://user?id=12345678` will be replaced with inline mentions when + /// possible). + #[cfg(feature = "html")] + pub fn html(mut self, s: T) -> Self + where + T: AsRef, + { + let (caption, entities) = crate::parsers::parse_html_message(s.as_ref()); + self.caption = caption; + self.entities = entities; + self + } + + /// The album identifier to which this album should reply to, if any. + /// + /// Otherwise, this album will not be a reply to any other. + /// + /// Only the reply_to from the first media is used. + pub fn reply_to(mut self, reply_to: Option) -> Self { + self.reply_to = reply_to; + self + } + + /// Include the uploaded file as a photo in the album. + /// + /// The Telegram server will compress the image and convert it to JPEG format if necessary. + /// + /// The text will be the caption of the photo, which may be empty for no caption. + pub fn photo(mut self, file: Uploaded) -> Self { + self.media = Some( + (tl::types::InputMediaUploadedPhoto { + spoiler: false, + file: file.raw, + stickers: None, + ttl_seconds: self.media_ttl, + live_photo: false, + video: None, + }) + .into(), + ); + self + } + + /// Include an external photo in the album. + /// + /// The Telegram server will download and compress the image and convert it to JPEG format if + /// necessary. + /// + /// The text will be the caption of the photo, which may be empty for no caption. + pub fn photo_url(mut self, url: impl Into) -> Self { + self.media = Some( + (tl::types::InputMediaPhotoExternal { + spoiler: false, + url: url.into(), + ttl_seconds: self.media_ttl, + }) + .into(), + ); + self + } + + /// Include the uploaded file as a document in the album. + /// + /// You can use this to send videos, stickers, audios, or uncompressed photos. + /// + /// The text will be the caption of the document, which may be empty for no caption. + pub fn document(mut self, file: Uploaded) -> Self { + let mime_type = self.get_file_mime(&file); + self.media = Some(super::uploaded_document( + file, + mime_type, + false, + self.media_ttl, + )); + self + } + + /// Include a media in the message using the raw TL types. + /// + /// You can use this to send any media using the raw TL types that don't have + /// a specific method in this builder such as Dice, Polls, etc. + /// + /// This can also be used to send media with a file reference, see `InputMediaDocument` + /// and `InputMediaPhoto` in the `grammers-tl-types` crate. + /// + /// The text will be the caption of the media, which may be empty for no caption. + pub fn media>(mut self, media: M) -> Self { + self.media = Some(media.into()); + self + } + + /// Include the video file with thumb in the album. + /// + /// The text will be the caption of the document, which may be empty for no caption. + /// + /// # Examples + /// + /// ``` + /// async fn f(client: &mut grammers_client::Client) -> Result<(), Box> { + /// use grammers_client::media::InputMedia; + /// + /// let video = client.upload_file("video.mp4").await?; + /// let thumb = client.upload_file("thumb.png").await?; + /// let media = InputMedia::new().caption("").document(video).thumbnail(thumb); + /// Ok(()) + /// } + /// ``` + pub fn thumbnail(mut self, thumb: Uploaded) -> Self { + if let Some(tl::enums::InputMedia::UploadedDocument(document)) = &mut self.media { + document.thumb = Some(thumb.raw); + } + self + } + + /// Include an external file as a document in the album. + /// + /// You can use this to send videos, stickers, audios, or uncompressed photos. + /// + /// The Telegram server will be the one that downloads and includes the document as media. + /// + /// The text will be the caption of the document, which may be empty for no caption. + pub fn document_url(mut self, url: impl Into) -> Self { + self.media = Some( + (tl::types::InputMediaDocumentExternal { + spoiler: false, + url: url.into(), + ttl_seconds: self.media_ttl, + video_cover: None, + video_timestamp: None, + }) + .into(), + ); + self + } + + /// Add additional attributes to the media. + /// + /// This must be called *after* setting a file. + /// + /// # Examples + /// + /// ``` + /// # async fn f(client: &mut grammers_client::Client) -> Result<(), Box> { + /// # let audio = client.upload_file("audio.flac").await?; + /// # + /// use std::time::Duration; + /// use grammers_client::media::{Attribute, InputMedia}; + /// + /// let media = InputMedia::new().caption("").document(audio).attribute( + /// Attribute::Audio { + /// duration: Duration::new(123, 0), + /// title: Some("Hello".to_string()), + /// performer: Some("World".to_string()), + /// } + /// ); + /// # Ok(()) + /// # } + /// ``` + pub fn attribute(mut self, attr: Attribute) -> Self { + if let Some(tl::enums::InputMedia::UploadedDocument(document)) = &mut self.media { + document.attributes.push(attr.into()); + } + self + } + + /// Copy media from an existing message. + /// + /// You can use this to send media from another message without re-uploading it. + pub fn copy_media(mut self, media: &Media) -> Self { + self.media = media.to_raw_input_media(); + self + } + + /// Include the uploaded file as a document file in the album. + /// + /// You can use this to send any type of media as a simple document file. + /// + /// The text will be the caption of the file, which may be empty for no caption. + pub fn file(mut self, file: Uploaded) -> Self { + let mime_type = self.get_file_mime(&file); + self.media = Some(super::uploaded_document( + file, + mime_type, + true, + self.media_ttl, + )); + self + } + + /// Change the media's Time To Live (TTL). + /// + /// For example, this enables you to send a `photo` that can only be viewed for a certain + /// amount of seconds before it expires. + /// + /// Not all media supports this feature. + /// + /// This method should be called before setting any media, else it won't have any effect. + pub fn media_ttl(mut self, seconds: i32) -> Self { + self.media_ttl = if seconds < 0 { None } else { Some(seconds) }; + self + } + + /// Change the media's mime type. + /// + /// This method will override the mime type that would otherwise be automatically inferred + /// from the extension of the used file + /// + /// If no mime type is set and it cannot be inferred, the mime type will be + /// "application/octet-stream". + /// + /// This method should be called before setting any media, else it won't have any effect. + pub fn mime_type(mut self, mime_type: &str) -> Self { + self.mime_type = Some(mime_type.to_string()); + self + } + + /// Return the mime type string for the given file. + fn get_file_mime(&self, file: &Uploaded) -> String { + if let Some(mime) = self.mime_type.as_ref() { + mime.clone() + } else if let Some(mime) = mime_guess::from_path(file.name()).first() { + mime.essence_str().to_string() + } else { + "application/octet-stream".to_string() + } + } +} diff --git a/vendor/grammers-client/src/media/media.rs b/vendor/grammers-client/src/media/media.rs new file mode 100644 index 0000000..11ebf71 --- /dev/null +++ b/vendor/grammers-client/src/media/media.rs @@ -0,0 +1,887 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +use std::fmt::Debug; + +use chrono::{DateTime, Utc}; +use grammers_tl_types as tl; + +use super::{Downloadable, PhotoSize}; + +/// Photo media contained within a message. +#[derive(Clone, Debug, PartialEq)] +pub struct Photo { + pub raw: tl::types::MessageMediaPhoto, +} + +/// File documents unlike the alternative of compressed photos. +/// +/// This includes uncompressed photos, videos, stickers, and files of any type. +#[derive(Clone, Debug, PartialEq)] +pub struct Document { + pub raw: tl::types::MessageMediaDocument, +} + +/// A sticker [`Document`]. +#[derive(Clone, Debug, PartialEq)] +pub struct Sticker { + pub document: Document, + pub raw_attrs: tl::types::DocumentAttributeSticker, + animated: bool, +} + +/// Data uploaded by the client to be used as either a [`Photo`] or [`Document`]. +#[derive(Clone, Debug, PartialEq)] +pub struct Uploaded { + pub raw: tl::enums::InputFile, +} + +/// Contact media containing a phone number. +#[derive(Clone, Debug, PartialEq)] +pub struct Contact { + pub raw: tl::types::MessageMediaContact, +} + +/// Poll or quiz which may be voted on while open. +#[derive(Clone, Debug, PartialEq)] +pub struct Poll { + pub raw: tl::types::Poll, + pub raw_results: tl::types::PollResults, +} + +/// Geo point with latitude and longitude coordinates on the globe. +#[derive(Clone, Debug, PartialEq)] +pub struct Geo { + pub raw: tl::types::GeoPoint, +} + +/// Animated built-in sticker with a set of possible outcomes. +/// +/// Originally used to roll dice (🎲), but other emoji such as darts (🎯), +/// basket-balls (🏀) and slot-machines (🎰) can also be used. +#[derive(Clone, Debug, PartialEq)] +pub struct Dice { + pub raw: tl::types::MessageMediaDice, +} + +/// A place with optional coordinates. +#[derive(Clone, Debug, PartialEq)] +pub struct Venue { + pub geo: Option, + pub raw_venue: tl::types::MessageMediaVenue, +} + +/// Like [`Geo`], but periodcally edits itself with updated information. +#[derive(Clone, Debug, PartialEq)] +pub struct GeoLive { + pub geo: Option, + pub raw_geolive: tl::types::MessageMediaGeoLive, +} + +/// Instant View web-page previews. +#[derive(Clone, Debug, PartialEq)] +pub struct WebPage { + pub raw: tl::types::MessageMediaWebPage, +} + +/// Profile picture of a group. +#[derive(Clone, Debug, PartialEq)] +pub struct ChatPhoto { + pub raw: tl::enums::InputFileLocation, +} + +/// Message media (e.g. photos, polls, videos). +#[derive(Clone, Debug, PartialEq)] +#[non_exhaustive] +pub enum Media { + /// Compressed JPEG photo. + Photo(Photo), + /// File which may or not be rendered directly by clients. + Document(Document), + /// Document that has the attributes of a sticker. + Sticker(Sticker), + /// Contact information. + Contact(Contact), + /// Poll which can be voted on while open. + Poll(Poll), + /// Coordinates of a place. + Geo(Geo), + /// Dice-like built-in sticker media. + Dice(Dice), + /// Information about a place with optional coordinates. + Venue(Venue), + /// Self-updating coordinates of a place or person. + GeoLive(GeoLive), + /// Web-page instant view. + WebPage(WebPage), +} + +impl Photo { + pub fn from_raw(photo: tl::enums::Photo) -> Self { + Self { + raw: tl::types::MessageMediaPhoto { + spoiler: false, + photo: Some(photo), + ttl_seconds: None, + live_photo: false, + video: None, + }, + } + } + + pub fn from_raw_media(photo: tl::types::MessageMediaPhoto) -> Self { + Self { raw: photo } + } + + pub fn to_raw_input_media(&self) -> tl::types::InputMediaPhoto { + use tl::{ + enums::{InputPhoto as eInputPhoto, Photo}, + types::InputPhoto, + }; + + tl::types::InputMediaPhoto { + spoiler: false, + id: match self.raw.photo { + Some(Photo::Photo(ref photo)) => InputPhoto { + id: photo.id, + access_hash: photo.access_hash, + file_reference: photo.file_reference.clone(), + } + .into(), + _ => eInputPhoto::Empty, + }, + ttl_seconds: self.raw.ttl_seconds, + live_photo: false, + video: None, + } + } + + /// The globally unique identifier for this media, as seen by any account. + pub fn id(&self) -> i64 { + use tl::enums::Photo as P; + + match self.raw.photo.as_ref().unwrap() { + P::Empty(photo) => photo.id, + P::Photo(photo) => photo.id, + } + } + + /// The size of the photo, if not empty. + pub fn size(&self) -> Option { + self.thumbs() + .iter() + .max_by_key(|x| x.size()) + .map(|thumb| thumb.size()) + } + + /// Get photo thumbs. + /// + /// Since Telegram doesn't store the original photo, it can be presented in different sizes + /// and quality, a.k.a. thumbnails. Each photo preview has a specific type, indicating + /// the resolution and image transform that was applied server-side. Some low-resolution + /// thumbnails already contain all necessary information that can be shown to the user, but + /// for other types an additional request to the Telegram should be performed. + /// Check the description of [`PhotoSize``] to get an information about each particular thumbnail. + /// + /// + pub fn thumbs(&self) -> Vec { + use tl::enums::Photo as P; + + let photo = match self.raw.photo.as_ref() { + Some(photo) => photo, + None => return vec![], + }; + + match photo { + P::Empty(_) => vec![], + P::Photo(photo) => photo + .sizes + .iter() + .map(|x| PhotoSize::make_from(x, photo)) + .collect(), + } + } + + /// Returns true if the photo is a spoiler. + pub fn is_spoiler(&self) -> bool { + self.raw.spoiler + } + + /// Returns TTL seconds if the photo is self-destructive, None otherwise + pub fn ttl_seconds(&self) -> Option { + self.raw.ttl_seconds + } +} + +impl Downloadable for Photo { + fn to_raw_input_location(&self) -> Option { + use tl::enums::Photo as P; + + self.raw.photo.as_ref().and_then(|p| match p { + P::Empty(_) => None, + P::Photo(photo) => Some( + tl::types::InputPhotoFileLocation { + id: photo.id, + access_hash: photo.access_hash, + file_reference: photo.file_reference.clone(), + thumb_size: self + .thumbs() + .iter() + .max_by_key(|x| x.size()) + .map(|ps| ps.photo_type()) + .unwrap_or(String::from("w")), + } + .into(), + ), + }) + } + + fn size(&self) -> Option { + self.size() + } +} + +impl Document { + pub fn from_raw_media(document: tl::types::MessageMediaDocument) -> Self { + Self { raw: document } + } + + pub fn to_raw_input_media(&self) -> tl::types::InputMediaDocument { + use tl::{ + enums::{Document, InputDocument as eInputDocument}, + types::InputDocument, + }; + + tl::types::InputMediaDocument { + spoiler: false, + id: match self.raw.document { + Some(Document::Document(ref document)) => InputDocument { + id: document.id, + access_hash: document.access_hash, + file_reference: document.file_reference.clone(), + } + .into(), + _ => eInputDocument::Empty, + }, + ttl_seconds: self.raw.ttl_seconds, + query: None, + video_cover: None, + video_timestamp: None, + } + } + + /// The globally unique identifier for this media, as seen by any account. + pub fn id(&self) -> i64 { + use tl::enums::Document as D; + + match self.raw.document.as_ref().unwrap() { + D::Empty(document) => document.id, + D::Document(document) => document.id, + } + } + + /// Return the file's name. + /// + /// If the file was uploaded with no file name, the returned string will be empty. + /// + /// Note that the file name is not necessarily valid as seen by the OS, + /// and using it directly as the download location is subject to causing + /// path trasversal attacks. + pub fn name(&self) -> Option<&str> { + use tl::enums::Document as D; + + match self.raw.document.as_ref().unwrap() { + D::Empty(_) => None, + D::Document(document) => document.attributes.iter().find_map(|attr| match attr { + tl::enums::DocumentAttribute::Filename(attr) => Some(attr.file_name.as_ref()), + _ => None, + }), + } + } + + /// Get the file's MIME type, if any. + pub fn mime_type(&self) -> Option<&str> { + match self.raw.document.as_ref() { + Some(tl::enums::Document::Document(d)) => Some(d.mime_type.as_str()), + _ => None, + } + } + + /// The date on which the file was created, if any. + pub fn creation_date(&self) -> Option> { + match self.raw.document.as_ref() { + Some(tl::enums::Document::Document(d)) => { + Some(DateTime::::from_timestamp(d.date as i64, 0).expect("date out of range")) + } + _ => None, + } + } + + /// The size of the file. + pub fn size(&self) -> Option { + match self.raw.document.as_ref() { + Some(tl::enums::Document::Document(d)) => Some(d.size as usize), + _ => None, + } + } + + /// Get document thumbs. + /// See . + pub fn thumbs(&self) -> Vec { + use tl::enums::Document as D; + + let document = match self.raw.document.as_ref() { + Some(document) => document, + None => return vec![], + }; + + match document { + D::Empty(_) => vec![], + D::Document(document) => match &document.thumbs { + Some(thumbs) => thumbs + .iter() + .map(|x| PhotoSize::make_from_document(x, document)) + .collect(), + None => vec![], + }, + } + } + + /// Duration of video/audio, in seconds. + pub fn duration(&self) -> Option { + match self.raw.document.as_ref() { + Some(tl::enums::Document::Document(d)) => { + for attr in &d.attributes { + match attr { + tl::enums::DocumentAttribute::Video(v) => return Some(v.duration), + tl::enums::DocumentAttribute::Audio(a) => return Some(a.duration as _), + _ => {} + } + } + None + } + _ => None, + } + } + + /// Width & height of video/image, in pixels. + pub fn resolution(&self) -> Option<(i32, i32)> { + match self.raw.document.as_ref() { + Some(tl::enums::Document::Document(d)) => { + for attr in &d.attributes { + match attr { + tl::enums::DocumentAttribute::Video(v) => return Some((v.w, v.h)), + tl::enums::DocumentAttribute::ImageSize(i) => return Some((i.w, i.h)), + _ => {} + } + } + None + } + _ => None, + } + } + + /// Title of audio. + pub fn audio_title(&self) -> Option<&str> { + match self.raw.document.as_ref() { + Some(tl::enums::Document::Document(d)) => { + for attr in &d.attributes { + #[allow(clippy::single_match)] + match attr { + tl::enums::DocumentAttribute::Audio(a) => return a.title.as_deref(), + _ => {} + } + } + None + } + _ => None, + } + } + + /// Performer (artist) of audio. + pub fn performer(&self) -> Option<&str> { + match self.raw.document.as_ref() { + Some(tl::enums::Document::Document(d)) => { + for attr in &d.attributes { + #[allow(clippy::single_match)] + match attr { + tl::enums::DocumentAttribute::Audio(a) => return a.performer.as_deref(), + _ => {} + } + } + None + } + _ => None, + } + } + + /// Returns `true` if the document is an animated sticker. + pub fn is_animated(&self) -> bool { + match self.raw.document.as_ref() { + Some(tl::enums::Document::Document(d)) => { + for attr in &d.attributes { + #[allow(clippy::single_match)] + match attr { + tl::enums::DocumentAttribute::Animated => return true, + _ => {} + } + } + false + } + _ => false, + } + } + + /// Returns `true` if the document is a spoiler. + pub fn is_spoiler(&self) -> bool { + self.raw.spoiler + } +} + +impl Downloadable for Document { + fn to_raw_input_location(&self) -> Option { + use tl::enums::Document as D; + + self.raw.document.as_ref().and_then(|p| match p { + D::Empty(_) => None, + D::Document(document) => Some( + tl::types::InputDocumentFileLocation { + id: document.id, + access_hash: document.access_hash, + file_reference: document.file_reference.clone(), + thumb_size: String::new(), + } + .into(), + ), + }) + } + + fn size(&self) -> Option { + self.size() + } +} + +impl Sticker { + pub fn from_document(document: &Document) -> Option { + match document.raw.document { + Some(tl::enums::Document::Document(ref doc)) => { + let mut animated = false; + let mut sticker_attrs: Option = None; + for attr in &doc.attributes { + match attr { + tl::enums::DocumentAttribute::Sticker(s) => sticker_attrs = Some(s.clone()), + tl::enums::DocumentAttribute::Animated => animated = true, + _ => (), + } + } + Some(Self { + document: document.clone(), + raw_attrs: sticker_attrs?, + animated, + }) + } + _ => None, + } + } + + /// Get the emoji associated with the sticker. + pub fn emoji(&self) -> &str { + self.raw_attrs.alt.as_str() + } + + /// Is this sticker an animated sticker? + pub fn is_animated(&self) -> bool { + self.animated + } +} + +impl Contact { + pub fn from_raw_media(contact: tl::types::MessageMediaContact) -> Self { + Self { raw: contact } + } + + pub fn to_raw_input_media(&self) -> tl::types::InputMediaContact { + tl::types::InputMediaContact { + phone_number: self.raw.phone_number.clone(), + first_name: self.raw.first_name.clone(), + last_name: self.raw.last_name.clone(), + vcard: self.raw.vcard.clone(), + } + } + + /// The contact's phone number, in international format. This field will always be a non-empty + /// string of digits, although there's no guarantee that the number actually exists. + pub fn phone_number(&self) -> &str { + self.raw.phone_number.as_str() + } + + /// The contact's first name. Although official clients will always send a non-empty string, + /// it is possible for this field to be empty when sent via different means. + pub fn first_name(&self) -> &str { + self.raw.first_name.as_str() + } + + /// The contact's last name. May be empty if it's not set by sender. + pub fn last_name(&self) -> &str { + self.raw.last_name.as_str() + } + + /// Contact information in [vCard format]. Applications such as Telegram Desktop leave this + /// field empty. The vCard version used in this field could be any. The field may also contain + /// arbitrary text when sent by non-official clients. + /// + /// [vCard format]: https://en.wikipedia.org/wiki/VCard + pub fn vcard(&self) -> &str { + self.raw.vcard.as_str() + } +} + +impl Poll { + pub fn from_raw_media(poll: tl::types::MessageMediaPoll) -> Self { + Self { + raw: match poll.poll { + tl::enums::Poll::Poll(poll) => poll, + }, + raw_results: match poll.results { + tl::enums::PollResults::Results(results) => *results, + }, + } + } + + pub fn to_raw_input_media(&self) -> tl::types::InputMediaPoll { + tl::types::InputMediaPoll { + poll: grammers_tl_types::enums::Poll::Poll(self.raw.clone()), + correct_answers: None, + solution: None, + solution_entities: None, + attached_media: None, + solution_media: None, + } + } + + /// Returns the question asked by the poll. + pub fn question(&self) -> &grammers_tl_types::enums::TextWithEntities { + &self.raw.question + } + + /// Return `true` if the poll is a quiz with a set of correct answers. + pub fn is_quiz(&self) -> bool { + self.raw.quiz + } + + /// Returns `true` if the poll is closed. + pub fn closed(&self) -> bool { + self.raw.closed + } + + /// Returns an iterator over the poll answer options. + pub fn iter_answers(&self) -> impl Iterator { + self.raw.answers.iter() + } + + /// Total voters that took part in the vote. + /// + /// May be `None` if poll hasn't started. + pub fn total_voters(&self) -> Option { + self.raw_results.total_voters + } + + /// Returns details of the voters choices, + /// such as amount of voters per answer and whether the answer + /// is currently selected by the logged-in account. + pub fn iter_voters_summary( + &self, + ) -> Option> { + self.raw_results.results.as_ref().map(|results| { + results.iter().map(|result| match result { + tl::enums::PollAnswerVoters::Voters(voters) => voters, + }) + }) + } +} + +impl Geo { + pub fn from_raw_media(geo: tl::types::MessageMediaGeo) -> Option { + use tl::enums::GeoPoint as eGeoPoint; + + match &geo.geo { + eGeoPoint::Empty => None, + eGeoPoint::Point(point) => Some(Self { raw: point.clone() }), + } + } + + pub fn to_raw_input_media(&self) -> tl::types::InputMediaGeoPoint { + use tl::types::InputGeoPoint; + + tl::types::InputMediaGeoPoint { + geo_point: InputGeoPoint { + lat: self.raw.lat, + long: self.raw.long, + accuracy_radius: self.raw.accuracy_radius, + } + .into(), + } + } + + pub fn to_raw_input_geo_point(&self) -> tl::enums::InputGeoPoint { + use tl::{enums::InputGeoPoint as eInputGeoPoint, types::InputGeoPoint}; + + eInputGeoPoint::Point(InputGeoPoint { + lat: self.raw.lat, + long: self.raw.long, + accuracy_radius: self.raw.accuracy_radius, + }) + } + + /// Get the latitude of the location. + pub fn latitue(&self) -> f64 { + self.raw.lat + } + + /// Get the latitude of the location. + pub fn longitude(&self) -> f64 { + self.raw.long + } + + /// Get the accuracy of the geo location, in meters. + pub fn accuracy_radius(&self) -> Option { + self.raw.accuracy_radius + } +} + +impl Dice { + pub fn from_raw_media(dice: tl::types::MessageMediaDice) -> Self { + Self { raw: dice } + } + + pub fn to_raw_input_media(&self) -> tl::types::InputMediaDice { + tl::types::InputMediaDice { + emoticon: self.raw.emoticon.clone(), + } + } + + /// Get the emoji of the dice. + pub fn emoji(&self) -> &str { + &self.raw.emoticon + } + + /// Get the value of the dice. + pub fn value(&self) -> i32 { + self.raw.value + } +} + +impl Venue { + pub fn from_raw_media(venue: tl::types::MessageMediaVenue) -> Self { + use tl::types::MessageMediaGeo; + Self { + geo: Geo::from_raw_media(MessageMediaGeo { + geo: venue.geo.clone(), + }), + raw_venue: venue, + } + } + + pub fn to_raw_input_media(&self) -> tl::types::InputMediaVenue { + tl::types::InputMediaVenue { + geo_point: match self.geo { + Some(ref geo) => geo.to_raw_input_geo_point(), + None => tl::enums::InputGeoPoint::Empty, + }, + title: self.raw_venue.title.clone(), + address: self.raw_venue.address.clone(), + provider: self.raw_venue.provider.clone(), + venue_id: self.raw_venue.venue_id.clone(), + venue_type: self.raw_venue.venue_type.clone(), + } + } + + /// Returns the title of the venue. + pub fn title(&self) -> &str { + &self.raw_venue.title + } + + /// Returns the address of the venue. + pub fn address(&self) -> &str { + &self.raw_venue.address + } + + /// Returns the provider of the venue location. + pub fn provider(&self) -> &str { + &self.raw_venue.provider + } + + /// Returns the id of the venue. + pub fn venue_id(&self) -> &str { + &self.raw_venue.venue_id + } + + /// Returns the type of the venue. + pub fn venue_type(&self) -> &str { + &self.raw_venue.venue_type + } +} + +impl GeoLive { + pub fn from_raw_media(geolive: tl::types::MessageMediaGeoLive) -> Self { + use tl::types::MessageMediaGeo; + Self { + geo: Geo::from_raw_media(MessageMediaGeo { + geo: geolive.geo.clone(), + }), + raw_geolive: geolive, + } + } + + pub fn to_raw_input_media(&self) -> tl::types::InputMediaGeoLive { + tl::types::InputMediaGeoLive { + geo_point: match self.geo { + Some(ref geo) => geo.to_raw_input_geo_point(), + None => tl::enums::InputGeoPoint::Empty, + }, + heading: self.raw_geolive.heading, + period: Some(self.raw_geolive.period), + proximity_notification_radius: self.raw_geolive.proximity_notification_radius, + stopped: false, + } + } + + /// Returns the heading of the live location in degress (1-360). + pub fn heading(&self) -> Option { + self.raw_geolive.heading + } + + /// Returns the validity period of the live location. + pub fn period(&self) -> i32 { + self.raw_geolive.period + } + + /// Returns the radius of the proximity alert. + pub fn proximity_notification_radius(&self) -> Option { + self.raw_geolive.proximity_notification_radius + } +} + +impl WebPage { + pub fn from_raw_media(webpage: tl::types::MessageMediaWebPage) -> Self { + Self { raw: webpage } + } +} + +impl Uploaded { + pub fn from_raw(input_file: tl::enums::InputFile) -> Self { + Self { raw: input_file } + } + + pub(crate) fn name(&self) -> &str { + match &self.raw { + tl::enums::InputFile::File(f) => f.name.as_ref(), + tl::enums::InputFile::Big(f) => f.name.as_ref(), + tl::enums::InputFile::StoryDocument(_) => "", + } + } +} + +impl Media { + pub fn from_raw(media: tl::enums::MessageMedia) -> Option { + use tl::enums::MessageMedia as M; + + // TODO implement the rest + match media { + M::Empty => None, + M::Photo(photo) => Some(Self::Photo(Photo::from_raw_media(photo))), + M::Geo(geo) => Geo::from_raw_media(geo).map(Self::Geo), + M::Contact(contact) => Some(Self::Contact(Contact::from_raw_media(contact))), + M::Unsupported => None, + M::Document(document) => { + let document = Document::from_raw_media(document); + Some(if let Some(sticker) = Sticker::from_document(&document) { + Self::Sticker(sticker) + } else { + Self::Document(document) + }) + } + M::WebPage(webpage) => Some(Self::WebPage(WebPage::from_raw_media(webpage))), + M::Venue(venue) => Some(Self::Venue(Venue::from_raw_media(venue))), + M::Game(_) => None, + M::Invoice(_) => None, + M::GeoLive(geolive) => Some(Self::GeoLive(GeoLive::from_raw_media(geolive))), + M::Poll(poll) => Some(Self::Poll(Poll::from_raw_media(*poll))), + M::Dice(dice) => Some(Self::Dice(Dice::from_raw_media(dice))), + M::Story(_) => None, + M::Giveaway(_) => None, + M::GiveawayResults(_) => None, + M::PaidMedia(_) => None, + M::ToDo(_) => None, + M::VideoStream(_) => None, + } + } + + pub fn size(&self) -> Option { + match self { + Media::Photo(photo) => photo.size(), + Media::Document(document) => document.size(), + Media::Sticker(sticker) => sticker.document.size(), + Media::Contact(_) => None, + Media::Poll(_) => None, + Media::Geo(_) => None, + Media::Dice(_) => None, + Media::Venue(_) => None, + Media::GeoLive(_) => None, + Media::WebPage(_) => None, + } + } + + pub fn to_raw_input_media(&self) -> Option { + match self { + Media::Photo(photo) => Some(photo.to_raw_input_media().into()), + Media::Document(document) => Some(document.to_raw_input_media().into()), + Media::Sticker(sticker) => Some(sticker.document.to_raw_input_media().into()), + Media::Contact(contact) => Some(contact.to_raw_input_media().into()), + Media::Poll(poll) => Some(poll.to_raw_input_media().into()), + Media::Geo(geo) => Some(geo.to_raw_input_media().into()), + Media::Dice(dice) => Some(dice.to_raw_input_media().into()), + Media::Venue(venue) => Some(venue.to_raw_input_media().into()), + Media::GeoLive(geolive) => Some(geolive.to_raw_input_media().into()), + Media::WebPage(_) => None, + } + } +} + +impl Downloadable for Media { + fn to_raw_input_location(&self) -> Option { + match self { + Media::Photo(photo) => photo.to_raw_input_location(), + Media::Document(document) => document.to_raw_input_location(), + Media::Sticker(sticker) => sticker.document.to_raw_input_location(), + Media::Contact(_) => None, + Media::Poll(_) => None, + Media::Geo(_) => None, + Media::Dice(_) => None, + Media::Venue(_) => None, + Media::GeoLive(_) => None, + Media::WebPage(_) => None, + } + } + + fn size(&self) -> Option { + self.size() + } +} + +impl From for Media { + fn from(photo: Photo) -> Self { + Self::Photo(photo) + } +} + +impl Downloadable for ChatPhoto { + fn to_raw_input_location(&self) -> Option { + Some(self.raw.clone()) + } +} diff --git a/vendor/grammers-client/src/media/mod.rs b/vendor/grammers-client/src/media/mod.rs new file mode 100644 index 0000000..dfcb653 --- /dev/null +++ b/vendor/grammers-client/src/media/mod.rs @@ -0,0 +1,56 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +//! Types relating to message media and downloadables. +//! +//! Properties containing raw types are public and will either be called "raw" or prefixed with "raw_".\ +//! Keep in mind that **these fields are not part of the semantic versioning guarantees**. + +mod attributes; +mod downloadable; +mod input_media; +mod media; +mod photo_sizes; + +pub use attributes::Attribute; +pub use downloadable::Downloadable; +pub use input_media::InputMedia; +pub use media::{ + ChatPhoto, Contact, Dice, Document, Geo, GeoLive, Media, Photo, Poll, Sticker, Uploaded, Venue, + WebPage, +}; +pub use photo_sizes::PhotoSize; + +use grammers_tl_types as tl; + +/// Build an `InputMediaUploadedDocument` from an uploaded file. +/// +/// Shared by the `document` (`force_file = false`) and `file` (`force_file = true`) +/// builder methods of `InputMedia` and `InputMessage`. +pub(crate) fn uploaded_document( + file: Uploaded, + mime_type: String, + force_file: bool, + ttl_seconds: Option, +) -> tl::enums::InputMedia { + let file_name = file.name().to_string(); + tl::types::InputMediaUploadedDocument { + nosound_video: false, + force_file, + spoiler: false, + file: file.raw, + thumb: None, + mime_type, + attributes: vec![(tl::types::DocumentAttributeFilename { file_name }).into()], + stickers: None, + ttl_seconds, + video_cover: None, + video_timestamp: None, + } + .into() +} diff --git a/vendor/grammers-client/src/media/photo_sizes.rs b/vendor/grammers-client/src/media/photo_sizes.rs new file mode 100644 index 0000000..73bb941 --- /dev/null +++ b/vendor/grammers-client/src/media/photo_sizes.rs @@ -0,0 +1,398 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +use grammers_tl_types as tl; + +use super::Downloadable; + +/// Build the raw input file location for a photo or document thumbnail. +fn raw_input_location( + from_document: bool, + id: i64, + access_hash: i64, + file_reference: Vec, + thumb_size: String, +) -> tl::enums::InputFileLocation { + if !from_document { + tl::types::InputPhotoFileLocation { + id, + access_hash, + file_reference, + thumb_size, + } + .into() + } else { + tl::types::InputDocumentFileLocation { + id, + access_hash, + file_reference, + thumb_size, + } + .into() + } +} + +/// Thumbnail of a [`Photo`] or [`Document`]. +/// +/// [`Photo`]: super::Photo +/// [`Document`]: super::Document +#[derive(Clone, Debug, PartialEq)] +pub enum PhotoSize { + /// Size that is no longer available. + Empty(SizeEmpty), + /// Size that may be downloaded. + Size(Size), + /// Size attached directly to the message object. + Cached(CachedSize), + /// Stripped size attached directly to the message object. + Stripped(StrippedSize), + /// Multiple sizes in one. + Progressive(ProgressiveSize), + /// Vector thumbnail. + Path(PathSize), +} + +impl PhotoSize { + pub(crate) fn make_from(size: &tl::enums::PhotoSize, photo: &tl::types::Photo) -> Self { + match size { + tl::enums::PhotoSize::Empty(size) => PhotoSize::Empty(SizeEmpty { + photo_type: size.r#type.clone(), + }), + tl::enums::PhotoSize::Size(size) => PhotoSize::Size(Size { + photo_type: size.r#type.clone(), + width: size.w, + height: size.h, + size: size.size, + id: photo.id, + access_hash: photo.access_hash, + file_reference: photo.file_reference.clone(), + from_document: false, + }), + tl::enums::PhotoSize::PhotoCachedSize(size) => PhotoSize::Cached(CachedSize { + photo_type: size.r#type.clone(), + width: size.w, + height: size.h, + bytes: size.bytes.clone(), + }), + tl::enums::PhotoSize::PhotoStrippedSize(size) => PhotoSize::Stripped(StrippedSize { + photo_type: size.r#type.clone(), + bytes: size.bytes.clone(), + }), + tl::enums::PhotoSize::Progressive(size) => PhotoSize::Progressive(ProgressiveSize { + photo_type: size.r#type.clone(), + width: size.w, + height: size.h, + sizes: size.sizes.clone(), + id: photo.id, + access_hash: photo.access_hash, + file_reference: photo.file_reference.clone(), + from_document: false, + }), + tl::enums::PhotoSize::PhotoPathSize(size) => PhotoSize::Path(PathSize { + photo_type: size.r#type.clone(), + bytes: size.bytes.clone(), + }), + } + } + + pub(crate) fn make_from_document( + size: &tl::enums::PhotoSize, + document: &tl::types::Document, + ) -> Self { + match size { + tl::enums::PhotoSize::Empty(size) => PhotoSize::Empty(SizeEmpty { + photo_type: size.r#type.clone(), + }), + tl::enums::PhotoSize::Size(size) => PhotoSize::Size(Size { + photo_type: size.r#type.clone(), + width: size.w, + height: size.h, + size: size.size, + id: document.id, + access_hash: document.access_hash, + file_reference: document.file_reference.clone(), + from_document: true, + }), + tl::enums::PhotoSize::PhotoCachedSize(size) => PhotoSize::Cached(CachedSize { + photo_type: size.r#type.clone(), + width: size.w, + height: size.h, + bytes: size.bytes.clone(), + }), + tl::enums::PhotoSize::PhotoStrippedSize(size) => PhotoSize::Stripped(StrippedSize { + photo_type: size.r#type.clone(), + bytes: size.bytes.clone(), + }), + tl::enums::PhotoSize::Progressive(size) => PhotoSize::Progressive(ProgressiveSize { + photo_type: size.r#type.clone(), + width: size.w, + height: size.h, + sizes: size.sizes.clone(), + id: document.id, + access_hash: document.access_hash, + file_reference: document.file_reference.clone(), + from_document: true, + }), + tl::enums::PhotoSize::PhotoPathSize(size) => PhotoSize::Path(PathSize { + photo_type: size.r#type.clone(), + bytes: size.bytes.clone(), + }), + } + } + + /// Size of the photo thumb. + pub fn size(&self) -> usize { + match self { + PhotoSize::Empty(_) => 0, + PhotoSize::Size(size) => size.size as usize, + PhotoSize::Cached(size) => size.bytes.len(), + PhotoSize::Stripped(size) => { + let bytes = &size.bytes; + if bytes.len() < 3 || bytes[0] != 0x01 { + return 0; + } + size.bytes.len() + 622 + } + PhotoSize::Progressive(size) => { + size.sizes.iter().max().unwrap_or(&0).to_owned() as usize + } + PhotoSize::Path(size) => size.bytes.len(), + } + } + + /// Resolution and image transform applied server-side. + /// + /// + pub fn photo_type(&self) -> String { + match self { + PhotoSize::Empty(size) => size.photo_type.clone(), + PhotoSize::Size(size) => size.photo_type.clone(), + PhotoSize::Cached(size) => size.photo_type.clone(), + PhotoSize::Stripped(size) => size.photo_type.clone(), + PhotoSize::Progressive(size) => size.photo_type.clone(), + PhotoSize::Path(size) => size.photo_type.clone(), + } + } +} + +impl Downloadable for PhotoSize { + fn to_raw_input_location(&self) -> Option { + match self { + PhotoSize::Size(size) => size.to_raw_input_location(), + PhotoSize::Empty(_) => None, + PhotoSize::Cached(_) => None, + PhotoSize::Stripped(_) => None, + PhotoSize::Progressive(size) => size.to_raw_input_location(), + PhotoSize::Path(_) => None, + } + } + + fn to_data(&self) -> Option> { + match self { + PhotoSize::Empty(_) => None, + PhotoSize::Size(_) => None, + PhotoSize::Cached(size) => Some(size.data()), + PhotoSize::Stripped(size) => Some(size.data()), + PhotoSize::Progressive(_) => None, + PhotoSize::Path(size) => Some(size.data()), + } + } +} + +/// Empty thumbnail. Image with this thumbnail is unavailable. +#[derive(Clone, Debug, PartialEq)] +pub struct SizeEmpty { + photo_type: String, +} + +/// Image description. An additional request to Telegram should be perfomed to download the image +#[derive(Clone, Debug, PartialEq)] +pub struct Size { + photo_type: String, + pub width: i32, + pub height: i32, + pub size: i32, + + id: i64, + access_hash: i64, + file_reference: Vec, + + from_document: bool, +} + +impl Size { + pub fn to_raw_input_location(&self) -> Option { + Some(raw_input_location( + self.from_document, + self.id, + self.access_hash, + self.file_reference.clone(), + self.photo_type.clone(), + )) + } +} + +/// Description of an image and its content. +#[derive(Clone, Debug, PartialEq)] +pub struct CachedSize { + photo_type: String, + + pub width: i32, + pub height: i32, + pub bytes: Vec, +} + +impl CachedSize { + fn data(&self) -> Vec { + self.bytes.clone() + } +} + +/// A low-resolution compressed JPG payload +#[derive(Clone, Debug, PartialEq)] +pub struct StrippedSize { + photo_type: String, + + pub bytes: Vec, +} + +impl StrippedSize { + fn data(&self) -> Vec { + // Based on https://core.tlgr.org/api/files#stripped-thumbnails + let bytes = &self.bytes; + if bytes.len() < 3 || bytes[0] != 0x01 { + return vec![]; + } + + let header = vec![ + 0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, + 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xff, 0xdb, 0x00, 0x43, 0x00, 0x28, 0x1c, 0x1e, + 0x23, 0x1e, 0x19, 0x28, 0x23, 0x21, 0x23, 0x2d, 0x2b, 0x28, 0x30, 0x3c, 0x64, 0x41, + 0x3c, 0x37, 0x37, 0x3c, 0x7b, 0x58, 0x5d, 0x49, 0x64, 0x91, 0x80, 0x99, 0x96, 0x8f, + 0x80, 0x8c, 0x8a, 0xa0, 0xb4, 0xe6, 0xc3, 0xa0, 0xaa, 0xda, 0xad, 0x8a, 0x8c, 0xc8, + 0xff, 0xcb, 0xda, 0xee, 0xf5, 0xff, 0xff, 0xff, 0x9b, 0xc1, 0xff, 0xff, 0xff, 0xfa, + 0xff, 0xe6, 0xfd, 0xff, 0xf8, 0xff, 0xdb, 0x00, 0x43, 0x01, 0x2b, 0x2d, 0x2d, 0x3c, + 0x35, 0x3c, 0x76, 0x41, 0x41, 0x76, 0xf8, 0xa5, 0x8c, 0xa5, 0xf8, 0xf8, 0xf8, 0xf8, + 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, + 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, + 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, + 0xf8, 0xf8, 0xf8, 0xf8, 0xff, 0xc0, 0x00, 0x11, 0x08, 0x00, 0x00, 0x00, 0x00, 0x03, + 0x01, 0x22, 0x00, 0x02, 0x11, 0x01, 0x03, 0x11, 0x01, 0xff, 0xc4, 0x00, 0x1f, 0x00, + 0x00, 0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, + 0xff, 0xc4, 0x00, 0xb5, 0x10, 0x00, 0x02, 0x01, 0x03, 0x03, 0x02, 0x04, 0x03, 0x05, + 0x05, 0x04, 0x04, 0x00, 0x00, 0x01, 0x7d, 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, + 0x12, 0x21, 0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07, 0x22, 0x71, 0x14, 0x32, 0x81, + 0x91, 0xa1, 0x08, 0x23, 0x42, 0xb1, 0xc1, 0x15, 0x52, 0xd1, 0xf0, 0x24, 0x33, 0x62, + 0x72, 0x82, 0x09, 0x0a, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x25, 0x26, 0x27, 0x28, 0x29, + 0x2a, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, + 0x49, 0x4a, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5a, 0x63, 0x64, 0x65, 0x66, + 0x67, 0x68, 0x69, 0x6a, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7a, 0x83, 0x84, + 0x85, 0x86, 0x87, 0x88, 0x89, 0x8a, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, + 0x9a, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xb2, 0xb3, 0xb4, 0xb5, + 0xb6, 0xb7, 0xb8, 0xb9, 0xba, 0xc2, 0xc3, 0xc4, 0xc5, 0xc6, 0xc7, 0xc8, 0xc9, 0xca, + 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7, 0xd8, 0xd9, 0xda, 0xe1, 0xe2, 0xe3, 0xe4, 0xe5, + 0xe6, 0xe7, 0xe8, 0xe9, 0xea, 0xf1, 0xf2, 0xf3, 0xf4, 0xf5, 0xf6, 0xf7, 0xf8, 0xf9, + 0xfa, 0xff, 0xc4, 0x00, 0x1f, 0x01, 0x00, 0x03, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, + 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0xff, 0xc4, 0x00, 0xb5, 0x11, 0x00, 0x02, 0x01, + 0x02, 0x04, 0x04, 0x03, 0x04, 0x07, 0x05, 0x04, 0x04, 0x00, 0x01, 0x02, 0x77, 0x00, + 0x01, 0x02, 0x03, 0x11, 0x04, 0x05, 0x21, 0x31, 0x06, 0x12, 0x41, 0x51, 0x07, 0x61, + 0x71, 0x13, 0x22, 0x32, 0x81, 0x08, 0x14, 0x42, 0x91, 0xa1, 0xb1, 0xc1, 0x09, 0x23, + 0x33, 0x52, 0xf0, 0x15, 0x62, 0x72, 0xd1, 0x0a, 0x16, 0x24, 0x34, 0xe1, 0x25, 0xf1, + 0x17, 0x18, 0x19, 0x1a, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x35, 0x36, 0x37, 0x38, 0x39, + 0x3a, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4a, 0x53, 0x54, 0x55, 0x56, 0x57, + 0x58, 0x59, 0x5a, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6a, 0x73, 0x74, 0x75, + 0x76, 0x77, 0x78, 0x79, 0x7a, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8a, + 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9a, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, + 0xa7, 0xa8, 0xa9, 0xaa, 0xb2, 0xb3, 0xb4, 0xb5, 0xb6, 0xb7, 0xb8, 0xb9, 0xba, 0xc2, + 0xc3, 0xc4, 0xc5, 0xc6, 0xc7, 0xc8, 0xc9, 0xca, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7, + 0xd8, 0xd9, 0xda, 0xe2, 0xe3, 0xe4, 0xe5, 0xe6, 0xe7, 0xe8, 0xe9, 0xea, 0xf2, 0xf3, + 0xf4, 0xf5, 0xf6, 0xf7, 0xf8, 0xf9, 0xfa, 0xff, 0xda, 0x00, 0x0c, 0x03, 0x01, 0x00, + 0x02, 0x11, 0x03, 0x11, 0x00, 0x3f, 0x00, + ]; + let mut footer = vec![0xff, 0xd9]; + let mut real = header; + real[164] = bytes[1]; + real[166] = bytes[2]; + + let mut bytes_clone = bytes.clone()[3..].to_vec(); + real.append(&mut bytes_clone); + real.append(&mut footer); + + real + } +} + +/// Progressively encoded photosize. An additional request to Telegram should be perfomed to download the image +#[derive(Clone, Debug, PartialEq)] +pub struct ProgressiveSize { + photo_type: String, + + pub width: i32, + pub height: i32, + pub sizes: Vec, + + id: i64, + access_hash: i64, + file_reference: Vec, + + from_document: bool, +} + +impl ProgressiveSize { + pub fn to_raw_input_location(&self) -> Option { + Some(raw_input_location( + self.from_document, + self.id, + self.access_hash, + self.file_reference.clone(), + self.photo_type.clone(), + )) + } +} + +/// Messages with animated stickers can have a compressed svg (< 300 bytes) to show the outline +/// of the sticker before fetching the actual lottie animation. +#[derive(Clone, Debug, PartialEq)] +pub struct PathSize { + photo_type: String, + + pub bytes: Vec, +} + +impl PathSize { + fn data(&self) -> Vec { + // Based on https://core.tlgr.org/api/files#vector-thumbnails + let lookup = "AACAAAAHAAALMAAAQASTAVAAAZaacaaaahaaalmaaaqastava.az0123456789-,"; + let mut path = String::from("M"); + for num in &self.bytes { + let num = *num; + if num >= 128 + 64 { + path.push(lookup.chars().nth((num - 128 - 64) as usize).unwrap()); + } else { + if num >= 128 { + path.push(','); + } else if num >= 64 { + path.push('-'); + } + path.push((num & 63) as char); + } + } + path.push('z'); + let res = format!( + r###" + + +"### + ); + + res.as_bytes().to_vec() + } +} diff --git a/vendor/grammers-client/src/message/button.rs b/vendor/grammers-client/src/message/button.rs new file mode 100755 index 0000000..aac9913 --- /dev/null +++ b/vendor/grammers-client/src/message/button.rs @@ -0,0 +1,179 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +//! Custom keyboards and inline buttons used by messages. + +use grammers_tl_types as tl; + +/// Inline button to be used as the reply markup underneath a message. +pub struct Button { + pub raw: tl::enums::KeyboardButton, +} + +/// Key to be used as the reply markup of an alternate virtual keyboard. +pub struct Key { + pub raw: tl::enums::KeyboardButton, +} + +impl Button { + /// Creates a button that will trigger a [`crate::update::CallbackQuery`] when clicked. + /// + /// Although any combination of bytes can be used (including null, and not just UTF-8), [there is + /// a limit](https://core.telegram.org/bots/api#inlinekeyboardbutton) to how long the payload data + /// can be (see the description for the `callback_data` field for an up-to-date value). If you + /// need to store more data than that, consider storing the real data in some form of database, + /// and a reference to that data's row in the button's payload. + /// + /// Both the text and bytes data must be non-empty. + pub fn data, B: Into>>(text: T, bytes: B) -> Button { + Button { + raw: tl::types::KeyboardButtonCallback { + text: text.into(), + data: bytes.into(), + requires_password: false, + style: None, + } + .into(), + } + } + + /// Creates a button to force the user to switch to inline mode (perform inline queries). + /// + /// Pressing the button will insert the bot's username and the specified inline query in the input + /// field. + pub fn switch, Q: Into>(text: T, query: Q) -> Button { + Button { + raw: tl::types::KeyboardButtonSwitchInline { + text: text.into(), + query: query.into(), + same_peer: true, + style: None, + peer_types: None, + } + .into(), + } + } + /// Creates a button identical to [`Self::switch`], except the user will be prompted to select a + /// different peer. + /// + /// Pressing the button will prompt the user to select one of their peers, open that peer and + /// insert the bot's username and the specified inline query in the input field. + pub fn switch_elsewhere, Q: Into>(text: T, query: Q) -> Button { + Button { + raw: tl::types::KeyboardButtonSwitchInline { + text: text.into(), + query: query.into(), + same_peer: false, + style: None, + peer_types: None, + } + .into(), + } + } + + /// Creates a button that when clicked will ask the user if they want to open the specified URL. + /// + /// The URL will be visible to the user before it's opened unless it's trusted (such as Telegram's + /// domain). + pub fn url, U: Into>(text: T, url: U) -> Button { + Button { + raw: tl::types::KeyboardButtonUrl { + style: None, + text: text.into(), + url: url.into(), + } + .into(), + } + } + + /// Creates a button that when clicked will open the specified URL in an in-app browser. + pub fn webview, U: Into>(text: T, url: U) -> Button { + Button { + raw: tl::types::KeyboardButtonWebView { + style: None, + text: text.into(), + url: url.into(), + } + .into(), + } + } +} + +impl Key { + /// Creates a simple keyboard key. + /// + /// When pressed, the button's text will be sent as a normal message, as if the user had typed it. + pub fn text>(text: T) -> Key { + Key { + raw: tl::types::KeyboardButton { + style: None, + text: text.into(), + } + .into(), + } + } + + /// Creates a keyboard key to request the user's contact information (including the phone). + pub fn request_phone>(text: T) -> Key { + Key { + raw: tl::types::KeyboardButtonRequestPhone { + style: None, + text: text.into(), + } + .into(), + } + } + + /// Creates a keyboard key to request the user's current geo-location. + pub fn request_geo>(text: T) -> Key { + Key { + raw: tl::types::KeyboardButtonRequestGeoLocation { + style: None, + text: text.into(), + } + .into(), + } + } + + /// Creates a keyboard key that will direct the user to create and send a poll when pressed. + /// + /// This is only available in direct conversations with the user. + pub fn request_poll>(text: T) -> Key { + Key { + raw: tl::types::KeyboardButtonRequestPoll { + style: None, + text: text.into(), + quiz: None, + } + .into(), + } + } + + /// Creates a keyboard key identical to [`Self::request_poll`], except the poll requested must be a quiz. + /// + /// This is only available in direct conversations with the user. + pub fn request_quiz>(text: T) -> Key { + Key { + raw: tl::types::KeyboardButtonRequestPoll { + style: None, + text: text.into(), + quiz: Some(true), + } + .into(), + } + } +} + +/* +TODO implement other buttons +(with password) keyboardButtonCallback#35bbdb6b flags:# requires_password:flags.0?true text:string data:bytes = KeyboardButton; +keyboardButtonUrlAuth#10b78d29 flags:# text:string fwd_text:flags.0?string url:string button_id:int = KeyboardButton; +keyboardButtonGame#50f41ccf text:string = KeyboardButton; +keyboardButtonBuy#afd93fbb text:string = KeyboardButton; +inputKeyboardButtonUrlAuth#d02e7fd4 flags:# request_write_access:flags.0?true text:string fwd_text:flags.1?string url:string bot:InputUser = KeyboardButton; +*/ diff --git a/vendor/grammers-client/src/message/input_message.rs b/vendor/grammers-client/src/message/input_message.rs new file mode 100644 index 0000000..5ef23d4 --- /dev/null +++ b/vendor/grammers-client/src/message/input_message.rs @@ -0,0 +1,427 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +use std::time::{SystemTime, UNIX_EPOCH}; + +use grammers_tl_types as tl; + +use super::reply_markup::ReplyMarkup; +use crate::media::{Attribute, Media, Uploaded}; + +// https://github.com/telegramdesktop/tdesktop/blob/e7fbcce9d9f0a8944eb2c34e74bd01b8776cb891/Telegram/SourceFiles/data/data_scheduled_messages.h#L52 +const SCHEDULE_ONCE_ONLINE: i32 = 0x7ffffffe; + +/// Construct and send rich text messages with various options. +#[derive(Clone, Default)] +pub struct InputMessage { + pub(crate) background: bool, + pub(crate) clear_draft: bool, + pub(crate) entities: Vec, + pub(crate) invert_media: bool, + pub(crate) link_preview: bool, + pub(crate) reply_markup: Option, + pub(crate) reply_to: Option, + pub(crate) schedule_date: Option, + pub(crate) silent: bool, + pub(crate) text: String, + pub(crate) media: Option, + media_ttl: Option, + mime_type: Option, +} + +impl InputMessage { + /// Creates a new empty message for input. + pub fn new() -> Self { + Self::default() + } + + /// Whether to "send this message as a background message". + /// + /// This description is taken from . + pub fn background(mut self, background: bool) -> Self { + self.background = background; + self + } + + /// Whether the draft in this peer, if any, should be cleared. + pub fn clear_draft(mut self, clear_draft: bool) -> Self { + self.clear_draft = clear_draft; + self + } + + /// Replaces the plaintext in the message. + /// + /// The caller must ensure that formatting entities remain valid for the given text. + /// If you need to update formatting entities, call method [`InputMessage::fmt_entities`]. + /// + ///
+ /// Note that this method does not modify formatting entities, which may break + /// formatting or cause out-of-bounds errors if entities do not match the given text. + ///
+ pub fn text(mut self, s: T) -> Self + where + T: Into, + { + self.text = s.into(); + self + } + + /// The formatting entities within the message (such as bold, italics, etc.). + pub fn fmt_entities(mut self, entities: I) -> Self + where + I: IntoIterator, + { + self.entities = entities.into_iter().collect(); + self + } + + /// Builds a new message from the given markdown-formatted string as the + /// message contents and entities. + /// + /// Note that Telegram only supports a very limited subset of entities: + /// bold, italic, underline, strikethrough, code blocks, pre blocks and inline links (inline + /// links with this format `tg://user?id=12345678` will be replaced with inline mentions when + /// possible). + #[cfg(feature = "markdown")] + pub fn markdown(mut self, s: T) -> Self + where + T: AsRef, + { + let (text, entities) = crate::parsers::parse_markdown_message(s.as_ref()); + self.text = text; + self.entities = entities; + self + } + + /// Builds a new message from the given HTML-formatted string as the + /// message contents and entities. + /// + /// Note that Telegram only supports a very limited subset of entities: + /// bold, italic, underline, strikethrough, code blocks, pre blocks and inline links (inline + /// links with this format `tg://user?id=12345678` will be replaced with inline mentions when + /// possible). + #[cfg(feature = "html")] + pub fn html(mut self, s: T) -> Self + where + T: AsRef, + { + let (text, entities) = crate::parsers::parse_html_message(s.as_ref()); + self.text = text; + self.entities = entities; + self + } + + /// Whether the media will be inverted. + /// + /// If inverted, photos, videos, and documents will appear at the bottom and link previews at the top of the message. + pub fn invert_media(mut self, invert_media: bool) -> Self { + self.invert_media = invert_media; + self + } + + /// Whether the link preview be shown for the message. + /// + /// This has no effect when sending media, which cannot contain a link preview. + pub fn link_preview(mut self, link_preview: bool) -> Self { + self.link_preview = link_preview; + self + } + + /// Defines the suggested reply markup for the message (such as adding inline buttons). + /// This will be displayed below the message. + /// + /// Only bot accounts can make use of the reply markup feature (a user attempting to send a + /// message with a reply markup will result in the markup being ignored by Telegram). + /// + /// The user is free to ignore the markup and continue sending usual text messages. + /// + /// See [`crate::message::ReplyMarkup`] for the different available markups along with how + /// they behave. + pub fn reply_markup(mut self, markup: ReplyMarkup) -> Self { + self.reply_markup = Some(markup.raw); + self + } + + /// The message identifier to which this message should reply to, if any. + /// + /// Otherwise, this message will not be a reply to any other. + pub fn reply_to(mut self, reply_to: Option) -> Self { + self.reply_to = reply_to; + self + } + + /// If set to a distant enough future time, the message won't be sent immediately, + /// and instead it will be scheduled to be automatically sent at a later time. + /// + /// This scheduling is done server-side, and may not be accurate to the second. + /// + /// Bot accounts cannot schedule messages. + pub fn schedule_date(mut self, schedule_date: Option) -> Self { + self.schedule_date = schedule_date.map(|t| { + t.duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i32) + .unwrap_or(0) + }); + self + } + + /// Schedule the message to be sent once the person comes online. + /// + /// This only works in private chats, and only if the person has their + /// last seen visible. + /// + /// Bot accounts cannot schedule messages. + pub fn schedule_once_online(mut self) -> Self { + self.schedule_date = Some(SCHEDULE_ONCE_ONLINE); + self + } + + /// Whether the message should notify people or not. + /// + /// Defaults to `false`, which means it will notify them. Set it to `true` + /// to alter this behaviour. + pub fn silent(mut self, silent: bool) -> Self { + self.silent = silent; + self + } + + /// Include the uploaded file as a photo in the message. + /// + /// The Telegram server will compress the image and convert it to JPEG format if necessary. + /// + /// The text will be the caption of the photo, which may be empty for no caption. + pub fn photo(mut self, file: Uploaded) -> Self { + self.media = Some( + (tl::types::InputMediaUploadedPhoto { + spoiler: false, + file: file.raw, + stickers: None, + ttl_seconds: self.media_ttl, + live_photo: false, + video: None, + }) + .into(), + ); + self + } + + /// Include an external photo in the message. + /// + /// The Telegram server will download and compress the image and convert it to JPEG format if + /// necessary. + /// + /// The text will be the caption of the photo, which may be empty for no caption. + pub fn photo_url(mut self, url: impl Into) -> Self { + self.media = Some( + (tl::types::InputMediaPhotoExternal { + spoiler: false, + url: url.into(), + ttl_seconds: self.media_ttl, + }) + .into(), + ); + self + } + + /// Include the uploaded file as a document in the message. + /// + /// You can use this to send videos, stickers, audios, or uncompressed photos. + /// + /// The text will be the caption of the document, which may be empty for no caption. + pub fn document(mut self, file: Uploaded) -> Self { + let mime_type = self.get_file_mime(&file); + self.media = Some(crate::media::uploaded_document( + file, + mime_type, + false, + self.media_ttl, + )); + self + } + + /// Include a media in the message using the raw TL types. + /// + /// You can use this to send any media using the raw TL types that don't have + /// a specific method in this builder such as Dice, Polls, etc. + /// + /// This can also be used to send media with a file reference, see `InputMediaDocument` + /// and `InputMediaPhoto` in the `grammers-tl-types` crate. + /// + /// The text will be the caption of the media, which may be empty for no caption. + pub fn media>(mut self, media: M) -> Self { + self.media = Some(media.into()); + self + } + + /// Include the video file with thumb in the message. + /// + /// The text will be the caption of the document, which may be empty for no caption. + /// + /// # Examples + /// + /// ``` + /// async fn f(client: &mut grammers_client::Client) -> Result<(), Box> { + /// use grammers_client::message::InputMessage; + /// + /// let video = client.upload_file("video.mp4").await?; + /// let thumb = client.upload_file("thumb.png").await?; + /// let message = InputMessage::new().text("").document(video).thumbnail(thumb); + /// Ok(()) + /// } + /// ``` + pub fn thumbnail(mut self, thumb: Uploaded) -> Self { + if let Some(tl::enums::InputMedia::UploadedDocument(document)) = &mut self.media { + document.thumb = Some(thumb.raw); + } + self + } + + /// Include an external file as a document in the message. + /// + /// You can use this to send videos, stickers, audios, or uncompressed photos. + /// + /// The Telegram server will be the one that downloads and includes the document as media. + /// + /// The text will be the caption of the document, which may be empty for no caption. + pub fn document_url(mut self, url: impl Into) -> Self { + self.media = Some( + (tl::types::InputMediaDocumentExternal { + spoiler: false, + url: url.into(), + ttl_seconds: self.media_ttl, + video_cover: None, + video_timestamp: None, + }) + .into(), + ); + self + } + + /// Add additional attributes to the message. + /// + /// This must be called *after* setting a file. + /// + /// # Examples + /// + /// ``` + /// # async fn f(client: &mut grammers_client::Client) -> Result<(), Box> { + /// # let audio = client.upload_file("audio.flac").await?; + /// # + /// use std::time::Duration; + /// use grammers_client::media::Attribute; + /// use grammers_client::message::InputMessage; + /// + /// let message = InputMessage::new().text("").document(audio).attribute( + /// Attribute::Audio { + /// duration: Duration::new(123, 0), + /// title: Some("Hello".to_string()), + /// performer: Some("World".to_string()), + /// } + /// ); + /// # Ok(()) + /// # } + /// ``` + pub fn attribute(mut self, attr: Attribute) -> Self { + if let Some(tl::enums::InputMedia::UploadedDocument(document)) = &mut self.media { + document.attributes.push(attr.into()); + } + self + } + + /// Copy media from an existing message. + /// + /// You can use this to send media from another message without re-uploading it. + pub fn copy_media(mut self, media: &Media) -> Self { + self.media = media.to_raw_input_media(); + self + } + + /// Include the uploaded file as a document file in the message. + /// + /// You can use this to send any type of media as a simple document file. + /// + /// The text will be the caption of the file, which may be empty for no caption. + pub fn file(mut self, file: Uploaded) -> Self { + let mime_type = self.get_file_mime(&file); + self.media = Some(crate::media::uploaded_document( + file, + mime_type, + true, + self.media_ttl, + )); + self + } + + /// Change the media's Time To Live (TTL). + /// + /// For example, this enables you to send a `photo` that can only be viewed for a certain + /// amount of seconds before it expires. + /// + /// Not all media supports this feature. + /// + /// This method should be called before setting any media, else it won't have any effect. + pub fn media_ttl(mut self, seconds: i32) -> Self { + self.media_ttl = if seconds < 0 { None } else { Some(seconds) }; + self + } + + /// Change the media's mime type. + /// + /// This method will override the mime type that would otherwise be automatically inferred + /// from the extension of the used file + /// + /// If no mime type is set and it cannot be inferred, the mime type will be + /// "application/octet-stream". + /// + /// This method should be called before setting any media, else it won't have any effect. + pub fn mime_type(mut self, mime_type: &str) -> Self { + self.mime_type = Some(mime_type.to_string()); + self + } + + /// Return the mime type string for the given file. + fn get_file_mime(&self, file: &Uploaded) -> String { + if let Some(mime) = self.mime_type.as_ref() { + mime.clone() + } else if let Some(mime) = mime_guess::from_path(file.name()).first() { + mime.essence_str().to_string() + } else { + "application/octet-stream".to_string() + } + } +} + +impl From for InputMessage +where + S: Into, +{ + fn from(text: S) -> Self { + Self::new().text(text) + } +} + +impl From for InputMessage { + fn from(message: super::Message) -> Self { + let (text, entities, media) = match message.raw { + tl::enums::Message::Empty(_) => (None, None, None), + tl::enums::Message::Message(message) => { + (Some(message.message), message.entities, message.media) + } + tl::enums::Message::Service(_) => (None, None, None), + }; + + Self { + text: text.unwrap_or_default(), + entities: entities.unwrap_or_default(), + media: media + .and_then(Media::from_raw) + .and_then(|m| m.to_raw_input_media()), + ..Default::default() + } + } +} diff --git a/vendor/grammers-client/src/message/message.rs b/vendor/grammers-client/src/message/message.rs new file mode 100644 index 0000000..4f27465 --- /dev/null +++ b/vendor/grammers-client/src/message/message.rs @@ -0,0 +1,842 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +use std::fmt; +#[cfg(feature = "fs")] +use std::path::Path; + +use chrono::{DateTime, Utc}; +use grammers_mtsender::InvocationError; +use grammers_session::types::{PeerId, PeerKind, PeerRef}; +use grammers_tl_types as tl; + +use super::{InputMessage, InputReactions}; +use crate::Client; +use crate::media::{InputMedia, Media, Photo}; +#[cfg(any(feature = "markdown", feature = "html"))] +use crate::parsers; +use crate::peer::{Peer, PeerMap}; +use crate::utils; + +/// Represents a Telegram message, which includes text messages, messages with media, and service +/// messages. +/// +/// This message should be treated as a snapshot in time, that is, if the message is edited while +/// using this object, those changes won't alter this structure. +#[derive(Clone)] +pub struct Message { + pub raw: tl::enums::Message, + pub(crate) fetched_in: Option, + pub(crate) client: Client, + // When fetching messages or receiving updates, a set of peers will be present. A single + // server response contains a lot of peers, and some might be related to deep layers of + // a message action for instance. Keeping the entire set like this allows for cheaper clones + // and moves, and saves us from worrying about picking out all the peers we care about. + pub(crate) peers: PeerMap, +} + +impl Message { + pub fn from_raw( + client: &Client, + message: tl::enums::Message, + fetched_in: Option, + peers: PeerMap, + ) -> Self { + Self { + raw: message, + fetched_in, + client: client.clone(), + peers, + } + } + + pub fn from_raw_short_updates( + client: &Client, + updates: tl::types::UpdateShortSentMessage, + input: InputMessage, + peer: PeerRef, + ) -> Self { + Self { + raw: tl::enums::Message::Message(tl::types::Message { + out: updates.out, + mentioned: false, + media_unread: false, + silent: input.silent, + post: false, // TODO true if sent to broadcast channel + from_scheduled: false, + legacy: false, + edit_hide: false, + pinned: false, + noforwards: false, // TODO true if channel has noforwads? + video_processing_pending: false, + paid_suggested_post_stars: false, + invert_media: input.invert_media, + id: updates.id, + from_id: None, // TODO self + from_boosts_applied: None, + from_rank: None, + peer_id: peer.id.into(), + saved_peer_id: None, + fwd_from: None, + via_bot_id: None, + reply_to: input.reply_to.map(|reply_to_msg_id| { + tl::types::MessageReplyHeader { + reply_to_scheduled: false, + forum_topic: false, + quote: false, + reply_to_ephemeral: false, + reply_to_msg_id: Some(reply_to_msg_id), + reply_to_peer_id: None, + reply_from: None, + reply_media: None, + reply_to_top_id: None, + quote_text: None, + quote_entities: None, + quote_offset: None, + todo_item_id: None, + poll_option: None, + } + .into() + }), + date: updates.date, + message: input.text, + media: updates.media, + reply_markup: input.reply_markup, + entities: updates.entities, + views: None, + forwards: None, + replies: None, + edit_date: None, + post_author: None, + grouped_id: None, + restriction_reason: None, + ttl_period: updates.ttl_period, + reactions: None, + quick_reply_shortcut_id: None, + via_business_bot_id: None, + guestchat_via_from: None, + offline: false, + effect: None, + factcheck: None, + report_delivery_until_date: None, + paid_message_stars: None, + paid_suggested_post_ton: false, + suggested_post: None, + schedule_repeat_period: None, + summary_from_language: None, + rich_message: None, + }), + fetched_in: Some(peer), + client: client.clone(), + peers: client.empty_peer_map(), + } + } + + /// Whether the message is outgoing (i.e. you sent this message to some other peer) or + /// incoming (i.e. someone else sent it to you or the peer). + pub fn outgoing(&self) -> bool { + match &self.raw { + tl::enums::Message::Empty(_) => false, + tl::enums::Message::Message(message) => message.out, + tl::enums::Message::Service(message) => message.out, + } + } + + /// Whether you were mentioned in this message or not. + /// + /// This includes @username mentions, text mentions, and messages replying to one of your + /// previous messages (even if it contains no mention in the message text). + pub fn mentioned(&self) -> bool { + match &self.raw { + tl::enums::Message::Empty(_) => false, + tl::enums::Message::Message(message) => message.mentioned, + tl::enums::Message::Service(message) => message.mentioned, + } + } + + /// Whether you have read the media in this message or not. + /// + /// Most commonly, these are voice notes that you have not played yet. + pub fn media_unread(&self) -> bool { + match &self.raw { + tl::enums::Message::Empty(_) => false, + tl::enums::Message::Message(message) => message.media_unread, + tl::enums::Message::Service(message) => message.media_unread, + } + } + + /// Whether the message should notify people with sound or not. + pub fn silent(&self) -> bool { + match &self.raw { + tl::enums::Message::Empty(_) => false, + tl::enums::Message::Message(message) => message.silent, + tl::enums::Message::Service(message) => message.silent, + } + } + + /// Whether this message is a post in a broadcast channel or not. + pub fn post(&self) -> bool { + match &self.raw { + tl::enums::Message::Empty(_) => false, + tl::enums::Message::Message(message) => message.post, + tl::enums::Message::Service(message) => message.post, + } + } + + /// Whether this message was originated from a previously-scheduled message or not. + pub fn from_scheduled(&self) -> bool { + match &self.raw { + tl::enums::Message::Empty(_) => false, + tl::enums::Message::Message(message) => message.from_scheduled, + tl::enums::Message::Service(_) => false, + } + } + + // `legacy` is not exposed, though it can be if it proves to be useful + + /// Whether the edited mark of this message is edited should be hidden (e.g. in GUI clients) + /// or shown. + pub fn edit_hide(&self) -> bool { + match &self.raw { + tl::enums::Message::Empty(_) => false, + tl::enums::Message::Message(message) => message.edit_hide, + tl::enums::Message::Service(_) => false, + } + } + + /// Whether this message is currently pinned or not. + pub fn pinned(&self) -> bool { + match &self.raw { + tl::enums::Message::Empty(_) => false, + tl::enums::Message::Message(message) => message.pinned, + tl::enums::Message::Service(_) => false, + } + } + + /// The ID of this message. + /// + /// Message identifiers are counters that start at 1 and grow by 1 for each message produced. + /// + /// Every channel has its own unique message counter. This counter is the same for all users, + /// but unique to each channel. + /// + /// Every account has another unique message counter which is used for private conversations + /// and small group chats. This means different accounts will likely have different message + /// identifiers for the same message in a private conversation or small group chat. This also + /// implies that the message identifier alone is enough to uniquely identify the message, + /// without the need to know the chat ID. + /// + /// **You cannot use the message ID of User A when running as User B**, unless this message + /// belongs to a megagroup or broadcast channel. Beware of this when using methods like + /// [`Client::delete_messages`], which **cannot** validate the peer where the message + /// should be deleted for those cases. + pub fn id(&self) -> i32 { + self.raw.id() + } + + /// The [`Self::peer`]'s identifier. + pub fn peer_id(&self) -> PeerId { + utils::peer_from_message(&self.raw) + .map(PeerId::from) + .or_else(|| self.fetched_in.map(|peer| peer.id)) + .expect("empty messages from updates should contain peer_id") + } + + /// Cached reference to the [`Self::peer`], if it is in cache. + pub async fn peer_ref( + &self, + ) -> Result, Box> { + match self.fetched_in { + Some(peer) => Ok(Some(peer)), + None => self.peers.get_ref(self.peer_id()).await, + } + } + + /// The peer where this message was sent to. + /// + /// This might be the user you're talking to for private conversations, or the group or + /// channel where the message was sent. + pub fn peer(&self) -> Option<&Peer> { + self.peers.get(self.peer_id()) + } + + /// The [`Self::sender`]'s identifier, if there is a sender. + pub fn sender_id(&self) -> Option { + let from_id = match &self.raw { + tl::enums::Message::Empty(_) => None, + tl::enums::Message::Message(message) => message.from_id.clone().map(PeerId::from), + tl::enums::Message::Service(message) => message.from_id.clone().map(PeerId::from), + }; + from_id.or_else(|| { + // Incoming messages in private conversations don't include `from_id` since + // layer 119, but the sender can only be the peer we're in. + let peer_id = self.peer_id(); + if matches!(peer_id.kind(), PeerKind::User) { + if self.outgoing() { + Some(PeerId::self_user()) + } else { + Some(peer_id) + } + } else { + None + } + }) + } + + /// Cached reference to the [`Self::sender`], if there is a sender and they are in cache. + pub async fn sender_ref( + &self, + ) -> Result, Box> { + match self.sender_id() { + None => Ok(None), + Some(x) => self.peers.get_ref(x).await, + } + } + + /// The sender of this message, if there is a sender **and** the sender is in cache. + pub fn sender(&self) -> Option<&Peer> { + self.sender_id().and_then(|id| self.peers.get(id)) + } + + /// If this message was forwarded from a previous message, return the header with information + /// about that forward. + pub fn forward_header(&self) -> Option { + match &self.raw { + tl::enums::Message::Empty(_) => None, + tl::enums::Message::Message(message) => message.fwd_from.clone(), + tl::enums::Message::Service(_) => None, + } + } + + /// If this message was sent @via some inline bot, return the bot's user identifier. + pub fn via_bot_id(&self) -> Option { + match &self.raw { + tl::enums::Message::Empty(_) => None, + tl::enums::Message::Message(message) => message.via_bot_id, + tl::enums::Message::Service(_) => None, + } + } + + /// If this message is replying to a previous message, return the header with information + /// about that reply. + pub fn reply_header(&self) -> Option { + match &self.raw { + tl::enums::Message::Empty(_) => None, + tl::enums::Message::Message(message) => message.reply_to.clone(), + tl::enums::Message::Service(message) => message.reply_to.clone(), + } + } + + pub(crate) fn date_timestamp(&self) -> i32 { + match &self.raw { + tl::enums::Message::Empty(_) => 0, + tl::enums::Message::Message(message) => message.date, + tl::enums::Message::Service(message) => message.date, + } + } + + /// The date when this message was produced. + pub fn date(&self) -> DateTime { + utils::date(self.date_timestamp()) + } + + /// The message's text. + /// + /// For service or empty messages, this will be the empty strings. + /// + /// If the message has media, this text is the caption commonly displayed underneath it. + pub fn text(&self) -> &str { + match &self.raw { + tl::enums::Message::Empty(_) => "", + tl::enums::Message::Message(message) => &message.message, + tl::enums::Message::Service(_) => "", + } + } + + fn entities(&self) -> Option<&Vec> { + match &self.raw { + tl::enums::Message::Empty(_) => None, + tl::enums::Message::Message(message) => message.entities.as_ref(), + tl::enums::Message::Service(_) => None, + } + } + + /// Like [`text`](Self::text), but with the [`fmt_entities`](Self::fmt_entities) + /// applied to produce a markdown string instead. + /// + /// Some formatting entities automatically added by Telegram, such as bot commands or + /// clickable emails, are ignored in the generated string, as those do not need to be + /// sent for Telegram to include them in the message. + /// + /// Formatting entities which cannot be represented in CommonMark without resorting to HTML, + /// such as underline, are also ignored. + #[cfg(feature = "markdown")] + pub fn markdown_text(&self) -> String { + if let Some(entities) = self.entities() { + parsers::generate_markdown_message(self.text(), entities) + } else { + self.text().to_owned() + } + } + + /// Like [`text`](Self::text), but with the [`fmt_entities`](Self::fmt_entities) + /// applied to produce a HTML string instead. + /// + /// Some formatting entities automatically added by Telegram, such as bot commands or + /// clickable emails, are ignored in the generated string, as those do not need to be + /// sent for Telegram to include them in the message. + #[cfg(feature = "html")] + pub fn html_text(&self) -> String { + if let Some(entities) = self.entities() { + parsers::generate_html_message(self.text(), entities) + } else { + self.text().to_owned() + } + } + + /// The media displayed by this message, if any. + /// + /// This not only includes photos or videos, but also contacts, polls, documents, locations + /// and many other types. + pub fn media(&self) -> Option { + let media = match &self.raw { + tl::enums::Message::Empty(_) => None, + tl::enums::Message::Message(message) => message.media.clone(), + tl::enums::Message::Service(_) => None, + }; + media.and_then(Media::from_raw) + } + + /// If the message has a reply markup (which can happen for messages produced by bots), + /// returns said markup. + pub fn reply_markup(&self) -> Option { + match &self.raw { + tl::enums::Message::Empty(_) => None, + tl::enums::Message::Message(message) => message.reply_markup.clone(), + tl::enums::Message::Service(_) => None, + } + } + + /// The formatting entities used to format this message, such as bold, italic, with their + /// offsets and lengths. + pub fn fmt_entities(&self) -> Option<&Vec> { + // TODO correct the offsets and lengths to match the byte offsets + self.entities() + } + + /// How many views does this message have, when applicable. + /// + /// The same user account can contribute to increment this counter indefinitedly, however + /// there is a server-side cooldown limitting how fast it can happen (several hours). + pub fn view_count(&self) -> Option { + match &self.raw { + tl::enums::Message::Empty(_) => None, + tl::enums::Message::Message(message) => message.views, + tl::enums::Message::Service(_) => None, + } + } + + /// How many times has this message been forwarded, when applicable. + pub fn forward_count(&self) -> Option { + match &self.raw { + tl::enums::Message::Empty(_) => None, + tl::enums::Message::Message(message) => message.forwards, + tl::enums::Message::Service(_) => None, + } + } + + /// How many replies does this message have, when applicable. + pub fn reply_count(&self) -> Option { + match &self.raw { + tl::enums::Message::Message(tl::types::Message { + replies: Some(tl::enums::MessageReplies::Replies(replies)), + .. + }) => Some(replies.replies), + _ => None, + } + } + + /// React to this message. + /// + /// # Examples + /// + /// ``` + /// # async fn f(message: grammers_client::message::Message, client: grammers_client::Client) -> Result<(), Box> { + /// message.react("👍").await?; + /// # Ok(()) + /// # } + /// ``` + /// + /// Make animation big & Add to recent + /// + /// ``` + /// # async fn f(message: grammers_client::message::Message, client: grammers_client::Client) -> Result<(), Box> { + /// use grammers_client::message::InputReactions; + /// + /// let reactions = InputReactions::emoticon("🤯").big().add_to_recent(); + /// + /// message.react(reactions).await?; + /// # Ok(()) + /// # } + /// ``` + /// + /// Remove reactions + /// + /// ``` + /// # async fn f(message: grammers_client::message::Message, client: grammers_client::Client) -> Result<(), Box> { + /// use grammers_client::message::InputReactions; + /// + /// message.react(InputReactions::remove()).await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn react>( + &self, + reactions: R, + ) -> Result<(), InvocationError> { + self.client + .send_reactions( + self.peer_ref().await?.ok_or(InvocationError::Dropped)?, + self.id(), + reactions, + ) + .await?; + Ok(()) + } + + /// How many reactions does this message have, when applicable. + pub fn reaction_count(&self) -> Option { + match &self.raw { + tl::enums::Message::Message(tl::types::Message { + reactions: Some(tl::enums::MessageReactions::Reactions(reactions)), + .. + }) => { + let count = reactions + .results + .iter() + .map(|reaction: &tl::enums::ReactionCount| { + let tl::enums::ReactionCount::Count(reaction) = reaction; + reaction.count + }) + .sum(); + Some(count) + } + _ => None, + } + } + + /// The date when this message was last edited. + pub fn edit_date(&self) -> Option> { + match &self.raw { + tl::enums::Message::Empty(_) => None, + tl::enums::Message::Message(message) => message.edit_date.map(utils::date), + tl::enums::Message::Service(_) => None, + } + } + + /// If this message was sent to a channel, return the name used by the author to post it. + pub fn post_author(&self) -> Option<&str> { + match &self.raw { + tl::enums::Message::Empty(_) => None, + tl::enums::Message::Message(message) => message.post_author.as_deref(), + tl::enums::Message::Service(_) => None, + } + } + + /// If this message belongs to a group of messages, return the unique identifier for that + /// group. + /// + /// This applies to albums of media, such as multiple photos grouped together. + /// + /// Note that there may be messages sent in between the messages forming a group. + pub fn grouped_id(&self) -> Option { + match &self.raw { + tl::enums::Message::Empty(_) => None, + tl::enums::Message::Message(message) => message.grouped_id, + tl::enums::Message::Service(_) => None, + } + } + + /// A list of reasons on why this message is restricted. + /// + /// The message is not restricted if the return value is `None`. + pub fn restriction_reason(&self) -> Option<&Vec> { + match &self.raw { + tl::enums::Message::Empty(_) => None, + tl::enums::Message::Message(message) => message.restriction_reason.as_ref(), + tl::enums::Message::Service(_) => None, + } + } + + /// If this message is a service message, return the service action that occured. + pub fn action(&self) -> Option<&tl::enums::MessageAction> { + match &self.raw { + tl::enums::Message::Empty(_) => None, + tl::enums::Message::Message(_) => None, + tl::enums::Message::Service(message) => Some(&message.action), + } + } + + /// If this message is replying to another message, return the replied message ID. + pub fn reply_to_message_id(&self) -> Option { + match &self.raw { + tl::enums::Message::Message(tl::types::Message { + reply_to: Some(tl::enums::MessageReplyHeader::Header(header)), + .. + }) => header.reply_to_msg_id, + _ => None, + } + } + + /// Fetch the message that this message is replying to, or `None` if this message is not a + /// reply to a previous message. + /// + /// Shorthand for `Client::get_reply_to_message`. + pub async fn get_reply(&self) -> Result, InvocationError> { + self.client + .clone() // TODO don't clone + .get_reply_to_message(self) + .await + } + + /// Respond to this message by sending a new message to the same peer, but without directly + /// replying to it. + /// + /// Shorthand for `Client::send_message`. + pub async fn respond>( + &self, + message: M, + ) -> Result { + self.client + .send_message( + self.peer_ref().await?.ok_or(InvocationError::Dropped)?, + message, + ) + .await + } + + /// Respond to this message by sending a album in the same peer, but without directly + /// replying to it. + /// + /// Shorthand for `Client::send_album`. + pub async fn respond_album( + &self, + medias: Vec, + ) -> Result>, InvocationError> { + self.client + .send_album( + self.peer_ref().await?.ok_or(InvocationError::Dropped)?, + medias, + ) + .await + } + + /// Directly reply to this message by sending a new message to the same peer that replies to + /// it. This methods overrides the `reply_to` on the `InputMessage` to point to `self`. + /// + /// Shorthand for `Client::send_message`. + pub async fn reply>(&self, message: M) -> Result { + let message = message.into(); + self.client + .send_message( + self.peer_ref().await?.ok_or(InvocationError::Dropped)?, + message.reply_to(Some(self.id())), + ) + .await + } + + /// Directly reply to this message by sending a album to the same peer that replies to + /// it. This methods overrides the `reply_to` on the first `InputMedia` to point to `self`. + /// + /// Shorthand for `Client::send_album`. + pub async fn reply_album( + &self, + mut medias: Vec, + ) -> Result>, InvocationError> { + medias.first_mut().unwrap().reply_to = Some(self.id()); + self.client + .send_album( + self.peer_ref().await?.ok_or(InvocationError::Dropped)?, + medias, + ) + .await + } + + /// Forward this message to another (or the same) peer. + /// + /// Shorthand for `Client::forward_messages`. If you need to forward multiple messages + /// at once, consider using that method instead. + pub async fn forward_to>(&self, chat: C) -> Result { + // TODO return `Message` + // When forwarding a single message, if it fails, Telegram should respond with RPC error. + // If it succeeds we will have the single forwarded message present which we can unwrap. + self.client + .forward_messages( + chat, + &[self.id()], + self.peer_ref().await?.ok_or(InvocationError::Dropped)?, + ) + .await + .map(|mut msgs| msgs.pop().unwrap().unwrap()) + } + + /// Edit this message to change its text or media. + /// + /// Shorthand for `Client::edit_message`. + pub async fn edit>(&self, new_message: M) -> Result<(), InvocationError> { + self.client + .edit_message( + self.peer_ref().await?.ok_or(InvocationError::Dropped)?, + self.id(), + new_message, + ) + .await + } + + /// Delete this message for everyone. + /// + /// Shorthand for `Client::delete_messages`. If you need to delete multiple messages + /// at once, consider using that method instead. + pub async fn delete(&self) -> Result<(), InvocationError> { + self.client + .delete_messages( + self.peer_ref().await?.ok_or(InvocationError::Dropped)?, + &[self.id()], + ) + .await + .map(drop) + } + + /// Mark this message and all messages above it as read. + /// + /// Unlike `Client::mark_as_read`, this method only will mark the conversation as read up to + /// this message, not the entire conversation. + pub async fn mark_as_read(&self) -> Result<(), InvocationError> { + let peer = self.peer_ref().await?.ok_or(InvocationError::Dropped)?; + if peer.id.kind() == PeerKind::Channel { + self.client + .invoke(&tl::functions::channels::ReadHistory { + channel: peer.into(), + max_id: self.id(), + }) + .await + .map(drop) + } else { + self.client + .invoke(&tl::functions::messages::ReadHistory { + peer: peer.into(), + max_id: self.id(), + }) + .await + .map(drop) + } + } + + /// Pin this message in the conversation. + /// + /// Shorthand for `Client::pin_message`. + pub async fn pin(&self) -> Result<(), InvocationError> { + self.client + .pin_message( + self.peer_ref().await?.ok_or(InvocationError::Dropped)?, + self.id(), + ) + .await + } + + /// Unpin this message from the conversation. + /// + /// Shorthand for `Client::unpin_message`. + pub async fn unpin(&self) -> Result<(), InvocationError> { + self.client + .unpin_message( + self.peer_ref().await?.ok_or(InvocationError::Dropped)?, + self.id(), + ) + .await + } + + /// Refetch this message, mutating all of its properties in-place. + /// + /// No changes will be made to the message if it fails to be fetched. + /// + /// Shorthand for `Client::get_messages_by_id`. + pub async fn refetch(&self) -> Result<(), InvocationError> { + // When fetching a single message, if it fails, Telegram should respond with RPC error. + // If it succeeds we will have the single message present which we can unwrap. + self.client + .get_messages_by_id( + self.peer_ref().await?.ok_or(InvocationError::Dropped)?, + &[self.id()], + ) + .await? + .pop() + .unwrap() + .unwrap(); + todo!("actually mutate self after get_messages_by_id returns `Message`") + } + + /// Download the message media in this message if applicable. + /// + /// Returns `true` if there was media to download, or `false` otherwise. + /// + /// Shorthand for `Client::download_media`. + #[cfg(feature = "fs")] + pub async fn download_media>(&self, path: P) -> Result { + // TODO probably encode failed download in error + if let Some(media) = self.media() { + self.client.download_media(&media, path).await.map(|_| true) + } else { + Ok(false) + } + } + + /// Get photo attached to the message if any. + pub fn photo(&self) -> Option { + if let Media::Photo(photo) = self.media()? { + return Some(photo); + } + + None + } +} + +impl fmt::Debug for Message { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Message") + .field("id", &self.id()) + .field("outgoing", &self.outgoing()) + .field("date", &self.date()) + .field("text", &self.text()) + .field("peer", &self.peer()) + .field("sender", &self.sender()) + .field("reply_to_message_id", &self.reply_to_message_id()) + .field("via_bot_id", &self.via_bot_id()) + .field("media", &self.media()) + .field("mentioned", &self.mentioned()) + .field("media_unread", &self.media_unread()) + .field("silent", &self.silent()) + .field("post", &self.post()) + .field("from_scheduled", &self.from_scheduled()) + .field("edit_hide", &self.edit_hide()) + .field("pinned", &self.pinned()) + .field("forward_header", &self.forward_header()) + .field("reply_header", &self.reply_header()) + .field("reply_markup", &self.reply_markup()) + .field("fmt_entities", &self.fmt_entities()) + .field("view_count", &self.view_count()) + .field("forward_count", &self.forward_count()) + .field("reply_count", &self.reply_count()) + .field("edit_date", &self.edit_date()) + .field("post_author", &self.post_author()) + .field("grouped_id", &self.grouped_id()) + .field("restriction_reason", &self.restriction_reason()) + .field("action", &self.action()) + .finish() + } +} diff --git a/vendor/grammers-client/src/message/mod.rs b/vendor/grammers-client/src/message/mod.rs new file mode 100644 index 0000000..0f866a6 --- /dev/null +++ b/vendor/grammers-client/src/message/mod.rs @@ -0,0 +1,24 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +//! Types relating to chat messages. +//! +//! Properties containing raw types are public and will either be called "raw" or prefixed with "raw_".\ +//! Keep in mind that **these fields are not part of the semantic versioning guarantees**. + +mod button; +mod input_message; +mod message; +mod reactions; +mod reply_markup; + +pub use button::{Button, Key}; +pub use input_message::InputMessage; +pub use message::Message; +pub use reactions::InputReactions; +pub use reply_markup::ReplyMarkup; diff --git a/vendor/grammers-client/src/message/reactions.rs b/vendor/grammers-client/src/message/reactions.rs new file mode 100755 index 0000000..3f1f4f5 --- /dev/null +++ b/vendor/grammers-client/src/message/reactions.rs @@ -0,0 +1,85 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +use grammers_tl_types::{self as tl, enums::Reaction}; + +/// Parameter for reacting to messages with emoji. +#[derive(Clone, Debug, Default)] +pub struct InputReactions { + pub(crate) reactions: Vec, + pub(crate) add_to_recent: bool, + pub(crate) big: bool, +} + +impl InputReactions { + /// Make reaction animation big. + pub fn big(mut self) -> Self { + self.big = true; + self + } + + /// Add this reaction to the recent reactions list. + /// + /// [Read more about recent reactions](https://core.telegram.org/api/reactions#recent-reactions). + pub fn add_to_recent(mut self) -> Self { + self.add_to_recent = true; + self + } + + /// Create new InputReactions with one emoticon reaction + pub fn emoticon>(emoticon: S) -> Self { + Self { + reactions: vec![Reaction::Emoji(tl::types::ReactionEmoji { + emoticon: emoticon.into(), + })], + ..Self::default() + } + } + + /// Create new InputReactions with one custom emoji reaction + pub fn custom_emoji(document_id: i64) -> Self { + Self { + reactions: vec![Reaction::CustomEmoji(tl::types::ReactionCustomEmoji { + document_id, + })], + ..Self::default() + } + } + + /// Create an empty InputReactions which will remove reactions + pub fn remove() -> Self { + Self::default() + } +} + +impl From for InputReactions { + fn from(val: String) -> Self { + InputReactions::emoticon(val) + } +} + +impl From<&str> for InputReactions { + fn from(val: &str) -> Self { + InputReactions::emoticon(val) + } +} + +impl From> for InputReactions { + fn from(reactions: Vec) -> Self { + Self { + reactions, + ..Self::default() + } + } +} + +impl From for Vec { + fn from(val: InputReactions) -> Self { + val.reactions + } +} diff --git a/vendor/grammers-client/src/message/reply_markup.rs b/vendor/grammers-client/src/message/reply_markup.rs new file mode 100755 index 0000000..bf91912 --- /dev/null +++ b/vendor/grammers-client/src/message/reply_markup.rs @@ -0,0 +1,291 @@ +// Copyright 2020 - developers of the `grammers` project. +// +// Licensed under the Apache License, Version 2.0 or the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +//! Contains functions to build reply markups. +//! +//! These can only be used by bot accounts when sending +//! messages through [`InputMessage::reply_markup`]. +//! +//! Each function returns a concrete builder-like type that +//! may be further configured via their inherent methods. +//! +//! The trait is used to group all types as "something that +//! may be used as a reply markup". +//! +//! [`InputMessage::reply_markup`]: crate::message::InputMessage::reply_markup + +use grammers_tl_types as tl; + +use super::{Button, Key}; + +const EMPTY_KEYBOARD_MARKUP: tl::types::ReplyKeyboardMarkup = tl::types::ReplyKeyboardMarkup { + resize: false, + single_use: false, + selective: false, + persistent: false, + rows: Vec::new(), + placeholder: None, +}; + +/// Markup to be used as the intended way to reply to the message it is attached to. +pub struct ReplyMarkup { + pub raw: tl::enums::ReplyMarkup, +} + +impl ReplyMarkup { + /// Define inline buttons for a message. + /// + /// These will display right under the message. + /// + /// You cannot add images to the buttons, but you can use emoji (simply copy-paste them into your + /// code, or use the correct escape sequence, or using any other input methods you like). + /// + /// You will need to provide a matrix of [`Button`], that is, a vector that contains the + /// rows from top to bottom, where the rows consist of a vector of buttons from left to right. + /// + /// # Examples + /// + /// ``` + /// # async fn f(client: &mut grammers_client::Client, peer: grammers_session::types::PeerRef) -> Result<(), Box> { + /// use grammers_client::message::{InputMessage, ReplyMarkup, Button}; + /// + /// let artist = "Krewella"; + /// let markup = ReplyMarkup::from_buttons(&[ + /// vec![Button::data(format!("Song by {}", artist), b"play")], + /// vec![Button::data("Previous", b"prev"), Button::data("Next", b"next")], + /// ]); + /// client.send_message(peer, InputMessage::new().text("Select song").reply_markup(markup)).await?; + /// # Ok(()) + /// # } + /// ``` + pub fn from_buttons(buttons: &[Vec