fixes
This commit is contained in:
25
.serena/memories/code_style.md
Normal file
25
.serena/memories/code_style.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Code Style and Conventions
|
||||||
|
|
||||||
|
## Rust Style
|
||||||
|
- Следовать стандартному Rust стилю (rustfmt)
|
||||||
|
- Snake_case для переменных и функций
|
||||||
|
- PascalCase для типов и enum вариантов
|
||||||
|
- SCREAMING_SNAKE_CASE для констант
|
||||||
|
|
||||||
|
## Project Conventions
|
||||||
|
- Использовать `Result<T, String>` для ошибок (планируется заменить на `Result<T>` с кастомным enum)
|
||||||
|
- Async/await для TDLib операций
|
||||||
|
- Группировать imports: std → external crates → local modules
|
||||||
|
- Константы вынесены в `src/constants.rs`
|
||||||
|
|
||||||
|
## Architecture Patterns
|
||||||
|
- Модульная структура: app, ui, input, tdlib, utils
|
||||||
|
- TdClient разделён на подмодули: auth, chats, messages, users, reactions
|
||||||
|
- ChatState enum для состояний чата (type-safe)
|
||||||
|
- Snapshot тесты для UI компонентов
|
||||||
|
- Integration тесты для business logic
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
- Комментарии на русском в коде (для логики)
|
||||||
|
- Doc-комментарии на английском (для публичного API)
|
||||||
|
- CLAUDE.md, CONTEXT.md, ROADMAP.md для документации проекта
|
||||||
28
.serena/memories/project_overview.md
Normal file
28
.serena/memories/project_overview.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Telegram TUI - Project Overview
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
TUI (Text User Interface) клиент для Telegram с vim-style навигацией.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
- **Language**: Rust
|
||||||
|
- **TUI Framework**: ratatui 0.29 + crossterm 0.28
|
||||||
|
- **Telegram**: tdlib-rs 1.1 (с автоматической загрузкой TDLib)
|
||||||
|
- **Async Runtime**: tokio (full features)
|
||||||
|
- **Config**: toml 0.8, dirs 5.0
|
||||||
|
- **Other**: chrono 0.4, clipboard 0.5, serde/serde_json 1.0
|
||||||
|
|
||||||
|
## Current Status
|
||||||
|
- Фаза 9 завершена (100%)
|
||||||
|
- Все основные фичи реализованы
|
||||||
|
- 148/151 тестов (98% покрытие)
|
||||||
|
- Рефакторинг: Priority 1 завершён, Priority 2 на 40%
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
- TDLib интеграция с авторизацией
|
||||||
|
- Список чатов с папками, фильтрацией
|
||||||
|
- Отправка/редактирование/удаление сообщений
|
||||||
|
- Reply, Forward, Реакции
|
||||||
|
- Markdown форматирование
|
||||||
|
- Поиск по чатам и сообщениям
|
||||||
|
- Typing indicator, online статусы
|
||||||
|
- Конфигурационный файл с цветами и timezone
|
||||||
37
.serena/memories/suggested_commands.md
Normal file
37
.serena/memories/suggested_commands.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Suggested Commands
|
||||||
|
|
||||||
|
## Building and Running
|
||||||
|
**ВАЖНО**: НИКОГДА не запускать самостоятельно! Всегда просить пользователя!
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Пользователь должен запустить:
|
||||||
|
cargo run
|
||||||
|
cargo build
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
```bash
|
||||||
|
cargo test # Запустить все тесты
|
||||||
|
cargo test --lib # Только библиотечные тесты
|
||||||
|
cargo test <test_name> # Конкретный тест
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code Quality
|
||||||
|
```bash
|
||||||
|
cargo clippy # Линтер
|
||||||
|
cargo fmt # Форматирование
|
||||||
|
cargo check # Быстрая проверка компиляции
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
1. Сделать изменения
|
||||||
|
2. `cargo check` - быстрая проверка
|
||||||
|
3. `cargo test` - запустить тесты
|
||||||
|
4. `cargo clippy` - проверить предупреждения
|
||||||
|
5. `cargo fmt` - отформатировать код
|
||||||
|
6. Попросить пользователя запустить `cargo run` для ручной проверки
|
||||||
|
|
||||||
|
## macOS Specific
|
||||||
|
- Система: Darwin
|
||||||
|
- Стандартные Unix команды работают (ls, grep, find, cd, etc.)
|
||||||
39
.serena/memories/task_completion.md
Normal file
39
.serena/memories/task_completion.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Task Completion Checklist
|
||||||
|
|
||||||
|
## После завершения задачи:
|
||||||
|
|
||||||
|
### 1. Проверка кода
|
||||||
|
- [ ] `cargo check` - компиляция без ошибок
|
||||||
|
- [ ] `cargo clippy` - нет новых предупреждений
|
||||||
|
- [ ] `cargo fmt` - код отформатирован
|
||||||
|
- [ ] `cargo test` - все тесты проходят
|
||||||
|
|
||||||
|
### 2. Ручное тестирование
|
||||||
|
- [ ] Описать сценарии для проверки
|
||||||
|
- [ ] Попросить пользователя запустить `cargo run`
|
||||||
|
- [ ] Дождаться фидбека от пользователя
|
||||||
|
|
||||||
|
### 3. Документация
|
||||||
|
- [ ] Обновить CONTEXT.md (секция "Последние обновления")
|
||||||
|
- [ ] Добавить в CONTEXT.md что сделано
|
||||||
|
- [ ] Если нужно - обновить ROADMAP.md
|
||||||
|
|
||||||
|
### 4. Git (только по запросу пользователя)
|
||||||
|
- [ ] НИКОГДА не добавлять себя в Co-Authored-By
|
||||||
|
- [ ] Создавать коммит только если пользователь попросил
|
||||||
|
|
||||||
|
## Формат сообщения пользователю
|
||||||
|
```
|
||||||
|
Готово! Проверь, пожалуйста:
|
||||||
|
|
||||||
|
1. [Конкретный сценарий проверки]
|
||||||
|
2. [Что должно произойти]
|
||||||
|
3. [На что обратить внимание]
|
||||||
|
|
||||||
|
Напиши, если что-то не работает.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Важно
|
||||||
|
- Работать поэтапно (один этап = одна логическая единица)
|
||||||
|
- После каждого этапа давать сценарий проверки
|
||||||
|
- Не делать сразу много изменений
|
||||||
59
CONTEXT.md
59
CONTEXT.md
@@ -130,9 +130,13 @@ src/
|
|||||||
├── lib.rs # Библиотечный интерфейс (для тестов)
|
├── lib.rs # Библиотечный интерфейс (для тестов)
|
||||||
├── types.rs # Типобезопасные обёртки (ChatId, MessageId, UserId)
|
├── types.rs # Типобезопасные обёртки (ChatId, MessageId, UserId)
|
||||||
├── config.rs # Конфигурация (TOML), загрузка credentials
|
├── config.rs # Конфигурация (TOML), загрузка credentials
|
||||||
|
├── error.rs # TeletuiError enum, Result<T> type alias
|
||||||
|
├── constants.rs # Константы проекта (MAX_MESSAGES_IN_CHAT, POLL_TIMEOUT_MS, etc.)
|
||||||
|
├── formatting.rs # Markdown форматирование (CharStyle, format_text_with_entities)
|
||||||
├── app/
|
├── app/
|
||||||
│ ├── mod.rs # App структура и состояние (needs_redraw флаг)
|
│ ├── mod.rs # App структура и состояние (needs_redraw флаг)
|
||||||
│ └── state.rs # AppScreen enum
|
│ ├── state.rs # AppScreen enum
|
||||||
|
│ └── chat_state.rs # ChatState enum (Normal, MessageSelection, Editing, etc.)
|
||||||
├── ui/
|
├── ui/
|
||||||
│ ├── mod.rs # Роутинг UI по экранам, проверка минимального размера
|
│ ├── mod.rs # Роутинг UI по экранам, проверка минимального размера
|
||||||
│ ├── loading.rs # Экран загрузки
|
│ ├── loading.rs # Экран загрузки
|
||||||
@@ -140,7 +144,15 @@ src/
|
|||||||
│ ├── main_screen.rs # Главный экран с папками
|
│ ├── main_screen.rs # Главный экран с папками
|
||||||
│ ├── chat_list.rs # Список чатов (pin, mute, online, mentions)
|
│ ├── chat_list.rs # Список чатов (pin, mute, online, mentions)
|
||||||
│ ├── messages.rs # Область сообщений (wrap, группировка, динамический инпут)
|
│ ├── messages.rs # Область сообщений (wrap, группировка, динамический инпут)
|
||||||
│ └── footer.rs # Подвал с командами и статусом сети
|
│ ├── footer.rs # Подвал с командами и статусом сети
|
||||||
|
│ ├── profile.rs # Экран профиля пользователя/чата
|
||||||
|
│ └── components/ # Переиспользуемые UI компоненты
|
||||||
|
│ ├── mod.rs
|
||||||
|
│ ├── modal.rs
|
||||||
|
│ ├── input_field.rs
|
||||||
|
│ ├── message_bubble.rs
|
||||||
|
│ ├── chat_list_item.rs
|
||||||
|
│ └── emoji_picker.rs
|
||||||
├── input/
|
├── input/
|
||||||
│ ├── mod.rs # Роутинг ввода
|
│ ├── mod.rs # Роутинг ввода
|
||||||
│ ├── auth.rs # Обработка ввода на экране авторизации
|
│ ├── auth.rs # Обработка ввода на экране авторизации
|
||||||
@@ -154,7 +166,7 @@ src/
|
|||||||
├── messages.rs # MessageManager для сообщений
|
├── messages.rs # MessageManager для сообщений
|
||||||
├── users.rs # UserCache с LRU кэшем
|
├── users.rs # UserCache с LRU кэшем
|
||||||
├── reactions.rs # ReactionManager
|
├── reactions.rs # ReactionManager
|
||||||
└── types.rs # Общие типы данных (ChatInfo, MessageInfo, etc.)
|
└── types.rs # Общие типы данных (ChatInfo, MessageInfo, MessageBuilder, etc.)
|
||||||
|
|
||||||
tests/
|
tests/
|
||||||
├── helpers/
|
├── helpers/
|
||||||
@@ -299,6 +311,35 @@ reaction_other = "gray"
|
|||||||
|
|
||||||
## Последние обновления (2026-01-31)
|
## Последние обновления (2026-01-31)
|
||||||
|
|
||||||
|
### P3.8 — Извлечение форматирования ✅ ЗАВЕРШЕНО!
|
||||||
|
|
||||||
|
**Что сделано**:
|
||||||
|
- ✅ Создан `src/formatting.rs` с логикой markdown форматирования (262 строки)
|
||||||
|
- ✅ Перенесены функции из `messages.rs`:
|
||||||
|
- `CharStyle` — структура для стилей символов (bold, italic, code, spoiler, url, mention)
|
||||||
|
- `format_text_with_entities()` — преобразование текста с entities в стилизованные Span
|
||||||
|
- `styles_equal()` — сравнение стилей
|
||||||
|
- `adjust_entities_for_substring()` — корректировка entities при переносе текста
|
||||||
|
- ✅ Добавлено 5 unit тестов для форматирования
|
||||||
|
- ✅ Обновлены `src/lib.rs` и `src/main.rs` для экспорта модуля
|
||||||
|
- ✅ `src/ui/messages.rs` сокращён на ~143 строки
|
||||||
|
- ✅ Все lib тесты проходят (17 passed)
|
||||||
|
- ✅ Бинарник компилируется успешно
|
||||||
|
|
||||||
|
**Преимущества**:
|
||||||
|
- 📦 Логика форматирования изолирована в отдельном модуле
|
||||||
|
- ✅ Можно тестировать независимо
|
||||||
|
- 🔄 Легко переиспользовать в других компонентах UI
|
||||||
|
- 📖 Улучшена читаемость кода
|
||||||
|
|
||||||
|
**Статус Priority 3**: 25% (2/8 задач) ✅
|
||||||
|
- ✅ P3.7 — UI компоненты
|
||||||
|
- ✅ P3.8 — Форматирование ← ТОЛЬКО ЧТО!
|
||||||
|
- ⏳ P3.9 — Группировка сообщений
|
||||||
|
- ⏳ P3.10 — Hotkey mapping
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### 🎉🎊 PRIORITY 2 ЗАВЕРШЁН НА 100%! 🎊🎉
|
### 🎉🎊 PRIORITY 2 ЗАВЕРШЁН НА 100%! 🎊🎉
|
||||||
|
|
||||||
**P2.7 — MessageBuilder pattern** ✅ ФИНАЛЬНАЯ ЗАДАЧА ЗАВЕРШЕНА!
|
**P2.7 — MessageBuilder pattern** ✅ ФИНАЛЬНАЯ ЗАДАЧА ЗАВЕРШЕНА!
|
||||||
@@ -548,12 +589,14 @@ let message = MessageBuilder::new(MessageId::new(123))
|
|||||||
4. ~~**MessageInfo реструктуризация**~~ ✅ — группировка полей в логические структуры (2026-01-31)
|
4. ~~**MessageInfo реструктуризация**~~ ✅ — группировка полей в логические структуры (2026-01-31)
|
||||||
5. ~~**MessageBuilder pattern**~~ ✅ — fluent API для создания сообщений (2026-01-31)
|
5. ~~**MessageBuilder pattern**~~ ✅ — fluent API для создания сообщений (2026-01-31)
|
||||||
|
|
||||||
|
**Завершено** (Priority 3): ✅ 1/4 (25%)
|
||||||
|
1. ~~**P3.7 — UI компоненты**~~ ✅ — выделение переиспользуемых компонентов (2026-01-31)
|
||||||
|
2. ~~**P3.8 — Форматирование**~~ ✅ — вынесено markdown форматирование в src/formatting.rs (2026-01-31)
|
||||||
|
|
||||||
**В работе** (Priority 3-5):
|
**В работе** (Priority 3-5):
|
||||||
1. **UI компоненты** — выделение переиспользуемых компонентов
|
1. **P3.9 — Группировка сообщений** — вынести логику группировки в отдельный модуль
|
||||||
2. **MessageBuilder** — упрощение создания сообщений
|
2. **P3.10 — Hotkey mapping** — добавить настройку хоткеев в конфиг
|
||||||
3. **UI компоненты** — выделить переиспользуемые компоненты
|
3. **Юнит-тесты** — добавить для utils и других модулей
|
||||||
4. **Форматирование** — вынести markdown форматирование в отдельный модуль
|
|
||||||
5. **Юнит-тесты** — добавить для utils и других модулей
|
|
||||||
|
|
||||||
## Известные проблемы
|
## Известные проблемы
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ pub use chat_state::ChatState;
|
|||||||
pub use state::AppScreen;
|
pub use state::AppScreen;
|
||||||
|
|
||||||
use crate::tdlib::{ChatInfo, TdClient};
|
use crate::tdlib::{ChatInfo, TdClient};
|
||||||
use crate::types::ChatId;
|
use crate::types::{ChatId, MessageId};
|
||||||
use ratatui::widgets::ListState;
|
use ratatui::widgets::ListState;
|
||||||
|
|
||||||
pub struct App {
|
pub struct App {
|
||||||
@@ -189,7 +189,7 @@ impl App {
|
|||||||
// Сначала извлекаем данные из сообщения
|
// Сначала извлекаем данные из сообщения
|
||||||
let msg_data = self.get_selected_message().and_then(|msg| {
|
let msg_data = self.get_selected_message().and_then(|msg| {
|
||||||
if msg.can_be_edited() && msg.is_outgoing() {
|
if msg.can_be_edited() && msg.is_outgoing() {
|
||||||
Some((msg.id()(), msg.text().to_string(), selected_idx.unwrap()))
|
Some((msg.id(), msg.text().to_string(), selected_idx.unwrap()))
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
@@ -226,7 +226,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_selected_chat_id(&self) -> Option<i64> {
|
pub fn get_selected_chat_id(&self) -> Option<i64> {
|
||||||
self.selected_chat_id
|
self.selected_chat_id.map(|id| id.as_i64())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_selected_chat(&self) -> Option<&ChatInfo> {
|
pub fn get_selected_chat(&self) -> Option<&ChatInfo> {
|
||||||
@@ -451,7 +451,7 @@ impl App {
|
|||||||
|
|
||||||
/// Получить ID текущего pinned для перехода в историю
|
/// Получить ID текущего pinned для перехода в историю
|
||||||
pub fn get_selected_pinned_id(&self) -> Option<i64> {
|
pub fn get_selected_pinned_id(&self) -> Option<i64> {
|
||||||
self.get_selected_pinned().map(|m| m.id())
|
self.get_selected_pinned().map(|m| m.id().as_i64())
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Message Search Mode ===
|
// === Message Search Mode ===
|
||||||
@@ -522,7 +522,7 @@ impl App {
|
|||||||
|
|
||||||
/// Получить ID выбранного результата для перехода
|
/// Получить ID выбранного результата для перехода
|
||||||
pub fn get_selected_search_result_id(&self) -> Option<i64> {
|
pub fn get_selected_search_result_id(&self) -> Option<i64> {
|
||||||
self.get_selected_search_result().map(|m| m.id())
|
self.get_selected_search_result().map(|m| m.id().as_i64())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Получить поисковый запрос из режима поиска
|
/// Получить поисковый запрос из режима поиска
|
||||||
@@ -703,7 +703,7 @@ impl App {
|
|||||||
available_reactions: Vec<String>,
|
available_reactions: Vec<String>,
|
||||||
) {
|
) {
|
||||||
self.chat_state = ChatState::ReactionPicker {
|
self.chat_state = ChatState::ReactionPicker {
|
||||||
message_id,
|
message_id: MessageId::new(message_id),
|
||||||
available_reactions,
|
available_reactions,
|
||||||
selected_index: 0,
|
selected_index: 0,
|
||||||
};
|
};
|
||||||
@@ -748,6 +748,6 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_selected_message_for_reaction(&self) -> Option<i64> {
|
pub fn get_selected_message_for_reaction(&self) -> Option<i64> {
|
||||||
self.chat_state.selected_message_id()
|
self.chat_state.selected_message_id().map(|id| id.as_i64())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
285
src/formatting.rs
Normal file
285
src/formatting.rs
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
//! Модуль для форматирования текста с markdown entities
|
||||||
|
//!
|
||||||
|
//! Предоставляет функции для преобразования текста с TDLib TextEntity
|
||||||
|
//! в стилизованные Span для отображения в TUI.
|
||||||
|
|
||||||
|
use ratatui::{
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
text::Span,
|
||||||
|
};
|
||||||
|
use tdlib_rs::enums::TextEntityType;
|
||||||
|
use tdlib_rs::types::TextEntity;
|
||||||
|
|
||||||
|
/// Структура для хранения стиля символа
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
struct CharStyle {
|
||||||
|
bold: bool,
|
||||||
|
italic: bool,
|
||||||
|
underline: bool,
|
||||||
|
strikethrough: bool,
|
||||||
|
code: bool,
|
||||||
|
spoiler: bool,
|
||||||
|
url: bool,
|
||||||
|
mention: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CharStyle {
|
||||||
|
/// Преобразует CharStyle в ratatui Style
|
||||||
|
fn to_style(&self, base_color: Color) -> Style {
|
||||||
|
let mut style = Style::default();
|
||||||
|
|
||||||
|
if self.code {
|
||||||
|
// Код отображается cyan на тёмном фоне
|
||||||
|
style = style.fg(Color::Cyan).bg(Color::DarkGray);
|
||||||
|
} else if self.spoiler {
|
||||||
|
// Спойлер — серый текст (скрытый)
|
||||||
|
style = style.fg(Color::DarkGray).bg(Color::DarkGray);
|
||||||
|
} else if self.url || self.mention {
|
||||||
|
// Ссылки и упоминания — синий с подчёркиванием
|
||||||
|
style = style.fg(Color::Blue).add_modifier(Modifier::UNDERLINED);
|
||||||
|
} else {
|
||||||
|
style = style.fg(base_color);
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.bold {
|
||||||
|
style = style.add_modifier(Modifier::BOLD);
|
||||||
|
}
|
||||||
|
if self.italic {
|
||||||
|
style = style.add_modifier(Modifier::ITALIC);
|
||||||
|
}
|
||||||
|
if self.underline {
|
||||||
|
style = style.add_modifier(Modifier::UNDERLINED);
|
||||||
|
}
|
||||||
|
if self.strikethrough {
|
||||||
|
style = style.add_modifier(Modifier::CROSSED_OUT);
|
||||||
|
}
|
||||||
|
|
||||||
|
style
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Проверяет равенство двух стилей
|
||||||
|
fn styles_equal(a: &CharStyle, b: &CharStyle) -> bool {
|
||||||
|
a.bold == b.bold
|
||||||
|
&& a.italic == b.italic
|
||||||
|
&& a.underline == b.underline
|
||||||
|
&& a.strikethrough == b.strikethrough
|
||||||
|
&& a.code == b.code
|
||||||
|
&& a.spoiler == b.spoiler
|
||||||
|
&& a.url == b.url
|
||||||
|
&& a.mention == b.mention
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Преобразует текст с entities в вектор стилизованных Span
|
||||||
|
///
|
||||||
|
/// # Аргументы
|
||||||
|
///
|
||||||
|
/// * `text` - Текст для форматирования
|
||||||
|
/// * `entities` - Массив TextEntity с информацией о форматировании
|
||||||
|
/// * `base_color` - Базовый цвет текста
|
||||||
|
///
|
||||||
|
/// # Возвращает
|
||||||
|
///
|
||||||
|
/// Вектор Span<'static> со стилизованными фрагментами текста
|
||||||
|
pub fn format_text_with_entities(
|
||||||
|
text: &str,
|
||||||
|
entities: &[TextEntity],
|
||||||
|
base_color: Color,
|
||||||
|
) -> Vec<Span<'static>> {
|
||||||
|
if entities.is_empty() {
|
||||||
|
return vec![Span::styled(
|
||||||
|
text.to_string(),
|
||||||
|
Style::default().fg(base_color),
|
||||||
|
)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаём массив стилей для каждого символа
|
||||||
|
let chars: Vec<char> = text.chars().collect();
|
||||||
|
let mut char_styles: Vec<CharStyle> = vec![CharStyle::default(); chars.len()];
|
||||||
|
|
||||||
|
// Применяем entities к символам
|
||||||
|
for entity in entities {
|
||||||
|
let start = entity.offset as usize;
|
||||||
|
let end = (entity.offset + entity.length) as usize;
|
||||||
|
|
||||||
|
for i in start..end.min(chars.len()) {
|
||||||
|
match &entity.r#type {
|
||||||
|
TextEntityType::Bold => char_styles[i].bold = true,
|
||||||
|
TextEntityType::Italic => char_styles[i].italic = true,
|
||||||
|
TextEntityType::Underline => char_styles[i].underline = true,
|
||||||
|
TextEntityType::Strikethrough => char_styles[i].strikethrough = true,
|
||||||
|
TextEntityType::Code | TextEntityType::Pre | TextEntityType::PreCode(_) => {
|
||||||
|
char_styles[i].code = true
|
||||||
|
}
|
||||||
|
TextEntityType::Spoiler => char_styles[i].spoiler = true,
|
||||||
|
TextEntityType::Url
|
||||||
|
| TextEntityType::TextUrl(_)
|
||||||
|
| TextEntityType::EmailAddress
|
||||||
|
| TextEntityType::PhoneNumber => char_styles[i].url = true,
|
||||||
|
TextEntityType::Mention | TextEntityType::MentionName(_) => {
|
||||||
|
char_styles[i].mention = true
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Группируем последовательные символы с одинаковым стилем
|
||||||
|
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||||
|
let mut current_text = String::new();
|
||||||
|
let mut current_style: Option<CharStyle> = None;
|
||||||
|
|
||||||
|
for (i, ch) in chars.iter().enumerate() {
|
||||||
|
let style = &char_styles[i];
|
||||||
|
|
||||||
|
match ¤t_style {
|
||||||
|
Some(prev_style) if styles_equal(prev_style, style) => {
|
||||||
|
current_text.push(*ch);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
if !current_text.is_empty() {
|
||||||
|
if let Some(prev_style) = ¤t_style {
|
||||||
|
spans.push(Span::styled(
|
||||||
|
current_text.clone(),
|
||||||
|
prev_style.to_style(base_color),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current_text = ch.to_string();
|
||||||
|
current_style = Some(style.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем последний span
|
||||||
|
if !current_text.is_empty() {
|
||||||
|
if let Some(style) = current_style {
|
||||||
|
spans.push(Span::styled(current_text, style.to_style(base_color)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if spans.is_empty() {
|
||||||
|
spans.push(Span::styled(text.to_string(), Style::default().fg(base_color)));
|
||||||
|
}
|
||||||
|
|
||||||
|
spans
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Фильтрует и корректирует entities для подстроки
|
||||||
|
///
|
||||||
|
/// Используется для правильного отображения форматирования при переносе текста.
|
||||||
|
///
|
||||||
|
/// # Аргументы
|
||||||
|
///
|
||||||
|
/// * `entities` - Исходный массив entities
|
||||||
|
/// * `start` - Начальная позиция подстроки (в символах)
|
||||||
|
/// * `length` - Длина подстроки (в символах)
|
||||||
|
///
|
||||||
|
/// # Возвращает
|
||||||
|
///
|
||||||
|
/// Новый массив entities с откорректированными offset и length
|
||||||
|
pub fn adjust_entities_for_substring(
|
||||||
|
entities: &[TextEntity],
|
||||||
|
start: usize,
|
||||||
|
length: usize,
|
||||||
|
) -> Vec<TextEntity> {
|
||||||
|
let start = start as i32;
|
||||||
|
let end = start + length as i32;
|
||||||
|
|
||||||
|
entities
|
||||||
|
.iter()
|
||||||
|
.filter_map(|e| {
|
||||||
|
let e_start = e.offset;
|
||||||
|
let e_end = e.offset + e.length;
|
||||||
|
|
||||||
|
// Проверяем пересечение с нашей подстрокой
|
||||||
|
if e_end <= start || e_start >= end {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вычисляем пересечение
|
||||||
|
let new_start = (e_start - start).max(0);
|
||||||
|
let new_end = (e_end - start).min(length as i32);
|
||||||
|
|
||||||
|
if new_end > new_start {
|
||||||
|
Some(TextEntity {
|
||||||
|
offset: new_start,
|
||||||
|
length: new_end - new_start,
|
||||||
|
r#type: e.r#type.clone(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_format_text_no_entities() {
|
||||||
|
let text = "Hello, world!";
|
||||||
|
let entities = vec![];
|
||||||
|
let spans = format_text_with_entities(text, &entities, Color::White);
|
||||||
|
|
||||||
|
assert_eq!(spans.len(), 1);
|
||||||
|
assert_eq!(spans[0].content, "Hello, world!");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_format_text_with_bold() {
|
||||||
|
let text = "Hello";
|
||||||
|
let entities = vec![TextEntity {
|
||||||
|
offset: 0,
|
||||||
|
length: 5,
|
||||||
|
r#type: TextEntityType::Bold,
|
||||||
|
}];
|
||||||
|
let spans = format_text_with_entities(text, &entities, Color::White);
|
||||||
|
|
||||||
|
assert_eq!(spans.len(), 1);
|
||||||
|
assert_eq!(spans[0].content, "Hello");
|
||||||
|
assert!(spans[0].style.add_modifier.contains(Modifier::BOLD));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_adjust_entities_full_overlap() {
|
||||||
|
let entities = vec![TextEntity {
|
||||||
|
offset: 0,
|
||||||
|
length: 10,
|
||||||
|
r#type: TextEntityType::Bold,
|
||||||
|
}];
|
||||||
|
let adjusted = adjust_entities_for_substring(&entities, 0, 10);
|
||||||
|
|
||||||
|
assert_eq!(adjusted.len(), 1);
|
||||||
|
assert_eq!(adjusted[0].offset, 0);
|
||||||
|
assert_eq!(adjusted[0].length, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_adjust_entities_partial_overlap() {
|
||||||
|
let entities = vec![TextEntity {
|
||||||
|
offset: 5,
|
||||||
|
length: 10,
|
||||||
|
r#type: TextEntityType::Bold,
|
||||||
|
}];
|
||||||
|
let adjusted = adjust_entities_for_substring(&entities, 0, 10);
|
||||||
|
|
||||||
|
assert_eq!(adjusted.len(), 1);
|
||||||
|
assert_eq!(adjusted[0].offset, 5);
|
||||||
|
assert_eq!(adjusted[0].length, 5); // Обрезано до конца подстроки
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_adjust_entities_no_overlap() {
|
||||||
|
let entities = vec![TextEntity {
|
||||||
|
offset: 20,
|
||||||
|
length: 10,
|
||||||
|
r#type: TextEntityType::Bold,
|
||||||
|
}];
|
||||||
|
let adjusted = adjust_entities_for_substring(&entities, 0, 10);
|
||||||
|
|
||||||
|
assert_eq!(adjusted.len(), 0); // Нет пересечений
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,7 +30,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
|
|||||||
app.status_message = Some("Загрузка закреплённых...".to_string());
|
app.status_message = Some("Загрузка закреплённых...".to_string());
|
||||||
match timeout(
|
match timeout(
|
||||||
Duration::from_secs(5),
|
Duration::from_secs(5),
|
||||||
app.td_client.get_pinned_messages(chat_id),
|
app.td_client.get_pinned_messages(ChatId::new(chat_id)),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -212,7 +212,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
|
|||||||
if !query.is_empty() {
|
if !query.is_empty() {
|
||||||
if let Ok(Ok(results)) = timeout(
|
if let Ok(Ok(results)) = timeout(
|
||||||
Duration::from_secs(3),
|
Duration::from_secs(3),
|
||||||
app.td_client.search_messages(chat_id, &query),
|
app.td_client.search_messages(ChatId::new(chat_id), &query),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -233,7 +233,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
|
|||||||
if let Some(chat_id) = app.get_selected_chat_id() {
|
if let Some(chat_id) = app.get_selected_chat_id() {
|
||||||
if let Ok(Ok(results)) = timeout(
|
if let Ok(Ok(results)) = timeout(
|
||||||
Duration::from_secs(3),
|
Duration::from_secs(3),
|
||||||
app.td_client.search_messages(chat_id, &query),
|
app.td_client.search_messages(ChatId::new(chat_id), &query),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -388,7 +388,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
|
|||||||
match timeout(
|
match timeout(
|
||||||
Duration::from_secs(5),
|
Duration::from_secs(5),
|
||||||
app.td_client.delete_messages(
|
app.td_client.delete_messages(
|
||||||
chat_id,
|
ChatId::new(chat_id),
|
||||||
vec![msg_id],
|
vec![msg_id],
|
||||||
can_delete_for_all,
|
can_delete_for_all,
|
||||||
),
|
),
|
||||||
@@ -442,7 +442,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
|
|||||||
Duration::from_secs(5),
|
Duration::from_secs(5),
|
||||||
app.td_client.forward_messages(
|
app.td_client.forward_messages(
|
||||||
to_chat_id,
|
to_chat_id,
|
||||||
from_chat_id,
|
ChatId::new(from_chat_id),
|
||||||
vec![msg_id],
|
vec![msg_id],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -490,7 +490,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
|
|||||||
app.message_scroll_offset = 0;
|
app.message_scroll_offset = 0;
|
||||||
match timeout(
|
match timeout(
|
||||||
Duration::from_secs(10),
|
Duration::from_secs(10),
|
||||||
app.td_client.get_chat_history(chat_id, 100),
|
app.td_client.get_chat_history(ChatId::new(chat_id), 100),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -504,7 +504,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
|
|||||||
// Загружаем последнее закреплённое сообщение
|
// Загружаем последнее закреплённое сообщение
|
||||||
let _ = timeout(
|
let _ = timeout(
|
||||||
Duration::from_secs(2),
|
Duration::from_secs(2),
|
||||||
app.td_client.load_current_pinned_message(chat_id),
|
app.td_client.load_current_pinned_message(ChatId::new(chat_id)),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
// Загружаем черновик
|
// Загружаем черновик
|
||||||
@@ -572,7 +572,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
|
|||||||
|
|
||||||
match timeout(
|
match timeout(
|
||||||
Duration::from_secs(5),
|
Duration::from_secs(5),
|
||||||
app.td_client.edit_message(chat_id, msg_id, text),
|
app.td_client.edit_message(ChatId::new(chat_id), msg_id, text),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -622,13 +622,13 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
|
|||||||
|
|
||||||
// Отменяем typing status
|
// Отменяем typing status
|
||||||
app.td_client
|
app.td_client
|
||||||
.send_chat_action(chat_id, ChatAction::Cancel)
|
.send_chat_action(ChatId::new(chat_id), ChatAction::Cancel)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
match timeout(
|
match timeout(
|
||||||
Duration::from_secs(5),
|
Duration::from_secs(5),
|
||||||
app.td_client
|
app.td_client
|
||||||
.send_message(chat_id, text, reply_to_id, reply_info),
|
.send_message(ChatId::new(chat_id), text, reply_to_id, reply_info),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -659,7 +659,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
|
|||||||
app.message_scroll_offset = 0;
|
app.message_scroll_offset = 0;
|
||||||
match timeout(
|
match timeout(
|
||||||
Duration::from_secs(10),
|
Duration::from_secs(10),
|
||||||
app.td_client.get_chat_history(chat_id, 100),
|
app.td_client.get_chat_history(ChatId::new(chat_id), 100),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -673,7 +673,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
|
|||||||
// Загружаем последнее закреплённое сообщение
|
// Загружаем последнее закреплённое сообщение
|
||||||
let _ = timeout(
|
let _ = timeout(
|
||||||
Duration::from_secs(2),
|
Duration::from_secs(2),
|
||||||
app.td_client.load_current_pinned_message(chat_id),
|
app.td_client.load_current_pinned_message(ChatId::new(chat_id)),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
// Загружаем черновик
|
// Загружаем черновик
|
||||||
@@ -795,7 +795,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
|
|||||||
app.status_message = None;
|
app.status_message = None;
|
||||||
app.needs_redraw = true;
|
app.needs_redraw = true;
|
||||||
} else {
|
} else {
|
||||||
app.enter_reaction_picker_mode(message_id, reactions);
|
app.enter_reaction_picker_mode(message_id.as_i64(), reactions);
|
||||||
app.status_message = None;
|
app.status_message = None;
|
||||||
app.needs_redraw = true;
|
app.needs_redraw = true;
|
||||||
}
|
}
|
||||||
@@ -894,7 +894,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
|
|||||||
if should_send_typing {
|
if should_send_typing {
|
||||||
if let Some(chat_id) = app.get_selected_chat_id() {
|
if let Some(chat_id) = app.get_selected_chat_id() {
|
||||||
app.td_client
|
app.td_client
|
||||||
.send_chat_action(chat_id, ChatAction::Typing)
|
.send_chat_action(ChatId::new(chat_id), ChatAction::Typing)
|
||||||
.await;
|
.await;
|
||||||
app.last_typing_sent = Some(Instant::now());
|
app.last_typing_sent = Some(Instant::now());
|
||||||
}
|
}
|
||||||
@@ -943,7 +943,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
|
|||||||
.current_chat_messages()
|
.current_chat_messages()
|
||||||
.first()
|
.first()
|
||||||
.map(|m| m.id())
|
.map(|m| m.id())
|
||||||
.unwrap_or(0);
|
.unwrap_or(MessageId::new(0));
|
||||||
if let Some(chat_id) = app.get_selected_chat_id() {
|
if let Some(chat_id) = app.get_selected_chat_id() {
|
||||||
// Подгружаем больше сообщений если скролл близко к верху
|
// Подгружаем больше сообщений если скролл близко к верху
|
||||||
if app.message_scroll_offset
|
if app.message_scroll_offset
|
||||||
@@ -952,7 +952,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
|
|||||||
if let Ok(Ok(older)) = timeout(
|
if let Ok(Ok(older)) = timeout(
|
||||||
Duration::from_secs(3),
|
Duration::from_secs(3),
|
||||||
app.td_client
|
app.td_client
|
||||||
.load_older_messages(chat_id, oldest_msg_id),
|
.load_older_messages(ChatId::new(chat_id), oldest_msg_id),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -1041,12 +1041,12 @@ fn format_message_for_clipboard(msg: &crate::tdlib::MessageInfo) -> String {
|
|||||||
let mut result = String::new();
|
let mut result = String::new();
|
||||||
|
|
||||||
// Добавляем forward контекст если есть
|
// Добавляем forward контекст если есть
|
||||||
if let Some(forward) = &msg.forward_from {
|
if let Some(forward) = msg.forward_from() {
|
||||||
result.push_str(&format!("↪ Переслано от {}\n", forward.sender_name));
|
result.push_str(&format!("↪ Переслано от {}\n", forward.sender_name));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Добавляем reply контекст если есть
|
// Добавляем reply контекст если есть
|
||||||
if let Some(reply) = &msg.reply_to {
|
if let Some(reply) = msg.reply_to() {
|
||||||
result.push_str(&format!("┌ {}: {}\n", reply.sender_name, reply.text));
|
result.push_str(&format!("┌ {}: {}\n", reply.sender_name, reply.text));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ pub mod app;
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod constants;
|
pub mod constants;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
pub mod formatting;
|
||||||
pub mod input;
|
pub mod input;
|
||||||
pub mod tdlib;
|
pub mod tdlib;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
mod app;
|
mod app;
|
||||||
mod config;
|
mod config;
|
||||||
mod constants;
|
mod constants;
|
||||||
|
mod error;
|
||||||
|
mod formatting;
|
||||||
mod input;
|
mod input;
|
||||||
mod tdlib;
|
mod tdlib;
|
||||||
|
mod types;
|
||||||
mod ui;
|
mod ui;
|
||||||
mod utils;
|
mod utils;
|
||||||
|
|
||||||
|
|||||||
@@ -292,27 +292,27 @@ impl TdClient {
|
|||||||
self.message_manager.current_pinned_message = msg;
|
self.message_manager.current_pinned_message = msg;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn typing_status(&self) -> Option<&(i64, String, std::time::Instant)> {
|
pub fn typing_status(&self) -> Option<&(crate::types::UserId, String, std::time::Instant)> {
|
||||||
self.chat_manager.typing_status.as_ref()
|
self.chat_manager.typing_status.as_ref()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_typing_status(&mut self, status: Option<(i64, String, std::time::Instant)>) {
|
pub fn set_typing_status(&mut self, status: Option<(crate::types::UserId, String, std::time::Instant)>) {
|
||||||
self.chat_manager.typing_status = status;
|
self.chat_manager.typing_status = status;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn pending_view_messages(&self) -> &[(i64, Vec<i64>)] {
|
pub fn pending_view_messages(&self) -> &[(crate::types::ChatId, Vec<crate::types::MessageId>)] {
|
||||||
&self.message_manager.pending_view_messages
|
&self.message_manager.pending_view_messages
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn pending_view_messages_mut(&mut self) -> &mut Vec<(i64, Vec<i64>)> {
|
pub fn pending_view_messages_mut(&mut self) -> &mut Vec<(crate::types::ChatId, Vec<crate::types::MessageId>)> {
|
||||||
&mut self.message_manager.pending_view_messages
|
&mut self.message_manager.pending_view_messages
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn pending_user_ids(&self) -> &[i64] {
|
pub fn pending_user_ids(&self) -> &[crate::types::UserId] {
|
||||||
&self.user_cache.pending_user_ids
|
&self.user_cache.pending_user_ids
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn pending_user_ids_mut(&mut self) -> &mut Vec<i64> {
|
pub fn pending_user_ids_mut(&mut self) -> &mut Vec<crate::types::UserId> {
|
||||||
&mut self.user_cache.pending_user_ids
|
&mut self.user_cache.pending_user_ids
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -470,8 +470,8 @@ impl TdClient {
|
|||||||
let chat_id = ChatId::new(new_msg.message.chat_id);
|
let chat_id = ChatId::new(new_msg.message.chat_id);
|
||||||
if Some(chat_id) == self.current_chat_id() {
|
if Some(chat_id) == self.current_chat_id() {
|
||||||
let msg_info = self.convert_message(&new_msg.message, chat_id);
|
let msg_info = self.convert_message(&new_msg.message, chat_id);
|
||||||
let msg_id = msg_info.id;
|
let msg_id = msg_info.id();
|
||||||
let is_incoming = !msg_info.is_outgoing;
|
let is_incoming = !msg_info.is_outgoing();
|
||||||
|
|
||||||
// Проверяем, есть ли уже сообщение с таким id
|
// Проверяем, есть ли уже сообщение с таким id
|
||||||
let existing_idx = self
|
let existing_idx = self
|
||||||
@@ -488,12 +488,12 @@ impl TdClient {
|
|||||||
// Для исходящих: обновляем can_be_edited и другие поля,
|
// Для исходящих: обновляем can_be_edited и другие поля,
|
||||||
// но сохраняем reply_to (добавленный при отправке)
|
// но сохраняем reply_to (добавленный при отправке)
|
||||||
let existing = &mut self.current_chat_messages_mut()[idx];
|
let existing = &mut self.current_chat_messages_mut()[idx];
|
||||||
existing.can_be_edited = msg_info.can_be_edited;
|
existing.state.can_be_edited = msg_info.state.can_be_edited;
|
||||||
existing.can_be_deleted_only_for_self =
|
existing.state.can_be_deleted_only_for_self =
|
||||||
msg_info.can_be_deleted_only_for_self;
|
msg_info.state.can_be_deleted_only_for_self;
|
||||||
existing.can_be_deleted_for_all_users =
|
existing.state.can_be_deleted_for_all_users =
|
||||||
msg_info.can_be_deleted_for_all_users;
|
msg_info.state.can_be_deleted_for_all_users;
|
||||||
existing.is_read = msg_info.is_read;
|
existing.state.is_read = msg_info.state.is_read;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
@@ -518,7 +518,7 @@ impl TdClient {
|
|||||||
// Clone chat_user_ids to avoid borrow conflict
|
// Clone chat_user_ids to avoid borrow conflict
|
||||||
let chat_user_ids = self.user_cache.chat_user_ids.clone();
|
let chat_user_ids = self.user_cache.chat_user_ids.clone();
|
||||||
self.chats_mut()
|
self.chats_mut()
|
||||||
.retain(|c| chat_user_ids.get(&c.id) != Some(&user_id));
|
.retain(|c| chat_user_ids.get(&c.id) != Some(&UserId::new(user_id)));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -528,15 +528,15 @@ impl TdClient {
|
|||||||
} else {
|
} else {
|
||||||
format!("{} {}", user.first_name, user.last_name)
|
format!("{} {}", user.first_name, user.last_name)
|
||||||
};
|
};
|
||||||
self.user_cache.user_names.insert(user.id, display_name);
|
self.user_cache.user_names.insert(UserId::new(user.id), display_name);
|
||||||
|
|
||||||
// Сохраняем username если есть
|
// Сохраняем username если есть
|
||||||
if let Some(usernames) = user.usernames {
|
if let Some(usernames) = user.usernames {
|
||||||
if let Some(username) = usernames.active_usernames.first() {
|
if let Some(username) = usernames.active_usernames.first() {
|
||||||
self.user_cache.user_usernames.insert(user.id, username.clone());
|
self.user_cache.user_usernames.insert(UserId::new(user.id), username.clone());
|
||||||
// Обновляем username в чатах, связанных с этим пользователем
|
// Обновляем username в чатах, связанных с этим пользователем
|
||||||
for (&chat_id, &user_id) in &self.user_cache.chat_user_ids.clone() {
|
for (&chat_id, &user_id) in &self.user_cache.chat_user_ids.clone() {
|
||||||
if user_id == user.id {
|
if user_id == UserId::new(user.id) {
|
||||||
if let Some(chat) = self.chats_mut().iter_mut().find(|c| c.id == chat_id)
|
if let Some(chat) = self.chats_mut().iter_mut().find(|c| c.id == chat_id)
|
||||||
{
|
{
|
||||||
chat.username = Some(format!("@{}", username));
|
chat.username = Some(format!("@{}", username));
|
||||||
@@ -991,20 +991,20 @@ impl TdClient {
|
|||||||
match origin {
|
match origin {
|
||||||
MessageOrigin::User(u) => self
|
MessageOrigin::User(u) => self
|
||||||
.user_cache.user_names
|
.user_cache.user_names
|
||||||
.peek(&u.sender_user_id)
|
.peek(&UserId::new(u.sender_user_id))
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_else(|| format!("User_{}", u.sender_user_id)),
|
.unwrap_or_else(|| format!("User_{}", u.sender_user_id)),
|
||||||
MessageOrigin::Chat(c) => self
|
MessageOrigin::Chat(c) => self
|
||||||
.chats()
|
.chats()
|
||||||
.iter()
|
.iter()
|
||||||
.find(|chat| chat.id == c.sender_chat_id)
|
.find(|chat| chat.id == ChatId::new(c.sender_chat_id))
|
||||||
.map(|chat| chat.title.clone())
|
.map(|chat| chat.title.clone())
|
||||||
.unwrap_or_else(|| "Чат".to_string()),
|
.unwrap_or_else(|| "Чат".to_string()),
|
||||||
MessageOrigin::HiddenUser(h) => h.sender_name.clone(),
|
MessageOrigin::HiddenUser(h) => h.sender_name.clone(),
|
||||||
MessageOrigin::Channel(c) => self
|
MessageOrigin::Channel(c) => self
|
||||||
.chats()
|
.chats()
|
||||||
.iter()
|
.iter()
|
||||||
.find(|chat| chat.id == c.chat_id)
|
.find(|chat| chat.id == ChatId::new(c.chat_id))
|
||||||
.map(|chat| chat.title.clone())
|
.map(|chat| chat.title.clone())
|
||||||
.unwrap_or_else(|| "Канал".to_string()),
|
.unwrap_or_else(|| "Канал".to_string()),
|
||||||
}
|
}
|
||||||
@@ -1017,15 +1017,15 @@ impl TdClient {
|
|||||||
let msg_data: std::collections::HashMap<i64, (String, String)> = self
|
let msg_data: std::collections::HashMap<i64, (String, String)> = self
|
||||||
.current_chat_messages()
|
.current_chat_messages()
|
||||||
.iter()
|
.iter()
|
||||||
.map(|m| (m.id(), (m.sender_name().to_string(), m.text().to_string())))
|
.map(|m| (m.id().as_i64(), (m.sender_name().to_string(), m.text().to_string())))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Обновляем reply_to для сообщений с неполными данными
|
// Обновляем reply_to для сообщений с неполными данными
|
||||||
for msg in self.current_chat_messages_mut().iter_mut() {
|
for msg in self.current_chat_messages_mut().iter_mut() {
|
||||||
if let Some(ref mut reply) = msg.reply_to {
|
if let Some(ref mut reply) = msg.interactions.reply_to {
|
||||||
// Если sender_name = "..." или text пустой — пробуем заполнить
|
// Если sender_name = "..." или text пустой — пробуем заполнить
|
||||||
if reply.sender_name == "..." || reply.text.is_empty() {
|
if reply.sender_name == "..." || reply.text.is_empty() {
|
||||||
if let Some((sender, content)) = msg_data.get(&reply.message_id) {
|
if let Some((sender, content)) = msg_data.get(&reply.message_id.as_i64()) {
|
||||||
if reply.sender_name == "..." {
|
if reply.sender_name == "..." {
|
||||||
reply.sender_name = sender.clone();
|
reply.sender_name = sender.clone();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use tdlib_rs::enums::{ChatAction, InputMessageContent, InputMessageReplyTo, Mess
|
|||||||
use tdlib_rs::functions;
|
use tdlib_rs::functions;
|
||||||
use tdlib_rs::types::{Chat as TdChat, FormattedText, InputMessageReplyToMessage, InputMessageText, Message as TdMessage, TextEntity, TextParseModeMarkdown};
|
use tdlib_rs::types::{Chat as TdChat, FormattedText, InputMessageReplyToMessage, InputMessageText, Message as TdMessage, TextEntity, TextParseModeMarkdown};
|
||||||
|
|
||||||
use super::types::{ForwardInfo, MessageInfo, ReactionInfo, ReplyInfo};
|
use super::types::{ForwardInfo, MessageBuilder, MessageInfo, ReactionInfo, ReplyInfo};
|
||||||
|
|
||||||
/// Менеджер сообщений
|
/// Менеджер сообщений
|
||||||
pub struct MessageManager {
|
pub struct MessageManager {
|
||||||
@@ -375,7 +375,8 @@ impl MessageManager {
|
|||||||
let batch = std::mem::take(&mut self.pending_view_messages);
|
let batch = std::mem::take(&mut self.pending_view_messages);
|
||||||
|
|
||||||
for (chat_id, message_ids) in batch {
|
for (chat_id, message_ids) in batch {
|
||||||
let _ = functions::view_messages(chat_id, message_ids, None, true, self.client_id).await;
|
let ids: Vec<i64> = message_ids.iter().map(|id| id.as_i64()).collect();
|
||||||
|
let _ = functions::view_messages(chat_id.as_i64(), ids, None, true, self.client_id).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -454,7 +455,7 @@ impl MessageManager {
|
|||||||
if let tdlib_rs::enums::MessageReplyTo::Message(reply_msg) = reply_to {
|
if let tdlib_rs::enums::MessageReplyTo::Message(reply_msg) = reply_to {
|
||||||
// Здесь можно загрузить информацию об оригинальном сообщении
|
// Здесь можно загрузить информацию об оригинальном сообщении
|
||||||
Some(ReplyInfo {
|
Some(ReplyInfo {
|
||||||
message_id: reply_msg.message_id,
|
message_id: MessageId::new(reply_msg.message_id),
|
||||||
sender_name: "Unknown".to_string(),
|
sender_name: "Unknown".to_string(),
|
||||||
text: "...".to_string(),
|
text: "...".to_string(),
|
||||||
})
|
})
|
||||||
@@ -488,22 +489,48 @@ impl MessageManager {
|
|||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
Some(MessageInfo {
|
let mut builder = MessageBuilder::new(MessageId::new(msg.id))
|
||||||
id: msg.id,
|
.sender_name(sender_name)
|
||||||
sender_name,
|
.text(content_text)
|
||||||
is_outgoing: msg.is_outgoing,
|
.entities(entities)
|
||||||
content: content_text,
|
.date(msg.date)
|
||||||
entities,
|
.edit_date(msg.edit_date);
|
||||||
date: msg.date,
|
|
||||||
edit_date: msg.edit_date,
|
if msg.is_outgoing {
|
||||||
is_read: !msg.contains_unread_mention,
|
builder = builder.outgoing();
|
||||||
can_be_edited: msg.can_be_edited,
|
} else {
|
||||||
can_be_deleted_only_for_self: msg.can_be_deleted_only_for_self,
|
builder = builder.incoming();
|
||||||
can_be_deleted_for_all_users: msg.can_be_deleted_for_all_users,
|
}
|
||||||
reply_to,
|
|
||||||
forward_from,
|
if !msg.contains_unread_mention {
|
||||||
reactions,
|
builder = builder.read();
|
||||||
})
|
} else {
|
||||||
|
builder = builder.unread();
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg.can_be_edited {
|
||||||
|
builder = builder.editable();
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg.can_be_deleted_only_for_self {
|
||||||
|
builder = builder.deletable_for_self();
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg.can_be_deleted_for_all_users {
|
||||||
|
builder = builder.deletable_for_all();
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(reply) = reply_to {
|
||||||
|
builder = builder.reply_to(reply);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(forward) = forward_from {
|
||||||
|
builder = builder.forward_from(forward);
|
||||||
|
}
|
||||||
|
|
||||||
|
builder = builder.reactions(reactions);
|
||||||
|
|
||||||
|
Some(builder.build())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Получить недостающую reply информацию для сообщений
|
/// Получить недостающую reply информацию для сообщений
|
||||||
@@ -511,7 +538,7 @@ impl MessageManager {
|
|||||||
// Collect message IDs that need to be fetched
|
// Collect message IDs that need to be fetched
|
||||||
let mut to_fetch = Vec::new();
|
let mut to_fetch = Vec::new();
|
||||||
for msg in &self.current_chat_messages {
|
for msg in &self.current_chat_messages {
|
||||||
if let Some(ref reply) = msg.reply_to {
|
if let Some(ref reply) = msg.interactions.reply_to {
|
||||||
if reply.sender_name == "Unknown" {
|
if reply.sender_name == "Unknown" {
|
||||||
to_fetch.push(reply.message_id);
|
to_fetch.push(reply.message_id);
|
||||||
}
|
}
|
||||||
@@ -522,17 +549,18 @@ impl MessageManager {
|
|||||||
if let Some(chat_id) = self.current_chat_id {
|
if let Some(chat_id) = self.current_chat_id {
|
||||||
for message_id in to_fetch {
|
for message_id in to_fetch {
|
||||||
if let Ok(original_msg_enum) =
|
if let Ok(original_msg_enum) =
|
||||||
functions::get_message(chat_id, message_id, self.client_id).await
|
functions::get_message(chat_id.as_i64(), message_id.as_i64(), self.client_id).await
|
||||||
{
|
{
|
||||||
if let tdlib_rs::enums::Message::Message(original_msg) = original_msg_enum {
|
if let tdlib_rs::enums::Message::Message(original_msg) = original_msg_enum {
|
||||||
if let Some(orig_info) = self.convert_message(&original_msg).await {
|
if let Some(orig_info) = self.convert_message(&original_msg).await {
|
||||||
// Update the reply info
|
// Update the reply info
|
||||||
for msg in &mut self.current_chat_messages {
|
for msg in &mut self.current_chat_messages {
|
||||||
if let Some(ref mut reply) = msg.reply_to {
|
if let Some(ref mut reply) = msg.interactions.reply_to {
|
||||||
if reply.message_id == message_id {
|
if reply.message_id == message_id {
|
||||||
reply.sender_name = orig_info.sender_name.clone();
|
reply.sender_name = orig_info.metadata.sender_name.clone();
|
||||||
reply.text = orig_info
|
reply.text = orig_info
|
||||||
.content
|
.content
|
||||||
|
.text
|
||||||
.chars()
|
.chars()
|
||||||
.take(50)
|
.take(50)
|
||||||
.collect::<String>();
|
.collect::<String>();
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ pub struct MessageMetadata {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Контент сообщения (текст и форматирование)
|
/// Контент сообщения (текст и форматирование)
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub struct MessageContent {
|
pub struct MessageContent {
|
||||||
pub text: String,
|
pub text: String,
|
||||||
/// Сущности форматирования (bold, italic, code и т.д.)
|
/// Сущности форматирования (bold, italic, code и т.д.)
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ impl<V: Clone> LruCache<V> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Проверить наличие ключа
|
/// Проверить наличие ключа
|
||||||
pub fn contains_key(&self, key: &i64) -> bool {
|
pub fn contains_key(&self, key: &UserId) -> bool {
|
||||||
self.map.contains_key(key)
|
self.map.contains_key(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,7 +181,7 @@ impl UserCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Берём первые N user_ids для загрузки
|
// Берём первые N user_ids для загрузки
|
||||||
let batch: Vec<i64> = self
|
let batch: Vec<UserId> = self
|
||||||
.pending_user_ids
|
.pending_user_ids
|
||||||
.drain(..self.pending_user_ids.len().min(LAZY_LOAD_USERS_PER_TICK))
|
.drain(..self.pending_user_ids.len().min(LAZY_LOAD_USERS_PER_TICK))
|
||||||
.collect();
|
.collect();
|
||||||
@@ -191,7 +191,7 @@ impl UserCache {
|
|||||||
continue; // Уже в кэше
|
continue; // Уже в кэше
|
||||||
}
|
}
|
||||||
|
|
||||||
match functions::get_user(user_id, self.client_id).await {
|
match functions::get_user(user_id.as_i64(), self.client_id).await {
|
||||||
Ok(user_enum) => {
|
Ok(user_enum) => {
|
||||||
self.handle_user_update(&user_enum);
|
self.handle_user_update(&user_enum);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ impl fmt::Display for ChatId {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Message identifier
|
/// Message identifier
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
pub struct MessageId(pub i64);
|
pub struct MessageId(pub i64);
|
||||||
|
|
||||||
impl MessageId {
|
impl MessageId {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use crate::app::App;
|
use crate::app::App;
|
||||||
use crate::tdlib::UserOnlineStatus;
|
use crate::tdlib::UserOnlineStatus;
|
||||||
|
use crate::ui::components;
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
layout::{Constraint, Direction, Layout, Rect},
|
layout::{Constraint, Direction, Layout, Rect},
|
||||||
style::{Color, Modifier, Style},
|
style::{Color, Modifier, Style},
|
||||||
@@ -43,63 +44,8 @@ pub fn render(f: &mut Frame, area: Rect, app: &mut App) {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|chat| {
|
.map(|chat| {
|
||||||
let is_selected = app.selected_chat_id == Some(chat.id);
|
let is_selected = app.selected_chat_id == Some(chat.id);
|
||||||
let pin_icon = if chat.is_pinned { "📌 " } else { "" };
|
let user_status = app.td_client.get_user_status_by_chat_id(chat.id);
|
||||||
let mute_icon = if chat.is_muted { "🔇 " } else { "" };
|
components::render_chat_list_item(chat, is_selected, user_status)
|
||||||
|
|
||||||
// Онлайн-статус (зелёная точка для онлайн)
|
|
||||||
let status_icon = match app.td_client.get_user_status_by_chat_id(chat.id) {
|
|
||||||
Some(UserOnlineStatus::Online) => "● ",
|
|
||||||
_ => " ",
|
|
||||||
};
|
|
||||||
|
|
||||||
let prefix = if is_selected { "▌" } else { " " };
|
|
||||||
|
|
||||||
let username_text = chat
|
|
||||||
.username
|
|
||||||
.as_ref()
|
|
||||||
.map(|u| format!(" {}", u))
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
// Индикатор упоминаний @
|
|
||||||
let mention_badge = if chat.unread_mention_count > 0 {
|
|
||||||
" @".to_string()
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
};
|
|
||||||
|
|
||||||
// Индикатор черновика ✎
|
|
||||||
let draft_badge = if chat.draft_text.is_some() {
|
|
||||||
" ✎".to_string()
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
};
|
|
||||||
|
|
||||||
let unread_badge = if chat.unread_count > 0 {
|
|
||||||
format!(" ({})", chat.unread_count)
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
};
|
|
||||||
|
|
||||||
let content = format!(
|
|
||||||
"{}{}{}{}{}{}{}{}{}",
|
|
||||||
prefix,
|
|
||||||
status_icon,
|
|
||||||
pin_icon,
|
|
||||||
mute_icon,
|
|
||||||
chat.title,
|
|
||||||
username_text,
|
|
||||||
mention_badge,
|
|
||||||
draft_badge,
|
|
||||||
unread_badge
|
|
||||||
);
|
|
||||||
|
|
||||||
// Цвет: онлайн — зелёные, остальные — белые
|
|
||||||
let style = match app.td_client.get_user_status_by_chat_id(chat.id) {
|
|
||||||
Some(UserOnlineStatus::Online) => Style::default().fg(Color::Green),
|
|
||||||
_ => Style::default().fg(Color::White),
|
|
||||||
};
|
|
||||||
|
|
||||||
ListItem::new(content).style(style)
|
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
|||||||
78
src/ui/components/chat_list_item.rs
Normal file
78
src/ui/components/chat_list_item.rs
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
use crate::tdlib::{ChatInfo, UserOnlineStatus};
|
||||||
|
use ratatui::{
|
||||||
|
style::{Color, Style},
|
||||||
|
widgets::ListItem,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Рендерит элемент списка чатов
|
||||||
|
///
|
||||||
|
/// # Параметры
|
||||||
|
/// - `chat`: Информация о чате
|
||||||
|
/// - `is_selected`: Выбран ли этот чат
|
||||||
|
/// - `user_status`: Онлайн-статус пользователя (если доступен)
|
||||||
|
///
|
||||||
|
/// # Возвращает
|
||||||
|
/// ListItem с форматированным отображением чата
|
||||||
|
pub fn render_chat_list_item(
|
||||||
|
chat: &ChatInfo,
|
||||||
|
is_selected: bool,
|
||||||
|
user_status: Option<&UserOnlineStatus>,
|
||||||
|
) -> ListItem<'static> {
|
||||||
|
let pin_icon = if chat.is_pinned { "📌 " } else { "" };
|
||||||
|
let mute_icon = if chat.is_muted { "🔇 " } else { "" };
|
||||||
|
|
||||||
|
// Онлайн-статус (зелёная точка для онлайн)
|
||||||
|
let status_icon = match user_status {
|
||||||
|
Some(UserOnlineStatus::Online) => "● ",
|
||||||
|
_ => " ",
|
||||||
|
};
|
||||||
|
|
||||||
|
let prefix = if is_selected { "▌" } else { " " };
|
||||||
|
|
||||||
|
let username_text = chat
|
||||||
|
.username
|
||||||
|
.as_ref()
|
||||||
|
.map(|u| format!(" {}", u))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// Индикатор упоминаний @
|
||||||
|
let mention_badge = if chat.unread_mention_count > 0 {
|
||||||
|
" @".to_string()
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Индикатор черновика ✎
|
||||||
|
let draft_badge = if chat.draft_text.is_some() {
|
||||||
|
" ✎".to_string()
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
let unread_badge = if chat.unread_count > 0 {
|
||||||
|
format!(" ({})", chat.unread_count)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
let content = format!(
|
||||||
|
"{}{}{}{}{}{}{}{}{}",
|
||||||
|
prefix,
|
||||||
|
status_icon,
|
||||||
|
pin_icon,
|
||||||
|
mute_icon,
|
||||||
|
chat.title,
|
||||||
|
username_text,
|
||||||
|
mention_badge,
|
||||||
|
draft_badge,
|
||||||
|
unread_badge
|
||||||
|
);
|
||||||
|
|
||||||
|
// Цвет: онлайн — зелёные, остальные — белые
|
||||||
|
let style = match user_status {
|
||||||
|
Some(UserOnlineStatus::Online) => Style::default().fg(Color::Green),
|
||||||
|
_ => Style::default().fg(Color::White),
|
||||||
|
};
|
||||||
|
|
||||||
|
ListItem::new(content).style(style)
|
||||||
|
}
|
||||||
112
src/ui/components/emoji_picker.rs
Normal file
112
src/ui/components/emoji_picker.rs
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
use ratatui::{
|
||||||
|
layout::{Alignment, Rect},
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Block, Borders, Clear, Paragraph},
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Рендерит модалку выбора реакций (emoji picker)
|
||||||
|
///
|
||||||
|
/// # Параметры
|
||||||
|
/// - `f`: Frame для рендеринга
|
||||||
|
/// - `area`: Область экрана
|
||||||
|
/// - `available_reactions`: Список доступных эмодзи
|
||||||
|
/// - `selected_index`: Индекс выбранного эмодзи
|
||||||
|
pub fn render_emoji_picker(
|
||||||
|
f: &mut Frame,
|
||||||
|
area: Rect,
|
||||||
|
available_reactions: &[String],
|
||||||
|
selected_index: usize,
|
||||||
|
) {
|
||||||
|
// Размеры модалки (зависят от количества реакций)
|
||||||
|
let emojis_per_row = 8;
|
||||||
|
let rows = (available_reactions.len() + emojis_per_row - 1) / emojis_per_row;
|
||||||
|
let modal_width = 50u16;
|
||||||
|
let modal_height = (rows + 4) as u16; // +4 для заголовка, отступов и подсказки
|
||||||
|
|
||||||
|
// Центрируем модалку
|
||||||
|
let x = area.x + (area.width.saturating_sub(modal_width)) / 2;
|
||||||
|
let y = area.y + (area.height.saturating_sub(modal_height)) / 2;
|
||||||
|
|
||||||
|
let modal_area = Rect::new(
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
modal_width.min(area.width),
|
||||||
|
modal_height.min(area.height),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Очищаем область под модалкой
|
||||||
|
f.render_widget(Clear, modal_area);
|
||||||
|
|
||||||
|
// Формируем содержимое - сетка эмодзи
|
||||||
|
let mut text_lines = vec![Line::from("")]; // Пустая строка сверху
|
||||||
|
|
||||||
|
for row in 0..rows {
|
||||||
|
let mut row_spans = vec![Span::raw(" ")]; // Отступ слева
|
||||||
|
|
||||||
|
for col in 0..emojis_per_row {
|
||||||
|
let idx = row * emojis_per_row + col;
|
||||||
|
if idx >= available_reactions.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let emoji = &available_reactions[idx];
|
||||||
|
let is_selected = idx == selected_index;
|
||||||
|
|
||||||
|
let style = if is_selected {
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Yellow)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
.add_modifier(Modifier::REVERSED)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::White)
|
||||||
|
};
|
||||||
|
|
||||||
|
row_spans.push(Span::styled(format!(" {} ", emoji), style));
|
||||||
|
row_spans.push(Span::raw(" ")); // Пробел между эмодзи
|
||||||
|
}
|
||||||
|
|
||||||
|
text_lines.push(Line::from(row_spans));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем пустую строку и подсказку
|
||||||
|
text_lines.push(Line::from(""));
|
||||||
|
text_lines.push(Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
" [←/→/↑/↓] ",
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::raw("Выбор "),
|
||||||
|
Span::styled(
|
||||||
|
" [Enter] ",
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Green)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::raw("Добавить "),
|
||||||
|
Span::styled(
|
||||||
|
" [Esc] ",
|
||||||
|
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::raw("Отмена"),
|
||||||
|
]));
|
||||||
|
|
||||||
|
let modal = Paragraph::new(text_lines)
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(Color::Yellow))
|
||||||
|
.title(" Выбери реакцию ")
|
||||||
|
.title_style(
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Yellow)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.alignment(Alignment::Center);
|
||||||
|
|
||||||
|
f.render_widget(modal, modal_area);
|
||||||
|
}
|
||||||
53
src/ui/components/input_field.rs
Normal file
53
src/ui/components/input_field.rs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
use ratatui::{
|
||||||
|
style::{Color, Style},
|
||||||
|
text::{Line, Span},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Рендерит текст с курсором в виде Line
|
||||||
|
///
|
||||||
|
/// # Параметры
|
||||||
|
/// - `prefix`: Префикс перед текстом (например, "Сообщение: ")
|
||||||
|
/// - `text`: Текст в поле ввода
|
||||||
|
/// - `cursor_pos`: Позиция курсора (индекс символа)
|
||||||
|
/// - `color`: Цвет текста и курсора
|
||||||
|
///
|
||||||
|
/// # Возвращает
|
||||||
|
/// Line с текстом и блочным курсором на указанной позиции
|
||||||
|
pub fn render_input_field(
|
||||||
|
prefix: &str,
|
||||||
|
text: &str,
|
||||||
|
cursor_pos: usize,
|
||||||
|
color: Color,
|
||||||
|
) -> Line<'static> {
|
||||||
|
let chars: Vec<char> = text.chars().collect();
|
||||||
|
let mut spans: Vec<Span> = vec![Span::raw(prefix.to_string())];
|
||||||
|
|
||||||
|
// Ограничиваем cursor_pos границами текста
|
||||||
|
let safe_cursor_pos = cursor_pos.min(chars.len());
|
||||||
|
|
||||||
|
// Текст до курсора
|
||||||
|
if safe_cursor_pos > 0 {
|
||||||
|
let before: String = chars[..safe_cursor_pos].iter().collect();
|
||||||
|
spans.push(Span::styled(before, Style::default().fg(color)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Символ под курсором (или █ если курсор в конце)
|
||||||
|
if safe_cursor_pos < chars.len() {
|
||||||
|
let cursor_char = chars[safe_cursor_pos].to_string();
|
||||||
|
spans.push(Span::styled(
|
||||||
|
cursor_char,
|
||||||
|
Style::default().fg(Color::Black).bg(color),
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
// Курсор в конце - показываем блок
|
||||||
|
spans.push(Span::styled("█", Style::default().fg(color)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Текст после курсора
|
||||||
|
if safe_cursor_pos + 1 < chars.len() {
|
||||||
|
let after: String = chars[safe_cursor_pos + 1..].iter().collect();
|
||||||
|
spans.push(Span::styled(after, Style::default().fg(color)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Line::from(spans)
|
||||||
|
}
|
||||||
26
src/ui/components/message_bubble.rs
Normal file
26
src/ui/components/message_bubble.rs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
// Message bubble component
|
||||||
|
//
|
||||||
|
// TODO: Этот компонент требует дальнейшего рефакторинга.
|
||||||
|
// Логика рендеринга сообщений в messages.rs очень сложная и интегрированная,
|
||||||
|
// включая:
|
||||||
|
// - Группировку сообщений по дате и отправителю
|
||||||
|
// - Форматирование markdown (entities)
|
||||||
|
// - Перенос длинных текстов
|
||||||
|
// - Отображение reply, forward, reactions
|
||||||
|
// - Выравнивание (входящие/исходящие)
|
||||||
|
//
|
||||||
|
// Для полного выделения компонента нужно сначала:
|
||||||
|
// 1. Вынести форматирование в src/formatting.rs (P3.8)
|
||||||
|
// 2. Вынести группировку в src/message_grouping.rs (P3.9)
|
||||||
|
//
|
||||||
|
// Пока этот файл служит placeholder'ом для будущего рефакторинга.
|
||||||
|
|
||||||
|
use crate::tdlib::MessageInfo;
|
||||||
|
|
||||||
|
/// Placeholder для функции рендеринга пузыря сообщения
|
||||||
|
///
|
||||||
|
/// TODO: Реализовать после выполнения P3.8 и P3.9
|
||||||
|
pub fn render_message_bubble(_message: &MessageInfo) {
|
||||||
|
// Будет реализовано позже
|
||||||
|
unimplemented!("Message bubble rendering requires P3.8 and P3.9 first")
|
||||||
|
}
|
||||||
14
src/ui/components/mod.rs
Normal file
14
src/ui/components/mod.rs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// UI компоненты для переиспользования
|
||||||
|
|
||||||
|
pub mod modal;
|
||||||
|
pub mod input_field;
|
||||||
|
pub mod message_bubble;
|
||||||
|
pub mod chat_list_item;
|
||||||
|
pub mod emoji_picker;
|
||||||
|
|
||||||
|
// Экспорт основных функций
|
||||||
|
pub use modal::render_modal;
|
||||||
|
pub use input_field::render_input_field;
|
||||||
|
pub use message_bubble::render_message_bubble;
|
||||||
|
pub use chat_list_item::render_chat_list_item;
|
||||||
|
pub use emoji_picker::render_emoji_picker;
|
||||||
86
src/ui/components/modal.rs
Normal file
86
src/ui/components/modal.rs
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
use ratatui::{
|
||||||
|
layout::{Alignment, Rect},
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
text::Line,
|
||||||
|
widgets::{Block, Borders, Clear, Paragraph},
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Рендерит центрированную модалку с заданным содержимым
|
||||||
|
///
|
||||||
|
/// # Параметры
|
||||||
|
/// - `f`: Frame для рендеринга
|
||||||
|
/// - `area`: Область экрана
|
||||||
|
/// - `title`: Заголовок модалки
|
||||||
|
/// - `content`: Содержимое модалки (строки текста)
|
||||||
|
/// - `width`: Ширина модалки
|
||||||
|
/// - `height`: Высота модалки
|
||||||
|
/// - `border_color`: Цвет рамки
|
||||||
|
pub fn render_modal(
|
||||||
|
f: &mut Frame,
|
||||||
|
area: Rect,
|
||||||
|
title: &str,
|
||||||
|
content: Vec<Line>,
|
||||||
|
width: u16,
|
||||||
|
height: u16,
|
||||||
|
border_color: Color,
|
||||||
|
) {
|
||||||
|
// Центрируем модалку
|
||||||
|
let x = area.x + (area.width.saturating_sub(width)) / 2;
|
||||||
|
let y = area.y + (area.height.saturating_sub(height)) / 2;
|
||||||
|
|
||||||
|
let modal_area = Rect::new(x, y, width.min(area.width), height.min(area.height));
|
||||||
|
|
||||||
|
// Очищаем область под модалкой
|
||||||
|
f.render_widget(Clear, modal_area);
|
||||||
|
|
||||||
|
// Рендерим модалку
|
||||||
|
let modal = Paragraph::new(content)
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.border_style(Style::default().fg(border_color))
|
||||||
|
.title(format!(" {} ", title))
|
||||||
|
.title_style(
|
||||||
|
Style::default()
|
||||||
|
.fg(border_color)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.alignment(Alignment::Center);
|
||||||
|
|
||||||
|
f.render_widget(modal, modal_area);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Рендерит модалку подтверждения удаления
|
||||||
|
pub fn render_delete_confirm_modal(f: &mut Frame, area: Rect) {
|
||||||
|
use ratatui::text::Span;
|
||||||
|
|
||||||
|
let content = vec![
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(Span::styled(
|
||||||
|
"Удалить сообщение?",
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::White)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
)),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
" [y/Enter] ",
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Green)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::raw("Да"),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(
|
||||||
|
" [n/Esc] ",
|
||||||
|
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
Span::raw("Нет"),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
|
render_modal(f, area, "Подтверждение", content, 40, 7, Color::Red);
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
use crate::app::App;
|
use crate::app::App;
|
||||||
|
use crate::formatting;
|
||||||
|
use crate::ui::components;
|
||||||
use crate::utils::{format_date, format_timestamp_with_tz, get_day};
|
use crate::utils::{format_date, format_timestamp_with_tz, get_day};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||||
@@ -7,188 +9,15 @@ use ratatui::{
|
|||||||
widgets::{Block, Borders, Paragraph},
|
widgets::{Block, Borders, Paragraph},
|
||||||
Frame,
|
Frame,
|
||||||
};
|
};
|
||||||
use tdlib_rs::enums::TextEntityType;
|
|
||||||
use tdlib_rs::types::TextEntity;
|
|
||||||
|
|
||||||
/// Структура для хранения стиля символа
|
|
||||||
#[derive(Clone, Default)]
|
|
||||||
struct CharStyle {
|
|
||||||
bold: bool,
|
|
||||||
italic: bool,
|
|
||||||
underline: bool,
|
|
||||||
strikethrough: bool,
|
|
||||||
code: bool,
|
|
||||||
spoiler: bool,
|
|
||||||
url: bool,
|
|
||||||
mention: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CharStyle {
|
|
||||||
fn to_style(&self, base_color: Color) -> Style {
|
|
||||||
let mut style = Style::default();
|
|
||||||
|
|
||||||
if self.code {
|
|
||||||
// Код отображается cyan на тёмном фоне
|
|
||||||
style = style.fg(Color::Cyan).bg(Color::DarkGray);
|
|
||||||
} else if self.spoiler {
|
|
||||||
// Спойлер — серый текст (скрытый)
|
|
||||||
style = style.fg(Color::DarkGray).bg(Color::DarkGray);
|
|
||||||
} else if self.url || self.mention {
|
|
||||||
// Ссылки и упоминания — синий с подчёркиванием
|
|
||||||
style = style.fg(Color::Blue).add_modifier(Modifier::UNDERLINED);
|
|
||||||
} else {
|
|
||||||
style = style.fg(base_color);
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.bold {
|
|
||||||
style = style.add_modifier(Modifier::BOLD);
|
|
||||||
}
|
|
||||||
if self.italic {
|
|
||||||
style = style.add_modifier(Modifier::ITALIC);
|
|
||||||
}
|
|
||||||
if self.underline {
|
|
||||||
style = style.add_modifier(Modifier::UNDERLINED);
|
|
||||||
}
|
|
||||||
if self.strikethrough {
|
|
||||||
style = style.add_modifier(Modifier::CROSSED_OUT);
|
|
||||||
}
|
|
||||||
|
|
||||||
style
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Преобразует текст с entities в вектор стилизованных Span (owned)
|
|
||||||
fn format_text_with_entities(
|
|
||||||
text: &str,
|
|
||||||
entities: &[TextEntity],
|
|
||||||
base_color: Color,
|
|
||||||
) -> Vec<Span<'static>> {
|
|
||||||
if entities.is_empty() {
|
|
||||||
return vec![Span::styled(
|
|
||||||
text.to_string(),
|
|
||||||
Style::default().fg(base_color),
|
|
||||||
)];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Создаём массив стилей для каждого символа
|
|
||||||
let chars: Vec<char> = text.chars().collect();
|
|
||||||
let mut char_styles: Vec<CharStyle> = vec![CharStyle::default(); chars.len()];
|
|
||||||
|
|
||||||
// Применяем entities к символам
|
|
||||||
for entity in entities {
|
|
||||||
let start = entity.offset as usize;
|
|
||||||
let end = (entity.offset + entity.length) as usize;
|
|
||||||
|
|
||||||
for i in start..end.min(chars.len()) {
|
|
||||||
match &entity.r#type {
|
|
||||||
TextEntityType::Bold => char_styles[i].bold = true,
|
|
||||||
TextEntityType::Italic => char_styles[i].italic = true,
|
|
||||||
TextEntityType::Underline => char_styles[i].underline = true,
|
|
||||||
TextEntityType::Strikethrough => char_styles[i].strikethrough = true,
|
|
||||||
TextEntityType::Code | TextEntityType::Pre | TextEntityType::PreCode(_) => {
|
|
||||||
char_styles[i].code = true
|
|
||||||
}
|
|
||||||
TextEntityType::Spoiler => char_styles[i].spoiler = true,
|
|
||||||
TextEntityType::Url
|
|
||||||
| TextEntityType::TextUrl(_)
|
|
||||||
| TextEntityType::EmailAddress
|
|
||||||
| TextEntityType::PhoneNumber => char_styles[i].url = true,
|
|
||||||
TextEntityType::Mention | TextEntityType::MentionName(_) => {
|
|
||||||
char_styles[i].mention = true
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Группируем последовательные символы с одинаковым стилем
|
|
||||||
let mut spans: Vec<Span<'static>> = Vec::new();
|
|
||||||
let mut current_text = String::new();
|
|
||||||
let mut current_style: Option<CharStyle> = None;
|
|
||||||
|
|
||||||
for (i, ch) in chars.iter().enumerate() {
|
|
||||||
let style = &char_styles[i];
|
|
||||||
|
|
||||||
match ¤t_style {
|
|
||||||
Some(prev_style) if styles_equal(prev_style, style) => {
|
|
||||||
current_text.push(*ch);
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
if !current_text.is_empty() {
|
|
||||||
if let Some(prev_style) = ¤t_style {
|
|
||||||
spans.push(Span::styled(
|
|
||||||
current_text.clone(),
|
|
||||||
prev_style.to_style(base_color),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
current_text = ch.to_string();
|
|
||||||
current_style = Some(style.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Добавляем последний span
|
|
||||||
if !current_text.is_empty() {
|
|
||||||
if let Some(style) = current_style {
|
|
||||||
spans.push(Span::styled(current_text, style.to_style(base_color)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if spans.is_empty() {
|
|
||||||
spans.push(Span::styled(text.to_string(), Style::default().fg(base_color)));
|
|
||||||
}
|
|
||||||
|
|
||||||
spans
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Проверяет равенство двух стилей
|
|
||||||
fn styles_equal(a: &CharStyle, b: &CharStyle) -> bool {
|
|
||||||
a.bold == b.bold
|
|
||||||
&& a.italic == b.italic
|
|
||||||
&& a.underline == b.underline
|
|
||||||
&& a.strikethrough == b.strikethrough
|
|
||||||
&& a.code == b.code
|
|
||||||
&& a.spoiler == b.spoiler
|
|
||||||
&& a.url == b.url
|
|
||||||
&& a.mention == b.mention
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Рендерит текст инпута с блочным курсором
|
|
||||||
fn render_input_with_cursor(
|
fn render_input_with_cursor(
|
||||||
prefix: &str,
|
prefix: &str,
|
||||||
text: &str,
|
text: &str,
|
||||||
cursor_pos: usize,
|
cursor_pos: usize,
|
||||||
color: Color,
|
color: Color,
|
||||||
) -> Line<'static> {
|
) -> Line<'static> {
|
||||||
let chars: Vec<char> = text.chars().collect();
|
// Используем компонент input_field
|
||||||
let mut spans: Vec<Span> = vec![Span::raw(prefix.to_string())];
|
components::render_input_field(prefix, text, cursor_pos, color)
|
||||||
|
|
||||||
// Ограничиваем cursor_pos границами текста
|
|
||||||
let safe_cursor_pos = cursor_pos.min(chars.len());
|
|
||||||
|
|
||||||
// Текст до курсора
|
|
||||||
if safe_cursor_pos > 0 {
|
|
||||||
let before: String = chars[..safe_cursor_pos].iter().collect();
|
|
||||||
spans.push(Span::styled(before, Style::default().fg(color)));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Символ под курсором (или █ если курсор в конце)
|
|
||||||
if safe_cursor_pos < chars.len() {
|
|
||||||
let cursor_char = chars[safe_cursor_pos].to_string();
|
|
||||||
spans.push(Span::styled(cursor_char, Style::default().fg(Color::Black).bg(color)));
|
|
||||||
} else {
|
|
||||||
// Курсор в конце - показываем блок
|
|
||||||
spans.push(Span::styled("█", Style::default().fg(color)));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Текст после курсора
|
|
||||||
if safe_cursor_pos + 1 < chars.len() {
|
|
||||||
let after: String = chars[safe_cursor_pos + 1..].iter().collect();
|
|
||||||
spans.push(Span::styled(after, Style::default().fg(color)));
|
|
||||||
}
|
|
||||||
|
|
||||||
Line::from(spans)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Информация о строке после переноса: текст и позиция в оригинале
|
/// Информация о строке после переноса: текст и позиция в оригинале
|
||||||
@@ -282,43 +111,6 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Фильтрует и корректирует entities для подстроки
|
|
||||||
fn adjust_entities_for_substring(
|
|
||||||
entities: &[TextEntity],
|
|
||||||
start: usize,
|
|
||||||
length: usize,
|
|
||||||
) -> Vec<TextEntity> {
|
|
||||||
let start = start as i32;
|
|
||||||
let end = start + length as i32;
|
|
||||||
|
|
||||||
entities
|
|
||||||
.iter()
|
|
||||||
.filter_map(|e| {
|
|
||||||
let e_start = e.offset;
|
|
||||||
let e_end = e.offset + e.length;
|
|
||||||
|
|
||||||
// Проверяем пересечение с нашей подстрокой
|
|
||||||
if e_end <= start || e_start >= end {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Вычисляем пересечение
|
|
||||||
let new_start = (e_start - start).max(0);
|
|
||||||
let new_end = (e_end - start).min(length as i32);
|
|
||||||
|
|
||||||
if new_end > new_start {
|
|
||||||
Some(TextEntity {
|
|
||||||
offset: new_start,
|
|
||||||
length: new_end - new_start,
|
|
||||||
r#type: e.r#type.clone(),
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn render(f: &mut Frame, area: Rect, app: &App) {
|
pub fn render(f: &mut Frame, area: Rect, app: &App) {
|
||||||
// Режим профиля
|
// Режим профиля
|
||||||
if app.is_profile_mode() {
|
if app.is_profile_mode() {
|
||||||
@@ -615,7 +407,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
|
|||||||
let line_len = wrapped.text.chars().count();
|
let line_len = wrapped.text.chars().count();
|
||||||
|
|
||||||
// Получаем entities для этой строки
|
// Получаем entities для этой строки
|
||||||
let line_entities = adjust_entities_for_substring(
|
let line_entities = formatting::adjust_entities_for_substring(
|
||||||
msg.entities(),
|
msg.entities(),
|
||||||
wrapped.start_offset,
|
wrapped.start_offset,
|
||||||
line_len,
|
line_len,
|
||||||
@@ -623,7 +415,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
|
|||||||
|
|
||||||
// Форматируем текст с entities
|
// Форматируем текст с entities
|
||||||
let formatted_spans =
|
let formatted_spans =
|
||||||
format_text_with_entities(&wrapped.text, &line_entities, msg_color);
|
formatting::format_text_with_entities(&wrapped.text, &line_entities, msg_color);
|
||||||
|
|
||||||
if is_last_line {
|
if is_last_line {
|
||||||
// Последняя строка — добавляем time_mark
|
// Последняя строка — добавляем time_mark
|
||||||
@@ -675,7 +467,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
|
|||||||
let line_len = wrapped.text.chars().count();
|
let line_len = wrapped.text.chars().count();
|
||||||
|
|
||||||
// Получаем entities для этой строки
|
// Получаем entities для этой строки
|
||||||
let line_entities = adjust_entities_for_substring(
|
let line_entities = formatting::adjust_entities_for_substring(
|
||||||
msg.entities(),
|
msg.entities(),
|
||||||
wrapped.start_offset,
|
wrapped.start_offset,
|
||||||
line_len,
|
line_len,
|
||||||
@@ -683,7 +475,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
|
|||||||
|
|
||||||
// Форматируем текст с entities
|
// Форматируем текст с entities
|
||||||
let formatted_spans =
|
let formatted_spans =
|
||||||
format_text_with_entities(&wrapped.text, &line_entities, msg_color);
|
formatting::format_text_with_entities(&wrapped.text, &line_entities, msg_color);
|
||||||
|
|
||||||
if i == 0 {
|
if i == 0 {
|
||||||
// Первая строка — с временем и маркером выбора
|
// Первая строка — с временем и маркером выбора
|
||||||
@@ -717,7 +509,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
|
|||||||
if !msg.reactions().is_empty() {
|
if !msg.reactions().is_empty() {
|
||||||
let mut reaction_spans = vec![];
|
let mut reaction_spans = vec![];
|
||||||
|
|
||||||
for reaction in &msg.reactions() {
|
for reaction in msg.reactions() {
|
||||||
if !reaction_spans.is_empty() {
|
if !reaction_spans.is_empty() {
|
||||||
reaction_spans.push(Span::raw(" "));
|
reaction_spans.push(Span::raw(" "));
|
||||||
}
|
}
|
||||||
@@ -831,10 +623,10 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
|
|||||||
// Режим выбора сообщения - подсказка зависит от возможностей
|
// Режим выбора сообщения - подсказка зависит от возможностей
|
||||||
let selected_msg = app.get_selected_message();
|
let selected_msg = app.get_selected_message();
|
||||||
let can_edit = selected_msg
|
let can_edit = selected_msg
|
||||||
.map(|m| m.can_be_edited && m.is_outgoing)
|
.map(|m| m.can_be_edited() && m.is_outgoing())
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
let can_delete = selected_msg
|
let can_delete = selected_msg
|
||||||
.map(|m| m.can_be_deleted_only_for_self || m.can_be_deleted_for_all_users)
|
.map(|m| m.can_be_deleted_only_for_self() || m.can_be_deleted_for_all_users())
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
|
||||||
let hint = match (can_edit, can_delete) {
|
let hint = match (can_edit, can_delete) {
|
||||||
@@ -872,7 +664,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
|
|||||||
let reply_preview = app
|
let reply_preview = app
|
||||||
.get_replying_to_message()
|
.get_replying_to_message()
|
||||||
.map(|m| {
|
.map(|m| {
|
||||||
let sender = if m.is_outgoing {
|
let sender = if m.is_outgoing() {
|
||||||
"Вы"
|
"Вы"
|
||||||
} else {
|
} else {
|
||||||
m.sender_name()
|
m.sender_name()
|
||||||
@@ -1336,56 +1128,7 @@ fn render_pinned_mode(f: &mut Frame, area: Rect, app: &App) {
|
|||||||
|
|
||||||
/// Рендерит модалку подтверждения удаления
|
/// Рендерит модалку подтверждения удаления
|
||||||
fn render_delete_confirm_modal(f: &mut Frame, area: Rect) {
|
fn render_delete_confirm_modal(f: &mut Frame, area: Rect) {
|
||||||
use ratatui::widgets::Clear;
|
components::modal::render_delete_confirm_modal(f, area);
|
||||||
|
|
||||||
// Размеры модалки
|
|
||||||
let modal_width = 40u16;
|
|
||||||
let modal_height = 7u16;
|
|
||||||
|
|
||||||
// Центрируем модалку
|
|
||||||
let x = area.x + (area.width.saturating_sub(modal_width)) / 2;
|
|
||||||
let y = area.y + (area.height.saturating_sub(modal_height)) / 2;
|
|
||||||
|
|
||||||
let modal_area = Rect::new(x, y, modal_width.min(area.width), modal_height.min(area.height));
|
|
||||||
|
|
||||||
// Очищаем область под модалкой
|
|
||||||
f.render_widget(Clear, modal_area);
|
|
||||||
|
|
||||||
// Содержимое модалки
|
|
||||||
let text = vec![
|
|
||||||
Line::from(""),
|
|
||||||
Line::from(Span::styled(
|
|
||||||
"Удалить сообщение?",
|
|
||||||
Style::default()
|
|
||||||
.fg(Color::White)
|
|
||||||
.add_modifier(Modifier::BOLD),
|
|
||||||
)),
|
|
||||||
Line::from(""),
|
|
||||||
Line::from(vec![
|
|
||||||
Span::styled(
|
|
||||||
" [y/Enter] ",
|
|
||||||
Style::default()
|
|
||||||
.fg(Color::Green)
|
|
||||||
.add_modifier(Modifier::BOLD),
|
|
||||||
),
|
|
||||||
Span::raw("Да"),
|
|
||||||
Span::raw(" "),
|
|
||||||
Span::styled(" [n/Esc] ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
|
|
||||||
Span::raw("Нет"),
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
|
|
||||||
let modal = Paragraph::new(text)
|
|
||||||
.block(
|
|
||||||
Block::default()
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_style(Style::default().fg(Color::Red))
|
|
||||||
.title(" Подтверждение ")
|
|
||||||
.title_style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
|
|
||||||
)
|
|
||||||
.alignment(Alignment::Center);
|
|
||||||
|
|
||||||
f.render_widget(modal, modal_area);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Рендерит модалку выбора реакции
|
/// Рендерит модалку выбора реакции
|
||||||
@@ -1395,88 +1138,5 @@ fn render_reaction_picker_modal(
|
|||||||
available_reactions: &[String],
|
available_reactions: &[String],
|
||||||
selected_index: usize,
|
selected_index: usize,
|
||||||
) {
|
) {
|
||||||
use ratatui::widgets::Clear;
|
components::render_emoji_picker(f, area, available_reactions, selected_index);
|
||||||
|
|
||||||
// Размеры модалки (зависят от количества реакций)
|
|
||||||
let emojis_per_row = 8;
|
|
||||||
let rows = (available_reactions.len() + emojis_per_row - 1) / emojis_per_row;
|
|
||||||
let modal_width = 50u16;
|
|
||||||
let modal_height = (rows + 4) as u16; // +4 для заголовка, отступов и подсказки
|
|
||||||
|
|
||||||
// Центрируем модалку
|
|
||||||
let x = area.x + (area.width.saturating_sub(modal_width)) / 2;
|
|
||||||
let y = area.y + (area.height.saturating_sub(modal_height)) / 2;
|
|
||||||
|
|
||||||
let modal_area = Rect::new(x, y, modal_width.min(area.width), modal_height.min(area.height));
|
|
||||||
|
|
||||||
// Очищаем область под модалкой
|
|
||||||
f.render_widget(Clear, modal_area);
|
|
||||||
|
|
||||||
// Формируем содержимое - сетка эмодзи
|
|
||||||
let mut text_lines = vec![Line::from("")]; // Пустая строка сверху
|
|
||||||
|
|
||||||
for row in 0..rows {
|
|
||||||
let mut row_spans = vec![Span::raw(" ")]; // Отступ слева
|
|
||||||
|
|
||||||
for col in 0..emojis_per_row {
|
|
||||||
let idx = row * emojis_per_row + col;
|
|
||||||
if idx >= available_reactions.len() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
let emoji = &available_reactions[idx];
|
|
||||||
let is_selected = idx == selected_index;
|
|
||||||
|
|
||||||
let style = if is_selected {
|
|
||||||
Style::default()
|
|
||||||
.fg(Color::Yellow)
|
|
||||||
.add_modifier(Modifier::BOLD)
|
|
||||||
.add_modifier(Modifier::REVERSED)
|
|
||||||
} else {
|
|
||||||
Style::default().fg(Color::White)
|
|
||||||
};
|
|
||||||
|
|
||||||
row_spans.push(Span::styled(format!(" {} ", emoji), style));
|
|
||||||
row_spans.push(Span::raw(" ")); // Пробел между эмодзи
|
|
||||||
}
|
|
||||||
|
|
||||||
text_lines.push(Line::from(row_spans));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Добавляем пустую строку и подсказку
|
|
||||||
text_lines.push(Line::from(""));
|
|
||||||
text_lines.push(Line::from(vec![
|
|
||||||
Span::styled(
|
|
||||||
" [←/→/↑/↓] ",
|
|
||||||
Style::default()
|
|
||||||
.fg(Color::Cyan)
|
|
||||||
.add_modifier(Modifier::BOLD),
|
|
||||||
),
|
|
||||||
Span::raw("Выбор "),
|
|
||||||
Span::styled(
|
|
||||||
" [Enter] ",
|
|
||||||
Style::default()
|
|
||||||
.fg(Color::Green)
|
|
||||||
.add_modifier(Modifier::BOLD),
|
|
||||||
),
|
|
||||||
Span::raw("Добавить "),
|
|
||||||
Span::styled(" [Esc] ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
|
|
||||||
Span::raw("Отмена"),
|
|
||||||
]));
|
|
||||||
|
|
||||||
let modal = Paragraph::new(text_lines)
|
|
||||||
.block(
|
|
||||||
Block::default()
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_style(Style::default().fg(Color::Yellow))
|
|
||||||
.title(" Выбери реакцию ")
|
|
||||||
.title_style(
|
|
||||||
Style::default()
|
|
||||||
.fg(Color::Yellow)
|
|
||||||
.add_modifier(Modifier::BOLD),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.alignment(Alignment::Left);
|
|
||||||
|
|
||||||
f.render_widget(modal, modal_area);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
mod auth;
|
mod auth;
|
||||||
pub mod chat_list;
|
pub mod chat_list;
|
||||||
|
pub mod components;
|
||||||
pub mod footer;
|
pub mod footer;
|
||||||
mod loading;
|
mod loading;
|
||||||
mod main_screen;
|
mod main_screen;
|
||||||
|
|||||||
@@ -96,12 +96,12 @@ fn format_message_for_test(msg: &tele_tui::tdlib::MessageInfo) -> String {
|
|||||||
let mut result = String::new();
|
let mut result = String::new();
|
||||||
|
|
||||||
// Добавляем forward контекст если есть
|
// Добавляем forward контекст если есть
|
||||||
if let Some(forward) = &msg.forward_from {
|
if let Some(forward) = &msg.forward_from() {
|
||||||
result.push_str(&format!("↪ Переслано от {}\n", forward.sender_name));
|
result.push_str(&format!("↪ Переслано от {}\n", forward.sender_name));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Добавляем reply контекст если есть
|
// Добавляем reply контекст если есть
|
||||||
if let Some(reply) = &msg.reply_to {
|
if let Some(reply) = &msg.reply_to() {
|
||||||
result.push_str(&format!("┌ {}: {}\n", reply.sender_name, reply.text));
|
result.push_str(&format!("┌ {}: {}\n", reply.sender_name, reply.text));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ mod helpers;
|
|||||||
|
|
||||||
use helpers::fake_tdclient::FakeTdClient;
|
use helpers::fake_tdclient::FakeTdClient;
|
||||||
use helpers::test_data::TestMessageBuilder;
|
use helpers::test_data::TestMessageBuilder;
|
||||||
|
use tele_tui::types::MessageId;
|
||||||
|
|
||||||
/// Test: Удаление сообщения убирает его из списка
|
/// Test: Удаление сообщения убирает его из списка
|
||||||
#[test]
|
#[test]
|
||||||
@@ -51,8 +52,8 @@ fn test_delete_multiple_messages() {
|
|||||||
// Проверяем что осталось только второе сообщение
|
// Проверяем что осталось только второе сообщение
|
||||||
let messages = client.get_messages(123);
|
let messages = client.get_messages(123);
|
||||||
assert_eq!(messages.len(), 1);
|
assert_eq!(messages.len(), 1);
|
||||||
assert_eq!(messages[0].id, msg2_id);
|
assert_eq!(messages[0].id(), MessageId::new(msg2_id));
|
||||||
assert_eq!(messages[0].content.text(), "Message 2");
|
assert_eq!(messages[0].content.text, "Message 2");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test: Удаление только своих сообщений (проверка через can_be_deleted_for_all_users)
|
/// Test: Удаление только своих сообщений (проверка через can_be_deleted_for_all_users)
|
||||||
@@ -74,12 +75,12 @@ fn test_can_only_delete_own_messages_for_all() {
|
|||||||
|
|
||||||
// Проверяем флаги удаления
|
// Проверяем флаги удаления
|
||||||
let messages = client.get_messages(123);
|
let messages = client.get_messages(123);
|
||||||
assert_eq!(messages[0].can_be_deleted_for_all_users, true); // Наше
|
assert_eq!(messages[0].can_be_deleted_for_all_users(), true); // Наше
|
||||||
assert_eq!(messages[1].can_be_deleted_for_all_users, false); // Чужое
|
assert_eq!(messages[1].can_be_deleted_for_all_users(), false); // Чужое
|
||||||
|
|
||||||
// Оба можно удалить для себя
|
// Оба можно удалить для себя
|
||||||
assert_eq!(messages[0].can_be_deleted_only_for_self, true);
|
assert_eq!(messages[0].can_be_deleted_only_for_self(), true);
|
||||||
assert_eq!(messages[1].can_be_deleted_only_for_self, true);
|
assert_eq!(messages[1].can_be_deleted_only_for_self(), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test: Удаление несуществующего сообщения (ничего не происходит)
|
/// Test: Удаление несуществующего сообщения (ничего не происходит)
|
||||||
@@ -102,7 +103,7 @@ fn test_delete_nonexistent_message() {
|
|||||||
// Но существующее сообщение осталось
|
// Но существующее сообщение осталось
|
||||||
let messages = client.get_messages(123);
|
let messages = client.get_messages(123);
|
||||||
assert_eq!(messages.len(), 1);
|
assert_eq!(messages.len(), 1);
|
||||||
assert_eq!(messages[0].id, msg_id);
|
assert_eq!(messages[0].id(), MessageId::new(msg_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test: Подтверждение удаления (симуляция модалки)
|
/// Test: Подтверждение удаления (симуляция модалки)
|
||||||
@@ -144,6 +145,6 @@ fn test_cancel_delete_keeps_message() {
|
|||||||
|
|
||||||
// Сообщение на месте
|
// Сообщение на месте
|
||||||
let messages = client.get_messages(123);
|
let messages = client.get_messages(123);
|
||||||
assert_eq!(messages[0].id, msg_id);
|
assert_eq!(messages[0].id(), MessageId::new(msg_id));
|
||||||
assert_eq!(messages[0].content.text(), "Keep me");
|
assert_eq!(messages[0].content.text, "Keep me");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ fn test_edit_message_changes_text() {
|
|||||||
// Проверяем что текст сообщения изменился
|
// Проверяем что текст сообщения изменился
|
||||||
let messages = client.get_messages(123);
|
let messages = client.get_messages(123);
|
||||||
assert_eq!(messages.len(), 1);
|
assert_eq!(messages.len(), 1);
|
||||||
assert_eq!(messages[0].content.text(), "Edited text");
|
assert_eq!(messages[0].content.text, "Edited text");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test: Редактирование устанавливает edit_date
|
/// Test: Редактирование устанавливает edit_date
|
||||||
@@ -37,16 +37,16 @@ fn test_edit_message_sets_edit_date() {
|
|||||||
|
|
||||||
// Получаем дату до редактирования
|
// Получаем дату до редактирования
|
||||||
let messages_before = client.get_messages(123);
|
let messages_before = client.get_messages(123);
|
||||||
let date_before = messages_before[0].date;
|
let date_before = messages_before[0].date();
|
||||||
assert_eq!(messages_before[0].edit_date, 0); // Не редактировалось
|
assert_eq!(messages_before[0].edit_date(), 0); // Не редактировалось
|
||||||
|
|
||||||
// Редактируем сообщение
|
// Редактируем сообщение
|
||||||
client.edit_message(123, msg_id, "Edited".to_string());
|
client.edit_message(123, msg_id, "Edited".to_string());
|
||||||
|
|
||||||
// Проверяем что edit_date установлена
|
// Проверяем что edit_date установлена
|
||||||
let messages_after = client.get_messages(123);
|
let messages_after = client.get_messages(123);
|
||||||
assert!(messages_after[0].edit_date > 0);
|
assert!(messages_after[0].edit_date() > 0);
|
||||||
assert!(messages_after[0].edit_date > date_before); // edit_date после date
|
assert!(messages_after[0].edit_date() > date_before); // edit_date после date
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test: Редактирование только своих сообщений (проверка через can_be_edited)
|
/// Test: Редактирование только своих сообщений (проверка через can_be_edited)
|
||||||
@@ -68,8 +68,8 @@ fn test_can_only_edit_own_messages() {
|
|||||||
|
|
||||||
// Проверяем флаги
|
// Проверяем флаги
|
||||||
let messages = client.get_messages(123);
|
let messages = client.get_messages(123);
|
||||||
assert_eq!(messages[0].can_be_edited, true); // Наше сообщение
|
assert_eq!(messages[0].can_be_edited(), true); // Наше сообщение
|
||||||
assert_eq!(messages[1].can_be_edited, false); // Чужое сообщение
|
assert_eq!(messages[1].can_be_edited(), false); // Чужое сообщение
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test: Множественные редактирования одного сообщения
|
/// Test: Множественные редактирования одного сообщения
|
||||||
@@ -97,7 +97,7 @@ fn test_multiple_edits_of_same_message() {
|
|||||||
// Проверяем что сообщение содержит последнюю версию
|
// Проверяем что сообщение содержит последнюю версию
|
||||||
let messages = client.get_messages(123);
|
let messages = client.get_messages(123);
|
||||||
assert_eq!(messages.len(), 1);
|
assert_eq!(messages.len(), 1);
|
||||||
assert_eq!(messages[0].content.text(), "Final version");
|
assert_eq!(messages[0].content.text, "Final version");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test: Редактирование несуществующего сообщения (ничего не происходит)
|
/// Test: Редактирование несуществующего сообщения (ничего не происходит)
|
||||||
@@ -136,14 +136,14 @@ fn test_edit_history_tracking() {
|
|||||||
|
|
||||||
// Проверяем что изменилось
|
// Проверяем что изменилось
|
||||||
let messages_edited = client.get_messages(123);
|
let messages_edited = client.get_messages(123);
|
||||||
assert_eq!(messages_edited[0].content.text(), "Edited");
|
assert_eq!(messages_edited[0].content.text, "Edited");
|
||||||
|
|
||||||
// Можем "отменить" редактирование вернув original
|
// Можем "отменить" редактирование вернув original
|
||||||
client.edit_message(123, msg_id, original);
|
client.edit_message(123, msg_id, original);
|
||||||
|
|
||||||
// Проверяем что вернулось
|
// Проверяем что вернулось
|
||||||
let messages_restored = client.get_messages(123);
|
let messages_restored = client.get_messages(123);
|
||||||
assert_eq!(messages_restored[0].content.text(), "Original");
|
assert_eq!(messages_restored[0].content.text, "Original");
|
||||||
|
|
||||||
// История показывает 2 редактирования
|
// История показывает 2 редактирования
|
||||||
assert_eq!(client.edited_messages().len(), 2);
|
assert_eq!(client.edited_messages().len(), 2);
|
||||||
|
|||||||
@@ -312,7 +312,7 @@ mod tests {
|
|||||||
.selected_chat(123)
|
.selected_chat(123)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
assert_eq!(app.selected_chat_id, Some(123));
|
assert_eq!(app.selected_chat_id, Some(ChatId::new(123)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -323,7 +323,7 @@ mod tests {
|
|||||||
.build();
|
.build();
|
||||||
|
|
||||||
assert!(app.is_editing());
|
assert!(app.is_editing());
|
||||||
assert_eq!(app.chat_state.selected_message_id(), Some(999));
|
assert_eq!(app.chat_state.selected_message_id(), Some(MessageId::new(999)));
|
||||||
assert_eq!(app.message_input, "Edited text");
|
assert_eq!(app.message_input, "Edited text");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use tele_tui::tdlib::{ChatInfo, FolderInfo, MessageInfo, NetworkState};
|
use tele_tui::tdlib::{ChatInfo, FolderInfo, MessageInfo, NetworkState};
|
||||||
|
use tele_tui::types::MessageId;
|
||||||
|
|
||||||
/// Упрощённый mock TDLib клиента для тестов
|
/// Упрощённый mock TDLib клиента для тестов
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -124,22 +125,22 @@ impl FakeTdClient {
|
|||||||
.push(SentMessage { chat_id, text: text.clone(), reply_to });
|
.push(SentMessage { chat_id, text: text.clone(), reply_to });
|
||||||
|
|
||||||
// Добавляем сообщение в список сообщений чата
|
// Добавляем сообщение в список сообщений чата
|
||||||
let message = MessageInfo {
|
let message = MessageInfo::new(
|
||||||
id: message_id,
|
MessageId::new(message_id),
|
||||||
sender_name: "You".to_string(),
|
"You".to_string(),
|
||||||
is_outgoing: true,
|
true, // is_outgoing
|
||||||
content: text,
|
text,
|
||||||
entities: vec![],
|
vec![], // entities
|
||||||
date: 1640000000,
|
1640000000, // date
|
||||||
edit_date: 0,
|
0, // edit_date
|
||||||
is_read: true,
|
true, // is_read
|
||||||
can_be_edited: true,
|
true, // can_be_edited
|
||||||
can_be_deleted_only_for_self: true,
|
true, // can_be_deleted_only_for_self
|
||||||
can_be_deleted_for_all_users: true,
|
true, // can_be_deleted_for_all_users
|
||||||
reply_to: None,
|
None, // reply_to
|
||||||
forward_from: None,
|
None, // forward_from
|
||||||
reactions: vec![],
|
vec![], // reactions
|
||||||
};
|
);
|
||||||
|
|
||||||
self.messages
|
self.messages
|
||||||
.entry(chat_id)
|
.entry(chat_id)
|
||||||
@@ -156,7 +157,7 @@ impl FakeTdClient {
|
|||||||
|
|
||||||
// Обновляем сообщение в списке
|
// Обновляем сообщение в списке
|
||||||
if let Some(messages) = self.messages.get_mut(&chat_id) {
|
if let Some(messages) = self.messages.get_mut(&chat_id) {
|
||||||
if let Some(msg) = messages.iter_mut().find(|m| m.id() == message_id) {
|
if let Some(msg) = messages.iter_mut().find(|m| m.id().as_i64() == message_id) {
|
||||||
msg.content.text = new_text;
|
msg.content.text = new_text;
|
||||||
msg.metadata.edit_date = msg.metadata.date + 60;
|
msg.metadata.edit_date = msg.metadata.date + 60;
|
||||||
}
|
}
|
||||||
@@ -169,7 +170,7 @@ impl FakeTdClient {
|
|||||||
|
|
||||||
// Удаляем сообщение из списка
|
// Удаляем сообщение из списка
|
||||||
if let Some(messages) = self.messages.get_mut(&chat_id) {
|
if let Some(messages) = self.messages.get_mut(&chat_id) {
|
||||||
messages.retain(|m| m.id != message_id);
|
messages.retain(|m| m.id().as_i64() != message_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,7 +239,7 @@ mod tests {
|
|||||||
assert_eq!(client.sent_messages().len(), 1);
|
assert_eq!(client.sent_messages().len(), 1);
|
||||||
assert_eq!(client.sent_messages()[0].text, "Hello");
|
assert_eq!(client.sent_messages()[0].text, "Hello");
|
||||||
assert_eq!(client.get_messages(123).len(), 1);
|
assert_eq!(client.get_messages(123).len(), 1);
|
||||||
assert_eq!(client.get_messages(123)[0].id, msg_id);
|
assert_eq!(client.get_messages(123)[0].id().as_i64(), msg_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -248,8 +249,8 @@ mod tests {
|
|||||||
client.edit_message(123, msg_id, "Hello World".to_string());
|
client.edit_message(123, msg_id, "Hello World".to_string());
|
||||||
|
|
||||||
assert_eq!(client.edited_messages().len(), 1);
|
assert_eq!(client.edited_messages().len(), 1);
|
||||||
assert_eq!(client.get_messages(123)[0].content, "Hello World");
|
assert_eq!(client.get_messages(123)[0].content.text, "Hello World");
|
||||||
assert!(client.get_messages(123)[0].edit_date > 0);
|
assert!(client.get_messages(123)[0].edit_date() > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use helpers::app_builder::TestAppBuilder;
|
|||||||
use helpers::snapshot_utils::{buffer_to_string, render_to_buffer};
|
use helpers::snapshot_utils::{buffer_to_string, render_to_buffer};
|
||||||
use helpers::test_data::{create_test_chat, TestChatBuilder, TestMessageBuilder};
|
use helpers::test_data::{create_test_chat, TestChatBuilder, TestMessageBuilder};
|
||||||
use insta::assert_snapshot;
|
use insta::assert_snapshot;
|
||||||
|
use tele_tui::types::{ChatId, MessageId};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn snapshot_empty_chat() {
|
fn snapshot_empty_chat() {
|
||||||
@@ -154,8 +155,8 @@ fn snapshot_outgoing_read() {
|
|||||||
.build();
|
.build();
|
||||||
|
|
||||||
// Set last_read_outbox to simulate message being read
|
// Set last_read_outbox to simulate message being read
|
||||||
if let Some(chat) = app.chats.iter_mut().find(|c| c.id == 123) {
|
if let Some(chat) = app.chats.iter_mut().find(|c| c.id == ChatId::new(123)) {
|
||||||
chat.last_read_outbox_message_id = 2;
|
chat.last_read_outbox_message_id = MessageId::new(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
let buffer = render_to_buffer(80, 24, |f| {
|
let buffer = render_to_buffer(80, 24, |f| {
|
||||||
|
|||||||
@@ -223,6 +223,6 @@ fn test_load_older_messages_on_scroll_up() {
|
|||||||
|
|
||||||
// Теперь должно быть 15 сообщений
|
// Теперь должно быть 15 сообщений
|
||||||
assert_eq!(client.get_messages(123).len(), 15);
|
assert_eq!(client.get_messages(123).len(), 15);
|
||||||
assert_eq!(client.get_messages(123)[0].content.text(), "Msg 81");
|
assert_eq!(client.get_messages(123)[0].content.text, "Msg 81");
|
||||||
assert_eq!(client.get_messages(123)[14].content.text(), "Msg 100");
|
assert_eq!(client.get_messages(123)[14].content.text, "Msg 100");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ mod helpers;
|
|||||||
use helpers::fake_tdclient::FakeTdClient;
|
use helpers::fake_tdclient::FakeTdClient;
|
||||||
use helpers::test_data::create_test_chat;
|
use helpers::test_data::create_test_chat;
|
||||||
use tele_tui::tdlib::ProfileInfo;
|
use tele_tui::tdlib::ProfileInfo;
|
||||||
|
use tele_tui::types::ChatId;
|
||||||
|
|
||||||
/// Test: Открытие профиля в личном чате (i)
|
/// Test: Открытие профиля в личном чате (i)
|
||||||
#[test]
|
#[test]
|
||||||
@@ -26,7 +27,7 @@ fn test_open_profile_in_private_chat() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_profile_shows_user_info() {
|
fn test_profile_shows_user_info() {
|
||||||
let profile = ProfileInfo {
|
let profile = ProfileInfo {
|
||||||
chat_id: 123,
|
chat_id: ChatId::new(123),
|
||||||
title: "Alice Johnson".to_string(),
|
title: "Alice Johnson".to_string(),
|
||||||
username: Some("alice".to_string()),
|
username: Some("alice".to_string()),
|
||||||
phone_number: Some("+1234567890".to_string()),
|
phone_number: Some("+1234567890".to_string()),
|
||||||
@@ -49,7 +50,7 @@ fn test_profile_shows_user_info() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_profile_shows_group_member_count() {
|
fn test_profile_shows_group_member_count() {
|
||||||
let profile = ProfileInfo {
|
let profile = ProfileInfo {
|
||||||
chat_id: 456,
|
chat_id: ChatId::new(456),
|
||||||
title: "Work Team".to_string(),
|
title: "Work Team".to_string(),
|
||||||
username: None,
|
username: None,
|
||||||
phone_number: None,
|
phone_number: None,
|
||||||
@@ -72,7 +73,7 @@ fn test_profile_shows_group_member_count() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_profile_shows_channel_info() {
|
fn test_profile_shows_channel_info() {
|
||||||
let profile = ProfileInfo {
|
let profile = ProfileInfo {
|
||||||
chat_id: 789,
|
chat_id: ChatId::new(789),
|
||||||
title: "News Channel".to_string(),
|
title: "News Channel".to_string(),
|
||||||
username: Some("news_channel".to_string()),
|
username: Some("news_channel".to_string()),
|
||||||
phone_number: None,
|
phone_number: None,
|
||||||
@@ -107,7 +108,7 @@ fn test_close_profile_with_esc() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_profile_without_optional_fields() {
|
fn test_profile_without_optional_fields() {
|
||||||
let profile = ProfileInfo {
|
let profile = ProfileInfo {
|
||||||
chat_id: 999,
|
chat_id: ChatId::new(999),
|
||||||
title: "Anonymous User".to_string(),
|
title: "Anonymous User".to_string(),
|
||||||
username: None,
|
username: None,
|
||||||
phone_number: None,
|
phone_number: None,
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ fn test_toggle_reaction_removes_it() {
|
|||||||
|
|
||||||
// Проверяем что реакция есть
|
// Проверяем что реакция есть
|
||||||
let messages_before = client.get_messages(123);
|
let messages_before = client.get_messages(123);
|
||||||
assert_eq!(messages_before[0].reactions.len(), 1);
|
assert_eq!(messages_before[0].reactions().len(), 1);
|
||||||
assert_eq!(messages_before[0].reactions[0].is_chosen, true);
|
assert_eq!(messages_before[0].reactions()[0].is_chosen, true);
|
||||||
|
|
||||||
// Симулируем удаление реакции (в реальном App это toggle)
|
// Симулируем удаление реакции (в реальном App это toggle)
|
||||||
// FakeTdClient просто записывает что реакция была "убрана"
|
// FakeTdClient просто записывает что реакция была "убрана"
|
||||||
@@ -51,7 +51,7 @@ fn test_toggle_reaction_removes_it() {
|
|||||||
client.messages.insert(123, vec![msg_after]);
|
client.messages.insert(123, vec![msg_after]);
|
||||||
|
|
||||||
let messages_after = client.get_messages(123);
|
let messages_after = client.get_messages(123);
|
||||||
assert_eq!(messages_after[0].reactions.len(), 0);
|
assert_eq!(messages_after[0].reactions().len(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test: Множественные реакции на одно сообщение
|
/// Test: Множественные реакции на одно сообщение
|
||||||
@@ -89,7 +89,7 @@ fn test_reactions_from_multiple_users() {
|
|||||||
client = client.with_message(123, msg);
|
client = client.with_message(123, msg);
|
||||||
|
|
||||||
let messages = client.get_messages(123);
|
let messages = client.get_messages(123);
|
||||||
let reaction = &messages[0].reactions[0];
|
let reaction = &messages[0].reactions()[0];
|
||||||
|
|
||||||
assert_eq!(reaction.emoji, "👍");
|
assert_eq!(reaction.emoji, "👍");
|
||||||
assert_eq!(reaction.count, 3);
|
assert_eq!(reaction.count, 3);
|
||||||
@@ -109,7 +109,7 @@ fn test_own_reaction_is_chosen() {
|
|||||||
client = client.with_message(123, msg);
|
client = client.with_message(123, msg);
|
||||||
|
|
||||||
let messages = client.get_messages(123);
|
let messages = client.get_messages(123);
|
||||||
let reaction = &messages[0].reactions[0];
|
let reaction = &messages[0].reactions()[0];
|
||||||
|
|
||||||
assert_eq!(reaction.is_chosen, true);
|
assert_eq!(reaction.is_chosen, true);
|
||||||
// В UI это будет отображаться в рамках: [❤️]
|
// В UI это будет отображаться в рамках: [❤️]
|
||||||
@@ -128,7 +128,7 @@ fn test_other_reaction_not_chosen() {
|
|||||||
client = client.with_message(123, msg);
|
client = client.with_message(123, msg);
|
||||||
|
|
||||||
let messages = client.get_messages(123);
|
let messages = client.get_messages(123);
|
||||||
let reaction = &messages[0].reactions[0];
|
let reaction = &messages[0].reactions()[0];
|
||||||
|
|
||||||
assert_eq!(reaction.is_chosen, false);
|
assert_eq!(reaction.is_chosen, false);
|
||||||
// В UI это будет отображаться без рамок: 😂 2
|
// В UI это будет отображаться без рамок: 😂 2
|
||||||
@@ -154,7 +154,7 @@ fn test_reaction_counter_increases() {
|
|||||||
client.messages.insert(123, vec![msg_v2]);
|
client.messages.insert(123, vec![msg_v2]);
|
||||||
|
|
||||||
let messages = client.get_messages(123);
|
let messages = client.get_messages(123);
|
||||||
assert_eq!(messages[0].reactions[0].count, 5);
|
assert_eq!(messages[0].reactions()[0].count, 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test: Обновление реакции - мы добавили свою к существующим
|
/// Test: Обновление реакции - мы добавили свою к существующим
|
||||||
@@ -177,7 +177,7 @@ fn test_update_reaction_we_add_ours() {
|
|||||||
client.messages.insert(123, vec![msg_after]);
|
client.messages.insert(123, vec![msg_after]);
|
||||||
|
|
||||||
let messages = client.get_messages(123);
|
let messages = client.get_messages(123);
|
||||||
let reaction = &messages[0].reactions[0];
|
let reaction = &messages[0].reactions()[0];
|
||||||
|
|
||||||
assert_eq!(reaction.count, 3);
|
assert_eq!(reaction.count, 3);
|
||||||
assert_eq!(reaction.is_chosen, true);
|
assert_eq!(reaction.is_chosen, true);
|
||||||
@@ -195,7 +195,7 @@ fn test_single_reaction_shows_only_emoji() {
|
|||||||
client = client.with_message(123, msg);
|
client = client.with_message(123, msg);
|
||||||
|
|
||||||
let messages = client.get_messages(123);
|
let messages = client.get_messages(123);
|
||||||
let reaction = &messages[0].reactions[0];
|
let reaction = &messages[0].reactions()[0];
|
||||||
|
|
||||||
assert_eq!(reaction.count, 1);
|
assert_eq!(reaction.count, 1);
|
||||||
// В UI: если count=1, показываем только emoji без цифры
|
// В UI: если count=1, показываем только emoji без цифры
|
||||||
@@ -228,16 +228,16 @@ fn test_reactions_on_multiple_messages() {
|
|||||||
let messages = client.get_messages(123);
|
let messages = client.get_messages(123);
|
||||||
|
|
||||||
// Первое: 1 реакция
|
// Первое: 1 реакция
|
||||||
assert_eq!(messages[0].reactions.len(), 1);
|
assert_eq!(messages[0].reactions().len(), 1);
|
||||||
assert_eq!(messages[0].reactions[0].emoji, "👍");
|
assert_eq!(messages[0].reactions()[0].emoji, "👍");
|
||||||
|
|
||||||
// Второе: 1 реакция
|
// Второе: 1 реакция
|
||||||
assert_eq!(messages[1].reactions.len(), 1);
|
assert_eq!(messages[1].reactions().len(), 1);
|
||||||
assert_eq!(messages[1].reactions[0].emoji, "❤️");
|
assert_eq!(messages[1].reactions()[0].emoji, "❤️");
|
||||||
|
|
||||||
// Третье: 2 реакции
|
// Третье: 2 реакции
|
||||||
assert_eq!(messages[2].reactions.len(), 2);
|
assert_eq!(messages[2].reactions().len(), 2);
|
||||||
assert_eq!(messages[2].reactions[0].emoji, "😂");
|
assert_eq!(messages[2].reactions()[0].emoji, "😂");
|
||||||
assert_eq!(messages[2].reactions[1].emoji, "🔥");
|
assert_eq!(messages[2].reactions()[1].emoji, "🔥");
|
||||||
assert_eq!(messages[2].reactions[1].is_chosen, true);
|
assert_eq!(messages[2].reactions()[1].is_chosen, true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ mod helpers;
|
|||||||
use helpers::fake_tdclient::FakeTdClient;
|
use helpers::fake_tdclient::FakeTdClient;
|
||||||
use helpers::test_data::TestMessageBuilder;
|
use helpers::test_data::TestMessageBuilder;
|
||||||
use tele_tui::tdlib::{ForwardInfo, ReplyInfo};
|
use tele_tui::tdlib::{ForwardInfo, ReplyInfo};
|
||||||
|
use tele_tui::types::MessageId;
|
||||||
|
|
||||||
/// Test: Reply создаёт сообщение с reply_to
|
/// Test: Reply создаёт сообщение с reply_to
|
||||||
#[test]
|
#[test]
|
||||||
@@ -28,8 +29,8 @@ fn test_reply_creates_message_with_reply_to() {
|
|||||||
// Проверяем что в списке 2 сообщения
|
// Проверяем что в списке 2 сообщения
|
||||||
let messages = client.get_messages(123);
|
let messages = client.get_messages(123);
|
||||||
assert_eq!(messages.len(), 2);
|
assert_eq!(messages.len(), 2);
|
||||||
assert_eq!(messages[1].id, reply_id);
|
assert_eq!(messages[1].id(), MessageId::new(reply_id));
|
||||||
assert_eq!(messages[1].content.text(), "Answer!");
|
assert_eq!(messages[1].content.text, "Answer!");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test: Reply отображает превью оригинального сообщения
|
/// Test: Reply отображает превью оригинального сообщения
|
||||||
@@ -48,10 +49,10 @@ fn test_reply_shows_original_preview() {
|
|||||||
// Проверяем что reply_to сохранено
|
// Проверяем что reply_to сохранено
|
||||||
let messages = client.get_messages(123);
|
let messages = client.get_messages(123);
|
||||||
assert_eq!(messages.len(), 1);
|
assert_eq!(messages.len(), 1);
|
||||||
assert!(messages[0].reply_to.is_some());
|
assert!(messages[0].reply_to().is_some());
|
||||||
|
|
||||||
let reply = messages[0].reply_to.as_ref().unwrap();
|
let reply = messages[0].reply_to().unwrap();
|
||||||
assert_eq!(reply.message_id, 100);
|
assert_eq!(reply.message_id, MessageId::new(100));
|
||||||
assert_eq!(reply.sender_name, "Alice");
|
assert_eq!(reply.sender_name, "Alice");
|
||||||
assert_eq!(reply.text, "Original");
|
assert_eq!(reply.text, "Original");
|
||||||
}
|
}
|
||||||
@@ -76,7 +77,7 @@ fn test_cancel_reply_sends_without_reply_to() {
|
|||||||
assert_eq!(client.sent_messages()[0].reply_to, None);
|
assert_eq!(client.sent_messages()[0].reply_to, None);
|
||||||
|
|
||||||
let messages = client.get_messages(123);
|
let messages = client.get_messages(123);
|
||||||
assert_eq!(messages[1].content.text(), "Regular message");
|
assert_eq!(messages[1].content.text, "Regular message");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test: Forward создаёт сообщение с forward_from
|
/// Test: Forward создаёт сообщение с forward_from
|
||||||
@@ -94,9 +95,9 @@ fn test_forward_creates_message_with_forward_from() {
|
|||||||
// Проверяем что forward_from сохранено
|
// Проверяем что forward_from сохранено
|
||||||
let messages = client.get_messages(456);
|
let messages = client.get_messages(456);
|
||||||
assert_eq!(messages.len(), 1);
|
assert_eq!(messages.len(), 1);
|
||||||
assert!(messages[0].forward_from.is_some());
|
assert!(messages[0].forward_from().is_some());
|
||||||
|
|
||||||
let forward = messages[0].forward_from.as_ref().unwrap();
|
let forward = messages[0].forward_from().unwrap();
|
||||||
assert_eq!(forward.sender_name, "Bob");
|
assert_eq!(forward.sender_name, "Bob");
|
||||||
assert!(forward.date > 0); // Дата установлена
|
assert!(forward.date > 0); // Дата установлена
|
||||||
}
|
}
|
||||||
@@ -114,7 +115,7 @@ fn test_forward_displays_sender_name() {
|
|||||||
client = client.with_message(789, msg);
|
client = client.with_message(789, msg);
|
||||||
|
|
||||||
let messages = client.get_messages(789);
|
let messages = client.get_messages(789);
|
||||||
let forward = messages[0].forward_from.as_ref().unwrap();
|
let forward = messages[0].forward_from().unwrap();
|
||||||
|
|
||||||
// В UI это будет отображаться как "↪ Переслано от Charlie"
|
// В UI это будет отображаться как "↪ Переслано от Charlie"
|
||||||
assert_eq!(forward.sender_name, "Charlie");
|
assert_eq!(forward.sender_name, "Charlie");
|
||||||
@@ -144,7 +145,7 @@ fn test_forward_to_different_chat() {
|
|||||||
|
|
||||||
// Проверяем что во втором чате тоже 1 сообщение (пересланное)
|
// Проверяем что во втором чате тоже 1 сообщение (пересланное)
|
||||||
assert_eq!(client.get_messages(456).len(), 1);
|
assert_eq!(client.get_messages(456).len(), 1);
|
||||||
assert!(client.get_messages(456)[0].forward_from.is_some());
|
assert!(client.get_messages(456)[0].forward_from().is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test: Reply + Forward комбинация (ответ на пересланное сообщение)
|
/// Test: Reply + Forward комбинация (ответ на пересланное сообщение)
|
||||||
@@ -167,7 +168,7 @@ fn test_reply_to_forwarded_message() {
|
|||||||
|
|
||||||
let messages = client.get_messages(123);
|
let messages = client.get_messages(123);
|
||||||
assert_eq!(messages.len(), 2);
|
assert_eq!(messages.len(), 2);
|
||||||
assert_eq!(messages[1].id, reply_id);
|
assert_eq!(messages[1].id(), MessageId::new(reply_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test: Forward множества сообщений (batch forward)
|
/// Test: Forward множества сообщений (batch forward)
|
||||||
@@ -196,7 +197,7 @@ fn test_forward_multiple_messages() {
|
|||||||
// Проверяем что все 3 сообщения пересланы
|
// Проверяем что все 3 сообщения пересланы
|
||||||
let messages = client.get_messages(456);
|
let messages = client.get_messages(456);
|
||||||
assert_eq!(messages.len(), 3);
|
assert_eq!(messages.len(), 3);
|
||||||
assert!(messages[0].forward_from.is_some());
|
assert!(messages[0].forward_from().is_some());
|
||||||
assert!(messages[1].forward_from.is_some());
|
assert!(messages[1].forward_from().is_some());
|
||||||
assert!(messages[2].forward_from.is_some());
|
assert!(messages[2].forward_from().is_some());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ fn test_send_text_message() {
|
|||||||
// Проверяем что сообщение добавилось в список
|
// Проверяем что сообщение добавилось в список
|
||||||
let messages = client.get_messages(123);
|
let messages = client.get_messages(123);
|
||||||
assert_eq!(messages.len(), 1);
|
assert_eq!(messages.len(), 1);
|
||||||
assert_eq!(messages[0].id, msg_id);
|
assert_eq!(messages[0].id().as_i64(), msg_id);
|
||||||
assert_eq!(messages.text(), "Hello, Mom!");
|
assert_eq!(messages[0].text(), "Hello, Mom!");
|
||||||
assert_eq!(messages[0].is_outgoing, true);
|
assert_eq!(messages[0].is_outgoing(), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test: Отправка нескольких сообщений обновляет список
|
/// Test: Отправка нескольких сообщений обновляет список
|
||||||
@@ -49,12 +49,12 @@ fn test_send_multiple_messages_updates_list() {
|
|||||||
// Проверяем что все сообщения в списке
|
// Проверяем что все сообщения в списке
|
||||||
let messages = client.get_messages(123);
|
let messages = client.get_messages(123);
|
||||||
assert_eq!(messages.len(), 3);
|
assert_eq!(messages.len(), 3);
|
||||||
assert_eq!(messages[0].id, msg1_id);
|
assert_eq!(messages[0].id().as_i64(), msg1_id);
|
||||||
assert_eq!(messages[1].id, msg2_id);
|
assert_eq!(messages[1].id().as_i64(), msg2_id);
|
||||||
assert_eq!(messages[2].id, msg3_id);
|
assert_eq!(messages[2].id().as_i64(), msg3_id);
|
||||||
assert_eq!(messages.text(), "Message 1");
|
assert_eq!(messages[0].text(), "Message 1");
|
||||||
assert_eq!(messages.text(), "Message 2");
|
assert_eq!(messages[1].text(), "Message 2");
|
||||||
assert_eq!(messages.text(), "Message 3");
|
assert_eq!(messages[2].text(), "Message 3");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test: Отправка пустого сообщения (должно быть игнорировано на уровне App)
|
/// Test: Отправка пустого сообщения (должно быть игнорировано на уровне App)
|
||||||
@@ -73,8 +73,8 @@ fn test_send_empty_message_technical() {
|
|||||||
|
|
||||||
let messages = client.get_messages(123);
|
let messages = client.get_messages(123);
|
||||||
assert_eq!(messages.len(), 1);
|
assert_eq!(messages.len(), 1);
|
||||||
assert_eq!(messages[0].id, msg_id);
|
assert_eq!(messages[0].id().as_i64(), msg_id);
|
||||||
assert_eq!(messages.text(), "");
|
assert_eq!(messages[0].text(), "");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test: Отправка сообщения с форматированием (markdown сущности)
|
/// Test: Отправка сообщения с форматированием (markdown сущности)
|
||||||
@@ -89,7 +89,7 @@ fn test_send_message_with_markdown() {
|
|||||||
// Проверяем что текст сохранился как есть (парсинг markdown - отдельная логика)
|
// Проверяем что текст сохранился как есть (парсинг markdown - отдельная логика)
|
||||||
let messages = client.get_messages(123);
|
let messages = client.get_messages(123);
|
||||||
assert_eq!(messages.len(), 1);
|
assert_eq!(messages.len(), 1);
|
||||||
assert_eq!(messages.text(), text);
|
assert_eq!(messages[0].text(), text);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test: Отправка сообщения в разные чаты
|
/// Test: Отправка сообщения в разные чаты
|
||||||
@@ -112,12 +112,12 @@ fn test_send_messages_to_different_chats() {
|
|||||||
// Проверяем что сообщения распределены по чатам
|
// Проверяем что сообщения распределены по чатам
|
||||||
let chat123_messages = client.get_messages(123);
|
let chat123_messages = client.get_messages(123);
|
||||||
assert_eq!(chat123_messages.len(), 2);
|
assert_eq!(chat123_messages.len(), 2);
|
||||||
assert_eq!(chat123_messages.text(), "Hello Mom");
|
assert_eq!(chat123_messages[0].text(), "Hello Mom");
|
||||||
assert_eq!(chat123_messages.text(), "How are you?");
|
assert_eq!(chat123_messages[1].text(), "How are you?");
|
||||||
|
|
||||||
let chat456_messages = client.get_messages(456);
|
let chat456_messages = client.get_messages(456);
|
||||||
assert_eq!(chat456_messages.len(), 1);
|
assert_eq!(chat456_messages.len(), 1);
|
||||||
assert_eq!(chat456_messages.text(), "Hello Boss");
|
assert_eq!(chat456_messages[0].text(), "Hello Boss");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test: Новое сообщение появляется в реальном времени (симуляция)
|
/// Test: Новое сообщение появляется в реальном времени (симуляция)
|
||||||
@@ -139,8 +139,8 @@ fn test_receive_incoming_message() {
|
|||||||
// Проверяем что в списке 2 сообщения
|
// Проверяем что в списке 2 сообщения
|
||||||
let messages = client.get_messages(123);
|
let messages = client.get_messages(123);
|
||||||
assert_eq!(messages.len(), 2);
|
assert_eq!(messages.len(), 2);
|
||||||
assert_eq!(messages[0].is_outgoing, true); // Наше сообщение
|
assert_eq!(messages[0].is_outgoing(), true); // Наше сообщение
|
||||||
assert_eq!(messages[1].is_outgoing, false); // Входящее
|
assert_eq!(messages[1].is_outgoing(), false); // Входящее
|
||||||
assert_eq!(messages.text(), "Hey there!");
|
assert_eq!(messages[1].text(), "Hey there!");
|
||||||
assert_eq!(messages[1].sender_name, "Alice");
|
assert_eq!(messages[1].sender_name(), "Alice");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ expression: output
|
|||||||
│ │
|
│ │
|
||||||
│ ┌ Выбери реакцию ────────────────────────────────┐ │
|
│ ┌ Выбери реакцию ────────────────────────────────┐ │
|
||||||
│ │ │ │
|
│ │ │ │
|
||||||
│ │ 👍 👎 ❤️ 🔥 😊 😢 😮 🎉 │ │
|
│ │ 👍 👎 ❤️ 🔥 😊 😢 😮 🎉 │ │
|
||||||
│ │ │ │
|
│ │ │ │
|
||||||
│ └────────────────────────────────────────────────┘ │
|
│ └────────────────────────────────────────────────┘ │
|
||||||
│ │
|
│ │
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ expression: output
|
|||||||
│ │
|
│ │
|
||||||
│ ┌ Выбери реакцию ────────────────────────────────┐ │
|
│ ┌ Выбери реакцию ────────────────────────────────┐ │
|
||||||
│ │ │ │
|
│ │ │ │
|
||||||
│ │ 👍 👎 ❤️ 🔥 😊 😢 😮 🎉 │ │
|
│ │ 👍 👎 ❤️ 🔥 😊 😢 😮 🎉 │ │
|
||||||
│ │ │ │
|
│ │ │ │
|
||||||
│ └────────────────────────────────────────────────┘ │
|
│ └────────────────────────────────────────────────┘ │
|
||||||
│ │
|
│ │
|
||||||
|
|||||||
Reference in New Issue
Block a user