Compare commits

..

5 Commits

Author SHA1 Message Date
Mikhail Kilin
e1bceada6d fixes 2026-01-20 00:57:28 +03:00
Mikhail Kilin
dfce86d3db fixes 2026-01-18 21:37:42 +03:00
Mikhail Kilin
7540a30e06 fixes 2026-01-18 19:52:44 +03:00
Mikhail Kilin
2edbc33afb readme fix 2026-01-18 14:55:43 +03:00
Mikhail Kilin
060170923e fixes 2026-01-18 14:49:31 +03:00
324 changed files with 2303 additions and 66512 deletions

View File

@@ -1,37 +0,0 @@
# EditorConfig is awesome: https://EditorConfig.org
# top-most EditorConfig file
root = true
# Unix-style newlines with a newline ending every file
[*]
end_of_line = lf
insert_final_newline = true
charset = utf-8
trim_trailing_whitespace = true
# Rust files
[*.rs]
indent_style = space
indent_size = 4
# TOML files
[*.toml]
indent_style = space
indent_size = 2
# Markdown files
[*.md]
indent_style = space
indent_size = 2
trim_trailing_whitespace = false
# YAML files
[*.{yml,yaml}]
indent_style = space
indent_size = 2
# JSON files
[*.json]
indent_style = space
indent_size = 2

View File

@@ -1,44 +0,0 @@
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

15
.gitignore vendored
View File

@@ -1,22 +1,7 @@
/target
/.build
/build
/apps/ios/TeleTuiIOS/BinaryArtifacts
/apps/ios/TeleTuiIOS/Generated
# TDLib session data (contains auth tokens - NEVER commit!)
/tdlib_data/
# Environment variables (contains API keys)
.env
.DS_Store
# Local config files (if created in project root)
config.toml
credentials
# Insta snapshot testing
# Commit snapshots, but not the .new files
tests/**/*.snap.new
*.snap.new
apps/ios/TeleTuiIOS/.build/

1
.serena/.gitignore vendored
View File

@@ -1 +0,0 @@
/cache

View File

@@ -1,25 +0,0 @@
# Code Style and Conventions
## Rust Style
- Следовать стандартному Rust стилю (rustfmt)
- Snake_case для переменных и функций
- PascalCase для типов и enum вариантов
- SCREAMING_SNAKE_CASE для констант
## Project Conventions
- Использовать `Result<T, String>` для ошибок (планируется заменить на `Result<T>` с кастомным enum)
- Async/await для TDLib операций
- Группировать imports: std → external crates → local modules
- Константы вынесены в `src/constants.rs`
## Architecture Patterns
- Модульная структура: app, ui, input, tdlib, utils
- TdClient разделён на подмодули: auth, chats, messages, users, reactions
- ChatState enum для состояний чата (type-safe)
- Snapshot тесты для UI компонентов
- Integration тесты для business logic
## Documentation
- Комментарии на русском в коде (для логики)
- Doc-комментарии на английском (для публичного API)
- CLAUDE.md, CONTEXT.md, ROADMAP.md для документации проекта

View File

@@ -1,28 +0,0 @@
# Telegram TUI - Project Overview
## Purpose
TUI (Text User Interface) клиент для Telegram с vim-style навигацией.
## Tech Stack
- **Language**: Rust
- **TUI Framework**: ratatui 0.29 + crossterm 0.28
- **Telegram**: tdlib-rs 1.1 (с автоматической загрузкой TDLib)
- **Async Runtime**: tokio (full features)
- **Config**: toml 0.8, dirs 5.0
- **Other**: chrono 0.4, clipboard 0.5, serde/serde_json 1.0
## Current Status
- Фаза 9 завершена (100%)
- Все основные фичи реализованы
- 148/151 тестов (98% покрытие)
- Рефакторинг: Priority 1 завершён, Priority 2 на 40%
## Key Features
- TDLib интеграция с авторизацией
- Список чатов с папками, фильтрацией
- Отправка/редактирование/удаление сообщений
- Reply, Forward, Реакции
- Markdown форматирование
- Поиск по чатам и сообщениям
- Typing indicator, online статусы
- Конфигурационный файл с цветами и timezone

View File

@@ -1,37 +0,0 @@
# Suggested Commands
## Building and Running
**ВАЖНО**: НИКОГДА не запускать самостоятельно! Всегда просить пользователя!
```bash
# Пользователь должен запустить:
cargo run
cargo build
cargo build --release
```
## Testing
```bash
cargo test # Запустить все тесты
cargo test --lib # Только библиотечные тесты
cargo test <test_name> # Конкретный тест
```
## Code Quality
```bash
cargo clippy # Линтер
cargo fmt # Форматирование
cargo check # Быстрая проверка компиляции
```
## Development Workflow
1. Сделать изменения
2. `cargo check` - быстрая проверка
3. `cargo test` - запустить тесты
4. `cargo clippy` - проверить предупреждения
5. `cargo fmt` - отформатировать код
6. Попросить пользователя запустить `cargo run` для ручной проверки
## macOS Specific
- Система: Darwin
- Стандартные Unix команды работают (ls, grep, find, cd, etc.)

View File

@@ -1,39 +0,0 @@
# Task Completion Checklist
## После завершения задачи:
### 1. Проверка кода
- [ ] `cargo check` - компиляция без ошибок
- [ ] `cargo clippy` - нет новых предупреждений
- [ ] `cargo fmt` - код отформатирован
- [ ] `cargo test` - все тесты проходят
### 2. Ручное тестирование
- [ ] Описать сценарии для проверки
- [ ] Попросить пользователя запустить `cargo run`
- [ ] Дождаться фидбека от пользователя
### 3. Документация
- [ ] Обновить CONTEXT.md (секция "Последние обновления")
- [ ] Добавить в CONTEXT.md что сделано
- [ ] Если нужно - обновить ROADMAP.md
### 4. Git (только по запросу пользователя)
- [ ] НИКОГДА не добавлять себя в Co-Authored-By
- [ ] Создавать коммит только если пользователь попросил
## Формат сообщения пользователю
```
Готово! Проверь, пожалуйста:
1. [Конкретный сценарий проверки]
2. [Что должно произойти]
3. [На что обратить внимание]
Напиши, если что-то не работает.
```
## Важно
- Работать поэтапно (один этап = одна логическая единица)
- После каждого этапа давать сценарий проверки
- Не делать сразу много изменений

View File

@@ -1,140 +0,0 @@
# list of languages for which language servers are started; choose from:
# al angular ansible bash clojure
# cpp cpp_ccls crystal csharp csharp_omnisharp
# dart elixir elm erlang fortran
# fsharp go groovy haskell haxe
# hlsl html java json julia
# kotlin lean4 lua luau markdown
# matlab msl nix ocaml pascal
# perl php php_phpactor powershell python
# python_jedi python_ty r rego ruby
# ruby_solargraph rust scala scss solidity
# swift systemverilog terraform toml typescript
# typescript_vts vue yaml zig
# (This list may be outdated. For the current list, see values of Language enum here:
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
# Note:
# - For C, use cpp
# - For JavaScript, use typescript
# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root)
# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three)
# - For Free Pascal/Lazarus, use pascal
# Special requirements:
# Some languages require additional setup/installations.
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
# When using multiple languages, the first language server that supports a given file will be used for that file.
# The first language is the default language and the respective language server will be used as a fallback.
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
languages:
- rust
# the encoding used by text files in the project
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
encoding: "utf-8"
# whether to use project's .gitignore files to ignore files
ignore_all_files_in_gitignore: true
# list of additional paths to ignore in this project.
# Same syntax as gitignore, so you can use * and **.
# Note: global ignored_paths from serena_config.yml are also applied additively.
ignored_paths: []
# whether the project is in read-only mode
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
# Added on 2025-04-18
read_only: false
# list of tool names to exclude.
# This extends the existing exclusions (e.g. from the global configuration)
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
excluded_tools: []
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ""
# the name by which the project can be referenced within Serena
project_name: "tele-tui"
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
# This extends the existing inclusions (e.g. from the global configuration).
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
included_optional_tools: []
# list of mode names to that are always to be included in the set of active modes
# The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this setting overrides the global configuration.
# Set this to [] to disable base modes for this project.
# Set this to a list of mode names to always include the respective modes for this project.
base_modes:
# list of mode names that are to be activated by default, overriding the setting in the global configuration.
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply
# for this project.
# This setting can, in turn, be overridden by CLI parameters (--mode).
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
default_modes:
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
fixed_tools: []
# time budget (seconds) per tool call for the retrieval of additional symbol information
# such as docstrings or parameter information.
# This overrides the corresponding setting in the global configuration; see the documentation there.
# If null or missing, use the setting from the global configuration.
symbol_info_budget:
# The language backend to use for this project.
# If not set, the global setting from serena_config.yml is used.
# Valid values: LSP, JetBrains
# Note: the backend is fixed at startup. If a project with a different backend
# is activated post-init, an error will be returned.
language_backend:
# list of regex patterns which, when matched, mark a memory entry as readonly.
# Extends the list from the global configuration, merging the two lists.
read_only_memory_patterns: []
# line ending convention to use when writing source files.
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
line_ending:
# list of regex patterns for memories to completely ignore.
# Matching memories will not appear in list_memories or activate_project output
# and cannot be accessed via read_memory or write_memory.
# To access ignored memory files, use the read_file tool on the raw file path.
# Extends the list from the global configuration, merging the two lists.
# Example: ["_archive/.*", "_episodes/.*"]
ignored_memory_patterns: []
# advanced configuration option allowing to configure language server-specific options.
# Maps the language key to the options.
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
# No documentation on options means no options are available.
ls_specific_settings: {}
# list of mode names to be activated additionally for this project, e.g. ["query-projects"]
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
added_modes:
# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos).
# Paths can be absolute or relative to the project root.
# Each folder is registered as an LSP workspace folder, enabling language servers to discover
# symbols and references across package boundaries.
# Currently supported for: TypeScript.
# Example:
# additional_workspace_folders:
# - ../sibling-package
# - ../shared-lib
additional_workspace_folders: []

View File

@@ -1,34 +0,0 @@
when:
- event: pull_request
steps:
- name: fmt
image: rust:latest
commands:
- rustup component add rustfmt
- 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
image: rust:latest
environment:
CARGO_HOME: /tmp/cargo
commands:
- apt-get update -qq && apt-get install -y -qq pkg-config libssl-dev libdbus-1-dev zlib1g-dev > /dev/null 2>&1
- rustup component add clippy
- cargo clippy --all-targets --all-features -- -D warnings
- name: test
image: rust:latest
environment:
CARGO_HOME: /tmp/cargo
commands:
- apt-get update -qq && apt-get install -y -qq pkg-config libssl-dev libdbus-1-dev zlib1g-dev > /dev/null 2>&1
- cargo test --all-features

View File

@@ -1,18 +0,0 @@
# tele-tui: правила для агента
Проект: TUI-клиент Telegram на Rust.
## Читать перед работой
1. [DEVELOPMENT.md](DEVELOPMENT.md) - обязательные правила локальной работы.
2. [CONTEXT.md](CONTEXT.md) - текущий статус и риски.
3. [docs/PROJECT_STRUCTURE.md](docs/PROJECT_STRUCTURE.md) - если нужна навигация по коду.
4. [docs/HOTKEYS.md](docs/HOTKEYS.md) - перед изменением ввода, режимов или keybindings.
## Правила
- Не запускай `cargo run`, `cargo build`, `cargo test`, `cargo check` без прямой команды пользователя.
- Не коммить изменения, пока пользователь не попросит.
- Если пользователь попросил тесты/коммит/план до конца, используй quality gate из [DEVELOPMENT.md](DEVELOPMENT.md).
- После функциональной правки дай короткий ручной сценарий проверки.
- Обновляй [CONTEXT.md](CONTEXT.md), только если изменились статус, риск, архитектурное решение или следующий шаг.

29
CLAUDE.md Normal file
View File

@@ -0,0 +1,29 @@
# Telegram TUI
## Prompt
Проект - TUI интерфейс для телеграмма
Порядок чтения:
1) DEVELOPMENT.md - правило работы (обязательно)
2) CONTEXT.md - текущий статус
3) ROADMAP.md - план и задачи
4) REQUIREMENTS.md / ARCHITECTURE.md - по необходимости
5) E2E_TESTS.md - перед написанием тестов
После работы обнови CONTEXT.md файл
После прочтения скажи "Жду инструкций"
---
## Важные файлы
- [DEVELOPMENT.md](DEVELOPMENT.md) — **читай первым!** Правила локальной разработки
- [CONTEXT.md](CONTEXT.md) — текущий статус, что сделано
- [ROADMAP.md](ROADMAP.md) — план разработки, задачи по фазам
- [REQUIREMENTS.md](REQUIREMENTS.md) — требования к продукту
- [ARCHITECTURE.md](ARCHITECTURE.md) — C4, sequence diagrams, API контракты, UI прототипы
- [E2E_TESTING.md](E2E_TESTING.md) — **читай перед написанием тестов!** Гайд по e2e тестированию

View File

@@ -1,52 +1,80 @@
# Контекст проекта
# Текущий контекст проекта
## Текущий статус
## Статус: Базовая интеграция с TDLib работает
Фаза 14: мультиаккаунт, в работе.
### Что сделано
Цель фазы: безопасные профили Telegram-аккаунтов, переключение аккаунтов и подготовка к более быстрому multi-client UX.
#### TDLib интеграция
- Подключена библиотека `tdlib-rs` v1.1 с автоматической загрузкой TDLib
- Реализована авторизация через телефон + код + 2FA пароль
- Сессия сохраняется автоматически в папке `tdlib_data/`
- Отключены логи TDLib через FFI вызов `td_execute` до создания клиента
- Updates обрабатываются в отдельном потоке через `mpsc` канал (неблокирующе)
## Уже сделано
#### Функциональность
- Загрузка списка чатов (до 50 штук)
- Отображение названия чата и счётчика непрочитанных
- Загрузка истории сообщений при открытии чата
- Отображение сообщений с именем отправителя и временем
- Профили аккаунтов: `AccountProfile`, `accounts.toml`, XDG data paths.
- Миграция старого `tdlib_data/` в per-account directory.
- CLI `--account <name>`.
- Account switcher modal по `Ctrl+A`: выбор и добавление аккаунта.
- Single-client переключение через `recreate_client()`.
- Footer показывает имя аккаунта, если он не `default`.
- Per-account lock file защищает TDLib database от двух процессов.
- TDLib receive loop передаёт `client_id`; UI применяет updates только активного клиента.
- `pending_chat_init` не должен блокировать первый redraw; reply-info и photo downloads уходят в фоновые tasks.
- Keybindings стали детерминированными; русская vim-раскладка: `h/j/k/l` -> `р/о/л/д`.
- `AudioPlayer` проверяет наличие `ffplay`.
- `message_grouping` группирует альбомы без клонирования сообщений.
- TDLib facade split на scoped traits; generic код больше не получает raw `*_mut` доступ к сообщениям.
- Локальный `build.rs` удалён: линковкой TDLib управляет зависимость `tdlib-rs`, `cargo check --all-targets --all-features` снова воспроизводим.
#### Управление
- `j/k` или стрелки — навигация по списку чатов
- `д/л` — русская раскладка для j/k
- `Enter` — открыть выбранный чат
- `Esc` — закрыть открытый чат
- `Ctrl+k` — перейти к первому чату
- `Ctrl+R` — обновить список чатов
- `Ctrl+C` — выход
## Осталось
### Структура проекта
- Быстрые hotkeys `Ctrl+1`..`Ctrl+9` для аккаунтов без модалки.
- Удаление/переименование аккаунта в account switcher.
- Бейджи непрочитанных с других аккаунтов.
- Решить, нужен ли переход от single-client reinit к одновременным клиентам.
- Добавить/уточнить tests для accounts + TDLib update routing.
```
src/
├── main.rs # Точка входа, UI рендеринг, event loop
├── tdlib/
│ ├── mod.rs # Модуль экспорта
│ └── client.rs # TdClient: авторизация, загрузка чатов, сообщений
```
## Риски
### Ключевые решения
- Multi-account код должен фильтровать TDLib updates по `client_id`.
- Инициализация чата и фоновые downloads не должны блокировать первый redraw.
- Read/unread статус исходящих сообщений зависит от корректной TDLib metadata.
- Конфликтующие keybindings должны оставаться детерминированными.
- Переключение аккаунтов требует проверки lock release/acquire и auth flow.
1. **Неблокирующий receive**: TDLib updates приходят в отдельном потоке и передаются в main loop через `mpsc::channel`. Это позволяет UI оставаться отзывчивым.
## Ключевые решения
2. **FFI для логов**: Используем прямой вызов `td_execute` для отключения логов синхронно, до создания клиента, чтобы избежать вывода в терминал.
- Главный 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.
- UI рендерится только при `needs_redraw`; текстовый интерфейс целится в 60 FPS.
- Фото под feature `images`: inline Halfblocks + modal iTerm2/Sixel.
- Голосовые сообщения проигрываются через `ffplay`, cache живёт в `~/.cache/tele-tui/voice/`.
- Credentials читаются из `~/.config/tele-tui/credentials`, fallback `.env`.
- TDLib data аккаунтов хранится в `~/.local/share/tele-tui/accounts/{name}/tdlib_data/`.
3. **Синхронизация чатов**: Чаты загружаются асинхронно через updates. Main loop периодически синхронизирует `app.chats` с `td_client.chats`.
### Зависимости (Cargo.toml)
```toml
ratatui = "0.29"
crossterm = "0.28"
tdlib-rs = { version = "1.1", features = ["download-tdlib"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
dotenvy = "0.15"
```
### Переменные окружения (.env)
```
API_ID=your_api_id
API_HASH=your_api_hash
```
## Что НЕ сделано / TODO
- [ ] Отправка сообщений
- [ ] Поиск по чатам
- [ ] Папки телеграма (сейчас только "All")
- [ ] Отображение онлайн-статуса пользователя
- [ ] Markdown форматирование в сообщениях
- [ ] Скролл истории сообщений
- [ ] Отметка сообщений как прочитанные
- [ ] Обновление чатов в реальном времени (новые сообщения)
## Известные проблемы
1. При первом запуске нужно пройти авторизацию
2. Имя отправителя показывается как "User_ID" (нужно загружать имена пользователей)

3378
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,16 @@
[workspace]
members = [
"crates/tele-core",
"crates/tele-ios-ffi",
"crates/tele-tui",
"tools/uniffi-bindgen-swift",
]
default-members = ["crates/tele-tui"]
resolver = "2"
[package]
name = "tele-tui"
version = "0.1.0"
edition = "2021"
[patch.crates-io]
tdlib-rs = { path = "crates/vendor/tdlib-rs" }
[dependencies]
ratatui = "0.29"
crossterm = "0.28"
tdlib-rs = { version = "1.1", features = ["download-tdlib"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
dotenvy = "0.15"
[build-dependencies]
tdlib-rs = { version = "1.1", features = ["download-tdlib"] }

View File

@@ -1,59 +1,102 @@
# Разработка
# Правила локальной разработки
> **Обязательно к прочтению перед началом работы!**
---
## Инструменты
- Для поиска используй `rg`.
- Для точечной навигации по Rust-коду можно использовать Serena.
- Для актуальной документации библиотек используй Context7, если это нужно для изменения.
### MCP серверы
- **Serena** — для работы с кодом (символьная навигация, редактирование)
- **Context7** — для получения актуальной документации по библиотекам
## Cargo-команды
Используй эти инструменты для эффективной работы с кодовой базой.
Агентам нельзя самостоятельно запускать:
---
```bash
## Правила работы
### 1. Никогда не запускай сервисы самостоятельно
**ЗАПРЕЩЕНО** запускать `cargo run`, `cargo build` и подобные команды.
**Вместо этого попроси пользователя запустить:**
```
Запусти, пожалуйста:
cargo run
cargo build
cargo test
cargo check
```
Исключение: пользователь прямо попросил запустить конкретную cargo-команду.
### 2. Тестирование — только ручное
В финальном ответе после изменения укажи, какие cargo-команды не запускались, и дай минимальную ручную проверку.
После завершения задачи:
1. Опиши сценарии для проверки
2. Попроси пользователя проверить вручную
3. Дождись фидбека
## Quality Gate
**Формат:**
```
Готово! Проверь, пожалуйста:
Если пользователь прямо попросил проверить, закоммитить или выполнить план с тестами, используй тот же набор проверок, что и CI:
1. Открой cargo run
2. понавигируйся в списке чатов кнопками h j k l
3. Нажми Enter для открытия чата
4. Убедись, что чат прогурзился
```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` файлы.
### 3. Работа поэтапно
## Scope
Делай работу **небольшими итерациями**:
- Делай одну логическую правку за раз.
- Не смешивай feature work, рефакторинг и документацию без необходимости.
- Не откатывай чужие изменения в dirty worktree.
- Не используй destructive git-команды без явной просьбы.
1. **Один этап = одна логическая единица**
- Один endpoint
- Один компонент
- Одна фича
## Ручная проверка
2. **После каждого этапа:**
- Сообщи что сделано
- Дай сценарий проверки
- Дождись подтверждения
Формат после функциональной правки:
3. **Не делай сразу много:**
- ❌ Весь CRUD за раз
- ✅ Сначала GET, проверили, потом POST, проверили...
---
## Чеклист перед началом работы
- [ ] Прочитал CONTEXT.md
- [ ] Прочитал ROADMAP.md (понял текущую фазу)
- [ ] Понял задачу
- [ ] Готов работать поэтапно
- [ ] Помню: не запускаю сервисы сам, прошу пользователя
---
## Пример правильного workflow
```text
Проверь:
1. Запусти ...
2. Открой ...
3. Выполни ...
4. Ожидаемый результат: ...
```
Пользователь: Сделай endpoint для получения списка идей
Для чисто документационных задач cargo-проверки не требуются.
Агент:
1. Читает документацию через Context7 (NestJS, TypeORM)
2. Использует Serena для навигации по коду
3. Создаёт endpoint GET /api/ideas
4. Сообщает:
"Создал endpoint GET /api/ideas.
Запусти backend:
cd backend && npm run dev
Проверь:
curl http://localhost:4001/api/ideas
Должен вернуться пустой массив: { data: [], meta: {...} }"
5. Ждёт фидбек
6. Переходит к следующему этапу
```

21
LICENSE
View File

@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2024 tele-tui contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,75 +1 @@
# tele-tui
Консольный Telegram-клиент на Rust с Vim-style навигацией, Normal/Insert режимами, TDLib и поддержкой русской раскладки.
## Возможности
- Авторизация через TDLib, список чатов, история и realtime updates.
- Отправка, редактирование, удаление, reply, forward, copy и реакции.
- Поиск по чатам и внутри открытого чата.
- Telegram-папки, pinned/muted/mentions/unread indicators.
- Markdown entities, inline фото, fullscreen image modal и фото-альбомы.
- Голосовые сообщения через `ffplay`.
- Desktop notifications, clipboard и открытие URL через feature flags.
- Multi-account profiles, account switcher и per-account lock files.
## Установка
Требования:
- Rust stable, рекомендовано 1.85+.
- TDLib скачивается автоматически через `tdlib-rs` feature `download-tdlib`.
- Для голосовых сообщений нужен `ffplay` из ffmpeg.
```bash
cargo build -p tele-tui --release
```
## Credentials
Получите Telegram API credentials на <https://my.telegram.org/apps> и сохраните их в:
```text
~/.config/tele-tui/credentials
```
Формат:
```text
API_ID=your_api_id
API_HASH=your_api_hash
```
Для локальной разработки можно использовать `.env` в корне проекта с теми же ключами.
## Запуск
```bash
cargo run --release
```
Для выбора аккаунта:
```bash
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).
## Документация
- [AGENT.md](AGENT.md) - краткие правила для агента.
- [DEVELOPMENT.md](DEVELOPMENT.md) - локальные правила разработки и проверок.
- [CONTEXT.md](CONTEXT.md) - текущий статус, риски и следующие шаги.
- [docs/HOTKEYS.md](docs/HOTKEYS.md) - горячие клавиши.
- [docs/PROJECT_STRUCTURE.md](docs/PROJECT_STRUCTURE.md) - карта подсистем.
- [docs/TDLIB_INTEGRATION.md](docs/TDLIB_INTEGRATION.md) - проектные заметки по TDLib.
- [docs/IOS_CORE_REUSE.md](docs/IOS_CORE_REUSE.md) - граница `tele-core` для будущего iOS-клиента.
## Лицензия
MIT
telegram-tui консольный телеграм

89
REQUIREMENTS.md Normal file
View File

@@ -0,0 +1,89 @@
# TTUI - Требование к приложению
## Описание приложения
Терминальный интерфейс для telegram
## Функциональные требования
### Интерфейс
┌─ TTUI ───────────────────────────────────────────────────────────────────────┐
│ 1:All │ 2:Personal │ 3:Work │ 4:Bots │
├──────────────────────┬───────────────────────────────────────────────────────┤
│ 🔍 Search... │ 👤 Mom (online) │
├──────────────────────┼───────────────────────────────────────────────────────┤
│ 📌 Saved Messages │ Today, Dec 21│
│ ▌ Mom (2)│ │
│ Boss │ Mom ────────────────────────────────────────── 14:20 │
│ Rust Community │ Привет! Ты покормил кота? │
│ Durov │ │
│ News Channel │ You ─────────────────────────────────────── 14:22 ✓✓ │
│ Spam Bot │ Да, конечно. Купил ему корм. │
│ Wife │ Скоро буду дома. │
│ Team Lead │ │
│ DevOps Chat (9)│ Mom ────────────────────────────────────────── 14:23 │
│ Server Alerts │ Отлично, захвати хлеба. │
│ Gym Bro │ │
│ Design Team │ You ─────────────────────────────────────── 14:25 ✓ │
│ Project X │ Ок. │
│ HR │ │
│ Mom's Friend │ │
│ Taxi Bot │ │
│ │ │
│ │ │
│ │ │
│ │ │
│ │ │
├──────────────────────┼───────────────────────────────────────────────────────┤
│ [User: Online] │ > **message**
└──────────────────────┴───────────────────────────────────────────────────────┘
**commands**
### Список желаемого
1) футер - список папок в телеграме
2) список с чатами - лички и группы, сверху инпут для поиска чата
3) основной контент - открытый чат с сообщениями из чата, если никакой чат не открыт, то контент пустой, ничего не показываем. Снизу - инпут для ввода сообщения в чат, который открыт
4) снизу списка чата статус онлайн или нет сам пользователь приложения
5) при открытии чата должна загружаться история чата, а так же подгружаться новые сообщения от собеседника.
6) выделяем сообщения собеседника его никнеймом, группируем его сообщения и разделяем наши сообщения и сообщения собеседника, как на интерфейсе сверху
7) отображаем наше сообщение символом `✓`, если телеграм подтвердил, что сообщение отправлено, и выделяем `✓✓` если собеседник прочитал его
8) при навигации в чате выделяем строку курсивом, при выборе чата (то есть его открытии) ставим в начало символ ▌
9) `(2)` — счетчик непрочитанных (можно красить в красный/зеленый).
10) `muted` — статус чата (серый цвет).
11) `@` — пинг/меншн.
12) с видео/картинками/голосовые пока ничего не делаем, ренденим заглушку (с упоминанием что это картинка или видео и тд)
### Управление
1) ctrl+c или command+c - выход из программы
2) "h j k l" - влево, вниз, вверх, вправо (навигация в левом столбце) vim-style управление
3) стрелки - управление, так же как и "h j k l"
4) "command + 1", "command + 2" и так далее - переключение между папками, которые созданы в телеграме
5) из интерфейса "**message**" - это инпут для ввода сообщения в открытый чат
6) ctrl + s - фокус в инпут поиска чата
7) Esc - закрытие открытого чата
8) command + стрелка вверх (или ctrl + k) - выделяем самый верхний чат (без открытия)
9) поддержка русской раскладки: "р о л д" соответствует "h j k l"
10) Ctrl+R - обновить список чатов
### Реализованные команды (footer)
```
j/k: Navigate | Ctrl+k: First | Enter: Open | Esc: Close | Ctrl+R: Refresh | Ctrl+C: Quit
```
## Технологии
Пишем на rust-е
1) ratatui - для tui интерфейса
2) tdlib-rs - для подключения апи телеграма (обёртка над TDLib)
3) tokio - async runtime
4) crossterm - кроссплатформенный терминал
## Нефункциональные требования
### Производительность
1) программа должна выдавать 60 фпс
2) интерфейс не должен мерцать
3) минимальное разрешение - 600 символов, максимального нет, не ограничиваем

48
ROADMAP.md Normal file
View File

@@ -0,0 +1,48 @@
# Roadmap
## Фаза 1: Базовая инфраструктура [DONE]
- [x] Настройка проекта (Cargo.toml)
- [x] TUI фреймворк (ratatui + crossterm)
- [x] Базовый layout (папки, список чатов, область сообщений)
- [x] Vim-style навигация (hjkl, стрелки)
- [x] Русская раскладка (ролд)
## Фаза 2: TDLib интеграция [DONE]
- [x] Подключение tdlib-rs
- [x] Авторизация (телефон + код + 2FA)
- [x] Сохранение сессии
- [x] Загрузка списка чатов
- [x] Загрузка истории сообщений
- [x] Отключение логов TDLib
## Фаза 3: Улучшение UX [IN PROGRESS]
- [ ] Отправка сообщений
- [ ] Поиск по чатам (Ctrl+S)
- [ ] Скролл истории сообщений
- [ ] Загрузка имён пользователей (вместо User_ID)
- [ ] Отметка сообщений как прочитанные
- [ ] Реальное время: новые сообщения
## Фаза 4: Папки и фильтрация
- [ ] Загрузка папок из Telegram
- [ ] Переключение между папками (Cmd+1, Cmd+2, ...)
- [ ] Фильтрация чатов по папке
## Фаза 5: Расширенный функционал
- [ ] Отображение онлайн-статуса
- [ ] Статус доставки/прочтения (✓, ✓✓)
- [ ] Поддержка медиа-заглушек (фото, видео, голосовые)
- [ ] Mentions (@)
- [ ] Muted чаты (серый цвет)
## Фаза 6: Полировка
- [ ] Оптимизация 60 FPS
- [ ] Минимальное разрешение 600 символов
- [ ] Обработка ошибок сети
- [ ] Graceful shutdown

View File

@@ -1,56 +0,0 @@
// 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
)

View File

@@ -1,47 +0,0 @@
# 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
```

View File

@@ -1,12 +0,0 @@
<?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>

View File

@@ -1,24 +0,0 @@
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)
)
}
}

View File

@@ -1,266 +0,0 @@
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"
}
}
}

View File

@@ -1,53 +0,0 @@
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
}
}

View File

@@ -1,255 +0,0 @@
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)
}

View File

@@ -1,236 +0,0 @@
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
}
}

View File

@@ -1,18 +0,0 @@
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
}
}

View File

@@ -1,45 +0,0 @@
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
}
}

View File

@@ -1,305 +0,0 @@
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

View File

@@ -1,346 +0,0 @@
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
}
}

View File

@@ -1,964 +0,0 @@
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")
}
}
}

View File

@@ -1,252 +0,0 @@
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")
}
}

3
build.rs Normal file
View File

@@ -0,0 +1,3 @@
fn main() {
tdlib_rs::build::build(None);
}

View File

@@ -1,26 +0,0 @@
# tele-tui configuration file example
#
# Этот файл автоматически создаётся при первом запуске в ~/.config/tele-tui/config.toml
# Скопируйте его туда и настройте по своему усмотрению
[colors]
# Цветовая схема интерфейса
# Поддерживаемые цвета:
# - Основные: black, red, green, yellow, blue, magenta, cyan, gray, white
# - Светлые: lightred, lightgreen, lightyellow, lightblue, lightmagenta, lightcyan
# - Тёмные: darkgray
# Цвет входящих сообщений
incoming_message = "white"
# Цвет исходящих сообщений
outgoing_message = "green"
# Цвет выбранного сообщения
selected_message = "yellow"
# Цвет своих реакций (отображаются в рамках [👍])
reaction_chosen = "yellow"
# Цвет чужих реакций
reaction_other = "gray"

View File

@@ -1,31 +0,0 @@
[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"

View File

@@ -1,5 +0,0 @@
//! Account profile data structures and validation.
pub mod profile;
pub use profile::{validate_account_name, AccountProfile, AccountsConfig};

View File

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

View File

@@ -1,6 +0,0 @@
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;

View File

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

View File

@@ -1,447 +0,0 @@
//! Модуль для группировки сообщений по дате и отправителю
//!
//! Предоставляет функции для логической группировки сообщений
//! перед отображением, отделяя логику группировки от рендеринга.
use crate::tdlib::MessageInfo;
use crate::utils::get_day;
/// Элемент группированного списка сообщений
#[derive(Debug, Clone)]
pub enum MessageGroup<'a> {
/// Разделитель даты (день в формате timestamp)
DateSeparator(i32),
/// Заголовок отправителя (is_outgoing, sender_name)
SenderHeader {
is_outgoing: bool,
sender_name: String,
},
/// Сообщение
Message(&'a MessageInfo),
/// Альбом (группа фото с одинаковым media_album_id)
Album(Vec<&'a MessageInfo>),
}
/// Группирует сообщения по дате и отправителю
///
/// # Аргументы
///
/// * `messages` - Список сообщений для группировки
///
/// # Возвращает
///
/// Вектор `MessageGroup` с разделителями дат, заголовками отправителей и сообщениями
///
/// # Примеры
///
/// ```no_run
/// use tele_core::message_grouping::{group_messages, MessageGroup};
///
/// # use tele_core::tdlib::types::MessageBuilder;
/// # use tele_core::types::MessageId;
/// # let msg = MessageBuilder::new(MessageId::new(1)).sender_name("Alice").text("Hello").build();
/// let messages = vec![msg];
/// let grouped = group_messages(&messages);
///
/// for group in grouped {
/// match group {
/// MessageGroup::DateSeparator(_day) => {
/// // Рендерим разделитель даты
/// }
/// MessageGroup::SenderHeader { is_outgoing, sender_name } => {
/// // Рендерим заголовок отправителя
/// println!("{}: {}", if is_outgoing { "Outgoing" } else { "Incoming" }, sender_name);
/// }
/// MessageGroup::Message(msg) => {
/// // Рендерим сообщение
/// println!("{}", msg.text());
/// }
/// MessageGroup::Album(messages) => {
/// // Рендерим альбом (группу фото)
/// println!("Album with {} photos", messages.len());
/// }
/// }
/// }
/// ```
pub fn group_messages<'a>(messages: &'a [MessageInfo]) -> Vec<MessageGroup<'a>> {
let mut result = Vec::new();
let mut last_day: Option<i64> = None;
let mut last_sender: Option<(bool, String)> = None; // (is_outgoing, sender_name)
let mut album_acc: Vec<&MessageInfo> = Vec::new();
/// Сбрасывает аккумулятор альбома в результат
fn flush_album<'a>(acc: &mut Vec<&'a MessageInfo>, result: &mut Vec<MessageGroup<'a>>) {
if acc.is_empty() {
return;
}
if acc.len() >= 2 {
result.push(MessageGroup::Album(std::mem::take(acc)));
} else {
// Одно сообщение — не альбом
result.push(MessageGroup::Message(acc.remove(0)));
}
}
for msg in messages {
// Проверяем, нужно ли добавить разделитель даты
let msg_day = get_day(msg.date());
if last_day != Some(msg_day) {
// Flush аккумулятор перед разделителем даты
flush_album(&mut album_acc, &mut result);
// Добавляем разделитель даты
result.push(MessageGroup::DateSeparator(msg.date()));
last_day = Some(msg_day);
last_sender = None; // Сбрасываем отправителя при смене дня
}
let sender_name = if msg.is_outgoing() {
"Вы".to_string()
} else {
msg.sender_name().to_string()
};
let current_sender = (msg.is_outgoing(), sender_name.clone());
// Проверяем, нужно ли показать заголовок отправителя
let show_sender_header = last_sender.as_ref() != Some(&current_sender);
if show_sender_header {
// Flush аккумулятор перед сменой отправителя
flush_album(&mut album_acc, &mut result);
result.push(MessageGroup::SenderHeader { is_outgoing: msg.is_outgoing(), sender_name });
last_sender = Some(current_sender);
}
// Проверяем, является ли сообщение частью альбома
let album_id = msg.media_album_id();
if album_id != 0 {
// Проверяем, совпадает ли album_id с текущим аккумулятором
if let Some(first) = album_acc.first() {
if first.media_album_id() == album_id {
// Тот же альбом — добавляем
album_acc.push(msg);
continue;
} else {
// Другой альбом — flush старый, начинаем новый
flush_album(&mut album_acc, &mut result);
album_acc.push(msg);
continue;
}
} else {
// Аккумулятор пуст — начинаем новый альбом
album_acc.push(msg);
continue;
}
}
// Обычное сообщение (не альбом) — flush аккумулятор
flush_album(&mut album_acc, &mut result);
result.push(MessageGroup::Message(msg));
}
// Flush оставшийся аккумулятор
flush_album(&mut album_acc, &mut result);
result
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tdlib::types::MessageBuilder;
use crate::types::MessageId;
#[test]
fn test_group_messages_by_date() {
// Создаём сообщения с разными датами
let msg1 = MessageBuilder::new(MessageId::new(1))
.sender_name("Alice")
.text("Message 1")
.date(1609459200) // 2021-01-01 00:00:00 UTC
.incoming()
.build();
let msg2 = MessageBuilder::new(MessageId::new(2))
.sender_name("Alice")
.text("Message 2")
.date(1609545600) // 2021-01-02 00:00:00 UTC
.incoming()
.build();
let messages = vec![msg1, msg2];
let grouped = group_messages(&messages);
// Должно быть: DateSep, SenderHeader, Message, DateSep, SenderHeader, Message
assert_eq!(grouped.len(), 6);
assert!(matches!(grouped[0], MessageGroup::DateSeparator(_)));
assert!(matches!(grouped[1], MessageGroup::SenderHeader { .. }));
assert!(matches!(grouped[2], MessageGroup::Message(_)));
assert!(matches!(grouped[3], MessageGroup::DateSeparator(_)));
assert!(matches!(grouped[4], MessageGroup::SenderHeader { .. }));
assert!(matches!(grouped[5], MessageGroup::Message(_)));
}
#[test]
fn test_group_messages_by_sender() {
// Создаём сообщения от разных отправителей
let msg1 = MessageBuilder::new(MessageId::new(1))
.sender_name("Alice")
.text("Message 1")
.date(1609459200)
.incoming()
.build();
let msg2 = MessageBuilder::new(MessageId::new(2))
.sender_name("Alice")
.text("Message 2")
.date(1609459300) // +100 секунд, тот же день
.incoming()
.build();
let msg3 = MessageBuilder::new(MessageId::new(3))
.sender_name("Bob")
.text("Message 3")
.date(1609459400)
.incoming()
.build();
let messages = vec![msg1, msg2, msg3];
let grouped = group_messages(&messages);
// Должно быть: DateSep, SenderHeader(Alice), Message, Message, SenderHeader(Bob), Message
assert_eq!(grouped.len(), 6);
assert!(matches!(grouped[0], MessageGroup::DateSeparator(_)));
if let MessageGroup::SenderHeader { sender_name, .. } = &grouped[1] {
assert_eq!(sender_name, "Alice");
} else {
panic!("Expected SenderHeader");
}
assert!(matches!(grouped[2], MessageGroup::Message(_)));
assert!(matches!(grouped[3], MessageGroup::Message(_)));
if let MessageGroup::SenderHeader { sender_name, .. } = &grouped[4] {
assert_eq!(sender_name, "Bob");
} else {
panic!("Expected SenderHeader");
}
assert!(matches!(grouped[5], MessageGroup::Message(_)));
}
#[test]
fn test_group_outgoing_vs_incoming() {
// Проверяем группировку исходящих и входящих сообщений
let msg1 = MessageBuilder::new(MessageId::new(1))
.sender_name("Alice")
.text("Hello")
.date(1609459200)
.incoming()
.build();
let msg2 = MessageBuilder::new(MessageId::new(2))
.sender_name("Me")
.text("Hi")
.date(1609459300)
.outgoing()
.build();
let messages = vec![msg1, msg2];
let grouped = group_messages(&messages);
// Должно быть: DateSep, SenderHeader(Alice), Message, SenderHeader(Me), Message
assert_eq!(grouped.len(), 5);
if let MessageGroup::SenderHeader { is_outgoing, sender_name } = &grouped[1] {
assert!(!*is_outgoing);
assert_eq!(sender_name, "Alice");
} else {
panic!("Expected SenderHeader");
}
if let MessageGroup::SenderHeader { is_outgoing, sender_name } = &grouped[3] {
assert!(*is_outgoing);
assert_eq!(sender_name, "Вы");
} else {
panic!("Expected SenderHeader");
}
}
#[test]
fn test_empty_messages() {
let messages: Vec<MessageInfo> = vec![];
let grouped = group_messages(&messages);
assert_eq!(grouped.len(), 0);
}
#[test]
fn test_single_message() {
let msg = MessageBuilder::new(MessageId::new(1))
.sender_name("Alice")
.text("Single message")
.date(1609459200)
.incoming()
.build();
let messages = vec![msg];
let grouped = group_messages(&messages);
// Должно быть: DateSep, SenderHeader, Message
assert_eq!(grouped.len(), 3);
assert!(matches!(grouped[0], MessageGroup::DateSeparator(_)));
assert!(matches!(grouped[1], MessageGroup::SenderHeader { .. }));
assert!(matches!(grouped[2], MessageGroup::Message(_)));
}
#[test]
fn test_album_grouping_two_photos() {
let msg1 = MessageBuilder::new(MessageId::new(1))
.sender_name("Alice")
.text("Photo 1")
.date(1609459200)
.incoming()
.media_album_id(12345)
.build();
let msg2 = MessageBuilder::new(MessageId::new(2))
.sender_name("Alice")
.text("Photo 2")
.date(1609459201)
.incoming()
.media_album_id(12345)
.build();
let messages = vec![msg1, msg2];
let grouped = group_messages(&messages);
// DateSep, SenderHeader, Album
assert_eq!(grouped.len(), 3);
assert!(matches!(grouped[0], MessageGroup::DateSeparator(_)));
assert!(matches!(grouped[1], MessageGroup::SenderHeader { .. }));
if let MessageGroup::Album(album) = &grouped[2] {
assert_eq!(album.len(), 2);
assert_eq!(album[0].id(), MessageId::new(1));
assert_eq!(album[1].id(), MessageId::new(2));
} else {
panic!("Expected Album, got {:?}", grouped[2]);
}
}
#[test]
fn test_album_single_photo_not_album() {
// Одно сообщение с album_id → не альбом, обычное сообщение
let msg = MessageBuilder::new(MessageId::new(1))
.sender_name("Alice")
.text("Single photo")
.date(1609459200)
.incoming()
.media_album_id(12345)
.build();
let messages = vec![msg];
let grouped = group_messages(&messages);
// DateSep, SenderHeader, Message (не Album)
assert_eq!(grouped.len(), 3);
assert!(matches!(grouped[2], MessageGroup::Message(_)));
}
#[test]
fn test_album_with_regular_messages() {
let msg1 = MessageBuilder::new(MessageId::new(1))
.sender_name("Alice")
.text("Text message")
.date(1609459200)
.incoming()
.build();
let msg2 = MessageBuilder::new(MessageId::new(2))
.sender_name("Alice")
.text("Photo 1")
.date(1609459201)
.incoming()
.media_album_id(100)
.build();
let msg3 = MessageBuilder::new(MessageId::new(3))
.sender_name("Alice")
.text("Photo 2")
.date(1609459202)
.incoming()
.media_album_id(100)
.build();
let msg4 = MessageBuilder::new(MessageId::new(4))
.sender_name("Alice")
.text("After album")
.date(1609459203)
.incoming()
.build();
let messages = vec![msg1, msg2, msg3, msg4];
let grouped = group_messages(&messages);
// DateSep, SenderHeader, Message, Album, Message
assert_eq!(grouped.len(), 5);
assert!(matches!(grouped[2], MessageGroup::Message(_)));
assert!(matches!(grouped[3], MessageGroup::Album(_)));
assert!(matches!(grouped[4], MessageGroup::Message(_)));
}
#[test]
fn test_two_different_albums() {
let msg1 = MessageBuilder::new(MessageId::new(1))
.sender_name("Alice")
.text("Album 1 - Photo 1")
.date(1609459200)
.incoming()
.media_album_id(100)
.build();
let msg2 = MessageBuilder::new(MessageId::new(2))
.sender_name("Alice")
.text("Album 1 - Photo 2")
.date(1609459201)
.incoming()
.media_album_id(100)
.build();
let msg3 = MessageBuilder::new(MessageId::new(3))
.sender_name("Alice")
.text("Album 2 - Photo 1")
.date(1609459202)
.incoming()
.media_album_id(200)
.build();
let msg4 = MessageBuilder::new(MessageId::new(4))
.sender_name("Alice")
.text("Album 2 - Photo 2")
.date(1609459203)
.incoming()
.media_album_id(200)
.build();
let messages = vec![msg1, msg2, msg3, msg4];
let grouped = group_messages(&messages);
// DateSep, SenderHeader, Album(2), Album(2)
assert_eq!(grouped.len(), 4);
if let MessageGroup::Album(a1) = &grouped[2] {
assert_eq!(a1.len(), 2);
assert_eq!(a1[0].media_album_id(), 100);
} else {
panic!("Expected first Album");
}
if let MessageGroup::Album(a2) = &grouped[3] {
assert_eq!(a2.len(), 2);
assert_eq!(a2[0].media_album_id(), 200);
} else {
panic!("Expected second Album");
}
}
}

View File

@@ -1,997 +0,0 @@
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 }]
);
}
}

View File

@@ -1,215 +0,0 @@
use tdlib_rs::enums::{AuthorizationState, Update};
use tdlib_rs::functions;
/// Состояние процесса авторизации в Telegram.
///
/// Отслеживает текущий этап аутентификации пользователя,
/// от инициализации TDLib до полной авторизации.
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq)]
pub enum AuthState {
/// Ожидание параметров TDLib (начальное состояние).
WaitTdlibParameters,
/// Ожидание ввода номера телефона.
WaitPhoneNumber,
/// Ожидание ввода кода подтверждения из SMS/Telegram.
WaitCode,
/// Ожидание ввода пароля двухфакторной аутентификации (2FA).
WaitPassword,
/// Авторизация завершена, клиент готов к работе.
Ready,
/// Соединение закрыто.
Closed,
/// Произошла ошибка авторизации.
Error(String),
}
/// Менеджер авторизации TDLib.
///
/// Управляет процессом авторизации пользователя в Telegram,
/// отслеживает текущее состояние и предоставляет методы
/// для отправки учетных данных (номер телефона, код, пароль).
///
/// # Процесс авторизации
///
/// 1. `WaitTdlibParameters` → автоматически
/// 2. `WaitPhoneNumber` → [`send_phone_number()`](Self::send_phone_number)
/// 3. `WaitCode` → [`send_code()`](Self::send_code)
/// 4. `WaitPassword` (опционально) → [`send_password()`](Self::send_password)
/// 5. `Ready` → авторизация завершена
///
/// # Examples
///
/// ```ignore
/// let mut auth_manager = AuthManager::new(client_id);
///
/// // Отправляем номер телефона
/// auth_manager.send_phone_number("+1234567890".to_string()).await?;
///
/// // После получения кода из SMS
/// auth_manager.send_code("12345".to_string()).await?;
///
/// // Если включена 2FA
/// if auth_manager.state == AuthState::WaitPassword {
/// auth_manager.send_password("my_password".to_string()).await?;
/// }
///
/// // Проверяем авторизацию
/// if auth_manager.is_authenticated() {
/// println!("Successfully authenticated!");
/// }
/// ```
pub struct AuthManager {
/// Текущее состояние авторизации.
pub state: AuthState,
/// ID клиента TDLib для API вызовов.
client_id: i32,
}
#[allow(dead_code)]
impl AuthManager {
/// Создает новый менеджер авторизации.
///
/// # Arguments
///
/// * `client_id` - ID клиента TDLib для API вызовов
///
/// # Returns
///
/// Новый экземпляр `AuthManager` в состоянии `WaitTdlibParameters`.
pub fn new(client_id: i32) -> Self {
Self { state: AuthState::WaitTdlibParameters, client_id }
}
/// Проверяет, завершена ли авторизация.
///
/// # Returns
///
/// `true` если состояние равно `AuthState::Ready`, иначе `false`.
///
/// # Examples
///
/// ```ignore
/// if auth_manager.is_authenticated() {
/// println!("User is authenticated");
/// }
/// ```
pub fn is_authenticated(&self) -> bool {
self.state == AuthState::Ready
}
/// Обрабатывает обновление состояния авторизации от TDLib.
///
/// Автоматически обновляет внутреннее состояние [`AuthState`] на основе
/// полученного update от TDLib.
///
/// # Arguments
///
/// * `update` - Обновление от TDLib (проверяется на `Update::AuthorizationState`)
///
/// # Note
///
/// Этот метод должен вызываться для каждого update от TDLib,
/// чтобы состояние авторизации оставалось актуальным.
pub fn handle_auth_update(&mut self, update: &Update) {
if let Update::AuthorizationState(auth_update) = update {
self.state = match &auth_update.authorization_state {
AuthorizationState::WaitTdlibParameters => AuthState::WaitTdlibParameters,
AuthorizationState::WaitPhoneNumber => AuthState::WaitPhoneNumber,
AuthorizationState::WaitCode(_) => AuthState::WaitCode,
AuthorizationState::WaitPassword(_) => AuthState::WaitPassword,
AuthorizationState::Ready => AuthState::Ready,
AuthorizationState::Closed => AuthState::Closed,
_ => return,
};
}
}
/// Отправляет номер телефона для авторизации.
///
/// Используется на этапе [`AuthState::WaitPhoneNumber`].
/// После успешной отправки состояние изменится на `WaitCode`.
///
/// # Arguments
///
/// * `phone` - Номер телефона в международном формате (например, "+1234567890")
///
/// # Returns
///
/// * `Ok(())` - Номер телефона принят, ожидайте SMS с кодом
/// * `Err(String)` - Ошибка (неверный формат, проблемы с сетью и т.д.)
///
/// # Examples
///
/// ```ignore
/// auth_manager.send_phone_number("+1234567890".to_string()).await?;
/// ```
pub async fn send_phone_number(&self, phone: String) -> Result<(), String> {
functions::set_authentication_phone_number(phone, None, self.client_id)
.await
.map(|_| ())
.map_err(|e| format!("Ошибка отправки номера: {:?}", e))
}
/// Отправляет код подтверждения из SMS или Telegram.
///
/// Используется на этапе [`AuthState::WaitCode`].
/// После успешной проверки состояние изменится на `Ready` или `WaitPassword`
/// (если включена двухфакторная аутентификация).
///
/// # Arguments
///
/// * `code` - Код подтверждения (обычно 5 цифр)
///
/// # Returns
///
/// * `Ok(())` - Код верный
/// * `Err(String)` - Неверный код или истек срок действия
///
/// # Examples
///
/// ```ignore
/// auth_manager.send_code("12345".to_string()).await?;
/// ```
pub async fn send_code(&self, code: String) -> Result<(), String> {
functions::check_authentication_code(code, self.client_id)
.await
.map(|_| ())
.map_err(|e| format!("Ошибка проверки кода: {:?}", e))
}
/// Отправляет пароль двухфакторной аутентификации (2FA).
///
/// Используется на этапе [`AuthState::WaitPassword`] (только если 2FA включена).
/// После успешной проверки состояние изменится на `Ready`.
///
/// # Arguments
///
/// * `password` - Пароль двухфакторной аутентификации
///
/// # Returns
///
/// * `Ok(())` - Пароль верный, авторизация завершена
/// * `Err(String)` - Неверный пароль
///
/// # Examples
///
/// ```ignore
/// if auth_manager.state == AuthState::WaitPassword {
/// auth_manager.send_password("my_2fa_password".to_string()).await?;
/// }
/// ```
pub async fn send_password(&self, password: String) -> Result<(), String> {
functions::check_authentication_password(password, self.client_id)
.await
.map(|_| ())
.map_err(|e| format!("Ошибка проверки пароля: {:?}", e))
}
}

View File

@@ -1,136 +0,0 @@
//! Chat management helper functions.
//!
//! This module contains utility functions for managing chats,
//! including finding, updating, and adding/removing chats.
use crate::constants::{MAX_CHATS, MAX_CHAT_USER_IDS};
use crate::types::{ChatId, MessageId, UserId};
use tdlib_rs::enums::{Chat as TdChat, ChatList, ChatType};
use super::client::TdClient;
use super::types::ChatInfo;
/// Обновляет поле чата, если чат найден.
pub fn update_chat<F>(client: &mut TdClient, chat_id: ChatId, updater: F)
where
F: FnOnce(&mut ChatInfo),
{
client.update_chat(chat_id, updater);
}
/// Добавляет новый чат или обновляет существующий
pub fn add_or_update_chat(client: &mut TdClient, td_chat_enum: &TdChat) {
// Pattern match to get inner Chat struct
let TdChat::Chat(td_chat) = td_chat_enum;
// Пропускаем удалённые аккаунты
if td_chat.title == "Deleted Account" || td_chat.title.is_empty() {
// Удаляем из списка если уже был добавлен
client.remove_chat(ChatId::new(td_chat.id));
return;
}
// Ищем позицию в Main списке (если есть)
let main_position = td_chat
.positions
.iter()
.find(|pos| matches!(pos.list, ChatList::Main));
// Получаем order и is_pinned из позиции, или используем значения по умолчанию
let (order, is_pinned) = main_position
.map(|p| (p.order, p.is_pinned))
.unwrap_or((1, false)); // order=1 чтобы чат отображался
let (last_message, last_message_date) = td_chat
.last_message
.as_ref()
.map(|m| (TdClient::extract_message_text_static(m).0, m.date))
.unwrap_or_default();
// Извлекаем user_id для приватных чатов и сохраняем связь
let username = match &td_chat.r#type {
ChatType::Private(private) => {
// Ограничиваем размер chat_user_ids
let chat_id = ChatId::new(td_chat.id);
let user_id = UserId::new(private.user_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) = cache.chat_user_ids.keys().next() {
cache.chat_user_ids.remove(&key);
}
}
cache.chat_user_ids.insert(chat_id, user_id);
// Проверяем, есть ли уже username в кэше (peek не обновляет LRU)
cache
.user_usernames
.peek(&user_id)
.map(|u| format!("@{}", u))
})
}
_ => None,
};
// Извлекаем ID папок из позиций
let folder_ids: Vec<i32> = td_chat
.positions
.iter()
.filter_map(|pos| match &pos.list {
ChatList::Folder(folder) => Some(folder.chat_folder_id),
_ => None,
})
.collect();
// Проверяем mute статус
let is_muted = td_chat.notification_settings.mute_for > 0;
let chat_info = ChatInfo {
id: ChatId::new(td_chat.id),
title: td_chat.title.clone(),
username,
last_message,
last_message_date,
unread_count: td_chat.unread_count,
unread_mention_count: td_chat.unread_mention_count,
is_pinned,
order,
last_read_outbox_message_id: MessageId::new(td_chat.last_read_outbox_message_id),
folder_ids,
is_muted,
draft_text: None,
};
let chat_info_for_update = chat_info.clone();
let updated_existing = client.update_chat(ChatId::new(td_chat.id), |existing| {
existing.title = chat_info_for_update.title;
existing.last_message = chat_info_for_update.last_message;
existing.last_message_date = chat_info_for_update.last_message_date;
existing.unread_count = chat_info_for_update.unread_count;
existing.unread_mention_count = chat_info_for_update.unread_mention_count;
existing.last_read_outbox_message_id = chat_info_for_update.last_read_outbox_message_id;
existing.folder_ids = chat_info_for_update.folder_ids;
existing.is_muted = chat_info_for_update.is_muted;
// Обновляем username если он появился
if let Some(username) = chat_info_for_update.username {
existing.username = Some(username);
}
// Обновляем позицию только если она пришла
if main_position.is_some() {
existing.is_pinned = chat_info_for_update.is_pinned;
existing.order = chat_info_for_update.order;
}
});
if !updated_existing {
client.push_chat(chat_info);
// Ограничиваем количество чатов
client.trim_chats_to_max_by_order(MAX_CHATS);
}
// Сортируем чаты по order (TDLib order учитывает pinned и время)
client.sort_chats_by_order();
}

View File

@@ -1,380 +0,0 @@
use crate::types::{ChatId, UserId};
use std::time::Instant;
use tdlib_rs::enums::{ChatAction, ChatList, ChatType};
use tdlib_rs::functions;
use super::types::{ChatInfo, FolderInfo, ProfileInfo};
/// Менеджер чатов TDLib.
///
/// Управляет списком чатов, папками, информацией о профилях
/// и typing-статусом собеседников.
///
/// # Основные возможности
///
/// - Загрузка чатов из главного списка и папок
/// - Получение информации о профиле чата/пользователя
/// - Отправка typing-индикатора ("печатает...")
/// - Отслеживание typing-статуса собеседников
/// - Выход из чатов/групп
///
/// # Examples
///
/// ```ignore
/// let mut chat_manager = ChatManager::new(client_id);
///
/// // Загружаем чаты
/// chat_manager.load_chats(50).await?;
///
/// // Получаем информацию о профиле
/// let profile = chat_manager.get_profile_info(chat_id).await?;
/// println!("Bio: {}", profile.bio.unwrap_or_default());
/// ```
pub struct ChatManager {
/// Список загруженных чатов.
pub chats: Vec<ChatInfo>,
/// Список папок чатов.
pub folders: Vec<FolderInfo>,
/// Позиция в главном списке чатов для пагинации.
pub main_chat_list_position: i32,
/// Typing status для текущего чата: (user_id, action_text, timestamp).
pub typing_status: Option<(UserId, String, Instant)>,
/// ID клиента TDLib для API вызовов.
client_id: i32,
}
impl ChatManager {
/// Создает новый менеджер чатов.
///
/// # Arguments
///
/// * `client_id` - ID клиента TDLib для API вызовов
///
/// # Returns
///
/// Новый экземпляр `ChatManager` с пустым списком чатов.
pub fn new(client_id: i32) -> Self {
Self {
chats: Vec::new(),
folders: Vec::new(),
main_chat_list_position: 0,
typing_status: None,
client_id,
}
}
/// Загружает чаты из главного списка.
///
/// Запрашивает у TDLib чаты из основного списка (исключая архив).
/// После вызова чаты будут доступны через updates от TDLib.
///
/// # Arguments
///
/// * `limit` - Максимальное количество чатов для загрузки
///
/// # Returns
///
/// * `Ok(())` - Запрос отправлен, чаты будут загружены через updates
/// * `Err(String)` - Ошибка при отправке запроса
///
/// # Examples
///
/// ```ignore
/// chat_manager.load_chats(50).await?;
/// ```
pub async fn load_chats(&mut self, limit: i32) -> Result<(), String> {
let result = functions::load_chats(Some(ChatList::Main), limit, self.client_id).await;
match result {
Ok(_) => Ok(()),
Err(e) => Err(format!("Ошибка загрузки чатов: {:?}", e)),
}
}
/// Загружает чаты из указанной папки.
///
/// # Arguments
///
/// * `folder_id` - ID папки чатов
/// * `limit` - Максимальное количество чатов для загрузки
///
/// # Returns
///
/// * `Ok(())` - Запрос отправлен
/// * `Err(String)` - Ошибка при отправке запроса
///
/// # Examples
///
/// ```ignore
/// // Загрузить чаты из папки с ID 1
/// chat_manager.load_folder_chats(1, 50).await?;
/// ```
pub async fn load_folder_chats(&mut self, folder_id: i32, limit: i32) -> Result<(), String> {
let chat_list =
ChatList::Folder(tdlib_rs::types::ChatListFolder { chat_folder_id: folder_id });
let result = functions::load_chats(Some(chat_list), limit, self.client_id).await;
match result {
Ok(_) => Ok(()),
Err(e) => Err(format!("Ошибка загрузки папки: {:?}", e)),
}
}
/// Выходит из чата или группы.
///
/// Для приватных чатов — удаляет историю, для групп — покидает группу.
///
/// # Arguments
///
/// * `chat_id` - ID чата для выхода
///
/// # Returns
///
/// * `Ok(())` - Успешный выход
/// * `Err(String)` - Ошибка (нет прав, чат не найден и т.д.)
///
/// # Examples
///
/// ```ignore
/// chat_manager.leave_chat(ChatId::new(123456)).await?;
/// ```
pub async fn leave_chat(&self, chat_id: ChatId) -> Result<(), String> {
let result = functions::leave_chat(chat_id.as_i64(), self.client_id).await;
match result {
Ok(_) => Ok(()),
Err(e) => Err(format!("Ошибка выхода из чата: {:?}", e)),
}
}
/// Получает детальную информацию о профиле чата или пользователя.
///
/// Загружает полную информацию включая bio, номер телефона, username,
/// статус онлайн (для личных чатов), количество участников и описание
/// (для групп/каналов).
///
/// # Arguments
///
/// * `chat_id` - ID чата для получения информации
///
/// # Returns
///
/// * `Ok(ProfileInfo)` - Информация о профиле
/// * `Err(String)` - Ошибка получения данных
///
/// # Examples
///
/// ```ignore
/// let profile = chat_manager.get_profile_info(ChatId::new(123)).await?;
/// println!("Title: {}", profile.title);
/// println!("Bio: {}", profile.bio.unwrap_or_default());
/// println!("Members: {}", profile.member_count.unwrap_or(0));
/// ```
pub async fn get_profile_info(&self, chat_id: ChatId) -> Result<ProfileInfo, String> {
// Получаем основную информацию о чате
let chat_result = functions::get_chat(chat_id.as_i64(), self.client_id).await;
let chat_enum = match chat_result {
Ok(c) => c,
Err(e) => return Err(format!("Ошибка получения чата: {:?}", e)),
};
let tdlib_rs::enums::Chat::Chat(chat) = chat_enum;
let chat_type_str = match &chat.r#type {
ChatType::Private(_) => "Личный чат",
ChatType::Supergroup(sg) => {
if sg.is_channel {
"Канал"
} else {
"Группа"
}
}
ChatType::BasicGroup(_) => "Группа",
ChatType::Secret(_) => "Секретный чат",
};
let is_group = matches!(&chat.r#type, ChatType::Supergroup(_) | ChatType::BasicGroup(_));
// Для личных чатов получаем информацию о пользователе
let (bio, phone_number, username, online_status) = if let ChatType::Private(private_chat) =
&chat.r#type
{
match functions::get_user(private_chat.user_id, self.client_id).await {
Ok(tdlib_rs::enums::User::User(user)) => {
let bio_opt =
if let Ok(tdlib_rs::enums::UserFullInfo::UserFullInfo(full_info)) =
functions::get_user_full_info(private_chat.user_id, self.client_id)
.await
{
full_info.bio.map(|b| b.text)
} else {
None
};
let online_status_str = match user.status {
tdlib_rs::enums::UserStatus::Online(_) => Some("В сети".to_string()),
tdlib_rs::enums::UserStatus::Recently(_) => {
Some("Был(а) недавно".to_string())
}
tdlib_rs::enums::UserStatus::LastWeek(_) => {
Some("Был(а) на этой неделе".to_string())
}
tdlib_rs::enums::UserStatus::LastMonth(_) => {
Some("Был(а) в этом месяце".to_string())
}
tdlib_rs::enums::UserStatus::Offline(s) => {
// Форматируем время последнего визита
Some(format!("Был(а) в сети {}", s.was_online))
}
_ => None,
};
let username_opt = user.usernames.as_ref().map(|u| u.editable_username.clone());
(bio_opt, Some(user.phone_number.clone()), username_opt, online_status_str)
}
_ => (None, None, None, None),
}
} else {
(None, None, None, None)
};
// Для групп/каналов получаем полную информацию
let (member_count, description, invite_link) = if is_group {
if let ChatType::Supergroup(sg) = &chat.r#type {
match functions::get_supergroup_full_info(sg.supergroup_id, self.client_id).await {
Ok(tdlib_rs::enums::SupergroupFullInfo::SupergroupFullInfo(full_info)) => {
let desc = if !full_info.description.is_empty() {
Some(full_info.description.clone())
} else {
None
};
let link = full_info
.invite_link
.as_ref()
.map(|l| l.invite_link.clone());
(Some(full_info.member_count), desc, link)
}
_ => (None, None, None),
}
} else if let ChatType::BasicGroup(bg) = &chat.r#type {
match functions::get_basic_group_full_info(bg.basic_group_id, self.client_id).await
{
Ok(tdlib_rs::enums::BasicGroupFullInfo::BasicGroupFullInfo(full_info)) => {
let desc = if !full_info.description.is_empty() {
Some(full_info.description.clone())
} else {
None
};
let link = full_info.invite_link.map(|l| l.invite_link);
(Some(full_info.members.len() as i32), desc, link)
}
Err(_) => (None, None, None),
}
} else {
(None, None, None)
}
} else {
(None, None, None)
};
Ok(ProfileInfo {
chat_id,
title: chat.title,
username,
bio,
phone_number,
chat_type: chat_type_str.to_string(),
member_count,
description,
invite_link,
is_group,
online_status,
})
}
/// Отправляет typing-действие в чат.
///
/// Показывает собеседнику индикатор "печатает..." или другой статус активности.
/// Действие автоматически сбрасывается через 5 секунд.
///
/// # Arguments
///
/// * `chat_id` - ID чата
/// * `action` - Тип действия (Typing, RecordingVideo, UploadingPhoto и т.д.)
///
/// # Note
///
/// Этот метод нужно вызывать периодически (каждые 5 секунд) пока действие активно.
///
/// # Examples
///
/// ```ignore
/// use tdlib_rs::enums::ChatAction;
///
/// // Показать индикатор "печатает..."
/// chat_manager.send_chat_action(
/// chat_id,
/// ChatAction::Typing
/// ).await;
/// ```
pub async fn send_chat_action(&self, chat_id: ChatId, action: ChatAction) {
let _ =
functions::send_chat_action(chat_id.as_i64(), 0, Some(action), self.client_id).await;
}
/// Очищает устаревший typing-статус.
///
/// Удаляет typing-статус если прошло более 5 секунд с момента последнего обновления.
/// Вызывайте этот метод периодически (например, каждый тик UI) для своевременной
/// очистки индикатора "печатает...".
///
/// # Returns
///
/// * `true` - Если статус был очищен
/// * `false` - Если статус актуален или его не было
///
/// # Examples
///
/// ```ignore
/// // В основном цикле UI
/// if chat_manager.clear_stale_typing_status() {
/// // Перерисовать UI чтобы убрать индикатор "печатает..."
/// needs_redraw = true;
/// }
/// ```
pub fn clear_stale_typing_status(&mut self) -> bool {
if let Some((_, _, timestamp)) = self.typing_status {
if timestamp.elapsed().as_secs() > 5 {
self.typing_status = None;
return true;
}
}
false
}
/// Получает текст typing-индикатора для отображения.
///
/// # Returns
///
/// * `Some(String)` - Текст действия (например, "печатает...", "записывает видео...")
/// * `None` - Нет активного typing-статуса
///
/// # Examples
///
/// ```ignore
/// if let Some(typing_text) = chat_manager.get_typing_text() {
/// println!("Status: {}", typing_text);
/// }
/// ```
#[allow(dead_code)]
pub fn get_typing_text(&self) -> Option<String> {
self.typing_status
.as_ref()
.map(|(_, action, _)| action.clone())
}
}

View File

@@ -1,848 +0,0 @@
use crate::types::{ChatId, MessageId, UserId};
use std::collections::VecDeque;
use std::path::PathBuf;
use tdlib_rs::enums::{Chat as TdChat, ChatList, ConnectionState, Update, UserStatus};
use tdlib_rs::functions;
use tdlib_rs::types::Message as TdMessage;
use super::auth::{AuthManager, AuthState};
use super::chats::ChatManager;
use super::messages::MessageManager;
use super::reactions::ReactionManager;
use super::types::{
ChatInfo, FolderInfo, MessageInfo, NetworkState, ProfileInfo, UserOnlineStatus,
};
use super::users::UserCache;
#[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.
///
/// Provides high-level API for authentication, chat management, messaging,
/// and user caching. Delegates functionality to specialized managers:
/// - `AuthManager` for authentication flow
/// - `ChatManager` for chat operations
/// - `MessageManager` for message operations
/// - `UserCache` for user information caching
/// - `ReactionManager` for message reactions
///
/// # Examples
///
/// ```ignore
/// use tele_core::tdlib::TdClient;
///
/// 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
/// client.send_phone_number("+1234567890".to_string()).await?;
/// client.send_code("12345".to_string()).await?;
///
/// // Load chats
/// client.load_chats(50).await?;
/// # Ok::<(), String>(())
/// ```
pub struct TdClient {
pub api_id: i32,
pub api_hash: String,
pub db_path: PathBuf,
client_id: i32,
// Менеджеры (делегируем им функциональность)
pub auth: AuthManager,
pub chat_manager: ChatManager,
pub message_manager: MessageManager,
pub user_cache: UserCache,
pub reaction_manager: ReactionManager,
incoming_message_events: VecDeque<IncomingMessageEvent>,
// Состояние сети
pub network_state: NetworkState,
}
#[allow(dead_code)]
impl TdClient {
/// Creates a new TDLib client instance.
///
/// Initializes all managers and sets initial network state to Connecting.
///
/// # Returns
///
/// A new `TdClient` instance ready for authentication.
pub fn new(config: TdClientConfig) -> Self {
let client_id = tdlib_rs::create_client();
Self {
api_id: config.credentials.api_id,
api_hash: config.credentials.api_hash,
db_path: config.db_path,
client_id,
auth: AuthManager::new(client_id),
chat_manager: ChatManager::new(client_id),
message_manager: MessageManager::new(client_id),
user_cache: UserCache::new(client_id),
reaction_manager: ReactionManager::new(client_id),
incoming_message_events: VecDeque::new(),
network_state: NetworkState::Connecting,
}
}
pub fn enqueue_incoming_message_event(
&mut self,
chat: ChatInfo,
message: MessageInfo,
sender_name: String,
) {
self.incoming_message_events
.push_back(IncomingMessageEvent { chat, message, sender_name });
}
pub fn drain_incoming_message_events(&mut self) -> Vec<IncomingMessageEvent> {
self.incoming_message_events.drain(..).collect()
}
// Делегирование к auth
/// Sends phone number for authentication.
///
/// This is the first step of the authentication flow.
///
/// # Arguments
///
/// * `phone` - Phone number in international format (e.g., "+1234567890")
///
/// # Errors
///
/// Returns an error if the phone number is invalid or network request fails.
pub async fn send_phone_number(&self, phone: String) -> Result<(), String> {
self.auth.send_phone_number(phone).await
}
/// Sends authentication code received via SMS.
///
/// This is the second step of the authentication flow.
///
/// # Arguments
///
/// * `code` - Authentication code (typically 5 digits)
///
/// # Errors
///
/// Returns an error if the code is invalid or expired.
pub async fn send_code(&self, code: String) -> Result<(), String> {
self.auth.send_code(code).await
}
/// Sends 2FA password if required.
///
/// This is the third step of the authentication flow (if 2FA is enabled).
///
/// # Arguments
///
/// * `password` - Two-factor authentication password
///
/// # Errors
///
/// Returns an error if the password is incorrect.
pub async fn send_password(&self, password: String) -> Result<(), String> {
self.auth.send_password(password).await
}
// Делегирование к chat_manager
/// Loads chats from the main chat list.
///
/// Loads up to `limit` chats from ChatList::Main, excluding archived chats.
/// Filters out "Deleted Account" chats automatically.
///
/// # Arguments
///
/// * `limit` - Maximum number of chats to load (typically 50-200)
///
/// # Errors
///
/// Returns an error if the network request fails.
pub async fn load_chats(&mut self, limit: i32) -> Result<(), String> {
self.chat_manager.load_chats(limit).await
}
/// Loads chats from a specific folder.
///
/// # Arguments
///
/// * `folder_id` - Folder ID (1-9 for user folders)
/// * `limit` - Maximum number of chats to load
///
/// # Errors
///
/// Returns an error if the folder doesn't exist or network request fails.
pub async fn load_folder_chats(&mut self, folder_id: i32, limit: i32) -> Result<(), String> {
self.chat_manager.load_folder_chats(folder_id, limit).await
}
/// Leaves a group or channel.
///
/// # Arguments
///
/// * `chat_id` - ID of the chat to leave
///
/// # Errors
///
/// Returns an error if the user is not a member or network request fails.
pub async fn leave_chat(&self, chat_id: ChatId) -> Result<(), String> {
self.chat_manager.leave_chat(chat_id).await
}
/// Gets profile information for a chat.
///
/// Fetches detailed information including bio, username, member count, etc.
///
/// # Arguments
///
/// * `chat_id` - ID of the chat
///
/// # Returns
///
/// `ProfileInfo` with chat details
///
/// # Errors
///
/// Returns an error if the chat doesn't exist or network request fails.
pub async fn get_profile_info(&self, chat_id: ChatId) -> Result<ProfileInfo, String> {
self.chat_manager.get_profile_info(chat_id).await
}
pub async fn send_chat_action(&self, chat_id: ChatId, action: tdlib_rs::enums::ChatAction) {
self.chat_manager.send_chat_action(chat_id, action).await
}
pub fn clear_stale_typing_status(&mut self) -> bool {
self.chat_manager.clear_stale_typing_status()
}
fn last_read_outbox_message_id(&self, chat_id: ChatId) -> MessageId {
self.chats()
.iter()
.find(|chat| chat.id == chat_id)
.map(|chat| chat.last_read_outbox_message_id)
.unwrap_or(MessageId::new(0))
}
// Делегирование к message_manager
pub async fn get_chat_history(
&mut self,
chat_id: ChatId,
limit: i32,
) -> Result<Vec<MessageInfo>, String> {
let last_read_outbox_message_id = self.last_read_outbox_message_id(chat_id);
self.message_manager
.get_chat_history(chat_id, limit, last_read_outbox_message_id)
.await
}
pub async fn load_older_messages(
&mut self,
chat_id: ChatId,
from_message_id: MessageId,
) -> Result<Vec<MessageInfo>, String> {
let last_read_outbox_message_id = self.last_read_outbox_message_id(chat_id);
self.message_manager
.load_older_messages(chat_id, from_message_id, last_read_outbox_message_id)
.await
}
pub async fn get_pinned_messages(
&mut self,
chat_id: ChatId,
) -> Result<Vec<MessageInfo>, String> {
let last_read_outbox_message_id = self.last_read_outbox_message_id(chat_id);
self.message_manager
.get_pinned_messages(chat_id, last_read_outbox_message_id)
.await
}
pub async fn load_current_pinned_message(&mut self, chat_id: ChatId) {
self.message_manager
.load_current_pinned_message(chat_id)
.await
}
pub async fn search_messages(
&self,
chat_id: ChatId,
query: &str,
) -> Result<Vec<MessageInfo>, String> {
let last_read_outbox_message_id = self.last_read_outbox_message_id(chat_id);
self.message_manager
.search_messages(chat_id, query, last_read_outbox_message_id)
.await
}
pub async fn send_message(
&self,
chat_id: ChatId,
text: String,
reply_to_message_id: Option<MessageId>,
reply_info: Option<super::types::ReplyInfo>,
) -> Result<MessageInfo, String> {
let last_read_outbox_message_id = self.last_read_outbox_message_id(chat_id);
self.message_manager
.send_message(
chat_id,
text,
reply_to_message_id,
reply_info,
last_read_outbox_message_id,
)
.await
}
pub async fn edit_message(
&self,
chat_id: ChatId,
message_id: MessageId,
text: String,
) -> Result<MessageInfo, String> {
let last_read_outbox_message_id = self.last_read_outbox_message_id(chat_id);
self.message_manager
.edit_message(chat_id, message_id, text, last_read_outbox_message_id)
.await
}
pub async fn delete_messages(
&self,
chat_id: ChatId,
message_ids: Vec<MessageId>,
revoke: bool,
) -> Result<(), String> {
self.message_manager
.delete_messages(chat_id, message_ids, revoke)
.await
}
pub async fn forward_messages(
&self,
to_chat_id: ChatId,
from_chat_id: ChatId,
message_ids: Vec<MessageId>,
) -> Result<(), String> {
self.message_manager
.forward_messages(to_chat_id, from_chat_id, message_ids)
.await
}
pub async fn set_draft_message(&self, chat_id: ChatId, text: String) -> Result<(), String> {
self.message_manager.set_draft_message(chat_id, text).await
}
pub fn push_message(&mut self, msg: MessageInfo) {
self.message_manager.push_message(msg)
}
pub async fn fetch_missing_reply_info(&mut self) {
self.message_manager.fetch_missing_reply_info().await
}
pub async fn process_pending_view_messages(&mut self) {
self.message_manager.process_pending_view_messages().await
}
// Делегирование к user_cache
pub fn get_user_status_by_chat_id(&self, chat_id: ChatId) -> Option<&UserOnlineStatus> {
self.user_cache.get_status_by_chat_id(chat_id)
}
pub async fn process_pending_user_ids(&mut self) {
self.user_cache.process_pending_user_ids().await
}
// Делегирование к reaction_manager
pub async fn get_message_available_reactions(
&self,
chat_id: ChatId,
message_id: MessageId,
) -> Result<Vec<String>, String> {
self.reaction_manager
.get_message_available_reactions(chat_id, message_id)
.await
}
pub async fn toggle_reaction(
&self,
chat_id: ChatId,
message_id: MessageId,
emoji: String,
) -> Result<(), String> {
self.reaction_manager
.toggle_reaction(chat_id, message_id, emoji)
.await
}
// Делегирование файловых операций
/// Скачивает файл по file_id и возвращает локальный путь.
pub async fn download_file(&self, file_id: i32) -> Result<String, String> {
match functions::download_file(file_id, 1, 0, 0, true, self.client_id).await {
Ok(tdlib_rs::enums::File::File(file)) => {
if file.local.is_downloading_completed && !file.local.path.is_empty() {
Ok(file.local.path)
} else {
Err("Файл не скачан".to_string())
}
}
Err(e) => Err(format!("Ошибка скачивания файла: {:?}", e)),
}
}
// Вспомогательные методы
pub fn client_id(&self) -> i32 {
self.client_id
}
pub async fn get_me(&self) -> Result<i64, String> {
match functions::get_me(self.client_id).await {
Ok(tdlib_rs::enums::User::User(user)) => Ok(user.id),
Err(e) => Err(format!("Ошибка получения текущего пользователя: {:?}", e)),
}
}
// Accessor methods для обратной совместимости
pub fn auth_state(&self) -> &AuthState {
&self.auth.state
}
pub fn chats(&self) -> &[ChatInfo] {
&self.chat_manager.chats
}
pub fn update_chats<F, R>(&mut self, updater: F) -> R
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] {
&self.chat_manager.folders
}
pub fn update_folders<F, R>(&mut self, updater: F) -> R
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] {
&self.message_manager.current_chat_messages
}
pub fn clear_current_chat_messages(&mut self) {
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> {
self.message_manager.current_chat_id
}
pub fn set_current_chat_id(&mut self, chat_id: Option<ChatId>) {
self.message_manager.current_chat_id = chat_id;
}
pub fn current_pinned_message(&self) -> Option<&MessageInfo> {
self.message_manager.current_pinned_message.as_ref()
}
pub fn set_current_pinned_message(&mut self, msg: Option<MessageInfo>) {
self.message_manager.current_pinned_message = msg;
}
pub fn typing_status(&self) -> Option<&(crate::types::UserId, String, std::time::Instant)> {
self.chat_manager.typing_status.as_ref()
}
pub fn set_typing_status(
&mut self,
status: Option<(crate::types::UserId, String, std::time::Instant)>,
) {
self.chat_manager.typing_status = status;
}
pub fn pending_view_messages(&self) -> &[(crate::types::ChatId, Vec<crate::types::MessageId>)] {
&self.message_manager.pending_view_messages
}
pub fn enqueue_pending_view_messages(
&mut self,
chat_id: crate::types::ChatId,
message_ids: Vec<crate::types::MessageId>,
) {
self.message_manager
.pending_view_messages
.push((chat_id, message_ids));
}
pub fn pending_user_ids(&self) -> &[crate::types::UserId] {
&self.user_cache.pending_user_ids
}
pub fn queue_pending_user_id(&mut self, user_id: crate::types::UserId) {
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 {
self.chat_manager.main_chat_list_position
}
pub fn set_main_chat_list_position(&mut self, position: i32) {
self.chat_manager.main_chat_list_position = position;
}
// User cache accessors
pub fn user_cache(&self) -> &UserCache {
&self.user_cache
}
pub fn update_user_cache<F, R>(&mut self, updater: F) -> R
where
F: FnOnce(&mut UserCache) -> R,
{
updater(&mut self.user_cache)
}
// ==================== Helper методы для упрощения обработки updates ====================
/// Обрабатываем одно обновление от TDLib
pub fn handle_update(&mut self, update: Update) {
match update {
Update::AuthorizationState(state) => {
crate::tdlib::update_handlers::handle_auth_state(self, state.authorization_state);
}
Update::NewChat(new_chat) => {
// new_chat.chat is already a Chat struct, wrap it in TdChat enum
let td_chat = TdChat::Chat(new_chat.chat.clone());
crate::tdlib::chat_helpers::add_or_update_chat(self, &td_chat);
}
Update::ChatLastMessage(update) => {
let chat_id = ChatId::new(update.chat_id);
let (last_message_text, last_message_date) = update
.last_message
.as_ref()
.map(|msg| (Self::extract_message_text_static(msg).0, msg.date))
.unwrap_or_default();
crate::tdlib::chat_helpers::update_chat(self, chat_id, |chat| {
chat.last_message = last_message_text;
chat.last_message_date = last_message_date;
});
// Обновляем позиции если они пришли
for pos in update
.positions
.iter()
.filter(|p| matches!(p.list, ChatList::Main))
{
crate::tdlib::chat_helpers::update_chat(self, chat_id, |chat| {
chat.order = pos.order;
chat.is_pinned = pos.is_pinned;
});
}
// Пересортируем по order
self.sort_chats_by_order();
}
Update::ChatReadInbox(update) => {
crate::tdlib::chat_helpers::update_chat(
self,
ChatId::new(update.chat_id),
|chat| {
chat.unread_count = update.unread_count;
},
);
}
Update::ChatUnreadMentionCount(update) => {
crate::tdlib::chat_helpers::update_chat(
self,
ChatId::new(update.chat_id),
|chat| {
chat.unread_mention_count = update.unread_mention_count;
},
);
}
Update::ChatNotificationSettings(update) => {
crate::tdlib::chat_helpers::update_chat(
self,
ChatId::new(update.chat_id),
|chat| {
// mute_for > 0 означает что чат замьючен
chat.is_muted = update.notification_settings.mute_for > 0;
},
);
}
Update::ChatReadOutbox(update) => {
// Обновляем last_read_outbox_message_id когда собеседник прочитал сообщения
let last_read_msg_id = MessageId::new(update.last_read_outbox_message_id);
crate::tdlib::chat_helpers::update_chat(
self,
ChatId::new(update.chat_id),
|chat| {
chat.last_read_outbox_message_id = last_read_msg_id;
},
);
// Если это текущий открытый чат — обновляем is_read у сообщений
if Some(ChatId::new(update.chat_id)) == self.current_chat_id() {
self.update_current_chat_messages(|messages| {
for msg in messages {
if msg.is_outgoing() && msg.id() <= last_read_msg_id {
msg.state.is_read = true;
}
}
});
}
}
Update::ChatPosition(update) => {
crate::tdlib::update_handlers::handle_chat_position_update(self, update);
}
Update::NewMessage(new_msg) => {
crate::tdlib::update_handlers::handle_new_message_update(self, new_msg);
}
Update::User(update) => {
crate::tdlib::update_handlers::handle_user_update(self, update);
}
Update::ChatFolders(update) => {
// Обновляем список папок
self.set_folders(
update
.chat_folders
.into_iter()
.map(|f| FolderInfo { id: f.id, name: f.title })
.collect(),
);
self.set_main_chat_list_position(update.main_chat_list_position);
}
Update::UserStatus(update) => {
// Обновляем онлайн-статус пользователя
let status = match update.status {
UserStatus::Online(_) => UserOnlineStatus::Online,
UserStatus::Offline(offline) => UserOnlineStatus::Offline(offline.was_online),
UserStatus::Recently(_) => UserOnlineStatus::Recently,
UserStatus::LastWeek(_) => UserOnlineStatus::LastWeek,
UserStatus::LastMonth(_) => UserOnlineStatus::LastMonth,
UserStatus::Empty => UserOnlineStatus::LongTimeAgo,
};
self.update_user_cache(|cache| {
cache
.user_statuses
.insert(UserId::new(update.user_id), status);
});
}
Update::ConnectionState(update) => {
// Обновляем состояние сетевого соединения
self.network_state = match update.state {
ConnectionState::WaitingForNetwork => NetworkState::WaitingForNetwork,
ConnectionState::ConnectingToProxy => NetworkState::ConnectingToProxy,
ConnectionState::Connecting => NetworkState::Connecting,
ConnectionState::Updating => NetworkState::Updating,
ConnectionState::Ready => NetworkState::Ready,
};
}
Update::ChatAction(update) => {
crate::tdlib::update_handlers::handle_chat_action_update(self, update);
}
Update::ChatDraftMessage(update) => {
crate::tdlib::update_handlers::handle_chat_draft_message_update(self, update);
}
Update::MessageInteractionInfo(update) => {
crate::tdlib::update_handlers::handle_message_interaction_info_update(self, update);
}
Update::MessageSendSucceeded(update) => {
crate::tdlib::update_handlers::handle_message_send_succeeded_update(self, update);
}
_ => {}
}
}
// Helper functions
pub fn extract_message_text_static(
message: &TdMessage,
) -> (String, Vec<tdlib_rs::types::TextEntity>) {
use tdlib_rs::enums::MessageContent;
match &message.content {
MessageContent::MessageText(text) => {
(text.text.text.clone(), text.text.entities.clone())
}
_ => (String::new(), Vec::new()),
}
}
/// Recreates the TDLib client with a new database path.
///
/// Closes the old client, creates a new one, and spawns TDLib parameter initialization.
pub async fn recreate_client(&mut self, db_path: PathBuf) -> Result<(), String> {
// 1. Close old client
let _ = functions::close(self.client_id).await;
// 2. Create new client
let new_client = TdClient::new(TdClientConfig {
credentials: TdCredentials {
api_id: self.api_id,
api_hash: self.api_hash.clone(),
},
db_path,
});
// 3. Spawn set_tdlib_parameters for new client
let new_client_id = new_client.client_id;
let api_id = new_client.api_id;
let api_hash = new_client.api_hash.clone();
let db_path_str = new_client.db_path.to_string_lossy().to_string();
tokio::spawn(async move {
if let Err(e) = functions::set_tdlib_parameters(
false,
db_path_str,
"".to_string(),
"".to_string(),
true,
true,
true,
false,
api_id,
api_hash,
"en".to_string(),
"Desktop".to_string(),
"".to_string(),
env!("CARGO_PKG_VERSION").to_string(),
new_client_id,
)
.await
{
tracing::error!("set_tdlib_parameters failed on recreate: {:?}", e);
}
});
// 4. Replace self
*self = new_client;
Ok(())
}
pub fn extract_content_text(content: &tdlib_rs::enums::MessageContent) -> String {
use tdlib_rs::enums::MessageContent;
match content {
MessageContent::MessageText(text) => text.text.text.clone(),
_ => String::new(),
}
}
}

View File

@@ -1,332 +0,0 @@
//! Implementation of TdClientTrait for TdClient
//!
//! This file contains the trait implementation that delegates to existing TdClient methods.
use super::client::TdClient;
use super::r#trait::{
AccountClient, AuthClient, ChatActionClient, ChatClient, ClientState, FileClient,
MessageClient, ReactionClient, UpdateClient, UserClient,
};
use super::{
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 TdClient {
async fn send_phone_number(&self, phone: String) -> Result<(), String> {
self.send_phone_number(phone).await
}
async fn send_code(&self, code: String) -> Result<(), String> {
self.send_code(code).await
}
async fn send_password(&self, password: String) -> Result<(), String> {
self.send_password(password).await
}
}
#[async_trait]
impl ChatClient for TdClient {
async fn load_chats(&mut self, limit: i32) -> Result<(), String> {
self.load_chats(limit).await
}
async fn load_folder_chats(&mut self, folder_id: i32, limit: i32) -> Result<(), String> {
self.load_folder_chats(folder_id, limit).await
}
async fn leave_chat(&self, chat_id: ChatId) -> Result<(), String> {
self.leave_chat(chat_id).await
}
async fn get_profile_info(&self, chat_id: ChatId) -> Result<ProfileInfo, String> {
self.get_profile_info(chat_id).await
}
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) {
self.send_chat_action(chat_id, action).await
}
fn clear_stale_typing_status(&mut self) -> bool {
self.clear_stale_typing_status()
}
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(
&mut self,
chat_id: ChatId,
limit: i32,
) -> Result<Vec<MessageInfo>, String> {
self.get_chat_history(chat_id, limit).await
}
async fn load_older_messages(
&mut self,
chat_id: ChatId,
from_message_id: MessageId,
) -> Result<Vec<MessageInfo>, String> {
self.load_older_messages(chat_id, from_message_id).await
}
async fn get_pinned_messages(&mut self, chat_id: ChatId) -> Result<Vec<MessageInfo>, String> {
self.get_pinned_messages(chat_id).await
}
async fn load_current_pinned_message(&mut self, chat_id: ChatId) {
self.load_current_pinned_message(chat_id).await
}
async fn search_messages(
&self,
chat_id: ChatId,
query: &str,
) -> Result<Vec<MessageInfo>, String> {
self.search_messages(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> {
TdClient::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> {
TdClient::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> {
self.message_manager
.delete_messages(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> {
self.message_manager
.forward_messages(to_chat_id, from_chat_id, message_ids)
.await
}
async fn set_draft_message(&self, chat_id: ChatId, text: String) -> Result<(), String> {
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) {
self.push_message(msg)
}
async fn fetch_missing_reply_info(&mut self) {
self.fetch_missing_reply_info().await
}
async fn process_pending_view_messages(&mut self) {
self.process_pending_view_messages().await
}
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> {
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) {
self.process_pending_user_ids().await
}
}
#[async_trait]
impl ReactionClient for TdClient {
async fn get_message_available_reactions(
&self,
chat_id: ChatId,
message_id: MessageId,
) -> Result<Vec<String>, String> {
self.get_message_available_reactions(chat_id, message_id)
.await
}
async fn toggle_reaction(
&self,
chat_id: ChatId,
message_id: MessageId,
reaction: String,
) -> Result<(), String> {
self.toggle_reaction(chat_id, message_id, reaction).await
}
}
#[async_trait]
impl FileClient for TdClient {
async fn download_file(&self, file_id: i32) -> Result<String, String> {
self.download_file(file_id).await
}
async fn download_voice_note(&self, file_id: i32) -> Result<String, String> {
// Voice notes use the same download mechanism as photos
self.download_file(file_id).await
}
}
#[async_trait]
impl ClientState for TdClient {
fn client_id(&self) -> i32 {
self.client_id()
}
async fn get_me(&self) -> Result<i64, String> {
self.get_me().await
}
fn auth_state(&self) -> &AuthState {
self.auth_state()
}
fn network_state(&self) -> super::types::NetworkState {
self.network_state.clone()
}
}
#[async_trait]
impl AccountClient for TdClient {
async fn recreate_client(&mut self, db_path: PathBuf) -> Result<(), String> {
TdClient::recreate_client(self, db_path).await
}
}
impl UpdateClient for TdClient {
fn handle_update(&mut self, update: Update) {
// Delegate to the real implementation
TdClient::handle_update(self, update)
}
fn drain_incoming_message_events(&mut self) -> Vec<super::IncomingMessageEvent> {
TdClient::drain_incoming_message_events(self)
}
}

View File

@@ -1,213 +0,0 @@
//! Вспомогательные функции для конвертации TDLib сообщений в MessageInfo
//!
//! Этот модуль содержит функции для извлечения различных частей сообщения
//! из TDLib Message и конвертации их в наш внутренний формат MessageInfo.
use crate::types::MessageId;
use tdlib_rs::enums::{MessageContent, MessageSender};
use tdlib_rs::types::Message as TdMessage;
use super::types::{
ForwardInfo, MediaInfo, PhotoDownloadState, PhotoInfo, ReactionInfo, ReplyInfo,
VoiceDownloadState, VoiceInfo,
};
/// Извлекает текст контента из TDLib Message
///
/// Обрабатывает различные типы сообщений (текст, фото, видео, стикеры, и т.д.)
/// и возвращает текстовое представление.
pub fn extract_content_text(msg: &TdMessage) -> String {
match &msg.content {
MessageContent::MessageText(t) => t.text.text.clone(),
MessageContent::MessagePhoto(p) => {
let caption_text = p.caption.text.clone();
if caption_text.is_empty() {
"📷 [Фото]".to_string()
} else {
format!("📷 {}", caption_text)
}
}
MessageContent::MessageVideo(v) => {
let caption_text = v.caption.text.clone();
if caption_text.is_empty() {
"[Видео]".to_string()
} else {
caption_text
}
}
MessageContent::MessageDocument(d) => {
let caption_text = d.caption.text.clone();
if caption_text.is_empty() {
format!("[Файл: {}]", d.document.file_name)
} else {
caption_text
}
}
MessageContent::MessageSticker(s) => {
format!("[Стикер: {}]", s.sticker.emoji)
}
MessageContent::MessageAnimation(a) => {
let caption_text = a.caption.text.clone();
if caption_text.is_empty() {
"[GIF]".to_string()
} else {
caption_text
}
}
MessageContent::MessageVoiceNote(v) => {
let duration = v.voice_note.duration;
let caption_text = v.caption.text.clone();
if caption_text.is_empty() {
format!("🎤 [Голосовое {:.0}s]", duration)
} else {
format!("🎤 {} ({:.0}s)", caption_text, duration)
}
}
MessageContent::MessageAudio(a) => {
let caption_text = a.caption.text.clone();
if caption_text.is_empty() {
let title = a.audio.title.clone();
let performer = a.audio.performer.clone();
if !title.is_empty() || !performer.is_empty() {
format!("[Аудио: {} - {}]", performer, title)
} else {
"[Аудио]".to_string()
}
} else {
caption_text
}
}
_ => "[Неподдерживаемый тип сообщения]".to_string(),
}
}
/// Извлекает entities (форматирование) из TDLib Message
pub fn extract_entities(msg: &TdMessage) -> Vec<tdlib_rs::types::TextEntity> {
if let MessageContent::MessageText(t) = &msg.content {
t.text.entities.clone()
} else {
vec![]
}
}
/// Извлекает имя отправителя из TDLib Message
///
/// Для пользователей делает API вызов get_user для получения имени.
/// Для чатов возвращает ID чата.
pub async fn extract_sender_name(msg: &TdMessage, client_id: i32) -> String {
match &msg.sender_id {
MessageSender::User(user) => {
match tdlib_rs::functions::get_user(user.user_id, client_id).await {
Ok(tdlib_rs::enums::User::User(u)) => format!("{} {}", u.first_name, u.last_name)
.trim()
.to_string(),
_ => format!("User {}", user.user_id),
}
}
MessageSender::Chat(chat) => format!("Chat {}", chat.chat_id),
}
}
/// Извлекает информацию о пересылке из TDLib Message
pub fn extract_forward_info(msg: &TdMessage) -> Option<ForwardInfo> {
msg.forward_info.as_ref().and_then(|fi| {
if let tdlib_rs::enums::MessageOrigin::User(origin_user) = &fi.origin {
Some(ForwardInfo {
sender_name: format!("User {}", origin_user.sender_user_id),
})
} else {
None
}
})
}
/// Извлекает информацию об ответе из TDLib Message
pub fn extract_reply_info(msg: &TdMessage) -> Option<ReplyInfo> {
msg.reply_to.as_ref().and_then(|reply_to| {
if let tdlib_rs::enums::MessageReplyTo::Message(reply_msg) = reply_to {
Some(ReplyInfo {
message_id: MessageId::new(reply_msg.message_id),
sender_name: "Unknown".to_string(),
text: "...".to_string(),
})
} else {
None
}
})
}
/// Извлекает информацию о медиа-контенте из TDLib Message
///
/// Для MessagePhoto: получает лучший размер фото, извлекает file_id, width, height.
/// Возвращает None для не-медийных типов сообщений.
pub fn extract_media_info(msg: &TdMessage) -> Option<MediaInfo> {
match &msg.content {
MessageContent::MessagePhoto(p) => {
// Берём лучший (последний = самый большой) размер фото
let best_size = p.photo.sizes.last()?;
let file_id = best_size.photo.id;
let width = best_size.width;
let height = best_size.height;
// Проверяем, скачан ли файл
let download_state = if !best_size.photo.local.path.is_empty()
&& best_size.photo.local.is_downloading_completed
{
PhotoDownloadState::Downloaded(best_size.photo.local.path.clone())
} else {
PhotoDownloadState::NotDownloaded
};
Some(MediaInfo::Photo(PhotoInfo { file_id, width, height, download_state }))
}
MessageContent::MessageVoiceNote(v) => {
let file_id = v.voice_note.voice.id;
let duration = v.voice_note.duration;
let mime_type = v.voice_note.mime_type.clone();
let waveform = v.voice_note.waveform.clone();
// Проверяем, скачан ли файл
let download_state = if !v.voice_note.voice.local.path.is_empty()
&& v.voice_note.voice.local.is_downloading_completed
{
VoiceDownloadState::Downloaded(v.voice_note.voice.local.path.clone())
} else {
VoiceDownloadState::NotDownloaded
};
Some(MediaInfo::Voice(VoiceInfo {
file_id,
duration,
mime_type,
waveform,
download_state,
}))
}
_ => None,
}
}
/// Извлекает реакции из TDLib Message
pub fn extract_reactions(msg: &TdMessage) -> Vec<ReactionInfo> {
msg.interaction_info
.as_ref()
.and_then(|ii| ii.reactions.as_ref())
.map(|reactions| {
reactions
.reactions
.iter()
.filter_map(|r| {
if let tdlib_rs::enums::ReactionType::Emoji(emoji_type) = &r.r#type {
Some(ReactionInfo {
emoji: emoji_type.emoji.clone(),
count: r.total_count,
is_chosen: r.is_chosen,
})
} else {
None
}
})
.collect()
})
.unwrap_or_default()
}

View File

@@ -1,234 +0,0 @@
//! Message conversion utilities for transforming TDLib messages.
//!
//! This module contains functions for converting TDLib message formats
//! to the application's internal MessageInfo format, including extraction
//! of replies, forwards, and reactions.
use crate::types::{ChatId, MessageId, UserId};
use tdlib_rs::types::Message as TdMessage;
use super::client::TdClient;
use super::types::{ForwardInfo, MessageInfo, ReactionInfo, ReplyInfo};
/// Конвертирует TDLib сообщение в MessageInfo
pub fn convert_message(client: &mut TdClient, message: &TdMessage, chat_id: ChatId) -> MessageInfo {
let sender_name = match &message.sender_id {
tdlib_rs::enums::MessageSender::User(user) => {
// Пробуем получить имя из кеша (get обновляет LRU порядок)
let user_id = UserId::new(user.user_id);
client
.user_cache
.user_names
.get(&user_id)
.cloned()
.unwrap_or_else(|| {
// Добавляем в очередь для загрузки
client.queue_pending_user_id(user_id);
format!("User_{}", user_id.as_i64())
})
}
tdlib_rs::enums::MessageSender::Chat(chat) => {
// Для чатов используем название чата
let sender_chat_id = ChatId::new(chat.chat_id);
client
.chats()
.iter()
.find(|c| c.id == sender_chat_id)
.map(|c| c.title.clone())
.unwrap_or_else(|| format!("Chat_{}", sender_chat_id.as_i64()))
}
};
// Определяем, прочитано ли исходящее сообщение
let message_id = MessageId::new(message.id);
let is_read = if message.is_outgoing {
// Сообщение прочитано, если его ID <= last_read_outbox_message_id чата
client
.chats()
.iter()
.find(|c| c.id == chat_id)
.map(|c| message_id <= c.last_read_outbox_message_id)
.unwrap_or(false)
} else {
true // Входящие сообщения не показывают галочки
};
let (content, entities) = TdClient::extract_message_text_static(message);
// Извлекаем информацию о reply
let reply_to = extract_reply_info(client, message);
// Извлекаем информацию о forward
let forward_from = extract_forward_info(client, message);
// Извлекаем реакции
let reactions = extract_reactions(client, message);
// Используем MessageBuilder для более читабельного создания
let mut builder = crate::tdlib::MessageBuilder::new(message_id)
.sender_name(sender_name)
.text(content)
.entities(entities)
.date(message.date)
.edit_date(message.edit_date)
.media_album_id(message.media_album_id);
// Применяем флаги
if message.is_outgoing {
builder = builder.outgoing();
}
if is_read {
builder = builder.read();
}
if message.can_be_edited {
builder = builder.editable();
}
if message.can_be_deleted_only_for_self {
builder = builder.deletable_for_self();
}
if message.can_be_deleted_for_all_users {
builder = builder.deletable_for_all();
}
// Добавляем опциональные данные
if let Some(reply) = reply_to {
builder = builder.reply_to(reply);
}
if let Some(forward) = forward_from {
builder = builder.forward_from(forward);
}
if !reactions.is_empty() {
builder = builder.reactions(reactions);
}
builder.build()
}
/// Извлекает информацию о reply из сообщения
pub fn extract_reply_info(client: &TdClient, message: &TdMessage) -> Option<ReplyInfo> {
use tdlib_rs::enums::MessageReplyTo;
match &message.reply_to {
Some(MessageReplyTo::Message(reply)) => {
// Получаем имя отправителя из origin или ищем сообщение в текущем списке
let sender_name = reply
.origin
.as_ref()
.map(get_origin_sender_name)
.unwrap_or_else(|| {
// Пробуем найти оригинальное сообщение в текущем списке
let reply_msg_id = MessageId::new(reply.message_id);
client
.current_chat_messages()
.iter()
.find(|m| m.id() == reply_msg_id)
.map(|m| m.sender_name().to_string())
.unwrap_or_else(|| "...".to_string())
});
// Получаем текст из content или quote
let reply_msg_id = MessageId::new(reply.message_id);
let text = reply
.quote
.as_ref()
.map(|q| q.text.text.clone())
.or_else(|| reply.content.as_ref().map(TdClient::extract_content_text))
.unwrap_or_else(|| {
// Пробуем найти в текущих сообщениях
client
.current_chat_messages()
.iter()
.find(|m| m.id() == reply_msg_id)
.map(|m| m.text().to_string())
.unwrap_or_default()
});
Some(ReplyInfo { message_id: reply_msg_id, sender_name, text })
}
_ => None,
}
}
/// Извлекает информацию о forward из сообщения
pub fn extract_forward_info(_client: &TdClient, message: &TdMessage) -> Option<ForwardInfo> {
message.forward_info.as_ref().map(|info| {
let sender_name = get_origin_sender_name(&info.origin);
ForwardInfo { sender_name }
})
}
/// Извлекает реакции из сообщения
pub fn extract_reactions(_client: &TdClient, message: &TdMessage) -> Vec<ReactionInfo> {
message
.interaction_info
.as_ref()
.and_then(|info| info.reactions.as_ref())
.map(|reactions| {
reactions
.reactions
.iter()
.filter_map(|reaction| {
let emoji = match &reaction.r#type {
tdlib_rs::enums::ReactionType::Emoji(e) => e.emoji.clone(),
tdlib_rs::enums::ReactionType::CustomEmoji(_) => return None,
};
Some(ReactionInfo {
emoji,
count: reaction.total_count,
is_chosen: reaction.is_chosen,
})
})
.collect()
})
.unwrap_or_default()
}
/// Получает имя отправителя из MessageOrigin
fn get_origin_sender_name(origin: &tdlib_rs::enums::MessageOrigin) -> String {
use tdlib_rs::enums::MessageOrigin;
match origin {
MessageOrigin::User(u) => format!("User_{}", u.sender_user_id),
MessageOrigin::Chat(c) => format!("Chat_{}", c.sender_chat_id),
MessageOrigin::Channel(c) => c.author_signature.clone(),
MessageOrigin::HiddenUser(h) => h.sender_name.clone(),
}
}
/// Обновляет reply info для сообщений, где данные не были загружены
/// Вызывается после загрузки истории, когда все сообщения уже в списке
#[allow(dead_code)]
pub fn update_reply_info_from_loaded_messages(client: &mut TdClient) {
// Собираем данные для обновления (id -> (sender_name, content))
let msg_data: std::collections::HashMap<i64, (String, String)> = client
.current_chat_messages()
.iter()
.map(|m| (m.id().as_i64(), (m.sender_name().to_string(), m.text().to_string())))
.collect();
// Обновляем reply_to для сообщений с неполными данными
client.update_current_chat_messages(|messages| {
for msg in messages {
let Some(ref mut reply) = msg.interactions.reply_to else {
continue;
};
// Если sender_name = "..." или text пустой — пробуем заполнить
if reply.sender_name != "..." && !reply.text.is_empty() {
continue;
}
let Some((sender, content)) = msg_data.get(&reply.message_id.as_i64()) else {
continue;
};
if reply.sender_name == "..." {
reply.sender_name = sender.clone();
}
if reply.text.is_empty() {
reply.text = content.clone();
}
}
});
}

View File

@@ -1,142 +0,0 @@
//! TDLib message conversion: JSON → MessageInfo, reply info fetching.
use crate::types::{ChatId, MessageId};
use tdlib_rs::functions;
use tdlib_rs::types::Message as TdMessage;
use crate::tdlib::types::{MessageBuilder, MessageInfo};
use super::MessageManager;
impl MessageManager {
/// Конвертировать TdMessage в MessageInfo
pub(crate) async fn convert_message(
&self,
msg: &TdMessage,
last_read_outbox_message_id: MessageId,
) -> Option<MessageInfo> {
use crate::tdlib::message_conversion::{
extract_content_text, extract_entities, extract_forward_info, extract_media_info,
extract_reactions, extract_reply_info, extract_sender_name,
};
// Извлекаем все части сообщения используя вспомогательные функции
let content_text = extract_content_text(msg);
let entities = extract_entities(msg);
let sender_name = extract_sender_name(msg, self.client_id).await;
let forward_from = extract_forward_info(msg);
let reply_to = extract_reply_info(msg);
let reactions = extract_reactions(msg);
let media = extract_media_info(msg);
let mut builder = MessageBuilder::new(MessageId::new(msg.id))
.sender_name(sender_name)
.text(content_text)
.entities(entities)
.date(msg.date)
.edit_date(msg.edit_date)
.media_album_id(msg.media_album_id);
if msg.is_outgoing {
builder = builder.outgoing();
} else {
builder = builder.incoming();
}
let is_read = !msg.is_outgoing || msg.id <= last_read_outbox_message_id.as_i64();
if is_read {
builder = builder.read();
} else {
builder = builder.unread();
}
if msg.can_be_edited {
builder = builder.editable();
}
if msg.can_be_deleted_only_for_self {
builder = builder.deletable_for_self();
}
if msg.can_be_deleted_for_all_users {
builder = builder.deletable_for_all();
}
if let Some(reply) = reply_to {
builder = builder.reply_to(reply);
}
if let Some(forward) = forward_from {
builder = builder.forward_from(forward);
}
builder = builder.reactions(reactions);
if let Some(media) = media {
builder = builder.media(media);
}
Some(builder.build())
}
/// Загружает недостающую информацию об исходных сообщениях для ответов.
///
/// Ищет все reply-сообщения с `sender_name == "Unknown"` и загружает
/// полную информацию (имя отправителя, текст) из TDLib.
///
/// # Note
///
/// Вызывайте после загрузки истории чата для заполнения информации о цитируемых сообщениях.
pub async fn fetch_missing_reply_info(&mut self) {
// Early return if no chat selected
let Some(chat_id) = self.current_chat_id else {
return;
};
// Collect message IDs with missing reply info using filter_map
let to_fetch: Vec<MessageId> = self
.current_chat_messages
.iter()
.filter_map(|msg| {
msg.interactions
.reply_to
.as_ref()
.filter(|reply| reply.sender_name == "Unknown")
.map(|reply| reply.message_id)
})
.collect();
// Fetch and update each missing message
for message_id in to_fetch {
self.fetch_and_update_reply(chat_id, message_id).await;
}
}
/// Загружает одно сообщение и обновляет reply информацию.
async fn fetch_and_update_reply(&mut self, chat_id: ChatId, message_id: MessageId) {
// Try to fetch the original message
let Ok(original_msg_enum) =
functions::get_message(chat_id.as_i64(), message_id.as_i64(), self.client_id).await
else {
return;
};
let tdlib_rs::enums::Message::Message(original_msg) = original_msg_enum;
let Some(orig_info) = self.convert_message(&original_msg, MessageId::new(0)).await else {
return;
};
// Extract text preview (first 50 chars)
let text_preview: String = orig_info.content.text.chars().take(50).collect();
// Update reply info in all messages that reference this message
self.current_chat_messages
.iter_mut()
.filter_map(|msg| msg.interactions.reply_to.as_mut())
.filter(|reply| reply.message_id == message_id)
.for_each(|reply| {
reply.sender_name = orig_info.metadata.sender_name.clone();
reply.text = text_preview.clone();
});
}
}

View File

@@ -1,102 +0,0 @@
//! Message management: storage, conversion, and TDLib API operations.
mod convert;
mod operations;
use crate::constants::MAX_MESSAGES_IN_CHAT;
use crate::types::{ChatId, MessageId};
use super::types::MessageInfo;
/// Менеджер сообщений TDLib.
///
/// Управляет загрузкой, отправкой, редактированием и удалением сообщений.
/// Кеширует сообщения текущего открытого чата и закрепленные сообщения.
///
/// # Основные возможности
///
/// - Загрузка истории сообщений чата
/// - Отправка текстовых сообщений с поддержкой Markdown
/// - Редактирование и удаление сообщений
/// - Пересылка сообщений между чатами
/// - Поиск сообщений по тексту
/// - Управление закрепленными сообщениями
/// - Управление черновиками
/// - Автоматическая отметка сообщений как прочитанных
///
/// # Examples
///
/// ```ignore
/// let mut msg_manager = MessageManager::new(client_id);
///
/// // Загрузить историю чата
/// let messages = msg_manager.get_chat_history(chat_id, 50).await?;
///
/// // Отправить сообщение
/// let msg = msg_manager.send_message(
/// chat_id,
/// "Hello, **world**!".to_string(),
/// None,
/// None
/// ).await?;
/// ```
pub struct MessageManager {
/// Список сообщений текущего открытого чата (до MAX_MESSAGES_IN_CHAT).
pub current_chat_messages: Vec<MessageInfo>,
/// ID текущего открытого чата.
pub current_chat_id: Option<ChatId>,
/// Текущее закрепленное сообщение открытого чата.
pub current_pinned_message: Option<MessageInfo>,
/// Очередь сообщений для отметки как прочитанных: (chat_id, message_ids).
pub pending_view_messages: Vec<(ChatId, Vec<MessageId>)>,
/// ID клиента TDLib для API вызовов.
pub(crate) client_id: i32,
}
impl MessageManager {
/// Создает новый менеджер сообщений.
///
/// # Arguments
///
/// * `client_id` - ID клиента TDLib для API вызовов
///
/// # Returns
///
/// Новый экземпляр `MessageManager` с пустым списком сообщений.
pub fn new(client_id: i32) -> Self {
Self {
current_chat_messages: Vec::new(),
current_chat_id: None,
current_pinned_message: None,
pending_view_messages: Vec::new(),
client_id,
}
}
/// Добавляет сообщение в список текущего чата.
///
/// Автоматически ограничивает размер списка до [`MAX_MESSAGES_IN_CHAT`],
/// удаляя старые сообщения при превышении лимита.
///
/// # Arguments
///
/// * `msg` - Сообщение для добавления
///
/// # Note
///
/// Сообщение добавляется в конец списка. При превышении лимита
/// удаляются самые старые сообщения из начала списка.
pub fn push_message(&mut self, msg: MessageInfo) {
self.current_chat_messages.push(msg); // Добавляем в конец
// Ограничиваем размер списка (удаляем старые с начала)
if self.current_chat_messages.len() > MAX_MESSAGES_IN_CHAT {
self.current_chat_messages
.drain(0..(self.current_chat_messages.len() - MAX_MESSAGES_IN_CHAT));
}
}
}

View File

@@ -1,625 +0,0 @@
//! TDLib message API operations: history, send, edit, delete, forward, search.
use crate::constants::TDLIB_MESSAGE_LIMIT;
use crate::types::{ChatId, MessageId};
use tdlib_rs::enums::{
InputMessageContent, InputMessageReplyTo, SearchMessagesFilter, TextParseMode,
};
use tdlib_rs::functions;
use tdlib_rs::types::{
FormattedText, InputMessageReplyToMessage, InputMessageText, TextParseModeMarkdown,
};
use tokio::time::{sleep, Duration};
use crate::tdlib::types::{MessageInfo, ReplyInfo};
use super::MessageManager;
impl MessageManager {
/// Загружает историю сообщений чата с динамической подгрузкой.
///
/// Загружает сообщения чанками, ожидая пока TDLib синхронизирует их с сервера.
/// Продолжает загрузку пока не будет достигнут `limit` или пока TDLib отдает сообщения.
///
/// # Arguments
///
/// * `chat_id` - ID чата
/// * `limit` - Желаемое минимальное количество сообщений (для заполнения экрана)
///
/// # Returns
///
/// * `Ok(Vec<MessageInfo>)` - Список сообщений (от старых к новым)
/// * `Err(String)` - Ошибка загрузки
///
/// # Examples
///
/// ```ignore
/// // Загрузить достаточно сообщений для экрана высотой 30 строк
/// let messages = msg_manager.get_chat_history(chat_id, 30).await?;
/// ```
pub async fn get_chat_history(
&mut self,
chat_id: ChatId,
limit: i32,
last_read_outbox_message_id: MessageId,
) -> Result<Vec<MessageInfo>, String> {
// ВАЖНО: Сначала открываем чат в TDLib
// Это сообщает TDLib что пользователь открыл чат и нужно загрузить историю
let _ = functions::open_chat(chat_id.as_i64(), self.client_id).await;
// Открываем чат - TDLib начнет синхронизацию автоматически
// НЕ устанавливаем current_chat_id здесь!
// Он будет установлен снаружи ПОСЛЕ сохранения истории
// Это предотвращает race condition с Update::NewMessage
let mut all_messages = Vec::new();
let mut from_message_id = 0i64; // 0 = начинаем с последних сообщений
let max_attempts_per_chunk = 20; // Максимум попыток на чанк
let mut consecutive_empty_results = 0; // Счетчик пустых результатов подряд
// Загружаем чанками по TDLIB_MESSAGE_LIMIT пока не достигнем limit
while (all_messages.len() as i32) < limit {
let remaining = limit - (all_messages.len() as i32);
let chunk_size = std::cmp::min(TDLIB_MESSAGE_LIMIT, remaining);
let mut chunk_loaded = false;
// Пробуем загрузить чанк (TDLib подгружает с сервера по мере готовности)
for attempt in 1..=max_attempts_per_chunk {
let result = functions::get_chat_history(
chat_id.as_i64(),
from_message_id,
0, // offset
chunk_size,
false, // only_local - false means can fetch from server
self.client_id,
)
.await;
let messages_obj = match result {
Ok(tdlib_rs::enums::Messages::Messages(obj)) => obj,
Err(e) => {
// При первой загрузке (from_message_id == 0) возвращаем ошибку
// При последующих чанках - прерываем цикл (возможно кончились сообщения)
if all_messages.is_empty() {
return Err(format!("Ошибка загрузки истории: {:?}", e));
} else {
break;
}
}
};
let received_count = messages_obj.messages.len();
// Если получили пустой результат
if messages_obj.messages.is_empty() {
consecutive_empty_results += 1;
// Если несколько раз подряд пусто - прерываем
if consecutive_empty_results >= 3 {
break;
}
// Пробуем еще раз
continue;
}
// Получили сообщения - сбрасываем счетчик
consecutive_empty_results = 0;
// Если это первая загрузка и получили мало сообщений - продолжаем попытки
// TDLib может подгружать данные с сервера постепенно
if all_messages.is_empty()
&& received_count < (chunk_size as usize)
&& attempt < max_attempts_per_chunk
{
// Даём TDLib время на синхронизацию с сервером
sleep(Duration::from_millis(100)).await;
continue;
}
// Конвертируем сообщения (от новых к старым, потом реверсим)
let mut chunk_messages = Vec::new();
for msg in messages_obj.messages.iter().flatten() {
if let Some(info) = self.convert_message(msg, last_read_outbox_message_id).await
{
chunk_messages.push(info);
}
}
// Реверсим чтобы получить порядок от старых к новым
chunk_messages.reverse();
// Добавляем загруженные сообщения
if !chunk_messages.is_empty() {
// Для следующей итерации: ID самого старого сообщения из текущего чанка
from_message_id = chunk_messages[0].id().as_i64();
// ВАЖНО: Вставляем чанк В НАЧАЛО списка!
// Первый чанк содержит НОВЫЕ сообщения (например 51-100)
// Второй чанк содержит СТАРЫЕ сообщения (например 1-50)
// Поэтому более старые чанки должны быть в начале списка
if all_messages.is_empty() {
// Первый чанк - просто добавляем
all_messages = chunk_messages;
} else {
// Последующие чанки - вставляем в начало
all_messages.splice(0..0, chunk_messages);
}
chunk_loaded = true;
}
// Если получили меньше чем chunk_size, значит это последний доступный чанк
if (messages_obj.messages.len() as i32) < chunk_size {
return Ok(all_messages);
}
break; // Чанк успешно загружен
}
// Если чанк не загрузился после всех попыток - прерываем
if !chunk_loaded {
break;
}
}
Ok(all_messages)
}
/// Загружает более старые сообщения для пагинации.
///
/// Используется для подгрузки предыдущих сообщений при прокрутке
/// истории чата вверх.
///
/// # Arguments
///
/// * `chat_id` - ID чата
/// * `from_message_id` - ID сообщения, от которого загружать историю
///
/// # Returns
///
/// * `Ok(Vec<MessageInfo>)` - Список старых сообщений (от старых к новым)
/// * `Err(String)` - Ошибка загрузки
///
/// # Examples
///
/// ```ignore
/// // Загрузить сообщения старше указанного
/// let older = msg_manager.load_older_messages(
/// chat_id,
/// MessageId::new(12345)
/// ).await?;
/// ```
pub async fn load_older_messages(
&mut self,
chat_id: ChatId,
from_message_id: MessageId,
last_read_outbox_message_id: MessageId,
) -> Result<Vec<MessageInfo>, String> {
let result = functions::get_chat_history(
chat_id.as_i64(),
from_message_id.as_i64(),
0, // offset
TDLIB_MESSAGE_LIMIT,
false,
self.client_id,
)
.await;
match result {
Ok(tdlib_rs::enums::Messages::Messages(messages_obj)) => {
let mut messages = Vec::new();
for msg in messages_obj.messages.iter().rev().flatten() {
if let Some(info) = self.convert_message(msg, last_read_outbox_message_id).await
{
messages.push(info);
}
}
Ok(messages)
}
Err(e) => Err(format!("Ошибка загрузки старых сообщений: {:?}", e)),
}
}
/// Получает все закрепленные сообщения чата.
///
/// Выполняет поиск всех сообщений с фильтром "pinned" и возвращает их список.
///
/// # Arguments
///
/// * `chat_id` - ID чата
///
/// # Returns
///
/// * `Ok(Vec<MessageInfo>)` - Список закрепленных сообщений (до 100)
/// * `Err(String)` - Ошибка загрузки
///
/// # Examples
///
/// ```ignore
/// let pinned = msg_manager.get_pinned_messages(chat_id).await?;
/// println!("Found {} pinned messages", pinned.len());
/// ```
pub async fn get_pinned_messages(
&mut self,
chat_id: ChatId,
last_read_outbox_message_id: MessageId,
) -> Result<Vec<MessageInfo>, String> {
let result = functions::search_chat_messages(
chat_id.as_i64(),
String::new(),
None,
0, // from_message_id
0, // offset
100, // limit
Some(SearchMessagesFilter::Pinned),
0, // message_thread_id
0, // saved_messages_topic_id
self.client_id,
)
.await;
match result {
Ok(tdlib_rs::enums::FoundChatMessages::FoundChatMessages(messages_obj)) => {
let mut pinned_messages = Vec::new();
for msg in messages_obj.messages.iter().rev() {
if let Some(info) = self.convert_message(msg, last_read_outbox_message_id).await
{
pinned_messages.push(info);
}
}
Ok(pinned_messages)
}
Err(e) => Err(format!("Ошибка загрузки закреплённых: {:?}", e)),
}
}
/// Загружает текущее верхнее закрепленное сообщение.
///
/// # Arguments
///
/// * `chat_id` - ID чата
///
/// # Compatibility
///
/// The current `tdlib-rs` schema no longer exposes `Chat.pinned_message_id`, and the
/// generated wrapper does not provide `getChatPinnedMessage`. The pinned-message modal
/// 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) {
self.current_pinned_message = None;
}
/// Выполняет поиск сообщений по тексту в указанном чате.
///
/// # Arguments
///
/// * `chat_id` - ID чата для поиска
/// * `query` - Текстовый запрос для поиска
///
/// # Returns
///
/// * `Ok(Vec<MessageInfo>)` - Найденные сообщения (до 100)
/// * `Err(String)` - Ошибка поиска
///
/// # Examples
///
/// ```ignore
/// let results = msg_manager.search_messages(chat_id, "hello").await?;
/// ```
pub async fn search_messages(
&self,
chat_id: ChatId,
query: &str,
last_read_outbox_message_id: MessageId,
) -> Result<Vec<MessageInfo>, String> {
let result = functions::search_chat_messages(
chat_id.as_i64(),
query.to_string(),
None,
0, // from_message_id
0, // offset
100, // limit
None,
0, // message_thread_id
0, // saved_messages_topic_id
self.client_id,
)
.await;
match result {
Ok(tdlib_rs::enums::FoundChatMessages::FoundChatMessages(messages_obj)) => {
let mut search_results = Vec::new();
for msg in messages_obj.messages.iter().rev() {
if let Some(info) = self.convert_message(msg, last_read_outbox_message_id).await
{
search_results.push(info);
}
}
Ok(search_results)
}
Err(e) => Err(format!("Ошибка поиска: {:?}", e)),
}
}
/// Отправляет текстовое сообщение в чат с поддержкой Markdown.
///
/// Автоматически парсит Markdown v2 форматирование (**bold**, *italic*, `code` и т.д.).
///
/// # Arguments
///
/// * `chat_id` - ID чата-получателя
/// * `text` - Текст сообщения (поддерживает Markdown v2)
/// * `reply_to_message_id` - Опциональный ID сообщения для ответа
/// * `reply_info` - Опциональная информация об исходном сообщении
///
/// # Returns
///
/// * `Ok(MessageInfo)` - Отправленное сообщение
/// * `Err(String)` - Ошибка отправки
///
/// # Examples
///
/// ```ignore
/// // Простое сообщение
/// let msg = msg_manager.send_message(
/// chat_id,
/// "Hello, **world**!".to_string(),
/// None,
/// None
/// ).await?;
///
/// // Ответ на сообщение
/// let reply = msg_manager.send_message(
/// chat_id,
/// "Got it!".to_string(),
/// Some(MessageId::new(123)),
/// Some(reply_info)
/// ).await?;
/// ```
pub async fn send_message(
&self,
chat_id: ChatId,
text: String,
reply_to_message_id: Option<MessageId>,
reply_info: Option<ReplyInfo>,
last_read_outbox_message_id: MessageId,
) -> Result<MessageInfo, String> {
// Парсим markdown в тексте
let formatted_text = match functions::parse_text_entities(
text.clone(),
TextParseMode::Markdown(TextParseModeMarkdown { version: 2 }),
self.client_id,
)
.await
{
Ok(tdlib_rs::enums::FormattedText::FormattedText(ft)) => {
FormattedText { text: ft.text, entities: ft.entities }
}
Err(_) => FormattedText { text: text.clone(), entities: vec![] },
};
let content = InputMessageContent::InputMessageText(InputMessageText {
text: formatted_text,
link_preview_options: None,
clear_draft: true,
});
let reply_to = reply_to_message_id.map(|msg_id| {
InputMessageReplyTo::Message(InputMessageReplyToMessage {
chat_id: 0,
message_id: msg_id.as_i64(),
quote: None,
})
});
let result = functions::send_message(
chat_id.as_i64(),
0, // message_thread_id
reply_to,
None, // options
content,
self.client_id,
)
.await;
match result {
Ok(tdlib_rs::enums::Message::Message(msg)) => {
let mut msg_info = self
.convert_message(&msg, last_read_outbox_message_id)
.await
.ok_or_else(|| "Не удалось конвертировать сообщение".to_string())?;
// Добавляем reply_info если был передан
if let Some(reply) = reply_info {
msg_info.interactions.reply_to = Some(reply);
}
Ok(msg_info)
}
Err(e) => Err(format!("Ошибка отправки сообщения: {:?}", e)),
}
}
/// Редактирует существующее сообщение.
///
/// # Arguments
///
/// * `chat_id` - ID чата
/// * `message_id` - ID сообщения для редактирования
/// * `text` - Новый текст (поддерживает Markdown v2)
///
/// # Returns
///
/// * `Ok(MessageInfo)` - Отредактированное сообщение
/// * `Err(String)` - Ошибка (нет прав, сообщение слишком старое и т.д.)
pub async fn edit_message(
&self,
chat_id: ChatId,
message_id: MessageId,
text: String,
last_read_outbox_message_id: MessageId,
) -> Result<MessageInfo, String> {
let formatted_text = match functions::parse_text_entities(
text.clone(),
TextParseMode::Markdown(TextParseModeMarkdown { version: 2 }),
self.client_id,
)
.await
{
Ok(tdlib_rs::enums::FormattedText::FormattedText(ft)) => {
FormattedText { text: ft.text, entities: ft.entities }
}
Err(_) => FormattedText { text: text.clone(), entities: vec![] },
};
let content = InputMessageContent::InputMessageText(InputMessageText {
text: formatted_text,
link_preview_options: None,
clear_draft: true,
});
let result = functions::edit_message_text(
chat_id.as_i64(),
message_id.as_i64(),
content,
self.client_id,
)
.await;
match result {
Ok(tdlib_rs::enums::Message::Message(msg)) => self
.convert_message(&msg, last_read_outbox_message_id)
.await
.ok_or_else(|| "Не удалось конвертировать отредактированное сообщение".to_string()),
Err(e) => Err(format!("Ошибка редактирования: {:?}", e)),
}
}
/// Удаляет одно или несколько сообщений.
///
/// # Arguments
///
/// * `chat_id` - ID чата
/// * `message_ids` - Список ID сообщений для удаления
/// * `revoke` - `true` - удалить для всех, `false` - только для себя
///
/// # Returns
///
/// * `Ok(())` - Сообщения удалены
/// * `Err(String)` - Ошибка удаления
pub async fn delete_messages(
&self,
chat_id: ChatId,
message_ids: Vec<MessageId>,
revoke: bool,
) -> Result<(), String> {
let message_ids_i64: Vec<i64> = message_ids.into_iter().map(|id| id.as_i64()).collect();
let result =
functions::delete_messages(chat_id.as_i64(), message_ids_i64, revoke, self.client_id)
.await;
match result {
Ok(_) => Ok(()),
Err(e) => Err(format!("Ошибка удаления: {:?}", e)),
}
}
/// Пересылает сообщения из одного чата в другой.
///
/// # Arguments
///
/// * `to_chat_id` - ID чата-получателя
/// * `from_chat_id` - ID чата-источника
/// * `message_ids` - Список ID сообщений для пересылки
///
/// # Returns
///
/// * `Ok(())` - Сообщения переслань
/// * `Err(String)` - Ошибка пересылки
pub async fn forward_messages(
&self,
to_chat_id: ChatId,
from_chat_id: ChatId,
message_ids: Vec<MessageId>,
) -> Result<(), String> {
let message_ids_i64: Vec<i64> = message_ids.into_iter().map(|id| id.as_i64()).collect();
let result = functions::forward_messages(
to_chat_id.as_i64(),
0, // message_thread_id
from_chat_id.as_i64(),
message_ids_i64,
None, // options
false, // send_copy
false, // remove_caption
self.client_id,
)
.await;
match result {
Ok(_) => Ok(()),
Err(e) => Err(format!("Ошибка пересылки: {:?}", e)),
}
}
/// Сохраняет черновик сообщения для чата.
///
/// Черновик отображается в списке чатов и восстанавливается
/// при следующем открытии чата.
///
/// # Arguments
///
/// * `chat_id` - ID чата
/// * `text` - Текст черновика (пустая строка удаляет черновик)
///
/// # Returns
///
/// * `Ok(())` - Черновик сохранен
/// * `Err(String)` - Ошибка сохранения
pub async fn set_draft_message(&self, chat_id: ChatId, text: String) -> Result<(), String> {
use tdlib_rs::types::DraftMessage;
let draft = if text.is_empty() {
None
} else {
Some(DraftMessage {
reply_to: None,
date: 0,
input_message_text: InputMessageContent::InputMessageText(InputMessageText {
text: FormattedText { text: text.clone(), entities: vec![] },
link_preview_options: None,
clear_draft: false,
}),
})
};
let result =
functions::set_chat_draft_message(chat_id.as_i64(), 0, draft, self.client_id).await;
match result {
Ok(_) => Ok(()),
Err(e) => Err(format!("Ошибка сохранения черновика: {:?}", e)),
}
}
/// Обрабатывает очередь сообщений для отметки как прочитанных.
///
/// Автоматически отмечает просмотренные сообщения как прочитанные,
/// что сбрасывает счетчик непрочитанных сообщений в чате.
///
/// # Note
///
/// Вызывайте периодически (например, в основном цикле) для обработки накопленной очереди.
pub async fn process_pending_view_messages(&mut self) {
if self.pending_view_messages.is_empty() {
return;
}
let batch = std::mem::take(&mut self.pending_view_messages);
for (chat_id, message_ids) in batch {
let ids: Vec<i64> = message_ids.iter().map(|id| id.as_i64()).collect();
let _ =
functions::view_messages(chat_id.as_i64(), ids, None, true, self.client_id).await;
}
}
}

View File

@@ -1,37 +0,0 @@
// Модули
pub mod auth;
mod chat_helpers; // Chat management helpers
pub mod chats;
pub mod client;
mod client_impl; // Private module for trait implementation
pub mod message_conversion; // Message conversion utilities (for messages.rs)
mod message_converter; // Message conversion utilities (for client.rs)
pub mod messages;
pub mod reactions;
pub mod r#trait;
pub mod types;
mod update_handlers; // Update handlers extracted from client
pub mod users;
// Экспорт основных типов
pub use auth::AuthState;
pub use client::TdClient;
#[allow(unused_imports)]
pub use r#trait::{
AccountClient, AuthClient, ChatActionClient, ChatClient, ClientState, FileClient,
MessageClient, ReactionClient, TdClientTrait, UpdateClient, UserClient,
};
#[allow(unused_imports)]
pub use types::{
ChatInfo, FolderInfo, MediaInfo, MessageBuilder, MessageInfo, NetworkState, PhotoDownloadState,
PhotoInfo, PlaybackState, PlaybackStatus, ProfileInfo, ReplyInfo, UserOnlineStatus,
VoiceDownloadState, VoiceInfo,
};
pub use client::{IncomingMessageEvent, TdClientConfig, TdCredentials};
#[cfg(feature = "images")]
pub use types::ImageModalState;
pub use users::UserCache;
// Re-export ChatAction для удобства
pub use tdlib_rs::enums::ChatAction;

View File

@@ -1,230 +0,0 @@
use crate::types::{ChatId, MessageId};
use tdlib_rs::enums::{AvailableReactions, ReactionType};
use tdlib_rs::functions;
use tdlib_rs::types::{AvailableReaction, ReactionTypeEmoji};
/// Менеджер реакций на сообщения.
///
/// Управляет добавлением, удалением и получением списка доступных
/// реакций (emoji) для сообщений в чатах.
///
/// # Examples
///
/// ```ignore
/// let reaction_manager = ReactionManager::new(client_id);
///
/// // Получить доступные реакции
/// let reactions = reaction_manager.get_message_available_reactions(
/// chat_id,
/// message_id
/// ).await?;
///
/// // Добавить/удалить реакцию
/// reaction_manager.toggle_reaction(chat_id, message_id, "👍".to_string()).await?;
/// ```
pub struct ReactionManager {
/// ID клиента TDLib для API вызовов.
client_id: i32,
}
impl ReactionManager {
/// Создает новый менеджер реакций.
///
/// # Arguments
///
/// * `client_id` - ID клиента TDLib для API вызовов
pub fn new(client_id: i32) -> Self {
Self { client_id }
}
/// Получает список доступных реакций для сообщения.
///
/// # Arguments
///
/// * `chat_id` - ID чата
/// * `message_id` - ID сообщения
///
/// # Returns
///
/// * `Ok(Vec<String>)` - Список доступных emoji реакций
/// * `Err(String)` - Ошибка получения
///
/// # Examples
///
/// ```ignore
/// let reactions = manager.get_message_available_reactions(
/// ChatId::new(123),
/// MessageId::new(456)
/// ).await?;
/// println!("Available: {:?}", reactions);
/// ```
pub async fn get_message_available_reactions(
&self,
chat_id: ChatId,
message_id: MessageId,
) -> Result<Vec<String>, String> {
// Получаем сообщение
let msg_result =
functions::get_message(chat_id.as_i64(), message_id.as_i64(), self.client_id).await;
let _msg = match msg_result {
Ok(m) => m,
Err(e) => return Err(format!("Ошибка получения сообщения: {:?}", e)),
};
// Получаем доступные реакции для чата
let reactions_result = functions::get_message_available_reactions(
chat_id.as_i64(),
message_id.as_i64(),
10, // row_size
self.client_id,
)
.await;
match reactions_result {
Ok(available) => {
let emojis = available_reaction_emojis(&available);
if emojis.is_empty() {
Ok(default_reaction_emojis())
} else {
Ok(emojis)
}
}
Err(_) => Ok(default_reaction_emojis()),
}
}
/// Переключает реакцию на сообщение (добавляет/удаляет).
///
/// Сначала пытается добавить реакцию. Если не удалось (уже есть),
/// то удаляет её.
///
/// # Arguments
///
/// * `chat_id` - ID чата
/// * `message_id` - ID сообщения
/// * `emoji` - Emoji реакции (например, "👍", "❤️")
///
/// # Returns
///
/// * `Ok(())` - Реакция переключена
/// * `Err(String)` - Ошибка переключения
///
/// # Examples
///
/// ```ignore
/// // Добавить или удалить 👍
/// manager.toggle_reaction(chat_id, message_id, "👍".to_string()).await?;
/// ```
pub async fn toggle_reaction(
&self,
chat_id: ChatId,
message_id: MessageId,
emoji: String,
) -> Result<(), String> {
let reaction = ReactionType::Emoji(ReactionTypeEmoji { emoji });
let result = functions::add_message_reaction(
chat_id.as_i64(),
message_id.as_i64(),
reaction.clone(),
false, // is_big
false, // update_recent_reactions
self.client_id,
)
.await;
match result {
Ok(_) => Ok(()),
Err(_) => {
// Если добавление не удалось, пытаемся удалить
let remove_result = functions::remove_message_reaction(
chat_id.as_i64(),
message_id.as_i64(),
reaction,
self.client_id,
)
.await;
match remove_result {
Ok(_) => Ok(()),
Err(e) => Err(format!("Ошибка переключения реакции: {:?}", e)),
}
}
}
}
}
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(),
]
);
}
}

View File

@@ -1,218 +0,0 @@
//! Trait definition for TdClient to enable dependency injection
//!
//! This trait allows tests to use FakeTdClient instead of real TDLib client.
#![allow(dead_code)]
use crate::tdlib::{
AuthState, FolderInfo, IncomingMessageEvent, MessageInfo, ProfileInfo, 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};
use super::ChatInfo;
/// Auth operations.
#[async_trait]
pub trait AuthClient: Send {
async fn send_phone_number(&self, phone: String) -> Result<(), String>;
async fn send_code(&self, code: String) -> Result<(), String>;
async fn send_password(&self, password: String) -> Result<(), String>;
}
/// Chat list and profile operations.
#[async_trait]
pub trait ChatClient: Send {
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 leave_chat(&self, chat_id: ChatId) -> Result<(), String>;
async fn get_profile_info(&self, chat_id: ChatId) -> Result<ProfileInfo, String>;
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);
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 history, search, and mutation operations.
#[async_trait]
pub trait MessageClient: Send {
async fn get_chat_history(
&mut self,
chat_id: ChatId,
limit: i32,
) -> Result<Vec<MessageInfo>, String>;
async fn load_older_messages(
&mut self,
chat_id: ChatId,
from_message_id: MessageId,
) -> Result<Vec<MessageInfo>, String>;
async fn get_pinned_messages(&mut self, chat_id: ChatId) -> Result<Vec<MessageInfo>, String>;
async fn load_current_pinned_message(&mut self, chat_id: ChatId);
async fn search_messages(
&self,
chat_id: ChatId,
query: &str,
) -> Result<Vec<MessageInfo>, String>;
async fn send_message(
&mut self,
chat_id: ChatId,
text: String,
reply_to_message_id: Option<MessageId>,
reply_info: Option<super::ReplyInfo>,
) -> Result<MessageInfo, String>;
async fn edit_message(
&mut self,
chat_id: ChatId,
message_id: MessageId,
new_text: String,
) -> Result<MessageInfo, String>;
async fn delete_messages(
&mut self,
chat_id: ChatId,
message_ids: Vec<MessageId>,
revoke: bool,
) -> Result<(), String>;
async fn forward_messages(
&mut self,
to_chat_id: ChatId,
from_chat_id: ChatId,
message_ids: Vec<MessageId>,
) -> 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 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 process_pending_view_messages(&mut self);
}
/// 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 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);
}
/// Message reaction operations.
#[async_trait]
pub trait ReactionClient: Send {
async fn get_message_available_reactions(
&self,
chat_id: ChatId,
message_id: MessageId,
) -> Result<Vec<String>, String>;
async fn toggle_reaction(
&self,
chat_id: ChatId,
message_id: MessageId,
reaction: String,
) -> Result<(), String>;
}
/// File download operations.
#[async_trait]
pub trait FileClient: Send {
async fn download_file(&self, file_id: i32) -> Result<String, String>;
async fn download_voice_note(&self, file_id: i32) -> Result<String, String>;
}
/// Shared client state that does not belong to one feature area.
#[async_trait]
pub trait ClientState: Send {
fn client_id(&self) -> i32;
async fn get_me(&self) -> Result<i64, String>;
fn auth_state(&self) -> &AuthState;
fn network_state(&self) -> super::types::NetworkState;
}
/// Account switching operations.
#[async_trait]
pub trait AccountClient: Send {
/// Recreates the client with a new database path (for account switching).
///
/// For real TdClient: closes old client, creates new one, inits TDLib parameters.
/// For FakeTdClient: no-op.
async fn recreate_client(&mut self, db_path: PathBuf) -> Result<(), String>;
}
/// 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
{
}

View File

@@ -1,724 +0,0 @@
use tdlib_rs::enums::TextEntityType;
use tdlib_rs::types::TextEntity;
use crate::types::{ChatId, MessageId};
#[derive(Debug, Clone)]
pub struct ChatInfo {
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,
/// ID последнего прочитанного исходящего сообщения (для галочек)
pub last_read_outbox_message_id: MessageId,
/// ID папок, в которых находится чат
pub folder_ids: Vec<i32>,
/// Чат замьючен (уведомления отключены)
pub is_muted: bool,
/// Черновик сообщения
pub draft_text: Option<String>,
}
/// Информация о сообщении, на которое отвечают
#[derive(Debug, Clone)]
pub struct ReplyInfo {
/// ID сообщения, на которое отвечают
pub message_id: MessageId,
/// Имя отправителя оригинального сообщения
pub sender_name: String,
/// Текст оригинального сообщения (превью)
pub text: String,
}
/// Информация о пересланном сообщении
#[derive(Debug, Clone)]
pub struct ForwardInfo {
/// Имя оригинального отправителя
pub sender_name: String,
}
/// Информация о реакции на сообщение
#[derive(Debug, Clone)]
pub struct ReactionInfo {
/// Эмодзи реакции (например, "👍")
pub emoji: String,
/// Количество людей, поставивших эту реакцию
pub count: i32,
/// Поставил ли текущий пользователь эту реакцию
pub is_chosen: bool,
}
/// Информация о медиа-контенте сообщения
#[derive(Debug, Clone)]
pub enum MediaInfo {
Photo(PhotoInfo),
Voice(VoiceInfo),
}
/// Информация о фотографии в сообщении
#[derive(Debug, Clone)]
pub struct PhotoInfo {
pub file_id: i32,
pub width: i32,
pub height: i32,
pub download_state: PhotoDownloadState,
}
/// Состояние загрузки фотографии
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub enum PhotoDownloadState {
NotDownloaded,
Downloading,
Downloaded(String),
Error(String),
}
/// Информация о голосовом сообщении
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct VoiceInfo {
pub file_id: i32,
pub duration: i32, // seconds
pub mime_type: String,
/// Waveform данные для визуализации (base64-encoded строка амплитуд)
pub waveform: String,
pub download_state: VoiceDownloadState,
}
/// Состояние загрузки голосового сообщения
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub enum VoiceDownloadState {
NotDownloaded,
Downloading,
Downloaded(String), // path to cached OGG file
Error(String),
}
/// Метаданные сообщения (ID, отправитель, время)
#[derive(Debug, Clone)]
pub struct MessageMetadata {
pub id: MessageId,
pub sender_name: String,
pub date: i32,
/// Дата редактирования (0 если не редактировалось)
pub edit_date: i32,
/// ID медиа-альбома (0 если не часть альбома)
pub media_album_id: i64,
}
/// Контент сообщения (текст и форматирование)
#[derive(Debug, Clone)]
pub struct MessageContent {
pub text: String,
/// Сущности форматирования (bold, italic, code и т.д.)
pub entities: Vec<TextEntity>,
/// Медиа-контент (фото, видео и т.д.)
pub media: Option<MediaInfo>,
}
/// Состояние и права доступа к сообщению
#[derive(Debug, Clone)]
pub struct MessageState {
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,
}
/// Взаимодействия с сообщением (reply, forward, reactions)
#[derive(Debug, Clone)]
pub struct MessageInteractions {
/// Информация о reply (если это ответ на сообщение)
pub reply_to: Option<ReplyInfo>,
/// Информация о forward (если сообщение переслано)
pub forward_from: Option<ForwardInfo>,
/// Реакции на сообщение
pub reactions: Vec<ReactionInfo>,
}
#[derive(Debug, Clone)]
pub struct MessageInfo {
pub metadata: MessageMetadata,
pub content: MessageContent,
pub state: MessageState,
pub interactions: MessageInteractions,
}
impl MessageInfo {
/// Создать новое сообщение
#[allow(clippy::too_many_arguments)]
pub fn new(
id: MessageId,
sender_name: String,
is_outgoing: bool,
content: String,
entities: Vec<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>,
) -> Self {
Self {
metadata: MessageMetadata {
id,
sender_name,
date,
edit_date,
media_album_id: 0,
},
content: MessageContent { text: content, entities, media: None },
state: MessageState {
is_outgoing,
is_read,
can_be_edited,
can_be_deleted_only_for_self,
can_be_deleted_for_all_users,
},
interactions: MessageInteractions { reply_to, forward_from, reactions },
}
}
// Удобные getter'ы для частых операций
pub fn id(&self) -> MessageId {
self.metadata.id
}
pub fn sender_name(&self) -> &str {
&self.metadata.sender_name
}
pub fn date(&self) -> i32 {
self.metadata.date
}
pub fn is_edited(&self) -> bool {
self.metadata.edit_date > 0
}
pub fn media_album_id(&self) -> i64 {
self.metadata.media_album_id
}
pub fn text(&self) -> &str {
&self.content.text
}
pub fn entities(&self) -> &[TextEntity] {
&self.content.entities
}
pub fn is_outgoing(&self) -> bool {
self.state.is_outgoing
}
pub fn is_read(&self) -> bool {
self.state.is_read
}
pub fn can_be_edited(&self) -> bool {
self.state.can_be_edited
}
pub fn can_be_deleted_only_for_self(&self) -> bool {
self.state.can_be_deleted_only_for_self
}
pub fn can_be_deleted_for_all_users(&self) -> bool {
self.state.can_be_deleted_for_all_users
}
/// Checks if the message contains a mention (@username or user mention)
pub fn has_mention(&self) -> bool {
self.content.entities.iter().any(|entity| {
matches!(entity.r#type, TextEntityType::Mention | TextEntityType::MentionName(_))
})
}
/// Проверяет, содержит ли сообщение фото
pub fn has_photo(&self) -> bool {
matches!(self.content.media, Some(MediaInfo::Photo(_)))
}
/// Возвращает ссылку на PhotoInfo (если есть)
pub fn photo_info(&self) -> Option<&PhotoInfo> {
match &self.content.media {
Some(MediaInfo::Photo(info)) => Some(info),
_ => None,
}
}
/// Возвращает мутабельную ссылку на PhotoInfo (если есть)
pub fn photo_info_mut(&mut self) -> Option<&mut PhotoInfo> {
match &mut self.content.media {
Some(MediaInfo::Photo(info)) => Some(info),
_ => None,
}
}
/// Проверяет, содержит ли сообщение голосовое
pub fn has_voice(&self) -> bool {
matches!(self.content.media, Some(MediaInfo::Voice(_)))
}
/// Возвращает ссылку на VoiceInfo (если есть)
pub fn voice_info(&self) -> Option<&VoiceInfo> {
match &self.content.media {
Some(MediaInfo::Voice(info)) => Some(info),
_ => None,
}
}
/// Возвращает мутабельную ссылку на VoiceInfo (если есть)
#[allow(dead_code)]
pub fn voice_info_mut(&mut self) -> Option<&mut VoiceInfo> {
match &mut self.content.media {
Some(MediaInfo::Voice(info)) => Some(info),
_ => None,
}
}
pub fn reply_to(&self) -> Option<&ReplyInfo> {
self.interactions.reply_to.as_ref()
}
pub fn forward_from(&self) -> Option<&ForwardInfo> {
self.interactions.forward_from.as_ref()
}
pub fn reactions(&self) -> &[ReactionInfo] {
&self.interactions.reactions
}
}
/// Builder для удобного создания MessageInfo с fluent API
///
/// # Примеры
///
/// ```
/// use tele_core::tdlib::MessageBuilder;
/// use tele_core::types::MessageId;
///
/// let message = MessageBuilder::new(MessageId::new(123))
/// .sender_name("Alice")
/// .text("Hello, world!")
/// .outgoing()
/// .date(1640000000)
/// .build();
/// ```
pub struct MessageBuilder {
id: MessageId,
sender_name: String,
is_outgoing: bool,
text: String,
entities: Vec<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: Option<MediaInfo>,
media_album_id: i64,
}
impl MessageBuilder {
/// Создать новый builder с обязательным ID сообщения
pub fn new(id: MessageId) -> Self {
Self {
id,
sender_name: String::new(),
is_outgoing: false,
text: String::new(),
entities: Vec::new(),
date: 0,
edit_date: 0,
is_read: false,
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::new(),
media: None,
media_album_id: 0,
}
}
/// Установить имя отправителя
pub fn sender_name(mut self, name: impl Into<String>) -> Self {
self.sender_name = name.into();
self
}
/// Пометить сообщение как исходящее
pub fn outgoing(mut self) -> Self {
self.is_outgoing = true;
self.can_be_edited = true;
self.can_be_deleted_for_all_users = true;
self
}
/// Пометить сообщение как входящее
pub fn incoming(mut self) -> Self {
self.is_outgoing = false;
self.can_be_edited = false;
self.can_be_deleted_for_all_users = false;
self
}
/// Установить текст сообщения
pub fn text(mut self, text: impl Into<String>) -> Self {
self.text = text.into();
self
}
/// Установить entities для форматирования
pub fn entities(mut self, entities: Vec<TextEntity>) -> Self {
self.entities = entities;
self
}
/// Установить дату сообщения (unix timestamp)
pub fn date(mut self, date: i32) -> Self {
self.date = date;
self
}
/// Установить дату редактирования (unix timestamp)
pub fn edit_date(mut self, edit_date: i32) -> Self {
self.edit_date = edit_date;
self
}
/// Пометить сообщение как прочитанное
pub fn read(mut self) -> Self {
self.is_read = true;
self
}
/// Пометить сообщение как непрочитанное
pub fn unread(mut self) -> Self {
self.is_read = false;
self
}
/// Разрешить редактирование
pub fn editable(mut self) -> Self {
self.can_be_edited = true;
self
}
/// Разрешить удаление только для себя
pub fn deletable_for_self(mut self) -> Self {
self.can_be_deleted_only_for_self = true;
self
}
/// Разрешить удаление для всех
pub fn deletable_for_all(mut self) -> Self {
self.can_be_deleted_for_all_users = true;
self
}
/// Установить информацию об ответе
pub fn reply_to(mut self, reply: ReplyInfo) -> Self {
self.reply_to = Some(reply);
self
}
/// Установить информацию о пересылке
pub fn forward_from(mut self, forward: ForwardInfo) -> Self {
self.forward_from = Some(forward);
self
}
/// Установить реакции
pub fn reactions(mut self, reactions: Vec<ReactionInfo>) -> Self {
self.reactions = reactions;
self
}
/// Установить медиа-контент
pub fn media(mut self, media: MediaInfo) -> Self {
self.media = Some(media);
self
}
/// Установить ID медиа-альбома
pub fn media_album_id(mut self, id: i64) -> Self {
self.media_album_id = id;
self
}
/// Построить MessageInfo из данных builder'а
pub fn build(self) -> MessageInfo {
let mut msg = MessageInfo::new(
self.id,
self.sender_name,
self.is_outgoing,
self.text,
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.content.media = self.media;
msg.metadata.media_album_id = self.media_album_id;
msg
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::MessageId;
#[test]
fn test_message_builder_basic() {
let message = MessageBuilder::new(MessageId::new(123))
.sender_name("Alice")
.text("Hello, world!")
.date(1640000000)
.build();
assert_eq!(message.id(), MessageId::new(123));
assert_eq!(message.sender_name(), "Alice");
assert_eq!(message.text(), "Hello, world!");
assert_eq!(message.date(), 1640000000);
assert!(!message.is_outgoing());
}
#[test]
fn test_message_builder_outgoing() {
let message = MessageBuilder::new(MessageId::new(456))
.sender_name("Me")
.text("Test message")
.outgoing()
.read()
.build();
assert!(message.is_outgoing());
assert!(message.is_read());
assert!(message.can_be_edited());
assert!(message.can_be_deleted_for_all_users());
}
#[test]
fn test_message_builder_edited() {
let message = MessageBuilder::new(MessageId::new(789))
.text("Original text")
.date(1640000000)
.edit_date(1640000060)
.build();
assert!(message.is_edited());
assert_eq!(message.metadata.edit_date, 1640000060);
}
#[test]
fn test_message_builder_with_reply() {
let reply = ReplyInfo {
message_id: MessageId::new(100),
sender_name: "Bob".to_string(),
text: "Original message".to_string(),
};
let message = MessageBuilder::new(MessageId::new(200))
.text("Reply text")
.reply_to(reply)
.build();
assert!(message.reply_to().is_some());
assert_eq!(message.reply_to().unwrap().sender_name, "Bob");
}
#[test]
fn test_message_builder_with_reactions() {
let reaction = ReactionInfo {
emoji: "👍".to_string(), count: 5, is_chosen: true
};
let message = MessageBuilder::new(MessageId::new(300))
.text("Cool message")
.reactions(vec![reaction.clone()])
.build();
assert_eq!(message.reactions().len(), 1);
assert_eq!(message.reactions()[0].emoji, "👍");
assert_eq!(message.reactions()[0].count, 5);
}
#[test]
fn test_message_builder_fluent_api() {
let message = MessageBuilder::new(MessageId::new(999))
.sender_name("Charlie")
.text("Complex message")
.date(1640000000)
.outgoing()
.read()
.editable()
.deletable_for_all()
.build();
assert_eq!(message.sender_name(), "Charlie");
assert_eq!(message.text(), "Complex message");
assert!(message.is_outgoing());
assert!(message.is_read());
assert!(message.can_be_edited());
assert!(message.can_be_deleted_for_all_users());
}
#[test]
fn test_message_has_mention() {
// Message without mentions
let message = MessageBuilder::new(MessageId::new(1))
.text("Hello world")
.build();
assert!(!message.has_mention());
// Message with @mention
let message_with_mention = MessageBuilder::new(MessageId::new(2))
.text("Hello @user")
.entities(vec![TextEntity {
offset: 6,
length: 5,
r#type: TextEntityType::Mention,
}])
.build();
assert!(message_with_mention.has_mention());
// Message with MentionName
let message_with_mention_name = MessageBuilder::new(MessageId::new(3))
.text("Hello John")
.entities(vec![TextEntity {
offset: 6,
length: 4,
r#type: TextEntityType::MentionName(tdlib_rs::types::TextEntityTypeMentionName {
user_id: 123,
}),
}])
.build();
assert!(message_with_mention_name.has_mention());
}
}
#[derive(Debug, Clone)]
pub struct FolderInfo {
pub id: i32,
pub name: String,
}
/// Информация о профиле чата/пользователя
#[derive(Debug, Clone)]
pub struct ProfileInfo {
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>,
}
/// Состояние сетевого соединения
#[derive(Debug, Clone, PartialEq)]
pub enum NetworkState {
/// Ожидание подключения к сети
WaitingForNetwork,
/// Подключение к прокси
ConnectingToProxy,
/// Подключение к серверам Telegram
Connecting,
/// Обновление данных
Updating,
/// Подключено
Ready,
}
/// Онлайн-статус пользователя
#[derive(Debug, Clone, PartialEq)]
pub enum UserOnlineStatus {
/// Онлайн
Online,
/// Был недавно (менее часа назад)
Recently,
/// Был на этой неделе
LastWeek,
/// Был в этом месяце
LastMonth,
/// Давно не был
LongTimeAgo,
/// Оффлайн с указанием времени (unix timestamp)
Offline(i32),
}
/// Состояние модального окна для просмотра изображения
#[cfg(feature = "images")]
#[derive(Debug, Clone)]
pub struct ImageModalState {
/// ID сообщения с фото
pub message_id: MessageId,
/// Путь к файлу изображения
pub photo_path: String,
/// Ширина оригинального изображения
pub photo_width: i32,
/// Высота оригинального изображения
pub photo_height: i32,
}
/// Состояние воспроизведения голосового сообщения
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct PlaybackState {
/// ID сообщения, которое воспроизводится
pub message_id: MessageId,
/// Статус воспроизведения
pub status: PlaybackStatus,
/// Текущая позиция (секунды)
pub position: f32,
/// Общая длительность (секунды)
pub duration: f32,
/// Громкость (0.0 - 1.0)
pub volume: f32,
}
/// Статус воспроизведения
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq)]
pub enum PlaybackStatus {
Playing,
Paused,
Stopped,
Loading,
Error(String),
}

View File

@@ -1,324 +0,0 @@
//! Update handlers for TDLib events.
//!
//! This module contains functions that process various types of updates from TDLib.
//! Each handler is responsible for updating the application state based on the received update.
use crate::types::{ChatId, MessageId, UserId};
use std::time::Instant;
use tdlib_rs::enums::{AuthorizationState, ChatAction, ChatList, MessageSender};
use tdlib_rs::types::{
UpdateChatAction, UpdateChatDraftMessage, UpdateChatPosition, UpdateMessageInteractionInfo,
UpdateMessageSendSucceeded, UpdateNewMessage, UpdateUser,
};
use super::auth::AuthState;
use super::client::TdClient;
use super::types::ReactionInfo;
/// Обрабатывает Update::NewMessage - добавление нового сообщения
pub fn handle_new_message_update(client: &mut TdClient, new_msg: UpdateNewMessage) {
let chat_id = ChatId::new(new_msg.message.chat_id);
// Если сообщение НЕ для текущего открытого чата - отправляем уведомление
if Some(chat_id) != client.current_chat_id() {
// Find and clone chat info to avoid borrow checker issues
if let Some(chat) = client.chats().iter().find(|c| c.id == chat_id).cloned() {
let msg_info =
crate::tdlib::message_converter::convert_message(client, &new_msg.message, chat_id);
// Get sender name (from message or user cache)
let sender_name = msg_info.sender_name().to_string();
client.enqueue_incoming_message_event(chat, msg_info, sender_name);
}
return;
}
// Добавляем новое сообщение если это текущий открытый чат
let msg_info =
crate::tdlib::message_converter::convert_message(client, &new_msg.message, chat_id);
let msg_id = msg_info.id();
let is_incoming = !msg_info.is_outgoing();
// Проверяем, есть ли уже сообщение с таким id
let existing_idx = client
.current_chat_messages()
.iter()
.position(|m| m.id() == msg_info.id());
match existing_idx {
Some(idx) => {
// Сообщение уже есть - обновляем
if is_incoming {
client.replace_current_chat_message(msg_id, msg_info);
} else {
// Для исходящих: обновляем can_be_edited и другие поля,
// но сохраняем reply_to (добавленный при отправке)
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_deleted_only_for_self =
msg_info.state.can_be_deleted_only_for_self;
existing.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;
});
}
}
None => {
// Нового сообщения нет - добавляем
client.push_message(msg_info.clone());
// Если это входящее сообщение — добавляем в очередь для отметки как прочитанное
if is_incoming {
client.enqueue_pending_view_messages(chat_id, vec![msg_id]);
}
}
}
}
/// Обрабатывает Update::ChatAction - статус набора текста/отправки файлов
pub fn handle_chat_action_update(client: &mut TdClient, update: UpdateChatAction) {
// Обрабатываем только для текущего открытого чата
if Some(ChatId::new(update.chat_id)) != client.current_chat_id() {
return;
}
// Извлекаем user_id из sender_id
let MessageSender::User(user) = update.sender_id else {
return; // Игнорируем действия от имени чата
};
let user_id = UserId::new(user.user_id);
// Определяем текст действия
let action_text = match update.action {
ChatAction::Typing => Some("печатает...".to_string()),
ChatAction::RecordingVideo => Some("записывает видео...".to_string()),
ChatAction::UploadingVideo(_) => Some("отправляет видео...".to_string()),
ChatAction::RecordingVoiceNote => Some("записывает голосовое...".to_string()),
ChatAction::UploadingVoiceNote(_) => Some("отправляет голосовое...".to_string()),
ChatAction::UploadingPhoto(_) => Some("отправляет фото...".to_string()),
ChatAction::UploadingDocument(_) => Some("отправляет файл...".to_string()),
ChatAction::ChoosingSticker => Some("выбирает стикер...".to_string()),
ChatAction::RecordingVideoNote => Some("записывает видеосообщение...".to_string()),
ChatAction::UploadingVideoNote(_) => Some("отправляет видеосообщение...".to_string()),
_ => None, // Отмена или неизвестное действие
};
match action_text {
Some(text) => client.set_typing_status(Some((user_id, text, Instant::now()))),
None => client.set_typing_status(None),
}
}
/// Обрабатывает Update::ChatPosition - изменение позиции чата в списке.
///
/// Обновляет order и is_pinned для чатов в Main списке,
/// управляет folder_ids для чатов в папках.
pub fn handle_chat_position_update(client: &mut TdClient, update: UpdateChatPosition) {
let chat_id = ChatId::new(update.chat_id);
match &update.position.list {
ChatList::Main => {
if update.position.order == 0 {
// Чат больше не в Main (перемещён в архив и т.д.)
client.remove_chat(chat_id);
} else {
// Обновляем позицию существующего чата
crate::tdlib::chat_helpers::update_chat(client, chat_id, |chat| {
chat.order = update.position.order;
chat.is_pinned = update.position.is_pinned;
});
}
// Пересортируем по order
client.sort_chats_by_order();
}
ChatList::Folder(folder) => {
// Обновляем folder_ids для чата
crate::tdlib::chat_helpers::update_chat(client, chat_id, |chat| {
if update.position.order == 0 {
// Чат удалён из папки
chat.folder_ids.retain(|&id| id != folder.chat_folder_id);
} else {
// Чат добавлен в папку
if !chat.folder_ids.contains(&folder.chat_folder_id) {
chat.folder_ids.push(folder.chat_folder_id);
}
}
});
}
ChatList::Archive => {
// Архив пока не обрабатываем
}
}
}
/// Обрабатывает Update::User - обновление информации о пользователе.
///
/// Сохраняет display name и username в кэше,
/// обновляет username в связанных чатах,
/// удаляет "Deleted Account" из списка чатов.
pub fn handle_user_update(client: &mut TdClient, update: UpdateUser) {
let user = update.user;
// Пропускаем удалённые аккаунты (пустое имя)
if user.first_name.is_empty() && user.last_name.is_empty() {
// Удаляем чаты с этим пользователем из списка
let user_id = user.id;
// Clone chat_user_ids to avoid borrow conflict
let chat_user_ids = client.user_cache().chat_user_ids.clone();
client.update_chats(|chats| {
chats.retain(|c| chat_user_ids.get(&c.id) != Some(&UserId::new(user_id)));
});
return;
}
// Сохраняем display name (first_name + last_name)
let display_name = if user.last_name.is_empty() {
user.first_name.clone()
} else {
format!("{} {}", user.first_name, user.last_name)
};
client.update_user_cache(|cache| {
cache.user_names.insert(UserId::new(user.id), display_name);
});
// Сохраняем username если есть (с упрощённым извлечением через and_then)
if let Some(username) = user
.usernames
.as_ref()
.and_then(|u| u.active_usernames.first())
{
let affected_chat_ids = client.update_user_cache(|cache| {
cache
.user_usernames
.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 в чатах, связанных с этим пользователем
for chat_id in affected_chat_ids {
crate::tdlib::chat_helpers::update_chat(client, chat_id, |chat| {
chat.username = Some(format!("@{}", username));
});
}
}
// LRU-кэш автоматически удаляет старые записи при вставке
}
/// Обрабатывает Update::MessageInteractionInfo - обновление реакций на сообщение.
///
/// Обновляет список реакций для сообщения в текущем открытом чате.
pub fn handle_message_interaction_info_update(
client: &mut TdClient,
update: UpdateMessageInteractionInfo,
) {
// Обновляем реакции в текущем открытом чате
if Some(ChatId::new(update.chat_id)) != client.current_chat_id() {
return;
}
// Извлекаем реакции из interaction_info
let reactions = update
.interaction_info
.as_ref()
.and_then(|info| info.reactions.as_ref())
.map(|reactions| {
reactions
.reactions
.iter()
.filter_map(|reaction| {
let emoji = match &reaction.r#type {
tdlib_rs::enums::ReactionType::Emoji(e) => e.emoji.clone(),
tdlib_rs::enums::ReactionType::CustomEmoji(_) => return None,
};
Some(ReactionInfo {
emoji,
count: reaction.total_count,
is_chosen: reaction.is_chosen,
})
})
.collect()
})
.unwrap_or_default();
client.update_current_chat_message(MessageId::new(update.message_id), |msg| {
msg.interactions.reactions = reactions;
});
}
/// Обрабатывает Update::MessageSendSucceeded - успешная отправка сообщения.
///
/// Заменяет временный ID сообщения на настоящий ID от сервера,
/// сохраняя reply_info из временного сообщения.
pub fn handle_message_send_succeeded_update(
client: &mut TdClient,
update: UpdateMessageSendSucceeded,
) {
let old_id = MessageId::new(update.old_message_id);
let chat_id = ChatId::new(update.message.chat_id);
// Обрабатываем только если это текущий открытый чат
if Some(chat_id) != client.current_chat_id() {
return;
}
// Находим сообщение с временным ID
let Some(idx) = client
.current_chat_messages()
.iter()
.position(|m| m.id() == old_id)
else {
return;
};
// Конвертируем новое сообщение
let mut new_msg =
crate::tdlib::message_converter::convert_message(client, &update.message, chat_id);
// Сохраняем reply_info из старого сообщения (если было)
let old_reply = client.current_chat_messages()[idx]
.interactions
.reply_to
.clone();
if let Some(reply) = old_reply {
new_msg.interactions.reply_to = Some(reply);
}
// Заменяем старое сообщение на новое
client.replace_current_chat_message(old_id, new_msg);
}
/// Обрабатывает Update::ChatDraftMessage - обновление черновика сообщения в чате.
///
/// Извлекает текст черновика и сохраняет его в ChatInfo для отображения в списке чатов.
pub fn handle_chat_draft_message_update(client: &mut TdClient, update: UpdateChatDraftMessage) {
crate::tdlib::chat_helpers::update_chat(client, ChatId::new(update.chat_id), |chat| {
chat.draft_text = update.draft_message.as_ref().and_then(|draft| {
// Извлекаем текст из InputMessageText с помощью pattern matching
match &draft.input_message_text {
tdlib_rs::enums::InputMessageContent::InputMessageText(text_msg) => {
Some(text_msg.text.text.clone())
}
_ => None,
}
});
});
}
/// Обрабатывает изменение состояния авторизации
pub fn handle_auth_state(client: &mut TdClient, state: AuthorizationState) {
client.auth.state = match state {
AuthorizationState::WaitTdlibParameters => AuthState::WaitTdlibParameters,
AuthorizationState::WaitPhoneNumber => AuthState::WaitPhoneNumber,
AuthorizationState::WaitCode(_) => AuthState::WaitCode,
AuthorizationState::WaitPassword(_) => AuthState::WaitPassword,
AuthorizationState::Ready => AuthState::Ready,
AuthorizationState::Closed => AuthState::Closed,
_ => client.auth.state.clone(),
};
}

View File

@@ -1,270 +0,0 @@
use crate::constants::{LAZY_LOAD_USERS_PER_TICK, MAX_CHAT_USER_IDS, MAX_USER_CACHE_SIZE};
use crate::types::{ChatId, UserId};
use std::collections::HashMap;
use tdlib_rs::enums::{User, UserStatus};
use tdlib_rs::functions;
use super::types::UserOnlineStatus;
/// LRU (Least Recently Used) кэш с фиксированной ёмкостью.
///
/// Автоматически удаляет самые давно использованные элементы при достижении лимита.
/// Основан на HashMap для быстрого доступа и Vec для отслеживания порядка использования.
///
/// # Type Parameters
///
/// * `K` - Тип ключа (должен реализовывать `Eq + Hash + Clone + Copy`)
/// * `V` - Тип значения (должен реализовывать `Clone`)
///
/// # Examples
///
/// ```ignore
/// let mut cache = LruCache::<UserId, String>::new(100);
/// cache.insert(UserId::new(1), "Alice".to_string());
/// assert_eq!(cache.get(&UserId::new(1)), Some(&"Alice".to_string()));
/// ```
pub struct LruCache<K, V> {
/// Хранилище ключ-значение.
map: HashMap<K, V>,
/// Порядок доступа: последний элемент — самый недавно использованный.
order: Vec<K>,
/// Максимальная ёмкость кэша.
capacity: usize,
}
impl<K, V> LruCache<K, V>
where
K: Eq + std::hash::Hash + Clone + Copy,
V: Clone,
{
/// Создает новый LRU кэш с заданной ёмкостью.
pub fn new(capacity: usize) -> Self {
Self {
map: HashMap::with_capacity(capacity),
order: Vec::with_capacity(capacity),
capacity,
}
}
/// Получает значение и обновляет порядок доступа (помечает как использованное).
pub fn get(&mut self, key: &K) -> Option<&V> {
if self.map.contains_key(key) {
// Перемещаем ключ в конец (самый недавно использованный)
self.order.retain(|k| k != key);
self.order.push(*key);
self.map.get(key)
} else {
None
}
}
/// Получить значение без обновления порядка (для read-only доступа)
pub fn peek(&self, key: &K) -> Option<&V> {
self.map.get(key)
}
/// Вставить значение
pub fn insert(&mut self, key: K, value: V) {
if self.map.contains_key(&key) {
// Обновляем существующее значение
self.map.insert(key, value);
self.order.retain(|k| *k != key);
self.order.push(key);
} else {
// Если кэш полон, удаляем самый старый элемент
if self.map.len() >= self.capacity {
if let Some(oldest) = self.order.first().copied() {
self.order.remove(0);
self.map.remove(&oldest);
}
}
self.map.insert(key, value);
self.order.push(key);
}
}
/// Проверить наличие ключа
pub fn contains_key(&self, key: &K) -> bool {
self.map.contains_key(key)
}
}
/// Кэш информации о пользователях Telegram.
///
/// Хранит данные пользователей (имена, usernames, статусы) в LRU-кэшах
/// для быстрого доступа без повторных запросов к TDLib.
///
/// # Возможности
///
/// - Кэширование имен пользователей (first_name + last_name)
/// - Кэширование usernames (@username)
/// - Кэширование онлайн-статусов
/// - Связь chat_id → user_id для приватных чатов
/// - Ленивая загрузка данных пользователей порциями
///
/// # Examples
///
/// ```ignore
/// let mut cache = UserCache::new(client_id);
///
/// // Обработать обновление пользователя
/// cache.handle_user_update(&user_enum);
///
/// // Получить имя
/// let name = cache.get_user_name(user_id).await;
/// ```
pub struct UserCache {
/// LRU-кэш usernames: user_id → username.
pub user_usernames: LruCache<UserId, String>,
/// LRU-кэш имён: user_id → display_name (first_name + last_name).
pub user_names: LruCache<UserId, String>,
/// Связь chat_id → user_id для приватных чатов.
pub chat_user_ids: HashMap<ChatId, UserId>,
/// Очередь user_id для ленивой загрузки имён.
pub pending_user_ids: Vec<UserId>,
/// LRU-кэш онлайн-статусов: user_id → status.
pub user_statuses: LruCache<UserId, UserOnlineStatus>,
/// ID клиента TDLib для API вызовов.
client_id: i32,
}
impl UserCache {
/// Создает новый кэш пользователей.
///
/// # Arguments
///
/// * `client_id` - ID клиента TDLib для API вызовов
pub fn new(client_id: i32) -> Self {
Self {
user_usernames: LruCache::new(MAX_USER_CACHE_SIZE),
user_names: LruCache::new(MAX_USER_CACHE_SIZE),
chat_user_ids: HashMap::with_capacity(MAX_CHAT_USER_IDS),
pending_user_ids: Vec::new(),
user_statuses: LruCache::new(MAX_USER_CACHE_SIZE),
client_id,
}
}
/// Получить статус пользователя по chat_id
pub fn get_status_by_chat_id(&self, chat_id: ChatId) -> Option<&UserOnlineStatus> {
let user_id = self.chat_user_ids.get(&chat_id)?;
self.user_statuses.peek(user_id)
}
/// Обрабатывает обновление пользователя от TDLib.
///
/// Сохраняет username, имя и статус пользователя в соответствующие кэши.
///
/// # Arguments
///
/// * `user_enum` - Обновление пользователя от TDLib
pub fn handle_user_update(&mut self, user_enum: &User) {
let User::User(user) = user_enum;
let user_id = user.id;
// Сохраняем username
if let Some(username) = user.usernames.as_ref().map(|u| u.editable_username.clone()) {
self.user_usernames.insert(UserId::new(user_id), username);
}
// Сохраняем имя
let display_name = format!("{} {}", user.first_name, user.last_name)
.trim()
.to_string();
self.user_names.insert(UserId::new(user_id), display_name);
// Обновляем статус
self.update_status(UserId::new(user_id), &user.status);
}
/// Обновляет онлайн-статус пользователя.
///
/// # Arguments
///
/// * `user_id` - ID пользователя
/// * `status` - Новый статус от TDLib
pub fn update_status(&mut self, user_id: UserId, status: &UserStatus) {
let online_status = match status {
UserStatus::Online(_) => UserOnlineStatus::Online,
UserStatus::Recently(_) => UserOnlineStatus::Recently,
UserStatus::LastWeek(_) => UserOnlineStatus::LastWeek,
UserStatus::LastMonth(_) => UserOnlineStatus::LastMonth,
UserStatus::Offline(s) => UserOnlineStatus::Offline(s.was_online),
_ => return,
};
self.user_statuses.insert(user_id, online_status);
}
/// Получает имя пользователя из кэша или загружает из TDLib.
///
/// Сначала проверяет кэш, затем при необходимости загружает из API.
///
/// # Arguments
///
/// * `user_id` - ID пользователя
///
/// # Returns
///
/// Имя пользователя (first_name + last_name) или "User {id}" если не найден.
#[allow(dead_code)]
pub async fn get_user_name(&self, user_id: UserId) -> String {
// Сначала пытаемся получить из кэша
if let Some(name) = self.user_names.peek(&user_id) {
return name.clone();
}
// Загружаем пользователя
match functions::get_user(user_id.as_i64(), self.client_id).await {
Ok(User::User(user)) => {
let name = format!("{} {}", user.first_name, user.last_name)
.trim()
.to_string();
name
}
_ => format!("User {}", user_id.as_i64()),
}
}
/// Обрабатывает очередь отложенных user_ids для ленивой загрузки.
///
/// Загружает данные пользователей небольшими порциями (по [`LAZY_LOAD_USERS_PER_TICK`])
/// для избежания блокировки UI.
///
/// # Note
///
/// Вызывайте периодически в основном цикле приложения.
pub async fn process_pending_user_ids(&mut self) {
if self.pending_user_ids.is_empty() {
return;
}
// Берём первые N user_ids для загрузки
let batch: Vec<UserId> = self
.pending_user_ids
.drain(..self.pending_user_ids.len().min(LAZY_LOAD_USERS_PER_TICK))
.collect();
for user_id in batch {
if self.user_names.contains_key(&user_id) {
continue; // Уже в кэше
}
match functions::get_user(user_id.as_i64(), self.client_id).await {
Ok(user_enum) => {
self.handle_user_update(&user_enum);
}
Err(_) => {
// Если не удалось загрузить, сохраняем placeholder
self.user_names.insert(user_id, format!("User {}", user_id));
}
}
}
}
}

View File

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

View File

@@ -1,86 +0,0 @@
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
}
}

View File

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

View File

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

View File

@@ -1,206 +0,0 @@
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)),
}
}
}

View File

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

View File

@@ -1,7 +0,0 @@
//! Core test support for deterministic TDLib fixtures.
pub mod fake_tdclient;
mod fake_tdclient_impl;
pub mod test_data;
pub use fake_tdclient::FakeTdClient;

View File

@@ -1,252 +0,0 @@
// 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,
}
}

View File

@@ -1,172 +0,0 @@
//! Type-safe ID wrappers to prevent mixing up different ID types.
//!
//! Provides `ChatId` and `MessageId` newtypes for compile-time safety.
use serde::{Deserialize, Serialize};
use std::fmt;
/// Chat identifier
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ChatId(pub i64);
impl ChatId {
pub fn new(id: i64) -> Self {
Self(id)
}
pub fn as_i64(&self) -> i64 {
self.0
}
}
impl From<i64> for ChatId {
fn from(id: i64) -> Self {
Self(id)
}
}
impl From<ChatId> for i64 {
fn from(id: ChatId) -> Self {
id.0
}
}
impl fmt::Display for ChatId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
/// Message identifier
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub struct MessageId(pub i64);
impl MessageId {
pub fn new(id: i64) -> Self {
Self(id)
}
pub fn as_i64(&self) -> i64 {
self.0
}
}
impl From<i64> for MessageId {
fn from(id: i64) -> Self {
Self(id)
}
}
impl From<MessageId> for i64 {
fn from(id: MessageId) -> Self {
id.0
}
}
impl fmt::Display for MessageId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
/// User identifier
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct UserId(pub i64);
impl UserId {
pub fn new(id: i64) -> Self {
Self(id)
}
pub fn as_i64(&self) -> i64 {
self.0
}
}
impl From<i64> for UserId {
fn from(id: i64) -> Self {
Self(id)
}
}
impl From<UserId> for i64 {
fn from(id: UserId) -> Self {
id.0
}
}
impl fmt::Display for UserId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_chat_id() {
let id = ChatId::new(123);
assert_eq!(id.as_i64(), 123);
assert_eq!(i64::from(id), 123);
let id2: ChatId = 456.into();
assert_eq!(id2.0, 456);
}
#[test]
fn test_message_id() {
let id = MessageId::new(789);
assert_eq!(id.as_i64(), 789);
assert_eq!(i64::from(id), 789);
}
#[test]
fn test_user_id() {
let id = UserId::new(111);
assert_eq!(id.as_i64(), 111);
assert_eq!(i64::from(id), 111);
}
#[test]
fn test_type_safety() {
// Type safety is enforced at compile time
// The following would not compile:
// let chat_id = ChatId::new(1);
// let message_id = MessageId::new(1);
// if chat_id == message_id { } // ERROR: mismatched types
// Runtime values can be the same, but types are different
let chat_id = ChatId::new(1);
let message_id = MessageId::new(1);
assert_eq!(chat_id.as_i64(), 1);
assert_eq!(message_id.as_i64(), 1);
// But they cannot be compared directly due to type safety
}
#[test]
fn test_display() {
let chat_id = ChatId::new(123);
assert_eq!(format!("{}", chat_id), "123");
let message_id = MessageId::new(456);
assert_eq!(format!("{}", message_id), "456");
let user_id = UserId::new(789);
assert_eq!(format!("{}", user_id), "789");
}
#[test]
fn test_hash_map() {
use std::collections::HashMap;
let mut map = HashMap::new();
map.insert(ChatId::new(1), "chat1");
map.insert(ChatId::new(2), "chat2");
assert_eq!(map.get(&ChatId::new(1)), Some(&"chat1"));
assert_eq!(map.get(&ChatId::new(2)), Some(&"chat2"));
assert_eq!(map.get(&ChatId::new(3)), None);
}
}

View File

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

View File

@@ -1,27 +0,0 @@
[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"] }

View File

@@ -1,46 +0,0 @@
# 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`.

File diff suppressed because it is too large Load Diff

View File

@@ -1,66 +0,0 @@
[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

View File

@@ -1,88 +0,0 @@
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use ratatui::style::Color;
use tdlib_rs::enums::TextEntityType;
use tdlib_rs::types::TextEntity;
use tele_tui::formatting::format_text_with_entities;
fn create_text_with_entities() -> (String, Vec<TextEntity>) {
let text = "This is bold and italic text with code and a link and mention".to_string();
let entities = vec![
TextEntity {
offset: 8,
length: 4, // bold
r#type: TextEntityType::Bold,
},
TextEntity {
offset: 17,
length: 6, // italic
r#type: TextEntityType::Italic,
},
TextEntity {
offset: 34,
length: 4, // code
r#type: TextEntityType::Code,
},
TextEntity {
offset: 45,
length: 4, // link
r#type: TextEntityType::Url,
},
TextEntity {
offset: 54,
length: 7, // mention
r#type: TextEntityType::Mention,
},
];
(text, entities)
}
fn benchmark_format_simple_text(c: &mut Criterion) {
let text = "Simple text without any formatting".to_string();
let entities = vec![];
c.bench_function("format_simple_text", |b| {
b.iter(|| format_text_with_entities(black_box(&text), black_box(&entities), Color::White));
});
}
fn benchmark_format_markdown_text(c: &mut Criterion) {
let (text, entities) = create_text_with_entities();
c.bench_function("format_markdown_text", |b| {
b.iter(|| format_text_with_entities(black_box(&text), black_box(&entities), Color::White));
});
}
fn benchmark_format_long_text(c: &mut Criterion) {
let mut text = String::new();
let mut entities = vec![];
// Создаем длинный текст с множеством форматирований
for i in 0..100 {
let start = text.len();
text.push_str(&format!("Word{} ", i));
// Добавляем форматирование к каждому 3-му слову
if i % 3 == 0 {
entities.push(TextEntity {
offset: start as i32,
length: format!("Word{}", i).len() as i32,
r#type: TextEntityType::Bold,
});
}
}
c.bench_function("format_long_text_with_100_entities", |b| {
b.iter(|| format_text_with_entities(black_box(&text), black_box(&entities), Color::White));
});
}
criterion_group!(
benches,
benchmark_format_simple_text,
benchmark_format_markdown_text,
benchmark_format_long_text
);
criterion_main!(benches);

View File

@@ -1,38 +0,0 @@
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use tele_tui::utils::formatting::{format_date, format_timestamp, get_day};
fn benchmark_format_timestamp(c: &mut Criterion) {
c.bench_function("format_timestamp_50_times", |b| {
b.iter(|| {
for i in 0..50 {
let timestamp = 1640000000 + (i * 60);
black_box(format_timestamp(timestamp));
}
});
});
}
fn benchmark_format_date(c: &mut Criterion) {
c.bench_function("format_date_50_times", |b| {
b.iter(|| {
for i in 0..50 {
let timestamp = 1640000000 + (i * 86400);
black_box(format_date(timestamp));
}
});
});
}
fn benchmark_get_day(c: &mut Criterion) {
c.bench_function("get_day_1000_times", |b| {
b.iter(|| {
for i in 0..1000 {
let timestamp = 1640000000 + (i * 60);
black_box(get_day(timestamp));
}
});
});
}
criterion_group!(benches, benchmark_format_timestamp, benchmark_format_date, benchmark_get_day);
criterion_main!(benches);

View File

@@ -1,43 +0,0 @@
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use tele_tui::message_grouping::group_messages;
use tele_tui::tdlib::types::MessageBuilder;
use tele_tui::types::MessageId;
fn create_test_messages(count: usize) -> Vec<tele_tui::tdlib::MessageInfo> {
(0..count)
.map(|i| {
let builder = MessageBuilder::new(MessageId::new(i as i64))
.sender_name(format!("User{}", i % 10))
.text(format!(
"Test message number {} with some longer text to make it more realistic",
i
))
.date(1640000000 + (i as i32 * 60));
if i % 2 == 0 {
builder.outgoing().read().build()
} else {
builder.incoming().build()
}
})
.collect()
}
fn benchmark_group_100_messages(c: &mut Criterion) {
let messages = create_test_messages(100);
c.bench_function("group_100_messages", |b| {
b.iter(|| group_messages(black_box(&messages)));
});
}
fn benchmark_group_500_messages(c: &mut Criterion) {
let messages = create_test_messages(500);
c.bench_function("group_500_messages", |b| {
b.iter(|| group_messages(black_box(&messages)));
});
}
criterion_group!(benches, benchmark_group_100_messages, benchmark_group_500_messages);
criterion_main!(benches);

View File

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

View File

@@ -1,122 +0,0 @@
//! Per-account advisory file locking to prevent concurrent access.
//!
//! Uses `flock` (via `fs2`) for automatic lock release on process crash/SIGKILL.
//! Lock file: `~/.local/share/tele-tui/accounts/{name}/tele-tui.lock`
use fs2::FileExt;
use std::fs::{self, File};
use std::path::PathBuf;
/// Returns the lock file path for a given account.
///
/// Path: `{data_dir}/tele-tui/accounts/{name}/tele-tui.lock`
pub fn account_lock_path(account_name: &str) -> PathBuf {
let mut path = dirs::data_dir().unwrap_or_else(|| PathBuf::from("."));
path.push("tele-tui");
path.push("accounts");
path.push(account_name);
path.push("tele-tui.lock");
path
}
/// Acquires an exclusive advisory lock for the given account.
///
/// Creates the lock file and parent directories if needed.
/// Returns the open `File` handle — the lock is held as long as this handle exists.
///
/// # Errors
///
/// Returns an error message if the lock is already held by another process
/// or if the lock file cannot be created.
pub fn acquire_lock(account_name: &str) -> Result<File, String> {
let lock_path = account_lock_path(account_name);
// Ensure parent directory exists
if let Some(parent) = lock_path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Не удалось создать директорию для lock-файла: {}", e))?;
}
let file = File::create(&lock_path)
.map_err(|e| format!("Не удалось создать lock-файл {}: {}", lock_path.display(), e))?;
file.try_lock_exclusive().map_err(|_| {
format!(
"Аккаунт '{}' уже используется другим экземпляром tele-tui.\n\
Lock-файл: {}",
account_name,
lock_path.display()
)
})?;
Ok(file)
}
/// Explicitly releases the lock by unlocking and dropping the file handle.
///
/// Used during account switching to release the old account's lock
/// before acquiring the new one.
pub fn release_lock(lock_file: File) {
let _ = lock_file.unlock();
drop(lock_file);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_lock_path_structure() {
let path = account_lock_path("default");
let path_str = path.to_string_lossy();
assert!(path_str.contains("tele-tui"));
assert!(path_str.contains("accounts"));
assert!(path_str.contains("default"));
assert!(path_str.ends_with("tele-tui.lock"));
}
#[test]
fn test_lock_path_per_account() {
let path1 = account_lock_path("work");
let path2 = account_lock_path("personal");
assert_ne!(path1, path2);
assert!(path1.to_string_lossy().contains("work"));
assert!(path2.to_string_lossy().contains("personal"));
}
#[test]
fn test_acquire_and_release() {
let name = "test-lock-acquire-release";
let lock = acquire_lock(name).expect("first acquire should succeed");
// Second acquire should fail (same process, exclusive lock)
let result = acquire_lock(name);
assert!(result.is_err(), "second acquire should fail");
assert!(
result.unwrap_err().contains("уже используется"),
"error should mention already in use"
);
// Release and re-acquire
release_lock(lock);
let lock2 = acquire_lock(name).expect("acquire after release should succeed");
// Cleanup
release_lock(lock2);
let _ = fs::remove_file(account_lock_path(name));
}
#[test]
fn test_lock_released_on_drop() {
let name = "test-lock-drop";
{
let _lock = acquire_lock(name).expect("acquire should succeed");
// _lock dropped here
}
// After drop, lock should be free
let lock = acquire_lock(name).expect("acquire after drop should succeed");
release_lock(lock);
let _ = fs::remove_file(account_lock_path(name));
}
}

View File

@@ -1,202 +0,0 @@
//! Account manager: loading, saving, migration, and resolution.
//!
//! Handles `accounts.toml` lifecycle and legacy `./tdlib_data/` migration
//! to XDG data directory.
use std::fs;
use std::path::PathBuf;
use super::profile::{account_db_path, validate_account_name, AccountsConfig};
/// Returns the path to `accounts.toml` in the config directory.
///
/// `~/.config/tele-tui/accounts.toml`
pub fn accounts_config_path() -> Option<PathBuf> {
dirs::config_dir().map(|mut path| {
path.push("tele-tui");
path.push("accounts.toml");
path
})
}
/// Loads `accounts.toml` or creates it with default values.
///
/// On first run, also attempts to migrate legacy `./tdlib_data/` directory
/// to the XDG data location.
pub fn load_or_create() -> AccountsConfig {
let config_path = match accounts_config_path() {
Some(path) => path,
None => {
tracing::warn!("Could not determine config directory for accounts, using defaults");
return AccountsConfig::default_single();
}
};
if config_path.exists() {
// Load existing config
match fs::read_to_string(&config_path) {
Ok(content) => match toml::from_str::<AccountsConfig>(&content) {
Ok(config) => return config,
Err(e) => {
tracing::warn!("Could not parse accounts.toml: {}", e);
return AccountsConfig::default_single();
}
},
Err(e) => {
tracing::warn!("Could not read accounts.toml: {}", e);
return AccountsConfig::default_single();
}
}
}
// First run: migrate legacy data if present, then create default config
migrate_legacy();
let config = AccountsConfig::default_single();
if let Err(e) = save(&config) {
tracing::warn!("Could not save initial accounts.toml: {}", e);
}
config
}
/// Saves `AccountsConfig` to `accounts.toml`.
pub fn save(config: &AccountsConfig) -> Result<(), String> {
let config_path =
accounts_config_path().ok_or_else(|| "Could not determine config directory".to_string())?;
// Ensure parent directory exists
if let Some(parent) = config_path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Could not create config directory: {}", e))?;
}
let toml_string = toml::to_string_pretty(config)
.map_err(|e| format!("Could not serialize accounts config: {}", e))?;
fs::write(&config_path, toml_string)
.map_err(|e| format!("Could not write accounts.toml: {}", e))?;
Ok(())
}
/// Migrates legacy `./tdlib_data/` from CWD to XDG data dir.
///
/// If `./tdlib_data/` exists in the current working directory, moves it to
/// `~/.local/share/tele-tui/accounts/default/tdlib_data/`.
fn migrate_legacy() {
let legacy_path = PathBuf::from("tdlib_data");
if !legacy_path.exists() || !legacy_path.is_dir() {
return;
}
let target = account_db_path("default");
// Don't overwrite if target already exists
if target.exists() {
tracing::info!(
"Legacy ./tdlib_data/ found but target already exists at {}, skipping migration",
target.display()
);
return;
}
// Create parent directories
if let Some(parent) = target.parent() {
if let Err(e) = fs::create_dir_all(parent) {
tracing::error!("Could not create target directory for migration: {}", e);
return;
}
}
// Move (rename) the directory
match fs::rename(&legacy_path, &target) {
Ok(()) => {
tracing::info!("Migrated ./tdlib_data/ -> {}", target.display());
}
Err(e) => {
tracing::error!("Could not migrate ./tdlib_data/ to {}: {}", target.display(), e);
}
}
}
/// Resolves which account to use from CLI arg or default.
///
/// # Arguments
///
/// * `config` - The loaded accounts configuration
/// * `account_arg` - Optional account name from `--account` CLI flag
///
/// # Returns
///
/// The resolved account name and its db_path.
///
/// # Errors
///
/// Returns an error if the specified account is not found or the name is invalid.
pub fn resolve_account(
config: &AccountsConfig,
account_arg: Option<&str>,
) -> Result<(String, PathBuf), String> {
let account_name = account_arg.unwrap_or(&config.default_account);
// Validate name
validate_account_name(account_name)?;
// Find account in config
let _account = config.find_account(account_name).ok_or_else(|| {
let available: Vec<&str> = config.accounts.iter().map(|a| a.name.as_str()).collect();
format!(
"Account '{}' not found. Available accounts: {}",
account_name,
available.join(", ")
)
})?;
let db_path = account_db_path(account_name);
Ok((account_name.to_string(), db_path))
}
/// Adds a new account to `accounts.toml` and creates its data directory.
///
/// Validates the name, checks for duplicates, adds the profile to config,
/// saves the config, and creates the data directory.
///
/// # Returns
///
/// The db_path for the new account.
///
/// # Errors
///
/// Returns an error if the name is invalid, already exists, or I/O fails.
pub fn add_account(name: &str, display_name: &str) -> Result<std::path::PathBuf, String> {
validate_account_name(name)?;
let mut config = load_or_create();
// Check for duplicate
if config.find_account(name).is_some() {
return Err(format!("Account '{}' already exists", name));
}
// Add new profile
config.accounts.push(super::profile::AccountProfile {
name: name.to_string(),
display_name: display_name.to_string(),
});
// Save config
save(&config)?;
// Create data directory
ensure_account_dir(name)
}
/// Ensures the account data directory exists.
///
/// Creates `~/.local/share/tele-tui/accounts/{name}/tdlib_data/` if needed.
pub fn ensure_account_dir(account_name: &str) -> Result<PathBuf, String> {
let db_path = account_db_path(account_name);
fs::create_dir_all(&db_path)
.map_err(|e| format!("Could not create account directory: {}", e))?;
Ok(db_path)
}

View File

@@ -1,15 +0,0 @@
//! Account profiles module for multi-account support.
//!
//! Manages account profiles stored in `~/.config/tele-tui/accounts.toml`.
//! Each account has its own TDLib database directory under
//! `~/.local/share/tele-tui/accounts/{name}/tdlib_data/`.
pub mod lock;
pub mod manager;
pub mod profile;
pub use lock::{acquire_lock, release_lock};
#[allow(unused_imports)]
pub use manager::{add_account, ensure_account_dir, load_or_create, resolve_account, save};
#[allow(unused_imports)]
pub use profile::{account_db_path, validate_account_name, AccountProfile, AccountsConfig};

View File

@@ -1,147 +0,0 @@
//! Account profile data structures and validation.
//!
//! Defines `AccountProfile` and `AccountsConfig` for multi-account support.
//! Account names are validated to contain only alphanumeric characters, hyphens, and underscores.
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
/// Configuration for all accounts, stored in `~/.config/tele-tui/accounts.toml`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccountsConfig {
/// Name of the default account to use when no `--account` flag is provided.
pub default_account: String,
/// List of configured accounts.
pub accounts: Vec<AccountProfile>,
}
/// A single account profile.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccountProfile {
/// Unique identifier (used in directory names and CLI flag).
pub name: String,
/// Human-readable display name.
pub display_name: String,
}
impl AccountsConfig {
/// Creates a default config with a single "default" account.
pub fn default_single() -> Self {
Self {
default_account: "default".to_string(),
accounts: vec![AccountProfile {
name: "default".to_string(),
display_name: "Default".to_string(),
}],
}
}
/// Finds an account by name.
pub fn find_account(&self, name: &str) -> Option<&AccountProfile> {
self.accounts.iter().find(|a| a.name == name)
}
}
impl AccountProfile {
/// Computes the TDLib database directory path for this account.
///
/// Returns `~/.local/share/tele-tui/accounts/{name}/tdlib_data`
/// (or platform equivalent via `dirs::data_dir()`).
pub fn db_path(&self) -> PathBuf {
account_db_path(&self.name)
}
}
/// Computes the TDLib database directory path for a given account name.
///
/// Returns `{data_dir}/tele-tui/accounts/{name}/tdlib_data`.
pub fn account_db_path(account_name: &str) -> PathBuf {
let mut path = dirs::data_dir().unwrap_or_else(|| PathBuf::from("."));
path.push("tele-tui");
path.push("accounts");
path.push(account_name);
path.push("tdlib_data");
path
}
/// Validates an account name.
///
/// Valid names contain only lowercase alphanumeric characters, hyphens, and underscores.
/// Must be 1-32 characters long.
///
/// # Errors
///
/// Returns a descriptive error message if the name is invalid.
pub fn validate_account_name(name: &str) -> Result<(), String> {
if name.is_empty() {
return Err("Account name cannot be empty".to_string());
}
if name.len() > 32 {
return Err("Account name cannot be longer than 32 characters".to_string());
}
if !name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
{
return Err(
"Account name can only contain lowercase letters, digits, hyphens, and underscores"
.to_string(),
);
}
if name.starts_with('-') || name.starts_with('_') {
return Err("Account name cannot start with a hyphen or underscore".to_string());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_account_name_valid() {
assert!(validate_account_name("default").is_ok());
assert!(validate_account_name("work").is_ok());
assert!(validate_account_name("my-account").is_ok());
assert!(validate_account_name("account_2").is_ok());
assert!(validate_account_name("a").is_ok());
}
#[test]
fn test_validate_account_name_invalid() {
assert!(validate_account_name("").is_err());
assert!(validate_account_name("My Account").is_err());
assert!(validate_account_name("UPPER").is_err());
assert!(validate_account_name("with spaces").is_err());
assert!(validate_account_name("-starts-with-dash").is_err());
assert!(validate_account_name("_starts-with-underscore").is_err());
assert!(validate_account_name(&"a".repeat(33)).is_err());
}
#[test]
fn test_default_single_config() {
let config = AccountsConfig::default_single();
assert_eq!(config.default_account, "default");
assert_eq!(config.accounts.len(), 1);
assert_eq!(config.accounts[0].name, "default");
}
#[test]
fn test_find_account() {
let config = AccountsConfig::default_single();
assert!(config.find_account("default").is_some());
assert!(config.find_account("nonexistent").is_none());
}
#[test]
fn test_db_path_contains_account_name() {
let path = account_db_path("work");
let path_str = path.to_string_lossy();
assert!(path_str.contains("tele-tui"));
assert!(path_str.contains("accounts"));
assert!(path_str.contains("work"));
assert!(path_str.ends_with("tdlib_data"));
}
}

View File

@@ -1,87 +0,0 @@
/// Состояние аутентификации
///
/// Отвечает за данные авторизации:
/// - Ввод номера телефона
/// - Ввод кода подтверждения
/// - Ввод пароля (2FA)
/// Состояние аутентификации
#[derive(Debug, Clone, Default)]
pub struct AuthState {
/// Введённый номер телефона
phone_input: String,
/// Введённый код подтверждения
code_input: String,
/// Введённый пароль (для 2FA)
password_input: String,
}
impl AuthState {
/// Создать новое состояние аутентификации
pub fn new() -> Self {
Self::default()
}
// === Phone input ===
pub fn phone_input(&self) -> &str {
&self.phone_input
}
pub fn phone_input_mut(&mut self) -> &mut String {
&mut self.phone_input
}
pub fn set_phone_input(&mut self, input: String) {
self.phone_input = input;
}
pub fn clear_phone_input(&mut self) {
self.phone_input.clear();
}
// === Code input ===
pub fn code_input(&self) -> &str {
&self.code_input
}
pub fn code_input_mut(&mut self) -> &mut String {
&mut self.code_input
}
pub fn set_code_input(&mut self, input: String) {
self.code_input = input;
}
pub fn clear_code_input(&mut self) {
self.code_input.clear();
}
// === Password input ===
pub fn password_input(&self) -> &str {
&self.password_input
}
pub fn password_input_mut(&mut self) -> &mut String {
&mut self.password_input
}
pub fn set_password_input(&mut self, input: String) {
self.password_input = input;
}
pub fn clear_password_input(&mut self) {
self.password_input.clear();
}
/// Очистить все поля ввода
pub fn clear_all(&mut self) {
self.phone_input.clear();
self.code_input.clear();
self.password_input.clear();
}
}

View File

@@ -1,327 +0,0 @@
/// Модуль для централизованной фильтрации чатов
///
/// Предоставляет единый источник правды для всех видов фильтрации:
/// - По папкам (folders)
/// - По поисковому запросу
/// - По статусу (archived, muted, и т.д.)
///
/// Используется как в App, так и в UI слое для консистентной фильтрации.
use crate::tdlib::ChatInfo;
/// Критерии фильтрации чатов
#[allow(dead_code)]
#[derive(Debug, Clone, Default)]
pub struct ChatFilterCriteria {
/// Фильтр по папке (folder_id)
pub folder_id: Option<i32>,
/// Поисковый запрос (по названию или username)
pub search_query: Option<String>,
/// Показывать только закреплённые
pub pinned_only: bool,
/// Показывать только непрочитанные
pub unread_only: bool,
/// Показывать только с упоминаниями
pub mentions_only: bool,
/// Скрывать muted чаты
pub hide_muted: bool,
/// Скрывать архивные чаты
pub hide_archived: bool,
}
#[allow(dead_code)]
impl ChatFilterCriteria {
/// Создаёт критерии с дефолтными значениями
pub fn new() -> Self {
Self::default()
}
/// Фильтр только по папке
pub fn by_folder(folder_id: Option<i32>) -> Self {
Self { folder_id, ..Default::default() }
}
/// Фильтр только по поисковому запросу
pub fn by_search(query: String) -> Self {
Self { search_query: Some(query), ..Default::default() }
}
/// Builder: установить папку
pub fn with_folder(mut self, folder_id: Option<i32>) -> Self {
self.folder_id = folder_id;
self
}
/// Builder: установить поисковый запрос
pub fn with_search(mut self, query: String) -> Self {
self.search_query = Some(query);
self
}
/// Builder: показывать только закреплённые
pub fn pinned_only(mut self, enabled: bool) -> Self {
self.pinned_only = enabled;
self
}
/// Builder: показывать только непрочитанные
pub fn unread_only(mut self, enabled: bool) -> Self {
self.unread_only = enabled;
self
}
/// Builder: показывать только с упоминаниями
pub fn mentions_only(mut self, enabled: bool) -> Self {
self.mentions_only = enabled;
self
}
/// Builder: скрывать muted
pub fn hide_muted(mut self, enabled: bool) -> Self {
self.hide_muted = enabled;
self
}
/// Builder: скрывать архивные
pub fn hide_archived(mut self, enabled: bool) -> Self {
self.hide_archived = enabled;
self
}
/// Проверяет подходит ли чат под все критерии
pub fn matches(&self, chat: &ChatInfo) -> bool {
// Фильтр по папке
if let Some(folder_id) = self.folder_id {
if !chat.folder_ids.contains(&folder_id) {
return false;
}
}
// Фильтр по поисковому запросу
if let Some(ref query) = self.search_query {
if !query.is_empty() {
let query_lower = query.to_lowercase();
let title_matches = chat.title.to_lowercase().contains(&query_lower);
let username_matches = chat
.username
.as_ref()
.map(|u| u.to_lowercase().contains(&query_lower))
.unwrap_or(false);
if !title_matches && !username_matches {
return false;
}
}
}
// Только закреплённые
if self.pinned_only && !chat.is_pinned {
return false;
}
// Только непрочитанные
if self.unread_only && chat.unread_count == 0 {
return false;
}
// Только с упоминаниями
if self.mentions_only && chat.unread_mention_count == 0 {
return false;
}
// Скрывать muted
if self.hide_muted && chat.is_muted {
return false;
}
// Скрывать архивные (folder_id == 1)
if self.hide_archived && chat.folder_ids.contains(&1) {
return false;
}
true
}
}
/// Централизованный фильтр чатов
#[allow(dead_code)]
pub struct ChatFilter;
#[allow(dead_code)]
impl ChatFilter {
/// Фильтрует список чатов по критериям
///
/// # Arguments
///
/// * `chats` - Исходный список чатов
/// * `criteria` - Критерии фильтрации
///
/// # Returns
///
/// Отфильтрованный список чатов (без клонирования, только references)
///
/// # Examples
///
/// ```ignore
/// let criteria = ChatFilterCriteria::by_folder(Some(0))
/// .with_search("John".to_string());
///
/// let filtered = ChatFilter::filter(&all_chats, &criteria);
/// ```
pub fn filter<'a>(chats: &'a [ChatInfo], criteria: &ChatFilterCriteria) -> Vec<&'a ChatInfo> {
chats.iter().filter(|chat| criteria.matches(chat)).collect()
}
/// Фильтрует чаты по папке
///
/// Упрощённая версия для наиболее частого случая.
pub fn by_folder(chats: &[ChatInfo], folder_id: Option<i32>) -> Vec<&ChatInfo> {
let criteria = ChatFilterCriteria::by_folder(folder_id);
Self::filter(chats, &criteria)
}
/// Фильтрует чаты по поисковому запросу
///
/// Упрощённая версия для поиска.
pub fn by_search<'a>(chats: &'a [ChatInfo], query: &str) -> Vec<&'a ChatInfo> {
if query.is_empty() {
return chats.iter().collect();
}
let criteria = ChatFilterCriteria::by_search(query.to_string());
Self::filter(chats, &criteria)
}
/// Подсчитывает чаты подходящие под критерии
pub fn count(chats: &[ChatInfo], criteria: &ChatFilterCriteria) -> usize {
chats.iter().filter(|chat| criteria.matches(chat)).count()
}
/// Подсчитывает непрочитанные сообщения в отфильтрованных чатах
pub fn count_unread(chats: &[ChatInfo], criteria: &ChatFilterCriteria) -> i32 {
chats
.iter()
.filter(|chat| criteria.matches(chat))
.map(|chat| chat.unread_count)
.sum()
}
/// Подсчитывает непрочитанные упоминания в отфильтрованных чатах
pub fn count_unread_mentions(chats: &[ChatInfo], criteria: &ChatFilterCriteria) -> i32 {
chats
.iter()
.filter(|chat| criteria.matches(chat))
.map(|chat| chat.unread_mention_count)
.sum()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::ChatId;
#[allow(clippy::too_many_arguments)]
fn create_test_chat(
id: i64,
title: &str,
username: Option<&str>,
folder_ids: Vec<i32>,
unread: i32,
mentions: i32,
is_pinned: bool,
is_muted: bool,
) -> ChatInfo {
use crate::types::MessageId;
ChatInfo {
id: ChatId::new(id),
title: title.to_string(),
username: username.map(String::from),
folder_ids,
unread_count: unread,
unread_mention_count: mentions,
is_pinned,
is_muted,
last_message_date: 0,
last_message: String::new(),
order: 0,
last_read_outbox_message_id: MessageId::new(0),
draft_text: None,
}
}
#[test]
fn test_filter_by_folder() {
let chats = vec![
create_test_chat(1, "Chat 1", None, vec![0], 0, 0, false, false),
create_test_chat(2, "Chat 2", None, vec![1], 0, 0, false, false),
create_test_chat(3, "Chat 3", None, vec![0, 1], 0, 0, false, false),
];
let filtered = ChatFilter::by_folder(&chats, Some(0));
assert_eq!(filtered.len(), 2); // Chat 1 and Chat 3
assert_eq!(filtered[0].id.as_i64(), 1);
assert_eq!(filtered[1].id.as_i64(), 3);
}
#[test]
fn test_filter_by_search() {
let chats = vec![
create_test_chat(1, "John Doe", Some("johndoe"), vec![0], 0, 0, false, false),
create_test_chat(2, "Jane Smith", Some("janesmith"), vec![0], 0, 0, false, false),
create_test_chat(3, "Bob Johnson", None, vec![0], 0, 0, false, false),
];
// Поиск по имени
let filtered = ChatFilter::by_search(&chats, "john");
assert_eq!(filtered.len(), 2); // John Doe and Bob Johnson
// Поиск по username
let filtered = ChatFilter::by_search(&chats, "smith");
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].title, "Jane Smith");
}
#[test]
fn test_filter_criteria_builder() {
let chats = vec![
create_test_chat(1, "Chat 1", None, vec![0], 5, 0, true, false),
create_test_chat(2, "Chat 2", None, vec![0], 0, 0, false, false),
create_test_chat(3, "Chat 3", None, vec![0], 10, 2, false, false),
];
let criteria = ChatFilterCriteria::new()
.with_folder(Some(0))
.unread_only(true)
.pinned_only(false);
let filtered = ChatFilter::filter(&chats, &criteria);
assert_eq!(filtered.len(), 2); // Chat 1 and Chat 3 have unread
let criteria = ChatFilterCriteria::new().pinned_only(true);
let filtered = ChatFilter::filter(&chats, &criteria);
assert_eq!(filtered.len(), 1); // Only Chat 1 is pinned
}
#[test]
fn test_count_methods() {
let chats = vec![
create_test_chat(1, "Chat 1", None, vec![0], 5, 1, false, false),
create_test_chat(2, "Chat 2", None, vec![0], 10, 2, false, false),
create_test_chat(3, "Chat 3", None, vec![1], 3, 0, false, false),
];
let criteria = ChatFilterCriteria::by_folder(Some(0));
assert_eq!(ChatFilter::count(&chats, &criteria), 2);
assert_eq!(ChatFilter::count_unread(&chats, &criteria), 15); // 5 + 10
assert_eq!(ChatFilter::count_unread_mentions(&chats, &criteria), 3); // 1 + 2
}
}

View File

@@ -1,195 +0,0 @@
/// Состояние списка чатов
///
/// Отвечает за:
/// - Список чатов
/// - Выбранный чат в списке
/// - Фильтрацию по папкам
/// - Поиск чатов
use crate::app::chat_filter::{ChatFilter, ChatFilterCriteria};
use crate::tdlib::ChatInfo;
use ratatui::widgets::ListState;
/// Состояние списка чатов
#[derive(Debug)]
pub struct ChatListState {
/// Список всех чатов
pub chats: Vec<ChatInfo>,
/// Состояние виджета списка (выбранный индекс)
pub list_state: ListState,
/// Выбранная папка (None = All, Some(id) = конкретная папка)
pub selected_folder_id: Option<i32>,
/// Флаг режима поиска чатов
pub is_searching: bool,
/// Поисковый запрос для фильтрации чатов
pub search_query: String,
}
impl Default for ChatListState {
fn default() -> Self {
let mut state = ListState::default();
state.select(Some(0));
Self {
chats: Vec::new(),
list_state: state,
selected_folder_id: None,
is_searching: false,
search_query: String::new(),
}
}
}
impl ChatListState {
/// Создать новое состояние списка чатов
pub fn new() -> Self {
Self::default()
}
// === Chats ===
pub fn chats(&self) -> &[ChatInfo] {
&self.chats
}
pub fn chats_mut(&mut self) -> &mut Vec<ChatInfo> {
&mut self.chats
}
pub fn set_chats(&mut self, chats: Vec<ChatInfo>) {
self.chats = chats;
}
pub fn add_chat(&mut self, chat: ChatInfo) {
self.chats.push(chat);
}
pub fn clear_chats(&mut self) {
self.chats.clear();
}
// === List state (selection) ===
pub fn list_state(&self) -> &ListState {
&self.list_state
}
pub fn list_state_mut(&mut self) -> &mut ListState {
&mut self.list_state
}
pub fn selected_index(&self) -> Option<usize> {
self.list_state.selected()
}
pub fn select(&mut self, index: Option<usize>) {
self.list_state.select(index);
}
// === Folder ===
pub fn selected_folder_id(&self) -> Option<i32> {
self.selected_folder_id
}
pub fn set_selected_folder_id(&mut self, id: Option<i32>) {
self.selected_folder_id = id;
}
// === Search ===
pub fn is_searching(&self) -> bool {
self.is_searching
}
pub fn set_searching(&mut self, searching: bool) {
self.is_searching = searching;
}
pub fn search_query(&self) -> &str {
&self.search_query
}
pub fn search_query_mut(&mut self) -> &mut String {
&mut self.search_query
}
pub fn set_search_query(&mut self, query: String) {
self.search_query = query;
}
pub fn start_search(&mut self) {
self.is_searching = true;
self.search_query.clear();
}
pub fn cancel_search(&mut self) {
self.is_searching = false;
self.search_query.clear();
self.list_state.select(Some(0));
}
// === Navigation ===
/// Получить отфильтрованный список чатов
pub fn get_filtered_chats(&self) -> Vec<&ChatInfo> {
// Используем ChatFilter для централизованной фильтрации
let mut criteria = ChatFilterCriteria::new().with_folder(self.selected_folder_id);
if !self.search_query.is_empty() {
criteria = criteria.with_search(self.search_query.clone());
}
ChatFilter::filter(&self.chats, &criteria)
}
/// Выбрать следующий чат
pub fn next_chat(&mut self) {
let filtered = self.get_filtered_chats();
if filtered.is_empty() {
return;
}
let i = match self.list_state.selected() {
Some(i) => {
if i >= filtered.len() - 1 {
0
} else {
i + 1
}
}
None => 0,
};
self.list_state.select(Some(i));
}
/// Выбрать предыдущий чат
pub fn previous_chat(&mut self) {
let filtered = self.get_filtered_chats();
if filtered.is_empty() {
return;
}
let i = match self.list_state.selected() {
Some(i) => {
if i == 0 {
filtered.len() - 1
} else {
i - 1
}
}
None => 0,
};
self.list_state.select(Some(i));
}
/// Получить выбранный в данный момент чат
pub fn get_selected_chat(&self) -> Option<&ChatInfo> {
let filtered = self.get_filtered_chats();
self.list_state
.selected()
.and_then(|i| filtered.get(i).copied())
}
}

View File

@@ -1,160 +0,0 @@
// Chat state management - type-safe state machine for chat modes
use crate::tdlib::{MessageInfo, ProfileInfo};
use crate::types::MessageId;
/// Vim-like input mode for chat view
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum InputMode {
/// Normal mode — navigation and commands (default)
#[default]
Normal,
/// Insert mode — text input only
Insert,
}
/// Состояния чата - взаимоисключающие режимы работы с чатом
#[derive(Debug, Clone, Default)]
pub enum ChatState {
/// Обычный режим - просмотр сообщений, набор текста
#[default]
Normal,
/// Выбор сообщения для действия (edit/delete/reply/forward/reaction)
MessageSelection {
/// Индекс выбранного сообщения (снизу вверх, 0 = последнее)
selected_index: usize,
},
/// Редактирование сообщения
Editing {
/// ID редактируемого сообщения
message_id: MessageId,
/// Индекс сообщения в списке
selected_index: usize,
},
/// Ответ на сообщение (reply)
Reply {
/// ID сообщения, на которое отвечаем
message_id: MessageId,
},
/// Пересылка сообщения (forward)
Forward {
/// ID сообщения для пересылки
message_id: MessageId,
},
/// Подтверждение удаления сообщения
DeleteConfirmation {
/// ID сообщения для удаления
message_id: MessageId,
},
/// Выбор реакции на сообщение
ReactionPicker {
/// ID сообщения для реакции
message_id: MessageId,
/// Список доступных реакций
available_reactions: Vec<String>,
/// Индекс выбранной реакции в picker
selected_index: usize,
},
/// Просмотр профиля пользователя/чата
Profile {
/// Информация профиля
info: ProfileInfo,
/// Индекс выбранного действия
selected_action: usize,
/// Шаг подтверждения выхода из группы (0 = не показано, 1-2 = подтверждения)
leave_group_confirmation_step: u8,
},
/// Поиск по сообщениям в текущем чате
SearchInChat {
/// Поисковый запрос
query: String,
/// Результаты поиска
results: Vec<MessageInfo>,
/// Индекс выбранного результата
selected_index: usize,
},
/// Просмотр закреплённых сообщений
PinnedMessages {
/// Список закреплённых сообщений
messages: Vec<MessageInfo>,
/// Индекс выбранного pinned сообщения
selected_index: usize,
},
}
impl ChatState {
/// Проверка: находимся в режиме выбора сообщения
pub fn is_message_selection(&self) -> bool {
matches!(self, ChatState::MessageSelection { .. })
}
/// Проверка: находимся в режиме редактирования
pub fn is_editing(&self) -> bool {
matches!(self, ChatState::Editing { .. })
}
/// Проверка: находимся в режиме ответа
pub fn is_reply(&self) -> bool {
matches!(self, ChatState::Reply { .. })
}
/// Проверка: находимся в режиме пересылки
pub fn is_forward(&self) -> bool {
matches!(self, ChatState::Forward { .. })
}
/// Проверка: показываем подтверждение удаления
pub fn is_delete_confirmation(&self) -> bool {
matches!(self, ChatState::DeleteConfirmation { .. })
}
/// Проверка: показываем reaction picker
pub fn is_reaction_picker(&self) -> bool {
matches!(self, ChatState::ReactionPicker { .. })
}
/// Проверка: показываем профиль
pub fn is_profile(&self) -> bool {
matches!(self, ChatState::Profile { .. })
}
/// Проверка: находимся в режиме поиска по сообщениям
pub fn is_search_in_chat(&self) -> bool {
matches!(self, ChatState::SearchInChat { .. })
}
/// Проверка: показываем pinned сообщения
pub fn is_pinned_mode(&self) -> bool {
matches!(self, ChatState::PinnedMessages { .. })
}
/// Возвращает ID выбранного сообщения (если есть)
pub fn selected_message_id(&self) -> Option<MessageId> {
match self {
ChatState::Editing { message_id, .. } => Some(*message_id),
ChatState::Reply { message_id } => Some(*message_id),
ChatState::Forward { message_id, .. } => Some(*message_id),
ChatState::DeleteConfirmation { message_id } => Some(*message_id),
ChatState::ReactionPicker { message_id, .. } => Some(*message_id),
_ => None,
}
}
/// Возвращает индекс выбранного сообщения (если есть)
pub fn selected_message_index(&self) -> Option<usize> {
match self {
ChatState::MessageSelection { selected_index } => Some(*selected_index),
ChatState::Editing { selected_index, .. } => Some(*selected_index),
_ => None,
}
}
}

View File

@@ -1,247 +0,0 @@
/// Состояние написания сообщения
///
/// Отвечает за:
/// - Текст сообщения
/// - Позицию курсора
/// - Typing indicator
use std::time::Instant;
/// Состояние написания сообщения
#[derive(Debug, Clone)]
pub struct ComposeState {
/// Текст вводимого сообщения
pub message_input: String,
/// Позиция курсора в message_input (в символах, не байтах)
pub cursor_position: usize,
/// Время последней отправки typing status (для throttling)
pub last_typing_sent: Option<Instant>,
}
impl Default for ComposeState {
fn default() -> Self {
Self {
message_input: String::new(),
cursor_position: 0,
last_typing_sent: None,
}
}
}
impl ComposeState {
/// Создать новое состояние написания сообщения
pub fn new() -> Self {
Self::default()
}
// === Message input ===
pub fn message_input(&self) -> &str {
&self.message_input
}
pub fn message_input_mut(&mut self) -> &mut String {
&mut self.message_input
}
pub fn set_message_input(&mut self, input: String) {
self.message_input = input;
self.cursor_position = self.message_input.chars().count();
}
pub fn clear_message_input(&mut self) {
self.message_input.clear();
self.cursor_position = 0;
}
pub fn is_empty(&self) -> bool {
self.message_input.is_empty()
}
// === Cursor position ===
pub fn cursor_position(&self) -> usize {
self.cursor_position
}
pub fn set_cursor_position(&mut self, pos: usize) {
let max_pos = self.message_input.chars().count();
self.cursor_position = pos.min(max_pos);
}
pub fn move_cursor_left(&mut self) {
if self.cursor_position > 0 {
self.cursor_position -= 1;
}
}
pub fn move_cursor_right(&mut self) {
let max_pos = self.message_input.chars().count();
if self.cursor_position < max_pos {
self.cursor_position += 1;
}
}
pub fn move_cursor_to_start(&mut self) {
self.cursor_position = 0;
}
pub fn move_cursor_to_end(&mut self) {
self.cursor_position = self.message_input.chars().count();
}
// === Typing indicator ===
pub fn last_typing_sent(&self) -> Option<Instant> {
self.last_typing_sent
}
pub fn set_last_typing_sent(&mut self, time: Option<Instant>) {
self.last_typing_sent = time;
}
pub fn update_last_typing_sent(&mut self) {
self.last_typing_sent = Some(Instant::now());
}
pub fn clear_typing_indicator(&mut self) {
self.last_typing_sent = None;
}
/// Проверить, нужно ли отправить typing indicator
/// (если прошло больше 5 секунд с последней отправки)
pub fn should_send_typing(&self) -> bool {
match self.last_typing_sent {
None => true,
Some(last) => last.elapsed().as_secs() >= 5,
}
}
// === Text editing ===
/// Вставить символ в текущую позицию курсора
pub fn insert_char(&mut self, c: char) {
let char_indices: Vec<usize> = self.message_input.char_indices().map(|(i, _)| i).collect();
let byte_pos = if self.cursor_position >= char_indices.len() {
self.message_input.len()
} else {
char_indices[self.cursor_position]
};
self.message_input.insert(byte_pos, c);
self.cursor_position += 1;
}
/// Удалить символ перед курсором (Backspace)
pub fn delete_char_before_cursor(&mut self) {
if self.cursor_position > 0 {
let char_indices: Vec<usize> = self.message_input.char_indices().map(|(i, _)| i).collect();
let byte_pos = char_indices[self.cursor_position - 1];
self.message_input.remove(byte_pos);
self.cursor_position -= 1;
}
}
/// Удалить символ после курсора (Delete)
pub fn delete_char_after_cursor(&mut self) {
let char_indices: Vec<usize> = self.message_input.char_indices().map(|(i, _)| i).collect();
if self.cursor_position < char_indices.len() {
let byte_pos = char_indices[self.cursor_position];
self.message_input.remove(byte_pos);
}
}
/// Удалить слово перед курсором (Ctrl+Backspace)
pub fn delete_word_before_cursor(&mut self) {
if self.cursor_position == 0 {
return;
}
let chars: Vec<char> = self.message_input.chars().collect();
let mut pos = self.cursor_position;
// Пропустить пробелы
while pos > 0 && chars[pos - 1].is_whitespace() {
pos -= 1;
}
// Удалить символы слова
while pos > 0 && !chars[pos - 1].is_whitespace() {
pos -= 1;
}
let removed_count = self.cursor_position - pos;
if removed_count > 0 {
let char_indices: Vec<usize> = self.message_input.char_indices().map(|(i, _)| i).collect();
let start_byte = char_indices[pos];
let end_byte = if self.cursor_position >= char_indices.len() {
self.message_input.len()
} else {
char_indices[self.cursor_position]
};
self.message_input.drain(start_byte..end_byte);
self.cursor_position = pos;
}
}
/// Очистить всё и сбросить состояние
pub fn reset(&mut self) {
self.message_input.clear();
self.cursor_position = 0;
self.last_typing_sent = None;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_insert_char() {
let mut state = ComposeState::new();
state.insert_char('H');
state.insert_char('i');
assert_eq!(state.message_input(), "Hi");
assert_eq!(state.cursor_position(), 2);
}
#[test]
fn test_delete_char_before_cursor() {
let mut state = ComposeState::new();
state.set_message_input("Hello".to_string());
state.delete_char_before_cursor();
assert_eq!(state.message_input(), "Hell");
assert_eq!(state.cursor_position(), 4);
}
#[test]
fn test_cursor_movement() {
let mut state = ComposeState::new();
state.set_message_input("Hello".to_string());
state.move_cursor_to_start();
assert_eq!(state.cursor_position(), 0);
state.move_cursor_right();
assert_eq!(state.cursor_position(), 1);
state.move_cursor_to_end();
assert_eq!(state.cursor_position(), 5);
state.move_cursor_left();
assert_eq!(state.cursor_position(), 4);
}
#[test]
fn test_delete_word() {
let mut state = ComposeState::new();
state.set_message_input("Hello World".to_string());
state.delete_word_before_cursor();
assert_eq!(state.message_input(), "Hello ");
}
}

View File

@@ -1,512 +0,0 @@
/// Модуль для бизнес-логики работы с сообщениями
///
/// Чёткое разделение ответственности:
/// - `tdlib/messages.rs` - только получение и преобразование из TDLib
/// - `app/message_service.rs` (этот модуль) - бизнес-логика и операции
/// - `ui/messages.rs` - только рендеринг
///
/// Этот модуль отвечает за:
/// - Группировку сообщений по дате и отправителю
/// - Фильтрацию сообщений
/// - Поиск внутри сообщений
/// - Навигацию по сообщениям
/// - Операции над сообщениями (edit, delete, reply и т.д.)
use crate::tdlib::MessageInfo;
use crate::types::MessageId;
use chrono::{DateTime, Local};
use std::collections::HashMap;
/// Группа сообщений по дате
#[derive(Debug, Clone)]
pub struct MessageGroup {
/// Дата группы (отображаемая строка, например "Сегодня", "Вчера", "1 января")
pub date_label: String,
/// Сообщения в этой группе (отсортированы по времени)
pub messages: Vec<MessageId>,
}
/// Подгруппа сообщений от одного отправителя
#[derive(Debug, Clone)]
pub struct SenderGroup {
/// ID первого сообщения в группе
pub first_message_id: MessageId,
/// Имя отправителя
pub sender_name: String,
/// Список ID сообщений от этого отправителя подряд
pub message_ids: Vec<MessageId>,
}
/// Результат поиска сообщений
#[derive(Debug, Clone)]
pub struct MessageSearchResult {
/// ID сообщения
pub message_id: MessageId,
/// Позиция в списке сообщений
pub index: usize,
/// Фрагмент текста с совпадением
pub snippet: String,
/// Позиция совпадения в тексте
pub match_position: usize,
}
/// Сервис для работы с сообщениями
pub struct MessageService;
impl MessageService {
/// Группирует сообщения по дате
///
/// # Arguments
///
/// * `messages` - Список сообщений (должен быть отсортирован по времени)
/// * `timezone_offset` - Смещение часового пояса в секундах
///
/// # Returns
///
/// Список групп сообщений по датам
pub fn group_by_date(
messages: &[MessageInfo],
timezone_offset: i32,
) -> Vec<MessageGroup> {
let mut groups: Vec<MessageGroup> = Vec::new();
let mut current_date: Option<String> = None;
let mut current_messages: Vec<MessageId> = Vec::new();
for msg in messages {
let date_label = Self::get_date_label(msg.date(), timezone_offset);
if current_date.as_ref() != Some(&date_label) {
// Начинается новая дата - сохраняем предыдущую группу
if let Some(date) = current_date {
groups.push(MessageGroup {
date_label: date,
messages: current_messages.clone(),
});
current_messages.clear();
}
current_date = Some(date_label);
}
current_messages.push(msg.id());
}
// Добавляем последнюю группу
if let Some(date) = current_date {
groups.push(MessageGroup {
date_label: date,
messages: current_messages,
});
}
groups
}
/// Группирует сообщения по отправителю внутри одной даты
///
/// Последовательные сообщения от одного отправителя объединяются в группу.
pub fn group_by_sender(messages: &[MessageInfo]) -> Vec<SenderGroup> {
let mut groups: Vec<SenderGroup> = Vec::new();
let mut current_sender: Option<String> = None;
let mut current_ids: Vec<MessageId> = Vec::new();
let mut first_id: Option<MessageId> = None;
for msg in messages {
let sender = msg.sender_name().to_string();
if current_sender.as_ref() != Some(&sender) {
// Новый отправитель - сохраняем предыдущую группу
if let (Some(name), Some(first)) = (current_sender, first_id) {
groups.push(SenderGroup {
first_message_id: first,
sender_name: name,
message_ids: current_ids.clone(),
});
current_ids.clear();
}
current_sender = Some(sender);
first_id = Some(msg.id());
}
current_ids.push(msg.id());
}
// Добавляем последнюю группу
if let (Some(name), Some(first)) = (current_sender, first_id) {
groups.push(SenderGroup {
first_message_id: first,
sender_name: name,
message_ids: current_ids,
});
}
groups
}
/// Получает человекочитаемую метку даты
///
/// Возвращает "Сегодня", "Вчера" или дату в формате "1 января 2024"
fn get_date_label(timestamp: i32, _timezone_offset: i32) -> String {
let dt = DateTime::from_timestamp(timestamp as i64, 0)
.map(|dt| dt.with_timezone(&Local))
.unwrap_or_else(|| Local::now());
let msg_date = dt.date_naive();
let today = Local::now().date_naive();
let yesterday = today.pred_opt().unwrap_or(today);
if msg_date == today {
"Сегодня".to_string()
} else if msg_date == yesterday {
"Вчера".to_string()
} else {
msg_date.format("%d %B %Y").to_string()
}
}
/// Ищет сообщения по текстовому запросу
///
/// # Arguments
///
/// * `messages` - Список сообщений для поиска
/// * `query` - Поисковый запрос (case-insensitive)
/// * `max_results` - Максимальное количество результатов (0 = без ограничений)
///
/// # Returns
///
/// Список результатов поиска с контекстом
pub fn search(
messages: &[MessageInfo],
query: &str,
max_results: usize,
) -> Vec<MessageSearchResult> {
if query.is_empty() {
return Vec::new();
}
let query_lower = query.to_lowercase();
let mut results = Vec::new();
for (index, msg) in messages.iter().enumerate() {
let text = msg.text().to_lowercase();
if let Some(pos) = text.find(&query_lower) {
// Создаём snippet с контекстом
let start = pos.saturating_sub(20);
let end = (pos + query.len() + 20).min(text.len());
let snippet = msg.text()[start..end].to_string();
results.push(MessageSearchResult {
message_id: msg.id(),
index,
snippet,
match_position: pos,
});
if max_results > 0 && results.len() >= max_results {
break;
}
}
}
results
}
/// Находит следующее сообщение по запросу
///
/// # Arguments
///
/// * `messages` - Список сообщений
/// * `current_index` - Текущая позиция
/// * `query` - Поисковый запрос
///
/// # Returns
///
/// Индекс следующего найденного сообщения или None
pub fn find_next(
messages: &[MessageInfo],
current_index: usize,
query: &str,
) -> Option<usize> {
if query.is_empty() {
return None;
}
let query_lower = query.to_lowercase();
for (index, msg) in messages.iter().enumerate().skip(current_index + 1) {
if msg.text().to_lowercase().contains(&query_lower) {
return Some(index);
}
}
None
}
/// Находит предыдущее сообщение по запросу
pub fn find_previous(
messages: &[MessageInfo],
current_index: usize,
query: &str,
) -> Option<usize> {
if query.is_empty() || current_index == 0 {
return None;
}
let query_lower = query.to_lowercase();
for (index, msg) in messages.iter().enumerate().take(current_index).rev() {
if msg.text().to_lowercase().contains(&query_lower) {
return Some(index);
}
}
None
}
/// Фильтрует сообщения по отправителю
pub fn filter_by_sender<'a>(
messages: &'a [MessageInfo],
sender_name: &str,
) -> Vec<&'a MessageInfo> {
messages
.iter()
.filter(|msg| msg.sender_name() == sender_name)
.collect()
}
/// Фильтрует только непрочитанные сообщения
pub fn filter_unread<'a>(
messages: &'a [MessageInfo],
last_read_id: MessageId,
) -> Vec<&'a MessageInfo> {
messages
.iter()
.filter(|msg| msg.id().as_i64() > last_read_id.as_i64())
.collect()
}
/// Находит сообщение по ID
pub fn find_by_id<'a>(
messages: &'a [MessageInfo],
id: MessageId,
) -> Option<&'a MessageInfo> {
messages.iter().find(|msg| msg.id() == id)
}
/// Находит индекс сообщения по ID
pub fn find_index_by_id(
messages: &[MessageInfo],
id: MessageId,
) -> Option<usize> {
messages.iter().position(|msg| msg.id() == id)
}
/// Получает N последних сообщений
pub fn get_last_n<'a>(
messages: &'a [MessageInfo],
n: usize,
) -> &'a [MessageInfo] {
let start = messages.len().saturating_sub(n);
&messages[start..]
}
/// Получает сообщения в диапазоне дат
pub fn get_in_date_range<'a>(
messages: &'a [MessageInfo],
start_date: i32,
end_date: i32,
) -> Vec<&'a MessageInfo> {
messages
.iter()
.filter(|msg| {
let date = msg.date();
date >= start_date && date <= end_date
})
.collect()
}
/// Подсчитывает сообщения по типу отправителя
pub fn count_by_sender_type(messages: &[MessageInfo]) -> (usize, usize) {
let mut incoming = 0;
let mut outgoing = 0;
for msg in messages {
if msg.is_outgoing() {
outgoing += 1;
} else {
incoming += 1;
}
}
(incoming, outgoing)
}
/// Создаёт индекс сообщений по ID для быстрого доступа
pub fn create_index(messages: &[MessageInfo]) -> HashMap<MessageId, usize> {
messages
.iter()
.enumerate()
.map(|(index, msg)| (msg.id(), index))
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tdlib::MessageInfo;
use crate::types::MessageId;
fn create_test_message(
id: i64,
text: &str,
sender: &str,
date: i32,
is_outgoing: bool,
) -> MessageInfo {
MessageInfo::new(
MessageId::new(id),
sender.to_string(),
is_outgoing,
text.to_string(),
Vec::new(), // entities
date,
0, // edit_date
true, // is_read
is_outgoing, // can_be_edited only for outgoing
true, // can_be_deleted_only_for_self
is_outgoing, // can_be_deleted_for_all_users only for outgoing
None, // reply_to
None, // forward_from
Vec::new(), // reactions
)
}
#[test]
fn test_search() {
let messages = vec![
create_test_message(1, "Hello world", "Alice", 1000, false),
create_test_message(2, "How are you?", "Bob", 1010, false),
create_test_message(3, "Hello there", "Alice", 1020, false),
];
let results = MessageService::search(&messages, "hello", 0);
assert_eq!(results.len(), 2);
assert_eq!(results[0].message_id.as_i64(), 1);
assert_eq!(results[1].message_id.as_i64(), 3);
// Case-insensitive
let results = MessageService::search(&messages, "HELLO", 0);
assert_eq!(results.len(), 2);
// Max results
let results = MessageService::search(&messages, "hello", 1);
assert_eq!(results.len(), 1);
}
#[test]
fn test_find_next_previous() {
let messages = vec![
create_test_message(1, "test 1", "Alice", 1000, false),
create_test_message(2, "message", "Bob", 1010, false),
create_test_message(3, "test 2", "Alice", 1020, false),
create_test_message(4, "test 3", "Bob", 1030, false),
];
// Find next
let next = MessageService::find_next(&messages, 0, "test");
assert_eq!(next, Some(2));
let next = MessageService::find_next(&messages, 2, "test");
assert_eq!(next, Some(3));
// Find previous
let prev = MessageService::find_previous(&messages, 3, "test");
assert_eq!(prev, Some(2));
let prev = MessageService::find_previous(&messages, 2, "test");
assert_eq!(prev, Some(0));
}
#[test]
fn test_filter_by_sender() {
let messages = vec![
create_test_message(1, "msg1", "Alice", 1000, false),
create_test_message(2, "msg2", "Bob", 1010, false),
create_test_message(3, "msg3", "Alice", 1020, false),
];
let filtered = MessageService::filter_by_sender(&messages, "Alice");
assert_eq!(filtered.len(), 2);
assert_eq!(filtered[0].id().as_i64(), 1);
assert_eq!(filtered[1].id().as_i64(), 3);
}
#[test]
fn test_find_by_id() {
let messages = vec![
create_test_message(1, "msg1", "Alice", 1000, false),
create_test_message(2, "msg2", "Bob", 1010, false),
];
let found = MessageService::find_by_id(&messages, MessageId::new(2));
assert!(found.is_some());
assert_eq!(found.unwrap().text(), "msg2");
let not_found = MessageService::find_by_id(&messages, MessageId::new(999));
assert!(not_found.is_none());
}
#[test]
fn test_count_by_sender_type() {
let messages = vec![
create_test_message(1, "msg1", "Alice", 1000, false),
create_test_message(2, "msg2", "Me", 1010, true),
create_test_message(3, "msg3", "Bob", 1020, false),
create_test_message(4, "msg4", "Me", 1030, true),
];
let (incoming, outgoing) = MessageService::count_by_sender_type(&messages);
assert_eq!(incoming, 2);
assert_eq!(outgoing, 2);
}
#[test]
fn test_get_last_n() {
let messages = vec![
create_test_message(1, "msg1", "Alice", 1000, false),
create_test_message(2, "msg2", "Bob", 1010, false),
create_test_message(3, "msg3", "Alice", 1020, false),
];
let last_2 = MessageService::get_last_n(&messages, 2);
assert_eq!(last_2.len(), 2);
assert_eq!(last_2[0].id().as_i64(), 2);
assert_eq!(last_2[1].id().as_i64(), 3);
// Request more than available
let last_10 = MessageService::get_last_n(&messages, 10);
assert_eq!(last_10.len(), 3);
}
#[test]
fn test_create_index() {
let messages = vec![
create_test_message(1, "msg1", "Alice", 1000, false),
create_test_message(2, "msg2", "Bob", 1010, false),
create_test_message(3, "msg3", "Alice", 1020, false),
];
let index = MessageService::create_index(&messages);
assert_eq!(index.len(), 3);
assert_eq!(index.get(&MessageId::new(1)), Some(&0));
assert_eq!(index.get(&MessageId::new(2)), Some(&1));
assert_eq!(index.get(&MessageId::new(3)), Some(&2));
}
}

View File

@@ -1,277 +0,0 @@
/// Состояние просмотра сообщений
///
/// Отвечает за:
/// - Текущий открытый чат
/// - Скроллинг сообщений
/// - Состояние чата (редактирование, ответ, и т.д.)
use crate::app::ChatState;
use crate::types::{ChatId, MessageId};
/// Состояние просмотра сообщений
#[derive(Debug, Clone)]
pub struct MessageViewState {
/// ID текущего открытого чата
pub selected_chat_id: Option<ChatId>,
/// Оффсет скроллинга для сообщений
pub message_scroll_offset: usize,
/// Состояние чата (Normal, Editing, Reply, и т.д.)
pub chat_state: ChatState,
}
impl Default for MessageViewState {
fn default() -> Self {
Self {
selected_chat_id: None,
message_scroll_offset: 0,
chat_state: ChatState::Normal,
}
}
}
impl MessageViewState {
/// Создать новое состояние просмотра сообщений
pub fn new() -> Self {
Self::default()
}
// === Selected chat ===
pub fn selected_chat_id(&self) -> Option<ChatId> {
self.selected_chat_id
}
pub fn set_selected_chat_id(&mut self, id: Option<ChatId>) {
self.selected_chat_id = id;
}
pub fn has_open_chat(&self) -> bool {
self.selected_chat_id.is_some()
}
pub fn close_chat(&mut self) {
self.selected_chat_id = None;
self.message_scroll_offset = 0;
self.chat_state = ChatState::Normal;
}
// === Scroll offset ===
pub fn message_scroll_offset(&self) -> usize {
self.message_scroll_offset
}
pub fn set_message_scroll_offset(&mut self, offset: usize) {
self.message_scroll_offset = offset;
}
pub fn reset_scroll(&mut self) {
self.message_scroll_offset = 0;
}
// === Chat state ===
pub fn chat_state(&self) -> &ChatState {
&self.chat_state
}
pub fn chat_state_mut(&mut self) -> &mut ChatState {
&mut self.chat_state
}
pub fn set_chat_state(&mut self, state: ChatState) {
self.chat_state = state;
}
pub fn reset_chat_state(&mut self) {
self.chat_state = ChatState::Normal;
}
// === Message selection ===
pub fn is_selecting_message(&self) -> bool {
self.chat_state.is_message_selection()
}
pub fn start_message_selection(&mut self, total_messages: usize) {
if total_messages == 0 {
return;
}
self.chat_state = ChatState::MessageSelection {
selected_index: total_messages - 1,
};
}
pub fn select_previous_message(&mut self) {
if let ChatState::MessageSelection { selected_index } = &mut self.chat_state {
if *selected_index > 0 {
*selected_index -= 1;
}
}
}
pub fn select_next_message(&mut self, total_messages: usize) {
if total_messages == 0 {
return;
}
if let ChatState::MessageSelection { selected_index } = &mut self.chat_state {
if *selected_index < total_messages - 1 {
*selected_index += 1;
} else {
self.chat_state = ChatState::Normal;
}
}
}
pub fn get_selected_message_index(&self) -> Option<usize> {
self.chat_state.selected_message_index()
}
// === Editing ===
pub fn is_editing(&self) -> bool {
self.chat_state.is_editing()
}
pub fn start_editing(&mut self, message_id: MessageId, selected_index: usize) {
self.chat_state = ChatState::Editing {
message_id,
selected_index,
};
}
pub fn cancel_editing(&mut self) {
self.chat_state = ChatState::Normal;
}
pub fn get_editing_message_id(&self) -> Option<MessageId> {
if let ChatState::Editing { message_id, .. } = &self.chat_state {
Some(*message_id)
} else {
None
}
}
// === Reply ===
pub fn is_replying(&self) -> bool {
self.chat_state.is_reply()
}
pub fn start_reply(&mut self, message_id: MessageId) {
self.chat_state = ChatState::Reply { message_id };
}
pub fn cancel_reply(&mut self) {
self.chat_state = ChatState::Normal;
}
pub fn get_replying_to_message_id(&self) -> Option<MessageId> {
if let ChatState::Reply { message_id } = &self.chat_state {
Some(*message_id)
} else {
None
}
}
// === Forward ===
pub fn is_forwarding(&self) -> bool {
self.chat_state.is_forward()
}
pub fn start_forward(&mut self, message_id: MessageId) {
self.chat_state = ChatState::Forward {
message_id,
};
}
pub fn cancel_forward(&mut self) {
self.chat_state = ChatState::Normal;
}
// === Delete confirmation ===
pub fn is_confirm_delete_shown(&self) -> bool {
self.chat_state.is_delete_confirmation()
}
// === Pinned messages ===
pub fn is_pinned_mode(&self) -> bool {
self.chat_state.is_pinned_mode()
}
pub fn enter_pinned_mode(&mut self, messages: Vec<crate::tdlib::MessageInfo>) {
if !messages.is_empty() {
self.chat_state = ChatState::PinnedMessages {
messages,
selected_index: 0,
};
}
}
pub fn exit_pinned_mode(&mut self) {
self.chat_state = ChatState::Normal;
}
// === Search in chat ===
pub fn is_message_search_mode(&self) -> bool {
self.chat_state.is_search_in_chat()
}
pub fn enter_message_search_mode(&mut self) {
self.chat_state = ChatState::SearchInChat {
query: String::new(),
results: Vec::new(),
selected_index: 0,
};
}
pub fn exit_message_search_mode(&mut self) {
self.chat_state = ChatState::Normal;
}
// === Profile ===
pub fn is_profile_mode(&self) -> bool {
self.chat_state.is_profile()
}
pub fn enter_profile_mode(&mut self, info: crate::tdlib::ProfileInfo) {
self.chat_state = ChatState::Profile {
info,
selected_action: 0,
leave_group_confirmation_step: 0,
};
}
pub fn exit_profile_mode(&mut self) {
self.chat_state = ChatState::Normal;
}
// === Reaction picker ===
pub fn is_reaction_picker_mode(&self) -> bool {
self.chat_state.is_reaction_picker()
}
pub fn enter_reaction_picker_mode(
&mut self,
message_id: MessageId,
available_reactions: Vec<String>,
) {
self.chat_state = ChatState::ReactionPicker {
message_id,
available_reactions,
selected_index: 0,
};
}
pub fn exit_reaction_picker_mode(&mut self) {
self.chat_state = ChatState::Normal;
}
}

View File

@@ -1,117 +0,0 @@
//! Compose methods for App
//!
//! Handles reply, forward, and draft functionality
use crate::app::methods::messages::MessageMethods;
use crate::app::{App, ChatState};
use crate::tdlib::{MessageInfo, TdClientTrait};
/// Compose methods for reply/forward/draft
pub trait ComposeMethods<T: TdClientTrait> {
/// Start replying to the selected message
/// Returns true if reply mode started, false if no message selected
fn start_reply_to_selected(&mut self) -> bool;
/// Cancel reply mode
fn cancel_reply(&mut self);
/// Check if currently in reply mode
fn is_replying(&self) -> bool;
/// Get the message being replied to
fn get_replying_to_message(&self) -> Option<MessageInfo>;
/// Start forwarding the selected message
/// Returns true if forward mode started, false if no message selected
fn start_forward_selected(&mut self) -> bool;
/// Cancel forward mode
fn cancel_forward(&mut self);
/// Check if currently in forward mode (selecting target chat)
fn is_forwarding(&self) -> bool;
/// Get the message being forwarded
fn get_forwarding_message(&self) -> Option<MessageInfo>;
/// Get draft for the currently selected chat
fn get_current_draft(&self) -> Option<String>;
/// Load draft into message_input (called when opening chat)
fn load_draft(&mut self);
}
impl<T: TdClientTrait> ComposeMethods<T> for App<T> {
fn start_reply_to_selected(&mut self) -> bool {
if let Some(msg) = self.get_selected_message() {
self.chat_state = ChatState::Reply { message_id: msg.id() };
return true;
}
false
}
fn cancel_reply(&mut self) {
self.chat_state = ChatState::Normal;
}
fn is_replying(&self) -> bool {
self.chat_state.is_reply()
}
fn get_replying_to_message(&self) -> Option<MessageInfo> {
self.chat_state.selected_message_id().and_then(|id| {
self.td_client
.current_chat_messages()
.iter()
.find(|m| m.id() == id)
.cloned()
})
}
fn start_forward_selected(&mut self) -> bool {
if let Some(msg) = self.get_selected_message() {
self.chat_state = ChatState::Forward { message_id: msg.id() };
// Сбрасываем выбор чата на первый
self.chat_list_state.select(Some(0));
return true;
}
false
}
fn cancel_forward(&mut self) {
self.chat_state = ChatState::Normal;
}
fn is_forwarding(&self) -> bool {
self.chat_state.is_forward()
}
fn get_forwarding_message(&self) -> Option<MessageInfo> {
if !self.chat_state.is_forward() {
return None;
}
self.chat_state.selected_message_id().and_then(|id| {
self.td_client
.current_chat_messages()
.iter()
.find(|m| m.id() == id)
.cloned()
})
}
fn get_current_draft(&self) -> Option<String> {
self.selected_chat_id.and_then(|chat_id| {
self.chats
.iter()
.find(|c| c.id == chat_id)
.and_then(|c| c.draft_text.clone())
})
}
fn load_draft(&mut self) {
if let Some(draft) = self.get_current_draft() {
self.message_input = draft;
self.cursor_position = self.message_input.chars().count();
}
}
}

View File

@@ -1,175 +0,0 @@
//! Message methods for App
//!
//! Handles message selection, editing, and operations
use crate::app::{App, ChatState};
use crate::tdlib::{MessageInfo, TdClientTrait};
/// Message operation methods
pub trait MessageMethods<T: TdClientTrait> {
/// Start message selection mode (triggered by Up arrow in empty input)
fn start_message_selection(&mut self);
/// Select previous message (up in history = older)
fn select_previous_message(&mut self);
/// Select next message (down in history = newer)
fn select_next_message(&mut self);
/// Get currently selected message
fn get_selected_message(&self) -> Option<MessageInfo>;
/// Start editing the selected message
/// Returns true if editing started, false if message cannot be edited
fn start_editing_selected(&mut self) -> bool;
/// Cancel message editing and clear input
fn cancel_editing(&mut self);
/// Check if currently in editing mode
fn is_editing(&self) -> bool;
/// Check if currently in message selection mode
fn is_selecting_message(&self) -> bool;
}
impl<T: TdClientTrait> MessageMethods<T> for App<T> {
fn start_message_selection(&mut self) {
let messages = self.td_client.current_chat_messages();
let total = messages.len();
if total == 0 {
return;
}
// Начинаем с последнего сообщения (индекс len-1 = самое новое внизу)
// Если оно часть альбома — перемещаемся к первому элементу альбома
let mut idx = total - 1;
let album_id = messages[idx].media_album_id();
if album_id != 0 {
while idx > 0 && messages[idx - 1].media_album_id() == album_id {
idx -= 1;
}
}
self.chat_state = ChatState::MessageSelection { selected_index: idx };
}
fn select_previous_message(&mut self) {
if let ChatState::MessageSelection { selected_index } = &mut self.chat_state {
if *selected_index > 0 {
let messages = self.td_client.current_chat_messages();
let current_album_id = messages[*selected_index].media_album_id();
// Перескакиваем через все сообщения текущего альбома назад
let mut new_index = *selected_index - 1;
if current_album_id != 0 {
while new_index > 0 && messages[new_index].media_album_id() == current_album_id
{
new_index -= 1;
}
}
// Если попали в середину другого альбома — перемещаемся к его первому элементу
let target_album_id = messages[new_index].media_album_id();
if target_album_id != 0 {
while new_index > 0
&& messages[new_index - 1].media_album_id() == target_album_id
{
new_index -= 1;
}
}
*selected_index = new_index;
self.stop_playback();
}
}
}
fn select_next_message(&mut self) {
let total = self.td_client.current_chat_messages().len();
if total == 0 {
return;
}
if let ChatState::MessageSelection { selected_index } = &mut self.chat_state {
if *selected_index < total - 1 {
let messages = self.td_client.current_chat_messages();
let current_album_id = messages[*selected_index].media_album_id();
// Перескакиваем через все сообщения текущего альбома вперёд
let mut new_index = *selected_index + 1;
if current_album_id != 0 {
while new_index < total - 1
&& messages[new_index].media_album_id() == current_album_id
{
new_index += 1;
}
// Если мы ещё на последнем элементе альбома — нужно шагнуть на следующее
if messages[new_index].media_album_id() == current_album_id
&& new_index < total - 1
{
new_index += 1;
}
}
if new_index < total {
*selected_index = new_index;
self.stop_playback();
}
// Если new_index >= total — остаёмся на текущем
}
// Если уже на последнем — ничего не делаем, остаёмся на месте
}
}
fn get_selected_message(&self) -> Option<MessageInfo> {
self.chat_state
.selected_message_index()
.and_then(|idx| self.td_client.current_chat_messages().get(idx).cloned())
}
fn start_editing_selected(&mut self) -> bool {
// Получаем selected_index из текущего состояния
let selected_idx = match &self.chat_state {
ChatState::MessageSelection { selected_index } => Some(*selected_index),
_ => None,
};
let Some(selected_idx) = selected_idx else {
return false;
};
// Сначала извлекаем данные из сообщения
let msg_data = self.get_selected_message().and_then(|msg| {
// Проверяем:
// 1. Можно редактировать
// 2. Это исходящее сообщение
// 3. ID не временный (временные ID в TDLib отрицательные)
if msg.can_be_edited() && msg.is_outgoing() && msg.id().as_i64() > 0 {
Some((msg.id(), msg.text().to_string(), selected_idx))
} else {
None
}
});
// Затем присваиваем
if let Some((id, content, idx)) = msg_data {
self.cursor_position = content.chars().count();
self.message_input = content;
self.chat_state = ChatState::Editing { message_id: id, selected_index: idx };
return true;
}
false
}
fn cancel_editing(&mut self) {
self.chat_state = ChatState::Normal;
self.message_input.clear();
self.cursor_position = 0;
}
fn is_editing(&self) -> bool {
self.chat_state.is_editing()
}
fn is_selecting_message(&self) -> bool {
self.chat_state.is_message_selection()
}
}

View File

@@ -1,25 +0,0 @@
//! App methods organized by functionality
//!
//! This module contains traits that organize App methods into logical groups:
//! - navigation: Chat list navigation
//! - messages: Message operations and selection
//! - compose: Reply/Forward/Draft functionality
//! - search: Search in chats and messages
//! - modal: Modal dialogs (Profile, Pinned, Reactions, Delete)
pub mod compose;
pub mod messages;
pub mod modal;
pub mod navigation;
pub mod search;
#[allow(unused_imports)]
pub use compose::ComposeMethods;
#[allow(unused_imports)]
pub use messages::MessageMethods;
#[allow(unused_imports)]
pub use modal::ModalMethods;
#[allow(unused_imports)]
pub use navigation::NavigationMethods;
#[allow(unused_imports)]
pub use search::SearchMethods;

View File

@@ -1,266 +0,0 @@
//! Modal methods for App
//!
//! Handles modal dialogs: Profile, Pinned Messages, Reactions, Delete Confirmation
use crate::app::{App, ChatState};
use crate::tdlib::{MessageInfo, ProfileInfo, TdClientTrait};
use crate::types::MessageId;
/// Modal dialog methods
pub trait ModalMethods<T: TdClientTrait> {
// === Delete Confirmation ===
/// Check if delete confirmation modal is shown
fn is_confirm_delete_shown(&self) -> bool;
// === Pinned Messages ===
/// Check if in pinned messages mode
fn is_pinned_mode(&self) -> bool;
/// Enter pinned messages mode
fn enter_pinned_mode(&mut self, messages: Vec<MessageInfo>);
/// Exit pinned messages mode
fn exit_pinned_mode(&mut self);
/// Select previous pinned message (up = older)
fn select_previous_pinned(&mut self);
/// Select next pinned message (down = newer)
fn select_next_pinned(&mut self);
/// Get currently selected pinned message
fn get_selected_pinned(&self) -> Option<&MessageInfo>;
/// Get ID of selected pinned message for navigation
fn get_selected_pinned_id(&self) -> Option<i64>;
// === Profile ===
/// Check if in profile mode
fn is_profile_mode(&self) -> bool;
/// Enter profile mode
fn enter_profile_mode(&mut self, info: ProfileInfo);
/// Exit profile mode
fn exit_profile_mode(&mut self);
/// Select previous profile action
fn select_previous_profile_action(&mut self);
/// Select next profile action
fn select_next_profile_action(&mut self, max_actions: usize);
/// Show first leave group confirmation
fn show_leave_group_confirmation(&mut self);
/// Show second leave group confirmation
fn show_leave_group_final_confirmation(&mut self);
/// Cancel leave group confirmation
fn cancel_leave_group(&mut self);
/// Get current leave group confirmation step (0, 1, or 2)
fn get_leave_group_confirmation_step(&self) -> u8;
/// Get profile info
fn get_profile_info(&self) -> Option<&ProfileInfo>;
/// Get selected profile action index
fn get_selected_profile_action(&self) -> Option<usize>;
// === Reactions ===
/// Check if in reaction picker mode
fn is_reaction_picker_mode(&self) -> bool;
/// Enter reaction picker mode
fn enter_reaction_picker_mode(&mut self, message_id: i64, available_reactions: Vec<String>);
/// Exit reaction picker mode
fn exit_reaction_picker_mode(&mut self);
/// Select previous reaction
fn select_previous_reaction(&mut self);
/// Select next reaction
fn select_next_reaction(&mut self);
/// Get currently selected reaction emoji
fn get_selected_reaction(&self) -> Option<&String>;
/// Get message ID for which reaction is being selected
fn get_selected_message_for_reaction(&self) -> Option<i64>;
}
impl<T: TdClientTrait> ModalMethods<T> for App<T> {
fn is_confirm_delete_shown(&self) -> bool {
self.chat_state.is_delete_confirmation()
}
fn is_pinned_mode(&self) -> bool {
self.chat_state.is_pinned_mode()
}
fn enter_pinned_mode(&mut self, messages: Vec<MessageInfo>) {
if !messages.is_empty() {
self.chat_state = ChatState::PinnedMessages { messages, selected_index: 0 };
}
}
fn exit_pinned_mode(&mut self) {
self.chat_state = ChatState::Normal;
}
fn select_previous_pinned(&mut self) {
if let ChatState::PinnedMessages { selected_index, messages } = &mut self.chat_state {
if *selected_index + 1 < messages.len() {
*selected_index += 1;
}
}
}
fn select_next_pinned(&mut self) {
if let ChatState::PinnedMessages { selected_index, .. } = &mut self.chat_state {
if *selected_index > 0 {
*selected_index -= 1;
}
}
}
fn get_selected_pinned(&self) -> Option<&MessageInfo> {
if let ChatState::PinnedMessages { messages, selected_index } = &self.chat_state {
messages.get(*selected_index)
} else {
None
}
}
fn get_selected_pinned_id(&self) -> Option<i64> {
self.get_selected_pinned().map(|m| m.id().as_i64())
}
fn is_profile_mode(&self) -> bool {
self.chat_state.is_profile()
}
fn enter_profile_mode(&mut self, info: ProfileInfo) {
self.chat_state = ChatState::Profile {
info,
selected_action: 0,
leave_group_confirmation_step: 0,
};
}
fn exit_profile_mode(&mut self) {
self.chat_state = ChatState::Normal;
}
fn select_previous_profile_action(&mut self) {
if let ChatState::Profile { selected_action, .. } = &mut self.chat_state {
if *selected_action > 0 {
*selected_action -= 1;
}
}
}
fn select_next_profile_action(&mut self, max_actions: usize) {
if let ChatState::Profile { selected_action, .. } = &mut self.chat_state {
if *selected_action < max_actions.saturating_sub(1) {
*selected_action += 1;
}
}
}
fn show_leave_group_confirmation(&mut self) {
if let ChatState::Profile { leave_group_confirmation_step, .. } = &mut self.chat_state {
*leave_group_confirmation_step = 1;
}
}
fn show_leave_group_final_confirmation(&mut self) {
if let ChatState::Profile { leave_group_confirmation_step, .. } = &mut self.chat_state {
*leave_group_confirmation_step = 2;
}
}
fn cancel_leave_group(&mut self) {
if let ChatState::Profile { leave_group_confirmation_step, .. } = &mut self.chat_state {
*leave_group_confirmation_step = 0;
}
}
fn get_leave_group_confirmation_step(&self) -> u8 {
if let ChatState::Profile { leave_group_confirmation_step, .. } = &self.chat_state {
*leave_group_confirmation_step
} else {
0
}
}
fn get_profile_info(&self) -> Option<&ProfileInfo> {
if let ChatState::Profile { info, .. } = &self.chat_state {
Some(info)
} else {
None
}
}
fn get_selected_profile_action(&self) -> Option<usize> {
if let ChatState::Profile { selected_action, .. } = &self.chat_state {
Some(*selected_action)
} else {
None
}
}
fn is_reaction_picker_mode(&self) -> bool {
self.chat_state.is_reaction_picker()
}
fn enter_reaction_picker_mode(&mut self, message_id: i64, available_reactions: Vec<String>) {
self.chat_state = ChatState::ReactionPicker {
message_id: MessageId::new(message_id),
available_reactions,
selected_index: 0,
};
}
fn exit_reaction_picker_mode(&mut self) {
self.chat_state = ChatState::Normal;
}
fn select_previous_reaction(&mut self) {
if let ChatState::ReactionPicker { selected_index, .. } = &mut self.chat_state {
if *selected_index > 0 {
*selected_index -= 1;
}
}
}
fn select_next_reaction(&mut self) {
if let ChatState::ReactionPicker { selected_index, available_reactions, .. } =
&mut self.chat_state
{
if *selected_index + 1 < available_reactions.len() {
*selected_index += 1;
}
}
}
fn get_selected_reaction(&self) -> Option<&String> {
if let ChatState::ReactionPicker { available_reactions, selected_index, .. } =
&self.chat_state
{
available_reactions.get(*selected_index)
} else {
None
}
}
fn get_selected_message_for_reaction(&self) -> Option<i64> {
self.chat_state.selected_message_id().map(|id| id.as_i64())
}
}

View File

@@ -1,148 +0,0 @@
//! Navigation methods for App
//!
//! Handles chat list navigation and selection
use crate::app::methods::search::SearchMethods;
use crate::app::{App, ChatState, InputMode};
use crate::tdlib::TdClientTrait;
/// Navigation methods for chat list
pub trait NavigationMethods<T: TdClientTrait> {
/// Move to next chat in the list (wraps around)
fn next_chat(&mut self);
/// Move to previous chat in the list (wraps around)
fn previous_chat(&mut self);
/// Select currently highlighted chat
fn select_current_chat(&mut self);
/// Close currently open chat and reset state
fn close_chat(&mut self);
/// Move to next filtered chat (considering search query)
fn next_filtered_chat(&mut self);
/// Move to previous filtered chat (considering search query)
fn previous_filtered_chat(&mut self);
/// Select currently highlighted filtered chat
fn select_filtered_chat(&mut self);
}
impl<T: TdClientTrait> NavigationMethods<T> for App<T> {
fn next_chat(&mut self) {
let filtered = self.get_filtered_chats();
if filtered.is_empty() {
return;
}
let i = match self.chat_list_state.selected() {
Some(i) => {
if i >= filtered.len() - 1 {
0
} else {
i + 1
}
}
None => 0,
};
self.chat_list_state.select(Some(i));
}
fn previous_chat(&mut self) {
let filtered = self.get_filtered_chats();
if filtered.is_empty() {
return;
}
let i = match self.chat_list_state.selected() {
Some(i) => {
if i == 0 {
filtered.len() - 1
} else {
i - 1
}
}
None => 0,
};
self.chat_list_state.select(Some(i));
}
fn select_current_chat(&mut self) {
let filtered = self.get_filtered_chats();
if let Some(i) = self.chat_list_state.selected() {
if let Some(chat) = filtered.get(i) {
self.selected_chat_id = Some(chat.id);
}
}
}
fn close_chat(&mut self) {
self.selected_chat_id = None;
self.message_input.clear();
self.cursor_position = 0;
self.message_scroll_offset = 0;
self.last_typing_sent = None;
self.pending_chat_init = None;
self.chat_init_rx = None;
// Останавливаем фоновую загрузку фото (drop receiver)
#[cfg(feature = "images")]
{
self.photo_download_rx = None;
self.pending_image_open = None;
}
// Сбрасываем состояние чата в нормальный режим
self.chat_state = ChatState::Normal;
self.input_mode = InputMode::Normal;
// Очищаем данные в TdClient
self.td_client.set_current_chat_id(None);
self.td_client.clear_current_chat_messages();
self.td_client.set_typing_status(None);
self.td_client.set_current_pinned_message(None);
}
fn next_filtered_chat(&mut self) {
let filtered = self.get_filtered_chats();
if filtered.is_empty() {
return;
}
let i = match self.chat_list_state.selected() {
Some(i) => {
if i >= filtered.len() - 1 {
0
} else {
i + 1
}
}
None => 0,
};
self.chat_list_state.select(Some(i));
}
fn previous_filtered_chat(&mut self) {
let filtered = self.get_filtered_chats();
if filtered.is_empty() {
return;
}
let i = match self.chat_list_state.selected() {
Some(i) => {
if i == 0 {
filtered.len() - 1
} else {
i - 1
}
}
None => 0,
};
self.chat_list_state.select(Some(i));
}
fn select_filtered_chat(&mut self) {
let filtered = self.get_filtered_chats();
if let Some(i) = self.chat_list_state.selected() {
if let Some(chat) = filtered.get(i) {
self.selected_chat_id = Some(chat.id);
self.cancel_search();
}
}
}
}

View File

@@ -1,165 +0,0 @@
//! Search methods for App
//!
//! Handles chat list search and message search within chat
use crate::app::{App, ChatFilter, ChatFilterCriteria, ChatState};
use crate::tdlib::{ChatInfo, MessageInfo, TdClientTrait};
/// Search methods for chats and messages
pub trait SearchMethods<T: TdClientTrait> {
// === Chat Search ===
/// Start search mode in chat list
fn start_search(&mut self);
/// Cancel search mode and reset query
fn cancel_search(&mut self);
/// Get filtered chats based on search query and selected folder
fn get_filtered_chats(&self) -> Vec<&ChatInfo>;
// === Message Search ===
/// Check if message search mode is active
fn is_message_search_mode(&self) -> bool;
/// Enter message search mode within chat
fn enter_message_search_mode(&mut self);
/// Exit message search mode
fn exit_message_search_mode(&mut self);
/// Set search results
fn set_search_results(&mut self, results: Vec<MessageInfo>);
/// Select previous search result (up)
fn select_previous_search_result(&mut self);
/// Select next search result (down)
fn select_next_search_result(&mut self);
/// Get currently selected search result
fn get_selected_search_result(&self) -> Option<&MessageInfo>;
/// Get ID of selected search result for navigation
fn get_selected_search_result_id(&self) -> Option<i64>;
/// Get current search query
fn get_search_query(&self) -> Option<&str>;
/// Update search query
fn update_search_query(&mut self, new_query: String);
/// Get index of selected search result
#[allow(dead_code)]
fn get_search_selected_index(&self) -> Option<usize>;
/// Get all search results
#[allow(dead_code)]
fn get_search_results(&self) -> Option<&[MessageInfo]>;
}
impl<T: TdClientTrait> SearchMethods<T> for App<T> {
fn start_search(&mut self) {
self.is_searching = true;
self.search_query.clear();
}
fn cancel_search(&mut self) {
self.is_searching = false;
self.search_query.clear();
self.chat_list_state.select(Some(0));
}
fn get_filtered_chats(&self) -> Vec<&ChatInfo> {
// Используем ChatFilter для централизованной фильтрации
let mut criteria = ChatFilterCriteria::new().with_folder(self.selected_folder_id);
if !self.search_query.is_empty() {
criteria = criteria.with_search(self.search_query.clone());
}
ChatFilter::filter(&self.chats, &criteria)
}
fn is_message_search_mode(&self) -> bool {
self.chat_state.is_search_in_chat()
}
fn enter_message_search_mode(&mut self) {
self.chat_state = ChatState::SearchInChat {
query: String::new(),
results: Vec::new(),
selected_index: 0,
};
}
fn exit_message_search_mode(&mut self) {
self.chat_state = ChatState::Normal;
}
fn set_search_results(&mut self, results: Vec<MessageInfo>) {
if let ChatState::SearchInChat { results: r, selected_index, .. } = &mut self.chat_state {
*r = results;
*selected_index = 0;
}
}
fn select_previous_search_result(&mut self) {
if let ChatState::SearchInChat { selected_index, .. } = &mut self.chat_state {
if *selected_index > 0 {
*selected_index -= 1;
}
}
}
fn select_next_search_result(&mut self) {
if let ChatState::SearchInChat { selected_index, results, .. } = &mut self.chat_state {
if *selected_index + 1 < results.len() {
*selected_index += 1;
}
}
}
fn get_selected_search_result(&self) -> Option<&MessageInfo> {
if let ChatState::SearchInChat { results, selected_index, .. } = &self.chat_state {
results.get(*selected_index)
} else {
None
}
}
fn get_selected_search_result_id(&self) -> Option<i64> {
self.get_selected_search_result().map(|m| m.id().as_i64())
}
fn get_search_query(&self) -> Option<&str> {
if let ChatState::SearchInChat { query, .. } = &self.chat_state {
Some(query.as_str())
} else {
None
}
}
fn update_search_query(&mut self, new_query: String) {
if let ChatState::SearchInChat { query, .. } = &mut self.chat_state {
*query = new_query;
}
}
fn get_search_selected_index(&self) -> Option<usize> {
if let ChatState::SearchInChat { selected_index, .. } = &self.chat_state {
Some(*selected_index)
} else {
None
}
}
fn get_search_results(&self) -> Option<&[MessageInfo]> {
if let ChatState::SearchInChat { results, .. } = &self.chat_state {
Some(results.as_slice())
} else {
None
}
}
}

View File

@@ -1,635 +0,0 @@
//! Application state module.
//!
//! Contains `App<T>` — the central state struct parameterized by `TdClientTrait`
//! for dependency injection. Methods are organized into trait modules in `methods/`.
mod chat_filter;
mod chat_state;
pub mod methods;
mod state;
pub use chat_filter::{ChatFilter, ChatFilterCriteria};
pub use chat_state::{ChatState, InputMode};
#[allow(unused_imports)]
pub use methods::*;
pub use state::AppScreen;
use crate::accounts::AccountProfile;
use crate::notifications::NotificationManager;
use crate::tdlib::{ChatInfo, TdClient, TdClientTrait};
use crate::tdlib::{TdClientConfig, TdCredentials};
use crate::types::{ChatId, MessageId};
use ratatui::widgets::ListState;
use std::path::PathBuf;
/// Pending intent to open the image modal once a photo finishes downloading.
///
/// Set when the user presses `v` on a photo that is still downloading.
/// The main loop opens the modal automatically when the download completes.
#[cfg(feature = "images")]
#[derive(Debug, Clone)]
pub struct PendingImageOpen {
pub file_id: i32,
pub message_id: crate::types::MessageId,
pub photo_width: i32,
pub photo_height: i32,
}
/// Result from background chat initialization tasks.
#[derive(Debug, Clone)]
pub enum ChatInitEvent {
ReplyInfoLoaded {
chat_id: ChatId,
message_id: MessageId,
sender_name: String,
text: String,
},
}
/// State of the account switcher modal overlay.
#[derive(Debug, Clone)]
pub enum AccountSwitcherState {
/// List of accounts with navigation.
SelectAccount {
accounts: Vec<AccountProfile>,
selected_index: usize,
current_account: String,
},
/// Input for new account name.
AddAccount {
name_input: String,
cursor_position: usize,
error: Option<String>,
},
}
/// Main application state for the Telegram TUI client.
///
/// Manages all application state including authentication, chats, messages,
/// and UI state. Integrates with TDLib через `TdClient` and handles user input.
///
/// # State Machine
///
/// The app uses a type-safe state machine (`ChatState`) for chat-related operations:
/// - `Normal` - default state
/// - `MessageSelection` - selecting a message
/// - `Editing` - editing a message
/// - `Reply` - replying to a message
/// - `Forward` - forwarding a message
/// - `DeleteConfirmation` - confirming deletion
/// - `ReactionPicker` - choosing a reaction
/// - `Profile` - viewing profile
/// - `SearchInChat` - searching within chat
/// - `PinnedMessages` - viewing pinned messages
///
/// # Examples
///
/// ```no_run
/// use tele_tui::app::App;
/// use tele_tui::app::methods::navigation::NavigationMethods;
/// use tele_tui::config::Config;
///
/// let config = Config::default();
/// let mut app = App::new(config, std::path::PathBuf::from("tdlib_data"));
///
/// // Navigate through chats
/// app.next_chat();
/// app.previous_chat();
///
/// // Open a chat
/// app.select_current_chat();
/// ```
pub struct App<T: TdClientTrait = TdClient> {
// Core (config - readonly через getter)
config: crate::config::Config,
pub screen: AppScreen,
pub td_client: T,
pub notification_manager: NotificationManager,
/// Состояние чата - type-safe state machine (новое!)
pub chat_state: ChatState,
/// Vim-like input mode: Normal (navigation) / Insert (text input)
pub input_mode: InputMode,
// Auth state (приватные, доступ через геттеры)
phone_input: String,
code_input: String,
password_input: String,
pub error_message: Option<String>,
pub status_message: Option<String>,
// Main app state (используются часто)
pub chats: Vec<ChatInfo>,
pub chat_list_state: ListState,
pub selected_chat_id: Option<ChatId>,
pub message_input: String,
/// Позиция курсора в message_input (в символах)
pub cursor_position: usize,
pub message_scroll_offset: usize,
/// None = All (основной список), Some(id) = папка с id
pub selected_folder_id: Option<i32>,
pub is_loading: bool,
// Search state
pub is_searching: bool,
pub search_query: String,
/// Флаг для оптимизации рендеринга - перерисовывать только при изменениях
pub needs_redraw: bool,
// Typing indicator
/// Время последней отправки typing status (для throttling)
pub last_typing_sent: Option<std::time::Instant>,
// Image support
#[allow(dead_code)]
#[cfg(feature = "images")]
pub image_cache: Option<crate::media::cache::ImageCache>,
/// Renderer для inline preview в чате (Halfblocks - быстро)
#[cfg(feature = "images")]
pub inline_image_renderer: Option<crate::media::image_renderer::ImageRenderer>,
/// Renderer для modal просмотра (iTerm2/Sixel - высокое качество)
#[cfg(feature = "images")]
pub modal_image_renderer: Option<crate::media::image_renderer::ImageRenderer>,
/// Состояние модального окна просмотра изображения
#[cfg(feature = "images")]
pub image_modal: Option<crate::tdlib::ImageModalState>,
/// Время последнего рендеринга изображений (для throttling до 15 FPS)
#[cfg(feature = "images")]
pub last_image_render_time: Option<std::time::Instant>,
/// Pending intent: открыть модалку для этого фото когда загрузится
#[cfg(feature = "images")]
pub pending_image_open: Option<PendingImageOpen>,
// Account lock
/// Advisory file lock to prevent concurrent access to the same account
pub account_lock: Option<std::fs::File>,
// Account switcher
/// Account switcher modal state (global overlay)
pub account_switcher: Option<AccountSwitcherState>,
/// Name of the currently active account
pub current_account_name: String,
/// Pending account switch: (account_name, db_path)
pub pending_account_switch: Option<(String, PathBuf)>,
/// Pending background chat init (reply info, photos) after fast open
pub pending_chat_init: Option<ChatId>,
/// Receiver for background chat initialization results
pub chat_init_rx: Option<tokio::sync::mpsc::UnboundedReceiver<ChatInitEvent>>,
/// Receiver for background photo downloads (file_id, result path)
#[cfg(feature = "images")]
pub photo_download_rx:
Option<tokio::sync::mpsc::UnboundedReceiver<(i32, Result<String, String>)>>,
// Voice playback
/// Аудиопроигрыватель для голосовых сообщений (ffplay)
pub audio_player: Option<crate::audio::AudioPlayer>,
/// Кэш голосовых файлов (LRU, max 100 MB)
pub voice_cache: Option<crate::audio::VoiceCache>,
/// Состояние текущего воспроизведения
pub playback_state: Option<crate::tdlib::PlaybackState>,
/// Время последнего тика для обновления позиции воспроизведения
pub last_playback_tick: Option<std::time::Instant>,
}
#[allow(dead_code)]
impl<T: TdClientTrait> App<T> {
/// Creates a new App instance with the given configuration and client.
///
/// Sets up empty chat list and configures the app to start on the Loading screen.
///
/// # Arguments
///
/// * `config` - Application configuration loaded from config.toml
/// * `td_client` - TDLib client instance (real or fake for tests)
///
/// # Returns
///
/// A new `App` instance ready to start authentication.
pub fn with_client(config: crate::config::Config, td_client: T) -> App<T> {
let mut state = ListState::default();
state.select(Some(0));
let audio_cache_size_mb = config.audio.cache_size_mb;
#[cfg(feature = "images")]
let image_cache = Some(crate::media::cache::ImageCache::new(config.images.cache_size_mb));
#[cfg(feature = "images")]
let inline_image_renderer = crate::media::image_renderer::ImageRenderer::new_fast();
#[cfg(feature = "images")]
let modal_image_renderer = crate::media::image_renderer::ImageRenderer::new();
let notification_manager = NotificationManager::from_config(&config.notifications);
App {
config,
screen: AppScreen::Loading,
td_client,
notification_manager,
chat_state: ChatState::Normal,
input_mode: InputMode::Normal,
phone_input: String::new(),
code_input: String::new(),
password_input: String::new(),
error_message: None,
status_message: Some("Инициализация TDLib...".to_string()),
chats: Vec::new(),
chat_list_state: state,
selected_chat_id: None,
message_input: String::new(),
cursor_position: 0,
message_scroll_offset: 0,
selected_folder_id: None, // None = All
is_loading: true,
is_searching: false,
search_query: String::new(),
needs_redraw: true,
last_typing_sent: None,
// Account lock
account_lock: None,
// Account switcher
account_switcher: None,
current_account_name: "default".to_string(),
pending_account_switch: None,
pending_chat_init: None,
chat_init_rx: None,
#[cfg(feature = "images")]
photo_download_rx: None,
#[cfg(feature = "images")]
image_cache,
#[cfg(feature = "images")]
inline_image_renderer,
#[cfg(feature = "images")]
modal_image_renderer,
#[cfg(feature = "images")]
image_modal: None,
#[cfg(feature = "images")]
last_image_render_time: None,
#[cfg(feature = "images")]
pending_image_open: None,
// Voice playback
audio_player: crate::audio::AudioPlayer::new().ok(),
voice_cache: crate::audio::VoiceCache::new(audio_cache_size_mb).ok(),
playback_state: None,
last_playback_tick: None,
}
}
/// Получить команду из KeyEvent используя настроенные keybindings.
///
/// # Arguments
///
/// * `key` - KeyEvent от пользователя
///
/// # Returns
///
/// `Some(Command)` если найдена команда для этой клавиши, `None` если нет
pub fn get_command(&self, key: crossterm::event::KeyEvent) -> Option<crate::config::Command> {
self.config.keybindings.get_command(&key)
}
/// Get the selected chat ID as i64
pub fn get_selected_chat_id(&self) -> Option<i64> {
self.selected_chat_id.map(|id| id.as_i64())
}
/// Останавливает воспроизведение голосового и сбрасывает состояние
pub fn stop_playback(&mut self) {
if let Some(ref player) = self.audio_player {
player.stop();
}
self.playback_state = None;
self.last_playback_tick = None;
self.status_message = None;
}
/// Opens the account switcher modal, loading accounts from config.
pub fn open_account_switcher(&mut self) {
let config = crate::accounts::load_or_create();
self.account_switcher = Some(AccountSwitcherState::SelectAccount {
accounts: config.accounts,
selected_index: 0,
current_account: self.current_account_name.clone(),
});
}
/// Closes the account switcher modal.
pub fn close_account_switcher(&mut self) {
self.account_switcher = None;
}
/// Navigate to previous item in account switcher list.
pub fn account_switcher_select_prev(&mut self) {
if let Some(AccountSwitcherState::SelectAccount { selected_index, .. }) =
&mut self.account_switcher
{
*selected_index = selected_index.saturating_sub(1);
}
}
/// Navigate to next item in account switcher list.
pub fn account_switcher_select_next(&mut self) {
if let Some(AccountSwitcherState::SelectAccount { accounts, selected_index, .. }) =
&mut self.account_switcher
{
// +1 for the "Add account" item at the end
let max_index = accounts.len();
if *selected_index < max_index {
*selected_index += 1;
}
}
}
/// Confirm selection in account switcher.
/// If on an account: sets pending_account_switch.
/// If on "+ Add": transitions to AddAccount state.
pub fn account_switcher_confirm(&mut self) {
let state = self.account_switcher.take();
match state {
Some(AccountSwitcherState::SelectAccount {
accounts,
selected_index,
current_account,
}) => {
if selected_index < accounts.len() {
// Selected an existing account
let account = &accounts[selected_index];
if account.name == current_account {
// Already on this account, just close
self.account_switcher = None;
return;
}
let db_path = account.db_path();
self.pending_account_switch = Some((account.name.clone(), db_path));
self.account_switcher = None;
} else {
// Selected "+ Add account"
self.account_switcher = Some(AccountSwitcherState::AddAccount {
name_input: String::new(),
cursor_position: 0,
error: None,
});
}
}
other => {
self.account_switcher = other;
}
}
}
/// Switch to AddAccount state from SelectAccount.
pub fn account_switcher_start_add(&mut self) {
self.account_switcher = Some(AccountSwitcherState::AddAccount {
name_input: String::new(),
cursor_position: 0,
error: None,
});
}
/// Confirm adding a new account. Validates, saves, and sets pending switch.
pub fn account_switcher_confirm_add(&mut self) {
let state = self.account_switcher.take();
match state {
Some(AccountSwitcherState::AddAccount { name_input, .. }) => {
match crate::accounts::manager::add_account(&name_input, &name_input) {
Ok(db_path) => {
self.pending_account_switch = Some((name_input, db_path));
self.account_switcher = None;
}
Err(e) => {
let cursor_pos = name_input.chars().count();
self.account_switcher = Some(AccountSwitcherState::AddAccount {
name_input,
cursor_position: cursor_pos,
error: Some(e),
});
}
}
}
other => {
self.account_switcher = other;
}
}
}
/// Go back from AddAccount to SelectAccount.
pub fn account_switcher_back(&mut self) {
self.open_account_switcher();
}
/// Get the selected chat info
pub fn get_selected_chat(&self) -> Option<&ChatInfo> {
self.selected_chat_id
.and_then(|id| self.chats.iter().find(|c| c.id == id))
}
// ========== Getter/Setter методы для инкапсуляции ==========
// Config
pub fn config(&self) -> &crate::config::Config {
&self.config
}
// Screen
pub fn screen(&self) -> &AppScreen {
&self.screen
}
pub fn set_screen(&mut self, screen: AppScreen) {
self.screen = screen;
}
// Auth state
pub fn phone_input(&self) -> &str {
&self.phone_input
}
pub fn phone_input_mut(&mut self) -> &mut String {
&mut self.phone_input
}
pub fn set_phone_input(&mut self, input: String) {
self.phone_input = input;
}
pub fn code_input(&self) -> &str {
&self.code_input
}
pub fn code_input_mut(&mut self) -> &mut String {
&mut self.code_input
}
pub fn set_code_input(&mut self, input: String) {
self.code_input = input;
}
pub fn password_input(&self) -> &str {
&self.password_input
}
pub fn password_input_mut(&mut self) -> &mut String {
&mut self.password_input
}
pub fn set_password_input(&mut self, input: String) {
self.password_input = input;
}
pub fn error_message(&self) -> Option<&str> {
self.error_message.as_deref()
}
pub fn set_error_message(&mut self, message: Option<String>) {
self.error_message = message;
}
pub fn status_message(&self) -> Option<&str> {
self.status_message.as_deref()
}
pub fn set_status_message(&mut self, message: Option<String>) {
self.status_message = message;
}
// Main app state
pub fn chats(&self) -> &[ChatInfo] {
&self.chats
}
pub fn chats_mut(&mut self) -> &mut Vec<ChatInfo> {
&mut self.chats
}
pub fn set_chats(&mut self, chats: Vec<ChatInfo>) {
self.chats = chats;
}
pub fn chat_list_state(&self) -> &ListState {
&self.chat_list_state
}
pub fn chat_list_state_mut(&mut self) -> &mut ListState {
&mut self.chat_list_state
}
pub fn selected_chat_id(&self) -> Option<ChatId> {
self.selected_chat_id
}
pub fn set_selected_chat_id(&mut self, id: Option<ChatId>) {
self.selected_chat_id = id;
}
pub fn message_input(&self) -> &str {
&self.message_input
}
pub fn message_input_mut(&mut self) -> &mut String {
&mut self.message_input
}
pub fn set_message_input(&mut self, input: String) {
self.message_input = input;
}
pub fn cursor_position(&self) -> usize {
self.cursor_position
}
pub fn set_cursor_position(&mut self, pos: usize) {
self.cursor_position = pos;
}
pub fn message_scroll_offset(&self) -> usize {
self.message_scroll_offset
}
pub fn set_message_scroll_offset(&mut self, offset: usize) {
self.message_scroll_offset = offset;
}
pub fn selected_folder_id(&self) -> Option<i32> {
self.selected_folder_id
}
pub fn set_selected_folder_id(&mut self, id: Option<i32>) {
self.selected_folder_id = id;
}
pub fn is_loading(&self) -> bool {
self.is_loading
}
pub fn set_loading(&mut self, loading: bool) {
self.is_loading = loading;
}
// Search state
pub fn is_searching(&self) -> bool {
self.is_searching
}
pub fn set_searching(&mut self, searching: bool) {
self.is_searching = searching;
}
pub fn search_query(&self) -> &str {
&self.search_query
}
pub fn search_query_mut(&mut self) -> &mut String {
&mut self.search_query
}
pub fn set_search_query(&mut self, query: String) {
self.search_query = query;
}
// Redraw flag
pub fn needs_redraw(&self) -> bool {
self.needs_redraw
}
pub fn set_needs_redraw(&mut self, redraw: bool) {
self.needs_redraw = redraw;
}
pub fn mark_for_redraw(&mut self) {
self.needs_redraw = true;
}
// Typing indicator
pub fn last_typing_sent(&self) -> Option<std::time::Instant> {
self.last_typing_sent
}
pub fn set_last_typing_sent(&mut self, time: Option<std::time::Instant>) {
self.last_typing_sent = time;
}
pub fn update_last_typing_sent(&mut self) {
self.last_typing_sent = Some(std::time::Instant::now());
}
}
// Convenience constructor for real TdClient (production use)
impl App<TdClient> {
/// Creates a new App instance with the given configuration and a real TDLib client.
///
/// This is a convenience method for production use that automatically creates
/// a new TdClient instance with the specified database path.
///
/// # Arguments
///
/// * `config` - Application configuration loaded from config.toml
/// * `db_path` - Path to the TDLib database directory for this account
///
/// # Returns
///
/// A new `App<TdClient>` instance ready to start authentication.
pub fn new(config: crate::config::Config, db_path: std::path::PathBuf) -> App<TdClient> {
let (api_id, api_hash) = crate::config::Config::load_credentials().unwrap_or_else(|_| {
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)
}
}

View File

@@ -1,128 +0,0 @@
/// UI состояние приложения
///
/// Отвечает за общее состояние интерфейса:
/// - Текущий экран (screen)
/// - Сообщения об ошибках и статусе
/// - Флаги загрузки и перерисовки
use crate::app::AppScreen;
/// Состояние UI приложения
#[derive(Debug, Clone)]
pub struct UIState {
/// Текущий экран приложения
pub screen: AppScreen,
/// Сообщение об ошибке (если есть)
pub error_message: Option<String>,
/// Статусное сообщение (загрузка, прогресс, и т.д.)
pub status_message: Option<String>,
/// Флаг необходимости перерисовки
pub needs_redraw: bool,
/// Флаг загрузки (общий)
pub is_loading: bool,
}
impl Default for UIState {
fn default() -> Self {
Self {
screen: AppScreen::Loading,
error_message: None,
status_message: Some("Инициализация TDLib...".to_string()),
needs_redraw: true,
is_loading: true,
}
}
}
impl UIState {
/// Создать новое UI состояние
pub fn new() -> Self {
Self::default()
}
// === Screen ===
pub fn screen(&self) -> &AppScreen {
&self.screen
}
pub fn set_screen(&mut self, screen: AppScreen) {
self.screen = screen;
self.mark_for_redraw();
}
// === Error message ===
pub fn error_message(&self) -> Option<&str> {
self.error_message.as_deref()
}
pub fn set_error_message(&mut self, message: Option<String>) {
self.error_message = message;
self.mark_for_redraw();
}
pub fn clear_error(&mut self) {
self.error_message = None;
self.mark_for_redraw();
}
// === Status message ===
pub fn status_message(&self) -> Option<&str> {
self.status_message.as_deref()
}
pub fn set_status_message(&mut self, message: Option<String>) {
self.status_message = message;
self.mark_for_redraw();
}
pub fn clear_status(&mut self) {
self.status_message = None;
self.mark_for_redraw();
}
// === Redraw flag ===
pub fn needs_redraw(&self) -> bool {
self.needs_redraw
}
pub fn set_needs_redraw(&mut self, redraw: bool) {
self.needs_redraw = redraw;
}
pub fn mark_for_redraw(&mut self) {
self.needs_redraw = true;
}
pub fn clear_redraw_flag(&mut self) {
self.needs_redraw = false;
}
// === Loading flag ===
pub fn is_loading(&self) -> bool {
self.is_loading
}
pub fn set_loading(&mut self, loading: bool) {
self.is_loading = loading;
if loading {
self.mark_for_redraw();
}
}
pub fn start_loading(&mut self) {
self.set_loading(true);
}
pub fn stop_loading(&mut self) {
self.set_loading(false);
}
}

View File

@@ -1,155 +0,0 @@
//! Voice message cache management.
//!
//! Caches downloaded OGG voice files in ~/.cache/tele-tui/voice/
//! with LRU eviction when cache size exceeds limit.
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
/// Cache for voice message files
pub struct VoiceCache {
cache_dir: PathBuf,
/// file_id -> (path, size_bytes, access_count)
files: HashMap<String, (PathBuf, u64, usize)>,
access_counter: usize,
max_size_bytes: u64,
}
impl VoiceCache {
/// Creates a new VoiceCache with the given max size in MB
pub fn new(max_size_mb: u64) -> Result<Self, String> {
let cache_dir = dirs::cache_dir()
.ok_or("Failed to get cache directory")?
.join("tele-tui")
.join("voice");
fs::create_dir_all(&cache_dir)
.map_err(|e| format!("Failed to create cache directory: {}", e))?;
Ok(Self {
cache_dir,
files: HashMap::new(),
access_counter: 0,
max_size_bytes: max_size_mb * 1024 * 1024,
})
}
/// Gets the path for a cached voice file, if it exists
pub fn get(&mut self, file_id: &str) -> Option<PathBuf> {
if let Some((path, _, access)) = self.files.get_mut(file_id) {
// Update access count for LRU
self.access_counter += 1;
*access = self.access_counter;
Some(path.clone())
} else {
None
}
}
/// Stores a voice file in the cache
pub fn store(&mut self, file_id: &str, source_path: &Path) -> Result<PathBuf, String> {
// Copy file to cache
let filename = format!("{}.ogg", file_id.replace('/', "_"));
let dest_path = self.cache_dir.join(&filename);
fs::copy(source_path, &dest_path)
.map_err(|e| format!("Failed to copy voice file to cache: {}", e))?;
// Get file size
let size = fs::metadata(&dest_path)
.map_err(|e| format!("Failed to get file size: {}", e))?
.len();
// Store in cache
self.access_counter += 1;
self.files
.insert(file_id.to_string(), (dest_path.clone(), size, self.access_counter));
// Check if we need to evict
self.evict_if_needed()?;
Ok(dest_path)
}
/// Returns the total size of all cached files
pub fn total_size(&self) -> u64 {
self.files.values().map(|(_, size, _)| size).sum()
}
/// Evicts oldest files until cache is under max size
fn evict_if_needed(&mut self) -> Result<(), String> {
while self.total_size() > self.max_size_bytes && !self.files.is_empty() {
// Find least recently accessed file
let oldest_id = self
.files
.iter()
.min_by_key(|(_, (_, _, access))| access)
.map(|(id, _)| id.clone());
if let Some(id) = oldest_id {
self.evict(&id)?;
}
}
Ok(())
}
/// Evicts a specific file from cache
fn evict(&mut self, file_id: &str) -> Result<(), String> {
if let Some((path, _, _)) = self.files.remove(file_id) {
fs::remove_file(&path).map_err(|e| format!("Failed to remove cached file: {}", e))?;
}
Ok(())
}
/// Clears all cached files
#[allow(dead_code)]
pub fn clear(&mut self) -> Result<(), String> {
for (path, _, _) in self.files.values() {
let _ = fs::remove_file(path); // Ignore errors
}
self.files.clear();
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn test_voice_cache_creation() {
let cache = VoiceCache::new(100);
assert!(cache.is_ok());
}
#[test]
fn test_cache_get_nonexistent() {
let mut cache = VoiceCache::new(100).unwrap();
assert!(cache.get("nonexistent").is_none());
}
#[test]
fn test_cache_store_and_get() {
let mut cache = VoiceCache::new(100).unwrap();
// Create temporary file
let temp_dir = std::env::temp_dir();
let temp_file = temp_dir.join("test_voice.ogg");
let mut file = fs::File::create(&temp_file).unwrap();
file.write_all(b"test audio data").unwrap();
// Store in cache
let result = cache.store("test123", &temp_file);
assert!(result.is_ok());
// Get from cache
let cached_path = cache.get("test123");
assert!(cached_path.is_some());
assert!(cached_path.unwrap().exists());
// Cleanup
fs::remove_file(&temp_file).unwrap();
}
}

View File

@@ -1,11 +0,0 @@
//! Audio playback module for voice messages.
//!
//! Provides:
//! - AudioPlayer: ffplay-based playback with play/pause/seek controls
//! - VoiceCache: LRU cache for downloaded OGG voice files
pub mod cache;
pub mod player;
pub use cache::VoiceCache;
pub use player::AudioPlayer;

View File

@@ -1,205 +0,0 @@
//! Audio player for voice messages.
//!
//! Uses ffplay (from FFmpeg) for reliable Opus/OGG playback.
//! Pause/resume implemented via SIGSTOP/SIGCONT signals.
use std::path::Path;
use std::process::Command;
use std::sync::{Arc, Mutex};
use std::time::Duration;
/// Audio player state and controls
pub struct AudioPlayer {
/// PID of current playback process (if any)
current_pid: Arc<Mutex<Option<u32>>>,
/// Whether the process is currently paused (SIGSTOP)
paused: Arc<Mutex<bool>>,
/// Path to the currently playing file (for restart with seek)
current_path: Arc<Mutex<Option<std::path::PathBuf>>>,
/// True between play_from() call and ffplay actually starting (race window)
starting: Arc<Mutex<bool>>,
}
impl AudioPlayer {
/// Creates a new AudioPlayer
pub fn new() -> Result<Self, String> {
let ffplay_check = Command::new("which")
.arg("ffplay")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.output()
.map_err(|_| "ffplay not found (install ffmpeg)".to_string())?;
if !ffplay_check.status.success() {
return Err("ffplay not found (install ffmpeg)".to_string());
}
Ok(Self {
current_pid: Arc::new(Mutex::new(None)),
paused: Arc::new(Mutex::new(false)),
current_path: Arc::new(Mutex::new(None)),
starting: Arc::new(Mutex::new(false)),
})
}
/// Plays an audio file from the given path
pub fn play<P: AsRef<Path>>(&self, path: P) -> Result<(), String> {
self.play_from(path, 0.0)
}
/// Plays an audio file starting from the given position (seconds)
pub fn play_from<P: AsRef<Path>>(&self, path: P, start_secs: f32) -> Result<(), String> {
self.stop();
let path_owned = path.as_ref().to_path_buf();
*self.starting.lock().unwrap() = true;
let current_pid = self.current_pid.clone();
let paused = self.paused.clone();
let starting = self.starting.clone();
let mut cmd = Command::new("ffplay");
cmd.arg("-nodisp")
.arg("-autoexit")
.arg("-loglevel")
.arg("quiet");
if start_secs > 0.0 {
cmd.arg("-ss").arg(format!("{:.1}", start_secs));
}
let mut child = match cmd
.arg(&path_owned)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
{
Ok(child) => child,
Err(e) => {
*self.starting.lock().unwrap() = false;
return Err(format!("failed to start ffplay: {}", e));
}
};
let pid = child.id();
*self.current_path.lock().unwrap() = Some(path_owned);
*current_pid.lock().unwrap() = Some(pid);
*paused.lock().unwrap() = false;
*starting.lock().unwrap() = false;
std::thread::spawn(move || {
let _ = child.wait();
// Обнуляем только если это наш pid (новый play мог уже заменить его)
let mut pid_guard = current_pid.lock().unwrap();
if *pid_guard == Some(pid) {
*pid_guard = None;
*paused.lock().unwrap() = false;
}
});
Ok(())
}
/// Pauses playback via SIGSTOP
pub fn pause(&self) {
if let Some(pid) = *self.current_pid.lock().unwrap() {
let _ = Command::new("kill")
.arg("-STOP")
.arg(pid.to_string())
.output();
*self.paused.lock().unwrap() = true;
}
}
/// Resumes playback via SIGCONT (from the same position)
pub fn resume(&self) {
if let Some(pid) = *self.current_pid.lock().unwrap() {
let _ = Command::new("kill")
.arg("-CONT")
.arg(pid.to_string())
.output();
*self.paused.lock().unwrap() = false;
}
}
/// Resumes playback from a specific position (restarts ffplay with -ss)
pub fn resume_from(&self, position_secs: f32) -> Result<(), String> {
let path = self.current_path.lock().unwrap().clone();
if let Some(path) = path {
self.play_from(&path, position_secs)
} else {
Err("No file to resume".to_string())
}
}
/// Stops playback (kills the process)
pub fn stop(&self) {
*self.starting.lock().unwrap() = false;
if let Some(pid) = self.current_pid.lock().unwrap().take() {
// Resume first if paused, then kill
let _ = Command::new("kill")
.arg("-CONT")
.arg(pid.to_string())
.output();
let _ = Command::new("kill").arg(pid.to_string()).output();
}
*self.paused.lock().unwrap() = false;
}
/// Returns true if a process is active (playing or paused)
#[allow(dead_code)]
pub fn is_playing(&self) -> bool {
self.current_pid.lock().unwrap().is_some() && !*self.paused.lock().unwrap()
}
/// Returns true if paused
#[allow(dead_code)]
pub fn is_paused(&self) -> bool {
self.current_pid.lock().unwrap().is_some() && *self.paused.lock().unwrap()
}
/// Returns true if no active process and not starting a new one
pub fn is_stopped(&self) -> bool {
self.current_pid.lock().unwrap().is_none() && !*self.starting.lock().unwrap()
}
#[allow(dead_code)]
pub fn set_volume(&self, _volume: f32) {}
#[allow(dead_code)]
pub fn adjust_volume(&self, _delta: f32) {}
pub fn volume(&self) -> f32 {
1.0
}
#[allow(dead_code)]
pub fn seek(&self, _delta: Duration) -> Result<(), String> {
Err("Seeking not supported".to_string())
}
}
impl Drop for AudioPlayer {
fn drop(&mut self) {
self.stop();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_audio_player_creation() {
if let Ok(player) = AudioPlayer::new() {
assert!(player.is_stopped());
assert!(!player.is_playing());
assert!(!player.is_paused());
}
}
#[test]
fn test_volume() {
if let Ok(player) = AudioPlayer::new() {
assert_eq!(player.volume(), 1.0);
}
}
}

View File

@@ -1,182 +0,0 @@
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()]
}

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