fix: stabilize multi-account chat handling #28
@@ -1,23 +1,30 @@
|
||||
|
||||
|
||||
# list of languages for which language servers are started; choose from:
|
||||
# al bash clojure cpp csharp
|
||||
# csharp_omnisharp dart elixir elm erlang
|
||||
# fortran fsharp go groovy haskell
|
||||
# java julia kotlin lua markdown
|
||||
# matlab nix pascal perl php
|
||||
# powershell python python_jedi r rego
|
||||
# ruby ruby_solargraph rust scala swift
|
||||
# terraform toml typescript typescript_vts vue
|
||||
# yaml zig
|
||||
# al angular ansible bash clojure
|
||||
# cpp cpp_ccls crystal csharp csharp_omnisharp
|
||||
# dart elixir elm erlang fortran
|
||||
# fsharp go groovy haskell haxe
|
||||
# hlsl html java json julia
|
||||
# kotlin lean4 lua luau markdown
|
||||
# matlab msl nix ocaml pascal
|
||||
# perl php php_phpactor powershell python
|
||||
# python_jedi python_ty r rego ruby
|
||||
# ruby_solargraph rust scala scss solidity
|
||||
# swift systemverilog terraform toml typescript
|
||||
# typescript_vts vue yaml zig
|
||||
# (This list may be outdated. For the current list, see values of Language enum here:
|
||||
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
|
||||
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
|
||||
# Note:
|
||||
# - For C, use cpp
|
||||
# - For JavaScript, use typescript
|
||||
# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root)
|
||||
# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three)
|
||||
# - For Free Pascal/Lazarus, use pascal
|
||||
# Special requirements:
|
||||
# - csharp: Requires the presence of a .sln file in the project folder.
|
||||
# - pascal: Requires Free Pascal Compiler (fpc) and optionally Lazarus.
|
||||
# Some languages require additional setup/installations.
|
||||
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
|
||||
# When using multiple languages, the first language server that supports a given file will be used for that file.
|
||||
# The first language is the default language and the respective language server will be used as a fallback.
|
||||
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
|
||||
@@ -31,8 +38,9 @@ encoding: "utf-8"
|
||||
# whether to use project's .gitignore files to ignore files
|
||||
ignore_all_files_in_gitignore: true
|
||||
|
||||
# list of additional paths to ignore in all projects
|
||||
# same syntax as gitignore, so you can use * and **
|
||||
# list of additional paths to ignore in this project.
|
||||
# Same syntax as gitignore, so you can use * and **.
|
||||
# Note: global ignored_paths from serena_config.yml are also applied additively.
|
||||
ignored_paths: []
|
||||
|
||||
# whether the project is in read-only mode
|
||||
@@ -40,45 +48,9 @@ ignored_paths: []
|
||||
# Added on 2025-04-18
|
||||
read_only: false
|
||||
|
||||
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
|
||||
# Below is the complete list of tools for convenience.
|
||||
# To make sure you have the latest list of tools, and to view their descriptions,
|
||||
# execute `uv run scripts/print_tool_overview.py`.
|
||||
#
|
||||
# * `activate_project`: Activates a project by name.
|
||||
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
|
||||
# * `create_text_file`: Creates/overwrites a file in the project directory.
|
||||
# * `delete_lines`: Deletes a range of lines within a file.
|
||||
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
|
||||
# * `execute_shell_command`: Executes a shell command.
|
||||
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
|
||||
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
|
||||
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
|
||||
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
|
||||
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
|
||||
# * `initial_instructions`: Gets the initial instructions for the current project.
|
||||
# Should only be used in settings where the system prompt cannot be set,
|
||||
# e.g. in clients you have no control over, like Claude Desktop.
|
||||
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
|
||||
# * `insert_at_line`: Inserts content at a given line in a file.
|
||||
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
|
||||
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
|
||||
# * `list_memories`: Lists memories in Serena's project-specific memory store.
|
||||
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
|
||||
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
|
||||
# * `read_file`: Reads a file within the project directory.
|
||||
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
|
||||
# * `remove_project`: Removes a project from the Serena configuration.
|
||||
# * `replace_lines`: Replaces a range of lines within a file with new content.
|
||||
# * `replace_symbol_body`: Replaces the full definition of a symbol.
|
||||
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
|
||||
# * `search_for_pattern`: Performs a search for a pattern in the project.
|
||||
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
|
||||
# * `switch_modes`: Activates modes by providing a list of their names
|
||||
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
|
||||
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
|
||||
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
|
||||
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
|
||||
# list of tool names to exclude.
|
||||
# This extends the existing exclusions (e.g. from the global configuration)
|
||||
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
||||
excluded_tools: []
|
||||
|
||||
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||
@@ -87,7 +59,9 @@ initial_prompt: ""
|
||||
# the name by which the project can be referenced within Serena
|
||||
project_name: "tele-tui"
|
||||
|
||||
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default)
|
||||
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
|
||||
# This extends the existing inclusions (e.g. from the global configuration).
|
||||
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
||||
included_optional_tools: []
|
||||
|
||||
# list of mode names to that are always to be included in the set of active modes
|
||||
@@ -98,19 +72,25 @@ included_optional_tools: []
|
||||
# Set this to a list of mode names to always include the respective modes for this project.
|
||||
base_modes:
|
||||
|
||||
# list of mode names that are to be activated by default.
|
||||
# The full set of modes to be activated is base_modes + default_modes.
|
||||
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
|
||||
# list of mode names that are to be activated by default, overriding the setting in the global configuration.
|
||||
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
|
||||
# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply.
|
||||
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
|
||||
# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply
|
||||
# for this project.
|
||||
# This setting can, in turn, be overridden by CLI parameters (--mode).
|
||||
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
|
||||
default_modes:
|
||||
|
||||
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
|
||||
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
|
||||
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
||||
fixed_tools: []
|
||||
|
||||
# override of the corresponding setting in serena_config.yml, see the documentation there.
|
||||
# If null or missing, the value from the global config is used.
|
||||
# time budget (seconds) per tool call for the retrieval of additional symbol information
|
||||
# such as docstrings or parameter information.
|
||||
# This overrides the corresponding setting in the global configuration; see the documentation there.
|
||||
# If null or missing, use the setting from the global configuration.
|
||||
symbol_info_budget:
|
||||
|
||||
# The language backend to use for this project.
|
||||
@@ -119,3 +99,42 @@ symbol_info_budget:
|
||||
# Note: the backend is fixed at startup. If a project with a different backend
|
||||
# is activated post-init, an error will be returned.
|
||||
language_backend:
|
||||
|
||||
# list of regex patterns which, when matched, mark a memory entry as read‑only.
|
||||
# Extends the list from the global configuration, merging the two lists.
|
||||
read_only_memory_patterns: []
|
||||
|
||||
# line ending convention to use when writing source files.
|
||||
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
|
||||
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
|
||||
line_ending:
|
||||
|
||||
# list of regex patterns for memories to completely ignore.
|
||||
# Matching memories will not appear in list_memories or activate_project output
|
||||
# and cannot be accessed via read_memory or write_memory.
|
||||
# To access ignored memory files, use the read_file tool on the raw file path.
|
||||
# Extends the list from the global configuration, merging the two lists.
|
||||
# Example: ["_archive/.*", "_episodes/.*"]
|
||||
ignored_memory_patterns: []
|
||||
|
||||
# advanced configuration option allowing to configure language server-specific options.
|
||||
# Maps the language key to the options.
|
||||
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
|
||||
# No documentation on options means no options are available.
|
||||
ls_specific_settings: {}
|
||||
|
||||
# list of mode names to be activated additionally for this project, e.g. ["query-projects"]
|
||||
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
|
||||
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
|
||||
added_modes:
|
||||
|
||||
# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos).
|
||||
# Paths can be absolute or relative to the project root.
|
||||
# Each folder is registered as an LSP workspace folder, enabling language servers to discover
|
||||
# symbols and references across package boundaries.
|
||||
# Currently supported for: TypeScript.
|
||||
# Example:
|
||||
# additional_workspace_folders:
|
||||
# - ../sibling-package
|
||||
# - ../shared-lib
|
||||
additional_workspace_folders: []
|
||||
|
||||
@@ -83,6 +83,7 @@ impl<T: TdClientTrait> NavigationMethods<T> for App<T> {
|
||||
self.message_scroll_offset = 0;
|
||||
self.last_typing_sent = None;
|
||||
self.pending_chat_init = None;
|
||||
self.chat_init_rx = None;
|
||||
// Останавливаем фоновую загрузку фото (drop receiver)
|
||||
#[cfg(feature = "images")]
|
||||
{
|
||||
|
||||
@@ -16,7 +16,7 @@ pub use state::AppScreen;
|
||||
|
||||
use crate::accounts::AccountProfile;
|
||||
use crate::tdlib::{ChatInfo, TdClient, TdClientTrait};
|
||||
use crate::types::ChatId;
|
||||
use crate::types::{ChatId, MessageId};
|
||||
use ratatui::widgets::ListState;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -33,6 +33,17 @@ pub struct PendingImageOpen {
|
||||
pub photo_height: i32,
|
||||
}
|
||||
|
||||
/// Result from background chat initialization tasks.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ChatInitEvent {
|
||||
ReplyInfoLoaded {
|
||||
chat_id: ChatId,
|
||||
message_id: MessageId,
|
||||
sender_name: String,
|
||||
text: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// State of the account switcher modal overlay.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AccountSwitcherState {
|
||||
@@ -149,14 +160,16 @@ pub struct App<T: TdClientTrait = TdClient> {
|
||||
pub current_account_name: String,
|
||||
/// Pending account switch: (account_name, db_path)
|
||||
pub pending_account_switch: Option<(String, PathBuf)>,
|
||||
/// Pending background chat init (reply info, pinned) after fast open
|
||||
/// Pending background chat init (reply info, photos) after fast open
|
||||
pub pending_chat_init: Option<ChatId>,
|
||||
/// Receiver for background chat initialization results
|
||||
pub chat_init_rx: Option<tokio::sync::mpsc::UnboundedReceiver<ChatInitEvent>>,
|
||||
/// Receiver for background photo downloads (file_id, result path)
|
||||
#[cfg(feature = "images")]
|
||||
pub photo_download_rx:
|
||||
Option<tokio::sync::mpsc::UnboundedReceiver<(i32, Result<String, String>)>>,
|
||||
// Voice playback
|
||||
/// Аудиопроигрыватель для голосовых сообщений (rodio)
|
||||
/// Аудиопроигрыватель для голосовых сообщений (ffplay)
|
||||
pub audio_player: Option<crate::audio::AudioPlayer>,
|
||||
/// Кэш голосовых файлов (LRU, max 100 MB)
|
||||
pub voice_cache: Option<crate::audio::VoiceCache>,
|
||||
@@ -223,6 +236,7 @@ impl<T: TdClientTrait> App<T> {
|
||||
current_account_name: "default".to_string(),
|
||||
pending_account_switch: None,
|
||||
pending_chat_init: None,
|
||||
chat_init_rx: None,
|
||||
#[cfg(feature = "images")]
|
||||
photo_download_rx: None,
|
||||
#[cfg(feature = "images")]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Audio playback module for voice messages.
|
||||
//!
|
||||
//! Provides:
|
||||
//! - AudioPlayer: rodio-based playback with play/pause/stop/volume controls
|
||||
//! - AudioPlayer: ffplay-based playback with play/pause/seek controls
|
||||
//! - VoiceCache: LRU cache for downloaded OGG voice files
|
||||
|
||||
pub mod cache;
|
||||
|
||||
@@ -23,12 +23,15 @@ pub struct AudioPlayer {
|
||||
impl AudioPlayer {
|
||||
/// Creates a new AudioPlayer
|
||||
pub fn new() -> Result<Self, String> {
|
||||
Command::new("which")
|
||||
let ffplay_check = Command::new("which")
|
||||
.arg("ffplay")
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.output()
|
||||
.map_err(|_| "ffplay not found (install ffmpeg)".to_string())?;
|
||||
if !ffplay_check.status.success() {
|
||||
return Err("ffplay not found (install ffmpeg)".to_string());
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
current_pid: Arc::new(Mutex::new(None)),
|
||||
@@ -48,44 +51,48 @@ impl AudioPlayer {
|
||||
self.stop();
|
||||
|
||||
let path_owned = path.as_ref().to_path_buf();
|
||||
*self.current_path.lock().unwrap() = Some(path_owned.clone());
|
||||
*self.starting.lock().unwrap() = true;
|
||||
let current_pid = self.current_pid.clone();
|
||||
let paused = self.paused.clone();
|
||||
let starting = self.starting.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let mut cmd = Command::new("ffplay");
|
||||
cmd.arg("-nodisp")
|
||||
.arg("-autoexit")
|
||||
.arg("-loglevel")
|
||||
.arg("quiet");
|
||||
let mut cmd = Command::new("ffplay");
|
||||
cmd.arg("-nodisp")
|
||||
.arg("-autoexit")
|
||||
.arg("-loglevel")
|
||||
.arg("quiet");
|
||||
|
||||
if start_secs > 0.0 {
|
||||
cmd.arg("-ss").arg(format!("{:.1}", start_secs));
|
||||
if start_secs > 0.0 {
|
||||
cmd.arg("-ss").arg(format!("{:.1}", start_secs));
|
||||
}
|
||||
|
||||
let mut child = match cmd
|
||||
.arg(&path_owned)
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
{
|
||||
Ok(child) => child,
|
||||
Err(e) => {
|
||||
*self.starting.lock().unwrap() = false;
|
||||
return Err(format!("failed to start ffplay: {}", e));
|
||||
}
|
||||
};
|
||||
|
||||
if let Ok(mut child) = cmd
|
||||
.arg(&path_owned)
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
{
|
||||
let pid = child.id();
|
||||
*current_pid.lock().unwrap() = Some(pid);
|
||||
let pid = child.id();
|
||||
*self.current_path.lock().unwrap() = Some(path_owned);
|
||||
*current_pid.lock().unwrap() = Some(pid);
|
||||
*paused.lock().unwrap() = false;
|
||||
*starting.lock().unwrap() = false;
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let _ = child.wait();
|
||||
|
||||
// Обнуляем только если это наш pid (новый play мог уже заменить его)
|
||||
let mut pid_guard = current_pid.lock().unwrap();
|
||||
if *pid_guard == Some(pid) {
|
||||
*pid_guard = None;
|
||||
*paused.lock().unwrap() = false;
|
||||
*starting.lock().unwrap() = false;
|
||||
|
||||
let _ = child.wait();
|
||||
|
||||
// Обнуляем только если это наш pid (новый play мог уже заменить его)
|
||||
let mut pid_guard = current_pid.lock().unwrap();
|
||||
if *pid_guard == Some(pid) {
|
||||
*pid_guard = None;
|
||||
*paused.lock().unwrap() = false;
|
||||
}
|
||||
} else {
|
||||
*starting.lock().unwrap() = false;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -71,6 +71,49 @@ pub enum Command {
|
||||
OpenProfile,
|
||||
}
|
||||
|
||||
const COMMAND_LOOKUP_ORDER: &[Command] = &[
|
||||
Command::Quit,
|
||||
Command::Cancel,
|
||||
Command::SubmitMessage,
|
||||
Command::OpenSearch,
|
||||
Command::OpenSearchInChat,
|
||||
Command::OpenProfile,
|
||||
Command::Help,
|
||||
Command::MoveUp,
|
||||
Command::MoveDown,
|
||||
Command::MoveLeft,
|
||||
Command::MoveRight,
|
||||
Command::PageUp,
|
||||
Command::PageDown,
|
||||
Command::SelectFolder1,
|
||||
Command::SelectFolder2,
|
||||
Command::SelectFolder3,
|
||||
Command::SelectFolder4,
|
||||
Command::SelectFolder5,
|
||||
Command::SelectFolder6,
|
||||
Command::SelectFolder7,
|
||||
Command::SelectFolder8,
|
||||
Command::SelectFolder9,
|
||||
Command::OpenChat,
|
||||
Command::EditMessage,
|
||||
Command::DeleteMessage,
|
||||
Command::ReplyMessage,
|
||||
Command::ForwardMessage,
|
||||
Command::CopyMessage,
|
||||
Command::ReactMessage,
|
||||
Command::SelectMessage,
|
||||
Command::ViewImage,
|
||||
Command::TogglePlayback,
|
||||
Command::SeekForward,
|
||||
Command::SeekBackward,
|
||||
Command::NewLine,
|
||||
Command::DeleteChar,
|
||||
Command::DeleteWord,
|
||||
Command::MoveToStart,
|
||||
Command::MoveToEnd,
|
||||
Command::EnterInsertMode,
|
||||
];
|
||||
|
||||
/// Привязка клавиши к команде
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct KeyBinding {
|
||||
@@ -114,13 +157,33 @@ pub struct Keybindings {
|
||||
impl Keybindings {
|
||||
/// Ищет команду по клавише
|
||||
pub fn get_command(&self, event: &KeyEvent) -> Option<Command> {
|
||||
for (command, bindings) in &self.bindings {
|
||||
if bindings.iter().any(|binding| binding.matches(event)) {
|
||||
for command in COMMAND_LOOKUP_ORDER {
|
||||
if self
|
||||
.bindings
|
||||
.get(command)
|
||||
.is_some_and(|bindings| bindings.iter().any(|binding| binding.matches(event)))
|
||||
{
|
||||
return Some(*command);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn duplicate_bindings(&self) -> Vec<(KeyBinding, Vec<Command>)> {
|
||||
let mut by_key: HashMap<KeyBinding, Vec<Command>> = HashMap::new();
|
||||
for command in COMMAND_LOOKUP_ORDER {
|
||||
if let Some(bindings) = self.bindings.get(command) {
|
||||
for binding in bindings {
|
||||
by_key.entry(binding.clone()).or_default().push(*command);
|
||||
}
|
||||
}
|
||||
}
|
||||
by_key
|
||||
.into_iter()
|
||||
.filter(|(_, commands)| commands.len() > 1)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Keybindings {
|
||||
@@ -133,7 +196,7 @@ impl Default for Keybindings {
|
||||
vec![
|
||||
KeyBinding::new(KeyCode::Up),
|
||||
KeyBinding::new(KeyCode::Char('k')),
|
||||
KeyBinding::new(KeyCode::Char('р')), // RU (custom mapping, not standard ЙЦУКЕН)
|
||||
KeyBinding::new(KeyCode::Char('л')), // RU
|
||||
],
|
||||
);
|
||||
bindings.insert(
|
||||
@@ -149,7 +212,7 @@ impl Default for Keybindings {
|
||||
vec![
|
||||
KeyBinding::new(KeyCode::Left),
|
||||
KeyBinding::new(KeyCode::Char('h')),
|
||||
KeyBinding::new(KeyCode::Char('л')), // RU (custom mapping, not standard ЙЦУКЕН)
|
||||
KeyBinding::new(KeyCode::Char('р')), // RU
|
||||
],
|
||||
);
|
||||
bindings.insert(
|
||||
@@ -264,8 +327,9 @@ impl Default for Keybindings {
|
||||
|
||||
// Voice playback
|
||||
bindings.insert(Command::TogglePlayback, vec![KeyBinding::new(KeyCode::Char(' '))]);
|
||||
bindings.insert(Command::SeekForward, vec![KeyBinding::new(KeyCode::Right)]);
|
||||
bindings.insert(Command::SeekBackward, vec![KeyBinding::new(KeyCode::Left)]);
|
||||
// Left/Right are MoveLeft/MoveRight globally; message selection treats them as voice seek.
|
||||
bindings.insert(Command::SeekForward, vec![]);
|
||||
bindings.insert(Command::SeekBackward, vec![]);
|
||||
|
||||
// Input
|
||||
bindings.insert(Command::SubmitMessage, vec![KeyBinding::new(KeyCode::Enter)]);
|
||||
@@ -279,13 +343,7 @@ impl Default for Keybindings {
|
||||
KeyBinding::with_ctrl(KeyCode::Char('w')),
|
||||
],
|
||||
);
|
||||
bindings.insert(
|
||||
Command::MoveToStart,
|
||||
vec![
|
||||
KeyBinding::new(KeyCode::Home),
|
||||
KeyBinding::with_ctrl(KeyCode::Char('a')),
|
||||
],
|
||||
);
|
||||
bindings.insert(Command::MoveToStart, vec![KeyBinding::new(KeyCode::Home)]);
|
||||
bindings.insert(
|
||||
Command::MoveToEnd,
|
||||
vec![
|
||||
@@ -307,8 +365,8 @@ impl Default for Keybindings {
|
||||
bindings.insert(
|
||||
Command::OpenProfile,
|
||||
vec![
|
||||
KeyBinding::with_ctrl(KeyCode::Char('u')), // Ctrl+U instead of Ctrl+I
|
||||
KeyBinding::with_ctrl(KeyCode::Char('г')), // RU
|
||||
KeyBinding::with_ctrl(KeyCode::Char('i')),
|
||||
KeyBinding::with_ctrl(KeyCode::Char('ш')), // RU
|
||||
],
|
||||
);
|
||||
|
||||
@@ -462,7 +520,8 @@ mod tests {
|
||||
// Проверяем навигацию
|
||||
assert_eq!(kb.get_command(&KeyEvent::from(KeyCode::Up)), Some(Command::MoveUp));
|
||||
assert_eq!(kb.get_command(&KeyEvent::from(KeyCode::Char('k'))), Some(Command::MoveUp));
|
||||
assert_eq!(kb.get_command(&KeyEvent::from(KeyCode::Char('р'))), Some(Command::MoveUp));
|
||||
assert_eq!(kb.get_command(&KeyEvent::from(KeyCode::Char('л'))), Some(Command::MoveUp));
|
||||
assert_eq!(kb.get_command(&KeyEvent::from(KeyCode::Char('р'))), Some(Command::MoveLeft));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -485,4 +544,11 @@ mod tests {
|
||||
|
||||
assert_eq!(kb.get_command(&event), Some(Command::OpenSearch));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_bindings_have_no_conflicts() {
|
||||
let kb = Keybindings::default();
|
||||
let duplicates = kb.duplicate_bindings();
|
||||
assert!(duplicates.is_empty(), "duplicate default keybindings: {:?}", duplicates);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,7 +379,7 @@ pub async fn handle_open_chat_keyboard_input<T: TdClientTrait>(app: &mut App<T>,
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
// Игнорируем символы с Ctrl/Alt модификаторами (кроме Shift)
|
||||
// Это позволяет обрабатывать хоткеи типа Ctrl+U для профиля
|
||||
// Это позволяет обрабатывать хоткеи типа Ctrl+I для профиля
|
||||
if key.modifiers.contains(KeyModifiers::CONTROL)
|
||||
|| key.modifiers.contains(KeyModifiers::ALT)
|
||||
{
|
||||
@@ -602,14 +602,11 @@ async fn handle_view_image<T: TdClientTrait>(app: &mut App<T>) {
|
||||
let client_id = app.td_client.client_id();
|
||||
tokio::spawn(async move {
|
||||
let result = tokio::time::timeout(Duration::from_secs(30), async {
|
||||
match tdlib_rs::functions::download_file(
|
||||
file_id, 1, 0, 0, true, client_id,
|
||||
)
|
||||
.await
|
||||
match tdlib_rs::functions::download_file(file_id, 1, 0, 0, true, client_id)
|
||||
.await
|
||||
{
|
||||
Ok(tdlib_rs::enums::File::File(f))
|
||||
if f.local.is_downloading_completed
|
||||
&& !f.local.path.is_empty() =>
|
||||
if f.local.is_downloading_completed && !f.local.path.is_empty() =>
|
||||
{
|
||||
Ok(f.local.path)
|
||||
}
|
||||
@@ -618,8 +615,7 @@ async fn handle_view_image<T: TdClientTrait>(app: &mut App<T>) {
|
||||
}
|
||||
})
|
||||
.await;
|
||||
let result =
|
||||
result.unwrap_or_else(|_| Err("Таймаут загрузки".to_string()));
|
||||
let result = result.unwrap_or_else(|_| Err("Таймаут загрузки".to_string()));
|
||||
let _ = tx.send((file_id, result));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
//! Chat loading logic — all three phases of message loading
|
||||
//!
|
||||
//! - Phase 1: `open_chat_and_load_data` — fast initial load (50 messages)
|
||||
//! - Phase 2: `process_pending_chat_init` — background tasks (reply info, pinned, photos)
|
||||
//! - Phase 2: `process_pending_chat_init` — starts background tasks (reply info, photos)
|
||||
//! - Phase 3: `load_older_messages_if_needed` — lazy load on scroll up
|
||||
|
||||
use crate::app::methods::{compose::ComposeMethods, messages::MessageMethods};
|
||||
use crate::app::App;
|
||||
use crate::app::InputMode;
|
||||
use crate::app::{App, ChatInitEvent};
|
||||
use crate::tdlib::message_conversion::{extract_content_text, extract_sender_name};
|
||||
use crate::tdlib::TdClientTrait;
|
||||
use crate::types::{ChatId, MessageId};
|
||||
use crate::utils::{with_timeout, with_timeout_ignore, with_timeout_msg};
|
||||
use crate::utils::{with_timeout, with_timeout_msg};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc::error::TryRecvError;
|
||||
|
||||
/// Открывает чат и загружает последние сообщения (быстро).
|
||||
///
|
||||
/// Загружает только 50 последних сообщений для мгновенного отображения.
|
||||
/// Фоновые задачи (reply info, pinned, photos) откладываются в `pending_chat_init`
|
||||
/// и выполняются на следующем тике main loop.
|
||||
/// Фоновые задачи (reply info, photos) откладываются в `pending_chat_init`
|
||||
/// и стартуют после первого redraw.
|
||||
///
|
||||
/// При ошибке устанавливает error_message и очищает status_message.
|
||||
pub async fn open_chat_and_load_data<T: TdClientTrait>(app: &mut App<T>, chat_id: i64) {
|
||||
@@ -62,7 +64,7 @@ pub async fn open_chat_and_load_data<T: TdClientTrait>(app: &mut App<T>, chat_id
|
||||
app.input_mode = InputMode::Normal;
|
||||
app.start_message_selection();
|
||||
|
||||
// Фоновые задачи (reply info, pinned, photos) — на следующем тике main loop
|
||||
// Фоновые задачи (reply info, photos) — после первого redraw
|
||||
app.pending_chat_init = Some(ChatId::new(chat_id));
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -72,21 +74,71 @@ pub async fn open_chat_and_load_data<T: TdClientTrait>(app: &mut App<T>, chat_id
|
||||
}
|
||||
}
|
||||
|
||||
/// Выполняет фоновую инициализацию после открытия чата.
|
||||
/// Запускает фоновую инициализацию после открытия чата.
|
||||
///
|
||||
/// Вызывается на следующем тике main loop после `open_chat_and_load_data`.
|
||||
/// Загружает reply info, закреплённое сообщение и начинает авто-загрузку фото.
|
||||
pub async fn process_pending_chat_init<T: TdClientTrait>(app: &mut App<T>, chat_id: ChatId) {
|
||||
// Загружаем недостающие reply info (игнорируем ошибки)
|
||||
with_timeout_ignore(Duration::from_secs(5), app.td_client.fetch_missing_reply_info())
|
||||
.await;
|
||||
/// Вызывается после первого redraw после `open_chat_and_load_data`.
|
||||
/// Не блокирует UI loop: TDLib запросы выполняются в отдельных Tokio tasks,
|
||||
/// а готовые результаты применяются через `process_chat_init_events`.
|
||||
pub fn process_pending_chat_init<T: TdClientTrait>(app: &mut App<T>, chat_id: ChatId) {
|
||||
app.chat_init_rx = None;
|
||||
|
||||
// Загружаем последнее закреплённое сообщение (игнорируем ошибки)
|
||||
with_timeout_ignore(
|
||||
Duration::from_secs(2),
|
||||
app.td_client.load_current_pinned_message(chat_id),
|
||||
)
|
||||
.await;
|
||||
let mut reply_message_ids: Vec<MessageId> = app
|
||||
.td_client
|
||||
.current_chat_messages()
|
||||
.iter()
|
||||
.filter_map(|msg| {
|
||||
msg.interactions
|
||||
.reply_to
|
||||
.as_ref()
|
||||
.filter(|reply| reply.sender_name == "Unknown")
|
||||
.map(|reply| reply.message_id)
|
||||
})
|
||||
.collect();
|
||||
reply_message_ids.sort_unstable();
|
||||
reply_message_ids.dedup();
|
||||
|
||||
if !reply_message_ids.is_empty() {
|
||||
let client_id = app.td_client.client_id();
|
||||
let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<ChatInitEvent>();
|
||||
app.chat_init_rx = Some(rx);
|
||||
|
||||
for message_id in reply_message_ids {
|
||||
let tx = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let result = tokio::time::timeout(Duration::from_secs(5), async {
|
||||
let Ok(original_msg_enum) = tdlib_rs::functions::get_message(
|
||||
chat_id.as_i64(),
|
||||
message_id.as_i64(),
|
||||
client_id,
|
||||
)
|
||||
.await
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let tdlib_rs::enums::Message::Message(original_msg) = original_msg_enum;
|
||||
let sender_name = extract_sender_name(&original_msg, client_id).await;
|
||||
let text: String = extract_content_text(&original_msg)
|
||||
.chars()
|
||||
.take(50)
|
||||
.collect();
|
||||
Some((sender_name, text))
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
|
||||
if let Some((sender_name, text)) = result {
|
||||
let _ = tx.send(ChatInitEvent::ReplyInfoLoaded {
|
||||
chat_id,
|
||||
message_id,
|
||||
sender_name,
|
||||
text,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Авто-загрузка фото — неблокирующая фоновая задача (до 5 фото параллельно)
|
||||
#[cfg(feature = "images")]
|
||||
@@ -145,8 +197,55 @@ pub async fn process_pending_chat_init<T: TdClientTrait>(app: &mut App<T>, chat_
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app.needs_redraw = true;
|
||||
/// Применяет готовые результаты фоновой инициализации чата.
|
||||
pub fn process_chat_init_events<T: TdClientTrait>(app: &mut App<T>) {
|
||||
let mut events = Vec::new();
|
||||
let mut disconnected = false;
|
||||
|
||||
if let Some(rx) = app.chat_init_rx.as_mut() {
|
||||
loop {
|
||||
match rx.try_recv() {
|
||||
Ok(event) => events.push(event),
|
||||
Err(TryRecvError::Empty) => break,
|
||||
Err(TryRecvError::Disconnected) => {
|
||||
disconnected = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if disconnected {
|
||||
app.chat_init_rx = None;
|
||||
}
|
||||
|
||||
for event in events {
|
||||
match event {
|
||||
ChatInitEvent::ReplyInfoLoaded { chat_id, message_id, sender_name, text } => {
|
||||
if app.td_client.current_chat_id() != Some(chat_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut changed = false;
|
||||
for msg in app.td_client.current_chat_messages_mut() {
|
||||
let Some(reply) = msg.interactions.reply_to.as_mut() else {
|
||||
continue;
|
||||
};
|
||||
if reply.message_id == message_id {
|
||||
reply.sender_name = sender_name.clone();
|
||||
reply.text = text.clone();
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if changed {
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Подгружает старые сообщения если скролл близко к верху
|
||||
|
||||
@@ -21,7 +21,10 @@ pub mod modal;
|
||||
pub mod profile;
|
||||
pub mod search;
|
||||
|
||||
pub use chat_loader::{load_older_messages_if_needed, open_chat_and_load_data, process_pending_chat_init};
|
||||
pub use chat_loader::{
|
||||
load_older_messages_if_needed, open_chat_and_load_data, process_chat_init_events,
|
||||
process_pending_chat_init,
|
||||
};
|
||||
pub use clipboard::*;
|
||||
pub use global::*;
|
||||
pub use profile::get_available_actions_count;
|
||||
|
||||
@@ -231,7 +231,7 @@ pub async fn handle_profile_mode<T: TdClientTrait>(
|
||||
}
|
||||
}
|
||||
|
||||
/// Обработка Ctrl+U для открытия профиля чата/пользователя
|
||||
/// Обработка Ctrl+I для открытия профиля чата/пользователя
|
||||
///
|
||||
/// Загружает информацию о профиле и переключает в режим просмотра профиля
|
||||
pub async fn handle_profile_open<T: TdClientTrait>(app: &mut App<T>) {
|
||||
|
||||
65
src/main.rs
65
src/main.rs
@@ -28,8 +28,8 @@ use tdlib_rs::enums::Update;
|
||||
|
||||
use app::{App, AppScreen};
|
||||
use constants::{POLL_TIMEOUT_MS, SHUTDOWN_TIMEOUT_SECS};
|
||||
use input::handlers::{process_chat_init_events, process_pending_chat_init};
|
||||
use input::{handle_auth_input, handle_main_input};
|
||||
use input::handlers::process_pending_chat_init;
|
||||
use tdlib::AuthState;
|
||||
use utils::{disable_tdlib_logs, with_timeout_ignore};
|
||||
|
||||
@@ -84,7 +84,9 @@ async fn main() -> Result<(), io::Error> {
|
||||
|
||||
// Acquire per-account lock BEFORE raw mode (so error prints to normal terminal)
|
||||
let account_lock = accounts::acquire_lock(
|
||||
account_arg.as_deref().unwrap_or(&accounts_config.default_account),
|
||||
account_arg
|
||||
.as_deref()
|
||||
.unwrap_or(&accounts_config.default_account),
|
||||
)
|
||||
.unwrap_or_else(|e| {
|
||||
eprintln!("Error: {}", e);
|
||||
@@ -166,16 +168,18 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
|
||||
let should_stop = Arc::new(AtomicBool::new(false));
|
||||
let should_stop_clone = should_stop.clone();
|
||||
|
||||
// Канал для передачи updates из polling задачи в main loop
|
||||
let (update_tx, mut update_rx) = tokio::sync::mpsc::unbounded_channel::<Update>();
|
||||
// Канал для передачи updates из polling задачи в main loop.
|
||||
// client_id нужен при переключении аккаунтов: TDLib может ещё отдать
|
||||
// updates от старого клиента после recreate_client().
|
||||
let (update_tx, mut update_rx) = tokio::sync::mpsc::unbounded_channel::<(i32, Update)>();
|
||||
|
||||
// Запускаем polling TDLib receive() в отдельной задаче
|
||||
let polling_handle = tokio::spawn(async move {
|
||||
while !should_stop_clone.load(Ordering::Relaxed) {
|
||||
// receive() с таймаутом 0.1 сек чтобы периодически проверять флаг
|
||||
let result = tokio::task::spawn_blocking(tdlib_rs::receive).await;
|
||||
if let Ok(Some((update, _client_id))) = result {
|
||||
if update_tx.send(update).is_err() {
|
||||
if let Ok(Some((update, client_id))) = result {
|
||||
if update_tx.send((client_id, update)).is_err() {
|
||||
break; // Канал закрыт, выходим
|
||||
}
|
||||
}
|
||||
@@ -185,9 +189,18 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
|
||||
loop {
|
||||
// Обрабатываем updates от TDLib из канала (неблокирующе)
|
||||
let mut had_updates = false;
|
||||
while let Ok(update) = update_rx.try_recv() {
|
||||
app.td_client.handle_update(update);
|
||||
had_updates = true;
|
||||
let active_client_id = app.td_client.client_id();
|
||||
while let Ok((client_id, update)) = update_rx.try_recv() {
|
||||
if client_id == active_client_id {
|
||||
app.td_client.handle_update(update);
|
||||
had_updates = true;
|
||||
} else {
|
||||
tracing::debug!(
|
||||
"Ignoring TDLib update for inactive client_id={} (active={})",
|
||||
client_id,
|
||||
active_client_id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Помечаем UI как требующий перерисовки если были обновления
|
||||
@@ -195,6 +208,8 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
|
||||
process_chat_init_events(app);
|
||||
|
||||
// Обрабатываем результаты фоновой загрузки фото
|
||||
#[cfg(feature = "images")]
|
||||
{
|
||||
@@ -224,11 +239,8 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
|
||||
.unwrap_or(false);
|
||||
if pending_matches {
|
||||
// Ищем путь из обновлённого состояния
|
||||
let downloaded_path = app
|
||||
.td_client
|
||||
.current_chat_messages()
|
||||
.iter()
|
||||
.find_map(|m| {
|
||||
let downloaded_path =
|
||||
app.td_client.current_chat_messages().iter().find_map(|m| {
|
||||
m.photo_info().and_then(|p| {
|
||||
if p.file_id == file_id {
|
||||
if let PhotoDownloadState::Downloaded(ref path) =
|
||||
@@ -385,9 +397,13 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
|
||||
}
|
||||
}
|
||||
|
||||
// Process pending chat initialization (reply info, pinned, photos)
|
||||
if let Some(chat_id) = app.pending_chat_init.take() {
|
||||
process_pending_chat_init(app, chat_id).await;
|
||||
// Process pending chat initialization only after pending redraw is flushed.
|
||||
// This guarantees the initial 50-message chat view is rendered before slower
|
||||
// reply/photo initialization tasks start.
|
||||
if !app.needs_redraw {
|
||||
if let Some(chat_id) = app.pending_chat_init.take() {
|
||||
process_pending_chat_init(app, chat_id);
|
||||
}
|
||||
}
|
||||
|
||||
// Check pending account switch
|
||||
@@ -410,17 +426,20 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
|
||||
// 1. Stop playback
|
||||
app.stop_playback();
|
||||
|
||||
// 2. Recreate client (closes old, creates new, inits TDLib params)
|
||||
// 2. Drop queued updates from the old client before recreating TDLib.
|
||||
while update_rx.try_recv().is_ok() {}
|
||||
|
||||
// 3. Recreate client (closes old, creates new, inits TDLib params)
|
||||
if let Err(e) = app.td_client.recreate_client(new_db_path).await {
|
||||
app.error_message = Some(format!("Ошибка переключения: {}", e));
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3. Reset app state
|
||||
// 4. Reset app state
|
||||
app.current_account_name = account_name.clone();
|
||||
app.screen = AppScreen::Loading;
|
||||
|
||||
// 4. Persist selected account as default for next launch
|
||||
// 5. 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) {
|
||||
@@ -438,6 +457,12 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
|
||||
app.cursor_position = 0;
|
||||
app.message_scroll_offset = 0;
|
||||
app.pending_chat_init = None;
|
||||
app.chat_init_rx = None;
|
||||
#[cfg(feature = "images")]
|
||||
{
|
||||
app.photo_download_rx = None;
|
||||
app.pending_image_open = None;
|
||||
}
|
||||
app.account_switcher = None;
|
||||
|
||||
app.needs_redraw = true;
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::utils::get_day;
|
||||
|
||||
/// Элемент группированного списка сообщений
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum MessageGroup {
|
||||
pub enum MessageGroup<'a> {
|
||||
/// Разделитель даты (день в формате timestamp)
|
||||
DateSeparator(i32),
|
||||
/// Заголовок отправителя (is_outgoing, sender_name)
|
||||
@@ -17,9 +17,9 @@ pub enum MessageGroup {
|
||||
sender_name: String,
|
||||
},
|
||||
/// Сообщение
|
||||
Message(Box<MessageInfo>),
|
||||
Message(&'a MessageInfo),
|
||||
/// Альбом (группа фото с одинаковым media_album_id)
|
||||
Album(Vec<MessageInfo>),
|
||||
Album(Vec<&'a MessageInfo>),
|
||||
}
|
||||
|
||||
/// Группирует сообщения по дате и отправителю
|
||||
@@ -63,14 +63,14 @@ pub enum MessageGroup {
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
pub fn group_messages(messages: &[MessageInfo]) -> Vec<MessageGroup> {
|
||||
pub fn group_messages(messages: &[MessageInfo]) -> Vec<MessageGroup<'_>> {
|
||||
let mut result = Vec::new();
|
||||
let mut last_day: Option<i64> = None;
|
||||
let mut last_sender: Option<(bool, String)> = None; // (is_outgoing, sender_name)
|
||||
let mut album_acc: Vec<MessageInfo> = Vec::new();
|
||||
let mut album_acc: Vec<&MessageInfo> = Vec::new();
|
||||
|
||||
/// Сбрасывает аккумулятор альбома в результат
|
||||
fn flush_album(acc: &mut Vec<MessageInfo>, result: &mut Vec<MessageGroup>) {
|
||||
fn flush_album<'a>(acc: &mut Vec<&'a MessageInfo>, result: &mut Vec<MessageGroup<'a>>) {
|
||||
if acc.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -78,7 +78,7 @@ pub fn group_messages(messages: &[MessageInfo]) -> Vec<MessageGroup> {
|
||||
result.push(MessageGroup::Album(std::mem::take(acc)));
|
||||
} else {
|
||||
// Одно сообщение — не альбом
|
||||
result.push(MessageGroup::Message(Box::new(acc.remove(0))));
|
||||
result.push(MessageGroup::Message(acc.remove(0)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,24 +120,24 @@ pub fn group_messages(messages: &[MessageInfo]) -> Vec<MessageGroup> {
|
||||
if let Some(first) = album_acc.first() {
|
||||
if first.media_album_id() == album_id {
|
||||
// Тот же альбом — добавляем
|
||||
album_acc.push(msg.clone());
|
||||
album_acc.push(msg);
|
||||
continue;
|
||||
} else {
|
||||
// Другой альбом — flush старый, начинаем новый
|
||||
flush_album(&mut album_acc, &mut result);
|
||||
album_acc.push(msg.clone());
|
||||
album_acc.push(msg);
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
// Аккумулятор пуст — начинаем новый альбом
|
||||
album_acc.push(msg.clone());
|
||||
album_acc.push(msg);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Обычное сообщение (не альбом) — flush аккумулятор
|
||||
flush_album(&mut album_acc, &mut result);
|
||||
result.push(MessageGroup::Message(Box::new(msg.clone())));
|
||||
result.push(MessageGroup::Message(msg));
|
||||
}
|
||||
|
||||
// Flush оставшийся аккумулятор
|
||||
|
||||
@@ -238,13 +238,24 @@ impl TdClient {
|
||||
self.chat_manager.clear_stale_typing_status()
|
||||
}
|
||||
|
||||
fn last_read_outbox_message_id(&self, chat_id: ChatId) -> MessageId {
|
||||
self.chats()
|
||||
.iter()
|
||||
.find(|chat| chat.id == chat_id)
|
||||
.map(|chat| chat.last_read_outbox_message_id)
|
||||
.unwrap_or(MessageId::new(0))
|
||||
}
|
||||
|
||||
// Делегирование к message_manager
|
||||
pub async fn get_chat_history(
|
||||
&mut self,
|
||||
chat_id: ChatId,
|
||||
limit: i32,
|
||||
) -> Result<Vec<MessageInfo>, String> {
|
||||
self.message_manager.get_chat_history(chat_id, limit).await
|
||||
let last_read_outbox_message_id = self.last_read_outbox_message_id(chat_id);
|
||||
self.message_manager
|
||||
.get_chat_history(chat_id, limit, last_read_outbox_message_id)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn load_older_messages(
|
||||
@@ -252,8 +263,9 @@ impl TdClient {
|
||||
chat_id: ChatId,
|
||||
from_message_id: MessageId,
|
||||
) -> Result<Vec<MessageInfo>, String> {
|
||||
let last_read_outbox_message_id = self.last_read_outbox_message_id(chat_id);
|
||||
self.message_manager
|
||||
.load_older_messages(chat_id, from_message_id)
|
||||
.load_older_messages(chat_id, from_message_id, last_read_outbox_message_id)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -261,7 +273,10 @@ impl TdClient {
|
||||
&mut self,
|
||||
chat_id: ChatId,
|
||||
) -> Result<Vec<MessageInfo>, String> {
|
||||
self.message_manager.get_pinned_messages(chat_id).await
|
||||
let last_read_outbox_message_id = self.last_read_outbox_message_id(chat_id);
|
||||
self.message_manager
|
||||
.get_pinned_messages(chat_id, last_read_outbox_message_id)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn load_current_pinned_message(&mut self, chat_id: ChatId) {
|
||||
@@ -275,7 +290,10 @@ impl TdClient {
|
||||
chat_id: ChatId,
|
||||
query: &str,
|
||||
) -> Result<Vec<MessageInfo>, String> {
|
||||
self.message_manager.search_messages(chat_id, query).await
|
||||
let last_read_outbox_message_id = self.last_read_outbox_message_id(chat_id);
|
||||
self.message_manager
|
||||
.search_messages(chat_id, query, last_read_outbox_message_id)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn send_message(
|
||||
@@ -285,8 +303,15 @@ impl TdClient {
|
||||
reply_to_message_id: Option<MessageId>,
|
||||
reply_info: Option<super::types::ReplyInfo>,
|
||||
) -> Result<MessageInfo, String> {
|
||||
let last_read_outbox_message_id = self.last_read_outbox_message_id(chat_id);
|
||||
self.message_manager
|
||||
.send_message(chat_id, text, reply_to_message_id, reply_info)
|
||||
.send_message(
|
||||
chat_id,
|
||||
text,
|
||||
reply_to_message_id,
|
||||
reply_info,
|
||||
last_read_outbox_message_id,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -296,8 +321,9 @@ impl TdClient {
|
||||
message_id: MessageId,
|
||||
text: String,
|
||||
) -> Result<MessageInfo, String> {
|
||||
let last_read_outbox_message_id = self.last_read_outbox_message_id(chat_id);
|
||||
self.message_manager
|
||||
.edit_message(chat_id, message_id, text)
|
||||
.edit_message(chat_id, message_id, text, last_read_outbox_message_id)
|
||||
.await
|
||||
}
|
||||
|
||||
|
||||
@@ -94,9 +94,7 @@ impl TdClientTrait for TdClient {
|
||||
reply_to_message_id: Option<MessageId>,
|
||||
reply_info: Option<ReplyInfo>,
|
||||
) -> Result<MessageInfo, String> {
|
||||
self.message_manager
|
||||
.send_message(chat_id, text, reply_to_message_id, reply_info)
|
||||
.await
|
||||
TdClient::send_message(self, chat_id, text, reply_to_message_id, reply_info).await
|
||||
}
|
||||
|
||||
async fn edit_message(
|
||||
@@ -105,9 +103,7 @@ impl TdClientTrait for TdClient {
|
||||
message_id: MessageId,
|
||||
new_text: String,
|
||||
) -> Result<MessageInfo, String> {
|
||||
self.message_manager
|
||||
.edit_message(chat_id, message_id, new_text)
|
||||
.await
|
||||
TdClient::edit_message(self, chat_id, message_id, new_text).await
|
||||
}
|
||||
|
||||
async fn delete_messages(
|
||||
|
||||
@@ -10,7 +10,11 @@ use super::MessageManager;
|
||||
|
||||
impl MessageManager {
|
||||
/// Конвертировать TdMessage в MessageInfo
|
||||
pub(crate) async fn convert_message(&self, msg: &TdMessage) -> Option<MessageInfo> {
|
||||
pub(crate) async fn convert_message(
|
||||
&self,
|
||||
msg: &TdMessage,
|
||||
last_read_outbox_message_id: MessageId,
|
||||
) -> Option<MessageInfo> {
|
||||
use crate::tdlib::message_conversion::{
|
||||
extract_content_text, extract_entities, extract_forward_info, extract_media_info,
|
||||
extract_reactions, extract_reply_info, extract_sender_name,
|
||||
@@ -39,7 +43,8 @@ impl MessageManager {
|
||||
builder = builder.incoming();
|
||||
}
|
||||
|
||||
if !msg.contains_unread_mention {
|
||||
let is_read = !msg.is_outgoing || msg.id <= last_read_outbox_message_id.as_i64();
|
||||
if is_read {
|
||||
builder = builder.read();
|
||||
} else {
|
||||
builder = builder.unread();
|
||||
@@ -117,7 +122,7 @@ impl MessageManager {
|
||||
};
|
||||
|
||||
let tdlib_rs::enums::Message::Message(original_msg) = original_msg_enum;
|
||||
let Some(orig_info) = self.convert_message(&original_msg).await else {
|
||||
let Some(orig_info) = self.convert_message(&original_msg, MessageId::new(0)).await else {
|
||||
return;
|
||||
};
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ impl MessageManager {
|
||||
&mut self,
|
||||
chat_id: ChatId,
|
||||
limit: i32,
|
||||
last_read_outbox_message_id: MessageId,
|
||||
) -> Result<Vec<MessageInfo>, String> {
|
||||
// ВАЖНО: Сначала открываем чат в TDLib
|
||||
// Это сообщает TDLib что пользователь открыл чат и нужно загрузить историю
|
||||
@@ -119,7 +120,8 @@ impl MessageManager {
|
||||
// Конвертируем сообщения (от новых к старым, потом реверсим)
|
||||
let mut chunk_messages = Vec::new();
|
||||
for msg in messages_obj.messages.iter().flatten() {
|
||||
if let Some(info) = self.convert_message(msg).await {
|
||||
if let Some(info) = self.convert_message(msg, last_read_outbox_message_id).await
|
||||
{
|
||||
chunk_messages.push(info);
|
||||
}
|
||||
}
|
||||
@@ -192,6 +194,7 @@ impl MessageManager {
|
||||
&mut self,
|
||||
chat_id: ChatId,
|
||||
from_message_id: MessageId,
|
||||
last_read_outbox_message_id: MessageId,
|
||||
) -> Result<Vec<MessageInfo>, String> {
|
||||
let result = functions::get_chat_history(
|
||||
chat_id.as_i64(),
|
||||
@@ -207,7 +210,8 @@ impl MessageManager {
|
||||
Ok(tdlib_rs::enums::Messages::Messages(messages_obj)) => {
|
||||
let mut messages = Vec::new();
|
||||
for msg in messages_obj.messages.iter().rev().flatten() {
|
||||
if let Some(info) = self.convert_message(msg).await {
|
||||
if let Some(info) = self.convert_message(msg, last_read_outbox_message_id).await
|
||||
{
|
||||
messages.push(info);
|
||||
}
|
||||
}
|
||||
@@ -239,6 +243,7 @@ impl MessageManager {
|
||||
pub async fn get_pinned_messages(
|
||||
&mut self,
|
||||
chat_id: ChatId,
|
||||
last_read_outbox_message_id: MessageId,
|
||||
) -> Result<Vec<MessageInfo>, String> {
|
||||
let result = functions::search_chat_messages(
|
||||
chat_id.as_i64(),
|
||||
@@ -258,7 +263,8 @@ impl MessageManager {
|
||||
Ok(tdlib_rs::enums::FoundChatMessages::FoundChatMessages(messages_obj)) => {
|
||||
let mut pinned_messages = Vec::new();
|
||||
for msg in messages_obj.messages.iter().rev() {
|
||||
if let Some(info) = self.convert_message(msg).await {
|
||||
if let Some(info) = self.convert_message(msg, last_read_outbox_message_id).await
|
||||
{
|
||||
pinned_messages.push(info);
|
||||
}
|
||||
}
|
||||
@@ -307,6 +313,7 @@ impl MessageManager {
|
||||
&self,
|
||||
chat_id: ChatId,
|
||||
query: &str,
|
||||
last_read_outbox_message_id: MessageId,
|
||||
) -> Result<Vec<MessageInfo>, String> {
|
||||
let result = functions::search_chat_messages(
|
||||
chat_id.as_i64(),
|
||||
@@ -326,7 +333,8 @@ impl MessageManager {
|
||||
Ok(tdlib_rs::enums::FoundChatMessages::FoundChatMessages(messages_obj)) => {
|
||||
let mut search_results = Vec::new();
|
||||
for msg in messages_obj.messages.iter().rev() {
|
||||
if let Some(info) = self.convert_message(msg).await {
|
||||
if let Some(info) = self.convert_message(msg, last_read_outbox_message_id).await
|
||||
{
|
||||
search_results.push(info);
|
||||
}
|
||||
}
|
||||
@@ -377,6 +385,7 @@ impl MessageManager {
|
||||
text: String,
|
||||
reply_to_message_id: Option<MessageId>,
|
||||
reply_info: Option<ReplyInfo>,
|
||||
last_read_outbox_message_id: MessageId,
|
||||
) -> Result<MessageInfo, String> {
|
||||
// Парсим markdown в тексте
|
||||
let formatted_text = match functions::parse_text_entities(
|
||||
@@ -419,7 +428,7 @@ impl MessageManager {
|
||||
match result {
|
||||
Ok(tdlib_rs::enums::Message::Message(msg)) => {
|
||||
let mut msg_info = self
|
||||
.convert_message(&msg)
|
||||
.convert_message(&msg, last_read_outbox_message_id)
|
||||
.await
|
||||
.ok_or_else(|| "Не удалось конвертировать сообщение".to_string())?;
|
||||
|
||||
@@ -451,6 +460,7 @@ impl MessageManager {
|
||||
chat_id: ChatId,
|
||||
message_id: MessageId,
|
||||
text: String,
|
||||
last_read_outbox_message_id: MessageId,
|
||||
) -> Result<MessageInfo, String> {
|
||||
let formatted_text = match functions::parse_text_entities(
|
||||
text.clone(),
|
||||
@@ -481,7 +491,7 @@ impl MessageManager {
|
||||
|
||||
match result {
|
||||
Ok(tdlib_rs::enums::Message::Message(msg)) => self
|
||||
.convert_message(&msg)
|
||||
.convert_message(&msg, last_read_outbox_message_id)
|
||||
.await
|
||||
.ok_or_else(|| "Не удалось конвертировать отредактированное сообщение".to_string()),
|
||||
Err(e) => Err(format!("Ошибка редактирования: {:?}", e)),
|
||||
|
||||
@@ -4,7 +4,7 @@ mod chat_helpers; // Chat management helpers
|
||||
pub mod chats;
|
||||
pub mod client;
|
||||
mod client_impl; // Private module for trait implementation
|
||||
mod message_conversion; // Message conversion utilities (for messages.rs)
|
||||
pub(crate) mod message_conversion; // Message conversion utilities (for messages.rs)
|
||||
mod message_converter; // Message conversion utilities (for client.rs)
|
||||
pub mod messages;
|
||||
pub mod reactions;
|
||||
|
||||
@@ -531,7 +531,7 @@ pub struct DeferredImageRender {
|
||||
/// Фото отображаются в сетке (до 3 в ряд), с общей подписью и timestamp.
|
||||
#[cfg(feature = "images")]
|
||||
pub fn render_album_bubble(
|
||||
messages: &[MessageInfo],
|
||||
messages: &[&MessageInfo],
|
||||
config: &Config,
|
||||
content_width: usize,
|
||||
selected_msg_id: Option<MessageId>,
|
||||
@@ -550,7 +550,7 @@ pub fn render_album_bubble(
|
||||
let selection_marker = if is_selected { "▶ " } else { " " };
|
||||
|
||||
// Фильтруем фото
|
||||
let photos: Vec<&MessageInfo> = messages.iter().filter(|m| m.has_photo()).collect();
|
||||
let photos: Vec<&MessageInfo> = messages.iter().copied().filter(|m| m.has_photo()).collect();
|
||||
let photo_count = photos.len();
|
||||
|
||||
if photo_count == 0 {
|
||||
|
||||
@@ -187,7 +187,8 @@ fn render_message_list<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut Ap
|
||||
let mut deferred_images: Vec<components::DeferredImageRender> = Vec::new();
|
||||
|
||||
// Используем message_grouping для группировки сообщений
|
||||
let grouped = group_messages(&app.td_client.current_chat_messages());
|
||||
let current_messages = app.td_client.current_chat_messages();
|
||||
let grouped = group_messages(¤t_messages);
|
||||
let mut is_first_date = true;
|
||||
let mut is_first_sender = true;
|
||||
|
||||
@@ -218,7 +219,7 @@ fn render_message_list<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut Ap
|
||||
|
||||
// Рендерим сообщение
|
||||
let bubble_lines = components::render_message_bubble(
|
||||
&msg,
|
||||
msg,
|
||||
app.config(),
|
||||
content_width,
|
||||
selected_msg_id,
|
||||
|
||||
@@ -83,7 +83,7 @@ async fn test_vim_navigation_in_chat_list() {
|
||||
assert_eq!(app.chat_list_state.selected(), Some(0));
|
||||
}
|
||||
|
||||
/// Test: Русские клавиши о/р для навигации
|
||||
/// Test: Русские клавиши о/л для навигации
|
||||
#[tokio::test]
|
||||
async fn test_russian_keyboard_navigation() {
|
||||
let mut app = TestAppBuilder::new()
|
||||
@@ -99,8 +99,8 @@ async fn test_russian_keyboard_navigation() {
|
||||
handle_main_input(&mut app, key(KeyCode::Char('о'))).await;
|
||||
assert_eq!(app.chat_list_state.selected(), Some(1));
|
||||
|
||||
// р (русская k) - вверх
|
||||
handle_main_input(&mut app, key(KeyCode::Char('р'))).await;
|
||||
// л (русская k) - вверх
|
||||
handle_main_input(&mut app, key(KeyCode::Char('л'))).await;
|
||||
assert_eq!(app.chat_list_state.selected(), Some(0));
|
||||
}
|
||||
|
||||
|
||||
@@ -25,4 +25,4 @@ expression: output
|
||||
┌──────────────────────┐│ │
|
||||
│ ││ │
|
||||
└──────────────────────┘└──────────────────────────────────────────────────────┘
|
||||
Инициализация TDLib...
|
||||
[default] Инициализация TDLib...
|
||||
|
||||
@@ -435,9 +435,9 @@ async fn test_ctrl_w_deletes_word_with_spaces() {
|
||||
assert_eq!(app.cursor_position, 9);
|
||||
}
|
||||
|
||||
/// Ctrl+A → курсор в начало в Insert mode
|
||||
/// Home → курсор в начало в Insert mode
|
||||
#[tokio::test]
|
||||
async fn test_ctrl_a_moves_to_start_in_insert() {
|
||||
async fn test_home_moves_to_start_in_insert() {
|
||||
let mut app = TestAppBuilder::new()
|
||||
.with_chats(vec![create_test_chat("Chat", 101)])
|
||||
.selected_chat(101)
|
||||
@@ -446,7 +446,7 @@ async fn test_ctrl_a_moves_to_start_in_insert() {
|
||||
.build();
|
||||
app.cursor_position = 11;
|
||||
|
||||
handle_main_input(&mut app, ctrl_key('a')).await;
|
||||
handle_main_input(&mut app, key(KeyCode::Home)).await;
|
||||
|
||||
assert_eq!(app.cursor_position, 0);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user