Files
telegram-tui/src/notifications.rs
Mikhail Kilin 264f183510
Some checks failed
ci/woodpecker/pr/check Pipeline failed
CI / Check (pull_request) Has been cancelled
CI / Format (pull_request) Has been cancelled
CI / Clippy (pull_request) Has been cancelled
CI / Build (macos-latest) (pull_request) Has been cancelled
CI / Build (ubuntu-latest) (pull_request) Has been cancelled
CI / Build (windows-latest) (pull_request) Has been cancelled
style: auto-format entire codebase with cargo fmt (stable rustfmt.toml)
2026-02-22 17:09:51 +03:00

340 lines
11 KiB
Rust

//! 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<ChatId>,
/// 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); // disabled by default
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!"
);
}
}