Implemented Phase 10 (Desktop Notifications) with three stages: notify-rust integration, smart filtering, and production polish. Stage 1 - Base Implementation: - Add NotificationManager module (src/notifications.rs, 350+ lines) - Integrate notify-rust 4.11 with feature flag "notifications" - Implement NotificationsConfig in config.toml (enabled, only_mentions, show_preview) - Add notification_manager field to TdClient - Create configure_notifications() method for config integration - Hook into handle_new_message_update() to send notifications - Send notifications for messages outside current chat - Format notification body with sender name and message preview Stage 2 - Smart Filtering: - Sync muted chats from Telegram (sync_muted_chats method) - Filter muted chats from notifications automatically - Add MessageInfo::has_mention() to detect @username mentions - Implement only_mentions filter (notify only when mentioned) - Beautify media labels with emojis (📷 📹 🎤 🎨 📎 etc.) - Support 10+ media types in notification preview Stage 3 - Production Polish: - Add graceful error handling (no panics on notification failure) - Implement comprehensive logging (tracing::debug!/warn!) - Add timeout_ms configuration (0 = system default) - Add urgency configuration (low/normal/critical, Linux only) - Platform-specific #[cfg] for urgency support - Log all notification skip reasons at debug level Hotkey Change: - Move profile view from 'i' to Ctrl+i to avoid conflicts Technical Details: - Cross-platform support (macOS, Linux, Windows) - Feature flag for optional notifications support - Graceful fallback when notifications unavailable - LRU-friendly muted chats sync - Test coverage for all core notification logic - All 75 tests passing Files Changed: - NEW: src/notifications.rs - Complete NotificationManager - NEW: config.example.toml - Example configuration with notifications - Modified: Cargo.toml - Add notify-rust 4.11 dependency - Modified: src/config/mod.rs - Add NotificationsConfig struct - Modified: src/tdlib/types.rs - Add has_mention() method - Modified: src/tdlib/client.rs - Add notification integration - Modified: src/tdlib/update_handlers.rs - Hook notifications - Modified: src/config/keybindings.rs - Change profile to Ctrl+i - Modified: tests/* - Add notification config to tests Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
426 lines
14 KiB
Rust
426 lines
14 KiB
Rust
/// Модуль для настраиваемых горячих клавиш
|
||
///
|
||
/// Поддерживает:
|
||
/// - Загрузку из конфигурационного файла
|
||
/// - Множественные binding для одной команды (EN/RU раскладки)
|
||
/// - Type-safe команды через enum
|
||
|
||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||
use serde::{Deserialize, Serialize};
|
||
use std::collections::HashMap;
|
||
|
||
/// Команды приложения
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||
#[serde(rename_all = "snake_case")]
|
||
pub enum Command {
|
||
// Navigation
|
||
MoveUp,
|
||
MoveDown,
|
||
MoveLeft,
|
||
MoveRight,
|
||
PageUp,
|
||
PageDown,
|
||
|
||
// Global
|
||
Quit,
|
||
OpenSearch,
|
||
OpenSearchInChat,
|
||
Help,
|
||
|
||
// Chat list
|
||
OpenChat,
|
||
SelectFolder1,
|
||
SelectFolder2,
|
||
SelectFolder3,
|
||
SelectFolder4,
|
||
SelectFolder5,
|
||
SelectFolder6,
|
||
SelectFolder7,
|
||
SelectFolder8,
|
||
SelectFolder9,
|
||
|
||
// Message actions
|
||
EditMessage,
|
||
DeleteMessage,
|
||
ReplyMessage,
|
||
ForwardMessage,
|
||
CopyMessage,
|
||
ReactMessage,
|
||
SelectMessage,
|
||
|
||
// Input
|
||
SubmitMessage,
|
||
Cancel,
|
||
NewLine,
|
||
DeleteChar,
|
||
DeleteWord,
|
||
MoveToStart,
|
||
MoveToEnd,
|
||
|
||
// Profile
|
||
OpenProfile,
|
||
}
|
||
|
||
/// Привязка клавиши к команде
|
||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||
pub struct KeyBinding {
|
||
#[serde(with = "key_code_serde")]
|
||
pub key: KeyCode,
|
||
#[serde(with = "key_modifiers_serde")]
|
||
pub modifiers: KeyModifiers,
|
||
}
|
||
|
||
impl KeyBinding {
|
||
pub fn new(key: KeyCode) -> Self {
|
||
Self {
|
||
key,
|
||
modifiers: KeyModifiers::NONE,
|
||
}
|
||
}
|
||
|
||
pub fn with_ctrl(key: KeyCode) -> Self {
|
||
Self {
|
||
key,
|
||
modifiers: KeyModifiers::CONTROL,
|
||
}
|
||
}
|
||
|
||
pub fn with_shift(key: KeyCode) -> Self {
|
||
Self {
|
||
key,
|
||
modifiers: KeyModifiers::SHIFT,
|
||
}
|
||
}
|
||
|
||
pub fn matches(&self, event: &KeyEvent) -> bool {
|
||
self.key == event.code && self.modifiers == event.modifiers
|
||
}
|
||
}
|
||
|
||
/// Конфигурация горячих клавиш
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct Keybindings {
|
||
#[serde(flatten)]
|
||
bindings: HashMap<Command, Vec<KeyBinding>>,
|
||
}
|
||
|
||
impl Keybindings {
|
||
/// Создаёт дефолтную конфигурацию
|
||
pub fn default() -> Self {
|
||
let mut bindings = HashMap::new();
|
||
|
||
// Navigation
|
||
bindings.insert(Command::MoveUp, vec![
|
||
KeyBinding::new(KeyCode::Up),
|
||
KeyBinding::new(KeyCode::Char('k')),
|
||
KeyBinding::new(KeyCode::Char('р')), // RU (custom mapping, not standard ЙЦУКЕН)
|
||
]);
|
||
bindings.insert(Command::MoveDown, vec![
|
||
KeyBinding::new(KeyCode::Down),
|
||
KeyBinding::new(KeyCode::Char('j')),
|
||
KeyBinding::new(KeyCode::Char('о')), // RU
|
||
]);
|
||
bindings.insert(Command::MoveLeft, vec![
|
||
KeyBinding::new(KeyCode::Left),
|
||
KeyBinding::new(KeyCode::Char('h')),
|
||
KeyBinding::new(KeyCode::Char('л')), // RU (custom mapping, not standard ЙЦУКЕН)
|
||
]);
|
||
bindings.insert(Command::MoveRight, vec![
|
||
KeyBinding::new(KeyCode::Right),
|
||
KeyBinding::new(KeyCode::Char('l')),
|
||
KeyBinding::new(KeyCode::Char('д')), // RU
|
||
]);
|
||
bindings.insert(Command::PageUp, vec![
|
||
KeyBinding::new(KeyCode::PageUp),
|
||
KeyBinding::with_ctrl(KeyCode::Char('u')),
|
||
]);
|
||
bindings.insert(Command::PageDown, vec![
|
||
KeyBinding::new(KeyCode::PageDown),
|
||
KeyBinding::with_ctrl(KeyCode::Char('d')),
|
||
]);
|
||
|
||
// Global
|
||
bindings.insert(Command::Quit, vec![
|
||
KeyBinding::new(KeyCode::Char('q')),
|
||
KeyBinding::new(KeyCode::Char('й')), // RU
|
||
KeyBinding::with_ctrl(KeyCode::Char('c')),
|
||
]);
|
||
bindings.insert(Command::OpenSearch, vec![
|
||
KeyBinding::with_ctrl(KeyCode::Char('s')),
|
||
]);
|
||
bindings.insert(Command::OpenSearchInChat, vec![
|
||
KeyBinding::with_ctrl(KeyCode::Char('f')),
|
||
]);
|
||
bindings.insert(Command::Help, vec![
|
||
KeyBinding::new(KeyCode::Char('?')),
|
||
]);
|
||
|
||
// Chat list
|
||
// Note: Enter обрабатывается через Command::SubmitMessage в handle_enter_key()
|
||
for i in 1..=9 {
|
||
let cmd = match i {
|
||
1 => Command::SelectFolder1,
|
||
2 => Command::SelectFolder2,
|
||
3 => Command::SelectFolder3,
|
||
4 => Command::SelectFolder4,
|
||
5 => Command::SelectFolder5,
|
||
6 => Command::SelectFolder6,
|
||
7 => Command::SelectFolder7,
|
||
8 => Command::SelectFolder8,
|
||
9 => Command::SelectFolder9,
|
||
_ => unreachable!(),
|
||
};
|
||
bindings.insert(cmd, vec![
|
||
KeyBinding::new(KeyCode::Char(char::from_digit(i, 10).unwrap())),
|
||
]);
|
||
}
|
||
|
||
// Message actions
|
||
// Note: EditMessage (Up) обрабатывается напрямую в handle_open_chat_keyboard_input
|
||
// в зависимости от контекста (пустой инпут). Не привязываем здесь, чтобы не
|
||
// конфликтовать с Command::MoveUp в списке чатов.
|
||
bindings.insert(Command::DeleteMessage, vec![
|
||
KeyBinding::new(KeyCode::Delete),
|
||
KeyBinding::new(KeyCode::Char('d')),
|
||
KeyBinding::new(KeyCode::Char('в')), // RU
|
||
]);
|
||
bindings.insert(Command::ReplyMessage, vec![
|
||
KeyBinding::new(KeyCode::Char('r')),
|
||
KeyBinding::new(KeyCode::Char('к')), // RU
|
||
]);
|
||
bindings.insert(Command::ForwardMessage, vec![
|
||
KeyBinding::new(KeyCode::Char('f')),
|
||
KeyBinding::new(KeyCode::Char('а')), // RU
|
||
]);
|
||
bindings.insert(Command::CopyMessage, vec![
|
||
KeyBinding::new(KeyCode::Char('y')),
|
||
KeyBinding::new(KeyCode::Char('н')), // RU
|
||
]);
|
||
bindings.insert(Command::ReactMessage, vec![
|
||
KeyBinding::new(KeyCode::Char('e')),
|
||
KeyBinding::new(KeyCode::Char('у')), // RU
|
||
]);
|
||
// Note: SelectMessage обрабатывается через Command::SubmitMessage в handle_enter_key()
|
||
|
||
// Input
|
||
bindings.insert(Command::SubmitMessage, vec![
|
||
KeyBinding::new(KeyCode::Enter),
|
||
]);
|
||
bindings.insert(Command::Cancel, vec![
|
||
KeyBinding::new(KeyCode::Esc),
|
||
]);
|
||
bindings.insert(Command::NewLine, vec![
|
||
KeyBinding::with_shift(KeyCode::Enter),
|
||
]);
|
||
bindings.insert(Command::DeleteChar, vec![
|
||
KeyBinding::new(KeyCode::Backspace),
|
||
]);
|
||
bindings.insert(Command::DeleteWord, vec![
|
||
KeyBinding::with_ctrl(KeyCode::Backspace),
|
||
KeyBinding::with_ctrl(KeyCode::Char('w')),
|
||
]);
|
||
bindings.insert(Command::MoveToStart, vec![
|
||
KeyBinding::new(KeyCode::Home),
|
||
KeyBinding::with_ctrl(KeyCode::Char('a')),
|
||
]);
|
||
bindings.insert(Command::MoveToEnd, vec![
|
||
KeyBinding::new(KeyCode::End),
|
||
KeyBinding::with_ctrl(KeyCode::Char('e')),
|
||
]);
|
||
|
||
// Profile
|
||
bindings.insert(Command::OpenProfile, vec![
|
||
KeyBinding::with_ctrl(KeyCode::Char('i')),
|
||
KeyBinding::with_ctrl(KeyCode::Char('ш')), // RU
|
||
]);
|
||
|
||
Self { bindings }
|
||
}
|
||
|
||
/// Ищет команду по клавише
|
||
pub fn get_command(&self, event: &KeyEvent) -> Option<Command> {
|
||
for (command, bindings) in &self.bindings {
|
||
if bindings.iter().any(|binding| binding.matches(event)) {
|
||
return Some(*command);
|
||
}
|
||
}
|
||
None
|
||
}
|
||
}
|
||
|
||
impl Default for Keybindings {
|
||
fn default() -> Self {
|
||
Self::default()
|
||
}
|
||
}
|
||
|
||
/// Сериализация KeyModifiers
|
||
mod key_modifiers_serde {
|
||
use crossterm::event::KeyModifiers;
|
||
use serde::{Deserialize, Deserializer, Serializer};
|
||
|
||
pub fn serialize<S>(modifiers: &KeyModifiers, serializer: S) -> Result<S::Ok, S::Error>
|
||
where
|
||
S: Serializer,
|
||
{
|
||
let mut parts = Vec::new();
|
||
if modifiers.contains(KeyModifiers::SHIFT) {
|
||
parts.push("Shift");
|
||
}
|
||
if modifiers.contains(KeyModifiers::CONTROL) {
|
||
parts.push("Ctrl");
|
||
}
|
||
if modifiers.contains(KeyModifiers::ALT) {
|
||
parts.push("Alt");
|
||
}
|
||
if modifiers.contains(KeyModifiers::SUPER) {
|
||
parts.push("Super");
|
||
}
|
||
if modifiers.contains(KeyModifiers::HYPER) {
|
||
parts.push("Hyper");
|
||
}
|
||
if modifiers.contains(KeyModifiers::META) {
|
||
parts.push("Meta");
|
||
}
|
||
|
||
if parts.is_empty() {
|
||
serializer.serialize_str("None")
|
||
} else {
|
||
serializer.serialize_str(&parts.join("+"))
|
||
}
|
||
}
|
||
|
||
pub fn deserialize<'de, D>(deserializer: D) -> Result<KeyModifiers, D::Error>
|
||
where
|
||
D: Deserializer<'de>,
|
||
{
|
||
let s = String::deserialize(deserializer)?;
|
||
|
||
if s == "None" || s.is_empty() {
|
||
return Ok(KeyModifiers::NONE);
|
||
}
|
||
|
||
let mut modifiers = KeyModifiers::NONE;
|
||
for part in s.split('+') {
|
||
match part.trim() {
|
||
"Shift" => modifiers |= KeyModifiers::SHIFT,
|
||
"Ctrl" | "Control" => modifiers |= KeyModifiers::CONTROL,
|
||
"Alt" => modifiers |= KeyModifiers::ALT,
|
||
"Super" => modifiers |= KeyModifiers::SUPER,
|
||
"Hyper" => modifiers |= KeyModifiers::HYPER,
|
||
"Meta" => modifiers |= KeyModifiers::META,
|
||
_ => return Err(serde::de::Error::custom(format!("Unknown modifier: {}", part))),
|
||
}
|
||
}
|
||
|
||
Ok(modifiers)
|
||
}
|
||
}
|
||
|
||
/// Сериализация KeyCode
|
||
mod key_code_serde {
|
||
use crossterm::event::KeyCode;
|
||
use serde::{Deserialize, Deserializer, Serializer};
|
||
|
||
pub fn serialize<S>(key: &KeyCode, serializer: S) -> Result<S::Ok, S::Error>
|
||
where
|
||
S: Serializer,
|
||
{
|
||
let s = match key {
|
||
KeyCode::Char(c) => format!("Char('{}')", c),
|
||
KeyCode::F(n) => format!("F{}", n),
|
||
KeyCode::Backspace => "Backspace".to_string(),
|
||
KeyCode::Enter => "Enter".to_string(),
|
||
KeyCode::Left => "Left".to_string(),
|
||
KeyCode::Right => "Right".to_string(),
|
||
KeyCode::Up => "Up".to_string(),
|
||
KeyCode::Down => "Down".to_string(),
|
||
KeyCode::Home => "Home".to_string(),
|
||
KeyCode::End => "End".to_string(),
|
||
KeyCode::PageUp => "PageUp".to_string(),
|
||
KeyCode::PageDown => "PageDown".to_string(),
|
||
KeyCode::Tab => "Tab".to_string(),
|
||
KeyCode::BackTab => "BackTab".to_string(),
|
||
KeyCode::Delete => "Delete".to_string(),
|
||
KeyCode::Insert => "Insert".to_string(),
|
||
KeyCode::Esc => "Esc".to_string(),
|
||
_ => "Unknown".to_string(),
|
||
};
|
||
serializer.serialize_str(&s)
|
||
}
|
||
|
||
pub fn deserialize<'de, D>(deserializer: D) -> Result<KeyCode, D::Error>
|
||
where
|
||
D: Deserializer<'de>,
|
||
{
|
||
let s = String::deserialize(deserializer)?;
|
||
|
||
if s.starts_with("Char('") && s.ends_with("')") {
|
||
let c = s.chars().nth(6).ok_or_else(|| {
|
||
serde::de::Error::custom("Invalid Char format")
|
||
})?;
|
||
return Ok(KeyCode::Char(c));
|
||
}
|
||
|
||
if s.starts_with("F") {
|
||
let n = s[1..].parse().map_err(serde::de::Error::custom)?;
|
||
return Ok(KeyCode::F(n));
|
||
}
|
||
|
||
match s.as_str() {
|
||
"Backspace" => Ok(KeyCode::Backspace),
|
||
"Enter" => Ok(KeyCode::Enter),
|
||
"Left" => Ok(KeyCode::Left),
|
||
"Right" => Ok(KeyCode::Right),
|
||
"Up" => Ok(KeyCode::Up),
|
||
"Down" => Ok(KeyCode::Down),
|
||
"Home" => Ok(KeyCode::Home),
|
||
"End" => Ok(KeyCode::End),
|
||
"PageUp" => Ok(KeyCode::PageUp),
|
||
"PageDown" => Ok(KeyCode::PageDown),
|
||
"Tab" => Ok(KeyCode::Tab),
|
||
"BackTab" => Ok(KeyCode::BackTab),
|
||
"Delete" => Ok(KeyCode::Delete),
|
||
"Insert" => Ok(KeyCode::Insert),
|
||
"Esc" => Ok(KeyCode::Esc),
|
||
_ => Err(serde::de::Error::custom(format!("Unknown key: {}", s))),
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn test_default_bindings() {
|
||
let kb = Keybindings::default();
|
||
|
||
// Проверяем навигацию
|
||
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));
|
||
}
|
||
|
||
#[test]
|
||
fn test_get_command() {
|
||
let kb = Keybindings::default();
|
||
|
||
let event = KeyEvent::from(KeyCode::Char('q'));
|
||
assert_eq!(kb.get_command(&event), Some(Command::Quit));
|
||
|
||
let event = KeyEvent::from(KeyCode::Char('й')); // RU
|
||
assert_eq!(kb.get_command(&event), Some(Command::Quit));
|
||
}
|
||
|
||
#[test]
|
||
fn test_ctrl_modifier() {
|
||
let kb = Keybindings::default();
|
||
|
||
let mut event = KeyEvent::from(KeyCode::Char('s'));
|
||
event.modifiers = KeyModifiers::CONTROL;
|
||
|
||
assert_eq!(kb.get_command(&event), Some(Command::OpenSearch));
|
||
}
|
||
}
|