Compare commits
6 Commits
main
...
b2aecf11da
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2aecf11da | ||
|
|
79861bbbac | ||
|
|
eea5eb9584 | ||
|
|
7540a30e06 | ||
|
|
2edbc33afb | ||
|
|
060170923e |
@@ -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
|
||||
44
.github/workflows/ios-rust.yml
vendored
44
.github/workflows/ios-rust.yml
vendored
@@ -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
15
.gitignore
vendored
@@ -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
1
.serena/.gitignore
vendored
@@ -1 +0,0 @@
|
||||
/cache
|
||||
@@ -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 для документации проекта
|
||||
@@ -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
|
||||
@@ -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.)
|
||||
@@ -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. [На что обратить внимание]
|
||||
|
||||
Напиши, если что-то не работает.
|
||||
```
|
||||
|
||||
## Важно
|
||||
- Работать поэтапно (один этап = одна логическая единица)
|
||||
- После каждого этапа давать сценарий проверки
|
||||
- Не делать сразу много изменений
|
||||
@@ -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 read‑only.
|
||||
# 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: []
|
||||
@@ -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
|
||||
18
AGENT.md
18
AGENT.md
@@ -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
29
CLAUDE.md
Normal 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 тестированию
|
||||
|
||||
|
||||
|
||||
110
CONTEXT.md
110
CONTEXT.md
@@ -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
3378
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
26
Cargo.toml
26
Cargo.toml
@@ -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"] }
|
||||
|
||||
117
DEVELOPMENT.md
117
DEVELOPMENT.md
@@ -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
21
LICENSE
@@ -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.
|
||||
76
README.md
76
README.md
@@ -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
89
REQUIREMENTS.md
Normal 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
48
ROADMAP.md
Normal 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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
```
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -1,5 +0,0 @@
|
||||
//! Account profile data structures and validation.
|
||||
|
||||
pub mod profile;
|
||||
|
||||
pub use profile::{validate_account_name, AccountProfile, AccountsConfig};
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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(¤t_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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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(),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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"] }
|
||||
@@ -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
@@ -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
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ");
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
#[derive(Debug, PartialEq, Clone)]
|
||||
pub enum AppScreen {
|
||||
Loading,
|
||||
Auth,
|
||||
Main,
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user