Compare commits
50 Commits
e09b83be69
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f65fe39ab | |||
|
|
d48a03f93d | ||
|
|
c12f9f9b78 | ||
|
|
5a32ee0a4c | ||
|
|
782f08e00e | ||
|
|
ec74961677 | ||
|
|
508db79c34 | ||
|
|
da41e1ed91 | ||
|
|
419f409d98 | ||
|
|
a0413f23b3 | ||
|
|
6062c1b503 | ||
|
|
217328505c | ||
|
|
aec3678bd6 | ||
|
|
75cd319f53 | ||
|
|
f7abd1dba0 | ||
|
|
928a5aeda2 | ||
|
|
b3b02835b6 | ||
|
|
3e67e0d1b8 | ||
|
|
892582df67 | ||
|
|
4fd2a18ed9 | ||
|
|
161cc343da | ||
|
|
9b4e277ce0 | ||
|
|
5ac63b84fb | ||
|
|
c83d2a1354 | ||
|
|
7bde72f715 | ||
|
|
f6b4b34ed4 | ||
|
|
99ae5106ae | ||
|
|
10f4c3a84b | ||
|
|
6576a37198 | ||
|
|
e5d0f2c064 | ||
|
|
59050d0b5f | ||
|
|
8bea159569 | ||
|
|
593b19ba8e | ||
|
|
d68d68aeda | ||
|
|
0878ba78df | ||
|
|
186f0edbb3 | ||
|
|
eefac431e5 | ||
| 91a8700b8e | |||
|
|
913055dd96 | ||
|
|
ceca8ab67e | ||
| d1d90ed3e2 | |||
| 348cc6598c | |||
|
|
51e9cf5c10 | ||
|
|
2d4c1906d5 | ||
|
|
91e4f118f3 | ||
|
|
679892beca | ||
|
|
6b27cbece9 | ||
|
|
d8af6a76a1 | ||
|
|
2e510dc932 | ||
| 5448d19b1a |
44
.github/workflows/ios-rust.yml
vendored
Normal file
44
.github/workflows/ios-rust.yml
vendored
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
name: iOS and Rust
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
rust:
|
||||||
|
runs-on: macos-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- name: Format
|
||||||
|
run: cargo fmt -- --check
|
||||||
|
- name: Core check
|
||||||
|
run: cargo check -p tele-core
|
||||||
|
- name: Workspace clippy
|
||||||
|
run: cargo clippy --workspace --all-targets --all-features -- -D warnings
|
||||||
|
- name: Workspace tests
|
||||||
|
run: cargo test --workspace --all-features
|
||||||
|
- name: Fake iOS FFI tests
|
||||||
|
run: cargo test -p tele-ios-ffi --no-default-features --features standalone-fake
|
||||||
|
- name: Swift FFI smoke
|
||||||
|
run: scripts/smoke-ios-ffi-swift.sh /tmp/tele-ios-ffi-swift-smoke
|
||||||
|
- name: Swift app UniFFI bridge typecheck
|
||||||
|
run: scripts/typecheck-ios-uniffi-app-bridge.sh /tmp/tele-ios-ffi-swift-smoke /tmp/tele-ios-ffi-app-typecheck-module-cache
|
||||||
|
- name: Generate iOS FFI bindings
|
||||||
|
run: scripts/generate-ios-ffi-bindings.sh /tmp/tele-ios-ffi
|
||||||
|
- name: Swift bindings typecheck
|
||||||
|
run: swiftc -typecheck -I /tmp/tele-ios-ffi/Headers /tmp/tele-ios-ffi/Swift/tele_ios_ffi.swift
|
||||||
|
- name: Build fake iOS FFI XCFramework
|
||||||
|
run: scripts/build-ios-fake-ffi-xcframework.sh /tmp/tele-ios-fake-ffi-xcframework
|
||||||
|
|
||||||
|
ios-shell:
|
||||||
|
runs-on: macos-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Build SwiftUI app shell
|
||||||
|
working-directory: apps/ios/TeleTuiIOS
|
||||||
|
run: swift build --product TeleTuiIOSApp
|
||||||
|
- name: Run SwiftUI smoke tests
|
||||||
|
working-directory: apps/ios/TeleTuiIOS
|
||||||
|
run: swift run TeleTuiIOSSmokeTests
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,4 +1,8 @@
|
|||||||
/target
|
/target
|
||||||
|
/.build
|
||||||
|
/build
|
||||||
|
/apps/ios/TeleTuiIOS/BinaryArtifacts
|
||||||
|
/apps/ios/TeleTuiIOS/Generated
|
||||||
|
|
||||||
# TDLib session data (contains auth tokens - NEVER commit!)
|
# TDLib session data (contains auth tokens - NEVER commit!)
|
||||||
/tdlib_data/
|
/tdlib_data/
|
||||||
@@ -15,3 +19,4 @@ credentials
|
|||||||
# Commit snapshots, but not the .new files
|
# Commit snapshots, but not the .new files
|
||||||
tests/**/*.snap.new
|
tests/**/*.snap.new
|
||||||
*.snap.new
|
*.snap.new
|
||||||
|
apps/ios/TeleTuiIOS/.build/
|
||||||
|
|||||||
@@ -8,6 +8,14 @@ steps:
|
|||||||
- rustup component add rustfmt
|
- rustup component add rustfmt
|
||||||
- cargo fmt -- --check
|
- cargo fmt -- --check
|
||||||
|
|
||||||
|
- name: check
|
||||||
|
image: rust:latest
|
||||||
|
environment:
|
||||||
|
CARGO_HOME: /tmp/cargo
|
||||||
|
commands:
|
||||||
|
- apt-get update -qq && apt-get install -y -qq pkg-config libssl-dev libdbus-1-dev zlib1g-dev > /dev/null 2>&1
|
||||||
|
- cargo check --all-targets --all-features
|
||||||
|
|
||||||
- name: clippy
|
- name: clippy
|
||||||
image: rust:latest
|
image: rust:latest
|
||||||
environment:
|
environment:
|
||||||
@@ -15,7 +23,7 @@ steps:
|
|||||||
commands:
|
commands:
|
||||||
- apt-get update -qq && apt-get install -y -qq pkg-config libssl-dev libdbus-1-dev zlib1g-dev > /dev/null 2>&1
|
- apt-get update -qq && apt-get install -y -qq pkg-config libssl-dev libdbus-1-dev zlib1g-dev > /dev/null 2>&1
|
||||||
- rustup component add clippy
|
- rustup component add clippy
|
||||||
- cargo clippy -- -D warnings
|
- cargo clippy --all-targets --all-features -- -D warnings
|
||||||
|
|
||||||
- name: test
|
- name: test
|
||||||
image: rust:latest
|
image: rust:latest
|
||||||
@@ -23,4 +31,4 @@ steps:
|
|||||||
CARGO_HOME: /tmp/cargo
|
CARGO_HOME: /tmp/cargo
|
||||||
commands:
|
commands:
|
||||||
- apt-get update -qq && apt-get install -y -qq pkg-config libssl-dev libdbus-1-dev zlib1g-dev > /dev/null 2>&1
|
- apt-get update -qq && apt-get install -y -qq pkg-config libssl-dev libdbus-1-dev zlib1g-dev > /dev/null 2>&1
|
||||||
- cargo test
|
- cargo test --all-features
|
||||||
|
|||||||
1
AGENT.md
1
AGENT.md
@@ -13,5 +13,6 @@
|
|||||||
|
|
||||||
- Не запускай `cargo run`, `cargo build`, `cargo test`, `cargo check` без прямой команды пользователя.
|
- Не запускай `cargo run`, `cargo build`, `cargo test`, `cargo check` без прямой команды пользователя.
|
||||||
- Не коммить изменения, пока пользователь не попросит.
|
- Не коммить изменения, пока пользователь не попросит.
|
||||||
|
- Если пользователь попросил тесты/коммит/план до конца, используй quality gate из [DEVELOPMENT.md](DEVELOPMENT.md).
|
||||||
- После функциональной правки дай короткий ручной сценарий проверки.
|
- После функциональной правки дай короткий ручной сценарий проверки.
|
||||||
- Обновляй [CONTEXT.md](CONTEXT.md), только если изменились статус, риск, архитектурное решение или следующий шаг.
|
- Обновляй [CONTEXT.md](CONTEXT.md), только если изменились статус, риск, архитектурное решение или следующий шаг.
|
||||||
|
|||||||
@@ -20,6 +20,8 @@
|
|||||||
- Keybindings стали детерминированными; русская vim-раскладка: `h/j/k/l` -> `р/о/л/д`.
|
- Keybindings стали детерминированными; русская vim-раскладка: `h/j/k/l` -> `р/о/л/д`.
|
||||||
- `AudioPlayer` проверяет наличие `ffplay`.
|
- `AudioPlayer` проверяет наличие `ffplay`.
|
||||||
- `message_grouping` группирует альбомы без клонирования сообщений.
|
- `message_grouping` группирует альбомы без клонирования сообщений.
|
||||||
|
- TDLib facade split на scoped traits; generic код больше не получает raw `*_mut` доступ к сообщениям.
|
||||||
|
- Локальный `build.rs` удалён: линковкой TDLib управляет зависимость `tdlib-rs`, `cargo check --all-targets --all-features` снова воспроизводим.
|
||||||
|
|
||||||
## Осталось
|
## Осталось
|
||||||
|
|
||||||
@@ -40,6 +42,8 @@
|
|||||||
## Ключевые решения
|
## Ключевые решения
|
||||||
|
|
||||||
- Главный state хранится в `App<T: TdClientTrait>`, чтобы тесты могли использовать `FakeTdClient`.
|
- Главный state хранится в `App<T: TdClientTrait>`, чтобы тесты могли использовать `FakeTdClient`.
|
||||||
|
- `TdClientTrait` теперь facade поверх scoped traits; чтение текущих сообщений идёт через `Cow`, mutation - через явные update-операции.
|
||||||
|
- Пользовательская timezone не хранится в config: runtime использует системную timezone, тесты форматирования используют deterministic time source.
|
||||||
- Методы `App` разбиты на traits: navigation, messages, compose, search, modal.
|
- Методы `App` разбиты на traits: navigation, messages, compose, search, modal.
|
||||||
- UI рендерится только при `needs_redraw`; текстовый интерфейс целится в 60 FPS.
|
- UI рендерится только при `needs_redraw`; текстовый интерфейс целится в 60 FPS.
|
||||||
- Фото под feature `images`: inline Halfblocks + modal iTerm2/Sixel.
|
- Фото под feature `images`: inline Halfblocks + modal iTerm2/Sixel.
|
||||||
|
|||||||
1089
Cargo.lock
generated
1089
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
70
Cargo.toml
70
Cargo.toml
@@ -1,60 +1,12 @@
|
|||||||
[package]
|
[workspace]
|
||||||
name = "tele-tui"
|
members = [
|
||||||
version = "0.1.0"
|
"crates/tele-core",
|
||||||
edition = "2021"
|
"crates/tele-ios-ffi",
|
||||||
authors = ["Your Name <your.email@example.com>"]
|
"crates/tele-tui",
|
||||||
description = "Terminal UI for Telegram with Vim-style navigation"
|
"tools/uniffi-bindgen-swift",
|
||||||
license = "MIT"
|
]
|
||||||
repository = "https://github.com/your-username/tele-tui"
|
default-members = ["crates/tele-tui"]
|
||||||
keywords = ["telegram", "tui", "terminal", "cli"]
|
resolver = "2"
|
||||||
categories = ["command-line-utilities"]
|
|
||||||
|
|
||||||
[features]
|
[patch.crates-io]
|
||||||
default = ["clipboard", "url-open", "notifications", "images"]
|
tdlib-rs = { path = "crates/vendor/tdlib-rs" }
|
||||||
clipboard = ["dep:arboard"]
|
|
||||||
url-open = ["dep:open"]
|
|
||||||
notifications = ["dep:notify-rust"]
|
|
||||||
images = ["dep:ratatui-image", "dep:image"]
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
ratatui = "0.29"
|
|
||||||
crossterm = "0.28"
|
|
||||||
tdlib-rs = { version = "1.2.0", features = ["download-tdlib"] }
|
|
||||||
tokio = { version = "1", features = ["full"] }
|
|
||||||
async-trait = "0.1"
|
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
|
||||||
serde_json = "1.0"
|
|
||||||
dotenvy = "0.15"
|
|
||||||
chrono = "0.4"
|
|
||||||
open = { version = "5.0", optional = true }
|
|
||||||
arboard = { version = "3.4", optional = true }
|
|
||||||
notify-rust = { version = "4.11", optional = true }
|
|
||||||
ratatui-image = { version = "8.1", optional = true, features = ["image-defaults"] }
|
|
||||||
image = { version = "0.25", optional = true }
|
|
||||||
toml = "0.8"
|
|
||||||
dirs = "5.0"
|
|
||||||
thiserror = "1.0"
|
|
||||||
tracing = "0.1"
|
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
|
||||||
base64 = "0.22.1"
|
|
||||||
fs2 = "0.4"
|
|
||||||
|
|
||||||
[dev-dependencies]
|
|
||||||
insta = "1.34"
|
|
||||||
tokio-test = "0.4"
|
|
||||||
criterion = "0.5"
|
|
||||||
|
|
||||||
[build-dependencies]
|
|
||||||
tdlib-rs = { version = "1.2.0", features = ["download-tdlib"] }
|
|
||||||
|
|
||||||
[[bench]]
|
|
||||||
name = "group_messages"
|
|
||||||
harness = false
|
|
||||||
|
|
||||||
[[bench]]
|
|
||||||
name = "formatting"
|
|
||||||
harness = false
|
|
||||||
|
|
||||||
[[bench]]
|
|
||||||
name = "format_markdown"
|
|
||||||
harness = false
|
|
||||||
|
|||||||
@@ -21,6 +21,22 @@ cargo check
|
|||||||
|
|
||||||
В финальном ответе после изменения укажи, какие cargo-команды не запускались, и дай минимальную ручную проверку.
|
В финальном ответе после изменения укажи, какие cargo-команды не запускались, и дай минимальную ручную проверку.
|
||||||
|
|
||||||
|
## Quality Gate
|
||||||
|
|
||||||
|
Если пользователь прямо попросил проверить, закоммитить или выполнить план с тестами, используй тот же набор проверок, что и CI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt -- --check
|
||||||
|
cargo check -p tele-core
|
||||||
|
cargo test -p tele-core
|
||||||
|
cargo check -p tele-tui --all-targets --all-features
|
||||||
|
cargo clippy --workspace --all-targets --all-features -- -D warnings
|
||||||
|
cargo test --workspace --all-features
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
Перед коммитом не оставляй `*.snap.new` файлы.
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
- Делай одну логическую правку за раз.
|
- Делай одну логическую правку за раз.
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
- Для голосовых сообщений нужен `ffplay` из ffmpeg.
|
- Для голосовых сообщений нужен `ffplay` из ffmpeg.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo build --release
|
cargo build -p tele-tui --release
|
||||||
```
|
```
|
||||||
|
|
||||||
## Credentials
|
## Credentials
|
||||||
@@ -54,6 +54,10 @@ cargo run --release
|
|||||||
cargo run --release -- --account work
|
cargo run --release -- --account work
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`Cargo.toml` в корне - workspace manifest. По умолчанию `cargo run` и `cargo test`
|
||||||
|
работают с `crates/tele-tui`; переиспользуемая TDLib-логика лежит в
|
||||||
|
`crates/tele-core`.
|
||||||
|
|
||||||
Runtime-конфиг создаётся в `~/.config/tele-tui/config.toml`; пример лежит в [config.toml.example](config.toml.example).
|
Runtime-конфиг создаётся в `~/.config/tele-tui/config.toml`; пример лежит в [config.toml.example](config.toml.example).
|
||||||
|
|
||||||
## Документация
|
## Документация
|
||||||
@@ -64,6 +68,7 @@ Runtime-конфиг создаётся в `~/.config/tele-tui/config.toml`; п
|
|||||||
- [docs/HOTKEYS.md](docs/HOTKEYS.md) - горячие клавиши.
|
- [docs/HOTKEYS.md](docs/HOTKEYS.md) - горячие клавиши.
|
||||||
- [docs/PROJECT_STRUCTURE.md](docs/PROJECT_STRUCTURE.md) - карта подсистем.
|
- [docs/PROJECT_STRUCTURE.md](docs/PROJECT_STRUCTURE.md) - карта подсистем.
|
||||||
- [docs/TDLIB_INTEGRATION.md](docs/TDLIB_INTEGRATION.md) - проектные заметки по TDLib.
|
- [docs/TDLIB_INTEGRATION.md](docs/TDLIB_INTEGRATION.md) - проектные заметки по TDLib.
|
||||||
|
- [docs/IOS_CORE_REUSE.md](docs/IOS_CORE_REUSE.md) - граница `tele-core` для будущего iOS-клиента.
|
||||||
|
|
||||||
## Лицензия
|
## Лицензия
|
||||||
|
|
||||||
|
|||||||
56
apps/ios/TeleTuiIOS/Package.swift
Normal file
56
apps/ios/TeleTuiIOS/Package.swift
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
// swift-tools-version: 6.0
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let useLocalFfi = ProcessInfo.processInfo.environment["TELE_IOS_USE_LOCAL_FFI"] == "1"
|
||||||
|
let localFfiTargets: [Target] = useLocalFfi ? [
|
||||||
|
.binaryTarget(
|
||||||
|
name: "tele_ios_ffiFFI",
|
||||||
|
path: "BinaryArtifacts/tele_ios_ffi.xcframework"
|
||||||
|
),
|
||||||
|
.binaryTarget(
|
||||||
|
name: "tdjson",
|
||||||
|
path: "BinaryArtifacts/tdjson.xcframework"
|
||||||
|
),
|
||||||
|
.target(
|
||||||
|
name: "tele_ios_ffi",
|
||||||
|
dependencies: ["tele_ios_ffiFFI", "tdjson"],
|
||||||
|
path: "Generated/tele_ios_ffi/Sources/tele_ios_ffi"
|
||||||
|
),
|
||||||
|
] : []
|
||||||
|
|
||||||
|
let coreDependencies: [Target.Dependency] = useLocalFfi ? [
|
||||||
|
"tele_ios_ffi",
|
||||||
|
] : []
|
||||||
|
let coreSwiftSettings: [SwiftSetting] = useLocalFfi ? [
|
||||||
|
.define("TELE_IOS_USE_LOCAL_FFI"),
|
||||||
|
] : []
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "TeleTuiIOS",
|
||||||
|
platforms: [
|
||||||
|
.iOS(.v17),
|
||||||
|
.macOS(.v14),
|
||||||
|
],
|
||||||
|
products: [
|
||||||
|
.library(name: "TeleTuiIOSCore", targets: ["TeleTuiIOSCore"]),
|
||||||
|
.executable(name: "TeleTuiIOSApp", targets: ["TeleTuiIOSApp"]),
|
||||||
|
.executable(name: "TeleTuiIOSSmokeTests", targets: ["TeleTuiIOSSmokeTests"]),
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
.target(
|
||||||
|
name: "TeleTuiIOSCore",
|
||||||
|
dependencies: coreDependencies,
|
||||||
|
swiftSettings: coreSwiftSettings
|
||||||
|
),
|
||||||
|
.executableTarget(
|
||||||
|
name: "TeleTuiIOSApp",
|
||||||
|
dependencies: ["TeleTuiIOSCore"]
|
||||||
|
),
|
||||||
|
.executableTarget(
|
||||||
|
name: "TeleTuiIOSSmokeTests",
|
||||||
|
dependencies: ["TeleTuiIOSCore"]
|
||||||
|
),
|
||||||
|
] + localFfiTargets
|
||||||
|
)
|
||||||
47
apps/ios/TeleTuiIOS/README.md
Normal file
47
apps/ios/TeleTuiIOS/README.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# TeleTuiIOS
|
||||||
|
|
||||||
|
Native SwiftUI shell for the iOS client.
|
||||||
|
|
||||||
|
Current scope:
|
||||||
|
|
||||||
|
- SwiftUI + MVVM app shell backed by a deterministic fake bridge.
|
||||||
|
- Auth, chat list, folder selector, chat detail, compose bar, profile sheet, and account switcher shell.
|
||||||
|
- iOS-oriented storage boundaries: Keychain-shaped credential API and Application Support account paths.
|
||||||
|
|
||||||
|
Build and smoke-test the portable shell:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/ios/TeleTuiIOS
|
||||||
|
swift run TeleTuiIOSSmokeTests
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify local iOS tooling:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer scripts/check-ios-prereqs.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Build the SwiftUI shell for iOS Simulator and package it as an installable `.app`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer scripts/build-ios-simulator-app.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Launch the fake-backed app in the first available iPhone simulator:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer scripts/run-ios-simulator-app.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the simulator launch plus screenshot sanity check:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer scripts/smoke-ios-simulator-ui.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Build the app against the local real Rust/TDLib FFI artifacts:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer scripts/build-ios-real-ffi-xcframework.sh
|
||||||
|
TELE_IOS_USE_LOCAL_FFI=1 DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer scripts/build-ios-simulator-app.sh
|
||||||
|
```
|
||||||
12
apps/ios/TeleTuiIOS/Resources/PrivacyInfo.xcprivacy
Normal file
12
apps/ios/TeleTuiIOS/Resources/PrivacyInfo.xcprivacy
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyCollectedDataTypes</key>
|
||||||
|
<array/>
|
||||||
|
<key>NSPrivacyAccessedAPITypes</key>
|
||||||
|
<array/>
|
||||||
|
<key>NSPrivacyTracking</key>
|
||||||
|
<false/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import TeleTuiIOSCore
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct TeleTuiIOSApp: App {
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
RootView(store: makeStore())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeStore() -> SessionStore {
|
||||||
|
let paths = AppStoragePaths()
|
||||||
|
let account = Account(
|
||||||
|
id: "fake",
|
||||||
|
displayName: "Fake",
|
||||||
|
databasePath: paths.databasePath(for: "fake")
|
||||||
|
)
|
||||||
|
return SessionStore(
|
||||||
|
account: account,
|
||||||
|
bridge: SessionBridgeFactory.makeDefaultBridge(account: account)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
266
apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Bridge.swift
Normal file
266
apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Bridge.swift
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public protocol SessionBridge: Sendable {
|
||||||
|
func authState() async throws -> AuthState
|
||||||
|
func networkState() async throws -> NetworkState
|
||||||
|
func pollEvents() async throws -> [SessionEvent]
|
||||||
|
func sendPhoneNumber(_ phone: String) async throws
|
||||||
|
func sendCode(_ code: String) async throws
|
||||||
|
func sendPassword(_ password: String) async throws
|
||||||
|
func loadFolders() async throws -> [Folder]
|
||||||
|
func loadChats(folderId: Int32?) async throws -> [ChatSummary]
|
||||||
|
func loadHistory(chatId: Int64) async throws -> [Message]
|
||||||
|
func searchMessages(chatId: Int64, query: String) async throws -> [Message]
|
||||||
|
func openProfile(chatId: Int64) async throws -> Profile
|
||||||
|
func leaveChat(chatId: Int64) async throws
|
||||||
|
func sendMessage(chatId: Int64, text: String, replyToMessageId: Int64?) async throws -> Message
|
||||||
|
func editMessage(chatId: Int64, messageId: Int64, text: String) async throws -> Message
|
||||||
|
func deleteMessages(chatId: Int64, messageIds: [Int64]) async throws
|
||||||
|
func forwardMessages(toChatId: Int64, fromChatId: Int64, messageIds: [Int64]) async throws
|
||||||
|
func react(chatId: Int64, messageId: Int64, reaction: String) async throws -> [Reaction]
|
||||||
|
func pinnedMessages(chatId: Int64) async throws -> [Message]
|
||||||
|
func copyPayload(chatId: Int64, messageId: Int64) async throws -> String
|
||||||
|
func setDraft(chatId: Int64, text: String) async throws
|
||||||
|
func downloadPhoto(fileId: Int32) async throws -> DownloadedFile
|
||||||
|
func downloadVoice(fileId: Int32) async throws -> DownloadedFile
|
||||||
|
}
|
||||||
|
|
||||||
|
public actor FakeSessionBridge: SessionBridge {
|
||||||
|
private var auth: AuthState
|
||||||
|
private var chats: [ChatSummary]
|
||||||
|
private var messages: [Int64: [Message]]
|
||||||
|
private var events: [SessionEvent]
|
||||||
|
private var nextMessageId: Int64
|
||||||
|
private static let baseMessageDate: Int32 = 1_700_000_000
|
||||||
|
|
||||||
|
public init(auth: AuthState = .waitPhoneNumber) {
|
||||||
|
self.auth = auth
|
||||||
|
let saved = ChatSummary(
|
||||||
|
id: 1,
|
||||||
|
title: "Saved Messages",
|
||||||
|
username: "saved",
|
||||||
|
lastMessage: "Hello from fake TDLib",
|
||||||
|
unreadCount: 1,
|
||||||
|
isPinned: true
|
||||||
|
)
|
||||||
|
let team = ChatSummary(
|
||||||
|
id: 2,
|
||||||
|
title: "iOS Team",
|
||||||
|
lastMessage: "Bridge smoke is green",
|
||||||
|
unreadMentionCount: 1,
|
||||||
|
folderIds: [0, 2],
|
||||||
|
isMuted: true,
|
||||||
|
draft: Draft(chatId: 2, text: "Follow up")
|
||||||
|
)
|
||||||
|
self.chats = [saved, team]
|
||||||
|
self.messages = [
|
||||||
|
1: [
|
||||||
|
Message(
|
||||||
|
id: 1,
|
||||||
|
chatId: 1,
|
||||||
|
senderName: "Alice",
|
||||||
|
text: "Hello from fake TDLib",
|
||||||
|
date: Self.baseMessageDate,
|
||||||
|
media: .photo(PhotoMedia(fileId: 100, width: 1280, height: 720)),
|
||||||
|
isOutgoing: false,
|
||||||
|
isRead: false
|
||||||
|
)
|
||||||
|
],
|
||||||
|
2: [
|
||||||
|
Message(
|
||||||
|
id: 2,
|
||||||
|
chatId: 2,
|
||||||
|
senderName: "Mikhail",
|
||||||
|
text: "Bridge smoke is green",
|
||||||
|
date: Self.baseMessageDate + 60,
|
||||||
|
media: .voice(VoiceMedia(fileId: 200, duration: 12, mimeType: "audio/ogg")),
|
||||||
|
isOutgoing: true
|
||||||
|
)
|
||||||
|
],
|
||||||
|
]
|
||||||
|
self.events = [.chatListChanged([saved, team])]
|
||||||
|
self.nextMessageId = 3
|
||||||
|
}
|
||||||
|
|
||||||
|
public func authState() async throws -> AuthState {
|
||||||
|
auth
|
||||||
|
}
|
||||||
|
|
||||||
|
public func networkState() async throws -> NetworkState {
|
||||||
|
.ready
|
||||||
|
}
|
||||||
|
|
||||||
|
public func pollEvents() async throws -> [SessionEvent] {
|
||||||
|
let drained = events
|
||||||
|
events.removeAll()
|
||||||
|
return drained
|
||||||
|
}
|
||||||
|
|
||||||
|
public func sendPhoneNumber(_ phone: String) async throws {
|
||||||
|
auth = .waitCode
|
||||||
|
events.append(.authChanged(auth))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func sendCode(_ code: String) async throws {
|
||||||
|
auth = .waitPassword
|
||||||
|
events.append(.authChanged(auth))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func sendPassword(_ password: String) async throws {
|
||||||
|
auth = .ready
|
||||||
|
events.append(.authChanged(auth))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func loadFolders() async throws -> [Folder] {
|
||||||
|
[Folder(id: 0, name: "All"), Folder(id: 2, name: "Work")]
|
||||||
|
}
|
||||||
|
|
||||||
|
public func loadChats(folderId: Int32?) async throws -> [ChatSummary] {
|
||||||
|
let result = folderId.map { folderId in
|
||||||
|
chats.filter { $0.folderIds.contains(folderId) }
|
||||||
|
} ?? chats
|
||||||
|
events.append(.chatListChanged(result))
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
public func loadHistory(chatId: Int64) async throws -> [Message] {
|
||||||
|
messages[chatId] ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
public func searchMessages(chatId: Int64, query: String) async throws -> [Message] {
|
||||||
|
guard !query.isEmpty else {
|
||||||
|
return messages[chatId] ?? []
|
||||||
|
}
|
||||||
|
return (messages[chatId] ?? []).filter {
|
||||||
|
$0.text.localizedCaseInsensitiveContains(query)
|
||||||
|
|| $0.senderName.localizedCaseInsensitiveContains(query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func openProfile(chatId: Int64) async throws -> Profile {
|
||||||
|
let chat = chats.first { $0.id == chatId }
|
||||||
|
let profile = Profile(
|
||||||
|
chatId: chatId,
|
||||||
|
title: chat?.title ?? "Unknown",
|
||||||
|
username: chat?.username,
|
||||||
|
bio: chatId == 1 ? "Fake profile for the iOS app shell" : "Team chat",
|
||||||
|
isGroup: chatId != 1,
|
||||||
|
memberCount: chatId == 1 ? nil : 4
|
||||||
|
)
|
||||||
|
events.append(.profileLoaded(profile))
|
||||||
|
return profile
|
||||||
|
}
|
||||||
|
|
||||||
|
public func leaveChat(chatId: Int64) async throws {
|
||||||
|
chats.removeAll { $0.id == chatId }
|
||||||
|
messages.removeValue(forKey: chatId)
|
||||||
|
events.append(.chatListChanged(chats))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func sendMessage(chatId: Int64, text: String, replyToMessageId: Int64?) async throws -> Message {
|
||||||
|
let message = Message(
|
||||||
|
id: nextMessageId,
|
||||||
|
chatId: chatId,
|
||||||
|
senderName: "Me",
|
||||||
|
text: text,
|
||||||
|
date: Int32(Date().timeIntervalSince1970),
|
||||||
|
isOutgoing: true,
|
||||||
|
replyText: replyToMessageId.map { "Reply to #\($0)" }
|
||||||
|
)
|
||||||
|
nextMessageId += 1
|
||||||
|
messages[chatId, default: []].append(message)
|
||||||
|
if let index = chats.firstIndex(where: { $0.id == chatId }) {
|
||||||
|
chats[index].lastMessage = text
|
||||||
|
chats[index].draft = nil
|
||||||
|
}
|
||||||
|
events.append(.messageAdded(chatId, message))
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
|
||||||
|
public func editMessage(chatId: Int64, messageId: Int64, text: String) async throws -> Message {
|
||||||
|
guard var chatMessages = messages[chatId],
|
||||||
|
let index = chatMessages.firstIndex(where: { $0.id == messageId })
|
||||||
|
else {
|
||||||
|
throw FakeBridgeError.messageNotFound
|
||||||
|
}
|
||||||
|
chatMessages[index].text = text
|
||||||
|
chatMessages[index].editDate = Int32(Date().timeIntervalSince1970)
|
||||||
|
messages[chatId] = chatMessages
|
||||||
|
return chatMessages[index]
|
||||||
|
}
|
||||||
|
|
||||||
|
public func deleteMessages(chatId: Int64, messageIds: [Int64]) async throws {
|
||||||
|
messages[chatId]?.removeAll { messageIds.contains($0.id) }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func forwardMessages(toChatId: Int64, fromChatId: Int64, messageIds: [Int64]) async throws {
|
||||||
|
let sourceMessages = (messages[fromChatId] ?? []).filter { messageIds.contains($0.id) }
|
||||||
|
for source in sourceMessages {
|
||||||
|
let forwarded = Message(
|
||||||
|
id: nextMessageId,
|
||||||
|
chatId: toChatId,
|
||||||
|
senderName: "Me",
|
||||||
|
text: source.text,
|
||||||
|
date: Int32(Date().timeIntervalSince1970),
|
||||||
|
isOutgoing: true,
|
||||||
|
forwardSenderName: source.senderName
|
||||||
|
)
|
||||||
|
nextMessageId += 1
|
||||||
|
messages[toChatId, default: []].append(forwarded)
|
||||||
|
events.append(.messageAdded(toChatId, forwarded))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func react(chatId: Int64, messageId: Int64, reaction: String) async throws -> [Reaction] {
|
||||||
|
guard var chatMessages = messages[chatId],
|
||||||
|
let index = chatMessages.firstIndex(where: { $0.id == messageId })
|
||||||
|
else {
|
||||||
|
throw FakeBridgeError.messageNotFound
|
||||||
|
}
|
||||||
|
if let reactionIndex = chatMessages[index].reactions.firstIndex(where: { $0.emoji == reaction }) {
|
||||||
|
chatMessages[index].reactions.remove(at: reactionIndex)
|
||||||
|
} else {
|
||||||
|
chatMessages[index].reactions.append(Reaction(emoji: reaction, count: 1, isChosen: true))
|
||||||
|
}
|
||||||
|
messages[chatId] = chatMessages
|
||||||
|
return chatMessages[index].reactions
|
||||||
|
}
|
||||||
|
|
||||||
|
public func pinnedMessages(chatId: Int64) async throws -> [Message] {
|
||||||
|
Array((messages[chatId] ?? []).prefix(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func copyPayload(chatId: Int64, messageId: Int64) async throws -> String {
|
||||||
|
guard let message = messages[chatId]?.first(where: { $0.id == messageId }) else {
|
||||||
|
throw FakeBridgeError.messageNotFound
|
||||||
|
}
|
||||||
|
return message.text
|
||||||
|
}
|
||||||
|
|
||||||
|
public func setDraft(chatId: Int64, text: String) async throws {
|
||||||
|
let draft = Draft(chatId: chatId, text: text)
|
||||||
|
if let index = chats.firstIndex(where: { $0.id == chatId }) {
|
||||||
|
chats[index].draft = text.isEmpty ? nil : draft
|
||||||
|
}
|
||||||
|
events.append(.draftChanged(draft))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func downloadPhoto(fileId: Int32) async throws -> DownloadedFile {
|
||||||
|
DownloadedFile(fileId: fileId, path: "/tmp/fake-photo-\(fileId).jpg")
|
||||||
|
}
|
||||||
|
|
||||||
|
public func downloadVoice(fileId: Int32) async throws -> DownloadedFile {
|
||||||
|
DownloadedFile(fileId: fileId, path: "/tmp/fake-voice-\(fileId).ogg")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum FakeBridgeError: LocalizedError {
|
||||||
|
case messageNotFound
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .messageNotFound:
|
||||||
|
"Message not found"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
53
apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Lifecycle.swift
Normal file
53
apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Lifecycle.swift
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum AppLifecycleState: Equatable, Sendable {
|
||||||
|
case foreground
|
||||||
|
case background
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ScopedSessionEvent: Equatable, Sendable {
|
||||||
|
public var accountId: String
|
||||||
|
public var generation: Int
|
||||||
|
public var event: SessionEvent
|
||||||
|
|
||||||
|
public init(accountId: String, generation: Int, event: SessionEvent) {
|
||||||
|
self.accountId = accountId
|
||||||
|
self.generation = generation
|
||||||
|
self.event = event
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public final class SessionLifecycleCoordinator: ObservableObject {
|
||||||
|
@Published public private(set) var lifecycleState: AppLifecycleState = .foreground
|
||||||
|
@Published public private(set) var activeAccountId: String
|
||||||
|
@Published public private(set) var generation = 0
|
||||||
|
|
||||||
|
public init(activeAccountId: String) {
|
||||||
|
self.activeAccountId = activeAccountId
|
||||||
|
}
|
||||||
|
|
||||||
|
public var shouldPollEvents: Bool {
|
||||||
|
lifecycleState == .foreground
|
||||||
|
}
|
||||||
|
|
||||||
|
public func enterBackground() {
|
||||||
|
lifecycleState = .background
|
||||||
|
}
|
||||||
|
|
||||||
|
public func enterForeground() {
|
||||||
|
lifecycleState = .foreground
|
||||||
|
}
|
||||||
|
|
||||||
|
public func switchAccount(to accountId: String) {
|
||||||
|
guard accountId != activeAccountId else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
activeAccountId = accountId
|
||||||
|
generation += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
public func accepts(_ event: ScopedSessionEvent) -> Bool {
|
||||||
|
event.accountId == activeAccountId && event.generation == generation
|
||||||
|
}
|
||||||
|
}
|
||||||
255
apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Models.swift
Normal file
255
apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Models.swift
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct Account: Identifiable, Equatable, Sendable {
|
||||||
|
public var id: String
|
||||||
|
public var displayName: String
|
||||||
|
public var databasePath: URL
|
||||||
|
|
||||||
|
public init(id: String, displayName: String, databasePath: URL) {
|
||||||
|
self.id = id
|
||||||
|
self.displayName = displayName
|
||||||
|
self.databasePath = databasePath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum AuthState: Equatable, Sendable {
|
||||||
|
case waitTdlibParameters
|
||||||
|
case waitPhoneNumber
|
||||||
|
case waitCode
|
||||||
|
case waitPassword
|
||||||
|
case ready
|
||||||
|
case closed
|
||||||
|
case error(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct Folder: Identifiable, Equatable, Sendable {
|
||||||
|
public var id: Int32
|
||||||
|
public var name: String
|
||||||
|
|
||||||
|
public init(id: Int32, name: String) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct Draft: Hashable, Sendable {
|
||||||
|
public var chatId: Int64
|
||||||
|
public var text: String
|
||||||
|
|
||||||
|
public init(chatId: Int64, text: String) {
|
||||||
|
self.chatId = chatId
|
||||||
|
self.text = text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ChatSummary: Identifiable, Hashable, Sendable {
|
||||||
|
public var id: Int64
|
||||||
|
public var title: String
|
||||||
|
public var username: String?
|
||||||
|
public var lastMessage: String
|
||||||
|
public var unreadCount: Int32
|
||||||
|
public var unreadMentionCount: Int32
|
||||||
|
public var isPinned: Bool
|
||||||
|
public var folderIds: [Int32]
|
||||||
|
public var isMuted: Bool
|
||||||
|
public var draft: Draft?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
id: Int64,
|
||||||
|
title: String,
|
||||||
|
username: String? = nil,
|
||||||
|
lastMessage: String,
|
||||||
|
unreadCount: Int32 = 0,
|
||||||
|
unreadMentionCount: Int32 = 0,
|
||||||
|
isPinned: Bool = false,
|
||||||
|
folderIds: [Int32] = [0],
|
||||||
|
isMuted: Bool = false,
|
||||||
|
draft: Draft? = nil
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.title = title
|
||||||
|
self.username = username
|
||||||
|
self.lastMessage = lastMessage
|
||||||
|
self.unreadCount = unreadCount
|
||||||
|
self.unreadMentionCount = unreadMentionCount
|
||||||
|
self.isPinned = isPinned
|
||||||
|
self.folderIds = folderIds
|
||||||
|
self.isMuted = isMuted
|
||||||
|
self.draft = draft
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct Reaction: Equatable, Sendable {
|
||||||
|
public var emoji: String
|
||||||
|
public var count: Int32
|
||||||
|
public var isChosen: Bool
|
||||||
|
|
||||||
|
public init(emoji: String, count: Int32, isChosen: Bool) {
|
||||||
|
self.emoji = emoji
|
||||||
|
self.count = count
|
||||||
|
self.isChosen = isChosen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum MediaDownloadState: Equatable, Sendable {
|
||||||
|
case notDownloaded
|
||||||
|
case downloading
|
||||||
|
case downloaded(path: String)
|
||||||
|
case error(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct PhotoMedia: Equatable, Sendable {
|
||||||
|
public var fileId: Int32
|
||||||
|
public var width: Int32
|
||||||
|
public var height: Int32
|
||||||
|
public var downloadState: MediaDownloadState
|
||||||
|
|
||||||
|
public init(
|
||||||
|
fileId: Int32,
|
||||||
|
width: Int32,
|
||||||
|
height: Int32,
|
||||||
|
downloadState: MediaDownloadState = .notDownloaded
|
||||||
|
) {
|
||||||
|
self.fileId = fileId
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
self.downloadState = downloadState
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct VoiceMedia: Equatable, Sendable {
|
||||||
|
public var fileId: Int32
|
||||||
|
public var duration: Int32
|
||||||
|
public var mimeType: String?
|
||||||
|
public var waveform: String?
|
||||||
|
public var downloadState: MediaDownloadState
|
||||||
|
|
||||||
|
public init(
|
||||||
|
fileId: Int32,
|
||||||
|
duration: Int32,
|
||||||
|
mimeType: String? = nil,
|
||||||
|
waveform: String? = nil,
|
||||||
|
downloadState: MediaDownloadState = .notDownloaded
|
||||||
|
) {
|
||||||
|
self.fileId = fileId
|
||||||
|
self.duration = duration
|
||||||
|
self.mimeType = mimeType
|
||||||
|
self.waveform = waveform
|
||||||
|
self.downloadState = downloadState
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum MessageMedia: Equatable, Sendable {
|
||||||
|
case photo(PhotoMedia)
|
||||||
|
case voice(VoiceMedia)
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct Message: Identifiable, Equatable, Sendable {
|
||||||
|
public var id: Int64
|
||||||
|
public var chatId: Int64
|
||||||
|
public var senderName: String
|
||||||
|
public var text: String
|
||||||
|
public var date: Int32
|
||||||
|
public var mediaAlbumId: Int64?
|
||||||
|
public var media: MessageMedia?
|
||||||
|
public var isOutgoing: Bool
|
||||||
|
public var isRead: Bool
|
||||||
|
public var editDate: Int32?
|
||||||
|
public var replyText: String?
|
||||||
|
public var forwardSenderName: String?
|
||||||
|
public var reactions: [Reaction]
|
||||||
|
|
||||||
|
public init(
|
||||||
|
id: Int64,
|
||||||
|
chatId: Int64,
|
||||||
|
senderName: String,
|
||||||
|
text: String,
|
||||||
|
date: Int32 = 0,
|
||||||
|
mediaAlbumId: Int64? = nil,
|
||||||
|
media: MessageMedia? = nil,
|
||||||
|
isOutgoing: Bool,
|
||||||
|
isRead: Bool = true,
|
||||||
|
editDate: Int32? = nil,
|
||||||
|
replyText: String? = nil,
|
||||||
|
forwardSenderName: String? = nil,
|
||||||
|
reactions: [Reaction] = []
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.chatId = chatId
|
||||||
|
self.senderName = senderName
|
||||||
|
self.text = text
|
||||||
|
self.date = date
|
||||||
|
self.mediaAlbumId = mediaAlbumId
|
||||||
|
self.media = media
|
||||||
|
self.isOutgoing = isOutgoing
|
||||||
|
self.isRead = isRead
|
||||||
|
self.editDate = editDate
|
||||||
|
self.replyText = replyText
|
||||||
|
self.forwardSenderName = forwardSenderName
|
||||||
|
self.reactions = reactions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct Profile: Equatable, Sendable {
|
||||||
|
public var chatId: Int64
|
||||||
|
public var title: String
|
||||||
|
public var username: String?
|
||||||
|
public var bio: String?
|
||||||
|
public var isGroup: Bool
|
||||||
|
public var memberCount: Int32?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
chatId: Int64,
|
||||||
|
title: String,
|
||||||
|
username: String? = nil,
|
||||||
|
bio: String? = nil,
|
||||||
|
isGroup: Bool = false,
|
||||||
|
memberCount: Int32? = nil
|
||||||
|
) {
|
||||||
|
self.chatId = chatId
|
||||||
|
self.title = title
|
||||||
|
self.username = username
|
||||||
|
self.bio = bio
|
||||||
|
self.isGroup = isGroup
|
||||||
|
self.memberCount = memberCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct DownloadedFile: Equatable, Sendable {
|
||||||
|
public var fileId: Int32
|
||||||
|
public var path: String
|
||||||
|
|
||||||
|
public init(fileId: Int32, path: String) {
|
||||||
|
self.fileId = fileId
|
||||||
|
self.path = path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum SessionEvent: Equatable, Sendable {
|
||||||
|
case authChanged(AuthState)
|
||||||
|
case chatListChanged([ChatSummary])
|
||||||
|
case folderListChanged([Folder])
|
||||||
|
case messageAdded(Int64, Message)
|
||||||
|
case messageUpdated(Int64, Message)
|
||||||
|
case messageDeleted(Int64, [Int64])
|
||||||
|
case reactionChanged(Int64, Int64, [Reaction])
|
||||||
|
case incomingNotificationCandidate(ChatSummary, Message, String)
|
||||||
|
case networkChanged(NetworkState)
|
||||||
|
case typingChanged(TypingState)
|
||||||
|
case draftChanged(Draft)
|
||||||
|
case profileLoaded(Profile)
|
||||||
|
case mediaDownloadProgress(fileId: Int32, downloadedSize: Int64, totalSize: Int64)
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum NetworkState: Equatable, Sendable {
|
||||||
|
case waitingForNetwork
|
||||||
|
case connectingToProxy
|
||||||
|
case connecting
|
||||||
|
case updating
|
||||||
|
case ready
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum TypingState: Equatable, Sendable {
|
||||||
|
case idle
|
||||||
|
case typing(chatId: Int64, userId: Int64, text: String)
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
import Foundation
|
||||||
|
import AVFoundation
|
||||||
|
|
||||||
|
#if canImport(UserNotifications)
|
||||||
|
import UserNotifications
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if canImport(UIKit)
|
||||||
|
import UIKit
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if canImport(AppKit)
|
||||||
|
import AppKit
|
||||||
|
#endif
|
||||||
|
|
||||||
|
public protocol ClipboardWriting: Sendable {
|
||||||
|
func write(text: String) async
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct SystemClipboardWriter: ClipboardWriting {
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func write(text: String) async {
|
||||||
|
#if os(iOS) && canImport(UIKit)
|
||||||
|
await MainActor.run {
|
||||||
|
UIPasteboard.general.string = text
|
||||||
|
}
|
||||||
|
#elseif os(macOS) && canImport(AppKit)
|
||||||
|
await MainActor.run {
|
||||||
|
NSPasteboard.general.clearContents()
|
||||||
|
NSPasteboard.general.setString(text, forType: .string)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public actor InMemoryClipboardWriter: ClipboardWriting {
|
||||||
|
public private(set) var lastText: String?
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func write(text: String) async {
|
||||||
|
lastText = text
|
||||||
|
}
|
||||||
|
|
||||||
|
public func currentText() async -> String? {
|
||||||
|
lastText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct NotificationPolicy: Sendable {
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func shouldNotify(chat: ChatSummary, message: Message, mentionOnly: Bool) -> Bool {
|
||||||
|
guard !message.isOutgoing, !chat.isMuted else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if mentionOnly {
|
||||||
|
return message.text.contains("@")
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public protocol NotificationScheduling: Sendable {
|
||||||
|
func schedule(chat: ChatSummary, message: Message) async throws
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct SystemNotificationScheduler: NotificationScheduling {
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func schedule(chat: ChatSummary, message: Message) async throws {
|
||||||
|
#if canImport(UserNotifications)
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = chat.title
|
||||||
|
content.body = "\(message.senderName): \(message.text)"
|
||||||
|
content.sound = .default
|
||||||
|
|
||||||
|
let request = UNNotificationRequest(
|
||||||
|
identifier: "chat-\(chat.id)-message-\(message.id)",
|
||||||
|
content: content,
|
||||||
|
trigger: nil
|
||||||
|
)
|
||||||
|
try await UNUserNotificationCenter.current().add(request)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct NotificationCoordinator: Sendable {
|
||||||
|
public var policy: NotificationPolicy
|
||||||
|
public var scheduler: NotificationScheduling
|
||||||
|
public var mentionOnly: Bool
|
||||||
|
|
||||||
|
public init(
|
||||||
|
policy: NotificationPolicy = NotificationPolicy(),
|
||||||
|
scheduler: NotificationScheduling,
|
||||||
|
mentionOnly: Bool = false
|
||||||
|
) {
|
||||||
|
self.policy = policy
|
||||||
|
self.scheduler = scheduler
|
||||||
|
self.mentionOnly = mentionOnly
|
||||||
|
}
|
||||||
|
|
||||||
|
public func handle(chat: ChatSummary, message: Message) async throws {
|
||||||
|
guard policy.shouldNotify(chat: chat, message: message, mentionOnly: mentionOnly) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try await scheduler.schedule(chat: chat, message: message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public actor RecordingNotificationScheduler: NotificationScheduling {
|
||||||
|
public private(set) var scheduled: [(ChatSummary, Message)] = []
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func schedule(chat: ChatSummary, message: Message) async throws {
|
||||||
|
scheduled.append((chat, message))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func scheduledCount() -> Int {
|
||||||
|
scheduled.count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public protocol URLOpening: Sendable {
|
||||||
|
func open(_ url: URL) async
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct SystemURLOpener: URLOpening {
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func open(_ url: URL) async {
|
||||||
|
#if os(iOS) && canImport(UIKit)
|
||||||
|
await MainActor.run {
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
}
|
||||||
|
#elseif os(macOS) && canImport(AppKit)
|
||||||
|
await MainActor.run {
|
||||||
|
_ = NSWorkspace.shared.open(url)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct MediaCache: Sendable {
|
||||||
|
public var root: URL
|
||||||
|
|
||||||
|
public init(root: URL) {
|
||||||
|
self.root = root
|
||||||
|
}
|
||||||
|
|
||||||
|
public func photoPath(fileId: Int32) -> URL {
|
||||||
|
root.appendingPathComponent("photos", isDirectory: true).appendingPathComponent("\(fileId).jpg")
|
||||||
|
}
|
||||||
|
|
||||||
|
public func voicePath(fileId: Int32) -> URL {
|
||||||
|
root.appendingPathComponent("voices", isDirectory: true).appendingPathComponent("\(fileId).ogg")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public protocol VoicePlayback: Sendable {
|
||||||
|
func load(url: URL) async throws
|
||||||
|
func play() async
|
||||||
|
func pause() async
|
||||||
|
func seek(to seconds: TimeInterval) async
|
||||||
|
}
|
||||||
|
|
||||||
|
public actor SystemVoicePlayer: VoicePlayback {
|
||||||
|
private var player: AVPlayer?
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func load(url: URL) async throws {
|
||||||
|
player = AVPlayer(url: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func play() async {
|
||||||
|
player?.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func pause() async {
|
||||||
|
player?.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func seek(to seconds: TimeInterval) async {
|
||||||
|
await player?.seek(to: CMTime(seconds: seconds, preferredTimescale: 600))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public actor RecordingVoicePlayer: VoicePlayback {
|
||||||
|
public private(set) var loadedURL: URL?
|
||||||
|
public private(set) var isPlaying = false
|
||||||
|
public private(set) var position: TimeInterval = 0
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func load(url: URL) async throws {
|
||||||
|
loadedURL = url
|
||||||
|
position = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
public func currentLoadedURL() async -> URL? {
|
||||||
|
loadedURL
|
||||||
|
}
|
||||||
|
|
||||||
|
public func play() async {
|
||||||
|
isPlaying = true
|
||||||
|
}
|
||||||
|
|
||||||
|
public func pause() async {
|
||||||
|
isPlaying = false
|
||||||
|
}
|
||||||
|
|
||||||
|
public func seek(to seconds: TimeInterval) async {
|
||||||
|
position = seconds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public final class AccountSwitcherViewModel: ObservableObject {
|
||||||
|
@Published public private(set) var accounts: [Account]
|
||||||
|
@Published public private(set) var activeAccount: Account
|
||||||
|
|
||||||
|
public init(accounts: [Account], activeAccount: Account) {
|
||||||
|
self.accounts = accounts
|
||||||
|
self.activeAccount = activeAccount
|
||||||
|
}
|
||||||
|
|
||||||
|
public func switchToAccount(id: String) {
|
||||||
|
guard let next = accounts.first(where: { $0.id == id }) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
activeAccount = next
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum SessionBridgeFactory {
|
||||||
|
public static func makeDefaultBridge(
|
||||||
|
account: Account,
|
||||||
|
useFakeTdlib: Bool = true
|
||||||
|
) -> SessionBridge {
|
||||||
|
#if TELE_IOS_USE_LOCAL_FFI || TELE_IOS_TYPECHECK_UNIFFI
|
||||||
|
do {
|
||||||
|
return try UniFfiSessionBridge(account: account, useFakeTdlib: useFakeTdlib)
|
||||||
|
} catch {
|
||||||
|
return FakeSessionBridge(auth: .waitPhoneNumber)
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
return FakeSessionBridge(auth: .ready)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
45
apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Storage.swift
Normal file
45
apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Storage.swift
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct AppStoragePaths: Sendable {
|
||||||
|
public var root: URL
|
||||||
|
|
||||||
|
public init(root: URL = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0].appendingPathComponent("TeleTuiIOS")) {
|
||||||
|
self.root = root
|
||||||
|
}
|
||||||
|
|
||||||
|
public func databasePath(for accountId: String) -> URL {
|
||||||
|
root
|
||||||
|
.appendingPathComponent("Accounts", isDirectory: true)
|
||||||
|
.appendingPathComponent(accountId, isDirectory: true)
|
||||||
|
.appendingPathComponent("tdlib", isDirectory: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func mediaCachePath(for accountId: String) -> URL {
|
||||||
|
root
|
||||||
|
.appendingPathComponent("Accounts", isDirectory: true)
|
||||||
|
.appendingPathComponent(accountId, isDirectory: true)
|
||||||
|
.appendingPathComponent("Media", isDirectory: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public protocol CredentialStore: Sendable {
|
||||||
|
func save(account: Account) async throws
|
||||||
|
func loadAccounts() async throws -> [Account]
|
||||||
|
}
|
||||||
|
|
||||||
|
public actor InMemoryCredentialStore: CredentialStore {
|
||||||
|
private var accounts: [Account]
|
||||||
|
|
||||||
|
public init(accounts: [Account] = []) {
|
||||||
|
self.accounts = accounts
|
||||||
|
}
|
||||||
|
|
||||||
|
public func save(account: Account) async throws {
|
||||||
|
accounts.removeAll { $0.id == account.id }
|
||||||
|
accounts.append(account)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func loadAccounts() async throws -> [Account] {
|
||||||
|
accounts
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
#if TELE_IOS_USE_LOCAL_FFI
|
||||||
|
import tele_ios_ffi
|
||||||
|
#elseif TELE_IOS_TYPECHECK_UNIFFI
|
||||||
|
import tele_ios_ffiFFI
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if TELE_IOS_USE_LOCAL_FFI || TELE_IOS_TYPECHECK_UNIFFI
|
||||||
|
public actor UniFfiSessionBridge: SessionBridge {
|
||||||
|
private let handle: SessionHandle
|
||||||
|
private let defaultLimit: Int32
|
||||||
|
|
||||||
|
public init(account: Account, useFakeTdlib: Bool = true, defaultLimit: Int32 = 100) throws {
|
||||||
|
self.handle = try createSession(config: IosSessionConfig(
|
||||||
|
accountId: account.id,
|
||||||
|
displayName: account.displayName,
|
||||||
|
databasePath: account.databasePath.path,
|
||||||
|
useFakeTdlib: useFakeTdlib
|
||||||
|
))
|
||||||
|
self.defaultLimit = defaultLimit
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(handle: SessionHandle, defaultLimit: Int32 = 100) {
|
||||||
|
self.handle = handle
|
||||||
|
self.defaultLimit = defaultLimit
|
||||||
|
}
|
||||||
|
|
||||||
|
public func authState() async throws -> AuthState {
|
||||||
|
Self.mapAuthState(handle.authState())
|
||||||
|
}
|
||||||
|
|
||||||
|
public func networkState() async throws -> NetworkState {
|
||||||
|
Self.mapNetworkState(handle.networkState())
|
||||||
|
}
|
||||||
|
|
||||||
|
public func pollEvents() async throws -> [SessionEvent] {
|
||||||
|
handle.pollEvents().map(Self.mapEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func sendPhoneNumber(_ phone: String) async throws {
|
||||||
|
try handle.sendPhoneNumber(phone: phone)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func sendCode(_ code: String) async throws {
|
||||||
|
try handle.sendCode(code: code)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func sendPassword(_ password: String) async throws {
|
||||||
|
try handle.sendPassword(password: password)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func loadFolders() async throws -> [Folder] {
|
||||||
|
handle.loadFolders().map(Self.mapFolder)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func loadChats(folderId: Int32?) async throws -> [ChatSummary] {
|
||||||
|
let chats = if let folderId {
|
||||||
|
try handle.loadFolderChats(folderId: folderId, limit: defaultLimit)
|
||||||
|
} else {
|
||||||
|
try handle.loadChats(limit: defaultLimit)
|
||||||
|
}
|
||||||
|
return chats.map(Self.mapChatSummary)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func loadHistory(chatId: Int64) async throws -> [Message] {
|
||||||
|
try handle.loadHistory(chatId: chatId, limit: defaultLimit)
|
||||||
|
.map { Self.mapMessage($0, chatId: chatId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func searchMessages(chatId: Int64, query: String) async throws -> [Message] {
|
||||||
|
try handle.searchMessages(chatId: chatId, query: query)
|
||||||
|
.map { Self.mapMessage($0.message, chatId: $0.chatId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func openProfile(chatId: Int64) async throws -> Profile {
|
||||||
|
try Self.mapProfile(handle.openProfile(chatId: chatId))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func leaveChat(chatId: Int64) async throws {
|
||||||
|
try handle.leaveChat(chatId: chatId)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func sendMessage(chatId: Int64, text: String, replyToMessageId: Int64?) async throws -> Message {
|
||||||
|
try Self.mapMessage(
|
||||||
|
handle.sendMessage(chatId: chatId, text: text, replyToMessageId: replyToMessageId),
|
||||||
|
chatId: chatId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func editMessage(chatId: Int64, messageId: Int64, text: String) async throws -> Message {
|
||||||
|
try Self.mapMessage(
|
||||||
|
handle.editMessage(chatId: chatId, messageId: messageId, text: text),
|
||||||
|
chatId: chatId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func deleteMessages(chatId: Int64, messageIds: [Int64]) async throws {
|
||||||
|
try handle.deleteMessages(chatId: chatId, messageIds: messageIds, revoke: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func forwardMessages(toChatId: Int64, fromChatId: Int64, messageIds: [Int64]) async throws {
|
||||||
|
try handle.forwardMessages(toChatId: toChatId, fromChatId: fromChatId, messageIds: messageIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func react(chatId: Int64, messageId: Int64, reaction: String) async throws -> [Reaction] {
|
||||||
|
try handle.react(chatId: chatId, messageId: messageId, reaction: reaction)
|
||||||
|
.map(Self.mapReaction)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func pinnedMessages(chatId: Int64) async throws -> [Message] {
|
||||||
|
try handle.pinnedMessages(chatId: chatId)
|
||||||
|
.map { Self.mapMessage($0, chatId: chatId) }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func copyPayload(chatId: Int64, messageId: Int64) async throws -> String {
|
||||||
|
try handle.copyPayload(chatId: chatId, messageId: messageId)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func setDraft(chatId: Int64, text: String) async throws {
|
||||||
|
try handle.setDraft(chatId: chatId, text: text)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func downloadPhoto(fileId: Int32) async throws -> DownloadedFile {
|
||||||
|
try Self.mapDownloadedFile(handle.downloadPhoto(fileId: fileId))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func downloadVoice(fileId: Int32) async throws -> DownloadedFile {
|
||||||
|
try Self.mapDownloadedFile(handle.downloadVoice(fileId: fileId))
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func mapAuthState(_ state: IosAuthState) -> AuthState {
|
||||||
|
switch state {
|
||||||
|
case .waitTdlibParameters:
|
||||||
|
.waitTdlibParameters
|
||||||
|
case .waitPhoneNumber:
|
||||||
|
.waitPhoneNumber
|
||||||
|
case .waitCode:
|
||||||
|
.waitCode
|
||||||
|
case .waitPassword:
|
||||||
|
.waitPassword
|
||||||
|
case .ready:
|
||||||
|
.ready
|
||||||
|
case .closed:
|
||||||
|
.closed
|
||||||
|
case let .error(message):
|
||||||
|
.error(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func mapNetworkState(_ state: IosNetworkState) -> NetworkState {
|
||||||
|
switch state {
|
||||||
|
case .waitingForNetwork:
|
||||||
|
.waitingForNetwork
|
||||||
|
case .connectingToProxy:
|
||||||
|
.connectingToProxy
|
||||||
|
case .connecting:
|
||||||
|
.connecting
|
||||||
|
case .updating:
|
||||||
|
.updating
|
||||||
|
case .ready:
|
||||||
|
.ready
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func mapTypingState(_ state: IosTypingState) -> TypingState {
|
||||||
|
switch state {
|
||||||
|
case .idle:
|
||||||
|
.idle
|
||||||
|
case let .typing(chatId, userId, text):
|
||||||
|
.typing(chatId: chatId, userId: userId, text: text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func mapFolder(_ folder: IosFolder) -> Folder {
|
||||||
|
Folder(id: folder.id, name: folder.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func mapDraft(_ draft: IosDraft) -> Draft {
|
||||||
|
Draft(chatId: draft.chatId, text: draft.text)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func mapChatSummary(_ chat: IosChatSummary) -> ChatSummary {
|
||||||
|
ChatSummary(
|
||||||
|
id: chat.id,
|
||||||
|
title: chat.title,
|
||||||
|
username: chat.username,
|
||||||
|
lastMessage: chat.lastMessage,
|
||||||
|
unreadCount: chat.unreadCount,
|
||||||
|
unreadMentionCount: chat.unreadMentionCount,
|
||||||
|
isPinned: chat.isPinned,
|
||||||
|
folderIds: chat.folderIds,
|
||||||
|
isMuted: chat.isMuted,
|
||||||
|
draft: chat.draft.map(mapDraft)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func mapReaction(_ reaction: IosReaction) -> Reaction {
|
||||||
|
Reaction(emoji: reaction.emoji, count: reaction.count, isChosen: reaction.isChosen)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func mapDownloadState(_ state: IosDownloadState) -> MediaDownloadState {
|
||||||
|
switch state {
|
||||||
|
case .notDownloaded:
|
||||||
|
.notDownloaded
|
||||||
|
case .downloading:
|
||||||
|
.downloading
|
||||||
|
case let .downloaded(path):
|
||||||
|
.downloaded(path: path)
|
||||||
|
case let .error(message):
|
||||||
|
.error(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func mapMedia(_ media: IosMedia) -> MessageMedia? {
|
||||||
|
switch media.kind {
|
||||||
|
case "photo":
|
||||||
|
.photo(PhotoMedia(
|
||||||
|
fileId: media.fileId,
|
||||||
|
width: media.width ?? 0,
|
||||||
|
height: media.height ?? 0,
|
||||||
|
downloadState: mapDownloadState(media.downloadState)
|
||||||
|
))
|
||||||
|
case "voice":
|
||||||
|
.voice(VoiceMedia(
|
||||||
|
fileId: media.fileId,
|
||||||
|
duration: media.duration ?? 0,
|
||||||
|
mimeType: media.mimeType,
|
||||||
|
waveform: media.waveform,
|
||||||
|
downloadState: mapDownloadState(media.downloadState)
|
||||||
|
))
|
||||||
|
default:
|
||||||
|
nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func mapMessage(_ message: IosMessage, chatId: Int64) -> Message {
|
||||||
|
Message(
|
||||||
|
id: message.id,
|
||||||
|
chatId: chatId,
|
||||||
|
senderName: message.senderName,
|
||||||
|
text: message.text,
|
||||||
|
date: message.date,
|
||||||
|
mediaAlbumId: message.mediaAlbumId,
|
||||||
|
media: message.media.flatMap(mapMedia),
|
||||||
|
isOutgoing: message.isOutgoing,
|
||||||
|
isRead: message.isRead,
|
||||||
|
editDate: message.editDate,
|
||||||
|
replyText: message.reply?.text,
|
||||||
|
forwardSenderName: message.forward?.senderName,
|
||||||
|
reactions: message.reactions.map(mapReaction)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func mapProfile(_ profile: IosProfile) -> Profile {
|
||||||
|
Profile(
|
||||||
|
chatId: profile.chatId,
|
||||||
|
title: profile.title,
|
||||||
|
username: profile.username,
|
||||||
|
bio: profile.bio ?? profile.description,
|
||||||
|
isGroup: profile.isGroup,
|
||||||
|
memberCount: profile.memberCount
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func mapDownloadedFile(_ file: IosDownloadedFile) -> DownloadedFile {
|
||||||
|
DownloadedFile(fileId: file.fileId, path: file.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func mapEvent(_ event: IosEvent) -> SessionEvent {
|
||||||
|
switch event {
|
||||||
|
case let .authChanged(state):
|
||||||
|
.authChanged(mapAuthState(state))
|
||||||
|
case let .chatListChanged(chats):
|
||||||
|
.chatListChanged(chats.map(mapChatSummary))
|
||||||
|
case let .folderListChanged(folders):
|
||||||
|
.folderListChanged(folders.map(mapFolder))
|
||||||
|
case let .messageAdded(chatId, message):
|
||||||
|
.messageAdded(chatId, mapMessage(message, chatId: chatId))
|
||||||
|
case let .messageUpdated(chatId, message):
|
||||||
|
.messageUpdated(chatId, mapMessage(message, chatId: chatId))
|
||||||
|
case let .messageDeleted(chatId, messageIds):
|
||||||
|
.messageDeleted(chatId, messageIds)
|
||||||
|
case let .reactionChanged(chatId, messageId, reactions):
|
||||||
|
.reactionChanged(chatId, messageId, reactions.map(mapReaction))
|
||||||
|
case let .incomingNotificationCandidate(chat, message, senderName):
|
||||||
|
.incomingNotificationCandidate(
|
||||||
|
mapChatSummary(chat),
|
||||||
|
mapMessage(message, chatId: chat.id),
|
||||||
|
senderName
|
||||||
|
)
|
||||||
|
case let .networkChanged(state):
|
||||||
|
.networkChanged(mapNetworkState(state))
|
||||||
|
case let .typingChanged(state):
|
||||||
|
.typingChanged(mapTypingState(state))
|
||||||
|
case let .draftChanged(draft):
|
||||||
|
.draftChanged(mapDraft(draft))
|
||||||
|
case let .profileLoaded(profile):
|
||||||
|
.profileLoaded(mapProfile(profile))
|
||||||
|
case let .mediaDownloadProgress(fileId, downloadedSize, totalSize):
|
||||||
|
.mediaDownloadProgress(fileId: fileId, downloadedSize: downloadedSize, totalSize: totalSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
346
apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/ViewModels.swift
Normal file
346
apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/ViewModels.swift
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public final class SessionStore: ObservableObject {
|
||||||
|
@Published public private(set) var account: Account
|
||||||
|
@Published public private(set) var authState: AuthState = .waitTdlibParameters
|
||||||
|
@Published public private(set) var networkState: NetworkState = .ready
|
||||||
|
@Published public private(set) var typingState: TypingState = .idle
|
||||||
|
@Published public private(set) var errorMessage: String?
|
||||||
|
|
||||||
|
public let bridge: SessionBridge
|
||||||
|
|
||||||
|
public init(account: Account, bridge: SessionBridge) {
|
||||||
|
self.account = account
|
||||||
|
self.bridge = bridge
|
||||||
|
}
|
||||||
|
|
||||||
|
public func refreshAuthState() async {
|
||||||
|
do {
|
||||||
|
authState = try await bridge.authState()
|
||||||
|
errorMessage = nil
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func refreshNetworkState() async {
|
||||||
|
do {
|
||||||
|
networkState = try await bridge.networkState()
|
||||||
|
errorMessage = nil
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func apply(events: [SessionEvent]) {
|
||||||
|
for event in events {
|
||||||
|
switch event {
|
||||||
|
case let .authChanged(state):
|
||||||
|
authState = state
|
||||||
|
case let .networkChanged(state):
|
||||||
|
networkState = state
|
||||||
|
case let .typingChanged(state):
|
||||||
|
typingState = state
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public final class AuthViewModel: ObservableObject {
|
||||||
|
@Published public var phone = ""
|
||||||
|
@Published public var code = ""
|
||||||
|
@Published public var password = ""
|
||||||
|
@Published public private(set) var isLoading = false
|
||||||
|
@Published public private(set) var errorMessage: String?
|
||||||
|
|
||||||
|
private let store: SessionStore
|
||||||
|
|
||||||
|
public init(store: SessionStore) {
|
||||||
|
self.store = store
|
||||||
|
}
|
||||||
|
|
||||||
|
public func submitCurrentStep() async {
|
||||||
|
isLoading = true
|
||||||
|
defer { isLoading = false }
|
||||||
|
|
||||||
|
do {
|
||||||
|
switch store.authState {
|
||||||
|
case .waitPhoneNumber:
|
||||||
|
try await store.bridge.sendPhoneNumber(phone)
|
||||||
|
case .waitCode:
|
||||||
|
try await store.bridge.sendCode(code)
|
||||||
|
case .waitPassword:
|
||||||
|
try await store.bridge.sendPassword(password)
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
let events = try await store.bridge.pollEvents()
|
||||||
|
store.apply(events: events)
|
||||||
|
errorMessage = nil
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public final class ChatListViewModel: ObservableObject {
|
||||||
|
@Published public private(set) var folders: [Folder] = []
|
||||||
|
@Published public private(set) var chats: [ChatSummary] = []
|
||||||
|
@Published public var selectedFolderId: Int32?
|
||||||
|
@Published public var searchText = ""
|
||||||
|
@Published public private(set) var isLoading = false
|
||||||
|
@Published public private(set) var errorMessage: String?
|
||||||
|
|
||||||
|
private let bridge: SessionBridge
|
||||||
|
|
||||||
|
public init(bridge: SessionBridge) {
|
||||||
|
self.bridge = bridge
|
||||||
|
}
|
||||||
|
|
||||||
|
public var filteredChats: [ChatSummary] {
|
||||||
|
guard !searchText.isEmpty else {
|
||||||
|
return chats
|
||||||
|
}
|
||||||
|
return chats.filter { chat in
|
||||||
|
chat.title.localizedCaseInsensitiveContains(searchText)
|
||||||
|
|| (chat.username?.localizedCaseInsensitiveContains(searchText) ?? false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func load() async {
|
||||||
|
isLoading = true
|
||||||
|
defer { isLoading = false }
|
||||||
|
|
||||||
|
do {
|
||||||
|
folders = try await bridge.loadFolders()
|
||||||
|
chats = try await bridge.loadChats(folderId: selectedFolderId)
|
||||||
|
errorMessage = nil
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public final class ChatViewModel: ObservableObject {
|
||||||
|
@Published public private(set) var chat: ChatSummary
|
||||||
|
@Published public private(set) var messages: [Message] = []
|
||||||
|
@Published public var composeText: String
|
||||||
|
@Published public var replyTo: Message?
|
||||||
|
@Published public var searchText = ""
|
||||||
|
@Published public private(set) var searchResults: [Message] = []
|
||||||
|
@Published public private(set) var pinnedMessages: [Message] = []
|
||||||
|
@Published public private(set) var copiedPayload: String?
|
||||||
|
@Published public private(set) var isLoading = false
|
||||||
|
@Published public private(set) var errorMessage: String?
|
||||||
|
|
||||||
|
private let bridge: SessionBridge
|
||||||
|
|
||||||
|
public init(chat: ChatSummary, bridge: SessionBridge) {
|
||||||
|
self.chat = chat
|
||||||
|
self.bridge = bridge
|
||||||
|
self.composeText = chat.draft?.text ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
public func load() async {
|
||||||
|
isLoading = true
|
||||||
|
defer { isLoading = false }
|
||||||
|
|
||||||
|
do {
|
||||||
|
messages = try await bridge.loadHistory(chatId: chat.id)
|
||||||
|
pinnedMessages = try await bridge.pinnedMessages(chatId: chat.id)
|
||||||
|
errorMessage = nil
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func send() async {
|
||||||
|
let text = composeText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !text.isEmpty else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let sent = try await bridge.sendMessage(
|
||||||
|
chatId: chat.id,
|
||||||
|
text: text,
|
||||||
|
replyToMessageId: replyTo?.id
|
||||||
|
)
|
||||||
|
messages.append(sent)
|
||||||
|
composeText = ""
|
||||||
|
replyTo = nil
|
||||||
|
errorMessage = nil
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func search() async {
|
||||||
|
do {
|
||||||
|
searchResults = try await bridge.searchMessages(chatId: chat.id, query: searchText)
|
||||||
|
errorMessage = nil
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func beginReply(to message: Message) {
|
||||||
|
replyTo = message
|
||||||
|
}
|
||||||
|
|
||||||
|
public func cancelReply() {
|
||||||
|
replyTo = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
public func edit(message: Message, text: String) async {
|
||||||
|
do {
|
||||||
|
let edited = try await bridge.editMessage(chatId: chat.id, messageId: message.id, text: text)
|
||||||
|
replaceMessage(edited)
|
||||||
|
errorMessage = nil
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func delete(message: Message) async {
|
||||||
|
do {
|
||||||
|
try await bridge.deleteMessages(chatId: chat.id, messageIds: [message.id])
|
||||||
|
messages.removeAll { $0.id == message.id }
|
||||||
|
errorMessage = nil
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func forward(message: Message, to chatId: Int64) async {
|
||||||
|
do {
|
||||||
|
try await bridge.forwardMessages(toChatId: chatId, fromChatId: chat.id, messageIds: [message.id])
|
||||||
|
errorMessage = nil
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func react(message: Message, reaction: String) async {
|
||||||
|
do {
|
||||||
|
let reactions = try await bridge.react(chatId: chat.id, messageId: message.id, reaction: reaction)
|
||||||
|
var updated = message
|
||||||
|
updated.reactions = reactions
|
||||||
|
replaceMessage(updated)
|
||||||
|
errorMessage = nil
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func copyPayload(for message: Message) async {
|
||||||
|
do {
|
||||||
|
copiedPayload = try await bridge.copyPayload(chatId: chat.id, messageId: message.id)
|
||||||
|
errorMessage = nil
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func saveDraft() async {
|
||||||
|
do {
|
||||||
|
try await bridge.setDraft(chatId: chat.id, text: composeText)
|
||||||
|
errorMessage = nil
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func replaceMessage(_ message: Message) {
|
||||||
|
if let index = messages.firstIndex(where: { $0.id == message.id }) {
|
||||||
|
messages[index] = message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public final class ProfileViewModel: ObservableObject {
|
||||||
|
@Published public private(set) var profile: Profile?
|
||||||
|
@Published public private(set) var isLoading = false
|
||||||
|
@Published public private(set) var errorMessage: String?
|
||||||
|
|
||||||
|
private let bridge: SessionBridge
|
||||||
|
|
||||||
|
public init(bridge: SessionBridge) {
|
||||||
|
self.bridge = bridge
|
||||||
|
}
|
||||||
|
|
||||||
|
public func load(chatId: Int64) async {
|
||||||
|
isLoading = true
|
||||||
|
defer { isLoading = false }
|
||||||
|
|
||||||
|
do {
|
||||||
|
profile = try await bridge.openProfile(chatId: chatId)
|
||||||
|
errorMessage = nil
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func leave(chatId: Int64) async {
|
||||||
|
do {
|
||||||
|
try await bridge.leaveChat(chatId: chatId)
|
||||||
|
errorMessage = nil
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
public final class MediaViewModel: ObservableObject {
|
||||||
|
@Published public private(set) var activePhotoPath: String?
|
||||||
|
@Published public private(set) var activeVoicePath: String?
|
||||||
|
@Published public private(set) var isVoicePlaying = false
|
||||||
|
|
||||||
|
private let cache: MediaCache?
|
||||||
|
private let voicePlayer: VoicePlayback?
|
||||||
|
|
||||||
|
public init(cache: MediaCache? = nil, voicePlayer: VoicePlayback? = nil) {
|
||||||
|
self.cache = cache
|
||||||
|
self.voicePlayer = voicePlayer
|
||||||
|
}
|
||||||
|
|
||||||
|
public func showPhoto(path: String) {
|
||||||
|
activePhotoPath = path
|
||||||
|
}
|
||||||
|
|
||||||
|
public func showVoice(path: String) {
|
||||||
|
activeVoicePath = path
|
||||||
|
}
|
||||||
|
|
||||||
|
public func cachedPhotoPath(fileId: Int32) -> URL? {
|
||||||
|
cache?.photoPath(fileId: fileId)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func cachedVoicePath(fileId: Int32) -> URL? {
|
||||||
|
cache?.voicePath(fileId: fileId)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func playVoice(url: URL) async {
|
||||||
|
do {
|
||||||
|
try await voicePlayer?.load(url: url)
|
||||||
|
await voicePlayer?.play()
|
||||||
|
isVoicePlaying = true
|
||||||
|
} catch {
|
||||||
|
isVoicePlaying = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func pauseVoice() async {
|
||||||
|
await voicePlayer?.pause()
|
||||||
|
isVoicePlaying = false
|
||||||
|
}
|
||||||
|
}
|
||||||
964
apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Views.swift
Normal file
964
apps/ios/TeleTuiIOS/Sources/TeleTuiIOSCore/Views.swift
Normal file
@@ -0,0 +1,964 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
public struct RootView: View {
|
||||||
|
@StateObject private var store: SessionStore
|
||||||
|
@StateObject private var authViewModel: AuthViewModel
|
||||||
|
@StateObject private var chatListViewModel: ChatListViewModel
|
||||||
|
|
||||||
|
public init(store: SessionStore) {
|
||||||
|
let authViewModel = AuthViewModel(store: store)
|
||||||
|
let chatListViewModel = ChatListViewModel(bridge: store.bridge)
|
||||||
|
_store = StateObject(wrappedValue: store)
|
||||||
|
_authViewModel = StateObject(wrappedValue: authViewModel)
|
||||||
|
_chatListViewModel = StateObject(wrappedValue: chatListViewModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
Group {
|
||||||
|
switch store.authState {
|
||||||
|
case .ready:
|
||||||
|
ChatListView(
|
||||||
|
viewModel: chatListViewModel,
|
||||||
|
bridge: store.bridge,
|
||||||
|
networkState: store.networkState,
|
||||||
|
typingState: store.typingState
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
AuthView(state: store.authState, viewModel: authViewModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await store.refreshAuthState()
|
||||||
|
await store.refreshNetworkState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct AuthView: View {
|
||||||
|
public var state: AuthState
|
||||||
|
@ObservedObject public var viewModel: AuthViewModel
|
||||||
|
|
||||||
|
public init(state: AuthState, viewModel: AuthViewModel) {
|
||||||
|
self.state = state
|
||||||
|
self.viewModel = viewModel
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Text("Telegram")
|
||||||
|
.font(.largeTitle)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
|
||||||
|
authField
|
||||||
|
|
||||||
|
Button(action: {
|
||||||
|
Task { await viewModel.submitCurrentStep() }
|
||||||
|
}) {
|
||||||
|
Text(buttonTitle)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(viewModel.isLoading || !canSubmit)
|
||||||
|
|
||||||
|
if let errorMessage = viewModel.errorMessage {
|
||||||
|
Text(errorMessage)
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var authField: some View {
|
||||||
|
switch state {
|
||||||
|
case .waitPhoneNumber, .waitTdlibParameters:
|
||||||
|
TextField("Phone number", text: $viewModel.phone)
|
||||||
|
.textContentType(.telephoneNumber)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
case .waitCode:
|
||||||
|
TextField("Code", text: $viewModel.code)
|
||||||
|
.textContentType(.oneTimeCode)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
case .waitPassword:
|
||||||
|
SecureField("Password", text: $viewModel.password)
|
||||||
|
.textContentType(.password)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
case .ready:
|
||||||
|
Text("Ready")
|
||||||
|
case .closed:
|
||||||
|
Text("Session closed")
|
||||||
|
case let .error(message):
|
||||||
|
Text(message)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var buttonTitle: String {
|
||||||
|
switch state {
|
||||||
|
case .waitPhoneNumber, .waitTdlibParameters:
|
||||||
|
"Continue"
|
||||||
|
case .waitCode:
|
||||||
|
"Verify"
|
||||||
|
case .waitPassword:
|
||||||
|
"Unlock"
|
||||||
|
default:
|
||||||
|
"Continue"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var canSubmit: Bool {
|
||||||
|
switch state {
|
||||||
|
case .waitPhoneNumber, .waitTdlibParameters:
|
||||||
|
!viewModel.phone.isEmpty
|
||||||
|
case .waitCode:
|
||||||
|
!viewModel.code.isEmpty
|
||||||
|
case .waitPassword:
|
||||||
|
!viewModel.password.isEmpty
|
||||||
|
default:
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ChatListView: View {
|
||||||
|
@ObservedObject public var viewModel: ChatListViewModel
|
||||||
|
public let bridge: SessionBridge
|
||||||
|
public var networkState: NetworkState
|
||||||
|
public var typingState: TypingState
|
||||||
|
@State private var selectedChat: ChatSummary?
|
||||||
|
@State private var showsAccountSwitcher = false
|
||||||
|
|
||||||
|
public init(
|
||||||
|
viewModel: ChatListViewModel,
|
||||||
|
bridge: SessionBridge,
|
||||||
|
networkState: NetworkState = .ready,
|
||||||
|
typingState: TypingState = .idle
|
||||||
|
) {
|
||||||
|
self.viewModel = viewModel
|
||||||
|
self.bridge = bridge
|
||||||
|
self.networkState = networkState
|
||||||
|
self.typingState = typingState
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
NavigationSplitView {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
List(selection: $selectedChat) {
|
||||||
|
ForEach(viewModel.filteredChats) { chat in
|
||||||
|
NavigationLink(value: chat) {
|
||||||
|
ChatRow(chat: chat)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ChatListStatusBar(networkState: networkState, typingState: typingState)
|
||||||
|
}
|
||||||
|
.navigationTitle("Chats")
|
||||||
|
.searchable(text: $viewModel.searchText)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem {
|
||||||
|
Button("Accounts") {
|
||||||
|
showsAccountSwitcher = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ToolbarItem {
|
||||||
|
folderMenu
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showsAccountSwitcher) {
|
||||||
|
AccountSwitcherView()
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await viewModel.load()
|
||||||
|
}
|
||||||
|
} detail: {
|
||||||
|
if let selectedChat {
|
||||||
|
ChatDetailView(viewModel: ChatViewModel(chat: selectedChat, bridge: bridge), bridge: bridge) {
|
||||||
|
self.selectedChat = nil
|
||||||
|
Task { await viewModel.load() }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text("Select a chat")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var folderMenu: some View {
|
||||||
|
Menu("Folders") {
|
||||||
|
Button("All") {
|
||||||
|
viewModel.selectedFolderId = nil
|
||||||
|
Task { await viewModel.load() }
|
||||||
|
}
|
||||||
|
ForEach(viewModel.folders) { folder in
|
||||||
|
Button(folder.name) {
|
||||||
|
viewModel.selectedFolderId = folder.id
|
||||||
|
Task { await viewModel.load() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ChatListStatusBar: View {
|
||||||
|
public var networkState: NetworkState
|
||||||
|
public var typingState: TypingState
|
||||||
|
|
||||||
|
public init(networkState: NetworkState, typingState: TypingState) {
|
||||||
|
self.networkState = networkState
|
||||||
|
self.typingState = typingState
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: networkIconName)
|
||||||
|
.foregroundStyle(networkState == .ready ? .green : .orange)
|
||||||
|
Text(statusText)
|
||||||
|
.font(.footnote)
|
||||||
|
.lineLimit(1)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(.bar)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var networkIconName: String {
|
||||||
|
switch networkState {
|
||||||
|
case .ready:
|
||||||
|
"checkmark.circle.fill"
|
||||||
|
case .waitingForNetwork:
|
||||||
|
"wifi.slash"
|
||||||
|
case .connectingToProxy:
|
||||||
|
"shield.lefthalf.filled"
|
||||||
|
case .connecting:
|
||||||
|
"antenna.radiowaves.left.and.right"
|
||||||
|
case .updating:
|
||||||
|
"arrow.triangle.2.circlepath"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var statusText: String {
|
||||||
|
switch typingState {
|
||||||
|
case let .typing(_, _, text) where !text.isEmpty:
|
||||||
|
text
|
||||||
|
case .typing:
|
||||||
|
"Typing"
|
||||||
|
case .idle:
|
||||||
|
networkText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var networkText: String {
|
||||||
|
switch networkState {
|
||||||
|
case .ready:
|
||||||
|
"Online"
|
||||||
|
case .waitingForNetwork:
|
||||||
|
"Waiting for network"
|
||||||
|
case .connectingToProxy:
|
||||||
|
"Connecting to proxy"
|
||||||
|
case .connecting:
|
||||||
|
"Connecting"
|
||||||
|
case .updating:
|
||||||
|
"Updating"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ChatRow: View {
|
||||||
|
public var chat: ChatSummary
|
||||||
|
|
||||||
|
public init(chat: ChatSummary) {
|
||||||
|
self.chat = chat
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack {
|
||||||
|
Text(chat.title)
|
||||||
|
.font(.headline)
|
||||||
|
.lineLimit(1)
|
||||||
|
if chat.isPinned {
|
||||||
|
Image(systemName: "pin.fill")
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
if chat.isMuted {
|
||||||
|
Image(systemName: "bell.slash")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if chat.unreadMentionCount > 0 {
|
||||||
|
Text("@\(chat.unreadMentionCount)")
|
||||||
|
.font(.caption)
|
||||||
|
.padding(.horizontal, 7)
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.background(.orange, in: Capsule())
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
}
|
||||||
|
if chat.unreadCount > 0 {
|
||||||
|
Text("\(chat.unreadCount)")
|
||||||
|
.font(.caption)
|
||||||
|
.padding(.horizontal, 7)
|
||||||
|
.padding(.vertical, 3)
|
||||||
|
.background(.blue, in: Capsule())
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
if chat.draft != nil {
|
||||||
|
Text("Draft")
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
}
|
||||||
|
Text(chat.draft?.text ?? chat.lastMessage)
|
||||||
|
}
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(chat.draft == nil ? Color.secondary : Color.red)
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ChatDetailView: View {
|
||||||
|
@StateObject public var viewModel: ChatViewModel
|
||||||
|
public let bridge: SessionBridge
|
||||||
|
public let clipboard: ClipboardWriting
|
||||||
|
@StateObject private var profileViewModel: ProfileViewModel
|
||||||
|
@State private var showsProfile = false
|
||||||
|
@State private var editingMessage: Message?
|
||||||
|
@State private var editedText = ""
|
||||||
|
@State private var deleteCandidate: Message?
|
||||||
|
@State private var forwardCandidate: Message?
|
||||||
|
@State private var reactionCandidate: Message?
|
||||||
|
@State private var forwardChatIdText = ""
|
||||||
|
private let onChatLeft: () -> Void
|
||||||
|
|
||||||
|
public init(
|
||||||
|
viewModel: ChatViewModel,
|
||||||
|
bridge: SessionBridge,
|
||||||
|
clipboard: ClipboardWriting = SystemClipboardWriter(),
|
||||||
|
onChatLeft: @escaping () -> Void = {}
|
||||||
|
) {
|
||||||
|
_viewModel = StateObject(wrappedValue: viewModel)
|
||||||
|
self.bridge = bridge
|
||||||
|
self.clipboard = clipboard
|
||||||
|
self.onChatLeft = onChatLeft
|
||||||
|
_profileViewModel = StateObject(wrappedValue: ProfileViewModel(bridge: bridge))
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ScrollViewReader { scrollProxy in
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
if !viewModel.pinnedMessages.isEmpty {
|
||||||
|
PinnedMessagesBar(messages: viewModel.pinnedMessages) { message in
|
||||||
|
withAnimation {
|
||||||
|
scrollProxy.scrollTo(message.id, anchor: .center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
List {
|
||||||
|
if !viewModel.searchText.isEmpty {
|
||||||
|
Section("Search") {
|
||||||
|
if viewModel.searchResults.isEmpty {
|
||||||
|
Text("No results")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
ForEach(viewModel.searchResults) { message in
|
||||||
|
MessageRow(message: message)
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Section {
|
||||||
|
ForEach(Array(viewModel.messages.enumerated()), id: \.element.id) { index, message in
|
||||||
|
if shouldShowDateSeparator(at: index) {
|
||||||
|
DateSeparatorView(timestamp: message.date)
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
}
|
||||||
|
MessageRow(message: message, showsSender: shouldShowSender(at: index))
|
||||||
|
.id(message.id)
|
||||||
|
.contextMenu {
|
||||||
|
Button {
|
||||||
|
viewModel.beginReply(to: message)
|
||||||
|
} label: {
|
||||||
|
Label("Reply", systemImage: "arrowshape.turn.up.left")
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
editingMessage = message
|
||||||
|
editedText = message.text
|
||||||
|
} label: {
|
||||||
|
Label("Edit", systemImage: "pencil")
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
forwardCandidate = message
|
||||||
|
forwardChatIdText = ""
|
||||||
|
} label: {
|
||||||
|
Label("Forward", systemImage: "arrowshape.turn.up.forward")
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
reactionCandidate = message
|
||||||
|
} label: {
|
||||||
|
Label("React", systemImage: "face.smiling")
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
await viewModel.copyPayload(for: message)
|
||||||
|
if let payload = viewModel.copiedPayload {
|
||||||
|
await clipboard.write(text: payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Copy", systemImage: "doc.on.doc")
|
||||||
|
}
|
||||||
|
Button(role: .destructive) {
|
||||||
|
deleteCandidate = message
|
||||||
|
} label: {
|
||||||
|
Label("Delete", systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ComposeBar(
|
||||||
|
text: $viewModel.composeText,
|
||||||
|
replyTo: viewModel.replyTo,
|
||||||
|
cancelReply: { viewModel.cancelReply() }
|
||||||
|
) {
|
||||||
|
Task { await viewModel.send() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(viewModel.chat.title)
|
||||||
|
.searchable(text: $viewModel.searchText)
|
||||||
|
.onSubmit(of: .search) {
|
||||||
|
Task { await viewModel.search() }
|
||||||
|
}
|
||||||
|
.toolbar {
|
||||||
|
Button("Profile") {
|
||||||
|
showsProfile = true
|
||||||
|
Task { await profileViewModel.load(chatId: viewModel.chat.id) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showsProfile) {
|
||||||
|
ProfileView(viewModel: profileViewModel, chatId: viewModel.chat.id) {
|
||||||
|
showsProfile = false
|
||||||
|
onChatLeft()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert("Edit Message", isPresented: editAlertBinding) {
|
||||||
|
TextField("Message", text: $editedText)
|
||||||
|
Button("Save") {
|
||||||
|
if let editingMessage {
|
||||||
|
Task { await viewModel.edit(message: editingMessage, text: editedText) }
|
||||||
|
}
|
||||||
|
editingMessage = nil
|
||||||
|
}
|
||||||
|
Button("Cancel", role: .cancel) {
|
||||||
|
editingMessage = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert("Delete Message", isPresented: deleteAlertBinding) {
|
||||||
|
Button("Delete", role: .destructive) {
|
||||||
|
if let deleteCandidate {
|
||||||
|
Task { await viewModel.delete(message: deleteCandidate) }
|
||||||
|
}
|
||||||
|
deleteCandidate = nil
|
||||||
|
}
|
||||||
|
Button("Cancel", role: .cancel) {
|
||||||
|
deleteCandidate = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert("Forward Message", isPresented: forwardAlertBinding) {
|
||||||
|
TextField("Chat ID", text: $forwardChatIdText)
|
||||||
|
Button("Forward") {
|
||||||
|
if let forwardCandidate, let chatId = Int64(forwardChatIdText) {
|
||||||
|
Task { await viewModel.forward(message: forwardCandidate, to: chatId) }
|
||||||
|
}
|
||||||
|
forwardCandidate = nil
|
||||||
|
}
|
||||||
|
Button("Cancel", role: .cancel) {
|
||||||
|
forwardCandidate = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.confirmationDialog("React", isPresented: reactionDialogBinding, titleVisibility: .visible) {
|
||||||
|
ForEach(["👍", "❤️", "😂", "😮", "😢", "🙏"], id: \.self) { reaction in
|
||||||
|
Button(reaction) {
|
||||||
|
if let reactionCandidate {
|
||||||
|
Task { await viewModel.react(message: reactionCandidate, reaction: reaction) }
|
||||||
|
}
|
||||||
|
reactionCandidate = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button("Cancel", role: .cancel) {
|
||||||
|
reactionCandidate = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await viewModel.load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var editAlertBinding: Binding<Bool> {
|
||||||
|
Binding(
|
||||||
|
get: { editingMessage != nil },
|
||||||
|
set: { isPresented in
|
||||||
|
if !isPresented {
|
||||||
|
editingMessage = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var deleteAlertBinding: Binding<Bool> {
|
||||||
|
Binding(
|
||||||
|
get: { deleteCandidate != nil },
|
||||||
|
set: { isPresented in
|
||||||
|
if !isPresented {
|
||||||
|
deleteCandidate = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var forwardAlertBinding: Binding<Bool> {
|
||||||
|
Binding(
|
||||||
|
get: { forwardCandidate != nil },
|
||||||
|
set: { isPresented in
|
||||||
|
if !isPresented {
|
||||||
|
forwardCandidate = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var reactionDialogBinding: Binding<Bool> {
|
||||||
|
Binding(
|
||||||
|
get: { reactionCandidate != nil },
|
||||||
|
set: { isPresented in
|
||||||
|
if !isPresented {
|
||||||
|
reactionCandidate = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func shouldShowDateSeparator(at index: Int) -> Bool {
|
||||||
|
guard viewModel.messages.indices.contains(index), viewModel.messages[index].date > 0 else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
guard index > 0, viewModel.messages.indices.contains(index - 1) else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
let current = Date(timeIntervalSince1970: TimeInterval(viewModel.messages[index].date))
|
||||||
|
let previous = Date(timeIntervalSince1970: TimeInterval(viewModel.messages[index - 1].date))
|
||||||
|
return !Calendar.current.isDate(current, inSameDayAs: previous)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func shouldShowSender(at index: Int) -> Bool {
|
||||||
|
guard viewModel.messages.indices.contains(index) else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
let message = viewModel.messages[index]
|
||||||
|
guard !message.isOutgoing else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
guard index > 0, viewModel.messages.indices.contains(index - 1) else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
let previous = viewModel.messages[index - 1]
|
||||||
|
return previous.isOutgoing
|
||||||
|
|| previous.senderName != message.senderName
|
||||||
|
|| shouldShowDateSeparator(at: index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct DateSeparatorView: View {
|
||||||
|
public var timestamp: Int32
|
||||||
|
|
||||||
|
public init(timestamp: Int32) {
|
||||||
|
self.timestamp = timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Text(label)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(Color.gray.opacity(0.12), in: Capsule())
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var label: String {
|
||||||
|
let date = Date(timeIntervalSince1970: TimeInterval(timestamp))
|
||||||
|
let calendar = Calendar.current
|
||||||
|
if calendar.isDateInToday(date) {
|
||||||
|
return "Today"
|
||||||
|
}
|
||||||
|
if calendar.isDateInYesterday(date) {
|
||||||
|
return "Yesterday"
|
||||||
|
}
|
||||||
|
return Self.formatter.string(from: date)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let formatter: DateFormatter = {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateStyle = .medium
|
||||||
|
formatter.timeStyle = .none
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct PinnedMessagesBar: View {
|
||||||
|
public var messages: [Message]
|
||||||
|
public var select: (Message) -> Void
|
||||||
|
|
||||||
|
public init(messages: [Message], select: @escaping (Message) -> Void) {
|
||||||
|
self.messages = messages
|
||||||
|
self.select = select
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ForEach(messages.prefix(3)) { message in
|
||||||
|
Button {
|
||||||
|
select(message)
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "pin.fill")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(message.senderName)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(message.text)
|
||||||
|
.font(.subheadline)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(Color.blue.opacity(0.08))
|
||||||
|
.overlay(alignment: .bottom) {
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct MessageRow: View {
|
||||||
|
public var message: Message
|
||||||
|
public var showsSender: Bool
|
||||||
|
|
||||||
|
public init(message: Message, showsSender: Bool = true) {
|
||||||
|
self.message = message
|
||||||
|
self.showsSender = showsSender
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
HStack {
|
||||||
|
if message.isOutgoing {
|
||||||
|
Spacer(minLength: 48)
|
||||||
|
}
|
||||||
|
VStack(alignment: .leading, spacing: 5) {
|
||||||
|
if showsSender && !message.isOutgoing {
|
||||||
|
Text(message.senderName)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
if let replyText = message.replyText {
|
||||||
|
Text(replyText)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.leading, 6)
|
||||||
|
}
|
||||||
|
if let forwardSenderName = message.forwardSenderName {
|
||||||
|
Label(forwardSenderName, systemImage: "arrowshape.turn.up.forward")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
if let media = message.media {
|
||||||
|
MediaPlaceholderView(media: media, mediaAlbumId: message.mediaAlbumId)
|
||||||
|
}
|
||||||
|
Text(renderedText)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
if !message.reactions.isEmpty {
|
||||||
|
Text(message.reactions.map(\.emoji).joined(separator: " "))
|
||||||
|
.font(.caption)
|
||||||
|
}
|
||||||
|
if message.editDate != nil || message.date > 0 || message.isOutgoing {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
if message.date > 0 {
|
||||||
|
Text(Self.timeFormatter.string(from: Date(timeIntervalSince1970: TimeInterval(message.date))))
|
||||||
|
}
|
||||||
|
if message.editDate != nil {
|
||||||
|
Text("edited")
|
||||||
|
}
|
||||||
|
if message.isOutgoing {
|
||||||
|
Image(systemName: message.isRead ? "checkmark.circle.fill" : "checkmark.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(10)
|
||||||
|
.background(message.isOutgoing ? Color.blue.opacity(0.16) : Color.gray.opacity(0.12), in: RoundedRectangle(cornerRadius: 8))
|
||||||
|
if !message.isOutgoing {
|
||||||
|
Spacer(minLength: 48)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var renderedText: AttributedString {
|
||||||
|
(
|
||||||
|
try? AttributedString(
|
||||||
|
markdown: message.text,
|
||||||
|
options: AttributedString.MarkdownParsingOptions(
|
||||||
|
interpretedSyntax: .inlineOnlyPreservingWhitespace
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) ?? AttributedString(message.text)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let timeFormatter: DateFormatter = {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateStyle = .none
|
||||||
|
formatter.timeStyle = .short
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct MediaPlaceholderView: View {
|
||||||
|
public var media: MessageMedia
|
||||||
|
public var mediaAlbumId: Int64?
|
||||||
|
|
||||||
|
public init(media: MessageMedia, mediaAlbumId: Int64? = nil) {
|
||||||
|
self.media = media
|
||||||
|
self.mediaAlbumId = mediaAlbumId
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: iconName)
|
||||||
|
.frame(width: 22, height: 22)
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text(title)
|
||||||
|
.font(.subheadline)
|
||||||
|
if mediaAlbumId != nil {
|
||||||
|
Image(systemName: "square.stack.3d.up.fill")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(detail)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.padding(8)
|
||||||
|
.background(Color.gray.opacity(0.10), in: RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var iconName: String {
|
||||||
|
switch media {
|
||||||
|
case .photo:
|
||||||
|
"photo"
|
||||||
|
case .voice:
|
||||||
|
"waveform"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var title: String {
|
||||||
|
switch media {
|
||||||
|
case .photo:
|
||||||
|
"Photo"
|
||||||
|
case .voice:
|
||||||
|
"Voice"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var detail: String {
|
||||||
|
switch media {
|
||||||
|
case let .photo(photo):
|
||||||
|
"\(photo.width)x\(photo.height) · \(downloadLabel(photo.downloadState))"
|
||||||
|
case let .voice(voice):
|
||||||
|
"\(voice.duration)s · \(downloadLabel(voice.downloadState))"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func downloadLabel(_ state: MediaDownloadState) -> String {
|
||||||
|
switch state {
|
||||||
|
case .notDownloaded:
|
||||||
|
"not downloaded"
|
||||||
|
case .downloading:
|
||||||
|
"downloading"
|
||||||
|
case .downloaded:
|
||||||
|
"downloaded"
|
||||||
|
case .error:
|
||||||
|
"error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ComposeBar: View {
|
||||||
|
@Binding public var text: String
|
||||||
|
public var replyTo: Message?
|
||||||
|
public var cancelReply: () -> Void
|
||||||
|
public var send: () -> Void
|
||||||
|
|
||||||
|
public init(
|
||||||
|
text: Binding<String>,
|
||||||
|
replyTo: Message? = nil,
|
||||||
|
cancelReply: @escaping () -> Void = {},
|
||||||
|
send: @escaping () -> Void
|
||||||
|
) {
|
||||||
|
_text = text
|
||||||
|
self.replyTo = replyTo
|
||||||
|
self.cancelReply = cancelReply
|
||||||
|
self.send = send
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
if let replyTo {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(replyTo.senderName)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(replyTo.text)
|
||||||
|
.font(.footnote)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Button(action: cancelReply) {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(Color.gray.opacity(0.12), in: RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
if !text.isEmpty {
|
||||||
|
Button {
|
||||||
|
text = ""
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
TextField("Message", text: $text, axis: .vertical)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.lineLimit(1...5)
|
||||||
|
Button {
|
||||||
|
send()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "paperplane.fill")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.background(.bar)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ProfileView: View {
|
||||||
|
@ObservedObject public var viewModel: ProfileViewModel
|
||||||
|
public var chatId: Int64?
|
||||||
|
public var onLeave: () -> Void
|
||||||
|
@State private var confirmsLeave = false
|
||||||
|
|
||||||
|
public init(viewModel: ProfileViewModel, chatId: Int64? = nil, onLeave: @escaping () -> Void = {}) {
|
||||||
|
self.viewModel = viewModel
|
||||||
|
self.chatId = chatId
|
||||||
|
self.onLeave = onLeave
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
if let profile = viewModel.profile {
|
||||||
|
Section {
|
||||||
|
Text(profile.title)
|
||||||
|
.font(.title2)
|
||||||
|
if let username = profile.username {
|
||||||
|
Text("@\(username)")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
if let bio = profile.bio {
|
||||||
|
Text(bio)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let memberCount = profile.memberCount {
|
||||||
|
Section {
|
||||||
|
Text("\(memberCount) members")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if profile.isGroup, chatId != nil {
|
||||||
|
Section {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
confirmsLeave = true
|
||||||
|
} label: {
|
||||||
|
Label("Leave Chat", systemImage: "rectangle.portrait.and.arrow.right")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ProgressView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Profile")
|
||||||
|
.alert("Leave Chat", isPresented: $confirmsLeave) {
|
||||||
|
Button("Leave", role: .destructive) {
|
||||||
|
if let chatId {
|
||||||
|
Task {
|
||||||
|
await viewModel.leave(chatId: chatId)
|
||||||
|
if viewModel.errorMessage == nil {
|
||||||
|
onLeave()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button("Cancel", role: .cancel) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct AccountSwitcherView: View {
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
Section {
|
||||||
|
Label("Default", systemImage: "person.crop.circle")
|
||||||
|
Label("Add account", systemImage: "plus.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Accounts")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
252
apps/ios/TeleTuiIOS/Sources/TeleTuiIOSSmokeTests/main.swift
Normal file
252
apps/ios/TeleTuiIOS/Sources/TeleTuiIOSSmokeTests/main.swift
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
import Foundation
|
||||||
|
import TeleTuiIOSCore
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct TeleTuiIOSSmokeTests {
|
||||||
|
static func main() async throws {
|
||||||
|
try await authFlowMatchesAllInteractiveStates()
|
||||||
|
try await chatListLoadsDeterministicFakeDataAndFilters()
|
||||||
|
try await chatDetailLoadsAndSendsMessage()
|
||||||
|
try await messageActionsCoverEditReplyForwardReactDeleteSearchAndCopy()
|
||||||
|
try await sessionBridgeFactoryUsesAvailableDefaultBridge()
|
||||||
|
try await platformServicesCoverNotificationsMediaVoiceClipboardAndAccounts()
|
||||||
|
lifecycleCoordinatorDropsStaleAccountEvents()
|
||||||
|
try await profileLoadsFromSelectedChat()
|
||||||
|
appStorageUsesApplicationSupportStyleAccountPaths()
|
||||||
|
print("TeleTuiIOS smoke tests passed")
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private static func authFlowMatchesAllInteractiveStates() async throws {
|
||||||
|
let account = Account(id: "fake", displayName: "Fake", databasePath: URL(fileURLWithPath: "/tmp/fake"))
|
||||||
|
let store = SessionStore(account: account, bridge: FakeSessionBridge())
|
||||||
|
let viewModel = AuthViewModel(store: store)
|
||||||
|
|
||||||
|
await store.refreshAuthState()
|
||||||
|
precondition(store.authState == .waitPhoneNumber)
|
||||||
|
await store.refreshNetworkState()
|
||||||
|
precondition(store.networkState == .ready)
|
||||||
|
store.apply(events: [.typingChanged(.typing(chatId: 1, userId: 10, text: "typing"))])
|
||||||
|
precondition(store.typingState == .typing(chatId: 1, userId: 10, text: "typing"))
|
||||||
|
|
||||||
|
viewModel.phone = "+10000000000"
|
||||||
|
await viewModel.submitCurrentStep()
|
||||||
|
precondition(store.authState == .waitCode)
|
||||||
|
|
||||||
|
viewModel.code = "12345"
|
||||||
|
await viewModel.submitCurrentStep()
|
||||||
|
precondition(store.authState == .waitPassword)
|
||||||
|
|
||||||
|
viewModel.password = "secret"
|
||||||
|
await viewModel.submitCurrentStep()
|
||||||
|
precondition(store.authState == .ready)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private static func chatListLoadsDeterministicFakeDataAndFilters() async throws {
|
||||||
|
let bridge = FakeSessionBridge(auth: .ready)
|
||||||
|
let viewModel = ChatListViewModel(bridge: bridge)
|
||||||
|
|
||||||
|
await viewModel.load()
|
||||||
|
precondition(viewModel.folders.map(\.name) == ["All", "Work"])
|
||||||
|
precondition(viewModel.chats.map(\.title) == ["Saved Messages", "iOS Team"])
|
||||||
|
|
||||||
|
viewModel.searchText = "team"
|
||||||
|
precondition(viewModel.filteredChats.map(\.title) == ["iOS Team"])
|
||||||
|
|
||||||
|
viewModel.searchText = ""
|
||||||
|
viewModel.selectedFolderId = 2
|
||||||
|
await viewModel.load()
|
||||||
|
precondition(viewModel.chats.map(\.title) == ["iOS Team"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private static func chatDetailLoadsAndSendsMessage() async throws {
|
||||||
|
let bridge = FakeSessionBridge(auth: .ready)
|
||||||
|
let chat = try await bridge.loadChats(folderId: nil)[0]
|
||||||
|
let viewModel = ChatViewModel(chat: chat, bridge: bridge)
|
||||||
|
|
||||||
|
await viewModel.load()
|
||||||
|
precondition(viewModel.messages.count == 1)
|
||||||
|
precondition(viewModel.messages[0].date == 1_700_000_000)
|
||||||
|
precondition(viewModel.pinnedMessages.map(\.id) == [1])
|
||||||
|
if case let .photo(photo) = viewModel.messages[0].media {
|
||||||
|
precondition(photo.fileId == 100)
|
||||||
|
precondition(photo.width == 1280)
|
||||||
|
precondition(photo.height == 720)
|
||||||
|
} else {
|
||||||
|
preconditionFailure("fake saved message should contain photo media")
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel.composeText = "Hi from SwiftUI"
|
||||||
|
await viewModel.send()
|
||||||
|
precondition(viewModel.messages.last?.text == "Hi from SwiftUI")
|
||||||
|
precondition(viewModel.composeText.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private static func messageActionsCoverEditReplyForwardReactDeleteSearchAndCopy() async throws {
|
||||||
|
let bridge = FakeSessionBridge(auth: .ready)
|
||||||
|
let chat = try await bridge.loadChats(folderId: nil)[0]
|
||||||
|
let viewModel = ChatViewModel(chat: chat, bridge: bridge)
|
||||||
|
|
||||||
|
await viewModel.load()
|
||||||
|
guard let first = viewModel.messages.first else {
|
||||||
|
preconditionFailure("fake chat should contain a message")
|
||||||
|
}
|
||||||
|
|
||||||
|
await viewModel.edit(message: first, text: "Edited text")
|
||||||
|
precondition(viewModel.messages.first?.text == "Edited text")
|
||||||
|
precondition(viewModel.messages.first?.editDate != nil)
|
||||||
|
|
||||||
|
viewModel.beginReply(to: viewModel.messages[0])
|
||||||
|
viewModel.composeText = "Reply text"
|
||||||
|
await viewModel.send()
|
||||||
|
precondition(viewModel.messages.last?.replyText == "Reply to #1")
|
||||||
|
|
||||||
|
await viewModel.react(message: viewModel.messages[0], reaction: "👍")
|
||||||
|
precondition(viewModel.messages[0].reactions.first?.emoji == "👍")
|
||||||
|
|
||||||
|
viewModel.searchText = "reply"
|
||||||
|
await viewModel.search()
|
||||||
|
precondition(viewModel.searchResults.count == 1)
|
||||||
|
|
||||||
|
await viewModel.copyPayload(for: viewModel.messages[0])
|
||||||
|
precondition(viewModel.copiedPayload == "Edited text")
|
||||||
|
|
||||||
|
viewModel.composeText = "Draft text"
|
||||||
|
await viewModel.saveDraft()
|
||||||
|
let draftEvents = try await bridge.pollEvents()
|
||||||
|
precondition(draftEvents.contains { event in
|
||||||
|
if case let .draftChanged(draft) = event {
|
||||||
|
return draft.chatId == chat.id && draft.text == "Draft text"
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
let photo = try await bridge.downloadPhoto(fileId: 100)
|
||||||
|
let voice = try await bridge.downloadVoice(fileId: 200)
|
||||||
|
precondition(photo.path == "/tmp/fake-photo-100.jpg")
|
||||||
|
precondition(voice.path == "/tmp/fake-voice-200.ogg")
|
||||||
|
|
||||||
|
await viewModel.forward(message: viewModel.messages[0], to: 2)
|
||||||
|
let forwarded = try await bridge.loadHistory(chatId: 2)
|
||||||
|
precondition(forwarded.contains { $0.forwardSenderName == "Alice" && $0.text == "Edited text" })
|
||||||
|
|
||||||
|
await viewModel.delete(message: viewModel.messages[0])
|
||||||
|
precondition(!viewModel.messages.contains { $0.id == 1 })
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private static func sessionBridgeFactoryUsesAvailableDefaultBridge() async throws {
|
||||||
|
let account = Account(id: "factory", displayName: "Factory", databasePath: URL(fileURLWithPath: "/tmp/factory"))
|
||||||
|
let bridge = SessionBridgeFactory.makeDefaultBridge(account: account)
|
||||||
|
let auth = try await bridge.authState()
|
||||||
|
precondition(auth == .ready)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private static func platformServicesCoverNotificationsMediaVoiceClipboardAndAccounts() async throws {
|
||||||
|
let root = URL(fileURLWithPath: "/tmp/TeleTuiIOS")
|
||||||
|
let paths = AppStoragePaths(root: root)
|
||||||
|
let cache = MediaCache(root: paths.mediaCachePath(for: "work"))
|
||||||
|
precondition(cache.photoPath(fileId: 10).path == "/tmp/TeleTuiIOS/Accounts/work/Media/photos/10.jpg")
|
||||||
|
precondition(cache.voicePath(fileId: 20).path == "/tmp/TeleTuiIOS/Accounts/work/Media/voices/20.ogg")
|
||||||
|
|
||||||
|
let policy = NotificationPolicy()
|
||||||
|
let chat = ChatSummary(id: 1, title: "Chat", lastMessage: "hello", isMuted: false)
|
||||||
|
let muted = ChatSummary(id: 2, title: "Muted", lastMessage: "hello", isMuted: true)
|
||||||
|
let incomingMention = Message(id: 1, chatId: 1, senderName: "Alice", text: "@me hello", isOutgoing: false)
|
||||||
|
let incomingPlain = Message(id: 2, chatId: 1, senderName: "Alice", text: "hello", isOutgoing: false)
|
||||||
|
precondition(policy.shouldNotify(chat: chat, message: incomingMention, mentionOnly: true))
|
||||||
|
precondition(!policy.shouldNotify(chat: chat, message: incomingPlain, mentionOnly: true))
|
||||||
|
precondition(!policy.shouldNotify(chat: muted, message: incomingMention, mentionOnly: false))
|
||||||
|
let scheduler = RecordingNotificationScheduler()
|
||||||
|
let mentionCoordinator = NotificationCoordinator(scheduler: scheduler, mentionOnly: true)
|
||||||
|
try await mentionCoordinator.handle(chat: chat, message: incomingPlain)
|
||||||
|
var scheduledCount = await scheduler.scheduledCount()
|
||||||
|
precondition(scheduledCount == 0)
|
||||||
|
try await mentionCoordinator.handle(chat: chat, message: incomingMention)
|
||||||
|
scheduledCount = await scheduler.scheduledCount()
|
||||||
|
precondition(scheduledCount == 1)
|
||||||
|
try await mentionCoordinator.handle(chat: muted, message: incomingMention)
|
||||||
|
scheduledCount = await scheduler.scheduledCount()
|
||||||
|
precondition(scheduledCount == 1)
|
||||||
|
|
||||||
|
let clipboard = InMemoryClipboardWriter()
|
||||||
|
await clipboard.write(text: "copied")
|
||||||
|
let copiedText = await clipboard.currentText()
|
||||||
|
precondition(copiedText == "copied")
|
||||||
|
|
||||||
|
let player = RecordingVoicePlayer()
|
||||||
|
let mediaViewModel = MediaViewModel(cache: cache, voicePlayer: player)
|
||||||
|
mediaViewModel.showPhoto(path: "/tmp/photo.jpg")
|
||||||
|
mediaViewModel.showVoice(path: "/tmp/voice.ogg")
|
||||||
|
precondition(mediaViewModel.activePhotoPath == "/tmp/photo.jpg")
|
||||||
|
precondition(mediaViewModel.activeVoicePath == "/tmp/voice.ogg")
|
||||||
|
let voiceURL = cache.voicePath(fileId: 20)
|
||||||
|
await mediaViewModel.playVoice(url: voiceURL)
|
||||||
|
precondition(mediaViewModel.isVoicePlaying)
|
||||||
|
let loadedURL = await player.currentLoadedURL()
|
||||||
|
precondition(loadedURL == voiceURL)
|
||||||
|
await mediaViewModel.pauseVoice()
|
||||||
|
precondition(!mediaViewModel.isVoicePlaying)
|
||||||
|
|
||||||
|
let personal = Account(id: "personal", displayName: "Personal", databasePath: paths.databasePath(for: "personal"))
|
||||||
|
let work = Account(id: "work", displayName: "Work", databasePath: paths.databasePath(for: "work"))
|
||||||
|
let switcher = AccountSwitcherViewModel(accounts: [personal, work], activeAccount: personal)
|
||||||
|
switcher.switchToAccount(id: "work")
|
||||||
|
precondition(switcher.activeAccount.id == "work")
|
||||||
|
precondition(switcher.activeAccount.databasePath.path.hasSuffix("/Accounts/work/tdlib"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private static func lifecycleCoordinatorDropsStaleAccountEvents() {
|
||||||
|
let coordinator = SessionLifecycleCoordinator(activeAccountId: "personal")
|
||||||
|
precondition(coordinator.shouldPollEvents)
|
||||||
|
|
||||||
|
coordinator.enterBackground()
|
||||||
|
precondition(!coordinator.shouldPollEvents)
|
||||||
|
|
||||||
|
coordinator.enterForeground()
|
||||||
|
precondition(coordinator.shouldPollEvents)
|
||||||
|
|
||||||
|
let oldGenerationEvent = ScopedSessionEvent(
|
||||||
|
accountId: "personal",
|
||||||
|
generation: coordinator.generation,
|
||||||
|
event: .authChanged(.ready)
|
||||||
|
)
|
||||||
|
precondition(coordinator.accepts(oldGenerationEvent))
|
||||||
|
|
||||||
|
coordinator.switchAccount(to: "work")
|
||||||
|
precondition(!coordinator.accepts(oldGenerationEvent))
|
||||||
|
|
||||||
|
let newGenerationEvent = ScopedSessionEvent(
|
||||||
|
accountId: "work",
|
||||||
|
generation: coordinator.generation,
|
||||||
|
event: .authChanged(.ready)
|
||||||
|
)
|
||||||
|
precondition(coordinator.accepts(newGenerationEvent))
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private static func profileLoadsFromSelectedChat() async throws {
|
||||||
|
let bridge = FakeSessionBridge(auth: .ready)
|
||||||
|
let viewModel = ProfileViewModel(bridge: bridge)
|
||||||
|
|
||||||
|
await viewModel.load(chatId: 1)
|
||||||
|
precondition(viewModel.profile?.title == "Saved Messages")
|
||||||
|
precondition(viewModel.profile?.username == "saved")
|
||||||
|
|
||||||
|
await viewModel.leave(chatId: 1)
|
||||||
|
let chats = try await bridge.loadChats(folderId: nil)
|
||||||
|
precondition(!chats.contains { $0.id == 1 })
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func appStorageUsesApplicationSupportStyleAccountPaths() {
|
||||||
|
let root = URL(fileURLWithPath: "/tmp/TeleTuiIOS")
|
||||||
|
let paths = AppStoragePaths(root: root)
|
||||||
|
|
||||||
|
precondition(paths.databasePath(for: "work").path == "/tmp/TeleTuiIOS/Accounts/work/tdlib")
|
||||||
|
}
|
||||||
|
}
|
||||||
31
crates/tele-core/Cargo.toml
Normal file
31
crates/tele-core/Cargo.toml
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
[package]
|
||||||
|
name = "tele-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
authors = ["Your Name <your.email@example.com>"]
|
||||||
|
description = "Reusable Telegram/TDLib core for tele-tui"
|
||||||
|
license = "MIT"
|
||||||
|
repository = "https://github.com/your-username/tele-tui"
|
||||||
|
keywords = ["telegram", "tdlib"]
|
||||||
|
categories = ["api-bindings"]
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["tdlib-download"]
|
||||||
|
images = []
|
||||||
|
test-support = []
|
||||||
|
tdlib-download = ["tdlib-rs/download-tdlib"]
|
||||||
|
tdlib-local = ["tdlib-rs/local-tdlib"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tdlib-rs = { version = "1.2.0", default-features = false }
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
async-trait = "0.1"
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
chrono = "0.4"
|
||||||
|
thiserror = "1.0"
|
||||||
|
tracing = "0.1"
|
||||||
|
base64 = "0.22.1"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio-test = "0.4"
|
||||||
5
crates/tele-core/src/accounts/mod.rs
Normal file
5
crates/tele-core/src/accounts/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
//! Account profile data structures and validation.
|
||||||
|
|
||||||
|
pub mod profile;
|
||||||
|
|
||||||
|
pub use profile::{validate_account_name, AccountProfile, AccountsConfig};
|
||||||
114
crates/tele-core/src/accounts/profile.rs
Normal file
114
crates/tele-core/src/accounts/profile.rs
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
//! Account profile data structures and validation.
|
||||||
|
//!
|
||||||
|
//! Defines `AccountProfile` and `AccountsConfig` for multi-account support.
|
||||||
|
//! Account names are validated to contain only alphanumeric characters, hyphens, and underscores.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Configuration for all accounts, stored in `~/.config/tele-tui/accounts.toml`.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AccountsConfig {
|
||||||
|
/// Name of the default account to use when no `--account` flag is provided.
|
||||||
|
pub default_account: String,
|
||||||
|
|
||||||
|
/// List of configured accounts.
|
||||||
|
pub accounts: Vec<AccountProfile>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single account profile.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AccountProfile {
|
||||||
|
/// Unique identifier (used in directory names and CLI flag).
|
||||||
|
pub name: String,
|
||||||
|
|
||||||
|
/// Human-readable display name.
|
||||||
|
pub display_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AccountsConfig {
|
||||||
|
/// Creates a default config with a single "default" account.
|
||||||
|
pub fn default_single() -> Self {
|
||||||
|
Self {
|
||||||
|
default_account: "default".to_string(),
|
||||||
|
accounts: vec![AccountProfile {
|
||||||
|
name: "default".to_string(),
|
||||||
|
display_name: "Default".to_string(),
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finds an account by name.
|
||||||
|
pub fn find_account(&self, name: &str) -> Option<&AccountProfile> {
|
||||||
|
self.accounts.iter().find(|a| a.name == name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates an account name.
|
||||||
|
///
|
||||||
|
/// Valid names contain only lowercase alphanumeric characters, hyphens, and underscores.
|
||||||
|
/// Must be 1-32 characters long.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns a descriptive error message if the name is invalid.
|
||||||
|
pub fn validate_account_name(name: &str) -> Result<(), String> {
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err("Account name cannot be empty".to_string());
|
||||||
|
}
|
||||||
|
if name.len() > 32 {
|
||||||
|
return Err("Account name cannot be longer than 32 characters".to_string());
|
||||||
|
}
|
||||||
|
if !name
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
|
||||||
|
{
|
||||||
|
return Err(
|
||||||
|
"Account name can only contain lowercase letters, digits, hyphens, and underscores"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if name.starts_with('-') || name.starts_with('_') {
|
||||||
|
return Err("Account name cannot start with a hyphen or underscore".to_string());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_account_name_valid() {
|
||||||
|
assert!(validate_account_name("default").is_ok());
|
||||||
|
assert!(validate_account_name("work").is_ok());
|
||||||
|
assert!(validate_account_name("my-account").is_ok());
|
||||||
|
assert!(validate_account_name("account_2").is_ok());
|
||||||
|
assert!(validate_account_name("a").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_account_name_invalid() {
|
||||||
|
assert!(validate_account_name("").is_err());
|
||||||
|
assert!(validate_account_name("My Account").is_err());
|
||||||
|
assert!(validate_account_name("UPPER").is_err());
|
||||||
|
assert!(validate_account_name("with spaces").is_err());
|
||||||
|
assert!(validate_account_name("-starts-with-dash").is_err());
|
||||||
|
assert!(validate_account_name("_starts-with-underscore").is_err());
|
||||||
|
assert!(validate_account_name(&"a".repeat(33)).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_single_config() {
|
||||||
|
let config = AccountsConfig::default_single();
|
||||||
|
assert_eq!(config.default_account, "default");
|
||||||
|
assert_eq!(config.accounts.len(), 1);
|
||||||
|
assert_eq!(config.accounts[0].name, "default");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_find_account() {
|
||||||
|
let config = AccountsConfig::default_single();
|
||||||
|
assert!(config.find_account("default").is_some());
|
||||||
|
assert!(config.find_account("nonexistent").is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
6
crates/tele-core/src/constants.rs
Normal file
6
crates/tele-core/src/constants.rs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
pub const MAX_MESSAGES_IN_CHAT: usize = 500;
|
||||||
|
pub const MAX_USER_CACHE_SIZE: usize = 500;
|
||||||
|
pub const MAX_CHATS: usize = 200;
|
||||||
|
pub const MAX_CHAT_USER_IDS: usize = 500;
|
||||||
|
pub const LAZY_LOAD_USERS_PER_TICK: usize = 5;
|
||||||
|
pub const TDLIB_MESSAGE_LIMIT: i32 = 50;
|
||||||
12
crates/tele-core/src/lib.rs
Normal file
12
crates/tele-core/src/lib.rs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
//! Reusable Telegram/TDLib core for tele-tui and future clients.
|
||||||
|
|
||||||
|
mod constants;
|
||||||
|
mod utils;
|
||||||
|
|
||||||
|
pub mod accounts;
|
||||||
|
pub mod message_grouping;
|
||||||
|
pub mod session;
|
||||||
|
pub mod tdlib;
|
||||||
|
#[cfg(any(test, feature = "test-support"))]
|
||||||
|
pub mod test_support;
|
||||||
|
pub mod types;
|
||||||
@@ -35,10 +35,10 @@ pub enum MessageGroup<'a> {
|
|||||||
/// # Примеры
|
/// # Примеры
|
||||||
///
|
///
|
||||||
/// ```no_run
|
/// ```no_run
|
||||||
/// use tele_tui::message_grouping::{group_messages, MessageGroup};
|
/// use tele_core::message_grouping::{group_messages, MessageGroup};
|
||||||
///
|
///
|
||||||
/// # use tele_tui::tdlib::types::MessageBuilder;
|
/// # use tele_core::tdlib::types::MessageBuilder;
|
||||||
/// # use tele_tui::types::MessageId;
|
/// # use tele_core::types::MessageId;
|
||||||
/// # let msg = MessageBuilder::new(MessageId::new(1)).sender_name("Alice").text("Hello").build();
|
/// # let msg = MessageBuilder::new(MessageId::new(1)).sender_name("Alice").text("Hello").build();
|
||||||
/// let messages = vec![msg];
|
/// let messages = vec![msg];
|
||||||
/// let grouped = group_messages(&messages);
|
/// let grouped = group_messages(&messages);
|
||||||
997
crates/tele-core/src/session.rs
Normal file
997
crates/tele-core/src/session.rs
Normal file
@@ -0,0 +1,997 @@
|
|||||||
|
use crate::tdlib::types::ForwardInfo;
|
||||||
|
use crate::tdlib::{
|
||||||
|
AuthState, ChatInfo, FolderInfo, MediaInfo, MessageInfo, NetworkState, ProfileInfo, ReplyInfo,
|
||||||
|
TdClientTrait,
|
||||||
|
};
|
||||||
|
use crate::types::{ChatId, MessageId, UserId};
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
|
/// Platform-neutral Telegram session facade for native clients.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CoreSession<C> {
|
||||||
|
client: C,
|
||||||
|
events: VecDeque<CoreEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<C> CoreSession<C> {
|
||||||
|
pub fn new(client: C) -> Self {
|
||||||
|
Self { client, events: VecDeque::new() }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn client(&self) -> &C {
|
||||||
|
&self.client
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn client_mut(&mut self) -> &mut C {
|
||||||
|
&mut self.client
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn into_client(self) -> C {
|
||||||
|
self.client
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn enqueue_event(&mut self, event: CoreEvent) {
|
||||||
|
self.events.push_back(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn poll_events(&mut self) -> Vec<CoreEvent> {
|
||||||
|
self.events.drain(..).collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<C: TdClientTrait> CoreSession<C> {
|
||||||
|
pub fn auth_state(&self) -> CoreAuthState {
|
||||||
|
CoreAuthState::from(self.client.auth_state())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn network_state(&self) -> CoreNetworkState {
|
||||||
|
CoreNetworkState::from(&self.client.network_state())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn emit_auth_state(&mut self) -> CoreAuthState {
|
||||||
|
let state = self.auth_state();
|
||||||
|
self.enqueue_event(CoreEvent::AuthChanged(state.clone()));
|
||||||
|
state
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn emit_network_state(&mut self) -> CoreNetworkState {
|
||||||
|
let state = self.network_state();
|
||||||
|
self.enqueue_event(CoreEvent::NetworkChanged(state.clone()));
|
||||||
|
state
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn send_phone_number(&self, phone: String) -> Result<(), String> {
|
||||||
|
self.client.send_phone_number(phone).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn send_code(&self, code: String) -> Result<(), String> {
|
||||||
|
self.client.send_code(code).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn send_password(&self, password: String) -> Result<(), String> {
|
||||||
|
self.client.send_password(password).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_chats(&mut self, limit: i32) -> Result<Vec<CoreChatSummary>, String> {
|
||||||
|
self.client.load_chats(limit).await?;
|
||||||
|
let chats = self.chat_summaries();
|
||||||
|
self.enqueue_event(CoreEvent::ChatListChanged(chats.clone()));
|
||||||
|
Ok(chats)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_folder_chats(
|
||||||
|
&mut self,
|
||||||
|
folder_id: i32,
|
||||||
|
limit: i32,
|
||||||
|
) -> Result<Vec<CoreChatSummary>, String> {
|
||||||
|
self.client.load_folder_chats(folder_id, limit).await?;
|
||||||
|
let chats = self.chat_summaries();
|
||||||
|
self.enqueue_event(CoreEvent::ChatListChanged(chats.clone()));
|
||||||
|
Ok(chats)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn chat_summaries(&self) -> Vec<CoreChatSummary> {
|
||||||
|
self.client
|
||||||
|
.chats()
|
||||||
|
.iter()
|
||||||
|
.map(CoreChatSummary::from)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn folders(&self) -> Vec<CoreFolder> {
|
||||||
|
self.client.folders().iter().map(CoreFolder::from).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn open_chat_history(
|
||||||
|
&mut self,
|
||||||
|
chat_id: ChatId,
|
||||||
|
limit: i32,
|
||||||
|
) -> Result<Vec<CoreMessage>, String> {
|
||||||
|
self.client.set_current_chat_id(Some(chat_id));
|
||||||
|
let messages = self.client.get_chat_history(chat_id, limit).await?;
|
||||||
|
let messages = messages.iter().map(CoreMessage::from).collect();
|
||||||
|
Ok(messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn send_text_message(
|
||||||
|
&mut self,
|
||||||
|
chat_id: ChatId,
|
||||||
|
text: String,
|
||||||
|
reply_to_message_id: Option<MessageId>,
|
||||||
|
reply: Option<ReplyInfo>,
|
||||||
|
) -> Result<CoreMessage, String> {
|
||||||
|
let message = self
|
||||||
|
.client
|
||||||
|
.send_message(chat_id, text, reply_to_message_id, reply)
|
||||||
|
.await?;
|
||||||
|
let message = CoreMessage::from(&message);
|
||||||
|
self.enqueue_event(CoreEvent::MessageAdded { chat_id, message: message.clone() });
|
||||||
|
Ok(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn edit_text_message(
|
||||||
|
&mut self,
|
||||||
|
chat_id: ChatId,
|
||||||
|
message_id: MessageId,
|
||||||
|
text: String,
|
||||||
|
) -> Result<CoreMessage, String> {
|
||||||
|
let message = self.client.edit_message(chat_id, message_id, text).await?;
|
||||||
|
let message = CoreMessage::from(&message);
|
||||||
|
self.enqueue_event(CoreEvent::MessageUpdated { chat_id, message: message.clone() });
|
||||||
|
Ok(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_messages(
|
||||||
|
&mut self,
|
||||||
|
chat_id: ChatId,
|
||||||
|
message_ids: Vec<MessageId>,
|
||||||
|
revoke: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
self.client
|
||||||
|
.delete_messages(chat_id, message_ids.clone(), revoke)
|
||||||
|
.await?;
|
||||||
|
self.enqueue_event(CoreEvent::MessageDeleted { chat_id, message_ids });
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn forward_messages(
|
||||||
|
&mut self,
|
||||||
|
to_chat_id: ChatId,
|
||||||
|
from_chat_id: ChatId,
|
||||||
|
message_ids: Vec<MessageId>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
self.client
|
||||||
|
.forward_messages(to_chat_id, from_chat_id, message_ids)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn toggle_reaction(
|
||||||
|
&mut self,
|
||||||
|
chat_id: ChatId,
|
||||||
|
message_id: MessageId,
|
||||||
|
reaction: String,
|
||||||
|
) -> Result<Vec<CoreReaction>, String> {
|
||||||
|
self.client
|
||||||
|
.toggle_reaction(chat_id, message_id, reaction)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let reactions: Vec<CoreReaction> = self
|
||||||
|
.client
|
||||||
|
.get_chat_history(chat_id, i32::MAX)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.find(|message| message.id() == message_id)
|
||||||
|
.map(|message| message.reactions().iter().map(CoreReaction::from).collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
self.enqueue_event(CoreEvent::ReactionChanged {
|
||||||
|
chat_id,
|
||||||
|
message_id,
|
||||||
|
reactions: reactions.clone(),
|
||||||
|
});
|
||||||
|
Ok(reactions)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn download_photo(&self, file_id: i32) -> Result<CoreDownloadedFile, String> {
|
||||||
|
self.client
|
||||||
|
.download_file(file_id)
|
||||||
|
.await
|
||||||
|
.map(|path| CoreDownloadedFile { file_id, path })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn download_voice(&self, file_id: i32) -> Result<CoreDownloadedFile, String> {
|
||||||
|
self.client
|
||||||
|
.download_voice_note(file_id)
|
||||||
|
.await
|
||||||
|
.map(|path| CoreDownloadedFile { file_id, path })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn search_messages(
|
||||||
|
&self,
|
||||||
|
chat_id: ChatId,
|
||||||
|
query: &str,
|
||||||
|
) -> Result<Vec<CoreSearchResult>, String> {
|
||||||
|
let messages = self.client.search_messages(chat_id, query).await?;
|
||||||
|
Ok(messages
|
||||||
|
.iter()
|
||||||
|
.map(|message| CoreSearchResult { chat_id, message: CoreMessage::from(message) })
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn pinned_messages(&mut self, chat_id: ChatId) -> Result<Vec<CoreMessage>, String> {
|
||||||
|
self.client
|
||||||
|
.get_pinned_messages(chat_id)
|
||||||
|
.await
|
||||||
|
.map(|messages| messages.iter().map(CoreMessage::from).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn copy_payload(
|
||||||
|
&mut self,
|
||||||
|
chat_id: ChatId,
|
||||||
|
message_id: MessageId,
|
||||||
|
) -> Result<String, String> {
|
||||||
|
self.client
|
||||||
|
.get_chat_history(chat_id, i32::MAX)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.find(|message| message.id() == message_id)
|
||||||
|
.map(|message| message.text().to_string())
|
||||||
|
.ok_or_else(|| "message not found".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn open_profile(&mut self, chat_id: ChatId) -> Result<CoreProfile, String> {
|
||||||
|
let profile = self
|
||||||
|
.client
|
||||||
|
.get_profile_info(chat_id)
|
||||||
|
.await
|
||||||
|
.map(|profile| CoreProfile::from(&profile))?;
|
||||||
|
self.enqueue_event(CoreEvent::ProfileLoaded(profile.clone()));
|
||||||
|
Ok(profile)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn leave_chat(&mut self, chat_id: ChatId) -> Result<(), String> {
|
||||||
|
self.client.leave_chat(chat_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_draft(&mut self, chat_id: ChatId, text: String) -> Result<(), String> {
|
||||||
|
self.client.set_draft_message(chat_id, text.clone()).await?;
|
||||||
|
self.enqueue_event(CoreEvent::DraftChanged(CoreDraft { chat_id, text }));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn drain_client_events(&mut self) -> Vec<CoreEvent> {
|
||||||
|
let events: Vec<_> = self
|
||||||
|
.client
|
||||||
|
.drain_incoming_message_events()
|
||||||
|
.into_iter()
|
||||||
|
.map(|event| {
|
||||||
|
CoreEvent::IncomingNotificationCandidate(CoreNotificationCandidate {
|
||||||
|
chat: CoreChatSummary::from(&event.chat),
|
||||||
|
message: CoreMessage::from(&event.message),
|
||||||
|
sender_name: event.sender_name,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
self.events.extend(events.iter().cloned());
|
||||||
|
events
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct CoreAccount {
|
||||||
|
pub id: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub is_active: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum CoreAuthState {
|
||||||
|
WaitTdlibParameters,
|
||||||
|
WaitPhoneNumber,
|
||||||
|
WaitCode,
|
||||||
|
WaitPassword,
|
||||||
|
Ready,
|
||||||
|
Closed,
|
||||||
|
Error { message: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&AuthState> for CoreAuthState {
|
||||||
|
fn from(value: &AuthState) -> Self {
|
||||||
|
match value {
|
||||||
|
AuthState::WaitTdlibParameters => Self::WaitTdlibParameters,
|
||||||
|
AuthState::WaitPhoneNumber => Self::WaitPhoneNumber,
|
||||||
|
AuthState::WaitCode => Self::WaitCode,
|
||||||
|
AuthState::WaitPassword => Self::WaitPassword,
|
||||||
|
AuthState::Ready => Self::Ready,
|
||||||
|
AuthState::Closed => Self::Closed,
|
||||||
|
AuthState::Error(message) => Self::Error { message: message.clone() },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct CoreChatSummary {
|
||||||
|
pub id: ChatId,
|
||||||
|
pub title: String,
|
||||||
|
pub username: Option<String>,
|
||||||
|
pub last_message: String,
|
||||||
|
pub last_message_date: i32,
|
||||||
|
pub unread_count: i32,
|
||||||
|
pub unread_mention_count: i32,
|
||||||
|
pub is_pinned: bool,
|
||||||
|
pub order: i64,
|
||||||
|
pub last_read_outbox_message_id: MessageId,
|
||||||
|
pub folder_ids: Vec<i32>,
|
||||||
|
pub is_muted: bool,
|
||||||
|
pub draft: Option<CoreDraft>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&ChatInfo> for CoreChatSummary {
|
||||||
|
fn from(value: &ChatInfo) -> Self {
|
||||||
|
Self {
|
||||||
|
id: value.id,
|
||||||
|
title: value.title.clone(),
|
||||||
|
username: value.username.clone(),
|
||||||
|
last_message: value.last_message.clone(),
|
||||||
|
last_message_date: value.last_message_date,
|
||||||
|
unread_count: value.unread_count,
|
||||||
|
unread_mention_count: value.unread_mention_count,
|
||||||
|
is_pinned: value.is_pinned,
|
||||||
|
order: value.order,
|
||||||
|
last_read_outbox_message_id: value.last_read_outbox_message_id,
|
||||||
|
folder_ids: value.folder_ids.clone(),
|
||||||
|
is_muted: value.is_muted,
|
||||||
|
draft: value
|
||||||
|
.draft_text
|
||||||
|
.as_ref()
|
||||||
|
.map(|text| CoreDraft { chat_id: value.id, text: text.clone() }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct CoreFolder {
|
||||||
|
pub id: i32,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&FolderInfo> for CoreFolder {
|
||||||
|
fn from(value: &FolderInfo) -> Self {
|
||||||
|
Self { id: value.id, name: value.name.clone() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct CoreMessage {
|
||||||
|
pub id: MessageId,
|
||||||
|
pub sender_name: String,
|
||||||
|
pub date: i32,
|
||||||
|
pub edit_date: Option<i32>,
|
||||||
|
pub media_album_id: Option<i64>,
|
||||||
|
pub text: String,
|
||||||
|
pub media: Option<CoreMedia>,
|
||||||
|
pub is_outgoing: bool,
|
||||||
|
pub is_read: bool,
|
||||||
|
pub can_be_edited: bool,
|
||||||
|
pub can_be_deleted_only_for_self: bool,
|
||||||
|
pub can_be_deleted_for_all_users: bool,
|
||||||
|
pub reply: Option<CoreReply>,
|
||||||
|
pub forward: Option<CoreForward>,
|
||||||
|
pub reactions: Vec<CoreReaction>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&MessageInfo> for CoreMessage {
|
||||||
|
fn from(value: &MessageInfo) -> Self {
|
||||||
|
Self {
|
||||||
|
id: value.id(),
|
||||||
|
sender_name: value.sender_name().to_string(),
|
||||||
|
date: value.date(),
|
||||||
|
edit_date: value.is_edited().then_some(value.metadata.edit_date),
|
||||||
|
media_album_id: (value.media_album_id() != 0).then_some(value.media_album_id()),
|
||||||
|
text: value.text().to_string(),
|
||||||
|
media: value.content.media.as_ref().map(CoreMedia::from),
|
||||||
|
is_outgoing: value.is_outgoing(),
|
||||||
|
is_read: value.is_read(),
|
||||||
|
can_be_edited: value.can_be_edited(),
|
||||||
|
can_be_deleted_only_for_self: value.can_be_deleted_only_for_self(),
|
||||||
|
can_be_deleted_for_all_users: value.can_be_deleted_for_all_users(),
|
||||||
|
reply: value.reply_to().map(CoreReply::from),
|
||||||
|
forward: value.forward_from().map(CoreForward::from),
|
||||||
|
reactions: value.reactions().iter().map(CoreReaction::from).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct CoreReply {
|
||||||
|
pub message_id: MessageId,
|
||||||
|
pub sender_name: String,
|
||||||
|
pub text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&ReplyInfo> for CoreReply {
|
||||||
|
fn from(value: &ReplyInfo) -> Self {
|
||||||
|
Self {
|
||||||
|
message_id: value.message_id,
|
||||||
|
sender_name: value.sender_name.clone(),
|
||||||
|
text: value.text.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct CoreForward {
|
||||||
|
pub sender_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&ForwardInfo> for CoreForward {
|
||||||
|
fn from(value: &ForwardInfo) -> Self {
|
||||||
|
Self { sender_name: value.sender_name.clone() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct CoreReaction {
|
||||||
|
pub emoji: String,
|
||||||
|
pub count: i32,
|
||||||
|
pub is_chosen: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&crate::tdlib::types::ReactionInfo> for CoreReaction {
|
||||||
|
fn from(value: &crate::tdlib::types::ReactionInfo) -> Self {
|
||||||
|
Self {
|
||||||
|
emoji: value.emoji.clone(),
|
||||||
|
count: value.count,
|
||||||
|
is_chosen: value.is_chosen,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum CoreMedia {
|
||||||
|
Photo(CorePhoto),
|
||||||
|
Voice(CoreVoice),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&MediaInfo> for CoreMedia {
|
||||||
|
fn from(value: &MediaInfo) -> Self {
|
||||||
|
match value {
|
||||||
|
MediaInfo::Photo(photo) => Self::Photo(CorePhoto {
|
||||||
|
file_id: photo.file_id,
|
||||||
|
width: photo.width,
|
||||||
|
height: photo.height,
|
||||||
|
download_state: CoreDownloadState::from(&photo.download_state),
|
||||||
|
}),
|
||||||
|
MediaInfo::Voice(voice) => Self::Voice(CoreVoice {
|
||||||
|
file_id: voice.file_id,
|
||||||
|
duration: voice.duration,
|
||||||
|
mime_type: voice.mime_type.clone(),
|
||||||
|
waveform: voice.waveform.clone(),
|
||||||
|
download_state: CoreDownloadState::from(&voice.download_state),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct CorePhoto {
|
||||||
|
pub file_id: i32,
|
||||||
|
pub width: i32,
|
||||||
|
pub height: i32,
|
||||||
|
pub download_state: CoreDownloadState,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct CoreVoice {
|
||||||
|
pub file_id: i32,
|
||||||
|
pub duration: i32,
|
||||||
|
pub mime_type: String,
|
||||||
|
pub waveform: String,
|
||||||
|
pub download_state: CoreDownloadState,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum CoreDownloadState {
|
||||||
|
NotDownloaded,
|
||||||
|
Downloading,
|
||||||
|
Downloaded { path: String },
|
||||||
|
Error { message: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&crate::tdlib::PhotoDownloadState> for CoreDownloadState {
|
||||||
|
fn from(value: &crate::tdlib::PhotoDownloadState) -> Self {
|
||||||
|
match value {
|
||||||
|
crate::tdlib::PhotoDownloadState::NotDownloaded => Self::NotDownloaded,
|
||||||
|
crate::tdlib::PhotoDownloadState::Downloading => Self::Downloading,
|
||||||
|
crate::tdlib::PhotoDownloadState::Downloaded(path) => {
|
||||||
|
Self::Downloaded { path: path.clone() }
|
||||||
|
}
|
||||||
|
crate::tdlib::PhotoDownloadState::Error(message) => {
|
||||||
|
Self::Error { message: message.clone() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&crate::tdlib::VoiceDownloadState> for CoreDownloadState {
|
||||||
|
fn from(value: &crate::tdlib::VoiceDownloadState) -> Self {
|
||||||
|
match value {
|
||||||
|
crate::tdlib::VoiceDownloadState::NotDownloaded => Self::NotDownloaded,
|
||||||
|
crate::tdlib::VoiceDownloadState::Downloading => Self::Downloading,
|
||||||
|
crate::tdlib::VoiceDownloadState::Downloaded(path) => {
|
||||||
|
Self::Downloaded { path: path.clone() }
|
||||||
|
}
|
||||||
|
crate::tdlib::VoiceDownloadState::Error(message) => {
|
||||||
|
Self::Error { message: message.clone() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct CoreProfile {
|
||||||
|
pub chat_id: ChatId,
|
||||||
|
pub title: String,
|
||||||
|
pub username: Option<String>,
|
||||||
|
pub bio: Option<String>,
|
||||||
|
pub phone_number: Option<String>,
|
||||||
|
pub chat_type: String,
|
||||||
|
pub member_count: Option<i32>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub invite_link: Option<String>,
|
||||||
|
pub is_group: bool,
|
||||||
|
pub online_status: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&ProfileInfo> for CoreProfile {
|
||||||
|
fn from(value: &ProfileInfo) -> Self {
|
||||||
|
Self {
|
||||||
|
chat_id: value.chat_id,
|
||||||
|
title: value.title.clone(),
|
||||||
|
username: value.username.clone(),
|
||||||
|
bio: value.bio.clone(),
|
||||||
|
phone_number: value.phone_number.clone(),
|
||||||
|
chat_type: value.chat_type.clone(),
|
||||||
|
member_count: value.member_count,
|
||||||
|
description: value.description.clone(),
|
||||||
|
invite_link: value.invite_link.clone(),
|
||||||
|
is_group: value.is_group,
|
||||||
|
online_status: value.online_status.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct CoreDraft {
|
||||||
|
pub chat_id: ChatId,
|
||||||
|
pub text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct CoreSearchResult {
|
||||||
|
pub chat_id: ChatId,
|
||||||
|
pub message: CoreMessage,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct CoreDownloadedFile {
|
||||||
|
pub file_id: i32,
|
||||||
|
pub path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum CoreNetworkState {
|
||||||
|
WaitingForNetwork,
|
||||||
|
ConnectingToProxy,
|
||||||
|
Connecting,
|
||||||
|
Updating,
|
||||||
|
Ready,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&NetworkState> for CoreNetworkState {
|
||||||
|
fn from(value: &NetworkState) -> Self {
|
||||||
|
match value {
|
||||||
|
NetworkState::WaitingForNetwork => Self::WaitingForNetwork,
|
||||||
|
NetworkState::ConnectingToProxy => Self::ConnectingToProxy,
|
||||||
|
NetworkState::Connecting => Self::Connecting,
|
||||||
|
NetworkState::Updating => Self::Updating,
|
||||||
|
NetworkState::Ready => Self::Ready,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum CoreTypingState {
|
||||||
|
Idle,
|
||||||
|
Typing {
|
||||||
|
chat_id: ChatId,
|
||||||
|
user_id: UserId,
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct CoreNotificationCandidate {
|
||||||
|
pub chat: CoreChatSummary,
|
||||||
|
pub message: CoreMessage,
|
||||||
|
pub sender_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum CoreEvent {
|
||||||
|
AuthChanged(CoreAuthState),
|
||||||
|
ChatListChanged(Vec<CoreChatSummary>),
|
||||||
|
FolderListChanged(Vec<CoreFolder>),
|
||||||
|
MessageAdded {
|
||||||
|
chat_id: ChatId,
|
||||||
|
message: CoreMessage,
|
||||||
|
},
|
||||||
|
MessageUpdated {
|
||||||
|
chat_id: ChatId,
|
||||||
|
message: CoreMessage,
|
||||||
|
},
|
||||||
|
MessageDeleted {
|
||||||
|
chat_id: ChatId,
|
||||||
|
message_ids: Vec<MessageId>,
|
||||||
|
},
|
||||||
|
ReactionChanged {
|
||||||
|
chat_id: ChatId,
|
||||||
|
message_id: MessageId,
|
||||||
|
reactions: Vec<CoreReaction>,
|
||||||
|
},
|
||||||
|
MediaDownloadProgress {
|
||||||
|
file_id: i32,
|
||||||
|
downloaded_size: i64,
|
||||||
|
total_size: i64,
|
||||||
|
},
|
||||||
|
IncomingNotificationCandidate(CoreNotificationCandidate),
|
||||||
|
NetworkChanged(CoreNetworkState),
|
||||||
|
TypingChanged(CoreTypingState),
|
||||||
|
DraftChanged(CoreDraft),
|
||||||
|
ProfileLoaded(CoreProfile),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::tdlib::types::ReactionInfo;
|
||||||
|
use crate::tdlib::{
|
||||||
|
AuthState, ChatInfo, FolderInfo, MessageBuilder, NetworkState, ProfileInfo,
|
||||||
|
};
|
||||||
|
use crate::test_support::FakeTdClient;
|
||||||
|
use crate::types::{ChatId, MessageId};
|
||||||
|
|
||||||
|
fn sample_chat() -> ChatInfo {
|
||||||
|
ChatInfo {
|
||||||
|
id: ChatId::new(42),
|
||||||
|
title: "Team".to_string(),
|
||||||
|
username: Some("team_chat".to_string()),
|
||||||
|
last_message: "Latest".to_string(),
|
||||||
|
last_message_date: 1_700_000_000,
|
||||||
|
unread_count: 3,
|
||||||
|
unread_mention_count: 1,
|
||||||
|
is_pinned: true,
|
||||||
|
order: 99,
|
||||||
|
last_read_outbox_message_id: MessageId::new(7),
|
||||||
|
folder_ids: vec![0, 2],
|
||||||
|
is_muted: true,
|
||||||
|
draft_text: Some("Draft".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn auth_state_mapping_is_stable_for_swift() {
|
||||||
|
assert_eq!(
|
||||||
|
CoreAuthState::from(&AuthState::WaitPhoneNumber),
|
||||||
|
CoreAuthState::WaitPhoneNumber
|
||||||
|
);
|
||||||
|
assert_eq!(CoreAuthState::from(&AuthState::WaitCode), CoreAuthState::WaitCode);
|
||||||
|
assert_eq!(CoreAuthState::from(&AuthState::WaitPassword), CoreAuthState::WaitPassword);
|
||||||
|
assert_eq!(CoreAuthState::from(&AuthState::Ready), CoreAuthState::Ready);
|
||||||
|
assert_eq!(
|
||||||
|
CoreAuthState::from(&AuthState::Error("bad code".to_string())),
|
||||||
|
CoreAuthState::Error { message: "bad code".to_string() }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn chat_summary_preserves_ios_relevant_state() {
|
||||||
|
let chat = CoreChatSummary::from(&sample_chat());
|
||||||
|
|
||||||
|
assert_eq!(chat.id, ChatId::new(42));
|
||||||
|
assert_eq!(chat.title, "Team");
|
||||||
|
assert_eq!(chat.username.as_deref(), Some("team_chat"));
|
||||||
|
assert_eq!(chat.last_message, "Latest");
|
||||||
|
assert_eq!(chat.unread_count, 3);
|
||||||
|
assert_eq!(chat.unread_mention_count, 1);
|
||||||
|
assert!(chat.is_pinned);
|
||||||
|
assert!(chat.is_muted);
|
||||||
|
assert_eq!(chat.folder_ids, vec![0, 2]);
|
||||||
|
assert_eq!(chat.draft.as_ref().map(|draft| draft.text.as_str()), Some("Draft"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn message_mapping_preserves_reply_reactions_and_state() {
|
||||||
|
let message = MessageBuilder::new(MessageId::new(100))
|
||||||
|
.sender_name("Alice")
|
||||||
|
.text("Hello")
|
||||||
|
.date(1_700_000_001)
|
||||||
|
.edit_date(1_700_000_002)
|
||||||
|
.reply_to(crate::tdlib::ReplyInfo {
|
||||||
|
message_id: MessageId::new(90),
|
||||||
|
sender_name: "Bob".to_string(),
|
||||||
|
text: "Original".to_string(),
|
||||||
|
})
|
||||||
|
.reactions(vec![ReactionInfo {
|
||||||
|
emoji: "👍".to_string(), count: 2, is_chosen: true
|
||||||
|
}])
|
||||||
|
.outgoing()
|
||||||
|
.read()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let mapped = CoreMessage::from(&message);
|
||||||
|
|
||||||
|
assert_eq!(mapped.id, MessageId::new(100));
|
||||||
|
assert_eq!(mapped.sender_name, "Alice");
|
||||||
|
assert_eq!(mapped.text, "Hello");
|
||||||
|
assert!(mapped.is_outgoing);
|
||||||
|
assert!(mapped.is_read);
|
||||||
|
assert_eq!(mapped.edit_date, Some(1_700_000_002));
|
||||||
|
assert_eq!(mapped.reply.as_ref().map(|reply| reply.message_id), Some(MessageId::new(90)));
|
||||||
|
assert_eq!(mapped.reactions[0].emoji, "👍");
|
||||||
|
assert!(mapped.reactions[0].is_chosen);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_event_queue_drains_in_fifo_order() {
|
||||||
|
let mut session = CoreSession::new(());
|
||||||
|
|
||||||
|
session.enqueue_event(CoreEvent::AuthChanged(CoreAuthState::WaitCode));
|
||||||
|
session.enqueue_event(CoreEvent::NetworkChanged(CoreNetworkState::Ready));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
session.poll_events(),
|
||||||
|
vec![
|
||||||
|
CoreEvent::AuthChanged(CoreAuthState::WaitCode),
|
||||||
|
CoreEvent::NetworkChanged(CoreNetworkState::Ready),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert!(session.poll_events().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_drains_incoming_message_events_as_notification_candidates() {
|
||||||
|
let chat = sample_chat();
|
||||||
|
let client = FakeTdClient::new().with_chat(chat.clone());
|
||||||
|
client.simulate_incoming_message(chat.id, "Ping".to_string(), "Alice");
|
||||||
|
let mut session = CoreSession::new(client);
|
||||||
|
|
||||||
|
let events = session.drain_client_events();
|
||||||
|
|
||||||
|
assert_eq!(events.len(), 1);
|
||||||
|
let CoreEvent::IncomingNotificationCandidate(candidate) = &events[0] else {
|
||||||
|
panic!("expected incoming notification candidate");
|
||||||
|
};
|
||||||
|
assert_eq!(candidate.chat.id, chat.id);
|
||||||
|
assert_eq!(candidate.message.text, "Ping");
|
||||||
|
assert_eq!(candidate.sender_name, "Alice");
|
||||||
|
assert_eq!(session.poll_events(), events);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn events_cover_chat_message_profile_and_folder_shapes() {
|
||||||
|
let chat = CoreChatSummary::from(&sample_chat());
|
||||||
|
let message = CoreMessage::from(
|
||||||
|
&MessageBuilder::new(MessageId::new(10))
|
||||||
|
.sender_name("Alice")
|
||||||
|
.text("Hi")
|
||||||
|
.build(),
|
||||||
|
);
|
||||||
|
let folder = CoreFolder::from(&FolderInfo { id: 2, name: "Work".to_string() });
|
||||||
|
let profile = CoreProfile::from(&ProfileInfo {
|
||||||
|
chat_id: ChatId::new(42),
|
||||||
|
title: "Team".to_string(),
|
||||||
|
username: Some("team_chat".to_string()),
|
||||||
|
bio: None,
|
||||||
|
phone_number: None,
|
||||||
|
chat_type: "Group".to_string(),
|
||||||
|
member_count: Some(10),
|
||||||
|
description: Some("Project group".to_string()),
|
||||||
|
invite_link: None,
|
||||||
|
is_group: true,
|
||||||
|
online_status: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
CoreEvent::ChatListChanged(vec![chat.clone()]),
|
||||||
|
CoreEvent::ChatListChanged(vec![chat])
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
CoreEvent::MessageAdded { chat_id: ChatId::new(42), message: message.clone() },
|
||||||
|
CoreEvent::MessageAdded { chat_id: ChatId::new(42), message }
|
||||||
|
);
|
||||||
|
assert_eq!(folder.name, "Work");
|
||||||
|
assert_eq!(profile.member_count, Some(10));
|
||||||
|
assert_eq!(
|
||||||
|
CoreNetworkState::from(&NetworkState::WaitingForNetwork),
|
||||||
|
CoreNetworkState::WaitingForNetwork
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
CoreTypingState::Typing {
|
||||||
|
chat_id: ChatId::new(42),
|
||||||
|
user_id: UserId::new(7),
|
||||||
|
text: "typing".to_string(),
|
||||||
|
},
|
||||||
|
CoreTypingState::Typing {
|
||||||
|
chat_id: ChatId::new(42),
|
||||||
|
user_id: UserId::new(7),
|
||||||
|
text: "typing".to_string(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn facade_methods_enqueue_state_profile_and_draft_events() {
|
||||||
|
let profile = ProfileInfo {
|
||||||
|
chat_id: ChatId::new(42),
|
||||||
|
title: "Team".to_string(),
|
||||||
|
username: Some("team_chat".to_string()),
|
||||||
|
bio: None,
|
||||||
|
phone_number: None,
|
||||||
|
chat_type: "Group".to_string(),
|
||||||
|
member_count: Some(10),
|
||||||
|
description: None,
|
||||||
|
invite_link: None,
|
||||||
|
is_group: true,
|
||||||
|
online_status: None,
|
||||||
|
};
|
||||||
|
let client = FakeTdClient::new()
|
||||||
|
.with_auth_state(AuthState::WaitPassword)
|
||||||
|
.with_network_state(NetworkState::Connecting)
|
||||||
|
.with_profile(42, profile);
|
||||||
|
let mut session = CoreSession::new(client);
|
||||||
|
|
||||||
|
session.emit_auth_state();
|
||||||
|
session.emit_network_state();
|
||||||
|
let loaded_profile = session.open_profile(ChatId::new(42)).await.unwrap();
|
||||||
|
session.leave_chat(ChatId::new(42)).await.unwrap();
|
||||||
|
session
|
||||||
|
.set_draft(ChatId::new(42), "Later".to_string())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(loaded_profile.title, "Team");
|
||||||
|
assert_eq!(
|
||||||
|
session.poll_events(),
|
||||||
|
vec![
|
||||||
|
CoreEvent::AuthChanged(CoreAuthState::WaitPassword),
|
||||||
|
CoreEvent::NetworkChanged(CoreNetworkState::Connecting),
|
||||||
|
CoreEvent::ProfileLoaded(CoreProfile {
|
||||||
|
chat_id: ChatId::new(42),
|
||||||
|
title: "Team".to_string(),
|
||||||
|
username: Some("team_chat".to_string()),
|
||||||
|
bio: None,
|
||||||
|
phone_number: None,
|
||||||
|
chat_type: "Group".to_string(),
|
||||||
|
member_count: Some(10),
|
||||||
|
description: None,
|
||||||
|
invite_link: None,
|
||||||
|
is_group: true,
|
||||||
|
online_status: None,
|
||||||
|
}),
|
||||||
|
CoreEvent::DraftChanged(CoreDraft {
|
||||||
|
chat_id: ChatId::new(42),
|
||||||
|
text: "Later".to_string(),
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn message_mutations_return_models_and_enqueue_events() {
|
||||||
|
let chat_id = ChatId::new(42);
|
||||||
|
let original = MessageBuilder::new(MessageId::new(10))
|
||||||
|
.sender_name("Me")
|
||||||
|
.text("Before")
|
||||||
|
.outgoing()
|
||||||
|
.build();
|
||||||
|
let client = FakeTdClient::new()
|
||||||
|
.with_chat(sample_chat())
|
||||||
|
.with_message(chat_id.as_i64(), original);
|
||||||
|
let mut session = CoreSession::new(client);
|
||||||
|
|
||||||
|
let sent = session
|
||||||
|
.send_text_message(chat_id, "Hello".to_string(), None, None)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let edited = session
|
||||||
|
.edit_text_message(chat_id, MessageId::new(10), "After".to_string())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let copied = session
|
||||||
|
.copy_payload(chat_id, MessageId::new(10))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
session
|
||||||
|
.delete_messages(chat_id, vec![MessageId::new(10)], true)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(sent.text, "Hello");
|
||||||
|
assert_eq!(edited.text, "After");
|
||||||
|
assert_eq!(copied, "After");
|
||||||
|
assert_eq!(
|
||||||
|
session.poll_events(),
|
||||||
|
vec![
|
||||||
|
CoreEvent::MessageAdded { chat_id, message: sent },
|
||||||
|
CoreEvent::MessageUpdated { chat_id, message: edited },
|
||||||
|
CoreEvent::MessageDeleted { chat_id, message_ids: vec![MessageId::new(10)] },
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn pinned_messages_are_mapped_for_native_clients() {
|
||||||
|
let chat_id = ChatId::new(42);
|
||||||
|
let pinned = MessageBuilder::new(MessageId::new(10))
|
||||||
|
.sender_name("Alice")
|
||||||
|
.text("Pinned")
|
||||||
|
.build();
|
||||||
|
let mut client = FakeTdClient::new();
|
||||||
|
client.set_current_pinned_message(Some(pinned));
|
||||||
|
let mut session = CoreSession::new(client);
|
||||||
|
|
||||||
|
let pinned = session.pinned_messages(chat_id).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(pinned.len(), 1);
|
||||||
|
assert_eq!(pinned[0].id, MessageId::new(10));
|
||||||
|
assert_eq!(pinned[0].text, "Pinned");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn facade_delegates_auth_forward_reactions_and_downloads() {
|
||||||
|
let chat_id = ChatId::new(42);
|
||||||
|
let other_chat_id = ChatId::new(100);
|
||||||
|
let message = MessageBuilder::new(MessageId::new(10))
|
||||||
|
.sender_name("Alice")
|
||||||
|
.text("React here")
|
||||||
|
.build();
|
||||||
|
let client = FakeTdClient::new()
|
||||||
|
.with_message(chat_id.as_i64(), message)
|
||||||
|
.with_downloaded_file(77, "/tmp/photo.jpg")
|
||||||
|
.with_downloaded_file(88, "/tmp/voice.ogg");
|
||||||
|
let mut session = CoreSession::new(client);
|
||||||
|
|
||||||
|
session
|
||||||
|
.send_phone_number("+10000000000".to_string())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
session.send_code("12345".to_string()).await.unwrap();
|
||||||
|
session.send_password("secret".to_string()).await.unwrap();
|
||||||
|
session
|
||||||
|
.forward_messages(other_chat_id, chat_id, vec![MessageId::new(10)])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let reactions = session
|
||||||
|
.toggle_reaction(chat_id, MessageId::new(10), "👍".to_string())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let downloaded = session.download_photo(77).await.unwrap();
|
||||||
|
let downloaded_voice = session.download_voice(88).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(downloaded.path, "/tmp/photo.jpg");
|
||||||
|
assert_eq!(downloaded_voice.path, "/tmp/voice.ogg");
|
||||||
|
assert_eq!(session.client().get_forwarded_messages().len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
reactions,
|
||||||
|
vec![CoreReaction {
|
||||||
|
emoji: "👍".to_string(), count: 1, is_chosen: true
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
session.poll_events(),
|
||||||
|
vec![CoreEvent::ReactionChanged { chat_id, message_id: MessageId::new(10), reactions }]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,19 +10,12 @@ use tdlib_rs::enums::{Chat as TdChat, ChatList, ChatType};
|
|||||||
use super::client::TdClient;
|
use super::client::TdClient;
|
||||||
use super::types::ChatInfo;
|
use super::types::ChatInfo;
|
||||||
|
|
||||||
/// Находит мутабельную ссылку на чат по ID.
|
|
||||||
pub fn find_chat_mut(client: &mut TdClient, chat_id: ChatId) -> Option<&mut ChatInfo> {
|
|
||||||
client.chats_mut().iter_mut().find(|c| c.id == chat_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Обновляет поле чата, если чат найден.
|
/// Обновляет поле чата, если чат найден.
|
||||||
pub fn update_chat<F>(client: &mut TdClient, chat_id: ChatId, updater: F)
|
pub fn update_chat<F>(client: &mut TdClient, chat_id: ChatId, updater: F)
|
||||||
where
|
where
|
||||||
F: FnOnce(&mut ChatInfo),
|
F: FnOnce(&mut ChatInfo),
|
||||||
{
|
{
|
||||||
if let Some(chat) = find_chat_mut(client, chat_id) {
|
client.update_chat(chat_id, updater);
|
||||||
updater(chat);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Добавляет новый чат или обновляет существующий
|
/// Добавляет новый чат или обновляет существующий
|
||||||
@@ -33,9 +26,7 @@ pub fn add_or_update_chat(client: &mut TdClient, td_chat_enum: &TdChat) {
|
|||||||
// Пропускаем удалённые аккаунты
|
// Пропускаем удалённые аккаунты
|
||||||
if td_chat.title == "Deleted Account" || td_chat.title.is_empty() {
|
if td_chat.title == "Deleted Account" || td_chat.title.is_empty() {
|
||||||
// Удаляем из списка если уже был добавлен
|
// Удаляем из списка если уже был добавлен
|
||||||
client
|
client.remove_chat(ChatId::new(td_chat.id));
|
||||||
.chats_mut()
|
|
||||||
.retain(|c| c.id != ChatId::new(td_chat.id));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,22 +52,23 @@ pub fn add_or_update_chat(client: &mut TdClient, td_chat_enum: &TdChat) {
|
|||||||
ChatType::Private(private) => {
|
ChatType::Private(private) => {
|
||||||
// Ограничиваем размер chat_user_ids
|
// Ограничиваем размер chat_user_ids
|
||||||
let chat_id = ChatId::new(td_chat.id);
|
let chat_id = ChatId::new(td_chat.id);
|
||||||
if client.user_cache.chat_user_ids.len() >= MAX_CHAT_USER_IDS
|
let user_id = UserId::new(private.user_id);
|
||||||
&& !client.user_cache.chat_user_ids.contains_key(&chat_id)
|
client.update_user_cache(|cache| {
|
||||||
|
if cache.chat_user_ids.len() >= MAX_CHAT_USER_IDS
|
||||||
|
&& !cache.chat_user_ids.contains_key(&chat_id)
|
||||||
{
|
{
|
||||||
// Удаляем случайную запись (первую найденную)
|
// Удаляем случайную запись (первую найденную)
|
||||||
if let Some(&key) = client.user_cache.chat_user_ids.keys().next() {
|
if let Some(&key) = cache.chat_user_ids.keys().next() {
|
||||||
client.user_cache.chat_user_ids.remove(&key);
|
cache.chat_user_ids.remove(&key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let user_id = UserId::new(private.user_id);
|
cache.chat_user_ids.insert(chat_id, user_id);
|
||||||
client.user_cache.chat_user_ids.insert(chat_id, user_id);
|
|
||||||
// Проверяем, есть ли уже username в кэше (peek не обновляет LRU)
|
// Проверяем, есть ли уже username в кэше (peek не обновляет LRU)
|
||||||
client
|
cache
|
||||||
.user_cache
|
|
||||||
.user_usernames
|
.user_usernames
|
||||||
.peek(&user_id)
|
.peek(&user_id)
|
||||||
.map(|u| format!("@{}", u))
|
.map(|u| format!("@{}", u))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
@@ -110,44 +102,35 @@ pub fn add_or_update_chat(client: &mut TdClient, td_chat_enum: &TdChat) {
|
|||||||
draft_text: None,
|
draft_text: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(existing) = find_chat_mut(client, ChatId::new(td_chat.id)) {
|
let chat_info_for_update = chat_info.clone();
|
||||||
existing.title = chat_info.title;
|
let updated_existing = client.update_chat(ChatId::new(td_chat.id), |existing| {
|
||||||
existing.last_message = chat_info.last_message;
|
existing.title = chat_info_for_update.title;
|
||||||
existing.last_message_date = chat_info.last_message_date;
|
existing.last_message = chat_info_for_update.last_message;
|
||||||
existing.unread_count = chat_info.unread_count;
|
existing.last_message_date = chat_info_for_update.last_message_date;
|
||||||
existing.unread_mention_count = chat_info.unread_mention_count;
|
existing.unread_count = chat_info_for_update.unread_count;
|
||||||
existing.last_read_outbox_message_id = chat_info.last_read_outbox_message_id;
|
existing.unread_mention_count = chat_info_for_update.unread_mention_count;
|
||||||
existing.folder_ids = chat_info.folder_ids;
|
existing.last_read_outbox_message_id = chat_info_for_update.last_read_outbox_message_id;
|
||||||
existing.is_muted = chat_info.is_muted;
|
existing.folder_ids = chat_info_for_update.folder_ids;
|
||||||
|
existing.is_muted = chat_info_for_update.is_muted;
|
||||||
|
|
||||||
// Обновляем username если он появился
|
// Обновляем username если он появился
|
||||||
if let Some(username) = chat_info.username {
|
if let Some(username) = chat_info_for_update.username {
|
||||||
existing.username = Some(username);
|
existing.username = Some(username);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обновляем позицию только если она пришла
|
// Обновляем позицию только если она пришла
|
||||||
if main_position.is_some() {
|
if main_position.is_some() {
|
||||||
existing.is_pinned = chat_info.is_pinned;
|
existing.is_pinned = chat_info_for_update.is_pinned;
|
||||||
existing.order = chat_info.order;
|
existing.order = chat_info_for_update.order;
|
||||||
}
|
}
|
||||||
} else {
|
});
|
||||||
client.chats_mut().push(chat_info);
|
|
||||||
|
if !updated_existing {
|
||||||
|
client.push_chat(chat_info);
|
||||||
// Ограничиваем количество чатов
|
// Ограничиваем количество чатов
|
||||||
if client.chats_mut().len() > MAX_CHATS {
|
client.trim_chats_to_max_by_order(MAX_CHATS);
|
||||||
// Удаляем чат с наименьшим order (наименее активный)
|
|
||||||
let Some(min_idx) = client
|
|
||||||
.chats()
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.min_by_key(|(_, c)| c.order)
|
|
||||||
.map(|(i, _)| i)
|
|
||||||
else {
|
|
||||||
return; // Нет чатов для удаления (не должно произойти)
|
|
||||||
};
|
|
||||||
client.chats_mut().remove(min_idx);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Сортируем чаты по order (TDLib order учитывает pinned и время)
|
// Сортируем чаты по order (TDLib order учитывает pinned и время)
|
||||||
client.chats_mut().sort_by(|a, b| b.order.cmp(&a.order));
|
client.sort_chats_by_order();
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
use crate::types::{ChatId, MessageId, UserId};
|
use crate::types::{ChatId, MessageId, UserId};
|
||||||
use std::env;
|
use std::collections::VecDeque;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use tdlib_rs::enums::{Chat as TdChat, ChatList, ConnectionState, Update, UserStatus};
|
use tdlib_rs::enums::{Chat as TdChat, ChatList, ConnectionState, Update, UserStatus};
|
||||||
use tdlib_rs::functions;
|
use tdlib_rs::functions;
|
||||||
@@ -13,7 +13,25 @@ use super::types::{
|
|||||||
ChatInfo, FolderInfo, MessageInfo, NetworkState, ProfileInfo, UserOnlineStatus,
|
ChatInfo, FolderInfo, MessageInfo, NetworkState, ProfileInfo, UserOnlineStatus,
|
||||||
};
|
};
|
||||||
use super::users::UserCache;
|
use super::users::UserCache;
|
||||||
use crate::notifications::NotificationManager;
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct TdCredentials {
|
||||||
|
pub api_id: i32,
|
||||||
|
pub api_hash: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct TdClientConfig {
|
||||||
|
pub credentials: TdCredentials,
|
||||||
|
pub db_path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct IncomingMessageEvent {
|
||||||
|
pub chat: ChatInfo,
|
||||||
|
pub message: MessageInfo,
|
||||||
|
pub sender_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
/// TDLib client wrapper for Telegram integration.
|
/// TDLib client wrapper for Telegram integration.
|
||||||
///
|
///
|
||||||
@@ -28,9 +46,15 @@ use crate::notifications::NotificationManager;
|
|||||||
/// # Examples
|
/// # Examples
|
||||||
///
|
///
|
||||||
/// ```ignore
|
/// ```ignore
|
||||||
/// use tele_tui::tdlib::TdClient;
|
/// use tele_core::tdlib::TdClient;
|
||||||
///
|
///
|
||||||
/// let mut client = TdClient::new(std::path::PathBuf::from("tdlib_data"));
|
/// let mut client = TdClient::new(tele_core::tdlib::TdClientConfig {
|
||||||
|
/// credentials: tele_core::tdlib::TdCredentials {
|
||||||
|
/// api_id: 123,
|
||||||
|
/// api_hash: "hash".to_string(),
|
||||||
|
/// },
|
||||||
|
/// db_path: std::path::PathBuf::from("tdlib_data"),
|
||||||
|
/// });
|
||||||
///
|
///
|
||||||
/// // Start authorization
|
/// // Start authorization
|
||||||
/// client.send_phone_number("+1234567890".to_string()).await?;
|
/// client.send_phone_number("+1234567890".to_string()).await?;
|
||||||
@@ -52,7 +76,7 @@ pub struct TdClient {
|
|||||||
pub message_manager: MessageManager,
|
pub message_manager: MessageManager,
|
||||||
pub user_cache: UserCache,
|
pub user_cache: UserCache,
|
||||||
pub reaction_manager: ReactionManager,
|
pub reaction_manager: ReactionManager,
|
||||||
pub notification_manager: NotificationManager,
|
incoming_message_events: VecDeque<IncomingMessageEvent>,
|
||||||
|
|
||||||
// Состояние сети
|
// Состояние сети
|
||||||
pub network_state: NetworkState,
|
pub network_state: NetworkState,
|
||||||
@@ -62,61 +86,41 @@ pub struct TdClient {
|
|||||||
impl TdClient {
|
impl TdClient {
|
||||||
/// Creates a new TDLib client instance.
|
/// Creates a new TDLib client instance.
|
||||||
///
|
///
|
||||||
/// Reads API credentials from:
|
|
||||||
/// 1. ~/.config/tele-tui/credentials file
|
|
||||||
/// 2. Environment variables `API_ID` and `API_HASH` (fallback)
|
|
||||||
///
|
|
||||||
/// Initializes all managers and sets initial network state to Connecting.
|
/// Initializes all managers and sets initial network state to Connecting.
|
||||||
///
|
///
|
||||||
/// # Returns
|
/// # Returns
|
||||||
///
|
///
|
||||||
/// A new `TdClient` instance ready for authentication.
|
/// A new `TdClient` instance ready for authentication.
|
||||||
pub fn new(db_path: PathBuf) -> Self {
|
pub fn new(config: TdClientConfig) -> Self {
|
||||||
// Пробуем загрузить credentials из Config (файл или env)
|
|
||||||
let (api_id, api_hash) = crate::config::Config::load_credentials().unwrap_or_else(|_| {
|
|
||||||
// Fallback на прямое чтение из env (старое поведение)
|
|
||||||
let api_id = env::var("API_ID")
|
|
||||||
.unwrap_or_else(|_| "0".to_string())
|
|
||||||
.parse()
|
|
||||||
.unwrap_or(0);
|
|
||||||
let api_hash = env::var("API_HASH").unwrap_or_default();
|
|
||||||
(api_id, api_hash)
|
|
||||||
});
|
|
||||||
|
|
||||||
let client_id = tdlib_rs::create_client();
|
let client_id = tdlib_rs::create_client();
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
api_id,
|
api_id: config.credentials.api_id,
|
||||||
api_hash,
|
api_hash: config.credentials.api_hash,
|
||||||
db_path,
|
db_path: config.db_path,
|
||||||
client_id,
|
client_id,
|
||||||
auth: AuthManager::new(client_id),
|
auth: AuthManager::new(client_id),
|
||||||
chat_manager: ChatManager::new(client_id),
|
chat_manager: ChatManager::new(client_id),
|
||||||
message_manager: MessageManager::new(client_id),
|
message_manager: MessageManager::new(client_id),
|
||||||
user_cache: UserCache::new(client_id),
|
user_cache: UserCache::new(client_id),
|
||||||
reaction_manager: ReactionManager::new(client_id),
|
reaction_manager: ReactionManager::new(client_id),
|
||||||
notification_manager: NotificationManager::new(),
|
incoming_message_events: VecDeque::new(),
|
||||||
network_state: NetworkState::Connecting,
|
network_state: NetworkState::Connecting,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Configures notification manager from app config
|
pub fn enqueue_incoming_message_event(
|
||||||
pub fn configure_notifications(&mut self, config: &crate::config::NotificationsConfig) {
|
&mut self,
|
||||||
self.notification_manager.set_enabled(config.enabled);
|
chat: ChatInfo,
|
||||||
self.notification_manager
|
message: MessageInfo,
|
||||||
.set_only_mentions(config.only_mentions);
|
sender_name: String,
|
||||||
self.notification_manager.set_show_preview(config.show_preview);
|
) {
|
||||||
self.notification_manager.set_timeout(config.timeout_ms);
|
self.incoming_message_events
|
||||||
self.notification_manager
|
.push_back(IncomingMessageEvent { chat, message, sender_name });
|
||||||
.set_urgency(config.urgency.clone());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Synchronizes muted chats from Telegram to notification manager.
|
pub fn drain_incoming_message_events(&mut self) -> Vec<IncomingMessageEvent> {
|
||||||
///
|
self.incoming_message_events.drain(..).collect()
|
||||||
/// Should be called after chats are loaded to ensure muted chats don't trigger notifications.
|
|
||||||
pub fn sync_notification_muted_chats(&mut self) {
|
|
||||||
self.notification_manager
|
|
||||||
.sync_muted_chats(&self.chat_manager.chats);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Делегирование к auth
|
// Делегирование к auth
|
||||||
@@ -433,24 +437,117 @@ impl TdClient {
|
|||||||
&self.chat_manager.chats
|
&self.chat_manager.chats
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn chats_mut(&mut self) -> &mut Vec<ChatInfo> {
|
pub fn update_chats<F, R>(&mut self, updater: F) -> R
|
||||||
&mut self.chat_manager.chats
|
where
|
||||||
|
F: FnOnce(&mut Vec<ChatInfo>) -> R,
|
||||||
|
{
|
||||||
|
updater(&mut self.chat_manager.chats)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_chat<F>(&mut self, chat_id: ChatId, updater: F) -> bool
|
||||||
|
where
|
||||||
|
F: FnOnce(&mut ChatInfo),
|
||||||
|
{
|
||||||
|
let Some(chat) = self.chat_manager.chats.iter_mut().find(|c| c.id == chat_id) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
updater(chat);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove_chat(&mut self, chat_id: ChatId) {
|
||||||
|
self.chat_manager.chats.retain(|c| c.id != chat_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push_chat(&mut self, chat: ChatInfo) {
|
||||||
|
self.chat_manager.chats.push(chat);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn trim_chats_to_max_by_order(&mut self, max_chats: usize) {
|
||||||
|
if self.chat_manager.chats.len() <= max_chats {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(min_idx) = self
|
||||||
|
.chat_manager
|
||||||
|
.chats
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.min_by_key(|(_, chat)| chat.order)
|
||||||
|
.map(|(idx, _)| idx)
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
self.chat_manager.chats.remove(min_idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sort_chats_by_order(&mut self) {
|
||||||
|
self.chat_manager
|
||||||
|
.chats
|
||||||
|
.sort_by(|a, b| b.order.cmp(&a.order));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn folders(&self) -> &[FolderInfo] {
|
pub fn folders(&self) -> &[FolderInfo] {
|
||||||
&self.chat_manager.folders
|
&self.chat_manager.folders
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn folders_mut(&mut self) -> &mut Vec<FolderInfo> {
|
pub fn update_folders<F, R>(&mut self, updater: F) -> R
|
||||||
&mut self.chat_manager.folders
|
where
|
||||||
|
F: FnOnce(&mut Vec<FolderInfo>) -> R,
|
||||||
|
{
|
||||||
|
updater(&mut self.chat_manager.folders)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_folders(&mut self, folders: Vec<FolderInfo>) {
|
||||||
|
self.chat_manager.folders = folders;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn current_chat_messages(&self) -> &[MessageInfo] {
|
pub fn current_chat_messages(&self) -> &[MessageInfo] {
|
||||||
&self.message_manager.current_chat_messages
|
&self.message_manager.current_chat_messages
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn current_chat_messages_mut(&mut self) -> &mut Vec<MessageInfo> {
|
pub fn clear_current_chat_messages(&mut self) {
|
||||||
&mut self.message_manager.current_chat_messages
|
self.message_manager.current_chat_messages.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_current_chat_messages(&mut self, messages: Vec<MessageInfo>) {
|
||||||
|
self.message_manager.current_chat_messages = messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_current_chat_messages<F, R>(&mut self, updater: F) -> R
|
||||||
|
where
|
||||||
|
F: FnOnce(&mut Vec<MessageInfo>) -> R,
|
||||||
|
{
|
||||||
|
updater(&mut self.message_manager.current_chat_messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_current_chat_message<F>(&mut self, message_id: MessageId, updater: F) -> bool
|
||||||
|
where
|
||||||
|
F: FnOnce(&mut MessageInfo),
|
||||||
|
{
|
||||||
|
let Some(message) = self
|
||||||
|
.message_manager
|
||||||
|
.current_chat_messages
|
||||||
|
.iter_mut()
|
||||||
|
.find(|message| message.id() == message_id)
|
||||||
|
else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
updater(message);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn replace_current_chat_message(
|
||||||
|
&mut self,
|
||||||
|
message_id: MessageId,
|
||||||
|
new_message: MessageInfo,
|
||||||
|
) -> bool {
|
||||||
|
self.update_current_chat_message(message_id, |message| {
|
||||||
|
*message = new_message;
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn current_chat_id(&self) -> Option<ChatId> {
|
pub fn current_chat_id(&self) -> Option<ChatId> {
|
||||||
@@ -498,8 +595,10 @@ impl TdClient {
|
|||||||
&self.user_cache.pending_user_ids
|
&self.user_cache.pending_user_ids
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn pending_user_ids_mut(&mut self) -> &mut Vec<crate::types::UserId> {
|
pub fn queue_pending_user_id(&mut self, user_id: crate::types::UserId) {
|
||||||
&mut self.user_cache.pending_user_ids
|
if !self.user_cache.pending_user_ids.contains(&user_id) {
|
||||||
|
self.user_cache.pending_user_ids.push(user_id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn main_chat_list_position(&self) -> i32 {
|
pub fn main_chat_list_position(&self) -> i32 {
|
||||||
@@ -515,8 +614,11 @@ impl TdClient {
|
|||||||
&self.user_cache
|
&self.user_cache
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn user_cache_mut(&mut self) -> &mut UserCache {
|
pub fn update_user_cache<F, R>(&mut self, updater: F) -> R
|
||||||
&mut self.user_cache
|
where
|
||||||
|
F: FnOnce(&mut UserCache) -> R,
|
||||||
|
{
|
||||||
|
updater(&mut self.user_cache)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Helper методы для упрощения обработки updates ====================
|
// ==================== Helper методы для упрощения обработки updates ====================
|
||||||
@@ -558,7 +660,7 @@ impl TdClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Пересортируем по order
|
// Пересортируем по order
|
||||||
self.chats_mut().sort_by(|a, b| b.order.cmp(&a.order));
|
self.sort_chats_by_order();
|
||||||
}
|
}
|
||||||
Update::ChatReadInbox(update) => {
|
Update::ChatReadInbox(update) => {
|
||||||
crate::tdlib::chat_helpers::update_chat(
|
crate::tdlib::chat_helpers::update_chat(
|
||||||
@@ -600,11 +702,13 @@ impl TdClient {
|
|||||||
);
|
);
|
||||||
// Если это текущий открытый чат — обновляем is_read у сообщений
|
// Если это текущий открытый чат — обновляем is_read у сообщений
|
||||||
if Some(ChatId::new(update.chat_id)) == self.current_chat_id() {
|
if Some(ChatId::new(update.chat_id)) == self.current_chat_id() {
|
||||||
for msg in self.current_chat_messages_mut().iter_mut() {
|
self.update_current_chat_messages(|messages| {
|
||||||
|
for msg in messages {
|
||||||
if msg.is_outgoing() && msg.id() <= last_read_msg_id {
|
if msg.is_outgoing() && msg.id() <= last_read_msg_id {
|
||||||
msg.state.is_read = true;
|
msg.state.is_read = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Update::ChatPosition(update) => {
|
Update::ChatPosition(update) => {
|
||||||
@@ -618,11 +722,13 @@ impl TdClient {
|
|||||||
}
|
}
|
||||||
Update::ChatFolders(update) => {
|
Update::ChatFolders(update) => {
|
||||||
// Обновляем список папок
|
// Обновляем список папок
|
||||||
*self.folders_mut() = update
|
self.set_folders(
|
||||||
|
update
|
||||||
.chat_folders
|
.chat_folders
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|f| FolderInfo { id: f.id, name: f.title })
|
.map(|f| FolderInfo { id: f.id, name: f.title })
|
||||||
.collect();
|
.collect(),
|
||||||
|
);
|
||||||
self.set_main_chat_list_position(update.main_chat_list_position);
|
self.set_main_chat_list_position(update.main_chat_list_position);
|
||||||
}
|
}
|
||||||
Update::UserStatus(update) => {
|
Update::UserStatus(update) => {
|
||||||
@@ -635,9 +741,11 @@ impl TdClient {
|
|||||||
UserStatus::LastMonth(_) => UserOnlineStatus::LastMonth,
|
UserStatus::LastMonth(_) => UserOnlineStatus::LastMonth,
|
||||||
UserStatus::Empty => UserOnlineStatus::LongTimeAgo,
|
UserStatus::Empty => UserOnlineStatus::LongTimeAgo,
|
||||||
};
|
};
|
||||||
self.user_cache
|
self.update_user_cache(|cache| {
|
||||||
|
cache
|
||||||
.user_statuses
|
.user_statuses
|
||||||
.insert(UserId::new(update.user_id), status);
|
.insert(UserId::new(update.user_id), status);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
Update::ConnectionState(update) => {
|
Update::ConnectionState(update) => {
|
||||||
// Обновляем состояние сетевого соединения
|
// Обновляем состояние сетевого соединения
|
||||||
@@ -686,7 +794,13 @@ impl TdClient {
|
|||||||
let _ = functions::close(self.client_id).await;
|
let _ = functions::close(self.client_id).await;
|
||||||
|
|
||||||
// 2. Create new client
|
// 2. Create new client
|
||||||
let new_client = TdClient::new(db_path);
|
let new_client = TdClient::new(TdClientConfig {
|
||||||
|
credentials: TdCredentials {
|
||||||
|
api_id: self.api_id,
|
||||||
|
api_hash: self.api_hash.clone(),
|
||||||
|
},
|
||||||
|
db_path,
|
||||||
|
});
|
||||||
|
|
||||||
// 3. Spawn set_tdlib_parameters for new client
|
// 3. Spawn set_tdlib_parameters for new client
|
||||||
let new_client_id = new_client.client_id;
|
let new_client_id = new_client.client_id;
|
||||||
@@ -3,19 +3,22 @@
|
|||||||
//! This file contains the trait implementation that delegates to existing TdClient methods.
|
//! This file contains the trait implementation that delegates to existing TdClient methods.
|
||||||
|
|
||||||
use super::client::TdClient;
|
use super::client::TdClient;
|
||||||
use super::r#trait::TdClientTrait;
|
use super::r#trait::{
|
||||||
|
AccountClient, AuthClient, ChatActionClient, ChatClient, ClientState, FileClient,
|
||||||
|
MessageClient, ReactionClient, UpdateClient, UserClient,
|
||||||
|
};
|
||||||
use super::{
|
use super::{
|
||||||
AuthState, ChatInfo, FolderInfo, MessageInfo, ProfileInfo, ReplyInfo, UserCache,
|
AuthState, ChatInfo, FolderInfo, MessageInfo, ProfileInfo, ReplyInfo, UserCache,
|
||||||
UserOnlineStatus,
|
UserOnlineStatus,
|
||||||
};
|
};
|
||||||
use crate::types::{ChatId, MessageId, UserId};
|
use crate::types::{ChatId, MessageId, UserId};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use std::borrow::Cow;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use tdlib_rs::enums::{ChatAction, Update};
|
use tdlib_rs::enums::{ChatAction, Update};
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl TdClientTrait for TdClient {
|
impl AuthClient for TdClient {
|
||||||
// ============ Auth methods ============
|
|
||||||
async fn send_phone_number(&self, phone: String) -> Result<(), String> {
|
async fn send_phone_number(&self, phone: String) -> Result<(), String> {
|
||||||
self.send_phone_number(phone).await
|
self.send_phone_number(phone).await
|
||||||
}
|
}
|
||||||
@@ -27,8 +30,10 @@ impl TdClientTrait for TdClient {
|
|||||||
async fn send_password(&self, password: String) -> Result<(), String> {
|
async fn send_password(&self, password: String) -> Result<(), String> {
|
||||||
self.send_password(password).await
|
self.send_password(password).await
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============ Chat methods ============
|
#[async_trait]
|
||||||
|
impl ChatClient for TdClient {
|
||||||
async fn load_chats(&mut self, limit: i32) -> Result<(), String> {
|
async fn load_chats(&mut self, limit: i32) -> Result<(), String> {
|
||||||
self.load_chats(limit).await
|
self.load_chats(limit).await
|
||||||
}
|
}
|
||||||
@@ -45,7 +50,39 @@ impl TdClientTrait for TdClient {
|
|||||||
self.get_profile_info(chat_id).await
|
self.get_profile_info(chat_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Chat actions ============
|
fn chats(&self) -> &[ChatInfo] {
|
||||||
|
self.chats()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn folders(&self) -> &[FolderInfo] {
|
||||||
|
self.folders()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main_chat_list_position(&self) -> i32 {
|
||||||
|
self.main_chat_list_position()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_main_chat_list_position(&mut self, position: i32) {
|
||||||
|
self.set_main_chat_list_position(position)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_chats<F>(&mut self, updater: F)
|
||||||
|
where
|
||||||
|
F: FnOnce(&mut Vec<ChatInfo>),
|
||||||
|
{
|
||||||
|
TdClient::update_chats(self, updater);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_folders<F>(&mut self, updater: F)
|
||||||
|
where
|
||||||
|
F: FnOnce(&mut Vec<FolderInfo>),
|
||||||
|
{
|
||||||
|
TdClient::update_folders(self, updater);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ChatActionClient for TdClient {
|
||||||
async fn send_chat_action(&self, chat_id: ChatId, action: ChatAction) {
|
async fn send_chat_action(&self, chat_id: ChatId, action: ChatAction) {
|
||||||
self.send_chat_action(chat_id, action).await
|
self.send_chat_action(chat_id, action).await
|
||||||
}
|
}
|
||||||
@@ -54,7 +91,17 @@ impl TdClientTrait for TdClient {
|
|||||||
self.clear_stale_typing_status()
|
self.clear_stale_typing_status()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Message methods ============
|
fn typing_status(&self) -> Option<&(UserId, String, std::time::Instant)> {
|
||||||
|
self.typing_status()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_typing_status(&mut self, status: Option<(UserId, String, std::time::Instant)>) {
|
||||||
|
self.set_typing_status(status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl MessageClient for TdClient {
|
||||||
async fn get_chat_history(
|
async fn get_chat_history(
|
||||||
&mut self,
|
&mut self,
|
||||||
chat_id: ChatId,
|
chat_id: ChatId,
|
||||||
@@ -132,6 +179,18 @@ impl TdClientTrait for TdClient {
|
|||||||
self.set_draft_message(chat_id, text).await
|
self.set_draft_message(chat_id, text).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn current_chat_messages(&self) -> Cow<'_, [MessageInfo]> {
|
||||||
|
Cow::Borrowed(self.current_chat_messages())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_chat_id(&self) -> Option<ChatId> {
|
||||||
|
self.current_chat_id()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_pinned_message(&self) -> Option<MessageInfo> {
|
||||||
|
self.current_pinned_message().cloned()
|
||||||
|
}
|
||||||
|
|
||||||
fn push_message(&mut self, msg: MessageInfo) {
|
fn push_message(&mut self, msg: MessageInfo) {
|
||||||
self.push_message(msg)
|
self.push_message(msg)
|
||||||
}
|
}
|
||||||
@@ -144,16 +203,66 @@ impl TdClientTrait for TdClient {
|
|||||||
self.process_pending_view_messages().await
|
self.process_pending_view_messages().await
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ User methods ============
|
fn clear_current_chat_messages(&mut self) {
|
||||||
|
TdClient::clear_current_chat_messages(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_current_chat_messages(&mut self, messages: Vec<MessageInfo>) {
|
||||||
|
TdClient::set_current_chat_messages(self, messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_current_chat_messages<F>(&mut self, updater: F)
|
||||||
|
where
|
||||||
|
F: FnOnce(&mut Vec<MessageInfo>),
|
||||||
|
{
|
||||||
|
TdClient::update_current_chat_messages(self, updater);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_current_chat_id(&mut self, chat_id: Option<ChatId>) {
|
||||||
|
self.set_current_chat_id(chat_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_current_pinned_message(&mut self, msg: Option<MessageInfo>) {
|
||||||
|
self.set_current_pinned_message(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pending_view_messages(&self) -> &[(ChatId, Vec<MessageId>)] {
|
||||||
|
self.pending_view_messages()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enqueue_pending_view_messages(&mut self, chat_id: ChatId, message_ids: Vec<MessageId>) {
|
||||||
|
self.enqueue_pending_view_messages(chat_id, message_ids);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl UserClient for TdClient {
|
||||||
fn get_user_status_by_chat_id(&self, chat_id: ChatId) -> Option<&UserOnlineStatus> {
|
fn get_user_status_by_chat_id(&self, chat_id: ChatId) -> Option<&UserOnlineStatus> {
|
||||||
self.get_user_status_by_chat_id(chat_id)
|
self.get_user_status_by_chat_id(chat_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn pending_user_ids(&self) -> &[UserId] {
|
||||||
|
self.pending_user_ids()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn user_cache(&self) -> &UserCache {
|
||||||
|
self.user_cache()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_user_cache<F>(&mut self, updater: F)
|
||||||
|
where
|
||||||
|
F: FnOnce(&mut UserCache),
|
||||||
|
{
|
||||||
|
TdClient::update_user_cache(self, updater);
|
||||||
|
}
|
||||||
|
|
||||||
async fn process_pending_user_ids(&mut self) {
|
async fn process_pending_user_ids(&mut self) {
|
||||||
self.process_pending_user_ids().await
|
self.process_pending_user_ids().await
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============ Reaction methods ============
|
#[async_trait]
|
||||||
|
impl ReactionClient for TdClient {
|
||||||
async fn get_message_available_reactions(
|
async fn get_message_available_reactions(
|
||||||
&self,
|
&self,
|
||||||
chat_id: ChatId,
|
chat_id: ChatId,
|
||||||
@@ -171,8 +280,10 @@ impl TdClientTrait for TdClient {
|
|||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
self.toggle_reaction(chat_id, message_id, reaction).await
|
self.toggle_reaction(chat_id, message_id, reaction).await
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============ File methods ============
|
#[async_trait]
|
||||||
|
impl FileClient for TdClient {
|
||||||
async fn download_file(&self, file_id: i32) -> Result<String, String> {
|
async fn download_file(&self, file_id: i32) -> Result<String, String> {
|
||||||
self.download_file(file_id).await
|
self.download_file(file_id).await
|
||||||
}
|
}
|
||||||
@@ -181,7 +292,10 @@ impl TdClientTrait for TdClient {
|
|||||||
// Voice notes use the same download mechanism as photos
|
// Voice notes use the same download mechanism as photos
|
||||||
self.download_file(file_id).await
|
self.download_file(file_id).await
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ClientState for TdClient {
|
||||||
fn client_id(&self) -> i32 {
|
fn client_id(&self) -> i32 {
|
||||||
self.client_id()
|
self.client_id()
|
||||||
}
|
}
|
||||||
@@ -194,116 +308,25 @@ impl TdClientTrait for TdClient {
|
|||||||
self.auth_state()
|
self.auth_state()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn chats(&self) -> &[ChatInfo] {
|
|
||||||
self.chats()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn folders(&self) -> &[FolderInfo] {
|
|
||||||
self.folders()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn current_chat_messages(&self) -> Vec<MessageInfo> {
|
|
||||||
self.message_manager.current_chat_messages.to_vec()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn current_chat_id(&self) -> Option<ChatId> {
|
|
||||||
self.current_chat_id()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn current_pinned_message(&self) -> Option<MessageInfo> {
|
|
||||||
self.message_manager.current_pinned_message.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn typing_status(&self) -> Option<&(UserId, String, std::time::Instant)> {
|
|
||||||
self.typing_status()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn pending_view_messages(&self) -> &[(ChatId, Vec<MessageId>)] {
|
|
||||||
self.pending_view_messages()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn pending_user_ids(&self) -> &[UserId] {
|
|
||||||
self.pending_user_ids()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn main_chat_list_position(&self) -> i32 {
|
|
||||||
self.main_chat_list_position()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn user_cache(&self) -> &UserCache {
|
|
||||||
self.user_cache()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn network_state(&self) -> super::types::NetworkState {
|
fn network_state(&self) -> super::types::NetworkState {
|
||||||
self.network_state.clone()
|
self.network_state.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn chats_mut(&mut self) -> &mut Vec<ChatInfo> {
|
|
||||||
self.chats_mut()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn folders_mut(&mut self) -> &mut Vec<FolderInfo> {
|
#[async_trait]
|
||||||
self.folders_mut()
|
impl AccountClient for TdClient {
|
||||||
}
|
|
||||||
|
|
||||||
fn current_chat_messages_mut(&mut self) -> &mut Vec<MessageInfo> {
|
|
||||||
self.current_chat_messages_mut()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn clear_current_chat_messages(&mut self) {
|
|
||||||
self.current_chat_messages_mut().clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_current_chat_messages(&mut self, messages: Vec<MessageInfo>) {
|
|
||||||
*self.current_chat_messages_mut() = messages;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_current_chat_id(&mut self, chat_id: Option<ChatId>) {
|
|
||||||
self.set_current_chat_id(chat_id)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_current_pinned_message(&mut self, msg: Option<MessageInfo>) {
|
|
||||||
self.set_current_pinned_message(msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_typing_status(&mut self, status: Option<(UserId, String, std::time::Instant)>) {
|
|
||||||
self.set_typing_status(status)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn enqueue_pending_view_messages(&mut self, chat_id: ChatId, message_ids: Vec<MessageId>) {
|
|
||||||
self.enqueue_pending_view_messages(chat_id, message_ids);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn pending_user_ids_mut(&mut self) -> &mut Vec<UserId> {
|
|
||||||
self.pending_user_ids_mut()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_main_chat_list_position(&mut self, position: i32) {
|
|
||||||
self.set_main_chat_list_position(position)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn user_cache_mut(&mut self) -> &mut UserCache {
|
|
||||||
&mut self.user_cache
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ Notification methods ============
|
|
||||||
fn configure_notifications(&mut self, config: &crate::config::NotificationsConfig) {
|
|
||||||
self.configure_notifications(config);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sync_notification_muted_chats(&mut self) {
|
|
||||||
self.notification_manager
|
|
||||||
.sync_muted_chats(&self.chat_manager.chats);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ Account switching ============
|
|
||||||
async fn recreate_client(&mut self, db_path: PathBuf) -> Result<(), String> {
|
async fn recreate_client(&mut self, db_path: PathBuf) -> Result<(), String> {
|
||||||
TdClient::recreate_client(self, db_path).await
|
TdClient::recreate_client(self, db_path).await
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============ Update handling ============
|
impl UpdateClient for TdClient {
|
||||||
fn handle_update(&mut self, update: Update) {
|
fn handle_update(&mut self, update: Update) {
|
||||||
// Delegate to the real implementation
|
// Delegate to the real implementation
|
||||||
TdClient::handle_update(self, update)
|
TdClient::handle_update(self, update)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn drain_incoming_message_events(&mut self) -> Vec<super::IncomingMessageEvent> {
|
||||||
|
TdClient::drain_incoming_message_events(self)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -23,9 +23,7 @@ pub fn convert_message(client: &mut TdClient, message: &TdMessage, chat_id: Chat
|
|||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_else(|| {
|
.unwrap_or_else(|| {
|
||||||
// Добавляем в очередь для загрузки
|
// Добавляем в очередь для загрузки
|
||||||
if !client.pending_user_ids().contains(&user_id) {
|
client.queue_pending_user_id(user_id);
|
||||||
client.pending_user_ids_mut().push(user_id);
|
|
||||||
}
|
|
||||||
format!("User_{}", user_id.as_i64())
|
format!("User_{}", user_id.as_i64())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -210,7 +208,8 @@ pub fn update_reply_info_from_loaded_messages(client: &mut TdClient) {
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Обновляем reply_to для сообщений с неполными данными
|
// Обновляем reply_to для сообщений с неполными данными
|
||||||
for msg in client.current_chat_messages_mut().iter_mut() {
|
client.update_current_chat_messages(|messages| {
|
||||||
|
for msg in messages {
|
||||||
let Some(ref mut reply) = msg.interactions.reply_to else {
|
let Some(ref mut reply) = msg.interactions.reply_to else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
@@ -231,4 +230,5 @@ pub fn update_reply_info_from_loaded_messages(client: &mut TdClient) {
|
|||||||
reply.text = content.clone();
|
reply.text = content.clone();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -280,15 +280,13 @@ impl MessageManager {
|
|||||||
///
|
///
|
||||||
/// * `chat_id` - ID чата
|
/// * `chat_id` - ID чата
|
||||||
///
|
///
|
||||||
/// # Note
|
/// # Compatibility
|
||||||
///
|
///
|
||||||
/// TODO: В tdlib-rs 1.8.29 поле `pinned_message_id` было удалено из `Chat`.
|
/// The current `tdlib-rs` schema no longer exposes `Chat.pinned_message_id`, and the
|
||||||
/// Нужно использовать `getChatPinnedMessage` или альтернативный способ.
|
/// generated wrapper does not provide `getChatPinnedMessage`. The pinned-message modal
|
||||||
/// Временно отключено, возвращает `None`.
|
/// uses `get_pinned_messages` with `SearchMessagesFilter::Pinned`; this method keeps the
|
||||||
|
/// legacy single-header state empty until TDLib exposes a direct top-pinned-message API.
|
||||||
pub async fn load_current_pinned_message(&mut self, _chat_id: ChatId) {
|
pub async fn load_current_pinned_message(&mut self, _chat_id: ChatId) {
|
||||||
// TODO: В tdlib-rs 1.8.29 поле pinned_message_id было удалено из Chat.
|
|
||||||
// Нужно использовать getChatPinnedMessage или альтернативный способ.
|
|
||||||
// Временно отключено.
|
|
||||||
self.current_pinned_message = None;
|
self.current_pinned_message = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4,7 +4,7 @@ mod chat_helpers; // Chat management helpers
|
|||||||
pub mod chats;
|
pub mod chats;
|
||||||
pub mod client;
|
pub mod client;
|
||||||
mod client_impl; // Private module for trait implementation
|
mod client_impl; // Private module for trait implementation
|
||||||
pub(crate) mod message_conversion; // Message conversion utilities (for messages.rs)
|
pub mod message_conversion; // Message conversion utilities (for messages.rs)
|
||||||
mod message_converter; // Message conversion utilities (for client.rs)
|
mod message_converter; // Message conversion utilities (for client.rs)
|
||||||
pub mod messages;
|
pub mod messages;
|
||||||
pub mod reactions;
|
pub mod reactions;
|
||||||
@@ -16,7 +16,11 @@ pub mod users;
|
|||||||
// Экспорт основных типов
|
// Экспорт основных типов
|
||||||
pub use auth::AuthState;
|
pub use auth::AuthState;
|
||||||
pub use client::TdClient;
|
pub use client::TdClient;
|
||||||
pub use r#trait::TdClientTrait;
|
#[allow(unused_imports)]
|
||||||
|
pub use r#trait::{
|
||||||
|
AccountClient, AuthClient, ChatActionClient, ChatClient, ClientState, FileClient,
|
||||||
|
MessageClient, ReactionClient, TdClientTrait, UpdateClient, UserClient,
|
||||||
|
};
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use types::{
|
pub use types::{
|
||||||
ChatInfo, FolderInfo, MediaInfo, MessageBuilder, MessageInfo, NetworkState, PhotoDownloadState,
|
ChatInfo, FolderInfo, MediaInfo, MessageBuilder, MessageInfo, NetworkState, PhotoDownloadState,
|
||||||
@@ -24,6 +28,7 @@ pub use types::{
|
|||||||
VoiceDownloadState, VoiceInfo,
|
VoiceDownloadState, VoiceInfo,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub use client::{IncomingMessageEvent, TdClientConfig, TdCredentials};
|
||||||
#[cfg(feature = "images")]
|
#[cfg(feature = "images")]
|
||||||
pub use types::ImageModalState;
|
pub use types::ImageModalState;
|
||||||
pub use users::UserCache;
|
pub use users::UserCache;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
use crate::types::{ChatId, MessageId};
|
use crate::types::{ChatId, MessageId};
|
||||||
use tdlib_rs::enums::ReactionType;
|
use tdlib_rs::enums::{AvailableReactions, ReactionType};
|
||||||
use tdlib_rs::functions;
|
use tdlib_rs::functions;
|
||||||
use tdlib_rs::types::ReactionTypeEmoji;
|
use tdlib_rs::types::{AvailableReaction, ReactionTypeEmoji};
|
||||||
|
|
||||||
/// Менеджер реакций на сообщения.
|
/// Менеджер реакций на сообщения.
|
||||||
///
|
///
|
||||||
@@ -49,11 +49,6 @@ impl ReactionManager {
|
|||||||
/// * `Ok(Vec<String>)` - Список доступных emoji реакций
|
/// * `Ok(Vec<String>)` - Список доступных emoji реакций
|
||||||
/// * `Err(String)` - Ошибка получения
|
/// * `Err(String)` - Ошибка получения
|
||||||
///
|
///
|
||||||
/// # Note
|
|
||||||
///
|
|
||||||
/// В tdlib-rs 1.8.29 структура AvailableReactions изменилась.
|
|
||||||
/// Временно возвращается стандартный набор из 12 популярных реакций.
|
|
||||||
///
|
|
||||||
/// # Examples
|
/// # Examples
|
||||||
///
|
///
|
||||||
/// ```ignore
|
/// ```ignore
|
||||||
@@ -86,54 +81,15 @@ impl ReactionManager {
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
match reactions_result {
|
match reactions_result {
|
||||||
Ok(_available) => {
|
Ok(available) => {
|
||||||
// TODO: В tdlib-rs 1.8.29 структура AvailableReactions изменилась
|
let emojis = available_reaction_emojis(&available);
|
||||||
// Временно используем fallback на стандартные реакции
|
|
||||||
let emojis: Vec<String> = Vec::new();
|
|
||||||
|
|
||||||
// let emojis: Vec<String> = if let tdlib_rs::enums::AvailableReactions::AvailableReactions(ar) = available {
|
|
||||||
// ar.top_reactions.iter().filter_map(...).collect()
|
|
||||||
// } else {
|
|
||||||
// Vec::new()
|
|
||||||
// };
|
|
||||||
|
|
||||||
if emojis.is_empty() {
|
if emojis.is_empty() {
|
||||||
// Фолбек на стандартные реакции
|
Ok(default_reaction_emojis())
|
||||||
Ok(vec![
|
|
||||||
"👍".to_string(),
|
|
||||||
"👎".to_string(),
|
|
||||||
"❤️".to_string(),
|
|
||||||
"🔥".to_string(),
|
|
||||||
"😊".to_string(),
|
|
||||||
"😢".to_string(),
|
|
||||||
"😮".to_string(),
|
|
||||||
"🎉".to_string(),
|
|
||||||
"🤔".to_string(),
|
|
||||||
"😡".to_string(),
|
|
||||||
"😎".to_string(),
|
|
||||||
"🤝".to_string(),
|
|
||||||
])
|
|
||||||
} else {
|
} else {
|
||||||
Ok(emojis)
|
Ok(emojis)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => Ok(default_reaction_emojis()),
|
||||||
// В случае ошибки возвращаем стандартный набор
|
|
||||||
Ok(vec![
|
|
||||||
"👍".to_string(),
|
|
||||||
"👎".to_string(),
|
|
||||||
"❤️".to_string(),
|
|
||||||
"🔥".to_string(),
|
|
||||||
"😊".to_string(),
|
|
||||||
"😢".to_string(),
|
|
||||||
"😮".to_string(),
|
|
||||||
"🎉".to_string(),
|
|
||||||
"🤔".to_string(),
|
|
||||||
"😡".to_string(),
|
|
||||||
"😎".to_string(),
|
|
||||||
"🤝".to_string(),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,3 +152,79 @@ impl ReactionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_reaction_emojis() -> Vec<String> {
|
||||||
|
vec![
|
||||||
|
"👍".to_string(),
|
||||||
|
"👎".to_string(),
|
||||||
|
"❤️".to_string(),
|
||||||
|
"🔥".to_string(),
|
||||||
|
"😊".to_string(),
|
||||||
|
"😢".to_string(),
|
||||||
|
"😮".to_string(),
|
||||||
|
"🎉".to_string(),
|
||||||
|
"🤔".to_string(),
|
||||||
|
"😡".to_string(),
|
||||||
|
"😎".to_string(),
|
||||||
|
"🤝".to_string(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn available_reaction_emojis(available: &AvailableReactions) -> Vec<String> {
|
||||||
|
let AvailableReactions::AvailableReactions(available) = available;
|
||||||
|
|
||||||
|
available
|
||||||
|
.top_reactions
|
||||||
|
.iter()
|
||||||
|
.chain(available.recent_reactions.iter())
|
||||||
|
.chain(available.popular_reactions.iter())
|
||||||
|
.filter_map(reaction_emoji)
|
||||||
|
.fold(Vec::new(), |mut emojis, emoji| {
|
||||||
|
if !emojis.contains(&emoji) {
|
||||||
|
emojis.push(emoji);
|
||||||
|
}
|
||||||
|
emojis
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reaction_emoji(reaction: &AvailableReaction) -> Option<String> {
|
||||||
|
match &reaction.r#type {
|
||||||
|
ReactionType::Emoji(emoji) => Some(emoji.emoji.clone()),
|
||||||
|
ReactionType::CustomEmoji(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tdlib_rs::types::{AvailableReaction, AvailableReactions as AvailableReactionsData};
|
||||||
|
|
||||||
|
fn emoji_reaction(emoji: &str) -> AvailableReaction {
|
||||||
|
AvailableReaction {
|
||||||
|
r#type: ReactionType::Emoji(ReactionTypeEmoji { emoji: emoji.to_string() }),
|
||||||
|
needs_premium: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extracts_unique_emoji_reactions_in_display_order() {
|
||||||
|
let available = AvailableReactions::AvailableReactions(AvailableReactionsData {
|
||||||
|
top_reactions: vec![emoji_reaction("👍"), emoji_reaction("🔥")],
|
||||||
|
recent_reactions: vec![emoji_reaction("🔥"), emoji_reaction("❤️")],
|
||||||
|
popular_reactions: vec![emoji_reaction("🎉")],
|
||||||
|
allow_custom_emoji: false,
|
||||||
|
are_tags: false,
|
||||||
|
unavailability_reason: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
available_reaction_emojis(&available),
|
||||||
|
vec![
|
||||||
|
"👍".to_string(),
|
||||||
|
"🔥".to_string(),
|
||||||
|
"❤️".to_string(),
|
||||||
|
"🎉".to_string(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,38 +1,60 @@
|
|||||||
//! Trait definition for TdClient to enable dependency injection
|
//! Trait definition for TdClient to enable dependency injection
|
||||||
//!
|
//!
|
||||||
//! This trait allows tests to use FakeTdClient instead of real TDLib client.
|
//! This trait allows tests to use FakeTdClient instead of real TDLib client.
|
||||||
|
#![allow(dead_code)]
|
||||||
|
|
||||||
use crate::tdlib::{AuthState, FolderInfo, MessageInfo, ProfileInfo, UserCache, UserOnlineStatus};
|
use crate::tdlib::{
|
||||||
|
AuthState, FolderInfo, IncomingMessageEvent, MessageInfo, ProfileInfo, UserCache,
|
||||||
|
UserOnlineStatus,
|
||||||
|
};
|
||||||
use crate::types::{ChatId, MessageId, UserId};
|
use crate::types::{ChatId, MessageId, UserId};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use std::borrow::Cow;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use tdlib_rs::enums::{ChatAction, Update};
|
use tdlib_rs::enums::{ChatAction, Update};
|
||||||
|
|
||||||
use super::ChatInfo;
|
use super::ChatInfo;
|
||||||
|
|
||||||
/// Trait for TDLib client operations
|
/// Auth operations.
|
||||||
///
|
|
||||||
/// This trait defines the interface for both real and fake TDLib clients,
|
|
||||||
/// enabling dependency injection and easier testing.
|
|
||||||
#[allow(dead_code)]
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait TdClientTrait: Send {
|
pub trait AuthClient: Send {
|
||||||
// ============ Auth methods ============
|
|
||||||
async fn send_phone_number(&self, phone: String) -> Result<(), String>;
|
async fn send_phone_number(&self, phone: String) -> Result<(), String>;
|
||||||
async fn send_code(&self, code: String) -> Result<(), String>;
|
async fn send_code(&self, code: String) -> Result<(), String>;
|
||||||
async fn send_password(&self, password: String) -> Result<(), String>;
|
async fn send_password(&self, password: String) -> Result<(), String>;
|
||||||
|
}
|
||||||
|
|
||||||
// ============ Chat methods ============
|
/// Chat list and profile operations.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait ChatClient: Send {
|
||||||
async fn load_chats(&mut self, limit: i32) -> Result<(), String>;
|
async fn load_chats(&mut self, limit: i32) -> Result<(), String>;
|
||||||
async fn load_folder_chats(&mut self, folder_id: i32, limit: i32) -> Result<(), String>;
|
async fn load_folder_chats(&mut self, folder_id: i32, limit: i32) -> Result<(), String>;
|
||||||
async fn leave_chat(&self, chat_id: ChatId) -> Result<(), String>;
|
async fn leave_chat(&self, chat_id: ChatId) -> Result<(), String>;
|
||||||
async fn get_profile_info(&self, chat_id: ChatId) -> Result<ProfileInfo, String>;
|
async fn get_profile_info(&self, chat_id: ChatId) -> Result<ProfileInfo, String>;
|
||||||
|
|
||||||
// ============ Chat actions ============
|
fn chats(&self) -> &[ChatInfo];
|
||||||
|
fn folders(&self) -> &[FolderInfo];
|
||||||
|
fn main_chat_list_position(&self) -> i32;
|
||||||
|
fn set_main_chat_list_position(&mut self, position: i32);
|
||||||
|
fn update_chats<F>(&mut self, updater: F)
|
||||||
|
where
|
||||||
|
F: FnOnce(&mut Vec<ChatInfo>);
|
||||||
|
fn update_folders<F>(&mut self, updater: F)
|
||||||
|
where
|
||||||
|
F: FnOnce(&mut Vec<FolderInfo>);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ephemeral chat actions such as typing status.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait ChatActionClient: Send {
|
||||||
async fn send_chat_action(&self, chat_id: ChatId, action: ChatAction);
|
async fn send_chat_action(&self, chat_id: ChatId, action: ChatAction);
|
||||||
fn clear_stale_typing_status(&mut self) -> bool;
|
fn clear_stale_typing_status(&mut self) -> bool;
|
||||||
|
fn typing_status(&self) -> Option<&(UserId, String, std::time::Instant)>;
|
||||||
|
fn set_typing_status(&mut self, status: Option<(UserId, String, std::time::Instant)>);
|
||||||
|
}
|
||||||
|
|
||||||
// ============ Message methods ============
|
/// Message history, search, and mutation operations.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait MessageClient: Send {
|
||||||
async fn get_chat_history(
|
async fn get_chat_history(
|
||||||
&mut self,
|
&mut self,
|
||||||
chat_id: ChatId,
|
chat_id: ChatId,
|
||||||
@@ -82,15 +104,38 @@ pub trait TdClientTrait: Send {
|
|||||||
|
|
||||||
async fn set_draft_message(&self, chat_id: ChatId, text: String) -> Result<(), String>;
|
async fn set_draft_message(&self, chat_id: ChatId, text: String) -> Result<(), String>;
|
||||||
|
|
||||||
|
fn current_chat_messages(&self) -> Cow<'_, [MessageInfo]>;
|
||||||
|
fn current_chat_id(&self) -> Option<ChatId>;
|
||||||
|
fn current_pinned_message(&self) -> Option<MessageInfo>;
|
||||||
fn push_message(&mut self, msg: MessageInfo);
|
fn push_message(&mut self, msg: MessageInfo);
|
||||||
|
fn clear_current_chat_messages(&mut self);
|
||||||
|
fn set_current_chat_messages(&mut self, messages: Vec<MessageInfo>);
|
||||||
|
fn update_current_chat_messages<F>(&mut self, updater: F)
|
||||||
|
where
|
||||||
|
F: FnOnce(&mut Vec<MessageInfo>);
|
||||||
|
fn set_current_chat_id(&mut self, chat_id: Option<ChatId>);
|
||||||
|
fn set_current_pinned_message(&mut self, msg: Option<MessageInfo>);
|
||||||
|
fn pending_view_messages(&self) -> &[(ChatId, Vec<MessageId>)];
|
||||||
|
fn enqueue_pending_view_messages(&mut self, chat_id: ChatId, message_ids: Vec<MessageId>);
|
||||||
async fn fetch_missing_reply_info(&mut self);
|
async fn fetch_missing_reply_info(&mut self);
|
||||||
async fn process_pending_view_messages(&mut self);
|
async fn process_pending_view_messages(&mut self);
|
||||||
|
}
|
||||||
|
|
||||||
// ============ User methods ============
|
/// User cache and user-status operations.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait UserClient: Send {
|
||||||
fn get_user_status_by_chat_id(&self, chat_id: ChatId) -> Option<&UserOnlineStatus>;
|
fn get_user_status_by_chat_id(&self, chat_id: ChatId) -> Option<&UserOnlineStatus>;
|
||||||
|
fn pending_user_ids(&self) -> &[UserId];
|
||||||
|
fn user_cache(&self) -> &UserCache;
|
||||||
|
fn update_user_cache<F>(&mut self, updater: F)
|
||||||
|
where
|
||||||
|
F: FnOnce(&mut UserCache);
|
||||||
async fn process_pending_user_ids(&mut self);
|
async fn process_pending_user_ids(&mut self);
|
||||||
|
}
|
||||||
|
|
||||||
// ============ Reaction methods ============
|
/// Message reaction operations.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait ReactionClient: Send {
|
||||||
async fn get_message_available_reactions(
|
async fn get_message_available_reactions(
|
||||||
&self,
|
&self,
|
||||||
chat_id: ChatId,
|
chat_id: ChatId,
|
||||||
@@ -103,52 +148,71 @@ pub trait TdClientTrait: Send {
|
|||||||
message_id: MessageId,
|
message_id: MessageId,
|
||||||
reaction: String,
|
reaction: String,
|
||||||
) -> Result<(), String>;
|
) -> Result<(), String>;
|
||||||
|
}
|
||||||
|
|
||||||
// ============ File methods ============
|
/// File download operations.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait FileClient: Send {
|
||||||
async fn download_file(&self, file_id: i32) -> Result<String, String>;
|
async fn download_file(&self, file_id: i32) -> Result<String, String>;
|
||||||
async fn download_voice_note(&self, file_id: i32) -> Result<String, String>;
|
async fn download_voice_note(&self, file_id: i32) -> Result<String, String>;
|
||||||
|
}
|
||||||
|
|
||||||
// ============ Getters (immutable) ============
|
/// Shared client state that does not belong to one feature area.
|
||||||
|
#[async_trait]
|
||||||
|
pub trait ClientState: Send {
|
||||||
fn client_id(&self) -> i32;
|
fn client_id(&self) -> i32;
|
||||||
async fn get_me(&self) -> Result<i64, String>;
|
async fn get_me(&self) -> Result<i64, String>;
|
||||||
fn auth_state(&self) -> &AuthState;
|
fn auth_state(&self) -> &AuthState;
|
||||||
fn chats(&self) -> &[ChatInfo];
|
|
||||||
fn folders(&self) -> &[FolderInfo];
|
|
||||||
fn current_chat_messages(&self) -> Vec<MessageInfo>;
|
|
||||||
fn current_chat_id(&self) -> Option<ChatId>;
|
|
||||||
fn current_pinned_message(&self) -> Option<MessageInfo>;
|
|
||||||
fn typing_status(&self) -> Option<&(UserId, String, std::time::Instant)>;
|
|
||||||
fn pending_view_messages(&self) -> &[(ChatId, Vec<MessageId>)];
|
|
||||||
fn pending_user_ids(&self) -> &[UserId];
|
|
||||||
fn main_chat_list_position(&self) -> i32;
|
|
||||||
fn user_cache(&self) -> &UserCache;
|
|
||||||
fn network_state(&self) -> super::types::NetworkState;
|
fn network_state(&self) -> super::types::NetworkState;
|
||||||
|
}
|
||||||
|
|
||||||
// ============ Setters (mutable) ============
|
/// Account switching operations.
|
||||||
fn chats_mut(&mut self) -> &mut Vec<ChatInfo>;
|
#[async_trait]
|
||||||
fn folders_mut(&mut self) -> &mut Vec<FolderInfo>;
|
pub trait AccountClient: Send {
|
||||||
fn current_chat_messages_mut(&mut self) -> &mut Vec<MessageInfo>;
|
|
||||||
fn clear_current_chat_messages(&mut self);
|
|
||||||
fn set_current_chat_messages(&mut self, messages: Vec<MessageInfo>);
|
|
||||||
fn set_current_chat_id(&mut self, chat_id: Option<ChatId>);
|
|
||||||
fn set_current_pinned_message(&mut self, msg: Option<MessageInfo>);
|
|
||||||
fn set_typing_status(&mut self, status: Option<(UserId, String, std::time::Instant)>);
|
|
||||||
fn enqueue_pending_view_messages(&mut self, chat_id: ChatId, message_ids: Vec<MessageId>);
|
|
||||||
fn pending_user_ids_mut(&mut self) -> &mut Vec<UserId>;
|
|
||||||
fn set_main_chat_list_position(&mut self, position: i32);
|
|
||||||
fn user_cache_mut(&mut self) -> &mut UserCache;
|
|
||||||
|
|
||||||
// ============ Notification methods ============
|
|
||||||
fn configure_notifications(&mut self, config: &crate::config::NotificationsConfig);
|
|
||||||
fn sync_notification_muted_chats(&mut self);
|
|
||||||
|
|
||||||
// ============ Account switching ============
|
|
||||||
/// Recreates the client with a new database path (for account switching).
|
/// Recreates the client with a new database path (for account switching).
|
||||||
///
|
///
|
||||||
/// For real TdClient: closes old client, creates new one, inits TDLib parameters.
|
/// For real TdClient: closes old client, creates new one, inits TDLib parameters.
|
||||||
/// For FakeTdClient: no-op.
|
/// For FakeTdClient: no-op.
|
||||||
async fn recreate_client(&mut self, db_path: PathBuf) -> Result<(), String>;
|
async fn recreate_client(&mut self, db_path: PathBuf) -> Result<(), String>;
|
||||||
|
}
|
||||||
// ============ Update handling ============
|
|
||||||
fn handle_update(&mut self, update: Update);
|
/// TDLib update routing.
|
||||||
|
pub trait UpdateClient: Send {
|
||||||
|
fn handle_update(&mut self, update: Update);
|
||||||
|
fn drain_incoming_message_events(&mut self) -> Vec<IncomingMessageEvent>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Facade trait for TDLib client operations
|
||||||
|
///
|
||||||
|
/// This trait defines the interface for both real and fake TDLib clients,
|
||||||
|
/// enabling dependency injection and easier testing.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub trait TdClientTrait:
|
||||||
|
AuthClient
|
||||||
|
+ ChatClient
|
||||||
|
+ ChatActionClient
|
||||||
|
+ MessageClient
|
||||||
|
+ UserClient
|
||||||
|
+ ReactionClient
|
||||||
|
+ FileClient
|
||||||
|
+ ClientState
|
||||||
|
+ AccountClient
|
||||||
|
+ UpdateClient
|
||||||
|
+ Send
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> TdClientTrait for T where
|
||||||
|
T: AuthClient
|
||||||
|
+ ChatClient
|
||||||
|
+ ChatActionClient
|
||||||
|
+ MessageClient
|
||||||
|
+ UserClient
|
||||||
|
+ ReactionClient
|
||||||
|
+ FileClient
|
||||||
|
+ ClientState
|
||||||
|
+ AccountClient
|
||||||
|
+ UpdateClient
|
||||||
|
+ Send
|
||||||
|
{
|
||||||
}
|
}
|
||||||
@@ -312,8 +312,8 @@ impl MessageInfo {
|
|||||||
/// # Примеры
|
/// # Примеры
|
||||||
///
|
///
|
||||||
/// ```
|
/// ```
|
||||||
/// use tele_tui::tdlib::MessageBuilder;
|
/// use tele_core::tdlib::MessageBuilder;
|
||||||
/// use tele_tui::types::MessageId;
|
/// use tele_core::types::MessageId;
|
||||||
///
|
///
|
||||||
/// let message = MessageBuilder::new(MessageId::new(123))
|
/// let message = MessageBuilder::new(MessageId::new(123))
|
||||||
/// .sender_name("Alice")
|
/// .sender_name("Alice")
|
||||||
@@ -27,12 +27,9 @@ pub fn handle_new_message_update(client: &mut TdClient, new_msg: UpdateNewMessag
|
|||||||
crate::tdlib::message_converter::convert_message(client, &new_msg.message, chat_id);
|
crate::tdlib::message_converter::convert_message(client, &new_msg.message, chat_id);
|
||||||
|
|
||||||
// Get sender name (from message or user cache)
|
// Get sender name (from message or user cache)
|
||||||
let sender_name = msg_info.sender_name();
|
let sender_name = msg_info.sender_name().to_string();
|
||||||
|
|
||||||
// Send notification
|
client.enqueue_incoming_message_event(chat, msg_info, sender_name);
|
||||||
let _ = client
|
|
||||||
.notification_manager
|
|
||||||
.notify_new_message(&chat, &msg_info, sender_name);
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -54,17 +51,19 @@ pub fn handle_new_message_update(client: &mut TdClient, new_msg: UpdateNewMessag
|
|||||||
Some(idx) => {
|
Some(idx) => {
|
||||||
// Сообщение уже есть - обновляем
|
// Сообщение уже есть - обновляем
|
||||||
if is_incoming {
|
if is_incoming {
|
||||||
client.current_chat_messages_mut()[idx] = msg_info;
|
client.replace_current_chat_message(msg_id, msg_info);
|
||||||
} else {
|
} else {
|
||||||
// Для исходящих: обновляем can_be_edited и другие поля,
|
// Для исходящих: обновляем can_be_edited и другие поля,
|
||||||
// но сохраняем reply_to (добавленный при отправке)
|
// но сохраняем reply_to (добавленный при отправке)
|
||||||
let existing = &mut client.current_chat_messages_mut()[idx];
|
client.update_current_chat_messages(|messages| {
|
||||||
|
let existing = &mut messages[idx];
|
||||||
existing.state.can_be_edited = msg_info.state.can_be_edited;
|
existing.state.can_be_edited = msg_info.state.can_be_edited;
|
||||||
existing.state.can_be_deleted_only_for_self =
|
existing.state.can_be_deleted_only_for_self =
|
||||||
msg_info.state.can_be_deleted_only_for_self;
|
msg_info.state.can_be_deleted_only_for_self;
|
||||||
existing.state.can_be_deleted_for_all_users =
|
existing.state.can_be_deleted_for_all_users =
|
||||||
msg_info.state.can_be_deleted_for_all_users;
|
msg_info.state.can_be_deleted_for_all_users;
|
||||||
existing.state.is_read = msg_info.state.is_read;
|
existing.state.is_read = msg_info.state.is_read;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
@@ -122,7 +121,7 @@ pub fn handle_chat_position_update(client: &mut TdClient, update: UpdateChatPosi
|
|||||||
ChatList::Main => {
|
ChatList::Main => {
|
||||||
if update.position.order == 0 {
|
if update.position.order == 0 {
|
||||||
// Чат больше не в Main (перемещён в архив и т.д.)
|
// Чат больше не в Main (перемещён в архив и т.д.)
|
||||||
client.chats_mut().retain(|c| c.id != chat_id);
|
client.remove_chat(chat_id);
|
||||||
} else {
|
} else {
|
||||||
// Обновляем позицию существующего чата
|
// Обновляем позицию существующего чата
|
||||||
crate::tdlib::chat_helpers::update_chat(client, chat_id, |chat| {
|
crate::tdlib::chat_helpers::update_chat(client, chat_id, |chat| {
|
||||||
@@ -131,7 +130,7 @@ pub fn handle_chat_position_update(client: &mut TdClient, update: UpdateChatPosi
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Пересортируем по order
|
// Пересортируем по order
|
||||||
client.chats_mut().sort_by(|a, b| b.order.cmp(&a.order));
|
client.sort_chats_by_order();
|
||||||
}
|
}
|
||||||
ChatList::Folder(folder) => {
|
ChatList::Folder(folder) => {
|
||||||
// Обновляем folder_ids для чата
|
// Обновляем folder_ids для чата
|
||||||
@@ -166,10 +165,10 @@ pub fn handle_user_update(client: &mut TdClient, update: UpdateUser) {
|
|||||||
// Удаляем чаты с этим пользователем из списка
|
// Удаляем чаты с этим пользователем из списка
|
||||||
let user_id = user.id;
|
let user_id = user.id;
|
||||||
// Clone chat_user_ids to avoid borrow conflict
|
// Clone chat_user_ids to avoid borrow conflict
|
||||||
let chat_user_ids = client.user_cache.chat_user_ids.clone();
|
let chat_user_ids = client.user_cache().chat_user_ids.clone();
|
||||||
client
|
client.update_chats(|chats| {
|
||||||
.chats_mut()
|
chats.retain(|c| chat_user_ids.get(&c.id) != Some(&UserId::new(user_id)));
|
||||||
.retain(|c| chat_user_ids.get(&c.id) != Some(&UserId::new(user_id)));
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,10 +178,9 @@ pub fn handle_user_update(client: &mut TdClient, update: UpdateUser) {
|
|||||||
} else {
|
} else {
|
||||||
format!("{} {}", user.first_name, user.last_name)
|
format!("{} {}", user.first_name, user.last_name)
|
||||||
};
|
};
|
||||||
client
|
client.update_user_cache(|cache| {
|
||||||
.user_cache
|
cache.user_names.insert(UserId::new(user.id), display_name);
|
||||||
.user_names
|
});
|
||||||
.insert(UserId::new(user.id), display_name);
|
|
||||||
|
|
||||||
// Сохраняем username если есть (с упрощённым извлечением через and_then)
|
// Сохраняем username если есть (с упрощённым извлечением через and_then)
|
||||||
if let Some(username) = user
|
if let Some(username) = user
|
||||||
@@ -190,19 +188,25 @@ pub fn handle_user_update(client: &mut TdClient, update: UpdateUser) {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|u| u.active_usernames.first())
|
.and_then(|u| u.active_usernames.first())
|
||||||
{
|
{
|
||||||
client
|
let affected_chat_ids = client.update_user_cache(|cache| {
|
||||||
.user_cache
|
cache
|
||||||
.user_usernames
|
.user_usernames
|
||||||
.insert(UserId::new(user.id), username.to_string());
|
.insert(UserId::new(user.id), username.to_string());
|
||||||
|
cache
|
||||||
|
.chat_user_ids
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(&chat_id, &user_id)| {
|
||||||
|
(user_id == UserId::new(user.id)).then_some(chat_id)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
});
|
||||||
// Обновляем username в чатах, связанных с этим пользователем
|
// Обновляем username в чатах, связанных с этим пользователем
|
||||||
for (&chat_id, &user_id) in &client.user_cache.chat_user_ids.clone() {
|
for chat_id in affected_chat_ids {
|
||||||
if user_id == UserId::new(user.id) {
|
|
||||||
crate::tdlib::chat_helpers::update_chat(client, chat_id, |chat| {
|
crate::tdlib::chat_helpers::update_chat(client, chat_id, |chat| {
|
||||||
chat.username = Some(format!("@{}", username));
|
chat.username = Some(format!("@{}", username));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// LRU-кэш автоматически удаляет старые записи при вставке
|
// LRU-кэш автоматически удаляет старые записи при вставке
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,16 +222,8 @@ pub fn handle_message_interaction_info_update(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(msg) = client
|
|
||||||
.current_chat_messages_mut()
|
|
||||||
.iter_mut()
|
|
||||||
.find(|m| m.id() == MessageId::new(update.message_id))
|
|
||||||
else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Извлекаем реакции из interaction_info
|
// Извлекаем реакции из interaction_info
|
||||||
msg.interactions.reactions = update
|
let reactions = update
|
||||||
.interaction_info
|
.interaction_info
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|info| info.reactions.as_ref())
|
.and_then(|info| info.reactions.as_ref())
|
||||||
@@ -250,6 +246,9 @@ pub fn handle_message_interaction_info_update(
|
|||||||
.collect()
|
.collect()
|
||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
client.update_current_chat_message(MessageId::new(update.message_id), |msg| {
|
||||||
|
msg.interactions.reactions = reactions;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Обрабатывает Update::MessageSendSucceeded - успешная отправка сообщения.
|
/// Обрабатывает Update::MessageSendSucceeded - успешная отправка сообщения.
|
||||||
@@ -291,7 +290,7 @@ pub fn handle_message_send_succeeded_update(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Заменяем старое сообщение на новое
|
// Заменяем старое сообщение на новое
|
||||||
client.current_chat_messages_mut()[idx] = new_msg;
|
client.replace_current_chat_message(old_id, new_msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Обрабатывает Update::ChatDraftMessage - обновление черновика сообщения в чате.
|
/// Обрабатывает Update::ChatDraftMessage - обновление черновика сообщения в чате.
|
||||||
12
crates/tele-core/src/test_support/fake_tdclient.rs
Normal file
12
crates/tele-core/src/test_support/fake_tdclient.rs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
// Fake TDLib client for testing.
|
||||||
|
|
||||||
|
mod builders;
|
||||||
|
mod inspect;
|
||||||
|
mod operations;
|
||||||
|
mod state;
|
||||||
|
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
pub use state::{
|
||||||
|
DeletedMessages, EditedMessage, FakeTdClient, ForwardedMessages, PendingViewMessages,
|
||||||
|
SearchQuery, SentMessage, TdUpdate, ViewedMessages,
|
||||||
|
};
|
||||||
86
crates/tele-core/src/test_support/fake_tdclient/builders.rs
Normal file
86
crates/tele-core/src/test_support/fake_tdclient/builders.rs
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
use super::{FakeTdClient, TdUpdate};
|
||||||
|
use crate::tdlib::types::FolderInfo;
|
||||||
|
use crate::tdlib::{AuthState, ChatInfo, MessageInfo, NetworkState, ProfileInfo};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
impl FakeTdClient {
|
||||||
|
/// Create an update channel for receiving simulated TDLib events.
|
||||||
|
pub fn with_update_channel(self) -> (Self, mpsc::UnboundedReceiver<TdUpdate>) {
|
||||||
|
let (tx, rx) = mpsc::unbounded_channel();
|
||||||
|
*self.update_tx.lock().unwrap() = Some(tx);
|
||||||
|
(self, rx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enable simulated delays, closer to real TDLib behavior.
|
||||||
|
pub fn with_delays(mut self) -> Self {
|
||||||
|
self.simulate_delays = true;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_chat(self, chat: ChatInfo) -> Self {
|
||||||
|
self.chats.lock().unwrap().push(chat);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_chats(self, chats: Vec<ChatInfo>) -> Self {
|
||||||
|
self.chats.lock().unwrap().extend(chats);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_message(self, chat_id: i64, message: MessageInfo) -> Self {
|
||||||
|
self.messages
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.entry(chat_id)
|
||||||
|
.or_default()
|
||||||
|
.push(message);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_messages(self, chat_id: i64, messages: Vec<MessageInfo>) -> Self {
|
||||||
|
self.messages.lock().unwrap().insert(chat_id, messages);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_folder(self, id: i32, name: &str) -> Self {
|
||||||
|
self.folders
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push(FolderInfo { id, name: name.to_string() });
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_user(self, id: i64, name: &str) -> Self {
|
||||||
|
self.user_names.lock().unwrap().insert(id, name.to_string());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_profile(self, chat_id: i64, profile: ProfileInfo) -> Self {
|
||||||
|
self.profiles.lock().unwrap().insert(chat_id, profile);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_network_state(self, state: NetworkState) -> Self {
|
||||||
|
*self.network_state.lock().unwrap() = state;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_auth_state(self, state: AuthState) -> Self {
|
||||||
|
*self.auth_state.lock().unwrap() = state;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_downloaded_file(self, file_id: i32, path: &str) -> Self {
|
||||||
|
self.downloaded_files
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.insert(file_id, path.to_string());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_available_reactions(self, reactions: Vec<String>) -> Self {
|
||||||
|
*self.available_reactions.lock().unwrap() = reactions;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
92
crates/tele-core/src/test_support/fake_tdclient/inspect.rs
Normal file
92
crates/tele-core/src/test_support/fake_tdclient/inspect.rs
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
use super::{
|
||||||
|
DeletedMessages, EditedMessage, FakeTdClient, ForwardedMessages, SearchQuery, SentMessage,
|
||||||
|
TdUpdate,
|
||||||
|
};
|
||||||
|
use crate::tdlib::types::FolderInfo;
|
||||||
|
use crate::tdlib::{ChatInfo, MessageInfo, NetworkState};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
impl FakeTdClient {
|
||||||
|
pub fn get_chats(&self) -> Vec<ChatInfo> {
|
||||||
|
self.chats.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_folders(&self) -> Vec<FolderInfo> {
|
||||||
|
self.folders.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_messages(&self, chat_id: i64) -> Vec<MessageInfo> {
|
||||||
|
self.messages
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(&chat_id)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_sent_messages(&self) -> Vec<SentMessage> {
|
||||||
|
self.sent_messages.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_edited_messages(&self) -> Vec<EditedMessage> {
|
||||||
|
self.edited_messages.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_deleted_messages(&self) -> Vec<DeletedMessages> {
|
||||||
|
self.deleted_messages.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_forwarded_messages(&self) -> Vec<ForwardedMessages> {
|
||||||
|
self.forwarded_messages.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_search_queries(&self) -> Vec<SearchQuery> {
|
||||||
|
self.searched_queries.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_viewed_messages(&self) -> Vec<(i64, Vec<i64>)> {
|
||||||
|
self.viewed_messages.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_chat_actions(&self) -> Vec<(i64, String)> {
|
||||||
|
self.chat_actions.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_network_state(&self) -> NetworkState {
|
||||||
|
self.network_state.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_current_chat_id(&self) -> Option<i64> {
|
||||||
|
*self.current_chat_id.lock().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_current_pinned_message(&mut self, msg: Option<MessageInfo>) {
|
||||||
|
*self.current_pinned_message.lock().unwrap() = msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn process_pending_view_messages(&mut self) {
|
||||||
|
let mut pending = self.pending_view_messages.lock().unwrap();
|
||||||
|
for (chat_id, message_ids) in pending.drain(..) {
|
||||||
|
let ids: Vec<i64> = message_ids.iter().map(|id| id.as_i64()).collect();
|
||||||
|
self.viewed_messages
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push((chat_id.as_i64(), ids));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_update_channel(&self, tx: mpsc::UnboundedSender<TdUpdate>) {
|
||||||
|
*self.update_tx.lock().unwrap() = Some(tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_all_history(&self) {
|
||||||
|
self.sent_messages.lock().unwrap().clear();
|
||||||
|
self.edited_messages.lock().unwrap().clear();
|
||||||
|
self.deleted_messages.lock().unwrap().clear();
|
||||||
|
self.forwarded_messages.lock().unwrap().clear();
|
||||||
|
self.searched_queries.lock().unwrap().clear();
|
||||||
|
self.viewed_messages.lock().unwrap().clear();
|
||||||
|
self.chat_actions.lock().unwrap().clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
476
crates/tele-core/src/test_support/fake_tdclient/operations.rs
Normal file
476
crates/tele-core/src/test_support/fake_tdclient/operations.rs
Normal file
@@ -0,0 +1,476 @@
|
|||||||
|
use super::{
|
||||||
|
DeletedMessages, EditedMessage, FakeTdClient, ForwardedMessages, SearchQuery, SentMessage,
|
||||||
|
TdUpdate,
|
||||||
|
};
|
||||||
|
use crate::tdlib::types::ReactionInfo;
|
||||||
|
use crate::tdlib::{ChatInfo, MessageInfo, ProfileInfo, ReplyInfo};
|
||||||
|
use crate::types::{ChatId, MessageId, UserId};
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
impl FakeTdClient {
|
||||||
|
pub async fn load_chats(&self, limit: usize) -> Result<Vec<ChatInfo>, String> {
|
||||||
|
if self.should_fail() {
|
||||||
|
return Err("Failed to load chats".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.simulate_delays {
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let chats = self
|
||||||
|
.chats
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.take(limit)
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
Ok(chats)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn open_chat(&self, chat_id: ChatId) -> Result<(), String> {
|
||||||
|
if self.should_fail() {
|
||||||
|
return Err("Failed to open chat".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
*self.current_chat_id.lock().unwrap() = Some(chat_id.as_i64());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_chat_history(
|
||||||
|
&self,
|
||||||
|
chat_id: ChatId,
|
||||||
|
limit: i32,
|
||||||
|
) -> Result<Vec<MessageInfo>, String> {
|
||||||
|
if self.should_fail() {
|
||||||
|
return Err("Failed to load history".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.simulate_delays {
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let messages = self
|
||||||
|
.messages
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(&chat_id.as_i64())
|
||||||
|
.map(|msgs| msgs.iter().take(limit as usize).cloned().collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
Ok(messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_older_messages(
|
||||||
|
&self,
|
||||||
|
chat_id: ChatId,
|
||||||
|
from_message_id: MessageId,
|
||||||
|
) -> Result<Vec<MessageInfo>, String> {
|
||||||
|
if self.should_fail() {
|
||||||
|
return Err("Failed to load older messages".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let messages = self.messages.lock().unwrap();
|
||||||
|
let chat_messages = messages.get(&chat_id.as_i64()).ok_or("Chat not found")?;
|
||||||
|
|
||||||
|
if let Some(idx) = chat_messages.iter().position(|m| m.id() == from_message_id) {
|
||||||
|
let older = chat_messages.iter().take(idx).cloned().collect();
|
||||||
|
Ok(older)
|
||||||
|
} else {
|
||||||
|
Ok(vec![])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn send_message(
|
||||||
|
&self,
|
||||||
|
chat_id: ChatId,
|
||||||
|
text: String,
|
||||||
|
reply_to: Option<MessageId>,
|
||||||
|
reply_info: Option<ReplyInfo>,
|
||||||
|
) -> Result<MessageInfo, String> {
|
||||||
|
if self.should_fail() {
|
||||||
|
return Err("Failed to send message".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.simulate_delays {
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let message_id = MessageId::new((self.sent_messages.lock().unwrap().len() as i64) + 1000);
|
||||||
|
|
||||||
|
self.sent_messages.lock().unwrap().push(SentMessage {
|
||||||
|
chat_id: chat_id.as_i64(),
|
||||||
|
text: text.clone(),
|
||||||
|
reply_to,
|
||||||
|
reply_info: reply_info.clone(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let message = MessageInfo::new(
|
||||||
|
message_id,
|
||||||
|
"You".to_string(),
|
||||||
|
true,
|
||||||
|
text,
|
||||||
|
vec![],
|
||||||
|
chrono::Utc::now().timestamp() as i32,
|
||||||
|
0,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
reply_info,
|
||||||
|
None,
|
||||||
|
vec![],
|
||||||
|
);
|
||||||
|
|
||||||
|
self.messages
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.entry(chat_id.as_i64())
|
||||||
|
.or_default()
|
||||||
|
.push(message.clone());
|
||||||
|
|
||||||
|
self.send_update(TdUpdate::NewMessage { chat_id, message: Box::new(message.clone()) });
|
||||||
|
|
||||||
|
Ok(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn edit_message(
|
||||||
|
&self,
|
||||||
|
chat_id: ChatId,
|
||||||
|
message_id: MessageId,
|
||||||
|
new_text: String,
|
||||||
|
) -> Result<MessageInfo, String> {
|
||||||
|
if self.should_fail() {
|
||||||
|
return Err("Failed to edit message".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.simulate_delays {
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(150)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.edited_messages.lock().unwrap().push(EditedMessage {
|
||||||
|
chat_id: chat_id.as_i64(),
|
||||||
|
message_id,
|
||||||
|
new_text: new_text.clone(),
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut messages = self.messages.lock().unwrap();
|
||||||
|
if let Some(chat_msgs) = messages.get_mut(&chat_id.as_i64()) {
|
||||||
|
if let Some(msg) = chat_msgs.iter_mut().find(|m| m.id() == message_id) {
|
||||||
|
msg.content.text = new_text.clone();
|
||||||
|
msg.metadata.edit_date = msg.metadata.date + 60;
|
||||||
|
|
||||||
|
let updated = msg.clone();
|
||||||
|
drop(messages);
|
||||||
|
|
||||||
|
self.send_update(TdUpdate::MessageContent { chat_id, message_id, new_text });
|
||||||
|
|
||||||
|
return Ok(updated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err("Message not found".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_messages(
|
||||||
|
&self,
|
||||||
|
chat_id: ChatId,
|
||||||
|
message_ids: Vec<MessageId>,
|
||||||
|
revoke: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if self.should_fail() {
|
||||||
|
return Err("Failed to delete messages".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.simulate_delays {
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.deleted_messages.lock().unwrap().push(DeletedMessages {
|
||||||
|
chat_id: chat_id.as_i64(),
|
||||||
|
message_ids: message_ids.clone(),
|
||||||
|
revoke,
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut messages = self.messages.lock().unwrap();
|
||||||
|
if let Some(chat_msgs) = messages.get_mut(&chat_id.as_i64()) {
|
||||||
|
chat_msgs.retain(|m| !message_ids.contains(&m.id()));
|
||||||
|
}
|
||||||
|
drop(messages);
|
||||||
|
|
||||||
|
self.send_update(TdUpdate::DeleteMessages { chat_id, message_ids });
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn forward_messages(
|
||||||
|
&self,
|
||||||
|
to_chat_id: ChatId,
|
||||||
|
from_chat_id: ChatId,
|
||||||
|
message_ids: Vec<MessageId>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if self.should_fail() {
|
||||||
|
return Err("Failed to forward messages".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.simulate_delays {
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(150)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.forwarded_messages
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push(ForwardedMessages {
|
||||||
|
from_chat_id: from_chat_id.as_i64(),
|
||||||
|
to_chat_id: to_chat_id.as_i64(),
|
||||||
|
message_ids,
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn search_messages(
|
||||||
|
&self,
|
||||||
|
chat_id: ChatId,
|
||||||
|
query: &str,
|
||||||
|
) -> Result<Vec<MessageInfo>, String> {
|
||||||
|
if self.should_fail() {
|
||||||
|
return Err("Failed to search messages".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let messages = self.messages.lock().unwrap();
|
||||||
|
let results: Vec<_> = messages
|
||||||
|
.get(&chat_id.as_i64())
|
||||||
|
.map(|msgs| {
|
||||||
|
msgs.iter()
|
||||||
|
.filter(|m| m.text().to_lowercase().contains(&query.to_lowercase()))
|
||||||
|
.cloned()
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
self.searched_queries.lock().unwrap().push(SearchQuery {
|
||||||
|
chat_id: chat_id.as_i64(),
|
||||||
|
query: query.to_string(),
|
||||||
|
results_count: results.len(),
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_draft_message(&self, chat_id: ChatId, text: String) -> Result<(), String> {
|
||||||
|
if text.is_empty() {
|
||||||
|
self.drafts.lock().unwrap().remove(&chat_id.as_i64());
|
||||||
|
} else {
|
||||||
|
self.drafts
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.insert(chat_id.as_i64(), text.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
self.send_update(TdUpdate::ChatDraftMessage {
|
||||||
|
chat_id,
|
||||||
|
draft_text: if text.is_empty() { None } else { Some(text) },
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn send_chat_action(&self, chat_id: ChatId, action: String) {
|
||||||
|
self.chat_actions
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push((chat_id.as_i64(), action.clone()));
|
||||||
|
|
||||||
|
if action == "Typing" {
|
||||||
|
*self.typing_chat_id.lock().unwrap() = Some(chat_id.as_i64());
|
||||||
|
} else if action == "Cancel" {
|
||||||
|
*self.typing_chat_id.lock().unwrap() = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_message_available_reactions(
|
||||||
|
&self,
|
||||||
|
_chat_id: ChatId,
|
||||||
|
_message_id: MessageId,
|
||||||
|
) -> Result<Vec<String>, String> {
|
||||||
|
if self.should_fail() {
|
||||||
|
return Err("Failed to get available reactions".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(self.available_reactions.lock().unwrap().clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn toggle_reaction(
|
||||||
|
&self,
|
||||||
|
chat_id: ChatId,
|
||||||
|
message_id: MessageId,
|
||||||
|
emoji: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if self.should_fail() {
|
||||||
|
return Err("Failed to toggle reaction".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut messages = self.messages.lock().unwrap();
|
||||||
|
if let Some(chat_msgs) = messages.get_mut(&chat_id.as_i64()) {
|
||||||
|
if let Some(msg) = chat_msgs.iter_mut().find(|m| m.id() == message_id) {
|
||||||
|
let reactions = &mut msg.interactions.reactions;
|
||||||
|
|
||||||
|
if let Some(pos) = reactions
|
||||||
|
.iter()
|
||||||
|
.position(|reaction| reaction.emoji == emoji && reaction.is_chosen)
|
||||||
|
{
|
||||||
|
reactions.remove(pos);
|
||||||
|
} else if let Some(reaction) = reactions
|
||||||
|
.iter_mut()
|
||||||
|
.find(|reaction| reaction.emoji == emoji)
|
||||||
|
{
|
||||||
|
reaction.is_chosen = true;
|
||||||
|
reaction.count += 1;
|
||||||
|
} else {
|
||||||
|
reactions.push(ReactionInfo {
|
||||||
|
emoji: emoji.clone(),
|
||||||
|
count: 1,
|
||||||
|
is_chosen: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let updated_reactions = reactions.clone();
|
||||||
|
drop(messages);
|
||||||
|
|
||||||
|
self.send_update(TdUpdate::MessageInteractionInfo {
|
||||||
|
chat_id,
|
||||||
|
message_id,
|
||||||
|
reactions: updated_reactions,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn download_file(&self, file_id: i32) -> Result<String, String> {
|
||||||
|
if self.should_fail() {
|
||||||
|
return Err("Failed to download file".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
self.downloaded_files
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(&file_id)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| format!("File {} not found", file_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_profile_info(&self, chat_id: ChatId) -> Result<ProfileInfo, String> {
|
||||||
|
if self.should_fail() {
|
||||||
|
return Err("Failed to get profile info".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
self.profiles
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.get(&chat_id.as_i64())
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| "Profile not found".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn view_messages(&self, chat_id: ChatId, message_ids: Vec<MessageId>) {
|
||||||
|
self.viewed_messages
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push((chat_id.as_i64(), message_ids.iter().map(|id| id.as_i64()).collect()));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_folder_chats(&self, _folder_id: i32, _limit: usize) -> Result<(), String> {
|
||||||
|
if self.should_fail() {
|
||||||
|
return Err("Failed to load folder chats".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_update(&self, update: TdUpdate) {
|
||||||
|
if let Some(tx) = self.update_tx.lock().unwrap().as_ref() {
|
||||||
|
let _ = tx.send(update);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn should_fail(&self) -> bool {
|
||||||
|
let mut fail = self.fail_next_operation.lock().unwrap();
|
||||||
|
if *fail {
|
||||||
|
*fail = false;
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fail_next(&self) {
|
||||||
|
*self.fail_next_operation.lock().unwrap() = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn simulate_incoming_message(&self, chat_id: ChatId, text: String, sender_name: &str) {
|
||||||
|
let message_id = MessageId::new(9000 + chrono::Utc::now().timestamp());
|
||||||
|
|
||||||
|
let message = MessageInfo::new(
|
||||||
|
message_id,
|
||||||
|
sender_name.to_string(),
|
||||||
|
false,
|
||||||
|
text,
|
||||||
|
vec![],
|
||||||
|
chrono::Utc::now().timestamp() as i32,
|
||||||
|
0,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
vec![],
|
||||||
|
);
|
||||||
|
|
||||||
|
self.messages
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.entry(chat_id.as_i64())
|
||||||
|
.or_default()
|
||||||
|
.push(message.clone());
|
||||||
|
|
||||||
|
if let Some(chat) = self
|
||||||
|
.chats
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.find(|chat| chat.id == chat_id)
|
||||||
|
.cloned()
|
||||||
|
{
|
||||||
|
self.incoming_message_events
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push(crate::tdlib::IncomingMessageEvent {
|
||||||
|
chat,
|
||||||
|
message: message.clone(),
|
||||||
|
sender_name: sender_name.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
self.send_update(TdUpdate::NewMessage { chat_id, message: Box::new(message) });
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn simulate_typing(&self, chat_id: ChatId, user_id: UserId) {
|
||||||
|
self.send_update(TdUpdate::ChatAction { chat_id, user_id, action: "Typing".to_string() });
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn simulate_network_change(&self, state: crate::tdlib::NetworkState) {
|
||||||
|
*self.network_state.lock().unwrap() = state.clone();
|
||||||
|
self.send_update(TdUpdate::ConnectionState { state });
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn simulate_read_outbox(&self, chat_id: ChatId, last_read_message_id: MessageId) {
|
||||||
|
self.send_update(TdUpdate::ChatReadOutbox {
|
||||||
|
chat_id,
|
||||||
|
last_read_outbox_message_id: last_read_message_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
206
crates/tele-core/src/test_support/fake_tdclient/state.rs
Normal file
206
crates/tele-core/src/test_support/fake_tdclient/state.rs
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
use crate::tdlib::types::{FolderInfo, ReactionInfo};
|
||||||
|
use crate::tdlib::{
|
||||||
|
AuthState, ChatInfo, IncomingMessageEvent, MessageInfo, NetworkState, ProfileInfo, ReplyInfo,
|
||||||
|
};
|
||||||
|
use crate::types::{ChatId, MessageId, UserId};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
pub type ViewedMessages = Vec<(i64, Vec<i64>)>;
|
||||||
|
pub type PendingViewMessages = Vec<(ChatId, Vec<MessageId>)>;
|
||||||
|
|
||||||
|
/// Update events from TDLib, simplified for tests.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub enum TdUpdate {
|
||||||
|
NewMessage {
|
||||||
|
chat_id: ChatId,
|
||||||
|
message: Box<MessageInfo>,
|
||||||
|
},
|
||||||
|
MessageContent {
|
||||||
|
chat_id: ChatId,
|
||||||
|
message_id: MessageId,
|
||||||
|
new_text: String,
|
||||||
|
},
|
||||||
|
DeleteMessages {
|
||||||
|
chat_id: ChatId,
|
||||||
|
message_ids: Vec<MessageId>,
|
||||||
|
},
|
||||||
|
ChatAction {
|
||||||
|
chat_id: ChatId,
|
||||||
|
user_id: UserId,
|
||||||
|
action: String,
|
||||||
|
},
|
||||||
|
MessageInteractionInfo {
|
||||||
|
chat_id: ChatId,
|
||||||
|
message_id: MessageId,
|
||||||
|
reactions: Vec<ReactionInfo>,
|
||||||
|
},
|
||||||
|
ConnectionState {
|
||||||
|
state: NetworkState,
|
||||||
|
},
|
||||||
|
ChatReadOutbox {
|
||||||
|
chat_id: ChatId,
|
||||||
|
last_read_outbox_message_id: MessageId,
|
||||||
|
},
|
||||||
|
ChatDraftMessage {
|
||||||
|
chat_id: ChatId,
|
||||||
|
draft_text: Option<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simplified mock TDLib client for tests.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct FakeTdClient {
|
||||||
|
pub chats: Arc<Mutex<Vec<ChatInfo>>>,
|
||||||
|
pub messages: Arc<Mutex<HashMap<i64, Vec<MessageInfo>>>>,
|
||||||
|
pub folders: Arc<Mutex<Vec<FolderInfo>>>,
|
||||||
|
pub user_names: Arc<Mutex<HashMap<i64, String>>>,
|
||||||
|
pub profiles: Arc<Mutex<HashMap<i64, ProfileInfo>>>,
|
||||||
|
pub drafts: Arc<Mutex<HashMap<i64, String>>>,
|
||||||
|
pub available_reactions: Arc<Mutex<Vec<String>>>,
|
||||||
|
|
||||||
|
pub network_state: Arc<Mutex<NetworkState>>,
|
||||||
|
pub typing_chat_id: Arc<Mutex<Option<i64>>>,
|
||||||
|
pub current_chat_id: Arc<Mutex<Option<i64>>>,
|
||||||
|
pub current_pinned_message: Arc<Mutex<Option<MessageInfo>>>,
|
||||||
|
pub auth_state: Arc<Mutex<AuthState>>,
|
||||||
|
|
||||||
|
pub sent_messages: Arc<Mutex<Vec<SentMessage>>>,
|
||||||
|
pub edited_messages: Arc<Mutex<Vec<EditedMessage>>>,
|
||||||
|
pub deleted_messages: Arc<Mutex<Vec<DeletedMessages>>>,
|
||||||
|
pub forwarded_messages: Arc<Mutex<Vec<ForwardedMessages>>>,
|
||||||
|
pub searched_queries: Arc<Mutex<Vec<SearchQuery>>>,
|
||||||
|
pub viewed_messages: Arc<Mutex<ViewedMessages>>,
|
||||||
|
pub chat_actions: Arc<Mutex<Vec<(i64, String)>>>,
|
||||||
|
pub pending_view_messages: Arc<Mutex<PendingViewMessages>>,
|
||||||
|
pub incoming_message_events: Arc<Mutex<Vec<IncomingMessageEvent>>>,
|
||||||
|
|
||||||
|
pub update_tx: Arc<Mutex<Option<mpsc::UnboundedSender<TdUpdate>>>>,
|
||||||
|
pub downloaded_files: Arc<Mutex<HashMap<i32, String>>>,
|
||||||
|
|
||||||
|
pub simulate_delays: bool,
|
||||||
|
pub fail_next_operation: Arc<Mutex<bool>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct SentMessage {
|
||||||
|
pub chat_id: i64,
|
||||||
|
pub text: String,
|
||||||
|
pub reply_to: Option<MessageId>,
|
||||||
|
pub reply_info: Option<ReplyInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct EditedMessage {
|
||||||
|
pub chat_id: i64,
|
||||||
|
pub message_id: MessageId,
|
||||||
|
pub new_text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct DeletedMessages {
|
||||||
|
pub chat_id: i64,
|
||||||
|
pub message_ids: Vec<MessageId>,
|
||||||
|
pub revoke: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct ForwardedMessages {
|
||||||
|
pub from_chat_id: i64,
|
||||||
|
pub to_chat_id: i64,
|
||||||
|
pub message_ids: Vec<MessageId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct SearchQuery {
|
||||||
|
pub chat_id: i64,
|
||||||
|
pub query: String,
|
||||||
|
pub results_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for FakeTdClient {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Clone for FakeTdClient {
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
Self {
|
||||||
|
chats: Arc::clone(&self.chats),
|
||||||
|
messages: Arc::clone(&self.messages),
|
||||||
|
folders: Arc::clone(&self.folders),
|
||||||
|
user_names: Arc::clone(&self.user_names),
|
||||||
|
profiles: Arc::clone(&self.profiles),
|
||||||
|
drafts: Arc::clone(&self.drafts),
|
||||||
|
available_reactions: Arc::clone(&self.available_reactions),
|
||||||
|
network_state: Arc::clone(&self.network_state),
|
||||||
|
typing_chat_id: Arc::clone(&self.typing_chat_id),
|
||||||
|
current_chat_id: Arc::clone(&self.current_chat_id),
|
||||||
|
current_pinned_message: Arc::clone(&self.current_pinned_message),
|
||||||
|
auth_state: Arc::clone(&self.auth_state),
|
||||||
|
sent_messages: Arc::clone(&self.sent_messages),
|
||||||
|
edited_messages: Arc::clone(&self.edited_messages),
|
||||||
|
deleted_messages: Arc::clone(&self.deleted_messages),
|
||||||
|
forwarded_messages: Arc::clone(&self.forwarded_messages),
|
||||||
|
searched_queries: Arc::clone(&self.searched_queries),
|
||||||
|
viewed_messages: Arc::clone(&self.viewed_messages),
|
||||||
|
chat_actions: Arc::clone(&self.chat_actions),
|
||||||
|
pending_view_messages: Arc::clone(&self.pending_view_messages),
|
||||||
|
incoming_message_events: Arc::clone(&self.incoming_message_events),
|
||||||
|
downloaded_files: Arc::clone(&self.downloaded_files),
|
||||||
|
update_tx: Arc::clone(&self.update_tx),
|
||||||
|
simulate_delays: self.simulate_delays,
|
||||||
|
fail_next_operation: Arc::clone(&self.fail_next_operation),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
impl FakeTdClient {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
chats: Arc::new(Mutex::new(vec![])),
|
||||||
|
messages: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
folders: Arc::new(Mutex::new(vec![FolderInfo { id: 0, name: "All".to_string() }])),
|
||||||
|
user_names: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
profiles: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
drafts: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
available_reactions: Arc::new(Mutex::new(vec![
|
||||||
|
"👍".to_string(),
|
||||||
|
"❤️".to_string(),
|
||||||
|
"😂".to_string(),
|
||||||
|
"😮".to_string(),
|
||||||
|
"😢".to_string(),
|
||||||
|
"🙏".to_string(),
|
||||||
|
"👏".to_string(),
|
||||||
|
"🔥".to_string(),
|
||||||
|
])),
|
||||||
|
network_state: Arc::new(Mutex::new(NetworkState::Ready)),
|
||||||
|
typing_chat_id: Arc::new(Mutex::new(None)),
|
||||||
|
current_chat_id: Arc::new(Mutex::new(None)),
|
||||||
|
current_pinned_message: Arc::new(Mutex::new(None)),
|
||||||
|
auth_state: Arc::new(Mutex::new(AuthState::Ready)),
|
||||||
|
sent_messages: Arc::new(Mutex::new(vec![])),
|
||||||
|
edited_messages: Arc::new(Mutex::new(vec![])),
|
||||||
|
deleted_messages: Arc::new(Mutex::new(vec![])),
|
||||||
|
forwarded_messages: Arc::new(Mutex::new(vec![])),
|
||||||
|
searched_queries: Arc::new(Mutex::new(vec![])),
|
||||||
|
viewed_messages: Arc::new(Mutex::new(vec![])),
|
||||||
|
chat_actions: Arc::new(Mutex::new(vec![])),
|
||||||
|
pending_view_messages: Arc::new(Mutex::new(vec![])),
|
||||||
|
incoming_message_events: Arc::new(Mutex::new(vec![])),
|
||||||
|
downloaded_files: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
update_tx: Arc::new(Mutex::new(None)),
|
||||||
|
simulate_delays: false,
|
||||||
|
fail_next_operation: Arc::new(Mutex::new(false)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
368
crates/tele-core/src/test_support/fake_tdclient_impl.rs
Normal file
368
crates/tele-core/src/test_support/fake_tdclient_impl.rs
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
//! Test implementation of the TDLib client traits for FakeTdClient.
|
||||||
|
|
||||||
|
use super::fake_tdclient::FakeTdClient;
|
||||||
|
use crate::tdlib::{
|
||||||
|
AccountClient, AuthClient, ChatActionClient, ChatClient, ClientState, FileClient,
|
||||||
|
MessageClient, ReactionClient, UpdateClient, UserClient,
|
||||||
|
};
|
||||||
|
use crate::tdlib::{
|
||||||
|
AuthState, ChatInfo, FolderInfo, MessageInfo, ProfileInfo, ReplyInfo, UserCache,
|
||||||
|
UserOnlineStatus,
|
||||||
|
};
|
||||||
|
use crate::types::{ChatId, MessageId, UserId};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use std::borrow::Cow;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tdlib_rs::enums::{ChatAction, Update};
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl AuthClient for FakeTdClient {
|
||||||
|
async fn send_phone_number(&self, _phone: String) -> Result<(), String> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_code(&self, _code: String) -> Result<(), String> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_password(&self, _password: String) -> Result<(), String> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ChatClient for FakeTdClient {
|
||||||
|
async fn load_chats(&mut self, limit: i32) -> Result<(), String> {
|
||||||
|
let _ = FakeTdClient::load_chats(self, limit as usize).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_folder_chats(&mut self, folder_id: i32, limit: i32) -> Result<(), String> {
|
||||||
|
FakeTdClient::load_folder_chats(self, folder_id, limit as usize).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn leave_chat(&self, _chat_id: ChatId) -> Result<(), String> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_profile_info(&self, chat_id: ChatId) -> Result<ProfileInfo, String> {
|
||||||
|
FakeTdClient::get_profile_info(self, chat_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn chats(&self) -> &[ChatInfo] {
|
||||||
|
&[]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn folders(&self) -> &[FolderInfo] {
|
||||||
|
&[]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main_chat_list_position(&self) -> i32 {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_main_chat_list_position(&mut self, _position: i32) {}
|
||||||
|
|
||||||
|
fn update_chats<F>(&mut self, updater: F)
|
||||||
|
where
|
||||||
|
F: FnOnce(&mut Vec<ChatInfo>),
|
||||||
|
{
|
||||||
|
updater(&mut self.chats.lock().unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_folders<F>(&mut self, updater: F)
|
||||||
|
where
|
||||||
|
F: FnOnce(&mut Vec<FolderInfo>),
|
||||||
|
{
|
||||||
|
updater(&mut self.folders.lock().unwrap());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ChatActionClient for FakeTdClient {
|
||||||
|
async fn send_chat_action(&self, chat_id: ChatId, action: ChatAction) {
|
||||||
|
FakeTdClient::send_chat_action(self, chat_id, format!("{:?}", action)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear_stale_typing_status(&mut self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn typing_status(&self) -> Option<&(UserId, String, std::time::Instant)> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_typing_status(&mut self, _status: Option<(UserId, String, std::time::Instant)>) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl MessageClient for FakeTdClient {
|
||||||
|
async fn get_chat_history(
|
||||||
|
&mut self,
|
||||||
|
chat_id: ChatId,
|
||||||
|
limit: i32,
|
||||||
|
) -> Result<Vec<MessageInfo>, String> {
|
||||||
|
FakeTdClient::get_chat_history(self, chat_id, limit).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_older_messages(
|
||||||
|
&mut self,
|
||||||
|
chat_id: ChatId,
|
||||||
|
from_message_id: MessageId,
|
||||||
|
) -> Result<Vec<MessageInfo>, String> {
|
||||||
|
FakeTdClient::load_older_messages(self, chat_id, from_message_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_pinned_messages(&mut self, _chat_id: ChatId) -> Result<Vec<MessageInfo>, String> {
|
||||||
|
Ok(self
|
||||||
|
.current_pinned_message
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.clone()
|
||||||
|
.into_iter()
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_current_pinned_message(&mut self, _chat_id: ChatId) {}
|
||||||
|
|
||||||
|
async fn search_messages(
|
||||||
|
&self,
|
||||||
|
chat_id: ChatId,
|
||||||
|
query: &str,
|
||||||
|
) -> Result<Vec<MessageInfo>, String> {
|
||||||
|
FakeTdClient::search_messages(self, chat_id, query).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_message(
|
||||||
|
&mut self,
|
||||||
|
chat_id: ChatId,
|
||||||
|
text: String,
|
||||||
|
reply_to_message_id: Option<MessageId>,
|
||||||
|
reply_info: Option<ReplyInfo>,
|
||||||
|
) -> Result<MessageInfo, String> {
|
||||||
|
FakeTdClient::send_message(self, chat_id, text, reply_to_message_id, reply_info).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn edit_message(
|
||||||
|
&mut self,
|
||||||
|
chat_id: ChatId,
|
||||||
|
message_id: MessageId,
|
||||||
|
new_text: String,
|
||||||
|
) -> Result<MessageInfo, String> {
|
||||||
|
FakeTdClient::edit_message(self, chat_id, message_id, new_text).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_messages(
|
||||||
|
&mut self,
|
||||||
|
chat_id: ChatId,
|
||||||
|
message_ids: Vec<MessageId>,
|
||||||
|
revoke: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
FakeTdClient::delete_messages(self, chat_id, message_ids, revoke).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn forward_messages(
|
||||||
|
&mut self,
|
||||||
|
to_chat_id: ChatId,
|
||||||
|
from_chat_id: ChatId,
|
||||||
|
message_ids: Vec<MessageId>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
FakeTdClient::forward_messages(self, from_chat_id, to_chat_id, message_ids).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_draft_message(&self, chat_id: ChatId, text: String) -> Result<(), String> {
|
||||||
|
FakeTdClient::set_draft_message(self, chat_id, text).await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_chat_messages(&self) -> Cow<'_, [MessageInfo]> {
|
||||||
|
if let Some(chat_id) = *self.current_chat_id.lock().unwrap() {
|
||||||
|
Cow::Owned(self.get_messages(chat_id))
|
||||||
|
} else {
|
||||||
|
Cow::Owned(Vec::new())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_chat_id(&self) -> Option<ChatId> {
|
||||||
|
self.get_current_chat_id().map(ChatId::new)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_pinned_message(&self) -> Option<MessageInfo> {
|
||||||
|
self.current_pinned_message.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_message(&mut self, msg: MessageInfo) {
|
||||||
|
if let Some(chat_id) = *self.current_chat_id.lock().unwrap() {
|
||||||
|
self.messages
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.entry(chat_id)
|
||||||
|
.or_default()
|
||||||
|
.push(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear_current_chat_messages(&mut self) {
|
||||||
|
if let Some(chat_id) = *self.current_chat_id.lock().unwrap() {
|
||||||
|
self.messages.lock().unwrap().remove(&chat_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_current_chat_messages(&mut self, messages: Vec<MessageInfo>) {
|
||||||
|
if let Some(chat_id) = *self.current_chat_id.lock().unwrap() {
|
||||||
|
self.messages.lock().unwrap().insert(chat_id, messages);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_current_chat_messages<F>(&mut self, updater: F)
|
||||||
|
where
|
||||||
|
F: FnOnce(&mut Vec<MessageInfo>),
|
||||||
|
{
|
||||||
|
if let Some(chat_id) = *self.current_chat_id.lock().unwrap() {
|
||||||
|
let mut all_messages = self.messages.lock().unwrap();
|
||||||
|
updater(all_messages.entry(chat_id).or_default());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_current_chat_id(&mut self, chat_id: Option<ChatId>) {
|
||||||
|
*self.current_chat_id.lock().unwrap() = chat_id.map(|id| id.as_i64());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_current_pinned_message(&mut self, msg: Option<MessageInfo>) {
|
||||||
|
*self.current_pinned_message.lock().unwrap() = msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pending_view_messages(&self) -> &[(ChatId, Vec<MessageId>)] {
|
||||||
|
&[]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enqueue_pending_view_messages(&mut self, chat_id: ChatId, message_ids: Vec<MessageId>) {
|
||||||
|
self.pending_view_messages
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push((chat_id, message_ids));
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_missing_reply_info(&mut self) {}
|
||||||
|
|
||||||
|
async fn process_pending_view_messages(&mut self) {
|
||||||
|
let mut pending = self.pending_view_messages.lock().unwrap();
|
||||||
|
for (chat_id, message_ids) in pending.drain(..) {
|
||||||
|
let ids: Vec<i64> = message_ids.iter().map(|id| id.as_i64()).collect();
|
||||||
|
self.viewed_messages
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push((chat_id.as_i64(), ids));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl UserClient for FakeTdClient {
|
||||||
|
fn get_user_status_by_chat_id(&self, _chat_id: ChatId) -> Option<&UserOnlineStatus> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pending_user_ids(&self) -> &[UserId] {
|
||||||
|
&[]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn user_cache(&self) -> &UserCache {
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
static EMPTY_CACHE: OnceLock<UserCache> = OnceLock::new();
|
||||||
|
EMPTY_CACHE.get_or_init(|| UserCache::new(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_user_cache<F>(&mut self, _updater: F)
|
||||||
|
where
|
||||||
|
F: FnOnce(&mut UserCache),
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn process_pending_user_ids(&mut self) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ReactionClient for FakeTdClient {
|
||||||
|
async fn get_message_available_reactions(
|
||||||
|
&self,
|
||||||
|
chat_id: ChatId,
|
||||||
|
message_id: MessageId,
|
||||||
|
) -> Result<Vec<String>, String> {
|
||||||
|
FakeTdClient::get_message_available_reactions(self, chat_id, message_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn toggle_reaction(
|
||||||
|
&self,
|
||||||
|
chat_id: ChatId,
|
||||||
|
message_id: MessageId,
|
||||||
|
reaction: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
FakeTdClient::toggle_reaction(self, chat_id, message_id, reaction).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl FileClient for FakeTdClient {
|
||||||
|
async fn download_file(&self, file_id: i32) -> Result<String, String> {
|
||||||
|
FakeTdClient::download_file(self, file_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn download_voice_note(&self, file_id: i32) -> Result<String, String> {
|
||||||
|
FakeTdClient::download_file(self, file_id).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ClientState for FakeTdClient {
|
||||||
|
fn client_id(&self) -> i32 {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_me(&self) -> Result<i64, String> {
|
||||||
|
Ok(12345)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn auth_state(&self) -> &AuthState {
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
static AUTH_STATE_READY: AuthState = AuthState::Ready;
|
||||||
|
static AUTH_STATE_WAIT_PHONE: OnceLock<AuthState> = OnceLock::new();
|
||||||
|
static AUTH_STATE_WAIT_CODE: OnceLock<AuthState> = OnceLock::new();
|
||||||
|
static AUTH_STATE_WAIT_PASSWORD: OnceLock<AuthState> = OnceLock::new();
|
||||||
|
|
||||||
|
let current = self.auth_state.lock().unwrap();
|
||||||
|
match *current {
|
||||||
|
AuthState::Ready => &AUTH_STATE_READY,
|
||||||
|
AuthState::WaitPhoneNumber => {
|
||||||
|
AUTH_STATE_WAIT_PHONE.get_or_init(|| AuthState::WaitPhoneNumber)
|
||||||
|
}
|
||||||
|
AuthState::WaitCode => AUTH_STATE_WAIT_CODE.get_or_init(|| AuthState::WaitCode),
|
||||||
|
AuthState::WaitPassword => {
|
||||||
|
AUTH_STATE_WAIT_PASSWORD.get_or_init(|| AuthState::WaitPassword)
|
||||||
|
}
|
||||||
|
_ => &AUTH_STATE_READY,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn network_state(&self) -> crate::tdlib::types::NetworkState {
|
||||||
|
FakeTdClient::get_network_state(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl AccountClient for FakeTdClient {
|
||||||
|
async fn recreate_client(&mut self, _db_path: PathBuf) -> Result<(), String> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UpdateClient for FakeTdClient {
|
||||||
|
fn handle_update(&mut self, _update: Update) {}
|
||||||
|
|
||||||
|
fn drain_incoming_message_events(&mut self) -> Vec<crate::tdlib::IncomingMessageEvent> {
|
||||||
|
self.incoming_message_events
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.drain(..)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
7
crates/tele-core/src/test_support/mod.rs
Normal file
7
crates/tele-core/src/test_support/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
//! Core test support for deterministic TDLib fixtures.
|
||||||
|
|
||||||
|
pub mod fake_tdclient;
|
||||||
|
mod fake_tdclient_impl;
|
||||||
|
pub mod test_data;
|
||||||
|
|
||||||
|
pub use fake_tdclient::FakeTdClient;
|
||||||
252
crates/tele-core/src/test_support/test_data.rs
Normal file
252
crates/tele-core/src/test_support/test_data.rs
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
// Test data builders and fixtures
|
||||||
|
|
||||||
|
use crate::tdlib::types::{ForwardInfo, ReactionInfo};
|
||||||
|
use crate::tdlib::{ChatInfo, MessageInfo, ProfileInfo, ReplyInfo};
|
||||||
|
use crate::types::{ChatId, MessageId};
|
||||||
|
|
||||||
|
/// Builder для создания тестового чата
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct TestChatBuilder {
|
||||||
|
id: i64,
|
||||||
|
title: String,
|
||||||
|
username: Option<String>,
|
||||||
|
last_message: String,
|
||||||
|
last_message_date: i32,
|
||||||
|
unread_count: i32,
|
||||||
|
unread_mention_count: i32,
|
||||||
|
is_pinned: bool,
|
||||||
|
order: i64,
|
||||||
|
last_read_outbox_message_id: i64,
|
||||||
|
folder_ids: Vec<i32>,
|
||||||
|
is_muted: bool,
|
||||||
|
draft_text: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
impl TestChatBuilder {
|
||||||
|
pub fn new(title: &str, id: i64) -> Self {
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
title: title.to_string(),
|
||||||
|
username: None,
|
||||||
|
last_message: "".to_string(),
|
||||||
|
last_message_date: 1640000000,
|
||||||
|
unread_count: 0,
|
||||||
|
unread_mention_count: 0,
|
||||||
|
is_pinned: false,
|
||||||
|
order: id,
|
||||||
|
last_read_outbox_message_id: 0,
|
||||||
|
folder_ids: vec![0],
|
||||||
|
is_muted: false,
|
||||||
|
draft_text: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn username(mut self, username: &str) -> Self {
|
||||||
|
self.username = Some(username.to_string());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn last_message(mut self, text: &str) -> Self {
|
||||||
|
self.last_message = text.to_string();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unread_count(mut self, count: i32) -> Self {
|
||||||
|
self.unread_count = count;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unread_mentions(mut self, count: i32) -> Self {
|
||||||
|
self.unread_mention_count = count;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pinned(mut self) -> Self {
|
||||||
|
self.is_pinned = true;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn muted(mut self) -> Self {
|
||||||
|
self.is_muted = true;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn draft(mut self, text: &str) -> Self {
|
||||||
|
self.draft_text = Some(text.to_string());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn folder(mut self, folder_id: i32) -> Self {
|
||||||
|
self.folder_ids = vec![folder_id];
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build(self) -> ChatInfo {
|
||||||
|
ChatInfo {
|
||||||
|
id: ChatId::new(self.id),
|
||||||
|
title: self.title,
|
||||||
|
username: self.username,
|
||||||
|
last_message: self.last_message,
|
||||||
|
last_message_date: self.last_message_date,
|
||||||
|
unread_count: self.unread_count,
|
||||||
|
unread_mention_count: self.unread_mention_count,
|
||||||
|
is_pinned: self.is_pinned,
|
||||||
|
order: self.order,
|
||||||
|
last_read_outbox_message_id: MessageId::new(self.last_read_outbox_message_id),
|
||||||
|
folder_ids: self.folder_ids,
|
||||||
|
is_muted: self.is_muted,
|
||||||
|
draft_text: self.draft_text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builder для создания тестового сообщения
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct TestMessageBuilder {
|
||||||
|
id: i64,
|
||||||
|
sender_name: String,
|
||||||
|
is_outgoing: bool,
|
||||||
|
content: String,
|
||||||
|
entities: Vec<tdlib_rs::types::TextEntity>,
|
||||||
|
date: i32,
|
||||||
|
edit_date: i32,
|
||||||
|
is_read: bool,
|
||||||
|
can_be_edited: bool,
|
||||||
|
can_be_deleted_only_for_self: bool,
|
||||||
|
can_be_deleted_for_all_users: bool,
|
||||||
|
reply_to: Option<ReplyInfo>,
|
||||||
|
forward_from: Option<ForwardInfo>,
|
||||||
|
reactions: Vec<ReactionInfo>,
|
||||||
|
media_album_id: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
impl TestMessageBuilder {
|
||||||
|
pub fn new(content: &str, id: i64) -> Self {
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
sender_name: "User".to_string(),
|
||||||
|
is_outgoing: false,
|
||||||
|
content: content.to_string(),
|
||||||
|
entities: vec![],
|
||||||
|
date: 1640000000,
|
||||||
|
edit_date: 0,
|
||||||
|
is_read: true,
|
||||||
|
can_be_edited: false,
|
||||||
|
can_be_deleted_only_for_self: true,
|
||||||
|
can_be_deleted_for_all_users: false,
|
||||||
|
reply_to: None,
|
||||||
|
forward_from: None,
|
||||||
|
reactions: vec![],
|
||||||
|
media_album_id: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn outgoing(mut self) -> Self {
|
||||||
|
self.is_outgoing = true;
|
||||||
|
self.sender_name = "You".to_string();
|
||||||
|
self.can_be_edited = true;
|
||||||
|
self.can_be_deleted_for_all_users = true;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sender(mut self, name: &str) -> Self {
|
||||||
|
self.sender_name = name.to_string();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn date(mut self, timestamp: i32) -> Self {
|
||||||
|
self.date = timestamp;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn edited(mut self) -> Self {
|
||||||
|
self.edit_date = self.date + 60;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unread(mut self) -> Self {
|
||||||
|
self.is_read = false;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reply_to(mut self, message_id: i64, sender: &str, text: &str) -> Self {
|
||||||
|
self.reply_to = Some(ReplyInfo {
|
||||||
|
message_id: MessageId::new(message_id),
|
||||||
|
sender_name: sender.to_string(),
|
||||||
|
text: text.to_string(),
|
||||||
|
});
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn forwarded_from(mut self, sender: &str) -> Self {
|
||||||
|
self.forward_from = Some(ForwardInfo { sender_name: sender.to_string() });
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reaction(mut self, emoji: &str, count: i32, chosen: bool) -> Self {
|
||||||
|
self.reactions
|
||||||
|
.push(ReactionInfo { emoji: emoji.to_string(), count, is_chosen: chosen });
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn media_album_id(mut self, id: i64) -> Self {
|
||||||
|
self.media_album_id = id;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build(self) -> MessageInfo {
|
||||||
|
let mut msg = MessageInfo::new(
|
||||||
|
MessageId::new(self.id),
|
||||||
|
self.sender_name,
|
||||||
|
self.is_outgoing,
|
||||||
|
self.content,
|
||||||
|
self.entities,
|
||||||
|
self.date,
|
||||||
|
self.edit_date,
|
||||||
|
self.is_read,
|
||||||
|
self.can_be_edited,
|
||||||
|
self.can_be_deleted_only_for_self,
|
||||||
|
self.can_be_deleted_for_all_users,
|
||||||
|
self.reply_to,
|
||||||
|
self.forward_from,
|
||||||
|
self.reactions,
|
||||||
|
);
|
||||||
|
msg.metadata.media_album_id = self.media_album_id;
|
||||||
|
msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Хелперы для быстрого создания тестовых данных
|
||||||
|
pub fn create_test_chat(title: &str, id: i64) -> ChatInfo {
|
||||||
|
TestChatBuilder::new(title, id).build()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn create_test_message(content: &str, id: i64) -> MessageInfo {
|
||||||
|
TestMessageBuilder::new(content, id).build()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn create_test_user(name: &str, id: i64) -> (i64, String) {
|
||||||
|
(id, name.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Хелпер для создания профиля
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn create_test_profile(title: &str, chat_id: i64) -> ProfileInfo {
|
||||||
|
ProfileInfo {
|
||||||
|
chat_id: ChatId::new(chat_id),
|
||||||
|
title: title.to_string(),
|
||||||
|
username: None,
|
||||||
|
bio: None,
|
||||||
|
phone_number: None,
|
||||||
|
chat_type: "Личный чат".to_string(),
|
||||||
|
member_count: None,
|
||||||
|
description: None,
|
||||||
|
invite_link: None,
|
||||||
|
is_group: false,
|
||||||
|
online_status: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
9
crates/tele-core/src/utils.rs
Normal file
9
crates/tele-core/src/utils.rs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
use chrono::{DateTime, Local, NaiveDate, Utc};
|
||||||
|
|
||||||
|
pub fn get_day(timestamp: i32) -> i64 {
|
||||||
|
let epoch = NaiveDate::from_ymd_opt(1970, 1, 1).expect("valid epoch date");
|
||||||
|
let msg_day = DateTime::<Utc>::from_timestamp(timestamp as i64, 0)
|
||||||
|
.map(|dt| dt.with_timezone(&Local).date_naive())
|
||||||
|
.unwrap_or(epoch);
|
||||||
|
msg_day.signed_duration_since(epoch).num_days()
|
||||||
|
}
|
||||||
27
crates/tele-ios-ffi/Cargo.toml
Normal file
27
crates/tele-ios-ffi/Cargo.toml
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
[package]
|
||||||
|
name = "tele-ios-ffi"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
authors = ["Your Name <your.email@example.com>"]
|
||||||
|
description = "UniFFI bridge for the iOS Telegram client"
|
||||||
|
license = "MIT"
|
||||||
|
repository = "https://github.com/your-username/tele-tui"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
crate-type = ["cdylib", "staticlib", "rlib"]
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["core-session-download"]
|
||||||
|
core-session = ["dep:tele-core"]
|
||||||
|
core-session-download = ["core-session", "tele-core/tdlib-download"]
|
||||||
|
core-session-local-tdlib = ["core-session", "tele-core/tdlib-local"]
|
||||||
|
standalone-fake = []
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tele-core = { path = "../tele-core", default-features = false, features = ["test-support"], optional = true }
|
||||||
|
tokio = { version = "1", features = ["rt-multi-thread"] }
|
||||||
|
thiserror = "1.0"
|
||||||
|
uniffi = { version = "0.31.1", features = ["tokio"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tele-core = { path = "../tele-core", default-features = false, features = ["test-support", "tdlib-download"] }
|
||||||
46
crates/tele-ios-ffi/README.md
Normal file
46
crates/tele-ios-ffi/README.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# tele-ios-ffi
|
||||||
|
|
||||||
|
UniFFI bridge for the future native iOS app.
|
||||||
|
|
||||||
|
Current scope:
|
||||||
|
|
||||||
|
- Exposes a fake-backed `SessionHandle` for Swift integration tests and app shell work.
|
||||||
|
- Mirrors the `tele-core::session` DTO/event model with UniFFI-compatible records and enums.
|
||||||
|
- Supports a fake-only build for UI work and a real TDLib build path using local iOS TDLib artifacts.
|
||||||
|
|
||||||
|
Generate Swift bindings and headers:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/generate-ios-ffi-bindings.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The script builds `target/release/libtele_ios_ffi.a` and writes Swift sources,
|
||||||
|
headers, a Swift typecheck-friendly `tele_ios_ffiFFI` module map, and an
|
||||||
|
XCFramework-compatible module map under `build/ios-ffi/`.
|
||||||
|
|
||||||
|
Build the fake-only iOS simulator XCFramework without linking TDLib:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer scripts/build-ios-fake-ffi-xcframework.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Run an executable Swift smoke test against matching fake-only UniFFI bindings:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer scripts/smoke-ios-ffi-swift.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Typecheck the Swift app bridge against generated UniFFI bindings:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer scripts/typecheck-ios-uniffi-app-bridge.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Current linking status:
|
||||||
|
|
||||||
|
- Xcode is installed at `/Applications/Xcode.app`, and `DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcodebuild -version` reports Xcode 26.5.
|
||||||
|
- The iOS 26.5 simulator runtime is installed and `scripts/check-ios-prereqs.sh` passes with available iPhone/iPad simulators.
|
||||||
|
- The current app shell uses the fake Swift bridge.
|
||||||
|
- `tdlib-rs` does not publish iOS `download-tdlib` archives, so real iOS linking uses `tele-core/tdlib-local` and `LOCAL_TDLIB_PATH`.
|
||||||
|
- Local TDLib linking is validated for `aarch64-apple-ios-sim` via `scripts/check-ios-tdlib-linking.sh` and for `aarch64-apple-ios` via `IOS_RUST_TARGET=aarch64-apple-ios scripts/build-ios-ffi-with-local-tdlib.sh`.
|
||||||
|
- `scripts/build-ios-real-ffi-xcframework.sh` packages local simulator Rust slices plus local `libtdjson` into app-local XCFrameworks, generates Swift bindings, and enables Xcode builds with `TELE_IOS_USE_LOCAL_FFI=1`.
|
||||||
1432
crates/tele-ios-ffi/src/lib.rs
Normal file
1432
crates/tele-ios-ffi/src/lib.rs
Normal file
File diff suppressed because it is too large
Load Diff
66
crates/tele-tui/Cargo.toml
Normal file
66
crates/tele-tui/Cargo.toml
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
[package]
|
||||||
|
name = "tele-tui"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
authors = ["Your Name <your.email@example.com>"]
|
||||||
|
description = "Terminal UI for Telegram with Vim-style navigation"
|
||||||
|
license = "MIT"
|
||||||
|
repository = "https://github.com/your-username/tele-tui"
|
||||||
|
keywords = ["telegram", "tui", "terminal", "cli"]
|
||||||
|
categories = ["command-line-utilities"]
|
||||||
|
default-run = "tele-tui"
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["clipboard", "url-open", "notifications", "images"]
|
||||||
|
clipboard = ["dep:arboard"]
|
||||||
|
url-open = ["dep:open"]
|
||||||
|
notifications = ["dep:notify-rust"]
|
||||||
|
images = ["dep:ratatui-image", "dep:image", "tele-core/images"]
|
||||||
|
test-support = ["tele-core/test-support"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tele-core = { path = "../tele-core", default-features = false, features = ["tdlib-download"] }
|
||||||
|
ratatui = "0.29"
|
||||||
|
crossterm = "0.28"
|
||||||
|
tdlib-rs = { version = "1.2.0", features = ["download-tdlib"] }
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
async-trait = "0.1"
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
dotenvy = "0.15"
|
||||||
|
chrono = "0.4"
|
||||||
|
open = { version = "5.0", optional = true }
|
||||||
|
arboard = { version = "3.4", optional = true }
|
||||||
|
notify-rust = { version = "4.11", optional = true }
|
||||||
|
ratatui-image = { version = "8.1", optional = true, features = ["image-defaults"] }
|
||||||
|
image = { version = "0.25", optional = true }
|
||||||
|
toml = "0.8"
|
||||||
|
dirs = "5.0"
|
||||||
|
thiserror = "1.0"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
base64 = "0.22.1"
|
||||||
|
fs2 = "0.4"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
insta = "1.34"
|
||||||
|
tokio-test = "0.4"
|
||||||
|
criterion = "0.5"
|
||||||
|
termwright = "0.2"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "tele-tui-test-fixture"
|
||||||
|
path = "src/bin/tele-tui-test-fixture.rs"
|
||||||
|
required-features = ["test-support"]
|
||||||
|
|
||||||
|
[[bench]]
|
||||||
|
name = "group_messages"
|
||||||
|
harness = false
|
||||||
|
|
||||||
|
[[bench]]
|
||||||
|
name = "formatting"
|
||||||
|
harness = false
|
||||||
|
|
||||||
|
[[bench]]
|
||||||
|
name = "format_markdown"
|
||||||
|
harness = false
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
use criterion::{black_box, criterion_group, criterion_main, Criterion};
|
use criterion::{black_box, criterion_group, criterion_main, Criterion};
|
||||||
use tdlib_rs::enums::{TextEntity, TextEntityType};
|
use ratatui::style::Color;
|
||||||
|
use tdlib_rs::enums::TextEntityType;
|
||||||
|
use tdlib_rs::types::TextEntity;
|
||||||
use tele_tui::formatting::format_text_with_entities;
|
use tele_tui::formatting::format_text_with_entities;
|
||||||
|
|
||||||
fn create_text_with_entities() -> (String, Vec<TextEntity>) {
|
fn create_text_with_entities() -> (String, Vec<TextEntity>) {
|
||||||
@@ -9,27 +11,27 @@ fn create_text_with_entities() -> (String, Vec<TextEntity>) {
|
|||||||
TextEntity {
|
TextEntity {
|
||||||
offset: 8,
|
offset: 8,
|
||||||
length: 4, // bold
|
length: 4, // bold
|
||||||
type_: TextEntityType::Bold,
|
r#type: TextEntityType::Bold,
|
||||||
},
|
},
|
||||||
TextEntity {
|
TextEntity {
|
||||||
offset: 17,
|
offset: 17,
|
||||||
length: 6, // italic
|
length: 6, // italic
|
||||||
type_: TextEntityType::Italic,
|
r#type: TextEntityType::Italic,
|
||||||
},
|
},
|
||||||
TextEntity {
|
TextEntity {
|
||||||
offset: 34,
|
offset: 34,
|
||||||
length: 4, // code
|
length: 4, // code
|
||||||
type_: TextEntityType::Code,
|
r#type: TextEntityType::Code,
|
||||||
},
|
},
|
||||||
TextEntity {
|
TextEntity {
|
||||||
offset: 45,
|
offset: 45,
|
||||||
length: 4, // link
|
length: 4, // link
|
||||||
type_: TextEntityType::Url,
|
r#type: TextEntityType::Url,
|
||||||
},
|
},
|
||||||
TextEntity {
|
TextEntity {
|
||||||
offset: 54,
|
offset: 54,
|
||||||
length: 7, // mention
|
length: 7, // mention
|
||||||
type_: TextEntityType::Mention,
|
r#type: TextEntityType::Mention,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -41,7 +43,7 @@ fn benchmark_format_simple_text(c: &mut Criterion) {
|
|||||||
let entities = vec![];
|
let entities = vec![];
|
||||||
|
|
||||||
c.bench_function("format_simple_text", |b| {
|
c.bench_function("format_simple_text", |b| {
|
||||||
b.iter(|| format_text_with_entities(black_box(&text), black_box(&entities)));
|
b.iter(|| format_text_with_entities(black_box(&text), black_box(&entities), Color::White));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +51,7 @@ fn benchmark_format_markdown_text(c: &mut Criterion) {
|
|||||||
let (text, entities) = create_text_with_entities();
|
let (text, entities) = create_text_with_entities();
|
||||||
|
|
||||||
c.bench_function("format_markdown_text", |b| {
|
c.bench_function("format_markdown_text", |b| {
|
||||||
b.iter(|| format_text_with_entities(black_box(&text), black_box(&entities)));
|
b.iter(|| format_text_with_entities(black_box(&text), black_box(&entities), Color::White));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,13 +69,13 @@ fn benchmark_format_long_text(c: &mut Criterion) {
|
|||||||
entities.push(TextEntity {
|
entities.push(TextEntity {
|
||||||
offset: start as i32,
|
offset: start as i32,
|
||||||
length: format!("Word{}", i).len() as i32,
|
length: format!("Word{}", i).len() as i32,
|
||||||
type_: TextEntityType::Bold,
|
r#type: TextEntityType::Bold,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
c.bench_function("format_long_text_with_100_entities", |b| {
|
c.bench_function("format_long_text_with_100_entities", |b| {
|
||||||
b.iter(|| format_text_with_entities(black_box(&text), black_box(&entities)));
|
b.iter(|| format_text_with_entities(black_box(&text), black_box(&entities), Color::White));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7,8 +7,8 @@ fn create_test_messages(count: usize) -> Vec<tele_tui::tdlib::MessageInfo> {
|
|||||||
(0..count)
|
(0..count)
|
||||||
.map(|i| {
|
.map(|i| {
|
||||||
let builder = MessageBuilder::new(MessageId::new(i as i64))
|
let builder = MessageBuilder::new(MessageId::new(i as i64))
|
||||||
.sender_name(&format!("User{}", i % 10))
|
.sender_name(format!("User{}", i % 10))
|
||||||
.text(&format!(
|
.text(format!(
|
||||||
"Test message number {} with some longer text to make it more realistic",
|
"Test message number {} with some longer text to make it more realistic",
|
||||||
i
|
i
|
||||||
))
|
))
|
||||||
38
crates/tele-tui/build.rs
Normal file
38
crates/tele-tui/build.rs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
use std::env;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
println!("cargo:rerun-if-changed=build.rs");
|
||||||
|
|
||||||
|
for lib_dir in tdlib_lib_dirs() {
|
||||||
|
println!("cargo:rustc-link-arg=-Wl,-rpath,{}", lib_dir.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tdlib_lib_dirs() -> Vec<PathBuf> {
|
||||||
|
let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap());
|
||||||
|
let profile = env::var("PROFILE").unwrap_or_else(|_| "debug".to_string());
|
||||||
|
let workspace_dir = manifest_dir
|
||||||
|
.parent()
|
||||||
|
.and_then(Path::parent)
|
||||||
|
.map(Path::to_path_buf)
|
||||||
|
.unwrap_or(manifest_dir);
|
||||||
|
let build_dir = workspace_dir.join("target").join(profile).join("build");
|
||||||
|
|
||||||
|
let Ok(entries) = fs::read_dir(build_dir) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
|
||||||
|
entries
|
||||||
|
.flatten()
|
||||||
|
.map(|entry| entry.path().join("out").join("tdlib").join("lib"))
|
||||||
|
.filter(|path| has_tdjson(path))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_tdjson(path: &Path) -> bool {
|
||||||
|
path.join("libtdjson.1.8.29.dylib").exists()
|
||||||
|
|| path.join("libtdjson.dylib").exists()
|
||||||
|
|| path.join("libtdjson.so").exists()
|
||||||
|
}
|
||||||
@@ -33,17 +33,12 @@ pub fn acquire_lock(account_name: &str) -> Result<File, String> {
|
|||||||
|
|
||||||
// Ensure parent directory exists
|
// Ensure parent directory exists
|
||||||
if let Some(parent) = lock_path.parent() {
|
if let Some(parent) = lock_path.parent() {
|
||||||
fs::create_dir_all(parent).map_err(|e| {
|
fs::create_dir_all(parent)
|
||||||
format!(
|
.map_err(|e| format!("Не удалось создать директорию для lock-файла: {}", e))?;
|
||||||
"Не удалось создать директорию для lock-файла: {}",
|
|
||||||
e
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let file = File::create(&lock_path).map_err(|e| {
|
let file = File::create(&lock_path)
|
||||||
format!("Не удалось создать lock-файл {}: {}", lock_path.display(), e)
|
.map_err(|e| format!("Не удалось создать lock-файл {}: {}", lock_path.display(), e))?;
|
||||||
})?;
|
|
||||||
|
|
||||||
file.try_lock_exclusive().map_err(|_| {
|
file.try_lock_exclusive().map_err(|_| {
|
||||||
format!(
|
format!(
|
||||||
@@ -226,6 +226,7 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::types::ChatId;
|
use crate::types::ChatId;
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn create_test_chat(
|
fn create_test_chat(
|
||||||
id: i64,
|
id: i64,
|
||||||
title: &str,
|
title: &str,
|
||||||
@@ -132,9 +132,9 @@ impl<T: TdClientTrait> MessageMethods<T> for App<T> {
|
|||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
if selected_idx.is_none() {
|
let Some(selected_idx) = selected_idx else {
|
||||||
return false;
|
return false;
|
||||||
}
|
};
|
||||||
|
|
||||||
// Сначала извлекаем данные из сообщения
|
// Сначала извлекаем данные из сообщения
|
||||||
let msg_data = self.get_selected_message().and_then(|msg| {
|
let msg_data = self.get_selected_message().and_then(|msg| {
|
||||||
@@ -143,7 +143,7 @@ impl<T: TdClientTrait> MessageMethods<T> for App<T> {
|
|||||||
// 2. Это исходящее сообщение
|
// 2. Это исходящее сообщение
|
||||||
// 3. ID не временный (временные ID в TDLib отрицательные)
|
// 3. ID не временный (временные ID в TDLib отрицательные)
|
||||||
if msg.can_be_edited() && msg.is_outgoing() && msg.id().as_i64() > 0 {
|
if msg.can_be_edited() && msg.is_outgoing() && msg.id().as_i64() > 0 {
|
||||||
Some((msg.id(), msg.text().to_string(), selected_idx.unwrap()))
|
Some((msg.id(), msg.text().to_string(), selected_idx))
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
@@ -15,7 +15,9 @@ pub use methods::*;
|
|||||||
pub use state::AppScreen;
|
pub use state::AppScreen;
|
||||||
|
|
||||||
use crate::accounts::AccountProfile;
|
use crate::accounts::AccountProfile;
|
||||||
|
use crate::notifications::NotificationManager;
|
||||||
use crate::tdlib::{ChatInfo, TdClient, TdClientTrait};
|
use crate::tdlib::{ChatInfo, TdClient, TdClientTrait};
|
||||||
|
use crate::tdlib::{TdClientConfig, TdCredentials};
|
||||||
use crate::types::{ChatId, MessageId};
|
use crate::types::{ChatId, MessageId};
|
||||||
use ratatui::widgets::ListState;
|
use ratatui::widgets::ListState;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
@@ -102,6 +104,7 @@ pub struct App<T: TdClientTrait = TdClient> {
|
|||||||
config: crate::config::Config,
|
config: crate::config::Config,
|
||||||
pub screen: AppScreen,
|
pub screen: AppScreen,
|
||||||
pub td_client: T,
|
pub td_client: T,
|
||||||
|
pub notification_manager: NotificationManager,
|
||||||
/// Состояние чата - type-safe state machine (новое!)
|
/// Состояние чата - type-safe state machine (новое!)
|
||||||
pub chat_state: ChatState,
|
pub chat_state: ChatState,
|
||||||
/// Vim-like input mode: Normal (navigation) / Insert (text input)
|
/// Vim-like input mode: Normal (navigation) / Insert (text input)
|
||||||
@@ -205,11 +208,13 @@ impl<T: TdClientTrait> App<T> {
|
|||||||
let inline_image_renderer = crate::media::image_renderer::ImageRenderer::new_fast();
|
let inline_image_renderer = crate::media::image_renderer::ImageRenderer::new_fast();
|
||||||
#[cfg(feature = "images")]
|
#[cfg(feature = "images")]
|
||||||
let modal_image_renderer = crate::media::image_renderer::ImageRenderer::new();
|
let modal_image_renderer = crate::media::image_renderer::ImageRenderer::new();
|
||||||
|
let notification_manager = NotificationManager::from_config(&config.notifications);
|
||||||
|
|
||||||
App {
|
App {
|
||||||
config,
|
config,
|
||||||
screen: AppScreen::Loading,
|
screen: AppScreen::Loading,
|
||||||
td_client,
|
td_client,
|
||||||
|
notification_manager,
|
||||||
chat_state: ChatState::Normal,
|
chat_state: ChatState::Normal,
|
||||||
input_mode: InputMode::Normal,
|
input_mode: InputMode::Normal,
|
||||||
phone_input: String::new(),
|
phone_input: String::new(),
|
||||||
@@ -613,8 +618,18 @@ impl App<TdClient> {
|
|||||||
///
|
///
|
||||||
/// A new `App<TdClient>` instance ready to start authentication.
|
/// A new `App<TdClient>` instance ready to start authentication.
|
||||||
pub fn new(config: crate::config::Config, db_path: std::path::PathBuf) -> App<TdClient> {
|
pub fn new(config: crate::config::Config, db_path: std::path::PathBuf) -> App<TdClient> {
|
||||||
let mut td_client = TdClient::new(db_path);
|
let (api_id, api_hash) = crate::config::Config::load_credentials().unwrap_or_else(|_| {
|
||||||
td_client.configure_notifications(&config.notifications);
|
let api_id = std::env::var("API_ID")
|
||||||
|
.unwrap_or_else(|_| "0".to_string())
|
||||||
|
.parse()
|
||||||
|
.unwrap_or(0);
|
||||||
|
let api_hash = std::env::var("API_HASH").unwrap_or_default();
|
||||||
|
(api_id, api_hash)
|
||||||
|
});
|
||||||
|
let td_client = TdClient::new(TdClientConfig {
|
||||||
|
credentials: TdCredentials { api_id, api_hash },
|
||||||
|
db_path,
|
||||||
|
});
|
||||||
App::with_client(config, td_client)
|
App::with_client(config, td_client)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
182
crates/tele-tui/src/bin/tele-tui-test-fixture.rs
Normal file
182
crates/tele-tui/src/bin/tele-tui-test-fixture.rs
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
use std::io;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crossterm::{
|
||||||
|
event::{
|
||||||
|
self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
|
||||||
|
Event, KeyCode, KeyEvent, KeyModifiers,
|
||||||
|
},
|
||||||
|
execute,
|
||||||
|
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||||
|
};
|
||||||
|
use ratatui::{backend::CrosstermBackend, Terminal};
|
||||||
|
use tele_tui::{
|
||||||
|
app::{App, AppScreen},
|
||||||
|
input::handle_main_input,
|
||||||
|
test_support::{
|
||||||
|
app_builder::TestAppBuilder,
|
||||||
|
fake_tdclient::FakeTdClient,
|
||||||
|
test_data::{TestChatBuilder, TestMessageBuilder},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> io::Result<()> {
|
||||||
|
let scenario = parse_scenario();
|
||||||
|
let mut app = build_app(&scenario);
|
||||||
|
|
||||||
|
enable_raw_mode()?;
|
||||||
|
let mut stdout = io::stdout();
|
||||||
|
execute!(stdout, EnterAlternateScreen, EnableMouseCapture, EnableBracketedPaste)?;
|
||||||
|
|
||||||
|
let backend = CrosstermBackend::new(stdout);
|
||||||
|
let mut terminal = Terminal::new(backend)?;
|
||||||
|
let result = run_fixture(&mut terminal, &mut app).await;
|
||||||
|
|
||||||
|
disable_raw_mode()?;
|
||||||
|
execute!(
|
||||||
|
terminal.backend_mut(),
|
||||||
|
LeaveAlternateScreen,
|
||||||
|
DisableMouseCapture,
|
||||||
|
DisableBracketedPaste
|
||||||
|
)?;
|
||||||
|
terminal.show_cursor()?;
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_scenario() -> String {
|
||||||
|
let mut args = std::env::args().skip(1);
|
||||||
|
while let Some(arg) = args.next() {
|
||||||
|
if arg == "--scenario" {
|
||||||
|
return args.next().unwrap_or_else(|| "inbox".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"inbox".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_fixture(
|
||||||
|
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||||
|
app: &mut App<FakeTdClient>,
|
||||||
|
) -> io::Result<()> {
|
||||||
|
loop {
|
||||||
|
if app.needs_redraw {
|
||||||
|
terminal.draw(|f| tele_tui::ui::render(f, app))?;
|
||||||
|
app.needs_redraw = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if event::poll(Duration::from_millis(16))? {
|
||||||
|
match event::read()? {
|
||||||
|
Event::Key(key) => {
|
||||||
|
if key.code == KeyCode::Char('c')
|
||||||
|
&& key.modifiers.contains(KeyModifiers::CONTROL)
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if key.code == KeyCode::F(10) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
handle_main_input(app, normalize_fixture_key(key)).await;
|
||||||
|
app.needs_redraw = true;
|
||||||
|
}
|
||||||
|
Event::Resize(_, _) => {
|
||||||
|
app.needs_redraw = true;
|
||||||
|
}
|
||||||
|
Event::Paste(text) => {
|
||||||
|
for ch in text.chars() {
|
||||||
|
handle_main_input(
|
||||||
|
app,
|
||||||
|
KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
app.needs_redraw = true;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_fixture_key(key: KeyEvent) -> KeyEvent {
|
||||||
|
match (key.code, key.modifiers) {
|
||||||
|
(KeyCode::Char('/'), KeyModifiers::NONE) => {
|
||||||
|
KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)
|
||||||
|
}
|
||||||
|
(KeyCode::Char('j' | 'm'), modifiers) if modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
|
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)
|
||||||
|
}
|
||||||
|
_ => key,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_app(scenario: &str) -> App<FakeTdClient> {
|
||||||
|
match scenario {
|
||||||
|
"open-chat" => TestAppBuilder::new()
|
||||||
|
.screen(AppScreen::Main)
|
||||||
|
.with_chats(sample_chats())
|
||||||
|
.selected_chat(102)
|
||||||
|
.with_messages(102, sample_messages())
|
||||||
|
.build(),
|
||||||
|
"compose-draft" => TestAppBuilder::new()
|
||||||
|
.screen(AppScreen::Main)
|
||||||
|
.with_chats(sample_chats())
|
||||||
|
.selected_chat(102)
|
||||||
|
.message_input("hello from e2e")
|
||||||
|
.with_messages(102, sample_messages())
|
||||||
|
.build(),
|
||||||
|
"inbox" => TestAppBuilder::new()
|
||||||
|
.screen(AppScreen::Main)
|
||||||
|
.with_chats(sample_chats())
|
||||||
|
.with_messages(101, mom_messages())
|
||||||
|
.with_messages(102, sample_messages())
|
||||||
|
.with_messages(103, boss_messages())
|
||||||
|
.build(),
|
||||||
|
other => {
|
||||||
|
eprintln!("unknown scenario: {other}");
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_chats() -> Vec<tele_tui::tdlib::ChatInfo> {
|
||||||
|
vec![
|
||||||
|
TestChatBuilder::new("Mom", 101)
|
||||||
|
.last_message("Dinner at 7?")
|
||||||
|
.unread_count(2)
|
||||||
|
.build(),
|
||||||
|
TestChatBuilder::new("Work Group", 102)
|
||||||
|
.last_message("Standup notes are ready")
|
||||||
|
.unread_mentions(1)
|
||||||
|
.build(),
|
||||||
|
TestChatBuilder::new("Boss", 103)
|
||||||
|
.last_message("Please review the deck")
|
||||||
|
.build(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_messages() -> Vec<tele_tui::tdlib::MessageInfo> {
|
||||||
|
vec![
|
||||||
|
TestMessageBuilder::new("Morning, team", 201)
|
||||||
|
.sender("Alice")
|
||||||
|
.build(),
|
||||||
|
TestMessageBuilder::new("Standup notes are ready", 202)
|
||||||
|
.sender("Bob")
|
||||||
|
.build(),
|
||||||
|
TestMessageBuilder::new("Thanks, I will review them after lunch", 203)
|
||||||
|
.outgoing()
|
||||||
|
.build(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mom_messages() -> Vec<tele_tui::tdlib::MessageInfo> {
|
||||||
|
vec![TestMessageBuilder::new("Dinner at 7?", 301)
|
||||||
|
.sender("Mom")
|
||||||
|
.build()]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn boss_messages() -> Vec<tele_tui::tdlib::MessageInfo> {
|
||||||
|
vec![TestMessageBuilder::new("Please review the deck", 401)
|
||||||
|
.sender("Boss")
|
||||||
|
.build()]
|
||||||
|
}
|
||||||
@@ -5,15 +5,19 @@
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/// Максимальное количество сообщений в одном чате (для оптимизации памяти)
|
/// Максимальное количество сообщений в одном чате (для оптимизации памяти)
|
||||||
|
#[allow(dead_code)]
|
||||||
pub const MAX_MESSAGES_IN_CHAT: usize = 500;
|
pub const MAX_MESSAGES_IN_CHAT: usize = 500;
|
||||||
|
|
||||||
/// Максимальный размер кэша пользователей (LRU)
|
/// Максимальный размер кэша пользователей (LRU)
|
||||||
|
#[allow(dead_code)]
|
||||||
pub const MAX_USER_CACHE_SIZE: usize = 500;
|
pub const MAX_USER_CACHE_SIZE: usize = 500;
|
||||||
|
|
||||||
/// Максимальное количество чатов для загрузки
|
/// Максимальное количество чатов для загрузки
|
||||||
|
#[allow(dead_code)]
|
||||||
pub const MAX_CHATS: usize = 200;
|
pub const MAX_CHATS: usize = 200;
|
||||||
|
|
||||||
/// Максимальное количество user_ids для хранения в чате
|
/// Максимальное количество user_ids для хранения в чате
|
||||||
|
#[allow(dead_code)]
|
||||||
pub const MAX_CHAT_USER_IDS: usize = 500;
|
pub const MAX_CHAT_USER_IDS: usize = 500;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -27,6 +31,7 @@ pub const POLL_TIMEOUT_MS: u64 = 16;
|
|||||||
pub const SHUTDOWN_TIMEOUT_SECS: u64 = 2;
|
pub const SHUTDOWN_TIMEOUT_SECS: u64 = 2;
|
||||||
|
|
||||||
/// Количество пользователей для ленивой загрузки за один тик
|
/// Количество пользователей для ленивой загрузки за один тик
|
||||||
|
#[allow(dead_code)]
|
||||||
pub const LAZY_LOAD_USERS_PER_TICK: usize = 5;
|
pub const LAZY_LOAD_USERS_PER_TICK: usize = 5;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -34,6 +39,7 @@ pub const LAZY_LOAD_USERS_PER_TICK: usize = 5;
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/// Лимит количества сообщений для загрузки через TDLib за раз
|
/// Лимит количества сообщений для загрузки через TDLib за раз
|
||||||
|
#[allow(dead_code)]
|
||||||
pub const TDLIB_MESSAGE_LIMIT: i32 = 50;
|
pub const TDLIB_MESSAGE_LIMIT: i32 = 50;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -6,6 +6,8 @@
|
|||||||
//! - Editing and sending messages
|
//! - Editing and sending messages
|
||||||
//! - Loading older messages
|
//! - Loading older messages
|
||||||
|
|
||||||
|
mod media;
|
||||||
|
|
||||||
use super::chat_loader::{load_older_messages_if_needed, open_chat_and_load_data};
|
use super::chat_loader::{load_older_messages_if_needed, open_chat_and_load_data};
|
||||||
use crate::app::methods::{
|
use crate::app::methods::{
|
||||||
compose::ComposeMethods, messages::MessageMethods, modal::ModalMethods,
|
compose::ComposeMethods, messages::MessageMethods, modal::ModalMethods,
|
||||||
@@ -77,22 +79,25 @@ pub async fn handle_message_selection<T: TdClientTrait>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(crate::config::Command::ViewImage) => {
|
Some(crate::config::Command::ViewImage) => {
|
||||||
handle_view_or_play_media(app).await;
|
media::handle_view_or_play_media(app).await;
|
||||||
}
|
}
|
||||||
Some(crate::config::Command::TogglePlayback) => {
|
Some(crate::config::Command::TogglePlayback) => {
|
||||||
handle_toggle_voice_playback(app).await;
|
media::handle_toggle_voice_playback(app).await;
|
||||||
}
|
}
|
||||||
Some(crate::config::Command::SeekForward | crate::config::Command::MoveRight) => {
|
Some(crate::config::Command::SeekForward | crate::config::Command::MoveRight) => {
|
||||||
handle_voice_seek(app, 5.0);
|
media::handle_voice_seek(app, 5.0);
|
||||||
}
|
}
|
||||||
Some(crate::config::Command::SeekBackward | crate::config::Command::MoveLeft) => {
|
Some(crate::config::Command::SeekBackward | crate::config::Command::MoveLeft) => {
|
||||||
handle_voice_seek(app, -5.0);
|
media::handle_voice_seek(app, -5.0);
|
||||||
}
|
}
|
||||||
Some(crate::config::Command::ReactMessage) => {
|
Some(crate::config::Command::ReactMessage) => {
|
||||||
|
let Some(chat_id) = app.selected_chat_id else {
|
||||||
|
app.error_message = Some("Чат не выбран".to_string());
|
||||||
|
return;
|
||||||
|
};
|
||||||
let Some(msg) = app.get_selected_message() else {
|
let Some(msg) = app.get_selected_message() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let chat_id = app.selected_chat_id.unwrap();
|
|
||||||
let message_id = msg.id();
|
let message_id = msg.id();
|
||||||
|
|
||||||
app.status_message = Some("Загрузка реакций...".to_string());
|
app.status_message = Some("Загрузка реакций...".to_string());
|
||||||
@@ -163,7 +168,7 @@ pub async fn edit_message<T: TdClientTrait>(
|
|||||||
{
|
{
|
||||||
Ok(mut edited_msg) => {
|
Ok(mut edited_msg) => {
|
||||||
// Сохраняем reply_to из старого сообщения (если есть)
|
// Сохраняем reply_to из старого сообщения (если есть)
|
||||||
let messages = app.td_client.current_chat_messages_mut();
|
app.td_client.update_current_chat_messages(|messages| {
|
||||||
if let Some(pos) = messages.iter().position(|m| m.id() == msg_id) {
|
if let Some(pos) = messages.iter().position(|m| m.id() == msg_id) {
|
||||||
let old_reply_to = messages[pos].interactions.reply_to.clone();
|
let old_reply_to = messages[pos].interactions.reply_to.clone();
|
||||||
// Если в старом сообщении был reply и в новом он "Unknown" - сохраняем старый
|
// Если в старом сообщении был reply и в новом он "Unknown" - сохраняем старый
|
||||||
@@ -180,6 +185,7 @@ pub async fn edit_message<T: TdClientTrait>(
|
|||||||
// Заменяем сообщение
|
// Заменяем сообщение
|
||||||
messages[pos] = edited_msg;
|
messages[pos] = edited_msg;
|
||||||
}
|
}
|
||||||
|
});
|
||||||
// Очищаем инпут и сбрасываем состояние ПОСЛЕ успешного редактирования
|
// Очищаем инпут и сбрасываем состояние ПОСЛЕ успешного редактирования
|
||||||
app.message_input.clear();
|
app.message_input.clear();
|
||||||
app.cursor_position = 0;
|
app.cursor_position = 0;
|
||||||
@@ -450,359 +456,3 @@ pub async fn handle_open_chat_keyboard_input<T: TdClientTrait>(app: &mut App<T>,
|
|||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Обработка команды ViewImage — только фото
|
|
||||||
async fn handle_view_or_play_media<T: TdClientTrait>(app: &mut App<T>) {
|
|
||||||
let Some(msg) = app.get_selected_message() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
if msg.has_photo() {
|
|
||||||
#[cfg(feature = "images")]
|
|
||||||
handle_view_image(app).await;
|
|
||||||
#[cfg(not(feature = "images"))]
|
|
||||||
{
|
|
||||||
app.status_message = Some("Просмотр изображений отключён".to_string());
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
app.status_message = Some("Сообщение не содержит фото".to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Space: play/pause toggle для голосовых сообщений
|
|
||||||
async fn handle_toggle_voice_playback<T: TdClientTrait>(app: &mut App<T>) {
|
|
||||||
use crate::tdlib::PlaybackStatus;
|
|
||||||
|
|
||||||
// Если уже есть активное воспроизведение — toggle pause/resume
|
|
||||||
if let Some(ref mut playback) = app.playback_state {
|
|
||||||
if let Some(ref player) = app.audio_player {
|
|
||||||
match playback.status {
|
|
||||||
PlaybackStatus::Playing => {
|
|
||||||
player.pause();
|
|
||||||
playback.status = PlaybackStatus::Paused;
|
|
||||||
app.last_playback_tick = None;
|
|
||||||
app.status_message = Some("⏸ Пауза".to_string());
|
|
||||||
}
|
|
||||||
PlaybackStatus::Paused => {
|
|
||||||
// Откатываем на 1 секунду для контекста
|
|
||||||
let resume_pos = (playback.position - 1.0).max(0.0);
|
|
||||||
// Перезапускаем ffplay с нужной позиции (-ss)
|
|
||||||
if player.resume_from(resume_pos).is_ok() {
|
|
||||||
playback.position = resume_pos;
|
|
||||||
} else {
|
|
||||||
// Fallback: простой SIGCONT без перемотки
|
|
||||||
player.resume();
|
|
||||||
}
|
|
||||||
playback.status = PlaybackStatus::Playing;
|
|
||||||
app.last_playback_tick = Some(Instant::now());
|
|
||||||
app.status_message = Some("▶ Воспроизведение".to_string());
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
app.needs_redraw = true;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Нет активного воспроизведения — пробуем запустить текущее голосовое
|
|
||||||
let Some(msg) = app.get_selected_message() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
if msg.has_voice() {
|
|
||||||
handle_play_voice(app).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Seek голосового сообщения на delta секунд
|
|
||||||
fn handle_voice_seek<T: TdClientTrait>(app: &mut App<T>, delta: f32) {
|
|
||||||
use crate::tdlib::PlaybackStatus;
|
|
||||||
|
|
||||||
let Some(ref mut playback) = app.playback_state else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let Some(ref player) = app.audio_player else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let was_playing = matches!(playback.status, PlaybackStatus::Playing);
|
|
||||||
let was_paused = matches!(playback.status, PlaybackStatus::Paused);
|
|
||||||
|
|
||||||
if was_playing || was_paused {
|
|
||||||
let new_position = (playback.position + delta).clamp(0.0, playback.duration);
|
|
||||||
|
|
||||||
if was_playing {
|
|
||||||
// Перезапускаем ffplay с новой позиции
|
|
||||||
if player.resume_from(new_position).is_ok() {
|
|
||||||
playback.position = new_position;
|
|
||||||
app.last_playback_tick = Some(std::time::Instant::now());
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// На паузе — только двигаем позицию, воспроизведение начнётся при resume
|
|
||||||
player.stop();
|
|
||||||
playback.position = new_position;
|
|
||||||
}
|
|
||||||
|
|
||||||
let arrow = if delta > 0.0 { "→" } else { "←" };
|
|
||||||
app.status_message = Some(format!("{} {:.0}s", arrow, new_position));
|
|
||||||
app.needs_redraw = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Обработка команды ViewImage — открыть модальное окно с фото
|
|
||||||
#[cfg(feature = "images")]
|
|
||||||
async fn handle_view_image<T: TdClientTrait>(app: &mut App<T>) {
|
|
||||||
use crate::tdlib::{ImageModalState, PhotoDownloadState};
|
|
||||||
|
|
||||||
if !app.config().images.show_images {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(msg) = app.get_selected_message() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
if !msg.has_photo() {
|
|
||||||
app.status_message = Some("Сообщение не содержит фото".to_string());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let photo = msg.photo_info().unwrap();
|
|
||||||
let msg_id = msg.id();
|
|
||||||
let file_id = photo.file_id;
|
|
||||||
let photo_width = photo.width;
|
|
||||||
let photo_height = photo.height;
|
|
||||||
let download_state = photo.download_state.clone();
|
|
||||||
|
|
||||||
match download_state {
|
|
||||||
PhotoDownloadState::Downloaded(path) => {
|
|
||||||
// Открываем модальное окно
|
|
||||||
app.image_modal = Some(ImageModalState {
|
|
||||||
message_id: msg_id,
|
|
||||||
photo_path: path,
|
|
||||||
photo_width,
|
|
||||||
photo_height,
|
|
||||||
});
|
|
||||||
app.needs_redraw = true;
|
|
||||||
}
|
|
||||||
PhotoDownloadState::NotDownloaded | PhotoDownloadState::Downloading => {
|
|
||||||
// Запоминаем намерение открыть модалку — откроется когда загрузится
|
|
||||||
app.pending_image_open = Some(crate::app::PendingImageOpen {
|
|
||||||
file_id,
|
|
||||||
message_id: msg_id,
|
|
||||||
photo_width,
|
|
||||||
photo_height,
|
|
||||||
});
|
|
||||||
app.status_message = Some("Загрузка фото...".to_string());
|
|
||||||
app.needs_redraw = true;
|
|
||||||
|
|
||||||
// Если нет активной фоновой загрузки — запускаем свою
|
|
||||||
if app.photo_download_rx.is_none() {
|
|
||||||
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
|
||||||
app.photo_download_rx = Some(rx);
|
|
||||||
let client_id = app.td_client.client_id();
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let result = tokio::time::timeout(Duration::from_secs(30), async {
|
|
||||||
match tdlib_rs::functions::download_file(file_id, 1, 0, 0, true, client_id)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(tdlib_rs::enums::File::File(f))
|
|
||||||
if f.local.is_downloading_completed && !f.local.path.is_empty() =>
|
|
||||||
{
|
|
||||||
Ok(f.local.path)
|
|
||||||
}
|
|
||||||
Ok(_) => Err("Файл не скачан".to_string()),
|
|
||||||
Err(e) => Err(format!("{:?}", e)),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
let result = result.unwrap_or_else(|_| Err("Таймаут загрузки".to_string()));
|
|
||||||
let _ = tx.send((file_id, result));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
PhotoDownloadState::Error(_) => {
|
|
||||||
// Повторная попытка загрузки
|
|
||||||
app.status_message = Some("Повторная загрузка фото...".to_string());
|
|
||||||
app.needs_redraw = true;
|
|
||||||
match app.td_client.download_file(file_id).await {
|
|
||||||
Ok(path) => {
|
|
||||||
for msg in app.td_client.current_chat_messages_mut() {
|
|
||||||
if let Some(photo) = msg.photo_info_mut() {
|
|
||||||
if photo.file_id == file_id {
|
|
||||||
photo.download_state = PhotoDownloadState::Downloaded(path.clone());
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
app.image_modal = Some(ImageModalState {
|
|
||||||
message_id: msg_id,
|
|
||||||
photo_path: path,
|
|
||||||
photo_width,
|
|
||||||
photo_height,
|
|
||||||
});
|
|
||||||
app.status_message = None;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
app.error_message = Some(format!("Ошибка загрузки фото: {}", e));
|
|
||||||
app.status_message = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Вспомогательная функция для воспроизведения из конкретного пути
|
|
||||||
async fn handle_play_voice_from_path<T: TdClientTrait>(
|
|
||||||
app: &mut App<T>,
|
|
||||||
path: &str,
|
|
||||||
voice: &crate::tdlib::VoiceInfo,
|
|
||||||
msg: &crate::tdlib::MessageInfo,
|
|
||||||
) {
|
|
||||||
use crate::tdlib::{PlaybackState, PlaybackStatus};
|
|
||||||
|
|
||||||
if let Some(ref player) = app.audio_player {
|
|
||||||
match player.play(path) {
|
|
||||||
Ok(_) => {
|
|
||||||
app.playback_state = Some(PlaybackState {
|
|
||||||
message_id: msg.id(),
|
|
||||||
status: PlaybackStatus::Playing,
|
|
||||||
position: 0.0,
|
|
||||||
duration: voice.duration as f32,
|
|
||||||
volume: player.volume(),
|
|
||||||
});
|
|
||||||
app.last_playback_tick = Some(Instant::now());
|
|
||||||
app.status_message = Some(format!("▶ Воспроизведение ({:.0}s)", voice.duration));
|
|
||||||
app.needs_redraw = true;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
app.error_message = Some(format!("Ошибка воспроизведения: {}", e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
app.error_message = Some("Аудиоплеер не инициализирован".to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Воспроизведение голосового сообщения
|
|
||||||
async fn handle_play_voice<T: TdClientTrait>(app: &mut App<T>) {
|
|
||||||
use crate::tdlib::VoiceDownloadState;
|
|
||||||
|
|
||||||
let Some(msg) = app.get_selected_message() else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
if !msg.has_voice() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let voice = msg.voice_info().unwrap();
|
|
||||||
let file_id = voice.file_id;
|
|
||||||
|
|
||||||
match &voice.download_state {
|
|
||||||
VoiceDownloadState::Downloaded(path) => {
|
|
||||||
// TDLib может вернуть путь без расширения — ищем файл с .oga
|
|
||||||
use std::path::Path;
|
|
||||||
let audio_path = if Path::new(path).exists() {
|
|
||||||
path.clone()
|
|
||||||
} else {
|
|
||||||
// Пробуем добавить .oga
|
|
||||||
let with_oga = format!("{}.oga", path);
|
|
||||||
if Path::new(&with_oga).exists() {
|
|
||||||
with_oga
|
|
||||||
} else {
|
|
||||||
// Пробуем найти файл с похожим именем в той же папке
|
|
||||||
if let Some(parent) = Path::new(path).parent() {
|
|
||||||
if let Some(stem) = Path::new(path).file_name() {
|
|
||||||
if let Ok(entries) = std::fs::read_dir(parent) {
|
|
||||||
for entry in entries.flatten() {
|
|
||||||
let entry_name = entry.file_name();
|
|
||||||
if entry_name
|
|
||||||
.to_string_lossy()
|
|
||||||
.starts_with(&stem.to_string_lossy().to_string())
|
|
||||||
{
|
|
||||||
let found_path = entry.path().to_string_lossy().to_string();
|
|
||||||
// Кэшируем найденный файл
|
|
||||||
if let Some(ref mut cache) = app.voice_cache {
|
|
||||||
let _ = cache.store(
|
|
||||||
&file_id.to_string(),
|
|
||||||
Path::new(&found_path),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return handle_play_voice_from_path(
|
|
||||||
app,
|
|
||||||
&found_path,
|
|
||||||
voice,
|
|
||||||
&msg,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
app.error_message = Some(format!("Файл не найден: {}", path));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Кэшируем файл если ещё не в кэше
|
|
||||||
if let Some(ref mut cache) = app.voice_cache {
|
|
||||||
let _ = cache.store(&file_id.to_string(), Path::new(&audio_path));
|
|
||||||
}
|
|
||||||
|
|
||||||
handle_play_voice_from_path(app, &audio_path, voice, &msg).await;
|
|
||||||
}
|
|
||||||
VoiceDownloadState::Downloading => {
|
|
||||||
app.status_message = Some("Загрузка голосового...".to_string());
|
|
||||||
}
|
|
||||||
VoiceDownloadState::NotDownloaded => {
|
|
||||||
// Проверяем кэш перед загрузкой
|
|
||||||
let cache_key = file_id.to_string();
|
|
||||||
if let Some(cached_path) = app.voice_cache.as_mut().and_then(|c| c.get(&cache_key)) {
|
|
||||||
let path_str = cached_path.to_string_lossy().to_string();
|
|
||||||
handle_play_voice_from_path(app, &path_str, voice, &msg).await;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Начинаем загрузку
|
|
||||||
app.status_message = Some("Загрузка голосового...".to_string());
|
|
||||||
match app.td_client.download_voice_note(file_id).await {
|
|
||||||
Ok(path) => {
|
|
||||||
// Кэшируем загруженный файл
|
|
||||||
if let Some(ref mut cache) = app.voice_cache {
|
|
||||||
let _ = cache.store(&cache_key, std::path::Path::new(&path));
|
|
||||||
}
|
|
||||||
|
|
||||||
handle_play_voice_from_path(app, &path, voice, &msg).await;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
app.error_message = Some(format!("Ошибка загрузки: {}", e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
VoiceDownloadState::Error(e) => {
|
|
||||||
app.error_message = Some(format!("Ошибка загрузки: {}", e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO (Этап 4): Эти функции будут переписаны для модального просмотрщика
|
|
||||||
/*
|
|
||||||
#[cfg(feature = "images")]
|
|
||||||
fn collapse_photo<T: TdClientTrait>(app: &mut App<T>, msg_id: crate::types::MessageId) {
|
|
||||||
// Закомментировано - будет реализовано в Этапе 4
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "images")]
|
|
||||||
fn expand_photo<T: TdClientTrait>(app: &mut App<T>, msg_id: crate::types::MessageId, path: &str) {
|
|
||||||
// Закомментировано - будет реализовано в Этапе 4
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
||||||
// TODO (Этап 4): Функция _download_and_expand будет переписана
|
|
||||||
/*
|
|
||||||
#[cfg(feature = "images")]
|
|
||||||
async fn _download_and_expand<T: TdClientTrait>(app: &mut App<T>, msg_id: crate::types::MessageId, file_id: i32) {
|
|
||||||
// Закомментировано - будет реализовано в Этапе 4
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
328
crates/tele-tui/src/input/handlers/chat/media.rs
Normal file
328
crates/tele-tui/src/input/handlers/chat/media.rs
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
//! Media actions for the open chat input handler.
|
||||||
|
|
||||||
|
use crate::app::methods::messages::MessageMethods;
|
||||||
|
use crate::app::App;
|
||||||
|
use crate::tdlib::TdClientTrait;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
/// Обработка команды ViewImage — только фото.
|
||||||
|
pub(super) async fn handle_view_or_play_media<T: TdClientTrait>(app: &mut App<T>) {
|
||||||
|
let Some(msg) = app.get_selected_message() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if msg.has_photo() {
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
handle_view_image(app).await;
|
||||||
|
#[cfg(not(feature = "images"))]
|
||||||
|
{
|
||||||
|
app.status_message = Some("Просмотр изображений отключён".to_string());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
app.status_message = Some("Сообщение не содержит фото".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Space: play/pause toggle для голосовых сообщений.
|
||||||
|
pub(super) async fn handle_toggle_voice_playback<T: TdClientTrait>(app: &mut App<T>) {
|
||||||
|
use crate::tdlib::PlaybackStatus;
|
||||||
|
|
||||||
|
if let Some(ref mut playback) = app.playback_state {
|
||||||
|
if let Some(ref player) = app.audio_player {
|
||||||
|
match playback.status {
|
||||||
|
PlaybackStatus::Playing => {
|
||||||
|
player.pause();
|
||||||
|
playback.status = PlaybackStatus::Paused;
|
||||||
|
app.last_playback_tick = None;
|
||||||
|
app.status_message = Some("⏸ Пауза".to_string());
|
||||||
|
}
|
||||||
|
PlaybackStatus::Paused => {
|
||||||
|
let resume_pos = (playback.position - 1.0).max(0.0);
|
||||||
|
if player.resume_from(resume_pos).is_ok() {
|
||||||
|
playback.position = resume_pos;
|
||||||
|
} else {
|
||||||
|
player.resume();
|
||||||
|
}
|
||||||
|
playback.status = PlaybackStatus::Playing;
|
||||||
|
app.last_playback_tick = Some(Instant::now());
|
||||||
|
app.status_message = Some("▶ Воспроизведение".to_string());
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
app.needs_redraw = true;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(msg) = app.get_selected_message() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if msg.has_voice() {
|
||||||
|
handle_play_voice(app).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seek голосового сообщения на delta секунд.
|
||||||
|
pub(super) fn handle_voice_seek<T: TdClientTrait>(app: &mut App<T>, delta: f32) {
|
||||||
|
use crate::tdlib::PlaybackStatus;
|
||||||
|
|
||||||
|
let Some(ref mut playback) = app.playback_state else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(ref player) = app.audio_player else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let was_playing = matches!(playback.status, PlaybackStatus::Playing);
|
||||||
|
let was_paused = matches!(playback.status, PlaybackStatus::Paused);
|
||||||
|
|
||||||
|
if was_playing || was_paused {
|
||||||
|
let new_position = (playback.position + delta).clamp(0.0, playback.duration);
|
||||||
|
|
||||||
|
if was_playing {
|
||||||
|
if player.resume_from(new_position).is_ok() {
|
||||||
|
playback.position = new_position;
|
||||||
|
app.last_playback_tick = Some(Instant::now());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
player.stop();
|
||||||
|
playback.position = new_position;
|
||||||
|
}
|
||||||
|
|
||||||
|
let arrow = if delta > 0.0 { "→" } else { "←" };
|
||||||
|
app.status_message = Some(format!("{} {:.0}s", arrow, new_position));
|
||||||
|
app.needs_redraw = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
async fn handle_view_image<T: TdClientTrait>(app: &mut App<T>) {
|
||||||
|
use crate::tdlib::{ImageModalState, PhotoDownloadState};
|
||||||
|
|
||||||
|
if !app.config().images.show_images {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(msg) = app.get_selected_message() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if !msg.has_photo() {
|
||||||
|
app.status_message = Some("Сообщение не содержит фото".to_string());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(photo) = msg.photo_info() else {
|
||||||
|
app.status_message = Some("Сообщение не содержит фото".to_string());
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let msg_id = msg.id();
|
||||||
|
let file_id = photo.file_id;
|
||||||
|
let photo_width = photo.width;
|
||||||
|
let photo_height = photo.height;
|
||||||
|
let download_state = photo.download_state.clone();
|
||||||
|
|
||||||
|
match download_state {
|
||||||
|
PhotoDownloadState::Downloaded(path) => {
|
||||||
|
app.image_modal = Some(ImageModalState {
|
||||||
|
message_id: msg_id,
|
||||||
|
photo_path: path,
|
||||||
|
photo_width,
|
||||||
|
photo_height,
|
||||||
|
});
|
||||||
|
app.needs_redraw = true;
|
||||||
|
}
|
||||||
|
PhotoDownloadState::NotDownloaded | PhotoDownloadState::Downloading => {
|
||||||
|
app.pending_image_open = Some(crate::app::PendingImageOpen {
|
||||||
|
file_id,
|
||||||
|
message_id: msg_id,
|
||||||
|
photo_width,
|
||||||
|
photo_height,
|
||||||
|
});
|
||||||
|
app.status_message = Some("Загрузка фото...".to_string());
|
||||||
|
app.needs_redraw = true;
|
||||||
|
|
||||||
|
if app.photo_download_rx.is_none() {
|
||||||
|
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||||
|
app.photo_download_rx = Some(rx);
|
||||||
|
let client_id = app.td_client.client_id();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let result = tokio::time::timeout(Duration::from_secs(30), async {
|
||||||
|
match tdlib_rs::functions::download_file(file_id, 1, 0, 0, true, client_id)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(tdlib_rs::enums::File::File(f))
|
||||||
|
if f.local.is_downloading_completed && !f.local.path.is_empty() =>
|
||||||
|
{
|
||||||
|
Ok(f.local.path)
|
||||||
|
}
|
||||||
|
Ok(_) => Err("Файл не скачан".to_string()),
|
||||||
|
Err(e) => Err(format!("{:?}", e)),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
let result = result.unwrap_or_else(|_| Err("Таймаут загрузки".to_string()));
|
||||||
|
let _ = tx.send((file_id, result));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PhotoDownloadState::Error(_) => {
|
||||||
|
app.status_message = Some("Повторная загрузка фото...".to_string());
|
||||||
|
app.needs_redraw = true;
|
||||||
|
match app.td_client.download_file(file_id).await {
|
||||||
|
Ok(path) => {
|
||||||
|
app.td_client.update_current_chat_messages(|messages| {
|
||||||
|
for msg in messages {
|
||||||
|
if let Some(photo) = msg.photo_info_mut() {
|
||||||
|
if photo.file_id == file_id {
|
||||||
|
photo.download_state =
|
||||||
|
PhotoDownloadState::Downloaded(path.clone());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
app.image_modal = Some(ImageModalState {
|
||||||
|
message_id: msg_id,
|
||||||
|
photo_path: path,
|
||||||
|
photo_width,
|
||||||
|
photo_height,
|
||||||
|
});
|
||||||
|
app.status_message = None;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
app.error_message = Some(format!("Ошибка загрузки фото: {}", e));
|
||||||
|
app.status_message = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_play_voice_from_path<T: TdClientTrait>(
|
||||||
|
app: &mut App<T>,
|
||||||
|
path: &str,
|
||||||
|
voice: &crate::tdlib::VoiceInfo,
|
||||||
|
msg: &crate::tdlib::MessageInfo,
|
||||||
|
) {
|
||||||
|
use crate::tdlib::{PlaybackState, PlaybackStatus};
|
||||||
|
|
||||||
|
if let Some(ref player) = app.audio_player {
|
||||||
|
match player.play(path) {
|
||||||
|
Ok(_) => {
|
||||||
|
app.playback_state = Some(PlaybackState {
|
||||||
|
message_id: msg.id(),
|
||||||
|
status: PlaybackStatus::Playing,
|
||||||
|
position: 0.0,
|
||||||
|
duration: voice.duration as f32,
|
||||||
|
volume: player.volume(),
|
||||||
|
});
|
||||||
|
app.last_playback_tick = Some(Instant::now());
|
||||||
|
app.status_message = Some(format!("▶ Воспроизведение ({:.0}s)", voice.duration));
|
||||||
|
app.needs_redraw = true;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
app.error_message = Some(format!("Ошибка воспроизведения: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
app.error_message = Some("Аудиоплеер не инициализирован".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_play_voice<T: TdClientTrait>(app: &mut App<T>) {
|
||||||
|
use crate::tdlib::VoiceDownloadState;
|
||||||
|
|
||||||
|
let Some(msg) = app.get_selected_message() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if !msg.has_voice() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(voice) = msg.voice_info() else {
|
||||||
|
app.status_message = Some("Сообщение не содержит голосовое".to_string());
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let file_id = voice.file_id;
|
||||||
|
|
||||||
|
match &voice.download_state {
|
||||||
|
VoiceDownloadState::Downloaded(path) => {
|
||||||
|
use std::path::Path;
|
||||||
|
let audio_path = if Path::new(path).exists() {
|
||||||
|
path.clone()
|
||||||
|
} else {
|
||||||
|
let with_oga = format!("{}.oga", path);
|
||||||
|
if Path::new(&with_oga).exists() {
|
||||||
|
with_oga
|
||||||
|
} else {
|
||||||
|
if let Some(parent) = Path::new(path).parent() {
|
||||||
|
if let Some(stem) = Path::new(path).file_name() {
|
||||||
|
if let Ok(entries) = std::fs::read_dir(parent) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let entry_name = entry.file_name();
|
||||||
|
if entry_name
|
||||||
|
.to_string_lossy()
|
||||||
|
.starts_with(&stem.to_string_lossy().to_string())
|
||||||
|
{
|
||||||
|
let found_path = entry.path().to_string_lossy().to_string();
|
||||||
|
if let Some(ref mut cache) = app.voice_cache {
|
||||||
|
let _ = cache.store(
|
||||||
|
&file_id.to_string(),
|
||||||
|
Path::new(&found_path),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return handle_play_voice_from_path(
|
||||||
|
app,
|
||||||
|
&found_path,
|
||||||
|
voice,
|
||||||
|
&msg,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app.error_message = Some(format!("Файл не найден: {}", path));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(ref mut cache) = app.voice_cache {
|
||||||
|
let _ = cache.store(&file_id.to_string(), Path::new(&audio_path));
|
||||||
|
}
|
||||||
|
|
||||||
|
handle_play_voice_from_path(app, &audio_path, voice, &msg).await;
|
||||||
|
}
|
||||||
|
VoiceDownloadState::Downloading => {
|
||||||
|
app.status_message = Some("Загрузка голосового...".to_string());
|
||||||
|
}
|
||||||
|
VoiceDownloadState::NotDownloaded => {
|
||||||
|
let cache_key = file_id.to_string();
|
||||||
|
if let Some(cached_path) = app.voice_cache.as_mut().and_then(|c| c.get(&cache_key)) {
|
||||||
|
let path_str = cached_path.to_string_lossy().to_string();
|
||||||
|
handle_play_voice_from_path(app, &path_str, voice, &msg).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
app.status_message = Some("Загрузка голосового...".to_string());
|
||||||
|
match app.td_client.download_voice_note(file_id).await {
|
||||||
|
Ok(path) => {
|
||||||
|
if let Some(ref mut cache) = app.voice_cache {
|
||||||
|
let _ = cache.store(&cache_key, std::path::Path::new(&path));
|
||||||
|
}
|
||||||
|
|
||||||
|
handle_play_voice_from_path(app, &path, voice, &msg).await;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
app.error_message = Some(format!("Ошибка загрузки: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
VoiceDownloadState::Error(e) => {
|
||||||
|
app.error_message = Some(format!("Ошибка загрузки: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -74,4 +74,3 @@ pub async fn select_folder<T: TdClientTrait>(app: &mut App<T>, folder_idx: usize
|
|||||||
app.chat_list_state.select(Some(0));
|
app.chat_list_state.select(Some(0));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,7 +228,8 @@ pub fn process_chat_init_events<T: TdClientTrait>(app: &mut App<T>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut changed = false;
|
let mut changed = false;
|
||||||
for msg in app.td_client.current_chat_messages_mut() {
|
app.td_client.update_current_chat_messages(|messages| {
|
||||||
|
for msg in messages {
|
||||||
let Some(reply) = msg.interactions.reply_to.as_mut() else {
|
let Some(reply) = msg.interactions.reply_to.as_mut() else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
@@ -238,6 +239,7 @@ pub fn process_chat_init_events<T: TdClientTrait>(app: &mut App<T>) {
|
|||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if changed {
|
if changed {
|
||||||
app.needs_redraw = true;
|
app.needs_redraw = true;
|
||||||
@@ -286,7 +288,8 @@ pub async fn load_older_messages_if_needed<T: TdClientTrait>(app: &mut App<T>) {
|
|||||||
|
|
||||||
// Add older messages to the beginning if any were loaded
|
// Add older messages to the beginning if any were loaded
|
||||||
if !older.is_empty() {
|
if !older.is_empty() {
|
||||||
let msgs = app.td_client.current_chat_messages_mut();
|
app.td_client.update_current_chat_messages(|messages| {
|
||||||
msgs.splice(0..0, older);
|
messages.splice(0..0, older);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user