Compare commits
2 Commits
main
...
f8aab8232a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8aab8232a | ||
|
|
3234607bcd |
40
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
40
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: Сообщить о проблеме или баге
|
||||
title: '[BUG] '
|
||||
labels: bug
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
## Описание бага
|
||||
Четкое и краткое описание проблемы.
|
||||
|
||||
## Шаги для воспроизведения
|
||||
1. Запустить '...'
|
||||
2. Нажать на '...'
|
||||
3. Прокрутить вниз до '...'
|
||||
4. Увидеть ошибку
|
||||
|
||||
## Ожидаемое поведение
|
||||
Что должно было произойти.
|
||||
|
||||
## Фактическое поведение
|
||||
Что произошло на самом деле.
|
||||
|
||||
## Скриншоты
|
||||
Если применимо, добавьте скриншоты для демонстрации проблемы.
|
||||
|
||||
## Окружение
|
||||
- **ОС**: [например, macOS 14.0, Ubuntu 22.04, Windows 11]
|
||||
- **Rust версия**: [вывод `rustc --version`]
|
||||
- **tele-tui версия**: [вывод `cargo pkgid`]
|
||||
- **Размер терминала**: [например, 100x30]
|
||||
|
||||
## Логи
|
||||
Если есть логи или сообщения об ошибках, вставьте их сюда:
|
||||
```
|
||||
вставьте логи здесь
|
||||
```
|
||||
|
||||
## Дополнительный контекст
|
||||
Любая другая информация, которая может помочь в решении проблемы.
|
||||
34
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
34
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Предложить новую функцию или улучшение
|
||||
title: '[FEATURE] '
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
## Связано с проблемой?
|
||||
Есть ли проблема, которую это решит? Например: "Меня расстраивает, что [...]"
|
||||
|
||||
## Описание решения
|
||||
Четкое и краткое описание того, что вы хотите.
|
||||
|
||||
## Альтернативы
|
||||
Какие альтернативные решения или функции вы рассматривали?
|
||||
|
||||
## Примеры использования
|
||||
Как эта функция будет использоваться? Приведите примеры:
|
||||
|
||||
1. Пользователь делает X
|
||||
2. Система делает Y
|
||||
3. Результат: Z
|
||||
|
||||
## Приоритет
|
||||
- [ ] Критичная функция — без неё приложение малополезно
|
||||
- [ ] Важная функция — значительно улучшит UX
|
||||
- [ ] Nice to have — было бы удобно
|
||||
|
||||
## Проверка roadmap
|
||||
- [ ] Я проверил [ROADMAP.md](../ROADMAP.md) и этой функции там нет
|
||||
|
||||
## Дополнительный контекст
|
||||
Скриншоты, ссылки на похожие реализации в других приложениях, и т.д.
|
||||
51
.github/pull_request_template.md
vendored
Normal file
51
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
## Описание
|
||||
|
||||
Краткое описание изменений в этом PR.
|
||||
|
||||
## Тип изменений
|
||||
|
||||
- [ ] Bug fix (исправление бага)
|
||||
- [ ] New feature (новая функция)
|
||||
- [ ] Breaking change (изменение, ломающее обратную совместимость)
|
||||
- [ ] Refactoring (рефакторинг без изменения функциональности)
|
||||
- [ ] Documentation (изменения в документации)
|
||||
- [ ] Performance improvement (улучшение производительности)
|
||||
|
||||
## Связанные Issue
|
||||
|
||||
Fixes #(номер issue)
|
||||
|
||||
## Как протестировано?
|
||||
|
||||
Опишите тесты, которые вы провели:
|
||||
|
||||
- [ ] Тест A
|
||||
- [ ] Тест B
|
||||
- [ ] Тест C
|
||||
|
||||
## Сценарии тестирования
|
||||
|
||||
Подробные шаги для проверки изменений:
|
||||
|
||||
1. Запустить `cargo run`
|
||||
2. Сделать X
|
||||
3. Убедиться, что Y
|
||||
|
||||
## Чеклист
|
||||
|
||||
- [ ] Мой код следует стилю проекта
|
||||
- [ ] Я запустил `cargo fmt`
|
||||
- [ ] Я запустил `cargo clippy` и исправил warnings
|
||||
- [ ] Код компилируется без ошибок (`cargo build`)
|
||||
- [ ] Я протестировал изменения вручную
|
||||
- [ ] Я обновил документацию (если необходимо)
|
||||
- [ ] Я добавил тесты (если применимо)
|
||||
- [ ] Все существующие тесты проходят
|
||||
|
||||
## Скриншоты (если применимо)
|
||||
|
||||
Добавьте скриншоты для демонстрации UI изменений.
|
||||
|
||||
## Дополнительные заметки
|
||||
|
||||
Любая дополнительная информация для ревьюверов.
|
||||
50
.github/workflows/ci.yml
vendored
Normal file
50
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
pull_request:
|
||||
branches: [ main, develop ]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- run: cargo check --all-features
|
||||
|
||||
fmt:
|
||||
name: Format
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt
|
||||
- run: cargo fmt --all -- --check
|
||||
|
||||
clippy:
|
||||
name: Clippy
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
- run: cargo clippy --all-features -- -D warnings
|
||||
|
||||
build:
|
||||
name: Build
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- run: cargo build --release --all-features
|
||||
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
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,8 +1,4 @@
|
||||
/target
|
||||
/.build
|
||||
/build
|
||||
/apps/ios/TeleTuiIOS/BinaryArtifacts
|
||||
/apps/ios/TeleTuiIOS/Generated
|
||||
|
||||
# TDLib session data (contains auth tokens - NEVER commit!)
|
||||
/tdlib_data/
|
||||
@@ -19,4 +15,3 @@ credentials
|
||||
# Commit snapshots, but not the .new files
|
||||
tests/**/*.snap.new
|
||||
*.snap.new
|
||||
apps/ios/TeleTuiIOS/.build/
|
||||
|
||||
@@ -1,30 +1,23 @@
|
||||
|
||||
|
||||
# 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
|
||||
# al bash clojure cpp csharp
|
||||
# csharp_omnisharp dart elixir elm erlang
|
||||
# fortran fsharp go groovy haskell
|
||||
# java julia kotlin lua markdown
|
||||
# matlab nix pascal perl php
|
||||
# powershell python python_jedi r rego
|
||||
# ruby ruby_solargraph rust scala swift
|
||||
# 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
|
||||
# - csharp: Requires the presence of a .sln file in the project folder.
|
||||
# - pascal: Requires Free Pascal Compiler (fpc) and optionally Lazarus.
|
||||
# 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.
|
||||
@@ -38,9 +31,8 @@ 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.
|
||||
# list of additional paths to ignore in all projects
|
||||
# same syntax as gitignore, so you can use * and **
|
||||
ignored_paths: []
|
||||
|
||||
# whether the project is in read-only mode
|
||||
@@ -48,9 +40,45 @@ ignored_paths: []
|
||||
# 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
|
||||
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
|
||||
# Below is the complete list of tools for convenience.
|
||||
# To make sure you have the latest list of tools, and to view their descriptions,
|
||||
# execute `uv run scripts/print_tool_overview.py`.
|
||||
#
|
||||
# * `activate_project`: Activates a project by name.
|
||||
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
|
||||
# * `create_text_file`: Creates/overwrites a file in the project directory.
|
||||
# * `delete_lines`: Deletes a range of lines within a file.
|
||||
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
|
||||
# * `execute_shell_command`: Executes a shell command.
|
||||
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
|
||||
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
|
||||
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
|
||||
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
|
||||
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
|
||||
# * `initial_instructions`: Gets the initial instructions for the current project.
|
||||
# Should only be used in settings where the system prompt cannot be set,
|
||||
# e.g. in clients you have no control over, like Claude Desktop.
|
||||
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
|
||||
# * `insert_at_line`: Inserts content at a given line in a file.
|
||||
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
|
||||
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
|
||||
# * `list_memories`: Lists memories in Serena's project-specific memory store.
|
||||
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
|
||||
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
|
||||
# * `read_file`: Reads a file within the project directory.
|
||||
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
|
||||
# * `remove_project`: Removes a project from the Serena configuration.
|
||||
# * `replace_lines`: Replaces a range of lines within a file with new content.
|
||||
# * `replace_symbol_body`: Replaces the full definition of a symbol.
|
||||
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
|
||||
# * `search_for_pattern`: Performs a search for a pattern in the project.
|
||||
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
|
||||
# * `switch_modes`: Activates modes by providing a list of their names
|
||||
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
|
||||
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
|
||||
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
|
||||
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
|
||||
excluded_tools: []
|
||||
|
||||
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||
@@ -59,9 +87,7 @@ 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
|
||||
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default)
|
||||
included_optional_tools: []
|
||||
|
||||
# list of mode names to that are always to be included in the set of active modes
|
||||
@@ -72,69 +98,13 @@ included_optional_tools: []
|
||||
# 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.
|
||||
# list of mode names that are to be activated by default.
|
||||
# The full set of modes to be activated is base_modes + default_modes.
|
||||
# If the setting is undefined, 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), только если изменились статус, риск, архитектурное решение или следующий шаг.
|
||||
66
CHANGELOG.md
Normal file
66
CHANGELOG.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Changelog
|
||||
|
||||
Все значительные изменения в этом проекте будут документированы в этом файле.
|
||||
|
||||
Формат основан на [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
и этот проект придерживается [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.1.0] - 2024-12-XX
|
||||
|
||||
### Добавлено
|
||||
|
||||
#### Базовая функциональность
|
||||
- TDLib интеграция с авторизацией (телефон + код + 2FA)
|
||||
- Отображение списка чатов с поддержкой папок
|
||||
- Загрузка и отображение истории сообщений
|
||||
- Отправка текстовых сообщений
|
||||
- Vim-style навигация (hjkl) с поддержкой русской раскладки (ролд)
|
||||
- Поиск по чатам (Ctrl+S)
|
||||
- Поиск внутри чата (Ctrl+F)
|
||||
|
||||
#### Сообщения
|
||||
- Группировка по дате и отправителю
|
||||
- Markdown форматирование (жирный, курсив, подчёркивание, зачёркивание, код, спойлеры)
|
||||
- Редактирование сообщений
|
||||
- Удаление сообщений с подтверждением
|
||||
- Reply на сообщения
|
||||
- Forward сообщений
|
||||
- Копирование в системный буфер обмена
|
||||
- Реакции на сообщения с emoji picker
|
||||
|
||||
#### UI/UX
|
||||
- Индикаторы: онлайн-статус (●), прочитанность (✓/✓✓), редактирование (✎)
|
||||
- Иконки: 📌 закреплённые чаты, 🔇 замьюченные, @ упоминания
|
||||
- Typing indicator ("печатает...")
|
||||
- Закреплённые сообщения
|
||||
- Профиль пользователя/чата
|
||||
- Черновики с автосохранением
|
||||
- Динамический инпут (расширение до 10 строк)
|
||||
- Блочный курсор с навигацией
|
||||
- Состояние сети в футере
|
||||
|
||||
#### Конфигурация
|
||||
- TOML конфигурация (~/.config/tele-tui/config.toml)
|
||||
- Настройка часового пояса
|
||||
- Настройка цветовой схемы
|
||||
- Приоритетная загрузка credentials из XDG config dir
|
||||
|
||||
#### Оптимизации
|
||||
- 60 FPS рендеринг
|
||||
- LRU кеширование пользователей (лимит 500)
|
||||
- Lazy loading имён пользователей
|
||||
- Лимиты памяти (500 сообщений на чат, 200 чатов)
|
||||
- Graceful shutdown
|
||||
|
||||
### Изменено
|
||||
- Время отображается с учётом настроенного timezone
|
||||
|
||||
### Исправлено
|
||||
- Корректная обработка TDLib updates в отдельном потоке
|
||||
- Правильное выравнивание для длинных сообщений
|
||||
- Приоритет обработки input для модалок
|
||||
|
||||
[Unreleased]: https://github.com/your-username/tele-tui/compare/v0.1.0...HEAD
|
||||
[0.1.0]: https://github.com/your-username/tele-tui/releases/tag/v0.1.0
|
||||
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 тестированию
|
||||
|
||||
|
||||
|
||||
341
CONTEXT.md
341
CONTEXT.md
@@ -1,52 +1,311 @@
|
||||
# Контекст проекта
|
||||
# Текущий контекст проекта
|
||||
|
||||
## Текущий статус
|
||||
## Статус: Фаза 14 — Мультиаккаунт (IN PROGRESS)
|
||||
|
||||
Фаза 14: мультиаккаунт, в работе.
|
||||
### Per-Account Lock File Protection — DONE
|
||||
|
||||
Цель фазы: безопасные профили Telegram-аккаунтов, переключение аккаунтов и подготовка к более быстрому multi-client UX.
|
||||
Защита от запуска двух экземпляров tele-tui с одним аккаунтом + логирование ошибок TDLib.
|
||||
|
||||
## Уже сделано
|
||||
**Проблема**: При запуске второго экземпляра с тем же аккаунтом, TDLib не может залочить свою БД. `set_tdlib_parameters` молча падает (`let _ = ...`), и приложение зависает на "Инициализация TDLib...".
|
||||
|
||||
- Профили аккаунтов: `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` снова воспроизводим.
|
||||
**Решение**: Advisory file locks через `fs2` (flock):
|
||||
- **Lock файл**: `~/.local/share/tele-tui/accounts/{name}/tele-tui.lock`
|
||||
- **Автоматическое освобождение** при crash/SIGKILL (ядро ОС закрывает file descriptors)
|
||||
- **При старте**: acquire lock ДО `enable_raw_mode()` → ошибка выводится в обычный терминал
|
||||
- **При переключении аккаунтов**: acquire new → release old → switch (при ошибке — остаёмся на старом)
|
||||
- **Логирование**: `set_tdlib_parameters` ошибки теперь логируются через `tracing::error!`
|
||||
|
||||
## Осталось
|
||||
**Новые файлы:**
|
||||
- `src/accounts/lock.rs` — `acquire_lock()`, `release_lock()`, `account_lock_path()` + 4 теста
|
||||
|
||||
- Быстрые hotkeys `Ctrl+1`..`Ctrl+9` для аккаунтов без модалки.
|
||||
- Удаление/переименование аккаунта в account switcher.
|
||||
- Бейджи непрочитанных с других аккаунтов.
|
||||
- Решить, нужен ли переход от single-client reinit к одновременным клиентам.
|
||||
- Добавить/уточнить tests для accounts + TDLib update routing.
|
||||
**Модифицированные файлы:**
|
||||
- `Cargo.toml` — зависимость `fs2 = "0.4"`
|
||||
- `src/accounts/mod.rs` — `pub mod lock;` + re-exports
|
||||
- `src/app/mod.rs` — поле `account_lock: Option<File>` в `App<T>`
|
||||
- `src/main.rs` — acquire lock при старте, lock при переключении аккаунтов, логирование set_tdlib_parameters
|
||||
- `src/tdlib/client.rs` — логирование set_tdlib_parameters в `recreate_client()`
|
||||
|
||||
## Риски
|
||||
---
|
||||
|
||||
- Multi-account код должен фильтровать TDLib updates по `client_id`.
|
||||
- Инициализация чата и фоновые downloads не должны блокировать первый redraw.
|
||||
- Read/unread статус исходящих сообщений зависит от корректной TDLib metadata.
|
||||
- Конфликтующие keybindings должны оставаться детерминированными.
|
||||
- Переключение аккаунтов требует проверки lock release/acquire и auth flow.
|
||||
### Photo Albums (Media Groups) — DONE
|
||||
|
||||
## Ключевые решения
|
||||
Фото-альбомы (несколько фото в одном сообщении) теперь группируются в один пузырь с сеткой фото.
|
||||
|
||||
- Главный 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/`.
|
||||
**Проблема**: TDLib отправляет альбомы как отдельные `Message` с общим `media_album_id: i64`. Ранее проект это поле игнорировал — каждое фото отображалось как отдельный пузырь.
|
||||
|
||||
**Решение:**
|
||||
|
||||
1. **Data Model** — `media_album_id: i64` в `MessageMetadata`, `MessageBuilder`, getter `MessageInfo::media_album_id()`. Оба конвертера (async + sync) передают поле из TDLib.
|
||||
|
||||
2. **Message Grouping** — новый вариант `MessageGroup::Album(Vec<MessageInfo>)`. Сообщения с одинаковым `media_album_id != 0` группируются; одиночное сообщение с album_id остаётся `Message`.
|
||||
|
||||
3. **Album Grid Constants** — `ALBUM_PHOTO_WIDTH: 16`, `ALBUM_PHOTO_HEIGHT: 8`, `ALBUM_PHOTO_GAP: 1`, `ALBUM_GRID_MAX_COLS: 3` (3×16 + 2×1 = 50 = `INLINE_IMAGE_MAX_WIDTH`).
|
||||
|
||||
4. **`render_album_bubble()`** — сетка фото (до 3 в ряд), `DeferredImageRender` с `x_offset` для каждого фото, общая подпись и timestamp, индикация выбора, статусы загрузки.
|
||||
|
||||
5. **Integration** — `Album` arm в `render_message_list`, `x_offset` в second pass. Без feature `images` — fallback через отдельные bubble.
|
||||
|
||||
**Модифицированные файлы:**
|
||||
- `src/tdlib/types.rs` — `media_album_id` в `MessageMetadata`, `MessageBuilder`, getter
|
||||
- `src/tdlib/messages/convert.rs` — передача `media_album_id` в builder
|
||||
- `src/tdlib/message_converter.rs` — передача `media_album_id` в builder
|
||||
- `src/message_grouping.rs` — `Album` variant + album detection + 4 новых теста
|
||||
- `src/constants.rs` — album grid constants
|
||||
- `src/ui/components/message_bubble.rs` — `x_offset` в `DeferredImageRender`, `render_album_bubble()`
|
||||
- `src/ui/components/mod.rs` — export `render_album_bubble`
|
||||
- `src/ui/messages.rs` — `Album` arm + `x_offset` в second pass
|
||||
|
||||
6. **Навигация j/k по альбомам** — альбом обрабатывается как одно сообщение. `select_previous_message()` / `select_next_message()` перескакивают через все сообщения альбома. `start_message_selection()` встаёт на первый элемент альбома если последнее сообщение — часть альбома.
|
||||
|
||||
7. **Тесты** — 4 unit-теста в `message_grouping.rs`, 5 snapshot-тестов в `tests/messages.rs`, 3 теста навигации в `tests/input_navigation.rs`.
|
||||
|
||||
**Дополнительно модифицированные файлы:**
|
||||
- `src/app/methods/messages.rs` — навигация перескакивает альбомы
|
||||
- `tests/helpers/test_data.rs` — `TestMessageBuilder::media_album_id()`
|
||||
- `tests/messages.rs` — 5 snapshot-тестов для альбомов
|
||||
- `tests/input_navigation.rs` — 3 теста навигации по альбомам
|
||||
|
||||
**Что НЕ меняется:** image modal (v), auto-download, одиночные фото.
|
||||
|
||||
---
|
||||
|
||||
### Оптимизация: Ленивая загрузка сообщений при открытии чата (DONE)
|
||||
|
||||
Чат открывается мгновенно (< 1 сек) вместо 5-30 сек для больших чатов.
|
||||
|
||||
**Проблема**: `open_chat_and_load_data()` блокировал UI до полной загрузки ВСЕХ сообщений (`get_chat_history(chat_id, i32::MAX)`). Для чата с 500+ сообщениями это 10+ запросов к TDLib.
|
||||
|
||||
**Решение**:
|
||||
- Загрузка только 50 последних сообщений (один запрос) → чат виден сразу
|
||||
- Фоновые задачи (reply info, pinned, photos) — на следующем тике main loop через `pending_chat_init`
|
||||
- Старые сообщения подгружаются при скролле вверх (существующий `load_older_messages_if_needed`)
|
||||
|
||||
**Модифицированные файлы:**
|
||||
- `src/app/mod.rs` — поле `pending_chat_init: Option<ChatId>`
|
||||
- `src/input/handlers/chat_list.rs` — `open_chat_and_load_data()`: 50 сообщений + `pending_chat_init`
|
||||
- `src/main.rs` — обработка `pending_chat_init` в main loop (reply info, pinned, photos)
|
||||
- `src/app/methods/navigation.rs` — сброс `pending_chat_init` в `close_chat()`
|
||||
|
||||
---
|
||||
|
||||
### Bugfix: Авто-загрузка фото в чате (DONE)
|
||||
|
||||
Фото не отображались — отсутствовал код загрузки файлов после открытия чата.
|
||||
|
||||
**Проблема**: `extract_media_info()` создавал `PhotoInfo` с `PhotoDownloadState::NotDownloaded`, но никакой код не инициировал `download_file()`. Фото оставались в состоянии "📷 [Фото]" без inline превью.
|
||||
|
||||
**Исправление:**
|
||||
- **Авто-загрузка при открытии чата**: после загрузки истории сообщений скачиваются фото из последних 30 сообщений (если `auto_download_images = true` и `show_images = true`). Каждый файл — с таймаутом 5 сек.
|
||||
- **Загрузка по `v`**: вместо "Фото не загружено" — скачивание + открытие модалки. Также повторная попытка при `Error`.
|
||||
- Обновление `PhotoDownloadState` в сообщении после успешной/неуспешной загрузки.
|
||||
|
||||
**Модифицированные файлы:**
|
||||
- `src/input/handlers/chat_list.rs` — авто-загрузка фото в `open_chat_and_load_data()`
|
||||
- `src/input/handlers/chat.rs` — `handle_view_image()`: download on NotDownloaded + retry on Error
|
||||
|
||||
---
|
||||
|
||||
### Этап 2+3: Account Switcher Modal + Переключение аккаунтов (DONE)
|
||||
|
||||
Реализована модалка переключения аккаунтов и механизм переключения:
|
||||
|
||||
- **Модалка `Ctrl+A`**: глобальный оверлей поверх любого экрана (Loading/Auth/Main)
|
||||
- **Навигация**: `j/k` по списку, `Enter` выбор, `a` добавление, `Esc` закрытие
|
||||
- **Переключение**: close TDLib → `recreate_client(new_db_path)` → auth flow
|
||||
- **Добавление аккаунта**: ввод имени в модалке → валидация → `add_account()` → переключение
|
||||
- **Footer индикатор**: `[account_name]` если не "default"
|
||||
- **`AccountSwitcherState`**: enum `SelectAccount` / `AddAccount` — глобальный оверлей в `App<T>`
|
||||
- **`recreate_client()`**: новый метод в `TdClientTrait` — close old → new TdClient → spawn set_tdlib_parameters
|
||||
|
||||
**Новые файлы:**
|
||||
- `src/ui/modals/account_switcher.rs` — UI рендеринг (SelectAccount + AddAccount)
|
||||
- `tests/account_switcher.rs` — 12 тестов
|
||||
|
||||
**Модифицированные файлы:**
|
||||
- `src/app/mod.rs` — `AccountSwitcherState` enum, 3 поля (`account_switcher`, `current_account_name`, `pending_account_switch`), 8 методов
|
||||
- `src/accounts/manager.rs` — `add_account()` (validate + save + ensure_dir)
|
||||
- `src/accounts/mod.rs` — re-export `add_account`
|
||||
- `src/tdlib/trait.rs` — `recreate_client(&mut self, db_path)` в TdClientTrait
|
||||
- `src/tdlib/client.rs` — реализация `recreate_client` (close → new → set_tdlib_parameters)
|
||||
- `src/tdlib/client_impl.rs` — trait impl делегирование
|
||||
- `tests/helpers/fake_tdclient_impl.rs` — no-op `recreate_client`
|
||||
- `src/input/main_input.rs` — account_switcher роутинг (highest priority)
|
||||
- `src/input/handlers/global.rs` — `Ctrl+A` → open_account_switcher
|
||||
- `src/input/handlers/modal.rs` — `handle_account_switcher()` (SelectAccount + AddAccount input)
|
||||
- `src/ui/modals/mod.rs` — `pub mod account_switcher;`
|
||||
- `src/ui/mod.rs` — overlay поверх любого экрана
|
||||
- `src/ui/footer.rs` — `[account_name]` индикатор
|
||||
- `src/main.rs` — `pending_account_switch` check в run_app, `Ctrl+A` из любого экрана
|
||||
|
||||
### Этап 1: Инфраструктура профилей аккаунтов (DONE)
|
||||
|
||||
Реализована инфраструктура для мультиаккаунта:
|
||||
|
||||
- **Модуль `accounts/`**: `profile.rs` (типы + валидация) + `manager.rs` (загрузка/сохранение/миграция)
|
||||
- **`accounts.toml`**: конфиг списка аккаунтов в `~/.config/tele-tui/accounts.toml`
|
||||
- **XDG data dir**: БД TDLib хранится в `~/.local/share/tele-tui/accounts/{name}/tdlib_data/`
|
||||
- **Автомиграция**: `./tdlib_data/` → XDG path при первом запуске
|
||||
- **CLI флаг `--account <name>`**: выбор аккаунта при запуске
|
||||
- **Параметризация `db_path`**: `TdClient::new(db_path)`, `App::new(config, db_path)`
|
||||
|
||||
---
|
||||
|
||||
## Предыдущий статус: Multiline Message Display (DONE)
|
||||
|
||||
### Multiline в сообщениях
|
||||
|
||||
- **Multiline в сообщениях**: `\n` корректно отображается в пузырях сообщений (split по `\n` + word wrap)
|
||||
- **Маркер выделения**: ▶ показывается только на первой строке multiline-сообщения
|
||||
- Перенос строки в инпуте отключён (Shift+Enter/Alt+Enter/Ctrl+J не вставляют `\n`)
|
||||
|
||||
**Файлы изменены:**
|
||||
- `ui/components/message_bubble.rs` — `wrap_text_with_offsets()` split по `\n` + `wrap_paragraph()` + selection marker fix
|
||||
|
||||
---
|
||||
|
||||
### Vim Normal/Insert Mode (DONE)
|
||||
|
||||
Реализован Vim-like режим работы с двумя состояниями:
|
||||
|
||||
- **Normal mode** (по умолчанию при открытии чата): навигация j/k, команды d/r/f/y, автоматический вход в MessageSelection
|
||||
- **Insert mode** (нажать `i`/`ш`): набор текста, Esc возвращает в Normal
|
||||
- Автопереключение в Insert при Reply (`r`) и Edit (`Enter`)
|
||||
- Визуальные индикаторы: `[NORMAL]`/`[INSERT]` в footer, зелёная/серая рамка compose bar
|
||||
- В Insert mode блокируются все команды кроме текстового ввода и Esc
|
||||
|
||||
**Файлы изменены:**
|
||||
- `app/chat_state.rs` — enum `InputMode`
|
||||
- `app/mod.rs` — поле `input_mode` в `App<T>`
|
||||
- `config/keybindings.rs` — `Command::EnterInsertMode` + keybinding `i`/`ш`
|
||||
- `app/methods/navigation.rs` — `close_chat()` сбрасывает input_mode
|
||||
- `input/main_input.rs` — главный роутер Insert/Normal
|
||||
- `input/handlers/chat.rs` — EnterInsertMode, auto-Insert при Reply/Edit
|
||||
- `input/handlers/chat_list.rs` — auto-MessageSelection при открытии чата
|
||||
- `ui/footer.rs` — mode indicator
|
||||
- `ui/compose_bar.rs` — visual mode differentiation
|
||||
- `tests/` — обновлены для нового поведения
|
||||
|
||||
---
|
||||
|
||||
## Предыдущий статус: Фаза 12 — Прослушивание голосовых сообщений (DONE)
|
||||
|
||||
### Завершённые фазы (краткий итог)
|
||||
|
||||
| Фаза | Описание | Статус |
|
||||
|------|----------|--------|
|
||||
| 1 | Базовая инфраструктура (ratatui + crossterm, vim-навигация) | DONE |
|
||||
| 2 | TDLib интеграция (авторизация, чаты, сообщения) | DONE |
|
||||
| 3 | Улучшение UX (отправка, поиск, скролл, realtime) | DONE |
|
||||
| 4 | Папки и фильтрация (загрузка папок, переключение 1-9) | DONE |
|
||||
| 5 | Расширенный функционал (онлайн-статус, медиа-заглушки, muted) | DONE |
|
||||
| 6 | Полировка (60 FPS, память, graceful shutdown, динамический инпут) | DONE |
|
||||
| 7 | Рефакторинг памяти (единый источник данных, LRU-кэш) | DONE |
|
||||
| 8 | Дополнительные фичи (markdown, edit/delete, reply/forward, блочный курсор) | DONE |
|
||||
| 9 | Расширенные возможности (typing, pinned, поиск в чате, черновики, профиль, копирование, реакции, конфиг) | DONE |
|
||||
| 10 | Desktop уведомления (notify-rust, muted фильтр, mentions, медиа) | DONE (83%) |
|
||||
| 11 | Inline просмотр фото (ratatui-image, кэш, загрузка) | DONE |
|
||||
| 12 | Прослушивание голосовых сообщений (ffplay, play/pause, seek, ticker, cache, config) | DONE |
|
||||
| 13 | Глубокий рефакторинг архитектуры (7 этапов) | DONE |
|
||||
|
||||
### Фаза 11: Inline фото + оптимизации (подробности)
|
||||
|
||||
Feature-gated (`images`), 2-tier архитектура:
|
||||
|
||||
**Базовая реализация:**
|
||||
1. **Типы**: `MediaInfo`, `PhotoInfo`, `PhotoDownloadState`, `ImageModalState`, `ImagesConfig`
|
||||
2. **Зависимости**: `ratatui-image 8.1`, `image 0.25` (feature-gated)
|
||||
3. **Media модуль**: `ImageCache` (LRU), dual `ImageRenderer` (inline + modal)
|
||||
4. **UX**: Always-show inline preview (фикс. ширина 50 chars) + полноэкранная модалка на `v`/`м`
|
||||
5. **Метаданные**: `extract_media_info()` из TDLib MessagePhoto; auto-download visible photos
|
||||
|
||||
**Оптимизации производительности:**
|
||||
1. **Dual protocol strategy**:
|
||||
- `inline_image_renderer`: Halfblocks → быстро (Unicode блоки), для навигации
|
||||
- `modal_image_renderer`: iTerm2/Sixel → медленно (high quality), для просмотра
|
||||
2. **Frame throttling**: inline images 15 FPS (66ms), текст 60 FPS
|
||||
3. **Lazy loading**: загрузка только видимых изображений (не все сразу)
|
||||
4. **LRU кэш**: max 100 протоколов, eviction старых
|
||||
5. **Loading indicator**: "⏳ Загрузка..." в модалке при первом открытии
|
||||
6. **Navigation hotkeys**: `←`/`→` между фото, `Esc`/`q` закрыть модалку
|
||||
|
||||
**UI рендеринг**:
|
||||
- `message_bubble.rs`: photo status (Downloading/Error/placeholder), inline preview
|
||||
- `messages.rs`: второй проход с `render_images()` + throttling + только видимые
|
||||
- `modals/image_viewer.rs`: fullscreen modal с aspect ratio + loading state
|
||||
|
||||
### Фаза 13: Рефакторинг (подробности)
|
||||
|
||||
Разбиты 5 монолитных файлов (4582 строк) на модульную архитектуру:
|
||||
|
||||
- **input/main_input.rs** (1199→164): чистый роутер + 5 handler модулей в `handlers/`
|
||||
- **app/mod.rs** (1015→371): 5 trait модулей в `methods/` (Navigation, Message, Compose, Search, Modal)
|
||||
- **ui/messages.rs** (893→365): модули `modals/` (search, pinned, delete, reactions) + `compose_bar.rs`
|
||||
- **tdlib/messages.rs** (836→3 файла): `messages/` (mod, convert, operations)
|
||||
- **config/mod.rs** (642→3 файла): validation.rs, loader.rs
|
||||
- **Очистка дублей**: ~220 строк удалено (shared components, format_user_status, scroll_to_message)
|
||||
- **Документация**: PROJECT_STRUCTURE.md переписан, 16 файлов получили `//!` docs
|
||||
|
||||
### Фаза 12: Голосовые сообщения (подробности)
|
||||
|
||||
**Реализовано:**
|
||||
- **AudioPlayer** на ffplay (subprocess): play, pause (SIGSTOP), resume (SIGCONT), stop
|
||||
- **VoiceCache**: LRU кэш OGG файлов в `~/.cache/tele-tui/voice/` (max 100 MB)
|
||||
- **Типы**: `VoiceInfo`, `VoiceDownloadState`, `PlaybackState`, `PlaybackStatus`
|
||||
- **TDLib интеграция**: `download_voice_note()`, конвертация `MessageVoiceNote`
|
||||
- **Хоткеи**: Space (play/pause), ←/→ (seek ±5s via ffplay restart с `-ss`)
|
||||
- **Автостоп**: при навигации на другое сообщение воспроизведение останавливается
|
||||
- **Ticker**: `last_playback_tick` в App + обновление position в event loop (1 FPS redraw)
|
||||
- **VoiceCache**: проверка кэша перед загрузкой, кэширование после download
|
||||
- **AudioConfig**: `[audio]` секция в config.toml (cache_size_mb, auto_download_voice)
|
||||
- **UI**: progress bar (━●─) + waveform (▁▂▃▄▅▆▇█) + иконки статуса в `message_bubble.rs`
|
||||
- **Race condition fixes**: `starting` flag + pid ownership guard в потоках AudioPlayer
|
||||
- **Seek**: `resume_from()` перезапускает ffplay с `-ss` offset; MoveLeft/MoveRight как alias для SeekBackward/SeekForward
|
||||
- **Resume with rewind**: пауза→продолжение откатывает на 1 секунду назад
|
||||
- **Graceful shutdown**: `stop_playback()` + Drop impl для AudioPlayer
|
||||
|
||||
### Ключевая архитектура
|
||||
|
||||
```
|
||||
main.rs → event loop (16ms poll)
|
||||
├── input/ → роутер + handlers/ (chat, chat_list, compose, modal, search)
|
||||
├── app/ → App<T: TdClientTrait> + methods/ (5 traits, 67 методов)
|
||||
├── ui/ → рендеринг (messages, chat_list, modals/, compose_bar, components/)
|
||||
├── audio/ → player.rs (ffplay), cache.rs (VoiceCache)
|
||||
├── media/ → [feature=images] cache.rs, image_renderer.rs
|
||||
└── tdlib/ → TDLib wrapper (client, auth, chats, messages/, users, reactions, types)
|
||||
```
|
||||
|
||||
### Тестирование
|
||||
|
||||
500+ тестов (0 failures):
|
||||
- Snapshot tests: 57 (UI компоненты)
|
||||
- Integration tests: 93 (user flows)
|
||||
- E2E tests: 12 (smoke + journey)
|
||||
- Utils tests: 18
|
||||
- Performance benchmarks: 8
|
||||
|
||||
### Ключевые решения
|
||||
|
||||
1. **Неблокирующий receive**: TDLib updates через `mpsc::channel` в отдельном потоке
|
||||
2. **Trait-based App**: методы разбиты на traits — требуют `use` import на call site
|
||||
3. **FakeTdClient**: mock для тестов без TDLib (реализует TdClientTrait)
|
||||
4. **Оптимизация рендеринга**: `needs_redraw` флаг, рендеринг только при изменениях
|
||||
5. **Конфиг**: TOML `~/.config/tele-tui/config.toml`, credentials с приоритетом (XDG → .env)
|
||||
6. **Feature-gated images**: `images` feature flag для ratatui-image + image deps
|
||||
7. **Dual renderer**: inline (Halfblocks, 15 FPS) + modal (iTerm2/Sixel, high quality) для баланса скорости/качества
|
||||
8. **Audio via ffplay**: subprocess с SIGSTOP/SIGCONT для pause/resume, автостоп при навигации
|
||||
|
||||
### Зависимости (основные)
|
||||
|
||||
```toml
|
||||
ratatui = "0.29" # TUI фреймворк
|
||||
crossterm = "0.28" # Терминальный backend
|
||||
tdlib-rs = "1.1" # Telegram TDLib binding
|
||||
tokio = "1" # Async runtime
|
||||
notify-rust = "4.11" # Desktop уведомления (feature flag)
|
||||
ratatui-image = "8.1" # Inline images (feature flag)
|
||||
image = "0.25" # Image decoding (feature flag)
|
||||
```
|
||||
|
||||
Полная структура проекта: см. [PROJECT_STRUCTURE.md](PROJECT_STRUCTURE.md)
|
||||
Подробный план: см. [ROADMAP.md](ROADMAP.md)
|
||||
|
||||
125
CONTRIBUTING.md
Normal file
125
CONTRIBUTING.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# Contributing to tele-tui
|
||||
|
||||
Спасибо за интерес к проекту! Мы рады любому вкладу.
|
||||
|
||||
## Как помочь проекту
|
||||
|
||||
### Сообщить о баге
|
||||
|
||||
1. Проверьте, нет ли уже такого issue в [Issues](https://github.com/your-username/tele-tui/issues)
|
||||
2. Создайте новый issue с описанием:
|
||||
- Шаги для воспроизведения
|
||||
- Ожидаемое поведение
|
||||
- Фактическое поведение
|
||||
- Версия ОС и Rust
|
||||
- Логи (если есть)
|
||||
|
||||
### Предложить новую фичу
|
||||
|
||||
1. Проверьте [ROADMAP.md](ROADMAP.md) — возможно, эта фича уже запланирована
|
||||
2. Создайте issue с меткой `enhancement`
|
||||
3. Опишите:
|
||||
- Зачем нужна эта фича
|
||||
- Как она должна работать
|
||||
- Примеры использования
|
||||
|
||||
### Внести код
|
||||
|
||||
1. **Fork** репозитория
|
||||
2. Создайте **feature branch**: `git checkout -b feature/amazing-feature`
|
||||
3. Прочитайте [DEVELOPMENT.md](DEVELOPMENT.md) для понимания процесса разработки
|
||||
4. Внесите изменения
|
||||
5. Протестируйте локально
|
||||
6. Commit: `git commit -m 'Add amazing feature'`
|
||||
7. Push: `git push origin feature/amazing-feature`
|
||||
8. Создайте **Pull Request**
|
||||
|
||||
## Правила кода
|
||||
|
||||
### Стиль кода
|
||||
|
||||
- Используйте `cargo fmt` перед коммитом
|
||||
- Следуйте [Rust API Guidelines](https://rust-lang.github.io/api-guidelines/)
|
||||
- Добавляйте комментарии для сложной логики
|
||||
|
||||
### Структура коммитов
|
||||
|
||||
```
|
||||
<type>: <краткое описание>
|
||||
|
||||
<подробное описание (опционально)>
|
||||
```
|
||||
|
||||
Типы:
|
||||
- `feat`: новая фича
|
||||
- `fix`: исправление бага
|
||||
- `refactor`: рефакторинг без изменения функциональности
|
||||
- `docs`: изменения в документации
|
||||
- `style`: форматирование, отступы
|
||||
- `test`: добавление тестов
|
||||
- `chore`: обновление зависимостей, конфигурации
|
||||
|
||||
Примеры:
|
||||
```
|
||||
feat: add emoji reactions to messages
|
||||
|
||||
fix: correct timezone offset calculation
|
||||
|
||||
docs: update installation instructions
|
||||
```
|
||||
|
||||
### Тестирование
|
||||
|
||||
- Протестируйте вручную все изменения
|
||||
- Опишите сценарии тестирования в PR
|
||||
- Убедитесь, что `cargo build` проходит без ошибок
|
||||
- Убедитесь, что `cargo fmt` и `cargo clippy` не дают предупреждений
|
||||
|
||||
## Процесс Review
|
||||
|
||||
1. Maintainer проверит ваш PR
|
||||
2. Возможны комментарии и запросы на изменения
|
||||
3. После одобрения PR будет смержен
|
||||
4. Ваш вклад появится в следующем релизе
|
||||
|
||||
## Архитектура проекта
|
||||
|
||||
Перед началом работы рекомендуем ознакомиться:
|
||||
|
||||
- [REQUIREMENTS.md](REQUIREMENTS.md) — функциональные требования
|
||||
- [CONTEXT.md](CONTEXT.md) — текущий статус и архитектурные решения
|
||||
- [ROADMAP.md](ROADMAP.md) — план развития
|
||||
|
||||
### Структура кода
|
||||
|
||||
```
|
||||
src/
|
||||
├── main.rs # Event loop, инициализация
|
||||
├── config.rs # Конфигурация
|
||||
├── app/ # Состояние приложения
|
||||
├── ui/ # Отрисовка UI
|
||||
├── input/ # Обработка ввода
|
||||
├── utils.rs # Утилиты
|
||||
└── tdlib/ # TDLib интеграция
|
||||
```
|
||||
|
||||
### Ключевые принципы
|
||||
|
||||
1. **Неблокирующий UI**: TDLib updates в отдельном потоке
|
||||
2. **Оптимизация памяти**: LRU кеши, лимиты на коллекции
|
||||
3. **Vim-style навигация**: консистентные хоткеи
|
||||
4. **Graceful degradation**: fallback для отсутствующих данных
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
- Будьте вежливы и уважительны
|
||||
- Конструктивная критика приветствуется
|
||||
- Фокус на технических аспектах
|
||||
|
||||
## Вопросы?
|
||||
|
||||
Создайте issue с меткой `question` или свяжитесь с maintainers.
|
||||
|
||||
## Лицензия
|
||||
|
||||
Внося код в этот проект, вы соглашаетесь с лицензией [MIT](LICENSE).
|
||||
1089
Cargo.lock
generated
1089
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
70
Cargo.toml
70
Cargo.toml
@@ -1,12 +1,60 @@
|
||||
[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"
|
||||
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"]
|
||||
|
||||
[patch.crates-io]
|
||||
tdlib-rs = { path = "crates/vendor/tdlib-rs" }
|
||||
[features]
|
||||
default = ["clipboard", "url-open", "notifications", "images"]
|
||||
clipboard = ["dep:arboard"]
|
||||
url-open = ["dep:open"]
|
||||
notifications = ["dep:notify-rust"]
|
||||
images = ["dep:ratatui-image", "dep:image"]
|
||||
|
||||
[dependencies]
|
||||
ratatui = "0.29"
|
||||
crossterm = "0.28"
|
||||
tdlib-rs = { version = "1.2.0", features = ["download-tdlib"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
async-trait = "0.1"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
dotenvy = "0.15"
|
||||
chrono = "0.4"
|
||||
open = { version = "5.0", optional = true }
|
||||
arboard = { version = "3.4", optional = true }
|
||||
notify-rust = { version = "4.11", optional = true }
|
||||
ratatui-image = { version = "8.1", optional = true, features = ["image-defaults"] }
|
||||
image = { version = "0.25", optional = true }
|
||||
toml = "0.8"
|
||||
dirs = "5.0"
|
||||
thiserror = "1.0"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
base64 = "0.22.1"
|
||||
fs2 = "0.4"
|
||||
|
||||
[dev-dependencies]
|
||||
insta = "1.34"
|
||||
tokio-test = "0.4"
|
||||
criterion = "0.5"
|
||||
|
||||
[build-dependencies]
|
||||
tdlib-rs = { version = "1.2.0", features = ["download-tdlib"] }
|
||||
|
||||
[[bench]]
|
||||
name = "group_messages"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "formatting"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "format_markdown"
|
||||
harness = false
|
||||
|
||||
125
DEVELOPMENT.md
125
DEVELOPMENT.md
@@ -1,59 +1,110 @@
|
||||
# Разработка
|
||||
# Правила локальной разработки
|
||||
|
||||
> **Обязательно к прочтению перед началом работы!**
|
||||
|
||||
---
|
||||
|
||||
## Инструменты
|
||||
|
||||
- Для поиска используй `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, проверили...
|
||||
|
||||
```text
|
||||
Проверь:
|
||||
1. Запусти ...
|
||||
2. Открой ...
|
||||
3. Выполни ...
|
||||
4. Ожидаемый результат: ...
|
||||
---
|
||||
|
||||
### 4. Работа с git
|
||||
|
||||
НИКОГДА НЕ КОММИТЬ ИЗМЕНЕНИЯ ПОКА ТЕБЯ НЕ ПОПРОСЯТ!!!
|
||||
|
||||
|
||||
## Чеклист перед началом работы
|
||||
|
||||
- [ ] Прочитал CONTEXT.md
|
||||
- [ ] Прочитал ROADMAP.md (понял текущую фазу)
|
||||
- [ ] Понял задачу
|
||||
- [ ] Готов работать поэтапно
|
||||
- [ ] Помню: не запускаю сервисы сам, прошу пользователя
|
||||
|
||||
---
|
||||
|
||||
## Пример правильного workflow
|
||||
|
||||
```
|
||||
Пользователь: Сделай endpoint для получения списка идей
|
||||
|
||||
Агент:
|
||||
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. Переходит к следующему этапу
|
||||
```
|
||||
|
||||
Для чисто документационных задач cargo-проверки не требуются.
|
||||
## Работа с git
|
||||
- никогда не добавляй себя в соавторов в тексте коммита
|
||||
227
FAQ.md
Normal file
227
FAQ.md
Normal file
@@ -0,0 +1,227 @@
|
||||
# FAQ — Часто задаваемые вопросы
|
||||
|
||||
## Установка и запуск
|
||||
|
||||
### Где получить API credentials?
|
||||
|
||||
1. Перейдите на https://my.telegram.org/apps
|
||||
2. Войдите с вашим номером телефона
|
||||
3. Создайте новое приложение
|
||||
4. Скопируйте `api_id` и `api_hash`
|
||||
|
||||
### Где хранить credentials?
|
||||
|
||||
**Рекомендуется** (приоритет 1):
|
||||
```bash
|
||||
~/.config/tele-tui/credentials
|
||||
```
|
||||
|
||||
**Альтернатива** (приоритет 2):
|
||||
```bash
|
||||
.env # в корне проекта
|
||||
```
|
||||
|
||||
### Ошибка "Telegram API credentials not found!"
|
||||
|
||||
Убедитесь, что вы создали файл credentials (см. выше) с правильным форматом:
|
||||
```
|
||||
API_ID=12345678
|
||||
API_HASH=abcdef1234567890abcdef1234567890
|
||||
```
|
||||
|
||||
### Где хранится сессия Telegram?
|
||||
|
||||
В папке `./tdlib_data/` в директории запуска приложения. Эта папка содержит:
|
||||
- Токены авторизации
|
||||
- Кеш сообщений
|
||||
- Другие данные TDLib
|
||||
|
||||
**Важно**: Не удаляйте эту папку, иначе придётся заново авторизоваться.
|
||||
|
||||
## Использование
|
||||
|
||||
### Как переключаться между папками?
|
||||
|
||||
Нажмите клавиши `1-9` для переключения между первыми 9 папками Telegram.
|
||||
|
||||
### Как искать сообщения в чате?
|
||||
|
||||
1. Откройте чат
|
||||
2. Нажмите `Ctrl+F`
|
||||
3. Введите поисковый запрос
|
||||
4. Используйте `n` / `N` для навигации по результатам
|
||||
|
||||
### Как скопировать текст сообщения?
|
||||
|
||||
1. При пустом поле ввода нажмите `↑` для выбора сообщения
|
||||
2. Нажмите `y` (или `н` на русской раскладке)
|
||||
3. Текст скопирован в системный буфер обмена
|
||||
|
||||
### Как ответить на сообщение?
|
||||
|
||||
1. Выберите сообщение (`↑` при пустом инпуте)
|
||||
2. Нажмите `r` (или `к` на русской раскладке)
|
||||
3. Введите ответ
|
||||
4. Нажмите `Enter`
|
||||
|
||||
### Как удалить сообщение?
|
||||
|
||||
1. Выберите сообщение
|
||||
2. Нажмите `d` / `в` / `Delete`
|
||||
3. Подтвердите удаление: `y` / `Enter`
|
||||
|
||||
### Как добавить реакцию?
|
||||
|
||||
1. Выберите сообщение
|
||||
2. Нажмите `e` (или `у` на русской раскладке)
|
||||
3. Выберите emoji стрелками
|
||||
4. Нажмите `Enter`
|
||||
|
||||
### Почему не работают хоткеи на русской раскладке?
|
||||
|
||||
Убедитесь, что вы используете **русскую раскладку**, а не транслит. Поддерживаемые комбинации:
|
||||
- `р о л д` → `h j k l` (навигация)
|
||||
- `к` → `r` (reply)
|
||||
- `а` → `f` (forward)
|
||||
- `в` → `d` (delete)
|
||||
- `н` → `y` (copy)
|
||||
- `у` → `e` (react)
|
||||
|
||||
## Конфигурация
|
||||
|
||||
### Где находится конфигурационный файл?
|
||||
|
||||
```bash
|
||||
~/.config/tele-tui/config.toml
|
||||
```
|
||||
|
||||
Создаётся автоматически при первом запуске.
|
||||
|
||||
### Как изменить часовой пояс?
|
||||
|
||||
Отредактируйте `~/.config/tele-tui/config.toml`:
|
||||
|
||||
```toml
|
||||
[general]
|
||||
timezone = "+05:00" # Ваш часовой пояс
|
||||
```
|
||||
|
||||
### Как изменить цветовую схему?
|
||||
|
||||
Отредактируйте секцию `[colors]` в конфиге:
|
||||
|
||||
```toml
|
||||
[colors]
|
||||
incoming_message = "cyan"
|
||||
outgoing_message = "lightgreen"
|
||||
selected_message = "lightyellow"
|
||||
```
|
||||
|
||||
Поддерживаемые цвета: black, red, green, yellow, blue, magenta, cyan, gray, white, darkgray, lightred, lightgreen, lightyellow, lightblue, lightmagenta, lightcyan.
|
||||
|
||||
### Нужно ли перезапускать приложение после изменения конфига?
|
||||
|
||||
Да, изменения в `config.toml` применяются только при запуске приложения.
|
||||
|
||||
## Проблемы
|
||||
|
||||
### Приложение зависает при запуске
|
||||
|
||||
Возможные причины:
|
||||
1. **Нет интернета**: проверьте подключение
|
||||
2. **TDLib не может подключиться**: проверьте firewall/прокси
|
||||
3. **Неверные credentials**: проверьте API_ID и API_HASH
|
||||
|
||||
### Сообщения не загружаются
|
||||
|
||||
1. Проверьте статус сети в футере (внизу экрана)
|
||||
2. Попробуйте обновить: `Ctrl+R`
|
||||
3. Перезапустите приложение
|
||||
|
||||
### "Deleted Account" в списке чатов
|
||||
|
||||
Это пользователи, которые удалили свой аккаунт Telegram. Они автоматически фильтруются и не отображаются в списке.
|
||||
|
||||
### Не отображаются медиафайлы
|
||||
|
||||
Медиафайлы (фото, видео, голосовые, стикеры) отображаются как заглушки: [Фото], [Видео], [Голосовое], [Стикер]. Полная поддержка медиа может быть добавлена в будущем.
|
||||
|
||||
### Ошибка компиляции при сборке
|
||||
|
||||
**TDLib download failed**:
|
||||
- Проверьте интернет-соединение
|
||||
- Убедитесь, что у вас достаточно места на диске
|
||||
|
||||
**Linking with cc failed**:
|
||||
- macOS: `xcode-select --install`
|
||||
- Linux: `sudo apt-get install build-essential`
|
||||
- Windows: установите Visual Studio Build Tools
|
||||
|
||||
### Как сбросить сессию?
|
||||
|
||||
Удалите папку `tdlib_data/`:
|
||||
```bash
|
||||
rm -rf tdlib_data/
|
||||
```
|
||||
|
||||
При следующем запуске потребуется заново авторизоваться.
|
||||
|
||||
## Производительность
|
||||
|
||||
### Приложение тормозит
|
||||
|
||||
Проверьте:
|
||||
1. Количество открытых чатов (лимит 200)
|
||||
2. Количество сообщений в открытом чате (лимит 500)
|
||||
3. Размер терминала (минимум 80x20)
|
||||
|
||||
Приложение автоматически очищает старые данные при достижении лимитов.
|
||||
|
||||
### Высокое использование памяти
|
||||
|
||||
Это нормально при большом количестве чатов и сообщений. Приложение использует LRU кеши с ограничениями:
|
||||
- 500 пользователей в кеше
|
||||
- 500 сообщений на чат
|
||||
- 200 чатов
|
||||
|
||||
## Разработка
|
||||
|
||||
### Как внести вклад в проект?
|
||||
|
||||
См. [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||
|
||||
### Где найти план развития?
|
||||
|
||||
См. [ROADMAP.md](ROADMAP.md)
|
||||
|
||||
### Как сообщить о баге?
|
||||
|
||||
Создайте issue на GitHub с описанием:
|
||||
- Шаги для воспроизведения
|
||||
- Ожидаемое и фактическое поведение
|
||||
- Версия ОС и Rust
|
||||
- Логи (если есть)
|
||||
|
||||
## Безопасность
|
||||
|
||||
### Безопасно ли хранить credentials в файле?
|
||||
|
||||
Да, если вы:
|
||||
1. Используете `~/.config/tele-tui/credentials`
|
||||
2. Установили права доступа: `chmod 600 ~/.config/tele-tui/credentials`
|
||||
3. Не коммитите этот файл в git (уже в `.gitignore`)
|
||||
|
||||
### Что делать при компрометации credentials?
|
||||
|
||||
1. Удалите приложение на https://my.telegram.org/apps
|
||||
2. Создайте новое приложение с новыми credentials
|
||||
3. Обновите файл `credentials`
|
||||
4. Удалите папку `tdlib_data/` и авторизуйтесь заново
|
||||
|
||||
### Включена ли двухфакторная аутентификация?
|
||||
|
||||
Если вы включили 2FA в Telegram, приложение запросит пароль при первой авторизации.
|
||||
|
||||
## Ещё вопросы?
|
||||
|
||||
Создайте issue на GitHub или свяжитесь с maintainers.
|
||||
194
HOTKEYS.md
Normal file
194
HOTKEYS.md
Normal file
@@ -0,0 +1,194 @@
|
||||
# Горячие клавиши tele-tui
|
||||
|
||||
## Общая навигация
|
||||
|
||||
| Клавиша | Русская раскладка | Действие |
|
||||
|---------|-------------------|----------|
|
||||
| `↑` / `k` | `р` | Вверх по списку |
|
||||
| `↓` / `j` | `о` | Вниз по списку |
|
||||
| `Enter` | | Открыть чат / Отправить сообщение |
|
||||
| `Esc` | | Закрыть чат / Отменить действие |
|
||||
| `Ctrl+C` | | Выход из приложения |
|
||||
| `Ctrl+R` | | Обновить список чатов |
|
||||
|
||||
## Папки и поиск
|
||||
|
||||
| Клавиша | Действие |
|
||||
|---------|----------|
|
||||
| `1-9` | Переключение между папками Telegram |
|
||||
| `Ctrl+S` | Открыть поиск по чатам |
|
||||
| `Ctrl+F` | Открыть поиск в текущем чате |
|
||||
| `n` | Следующий результат поиска |
|
||||
| `N` | Предыдущий результат поиска |
|
||||
|
||||
## Работа с сообщениями
|
||||
|
||||
### Навигация и выбор
|
||||
|
||||
| Клавиша | Действие |
|
||||
|---------|----------|
|
||||
| `↑/↓` | Скролл сообщений (в открытом чате) |
|
||||
| `↑` | Выбор сообщения (при пустом поле ввода) |
|
||||
| `Esc` | Отменить выбор |
|
||||
|
||||
### Действия с сообщениями
|
||||
|
||||
| Клавиша | Русская раскладка | Действие |
|
||||
|---------|-------------------|----------|
|
||||
| `Enter` | | Редактировать выбранное сообщение |
|
||||
| `r` | `к` | Ответить на сообщение (Reply) |
|
||||
| `f` | `а` | Переслать сообщение (Forward) |
|
||||
| `d` / `Delete` | `в` | Удалить сообщение |
|
||||
| `y` | `н` | Копировать текст в буфер обмена |
|
||||
| `e` | `у` | Добавить реакцию (Emoji picker) |
|
||||
| `v` | `м` | Открыть изображение в полном размере |
|
||||
| `Ctrl+i` | `Ctrl+ш` | Открыть профиль чата/пользователя |
|
||||
|
||||
## Просмотр изображений
|
||||
|
||||
### Режим просмотра изображения
|
||||
|
||||
| Клавиша | Действие |
|
||||
|---------|----------|
|
||||
| `v` / `м` | Открыть изображение (в режиме выбора) |
|
||||
| `←` | Предыдущее изображение в чате |
|
||||
| `→` | Следующее изображение в чате |
|
||||
| `Esc` | Закрыть просмотр изображения |
|
||||
|
||||
**Примечание**: Изображения отображаются inline в чате автоматически. Используйте `v` для просмотра в полном размере.
|
||||
|
||||
## Прослушивание голосовых сообщений
|
||||
|
||||
### Управление воспроизведением
|
||||
|
||||
| Клавиша | Русская раскладка | Действие |
|
||||
|---------|-------------------|----------|
|
||||
| `Space` | | Воспроизвести/Пауза (в режиме выбора голосового) |
|
||||
| `s` | `ы` | Остановить воспроизведение |
|
||||
|
||||
### Во время воспроизведения
|
||||
|
||||
| Клавиша | Действие |
|
||||
|---------|----------|
|
||||
| `Space` | Пауза / Возобновить |
|
||||
| `s` / `ы` | Остановить |
|
||||
| `←` | Перемотка назад (по умолчанию -5 сек) |
|
||||
| `→` | Перемотка вперед (по умолчанию +5 сек) |
|
||||
| `↑` | Увеличить громкость (+10%) |
|
||||
| `↓` | Уменьшить громкость (-10%) |
|
||||
|
||||
**Примечание**: Голосовые сообщения показывают progress bar во время воспроизведения: `▶ ████████░░░░░░ 0:08 / 0:15`
|
||||
|
||||
## Модалки подтверждения
|
||||
|
||||
### Удаление сообщения
|
||||
|
||||
| Клавиша | Русская раскладка | Действие |
|
||||
|---------|-------------------|----------|
|
||||
| `y` / `Enter` | `н` | Подтвердить удаление |
|
||||
| `n` / `Esc` | `т` | Отменить удаление |
|
||||
|
||||
## Emoji Picker (реакции)
|
||||
|
||||
| Клавиша | Действие |
|
||||
|---------|----------|
|
||||
| `←` | Влево по сетке эмодзи |
|
||||
| `→` | Вправо по сетке эмодзи |
|
||||
| `↑` | Вверх по сетке эмодзи |
|
||||
| `↓` | Вниз по сетке эмодзи |
|
||||
| `Enter` | Добавить/удалить реакцию |
|
||||
| `Esc` | Закрыть emoji picker |
|
||||
|
||||
## Редактирование текста
|
||||
|
||||
### Навигация по тексту
|
||||
|
||||
| Клавиша | Действие |
|
||||
|---------|----------|
|
||||
| `←` | Курсор влево |
|
||||
| `→` | Курсор вправо |
|
||||
| `Home` | Курсор в начало строки |
|
||||
| `End` | Курсор в конец строки |
|
||||
|
||||
### Редактирование
|
||||
|
||||
| Клавиша | Действие |
|
||||
|---------|----------|
|
||||
| `Backspace` | Удалить символ слева от курсора |
|
||||
| `Delete` | Удалить символ справа от курсора |
|
||||
| `Enter` | Новая строка / Отправить (зависит от контекста) |
|
||||
|
||||
## Режимы работы
|
||||
|
||||
### Режим списка чатов
|
||||
- Навигация: `↑/↓`
|
||||
- Открыть чат: `Enter`
|
||||
- Поиск: `Ctrl+S`
|
||||
- Папки: `1-9`
|
||||
|
||||
### Режим открытого чата
|
||||
- Скролл: `↑/↓`
|
||||
- Выбор сообщения: `↑` (при пустом инпуте)
|
||||
- Поиск в чате: `Ctrl+F`
|
||||
- Закрыть чат: `Esc`
|
||||
|
||||
### Режим выбора сообщения
|
||||
- Редактировать: `Enter`
|
||||
- Ответить: `r` / `к`
|
||||
- Переслать: `f` / `а`
|
||||
- Удалить: `d` / `в` / `Delete`
|
||||
- Копировать: `y` / `н`
|
||||
- Реакция: `e` / `у`
|
||||
- Просмотр изображения: `v` / `м` (если выбрано сообщение с фото)
|
||||
- Воспроизведение голосового: `Space` (если выбрано голосовое сообщение)
|
||||
- Отменить: `Esc`
|
||||
|
||||
### Режим редактирования
|
||||
- Редактировать текст: см. "Редактирование текста"
|
||||
- Отправить: `Enter`
|
||||
- Отменить: `Esc`
|
||||
|
||||
### Режим ответа (Reply)
|
||||
- Редактировать ответ: см. "Редактирование текста"
|
||||
- Отправить: `Enter`
|
||||
- Отменить: `Esc`
|
||||
|
||||
### Режим пересылки (Forward)
|
||||
- Выбрать чат: `↑/↓`
|
||||
- Переслать: `Enter`
|
||||
- Отменить: `Esc`
|
||||
|
||||
### Режим просмотра изображения
|
||||
- Навигация: `←/→` (предыдущее/следующее изображение)
|
||||
- Закрыть: `Esc`
|
||||
|
||||
### Режим воспроизведения голосового
|
||||
- Пауза/Возобновить: `Space`
|
||||
- Остановить: `s` / `ы`
|
||||
- Перемотка: `←/→` (-5с / +5с)
|
||||
- Громкость: `↑/↓` (+/- 10%)
|
||||
|
||||
## Поддержка русской раскладки
|
||||
|
||||
Все основные vim-клавиши поддерживают русскую раскладку:
|
||||
|
||||
| Английская | Русская | Действие |
|
||||
|------------|---------|----------|
|
||||
| `h` | `р` | Влево |
|
||||
| `j` | `о` | Вниз |
|
||||
| `k` | `л` | Вверх |
|
||||
| `l` | `д` | Вправо |
|
||||
| `r` | `к` | Reply |
|
||||
| `f` | `а` | Forward |
|
||||
| `d` | `в` | Delete |
|
||||
| `y` | `н` | Copy (Yank) |
|
||||
| `e` | `у` | Emoji reaction |
|
||||
| `v` | `м` | View image |
|
||||
| `s` | `ы` | Stop audio |
|
||||
|
||||
## Подсказки
|
||||
|
||||
- Текущие доступные команды всегда отображаются в нижней части экрана (footer)
|
||||
- При открытой модалке доступны только действия этой модалки
|
||||
- `Esc` всегда отменяет текущее действие и возвращает на шаг назад
|
||||
- Блочный курсор █ показывает текущую позицию при редактировании текста
|
||||
122
INSTALL.md
Normal file
122
INSTALL.md
Normal file
@@ -0,0 +1,122 @@
|
||||
# Установка tele-tui
|
||||
|
||||
## Требования
|
||||
|
||||
- **Rust**: версия 1.70 или выше ([установить](https://rustup.rs/))
|
||||
- **TDLib**: скачивается автоматически через tdlib-rs
|
||||
|
||||
## Шаг 1: Клонирование репозитория
|
||||
|
||||
```bash
|
||||
git clone https://github.com/your-username/tele-tui.git
|
||||
cd tele-tui
|
||||
```
|
||||
|
||||
## Шаг 2: Получение API credentials
|
||||
|
||||
1. Перейдите на https://my.telegram.org/apps
|
||||
2. Войдите с вашим номером телефона
|
||||
3. Создайте новое приложение
|
||||
4. Скопируйте **api_id** и **api_hash**
|
||||
|
||||
## Шаг 3: Настройка credentials
|
||||
|
||||
### Вариант A: XDG config directory (рекомендуется)
|
||||
|
||||
Создайте файл `~/.config/tele-tui/credentials`:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.config/tele-tui
|
||||
cat > ~/.config/tele-tui/credentials << EOF
|
||||
API_ID=your_api_id_here
|
||||
API_HASH=your_api_hash_here
|
||||
EOF
|
||||
```
|
||||
|
||||
### Вариант B: .env файл
|
||||
|
||||
Создайте файл `.env` в корне проекта:
|
||||
|
||||
```bash
|
||||
cp credentials.example .env
|
||||
# Отредактируйте .env и вставьте ваши credentials
|
||||
```
|
||||
|
||||
## Шаг 4: Сборка
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
## Шаг 5: Запуск
|
||||
|
||||
```bash
|
||||
cargo run --release
|
||||
```
|
||||
|
||||
Или запустите скомпилированный бинарник:
|
||||
|
||||
```bash
|
||||
./target/release/tele-tui
|
||||
```
|
||||
|
||||
## Первый запуск
|
||||
|
||||
При первом запуске вам нужно будет:
|
||||
|
||||
1. Ввести номер телефона (с кодом страны, например: +79991234567)
|
||||
2. Ввести код подтверждения из Telegram
|
||||
3. Если включена 2FA — ввести пароль
|
||||
|
||||
Сессия сохраняется в `./tdlib_data/`, при следующем запуске авторизация не потребуется.
|
||||
|
||||
## Настройка (опционально)
|
||||
|
||||
Конфигурационный файл создаётся автоматически при первом запуске в `~/.config/tele-tui/config.toml`.
|
||||
|
||||
Вы можете отредактировать его для настройки:
|
||||
- Часового пояса
|
||||
- Цветовой схемы
|
||||
|
||||
Пример конфигурации см. в файле `config.toml.example`.
|
||||
|
||||
## Устранение неполадок
|
||||
|
||||
### "Telegram API credentials not found!"
|
||||
|
||||
Убедитесь, что вы создали файл credentials (см. Шаг 3).
|
||||
|
||||
### "error: linking with `cc` failed"
|
||||
|
||||
Убедитесь, что у вас установлен C компилятор:
|
||||
- macOS: `xcode-select --install`
|
||||
- Linux: `sudo apt-get install build-essential` (Debian/Ubuntu)
|
||||
- Windows: установите Visual Studio Build Tools
|
||||
|
||||
### TDLib download failed
|
||||
|
||||
Проверьте подключение к интернету. TDLib скачивается автоматически при первой сборке.
|
||||
|
||||
## Обновление
|
||||
|
||||
```bash
|
||||
git pull
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
Ваши credentials и конфигурация сохранятся.
|
||||
|
||||
## Удаление
|
||||
|
||||
Чтобы полностью удалить приложение и все данные:
|
||||
|
||||
```bash
|
||||
# Удалить проект
|
||||
rm -rf tele-tui/
|
||||
|
||||
# Удалить конфигурацию и credentials
|
||||
rm -rf ~/.config/tele-tui/
|
||||
|
||||
# Удалить сессию Telegram (опционально, потребуется новая авторизация)
|
||||
# rm -rf ./tdlib_data/
|
||||
```
|
||||
328
PROJECT_STRUCTURE.md
Normal file
328
PROJECT_STRUCTURE.md
Normal file
@@ -0,0 +1,328 @@
|
||||
# Структура проекта
|
||||
|
||||
## Архитектура (ASCII)
|
||||
|
||||
```
|
||||
┌─────────────┐
|
||||
│ main.rs │ Event loop (60 FPS)
|
||||
└──────┬──────┘
|
||||
│
|
||||
┌────────────┼────────────┐
|
||||
▼ ▼ ▼
|
||||
┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||
│ input/ │ │ app/ │ │ ui/ │
|
||||
│ handlers │ │ state │ │ render │
|
||||
└────┬─────┘ └────┬─────┘ └────┬─────┘
|
||||
│ │ │
|
||||
│ ┌──────┴──────┐ │
|
||||
│ │ methods/ │ │
|
||||
│ │ (5 traits) │ │
|
||||
│ └──────┬──────┘ │
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────────────────────────────┐
|
||||
│ tdlib/ │
|
||||
│ TdClientTrait → TdClient │
|
||||
│ messages/ | auth | chats │
|
||||
└──────────────┬──────────────────┘
|
||||
│
|
||||
┌─────▼─────┐
|
||||
│ TDLib C │
|
||||
│ library │
|
||||
└───────────┘
|
||||
```
|
||||
|
||||
### Data Flow
|
||||
```
|
||||
TDLib Updates → mpsc channel → App state → UI rendering
|
||||
User Input → handlers → App methods (traits) → TdClient → TDLib API
|
||||
```
|
||||
|
||||
## Обзор директорий
|
||||
|
||||
```
|
||||
tele-tui/
|
||||
├── src/
|
||||
│ ├── main.rs # Точка входа, event loop
|
||||
│ ├── lib.rs # Экспорт модулей для тестов
|
||||
│ ├── types.rs # ChatId, MessageId (newtype wrappers)
|
||||
│ ├── constants.rs # MAX_MESSAGES_IN_CHAT, etc.
|
||||
│ ├── formatting.rs # Markdown entity форматирование
|
||||
│ ├── message_grouping.rs # Группировка сообщений по дате/отправителю
|
||||
│ ├── notifications.rs # Desktop уведомления (NotificationManager)
|
||||
│ │
|
||||
│ ├── app/ # Состояние приложения
|
||||
│ │ ├── mod.rs # App<T> struct, конструкторы, getters (372 loc)
|
||||
│ │ ├── state.rs # AppScreen enum
|
||||
│ │ ├── chat_state.rs # ChatState enum (state machine)
|
||||
│ │ ├── chat_filter.rs # ChatFilter, ChatFilterCriteria
|
||||
│ │ ├── chat_list_state.rs # Состояние списка чатов
|
||||
│ │ ├── auth_state.rs # Состояние авторизации
|
||||
│ │ ├── compose_state.rs # Состояние compose bar
|
||||
│ │ ├── ui_state.rs # UI-related state
|
||||
│ │ ├── message_service.rs # Сервис сообщений
|
||||
│ │ ├── message_view_state.rs # Состояние просмотра сообщений
|
||||
│ │ └── methods/ # Trait-based методы App (Этап 2)
|
||||
│ │ ├── mod.rs # Re-exports 5 trait модулей
|
||||
│ │ ├── navigation.rs # NavigationMethods (7 методов)
|
||||
│ │ ├── messages.rs # MessageMethods (8 методов)
|
||||
│ │ ├── compose.rs # ComposeMethods (10 методов)
|
||||
│ │ ├── search.rs # SearchMethods (15 методов)
|
||||
│ │ └── modal.rs # ModalMethods (27 методов)
|
||||
│ │
|
||||
│ ├── config/ # Конфигурация (Этап 5)
|
||||
│ │ ├── mod.rs # Config struct, defaults (350 loc)
|
||||
│ │ ├── keybindings.rs # Command enum, Keybindings
|
||||
│ │ ├── validation.rs # validate(), parse_color()
|
||||
│ │ └── loader.rs # load(), save(), credentials
|
||||
│ │
|
||||
│ ├── input/ # Обработка пользовательского ввода
|
||||
│ │ ├── mod.rs # Роутинг по экранам
|
||||
│ │ ├── auth.rs # Ввод на экране авторизации
|
||||
│ │ ├── main_input.rs # Роутер главного экрана (159 loc, Этап 1)
|
||||
│ │ ├── key_handler.rs # Trait-based обработка клавиш
|
||||
│ │ └── handlers/ # Специализированные обработчики (Этап 1)
|
||||
│ │ ├── mod.rs # Exports + scroll_to_message()
|
||||
│ │ ├── global.rs # Ctrl+R/S/P/F глобальные команды
|
||||
│ │ ├── chat.rs # Открытый чат: ввод, скролл, selection
|
||||
│ │ ├── chat_list.rs # Навигация по списку чатов, папки
|
||||
│ │ ├── compose.rs # Forward mode
|
||||
│ │ ├── modal.rs # Profile, reactions, pinned, delete
|
||||
│ │ ├── search.rs # Поиск чатов и сообщений
|
||||
│ │ ├── clipboard.rs # Копирование в буфер обмена
|
||||
│ │ └── profile.rs # Хелперы профиля
|
||||
│ │
|
||||
│ ├── tdlib/ # TDLib интеграция
|
||||
│ │ ├── mod.rs # Экспорт публичных типов
|
||||
│ │ ├── types.rs # MessageInfo, ChatInfo, ProfileInfo, etc.
|
||||
│ │ ├── trait.rs # TdClientTrait (DI для тестов)
|
||||
│ │ ├── client.rs # TdClient struct, конструктор
|
||||
│ │ ├── client_impl.rs # impl TdClientTrait for TdClient
|
||||
│ │ ├── auth.rs # Авторизация (phone, code, 2FA)
|
||||
│ │ ├── chats.rs # Загрузка чатов, папок
|
||||
│ │ ├── users.rs # Кеш пользователей, статусы
|
||||
│ │ ├── reactions.rs # ReactionInfo, toggle_reaction
|
||||
│ │ ├── chat_helpers.rs # Вспомогательные функции чатов
|
||||
│ │ ├── update_handlers.rs # Обработка TDLib update events
|
||||
│ │ ├── message_converter.rs # Конвертация TDLib → MessageInfo
|
||||
│ │ ├── message_conversion.rs # Доп. функции конвертации
|
||||
│ │ └── messages/ # Менеджер сообщений (Этап 4)
|
||||
│ │ ├── mod.rs # MessageManager struct (99 loc)
|
||||
│ │ ├── convert.rs # convert_message, fetch_reply_info
|
||||
│ │ └── operations.rs # 11 TDLib API операций (616 loc)
|
||||
│ │
|
||||
│ ├── ui/ # Рендеринг интерфейса
|
||||
│ │ ├── mod.rs # render() — роутинг по экранам
|
||||
│ │ ├── loading.rs # Экран загрузки
|
||||
│ │ ├── auth.rs # Экран авторизации
|
||||
│ │ ├── main_screen.rs # Главный экран + папки
|
||||
│ │ ├── footer.rs # Футер с командами и статусом сети
|
||||
│ │ ├── chat_list.rs # Список чатов + онлайн-статус
|
||||
│ │ ├── messages.rs # Область сообщений (364 loc, Этап 3)
|
||||
│ │ ├── compose_bar.rs # Multi-mode input box (Этап 3)
|
||||
│ │ ├── profile.rs # Профиль пользователя/чата
|
||||
│ │ ├── modals/ # Модальные окна (Этап 3)
|
||||
│ │ │ ├── mod.rs # Re-exports
|
||||
│ │ │ ├── delete_confirm.rs # Подтверждение удаления
|
||||
│ │ │ ├── reaction_picker.rs # Выбор реакции
|
||||
│ │ │ ├── search.rs # Поиск по сообщениям
|
||||
│ │ │ └── pinned.rs # Закреплённые сообщения
|
||||
│ │ └── components/ # Переиспользуемые UI компоненты (Этап 6)
|
||||
│ │ ├── mod.rs # Re-exports
|
||||
│ │ ├── modal.rs # render_modal(), render_delete_confirm
|
||||
│ │ ├── input_field.rs # render_input_field()
|
||||
│ │ ├── message_bubble.rs # render_message_bubble(), sender, date
|
||||
│ │ ├── message_list.rs # render_message_item(), help_bar, scroll
|
||||
│ │ ├── chat_list_item.rs # render_chat_list_item()
|
||||
│ │ └── emoji_picker.rs # render_emoji_picker()
|
||||
│ │
|
||||
│ └── utils/ # Утилиты
|
||||
│ ├── mod.rs # Exports, with_timeout helpers
|
||||
│ ├── formatting.rs # format_timestamp, format_date, etc.
|
||||
│ ├── tdlib.rs # disable_tdlib_logs (FFI)
|
||||
│ ├── validation.rs # is_non_empty и др.
|
||||
│ ├── modal_handler.rs # handle_yes_no для Y/N модалок
|
||||
│ └── retry.rs # Retry утилиты
|
||||
│
|
||||
├── tests/ # Интеграционные тесты
|
||||
│ ├── helpers/ # Тестовая инфраструктура
|
||||
│ │ ├── mod.rs
|
||||
│ │ ├── app_builder.rs # TestAppBuilder (fluent API)
|
||||
│ │ ├── fake_tdclient.rs # FakeTdClient (мок TDLib)
|
||||
│ │ ├── fake_tdclient_impl.rs # impl TdClientTrait for FakeTdClient
|
||||
│ │ ├── test_data.rs # create_test_chat, TestMessageBuilder
|
||||
│ │ └── snapshot_utils.rs # Snapshot testing хелперы
|
||||
│ ├── input_navigation.rs # Тесты навигации клавиатурой
|
||||
│ ├── chat_list.rs # Тесты списка чатов
|
||||
│ ├── messages.rs # Тесты сообщений
|
||||
│ ├── send_message.rs # Тесты отправки
|
||||
│ ├── edit_message.rs # Тесты редактирования
|
||||
│ ├── delete_message.rs # Тесты удаления
|
||||
│ ├── reply_forward.rs # Тесты reply/forward
|
||||
│ ├── reactions.rs # Тесты реакций
|
||||
│ ├── search.rs # Тесты поиска
|
||||
│ ├── modals.rs # Тесты модальных окон
|
||||
│ ├── profile.rs # Тесты профиля
|
||||
│ ├── navigation.rs # Тесты навигации
|
||||
│ ├── drafts.rs # Тесты черновиков
|
||||
│ ├── copy.rs # Тесты копирования
|
||||
│ ├── screens.rs # Тесты экранов
|
||||
│ ├── footer.rs # Тесты футера
|
||||
│ ├── input_field.rs # Тесты поля ввода
|
||||
│ ├── config.rs # Тесты конфигурации
|
||||
│ ├── network_typing.rs # Тесты typing status
|
||||
│ ├── e2e_smoke.rs # Smoke тесты
|
||||
│ └── e2e_user_journey.rs # E2E user journey тесты
|
||||
│
|
||||
├── .github/ # GitHub конфигурация
|
||||
│ ├── ISSUE_TEMPLATE/
|
||||
│ ├── workflows/ci.yml
|
||||
│ └── pull_request_template.md
|
||||
│
|
||||
├── Cargo.toml # Манифест проекта
|
||||
├── Cargo.lock # Точные версии зависимостей
|
||||
├── build.rs # Build script (TDLib)
|
||||
├── rustfmt.toml # cargo fmt конфигурация
|
||||
├── .editorconfig # Настройки IDE
|
||||
├── .gitignore # Git ignore
|
||||
│
|
||||
├── config.toml.example # Пример конфигурации
|
||||
├── credentials.example # Пример credentials
|
||||
│
|
||||
├── CLAUDE.md # Инструкции для AI
|
||||
├── CONTEXT.md # Текущий статус
|
||||
├── ROADMAP.md # План развития
|
||||
├── DEVELOPMENT.md # Правила разработки
|
||||
├── REQUIREMENTS.md # Требования
|
||||
├── ARCHITECTURE.md # C4, sequence diagrams
|
||||
├── PROJECT_STRUCTURE.md # Этот файл
|
||||
├── E2E_TESTING.md # Гайд по тестированию
|
||||
├── HOTKEYS.md # Горячие клавиши
|
||||
├── CHANGELOG.md # История изменений
|
||||
├── README.md # Главная документация
|
||||
├── INSTALL.md # Установка
|
||||
├── FAQ.md # FAQ
|
||||
├── CONTRIBUTING.md # Гайд по контрибуции
|
||||
├── SECURITY.md # Безопасность
|
||||
└── LICENSE # MIT лицензия
|
||||
```
|
||||
|
||||
## Ключевые модули
|
||||
|
||||
### app/ — Состояние приложения
|
||||
|
||||
`App<T: TdClientTrait>` — главная структура, параметризована trait'ом для DI.
|
||||
|
||||
**State machine** (`ChatState` enum):
|
||||
```
|
||||
Normal → MessageSelection → Editing
|
||||
→ Reply
|
||||
→ Forward
|
||||
→ DeleteConfirmation
|
||||
→ ReactionPicker
|
||||
→ Profile
|
||||
→ SearchInChat
|
||||
→ PinnedMessages
|
||||
```
|
||||
|
||||
**Trait-based methods** (5 traits на `App<T>`):
|
||||
| Trait | Методы | Описание |
|
||||
|-------|--------|----------|
|
||||
| NavigationMethods | 7 | next/previous_chat, close_chat, select_current_chat |
|
||||
| MessageMethods | 8 | is_editing, is_replying, get_selected_message, etc. |
|
||||
| ComposeMethods | 10 | start_reply, cancel_editing, load_draft, etc. |
|
||||
| SearchMethods | 15 | start_search, enter_message_search_mode, etc. |
|
||||
| ModalMethods | 27 | enter_profile_mode, exit_pinned_mode, etc. |
|
||||
|
||||
### input/ — Обработка ввода
|
||||
|
||||
**Маршрутизация** (порядок приоритетов в `main_input.rs`):
|
||||
1. Global commands (Ctrl+R/S/P/F)
|
||||
2. Profile mode
|
||||
3. Message search mode
|
||||
4. Pinned messages mode
|
||||
5. Reaction picker mode
|
||||
6. Delete confirmation
|
||||
7. Forward mode
|
||||
8. Chat search mode
|
||||
9. Enter/Esc commands
|
||||
10. Open chat input / Chat list navigation
|
||||
|
||||
### tdlib/ — Telegram интеграция
|
||||
|
||||
**Dependency Injection**: `TdClientTrait` позволяет подменять TdClient на `FakeTdClient` в тестах.
|
||||
|
||||
**MessageManager** — управление сообщениями:
|
||||
- `convert.rs` — конвертация TDLib JSON → MessageInfo
|
||||
- `operations.rs` — 11 API операций (get_history, send, edit, delete, forward, search, etc.)
|
||||
|
||||
### ui/ — Рендеринг
|
||||
|
||||
**Компоненты** (`ui/components/`):
|
||||
| Компонент | Описание |
|
||||
|-----------|----------|
|
||||
| message_bubble | Рендеринг пузыря сообщения с реакциями |
|
||||
| message_list | Элемент списка сообщений (search/pinned) |
|
||||
| chat_list_item | Элемент списка чатов |
|
||||
| input_field | Поле ввода с курсором |
|
||||
| emoji_picker | Сетка выбора реакций |
|
||||
| modal | Центрированная модалка |
|
||||
|
||||
### config/ — Конфигурация
|
||||
|
||||
- **mod.rs** — struct Config, GeneralConfig, ColorsConfig, NotificationsConfig
|
||||
- **keybindings.rs** — Command enum (30+ команд), кастомные горячие клавиши
|
||||
- **validation.rs** — валидация timezone, цветов
|
||||
- **loader.rs** — загрузка из `~/.config/tele-tui/config.toml`, credentials
|
||||
|
||||
## Тестирование
|
||||
|
||||
**500+ тестов** через `cargo test` (без TDLib).
|
||||
|
||||
**Инфраструктура**:
|
||||
- `TestAppBuilder` — fluent API для создания App с нужным состоянием
|
||||
- `FakeTdClient` — мок TDLib, реализует TdClientTrait
|
||||
- `TestMessageBuilder` — создание тестовых сообщений
|
||||
|
||||
**Типы тестов**:
|
||||
- Unit-тесты — в `#[cfg(test)]` секциях модулей
|
||||
- Integration-тесты — в `tests/` (навигация, отправка, UI рендеринг)
|
||||
- Doc-тесты — примеры в документации
|
||||
- E2E — smoke и user journey тесты
|
||||
|
||||
## Потоки выполнения
|
||||
|
||||
```
|
||||
Main thread TDLib thread
|
||||
│ │
|
||||
│ ◄── mpsc ─────── │ td_client.receive() в Tokio task
|
||||
│ │
|
||||
├── poll events │
|
||||
├── handle input │
|
||||
├── update state │
|
||||
├── render UI │
|
||||
└── sleep 16ms ──► │
|
||||
```
|
||||
|
||||
## Рантайм файлы
|
||||
|
||||
| Путь | Описание |
|
||||
|------|----------|
|
||||
| `~/.config/tele-tui/config.toml` | Пользовательская конфигурация |
|
||||
| `~/.config/tele-tui/credentials` | API_ID и API_HASH |
|
||||
| `tdlib_data/` | TDLib сессия (НЕ коммитится) |
|
||||
|
||||
## Зависимости
|
||||
|
||||
| Категория | Крейт | Назначение |
|
||||
|-----------|-------|------------|
|
||||
| UI | ratatui 0.29 | TUI framework |
|
||||
| UI | crossterm 0.28 | Terminal control |
|
||||
| Telegram | tdlib-rs 1.1 | TDLib bindings |
|
||||
| Async | tokio 1.x | Async runtime |
|
||||
| Config | serde + toml | Serialization |
|
||||
| Time | chrono 0.4 | Date/time |
|
||||
| System | dirs 5.0 | XDG directories |
|
||||
| System | arboard 3.4 | Clipboard |
|
||||
| Notify | notify-rust 4.11 | Desktop уведомления (feature) |
|
||||
| URL | open 5.0 | Открытие URL (feature) |
|
||||
263
README.md
263
README.md
@@ -1,74 +1,261 @@
|
||||
# tele-tui
|
||||
|
||||
Консольный Telegram-клиент на Rust с Vim-style навигацией, Normal/Insert режимами, TDLib и поддержкой русской раскладки.
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.rust-lang.org/)
|
||||
|
||||
Консольный Telegram клиент с Vim-style навигацией.
|
||||
|
||||

|
||||
|
||||
## Возможности
|
||||
|
||||
- Авторизация через 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.
|
||||
- **Полная интеграция с Telegram**: отправка/получение сообщений, редактирование, удаление, пересылка
|
||||
- **Vim-style навигация**: hjkl + поддержка русской раскладки (ролд)
|
||||
- **Markdown форматирование**: жирный, курсив, подчёркивание, зачёркивание, код, спойлеры, ссылки
|
||||
- **Реакции на сообщения**: emoji picker с навигацией стрелками
|
||||
- **Папки Telegram**: переключение между папками (1-9)
|
||||
- **Поиск**: по чатам (Ctrl+S) и внутри чата (Ctrl+F)
|
||||
- **Черновики**: автосохранение набранного текста при переключении чатов
|
||||
- **Typing indicator**: показывает когда собеседник печатает
|
||||
- **Закреплённые сообщения**: отображение и переход к закреплённому сообщению
|
||||
- **Копирование в буфер**: copy сообщений в системный буфер обмена
|
||||
- **Профиль**: просмотр информации о пользователе/чате
|
||||
- **Конфигурация**: настройка цветов и часового пояса через TOML
|
||||
- **Оптимизация**: 60 FPS, умное кеширование, graceful shutdown
|
||||
|
||||
## Установка
|
||||
|
||||
Требования:
|
||||
### Требования
|
||||
|
||||
- Rust stable, рекомендовано 1.85+.
|
||||
- TDLib скачивается автоматически через `tdlib-rs` feature `download-tdlib`.
|
||||
- Для голосовых сообщений нужен `ffplay` из ffmpeg.
|
||||
- Rust 1.70+
|
||||
- TDLib (скачивается автоматически через tdlib-rs)
|
||||
|
||||
### Сборка
|
||||
|
||||
```bash
|
||||
cargo build -p tele-tui --release
|
||||
git clone https://github.com/your-username/tele-tui.git
|
||||
cd tele-tui
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
## Credentials
|
||||
### API Credentials
|
||||
|
||||
Получите Telegram API credentials на <https://my.telegram.org/apps> и сохраните их в:
|
||||
Получите API credentials на https://my.telegram.org/apps
|
||||
|
||||
```text
|
||||
~/.config/tele-tui/credentials
|
||||
Создайте файл `~/.config/tele-tui/credentials`:
|
||||
```
|
||||
|
||||
Формат:
|
||||
|
||||
```text
|
||||
API_ID=your_api_id
|
||||
API_HASH=your_api_hash
|
||||
```
|
||||
|
||||
Для локальной разработки можно использовать `.env` в корне проекта с теми же ключами.
|
||||
Или используйте `.env` файл в директории проекта:
|
||||
```
|
||||
API_ID=your_api_id
|
||||
API_HASH=your_api_hash
|
||||
```
|
||||
|
||||
## Запуск
|
||||
## Использование
|
||||
|
||||
```bash
|
||||
cargo run --release
|
||||
```
|
||||
|
||||
Для выбора аккаунта:
|
||||
При первом запуске нужно пройти авторизацию (телефон + код + опционально 2FA пароль).
|
||||
|
||||
```bash
|
||||
cargo run --release -- --account work
|
||||
## Конфигурация
|
||||
|
||||
Конфигурационный файл создаётся автоматически в `~/.config/tele-tui/config.toml`:
|
||||
|
||||
```toml
|
||||
[general]
|
||||
# Часовой пояс в формате "+03:00" или "-05:00"
|
||||
timezone = "+03:00"
|
||||
|
||||
[colors]
|
||||
# Поддерживаемые цвета: black, red, green, yellow, blue, magenta, cyan, gray, white,
|
||||
# darkgray, lightred, lightgreen, lightyellow, lightblue, lightmagenta, lightcyan
|
||||
incoming_message = "white"
|
||||
outgoing_message = "green"
|
||||
selected_message = "yellow"
|
||||
reaction_chosen = "yellow"
|
||||
reaction_other = "gray"
|
||||
```
|
||||
|
||||
`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).
|
||||
### Навигация
|
||||
- `↑/↓` или `k/j` (р/о) — навигация по списку чатов
|
||||
- `Enter` — открыть чат / отправить сообщение
|
||||
- `Esc` — закрыть чат / отменить действие
|
||||
- `1-9` — переключение между папками
|
||||
- `Ctrl+S` — поиск по чатам
|
||||
- `Ctrl+R` — обновить список чатов
|
||||
- `Ctrl+C` — выход
|
||||
|
||||
### В открытом чате
|
||||
- `↑/↓` — скролл сообщений
|
||||
- `Ctrl+F` — поиск в чате
|
||||
- `n/N` — следующий/предыдущий результат поиска
|
||||
- `i` — информация о чате/пользователе
|
||||
|
||||
### Работа с сообщениями
|
||||
- `↑` при пустом инпуте — выбор сообщения
|
||||
- `Enter` в режиме выбора — редактировать
|
||||
- `r` / `к` — ответить (reply)
|
||||
- `f` / `а` — переслать (forward)
|
||||
- `d` / `в` / `Delete` — удалить
|
||||
- `y` / `н` — скопировать в буфер
|
||||
- `e` / `у` — добавить реакцию
|
||||
|
||||
### Emoji Picker (реакции)
|
||||
- `←/→/↑/↓` — навигация по сетке
|
||||
- `Enter` — добавить/удалить реакцию
|
||||
- `Esc` — закрыть picker
|
||||
|
||||
### Редактирование текста
|
||||
- `←/→` — перемещение курсора
|
||||
- `Home` — в начало строки
|
||||
- `End` — в конец строки
|
||||
- `Backspace` — удалить символ слева
|
||||
- `Delete` — удалить символ справа
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
src/
|
||||
├── main.rs # Точка входа, event loop
|
||||
├── config.rs # Конфигурация (TOML), credentials
|
||||
├── app/ # Состояние приложения
|
||||
├── ui/ # Отрисовка интерфейса
|
||||
├── input/ # Обработка ввода
|
||||
├── utils.rs # Утилиты (форматирование времени, логи)
|
||||
└── tdlib/ # TDLib интеграция
|
||||
```
|
||||
|
||||
## Зависимости
|
||||
|
||||
- `ratatui` 0.29 — TUI framework
|
||||
- `crossterm` 0.28 — terminal handling
|
||||
- `tdlib-rs` 1.1 — Telegram API
|
||||
- `tokio` 1.x — async runtime
|
||||
- `serde` + `serde_json` — serialization
|
||||
- `toml` 0.8 — config parsing
|
||||
- `dirs` 5.0 — XDG directories
|
||||
- `clipboard` 0.5 — clipboard access
|
||||
- `chrono` 0.4 — date/time formatting
|
||||
|
||||
## Тестирование
|
||||
|
||||
tele-tui использует **snapshot тестирование** для UI и интеграционные тесты для логики.
|
||||
|
||||
### Запуск всех тестов
|
||||
|
||||
```bash
|
||||
cargo test
|
||||
```
|
||||
|
||||
### Snapshot тесты
|
||||
|
||||
Snapshot тесты проверяют отображение UI компонентов через виртуальный терминал:
|
||||
|
||||
```bash
|
||||
# Прогнать snapshot тесты
|
||||
cargo test --test chat_list
|
||||
cargo test --test messages
|
||||
|
||||
# Посмотреть изменения в snapshots
|
||||
cargo insta review
|
||||
|
||||
# Принять все новые snapshots
|
||||
cargo insta accept
|
||||
|
||||
# Отклонить все изменения
|
||||
cargo insta reject
|
||||
```
|
||||
|
||||
### Установка cargo-insta
|
||||
|
||||
Для работы со snapshot тестами нужен `cargo-insta`:
|
||||
|
||||
```bash
|
||||
cargo install cargo-insta
|
||||
```
|
||||
|
||||
### Структура тестов
|
||||
|
||||
```
|
||||
tests/
|
||||
├── helpers/ # Тестовые утилиты
|
||||
│ ├── app_builder.rs # TestAppBuilder для создания тестовых App
|
||||
│ ├── test_data.rs # Builders для чатов и сообщений
|
||||
│ ├── snapshot_utils.rs # Утилиты для snapshot тестов
|
||||
│ └── fake_tdclient.rs # Mock TDLib клиент (для будущих integration тестов)
|
||||
├── chat_list.rs # Snapshot тесты для списка чатов (9 тестов)
|
||||
├── messages.rs # Snapshot тесты для сообщений (18 тестов)
|
||||
├── modals.rs # Snapshot тесты для модалок (8 тестов)
|
||||
└── input_field.rs # Snapshot тесты для поля ввода (7 тестов)
|
||||
```
|
||||
|
||||
### Создание snapshot теста
|
||||
|
||||
```rust
|
||||
use helpers::test_data::TestChatBuilder;
|
||||
use helpers::app_builder::TestAppBuilder;
|
||||
use helpers::snapshot_utils::{render_to_buffer, buffer_to_string};
|
||||
use insta::assert_snapshot;
|
||||
|
||||
#[test]
|
||||
fn snapshot_my_feature() {
|
||||
let chat = TestChatBuilder::new("Test Chat", 123)
|
||||
.unread_count(5)
|
||||
.build();
|
||||
|
||||
let mut app = TestAppBuilder::new()
|
||||
.with_chat(chat)
|
||||
.selected_chat(123)
|
||||
.build();
|
||||
|
||||
let buffer = render_to_buffer(80, 24, |f| {
|
||||
tele_tui::ui::chat_list::render(f, f.area(), &mut app);
|
||||
});
|
||||
|
||||
let output = buffer_to_string(&buffer);
|
||||
assert_snapshot!("my_feature", output);
|
||||
}
|
||||
```
|
||||
|
||||
### Покрытие тестами
|
||||
|
||||
**Текущий прогресс**: 81/151 тестов (54%)
|
||||
|
||||
- ✅ Фаза 0: Инфраструктура (100%)
|
||||
- ✅ Фаза 1: UI Snapshot Tests (100%)
|
||||
- Chat List, Messages, Modals, Input Field, Footer, Screens
|
||||
- 🔄 Фаза 2: Integration Tests (35%)
|
||||
- ✅ Send Message Flow (6 тестов)
|
||||
- ✅ Edit Message Flow (6 тестов)
|
||||
- ✅ Delete Message Flow (6 тестов)
|
||||
- ✅ Reply & Forward Flow (8 тестов)
|
||||
- 📋 Reactions, Search, Drafts, Navigation, Profile, Network (0/48)
|
||||
|
||||
Подробный план: [TESTING_ROADMAP.md](TESTING_ROADMAP.md)
|
||||
|
||||
## Документация
|
||||
|
||||
- [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-клиента.
|
||||
- [INSTALL.md](INSTALL.md) — подробная инструкция по установке
|
||||
- [HOTKEYS.md](HOTKEYS.md) — все горячие клавиши
|
||||
- [FAQ.md](FAQ.md) — часто задаваемые вопросы
|
||||
- [CONTRIBUTING.md](CONTRIBUTING.md) — как внести вклад
|
||||
- [PROJECT_STRUCTURE.md](PROJECT_STRUCTURE.md) — структура проекта
|
||||
- [SECURITY.md](SECURITY.md) — политика безопасности
|
||||
- [CHANGELOG.md](CHANGELOG.md) — история изменений
|
||||
- [REQUIREMENTS.md](REQUIREMENTS.md) — функциональные требования
|
||||
- [DEVELOPMENT.md](DEVELOPMENT.md) — правила разработки
|
||||
- [ROADMAP.md](ROADMAP.md) — план развития проекта
|
||||
- [REFACTORING_ROADMAP.md](REFACTORING_ROADMAP.md) — план рефакторинга кода
|
||||
- [TESTING_ROADMAP.md](TESTING_ROADMAP.md) — план покрытия тестами
|
||||
- [TESTING_PROGRESS.md](TESTING_PROGRESS.md) — прогресс тестирования
|
||||
- [CONTEXT.md](CONTEXT.md) — текущий статус разработки
|
||||
|
||||
## Лицензия
|
||||
|
||||
|
||||
855
REFACTORING_OPPORTUNITIES.md
Normal file
855
REFACTORING_OPPORTUNITIES.md
Normal file
@@ -0,0 +1,855 @@
|
||||
# Возможности для рефакторинга
|
||||
|
||||
> Результаты аудита кодовой базы от 2026-02-02
|
||||
> Обновлено: 2026-02-04
|
||||
> Статус: В работе (2/10 категорий полностью завершены, 3 частично)
|
||||
|
||||
## Оглавление
|
||||
|
||||
1. [Дублирование кода](#1-дублирование-кода)
|
||||
2. [Большие файлы/функции](#2-большие-файлыфункции)
|
||||
3. [Сложная вложенность](#3-сложная-вложенность)
|
||||
4. [Нарушение Single Responsibility](#4-нарушение-single-responsibility)
|
||||
5. [Плохая инкапсуляция](#5-плохая-инкапсуляция)
|
||||
6. [Отсутствующие абстракции](#6-отсутствующие-абстракции)
|
||||
7. [Несогласованность](#7-несогласованность)
|
||||
8. [Перекрытие функциональности](#8-перекрытие-функциональности)
|
||||
9. [Проблемы производительности](#9-проблемы-производительности)
|
||||
10. [Отсутствующие архитектурные паттерны](#10-отсутствующие-архитектурные-паттерны)
|
||||
|
||||
---
|
||||
|
||||
## 1. Дублирование кода
|
||||
|
||||
**Приоритет:** 🔴 Высокий
|
||||
**Статус:** ✅ ПОЛНОСТЬЮ ЗАВЕРШЕНО! (2026-02-02)
|
||||
**Объем:** 15-20% кодовой базы → Устранено!
|
||||
|
||||
### Проблемы
|
||||
|
||||
- **Timeout/Retry паттерны** (~20 экземпляров в обработке ввода)
|
||||
- Повторяющаяся логика таймаутов в `src/input/main_input.rs`
|
||||
- Одинаковые паттерны retry в разных обработчиках
|
||||
|
||||
- **Обработка модальных окон** (5+ мест)
|
||||
- Логика открытия/закрытия модалок дублируется
|
||||
- Валидация ввода в модальных окнах повторяется
|
||||
- Обработка Escape для закрытия модалок в каждом месте
|
||||
|
||||
- **Паттерны валидации**
|
||||
- Проверка пустых строк
|
||||
- Валидация ID чатов/сообщений
|
||||
- Проверка длины текста
|
||||
|
||||
### Решение
|
||||
|
||||
- [x] Создать `retry_utils.rs` с функциями `with_timeout()`, `with_retry()` - **Выполнено и интегрировано** (2026-02-02)
|
||||
- Создан `src/utils/retry.rs` с тремя функциями: `with_timeout()`, `with_timeout_msg()`, `with_timeout_ignore()`
|
||||
- Заменены ВСЕ прямые использования `tokio::time::timeout` (8+ мест: main_input.rs, auth.rs, main.rs)
|
||||
- Код стал чище и короче (убрано вложенное Ok/Err матчинг)
|
||||
- **100% покрытие** - больше нет прямых timeout вызовов
|
||||
- [x] Создать `modal_handler.rs` с общей логикой модальных окон - **Выполнено** (2026-02-01)
|
||||
- Создан `src/utils/modal_handler.rs` (120+ строк)
|
||||
- 4 функции: `handle_modal_key()`, `should_close_modal()`, `should_confirm_modal()`, `handle_yes_no()`
|
||||
- Enum `ModalAction` для type-safe обработки
|
||||
- Поддержка английской и русской раскладки (y/д, n/т)
|
||||
- 4 unit теста (все проходят)
|
||||
- [x] Создать `validation.rs` с переиспользуемыми валидаторами - **Выполнено и интегрировано** (2026-02-02)
|
||||
- Создан `src/utils/validation.rs` (180+ строк)
|
||||
- 7 функций валидации: `is_non_empty()`, `is_within_length()`, `is_valid_chat_id()`, `is_valid_message_id()`, `is_valid_user_id()`, `has_items()`, `validate_text_input()`
|
||||
- Покрывает все основные паттерны валидации
|
||||
- 7 unit тестов (все проходят)
|
||||
- **Интегрировано в 4 местах:** auth.rs (phone/code/password), main_input.rs (message validation)
|
||||
|
||||
### Файлы
|
||||
|
||||
- `src/input/main_input.rs`
|
||||
- `src/app/handlers/*.rs`
|
||||
- `src/ui/modals/*.rs`
|
||||
|
||||
---
|
||||
|
||||
## 2. Большие файлы/функции
|
||||
|
||||
**Приоритет:** 🔴 Высокий
|
||||
**Статус:** ✅ **ПОЛНОСТЬЮ ЗАВЕРШЕНО!** (обновлено 2026-02-04)
|
||||
**Объем:** Все 4 файла отрефакторены! (4/4, 100%! 🎉)
|
||||
|
||||
### Проблемы
|
||||
|
||||
| Файл | Строки | Проблема | Статус |
|
||||
|------|--------|----------|--------|
|
||||
| `src/input/main_input.rs` | ~~1164~~ → ~1200 | ~~Одна функция `handle()` на ~800 строк~~ | ✅ **РЕШЕНО** (handle() → 82 строки) |
|
||||
| `src/tdlib/client.rs` | ~~1259~~ → 599 | ~~Смешение facade и бизнес-логики~~ | ✅ **РЕШЕНО** (1259 → 599 строк, -52%) |
|
||||
| `src/ui/messages.rs` | 905 | ~~Рендеринг всех типов сообщений~~ | ✅ **НЕ ТРЕБУЕТСЯ** (render() → 92 строки, Phase 5) |
|
||||
| `src/tdlib/messages.rs` | ~~850~~ → 757 | ~~Обработка всех типов обновлений сообщений~~ | ✅ **РЕШЕНО** (convert_message() → 57 строк, -62%) |
|
||||
|
||||
### Решение
|
||||
|
||||
#### 2.1. Разделить `src/input/main_input.rs` - ✅ **ЗАВЕРШЕНО** (2026-02-03)
|
||||
|
||||
**Phase 1-2** (2026-02-02):
|
||||
- [x] Создана структура `src/input/handlers/` (7 модулей) - ПОДГОТОВКА
|
||||
- [x] Создан `handlers/clipboard.rs` (~100 строк) - извлечён из main_input
|
||||
- [x] Создан `handlers/global.rs` (~90 строк) - извлечён из main_input
|
||||
- [x] Созданы заглушки: `profile.rs`, `search.rs`, `modal.rs`, `messages.rs`, `chat_list.rs`
|
||||
|
||||
**Phase 2-3** (2026-02-03):
|
||||
- [x] **Извлечено 13 специализированных функций-обработчиков** (~946 строк):
|
||||
- `handle_open_chat_keyboard_input()` (~129 строк)
|
||||
- `handle_chat_list_navigation()` (~34 строки)
|
||||
- `handle_profile_mode()` (~120 строк)
|
||||
- `handle_message_search_mode()` (~73 строки)
|
||||
- `handle_pinned_mode()` (~42 строки)
|
||||
- `handle_reaction_picker_mode()` (~90 строк)
|
||||
- `handle_delete_confirmation()` (~60 строк)
|
||||
- `handle_forward_mode()` (~52 строки)
|
||||
- `handle_chat_search_mode()` (~43 строки)
|
||||
- `handle_enter_key()` (~145 строк)
|
||||
- `handle_escape_key()` (~35 строк)
|
||||
- `handle_message_selection()` (~95 строк)
|
||||
- `handle_profile_open()` (~28 строк)
|
||||
|
||||
**Phase 4** (2026-02-03):
|
||||
- [x] **Упрощена вложенность** (early returns, let-else guards)
|
||||
- [x] **Извлечено 3 вспомогательных функции**:
|
||||
- `edit_message()` (~50 строк)
|
||||
- `send_new_message()` (~55 строк)
|
||||
- `perform_message_search()` (~20 строк)
|
||||
|
||||
**Итоговый результат**:
|
||||
- ✅ Функция `handle()` сократилась с **891 до 82 строк** (91% сокращение! 🎉)
|
||||
- ✅ Глубина вложенности: **6+ уровней → 2-3 уровня**
|
||||
- ✅ Все 196 тестов проходят успешно
|
||||
- ✅ Код стал **линейным и простым для понимания**
|
||||
|
||||
**Примечание**: Вместо создания отдельных файлов в handlers/ (что привело бы к поломке), мы выбрали подход извлечения функций внутри main_input.rs. Это позволило радикально упростить код без риска регрессий.
|
||||
|
||||
#### 2.2. Разделить `src/tdlib/client.rs` - ✅ **ЗАВЕРШЕНО** (2026-02-04)
|
||||
|
||||
**Этап 1** (2026-02-04): Извлечение Update Handlers
|
||||
- [x] Создан модуль `src/tdlib/update_handlers.rs` (302 строки)
|
||||
- [x] **Извлечено 8 handler функций** (~350 строк):
|
||||
- `handle_new_message_update()` — добавление новых сообщений (44 строки)
|
||||
- `handle_chat_action_update()` — статус набора текста (32 строки)
|
||||
- `handle_chat_position_update()` — управление позициями чатов (36 строк)
|
||||
- `handle_user_update()` — обработка информации о пользователях (40 строк)
|
||||
- `handle_message_interaction_info_update()` — обновление реакций (44 строки)
|
||||
- `handle_message_send_succeeded_update()` — успешная отправка (35 строк)
|
||||
- `handle_chat_draft_message_update()` — черновики сообщений (15 строк)
|
||||
- `handle_auth_state()` — изменение состояния авторизации (10 строк)
|
||||
- [x] Обновлён `handle_update()` для делегирования в update_handlers
|
||||
- [x] Результат: **client.rs 1259 → 983 строки** (22% сокращение)
|
||||
|
||||
**Этап 2** (2026-02-04): Извлечение Message Converter
|
||||
- [x] Создан модуль `src/tdlib/message_converter.rs` (250 строк)
|
||||
- [x] **Извлечено 6 conversion функций** (~240 строк):
|
||||
- `convert_message()` — основная конвертация TDLib → MessageInfo (150+ строк)
|
||||
- `extract_reply_info()` — извлечение reply информации (30 строк)
|
||||
- `extract_forward_info()` — извлечение forward информации (25 строк)
|
||||
- `extract_reactions()` — извлечение реакций (20 строк)
|
||||
- `get_origin_sender_name()` — получение имени отправителя (15 строк)
|
||||
- `update_reply_info_from_loaded_messages()` — обновление reply из кэша (30 строк)
|
||||
- [x] Исправлены ошибки компиляции с неверными именами полей
|
||||
- [x] Обновлены вызовы в update_handlers.rs
|
||||
- [x] Результат: **client.rs 983 → 754 строки** (23% сокращение)
|
||||
|
||||
**Этап 3** (2026-02-04): Извлечение Chat Helpers
|
||||
- [x] Создан модуль `src/tdlib/chat_helpers.rs` (149 строк)
|
||||
- [x] **Извлечено 3 helper функции** (~140 строк):
|
||||
- `find_chat_mut()` — поиск чата по ID (15 строк)
|
||||
- `update_chat()` — обновление чата через closure (15 строк, используется 9+ раз)
|
||||
- `add_or_update_chat()` — добавление/обновление чата в списке (110+ строк)
|
||||
- [x] Использован sed для замены вызовов методов по всей кодовой базе
|
||||
- [x] Результат: **client.rs 754 → 599 строк** (21% сокращение)
|
||||
|
||||
**Итоговый результат**:
|
||||
- ✅ Файл `client.rs` сократился с **1259 до 599 строк** (52% сокращение! 🎉)
|
||||
- ✅ Создано **3 новых модуля** с чёткой ответственностью:
|
||||
- `update_handlers.rs` — обработка всех типов TDLib Update
|
||||
- `message_converter.rs` — конвертация TDLib Message → MessageInfo
|
||||
- `chat_helpers.rs` — утилиты для работы с чатами
|
||||
- ✅ Все **590+ тестов** проходят успешно
|
||||
- ✅ Код стал **модульным и лучше организованным**
|
||||
- ✅ `TdClient` теперь ближе к **facade pattern** (делегирует в специализированные модули)
|
||||
|
||||
#### 2.3. Упростить `src/ui/messages.rs` - ✅ **ЗАВЕРШЕНО** (Phase 5, 2026-02-03)
|
||||
|
||||
**Уже выполнено в Phase 5**:
|
||||
- [x] Извлечены 4 функции рендеринга (~350 строк):
|
||||
- `render_chat_header()` — заголовок с typing status (~47 строк)
|
||||
- `render_pinned_bar()` — панель закреплённого сообщения (~30 строк)
|
||||
- `render_message_list()` — список сообщений с автоскроллом (~98 строк)
|
||||
- `render_input_box()` — input с режимами (forward/select/edit/reply) (~146 строк)
|
||||
- [x] Функция `render()` сократилась с **390 до 92 строк** (76% сокращение! 🎉)
|
||||
- [x] Глубина вложенности: **6+ уровней → 2-3 уровня**
|
||||
- [x] Код стал **модульным и простым для понимания**
|
||||
|
||||
**Итоговый результат**:
|
||||
- ✅ Файл остался цельным (905 строк), но хорошо организован
|
||||
- ✅ Главная функция `render()` компактная (92 строки)
|
||||
- ✅ Все вспомогательные функции извлечены (render_search_mode, render_pinned_mode, и др.)
|
||||
- ✅ **Дальнейшее разделение не требуется** — цели достигнуты
|
||||
|
||||
#### 2.4. Упростить `src/tdlib/messages.rs` - ✅ **ЗАВЕРШЕНО** (2026-02-04)
|
||||
|
||||
**Этап 1** (2026-02-04): Извлечение Message Conversion Helpers
|
||||
- [x] Создан модуль `src/tdlib/message_conversion.rs` (158 строк)
|
||||
- [x] **Извлечено 6 вспомогательных функций**:
|
||||
- `extract_content_text()` — извлечение текста из различных типов сообщений (~80 строк)
|
||||
- `extract_entities()` — извлечение форматирования (~10 строк)
|
||||
- `extract_sender_name()` — получение имени отправителя с API вызовом (~15 строк)
|
||||
- `extract_forward_info()` — информация о пересылке (~12 строк)
|
||||
- `extract_reply_info()` — информация об ответе (~15 строк)
|
||||
- `extract_reactions()` — реакции на сообщение (~26 строк)
|
||||
- [x] Метод `convert_message()` сократился с **150 до 57 строк** (62% сокращение! 🎉)
|
||||
- [x] Результат: **messages.rs 850 → 757 строк** (11% сокращение)
|
||||
|
||||
**Итоговый результат**:
|
||||
- ✅ Файл `messages.rs` сократился до **757 строк**
|
||||
- ✅ Создан модуль **message_conversion.rs** с переиспользуемыми функциями
|
||||
- ✅ Метод `convert_message()` теперь **компактный и читаемый** (57 строк)
|
||||
- ✅ Все **629 тестов** проходят успешно
|
||||
- ✅ **Дальнейшее разделение не требуется** — MessageManager хорошо организован
|
||||
|
||||
### Файлы
|
||||
|
||||
- `src/input/main_input.rs`
|
||||
- `src/tdlib/client.rs`
|
||||
- `src/ui/messages.rs`
|
||||
- `src/tdlib/messages.rs`
|
||||
|
||||
---
|
||||
|
||||
## 3. Сложная вложенность
|
||||
|
||||
**Приоритет:** 🟡 Средний
|
||||
**Статус:** ✅ **ПОЛНОСТЬЮ ЗАВЕРШЕНО!** (обновлено 2026-02-04)
|
||||
**Объем:** ~30 функций → 0 функций (все проблемные решены)
|
||||
|
||||
### Проблемы
|
||||
|
||||
- ~~4-5 уровней вложенности в обработке ввода~~ ✅ **Решено в main_input.rs**
|
||||
- Глубокая вложенность в обработке обновлений TDLib
|
||||
- ~~Множественные `if let` / `match` вложенные друг в друга~~ ✅ **Решено в main_input.rs**
|
||||
|
||||
### Примеры
|
||||
|
||||
```rust
|
||||
// src/input/main_input.rs - было (типичный пример)
|
||||
if let Some(chat_id) = app.selected_chat {
|
||||
if let Some(message_id) = app.selected_message {
|
||||
if app.is_message_outgoing(chat_id, message_id) {
|
||||
match key.code {
|
||||
// еще больше вложенности (6+ уровней)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Стало (после Phase 4 рефакторинга)
|
||||
let Some(chat_id) = app.selected_chat else { return Ok(false) };
|
||||
let Some(message_id) = app.selected_message else { return Ok(false) };
|
||||
|
||||
if !app.is_message_outgoing(chat_id, message_id) {
|
||||
return Ok(false); // early return
|
||||
}
|
||||
// Линейная логика (2-3 уровня максимум)
|
||||
```
|
||||
|
||||
### Решение
|
||||
|
||||
#### Выполнено в `src/input/main_input.rs` (2026-02-03)
|
||||
|
||||
- [x] **Применены early returns** - уменьшили вложенность с 6+ до 2-3 уровней
|
||||
- [x] **Извлечена вложенная логика** в 3 функции:
|
||||
- `edit_message()` — редактирование сообщения (~50 строк)
|
||||
- `send_new_message()` — отправка нового сообщения (~55 строк)
|
||||
- `perform_message_search()` — поиск по сообщениям (~20 строк)
|
||||
- [x] **Использованы let-else guard clauses** — современный Rust паттерн
|
||||
- [x] **Упрощены 6 функций**:
|
||||
- `handle_profile_mode()` — упрощён блок Enter с let-else
|
||||
- `handle_profile_open()` — применён early return guard
|
||||
- `handle_enter_key()` — разделена на части, сокращена с ~130 до ~40 строк
|
||||
- `handle_message_search_mode()` — извлечена логика поиска
|
||||
- `handle_escape_key()` — преобразован в early returns
|
||||
- `handle_message_selection()` — применены let-else guards
|
||||
|
||||
**Результат Phase 4**:
|
||||
- ✅ Глубина вложенности: **6+ уровней → 2-3 уровня**
|
||||
- ✅ Код стал **максимально линейным и читаемым**
|
||||
- ✅ Применены современные Rust паттерны (let-else, guards)
|
||||
|
||||
#### Выполнено в `src/tdlib/client.rs` (2026-02-03, Этап 3)
|
||||
|
||||
- [x] **Добавлены helper методы** для устранения дублирования:
|
||||
- `find_chat_mut()` — поиск чата по ID
|
||||
- `update_chat()` — обновление чата через closure (использовано 9+ раз)
|
||||
- [x] **Извлечено 5 handler методов** из `handle_update()`:
|
||||
- `handle_chat_position_update()` — управление позициями чатов (43 строки)
|
||||
- `handle_user_update()` — обработка информации о пользователях (46 строк)
|
||||
- `handle_message_interaction_info_update()` — обновление реакций (44 строки)
|
||||
- `handle_message_send_succeeded_update()` — успешная отправка (38 строк)
|
||||
- `handle_chat_draft_message_update()` — черновики (18 строк)
|
||||
- [x] **Упрощено 7 функций** с применением let-else guards, early returns, unwrap_or_else:
|
||||
- `handle_chat_action_update()` — статус набора текста (4 → 2 уровня)
|
||||
- `handle_new_message_update()` — добавление сообщений (3 → 2 уровня)
|
||||
- `handle_chat_draft_message_update()` — черновики (if-let → match)
|
||||
- `handle_user_update()` — usernames (вложенные if-let → and_then)
|
||||
- `convert_message()` — кэш имён (if-let → unwrap_or_else)
|
||||
- `extract_reply_info()` — reply информация (вложенные if-let → map/or_else)
|
||||
- `update_reply_info_from_loaded_messages()` — обновление reply (4 → 1-2 уровня)
|
||||
|
||||
**Результат Этапа 3 (client.rs)**:
|
||||
- ✅ Функция `handle_update()` сократилась с **268 до 122 строк** (54% сокращение!)
|
||||
- ✅ Устранено дублирование: ~9 повторений pattern → 2 helper метода
|
||||
- ✅ Глубина вложенности: **4-5 уровней → 2-3 уровня**
|
||||
- ✅ Применены modern patterns: let-else guards, early returns, filter chains
|
||||
|
||||
#### Дополнительные улучшения вложенности (2026-02-04)
|
||||
|
||||
- [x] **Упрощена `src/tdlib/messages.rs`** (строки 718-755)
|
||||
- `fetch_missing_reply_info()`: 7 уровней → 2-3 уровня
|
||||
- Извлечена функция `fetch_and_update_reply()`
|
||||
- Использованы let-else guards и iterator chains
|
||||
- Максимальная вложенность: **44 → 28 пробелов**
|
||||
|
||||
- [x] **Упрощена `src/tdlib/messages.rs`** (строки 147-182)
|
||||
- `get_chat_history()` retry loop: 6 уровней → 3 уровня
|
||||
- Извлечен `messages_obj` после match
|
||||
- Early continue для пустых результатов
|
||||
- Использован `.flatten()` вместо вложенного if-let
|
||||
|
||||
- [x] **Упрощена `src/input/main_input.rs`** (строки 500-546)
|
||||
- `handle_forward_mode()`: 7 уровней → 2-3 уровня
|
||||
- Извлечена функция `forward_selected_message()`
|
||||
- Использованы early returns (let-else guards)
|
||||
- Максимальная вложенность: **40 → 36 пробелов**
|
||||
|
||||
- [x] **Упрощена `src/input/main_input.rs`** (reaction picker)
|
||||
- Извлечена функция `send_reaction()`
|
||||
- Использованы let-else guards
|
||||
- Вложенность: 5 уровней → 2-3 уровня
|
||||
|
||||
- [x] **Упрощена `src/input/main_input.rs`** (scroll + load older)
|
||||
- Извлечена функция `load_older_messages_if_needed()`
|
||||
- Использованы early returns
|
||||
- Вложенность: 6 уровней → 2-3 уровня
|
||||
|
||||
- [x] **Упрощена `src/config.rs`** (строки 563-609)
|
||||
- `load_credentials()`: 7 уровней → 2-3 уровня
|
||||
- Извлечены функции `load_credentials_from_file()` и `load_credentials_from_env()`
|
||||
- Использованы `?` operator для Option chains
|
||||
- Максимальная вложенность: **36 → 32 пробелов**
|
||||
|
||||
**Итоговый результат**:
|
||||
- ✅ Все файлы с вложенностью >32 пробелов обработаны
|
||||
- ✅ Применены современные Rust паттерны (let-else guards, early returns, ? operator, iterator chains)
|
||||
- ✅ Извлечено 8 новых функций для разделения ответственности
|
||||
- ✅ Максимальная вложенность во всем проекте: **≤32 пробелов (8 уровней)**
|
||||
|
||||
### Файлы
|
||||
|
||||
- ✅ `src/input/main_input.rs` — **ПОЛНОСТЬЮ ЗАВЕРШЕНО** (Phase 4 + доп. улучшения: 40 → 36 пробелов)
|
||||
- ✅ `src/tdlib/client.rs` — **ЗАВЕРШЕНО** (Этап 3: 268 → 122 строки в handle_update)
|
||||
- ✅ `src/tdlib/messages.rs` — **ПОЛНОСТЬЮ ЗАВЕРШЕНО** (44 → 28 пробелов)
|
||||
- ✅ `src/config.rs` — **ПОЛНОСТЬЮ ЗАВЕРШЕНО** (36 → 32 пробелов)
|
||||
- ✅ Все остальные модули — **проверены, вложенность приемлема** (≤32 пробелов)
|
||||
|
||||
---
|
||||
|
||||
## 4. Нарушение Single Responsibility
|
||||
|
||||
**Приоритет:** 🟡 Средний
|
||||
**Статус:** ❌ Не начато
|
||||
**Объем:** 2 основных структуры
|
||||
|
||||
### Проблемы
|
||||
|
||||
#### 4.1. `App` struct (50+ методов)
|
||||
|
||||
Смешивает ответственности:
|
||||
- UI state management
|
||||
- Business logic
|
||||
- TDLib interaction
|
||||
- Input handling
|
||||
- Search logic
|
||||
- Profile management
|
||||
- Folder management
|
||||
|
||||
#### 4.2. `TdClient` (facade + бизнес-логика)
|
||||
|
||||
Смешивает:
|
||||
- Facade pattern (делегирование)
|
||||
- Update processing
|
||||
- Cache management
|
||||
- Network operations
|
||||
|
||||
### Решение
|
||||
|
||||
#### Разделить `App`
|
||||
|
||||
- [ ] Создать `ChatListState` (состояние списка чатов)
|
||||
- [ ] Создать `MessageViewState` (состояние просмотра сообщений)
|
||||
- [ ] Создать `ComposeState` (состояние написания сообщения)
|
||||
- [ ] Создать `SearchState` (состояние поиска)
|
||||
- [ ] Создать `ProfileState` (состояние профиля)
|
||||
- [ ] `App` становится координатором этих state объектов
|
||||
|
||||
#### Разделить `TdClient`
|
||||
|
||||
- [ ] `TdClient` только facade (делегирование)
|
||||
- [ ] Бизнес-логика в `MessageService`, `ChatService`, etc.
|
||||
- [ ] Update processing в отдельном модуле
|
||||
|
||||
### Файлы
|
||||
|
||||
- `src/app/mod.rs`
|
||||
- `src/tdlib/client.rs`
|
||||
|
||||
---
|
||||
|
||||
## 5. Плохая инкапсуляция
|
||||
|
||||
**Приоритет:** 🔴 Высокий
|
||||
**Статус:** ✅ Частично выполнено (2026-02-01)
|
||||
**Объем:** Вся структура `App`
|
||||
|
||||
### Проблемы
|
||||
|
||||
- **22 публичных поля** в `App`
|
||||
```rust
|
||||
pub struct App {
|
||||
pub td_client: TdClient,
|
||||
pub chats: Vec<ChatInfo>,
|
||||
pub selected_chat: Option<ChatId>,
|
||||
pub messages: HashMap<ChatId, Vec<MessageInfo>>,
|
||||
// ... еще 18 полей
|
||||
}
|
||||
```
|
||||
|
||||
- **Прямой доступ везде**
|
||||
```rust
|
||||
app.selected_chat = Some(chat_id); // Плохо
|
||||
app.chats.push(new_chat); // Плохо
|
||||
app.messages.clear(); // Плохо
|
||||
```
|
||||
|
||||
- **Тесты манипулируют внутренностями**
|
||||
```rust
|
||||
app.td_client.user_cache.chat_user_ids.insert(...); // Слишком глубоко
|
||||
```
|
||||
|
||||
### Решение
|
||||
|
||||
- [x] Сделать критичные поля приватными - **Частично выполнено** (2026-02-01)
|
||||
- ✅ `config` сделан приватным (readonly через getter `app.config()`)
|
||||
- ✅ Добавлены 30+ методов-геттеров и сеттеров для всех полей
|
||||
- ⏳ Остальные поля оставлены pub для совместимости (требуется массовый рефакторинг)
|
||||
- [x] Добавить getter методы где нужно - **Выполнено**
|
||||
- 30+ методов: `phone_input()`, `set_phone_input()`, `screen()`, `set_screen()`, `is_loading()`, и т.д.
|
||||
- [ ] Полная инкапсуляция всех полей (требует обновления 170+ мест в коде)
|
||||
- [ ] Создать методы для операций (вместо прямого доступа)
|
||||
```rust
|
||||
// Вместо app.selected_chat = Some(chat_id)
|
||||
app.select_chat(chat_id); // Уже есть!
|
||||
|
||||
// Вместо app.chats.push(new_chat)
|
||||
app.add_chat(new_chat); // TODO
|
||||
```
|
||||
|
||||
### Файлы
|
||||
|
||||
- `src/app/mod.rs`
|
||||
- `src/app/state.rs` (новый)
|
||||
- Все тесты
|
||||
|
||||
---
|
||||
|
||||
## 6. Отсутствующие абстракции
|
||||
|
||||
**Приоритет:** 🟡 Средний
|
||||
**Статус:** ✅ Частично выполнено (2026-02-04)
|
||||
**Объем:** 3 основные абстракции (2/3 завершены, 1/3 уже была)
|
||||
|
||||
### Проблемы
|
||||
|
||||
#### 6.1. Создать `KeyHandler` trait ✅ ЗАВЕРШЕНО! (2026-02-04)
|
||||
|
||||
- [x] Создать `src/input/key_handler.rs` - **Выполнено** (380+ строк)
|
||||
- Enum `KeyResult` (Handled, HandledNeedsRedraw, NotHandled, Quit)
|
||||
- Trait `KeyHandler` с методом `handle_key()` и `priority()`
|
||||
- Struct `GlobalKeyHandler` - обработчик глобальных команд (Quit, OpenSearch)
|
||||
- Struct `ChatListKeyHandler` - навигация по списку чатов, выбор папок
|
||||
- Struct `MessageViewKeyHandler` - скролл сообщений, поиск в чате
|
||||
- Struct `MessageSelectionKeyHandler` - действия с выбранным сообщением
|
||||
- Struct `KeyHandlerChain` - цепочка обработчиков с приоритетами
|
||||
- 3 unit теста (все проходят)
|
||||
- [ ] Интегрировать в main_input.rs (заменить текущую логику)
|
||||
- [ ] Добавить недостающие методы в App (enter_search_mode и т.д.)
|
||||
|
||||
#### 6.2. Нет абстракции для network operations
|
||||
|
||||
Timeout/retry логика дублируется:
|
||||
|
||||
```rust
|
||||
// Повторяется ~20 раз
|
||||
let result = tokio::time::timeout(
|
||||
Duration::from_millis(100),
|
||||
operation()
|
||||
).await;
|
||||
```
|
||||
|
||||
#### 6.3. Хардкод горячих клавиш
|
||||
|
||||
Невозможно изменить без правки кода:
|
||||
|
||||
```rust
|
||||
KeyCode::Char('e') => edit_message(), // Хардкод
|
||||
KeyCode::Char('d') => delete_message(), // Хардкод
|
||||
```
|
||||
|
||||
### Решение
|
||||
|
||||
#### 6.1. Создать `KeyHandler` trait
|
||||
|
||||
- [ ] Создать `src/input/key_handler.rs`
|
||||
```rust
|
||||
trait KeyHandler {
|
||||
fn handle_key(&mut self, app: &mut App, key: KeyEvent) -> Result<bool>;
|
||||
}
|
||||
```
|
||||
- [ ] Реализовать для каждого экрана:
|
||||
- `ChatListKeyHandler`
|
||||
- `MessagesKeyHandler`
|
||||
- `ComposeKeyHandler`
|
||||
- `SearchKeyHandler`
|
||||
|
||||
#### 6.2. Создать network utilities
|
||||
|
||||
- [ ] Создать `src/utils/network.rs`
|
||||
```rust
|
||||
async fn with_timeout<F, T>(f: F, timeout_ms: u64) -> Result<T>
|
||||
async fn with_retry<F, T>(f: F, max_retries: u32) -> Result<T>
|
||||
```
|
||||
|
||||
#### 6.3. Создать систему горячих клавиш ✅ ЗАВЕРШЕНО! (2026-02-04)
|
||||
|
||||
- [x] Создать `src/config/keybindings.rs` - **Выполнено**
|
||||
- Enum `Command` с 40+ командами (навигация, чат, сообщения, input)
|
||||
- Struct `KeyBinding` с поддержкой модификаторов (Ctrl, Shift, Alt и т.д.)
|
||||
- Struct `Keybindings` с HashMap<Command, Vec<KeyBinding>>
|
||||
- Поддержка множественных bindings для одной команды (EN/RU раскладки)
|
||||
- Сериализация/десериализация KeyCode и KeyModifiers
|
||||
- 4 unit теста (все проходят)
|
||||
- [ ] Интегрировать в приложение (вместо HotkeysConfig)
|
||||
- [ ] Загружать из конфига (опционально, с fallback на defaults)
|
||||
|
||||
### Файлы
|
||||
|
||||
- `src/input/key_handler.rs` (новый)
|
||||
- `src/utils/network.rs` (новый)
|
||||
- `src/config/keybindings.rs` (новый)
|
||||
|
||||
---
|
||||
|
||||
## 7. Несогласованность
|
||||
|
||||
**Приоритет:** 🟢 Низкий
|
||||
**Статус:** ❌ Не начато
|
||||
**Объем:** Вся кодовая база
|
||||
|
||||
### Проблемы
|
||||
|
||||
#### 7.1. Разные типы ошибок
|
||||
|
||||
```rust
|
||||
// В одних местах
|
||||
Result<T, String>
|
||||
|
||||
// В других
|
||||
Result<T, Box<dyn Error>>
|
||||
|
||||
// В третьих
|
||||
Result<T> // с неявным типом ошибки
|
||||
```
|
||||
|
||||
#### 7.2. Разные паттерны state management
|
||||
|
||||
- В одних местах флаги (`is_editing: bool`)
|
||||
- В других энумы (`EditMode::Active`)
|
||||
- В третьих Option (`editing_message: Option<MessageId>`)
|
||||
|
||||
#### 7.3. Разные подходы к валидации
|
||||
|
||||
- Иногда в UI слое
|
||||
- Иногда в бизнес-логике
|
||||
- Иногда в обработчиках ввода
|
||||
|
||||
### Решение
|
||||
|
||||
- [ ] Стандартизировать обработку ошибок (один тип ошибки)
|
||||
- [ ] Выбрать единый подход к state management (enum-based)
|
||||
- [ ] Определить слой для валидации (бизнес-логика)
|
||||
- [ ] Создать style guide в документации
|
||||
|
||||
### Файлы
|
||||
|
||||
- Вся кодовая база
|
||||
|
||||
---
|
||||
|
||||
## 8. Перекрытие функциональности
|
||||
|
||||
**Приоритет:** 🟡 Средний
|
||||
**Статус:** ✅ Выполнено (2026-02-04)
|
||||
**Объем:** 2 основные области (2/2 завершены)
|
||||
|
||||
### Проблемы
|
||||
|
||||
#### 8.1. Централизовать фильтрацию чатов ✅ ЗАВЕРШЕНО! (2026-02-04)
|
||||
|
||||
- [x] Создать `src/app/chat_filter.rs` - **Выполнено** (470+ строк)
|
||||
- Struct `ChatFilterCriteria` с builder pattern
|
||||
- Поддержка фильтрации по: папке, поиску, pinned, unread, mentions, muted, archived
|
||||
- Struct `ChatFilter` с методами фильтрации
|
||||
- Enum `ChatSortOrder` для сортировки (ByLastMessage, ByTitle, ByUnreadCount, PinnedFirst)
|
||||
- Методы подсчёта: count, count_unread, count_unread_mentions
|
||||
- 6 unit тестов (все проходят)
|
||||
- [ ] Заменить дублирующуюся логику в App и UI на ChatFilter
|
||||
- [ ] Удалить старые методы фильтрации из App
|
||||
|
||||
#### 8.2. Централизовать обработку сообщений ✅ ЗАВЕРШЕНО! (2026-02-04)
|
||||
|
||||
- [x] Создать `src/app/message_service.rs` - **Выполнено** (508+ строк)
|
||||
- Struct `MessageGroup` для группировки по дате
|
||||
- Struct `SenderGroup` для группировки по отправителю
|
||||
- Struct `MessageSearchResult` с контекстом поиска
|
||||
- Struct `MessageService` с 13 методами бизнес-логики:
|
||||
- `group_by_date()` - группировка сообщений по датам
|
||||
- `group_by_sender()` - группировка по отправителю
|
||||
- `search()` - полнотекстовый поиск с контекстом
|
||||
- `find_next()` / `find_previous()` - навигация по результатам
|
||||
- `filter_by_sender()` / `filter_unread()` - фильтрация
|
||||
- `find_by_id()` / `find_index_by_id()` - поиск по ID
|
||||
- `get_last_n()` - получение последних N сообщений
|
||||
- `get_in_date_range()` - фильтрация по диапазону дат
|
||||
- `count_by_sender_type()` - статистика по типам
|
||||
- `create_index()` - создание быстрого индекса
|
||||
- 7 unit тестов (все проходят)
|
||||
- [ ] Заменить разрозненную логику в App/UI на MessageService
|
||||
- [ ] Чёткое разделение: TDLib → Service → UI
|
||||
|
||||
### Решение
|
||||
|
||||
#### 8.1. Централизовать фильтрацию ✅
|
||||
|
||||
- [x] Создать `src/app/chat_filter.rs` - **Выполнено**
|
||||
- [x] Один источник правды для фильтрации - **Выполнено**
|
||||
- [ ] UI и App используют его - TODO (требует интеграции)
|
||||
|
||||
#### 8.2. Четко разделить слои обработки сообщений ✅
|
||||
|
||||
- [x] `tdlib/messages.rs` - только получение и преобразование - **Выполнено**
|
||||
- [x] `app/message_service.rs` - бизнес-логика - **Выполнено**
|
||||
- [x] `ui/messages.rs` - только рендеринг - **Было уже реализовано**
|
||||
|
||||
### Файлы
|
||||
|
||||
- `src/app/chat_filter.rs` (новый)
|
||||
- `src/app/message_service.rs` (новый)
|
||||
- `src/tdlib/messages.rs`
|
||||
- `src/ui/messages.rs`
|
||||
|
||||
---
|
||||
|
||||
## 9. Проблемы производительности
|
||||
|
||||
**Приоритет:** 🟢 Низкий
|
||||
**Статус:** ❌ Не начато
|
||||
**Объем:** Локальные оптимизации
|
||||
|
||||
### Проблемы
|
||||
|
||||
#### 9.1. Множественные клоны в обработке ввода
|
||||
|
||||
```rust
|
||||
let text = app.input_text.clone(); // Клон
|
||||
let chat_id = app.selected_chat.clone(); // Клон
|
||||
// Используются только для чтения
|
||||
```
|
||||
|
||||
#### 9.2. Нет кеширования результатов поиска
|
||||
|
||||
- Каждый поиск выполняется заново
|
||||
- Нет инвалидации кеша при изменениях
|
||||
|
||||
#### 9.3. Неэффективная LRU cache
|
||||
|
||||
- `Vec::retain()` + `Vec::push()` на каждый доступ
|
||||
- O(n) вместо потенциального O(1)
|
||||
|
||||
### Решение
|
||||
|
||||
- [ ] Заменить клоны на borrowing где возможно
|
||||
- [ ] Добавить `SearchCache` с TTL
|
||||
- [ ] Оптимизировать `LruCache` (использовать `VecDeque` или готовую библиотеку)
|
||||
|
||||
### Файлы
|
||||
|
||||
- `src/input/main_input.rs`
|
||||
- `src/app/search.rs`
|
||||
- `src/tdlib/users.rs` (LruCache)
|
||||
|
||||
---
|
||||
|
||||
## 10. Отсутствующие архитектурные паттерны
|
||||
|
||||
**Приоритет:** 🟢 Низкий
|
||||
**Статус:** ❌ Не начато
|
||||
**Объем:** Архитектурные изменения
|
||||
|
||||
### Проблемы
|
||||
|
||||
#### 10.1. Нет Event Bus
|
||||
|
||||
Компоненты напрямую вызывают друг друга:
|
||||
- Сложно тестировать
|
||||
- Сильная связанность
|
||||
- Тяжело добавлять новые фичи
|
||||
|
||||
#### 10.2. Нет Repository паттерна
|
||||
|
||||
Прямой доступ к данным везде:
|
||||
- `app.messages.get(chat_id)`
|
||||
- `app.chats.iter().find(...)`
|
||||
- Нет единой точки доступа к данным
|
||||
|
||||
#### 10.3. Нет Service Layer
|
||||
|
||||
Бизнес-логика размазана:
|
||||
- Часть в `App`
|
||||
- Часть в `TdClient`
|
||||
- Часть в UI handlers
|
||||
|
||||
### Решение
|
||||
|
||||
#### 10.1. Event Bus (опционально)
|
||||
|
||||
- [ ] Создать `src/event_bus.rs`
|
||||
- [ ] Pub/Sub для событий между компонентами
|
||||
- [ ] Decoupling
|
||||
|
||||
#### 10.2. Repository Pattern
|
||||
|
||||
- [ ] Создать `src/repositories/chat_repository.rs`
|
||||
- [ ] Создать `src/repositories/message_repository.rs`
|
||||
- [ ] Создать `src/repositories/user_repository.rs`
|
||||
- [ ] Единая точка доступа к данным
|
||||
|
||||
#### 10.3. Service Layer
|
||||
|
||||
- [ ] Создать `src/services/chat_service.rs`
|
||||
- [ ] Создать `src/services/message_service.rs`
|
||||
- [ ] Создать `src/services/search_service.rs`
|
||||
- [ ] Вся бизнес-логика в сервисах
|
||||
|
||||
### Файлы
|
||||
|
||||
- `src/event_bus.rs` (новый, опционально)
|
||||
- `src/repositories/*.rs` (новые)
|
||||
- `src/services/*.rs` (новые)
|
||||
|
||||
---
|
||||
|
||||
## Приоритизация
|
||||
|
||||
### 🔴 Высокий приоритет (начать первым)
|
||||
|
||||
1. **Дублирование кода** - быстрый win, улучшит поддерживаемость
|
||||
2. **Большие файлы** - критично для навигации и понимания кода
|
||||
3. **Плохая инкапсуляция** - защитит от ошибок, улучшит API
|
||||
|
||||
### 🟡 Средний приоритет (после высокого)
|
||||
|
||||
4. **Сложная вложенность** - улучшит читаемость
|
||||
5. **Single Responsibility** - улучшит архитектуру
|
||||
6. **Отсутствующие абстракции** - упростит расширение
|
||||
7. **Перекрытие функциональности** - уберет путаницу
|
||||
|
||||
### 🟢 Низкий приоритет (когда будет время)
|
||||
|
||||
8. **Несогласованность** - косметические улучшения
|
||||
9. **Производительность** - пока не critical path
|
||||
10. **Архитектурные паттерны** - nice to have
|
||||
|
||||
---
|
||||
|
||||
## План выполнения
|
||||
|
||||
### Фаза 1: Быстрые победы (1-2 дня)
|
||||
|
||||
- [x] #1: Создать утилиты для дублирующегося кода - **ЗАВЕРШЕНО** (2026-02-02)
|
||||
- retry utils: 100% покрытие (все timeout заменены)
|
||||
- modal_handler: интегрирован в 2 диалогах
|
||||
- validation: интегрирован в 4 местах
|
||||
- [ ] #5: Инкапсулировать поля App - **Частично** (геттеры добавлены)
|
||||
|
||||
### Фаза 2: Разделение больших файлов (3-5 дней)
|
||||
|
||||
- [ ] #2.1: Разделить `main_input.rs`
|
||||
- [ ] #2.2: Разделить `client.rs`
|
||||
- [ ] #2.3: Разделить `messages.rs`
|
||||
|
||||
### Фаза 3: Улучшение архитектуры (5-7 дней)
|
||||
|
||||
- [ ] #4: Разделить ответственности App/TdClient
|
||||
- [ ] #6: Добавить абстракции (KeyHandler, network utils)
|
||||
- [ ] #8: Убрать перекрытие функциональности
|
||||
|
||||
### Фаза 4: Полировка (2-3 дня)
|
||||
|
||||
- [x] #3: Упростить вложенность - **Частично** (main_input.rs завершён 2026-02-03)
|
||||
- [ ] #7: Стандартизировать подходы
|
||||
- [ ] #9: Оптимизировать производительность
|
||||
|
||||
### Фаза 5: Архитектурные паттерны (опционально)
|
||||
|
||||
- [ ] #10: Рассмотреть Event Bus / Repository / Service Layer
|
||||
|
||||
---
|
||||
|
||||
## Метрики
|
||||
|
||||
### До рефакторинга
|
||||
|
||||
- Строк кода: ~15,000
|
||||
- Файлов: ~50
|
||||
- Средний размер файла: 300 строк
|
||||
- Максимальный файл: 1167 строк
|
||||
- Дублирование: ~15-20%
|
||||
- Публичных полей в App: 22
|
||||
- Прямые вызовы timeout: 8+
|
||||
|
||||
### Текущее состояние (2026-02-04)
|
||||
|
||||
- ✅ Дублирование timeout: **УСТРАНЕНО** (0 прямых вызовов, все через retry utils)
|
||||
- ✅ Дублирование modal: **УСТРАНЕНО** (используется modal_handler)
|
||||
- ✅ Дублирование validation: **УСТРАНЕНО** (используется validation utils)
|
||||
- ✅ Вложенность в main_input.rs: **УПРОЩЕНА** (6+ уровней → 2-3 уровня)
|
||||
- ✅ Размер handle() в main_input.rs: **СОКРАЩЁН** (891 строк → 82 строки, 91% сокращение)
|
||||
- ✅ Размер client.rs: **СОКРАЩЁН** (1259 строк → 599 строк, 52% сокращение)
|
||||
- ✅ Размер render() в ui/messages.rs: **СОКРАЩЁН** (390 строк → 92 строки, 76% сокращение)
|
||||
- ✅ Размер convert_message() в tdlib/messages.rs: **СОКРАЩЁН** (150 строк → 57 строк, 62% сокращение)
|
||||
- ⏳ Публичных полей в App: 22 → 21 (config приватный, геттеры добавлены)
|
||||
- ✅ **Все большие функции отрефакторены!** 🎉
|
||||
|
||||
### Цели после рефакторинга
|
||||
|
||||
- Максимальный файл: <500 строк
|
||||
- Дублирование: <5% ✅ **ДОСТИГНУТО для категории #1!**
|
||||
- Глубина вложенности: ≤3 уровня ✅ **ДОСТИГНУТО для main_input.rs!**
|
||||
- Публичных полей в App: 0
|
||||
- Все файлы <400 строк (в идеале)
|
||||
- Улучшенная тестируемость
|
||||
- Более четкое разделение ответственностей
|
||||
1120
REFACTORING_ROADMAP.md
Normal file
1120
REFACTORING_ROADMAP.md
Normal file
File diff suppressed because it is too large
Load Diff
131
REQUIREMENTS.md
Normal file
131
REQUIREMENTS.md
Normal file
@@ -0,0 +1,131 @@
|
||||
# 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) с видео/картинками/голосовые пока ничего не делаем, ренденим заглушку (с упоминанием что это картинка или видео и тд)
|
||||
|
||||
### Дополнительно реализованные возможности
|
||||
|
||||
13) **Markdown форматирование**: жирный, курсив, подчёркивание, зачёркивание, код, спойлеры, ссылки, упоминания
|
||||
14) **Редактирование сообщений**: ↑ при пустом инпуте → выбор → Enter → редактирование
|
||||
15) **Удаление сообщений**: d/в/Delete в режиме выбора → подтверждение → удаление
|
||||
16) **Reply на сообщения**: r/к в режиме выбора → превью → отправка ответа
|
||||
17) **Forward сообщений**: f/а в режиме выбора → выбор чата → пересылка
|
||||
18) **Typing indicator**: отображение "печатает..." когда собеседник набирает текст
|
||||
19) **Закреплённые сообщения**: отображение pinned message вверху чата с переходом
|
||||
20) **Поиск по сообщениям**: Ctrl+F для поиска внутри чата, n/N для навигации
|
||||
21) **Черновики**: автосохранение текста при переключении между чатами
|
||||
22) **Профиль**: i для просмотра информации о пользователе/группе
|
||||
23) **Копирование**: y/н для копирования сообщения в системный буфер
|
||||
24) **Реакции**: e/у для добавления реакций, emoji picker с навигацией стрелками
|
||||
25) **Конфигурация**: ~/.config/tele-tui/config.toml для настройки цветов и timezone
|
||||
26) **Credentials**: приоритетная загрузка из ~/.config/tele-tui/credentials или .env
|
||||
27) **Блочный курсор**: Vim-style курсор █ с навигацией ←/→/Home/End
|
||||
28) **Динамический инпут**: автоматическое расширение до 10 строк
|
||||
29) **Онлайн-статус**: зелёная точка ● для онлайн пользователей
|
||||
30) **Индикаторы**: 📌 закреплённые чаты, 🔇 замьюченные, @ упоминания
|
||||
31) **Состояние сети**: индикатор в футере (⚠ Нет сети, ⏳ Подключение...)
|
||||
32) **Graceful shutdown**: корректное закрытие при Ctrl+C
|
||||
|
||||
### Управление
|
||||
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 - обновить список чатов
|
||||
|
||||
### Реализованные команды
|
||||
|
||||
#### Навигация
|
||||
```
|
||||
↑/↓ или k/j (р/о): Navigate | Enter: Open/Send | Esc: Close/Cancel | 1-9: Folders
|
||||
Ctrl+S: Search Chats | Ctrl+R: Refresh | Ctrl+F: Search in Chat | Ctrl+C: Quit
|
||||
```
|
||||
|
||||
#### Работа с сообщениями
|
||||
```
|
||||
↑ (пустой инпут): Select message | Enter: Edit | r/к: Reply | f/а: Forward
|
||||
d/в/Delete: Delete | y/н: Copy | e/у: React | i: Profile
|
||||
```
|
||||
|
||||
#### Emoji Picker (реакции)
|
||||
```
|
||||
←/→/↑/↓: Navigate | Enter: Toggle reaction | Esc: Close
|
||||
```
|
||||
|
||||
## Технологии
|
||||
Пишем на rust-е
|
||||
|
||||
1) ratatui 0.29 - для tui интерфейса
|
||||
2) tdlib-rs 1.1 - для подключения апи телеграма (обёртка над TDLib)
|
||||
3) tokio 1.x - async runtime
|
||||
4) crossterm 0.28 - кроссплатформенный терминал
|
||||
5) serde + serde_json 1.0 - сериализация/десериализация
|
||||
6) toml 0.8 - парсинг конфигурации
|
||||
7) dirs 5.0 - XDG директории (config, data)
|
||||
8) clipboard 0.5 - работа с системным буфером обмена
|
||||
9) chrono 0.4 - форматирование даты/времени
|
||||
10) dotenvy 0.15 - загрузка .env файлов
|
||||
|
||||
## Нефункциональные требования
|
||||
|
||||
### Производительность
|
||||
1) программа должна выдавать 60 фпс
|
||||
2) интерфейс не должен мерцать
|
||||
3) минимальное разрешение - 600 символов, максимального нет, не ограничиваем
|
||||
170
ROADMAP.md
Normal file
170
ROADMAP.md
Normal file
@@ -0,0 +1,170 @@
|
||||
# Roadmap
|
||||
|
||||
## Завершённые фазы
|
||||
|
||||
| Фаза | Описание | Ключевые результаты |
|
||||
|------|----------|---------------------|
|
||||
| 1 | Базовая инфраструктура | ratatui + crossterm, vim-навигация, русская раскладка |
|
||||
| 2 | TDLib интеграция | tdlib-rs, авторизация, загрузка чатов и сообщений |
|
||||
| 3 | Улучшение UX | Отправка, поиск, скролл, realtime обновления |
|
||||
| 4 | Папки и фильтрация | Загрузка папок из Telegram, переключение 1-9 |
|
||||
| 5 | Расширенный функционал | Онлайн-статус, галочки прочтения, медиа-заглушки, muted |
|
||||
| 6 | Полировка | 60 FPS, оптимизация памяти, graceful shutdown, динамический инпут |
|
||||
| 7 | Рефакторинг памяти | Единый источник данных, LRU-кэш (500 users), lazy loading |
|
||||
| 8 | Дополнительные фичи | Markdown, edit/delete, reply/forward, блочный курсор |
|
||||
| 9 | Расширенные возможности | Typing, pinned, поиск в чате, черновики, профиль, копирование, реакции, конфиг |
|
||||
| 10 | Desktop уведомления (83%) | notify-rust, muted фильтр, mentions, медиа. TODO: кастомные звуки |
|
||||
| 11 | Inline просмотр фото | Dual renderer (Halfblocks + iTerm2/Sixel), throttling 15 FPS, modal viewer, lazy loading, auto-download |
|
||||
| 12 | Голосовые сообщения | ffplay player, pause/resume with seek, VoiceCache, AudioConfig, progress bar + waveform UI |
|
||||
| 13 | Глубокий рефакторинг | 5 файлов (4582->модули), 5 traits, shared components, docs |
|
||||
|
||||
---
|
||||
|
||||
## Фаза 11: Inline просмотр фото в чате [DONE]
|
||||
|
||||
**UX**: Always-show inline preview (50 chars, Halfblocks) -> `v`/`м` открывает fullscreen modal (iTerm2/Sixel) -> `←`/`→` навигация между фото.
|
||||
|
||||
### Реализовано:
|
||||
- [x] **Dual renderer архитектура**:
|
||||
- `inline_image_renderer`: Halfblocks (быстро, Unicode блоки) для навигации
|
||||
- `modal_image_renderer`: iTerm2/Sixel (медленно, высокое качество) для просмотра
|
||||
- [x] **Performance optimizations**:
|
||||
- Frame throttling: inline 15 FPS, текст 60 FPS
|
||||
- Lazy loading: только видимые изображения
|
||||
- LRU cache: max 100 протоколов
|
||||
- Skip partial rendering (no flickering)
|
||||
- [x] **UX улучшения**:
|
||||
- Always-show inline preview (фикс. ширина 50 chars)
|
||||
- Fullscreen modal на `v`/`м` с aspect ratio
|
||||
- Loading indicator в модалке
|
||||
- Navigation hotkeys: `←`/`→` между фото, `Esc`/`q` закрыть
|
||||
- [x] **Типы и API**:
|
||||
- `MediaInfo`, `PhotoInfo`, `PhotoDownloadState`, `ImageModalState`
|
||||
- `ImagesConfig` в config.toml
|
||||
- Feature flag `images` для зависимостей
|
||||
- [x] **Media модуль**:
|
||||
- `cache.rs`: ImageCache (LRU)
|
||||
- `image_renderer.rs`: new() + new_fast()
|
||||
- [x] **UI модули**:
|
||||
- `modals/image_viewer.rs`: fullscreen modal
|
||||
- `messages.rs`: throttled second-pass rendering
|
||||
- [x] **Авто-загрузка фото** (bugfix):
|
||||
- Auto-download последних 30 фото при открытии чата (`open_chat_and_load_data`)
|
||||
- Download on demand по `v` (вместо "Фото не загружено")
|
||||
- Retry при ошибке загрузки
|
||||
- Конфиг: `auto_download_images` + `show_images` в `[images]`
|
||||
|
||||
---
|
||||
|
||||
## Фаза 12: Прослушивание голосовых сообщений [DONE]
|
||||
|
||||
### Этап 1: Инфраструктура аудио [DONE]
|
||||
- [x] Модуль `src/audio/`
|
||||
- `player.rs` — AudioPlayer на ffplay (subprocess)
|
||||
- `cache.rs` — VoiceCache (LRU, configurable size, `~/.cache/tele-tui/voice/`)
|
||||
- [x] AudioPlayer API: play(), play_from(ss), pause() (SIGSTOP), resume(), resume_from(ss), stop()
|
||||
- [x] Race condition fix: `starting` flag + pid ownership guard в потоках
|
||||
- [x] Drop impl для AudioPlayer (убивает ffplay при выходе)
|
||||
|
||||
### Этап 2: Интеграция с TDLib [DONE]
|
||||
- [x] Типы: `VoiceInfo`, `VoiceDownloadState`, `PlaybackState`, `PlaybackStatus`
|
||||
- [x] Конвертация `MessageVoiceNote` в `message_conversion.rs`
|
||||
- [x] `download_voice_note()` в TdClientTrait + client_impl + fake
|
||||
- [x] Методы `has_voice()`, `voice_info()`, `voice_info_mut()` на `MessageInfo`
|
||||
|
||||
### Этап 3: UI для воспроизведения [DONE]
|
||||
- [x] Progress bar (━●─) с позицией и длительностью
|
||||
- [x] Waveform визуализация (▁▂▃▄▅▆▇█) из base64-encoded TDLib данных
|
||||
- [x] Иконки статуса: ▶ Playing, ⏸ Paused, ⏹ Stopped
|
||||
- [x] Throttled redraw: обновление UI только при смене секунды (не 60 FPS)
|
||||
|
||||
### Этап 4: Хоткеи [DONE]
|
||||
- [x] Space — play/pause toggle (запуск + пауза/возобновление с откатом 1s)
|
||||
- [x] ←/→ — seek ±5 сек (через `resume_from()` — перезапуск ffplay с `-ss`)
|
||||
- [x] Seek работает и при воспроизведении, и на паузе (на паузе двигает позицию, при resume стартует с неё)
|
||||
- [x] MoveLeft/MoveRight как alias для SeekBackward/SeekForward (HashMap non-deterministic order fix)
|
||||
- [x] Автоматическая остановка при навигации на другое сообщение
|
||||
- [x] Остановка ffplay при выходе из приложения (Ctrl+C)
|
||||
|
||||
### Этап 5: Конфигурация и кэш [DONE]
|
||||
- [x] `AudioConfig` в config.toml (`cache_size_mb`, `auto_download_voice`)
|
||||
- [x] `DEFAULT_AUDIO_CACHE_SIZE_MB` константа (100 MB)
|
||||
- [x] Ticker для progress bar в event loop (delta-based position tracking)
|
||||
- [x] VoiceCache интеграция: проверка кэша перед загрузкой, кэширование после download
|
||||
|
||||
### Технические детали
|
||||
- **Аудио:** ffplay (subprocess), resume/seek через перезапуск с `-ss` offset
|
||||
- **Race conditions:** `starting` flag предотвращает false `is_stopped()` при старте ffplay; pid ownership guard в потоках предотвращает затирание pid нового процесса старым
|
||||
- **Keybinding conflict:** Left/Right привязаны к MoveLeft/MoveRight и SeekBackward/SeekForward; HashMap iteration order не гарантирован → оба варианта обрабатываются как seek в режиме выбора сообщения
|
||||
- **Платформы:** macOS, Linux (везде где есть ffmpeg)
|
||||
- **Хоткеи:** Space (play/pause), ←/→ (seek ±5s)
|
||||
|
||||
---
|
||||
|
||||
## Фаза 14: Мультиаккаунт
|
||||
|
||||
**Цель**: поддержка нескольких Telegram-аккаунтов с мгновенным переключением внутри приложения.
|
||||
|
||||
### UI: Индикатор в footer + хоткеи
|
||||
|
||||
```
|
||||
┌──────────────┬───────────────────────────┐
|
||||
│ Saved Msgs │ Привет! │
|
||||
│ Иван Петров │ Как дела? │
|
||||
│ Работа чат │ │
|
||||
├──────────────┴───────────────────────────┤
|
||||
│ [NORMAL] Михаил ⟨1/2⟩ Work(3) │ Ctrl+A │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Footer**: текущий аккаунт + номер `⟨1/2⟩` + бейджи непрочитанных с других аккаунтов
|
||||
- **Быстрое переключение**: `Ctrl+1`..`Ctrl+9` — мгновенный switch без модалки
|
||||
- **Модалка управления** (`Ctrl+A`): список аккаунтов, добавление/удаление, выбор активного
|
||||
|
||||
### Модалка переключения аккаунтов
|
||||
|
||||
```
|
||||
┌──────────────────────────────────┐
|
||||
│ Аккаунты │
|
||||
│ │
|
||||
│ 1. Михаил (+7 900 ...) ● │ ← активный
|
||||
│ 2. Work (+7 911 ...) (3) │ ← 3 непрочитанных
|
||||
│ 3. + Добавить аккаунт │
|
||||
│ │
|
||||
│ [j/k навигация, Enter выбор] │
|
||||
│ [d — удалить аккаунт] │
|
||||
└──────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Техническая реализация: все клиенты одновременно
|
||||
|
||||
- **Несколько TdClient**: каждый аккаунт — отдельный `TdClient` со своим `database_directory`
|
||||
- Аккаунт 1: `~/.local/share/tele-tui/accounts/1/tdlib_data/`
|
||||
- Аккаунт 2: `~/.local/share/tele-tui/accounts/2/tdlib_data/`
|
||||
- **Все клиенты активны**: polling updates со всех аккаунтов одновременно (уведомления, непрочитанные)
|
||||
- **Мгновенное переключение**: swap активного `App.td_client` — чаты и сообщения уже загружены
|
||||
- **Общий конфиг**: `~/.config/tele-tui/config.toml` (один для всех аккаунтов)
|
||||
- **Профили аккаунтов**: `~/.config/tele-tui/accounts.toml` — список аккаунтов (имя, путь к БД)
|
||||
|
||||
### Этапы
|
||||
|
||||
- [x] **Этап 1: Инфраструктура профилей** (DONE)
|
||||
- Структура `AccountProfile` (name, display_name, db_path)
|
||||
- `accounts.toml` — хранение списка аккаунтов
|
||||
- Миграция `tdlib_data/` → `accounts/default/tdlib_data/` (обратная совместимость)
|
||||
- CLI: `--account <name>` для запуска конкретного аккаунта
|
||||
|
||||
- [x] **Этап 2+3: Account Switcher Modal + Переключение** (DONE)
|
||||
- Подход: single-client reinit (close TDLib → new TdClient → auth)
|
||||
- Модалка `Ctrl+A` — глобальный оверлей с навигацией j/k
|
||||
- Footer индикатор `[account_name]` если не "default"
|
||||
- `AccountSwitcherState` enum (SelectAccount / AddAccount)
|
||||
- `recreate_client()` метод в TdClientTrait (close → new → set_tdlib_parameters)
|
||||
- `add_account()` — создание нового аккаунта из модалки
|
||||
- `pending_account_switch` флаг → обработка в main loop
|
||||
|
||||
- [ ] **Этап 4: Расширенные возможности мультиаккаунта**
|
||||
- Удаление аккаунта из модалки
|
||||
- Хоткеи `Ctrl+1`..`Ctrl+9` — быстрое переключение
|
||||
- Бейджи непрочитанных с других аккаунтов (требует множественных TdClient)
|
||||
- Параллельный polling updates со всех аккаунтов
|
||||
64
SECURITY.md
Normal file
64
SECURITY.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# Security Policy
|
||||
|
||||
## Поддерживаемые версии
|
||||
|
||||
| Версия | Поддержка |
|
||||
| ------ | ------------------ |
|
||||
| 0.1.x | :white_check_mark: |
|
||||
|
||||
## Сообщить об уязвимости
|
||||
|
||||
Если вы обнаружили уязвимость в безопасности, пожалуйста:
|
||||
|
||||
1. **НЕ создавайте публичный issue**
|
||||
2. Отправьте email на: [security@example.com]
|
||||
3. Опишите:
|
||||
- Тип уязвимости
|
||||
- Шаги для воспроизведения
|
||||
- Потенциальное влияние
|
||||
- Предложения по исправлению (если есть)
|
||||
|
||||
## Безопасность credentials
|
||||
|
||||
### Важно
|
||||
|
||||
- **НИКОГДА** не коммитьте файлы с credentials в git
|
||||
- Файлы `credentials` и `.env` уже добавлены в `.gitignore`
|
||||
- TDLib сессия в `tdlib_data/` содержит токены авторизации — НЕ делитесь этой папкой
|
||||
|
||||
### Рекомендации
|
||||
|
||||
1. **Используйте XDG config directory**:
|
||||
- Храните credentials в `~/.config/tele-tui/credentials`
|
||||
- Установите права доступа: `chmod 600 ~/.config/tele-tui/credentials`
|
||||
|
||||
2. **Защита сессии**:
|
||||
- Папка `tdlib_data/` содержит вашу авторизованную сессию
|
||||
- Не копируйте её на другие машины
|
||||
- При компрометации — удалите папку и авторизуйтесь заново
|
||||
|
||||
3. **Двухфакторная аутентификация**:
|
||||
- Настоятельно рекомендуется включить 2FA в Telegram
|
||||
- Это защитит ваш аккаунт даже при утечке API credentials
|
||||
|
||||
## Известные ограничения
|
||||
|
||||
### TDLib
|
||||
- Приложение использует официальную библиотеку TDLib от Telegram
|
||||
- Безопасность зависит от актуальности TDLib (автообновление через tdlib-rs)
|
||||
|
||||
### Конфигурация
|
||||
- Конфигурационный файл `config.toml` НЕ содержит чувствительных данных
|
||||
- Credentials хранятся отдельно в файле `credentials`
|
||||
|
||||
## Обновления безопасности
|
||||
|
||||
Мы оперативно реагируем на сообщения об уязвимостях и выпускаем патчи в течение:
|
||||
- **Критические**: 24-48 часов
|
||||
- **Высокие**: 3-7 дней
|
||||
- **Средние**: 2-4 недели
|
||||
- **Низкие**: включаются в следующий релиз
|
||||
|
||||
## Спасибо
|
||||
|
||||
Мы ценим ваш вклад в безопасность проекта!
|
||||
571
TESTING_PROGRESS.md
Normal file
571
TESTING_PROGRESS.md
Normal file
@@ -0,0 +1,571 @@
|
||||
# Testing Progress Report
|
||||
|
||||
## Текущий статус: ВСЕ ТЕСТЫ ЗАВЕРШЕНЫ! 🎉🎊🚀
|
||||
|
||||
Все UI snapshot тесты и все integration тесты готовы! Превзошли план!
|
||||
|
||||
Дата: 2026-01-30 (обновлено #6 — ФИНАЛ)
|
||||
|
||||
---
|
||||
|
||||
## ✅ Что сделано
|
||||
|
||||
### Phase 2: Integration Tests (99%) 🔥
|
||||
|
||||
**Всего:** 73 integration теста из 74 запланированных
|
||||
|
||||
#### Phase 2.1: Send Message Flow (100%) ✅
|
||||
**Файл**: `tests/send_message.rs` (6 тестов)
|
||||
|
||||
- ✅ Отправка текстового сообщения
|
||||
- ✅ Отправка нескольких сообщений обновляет список
|
||||
- ✅ Отправка с markdown форматированием
|
||||
- ✅ Отправка в разные чаты
|
||||
- ✅ Получение входящего сообщения
|
||||
- ✅ Отправка с reply
|
||||
|
||||
#### Phase 2.2: Edit Message Flow (100%) ✅
|
||||
**Файл**: `tests/edit_message.rs` (6 тестов)
|
||||
|
||||
- ✅ Редактирование текста сообщения
|
||||
- ✅ Установка edit_date после редактирования
|
||||
- ✅ Проверка can_be_edited перед редактированием
|
||||
- ✅ Редактирование только своих сообщений
|
||||
- ✅ Множественные редактирования
|
||||
- ✅ Редактирование с форматированием
|
||||
|
||||
#### Phase 2.3: Delete Message Flow (100%) ✅
|
||||
**Файл**: `tests/delete_message.rs` (6 тестов)
|
||||
|
||||
- ✅ Удаление сообщения из списка
|
||||
- ✅ Множественные удаления
|
||||
- ✅ Проверка can_be_deleted
|
||||
- ✅ Удаление только своих сообщений
|
||||
- ✅ Удаление из разных чатов
|
||||
- ✅ Delete with revoke
|
||||
|
||||
#### Phase 2.4: Reply & Forward Flow (100%) ✅
|
||||
**Файл**: `tests/reply_forward.rs` (8 тестов)
|
||||
|
||||
- ✅ Reply на сообщение с превью
|
||||
- ✅ Reply сохраняет связь с оригиналом
|
||||
- ✅ Forward сообщения
|
||||
- ✅ Forward с sender_name
|
||||
- ✅ Forward в разные чаты
|
||||
- ✅ Reply + Forward комбо
|
||||
- ✅ Reply на forwarded сообщение
|
||||
- ✅ Forward reply сообщения
|
||||
|
||||
#### Phase 2.5: Reactions Flow (100%) ✅
|
||||
**Файл**: `tests/reactions.rs` (10 тестов)
|
||||
|
||||
- ✅ Добавление реакции на сообщение
|
||||
- ✅ Удаление реакции (toggle)
|
||||
- ✅ Множественные реакции на одно сообщение
|
||||
- ✅ Реакции от разных пользователей
|
||||
- ✅ Подсчёт реакций
|
||||
- ✅ Chosen реакция (своя)
|
||||
- ✅ Реакции обновляются в реальном времени
|
||||
- ✅ Получение доступных реакций чата
|
||||
- ✅ Реакции на forwarded сообщения
|
||||
- ✅ Очистка всех реакций
|
||||
|
||||
#### Phase 2.6: Search Flow (100%) ✅
|
||||
**Файл**: `tests/search.rs` (8 тестов)
|
||||
|
||||
- ✅ Поиск по названию чата
|
||||
- ✅ Поиск по @username
|
||||
- ✅ Поиск по сообщениям в чате
|
||||
- ✅ Навигация по результатам поиска
|
||||
- ✅ Case-insensitive поиск
|
||||
- ✅ Поиск с пробелами
|
||||
- ✅ Поиск возвращает пустой список если нет совпадений
|
||||
- ✅ Очистка поиска
|
||||
|
||||
#### Phase 2.7: Drafts Flow (100%) ✅
|
||||
**Файл**: `tests/drafts.rs` (7 тестов)
|
||||
|
||||
- ✅ Сохранение черновика при переключении чатов
|
||||
- ✅ Восстановление черновика при возврате
|
||||
- ✅ Удаление черновика после отправки
|
||||
- ✅ Черновики для разных чатов независимы
|
||||
- ✅ Индикатор черновика в списке чатов
|
||||
- ✅ Пустой черновик не сохраняется
|
||||
- ✅ Черновик сохраняется при закрытии чата
|
||||
|
||||
#### Phase 2.8: Navigation Flow (100%) ✅
|
||||
**Файл**: `tests/navigation.rs` (7 тестов)
|
||||
|
||||
- ✅ Навигация по списку чатов (↑/↓)
|
||||
- ✅ Открытие чата (Enter)
|
||||
- ✅ Закрытие чата (Esc)
|
||||
- ✅ Скролл сообщений (↑/↓)
|
||||
- ✅ Переключение между папками (1-9)
|
||||
- ✅ Навигация с wrap (переход с конца на начало)
|
||||
- ✅ Навигация в пустом списке
|
||||
|
||||
#### Phase 2.9: Profile Flow (100%) ✅
|
||||
**Файл**: `tests/profile.rs` (6 тестов)
|
||||
|
||||
- ✅ Открытие профиля личного чата
|
||||
- ✅ Профиль показывает имя и username
|
||||
- ✅ Профиль показывает телефон
|
||||
- ✅ Открытие профиля группы
|
||||
- ✅ Профиль группы показывает участников
|
||||
- ✅ Закрытие профиля (Esc)
|
||||
|
||||
#### Phase 2.10: Network & Typing Flow (100%) ✅
|
||||
**Файл**: `tests/network_typing.rs` (9 тестов)
|
||||
|
||||
- ✅ Typing indicator при наборе текста
|
||||
- ✅ Отправка typing action
|
||||
- ✅ Получение typing статуса
|
||||
- ✅ Typing timeout
|
||||
- ✅ Network state: WaitingForNetwork
|
||||
- ✅ Network state: ConnectingToProxy
|
||||
- ✅ Network state: Connecting
|
||||
- ✅ Network state: Updating
|
||||
- ✅ Network state: Ready
|
||||
|
||||
#### Phase 2.11: Copy Flow (100%) ✅
|
||||
**Файл**: `tests/copy.rs` (9 тестов)
|
||||
|
||||
- ✅ Форматирование простого сообщения
|
||||
- ✅ Форматирование с forward контекстом
|
||||
- ✅ Форматирование с reply контекстом
|
||||
- ✅ Форматирование с forward + reply одновременно
|
||||
- ✅ Форматирование длинного сообщения
|
||||
- ✅ Форматирование с markdown entities
|
||||
- ✅ Clipboard initialization (игнорируется в CI)
|
||||
- ✅ Копирование в реальный clipboard (ручное тестирование)
|
||||
- ✅ Кроссплатформенность clipboard
|
||||
|
||||
#### Phase 2.12: Config Flow (100%) ✅
|
||||
**Файл**: `tests/config.rs` (11 тестов)
|
||||
|
||||
- ✅ Дефолтные значения конфигурации
|
||||
- ✅ Кастомные значения конфигурации
|
||||
- ✅ Парсинг валидных цветов (red, green, blue, etc.)
|
||||
- ✅ Парсинг light цветов (lightred, lightgreen, etc.)
|
||||
- ✅ Парсинг невалидного цвета с fallback на White
|
||||
- ✅ Case-insensitive парсинг цветов
|
||||
- ✅ TOML сериализация и десериализация
|
||||
- ✅ Частичный TOML использует дефолты
|
||||
- ✅ Различные форматы timezone (+03:00, -05:00, +00:00)
|
||||
- ✅ Загрузка credentials из переменных окружения
|
||||
- ✅ Проверка формата ошибки когда credentials не найдены
|
||||
|
||||
---
|
||||
|
||||
### Фаза 1: UI Snapshot Tests (100%) ✅
|
||||
|
||||
**Всего:** 55 snapshot тестов
|
||||
|
||||
#### Фаза 1.1: Chat List (100%) ✅
|
||||
**Файл**: `tests/chat_list.rs` (9 тестов)
|
||||
|
||||
#### Фаза 1.2: Messages (100%) ✅
|
||||
**Файл**: `tests/messages.rs` (18 тестов)
|
||||
|
||||
#### Фаза 1.3: Modals (100%) ✅
|
||||
**Файл**: `tests/modals.rs` (8 тестов)
|
||||
|
||||
#### Фаза 1.4: Input Field (100%) ✅
|
||||
**Файл**: `tests/input_field.rs` (7 тестов)
|
||||
|
||||
#### Snapshot тесты для поля ввода:
|
||||
- ✅ `snapshot_empty_input` — пустое поле ввода с плейсхолдером
|
||||
- ✅ `snapshot_input_with_text` — поле с текстом и курсором █
|
||||
- ✅ `snapshot_input_long_text_2_lines` — длинный текст на 2 строки
|
||||
- ✅ `snapshot_input_long_text_max_lines` — очень длинный текст (максимум 10 строк)
|
||||
- ✅ `snapshot_input_editing_mode` — режим редактирования с превью оригинального сообщения
|
||||
- ✅ `snapshot_input_reply_mode` — режим ответа с превью сообщения
|
||||
- ✅ `snapshot_input_search_mode` — поле поиска с query
|
||||
|
||||
#### Результаты:
|
||||
- **7 новых snapshot тестов** — все проходят ✅
|
||||
- **7 snapshots приняты** через `cargo insta accept`
|
||||
- **Все тесты проходят**: 90 тестов (21 chat_list + 19 input_field + 30 messages + 20 modals)
|
||||
|
||||
---
|
||||
|
||||
### Фаза 1.6: Screens Snapshot Tests (100%) ✅
|
||||
|
||||
**Файл**: `tests/screens.rs` (7 тестов)
|
||||
|
||||
#### Snapshot тесты для полных экранов:
|
||||
- ✅ `snapshot_loading_screen_default` — экран загрузки (дефолтный)
|
||||
- ✅ `snapshot_loading_screen_with_status` — экран загрузки со статусом
|
||||
- ✅ `snapshot_auth_screen_phone` — экран авторизации (ввод телефона)
|
||||
- ✅ `snapshot_auth_screen_code` — экран авторизации (ввод кода)
|
||||
- ✅ `snapshot_auth_screen_password` — экран авторизации (ввод пароля 2FA)
|
||||
- ✅ `snapshot_main_screen_empty` — главный экран (пустой список чатов)
|
||||
- ✅ `snapshot_main_screen_terminal_too_small` — предупреждение о маленьком терминале
|
||||
|
||||
#### Обновления TestAppBuilder:
|
||||
- ✅ Добавлен метод `status_message(message)` — установить статус для loading screen
|
||||
- ✅ Добавлен метод `auth_state(state)` — установить состояние авторизации
|
||||
- ✅ Добавлен метод `phone_input(phone)` — установить phone input
|
||||
- ✅ Добавлен метод `code_input(code)` — установить code input
|
||||
- ✅ Добавлен метод `password_input(password)` — установить password input
|
||||
- ✅ Добавлены поля: `status_message`, `auth_state`, `phone_input`, `code_input`, `password_input`
|
||||
- ✅ Обновлен `build()` — применяет auth состояние и inputs
|
||||
|
||||
#### Результаты:
|
||||
- **7 новых snapshot тестов** — все проходят ✅
|
||||
- **7 snapshots приняты** через `cargo insta accept`
|
||||
- **Все тесты проходят**: 127 тестов (21 chat_list + 19 input_field + 30 messages + 20 modals + 18 footer + 19 screens)
|
||||
|
||||
---
|
||||
|
||||
### Фаза 1.5: Footer Snapshot Tests (100%) ✅
|
||||
|
||||
**Файл**: `tests/footer.rs` (6 тестов)
|
||||
|
||||
#### Snapshot тесты для нижней панели:
|
||||
- ✅ `snapshot_footer_chat_list` — footer в списке чатов
|
||||
- ✅ `snapshot_footer_open_chat` — footer в открытом чате
|
||||
- ✅ `snapshot_footer_network_waiting` — footer с "⚠ Нет сети"
|
||||
- ✅ `snapshot_footer_network_connecting_proxy` — footer с "⏳ Прокси..."
|
||||
- ✅ `snapshot_footer_network_connecting` — footer с "⏳ Подключение..."
|
||||
- ✅ `snapshot_footer_search_mode` — footer в режиме поиска
|
||||
|
||||
#### Изменения:
|
||||
- ✅ Сделан `footer` модуль публичным в `src/ui/mod.rs`
|
||||
|
||||
#### Результаты:
|
||||
- **6 новых snapshot тестов** — все проходят ✅
|
||||
- **6 snapshots приняты** через `cargo insta accept`
|
||||
- **Все тесты проходят**: 96 тестов (21 chat_list + 19 input_field + 30 messages + 20 modals + 18 footer)
|
||||
|
||||
---
|
||||
|
||||
### Фаза 1.4: Input Field Snapshot Tests (100%) ✅
|
||||
|
||||
**Файл**: `tests/modals.rs` (8 тестов)
|
||||
|
||||
#### Snapshot тесты для модальных окон:
|
||||
- ✅ `snapshot_delete_confirmation_modal` — модалка подтверждения удаления
|
||||
- ✅ `snapshot_emoji_picker_default` — emoji picker с дефолтным выбором
|
||||
- ✅ `snapshot_emoji_picker_with_selection` — emoji picker с выбранной реакцией (курсор)
|
||||
- ✅ `snapshot_profile_personal_chat` — профиль личного чата
|
||||
- ✅ `snapshot_profile_group_chat` — профиль группы (с участниками)
|
||||
- ✅ `snapshot_pinned_message` — закреплённое сообщение вверху чата
|
||||
- ✅ `snapshot_search_in_chat` — поиск в чате с результатами
|
||||
- ✅ `snapshot_forward_mode` — режим пересылки (выбор чата)
|
||||
|
||||
#### Обновления TestAppBuilder:
|
||||
- ✅ Добавлен метод `with_chats(chats)` — добавить несколько чатов сразу
|
||||
- ✅ Добавлен метод `message_search(query)` — режим поиска по сообщениям
|
||||
- ✅ Добавлен метод `forward_mode(message_id)` — режим пересылки
|
||||
- ✅ Добавлены поля: `message_search_mode`, `message_search_query`, `forwarding_message_id`, `is_selecting_forward_chat`
|
||||
|
||||
#### Исправления:
|
||||
- ✅ Переименованы тесты с динамическими датами (today/yesterday) на фиксированный old_date
|
||||
- ✅ Удалены нестабильные snapshots зависящие от текущей даты
|
||||
- ✅ Все модальные режимы теперь тестируются через snapshots
|
||||
|
||||
#### Результаты:
|
||||
- **8 новых snapshot тестов** — все проходят ✅
|
||||
- **8 snapshots приняты** через `cargo insta accept`
|
||||
- **Все тесты проходят**: 71 тест (21 chat_list + 30 messages + 20 modals)
|
||||
|
||||
---
|
||||
|
||||
### Фаза 1.2: Messages Snapshot Tests (95%) ✅
|
||||
|
||||
**Файл**: `tests/messages.rs` (19 тестов)
|
||||
|
||||
#### Snapshot тесты для области сообщений:
|
||||
- ✅ `snapshot_empty_chat` — пустой чат без сообщений
|
||||
- ✅ `snapshot_single_incoming_message` — одно входящее сообщение
|
||||
- ✅ `snapshot_single_outgoing_message` — одно исходящее сообщение
|
||||
- ✅ `snapshot_date_separator_today` — разделитель "Сегодня"
|
||||
- ✅ `snapshot_date_separator_yesterday` — разделитель "Вчера"
|
||||
- ✅ `snapshot_sender_grouping` — группировка по отправителю (Alice → Alice → Bob)
|
||||
- ✅ `snapshot_outgoing_sent` — исходящее с ✓ (отправлено)
|
||||
- ✅ `snapshot_outgoing_read` — исходящее с ✓✓ (прочитано)
|
||||
- ✅ `snapshot_edited_message` — сообщение с индикатором ✎
|
||||
- ✅ `snapshot_long_message_wrap` — длинное сообщение с переносом
|
||||
- ✅ `snapshot_markdown_bold_italic_code` — **bold** *italic* `code`
|
||||
- ✅ `snapshot_markdown_link_mention` — [links](url) и @mentions
|
||||
- ✅ `snapshot_markdown_spoiler` — ||спойлер||
|
||||
- ✅ `snapshot_media_placeholder` — [Фото], [Видео] и т.д.
|
||||
- ✅ `snapshot_reply_message` — reply с превью оригинала
|
||||
- ✅ `snapshot_forwarded_message` — ↪ Переслано от Alice
|
||||
- ✅ `snapshot_single_reaction` — сообщение с одной реакцией [👍]
|
||||
- ✅ `snapshot_multiple_reactions` — [👍] 5 👎 3
|
||||
- ✅ `snapshot_selected_message` — выбранное сообщение (подсветка)
|
||||
|
||||
#### Обновления TestAppBuilder:
|
||||
- ✅ Добавлен метод `with_message(chat_id, message)` — добавить одно сообщение
|
||||
- ✅ Добавлен метод `with_messages(chat_id, messages)` — добавить несколько сообщений
|
||||
- ✅ Добавлен метод `selecting_message(index)` — установить выбранное сообщение
|
||||
- ✅ Обновлен `build()` — применяет сообщения к `app.td_client.current_chat_messages`
|
||||
|
||||
#### Результаты:
|
||||
- **19 новых snapshot тестов** — все проходят ✅
|
||||
- **19 snapshots приняты** через `cargo insta accept`
|
||||
- **Все тесты проходят**: 52 теста (21 chat_list + 31 messages)
|
||||
|
||||
---
|
||||
|
||||
### Фаза 0: Инфраструктура (100%)
|
||||
|
||||
#### 1. Зависимости
|
||||
- ✅ Добавлено `insta = "1.34"` для snapshot тестов
|
||||
- ✅ Добавлено `tokio-test = "0.4"` для async тестов
|
||||
- ✅ Настроен `.gitignore` для `.snap.new` файлов
|
||||
|
||||
#### 2. Test Helpers (5 модулей)
|
||||
|
||||
**`tests/helpers/mod.rs`**
|
||||
- Экспортирует все вспомогательные модули
|
||||
- Удобный доступ к TestAppBuilder, FakeTdClient и утилитам
|
||||
|
||||
**`tests/helpers/test_data.rs`**
|
||||
- ✅ `TestChatBuilder` — fluent API для создания тестовых чатов
|
||||
- ✅ `TestMessageBuilder` — fluent API для создания тестовых сообщений
|
||||
- ✅ Хелперы: `create_test_chat()`, `create_test_message()`, `create_test_user()`
|
||||
- ✅ Поддержка всех полей: unread, pinned, muted, mentions, reactions, reply, forward
|
||||
|
||||
**`tests/helpers/fake_tdclient.rs`**
|
||||
- ✅ `FakeTdClient` — in-memory мок для интеграционных тестов
|
||||
- ✅ Методы: `send_message()`, `edit_message()`, `delete_message()`, `add_reaction()`
|
||||
- ✅ Tracking отправленных/отредактированных/удалённых сообщений
|
||||
- ✅ Fluent API для построения клиента с данными
|
||||
- ✅ Встроенные юнит-тесты для проверки мока
|
||||
|
||||
**`tests/helpers/snapshot_utils.rs`**
|
||||
- ✅ `buffer_to_string()` — конвертация ratatui Buffer в строку для snapshots
|
||||
- ✅ `render_to_buffer()` — рендеринг UI в виртуальный терминал
|
||||
- ✅ `assert_ui_snapshot!` макрос для упрощения snapshot тестов
|
||||
- ✅ Удаление trailing spaces для чистых snapshots
|
||||
- ✅ Встроенные тесты
|
||||
|
||||
**`tests/helpers/app_builder.rs`**
|
||||
- ✅ `TestAppBuilder` — fluent API для создания тестового App
|
||||
- ✅ Методы: `with_chat()`, `selected_chat()`, `message_input()`, `searching()`, etc.
|
||||
- ✅ Поддержка всех режимов: edit, reply, search, reaction_picker, profile
|
||||
- ✅ Встроенные тесты для билдера
|
||||
|
||||
#### 3. Первые UI тесты
|
||||
|
||||
**`tests/ui/chat_list_test.rs`** (9 тестов)
|
||||
- ✅ snapshot_empty_chat_list
|
||||
- ✅ snapshot_chat_list_with_three_chats
|
||||
- ✅ snapshot_chat_with_unread_count
|
||||
- ✅ snapshot_chat_with_pinned
|
||||
- ✅ snapshot_chat_with_muted
|
||||
- ✅ snapshot_chat_with_mentions
|
||||
- ✅ snapshot_selected_chat
|
||||
- ✅ snapshot_chat_long_title
|
||||
- ✅ snapshot_chat_search_mode
|
||||
|
||||
---
|
||||
|
||||
## 📊 Метрики
|
||||
|
||||
**Создано файлов**: 18
|
||||
- 5 helpers
|
||||
- 6 snapshot test files (chat_list, messages, modals, input_field, footer, screens)
|
||||
- 10 integration test files (send_message, edit_message, delete_message, reply_forward, reactions, search, drafts, navigation, profile, network_typing)
|
||||
- 1 mod.rs
|
||||
|
||||
**Строк кода**: ~6500+
|
||||
- Helpers: ~1000 строк
|
||||
- Snapshot тесты: ~1200 строк
|
||||
- Integration тесты: ~4300 строк
|
||||
|
||||
**Тестов написано**:
|
||||
- Snapshot тесты: 55
|
||||
- Integration тесты: 73
|
||||
- Helper тесты: ~12
|
||||
- **Всего: 140+ тестов**
|
||||
|
||||
**Покрытие**:
|
||||
- Фаза 0: Инфраструктура ✅ (100%)
|
||||
- Фаза 1: UI Snapshot Tests ✅ (100%)
|
||||
- 1.1 Chat List: 9/9 ✅
|
||||
- 1.2 Messages: 18/18 ✅
|
||||
- 1.3 Modals: 8/8 ✅
|
||||
- 1.4 Input Field: 7/7 ✅
|
||||
- 1.5 Footer: 6/6 ✅
|
||||
- 1.6 Screens: 7/7 ✅
|
||||
- Фаза 2: Integration Tests ✅ (100%!)
|
||||
- 2.1 Send Message: 6/6 ✅
|
||||
- 2.2 Edit Message: 6/6 ✅
|
||||
- 2.3 Delete Message: 6/6 ✅
|
||||
- 2.4 Reply & Forward: 8/8 ✅
|
||||
- 2.5 Reactions: 10/10 ✅
|
||||
- 2.6 Search: 8/8 ✅
|
||||
- 2.7 Drafts: 7/7 ✅
|
||||
- 2.8 Navigation: 7/7 ✅
|
||||
- 2.9 Profile: 6/6 ✅
|
||||
- 2.10 Network & Typing: 9/9 ✅
|
||||
- 2.11 Copy: 9/9 ✅ (вместо 3!)
|
||||
- 2.12 Config: 11/11 ✅ (вместо 8!)
|
||||
- **Общий прогресс: 148/151 (98%) — ПРЕВЗОШЛИ ПЛАН!** 🎉
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Структура
|
||||
|
||||
```
|
||||
tests/
|
||||
├── helpers/
|
||||
│ ├── mod.rs ✅ Создан
|
||||
│ ├── app_builder.rs ✅ Создан + 5 тестов
|
||||
│ ├── fake_tdclient.rs ✅ Создан + 4 теста
|
||||
│ ├── snapshot_utils.rs ✅ Создан + 2 теста
|
||||
│ └── test_data.rs ✅ Создан
|
||||
└── ui/
|
||||
├── mod.rs ✅ Создан
|
||||
└── chat_list_test.rs ✅ Создан (9 snapshot тестов)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Примеры использования
|
||||
|
||||
### Создание тестового чата
|
||||
```rust
|
||||
let chat = TestChatBuilder::new("Mom", 123)
|
||||
.unread_count(5)
|
||||
.pinned()
|
||||
.muted()
|
||||
.draft("Hello...")
|
||||
.build();
|
||||
```
|
||||
|
||||
### Создание тестового App
|
||||
```rust
|
||||
let app = TestAppBuilder::new()
|
||||
.with_chat(chat)
|
||||
.selected_chat(123)
|
||||
.message_input("Hello!")
|
||||
.build();
|
||||
```
|
||||
|
||||
### Snapshot тест
|
||||
```rust
|
||||
#[test]
|
||||
fn snapshot_my_ui() {
|
||||
let app = TestAppBuilder::new()
|
||||
.with_chat(create_test_chat("Mom", 123))
|
||||
.build();
|
||||
|
||||
let buffer = render_to_buffer(80, 24, |f| {
|
||||
render_chat_list(f, f.size(), &app);
|
||||
});
|
||||
|
||||
assert_snapshot!("my_ui", buffer_to_string(&buffer));
|
||||
}
|
||||
```
|
||||
|
||||
### Мок клиент для интеграционных тестов
|
||||
```rust
|
||||
let mut client = FakeTdClient::new()
|
||||
.with_chat(create_test_chat("Mom", 123));
|
||||
|
||||
let msg_id = client.send_message(123, "Hello".to_string(), None);
|
||||
assert_eq!(client.sent_messages().len(), 1);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎉 ВСЕ ОСНОВНЫЕ ТЕСТЫ ЗАВЕРШЕНЫ!
|
||||
|
||||
### Прогресс: 98% (148/151 тестов) — ПРЕВЗОШЛИ ПЛАН! 🚀
|
||||
|
||||
**Все основные тесты готовы:**
|
||||
- ✅ Phase 0: Инфраструктура (100%)
|
||||
- ✅ Phase 1: UI Snapshot Tests (100%) — 55 тестов
|
||||
- ✅ Phase 2: Integration Tests (100%!) — 93 теста
|
||||
|
||||
**Превзошли план на 9 тестов!**
|
||||
- Copy Flow: 9 тестов (вместо 3)
|
||||
- Config Flow: 11 тестов (вместо 8)
|
||||
|
||||
### Опциональные тесты (можно сделать позже)
|
||||
|
||||
#### Фаза 3: E2E Smoke Tests (4 теста)
|
||||
**Файл**: `tests/e2e/smoke_test.rs`
|
||||
|
||||
- [ ] Приложение запускается без краша
|
||||
- [ ] Приложение рендерит loading screen
|
||||
- [ ] Приложение корректно завершается по Ctrl+C
|
||||
- [ ] Минимальный размер терминала не крашит приложение
|
||||
|
||||
**Примечание**: E2E тесты требуют реального TDLib или сложного мока, поэтому опциональны.
|
||||
|
||||
#### Фаза 4: Дополнительные тесты (8 тестов)
|
||||
|
||||
**4.1 Utils Tests** (5 тестов)
|
||||
- [ ] `format_timestamp_with_tz` с разными timezone
|
||||
- [ ] `parse_timezone_offset` валидные значения
|
||||
- [ ] `parse_timezone_offset` инвалидные значения (fallback)
|
||||
- [ ] `format_date` для сегодня, вчера, старых дат
|
||||
- [ ] `format_was_online` для разных временных промежутков
|
||||
|
||||
**4.2 Performance Benchmarks** (3 теста)
|
||||
- [ ] Benchmark рендеринга 100 сообщений
|
||||
- [ ] Benchmark рендеринга списка 50 чатов
|
||||
- [ ] Benchmark форматирования markdown текста
|
||||
|
||||
### Итого
|
||||
|
||||
**Завершено**: 148 тестов (98%)
|
||||
**Опционально**: 12 тестов (2%)
|
||||
**Всего**: 160 тестов потенциально
|
||||
|
||||
---
|
||||
|
||||
## 💡 Технические заметки
|
||||
|
||||
### Текущие ограничения
|
||||
1. **TestAppBuilder создаёт реальный TdClient** — подходит только для UI/snapshot тестов
|
||||
2. **Для интеграционных тестов** понадобится рефакторинг: либо trait для TdClient, либо dependency injection
|
||||
|
||||
### Решения
|
||||
- Snapshot тесты используют TestAppBuilder (UI рендеринг без вызова TdClient методов)
|
||||
- Интеграционные тесты будут использовать FakeTdClient напрямую
|
||||
- Возможно потребуется создать `IntegrationTestSession` для комплексных сценариев
|
||||
|
||||
---
|
||||
|
||||
## ✨ Качество кода
|
||||
|
||||
**Все helpers покрыты тестами**:
|
||||
- `app_builder.rs`: 5 тестов
|
||||
- `fake_tdclient.rs`: 4 теста
|
||||
- `snapshot_utils.rs`: 2 теста
|
||||
|
||||
**Документация**:
|
||||
- Все публичные функции имеют doc-комментарии
|
||||
- Примеры использования в комментариях
|
||||
- README-секция в TESTING_ROADMAP.md
|
||||
|
||||
---
|
||||
|
||||
## 🎓 Что изучили
|
||||
|
||||
1. **Snapshot testing** с insta — мощный инструмент для TUI
|
||||
2. **ratatui::backend::TestBackend** — виртуальный терминал для тестов
|
||||
3. **Fluent builder pattern** — удобно для построения тестовых данных
|
||||
4. **Test helpers organization** — разделение на модули для переиспользования
|
||||
|
||||
---
|
||||
|
||||
## 📝 Обновлённые файлы
|
||||
|
||||
- `Cargo.toml` — добавлены dev-dependencies
|
||||
- `.gitignore` — добавлены правила для snapshots
|
||||
- `TESTING_ROADMAP.md` — обновлён прогресс
|
||||
- `README.md` — добавлена ссылка на TESTING_ROADMAP
|
||||
- `REFACTORING_ROADMAP.md` — добавлено предусловие о тестах
|
||||
|
||||
---
|
||||
|
||||
**Статус**: Готов к продолжению! 🚀
|
||||
**Следующий шаг**: Запустить тесты и убедиться что всё компилируется, затем продолжить с Фазы 1.2
|
||||
620
TESTING_ROADMAP.md
Normal file
620
TESTING_ROADMAP.md
Normal file
@@ -0,0 +1,620 @@
|
||||
# Testing Roadmap
|
||||
|
||||
План покрытия tele-tui тестами с фокусом на интеграционные и e2e тесты.
|
||||
|
||||
## Стратегия тестирования
|
||||
|
||||
### Подход: Комбо (Snapshot + Integration + E2E)
|
||||
|
||||
1. **Snapshot Testing (70%)** — проверка UI рендеринга через insta
|
||||
2. **Integration Testing (25%)** — проверка логики и flow через FakeTdClient
|
||||
3. **E2E Smoke Testing (5%)** — базовая проверка что приложение запускается
|
||||
|
||||
### Почему не юнит-тесты?
|
||||
|
||||
- TUI сложно тестировать через юниты (моки, хрупкость)
|
||||
- Интеграционные тесты дают больше уверенности
|
||||
- Snapshots ловят UI регрессии лучше, чем assert координат
|
||||
|
||||
---
|
||||
|
||||
## Фаза 0: Инфраструктура
|
||||
|
||||
### Зависимости
|
||||
|
||||
- [x] Добавить `insta = "1.34"` в dev-dependencies
|
||||
- [x] Добавить `tokio-test = "0.4"` в dev-dependencies
|
||||
- [x] Настроить `.gitignore` для snapshots (добавить `tests/snapshots/*.new`)
|
||||
|
||||
### Helpers и Test Utilities
|
||||
|
||||
- [x] Создать `tests/helpers/mod.rs`
|
||||
- [x] Создать `tests/helpers/app_builder.rs` — builder для тестового App
|
||||
- [x] Создать `tests/helpers/fake_tdclient.rs` — mock TDLib клиент
|
||||
- [x] Создать `tests/helpers/snapshot_utils.rs` — утилиты для snapshot тестов
|
||||
- [x] Создать `tests/helpers/test_data.rs` — фикстуры данных (чаты, сообщения)
|
||||
|
||||
```rust
|
||||
// tests/helpers/mod.rs
|
||||
pub mod app_builder;
|
||||
pub mod fake_tdclient;
|
||||
pub mod snapshot_utils;
|
||||
pub mod test_data;
|
||||
|
||||
pub use app_builder::TestAppBuilder;
|
||||
pub use fake_tdclient::FakeTdClient;
|
||||
pub use snapshot_utils::{render_to_string, assert_ui_snapshot};
|
||||
pub use test_data::{create_test_chat, create_test_message};
|
||||
```
|
||||
|
||||
**Файлы для создания**:
|
||||
```
|
||||
tests/
|
||||
├── helpers/
|
||||
│ ├── mod.rs
|
||||
│ ├── app_builder.rs
|
||||
│ ├── fake_tdclient.rs
|
||||
│ ├── snapshot_utils.rs
|
||||
│ └── test_data.rs
|
||||
└── snapshots/ # Создаётся insta автоматически
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Фаза 1: Snapshot Tests для UI (Приоритет: ВЫСОКИЙ)
|
||||
|
||||
### 1.1 Chat List — Список чатов
|
||||
|
||||
**Файл**: `tests/ui/chat_list_test.rs`
|
||||
|
||||
- [x] Пустой список чатов
|
||||
- [x] Список с 3 чатами (без индикаторов)
|
||||
- [x] Чат с непрочитанными сообщениями `(5)`
|
||||
- [x] Чат с иконкой закреплённого 📌
|
||||
- [x] Чат с иконкой mute 🔇
|
||||
- [x] Чат с индикатором mention @
|
||||
- [ ] Чат с онлайн-статусом ●
|
||||
- [x] Выбранный чат (с ▌)
|
||||
- [x] Список чатов в режиме поиска
|
||||
- [x] Длинное название чата (обрезка)
|
||||
|
||||
**Пример теста**:
|
||||
```rust
|
||||
#[test]
|
||||
fn snapshot_chat_list_with_unread() {
|
||||
let app = TestAppBuilder::new()
|
||||
.with_chat(create_test_chat("Mom", 123, unread: 5))
|
||||
.with_chat(create_test_chat("Boss", 456, unread: 0))
|
||||
.build();
|
||||
|
||||
assert_ui_snapshot!("chat_list_with_unread", app, |f, app| {
|
||||
render_chat_list(f, f.size(), app);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 1.2 Messages — Область сообщений
|
||||
|
||||
**Файл**: `tests/messages.rs`
|
||||
|
||||
- [x] Пустой чат (нет сообщений)
|
||||
- [x] Одно входящее сообщение
|
||||
- [x] Одно исходящее сообщение
|
||||
- [x] Группировка по дате (разделитель "Сегодня")
|
||||
- [x] Группировка по дате (разделитель "Вчера")
|
||||
- [x] Группировка по отправителю (заголовок с именем)
|
||||
- [x] Исходящее сообщение с ✓ (отправлено)
|
||||
- [x] Исходящее сообщение с ✓✓ (прочитано)
|
||||
- [x] Сообщение с индикатором редактирования ✎
|
||||
- [x] Длинное сообщение (wrap на несколько строк)
|
||||
- [x] Markdown: жирный, курсив, код
|
||||
- [x] Markdown: ссылка, упоминание
|
||||
- [x] Markdown: спойлер
|
||||
- [x] Сообщение с медиа-заглушкой [Фото]
|
||||
- [x] Reply сообщение с превью
|
||||
- [x] Пересланное сообщение (↪ Переслано от)
|
||||
- [x] Сообщение с одной реакцией [👍]
|
||||
- [x] Сообщение с несколькими реакциями [👍] 5 👎 3
|
||||
- [x] Выбранное сообщение (подсветка)
|
||||
|
||||
---
|
||||
|
||||
### 1.3 Modals — Модальные окна
|
||||
|
||||
**Файл**: `tests/modals.rs`
|
||||
|
||||
- [x] Delete confirmation модалка
|
||||
- [x] Emoji picker (8x6 сетка)
|
||||
- [x] Emoji picker с выбранной реакцией (курсор)
|
||||
- [x] Profile модалка (личный чат)
|
||||
- [x] Profile модалка (группа)
|
||||
- [x] Pinned message вверху чата
|
||||
- [x] Search в чате (с результатами)
|
||||
- [x] Forward mode (список чатов для пересылки)
|
||||
|
||||
---
|
||||
|
||||
### 1.4 Input Field — Поле ввода
|
||||
|
||||
**Файл**: `tests/input_field.rs`
|
||||
|
||||
- [x] Пустое поле ввода
|
||||
- [x] Поле ввода с текстом и курсором █
|
||||
- [x] Поле ввода с длинным текстом (2 строки)
|
||||
- [x] Поле ввода с длинным текстом (10 строк, максимум)
|
||||
- [x] Режим редактирования (с превью)
|
||||
- [x] Режим reply (с превью сообщения)
|
||||
- [x] Режим поиска (с query)
|
||||
|
||||
---
|
||||
|
||||
### 1.5 Footer — Нижняя панель ✅
|
||||
|
||||
**Файл**: `tests/footer.rs`
|
||||
|
||||
- [x] Footer в списке чатов (команды навигации)
|
||||
- [x] Footer в открытом чате (команды сообщений)
|
||||
- [x] Footer с индикатором "⚠ Нет сети"
|
||||
- [x] Footer с индикатором "⏳ Подключение к прокси..."
|
||||
- [x] Footer с индикатором "⏳ Подключение..."
|
||||
- [x] Footer в режиме поиска
|
||||
|
||||
---
|
||||
|
||||
### 1.6 Screens — Полные экраны ✅
|
||||
|
||||
**Файл**: `tests/screens.rs`
|
||||
|
||||
- [x] Loading screen (default)
|
||||
- [x] Loading screen (со статусом)
|
||||
- [x] Auth screen (ввод телефона)
|
||||
- [x] Auth screen (ввод кода)
|
||||
- [x] Auth screen (ввод пароля 2FA)
|
||||
- [x] Main screen (пустой список чатов)
|
||||
- [x] Минимальный размер терминала (предупреждение)
|
||||
|
||||
---
|
||||
|
||||
## Фаза 2: Integration Tests для логики (Приоритет: ВЫСОКИЙ)
|
||||
|
||||
### 2.1 Send Message Flow ✅
|
||||
|
||||
**Файл**: `tests/send_message.rs` (6 тестов)
|
||||
|
||||
- [x] Отправка текстового сообщения
|
||||
- [x] Отправка нескольких сообщений
|
||||
- [x] Отправка с markdown форматированием
|
||||
- [x] Отправка в разные чаты
|
||||
- [x] Получение входящего сообщения
|
||||
- [x] Отправка с reply
|
||||
|
||||
---
|
||||
|
||||
### 2.2 Edit Message Flow ✅
|
||||
|
||||
**Файл**: `tests/edit_message.rs` (6 тестов)
|
||||
|
||||
- [x] Редактирование текста сообщения
|
||||
- [x] Установка edit_date после редактирования
|
||||
- [x] Проверка can_be_edited перед редактированием
|
||||
- [x] Редактирование только своих сообщений
|
||||
- [x] Множественные редактирования
|
||||
- [x] Редактирование с форматированием
|
||||
|
||||
---
|
||||
|
||||
### 2.3 Delete Message Flow ✅
|
||||
|
||||
**Файл**: `tests/delete_message.rs` (6 тестов)
|
||||
|
||||
- [x] Удаление сообщения из списка
|
||||
- [x] Множественные удаления
|
||||
- [x] Проверка can_be_deleted
|
||||
- [x] Удаление только своих сообщений
|
||||
- [x] Удаление из разных чатов
|
||||
- [x] Delete with revoke
|
||||
|
||||
---
|
||||
|
||||
### 2.4 Reply & Forward Flow ✅
|
||||
|
||||
**Файл**: `tests/reply_forward.rs` (8 тестов)
|
||||
|
||||
- [x] Reply на сообщение с превью
|
||||
- [x] Reply сохраняет связь с оригиналом
|
||||
- [x] Forward сообщения
|
||||
- [x] Forward с sender_name
|
||||
- [x] Forward в разные чаты
|
||||
- [x] Reply + Forward комбо
|
||||
- [x] Reply на forwarded сообщение
|
||||
- [x] Forward reply сообщения
|
||||
|
||||
---
|
||||
|
||||
### 2.5 Reactions Flow ✅
|
||||
|
||||
**Файл**: `tests/reactions.rs` (10 тестов)
|
||||
|
||||
- [x] Добавление реакции на сообщение
|
||||
- [x] Удаление реакции (toggle)
|
||||
- [x] Множественные реакции на одно сообщение
|
||||
- [x] Реакции от разных пользователей
|
||||
- [x] Подсчёт реакций
|
||||
- [x] Chosen реакция (своя)
|
||||
- [x] Реакции обновляются в реальном времени
|
||||
- [x] Получение доступных реакций чата
|
||||
- [x] Реакции на forwarded сообщения
|
||||
- [x] Очистка всех реакций
|
||||
|
||||
---
|
||||
|
||||
### 2.6 Search Flow ✅
|
||||
|
||||
**Файл**: `tests/search.rs` (8 тестов)
|
||||
|
||||
- [x] Поиск по названию чата
|
||||
- [x] Поиск по @username
|
||||
- [x] Поиск по сообщениям в чате
|
||||
- [x] Навигация по результатам поиска
|
||||
- [x] Case-insensitive поиск
|
||||
- [x] Поиск с пробелами
|
||||
- [x] Поиск возвращает пустой список если нет совпадений
|
||||
- [x] Очистка поиска
|
||||
|
||||
---
|
||||
|
||||
### 2.7 Drafts Flow ✅
|
||||
|
||||
**Файл**: `tests/drafts.rs` (7 тестов)
|
||||
|
||||
- [x] Сохранение черновика при переключении чатов
|
||||
- [x] Восстановление черновика при возврате
|
||||
- [x] Удаление черновика после отправки
|
||||
- [x] Черновики для разных чатов независимы
|
||||
- [x] Индикатор черновика в списке чатов
|
||||
- [x] Пустой черновик не сохраняется
|
||||
- [x] Черновик сохраняется при закрытии чата
|
||||
|
||||
---
|
||||
|
||||
### 2.8 Navigation Flow ✅
|
||||
|
||||
**Файл**: `tests/navigation.rs` (7 тестов)
|
||||
|
||||
- [x] Навигация по списку чатов (↑/↓)
|
||||
- [x] Открытие чата (Enter)
|
||||
- [x] Закрытие чата (Esc)
|
||||
- [x] Скролл сообщений (↑/↓)
|
||||
- [x] Переключение между папками (1-9)
|
||||
- [x] Навигация с wrap (переход с конца на начало)
|
||||
- [x] Навигация в пустом списке
|
||||
|
||||
---
|
||||
|
||||
### 2.9 Profile Flow ✅
|
||||
|
||||
**Файл**: `tests/profile.rs` (6 тестов)
|
||||
|
||||
- [x] Открытие профиля личного чата
|
||||
- [x] Профиль показывает имя и username
|
||||
- [x] Профиль показывает телефон
|
||||
- [x] Открытие профиля группы
|
||||
- [x] Профиль группы показывает участников
|
||||
- [x] Закрытие профиля (Esc)
|
||||
|
||||
---
|
||||
|
||||
### 2.10 Network & Typing Flow ✅
|
||||
|
||||
**Файл**: `tests/network_typing.rs` (9 тестов)
|
||||
|
||||
- [x] Typing indicator при наборе текста
|
||||
- [x] Отправка typing action
|
||||
- [x] Получение typing статуса
|
||||
- [x] Typing timeout
|
||||
- [x] Network state: WaitingForNetwork
|
||||
- [x] Network state: ConnectingToProxy
|
||||
- [x] Network state: Connecting
|
||||
- [x] Network state: Updating
|
||||
- [x] Network state: Ready
|
||||
|
||||
---
|
||||
|
||||
### 2.11 Copy Flow ✅
|
||||
|
||||
**Файл**: `tests/copy.rs` (9 тестов - ПРЕВЗОШЛИ ПЛАН!)
|
||||
|
||||
- [x] Форматирование простого сообщения
|
||||
- [x] Форматирование с forward контекстом
|
||||
- [x] Форматирование с reply контекстом
|
||||
- [x] Форматирование с forward + reply одновременно
|
||||
- [x] Форматирование длинного сообщения
|
||||
- [x] Форматирование с markdown entities
|
||||
- [x] Clipboard initialization
|
||||
- [x] Копирование в реальный clipboard (ручное)
|
||||
- [x] Кроссплатформенность clipboard
|
||||
|
||||
---
|
||||
|
||||
### 2.12 Config Flow ✅
|
||||
|
||||
**Файл**: `tests/config.rs` (11 тестов - ПРЕВЗОШЛИ ПЛАН!)
|
||||
|
||||
- [x] Дефолтные значения конфигурации
|
||||
- [x] Кастомные значения конфигурации
|
||||
- [x] Парсинг валидных цветов
|
||||
- [x] Парсинг light цветов
|
||||
- [x] Парсинг невалидного цвета с fallback
|
||||
- [x] Case-insensitive парсинг цветов
|
||||
- [x] TOML сериализация и десериализация
|
||||
- [x] Частичный TOML использует дефолты
|
||||
- [x] Различные форматы timezone
|
||||
- [x] Загрузка credentials из переменных окружения
|
||||
- [x] Проверка формата ошибки когда credentials не найдены
|
||||
|
||||
---
|
||||
|
||||
## Фаза 3: E2E Integration Tests (Приоритет: СРЕДНИЙ) ✅
|
||||
|
||||
### 3.1 Smoke Tests ✅
|
||||
**Файл**: `tests/e2e_smoke.rs` (4 теста)
|
||||
|
||||
- [x] Приложение запускается без краша
|
||||
- [x] Проверка минимального размера терминала
|
||||
- [x] Базовые константы приложения
|
||||
- [x] Graceful shutdown флаг
|
||||
|
||||
### 3.2 User Journey Tests ✅
|
||||
**Файл**: `tests/e2e_user_journey.rs` (8 тестов)
|
||||
|
||||
- [x] App Launch → Auth → Chat List
|
||||
- [x] Open Chat → Load History → Send Message
|
||||
- [x] Receive Incoming Message While Chat Open
|
||||
- [x] Multi-step conversation flow
|
||||
- [x] Switch between chats
|
||||
- [x] Edit message in conversation flow
|
||||
- [x] Reply to message in conversation
|
||||
- [x] Network state changes during conversation
|
||||
|
||||
**Итого**: 12/12 E2E тестов (100%) ✅
|
||||
|
||||
**Примечание**: Все тесты используют FakeTdClient для полной симуляции TDLib без реального подключения.
|
||||
|
||||
---
|
||||
|
||||
## Фаза 4: Дополнительные тесты (Приоритет: НИЗКИЙ)
|
||||
|
||||
### 4.1 Utils Tests
|
||||
|
||||
**Файл**: `tests/unit/utils_test.rs`
|
||||
|
||||
- [ ] `format_timestamp_with_tz` с разными timezone
|
||||
- [ ] `parse_timezone_offset` валидные значения
|
||||
- [ ] `parse_timezone_offset` инвалидные значения (fallback)
|
||||
- [ ] `format_date` для сегодня, вчера, старых дат
|
||||
- [ ] `format_was_online` для разных временных промежутков
|
||||
|
||||
### 4.2 Performance Tests
|
||||
|
||||
**Файл**: `tests/performance/render_bench.rs`
|
||||
|
||||
- [ ] Benchmark рендеринга 100 сообщений
|
||||
- [ ] Benchmark рендеринга списка 50 чатов
|
||||
- [ ] Benchmark форматирования markdown текста
|
||||
|
||||
---
|
||||
|
||||
## Метрики прогресса
|
||||
|
||||
### Фаза 0: Инфраструктура
|
||||
- [x] 8/8 задач выполнено ✅
|
||||
|
||||
### Фаза 1: Snapshot Tests ✅
|
||||
- [x] 1.1 Chat List: 10/10 (100%) ✅
|
||||
- [x] 1.2 Messages: 19/19 (100%) ✅
|
||||
- [x] 1.3 Modals: 8/8 (100%) ✅
|
||||
- [x] 1.4 Input Field: 7/7 (100%) ✅
|
||||
- [x] 1.5 Footer: 6/6 (100%) ✅
|
||||
- [x] 1.6 Screens: 7/7 (100%) ✅
|
||||
- **Итого: 57/57 snapshot тестов (100%)** ✅
|
||||
|
||||
### Фаза 2: Integration Tests ✅
|
||||
- [x] 2.1 Send Message: 6/6 ✅
|
||||
- [x] 2.2 Edit Message: 6/6 ✅
|
||||
- [x] 2.3 Delete Message: 6/6 ✅
|
||||
- [x] 2.4 Reply & Forward: 8/8 ✅
|
||||
- [x] 2.5 Reactions: 10/10 ✅
|
||||
- [x] 2.6 Search: 8/8 ✅
|
||||
- [x] 2.7 Drafts: 7/7 ✅
|
||||
- [x] 2.8 Navigation: 7/7 ✅
|
||||
- [x] 2.9 Profile: 6/6 ✅
|
||||
- [x] 2.10 Network & Typing: 9/9 ✅
|
||||
- [x] 2.11 Copy: 9/9 ✅ (вместо 3!)
|
||||
- [x] 2.12 Config: 11/11 ✅ (вместо 8!)
|
||||
- **Итого: 93/93 интеграционных тестов (100%!) — ПРЕВЗОШЛИ ПЛАН!** 🎉
|
||||
|
||||
### Фаза 3: E2E Integration
|
||||
- [x] 3.1 Smoke Tests: 4/4 ✅
|
||||
- [x] 3.2 User Journey: 8/8 ✅
|
||||
- **Итого: 12/12 E2E тестов (100%)** ✅
|
||||
|
||||
### Фаза 4: Дополнительно
|
||||
- [ ] 4.1 Utils: 0/5
|
||||
- [ ] 4.2 Performance: 0/3
|
||||
- **Итого: 0/8 дополнительных тестов**
|
||||
|
||||
---
|
||||
|
||||
## Общий прогресс
|
||||
|
||||
**Всего**: 164/171 тестов (96%) — ПРЕВЗОШЛИ ПЛАН! 🎉🎉🎉
|
||||
|
||||
**Фаза 0 (Инфраструктура)**: ✅ Завершена (100%)
|
||||
**Фаза 1 (UI Snapshot Tests)**: ✅ 57/57 (100%) — ЗАВЕРШЕНА! 🎉
|
||||
- 1.1 Chat List: 10/10 (включая онлайн-статус) ✅
|
||||
- 1.2 Messages: 19/19 ✅
|
||||
- 1.3 Modals: 8/8 ✅
|
||||
- 1.4 Input Field: 7/7 ✅
|
||||
- 1.5 Footer: 6/6 ✅
|
||||
- 1.6 Screens: 7/7 ✅
|
||||
|
||||
**Фаза 2 (Integration Tests)**: ✅ 93/93 (100%!) — ПРЕВЗОШЛИ ПЛАН!
|
||||
- Завершено: 2.1-2.12 ✅
|
||||
- Превзошли план на 9 тестов: Copy (9 вместо 3), Config (11 вместо 8)
|
||||
|
||||
**Фаза 3 (E2E Integration Tests)**: ✅ 12/12 (100%) — ЗАВЕРШЕНА! 🎉
|
||||
- Smoke Tests: 4/4 ✅
|
||||
- User Journey: 8/8 ✅
|
||||
|
||||
**Опционально**:
|
||||
- Фаза 4 (Utils + Performance): 0/8
|
||||
|
||||
---
|
||||
|
||||
## Приоритизация
|
||||
|
||||
### Критичные (делать в первую очередь):
|
||||
1. **Фаза 0**: Инфраструктура (без неё никуда)
|
||||
2. **1.2**: Messages snapshots (ядро приложения)
|
||||
3. **2.1**: Send message (основной flow)
|
||||
4. **2.8**: Navigation (базовая навигация)
|
||||
|
||||
### Важные (делать после критичных):
|
||||
5. **1.1**: Chat list snapshots
|
||||
6. **2.2**: Edit message
|
||||
7. **2.3**: Delete message
|
||||
8. **2.5**: Reactions
|
||||
9. **2.6**: Search
|
||||
|
||||
### Желательные (можно отложить):
|
||||
10. **1.3-1.6**: Остальные snapshots
|
||||
11. **2.4, 2.7, 2.9-2.12**: Остальные flows
|
||||
12. **Фаза 3**: E2E smoke tests
|
||||
|
||||
### Опциональные (по желанию):
|
||||
13. **Фаза 4**: Utils и performance
|
||||
|
||||
---
|
||||
|
||||
## Технологии
|
||||
|
||||
### Основные
|
||||
- **insta** — snapshot testing
|
||||
- **tokio-test** — async testing utilities
|
||||
- **ratatui::backend::TestBackend** — виртуальный терминал
|
||||
|
||||
### Дополнительные (опционально)
|
||||
- **expectrl** — для E2E тестов с реальным бинарником
|
||||
- **criterion** — для бенчмарков (фаза 4.2)
|
||||
- **mockall** — если понадобятся моки (скорее всего нет)
|
||||
|
||||
---
|
||||
|
||||
## Примеры структуры тестов
|
||||
|
||||
### Snapshot Test
|
||||
```rust
|
||||
use insta::assert_snapshot;
|
||||
use ratatui::backend::TestBackend;
|
||||
use ratatui::Terminal;
|
||||
|
||||
#[test]
|
||||
fn snapshot_messages_with_reactions() {
|
||||
let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
|
||||
|
||||
let app = TestAppBuilder::new()
|
||||
.with_message(create_test_message("Hello!", reactions: vec![
|
||||
reaction("👍", 1, chosen: true),
|
||||
reaction("👎", 3, chosen: false),
|
||||
]))
|
||||
.build();
|
||||
|
||||
terminal.draw(|f| {
|
||||
render_messages(f, f.size(), &app);
|
||||
}).unwrap();
|
||||
|
||||
let buffer = terminal.backend().buffer();
|
||||
assert_snapshot!(buffer_to_string(buffer));
|
||||
}
|
||||
```
|
||||
|
||||
### Integration Test
|
||||
```rust
|
||||
use crate::helpers::{TestAppBuilder, FakeTdClient};
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_send_message_updates_ui() {
|
||||
let fake_client = FakeTdClient::new()
|
||||
.with_chat("Mom", 123);
|
||||
|
||||
let mut app = TestAppBuilder::new()
|
||||
.with_client(fake_client)
|
||||
.with_selected_chat(123)
|
||||
.build();
|
||||
|
||||
// Ввод текста
|
||||
app.input_text = "Hello!".to_string();
|
||||
|
||||
// Отправка
|
||||
app.handle_key(KeyCode::Enter).await;
|
||||
|
||||
// Проверки
|
||||
assert_eq!(app.input_text, ""); // Инпут очистился
|
||||
assert_eq!(app.current_messages().len(), 1);
|
||||
assert_eq!(app.current_messages()[0].text, "Hello!");
|
||||
assert_eq!(fake_client.sent_messages().len(), 1);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Команды
|
||||
|
||||
```bash
|
||||
# Прогнать все тесты
|
||||
cargo test
|
||||
|
||||
# Прогнать только snapshot тесты
|
||||
cargo test --test ui
|
||||
|
||||
# Прогнать только integration тесты
|
||||
cargo test --test integration
|
||||
|
||||
# Обновить snapshots (после ревью изменений)
|
||||
cargo insta review
|
||||
|
||||
# Принять все новые snapshots
|
||||
cargo insta accept
|
||||
|
||||
# Показать diff для изменённых snapshots
|
||||
cargo insta test --review
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Правила
|
||||
|
||||
1. **Один тест = один сценарий** — не делать мега-тесты
|
||||
2. **Snapshots коммитим** — они часть тестов
|
||||
3. **Фикстуры переиспользуем** — общие данные в `test_data.rs`
|
||||
4. **Тесты изолированы** — каждый тест создаёт свой App
|
||||
5. **Порядок не важен** — тесты можно запускать в любом порядке
|
||||
|
||||
---
|
||||
|
||||
## TODO перед началом
|
||||
|
||||
- [ ] Прочитать документацию insta: https://insta.rs/
|
||||
- [ ] Решить: нужен ли trait для TdClient или достаточно FakeTdClient
|
||||
- [ ] Обсудить: какие тесты делать в первую очередь
|
||||
|
||||
---
|
||||
|
||||
## Примечания
|
||||
|
||||
- Этот документ будет обновляться по мере написания тестов
|
||||
- После завершения фазы — отмечать в метриках
|
||||
- Если тест падает или не актуален — документировать причину
|
||||
- Snapshots хранятся в `tests/snapshots/__snapshots__/`
|
||||
@@ -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,8 +1,6 @@
|
||||
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;
|
||||
use tdlib_rs::enums::{TextEntity, TextEntityType};
|
||||
|
||||
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();
|
||||
@@ -11,27 +9,27 @@ fn create_text_with_entities() -> (String, Vec<TextEntity>) {
|
||||
TextEntity {
|
||||
offset: 8,
|
||||
length: 4, // bold
|
||||
r#type: TextEntityType::Bold,
|
||||
type_: TextEntityType::Bold,
|
||||
},
|
||||
TextEntity {
|
||||
offset: 17,
|
||||
length: 6, // italic
|
||||
r#type: TextEntityType::Italic,
|
||||
type_: TextEntityType::Italic,
|
||||
},
|
||||
TextEntity {
|
||||
offset: 34,
|
||||
length: 4, // code
|
||||
r#type: TextEntityType::Code,
|
||||
type_: TextEntityType::Code,
|
||||
},
|
||||
TextEntity {
|
||||
offset: 45,
|
||||
length: 4, // link
|
||||
r#type: TextEntityType::Url,
|
||||
type_: TextEntityType::Url,
|
||||
},
|
||||
TextEntity {
|
||||
offset: 54,
|
||||
length: 7, // mention
|
||||
r#type: TextEntityType::Mention,
|
||||
type_: TextEntityType::Mention,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -43,7 +41,9 @@ fn benchmark_format_simple_text(c: &mut Criterion) {
|
||||
let entities = vec![];
|
||||
|
||||
c.bench_function("format_simple_text", |b| {
|
||||
b.iter(|| format_text_with_entities(black_box(&text), black_box(&entities), Color::White));
|
||||
b.iter(|| {
|
||||
format_text_with_entities(black_box(&text), black_box(&entities))
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -51,7 +51,9 @@ 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));
|
||||
b.iter(|| {
|
||||
format_text_with_entities(black_box(&text), black_box(&entities))
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -69,13 +71,15 @@ fn benchmark_format_long_text(c: &mut Criterion) {
|
||||
entities.push(TextEntity {
|
||||
offset: start as i32,
|
||||
length: format!("Word{}", i).len() as i32,
|
||||
r#type: TextEntityType::Bold,
|
||||
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));
|
||||
b.iter(|| {
|
||||
format_text_with_entities(black_box(&text), black_box(&entities))
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
use criterion::{black_box, criterion_group, criterion_main, Criterion};
|
||||
use tele_tui::utils::formatting::{format_date, format_timestamp, get_day};
|
||||
use tele_tui::utils::formatting::{format_timestamp_with_tz, format_date, 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));
|
||||
black_box(format_timestamp_with_tz(timestamp, "+03:00"));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -34,5 +34,10 @@ fn benchmark_get_day(c: &mut Criterion) {
|
||||
});
|
||||
}
|
||||
|
||||
criterion_group!(benches, benchmark_format_timestamp, benchmark_format_date, benchmark_get_day);
|
||||
criterion_group!(
|
||||
benches,
|
||||
benchmark_format_timestamp,
|
||||
benchmark_format_date,
|
||||
benchmark_get_day
|
||||
);
|
||||
criterion_main!(benches);
|
||||
@@ -7,11 +7,8 @@ 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
|
||||
))
|
||||
.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 {
|
||||
@@ -27,7 +24,9 @@ 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)));
|
||||
b.iter(|| {
|
||||
group_messages(black_box(&messages))
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -35,7 +34,9 @@ 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)));
|
||||
b.iter(|| {
|
||||
group_messages(black_box(&messages))
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
35
config.example.toml
Normal file
35
config.example.toml
Normal file
@@ -0,0 +1,35 @@
|
||||
# Telegram TUI Configuration Example
|
||||
# Copy this file to ~/.config/tele-tui/config.toml
|
||||
|
||||
[general]
|
||||
# Timezone offset (e.g., "+03:00", "-05:00")
|
||||
timezone = "+03:00"
|
||||
|
||||
[colors]
|
||||
# Colors: red, green, blue, yellow, cyan, magenta, white, black, gray
|
||||
# Also available: lightred, lightgreen, lightblue, lightyellow, lightcyan, lightmagenta
|
||||
incoming_message = "white"
|
||||
outgoing_message = "green"
|
||||
selected_message = "yellow"
|
||||
reaction_chosen = "yellow"
|
||||
reaction_other = "gray"
|
||||
|
||||
[notifications]
|
||||
# Enable desktop notifications for new messages
|
||||
enabled = true
|
||||
|
||||
# Only notify when you are mentioned (@username)
|
||||
only_mentions = false
|
||||
|
||||
# Show message preview text in notifications
|
||||
show_preview = true
|
||||
|
||||
# Notification timeout in milliseconds (0 = system default)
|
||||
timeout_ms = 5000
|
||||
|
||||
# Notification urgency level: "low", "normal", "critical"
|
||||
# Note: Only works on Linux (libnotify), ignored on macOS/Windows
|
||||
urgency = "normal"
|
||||
|
||||
# Note: Notifications respect Telegram's mute settings
|
||||
# Muted chats won't trigger notifications
|
||||
@@ -3,6 +3,11 @@
|
||||
# Этот файл автоматически создаётся при первом запуске в ~/.config/tele-tui/config.toml
|
||||
# Скопируйте его туда и настройте по своему усмотрению
|
||||
|
||||
[general]
|
||||
# Часовой пояс в формате "+03:00" или "-05:00"
|
||||
# Применяется к отображению времени сообщений
|
||||
timezone = "+03:00"
|
||||
|
||||
[colors]
|
||||
# Цветовая схема интерфейса
|
||||
# Поддерживаемые цвета:
|
||||
|
||||
@@ -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,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,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,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,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,182 +0,0 @@
|
||||
use std::io;
|
||||
use std::time::Duration;
|
||||
|
||||
use crossterm::{
|
||||
event::{
|
||||
self, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
|
||||
Event, KeyCode, KeyEvent, KeyModifiers,
|
||||
},
|
||||
execute,
|
||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
};
|
||||
use ratatui::{backend::CrosstermBackend, Terminal};
|
||||
use tele_tui::{
|
||||
app::{App, AppScreen},
|
||||
input::handle_main_input,
|
||||
test_support::{
|
||||
app_builder::TestAppBuilder,
|
||||
fake_tdclient::FakeTdClient,
|
||||
test_data::{TestChatBuilder, TestMessageBuilder},
|
||||
},
|
||||
};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> io::Result<()> {
|
||||
let scenario = parse_scenario();
|
||||
let mut app = build_app(&scenario);
|
||||
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen, EnableMouseCapture, EnableBracketedPaste)?;
|
||||
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
let result = run_fixture(&mut terminal, &mut app).await;
|
||||
|
||||
disable_raw_mode()?;
|
||||
execute!(
|
||||
terminal.backend_mut(),
|
||||
LeaveAlternateScreen,
|
||||
DisableMouseCapture,
|
||||
DisableBracketedPaste
|
||||
)?;
|
||||
terminal.show_cursor()?;
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn parse_scenario() -> String {
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
if arg == "--scenario" {
|
||||
return args.next().unwrap_or_else(|| "inbox".to_string());
|
||||
}
|
||||
}
|
||||
"inbox".to_string()
|
||||
}
|
||||
|
||||
async fn run_fixture(
|
||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||
app: &mut App<FakeTdClient>,
|
||||
) -> io::Result<()> {
|
||||
loop {
|
||||
if app.needs_redraw {
|
||||
terminal.draw(|f| tele_tui::ui::render(f, app))?;
|
||||
app.needs_redraw = false;
|
||||
}
|
||||
|
||||
if event::poll(Duration::from_millis(16))? {
|
||||
match event::read()? {
|
||||
Event::Key(key) => {
|
||||
if key.code == KeyCode::Char('c')
|
||||
&& key.modifiers.contains(KeyModifiers::CONTROL)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
if key.code == KeyCode::F(10) {
|
||||
return Ok(());
|
||||
}
|
||||
handle_main_input(app, normalize_fixture_key(key)).await;
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
Event::Resize(_, _) => {
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
Event::Paste(text) => {
|
||||
for ch in text.chars() {
|
||||
handle_main_input(
|
||||
app,
|
||||
KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_fixture_key(key: KeyEvent) -> KeyEvent {
|
||||
match (key.code, key.modifiers) {
|
||||
(KeyCode::Char('/'), KeyModifiers::NONE) => {
|
||||
KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)
|
||||
}
|
||||
(KeyCode::Char('j' | 'm'), modifiers) if modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)
|
||||
}
|
||||
_ => key,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_app(scenario: &str) -> App<FakeTdClient> {
|
||||
match scenario {
|
||||
"open-chat" => TestAppBuilder::new()
|
||||
.screen(AppScreen::Main)
|
||||
.with_chats(sample_chats())
|
||||
.selected_chat(102)
|
||||
.with_messages(102, sample_messages())
|
||||
.build(),
|
||||
"compose-draft" => TestAppBuilder::new()
|
||||
.screen(AppScreen::Main)
|
||||
.with_chats(sample_chats())
|
||||
.selected_chat(102)
|
||||
.message_input("hello from e2e")
|
||||
.with_messages(102, sample_messages())
|
||||
.build(),
|
||||
"inbox" => TestAppBuilder::new()
|
||||
.screen(AppScreen::Main)
|
||||
.with_chats(sample_chats())
|
||||
.with_messages(101, mom_messages())
|
||||
.with_messages(102, sample_messages())
|
||||
.with_messages(103, boss_messages())
|
||||
.build(),
|
||||
other => {
|
||||
eprintln!("unknown scenario: {other}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_chats() -> Vec<tele_tui::tdlib::ChatInfo> {
|
||||
vec![
|
||||
TestChatBuilder::new("Mom", 101)
|
||||
.last_message("Dinner at 7?")
|
||||
.unread_count(2)
|
||||
.build(),
|
||||
TestChatBuilder::new("Work Group", 102)
|
||||
.last_message("Standup notes are ready")
|
||||
.unread_mentions(1)
|
||||
.build(),
|
||||
TestChatBuilder::new("Boss", 103)
|
||||
.last_message("Please review the deck")
|
||||
.build(),
|
||||
]
|
||||
}
|
||||
|
||||
fn sample_messages() -> Vec<tele_tui::tdlib::MessageInfo> {
|
||||
vec![
|
||||
TestMessageBuilder::new("Morning, team", 201)
|
||||
.sender("Alice")
|
||||
.build(),
|
||||
TestMessageBuilder::new("Standup notes are ready", 202)
|
||||
.sender("Bob")
|
||||
.build(),
|
||||
TestMessageBuilder::new("Thanks, I will review them after lunch", 203)
|
||||
.outgoing()
|
||||
.build(),
|
||||
]
|
||||
}
|
||||
|
||||
fn mom_messages() -> Vec<tele_tui::tdlib::MessageInfo> {
|
||||
vec![TestMessageBuilder::new("Dinner at 7?", 301)
|
||||
.sender("Mom")
|
||||
.build()]
|
||||
}
|
||||
|
||||
fn boss_messages() -> Vec<tele_tui::tdlib::MessageInfo> {
|
||||
vec![TestMessageBuilder::new("Please review the deck", 401)
|
||||
.sender("Boss")
|
||||
.build()]
|
||||
}
|
||||
@@ -1,458 +0,0 @@
|
||||
//! Chat input handlers
|
||||
//!
|
||||
//! Handles keyboard input when a chat is open, including:
|
||||
//! - Message scrolling and navigation
|
||||
//! - Message selection and actions
|
||||
//! - Editing and sending messages
|
||||
//! - Loading older messages
|
||||
|
||||
mod media;
|
||||
|
||||
use super::chat_loader::{load_older_messages_if_needed, open_chat_and_load_data};
|
||||
use crate::app::methods::{
|
||||
compose::ComposeMethods, messages::MessageMethods, modal::ModalMethods,
|
||||
navigation::NavigationMethods,
|
||||
};
|
||||
use crate::app::App;
|
||||
use crate::app::InputMode;
|
||||
use crate::input::handlers::{copy_to_clipboard, format_message_for_clipboard};
|
||||
use crate::tdlib::{ChatAction, TdClientTrait};
|
||||
use crate::types::{ChatId, MessageId};
|
||||
use crate::utils::{is_non_empty, with_timeout_msg};
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Обработка режима выбора сообщения для действий
|
||||
///
|
||||
/// Обрабатывает:
|
||||
/// - Навигацию по сообщениям (Up/Down)
|
||||
/// - Удаление сообщения (d/в/Delete)
|
||||
/// - Ответ на сообщение (r/к)
|
||||
/// - Пересылку сообщения (f/а)
|
||||
/// - Копирование сообщения (y/н)
|
||||
/// - Добавление реакции (e/у)
|
||||
pub async fn handle_message_selection<T: TdClientTrait>(
|
||||
app: &mut App<T>,
|
||||
_key: KeyEvent,
|
||||
command: Option<crate::config::Command>,
|
||||
) {
|
||||
match command {
|
||||
Some(crate::config::Command::MoveUp) => {
|
||||
app.select_previous_message();
|
||||
}
|
||||
Some(crate::config::Command::MoveDown) => {
|
||||
app.select_next_message();
|
||||
}
|
||||
Some(crate::config::Command::DeleteMessage) => {
|
||||
let Some(msg) = app.get_selected_message() else {
|
||||
return;
|
||||
};
|
||||
let can_delete =
|
||||
msg.can_be_deleted_only_for_self() || msg.can_be_deleted_for_all_users();
|
||||
if can_delete {
|
||||
app.chat_state = crate::app::ChatState::DeleteConfirmation { message_id: msg.id() };
|
||||
}
|
||||
}
|
||||
Some(crate::config::Command::EnterInsertMode) => {
|
||||
app.input_mode = InputMode::Insert;
|
||||
app.chat_state = crate::app::ChatState::Normal;
|
||||
}
|
||||
Some(crate::config::Command::ReplyMessage) => {
|
||||
app.start_reply_to_selected();
|
||||
app.input_mode = InputMode::Insert;
|
||||
}
|
||||
Some(crate::config::Command::ForwardMessage) => {
|
||||
app.start_forward_selected();
|
||||
}
|
||||
Some(crate::config::Command::CopyMessage) => {
|
||||
let Some(msg) = app.get_selected_message() else {
|
||||
return;
|
||||
};
|
||||
let text = format_message_for_clipboard(&msg);
|
||||
match copy_to_clipboard(&text) {
|
||||
Ok(_) => {
|
||||
app.status_message = Some("Сообщение скопировано".to_string());
|
||||
}
|
||||
Err(e) => {
|
||||
app.error_message = Some(format!("Ошибка копирования: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(crate::config::Command::ViewImage) => {
|
||||
media::handle_view_or_play_media(app).await;
|
||||
}
|
||||
Some(crate::config::Command::TogglePlayback) => {
|
||||
media::handle_toggle_voice_playback(app).await;
|
||||
}
|
||||
Some(crate::config::Command::SeekForward | crate::config::Command::MoveRight) => {
|
||||
media::handle_voice_seek(app, 5.0);
|
||||
}
|
||||
Some(crate::config::Command::SeekBackward | crate::config::Command::MoveLeft) => {
|
||||
media::handle_voice_seek(app, -5.0);
|
||||
}
|
||||
Some(crate::config::Command::ReactMessage) => {
|
||||
let Some(chat_id) = app.selected_chat_id else {
|
||||
app.error_message = Some("Чат не выбран".to_string());
|
||||
return;
|
||||
};
|
||||
let Some(msg) = app.get_selected_message() else {
|
||||
return;
|
||||
};
|
||||
let message_id = msg.id();
|
||||
|
||||
app.status_message = Some("Загрузка реакций...".to_string());
|
||||
app.needs_redraw = true;
|
||||
|
||||
match with_timeout_msg(
|
||||
Duration::from_secs(5),
|
||||
app.td_client
|
||||
.get_message_available_reactions(chat_id, message_id),
|
||||
"Таймаут загрузки реакций",
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(reactions) => {
|
||||
let reactions: Vec<String> = reactions;
|
||||
if reactions.is_empty() {
|
||||
app.error_message =
|
||||
Some("Реакции недоступны для этого сообщения".to_string());
|
||||
app.status_message = None;
|
||||
app.needs_redraw = true;
|
||||
} else {
|
||||
app.enter_reaction_picker_mode(message_id.as_i64(), reactions);
|
||||
app.status_message = None;
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
app.error_message = Some(e);
|
||||
app.status_message = None;
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Редактирование существующего сообщения
|
||||
pub async fn edit_message<T: TdClientTrait>(
|
||||
app: &mut App<T>,
|
||||
chat_id: i64,
|
||||
msg_id: MessageId,
|
||||
text: String,
|
||||
) {
|
||||
// Проверяем, что сообщение есть в локальном кэше
|
||||
let msg_exists = app
|
||||
.td_client
|
||||
.current_chat_messages()
|
||||
.iter()
|
||||
.any(|m| m.id() == msg_id);
|
||||
|
||||
if !msg_exists {
|
||||
app.error_message =
|
||||
Some(format!("Сообщение {} не найдено в кэше чата {}", msg_id.as_i64(), chat_id));
|
||||
app.chat_state = crate::app::ChatState::Normal;
|
||||
app.message_input.clear();
|
||||
app.cursor_position = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
match with_timeout_msg(
|
||||
Duration::from_secs(5),
|
||||
app.td_client
|
||||
.edit_message(ChatId::new(chat_id), msg_id, text),
|
||||
"Таймаут редактирования",
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(mut edited_msg) => {
|
||||
// Сохраняем reply_to из старого сообщения (если есть)
|
||||
app.td_client.update_current_chat_messages(|messages| {
|
||||
if let Some(pos) = messages.iter().position(|m| m.id() == msg_id) {
|
||||
let old_reply_to = messages[pos].interactions.reply_to.clone();
|
||||
// Если в старом сообщении был reply и в новом он "Unknown" - сохраняем старый
|
||||
if let Some(old_reply) = old_reply_to {
|
||||
if edited_msg
|
||||
.interactions
|
||||
.reply_to
|
||||
.as_ref()
|
||||
.is_none_or(|r| r.sender_name == "Unknown")
|
||||
{
|
||||
edited_msg.interactions.reply_to = Some(old_reply);
|
||||
}
|
||||
}
|
||||
// Заменяем сообщение
|
||||
messages[pos] = edited_msg;
|
||||
}
|
||||
});
|
||||
// Очищаем инпут и сбрасываем состояние ПОСЛЕ успешного редактирования
|
||||
app.message_input.clear();
|
||||
app.cursor_position = 0;
|
||||
app.chat_state = crate::app::ChatState::Normal;
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
Err(e) => {
|
||||
app.error_message = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Отправка нового сообщения (с опциональным reply)
|
||||
pub async fn send_new_message<T: TdClientTrait>(app: &mut App<T>, chat_id: i64, text: String) {
|
||||
let reply_to_id = if app.is_replying() {
|
||||
app.chat_state.selected_message_id()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Создаём ReplyInfo ДО отправки, пока сообщение точно доступно
|
||||
let reply_info = app
|
||||
.get_replying_to_message()
|
||||
.map(|m| crate::tdlib::ReplyInfo {
|
||||
message_id: m.id(),
|
||||
sender_name: m.sender_name().to_string(),
|
||||
text: m.text().to_string(),
|
||||
});
|
||||
|
||||
app.message_input.clear();
|
||||
app.cursor_position = 0;
|
||||
// Сбрасываем режим reply если он был активен
|
||||
if app.is_replying() {
|
||||
app.chat_state = crate::app::ChatState::Normal;
|
||||
}
|
||||
app.last_typing_sent = None;
|
||||
|
||||
// Отменяем typing status
|
||||
app.td_client
|
||||
.send_chat_action(ChatId::new(chat_id), ChatAction::Cancel)
|
||||
.await;
|
||||
|
||||
match with_timeout_msg(
|
||||
Duration::from_secs(5),
|
||||
app.td_client
|
||||
.send_message(ChatId::new(chat_id), text, reply_to_id, reply_info),
|
||||
"Таймаут отправки",
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(sent_msg) => {
|
||||
// Добавляем отправленное сообщение в список (с лимитом)
|
||||
app.td_client.push_message(sent_msg);
|
||||
// Сбрасываем скролл чтобы видеть новое сообщение
|
||||
app.message_scroll_offset = 0;
|
||||
}
|
||||
Err(e) => {
|
||||
app.error_message = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Обработка клавиши Enter
|
||||
///
|
||||
/// Обрабатывает три сценария:
|
||||
/// 1. В режиме выбора сообщения: начать редактирование
|
||||
/// 2. В открытом чате: отправить новое или редактировать существующее сообщение
|
||||
/// 3. В списке чатов: открыть выбранный чат
|
||||
pub async fn handle_enter_key<T: TdClientTrait>(app: &mut App<T>) {
|
||||
// Сценарий 1: Открытие чата из списка
|
||||
if app.selected_chat_id.is_none() {
|
||||
let prev_selected = app.selected_chat_id;
|
||||
app.select_current_chat();
|
||||
|
||||
if app.selected_chat_id != prev_selected {
|
||||
if let Some(chat_id) = app.get_selected_chat_id() {
|
||||
open_chat_and_load_data(app, chat_id).await;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Сценарий 2: Режим выбора сообщения - начать редактирование
|
||||
if app.is_selecting_message() {
|
||||
if app.start_editing_selected() {
|
||||
app.input_mode = InputMode::Insert;
|
||||
} else {
|
||||
// Нельзя редактировать это сообщение
|
||||
app.chat_state = crate::app::ChatState::Normal;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Сценарий 3: Отправка или редактирование сообщения
|
||||
if !is_non_empty(&app.message_input) {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(chat_id) = app.get_selected_chat_id() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let text = app.message_input.clone();
|
||||
|
||||
if app.is_editing() {
|
||||
// Редактирование существующего сообщения
|
||||
if let Some(msg_id) = app.chat_state.selected_message_id() {
|
||||
edit_message(app, chat_id, msg_id, text).await;
|
||||
}
|
||||
} else {
|
||||
// Отправка нового сообщения
|
||||
send_new_message(app, chat_id, text).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Отправляет реакцию на выбранное сообщение
|
||||
pub async fn send_reaction<T: TdClientTrait>(app: &mut App<T>) {
|
||||
// Get selected reaction emoji
|
||||
let Some(emoji) = app.get_selected_reaction().cloned() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Get selected message ID
|
||||
let Some(message_id) = app.get_selected_message_for_reaction() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Get chat ID
|
||||
let Some(chat_id) = app.selected_chat_id else {
|
||||
return;
|
||||
};
|
||||
|
||||
let message_id = MessageId::new(message_id);
|
||||
app.status_message = Some("Отправка реакции...".to_string());
|
||||
app.needs_redraw = true;
|
||||
|
||||
// Send reaction with timeout
|
||||
let result = with_timeout_msg(
|
||||
Duration::from_secs(5),
|
||||
app.td_client
|
||||
.toggle_reaction(chat_id, message_id, emoji.clone()),
|
||||
"Таймаут отправки реакции",
|
||||
)
|
||||
.await;
|
||||
|
||||
// Handle result
|
||||
match result {
|
||||
Ok(_) => {
|
||||
app.status_message = Some(format!("Реакция {} добавлена", emoji));
|
||||
app.exit_reaction_picker_mode();
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
Err(e) => {
|
||||
app.error_message = Some(e);
|
||||
app.status_message = None;
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Обработка ввода клавиатуры в открытом чате
|
||||
///
|
||||
/// Обрабатывает:
|
||||
/// - Backspace/Delete: удаление символов относительно курсора
|
||||
/// - Char: вставка символов в позицию курсора + typing status
|
||||
/// - Left/Right/Home/End: навигация курсора
|
||||
/// - Up/Down: скролл сообщений или начало режима выбора
|
||||
pub async fn handle_open_chat_keyboard_input<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
|
||||
match key.code {
|
||||
KeyCode::Backspace => {
|
||||
// Удаляем символ слева от курсора
|
||||
if app.cursor_position > 0 {
|
||||
let chars: Vec<char> = app.message_input.chars().collect();
|
||||
let mut new_input = String::new();
|
||||
for (i, ch) in chars.iter().enumerate() {
|
||||
if i != app.cursor_position - 1 {
|
||||
new_input.push(*ch);
|
||||
}
|
||||
}
|
||||
app.message_input = new_input;
|
||||
app.cursor_position -= 1;
|
||||
}
|
||||
}
|
||||
KeyCode::Delete => {
|
||||
// Удаляем символ справа от курсора
|
||||
let len = app.message_input.chars().count();
|
||||
if app.cursor_position < len {
|
||||
let chars: Vec<char> = app.message_input.chars().collect();
|
||||
let mut new_input = String::new();
|
||||
for (i, ch) in chars.iter().enumerate() {
|
||||
if i != app.cursor_position {
|
||||
new_input.push(*ch);
|
||||
}
|
||||
}
|
||||
app.message_input = new_input;
|
||||
}
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
// Игнорируем символы с Ctrl/Alt модификаторами (кроме Shift)
|
||||
// Это позволяет обрабатывать хоткеи типа Ctrl+I для профиля
|
||||
if key.modifiers.contains(KeyModifiers::CONTROL)
|
||||
|| key.modifiers.contains(KeyModifiers::ALT)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Вставляем символ в позицию курсора
|
||||
let chars: Vec<char> = app.message_input.chars().collect();
|
||||
let mut new_input = String::new();
|
||||
for (i, ch) in chars.iter().enumerate() {
|
||||
if i == app.cursor_position {
|
||||
new_input.push(c);
|
||||
}
|
||||
new_input.push(*ch);
|
||||
}
|
||||
if app.cursor_position >= chars.len() {
|
||||
new_input.push(c);
|
||||
}
|
||||
app.message_input = new_input;
|
||||
app.cursor_position += 1;
|
||||
|
||||
// Отправляем typing status с throttling (не чаще 1 раза в 5 сек)
|
||||
let should_send_typing = app
|
||||
.last_typing_sent
|
||||
.map(|t| t.elapsed().as_secs() >= 5)
|
||||
.unwrap_or(true);
|
||||
if should_send_typing {
|
||||
if let Some(chat_id) = app.get_selected_chat_id() {
|
||||
app.td_client
|
||||
.send_chat_action(ChatId::new(chat_id), ChatAction::Typing)
|
||||
.await;
|
||||
app.last_typing_sent = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Left => {
|
||||
// Курсор влево
|
||||
if app.cursor_position > 0 {
|
||||
app.cursor_position -= 1;
|
||||
}
|
||||
}
|
||||
KeyCode::Right => {
|
||||
// Курсор вправо
|
||||
let len = app.message_input.chars().count();
|
||||
if app.cursor_position < len {
|
||||
app.cursor_position += 1;
|
||||
}
|
||||
}
|
||||
KeyCode::Home => {
|
||||
// Курсор в начало
|
||||
app.cursor_position = 0;
|
||||
}
|
||||
KeyCode::End => {
|
||||
// Курсор в конец
|
||||
app.cursor_position = app.message_input.chars().count();
|
||||
}
|
||||
// Стрелки вверх/вниз - скролл сообщений (в Insert mode)
|
||||
KeyCode::Down => {
|
||||
if app.message_scroll_offset > 0 {
|
||||
app.message_scroll_offset = app.message_scroll_offset.saturating_sub(3);
|
||||
}
|
||||
}
|
||||
KeyCode::Up => {
|
||||
// В Insert mode — только скролл
|
||||
app.message_scroll_offset += 3;
|
||||
load_older_messages_if_needed(app).await;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -1,328 +0,0 @@
|
||||
//! Media actions for the open chat input handler.
|
||||
|
||||
use crate::app::methods::messages::MessageMethods;
|
||||
use crate::app::App;
|
||||
use crate::tdlib::TdClientTrait;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Обработка команды ViewImage — только фото.
|
||||
pub(super) async fn handle_view_or_play_media<T: TdClientTrait>(app: &mut App<T>) {
|
||||
let Some(msg) = app.get_selected_message() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if msg.has_photo() {
|
||||
#[cfg(feature = "images")]
|
||||
handle_view_image(app).await;
|
||||
#[cfg(not(feature = "images"))]
|
||||
{
|
||||
app.status_message = Some("Просмотр изображений отключён".to_string());
|
||||
}
|
||||
} else {
|
||||
app.status_message = Some("Сообщение не содержит фото".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Space: play/pause toggle для голосовых сообщений.
|
||||
pub(super) async fn handle_toggle_voice_playback<T: TdClientTrait>(app: &mut App<T>) {
|
||||
use crate::tdlib::PlaybackStatus;
|
||||
|
||||
if let Some(ref mut playback) = app.playback_state {
|
||||
if let Some(ref player) = app.audio_player {
|
||||
match playback.status {
|
||||
PlaybackStatus::Playing => {
|
||||
player.pause();
|
||||
playback.status = PlaybackStatus::Paused;
|
||||
app.last_playback_tick = None;
|
||||
app.status_message = Some("⏸ Пауза".to_string());
|
||||
}
|
||||
PlaybackStatus::Paused => {
|
||||
let resume_pos = (playback.position - 1.0).max(0.0);
|
||||
if player.resume_from(resume_pos).is_ok() {
|
||||
playback.position = resume_pos;
|
||||
} else {
|
||||
player.resume();
|
||||
}
|
||||
playback.status = PlaybackStatus::Playing;
|
||||
app.last_playback_tick = Some(Instant::now());
|
||||
app.status_message = Some("▶ Воспроизведение".to_string());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(msg) = app.get_selected_message() else {
|
||||
return;
|
||||
};
|
||||
if msg.has_voice() {
|
||||
handle_play_voice(app).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Seek голосового сообщения на delta секунд.
|
||||
pub(super) fn handle_voice_seek<T: TdClientTrait>(app: &mut App<T>, delta: f32) {
|
||||
use crate::tdlib::PlaybackStatus;
|
||||
|
||||
let Some(ref mut playback) = app.playback_state else {
|
||||
return;
|
||||
};
|
||||
let Some(ref player) = app.audio_player else {
|
||||
return;
|
||||
};
|
||||
|
||||
let was_playing = matches!(playback.status, PlaybackStatus::Playing);
|
||||
let was_paused = matches!(playback.status, PlaybackStatus::Paused);
|
||||
|
||||
if was_playing || was_paused {
|
||||
let new_position = (playback.position + delta).clamp(0.0, playback.duration);
|
||||
|
||||
if was_playing {
|
||||
if player.resume_from(new_position).is_ok() {
|
||||
playback.position = new_position;
|
||||
app.last_playback_tick = Some(Instant::now());
|
||||
}
|
||||
} else {
|
||||
player.stop();
|
||||
playback.position = new_position;
|
||||
}
|
||||
|
||||
let arrow = if delta > 0.0 { "→" } else { "←" };
|
||||
app.status_message = Some(format!("{} {:.0}s", arrow, new_position));
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "images")]
|
||||
async fn handle_view_image<T: TdClientTrait>(app: &mut App<T>) {
|
||||
use crate::tdlib::{ImageModalState, PhotoDownloadState};
|
||||
|
||||
if !app.config().images.show_images {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(msg) = app.get_selected_message() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if !msg.has_photo() {
|
||||
app.status_message = Some("Сообщение не содержит фото".to_string());
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(photo) = msg.photo_info() else {
|
||||
app.status_message = Some("Сообщение не содержит фото".to_string());
|
||||
return;
|
||||
};
|
||||
let msg_id = msg.id();
|
||||
let file_id = photo.file_id;
|
||||
let photo_width = photo.width;
|
||||
let photo_height = photo.height;
|
||||
let download_state = photo.download_state.clone();
|
||||
|
||||
match download_state {
|
||||
PhotoDownloadState::Downloaded(path) => {
|
||||
app.image_modal = Some(ImageModalState {
|
||||
message_id: msg_id,
|
||||
photo_path: path,
|
||||
photo_width,
|
||||
photo_height,
|
||||
});
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
PhotoDownloadState::NotDownloaded | PhotoDownloadState::Downloading => {
|
||||
app.pending_image_open = Some(crate::app::PendingImageOpen {
|
||||
file_id,
|
||||
message_id: msg_id,
|
||||
photo_width,
|
||||
photo_height,
|
||||
});
|
||||
app.status_message = Some("Загрузка фото...".to_string());
|
||||
app.needs_redraw = true;
|
||||
|
||||
if app.photo_download_rx.is_none() {
|
||||
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
app.photo_download_rx = Some(rx);
|
||||
let client_id = app.td_client.client_id();
|
||||
tokio::spawn(async move {
|
||||
let result = tokio::time::timeout(Duration::from_secs(30), async {
|
||||
match tdlib_rs::functions::download_file(file_id, 1, 0, 0, true, client_id)
|
||||
.await
|
||||
{
|
||||
Ok(tdlib_rs::enums::File::File(f))
|
||||
if f.local.is_downloading_completed && !f.local.path.is_empty() =>
|
||||
{
|
||||
Ok(f.local.path)
|
||||
}
|
||||
Ok(_) => Err("Файл не скачан".to_string()),
|
||||
Err(e) => Err(format!("{:?}", e)),
|
||||
}
|
||||
})
|
||||
.await;
|
||||
let result = result.unwrap_or_else(|_| Err("Таймаут загрузки".to_string()));
|
||||
let _ = tx.send((file_id, result));
|
||||
});
|
||||
}
|
||||
}
|
||||
PhotoDownloadState::Error(_) => {
|
||||
app.status_message = Some("Повторная загрузка фото...".to_string());
|
||||
app.needs_redraw = true;
|
||||
match app.td_client.download_file(file_id).await {
|
||||
Ok(path) => {
|
||||
app.td_client.update_current_chat_messages(|messages| {
|
||||
for msg in messages {
|
||||
if let Some(photo) = msg.photo_info_mut() {
|
||||
if photo.file_id == file_id {
|
||||
photo.download_state =
|
||||
PhotoDownloadState::Downloaded(path.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
app.image_modal = Some(ImageModalState {
|
||||
message_id: msg_id,
|
||||
photo_path: path,
|
||||
photo_width,
|
||||
photo_height,
|
||||
});
|
||||
app.status_message = None;
|
||||
}
|
||||
Err(e) => {
|
||||
app.error_message = Some(format!("Ошибка загрузки фото: {}", e));
|
||||
app.status_message = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_play_voice_from_path<T: TdClientTrait>(
|
||||
app: &mut App<T>,
|
||||
path: &str,
|
||||
voice: &crate::tdlib::VoiceInfo,
|
||||
msg: &crate::tdlib::MessageInfo,
|
||||
) {
|
||||
use crate::tdlib::{PlaybackState, PlaybackStatus};
|
||||
|
||||
if let Some(ref player) = app.audio_player {
|
||||
match player.play(path) {
|
||||
Ok(_) => {
|
||||
app.playback_state = Some(PlaybackState {
|
||||
message_id: msg.id(),
|
||||
status: PlaybackStatus::Playing,
|
||||
position: 0.0,
|
||||
duration: voice.duration as f32,
|
||||
volume: player.volume(),
|
||||
});
|
||||
app.last_playback_tick = Some(Instant::now());
|
||||
app.status_message = Some(format!("▶ Воспроизведение ({:.0}s)", voice.duration));
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
Err(e) => {
|
||||
app.error_message = Some(format!("Ошибка воспроизведения: {}", e));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
app.error_message = Some("Аудиоплеер не инициализирован".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_play_voice<T: TdClientTrait>(app: &mut App<T>) {
|
||||
use crate::tdlib::VoiceDownloadState;
|
||||
|
||||
let Some(msg) = app.get_selected_message() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if !msg.has_voice() {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(voice) = msg.voice_info() else {
|
||||
app.status_message = Some("Сообщение не содержит голосовое".to_string());
|
||||
return;
|
||||
};
|
||||
let file_id = voice.file_id;
|
||||
|
||||
match &voice.download_state {
|
||||
VoiceDownloadState::Downloaded(path) => {
|
||||
use std::path::Path;
|
||||
let audio_path = if Path::new(path).exists() {
|
||||
path.clone()
|
||||
} else {
|
||||
let with_oga = format!("{}.oga", path);
|
||||
if Path::new(&with_oga).exists() {
|
||||
with_oga
|
||||
} else {
|
||||
if let Some(parent) = Path::new(path).parent() {
|
||||
if let Some(stem) = Path::new(path).file_name() {
|
||||
if let Ok(entries) = std::fs::read_dir(parent) {
|
||||
for entry in entries.flatten() {
|
||||
let entry_name = entry.file_name();
|
||||
if entry_name
|
||||
.to_string_lossy()
|
||||
.starts_with(&stem.to_string_lossy().to_string())
|
||||
{
|
||||
let found_path = entry.path().to_string_lossy().to_string();
|
||||
if let Some(ref mut cache) = app.voice_cache {
|
||||
let _ = cache.store(
|
||||
&file_id.to_string(),
|
||||
Path::new(&found_path),
|
||||
);
|
||||
}
|
||||
return handle_play_voice_from_path(
|
||||
app,
|
||||
&found_path,
|
||||
voice,
|
||||
&msg,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
app.error_message = Some(format!("Файл не найден: {}", path));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(ref mut cache) = app.voice_cache {
|
||||
let _ = cache.store(&file_id.to_string(), Path::new(&audio_path));
|
||||
}
|
||||
|
||||
handle_play_voice_from_path(app, &audio_path, voice, &msg).await;
|
||||
}
|
||||
VoiceDownloadState::Downloading => {
|
||||
app.status_message = Some("Загрузка голосового...".to_string());
|
||||
}
|
||||
VoiceDownloadState::NotDownloaded => {
|
||||
let cache_key = file_id.to_string();
|
||||
if let Some(cached_path) = app.voice_cache.as_mut().and_then(|c| c.get(&cache_key)) {
|
||||
let path_str = cached_path.to_string_lossy().to_string();
|
||||
handle_play_voice_from_path(app, &path_str, voice, &msg).await;
|
||||
return;
|
||||
}
|
||||
|
||||
app.status_message = Some("Загрузка голосового...".to_string());
|
||||
match app.td_client.download_voice_note(file_id).await {
|
||||
Ok(path) => {
|
||||
if let Some(ref mut cache) = app.voice_cache {
|
||||
let _ = cache.store(&cache_key, std::path::Path::new(&path));
|
||||
}
|
||||
|
||||
handle_play_voice_from_path(app, &path, voice, &msg).await;
|
||||
}
|
||||
Err(e) => {
|
||||
app.error_message = Some(format!("Ошибка загрузки: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
VoiceDownloadState::Error(e) => {
|
||||
app.error_message = Some(format!("Ошибка загрузки: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
//! Chat list input handlers
|
||||
//!
|
||||
//! Handles keyboard input for the chat list view, including:
|
||||
//! - Navigation between chats
|
||||
//! - Folder selection
|
||||
//! - Opening chats
|
||||
|
||||
use crate::app::methods::navigation::NavigationMethods;
|
||||
use crate::app::App;
|
||||
use crate::tdlib::TdClientTrait;
|
||||
use crate::utils::with_timeout;
|
||||
use crossterm::event::KeyEvent;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Обработка навигации в списке чатов
|
||||
///
|
||||
/// Обрабатывает:
|
||||
/// - Up/Down/j/k: навигация между чатами
|
||||
/// - Цифры 1-9: переключение папок (1=All, 2-9=папки из TDLib)
|
||||
pub async fn handle_chat_list_navigation<T: TdClientTrait>(
|
||||
app: &mut App<T>,
|
||||
_key: KeyEvent,
|
||||
command: Option<crate::config::Command>,
|
||||
) {
|
||||
match command {
|
||||
Some(crate::config::Command::MoveDown) => {
|
||||
app.next_chat();
|
||||
}
|
||||
Some(crate::config::Command::MoveUp) => {
|
||||
app.previous_chat();
|
||||
}
|
||||
Some(crate::config::Command::SelectFolder1) => {
|
||||
app.selected_folder_id = None;
|
||||
app.chat_list_state.select(Some(0));
|
||||
}
|
||||
Some(crate::config::Command::SelectFolder2) => {
|
||||
select_folder(app, 0).await;
|
||||
}
|
||||
Some(crate::config::Command::SelectFolder3) => {
|
||||
select_folder(app, 1).await;
|
||||
}
|
||||
Some(crate::config::Command::SelectFolder4) => {
|
||||
select_folder(app, 2).await;
|
||||
}
|
||||
Some(crate::config::Command::SelectFolder5) => {
|
||||
select_folder(app, 3).await;
|
||||
}
|
||||
Some(crate::config::Command::SelectFolder6) => {
|
||||
select_folder(app, 4).await;
|
||||
}
|
||||
Some(crate::config::Command::SelectFolder7) => {
|
||||
select_folder(app, 5).await;
|
||||
}
|
||||
Some(crate::config::Command::SelectFolder8) => {
|
||||
select_folder(app, 6).await;
|
||||
}
|
||||
Some(crate::config::Command::SelectFolder9) => {
|
||||
select_folder(app, 7).await;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Выбирает папку по индексу и загружает её чаты
|
||||
pub async fn select_folder<T: TdClientTrait>(app: &mut App<T>, folder_idx: usize) {
|
||||
if let Some(folder) = app.td_client.folders().get(folder_idx) {
|
||||
let folder_id = folder.id;
|
||||
app.selected_folder_id = Some(folder_id);
|
||||
app.status_message = Some("Загрузка чатов папки...".to_string());
|
||||
let _ =
|
||||
with_timeout(Duration::from_secs(5), app.td_client.load_folder_chats(folder_id, 50))
|
||||
.await;
|
||||
app.status_message = None;
|
||||
app.chat_list_state.select(Some(0));
|
||||
}
|
||||
}
|
||||
@@ -1,295 +0,0 @@
|
||||
//! Chat loading logic — all three phases of message loading
|
||||
//!
|
||||
//! - Phase 1: `open_chat_and_load_data` — fast initial load (50 messages)
|
||||
//! - Phase 2: `process_pending_chat_init` — starts background tasks (reply info, photos)
|
||||
//! - Phase 3: `load_older_messages_if_needed` — lazy load on scroll up
|
||||
|
||||
use crate::app::methods::{compose::ComposeMethods, messages::MessageMethods};
|
||||
use crate::app::InputMode;
|
||||
use crate::app::{App, ChatInitEvent};
|
||||
use crate::tdlib::message_conversion::{extract_content_text, extract_sender_name};
|
||||
use crate::tdlib::TdClientTrait;
|
||||
use crate::types::{ChatId, MessageId};
|
||||
use crate::utils::{with_timeout, with_timeout_msg};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc::error::TryRecvError;
|
||||
|
||||
/// Открывает чат и загружает последние сообщения (быстро).
|
||||
///
|
||||
/// Загружает только 50 последних сообщений для мгновенного отображения.
|
||||
/// Фоновые задачи (reply info, photos) откладываются в `pending_chat_init`
|
||||
/// и стартуют после первого redraw.
|
||||
///
|
||||
/// При ошибке устанавливает error_message и очищает status_message.
|
||||
pub async fn open_chat_and_load_data<T: TdClientTrait>(app: &mut App<T>, chat_id: i64) {
|
||||
app.status_message = Some("Загрузка сообщений...".to_string());
|
||||
app.message_scroll_offset = 0;
|
||||
|
||||
// Загружаем только 50 последних сообщений (один запрос к TDLib)
|
||||
match with_timeout_msg(
|
||||
Duration::from_secs(10),
|
||||
app.td_client.get_chat_history(ChatId::new(chat_id), 50),
|
||||
"Таймаут загрузки сообщений",
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(messages) => {
|
||||
// Собираем ID всех входящих сообщений для отметки как прочитанные
|
||||
let incoming_message_ids: Vec<MessageId> = messages
|
||||
.iter()
|
||||
.filter(|msg| !msg.is_outgoing())
|
||||
.map(|msg| msg.id())
|
||||
.collect();
|
||||
|
||||
// Сохраняем загруженные сообщения
|
||||
app.td_client.set_current_chat_messages(messages);
|
||||
|
||||
// Добавляем входящие сообщения в очередь для отметки как прочитанные
|
||||
if !incoming_message_ids.is_empty() {
|
||||
app.td_client
|
||||
.enqueue_pending_view_messages(ChatId::new(chat_id), incoming_message_ids);
|
||||
}
|
||||
|
||||
// ВАЖНО: Устанавливаем current_chat_id ТОЛЬКО ПОСЛЕ сохранения истории
|
||||
// Это предотвращает race condition с Update::NewMessage
|
||||
app.td_client
|
||||
.set_current_chat_id(Some(ChatId::new(chat_id)));
|
||||
|
||||
// Загружаем черновик (локальная операция, мгновенно)
|
||||
app.load_draft();
|
||||
|
||||
// Показываем чат СРАЗУ
|
||||
app.status_message = None;
|
||||
app.input_mode = InputMode::Normal;
|
||||
app.start_message_selection();
|
||||
|
||||
// Фоновые задачи (reply info, photos) — после первого redraw
|
||||
app.pending_chat_init = Some(ChatId::new(chat_id));
|
||||
}
|
||||
Err(e) => {
|
||||
app.error_message = Some(e);
|
||||
app.status_message = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Запускает фоновую инициализацию после открытия чата.
|
||||
///
|
||||
/// Вызывается после первого redraw после `open_chat_and_load_data`.
|
||||
/// Не блокирует UI loop: TDLib запросы выполняются в отдельных Tokio tasks,
|
||||
/// а готовые результаты применяются через `process_chat_init_events`.
|
||||
pub fn process_pending_chat_init<T: TdClientTrait>(app: &mut App<T>, chat_id: ChatId) {
|
||||
app.chat_init_rx = None;
|
||||
|
||||
let mut reply_message_ids: Vec<MessageId> = app
|
||||
.td_client
|
||||
.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();
|
||||
reply_message_ids.sort_unstable();
|
||||
reply_message_ids.dedup();
|
||||
|
||||
if !reply_message_ids.is_empty() {
|
||||
let client_id = app.td_client.client_id();
|
||||
let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<ChatInitEvent>();
|
||||
app.chat_init_rx = Some(rx);
|
||||
|
||||
for message_id in reply_message_ids {
|
||||
let tx = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let result = tokio::time::timeout(Duration::from_secs(5), async {
|
||||
let Ok(original_msg_enum) = tdlib_rs::functions::get_message(
|
||||
chat_id.as_i64(),
|
||||
message_id.as_i64(),
|
||||
client_id,
|
||||
)
|
||||
.await
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let tdlib_rs::enums::Message::Message(original_msg) = original_msg_enum;
|
||||
let sender_name = extract_sender_name(&original_msg, client_id).await;
|
||||
let text: String = extract_content_text(&original_msg)
|
||||
.chars()
|
||||
.take(50)
|
||||
.collect();
|
||||
Some((sender_name, text))
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
if let Some((sender_name, text)) = result {
|
||||
let _ = tx.send(ChatInitEvent::ReplyInfoLoaded {
|
||||
chat_id,
|
||||
message_id,
|
||||
sender_name,
|
||||
text,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Авто-загрузка фото — неблокирующая фоновая задача (до 5 фото параллельно)
|
||||
#[cfg(feature = "images")]
|
||||
{
|
||||
use crate::tdlib::PhotoDownloadState;
|
||||
|
||||
if app.config().images.auto_download_images && app.config().images.show_images {
|
||||
let photo_file_ids: Vec<i32> = app
|
||||
.td_client
|
||||
.current_chat_messages()
|
||||
.iter()
|
||||
.rev()
|
||||
.take(5)
|
||||
.filter_map(|msg| {
|
||||
msg.photo_info().and_then(|p| {
|
||||
matches!(p.download_state, PhotoDownloadState::NotDownloaded)
|
||||
.then_some(p.file_id)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
if !photo_file_ids.is_empty() {
|
||||
let client_id = app.td_client.client_id();
|
||||
let (tx, rx) =
|
||||
tokio::sync::mpsc::unbounded_channel::<(i32, Result<String, String>)>();
|
||||
app.photo_download_rx = Some(rx);
|
||||
|
||||
for file_id in photo_file_ids {
|
||||
let tx = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let result = tokio::time::timeout(Duration::from_secs(5), async {
|
||||
match tdlib_rs::functions::download_file(
|
||||
file_id, 1, 0, 0, true, client_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(tdlib_rs::enums::File::File(file))
|
||||
if file.local.is_downloading_completed
|
||||
&& !file.local.path.is_empty() =>
|
||||
{
|
||||
Ok(file.local.path)
|
||||
}
|
||||
Ok(_) => Err("Файл не скачан".to_string()),
|
||||
Err(e) => Err(format!("{:?}", e)),
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
let result = match result {
|
||||
Ok(r) => r,
|
||||
Err(_) => Err("Таймаут загрузки".to_string()),
|
||||
};
|
||||
let _ = tx.send((file_id, result));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Применяет готовые результаты фоновой инициализации чата.
|
||||
pub fn process_chat_init_events<T: TdClientTrait>(app: &mut App<T>) {
|
||||
let mut events = Vec::new();
|
||||
let mut disconnected = false;
|
||||
|
||||
if let Some(rx) = app.chat_init_rx.as_mut() {
|
||||
loop {
|
||||
match rx.try_recv() {
|
||||
Ok(event) => events.push(event),
|
||||
Err(TryRecvError::Empty) => break,
|
||||
Err(TryRecvError::Disconnected) => {
|
||||
disconnected = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if disconnected {
|
||||
app.chat_init_rx = None;
|
||||
}
|
||||
|
||||
for event in events {
|
||||
match event {
|
||||
ChatInitEvent::ReplyInfoLoaded { chat_id, message_id, sender_name, text } => {
|
||||
if app.td_client.current_chat_id() != Some(chat_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut changed = false;
|
||||
app.td_client.update_current_chat_messages(|messages| {
|
||||
for msg in messages {
|
||||
let Some(reply) = msg.interactions.reply_to.as_mut() else {
|
||||
continue;
|
||||
};
|
||||
if reply.message_id == message_id {
|
||||
reply.sender_name = sender_name.clone();
|
||||
reply.text = text.clone();
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if changed {
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Подгружает старые сообщения если скролл близко к верху
|
||||
pub async fn load_older_messages_if_needed<T: TdClientTrait>(app: &mut App<T>) {
|
||||
// Check if there are messages to load from
|
||||
if app.td_client.current_chat_messages().is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the oldest message ID
|
||||
let oldest_msg_id = app
|
||||
.td_client
|
||||
.current_chat_messages()
|
||||
.first()
|
||||
.map(|m| m.id())
|
||||
.unwrap_or(MessageId::new(0));
|
||||
|
||||
// Get current chat ID
|
||||
let Some(chat_id) = app.get_selected_chat_id() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Check if scroll is near the top
|
||||
let message_count = app.td_client.current_chat_messages().len();
|
||||
if app.message_scroll_offset <= message_count.saturating_sub(10) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Load older messages with timeout
|
||||
let Ok(older) = with_timeout(
|
||||
Duration::from_secs(3),
|
||||
app.td_client
|
||||
.load_older_messages(ChatId::new(chat_id), oldest_msg_id),
|
||||
)
|
||||
.await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Add older messages to the beginning if any were loaded
|
||||
if !older.is_empty() {
|
||||
app.td_client.update_current_chat_messages(|messages| {
|
||||
messages.splice(0..0, older);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
//! Modal dialog handlers.
|
||||
|
||||
mod account;
|
||||
mod delete;
|
||||
mod pinned;
|
||||
mod profile;
|
||||
mod reactions;
|
||||
|
||||
pub use account::handle_account_switcher;
|
||||
pub use delete::handle_delete_confirmation;
|
||||
pub use pinned::handle_pinned_mode;
|
||||
pub use profile::{handle_profile_mode, handle_profile_open};
|
||||
pub use reactions::handle_reaction_picker_mode;
|
||||
@@ -1,76 +0,0 @@
|
||||
use crate::app::{AccountSwitcherState, App};
|
||||
use crate::tdlib::TdClientTrait;
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
|
||||
/// Обработка ввода в модалке переключения аккаунтов.
|
||||
pub async fn handle_account_switcher<T: TdClientTrait>(
|
||||
app: &mut App<T>,
|
||||
key: KeyEvent,
|
||||
command: Option<crate::config::Command>,
|
||||
) {
|
||||
let Some(state) = &app.account_switcher else {
|
||||
return;
|
||||
};
|
||||
|
||||
match state {
|
||||
AccountSwitcherState::SelectAccount { .. } => match command {
|
||||
Some(crate::config::Command::MoveUp) => {
|
||||
app.account_switcher_select_prev();
|
||||
}
|
||||
Some(crate::config::Command::MoveDown) => {
|
||||
app.account_switcher_select_next();
|
||||
}
|
||||
Some(crate::config::Command::SubmitMessage) => {
|
||||
app.account_switcher_confirm();
|
||||
}
|
||||
Some(crate::config::Command::Cancel) => {
|
||||
app.close_account_switcher();
|
||||
}
|
||||
_ => match key.code {
|
||||
KeyCode::Char('a') | KeyCode::Char('ф') => {
|
||||
app.account_switcher_start_add();
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
},
|
||||
AccountSwitcherState::AddAccount { .. } => match key.code {
|
||||
KeyCode::Esc => {
|
||||
app.account_switcher_back();
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
app.account_switcher_confirm_add();
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
if let Some(AccountSwitcherState::AddAccount {
|
||||
name_input,
|
||||
cursor_position,
|
||||
error,
|
||||
}) = &mut app.account_switcher
|
||||
{
|
||||
if *cursor_position > 0 {
|
||||
let mut chars: Vec<char> = name_input.chars().collect();
|
||||
chars.remove(*cursor_position - 1);
|
||||
*name_input = chars.into_iter().collect();
|
||||
*cursor_position -= 1;
|
||||
*error = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
if let Some(AccountSwitcherState::AddAccount {
|
||||
name_input,
|
||||
cursor_position,
|
||||
error,
|
||||
}) = &mut app.account_switcher
|
||||
{
|
||||
let mut chars: Vec<char> = name_input.chars().collect();
|
||||
chars.insert(*cursor_position, c);
|
||||
*name_input = chars.into_iter().collect();
|
||||
*cursor_position += 1;
|
||||
*error = None;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
use crate::app::{App, ChatState};
|
||||
use crate::tdlib::TdClientTrait;
|
||||
use crate::types::ChatId;
|
||||
use crate::utils::{modal_handler::handle_yes_no, with_timeout_msg};
|
||||
use crossterm::event::KeyEvent;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Обработка модалки подтверждения удаления сообщения.
|
||||
pub async fn handle_delete_confirmation<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
|
||||
match handle_yes_no(key.code) {
|
||||
Some(true) => {
|
||||
if let Some(msg_id) = app.chat_state.selected_message_id() {
|
||||
if let Some(chat_id) = app.get_selected_chat_id() {
|
||||
let can_delete_for_all = app
|
||||
.td_client
|
||||
.current_chat_messages()
|
||||
.iter()
|
||||
.find(|m| m.id() == msg_id)
|
||||
.map(|m| m.can_be_deleted_for_all_users())
|
||||
.unwrap_or(false);
|
||||
|
||||
match with_timeout_msg(
|
||||
Duration::from_secs(5),
|
||||
app.td_client.delete_messages(
|
||||
ChatId::new(chat_id),
|
||||
vec![msg_id],
|
||||
can_delete_for_all,
|
||||
),
|
||||
"Таймаут удаления",
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
app.td_client.update_current_chat_messages(|messages| {
|
||||
messages.retain(|m| m.id() != msg_id);
|
||||
});
|
||||
app.chat_state = ChatState::Normal;
|
||||
}
|
||||
Err(e) => {
|
||||
app.error_message = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
app.chat_state = ChatState::Normal;
|
||||
}
|
||||
Some(false) => {
|
||||
app.chat_state = ChatState::Normal;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
use crate::app::methods::modal::ModalMethods;
|
||||
use crate::app::App;
|
||||
use crate::input::handlers::scroll_to_message;
|
||||
use crate::tdlib::TdClientTrait;
|
||||
use crate::types::MessageId;
|
||||
use crossterm::event::KeyEvent;
|
||||
|
||||
/// Обработка режима просмотра закреплённых сообщений.
|
||||
pub async fn handle_pinned_mode<T: TdClientTrait>(
|
||||
app: &mut App<T>,
|
||||
_key: KeyEvent,
|
||||
command: Option<crate::config::Command>,
|
||||
) {
|
||||
match command {
|
||||
Some(crate::config::Command::Cancel) => {
|
||||
app.exit_pinned_mode();
|
||||
}
|
||||
Some(crate::config::Command::MoveUp) => {
|
||||
app.select_previous_pinned();
|
||||
}
|
||||
Some(crate::config::Command::MoveDown) => {
|
||||
app.select_next_pinned();
|
||||
}
|
||||
Some(crate::config::Command::SubmitMessage) => {
|
||||
if let Some(msg_id) = app.get_selected_pinned_id() {
|
||||
scroll_to_message(app, MessageId::new(msg_id));
|
||||
app.exit_pinned_mode();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
use crate::app::methods::modal::ModalMethods;
|
||||
use crate::app::methods::navigation::NavigationMethods;
|
||||
use crate::app::App;
|
||||
use crate::input::handlers::get_available_actions_count;
|
||||
use crate::tdlib::TdClientTrait;
|
||||
use crate::utils::{modal_handler::handle_yes_no, with_timeout_msg};
|
||||
use crossterm::event::KeyEvent;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Обработка режима профиля пользователя/чата.
|
||||
pub async fn handle_profile_mode<T: TdClientTrait>(
|
||||
app: &mut App<T>,
|
||||
key: KeyEvent,
|
||||
command: Option<crate::config::Command>,
|
||||
) {
|
||||
let confirmation_step = app.get_leave_group_confirmation_step();
|
||||
if confirmation_step > 0 {
|
||||
match handle_yes_no(key.code) {
|
||||
Some(true) => {
|
||||
if confirmation_step == 1 {
|
||||
app.show_leave_group_final_confirmation();
|
||||
} else if confirmation_step == 2 {
|
||||
if let Some(chat_id) = app.selected_chat_id {
|
||||
let leave_result = app.td_client.leave_chat(chat_id).await;
|
||||
match leave_result {
|
||||
Ok(_) => {
|
||||
app.status_message = Some("Вы вышли из группы".to_string());
|
||||
app.exit_profile_mode();
|
||||
app.close_chat();
|
||||
}
|
||||
Err(e) => {
|
||||
app.error_message = Some(e);
|
||||
app.cancel_leave_group();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(false) => {
|
||||
app.cancel_leave_group();
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
match command {
|
||||
Some(crate::config::Command::Cancel) => {
|
||||
app.exit_profile_mode();
|
||||
}
|
||||
Some(crate::config::Command::MoveUp) => {
|
||||
app.select_previous_profile_action();
|
||||
}
|
||||
Some(crate::config::Command::MoveDown) => {
|
||||
if let Some(profile) = app.get_profile_info() {
|
||||
let max_actions = get_available_actions_count(profile);
|
||||
app.select_next_profile_action(max_actions);
|
||||
}
|
||||
}
|
||||
Some(crate::config::Command::SubmitMessage) => {
|
||||
let Some(profile) = app.get_profile_info() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let actions = get_available_actions_count(profile);
|
||||
let action_index = app.get_selected_profile_action().unwrap_or(0);
|
||||
if action_index >= actions {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut current_idx = 0;
|
||||
|
||||
if let Some(username) = &profile.username {
|
||||
if action_index == current_idx {
|
||||
let url = format!("https://t.me/{}", username.trim_start_matches('@'));
|
||||
#[cfg(feature = "url-open")]
|
||||
{
|
||||
match open::that(&url) {
|
||||
Ok(_) => {
|
||||
app.status_message = Some(format!("Открыто: {}", url));
|
||||
}
|
||||
Err(e) => {
|
||||
app.error_message =
|
||||
Some(format!("Ошибка открытия браузера: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "url-open"))]
|
||||
{
|
||||
app.error_message = Some(
|
||||
"Открытие URL недоступно (требуется feature 'url-open')".to_string(),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
current_idx += 1;
|
||||
}
|
||||
|
||||
if action_index == current_idx {
|
||||
app.status_message = Some(format!("ID скопирован: {}", profile.chat_id));
|
||||
return;
|
||||
}
|
||||
current_idx += 1;
|
||||
|
||||
if profile.is_group && action_index == current_idx {
|
||||
app.show_leave_group_confirmation();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Обработка Ctrl+I для открытия профиля чата/пользователя.
|
||||
pub async fn handle_profile_open<T: TdClientTrait>(app: &mut App<T>) {
|
||||
let Some(chat_id) = app.selected_chat_id else {
|
||||
return;
|
||||
};
|
||||
|
||||
app.status_message = Some("Загрузка профиля...".to_string());
|
||||
match with_timeout_msg(
|
||||
Duration::from_secs(5),
|
||||
app.td_client.get_profile_info(chat_id),
|
||||
"Таймаут загрузки профиля",
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(profile) => {
|
||||
app.enter_profile_mode(profile);
|
||||
app.status_message = None;
|
||||
}
|
||||
Err(e) => {
|
||||
app.error_message = Some(e);
|
||||
app.status_message = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
use crate::app::methods::modal::ModalMethods;
|
||||
use crate::app::App;
|
||||
use crate::tdlib::TdClientTrait;
|
||||
use crossterm::event::KeyEvent;
|
||||
|
||||
/// Обработка режима выбора реакции (emoji picker).
|
||||
pub async fn handle_reaction_picker_mode<T: TdClientTrait>(
|
||||
app: &mut App<T>,
|
||||
_key: KeyEvent,
|
||||
command: Option<crate::config::Command>,
|
||||
) {
|
||||
match command {
|
||||
Some(crate::config::Command::MoveLeft) => {
|
||||
app.select_previous_reaction();
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
Some(crate::config::Command::MoveRight) => {
|
||||
app.select_next_reaction();
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
Some(crate::config::Command::MoveUp) => {
|
||||
if let crate::app::ChatState::ReactionPicker { selected_index, .. } =
|
||||
&mut app.chat_state
|
||||
{
|
||||
if *selected_index >= 8 {
|
||||
*selected_index = selected_index.saturating_sub(8);
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(crate::config::Command::MoveDown) => {
|
||||
if let crate::app::ChatState::ReactionPicker {
|
||||
selected_index,
|
||||
available_reactions,
|
||||
..
|
||||
} = &mut app.chat_state
|
||||
{
|
||||
let new_index = *selected_index + 8;
|
||||
if new_index < available_reactions.len() {
|
||||
*selected_index = new_index;
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(crate::config::Command::SubmitMessage) => {
|
||||
crate::input::handlers::chat::send_reaction(app).await;
|
||||
}
|
||||
Some(crate::config::Command::Cancel) => {
|
||||
app.exit_reaction_picker_mode();
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -1,288 +0,0 @@
|
||||
// Test App builder
|
||||
|
||||
use super::FakeTdClient;
|
||||
use crate::app::{App, AppScreen, ChatState, InputMode};
|
||||
use crate::config::Config;
|
||||
use crate::tdlib::AuthState;
|
||||
use crate::tdlib::{ChatInfo, MessageInfo};
|
||||
use crate::types::{ChatId, MessageId};
|
||||
use ratatui::widgets::ListState;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Builder для создания тестового App с FakeTdClient\n///\n/// Использует trait-based DI для подмены TdClient на FakeTdClient в тестах.
|
||||
#[allow(dead_code)]
|
||||
pub struct TestAppBuilder {
|
||||
config: Config,
|
||||
screen: AppScreen,
|
||||
chats: Vec<ChatInfo>,
|
||||
selected_chat_id: Option<i64>,
|
||||
message_input: String,
|
||||
is_searching: bool,
|
||||
search_query: String,
|
||||
chat_state: Option<ChatState>,
|
||||
input_mode: Option<InputMode>,
|
||||
messages: HashMap<i64, Vec<MessageInfo>>,
|
||||
status_message: Option<String>,
|
||||
auth_state: Option<AuthState>,
|
||||
phone_input: Option<String>,
|
||||
code_input: Option<String>,
|
||||
password_input: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for TestAppBuilder {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl TestAppBuilder {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
config: Config::default(),
|
||||
screen: AppScreen::Main,
|
||||
chats: vec![],
|
||||
selected_chat_id: None,
|
||||
message_input: String::new(),
|
||||
is_searching: false,
|
||||
search_query: String::new(),
|
||||
chat_state: None,
|
||||
input_mode: None,
|
||||
messages: HashMap::new(),
|
||||
status_message: None,
|
||||
auth_state: None,
|
||||
phone_input: None,
|
||||
code_input: None,
|
||||
password_input: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Установить экран
|
||||
pub fn screen(mut self, screen: AppScreen) -> Self {
|
||||
self.screen = screen;
|
||||
self
|
||||
}
|
||||
|
||||
/// Установить конфиг
|
||||
pub fn config(mut self, config: Config) -> Self {
|
||||
self.config = config;
|
||||
self
|
||||
}
|
||||
|
||||
/// Добавить чат
|
||||
pub fn with_chat(mut self, chat: ChatInfo) -> Self {
|
||||
self.chats.push(chat);
|
||||
self
|
||||
}
|
||||
|
||||
/// Добавить несколько чатов
|
||||
pub fn with_chats(mut self, chats: Vec<ChatInfo>) -> Self {
|
||||
self.chats.extend(chats);
|
||||
self
|
||||
}
|
||||
|
||||
/// Выбрать чат
|
||||
pub fn selected_chat(mut self, chat_id: i64) -> Self {
|
||||
self.selected_chat_id = Some(chat_id);
|
||||
self
|
||||
}
|
||||
|
||||
/// Установить текст в инпуте
|
||||
pub fn message_input(mut self, text: &str) -> Self {
|
||||
self.message_input = text.to_string();
|
||||
self
|
||||
}
|
||||
|
||||
/// Режим поиска
|
||||
pub fn searching(mut self, query: &str) -> Self {
|
||||
self.is_searching = true;
|
||||
self.search_query = query.to_string();
|
||||
self
|
||||
}
|
||||
|
||||
/// Режим редактирования сообщения
|
||||
pub fn editing_message(mut self, message_id: i64, selected_index: usize) -> Self {
|
||||
self.chat_state = Some(ChatState::Editing {
|
||||
message_id: MessageId::new(message_id),
|
||||
selected_index,
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
/// Режим ответа на сообщение
|
||||
pub fn replying_to(mut self, message_id: i64) -> Self {
|
||||
self.chat_state = Some(ChatState::Reply { message_id: MessageId::new(message_id) });
|
||||
self
|
||||
}
|
||||
|
||||
/// Режим выбора реакции
|
||||
pub fn reaction_picker(mut self, message_id: i64, available_reactions: Vec<String>) -> Self {
|
||||
self.chat_state = Some(ChatState::ReactionPicker {
|
||||
message_id: MessageId::new(message_id),
|
||||
available_reactions,
|
||||
selected_index: 0,
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
/// Режим профиля
|
||||
pub fn profile_mode(mut self, info: crate::tdlib::ProfileInfo) -> Self {
|
||||
self.chat_state = Some(ChatState::Profile {
|
||||
info,
|
||||
selected_action: 0,
|
||||
leave_group_confirmation_step: 0,
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
/// Подтверждение удаления
|
||||
pub fn delete_confirmation(mut self, message_id: i64) -> Self {
|
||||
self.chat_state =
|
||||
Some(ChatState::DeleteConfirmation { message_id: MessageId::new(message_id) });
|
||||
self
|
||||
}
|
||||
|
||||
/// Добавить сообщение для чата
|
||||
pub fn with_message(mut self, chat_id: i64, message: MessageInfo) -> Self {
|
||||
self.messages.entry(chat_id).or_default().push(message);
|
||||
self
|
||||
}
|
||||
|
||||
/// Добавить несколько сообщений для чата
|
||||
pub fn with_messages(mut self, chat_id: i64, messages: Vec<MessageInfo>) -> Self {
|
||||
self.messages.entry(chat_id).or_default().extend(messages);
|
||||
self
|
||||
}
|
||||
|
||||
/// Установить выбранное сообщение (режим selection)
|
||||
pub fn selecting_message(mut self, selected_index: usize) -> Self {
|
||||
self.chat_state = Some(ChatState::MessageSelection { selected_index });
|
||||
self
|
||||
}
|
||||
|
||||
/// Режим поиска по сообщениям в чате
|
||||
pub fn message_search(mut self, query: &str) -> Self {
|
||||
self.chat_state = Some(ChatState::SearchInChat {
|
||||
query: query.to_string(),
|
||||
results: Vec::new(),
|
||||
selected_index: 0,
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
/// Установить Insert mode
|
||||
pub fn insert_mode(mut self) -> Self {
|
||||
self.input_mode = Some(InputMode::Insert);
|
||||
self
|
||||
}
|
||||
|
||||
/// Режим пересылки сообщения
|
||||
pub fn forward_mode(mut self, message_id: i64) -> Self {
|
||||
self.chat_state = Some(ChatState::Forward { message_id: MessageId::new(message_id) });
|
||||
self
|
||||
}
|
||||
|
||||
/// Установить статус сообщение (для loading screen)
|
||||
pub fn status_message(mut self, message: &str) -> Self {
|
||||
self.status_message = Some(message.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
/// Установить auth state
|
||||
pub fn auth_state(mut self, state: AuthState) -> Self {
|
||||
self.auth_state = Some(state);
|
||||
self
|
||||
}
|
||||
|
||||
/// Установить phone input
|
||||
pub fn phone_input(mut self, phone: &str) -> Self {
|
||||
self.phone_input = Some(phone.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
/// Установить code input
|
||||
pub fn code_input(mut self, code: &str) -> Self {
|
||||
self.code_input = Some(code.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
/// Установить password input
|
||||
pub fn password_input(mut self, password: &str) -> Self {
|
||||
self.password_input = Some(password.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
/// Построить App с FakeTdClient
|
||||
///
|
||||
/// Создаёт App с FakeTdClient, подходит для любых тестов включая
|
||||
/// интеграционные тесты логики.
|
||||
pub fn build(self) -> App<FakeTdClient> {
|
||||
// Создаём FakeTdClient с чатами и сообщениями
|
||||
let mut fake_client = FakeTdClient::new();
|
||||
|
||||
// Добавляем чаты
|
||||
for chat in &self.chats {
|
||||
fake_client = fake_client.with_chat(chat.clone());
|
||||
}
|
||||
|
||||
// Добавляем сообщения
|
||||
for (chat_id, messages) in self.messages {
|
||||
fake_client = fake_client.with_messages(chat_id, messages);
|
||||
}
|
||||
|
||||
// Устанавливаем текущий чат если нужно
|
||||
if let Some(chat_id) = self.selected_chat_id {
|
||||
*fake_client.current_chat_id.lock().unwrap() = Some(chat_id);
|
||||
}
|
||||
|
||||
// Устанавливаем auth state если нужно
|
||||
if let Some(auth_state) = self.auth_state {
|
||||
fake_client = fake_client.with_auth_state(auth_state);
|
||||
}
|
||||
|
||||
// Создаём App с FakeTdClient
|
||||
let mut app = App::with_client(self.config, fake_client);
|
||||
|
||||
app.screen = self.screen;
|
||||
app.chats = self.chats;
|
||||
app.selected_chat_id = self.selected_chat_id.map(ChatId::new);
|
||||
app.message_input = self.message_input;
|
||||
app.is_searching = self.is_searching;
|
||||
app.search_query = self.search_query;
|
||||
|
||||
// Применяем chat_state если он установлен
|
||||
if let Some(chat_state) = self.chat_state {
|
||||
app.chat_state = chat_state;
|
||||
}
|
||||
|
||||
// Применяем input_mode если он установлен
|
||||
if let Some(input_mode) = self.input_mode {
|
||||
app.input_mode = input_mode;
|
||||
}
|
||||
|
||||
// Применяем status_message
|
||||
if let Some(status) = self.status_message {
|
||||
app.status_message = Some(status);
|
||||
}
|
||||
|
||||
// Применяем auth inputs
|
||||
if let Some(phone) = self.phone_input {
|
||||
app.set_phone_input(phone);
|
||||
}
|
||||
if let Some(code) = self.code_input {
|
||||
app.set_code_input(code);
|
||||
}
|
||||
if let Some(password) = self.password_input {
|
||||
app.set_password_input(password);
|
||||
}
|
||||
|
||||
// Выбираем первый чат если есть
|
||||
if !app.chats.is_empty() {
|
||||
let mut list_state = ListState::default();
|
||||
list_state.select(Some(0));
|
||||
app.chat_list_state = list_state;
|
||||
}
|
||||
|
||||
app
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
//! Test-only support for deterministic UI fixtures and integration tests.
|
||||
|
||||
pub mod app_builder;
|
||||
pub mod snapshot_utils;
|
||||
|
||||
pub use tele_core::test_support::{fake_tdclient, test_data, FakeTdClient};
|
||||
@@ -1,144 +0,0 @@
|
||||
// Snapshot testing utilities
|
||||
|
||||
use ratatui::backend::TestBackend;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::style::{Color, Modifier};
|
||||
use ratatui::Terminal;
|
||||
|
||||
/// Конвертирует Buffer в читаемую строку для snapshot тестов
|
||||
pub fn buffer_to_string(buffer: &Buffer) -> String {
|
||||
let area = buffer.area();
|
||||
let mut result = String::new();
|
||||
|
||||
for y in 0..area.height {
|
||||
let mut line = String::new();
|
||||
for x in 0..area.width {
|
||||
line.push_str(buffer[(x, y)].symbol());
|
||||
}
|
||||
// Убираем trailing spaces в конце строки
|
||||
result.push_str(line.trim_end());
|
||||
if y < area.height - 1 {
|
||||
result.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Serializes only cells with non-default style, grouped by row and style.
|
||||
pub fn buffer_to_style_snapshot(buffer: &Buffer) -> String {
|
||||
let area = buffer.area();
|
||||
let mut rows = Vec::new();
|
||||
|
||||
for y in 0..area.height {
|
||||
let mut segments = Vec::new();
|
||||
let mut x = 0;
|
||||
|
||||
while x < area.width {
|
||||
let cell = &buffer[(x, y)];
|
||||
if is_default_style(cell) {
|
||||
x += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let start = x;
|
||||
let fg = cell.fg;
|
||||
let bg = cell.bg;
|
||||
let modifier = cell.modifier;
|
||||
let mut text = String::new();
|
||||
|
||||
while x < area.width {
|
||||
let next = &buffer[(x, y)];
|
||||
if is_default_style(next)
|
||||
|| next.fg != fg
|
||||
|| next.bg != bg
|
||||
|| next.modifier != modifier
|
||||
{
|
||||
break;
|
||||
}
|
||||
text.push_str(next.symbol());
|
||||
x += 1;
|
||||
}
|
||||
|
||||
segments.push(format!(
|
||||
"{}..{} {:?}/{:?}/{:?}: {:?}",
|
||||
start,
|
||||
x.saturating_sub(1),
|
||||
fg,
|
||||
bg,
|
||||
modifier,
|
||||
text.trim_end()
|
||||
));
|
||||
}
|
||||
|
||||
if !segments.is_empty() {
|
||||
rows.push(format!("y={}: {}", y, segments.join(" | ")));
|
||||
}
|
||||
}
|
||||
|
||||
rows.join("\n")
|
||||
}
|
||||
|
||||
fn is_default_style(cell: &ratatui::buffer::Cell) -> bool {
|
||||
cell.fg == Color::Reset && cell.bg == Color::Reset && cell.modifier == Modifier::empty()
|
||||
}
|
||||
|
||||
/// Создаёт TestBackend с заданным размером и рендерит UI
|
||||
pub fn render_to_buffer<F>(width: u16, height: u16, render_fn: F) -> Buffer
|
||||
where
|
||||
F: FnOnce(&mut ratatui::Frame),
|
||||
{
|
||||
let backend = TestBackend::new(width, height);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
|
||||
terminal.draw(render_fn).unwrap();
|
||||
|
||||
terminal.backend().buffer().clone()
|
||||
}
|
||||
|
||||
/// Макрос для упрощения snapshot тестов
|
||||
#[macro_export]
|
||||
macro_rules! assert_ui_snapshot {
|
||||
($name:expr, $width:expr, $height:expr, $render_fn:expr) => {{
|
||||
use $crate::test_support::snapshot_utils::{buffer_to_string, render_to_buffer};
|
||||
let buffer = render_to_buffer($width, $height, $render_fn);
|
||||
let output = buffer_to_string(&buffer);
|
||||
insta::assert_snapshot!($name, output);
|
||||
}};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::widgets::{Block, Borders};
|
||||
|
||||
#[test]
|
||||
fn test_buffer_to_string_simple() {
|
||||
let buffer = render_to_buffer(10, 3, |f| {
|
||||
let block = Block::default().borders(Borders::ALL).title("Hi");
|
||||
f.render_widget(block, f.area());
|
||||
});
|
||||
|
||||
let result = buffer_to_string(&buffer);
|
||||
assert!(result.contains("Hi"));
|
||||
assert!(result.contains("┌"));
|
||||
assert!(result.contains("└"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_buffer_to_string_removes_trailing_spaces() {
|
||||
let buffer = render_to_buffer(20, 3, |f| {
|
||||
let block = Block::default().title("Test");
|
||||
f.render_widget(block, Rect::new(0, 0, 10, 3));
|
||||
});
|
||||
|
||||
let result = buffer_to_string(&buffer);
|
||||
let lines: Vec<&str> = result.lines().collect();
|
||||
|
||||
// Проверяем что trailing spaces убраны
|
||||
for line in lines {
|
||||
assert!(!line.ends_with(' ') || line.trim().is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
//! Chat message area rendering.
|
||||
|
||||
mod header;
|
||||
mod list;
|
||||
mod pinned;
|
||||
|
||||
use crate::app::methods::{modal::ModalMethods, search::SearchMethods};
|
||||
use crate::app::App;
|
||||
use crate::tdlib::TdClientTrait;
|
||||
use crate::ui::{compose_bar, modals};
|
||||
use ratatui::{
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Style},
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
use header::render_chat_header;
|
||||
use list::render_message_list;
|
||||
use pinned::render_pinned_bar;
|
||||
|
||||
pub(crate) use list::wrap_text_with_offsets;
|
||||
|
||||
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut App<T>) {
|
||||
#[cfg(feature = "images")]
|
||||
if let Some(modal_state) = app.image_modal.clone() {
|
||||
modals::render_image_viewer(f, app, &modal_state);
|
||||
return;
|
||||
}
|
||||
|
||||
if app.is_profile_mode() {
|
||||
if let Some(profile) = app.get_profile_info() {
|
||||
crate::ui::profile::render(f, area, app, profile);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if app.is_message_search_mode() {
|
||||
modals::render_search(f, area, app);
|
||||
return;
|
||||
}
|
||||
|
||||
if app.is_pinned_mode() {
|
||||
modals::render_pinned(f, area, app);
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(chat) = app.get_selected_chat().cloned() {
|
||||
let input_width = area.width.saturating_sub(4) as usize;
|
||||
let input_lines: u16 = if input_width > 0 {
|
||||
let len = app.message_input.chars().count() + 2;
|
||||
((len as f32 / input_width as f32).ceil() as u16).max(1)
|
||||
} else {
|
||||
1
|
||||
};
|
||||
let input_height = (input_lines + 2).clamp(3, 10);
|
||||
|
||||
let has_pinned = app.td_client.current_pinned_message().is_some();
|
||||
let message_chunks = if has_pinned {
|
||||
Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(0),
|
||||
Constraint::Length(input_height),
|
||||
])
|
||||
.split(area)
|
||||
} else {
|
||||
Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(0),
|
||||
Constraint::Min(0),
|
||||
Constraint::Length(input_height),
|
||||
])
|
||||
.split(area)
|
||||
};
|
||||
|
||||
render_chat_header(f, message_chunks[0], app, &chat);
|
||||
render_pinned_bar(f, message_chunks[1], app);
|
||||
render_message_list(f, message_chunks[2], app);
|
||||
compose_bar::render(f, message_chunks[3], app);
|
||||
} else {
|
||||
let empty = Paragraph::new("Выберите чат")
|
||||
.block(Block::default().borders(Borders::ALL))
|
||||
.style(Style::default().fg(Color::Gray))
|
||||
.alignment(Alignment::Center);
|
||||
f.render_widget(empty, area);
|
||||
}
|
||||
|
||||
if app.is_confirm_delete_shown() {
|
||||
modals::render_delete_confirm(f, area);
|
||||
}
|
||||
|
||||
if let crate::app::ChatState::ReactionPicker { available_reactions, selected_index, .. } =
|
||||
&app.chat_state
|
||||
{
|
||||
modals::render_reaction_picker(f, area, available_reactions, *selected_index);
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
use crate::app::App;
|
||||
use crate::tdlib::{ChatInfo, TdClientTrait};
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
pub(super) fn render_chat_header<T: TdClientTrait>(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
app: &App<T>,
|
||||
chat: &ChatInfo,
|
||||
) {
|
||||
let typing_action = app
|
||||
.td_client
|
||||
.typing_status()
|
||||
.as_ref()
|
||||
.map(|(_, action, _)| action.clone());
|
||||
|
||||
let header_line = if let Some(action) = typing_action {
|
||||
let mut spans = vec![Span::styled(
|
||||
format!("👤 {}", chat.title),
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)];
|
||||
if let Some(username) = &chat.username {
|
||||
spans.push(Span::styled(format!(" {}", username), Style::default().fg(Color::Gray)));
|
||||
}
|
||||
spans.push(Span::styled(
|
||||
format!(" {}", action),
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::ITALIC),
|
||||
));
|
||||
Line::from(spans)
|
||||
} else {
|
||||
let header_text = match &chat.username {
|
||||
Some(username) => format!("👤 {} {}", chat.title, username),
|
||||
None => format!("👤 {}", chat.title),
|
||||
};
|
||||
Line::from(Span::styled(
|
||||
header_text,
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
))
|
||||
};
|
||||
|
||||
let header = Paragraph::new(header_line).block(Block::default().borders(Borders::ALL));
|
||||
f.render_widget(header, area);
|
||||
}
|
||||
@@ -1,286 +0,0 @@
|
||||
use crate::app::methods::messages::MessageMethods;
|
||||
use crate::app::App;
|
||||
use crate::message_grouping::{group_messages, MessageGroup};
|
||||
use crate::tdlib::TdClientTrait;
|
||||
use crate::ui::components;
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::{Color, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
Frame,
|
||||
};
|
||||
|
||||
/// Информация о строке после переноса: текст и позиция в оригинале.
|
||||
pub(crate) struct WrappedLine {
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// Разбивает текст на строки с учётом максимальной ширины.
|
||||
pub(crate) fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
|
||||
if max_width == 0 {
|
||||
return vec![WrappedLine { text: text.to_string() }];
|
||||
}
|
||||
|
||||
let mut result = Vec::new();
|
||||
let mut current_line = String::new();
|
||||
let mut current_width = 0;
|
||||
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let mut word_start = 0;
|
||||
let mut in_word = false;
|
||||
|
||||
for (i, ch) in chars.iter().enumerate() {
|
||||
if ch.is_whitespace() {
|
||||
if in_word {
|
||||
let word: String = chars[word_start..i].iter().collect();
|
||||
let word_width = word.chars().count();
|
||||
|
||||
if current_width == 0 {
|
||||
current_line = word;
|
||||
current_width = word_width;
|
||||
} else if current_width + 1 + word_width <= max_width {
|
||||
current_line.push(' ');
|
||||
current_line.push_str(&word);
|
||||
current_width += 1 + word_width;
|
||||
} else {
|
||||
result.push(WrappedLine { text: current_line });
|
||||
current_line = word;
|
||||
current_width = word_width;
|
||||
}
|
||||
in_word = false;
|
||||
}
|
||||
} else if !in_word {
|
||||
word_start = i;
|
||||
in_word = true;
|
||||
}
|
||||
}
|
||||
|
||||
if in_word {
|
||||
let word: String = chars[word_start..].iter().collect();
|
||||
let word_width = word.chars().count();
|
||||
|
||||
if current_width == 0 {
|
||||
current_line = word;
|
||||
} else if current_width + 1 + word_width <= max_width {
|
||||
current_line.push(' ');
|
||||
current_line.push_str(&word);
|
||||
} else {
|
||||
result.push(WrappedLine { text: current_line });
|
||||
current_line = word;
|
||||
}
|
||||
}
|
||||
|
||||
if !current_line.is_empty() {
|
||||
result.push(WrappedLine { text: current_line });
|
||||
}
|
||||
|
||||
if result.is_empty() {
|
||||
result.push(WrappedLine { text: String::new() });
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Рендерит список сообщений с группировкой по дате/отправителю и автоскроллом.
|
||||
pub(super) fn render_message_list<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut App<T>) {
|
||||
let content_width = area.width.saturating_sub(2) as usize;
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
|
||||
let selected_msg_id = app.get_selected_message().map(|m| m.id());
|
||||
let mut selected_msg_line: Option<usize> = None;
|
||||
|
||||
#[cfg(feature = "images")]
|
||||
let mut deferred_images: Vec<components::DeferredImageRender> = Vec::new();
|
||||
|
||||
let current_messages = app.td_client.current_chat_messages();
|
||||
let grouped = group_messages(¤t_messages);
|
||||
let mut is_first_date = true;
|
||||
let mut is_first_sender = true;
|
||||
|
||||
for group in grouped {
|
||||
match group {
|
||||
MessageGroup::DateSeparator(date) => {
|
||||
lines.extend(components::render_date_separator(date, content_width, is_first_date));
|
||||
is_first_date = false;
|
||||
is_first_sender = true;
|
||||
}
|
||||
MessageGroup::SenderHeader { is_outgoing, sender_name } => {
|
||||
lines.extend(components::render_sender_header(
|
||||
is_outgoing,
|
||||
&sender_name,
|
||||
content_width,
|
||||
is_first_sender,
|
||||
));
|
||||
is_first_sender = false;
|
||||
}
|
||||
MessageGroup::Message(msg) => {
|
||||
let is_selected = selected_msg_id == Some(msg.id());
|
||||
if is_selected {
|
||||
selected_msg_line = Some(lines.len());
|
||||
}
|
||||
|
||||
let bubble_lines = components::render_message_bubble(
|
||||
msg,
|
||||
app.config(),
|
||||
content_width,
|
||||
selected_msg_id,
|
||||
app.playback_state.as_ref(),
|
||||
);
|
||||
|
||||
#[cfg(feature = "images")]
|
||||
if let Some(photo) = msg.photo_info() {
|
||||
if let crate::tdlib::PhotoDownloadState::Downloaded(path) =
|
||||
&photo.download_state
|
||||
{
|
||||
let inline_width =
|
||||
content_width.min(crate::constants::INLINE_IMAGE_MAX_WIDTH);
|
||||
let img_height = components::calculate_image_height(
|
||||
photo.width,
|
||||
photo.height,
|
||||
inline_width,
|
||||
);
|
||||
let img_width = inline_width as u16;
|
||||
let bubble_len = bubble_lines.len();
|
||||
let placeholder_start = lines.len() + bubble_len - img_height as usize;
|
||||
|
||||
deferred_images.push(components::DeferredImageRender {
|
||||
message_id: msg.id(),
|
||||
photo_path: path.clone(),
|
||||
line_offset: placeholder_start,
|
||||
x_offset: 0,
|
||||
width: img_width,
|
||||
height: img_height,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
lines.extend(bubble_lines);
|
||||
}
|
||||
MessageGroup::Album(album_messages) => {
|
||||
#[cfg(feature = "images")]
|
||||
{
|
||||
let is_selected = album_messages
|
||||
.iter()
|
||||
.any(|m| selected_msg_id == Some(m.id()));
|
||||
if is_selected {
|
||||
selected_msg_line = Some(lines.len());
|
||||
}
|
||||
|
||||
let (bubble_lines, album_deferred) = components::render_album_bubble(
|
||||
&album_messages,
|
||||
app.config(),
|
||||
content_width,
|
||||
selected_msg_id,
|
||||
);
|
||||
|
||||
for mut d in album_deferred {
|
||||
d.line_offset += lines.len();
|
||||
deferred_images.push(d);
|
||||
}
|
||||
|
||||
lines.extend(bubble_lines);
|
||||
}
|
||||
#[cfg(not(feature = "images"))]
|
||||
{
|
||||
for msg in &album_messages {
|
||||
let is_selected = selected_msg_id == Some(msg.id());
|
||||
if is_selected {
|
||||
selected_msg_line = Some(lines.len());
|
||||
}
|
||||
lines.extend(components::render_message_bubble(
|
||||
msg,
|
||||
app.config(),
|
||||
content_width,
|
||||
selected_msg_id,
|
||||
app.playback_state.as_ref(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if lines.is_empty() {
|
||||
lines.push(Line::from(Span::styled("Нет сообщений", Style::default().fg(Color::Gray))));
|
||||
}
|
||||
|
||||
let visible_height = area.height.saturating_sub(2) as usize;
|
||||
let total_lines = lines.len();
|
||||
let base_scroll = total_lines.saturating_sub(visible_height);
|
||||
|
||||
let scroll_offset = if app.is_selecting_message() {
|
||||
if let Some(selected_line) = selected_msg_line {
|
||||
if selected_line < visible_height / 2 {
|
||||
0
|
||||
} else if selected_line > total_lines.saturating_sub(visible_height / 2) {
|
||||
base_scroll
|
||||
} else {
|
||||
selected_line.saturating_sub(visible_height / 2)
|
||||
}
|
||||
} else {
|
||||
base_scroll.saturating_sub(app.message_scroll_offset)
|
||||
}
|
||||
} else {
|
||||
base_scroll.saturating_sub(app.message_scroll_offset)
|
||||
} as u16;
|
||||
|
||||
let messages_widget = Paragraph::new(lines)
|
||||
.block(Block::default().borders(Borders::ALL))
|
||||
.scroll((scroll_offset, 0));
|
||||
f.render_widget(messages_widget, area);
|
||||
|
||||
#[cfg(feature = "images")]
|
||||
render_deferred_images(f, area, app, &deferred_images, visible_height, scroll_offset);
|
||||
}
|
||||
|
||||
#[cfg(feature = "images")]
|
||||
fn render_deferred_images<T: TdClientTrait>(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
app: &mut App<T>,
|
||||
deferred_images: &[components::DeferredImageRender],
|
||||
visible_height: usize,
|
||||
scroll_offset: u16,
|
||||
) {
|
||||
use ratatui_image::StatefulImage;
|
||||
|
||||
let should_render_images = app
|
||||
.last_image_render_time
|
||||
.map(|t| t.elapsed() > std::time::Duration::from_millis(66))
|
||||
.unwrap_or(true);
|
||||
|
||||
if deferred_images.is_empty() || !should_render_images {
|
||||
return;
|
||||
}
|
||||
|
||||
let content_x = area.x + 1;
|
||||
let content_y = area.y + 1;
|
||||
|
||||
for d in deferred_images {
|
||||
let y_in_content = d.line_offset as i32 - scroll_offset as i32;
|
||||
|
||||
if y_in_content < 0 || y_in_content as usize >= visible_height {
|
||||
continue;
|
||||
}
|
||||
|
||||
let img_y = content_y + y_in_content as u16;
|
||||
let remaining_height = (content_y + visible_height as u16).saturating_sub(img_y);
|
||||
|
||||
if d.height > remaining_height {
|
||||
continue;
|
||||
}
|
||||
|
||||
let img_rect = Rect::new(content_x + d.x_offset, img_y, d.width, d.height);
|
||||
|
||||
if let Some(renderer) = &mut app.inline_image_renderer {
|
||||
let _ = renderer.load_image(d.message_id, &d.photo_path);
|
||||
|
||||
if let Some(protocol) = renderer.get_protocol(&d.message_id) {
|
||||
f.render_stateful_widget(StatefulImage::default(), img_rect, protocol);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app.last_image_render_time = Some(std::time::Instant::now());
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
use crate::app::App;
|
||||
use crate::tdlib::TdClientTrait;
|
||||
use ratatui::{
|
||||
layout::Rect,
|
||||
style::{Color, Style},
|
||||
text::{Line, Span},
|
||||
widgets::Paragraph,
|
||||
Frame,
|
||||
};
|
||||
|
||||
pub(super) fn render_pinned_bar<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
|
||||
let Some(pinned_msg) = app.td_client.current_pinned_message() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let pinned_preview: String = pinned_msg.text().chars().take(40).collect();
|
||||
let ellipsis = if pinned_msg.text().chars().count() > 40 {
|
||||
"..."
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let pinned_datetime = crate::utils::format_datetime(pinned_msg.date());
|
||||
let pinned_text = format!("📌 {} {}{}", pinned_datetime, pinned_preview, ellipsis);
|
||||
let pinned_hint = "Ctrl+P";
|
||||
|
||||
let pinned_bar_width = area.width as usize;
|
||||
let text_len = pinned_text.chars().count();
|
||||
let hint_len = pinned_hint.chars().count();
|
||||
let padding = pinned_bar_width.saturating_sub(text_len + hint_len + 2);
|
||||
|
||||
let pinned_line = Line::from(vec![
|
||||
Span::styled(pinned_text, Style::default().fg(Color::Magenta)),
|
||||
Span::raw(" ".repeat(padding)),
|
||||
Span::styled(pinned_hint, Style::default().fg(Color::Gray)),
|
||||
]);
|
||||
let pinned_bar = Paragraph::new(pinned_line).style(Style::default().bg(Color::Rgb(40, 20, 40)));
|
||||
f.render_widget(pinned_bar, area);
|
||||
}
|
||||
@@ -1,282 +0,0 @@
|
||||
#[cfg(test)]
|
||||
use chrono::FixedOffset;
|
||||
use chrono::{DateTime, Local, NaiveDate, Utc};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
pub trait LocalTimeSource {
|
||||
fn now_date(&self) -> NaiveDate;
|
||||
fn now_timestamp(&self) -> i32;
|
||||
fn format_timestamp(&self, timestamp: i32, format: &str) -> Option<String>;
|
||||
fn date_for_timestamp(&self, timestamp: i32) -> Option<NaiveDate>;
|
||||
}
|
||||
|
||||
pub struct SystemLocalTime;
|
||||
|
||||
impl LocalTimeSource for SystemLocalTime {
|
||||
fn now_date(&self) -> NaiveDate {
|
||||
Local::now().date_naive()
|
||||
}
|
||||
|
||||
fn now_timestamp(&self) -> i32 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i32
|
||||
}
|
||||
|
||||
fn format_timestamp(&self, timestamp: i32, format: &str) -> Option<String> {
|
||||
DateTime::<Utc>::from_timestamp(timestamp as i64, 0)
|
||||
.map(|dt| dt.with_timezone(&Local).format(format).to_string())
|
||||
}
|
||||
|
||||
fn date_for_timestamp(&self, timestamp: i32) -> Option<NaiveDate> {
|
||||
DateTime::<Utc>::from_timestamp(timestamp as i64, 0)
|
||||
.map(|dt| dt.with_timezone(&Local).date_naive())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg(test)]
|
||||
pub struct FixedLocalTime {
|
||||
offset: FixedOffset,
|
||||
now: DateTime<FixedOffset>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl FixedLocalTime {
|
||||
fn new(offset: FixedOffset, now_timestamp: i32) -> Self {
|
||||
let now = DateTime::<Utc>::from_timestamp(now_timestamp as i64, 0)
|
||||
.expect("valid fixed timestamp")
|
||||
.with_timezone(&offset);
|
||||
Self { offset, now }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl LocalTimeSource for FixedLocalTime {
|
||||
fn now_date(&self) -> NaiveDate {
|
||||
self.now.date_naive()
|
||||
}
|
||||
|
||||
fn now_timestamp(&self) -> i32 {
|
||||
self.now.timestamp() as i32
|
||||
}
|
||||
|
||||
fn format_timestamp(&self, timestamp: i32, format: &str) -> Option<String> {
|
||||
DateTime::<Utc>::from_timestamp(timestamp as i64, 0)
|
||||
.map(|dt| dt.with_timezone(&self.offset).format(format).to_string())
|
||||
}
|
||||
|
||||
fn date_for_timestamp(&self, timestamp: i32) -> Option<NaiveDate> {
|
||||
DateTime::<Utc>::from_timestamp(timestamp as i64, 0)
|
||||
.map(|dt| dt.with_timezone(&self.offset).date_naive())
|
||||
}
|
||||
}
|
||||
|
||||
fn system_time() -> SystemLocalTime {
|
||||
SystemLocalTime
|
||||
}
|
||||
|
||||
/// Форматирование timestamp во время HH:MM в системной таймзоне.
|
||||
pub fn format_timestamp(timestamp: i32) -> String {
|
||||
format_timestamp_with(timestamp, &system_time())
|
||||
}
|
||||
|
||||
pub fn format_timestamp_with(timestamp: i32, time: &impl LocalTimeSource) -> String {
|
||||
time.format_timestamp(timestamp, "%H:%M")
|
||||
.unwrap_or_else(|| "00:00".to_string())
|
||||
}
|
||||
|
||||
/// Форматирование timestamp в дату для разделителя.
|
||||
pub fn format_date(timestamp: i32) -> String {
|
||||
format_date_with(timestamp, &system_time())
|
||||
}
|
||||
|
||||
pub fn format_date_with(timestamp: i32, time: &impl LocalTimeSource) -> String {
|
||||
let Some(msg_day) = time.date_for_timestamp(timestamp) else {
|
||||
return "01.01.1970".to_string();
|
||||
};
|
||||
|
||||
let today = time.now_date();
|
||||
|
||||
if msg_day == today {
|
||||
"Сегодня".to_string()
|
||||
} else if Some(msg_day) == today.pred_opt() {
|
||||
"Вчера".to_string()
|
||||
} else {
|
||||
time.format_timestamp(timestamp, "%d.%m.%Y")
|
||||
.unwrap_or_else(|| "01.01.1970".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Получить день из timestamp для группировки.
|
||||
/// Возвращает число дней с 1970-01-01 в системной таймзоне.
|
||||
#[allow(dead_code)]
|
||||
pub fn get_day(timestamp: i32) -> i64 {
|
||||
get_day_with(timestamp, &system_time())
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn get_day_with(timestamp: i32, time: &impl LocalTimeSource) -> i64 {
|
||||
let epoch = NaiveDate::from_ymd_opt(1970, 1, 1).expect("valid epoch date");
|
||||
|
||||
time.date_for_timestamp(timestamp)
|
||||
.map(|date| date.signed_duration_since(epoch).num_days())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Форматирование timestamp в полную дату и время (DD.MM.YYYY HH:MM) в системной таймзоне.
|
||||
pub fn format_datetime(timestamp: i32) -> String {
|
||||
format_datetime_with(timestamp, &system_time())
|
||||
}
|
||||
|
||||
pub fn format_datetime_with(timestamp: i32, time: &impl LocalTimeSource) -> String {
|
||||
time.format_timestamp(timestamp, "%d.%m.%Y %H:%M")
|
||||
.unwrap_or_else(|| "01.01.1970 00:00".to_string())
|
||||
}
|
||||
|
||||
/// Форматирование "был(а) онлайн" из timestamp
|
||||
pub fn format_was_online(timestamp: i32) -> String {
|
||||
format_was_online_with(timestamp, &system_time())
|
||||
}
|
||||
|
||||
pub fn format_was_online_with(timestamp: i32, time: &impl LocalTimeSource) -> String {
|
||||
let now = time.now_timestamp();
|
||||
let diff = now - timestamp;
|
||||
|
||||
if diff < 60 {
|
||||
"был(а) только что".to_string()
|
||||
} else if diff < 3600 {
|
||||
let mins = diff / 60;
|
||||
format!("был(а) {} мин. назад", mins)
|
||||
} else if diff < 86400 {
|
||||
let hours = diff / 3600;
|
||||
format!("был(а) {} ч. назад", hours)
|
||||
} else {
|
||||
// Показываем локальную дату
|
||||
let datetime = time
|
||||
.format_timestamp(timestamp, "%d.%m %H:%M")
|
||||
.unwrap_or_else(|| "давно".to_string());
|
||||
format!("был(а) {}", datetime)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fixed_time() -> FixedLocalTime {
|
||||
FixedLocalTime::new(
|
||||
FixedOffset::east_opt(3 * 3600).unwrap(),
|
||||
1_640_448_000, // 25.12.2021 03:00:00 +03:00
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_timestamp_uses_supplied_timezone() {
|
||||
let timestamp = 1640000000;
|
||||
assert_eq!(format_timestamp_with(timestamp, &fixed_time()), "14:33");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_day() {
|
||||
let time = FixedLocalTime::new(FixedOffset::east_opt(0).unwrap(), 3 * 86_400);
|
||||
assert_eq!(get_day_with(0, &time), 0);
|
||||
assert_eq!(get_day_with(86400, &time), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_day_grouping() {
|
||||
let time = fixed_time();
|
||||
let msg1 = 1640000000;
|
||||
let msg2 = msg1 + 3600;
|
||||
assert_eq!(get_day_with(msg1, &time), get_day_with(msg2, &time));
|
||||
|
||||
let msg3 = msg1 + 172800;
|
||||
assert_ne!(get_day_with(msg1, &time), get_day_with(msg3, &time));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_datetime() {
|
||||
let timestamp = 1640000000;
|
||||
assert_eq!(format_datetime_with(timestamp, &fixed_time()), "20.12.2021 14:33");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_date_today() {
|
||||
let time = fixed_time();
|
||||
let result = format_date_with(time.now_timestamp(), &time);
|
||||
assert_eq!(result, "Сегодня");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_date_yesterday() {
|
||||
let time = fixed_time();
|
||||
let yesterday = time.now_timestamp() - 86400;
|
||||
let result = format_date_with(yesterday, &time);
|
||||
assert_eq!(result, "Вчера");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_date_old() {
|
||||
let old_timestamp = 1640000000;
|
||||
assert_eq!(format_date_with(old_timestamp, &fixed_time()), "20.12.2021");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_date_epoch() {
|
||||
let epoch = 0;
|
||||
let time = FixedLocalTime::new(FixedOffset::east_opt(0).unwrap(), 3 * 86_400);
|
||||
let result = format_date_with(epoch, &time);
|
||||
|
||||
assert!(result.contains('.'));
|
||||
assert!(result.contains("1970"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_was_online_just_now() {
|
||||
let time = fixed_time();
|
||||
let now = time.now_timestamp();
|
||||
let recent = now - 30;
|
||||
let result = format_was_online_with(recent, &time);
|
||||
assert_eq!(result, "был(а) только что");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_was_online_minutes_ago() {
|
||||
let time = fixed_time();
|
||||
let now = time.now_timestamp();
|
||||
let mins_ago = now - (15 * 60);
|
||||
let result = format_was_online_with(mins_ago, &time);
|
||||
assert_eq!(result, "был(а) 15 мин. назад");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_was_online_hours_ago() {
|
||||
let time = fixed_time();
|
||||
let now = time.now_timestamp();
|
||||
let hours_ago = now - (5 * 3600);
|
||||
let result = format_was_online_with(hours_ago, &time);
|
||||
assert_eq!(result, "был(а) 5 ч. назад");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_was_online_days_ago() {
|
||||
let time = fixed_time();
|
||||
let now = time.now_timestamp();
|
||||
let days_ago = now - (3 * 86400);
|
||||
let result = format_was_online_with(days_ago, &time);
|
||||
|
||||
assert!(result.starts_with("был(а)"));
|
||||
assert!(result.contains('.') || result.contains(':'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_was_online_very_old() {
|
||||
let old = 1577836800;
|
||||
let result = format_was_online_with(old, &fixed_time());
|
||||
|
||||
assert!(result.starts_with("был(а)"));
|
||||
assert!(result.contains('.'));
|
||||
}
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
#![cfg(feature = "test-support")]
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
use termwright::prelude::*;
|
||||
|
||||
fn fixture_path() -> &'static str {
|
||||
env!("CARGO_BIN_EXE_tele-tui-test-fixture")
|
||||
}
|
||||
|
||||
async fn spawn_fixture(scenario: &str) -> Result<Terminal> {
|
||||
let mut builder = Terminal::builder()
|
||||
.size(100, 30)
|
||||
.working_dir(env!("CARGO_MANIFEST_DIR"));
|
||||
|
||||
if let Some(lib_path) = tdlib_library_path() {
|
||||
builder = builder
|
||||
.env("DYLD_LIBRARY_PATH", &lib_path)
|
||||
.env("LD_LIBRARY_PATH", &lib_path);
|
||||
}
|
||||
let command = format!(
|
||||
"stty -echo -ixon; exec {} --scenario {}",
|
||||
shell_quote(fixture_path()),
|
||||
shell_quote(scenario)
|
||||
);
|
||||
builder.spawn("/bin/sh", &["-lc", &command]).await
|
||||
}
|
||||
|
||||
fn shell_quote(value: &str) -> String {
|
||||
format!("'{}'", value.replace('\'', "'\\''"))
|
||||
}
|
||||
|
||||
fn tdlib_library_path() -> Option<String> {
|
||||
let build_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("target")
|
||||
.join("debug")
|
||||
.join("build");
|
||||
let entries = std::fs::read_dir(build_dir).ok()?;
|
||||
|
||||
let mut paths = Vec::new();
|
||||
for entry in entries.flatten() {
|
||||
let lib_dir = entry.path().join("out").join("tdlib").join("lib");
|
||||
if lib_dir.join("libtdjson.1.8.29.dylib").exists() || lib_dir.join("libtdjson.so").exists()
|
||||
{
|
||||
paths.push(lib_dir);
|
||||
}
|
||||
}
|
||||
|
||||
(!paths.is_empty()).then(|| {
|
||||
paths
|
||||
.into_iter()
|
||||
.map(|path| path.to_string_lossy().into_owned())
|
||||
.collect::<Vec<_>>()
|
||||
.join(":")
|
||||
})
|
||||
}
|
||||
|
||||
async fn stop_fixture(term: &mut Terminal) {
|
||||
let _ = tokio::time::timeout(Duration::from_millis(500), term.send_key(Key::F(10))).await;
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
let _ = std::process::Command::new("pkill")
|
||||
.arg("-f")
|
||||
.arg("tele-tui-test-fixture")
|
||||
.status();
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
let _ = tokio::time::timeout(Duration::from_secs(1), term.kill()).await;
|
||||
}
|
||||
|
||||
async fn wait_for_text(term: &Terminal, needle: &str) -> Result<()> {
|
||||
let started = Instant::now();
|
||||
let mut last_screen = String::new();
|
||||
for _ in 0..100 {
|
||||
let Ok(screen) = screen_text(term).await else {
|
||||
continue;
|
||||
};
|
||||
if screen.contains(needle) {
|
||||
return Ok(());
|
||||
}
|
||||
last_screen = screen;
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
|
||||
let elapsed = started.elapsed();
|
||||
Err(TermwrightError::Timeout {
|
||||
condition: format!("text '{needle}' to appear\n\n{last_screen}"),
|
||||
timeout: elapsed,
|
||||
})
|
||||
}
|
||||
|
||||
async fn screen_text(term: &Terminal) -> Result<String> {
|
||||
tokio::time::timeout(Duration::from_millis(500), term.screen())
|
||||
.await
|
||||
.map(|screen| screen.text())
|
||||
.map_err(|_| TermwrightError::Timeout {
|
||||
condition: "terminal screen snapshot".to_string(),
|
||||
timeout: Duration::from_millis(500),
|
||||
})
|
||||
}
|
||||
|
||||
async fn enter_insert_mode(term: &Terminal) -> Result<()> {
|
||||
for _ in 0..5 {
|
||||
term.send_key(Key::Char('i')).await?;
|
||||
std::thread::sleep(Duration::from_millis(150));
|
||||
if !screen_text(term).await?.contains("Press i to type") {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let screen = screen_text(term).await?;
|
||||
Err(TermwrightError::Timeout {
|
||||
condition: format!("insert mode to start\n\n{screen}"),
|
||||
timeout: Duration::from_millis(750),
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn e2e_termwright_user_flows() -> Result<()> {
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(2)
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("failed to build e2e runtime");
|
||||
|
||||
let result = runtime.block_on(async {
|
||||
tokio::time::timeout(Duration::from_secs(15), compose_and_send_message()).await
|
||||
});
|
||||
kill_fixture_processes();
|
||||
|
||||
match result {
|
||||
Ok(result) => result,
|
||||
Err(_) => Err(TermwrightError::Timeout {
|
||||
condition: "termwright e2e user flow".to_string(),
|
||||
timeout: Duration::from_secs(15),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn compose_and_send_message() -> Result<()> {
|
||||
let mut term = spawn_fixture("compose-draft").await?;
|
||||
let result = async {
|
||||
wait_for_text(&term, "Work Group").await?;
|
||||
wait_for_text(&term, "Standup notes are ready").await?;
|
||||
wait_for_text(&term, "hello from e2e").await?;
|
||||
enter_insert_mode(&term).await?;
|
||||
wait_for_text(&term, "hello from e2e").await?;
|
||||
term.send_key(Key::Enter).await?;
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
|
||||
let screen = screen_text(&term).await?;
|
||||
assert!(screen.contains("hello from e2e"), "sent message should appear\n\n{}", screen);
|
||||
assert!(
|
||||
!screen.contains("Сообщение: hello from e2e"),
|
||||
"compose input should clear after send"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
stop_fixture(&mut term).await;
|
||||
result
|
||||
}
|
||||
|
||||
fn kill_fixture_processes() {
|
||||
let _ = std::process::Command::new("pkill")
|
||||
.arg("-f")
|
||||
.arg("tele-tui-test-fixture")
|
||||
.status();
|
||||
}
|
||||
@@ -1,146 +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,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::helpers::test_data::create_test_chat;
|
||||
use tele_tui::types::ChatId;
|
||||
|
||||
#[test]
|
||||
fn test_fake_client_creation() {
|
||||
let client = FakeTdClient::new();
|
||||
assert_eq!(client.get_chats().len(), 0);
|
||||
assert_eq!(client.folders.lock().unwrap().len(), 1); // Default "All" folder
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fake_client_with_chat() {
|
||||
let chat = create_test_chat("Mom", 123);
|
||||
let client = FakeTdClient::new().with_chat(chat);
|
||||
|
||||
let chats = client.get_chats();
|
||||
assert_eq!(chats.len(), 1);
|
||||
assert_eq!(chats[0].title, "Mom");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_send_message() {
|
||||
let client = FakeTdClient::new();
|
||||
let chat_id = ChatId::new(123);
|
||||
|
||||
let result = client
|
||||
.send_message(chat_id, "Hello".to_string(), None, None)
|
||||
.await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let sent = client.get_sent_messages();
|
||||
assert_eq!(sent.len(), 1);
|
||||
assert_eq!(sent[0].text, "Hello");
|
||||
assert_eq!(client.get_messages(123).len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_edit_message() {
|
||||
let client = FakeTdClient::new();
|
||||
let chat_id = ChatId::new(123);
|
||||
|
||||
let msg = client
|
||||
.send_message(chat_id, "Hello".to_string(), None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
let msg_id = msg.id();
|
||||
|
||||
let _ = client
|
||||
.edit_message(chat_id, msg_id, "Hello World".to_string())
|
||||
.await;
|
||||
|
||||
let edited = client.get_edited_messages();
|
||||
assert_eq!(edited.len(), 1);
|
||||
assert_eq!(client.get_messages(123)[0].text(), "Hello World");
|
||||
assert!(client.get_messages(123)[0].metadata.edit_date > 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_delete_message() {
|
||||
let client = FakeTdClient::new();
|
||||
let chat_id = ChatId::new(123);
|
||||
|
||||
let msg = client
|
||||
.send_message(chat_id, "Hello".to_string(), None, None)
|
||||
.await
|
||||
.unwrap();
|
||||
let msg_id = msg.id();
|
||||
|
||||
let _ = client.delete_messages(chat_id, vec![msg_id], false).await;
|
||||
|
||||
let deleted = client.get_deleted_messages();
|
||||
assert_eq!(deleted.len(), 1);
|
||||
assert_eq!(client.get_messages(123).len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_update_channel() {
|
||||
let (client, mut rx) = FakeTdClient::new().with_update_channel();
|
||||
let chat_id = ChatId::new(123);
|
||||
|
||||
let _ = client
|
||||
.send_message(chat_id, "Test".to_string(), None, None)
|
||||
.await;
|
||||
|
||||
if let Some(update) = rx.recv().await {
|
||||
match update {
|
||||
TdUpdate::NewMessage { chat_id: updated_chat, .. } => {
|
||||
assert_eq!(updated_chat, chat_id);
|
||||
}
|
||||
_ => panic!("Expected NewMessage update"),
|
||||
}
|
||||
} else {
|
||||
panic!("No update received");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_simulate_incoming_message() {
|
||||
let (client, mut rx) = FakeTdClient::new().with_update_channel();
|
||||
let chat_id = ChatId::new(123);
|
||||
|
||||
client.simulate_incoming_message(chat_id, "Hello from Bob".to_string(), "Bob");
|
||||
|
||||
if let Some(TdUpdate::NewMessage { message, .. }) = rx.recv().await {
|
||||
assert_eq!(message.text(), "Hello from Bob");
|
||||
assert_eq!(message.sender_name(), "Bob");
|
||||
assert!(!message.is_outgoing());
|
||||
}
|
||||
|
||||
assert_eq!(client.get_messages(123).len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fail_next_operation() {
|
||||
let client = FakeTdClient::new();
|
||||
let chat_id = ChatId::new(123);
|
||||
|
||||
client.fail_next();
|
||||
|
||||
let result = client
|
||||
.send_message(chat_id, "Test".to_string(), None, None)
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
|
||||
let result2 = client
|
||||
.send_message(chat_id, "Test2".to_string(), None, None)
|
||||
.await;
|
||||
assert!(result2.is_ok());
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
use super::{FakeTdClient, TdUpdate};
|
||||
use tele_tui::tdlib::types::FolderInfo;
|
||||
use tele_tui::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 tele_tui::tdlib::types::FolderInfo;
|
||||
use tele_tui::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,458 +0,0 @@
|
||||
use super::{
|
||||
DeletedMessages, EditedMessage, FakeTdClient, ForwardedMessages, SearchQuery, SentMessage,
|
||||
TdUpdate,
|
||||
};
|
||||
use tele_tui::tdlib::types::ReactionInfo;
|
||||
use tele_tui::tdlib::{ChatInfo, MessageInfo, ProfileInfo, ReplyInfo};
|
||||
use tele_tui::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());
|
||||
|
||||
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: tele_tui::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,201 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tele_tui::tdlib::types::{FolderInfo, ReactionInfo};
|
||||
use tele_tui::tdlib::{AuthState, ChatInfo, MessageInfo, NetworkState, ProfileInfo, ReplyInfo};
|
||||
use tele_tui::types::{ChatId, MessageId, UserId};
|
||||
use 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 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),
|
||||
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![])),
|
||||
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,22 +0,0 @@
|
||||
// Test helpers module.
|
||||
//
|
||||
// In all-features runs, integration tests exercise the same gated support module
|
||||
// used by the PTY fixture binary. Plain `cargo test` keeps the local copies so
|
||||
// existing tests do not need the internal feature enabled.
|
||||
|
||||
#[cfg(feature = "test-support")]
|
||||
pub use tele_tui::test_support::*;
|
||||
|
||||
#[cfg(not(feature = "test-support"))]
|
||||
pub mod app_builder;
|
||||
#[cfg(not(feature = "test-support"))]
|
||||
pub mod fake_tdclient;
|
||||
#[cfg(not(feature = "test-support"))]
|
||||
mod fake_tdclient_impl; // TdClientTrait implementation for FakeTdClient
|
||||
#[cfg(not(feature = "test-support"))]
|
||||
pub mod snapshot_utils;
|
||||
#[cfg(not(feature = "test-support"))]
|
||||
pub mod test_data;
|
||||
|
||||
#[cfg(not(feature = "test-support"))]
|
||||
pub use fake_tdclient::FakeTdClient;
|
||||
@@ -1,228 +0,0 @@
|
||||
// Screen snapshot tests
|
||||
|
||||
mod helpers;
|
||||
|
||||
use helpers::app_builder::TestAppBuilder;
|
||||
use helpers::snapshot_utils::{buffer_to_string, render_to_buffer};
|
||||
use helpers::test_data::{create_test_chat, TestChatBuilder, TestMessageBuilder};
|
||||
use insta::assert_snapshot;
|
||||
use tele_tui::accounts::AccountProfile;
|
||||
use tele_tui::app::AccountSwitcherState;
|
||||
use tele_tui::app::AppScreen;
|
||||
use tele_tui::tdlib::AuthState;
|
||||
|
||||
#[test]
|
||||
fn snapshot_loading_screen_default() {
|
||||
let mut app = TestAppBuilder::new().screen(AppScreen::Loading).build();
|
||||
|
||||
let buffer = render_to_buffer(80, 24, |f| {
|
||||
tele_tui::ui::render(f, &mut app);
|
||||
});
|
||||
|
||||
let output = buffer_to_string(&buffer);
|
||||
assert_snapshot!("loading_screen_default", output);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_loading_screen_with_status() {
|
||||
let mut app = TestAppBuilder::new()
|
||||
.screen(AppScreen::Loading)
|
||||
.status_message("Подключение к Telegram...")
|
||||
.build();
|
||||
|
||||
let buffer = render_to_buffer(80, 24, |f| {
|
||||
tele_tui::ui::render(f, &mut app);
|
||||
});
|
||||
|
||||
let output = buffer_to_string(&buffer);
|
||||
assert_snapshot!("loading_screen_with_status", output);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_auth_screen_phone() {
|
||||
let mut app = TestAppBuilder::new()
|
||||
.screen(AppScreen::Auth)
|
||||
.auth_state(AuthState::WaitPhoneNumber)
|
||||
.phone_input("+7")
|
||||
.build();
|
||||
|
||||
let buffer = render_to_buffer(80, 24, |f| {
|
||||
tele_tui::ui::render(f, &mut app);
|
||||
});
|
||||
|
||||
let output = buffer_to_string(&buffer);
|
||||
assert_snapshot!("auth_screen_phone", output);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_auth_screen_code() {
|
||||
let mut app = TestAppBuilder::new()
|
||||
.screen(AppScreen::Auth)
|
||||
.auth_state(AuthState::WaitCode)
|
||||
.code_input("1234")
|
||||
.build();
|
||||
|
||||
let buffer = render_to_buffer(80, 24, |f| {
|
||||
tele_tui::ui::render(f, &mut app);
|
||||
});
|
||||
|
||||
let output = buffer_to_string(&buffer);
|
||||
assert_snapshot!("auth_screen_code", output);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_auth_screen_password() {
|
||||
let mut app = TestAppBuilder::new()
|
||||
.screen(AppScreen::Auth)
|
||||
.auth_state(AuthState::WaitPassword)
|
||||
.password_input("pass")
|
||||
.build();
|
||||
|
||||
let buffer = render_to_buffer(80, 24, |f| {
|
||||
tele_tui::ui::render(f, &mut app);
|
||||
});
|
||||
|
||||
let output = buffer_to_string(&buffer);
|
||||
assert_snapshot!("auth_screen_password", output);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_main_screen_empty() {
|
||||
let mut app = TestAppBuilder::new().screen(AppScreen::Main).build();
|
||||
|
||||
let buffer = render_to_buffer(80, 24, |f| {
|
||||
tele_tui::ui::render(f, &mut app);
|
||||
});
|
||||
|
||||
let output = buffer_to_string(&buffer);
|
||||
assert_snapshot!("main_screen_empty", output);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_main_screen_terminal_too_small() {
|
||||
let chat = create_test_chat("Mom", 123);
|
||||
|
||||
let mut app = TestAppBuilder::new()
|
||||
.screen(AppScreen::Main)
|
||||
.with_chat(chat)
|
||||
.build();
|
||||
|
||||
// Use smaller terminal size (30x8) - below minimum 40x10
|
||||
let buffer = render_to_buffer(30, 8, |f| {
|
||||
tele_tui::ui::render(f, &mut app);
|
||||
});
|
||||
|
||||
let output = buffer_to_string(&buffer);
|
||||
assert_snapshot!("main_screen_terminal_too_small", output);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_main_screen_chat_list_loaded() {
|
||||
let mut app = TestAppBuilder::new()
|
||||
.screen(AppScreen::Main)
|
||||
.with_chats(sample_chats())
|
||||
.build();
|
||||
|
||||
let buffer = render_to_buffer(100, 30, |f| {
|
||||
tele_tui::ui::render(f, &mut app);
|
||||
});
|
||||
|
||||
let output = buffer_to_string(&buffer);
|
||||
assert_snapshot!("main_screen_chat_list_loaded", output);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_main_screen_chat_open_with_messages() {
|
||||
let mut app = TestAppBuilder::new()
|
||||
.screen(AppScreen::Main)
|
||||
.with_chats(sample_chats())
|
||||
.selected_chat(102)
|
||||
.with_messages(102, sample_work_messages())
|
||||
.message_input("Draft reply")
|
||||
.insert_mode()
|
||||
.build();
|
||||
|
||||
let buffer = render_to_buffer(100, 30, |f| {
|
||||
tele_tui::ui::render(f, &mut app);
|
||||
});
|
||||
|
||||
let output = buffer_to_string(&buffer);
|
||||
assert_snapshot!("main_screen_chat_open_with_messages", output);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_main_screen_chat_open_narrow_valid() {
|
||||
let mut app = TestAppBuilder::new()
|
||||
.screen(AppScreen::Main)
|
||||
.with_chats(sample_chats())
|
||||
.selected_chat(102)
|
||||
.with_messages(102, sample_work_messages())
|
||||
.build();
|
||||
|
||||
let buffer = render_to_buffer(60, 16, |f| {
|
||||
tele_tui::ui::render(f, &mut app);
|
||||
});
|
||||
|
||||
let output = buffer_to_string(&buffer);
|
||||
assert_snapshot!("main_screen_chat_open_narrow_valid", output);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_main_screen_account_switcher_overlay() {
|
||||
let mut app = TestAppBuilder::new()
|
||||
.screen(AppScreen::Main)
|
||||
.with_chats(sample_chats())
|
||||
.build();
|
||||
app.current_account_name = "personal".to_string();
|
||||
app.account_switcher = Some(AccountSwitcherState::SelectAccount {
|
||||
accounts: vec![
|
||||
AccountProfile {
|
||||
name: "personal".to_string(),
|
||||
display_name: "Personal".to_string(),
|
||||
},
|
||||
AccountProfile {
|
||||
name: "work".to_string(),
|
||||
display_name: "Work".to_string(),
|
||||
},
|
||||
],
|
||||
selected_index: 1,
|
||||
current_account: "personal".to_string(),
|
||||
});
|
||||
|
||||
let buffer = render_to_buffer(100, 30, |f| {
|
||||
tele_tui::ui::render(f, &mut app);
|
||||
});
|
||||
|
||||
let output = buffer_to_string(&buffer);
|
||||
assert_snapshot!("main_screen_account_switcher_overlay", output);
|
||||
}
|
||||
|
||||
fn sample_chats() -> Vec<tele_tui::tdlib::ChatInfo> {
|
||||
vec![
|
||||
TestChatBuilder::new("Mom", 101)
|
||||
.last_message("Dinner at 7?")
|
||||
.unread_count(2)
|
||||
.build(),
|
||||
TestChatBuilder::new("Work Group", 102)
|
||||
.last_message("Standup notes are ready")
|
||||
.unread_mentions(1)
|
||||
.build(),
|
||||
TestChatBuilder::new("Boss", 103)
|
||||
.last_message("Please review the deck")
|
||||
.build(),
|
||||
]
|
||||
}
|
||||
|
||||
fn sample_work_messages() -> Vec<tele_tui::tdlib::MessageInfo> {
|
||||
vec![
|
||||
TestMessageBuilder::new("Morning, team", 201)
|
||||
.sender("Alice")
|
||||
.build(),
|
||||
TestMessageBuilder::new("Standup notes are ready", 202)
|
||||
.sender("Bob")
|
||||
.build(),
|
||||
TestMessageBuilder::new("Thanks, I will review them after lunch", 203)
|
||||
.outgoing()
|
||||
.build(),
|
||||
]
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
source: tests/footer.rs
|
||||
assertion_line: 22
|
||||
expression: output
|
||||
---
|
||||
[default] Инициализация TDLib...
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
source: tests/footer.rs
|
||||
assertion_line: 90
|
||||
expression: output
|
||||
---
|
||||
[default] ⏳ Подключение... | Инициализация TDLib...
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
source: tests/footer.rs
|
||||
assertion_line: 73
|
||||
expression: output
|
||||
---
|
||||
[default] ⏳ Прокси... | Инициализация TDLib...
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
source: tests/footer.rs
|
||||
assertion_line: 56
|
||||
expression: output
|
||||
---
|
||||
[default] ⚠ Нет сети | Инициализация TDLib...
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
source: tests/footer.rs
|
||||
assertion_line: 39
|
||||
expression: output
|
||||
---
|
||||
[default] Инициализация TDLib...
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user