mirror of
https://github.com/youwen5/iamb.git
synced 2025-08-04 11:38:28 -07:00
Add support for desktop notifications (#192)
This commit is contained in:
parent
c63f8d98d5
commit
0c52375e06
10 changed files with 845 additions and 30 deletions
|
@ -761,6 +761,9 @@ pub struct RoomInfo {
|
|||
|
||||
/// The display names for users in this room.
|
||||
pub display_names: HashMap<OwnedUserId, String>,
|
||||
|
||||
/// The last time the room was rendered, used to detect if it is currently open.
|
||||
pub draw_last: Option<Instant>,
|
||||
}
|
||||
|
||||
impl RoomInfo {
|
||||
|
@ -1192,6 +1195,9 @@ pub struct ChatStore {
|
|||
|
||||
/// Image preview "protocol" picker.
|
||||
pub picker: Option<Picker>,
|
||||
|
||||
/// Last draw time, used to match with RoomInfo's draw_last.
|
||||
pub draw_curr: Option<Instant>,
|
||||
}
|
||||
|
||||
impl ChatStore {
|
||||
|
@ -1212,6 +1218,7 @@ impl ChatStore {
|
|||
verifications: Default::default(),
|
||||
need_load: Default::default(),
|
||||
sync_info: Default::default(),
|
||||
draw_curr: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -391,6 +391,12 @@ pub enum UserDisplayStyle {
|
|||
DisplayName,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
|
||||
pub struct Notifications {
|
||||
pub enabled: bool,
|
||||
pub show_message: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ImagePreviewValues {
|
||||
pub size: ImagePreviewSize,
|
||||
|
@ -476,6 +482,7 @@ pub struct TunableValues {
|
|||
pub message_user_color: bool,
|
||||
pub default_room: Option<String>,
|
||||
pub open_command: Option<Vec<String>>,
|
||||
pub notifications: Notifications,
|
||||
pub image_preview: Option<ImagePreviewValues>,
|
||||
}
|
||||
|
||||
|
@ -496,6 +503,7 @@ pub struct Tunables {
|
|||
pub message_user_color: Option<bool>,
|
||||
pub default_room: Option<String>,
|
||||
pub open_command: Option<Vec<String>>,
|
||||
pub notifications: Option<Notifications>,
|
||||
pub image_preview: Option<ImagePreview>,
|
||||
}
|
||||
|
||||
|
@ -518,6 +526,7 @@ impl Tunables {
|
|||
message_user_color: self.message_user_color.or(other.message_user_color),
|
||||
default_room: self.default_room.or(other.default_room),
|
||||
open_command: self.open_command.or(other.open_command),
|
||||
notifications: self.notifications.or(other.notifications),
|
||||
image_preview: self.image_preview.or(other.image_preview),
|
||||
}
|
||||
}
|
||||
|
@ -538,6 +547,7 @@ impl Tunables {
|
|||
message_user_color: self.message_user_color.unwrap_or(false),
|
||||
default_room: self.default_room,
|
||||
open_command: self.open_command,
|
||||
notifications: self.notifications.unwrap_or_default(),
|
||||
image_preview: self.image_preview.map(ImagePreview::values),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,7 +24,7 @@ use std::ops::DerefMut;
|
|||
use std::process;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use clap::Parser;
|
||||
use matrix_sdk::crypto::encrypt_room_key_export;
|
||||
|
@ -66,6 +66,7 @@ mod commands;
|
|||
mod config;
|
||||
mod keybindings;
|
||||
mod message;
|
||||
mod notifications;
|
||||
mod preview;
|
||||
mod sled_export;
|
||||
mod util;
|
||||
|
@ -305,6 +306,7 @@ impl Application {
|
|||
// Don't show terminal cursor when we show a dialog.
|
||||
let hide_cursor = !dialogstr.is_empty();
|
||||
|
||||
store.application.draw_curr = Some(Instant::now());
|
||||
let screen = Screen::new(store)
|
||||
.show_dialog(dialogstr)
|
||||
.show_mode(modestr)
|
||||
|
|
221
src/notifications.rs
Normal file
221
src/notifications.rs
Normal file
|
@ -0,0 +1,221 @@
|
|||
use std::time::SystemTime;
|
||||
|
||||
use matrix_sdk::{
|
||||
notification_settings::{IsEncrypted, IsOneToOne, NotificationSettings, RoomNotificationMode},
|
||||
room::Room as MatrixRoom,
|
||||
ruma::{
|
||||
api::client::push::get_notifications::v3::Notification,
|
||||
events::{room::message::MessageType, AnyMessageLikeEventContent, AnySyncTimelineEvent},
|
||||
MilliSecondsSinceUnixEpoch,
|
||||
RoomId,
|
||||
},
|
||||
Client,
|
||||
};
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
use crate::{
|
||||
base::{AsyncProgramStore, IambError, IambResult},
|
||||
config::ApplicationSettings,
|
||||
};
|
||||
|
||||
pub async fn register_notifications(
|
||||
client: &Client,
|
||||
settings: &ApplicationSettings,
|
||||
store: &AsyncProgramStore,
|
||||
) {
|
||||
if !settings.tunables.notifications.enabled {
|
||||
return;
|
||||
}
|
||||
let show_message = settings.tunables.notifications.show_message;
|
||||
let server_settings = client.notification_settings().await;
|
||||
let Some(startup_ts) = MilliSecondsSinceUnixEpoch::from_system_time(SystemTime::now()) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let store = store.clone();
|
||||
client
|
||||
.register_notification_handler(move |notification, room: MatrixRoom, client: Client| {
|
||||
let store = store.clone();
|
||||
let server_settings = server_settings.clone();
|
||||
async move {
|
||||
let mode = global_or_room_mode(&server_settings, &room).await;
|
||||
if mode == RoomNotificationMode::Mute {
|
||||
return;
|
||||
}
|
||||
|
||||
if is_open(&store, room.room_id()).await {
|
||||
return;
|
||||
}
|
||||
|
||||
match parse_notification(notification, room).await {
|
||||
Ok((summary, body, server_ts)) => {
|
||||
if server_ts < startup_ts {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut desktop_notification = notify_rust::Notification::new();
|
||||
desktop_notification
|
||||
.summary(&summary)
|
||||
.appname("iamb")
|
||||
.timeout(notify_rust::Timeout::Milliseconds(3000))
|
||||
.action("default", "default");
|
||||
|
||||
if is_missing_mention(&body, mode, &client) {
|
||||
return;
|
||||
}
|
||||
if show_message != Some(false) {
|
||||
if let Some(body) = body {
|
||||
desktop_notification.body(&body);
|
||||
}
|
||||
}
|
||||
if let Err(err) = desktop_notification.show() {
|
||||
tracing::error!("Failed to send notification: {err}")
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
tracing::error!("Failed to extract notification data: {err}")
|
||||
},
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn global_or_room_mode(
|
||||
settings: &NotificationSettings,
|
||||
room: &MatrixRoom,
|
||||
) -> RoomNotificationMode {
|
||||
let room_mode = settings.get_user_defined_room_notification_mode(room.room_id()).await;
|
||||
if let Some(mode) = room_mode {
|
||||
return mode;
|
||||
}
|
||||
let is_one_to_one = match room.is_direct().await {
|
||||
Ok(true) => IsOneToOne::Yes,
|
||||
_ => IsOneToOne::No,
|
||||
};
|
||||
let is_encrypted = match room.is_encrypted().await {
|
||||
Ok(true) => IsEncrypted::Yes,
|
||||
_ => IsEncrypted::No,
|
||||
};
|
||||
settings
|
||||
.get_default_room_notification_mode(is_encrypted, is_one_to_one)
|
||||
.await
|
||||
}
|
||||
|
||||
fn is_missing_mention(body: &Option<String>, mode: RoomNotificationMode, client: &Client) -> bool {
|
||||
if let Some(body) = body {
|
||||
if mode == RoomNotificationMode::MentionsAndKeywordsOnly {
|
||||
let mentioned = match client.user_id() {
|
||||
Some(user_id) => body.contains(user_id.localpart()),
|
||||
_ => false,
|
||||
};
|
||||
return !mentioned;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
async fn is_open(store: &AsyncProgramStore, room_id: &RoomId) -> bool {
|
||||
let mut locked = store.lock().await;
|
||||
if let Some(draw_curr) = locked.application.draw_curr {
|
||||
let info = locked.application.get_room_info(room_id.to_owned());
|
||||
if let Some(draw_last) = info.draw_last {
|
||||
return draw_last == draw_curr;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub async fn parse_notification(
|
||||
notification: Notification,
|
||||
room: MatrixRoom,
|
||||
) -> IambResult<(String, Option<String>, MilliSecondsSinceUnixEpoch)> {
|
||||
let event = notification.event.deserialize().map_err(IambError::from)?;
|
||||
|
||||
let server_ts = event.origin_server_ts();
|
||||
|
||||
let sender_id = event.sender();
|
||||
let sender = room.get_member_no_sync(sender_id).await.map_err(IambError::from)?;
|
||||
|
||||
let sender_name = sender
|
||||
.as_ref()
|
||||
.and_then(|m| m.display_name())
|
||||
.unwrap_or_else(|| sender_id.localpart());
|
||||
|
||||
let body = event_notification_body(
|
||||
&event,
|
||||
sender_name,
|
||||
room.is_direct().await.map_err(IambError::from)?,
|
||||
)
|
||||
.map(truncate);
|
||||
return Ok((sender_name.to_string(), body, server_ts));
|
||||
}
|
||||
|
||||
pub fn event_notification_body(
|
||||
event: &AnySyncTimelineEvent,
|
||||
sender_name: &str,
|
||||
is_direct: bool,
|
||||
) -> Option<String> {
|
||||
let AnySyncTimelineEvent::MessageLike(event) = event else {
|
||||
return None;
|
||||
};
|
||||
|
||||
match event.original_content()? {
|
||||
AnyMessageLikeEventContent::RoomMessage(message) => {
|
||||
let body = match message.msgtype {
|
||||
MessageType::Audio(_) => {
|
||||
format!("{sender_name} sent an audio file.")
|
||||
},
|
||||
MessageType::Emote(content) => {
|
||||
let message = &content.body;
|
||||
format!("{sender_name}: {message}")
|
||||
},
|
||||
MessageType::File(_) => {
|
||||
format!("{sender_name} sent a file.")
|
||||
},
|
||||
MessageType::Image(_) => {
|
||||
format!("{sender_name} sent an image.")
|
||||
},
|
||||
MessageType::Location(_) => {
|
||||
format!("{sender_name} sent their location.")
|
||||
},
|
||||
MessageType::Notice(content) => {
|
||||
let message = &content.body;
|
||||
format!("{sender_name}: {message}")
|
||||
},
|
||||
MessageType::ServerNotice(content) => {
|
||||
let message = &content.body;
|
||||
format!("{sender_name}: {message}")
|
||||
},
|
||||
MessageType::Text(content) => {
|
||||
if is_direct {
|
||||
content.body
|
||||
} else {
|
||||
let message = &content.body;
|
||||
format!("{sender_name}: {message}")
|
||||
}
|
||||
},
|
||||
MessageType::Video(_) => {
|
||||
format!("{sender_name} sent a video.")
|
||||
},
|
||||
MessageType::VerificationRequest(_) => {
|
||||
format!("{sender_name} sent a verification request.")
|
||||
},
|
||||
_ => unimplemented!(),
|
||||
};
|
||||
Some(body)
|
||||
},
|
||||
AnyMessageLikeEventContent::Sticker(_) => Some(format!("{sender_name} sent a sticker.")),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate(s: String) -> String {
|
||||
static MAX_LENGTH: usize = 100;
|
||||
if s.graphemes(true).count() > MAX_LENGTH {
|
||||
let truncated: String = s.graphemes(true).take(MAX_LENGTH).collect();
|
||||
truncated + "..."
|
||||
} else {
|
||||
s
|
||||
}
|
||||
}
|
|
@ -27,6 +27,7 @@ use crate::{
|
|||
user_style_from_color,
|
||||
ApplicationSettings,
|
||||
DirectoryValues,
|
||||
Notifications,
|
||||
ProfileConfig,
|
||||
SortOverrides,
|
||||
TunableValues,
|
||||
|
@ -164,6 +165,7 @@ pub fn mock_room() -> RoomInfo {
|
|||
fetch_last: None,
|
||||
users_typing: None,
|
||||
display_names: HashMap::new(),
|
||||
draw_last: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -198,6 +200,7 @@ pub fn mock_tunables() -> TunableValues {
|
|||
open_command: None,
|
||||
username_display: UserDisplayStyle::Username,
|
||||
message_user_color: false,
|
||||
notifications: Notifications { enabled: false, show_message: None },
|
||||
image_preview: None,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1437,6 +1437,8 @@ impl<'a> StatefulWidget for Scrollback<'a> {
|
|||
.need_load
|
||||
.insert(state.room_id.to_owned(), Need::MESSAGES);
|
||||
}
|
||||
|
||||
info.draw_last = self.store.application.draw_curr;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -86,6 +86,7 @@ use modalkit::errors::UIError;
|
|||
use modalkit::prelude::{EditInfo, InfoMessage};
|
||||
|
||||
use crate::base::Need;
|
||||
use crate::notifications::register_notifications;
|
||||
use crate::{
|
||||
base::{
|
||||
AsyncProgramStore,
|
||||
|
@ -1242,12 +1243,14 @@ impl ClientWorker {
|
|||
|
||||
self.load_handle = tokio::spawn({
|
||||
let client = self.client.clone();
|
||||
let settings = self.settings.clone();
|
||||
|
||||
async move {
|
||||
let load = load_older_forever(&client, &store);
|
||||
let rcpt = send_receipts_forever(&client, &store);
|
||||
let room = refresh_rooms_forever(&client, &store);
|
||||
let ((), (), ()) = tokio::join!(load, rcpt, room);
|
||||
let notifications = register_notifications(&client, &settings, &store);
|
||||
let ((), (), (), ()) = tokio::join!(load, rcpt, room, notifications);
|
||||
}
|
||||
})
|
||||
.into();
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue