Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for desktop notifications #192

Merged
merged 1 commit into from
Mar 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
610 changes: 583 additions & 27 deletions Cargo.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ libc = "0.2"
markup5ever_rcdom = "0.2.0"
mime = "^0.3.16"
mime_guess = "^2.0.4"
notify-rust = { version = "4.10.0", default-features = false, features = ["zbus", "serde"] }
open = "3.2.0"
rand = "0.8.5"
ratatui = "0.23"
Expand Down
10 changes: 10 additions & 0 deletions docs/iamb.5.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,16 @@ overridden as described in *PROFILES*.
**message_user_color** (type: boolean)
> Defines whether or not the message body is colored like the username.

**notifications** (type: notifications object)
> Configures push-notifications, which are delivered as desktop
> notifications if available.
> *enabled* `true` to enable the feature, defaults to `false`.
> *show_message* to show the message in the desktop notification. Defaults
> to `true`. Messages are truncated beyond a small length.
> The notification _rules_ are stored server side, loaded once at startup,
> and are currently not configurable in iamb. In other words, you can
> simply change the rules with another client.

**image_preview** (type: image_preview object)
> Enable image previews and configure it. An empty object will enable the
> feature with default settings, omitting it will disable the feature.
Expand Down
7 changes: 7 additions & 0 deletions src/base.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -1212,6 +1218,7 @@ impl ChatStore {
verifications: Default::default(),
need_load: Default::default(),
sync_info: Default::default(),
draw_curr: None,
}
}

Expand Down
10 changes: 10 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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>,
}

Expand All @@ -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>,
}

Expand All @@ -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),
}
}
Expand All @@ -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),
}
}
Expand Down
4 changes: 3 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -66,6 +66,7 @@ mod commands;
mod config;
mod keybindings;
mod message;
mod notifications;
mod preview;
mod sled_export;
mod util;
Expand Down Expand Up @@ -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)
Expand Down
221 changes: 221 additions & 0 deletions src/notifications.rs
Original file line number Diff line number Diff line change
@@ -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
}
}
3 changes: 3 additions & 0 deletions src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ use crate::{
user_style_from_color,
ApplicationSettings,
DirectoryValues,
Notifications,
ProfileConfig,
SortOverrides,
TunableValues,
Expand Down Expand Up @@ -164,6 +165,7 @@ pub fn mock_room() -> RoomInfo {
fetch_last: None,
users_typing: None,
display_names: HashMap::new(),
draw_last: None,
}
}

Expand Down Expand Up @@ -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,
}
}
Expand Down
2 changes: 2 additions & 0 deletions src/windows/room/scrollback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

Expand Down