Compare commits
2 Commits
8bd08318bb
...
df19bc742c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df19bc742c | ||
|
|
78fe09bf11 |
42
CONTEXT.md
42
CONTEXT.md
@@ -2,6 +2,48 @@
|
|||||||
|
|
||||||
## Статус: Фаза 14 — Мультиаккаунт (IN PROGRESS)
|
## Статус: Фаза 14 — Мультиаккаунт (IN PROGRESS)
|
||||||
|
|
||||||
|
### Photo Albums (Media Groups) — DONE
|
||||||
|
|
||||||
|
Фото-альбомы (несколько фото в одном сообщении) теперь группируются в один пузырь с сеткой фото.
|
||||||
|
|
||||||
|
**Проблема**: TDLib отправляет альбомы как отдельные `Message` с общим `media_album_id: i64`. Ранее проект это поле игнорировал — каждое фото отображалось как отдельный пузырь.
|
||||||
|
|
||||||
|
**Решение:**
|
||||||
|
|
||||||
|
1. **Data Model** — `media_album_id: i64` в `MessageMetadata`, `MessageBuilder`, getter `MessageInfo::media_album_id()`. Оба конвертера (async + sync) передают поле из TDLib.
|
||||||
|
|
||||||
|
2. **Message Grouping** — новый вариант `MessageGroup::Album(Vec<MessageInfo>)`. Сообщения с одинаковым `media_album_id != 0` группируются; одиночное сообщение с album_id остаётся `Message`.
|
||||||
|
|
||||||
|
3. **Album Grid Constants** — `ALBUM_PHOTO_WIDTH: 16`, `ALBUM_PHOTO_HEIGHT: 8`, `ALBUM_PHOTO_GAP: 1`, `ALBUM_GRID_MAX_COLS: 3` (3×16 + 2×1 = 50 = `INLINE_IMAGE_MAX_WIDTH`).
|
||||||
|
|
||||||
|
4. **`render_album_bubble()`** — сетка фото (до 3 в ряд), `DeferredImageRender` с `x_offset` для каждого фото, общая подпись и timestamp, индикация выбора, статусы загрузки.
|
||||||
|
|
||||||
|
5. **Integration** — `Album` arm в `render_message_list`, `x_offset` в second pass. Без feature `images` — fallback через отдельные bubble.
|
||||||
|
|
||||||
|
**Модифицированные файлы:**
|
||||||
|
- `src/tdlib/types.rs` — `media_album_id` в `MessageMetadata`, `MessageBuilder`, getter
|
||||||
|
- `src/tdlib/messages/convert.rs` — передача `media_album_id` в builder
|
||||||
|
- `src/tdlib/message_converter.rs` — передача `media_album_id` в builder
|
||||||
|
- `src/message_grouping.rs` — `Album` variant + album detection + 4 новых теста
|
||||||
|
- `src/constants.rs` — album grid constants
|
||||||
|
- `src/ui/components/message_bubble.rs` — `x_offset` в `DeferredImageRender`, `render_album_bubble()`
|
||||||
|
- `src/ui/components/mod.rs` — export `render_album_bubble`
|
||||||
|
- `src/ui/messages.rs` — `Album` arm + `x_offset` в second pass
|
||||||
|
|
||||||
|
6. **Навигация j/k по альбомам** — альбом обрабатывается как одно сообщение. `select_previous_message()` / `select_next_message()` перескакивают через все сообщения альбома. `start_message_selection()` встаёт на первый элемент альбома если последнее сообщение — часть альбома.
|
||||||
|
|
||||||
|
7. **Тесты** — 4 unit-теста в `message_grouping.rs`, 5 snapshot-тестов в `tests/messages.rs`, 3 теста навигации в `tests/input_navigation.rs`.
|
||||||
|
|
||||||
|
**Дополнительно модифицированные файлы:**
|
||||||
|
- `src/app/methods/messages.rs` — навигация перескакивает альбомы
|
||||||
|
- `tests/helpers/test_data.rs` — `TestMessageBuilder::media_album_id()`
|
||||||
|
- `tests/messages.rs` — 5 snapshot-тестов для альбомов
|
||||||
|
- `tests/input_navigation.rs` — 3 теста навигации по альбомам
|
||||||
|
|
||||||
|
**Что НЕ меняется:** image modal (v), auto-download, одиночные фото.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Оптимизация: Ленивая загрузка сообщений при открытии чата (DONE)
|
### Оптимизация: Ленивая загрузка сообщений при открытии чата (DONE)
|
||||||
|
|
||||||
Чат открывается мгновенно (< 1 сек) вместо 5-30 сек для больших чатов.
|
Чат открывается мгновенно (< 1 сек) вместо 5-30 сек для больших чатов.
|
||||||
|
|||||||
@@ -35,18 +35,50 @@ pub trait MessageMethods<T: TdClientTrait> {
|
|||||||
|
|
||||||
impl<T: TdClientTrait> MessageMethods<T> for App<T> {
|
impl<T: TdClientTrait> MessageMethods<T> for App<T> {
|
||||||
fn start_message_selection(&mut self) {
|
fn start_message_selection(&mut self) {
|
||||||
let total = self.td_client.current_chat_messages().len();
|
let messages = self.td_client.current_chat_messages();
|
||||||
|
let total = messages.len();
|
||||||
if total == 0 {
|
if total == 0 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Начинаем с последнего сообщения (индекс len-1 = самое новое внизу)
|
// Начинаем с последнего сообщения (индекс len-1 = самое новое внизу)
|
||||||
self.chat_state = ChatState::MessageSelection { selected_index: total - 1 };
|
// Если оно часть альбома — перемещаемся к первому элементу альбома
|
||||||
|
let mut idx = total - 1;
|
||||||
|
let album_id = messages[idx].media_album_id();
|
||||||
|
if album_id != 0 {
|
||||||
|
while idx > 0 && messages[idx - 1].media_album_id() == album_id {
|
||||||
|
idx -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.chat_state = ChatState::MessageSelection { selected_index: idx };
|
||||||
}
|
}
|
||||||
|
|
||||||
fn select_previous_message(&mut self) {
|
fn select_previous_message(&mut self) {
|
||||||
if let ChatState::MessageSelection { selected_index } = &mut self.chat_state {
|
if let ChatState::MessageSelection { selected_index } = &mut self.chat_state {
|
||||||
if *selected_index > 0 {
|
if *selected_index > 0 {
|
||||||
*selected_index -= 1;
|
let messages = self.td_client.current_chat_messages();
|
||||||
|
let current_album_id = messages[*selected_index].media_album_id();
|
||||||
|
|
||||||
|
// Перескакиваем через все сообщения текущего альбома назад
|
||||||
|
let mut new_index = *selected_index - 1;
|
||||||
|
if current_album_id != 0 {
|
||||||
|
while new_index > 0
|
||||||
|
&& messages[new_index].media_album_id() == current_album_id
|
||||||
|
{
|
||||||
|
new_index -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если попали в середину другого альбома — перемещаемся к его первому элементу
|
||||||
|
let target_album_id = messages[new_index].media_album_id();
|
||||||
|
if target_album_id != 0 {
|
||||||
|
while new_index > 0
|
||||||
|
&& messages[new_index - 1].media_album_id() == target_album_id
|
||||||
|
{
|
||||||
|
new_index -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*selected_index = new_index;
|
||||||
self.stop_playback();
|
self.stop_playback();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,7 +91,30 @@ impl<T: TdClientTrait> MessageMethods<T> for App<T> {
|
|||||||
}
|
}
|
||||||
if let ChatState::MessageSelection { selected_index } = &mut self.chat_state {
|
if let ChatState::MessageSelection { selected_index } = &mut self.chat_state {
|
||||||
if *selected_index < total - 1 {
|
if *selected_index < total - 1 {
|
||||||
*selected_index += 1;
|
let messages = self.td_client.current_chat_messages();
|
||||||
|
let current_album_id = messages[*selected_index].media_album_id();
|
||||||
|
|
||||||
|
// Перескакиваем через все сообщения текущего альбома вперёд
|
||||||
|
let mut new_index = *selected_index + 1;
|
||||||
|
if current_album_id != 0 {
|
||||||
|
while new_index < total - 1
|
||||||
|
&& messages[new_index].media_album_id() == current_album_id
|
||||||
|
{
|
||||||
|
new_index += 1;
|
||||||
|
}
|
||||||
|
// Если мы ещё на последнем элементе альбома — нужно шагнуть на следующее
|
||||||
|
if messages[new_index].media_album_id() == current_album_id
|
||||||
|
&& new_index < total - 1
|
||||||
|
{
|
||||||
|
new_index += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if new_index >= total {
|
||||||
|
self.chat_state = ChatState::Normal;
|
||||||
|
} else {
|
||||||
|
*selected_index = new_index;
|
||||||
|
}
|
||||||
self.stop_playback();
|
self.stop_playback();
|
||||||
} else {
|
} else {
|
||||||
// Дошли до самого нового сообщения - выходим из режима выбора
|
// Дошли до самого нового сообщения - выходим из режима выбора
|
||||||
|
|||||||
@@ -83,6 +83,11 @@ impl<T: TdClientTrait> NavigationMethods<T> for App<T> {
|
|||||||
self.message_scroll_offset = 0;
|
self.message_scroll_offset = 0;
|
||||||
self.last_typing_sent = None;
|
self.last_typing_sent = None;
|
||||||
self.pending_chat_init = None;
|
self.pending_chat_init = None;
|
||||||
|
// Останавливаем фоновую загрузку фото (drop receiver)
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
{
|
||||||
|
self.photo_download_rx = None;
|
||||||
|
}
|
||||||
// Сбрасываем состояние чата в нормальный режим
|
// Сбрасываем состояние чата в нормальный режим
|
||||||
self.chat_state = ChatState::Normal;
|
self.chat_state = ChatState::Normal;
|
||||||
self.input_mode = InputMode::Normal;
|
self.input_mode = InputMode::Normal;
|
||||||
|
|||||||
@@ -128,8 +128,12 @@ pub struct App<T: TdClientTrait = TdClient> {
|
|||||||
pub current_account_name: String,
|
pub current_account_name: String,
|
||||||
/// Pending account switch: (account_name, db_path)
|
/// Pending account switch: (account_name, db_path)
|
||||||
pub pending_account_switch: Option<(String, PathBuf)>,
|
pub pending_account_switch: Option<(String, PathBuf)>,
|
||||||
/// Pending background chat init (reply info, pinned, photos) after fast open
|
/// Pending background chat init (reply info, pinned) after fast open
|
||||||
pub pending_chat_init: Option<ChatId>,
|
pub pending_chat_init: Option<ChatId>,
|
||||||
|
/// Receiver for background photo downloads (file_id, result path)
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
pub photo_download_rx:
|
||||||
|
Option<tokio::sync::mpsc::UnboundedReceiver<(i32, Result<String, String>)>>,
|
||||||
// Voice playback
|
// Voice playback
|
||||||
/// Аудиопроигрыватель для голосовых сообщений (rodio)
|
/// Аудиопроигрыватель для голосовых сообщений (rodio)
|
||||||
pub audio_player: Option<crate::audio::AudioPlayer>,
|
pub audio_player: Option<crate::audio::AudioPlayer>,
|
||||||
@@ -198,6 +202,8 @@ impl<T: TdClientTrait> App<T> {
|
|||||||
pending_account_switch: None,
|
pending_account_switch: None,
|
||||||
pending_chat_init: None,
|
pending_chat_init: None,
|
||||||
#[cfg(feature = "images")]
|
#[cfg(feature = "images")]
|
||||||
|
photo_download_rx: None,
|
||||||
|
#[cfg(feature = "images")]
|
||||||
image_cache,
|
image_cache,
|
||||||
#[cfg(feature = "images")]
|
#[cfg(feature = "images")]
|
||||||
inline_image_renderer,
|
inline_image_renderer,
|
||||||
|
|||||||
@@ -59,6 +59,22 @@ pub const DEFAULT_IMAGE_CACHE_SIZE_MB: u64 = 500;
|
|||||||
#[cfg(feature = "images")]
|
#[cfg(feature = "images")]
|
||||||
pub const INLINE_IMAGE_MAX_WIDTH: usize = 50;
|
pub const INLINE_IMAGE_MAX_WIDTH: usize = 50;
|
||||||
|
|
||||||
|
/// Ширина одного фото в альбоме (в символах)
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
pub const ALBUM_PHOTO_WIDTH: u16 = 16;
|
||||||
|
|
||||||
|
/// Высота одного фото в альбоме (в строках)
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
pub const ALBUM_PHOTO_HEIGHT: u16 = 8;
|
||||||
|
|
||||||
|
/// Отступ между фото в альбоме (в символах)
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
pub const ALBUM_PHOTO_GAP: u16 = 1;
|
||||||
|
|
||||||
|
/// Максимальное количество фото в одном ряду альбома
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
pub const ALBUM_GRID_MAX_COLS: usize = 3;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Audio
|
// Audio
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
93
src/main.rs
93
src/main.rs
@@ -182,6 +182,34 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
|
|||||||
app.needs_redraw = true;
|
app.needs_redraw = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Обрабатываем результаты фоновой загрузки фото
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
{
|
||||||
|
use crate::tdlib::PhotoDownloadState;
|
||||||
|
|
||||||
|
let mut got_photos = false;
|
||||||
|
if let Some(ref mut rx) = app.photo_download_rx {
|
||||||
|
while let Ok((file_id, result)) = rx.try_recv() {
|
||||||
|
let new_state = match result {
|
||||||
|
Ok(path) => PhotoDownloadState::Downloaded(path),
|
||||||
|
Err(_) => PhotoDownloadState::Error("Ошибка загрузки".to_string()),
|
||||||
|
};
|
||||||
|
for msg in app.td_client.current_chat_messages_mut() {
|
||||||
|
if let Some(photo) = msg.photo_info_mut() {
|
||||||
|
if photo.file_id == file_id {
|
||||||
|
photo.download_state = new_state;
|
||||||
|
got_photos = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got_photos {
|
||||||
|
app.needs_redraw = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Очищаем устаревший typing status
|
// Очищаем устаревший typing status
|
||||||
if app.td_client.clear_stale_typing_status() {
|
if app.td_client.clear_stale_typing_status() {
|
||||||
app.needs_redraw = true;
|
app.needs_redraw = true;
|
||||||
@@ -315,7 +343,7 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
// Авто-загрузка фото (последние 30 сообщений)
|
// Авто-загрузка фото — неблокирующая фоновая задача (до 5 фото параллельно)
|
||||||
#[cfg(feature = "images")]
|
#[cfg(feature = "images")]
|
||||||
{
|
{
|
||||||
use crate::tdlib::PhotoDownloadState;
|
use crate::tdlib::PhotoDownloadState;
|
||||||
@@ -326,7 +354,7 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
|
|||||||
.current_chat_messages()
|
.current_chat_messages()
|
||||||
.iter()
|
.iter()
|
||||||
.rev()
|
.rev()
|
||||||
.take(30)
|
.take(5)
|
||||||
.filter_map(|msg| {
|
.filter_map(|msg| {
|
||||||
msg.photo_info().and_then(|p| {
|
msg.photo_info().and_then(|p| {
|
||||||
matches!(p.download_state, PhotoDownloadState::NotDownloaded)
|
matches!(p.download_state, PhotoDownloadState::NotDownloaded)
|
||||||
@@ -335,22 +363,42 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
for file_id in &photo_file_ids {
|
if !photo_file_ids.is_empty() {
|
||||||
if let Ok(Ok(path)) = tokio::time::timeout(
|
let client_id = app.td_client.client_id();
|
||||||
Duration::from_secs(5),
|
let (tx, rx) =
|
||||||
app.td_client.download_file(*file_id),
|
tokio::sync::mpsc::unbounded_channel::<(i32, Result<String, String>)>();
|
||||||
)
|
app.photo_download_rx = Some(rx);
|
||||||
.await
|
|
||||||
{
|
for file_id in photo_file_ids {
|
||||||
for msg in app.td_client.current_chat_messages_mut() {
|
let tx = tx.clone();
|
||||||
if let Some(photo) = msg.photo_info_mut() {
|
tokio::spawn(async move {
|
||||||
if photo.file_id == *file_id {
|
let result = tokio::time::timeout(
|
||||||
photo.download_state =
|
Duration::from_secs(5),
|
||||||
PhotoDownloadState::Downloaded(path);
|
async {
|
||||||
break;
|
match tdlib_rs::functions::download_file(
|
||||||
}
|
file_id, 1, 0, 0, true, client_id,
|
||||||
}
|
)
|
||||||
}
|
.await
|
||||||
|
{
|
||||||
|
Ok(tdlib_rs::enums::File::File(file))
|
||||||
|
if file.local.is_downloading_completed
|
||||||
|
&& !file.local.path.is_empty() =>
|
||||||
|
{
|
||||||
|
Ok(file.local.path)
|
||||||
|
}
|
||||||
|
Ok(_) => Err("Файл не скачан".to_string()),
|
||||||
|
Err(e) => Err(format!("{:?}", e)),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let result = match result {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(_) => Err("Таймаут загрузки".to_string()),
|
||||||
|
};
|
||||||
|
let _ = tx.send((file_id, result));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -371,8 +419,15 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. Reset app state
|
// 3. Reset app state
|
||||||
app.current_account_name = account_name;
|
app.current_account_name = account_name.clone();
|
||||||
app.screen = AppScreen::Loading;
|
app.screen = AppScreen::Loading;
|
||||||
|
|
||||||
|
// 4. Persist selected account as default for next launch
|
||||||
|
let mut accounts_config = accounts::load_or_create();
|
||||||
|
accounts_config.default_account = account_name;
|
||||||
|
if let Err(e) = accounts::save(&accounts_config) {
|
||||||
|
tracing::warn!("Could not save default account: {}", e);
|
||||||
|
}
|
||||||
app.chats.clear();
|
app.chats.clear();
|
||||||
app.selected_chat_id = None;
|
app.selected_chat_id = None;
|
||||||
app.chat_state = Default::default();
|
app.chat_state = Default::default();
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ pub enum MessageGroup {
|
|||||||
SenderHeader { is_outgoing: bool, sender_name: String },
|
SenderHeader { is_outgoing: bool, sender_name: String },
|
||||||
/// Сообщение
|
/// Сообщение
|
||||||
Message(MessageInfo),
|
Message(MessageInfo),
|
||||||
|
/// Альбом (группа фото с одинаковым media_album_id)
|
||||||
|
Album(Vec<MessageInfo>),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Группирует сообщения по дате и отправителю
|
/// Группирует сообщения по дате и отправителю
|
||||||
@@ -51,6 +53,10 @@ pub enum MessageGroup {
|
|||||||
/// // Рендерим сообщение
|
/// // Рендерим сообщение
|
||||||
/// println!("{}", msg.text());
|
/// println!("{}", msg.text());
|
||||||
/// }
|
/// }
|
||||||
|
/// MessageGroup::Album(messages) => {
|
||||||
|
/// // Рендерим альбом (группу фото)
|
||||||
|
/// println!("Album with {} photos", messages.len());
|
||||||
|
/// }
|
||||||
/// }
|
/// }
|
||||||
/// }
|
/// }
|
||||||
/// ```
|
/// ```
|
||||||
@@ -58,12 +64,28 @@ pub fn group_messages(messages: &[MessageInfo]) -> Vec<MessageGroup> {
|
|||||||
let mut result = Vec::new();
|
let mut result = Vec::new();
|
||||||
let mut last_day: Option<i64> = None;
|
let mut last_day: Option<i64> = None;
|
||||||
let mut last_sender: Option<(bool, String)> = None; // (is_outgoing, sender_name)
|
let mut last_sender: Option<(bool, String)> = None; // (is_outgoing, sender_name)
|
||||||
|
let mut album_acc: Vec<MessageInfo> = Vec::new();
|
||||||
|
|
||||||
|
/// Сбрасывает аккумулятор альбома в результат
|
||||||
|
fn flush_album(acc: &mut Vec<MessageInfo>, result: &mut Vec<MessageGroup>) {
|
||||||
|
if acc.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if acc.len() >= 2 {
|
||||||
|
result.push(MessageGroup::Album(std::mem::take(acc)));
|
||||||
|
} else {
|
||||||
|
// Одно сообщение — не альбом
|
||||||
|
result.push(MessageGroup::Message(acc.remove(0)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for msg in messages {
|
for msg in messages {
|
||||||
// Проверяем, нужно ли добавить разделитель даты
|
// Проверяем, нужно ли добавить разделитель даты
|
||||||
let msg_day = get_day(msg.date());
|
let msg_day = get_day(msg.date());
|
||||||
|
|
||||||
if last_day != Some(msg_day) {
|
if last_day != Some(msg_day) {
|
||||||
|
// Flush аккумулятор перед разделителем даты
|
||||||
|
flush_album(&mut album_acc, &mut result);
|
||||||
// Добавляем разделитель даты
|
// Добавляем разделитель даты
|
||||||
result.push(MessageGroup::DateSeparator(msg.date()));
|
result.push(MessageGroup::DateSeparator(msg.date()));
|
||||||
last_day = Some(msg_day);
|
last_day = Some(msg_day);
|
||||||
@@ -82,6 +104,8 @@ pub fn group_messages(messages: &[MessageInfo]) -> Vec<MessageGroup> {
|
|||||||
let show_sender_header = last_sender.as_ref() != Some(¤t_sender);
|
let show_sender_header = last_sender.as_ref() != Some(¤t_sender);
|
||||||
|
|
||||||
if show_sender_header {
|
if show_sender_header {
|
||||||
|
// Flush аккумулятор перед сменой отправителя
|
||||||
|
flush_album(&mut album_acc, &mut result);
|
||||||
result.push(MessageGroup::SenderHeader {
|
result.push(MessageGroup::SenderHeader {
|
||||||
is_outgoing: msg.is_outgoing(),
|
is_outgoing: msg.is_outgoing(),
|
||||||
sender_name,
|
sender_name,
|
||||||
@@ -89,10 +113,36 @@ pub fn group_messages(messages: &[MessageInfo]) -> Vec<MessageGroup> {
|
|||||||
last_sender = Some(current_sender);
|
last_sender = Some(current_sender);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Добавляем само сообщение
|
// Проверяем, является ли сообщение частью альбома
|
||||||
|
let album_id = msg.media_album_id();
|
||||||
|
if album_id != 0 {
|
||||||
|
// Проверяем, совпадает ли album_id с текущим аккумулятором
|
||||||
|
if let Some(first) = album_acc.first() {
|
||||||
|
if first.media_album_id() == album_id {
|
||||||
|
// Тот же альбом — добавляем
|
||||||
|
album_acc.push(msg.clone());
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
// Другой альбом — flush старый, начинаем новый
|
||||||
|
flush_album(&mut album_acc, &mut result);
|
||||||
|
album_acc.push(msg.clone());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Аккумулятор пуст — начинаем новый альбом
|
||||||
|
album_acc.push(msg.clone());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обычное сообщение (не альбом) — flush аккумулятор
|
||||||
|
flush_album(&mut album_acc, &mut result);
|
||||||
result.push(MessageGroup::Message(msg.clone()));
|
result.push(MessageGroup::Message(msg.clone()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Flush оставшийся аккумулятор
|
||||||
|
flush_album(&mut album_acc, &mut result);
|
||||||
|
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,4 +296,152 @@ mod tests {
|
|||||||
assert!(matches!(grouped[1], MessageGroup::SenderHeader { .. }));
|
assert!(matches!(grouped[1], MessageGroup::SenderHeader { .. }));
|
||||||
assert!(matches!(grouped[2], MessageGroup::Message(_)));
|
assert!(matches!(grouped[2], MessageGroup::Message(_)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_album_grouping_two_photos() {
|
||||||
|
let msg1 = MessageBuilder::new(MessageId::new(1))
|
||||||
|
.sender_name("Alice")
|
||||||
|
.text("Photo 1")
|
||||||
|
.date(1609459200)
|
||||||
|
.incoming()
|
||||||
|
.media_album_id(12345)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let msg2 = MessageBuilder::new(MessageId::new(2))
|
||||||
|
.sender_name("Alice")
|
||||||
|
.text("Photo 2")
|
||||||
|
.date(1609459201)
|
||||||
|
.incoming()
|
||||||
|
.media_album_id(12345)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let messages = vec![msg1, msg2];
|
||||||
|
let grouped = group_messages(&messages);
|
||||||
|
|
||||||
|
// DateSep, SenderHeader, Album
|
||||||
|
assert_eq!(grouped.len(), 3);
|
||||||
|
assert!(matches!(grouped[0], MessageGroup::DateSeparator(_)));
|
||||||
|
assert!(matches!(grouped[1], MessageGroup::SenderHeader { .. }));
|
||||||
|
if let MessageGroup::Album(album) = &grouped[2] {
|
||||||
|
assert_eq!(album.len(), 2);
|
||||||
|
assert_eq!(album[0].id(), MessageId::new(1));
|
||||||
|
assert_eq!(album[1].id(), MessageId::new(2));
|
||||||
|
} else {
|
||||||
|
panic!("Expected Album, got {:?}", grouped[2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_album_single_photo_not_album() {
|
||||||
|
// Одно сообщение с album_id → не альбом, обычное сообщение
|
||||||
|
let msg = MessageBuilder::new(MessageId::new(1))
|
||||||
|
.sender_name("Alice")
|
||||||
|
.text("Single photo")
|
||||||
|
.date(1609459200)
|
||||||
|
.incoming()
|
||||||
|
.media_album_id(12345)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let messages = vec![msg];
|
||||||
|
let grouped = group_messages(&messages);
|
||||||
|
|
||||||
|
// DateSep, SenderHeader, Message (не Album)
|
||||||
|
assert_eq!(grouped.len(), 3);
|
||||||
|
assert!(matches!(grouped[2], MessageGroup::Message(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_album_with_regular_messages() {
|
||||||
|
let msg1 = MessageBuilder::new(MessageId::new(1))
|
||||||
|
.sender_name("Alice")
|
||||||
|
.text("Text message")
|
||||||
|
.date(1609459200)
|
||||||
|
.incoming()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let msg2 = MessageBuilder::new(MessageId::new(2))
|
||||||
|
.sender_name("Alice")
|
||||||
|
.text("Photo 1")
|
||||||
|
.date(1609459201)
|
||||||
|
.incoming()
|
||||||
|
.media_album_id(100)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let msg3 = MessageBuilder::new(MessageId::new(3))
|
||||||
|
.sender_name("Alice")
|
||||||
|
.text("Photo 2")
|
||||||
|
.date(1609459202)
|
||||||
|
.incoming()
|
||||||
|
.media_album_id(100)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let msg4 = MessageBuilder::new(MessageId::new(4))
|
||||||
|
.sender_name("Alice")
|
||||||
|
.text("After album")
|
||||||
|
.date(1609459203)
|
||||||
|
.incoming()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let messages = vec![msg1, msg2, msg3, msg4];
|
||||||
|
let grouped = group_messages(&messages);
|
||||||
|
|
||||||
|
// DateSep, SenderHeader, Message, Album, Message
|
||||||
|
assert_eq!(grouped.len(), 5);
|
||||||
|
assert!(matches!(grouped[2], MessageGroup::Message(_)));
|
||||||
|
assert!(matches!(grouped[3], MessageGroup::Album(_)));
|
||||||
|
assert!(matches!(grouped[4], MessageGroup::Message(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_two_different_albums() {
|
||||||
|
let msg1 = MessageBuilder::new(MessageId::new(1))
|
||||||
|
.sender_name("Alice")
|
||||||
|
.text("Album 1 - Photo 1")
|
||||||
|
.date(1609459200)
|
||||||
|
.incoming()
|
||||||
|
.media_album_id(100)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let msg2 = MessageBuilder::new(MessageId::new(2))
|
||||||
|
.sender_name("Alice")
|
||||||
|
.text("Album 1 - Photo 2")
|
||||||
|
.date(1609459201)
|
||||||
|
.incoming()
|
||||||
|
.media_album_id(100)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let msg3 = MessageBuilder::new(MessageId::new(3))
|
||||||
|
.sender_name("Alice")
|
||||||
|
.text("Album 2 - Photo 1")
|
||||||
|
.date(1609459202)
|
||||||
|
.incoming()
|
||||||
|
.media_album_id(200)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let msg4 = MessageBuilder::new(MessageId::new(4))
|
||||||
|
.sender_name("Alice")
|
||||||
|
.text("Album 2 - Photo 2")
|
||||||
|
.date(1609459203)
|
||||||
|
.incoming()
|
||||||
|
.media_album_id(200)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let messages = vec![msg1, msg2, msg3, msg4];
|
||||||
|
let grouped = group_messages(&messages);
|
||||||
|
|
||||||
|
// DateSep, SenderHeader, Album(2), Album(2)
|
||||||
|
assert_eq!(grouped.len(), 4);
|
||||||
|
if let MessageGroup::Album(a1) = &grouped[2] {
|
||||||
|
assert_eq!(a1.len(), 2);
|
||||||
|
assert_eq!(a1[0].media_album_id(), 100);
|
||||||
|
} else {
|
||||||
|
panic!("Expected first Album");
|
||||||
|
}
|
||||||
|
if let MessageGroup::Album(a2) = &grouped[3] {
|
||||||
|
assert_eq!(a2.len(), 2);
|
||||||
|
assert_eq!(a2[0].media_album_id(), 200);
|
||||||
|
} else {
|
||||||
|
panic!("Expected second Album");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,7 +76,8 @@ pub fn convert_message(
|
|||||||
.text(content)
|
.text(content)
|
||||||
.entities(entities)
|
.entities(entities)
|
||||||
.date(message.date)
|
.date(message.date)
|
||||||
.edit_date(message.edit_date);
|
.edit_date(message.edit_date)
|
||||||
|
.media_album_id(message.media_album_id);
|
||||||
|
|
||||||
// Применяем флаги
|
// Применяем флаги
|
||||||
if message.is_outgoing {
|
if message.is_outgoing {
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ impl MessageManager {
|
|||||||
.text(content_text)
|
.text(content_text)
|
||||||
.entities(entities)
|
.entities(entities)
|
||||||
.date(msg.date)
|
.date(msg.date)
|
||||||
.edit_date(msg.edit_date);
|
.edit_date(msg.edit_date)
|
||||||
|
.media_album_id(msg.media_album_id);
|
||||||
|
|
||||||
if msg.is_outgoing {
|
if msg.is_outgoing {
|
||||||
builder = builder.outgoing();
|
builder = builder.outgoing();
|
||||||
|
|||||||
@@ -107,6 +107,8 @@ pub struct MessageMetadata {
|
|||||||
pub date: i32,
|
pub date: i32,
|
||||||
/// Дата редактирования (0 если не редактировалось)
|
/// Дата редактирования (0 если не редактировалось)
|
||||||
pub edit_date: i32,
|
pub edit_date: i32,
|
||||||
|
/// ID медиа-альбома (0 если не часть альбома)
|
||||||
|
pub media_album_id: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Контент сообщения (текст и форматирование)
|
/// Контент сообщения (текст и форматирование)
|
||||||
@@ -175,6 +177,7 @@ impl MessageInfo {
|
|||||||
sender_name,
|
sender_name,
|
||||||
date,
|
date,
|
||||||
edit_date,
|
edit_date,
|
||||||
|
media_album_id: 0,
|
||||||
},
|
},
|
||||||
content: MessageContent {
|
content: MessageContent {
|
||||||
text: content,
|
text: content,
|
||||||
@@ -213,6 +216,10 @@ impl MessageInfo {
|
|||||||
self.metadata.edit_date > 0
|
self.metadata.edit_date > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn media_album_id(&self) -> i64 {
|
||||||
|
self.metadata.media_album_id
|
||||||
|
}
|
||||||
|
|
||||||
pub fn text(&self) -> &str {
|
pub fn text(&self) -> &str {
|
||||||
&self.content.text
|
&self.content.text
|
||||||
}
|
}
|
||||||
@@ -337,6 +344,7 @@ pub struct MessageBuilder {
|
|||||||
forward_from: Option<ForwardInfo>,
|
forward_from: Option<ForwardInfo>,
|
||||||
reactions: Vec<ReactionInfo>,
|
reactions: Vec<ReactionInfo>,
|
||||||
media: Option<MediaInfo>,
|
media: Option<MediaInfo>,
|
||||||
|
media_album_id: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MessageBuilder {
|
impl MessageBuilder {
|
||||||
@@ -358,6 +366,7 @@ impl MessageBuilder {
|
|||||||
forward_from: None,
|
forward_from: None,
|
||||||
reactions: Vec::new(),
|
reactions: Vec::new(),
|
||||||
media: None,
|
media: None,
|
||||||
|
media_album_id: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -461,6 +470,12 @@ impl MessageBuilder {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Установить ID медиа-альбома
|
||||||
|
pub fn media_album_id(mut self, id: i64) -> Self {
|
||||||
|
self.media_album_id = id;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Построить MessageInfo из данных builder'а
|
/// Построить MessageInfo из данных builder'а
|
||||||
pub fn build(self) -> MessageInfo {
|
pub fn build(self) -> MessageInfo {
|
||||||
let mut msg = MessageInfo::new(
|
let mut msg = MessageInfo::new(
|
||||||
@@ -480,6 +495,7 @@ impl MessageBuilder {
|
|||||||
self.reactions,
|
self.reactions,
|
||||||
);
|
);
|
||||||
msg.content.media = self.media;
|
msg.content.media = self.media;
|
||||||
|
msg.metadata.media_album_id = self.media_album_id;
|
||||||
msg
|
msg
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -524,10 +524,197 @@ pub struct DeferredImageRender {
|
|||||||
pub photo_path: String,
|
pub photo_path: String,
|
||||||
/// Смещение в строках от начала всего списка сообщений
|
/// Смещение в строках от начала всего списка сообщений
|
||||||
pub line_offset: usize,
|
pub line_offset: usize,
|
||||||
|
/// Горизонтальное смещение от левого края контента (для сетки альбомов)
|
||||||
|
pub x_offset: u16,
|
||||||
pub width: u16,
|
pub width: u16,
|
||||||
pub height: u16,
|
pub height: u16,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Рендерит bubble для альбома (группы фото с общим media_album_id)
|
||||||
|
///
|
||||||
|
/// Фото отображаются в сетке (до 3 в ряд), с общей подписью и timestamp.
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
pub fn render_album_bubble(
|
||||||
|
messages: &[MessageInfo],
|
||||||
|
config: &Config,
|
||||||
|
content_width: usize,
|
||||||
|
selected_msg_id: Option<MessageId>,
|
||||||
|
) -> (Vec<Line<'static>>, Vec<DeferredImageRender>) {
|
||||||
|
use crate::constants::{ALBUM_GRID_MAX_COLS, ALBUM_PHOTO_GAP, ALBUM_PHOTO_HEIGHT, ALBUM_PHOTO_WIDTH};
|
||||||
|
|
||||||
|
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||||
|
let mut deferred: Vec<DeferredImageRender> = Vec::new();
|
||||||
|
|
||||||
|
let is_selected = messages.iter().any(|m| selected_msg_id == Some(m.id()));
|
||||||
|
let is_outgoing = messages.first().map_or(false, |m| m.is_outgoing());
|
||||||
|
|
||||||
|
// Selection marker
|
||||||
|
let selection_marker = if is_selected { "▶ " } else { "" };
|
||||||
|
|
||||||
|
// Фильтруем фото
|
||||||
|
let photos: Vec<&MessageInfo> = messages.iter().filter(|m| m.has_photo()).collect();
|
||||||
|
let photo_count = photos.len();
|
||||||
|
|
||||||
|
if photo_count == 0 {
|
||||||
|
// Нет фото — рендерим как обычные сообщения
|
||||||
|
for msg in messages {
|
||||||
|
lines.extend(render_message_bubble(msg, config, content_width, selected_msg_id, None));
|
||||||
|
}
|
||||||
|
return (lines, deferred);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grid layout
|
||||||
|
let cols = photo_count.min(ALBUM_GRID_MAX_COLS);
|
||||||
|
let rows = (photo_count + cols - 1) / cols;
|
||||||
|
|
||||||
|
// Добавляем маркер выбора на первую строку
|
||||||
|
if is_selected {
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
selection_marker,
|
||||||
|
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
let grid_start_line = lines.len();
|
||||||
|
|
||||||
|
// Генерируем placeholder-строки для сетки
|
||||||
|
for row in 0..rows {
|
||||||
|
for line_in_row in 0..ALBUM_PHOTO_HEIGHT {
|
||||||
|
let mut spans = Vec::new();
|
||||||
|
|
||||||
|
// Для исходящих — добавляем отступ справа
|
||||||
|
if is_outgoing {
|
||||||
|
let grid_width = cols as u16 * ALBUM_PHOTO_WIDTH
|
||||||
|
+ (cols as u16).saturating_sub(1) * ALBUM_PHOTO_GAP;
|
||||||
|
let padding = content_width.saturating_sub(grid_width as usize + 1);
|
||||||
|
spans.push(Span::raw(" ".repeat(padding)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Для каждого столбца в этом ряду
|
||||||
|
for col in 0..cols {
|
||||||
|
let photo_idx = row * cols + col;
|
||||||
|
if photo_idx >= photo_count {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let msg = photos[photo_idx];
|
||||||
|
if let Some(photo) = msg.photo_info() {
|
||||||
|
match &photo.download_state {
|
||||||
|
PhotoDownloadState::Downloaded(path) => {
|
||||||
|
if line_in_row == 0 {
|
||||||
|
// Регистрируем deferred render для этого фото
|
||||||
|
let x_off = if is_outgoing {
|
||||||
|
let grid_width = cols as u16 * ALBUM_PHOTO_WIDTH
|
||||||
|
+ (cols as u16).saturating_sub(1) * ALBUM_PHOTO_GAP;
|
||||||
|
let padding = content_width.saturating_sub(grid_width as usize + 1) as u16;
|
||||||
|
padding + col as u16 * (ALBUM_PHOTO_WIDTH + ALBUM_PHOTO_GAP)
|
||||||
|
} else {
|
||||||
|
col as u16 * (ALBUM_PHOTO_WIDTH + ALBUM_PHOTO_GAP)
|
||||||
|
};
|
||||||
|
|
||||||
|
deferred.push(DeferredImageRender {
|
||||||
|
message_id: msg.id(),
|
||||||
|
photo_path: path.clone(),
|
||||||
|
line_offset: grid_start_line + row * ALBUM_PHOTO_HEIGHT as usize,
|
||||||
|
x_offset: x_off,
|
||||||
|
width: ALBUM_PHOTO_WIDTH,
|
||||||
|
height: ALBUM_PHOTO_HEIGHT,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Пустая строка — placeholder для изображения
|
||||||
|
}
|
||||||
|
PhotoDownloadState::Downloading => {
|
||||||
|
if line_in_row == ALBUM_PHOTO_HEIGHT / 2 {
|
||||||
|
spans.push(Span::styled(
|
||||||
|
"⏳ Загрузка...",
|
||||||
|
Style::default().fg(Color::Yellow),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PhotoDownloadState::Error(e) => {
|
||||||
|
if line_in_row == ALBUM_PHOTO_HEIGHT / 2 {
|
||||||
|
let err_text: String = e.chars().take(14).collect();
|
||||||
|
spans.push(Span::styled(
|
||||||
|
format!("❌ {}", err_text),
|
||||||
|
Style::default().fg(Color::Red),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PhotoDownloadState::NotDownloaded => {
|
||||||
|
if line_in_row == ALBUM_PHOTO_HEIGHT / 2 {
|
||||||
|
spans.push(Span::styled(
|
||||||
|
"📷",
|
||||||
|
Style::default().fg(Color::Gray),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(Line::from(spans));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caption: собираем непустые тексты (без "📷 [Фото]" prefix)
|
||||||
|
let captions: Vec<&str> = messages
|
||||||
|
.iter()
|
||||||
|
.map(|m| m.text())
|
||||||
|
.filter(|t| !t.is_empty() && !t.starts_with("📷"))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let msg_color = if is_selected {
|
||||||
|
config.parse_color(&config.colors.selected_message)
|
||||||
|
} else if is_outgoing {
|
||||||
|
config.parse_color(&config.colors.outgoing_message)
|
||||||
|
} else {
|
||||||
|
config.parse_color(&config.colors.incoming_message)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Timestamp из последнего сообщения
|
||||||
|
let last_msg = messages.last().unwrap();
|
||||||
|
let time = format_timestamp_with_tz(last_msg.date(), &config.general.timezone);
|
||||||
|
|
||||||
|
if !captions.is_empty() {
|
||||||
|
let caption_text = captions.join(" ");
|
||||||
|
let time_suffix = format!(" ({})", time);
|
||||||
|
|
||||||
|
if is_outgoing {
|
||||||
|
let total_len = caption_text.chars().count() + time_suffix.chars().count();
|
||||||
|
let padding = content_width.saturating_sub(total_len + 1);
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::raw(" ".repeat(padding)),
|
||||||
|
Span::styled(caption_text, Style::default().fg(msg_color)),
|
||||||
|
Span::styled(time_suffix, Style::default().fg(Color::Gray)),
|
||||||
|
]));
|
||||||
|
} else {
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(format!(" ({})", time), Style::default().fg(Color::Gray)),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(caption_text, Style::default().fg(msg_color)),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Без подписи — только timestamp
|
||||||
|
let time_text = format!("({})", time);
|
||||||
|
if is_outgoing {
|
||||||
|
let padding = content_width.saturating_sub(time_text.chars().count() + 1);
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::raw(" ".repeat(padding)),
|
||||||
|
Span::styled(time_text, Style::default().fg(Color::Gray)),
|
||||||
|
]));
|
||||||
|
} else {
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(format!(" {}", time_text), Style::default().fg(Color::Gray)),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(lines, deferred)
|
||||||
|
}
|
||||||
|
|
||||||
/// Вычисляет высоту изображения (в строках) с учётом пропорций
|
/// Вычисляет высоту изображения (в строках) с учётом пропорций
|
||||||
#[cfg(feature = "images")]
|
#[cfg(feature = "images")]
|
||||||
pub fn calculate_image_height(img_width: i32, img_height: i32, content_width: usize) -> u16 {
|
pub fn calculate_image_height(img_width: i32, img_height: i32, content_width: usize) -> u16 {
|
||||||
|
|||||||
@@ -13,5 +13,5 @@ pub use chat_list_item::render_chat_list_item;
|
|||||||
pub use emoji_picker::render_emoji_picker;
|
pub use emoji_picker::render_emoji_picker;
|
||||||
pub use message_bubble::{render_date_separator, render_message_bubble, render_sender_header};
|
pub use message_bubble::{render_date_separator, render_message_bubble, render_sender_header};
|
||||||
#[cfg(feature = "images")]
|
#[cfg(feature = "images")]
|
||||||
pub use message_bubble::{DeferredImageRender, calculate_image_height};
|
pub use message_bubble::{DeferredImageRender, calculate_image_height, render_album_bubble};
|
||||||
pub use message_list::{render_message_item, calculate_scroll_offset, render_help_bar};
|
pub use message_list::{render_message_item, calculate_scroll_offset, render_help_bar};
|
||||||
|
|||||||
@@ -251,6 +251,7 @@ fn render_message_list<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut Ap
|
|||||||
message_id: msg.id(),
|
message_id: msg.id(),
|
||||||
photo_path: path.clone(),
|
photo_path: path.clone(),
|
||||||
line_offset: placeholder_start,
|
line_offset: placeholder_start,
|
||||||
|
x_offset: 0,
|
||||||
width: img_width,
|
width: img_width,
|
||||||
height: img_height,
|
height: img_height,
|
||||||
});
|
});
|
||||||
@@ -259,6 +260,48 @@ fn render_message_list<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut Ap
|
|||||||
|
|
||||||
lines.extend(bubble_lines);
|
lines.extend(bubble_lines);
|
||||||
}
|
}
|
||||||
|
MessageGroup::Album(album_messages) => {
|
||||||
|
#[cfg(feature = "images")]
|
||||||
|
{
|
||||||
|
let is_selected = album_messages
|
||||||
|
.iter()
|
||||||
|
.any(|m| selected_msg_id == Some(m.id()));
|
||||||
|
if is_selected {
|
||||||
|
selected_msg_line = Some(lines.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
let (bubble_lines, album_deferred) = components::render_album_bubble(
|
||||||
|
&album_messages,
|
||||||
|
app.config(),
|
||||||
|
content_width,
|
||||||
|
selected_msg_id,
|
||||||
|
);
|
||||||
|
|
||||||
|
for mut d in album_deferred {
|
||||||
|
d.line_offset += lines.len();
|
||||||
|
deferred_images.push(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.extend(bubble_lines);
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "images"))]
|
||||||
|
{
|
||||||
|
// Fallback: рендерим каждое сообщение отдельно
|
||||||
|
for msg in &album_messages {
|
||||||
|
let is_selected = selected_msg_id == Some(msg.id());
|
||||||
|
if is_selected {
|
||||||
|
selected_msg_line = Some(lines.len());
|
||||||
|
}
|
||||||
|
lines.extend(components::render_message_bubble(
|
||||||
|
msg,
|
||||||
|
app.config(),
|
||||||
|
content_width,
|
||||||
|
selected_msg_id,
|
||||||
|
app.playback_state.as_ref(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,7 +377,7 @@ fn render_message_list<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut Ap
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Рендерим с ПОЛНОЙ высотой (не сжимаем)
|
// Рендерим с ПОЛНОЙ высотой (не сжимаем)
|
||||||
let img_rect = Rect::new(content_x, img_y, d.width, d.height);
|
let img_rect = Rect::new(content_x + d.x_offset, img_y, d.width, d.height);
|
||||||
|
|
||||||
// ОПТИМИЗАЦИЯ: Загружаем только видимые изображения (не все сразу)
|
// ОПТИМИЗАЦИЯ: Загружаем только видимые изображения (не все сразу)
|
||||||
// Используем inline_renderer с Halfblocks для скорости
|
// Используем inline_renderer с Halfblocks для скорости
|
||||||
|
|||||||
@@ -47,8 +47,15 @@ fn test_account_switcher_navigate_down() {
|
|||||||
let mut app = TestAppBuilder::new().build();
|
let mut app = TestAppBuilder::new().build();
|
||||||
app.open_account_switcher();
|
app.open_account_switcher();
|
||||||
|
|
||||||
// Initially at 0, navigate down to "Add account" item
|
let num_accounts = match &app.account_switcher {
|
||||||
app.account_switcher_select_next();
|
Some(AccountSwitcherState::SelectAccount { accounts, .. }) => accounts.len(),
|
||||||
|
_ => panic!("Expected SelectAccount state"),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Navigate down past all accounts to "Add account" item
|
||||||
|
for _ in 0..num_accounts {
|
||||||
|
app.account_switcher_select_next();
|
||||||
|
}
|
||||||
|
|
||||||
match &app.account_switcher {
|
match &app.account_switcher {
|
||||||
Some(AccountSwitcherState::SelectAccount {
|
Some(AccountSwitcherState::SelectAccount {
|
||||||
@@ -56,7 +63,7 @@ fn test_account_switcher_navigate_down() {
|
|||||||
accounts,
|
accounts,
|
||||||
..
|
..
|
||||||
}) => {
|
}) => {
|
||||||
// Should be at index 1 (the "Add account" item, since default config has 1 account)
|
// Should be at the "Add account" item (index == accounts.len())
|
||||||
assert_eq!(*selected_index, accounts.len());
|
assert_eq!(*selected_index, accounts.len());
|
||||||
}
|
}
|
||||||
_ => panic!("Expected SelectAccount state"),
|
_ => panic!("Expected SelectAccount state"),
|
||||||
@@ -116,8 +123,15 @@ fn test_confirm_add_account_transitions_to_add_state() {
|
|||||||
let mut app = TestAppBuilder::new().build();
|
let mut app = TestAppBuilder::new().build();
|
||||||
app.open_account_switcher();
|
app.open_account_switcher();
|
||||||
|
|
||||||
// Navigate to "+ Add account"
|
let num_accounts = match &app.account_switcher {
|
||||||
app.account_switcher_select_next();
|
Some(AccountSwitcherState::SelectAccount { accounts, .. }) => accounts.len(),
|
||||||
|
_ => panic!("Expected SelectAccount state"),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Navigate past all accounts to "+ Add account"
|
||||||
|
for _ in 0..num_accounts {
|
||||||
|
app.account_switcher_select_next();
|
||||||
|
}
|
||||||
|
|
||||||
// Confirm should transition to AddAccount
|
// Confirm should transition to AddAccount
|
||||||
app.account_switcher_confirm();
|
app.account_switcher_confirm();
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ pub struct TestMessageBuilder {
|
|||||||
reply_to: Option<ReplyInfo>,
|
reply_to: Option<ReplyInfo>,
|
||||||
forward_from: Option<ForwardInfo>,
|
forward_from: Option<ForwardInfo>,
|
||||||
reactions: Vec<ReactionInfo>,
|
reactions: Vec<ReactionInfo>,
|
||||||
|
media_album_id: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TestMessageBuilder {
|
impl TestMessageBuilder {
|
||||||
@@ -134,6 +135,7 @@ impl TestMessageBuilder {
|
|||||||
reply_to: None,
|
reply_to: None,
|
||||||
forward_from: None,
|
forward_from: None,
|
||||||
reactions: vec![],
|
reactions: vec![],
|
||||||
|
media_album_id: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,8 +189,13 @@ impl TestMessageBuilder {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn media_album_id(mut self, id: i64) -> Self {
|
||||||
|
self.media_album_id = id;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub fn build(self) -> MessageInfo {
|
pub fn build(self) -> MessageInfo {
|
||||||
MessageInfo::new(
|
let mut msg = MessageInfo::new(
|
||||||
MessageId::new(self.id),
|
MessageId::new(self.id),
|
||||||
self.sender_name,
|
self.sender_name,
|
||||||
self.is_outgoing,
|
self.is_outgoing,
|
||||||
@@ -203,7 +210,9 @@ impl TestMessageBuilder {
|
|||||||
self.reply_to,
|
self.reply_to,
|
||||||
self.forward_from,
|
self.forward_from,
|
||||||
self.reactions,
|
self.reactions,
|
||||||
)
|
);
|
||||||
|
msg.metadata.media_album_id = self.media_album_id;
|
||||||
|
msg
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -288,6 +288,137 @@ async fn test_normal_mode_auto_enters_message_selection() {
|
|||||||
assert!(app.is_selecting_message());
|
assert!(app.is_selecting_message());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Test: j/k перескакивают через альбом как одно сообщение
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_album_navigation_skips_grouped_messages() {
|
||||||
|
let messages = vec![
|
||||||
|
TestMessageBuilder::new("Before album", 1).sender("Alice").build(),
|
||||||
|
TestMessageBuilder::new("Photo 1", 2)
|
||||||
|
.sender("Alice")
|
||||||
|
.media_album_id(100)
|
||||||
|
.build(),
|
||||||
|
TestMessageBuilder::new("Photo 2", 3)
|
||||||
|
.sender("Alice")
|
||||||
|
.media_album_id(100)
|
||||||
|
.build(),
|
||||||
|
TestMessageBuilder::new("Photo 3", 4)
|
||||||
|
.sender("Alice")
|
||||||
|
.media_album_id(100)
|
||||||
|
.build(),
|
||||||
|
TestMessageBuilder::new("After album", 5).sender("Alice").build(),
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut app = TestAppBuilder::new()
|
||||||
|
.with_chats(vec![create_test_chat("Chat 1", 101)])
|
||||||
|
.selected_chat(101)
|
||||||
|
.with_messages(101, messages)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Входим в режим выбора — начинаем с последнего (index=4, "After album")
|
||||||
|
app.start_message_selection();
|
||||||
|
assert!(app.is_selecting_message());
|
||||||
|
|
||||||
|
let msg = app.get_selected_message().unwrap();
|
||||||
|
assert_eq!(msg.text(), "After album");
|
||||||
|
|
||||||
|
// k (up) — перескакиваем альбом, попадаем на первый элемент альбома (index=1)
|
||||||
|
app.select_previous_message();
|
||||||
|
let msg = app.get_selected_message().unwrap();
|
||||||
|
assert_eq!(msg.text(), "Photo 1");
|
||||||
|
assert_eq!(msg.media_album_id(), 100);
|
||||||
|
|
||||||
|
// k (up) — перескакиваем на сообщение до альбома (index=0)
|
||||||
|
app.select_previous_message();
|
||||||
|
let msg = app.get_selected_message().unwrap();
|
||||||
|
assert_eq!(msg.text(), "Before album");
|
||||||
|
|
||||||
|
// j (down) — перескакиваем на первый элемент альбома (index=1)
|
||||||
|
app.select_next_message();
|
||||||
|
let msg = app.get_selected_message().unwrap();
|
||||||
|
assert_eq!(msg.text(), "Photo 1");
|
||||||
|
|
||||||
|
// j (down) — перескакиваем альбом, попадаем на "After album" (index=4)
|
||||||
|
app.select_next_message();
|
||||||
|
let msg = app.get_selected_message().unwrap();
|
||||||
|
assert_eq!(msg.text(), "After album");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test: Начало выбора, когда последнее сообщение — часть альбома
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_album_navigation_start_at_album_end() {
|
||||||
|
let messages = vec![
|
||||||
|
TestMessageBuilder::new("Regular", 1).sender("Alice").build(),
|
||||||
|
TestMessageBuilder::new("Album Photo 1", 2)
|
||||||
|
.sender("Alice")
|
||||||
|
.media_album_id(200)
|
||||||
|
.build(),
|
||||||
|
TestMessageBuilder::new("Album Photo 2", 3)
|
||||||
|
.sender("Alice")
|
||||||
|
.media_album_id(200)
|
||||||
|
.build(),
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut app = TestAppBuilder::new()
|
||||||
|
.with_chats(vec![create_test_chat("Chat 1", 101)])
|
||||||
|
.selected_chat(101)
|
||||||
|
.with_messages(101, messages)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Входим в режим выбора — должны оказаться на первом элементе альбома (index=1)
|
||||||
|
app.start_message_selection();
|
||||||
|
let msg = app.get_selected_message().unwrap();
|
||||||
|
assert_eq!(msg.text(), "Album Photo 1");
|
||||||
|
|
||||||
|
// k (up) — на обычное сообщение
|
||||||
|
app.select_previous_message();
|
||||||
|
let msg = app.get_selected_message().unwrap();
|
||||||
|
assert_eq!(msg.text(), "Regular");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test: Два альбома подряд — навигация между ними
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_album_navigation_two_albums() {
|
||||||
|
let messages = vec![
|
||||||
|
TestMessageBuilder::new("A1-P1", 1)
|
||||||
|
.sender("Alice")
|
||||||
|
.media_album_id(100)
|
||||||
|
.build(),
|
||||||
|
TestMessageBuilder::new("A1-P2", 2)
|
||||||
|
.sender("Alice")
|
||||||
|
.media_album_id(100)
|
||||||
|
.build(),
|
||||||
|
TestMessageBuilder::new("A2-P1", 3)
|
||||||
|
.sender("Alice")
|
||||||
|
.media_album_id(200)
|
||||||
|
.build(),
|
||||||
|
TestMessageBuilder::new("A2-P2", 4)
|
||||||
|
.sender("Alice")
|
||||||
|
.media_album_id(200)
|
||||||
|
.build(),
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut app = TestAppBuilder::new()
|
||||||
|
.with_chats(vec![create_test_chat("Chat 1", 101)])
|
||||||
|
.selected_chat(101)
|
||||||
|
.with_messages(101, messages)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Начинаем — последний альбом (index=2, первый элемент album 200)
|
||||||
|
app.start_message_selection();
|
||||||
|
let msg = app.get_selected_message().unwrap();
|
||||||
|
assert_eq!(msg.text(), "A2-P1");
|
||||||
|
|
||||||
|
// k — перескакиваем на первый альбом (index=0)
|
||||||
|
app.select_previous_message();
|
||||||
|
let msg = app.get_selected_message().unwrap();
|
||||||
|
assert_eq!(msg.text(), "A1-P1");
|
||||||
|
|
||||||
|
// j — перескакиваем на второй альбом (index=2)
|
||||||
|
app.select_next_message();
|
||||||
|
let msg = app.get_selected_message().unwrap();
|
||||||
|
assert_eq!(msg.text(), "A2-P1");
|
||||||
|
}
|
||||||
|
|
||||||
/// Test: Циклическая навигация по списку чатов (переход с конца в начало)
|
/// Test: Циклическая навигация по списку чатов (переход с конца в начало)
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_circular_navigation_optional() {
|
async fn test_circular_navigation_optional() {
|
||||||
|
|||||||
@@ -387,3 +387,118 @@ fn snapshot_selected_message() {
|
|||||||
let output = buffer_to_string(&buffer);
|
let output = buffer_to_string(&buffer);
|
||||||
assert_snapshot!("selected_message", output);
|
assert_snapshot!("selected_message", output);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snapshot_album_incoming() {
|
||||||
|
let chat = create_test_chat("Mom", 123);
|
||||||
|
let msg1 = TestMessageBuilder::new("📷 [Фото]", 1)
|
||||||
|
.sender("Alice")
|
||||||
|
.media_album_id(12345)
|
||||||
|
.build();
|
||||||
|
let msg2 = TestMessageBuilder::new("Caption for album", 2)
|
||||||
|
.sender("Alice")
|
||||||
|
.media_album_id(12345)
|
||||||
|
.build();
|
||||||
|
let msg3 = TestMessageBuilder::new("📷 [Фото]", 3)
|
||||||
|
.sender("Alice")
|
||||||
|
.media_album_id(12345)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let mut app = TestAppBuilder::new()
|
||||||
|
.with_chat(chat)
|
||||||
|
.with_messages(123, vec![msg1, msg2, msg3])
|
||||||
|
.selected_chat(123)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let buffer = render_to_buffer(80, 24, |f| {
|
||||||
|
tele_tui::ui::messages::render(f, f.area(), &mut app);
|
||||||
|
});
|
||||||
|
|
||||||
|
let output = buffer_to_string(&buffer);
|
||||||
|
assert_snapshot!("album_incoming", output);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snapshot_album_outgoing() {
|
||||||
|
let chat = create_test_chat("Mom", 123);
|
||||||
|
let msg1 = TestMessageBuilder::new("📷 [Фото]", 1)
|
||||||
|
.outgoing()
|
||||||
|
.media_album_id(99999)
|
||||||
|
.build();
|
||||||
|
let msg2 = TestMessageBuilder::new("My vacation photos", 2)
|
||||||
|
.outgoing()
|
||||||
|
.media_album_id(99999)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let mut app = TestAppBuilder::new()
|
||||||
|
.with_chat(chat)
|
||||||
|
.with_messages(123, vec![msg1, msg2])
|
||||||
|
.selected_chat(123)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let buffer = render_to_buffer(80, 24, |f| {
|
||||||
|
tele_tui::ui::messages::render(f, f.area(), &mut app);
|
||||||
|
});
|
||||||
|
|
||||||
|
let output = buffer_to_string(&buffer);
|
||||||
|
assert_snapshot!("album_outgoing", output);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snapshot_album_with_regular_messages() {
|
||||||
|
let chat = create_test_chat("Group Chat", 123);
|
||||||
|
let msg1 = TestMessageBuilder::new("Regular message before", 1)
|
||||||
|
.sender("Alice")
|
||||||
|
.build();
|
||||||
|
let msg2 = TestMessageBuilder::new("📷 [Фото]", 2)
|
||||||
|
.sender("Alice")
|
||||||
|
.media_album_id(555)
|
||||||
|
.build();
|
||||||
|
let msg3 = TestMessageBuilder::new("Album caption", 3)
|
||||||
|
.sender("Alice")
|
||||||
|
.media_album_id(555)
|
||||||
|
.build();
|
||||||
|
let msg4 = TestMessageBuilder::new("Regular message after", 4)
|
||||||
|
.sender("Alice")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let mut app = TestAppBuilder::new()
|
||||||
|
.with_chat(chat)
|
||||||
|
.with_messages(123, vec![msg1, msg2, msg3, msg4])
|
||||||
|
.selected_chat(123)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let buffer = render_to_buffer(80, 24, |f| {
|
||||||
|
tele_tui::ui::messages::render(f, f.area(), &mut app);
|
||||||
|
});
|
||||||
|
|
||||||
|
let output = buffer_to_string(&buffer);
|
||||||
|
assert_snapshot!("album_with_regular_messages", output);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snapshot_album_selected() {
|
||||||
|
let chat = create_test_chat("Mom", 123);
|
||||||
|
let msg1 = TestMessageBuilder::new("📷 [Фото]", 1)
|
||||||
|
.sender("Alice")
|
||||||
|
.media_album_id(777)
|
||||||
|
.build();
|
||||||
|
let msg2 = TestMessageBuilder::new("📷 [Фото]", 2)
|
||||||
|
.sender("Alice")
|
||||||
|
.media_album_id(777)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let mut app = TestAppBuilder::new()
|
||||||
|
.with_chat(chat)
|
||||||
|
.with_messages(123, vec![msg1, msg2])
|
||||||
|
.selected_chat(123)
|
||||||
|
.selecting_message(1) // Выбираем одно из сообщений альбома
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let buffer = render_to_buffer(80, 24, |f| {
|
||||||
|
tele_tui::ui::messages::render(f, f.area(), &mut app);
|
||||||
|
});
|
||||||
|
|
||||||
|
let output = buffer_to_string(&buffer);
|
||||||
|
assert_snapshot!("album_selected", output);
|
||||||
|
}
|
||||||
|
|||||||
28
tests/snapshots/messages__album_incoming.snap
Normal file
28
tests/snapshots/messages__album_incoming.snap
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
source: tests/messages.rs
|
||||||
|
expression: output
|
||||||
|
---
|
||||||
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│👤 Mom │
|
||||||
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ ──────── 02.01.2022 ──────── │
|
||||||
|
│ │
|
||||||
|
│Alice ──────────────── │
|
||||||
|
│ (14:33) 📷 [Фото] │
|
||||||
|
│ (14:33) Caption for album │
|
||||||
|
│ (14:33) 📷 [Фото] │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│> Press i to type... │
|
||||||
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
28
tests/snapshots/messages__album_outgoing.snap
Normal file
28
tests/snapshots/messages__album_outgoing.snap
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
source: tests/messages.rs
|
||||||
|
expression: output
|
||||||
|
---
|
||||||
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│👤 Mom │
|
||||||
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ ──────── 02.01.2022 ──────── │
|
||||||
|
│ │
|
||||||
|
│ Вы ──────────────── │
|
||||||
|
│ 📷 [Фото] (14:33 ✓✓)│
|
||||||
|
│ My vacation photos (14:33 ✓✓) │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│> Press i to type... │
|
||||||
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
28
tests/snapshots/messages__album_selected.snap
Normal file
28
tests/snapshots/messages__album_selected.snap
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
source: tests/messages.rs
|
||||||
|
expression: output
|
||||||
|
---
|
||||||
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│👤 Mom │
|
||||||
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ ──────── 02.01.2022 ──────── │
|
||||||
|
│ │
|
||||||
|
│Alice ──────────────── │
|
||||||
|
│ (14:33) 📷 [Фото] │
|
||||||
|
│▶ (14:33) 📷 [Фото] │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
┌ Выбор сообщения ─────────────────────────────────────────────────────────────┐
|
||||||
|
│↑↓ · r ответ · f переслать · y копир. · d удалить · Esc │
|
||||||
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
28
tests/snapshots/messages__album_with_regular_messages.snap
Normal file
28
tests/snapshots/messages__album_with_regular_messages.snap
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
source: tests/messages.rs
|
||||||
|
expression: output
|
||||||
|
---
|
||||||
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│👤 Group Chat │
|
||||||
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ ──────── 02.01.2022 ──────── │
|
||||||
|
│ │
|
||||||
|
│Alice ──────────────── │
|
||||||
|
│ (14:33) Regular message before │
|
||||||
|
│ (14:33) 📷 [Фото] │
|
||||||
|
│ (14:33) Album caption │
|
||||||
|
│ (14:33) Regular message after │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│> Press i to type... │
|
||||||
|
└──────────────────────────────────────────────────────────────────────────────┘
|
||||||
Reference in New Issue
Block a user