//! Desktop notifications module //! //! Provides cross-platform desktop notifications for new messages. use crate::tdlib::{ChatInfo, MessageInfo}; use crate::types::ChatId; use std::collections::HashSet; #[cfg(feature = "notifications")] use notify_rust::{Notification, Timeout}; /// Manages desktop notifications pub struct NotificationManager { /// Whether notifications are enabled enabled: bool, /// Set of muted chat IDs (don't notify for these chats) muted_chats: HashSet, /// Only notify for mentions (@username) only_mentions: bool, /// Show message preview text show_preview: bool, /// Notification timeout in milliseconds (0 = system default) timeout_ms: i32, /// Notification urgency level urgency: String, } impl NotificationManager { /// Creates a new notification manager with default settings pub fn new() -> Self { Self { enabled: false, muted_chats: HashSet::new(), only_mentions: false, show_preview: true, timeout_ms: 5000, urgency: "normal".to_string(), } } /// Creates a notification manager with custom settings pub fn with_config( enabled: bool, only_mentions: bool, show_preview: bool, ) -> Self { Self { enabled, muted_chats: HashSet::new(), only_mentions, show_preview, timeout_ms: 5000, urgency: "normal".to_string(), } } /// Sets whether notifications are enabled pub fn set_enabled(&mut self, enabled: bool) { self.enabled = enabled; } /// Sets whether to only notify for mentions pub fn set_only_mentions(&mut self, only_mentions: bool) { self.only_mentions = only_mentions; } /// Sets notification timeout in milliseconds pub fn set_timeout(&mut self, timeout_ms: i32) { self.timeout_ms = timeout_ms; } /// Sets notification urgency level pub fn set_urgency(&mut self, urgency: String) { self.urgency = urgency; } /// Adds a chat to the muted list pub fn mute_chat(&mut self, chat_id: ChatId) { self.muted_chats.insert(chat_id); } /// Removes a chat from the muted list pub fn unmute_chat(&mut self, chat_id: ChatId) { self.muted_chats.remove(&chat_id); } /// Checks if a chat should be muted based on Telegram mute status pub fn sync_muted_chats(&mut self, chats: &[ChatInfo]) { self.muted_chats.clear(); for chat in chats { if chat.is_muted { self.muted_chats.insert(chat.id); } } } /// Sends a notification for a new message /// /// # Arguments /// /// * `chat` - Chat information /// * `message` - Message information /// * `sender_name` - Name of the message sender /// /// Returns `Ok(())` if notification was sent or skipped, `Err` if failed pub fn notify_new_message( &self, chat: &ChatInfo, message: &MessageInfo, sender_name: &str, ) -> Result<(), String> { // Check if notifications are enabled if !self.enabled { tracing::debug!("Notifications disabled, skipping"); return Ok(()); } // Don't notify for outgoing messages if message.is_outgoing() { tracing::debug!("Outgoing message, skipping notification"); return Ok(()); } // Check if chat is muted if self.muted_chats.contains(&chat.id) { tracing::debug!("Chat {} is muted, skipping notification", chat.title); return Ok(()); } // Check if we only notify for mentions if self.only_mentions && !message.has_mention() { tracing::debug!("only_mentions=true but no mention found, skipping"); return Ok(()); } // Format the notification let title = &chat.title; let body = self.format_message_body(sender_name, message); tracing::debug!("Sending notification for chat: {}", title); // Send the notification self.send_notification(title, &body)?; Ok(()) } /// Formats the message body for notification fn format_message_body(&self, sender_name: &str, message: &MessageInfo) -> String { // For groups, include sender name. For private chats, sender name is in title let prefix = if !sender_name.is_empty() && sender_name != message.sender_name() { format!("{}: ", sender_name) } else { String::new() }; let content = if self.show_preview { let text = message.text(); // Check if message is empty (media, sticker, etc.) if text.is_empty() { "Новое сообщение".to_string() } else { // Beautify media labels with emojis let beautified = Self::beautify_media_labels(text); // Limit preview length (use char count, not byte count for UTF-8 safety) const MAX_PREVIEW_CHARS: usize = 147; let char_count = beautified.chars().count(); if char_count > MAX_PREVIEW_CHARS { let truncated: String = beautified.chars().take(MAX_PREVIEW_CHARS).collect(); format!("{}...", truncated) } else { beautified } } } else { "Новое сообщение".to_string() }; format!("{}{}", prefix, content) } /// Replaces text media labels with emoji-enhanced versions fn beautify_media_labels(text: &str) -> String { text.replace("[Фото]", "📷 Фото") .replace("[Видео]", "🎥 Видео") .replace("[GIF]", "🎞️ GIF") .replace("[Голосовое]", "🎤 Голосовое") .replace("[Стикер:", "🎨 Стикер:") .replace("[Файл:", "📎 Файл:") .replace("[Аудио:", "🎵 Аудио:") .replace("[Аудио]", "🎵 Аудио") .replace("[Видеосообщение]", "📹 Видеосообщение") .replace("[Локация]", "📍 Локация") .replace("[Контакт:", "👤 Контакт:") .replace("[Опрос:", "📊 Опрос:") .replace("[Место встречи:", "📍 Место встречи:") .replace("[Неподдерживаемый тип сообщения]", "📨 Сообщение") } /// Sends a desktop notification /// /// Returns `Ok(())` if notification was sent successfully or skipped. /// Logs errors but doesn't fail - notifications are not critical for app functionality. #[cfg(feature = "notifications")] fn send_notification(&self, title: &str, body: &str) -> Result<(), String> { // Don't send if notifications are disabled if !self.enabled { return Ok(()); } // Determine timeout let timeout = if self.timeout_ms <= 0 { Timeout::Default } else { Timeout::Milliseconds(self.timeout_ms as u32) }; // Build notification let mut notification = Notification::new(); notification .summary(title) .body(body) .icon("telegram") .appname("tele-tui") .timeout(timeout); // Set urgency if supported #[cfg(all(unix, not(target_os = "macos")))] { use notify_rust::Urgency; let urgency_level = match self.urgency.to_lowercase().as_str() { "low" => Urgency::Low, "critical" => Urgency::Critical, _ => Urgency::Normal, }; notification.urgency(urgency_level); } match notification.show() { Ok(_) => Ok(()), Err(e) => { // Log error but don't fail - notifications are optional tracing::warn!("Failed to send desktop notification: {}", e); // Return Ok to not break the app flow Ok(()) } } } /// Fallback when notifications feature is disabled #[cfg(not(feature = "notifications"))] fn send_notification(&self, _title: &str, _body: &str) -> Result<(), String> { Ok(()) } } impl Default for NotificationManager { fn default() -> Self { Self::new() } } #[cfg(test)] mod tests { use super::*; #[test] fn test_notification_manager_creation() { let manager = NotificationManager::new(); assert!(manager.enabled); assert!(!manager.only_mentions); assert!(manager.show_preview); } #[test] fn test_mute_unmute() { let mut manager = NotificationManager::new(); let chat_id = ChatId::new(123); manager.mute_chat(chat_id); assert!(manager.muted_chats.contains(&chat_id)); manager.unmute_chat(chat_id); assert!(!manager.muted_chats.contains(&chat_id)); } #[test] fn test_disabled_notifications() { let mut manager = NotificationManager::new(); manager.set_enabled(false); // Should return Ok without sending notification let result = manager.send_notification("Test", "Body"); assert!(result.is_ok()); } #[test] fn test_only_mentions_setting() { let mut manager = NotificationManager::new(); assert!(!manager.only_mentions); manager.set_only_mentions(true); assert!(manager.only_mentions); manager.set_only_mentions(false); assert!(!manager.only_mentions); } #[test] fn test_beautify_media_labels() { // Test photo assert_eq!( NotificationManager::beautify_media_labels("[Фото]"), "📷 Фото" ); // Test video assert_eq!( NotificationManager::beautify_media_labels("[Видео]"), "🎥 Видео" ); // Test sticker with emoji assert_eq!( NotificationManager::beautify_media_labels("[Стикер: 😊]"), "🎨 Стикер: 😊]" ); // Test audio with title assert_eq!( NotificationManager::beautify_media_labels("[Аудио: Artist - Song]"), "🎵 Аудио: Artist - Song]" ); // Test file assert_eq!( NotificationManager::beautify_media_labels("[Файл: document.pdf]"), "📎 Файл: document.pdf]" ); // Test regular text (no changes) assert_eq!( NotificationManager::beautify_media_labels("Hello, world!"), "Hello, world!" ); // Test mixed content assert_eq!( NotificationManager::beautify_media_labels("[Фото] Check this out!"), "📷 Фото Check this out!" ); } }