Files
telegram-tui/src/config/keybindings.rs
Mikhail Kilin bea0bcbed0 feat: implement desktop notifications with comprehensive filtering
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>
2026-02-05 01:27:44 +03:00

426 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// Модуль для настраиваемых горячих клавиш
///
/// Поддерживает:
/// - Загрузку из конфигурационного файла
/// - Множественные 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));
}
}