Compare commits

...

7 commits

12 changed files with 171 additions and 27 deletions

10
Cargo.lock generated
View file

@ -2274,6 +2274,7 @@ dependencies = [
"image", "image",
"lazy_static 1.5.0", "lazy_static 1.5.0",
"libc", "libc",
"linkify",
"markup5ever_rcdom", "markup5ever_rcdom",
"matrix-sdk", "matrix-sdk",
"mime", "mime",
@ -2831,6 +2832,15 @@ dependencies = [
"vcpkg", "vcpkg",
] ]
[[package]]
name = "linkify"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dfa36d52c581e9ec783a7ce2a5e0143da6237be5811a0b3153fedfdbe9f780"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
version = "0.3.8" version = "0.3.8"

View file

@ -64,6 +64,7 @@ unicode-width = "0.1.10"
url = {version = "^2.2.2", features = ["serde"]} url = {version = "^2.2.2", features = ["serde"]}
edit = "0.1.4" edit = "0.1.4"
humansize = "2.0.0" humansize = "2.0.0"
linkify = "0.10.0"
[dependencies.comrak] [dependencies.comrak]
version = "0.22.0" version = "0.22.0"

View file

@ -173,6 +173,9 @@ respective shortcodes.
.It Sy message_user_color .It Sy message_user_color
Defines whether or not the message body is colored like the username. Defines whether or not the message body is colored like the username.
.It Sy normal_after_send
Defines whether to reset input to Normal mode after sending a message.
.It Sy notifications .It Sy notifications
When this subsection is present, you can enable and configure push notifications. When this subsection is present, you can enable and configure push notifications.
See See

View file

@ -92,6 +92,7 @@ use modalkit::{
use crate::config::ImagePreviewProtocolValues; use crate::config::ImagePreviewProtocolValues;
use crate::message::ImageStatus; use crate::message::ImageStatus;
use crate::notifications::NotificationHandle;
use crate::preview::{source_from_event, spawn_insert_preview}; use crate::preview::{source_from_event, spawn_insert_preview};
use crate::{ use crate::{
message::{Message, MessageEvent, MessageKey, MessageTimeStamp, Messages}, message::{Message, MessageEvent, MessageKey, MessageTimeStamp, Messages},
@ -1558,6 +1559,9 @@ pub struct ChatStore {
/// Collator for locale-aware text sorting. /// Collator for locale-aware text sorting.
pub collator: feruca::Collator, pub collator: feruca::Collator,
/// Notifications that should be dismissed when the user opens the room.
pub open_notifications: HashMap<OwnedRoomId, Vec<NotificationHandle>>,
} }
impl ChatStore { impl ChatStore {
@ -1582,6 +1586,7 @@ impl ChatStore {
draw_curr: None, draw_curr: None,
ring_bell: false, ring_bell: false,
focused: true, focused: true,
open_notifications: Default::default(),
} }
} }

View file

@ -2,6 +2,7 @@
use std::borrow::Cow; use std::borrow::Cow;
use std::collections::hash_map::DefaultHasher; use std::collections::hash_map::DefaultHasher;
use std::collections::{BTreeMap, HashMap}; use std::collections::{BTreeMap, HashMap};
use std::env;
use std::fmt; use std::fmt;
use std::fs::File; use std::fs::File;
use std::hash::{Hash, Hasher}; use std::hash::{Hash, Hasher};
@ -399,6 +400,9 @@ pub enum UserDisplayStyle {
// it can wind up being the Matrix username if there are display name collisions in the room, // it can wind up being the Matrix username if there are display name collisions in the room,
// in order to avoid any confusion. // in order to avoid any confusion.
DisplayName, DisplayName,
// Acts like Username, except when the username matches given regex, then acts like DisplayName
Regex,
} }
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
@ -557,6 +561,7 @@ impl SortOverrides {
pub struct TunableValues { pub struct TunableValues {
pub log_level: Level, pub log_level: Level,
pub message_shortcode_display: bool, pub message_shortcode_display: bool,
pub normal_after_send: bool,
pub reaction_display: bool, pub reaction_display: bool,
pub reaction_shortcode_display: bool, pub reaction_shortcode_display: bool,
pub read_receipt_send: bool, pub read_receipt_send: bool,
@ -568,6 +573,7 @@ pub struct TunableValues {
pub typing_notice_display: bool, pub typing_notice_display: bool,
pub users: UserOverrides, pub users: UserOverrides,
pub username_display: UserDisplayStyle, pub username_display: UserDisplayStyle,
pub username_display_regex: Option<String>,
pub message_user_color: bool, pub message_user_color: bool,
pub default_room: Option<String>, pub default_room: Option<String>,
pub open_command: Option<Vec<String>>, pub open_command: Option<Vec<String>>,
@ -583,6 +589,7 @@ pub struct TunableValues {
pub struct Tunables { pub struct Tunables {
pub log_level: Option<LogLevel>, pub log_level: Option<LogLevel>,
pub message_shortcode_display: Option<bool>, pub message_shortcode_display: Option<bool>,
pub normal_after_send: Option<bool>,
pub reaction_display: Option<bool>, pub reaction_display: Option<bool>,
pub reaction_shortcode_display: Option<bool>, pub reaction_shortcode_display: Option<bool>,
pub read_receipt_send: Option<bool>, pub read_receipt_send: Option<bool>,
@ -595,6 +602,7 @@ pub struct Tunables {
pub typing_notice_display: Option<bool>, pub typing_notice_display: Option<bool>,
pub users: Option<UserOverrides>, pub users: Option<UserOverrides>,
pub username_display: Option<UserDisplayStyle>, pub username_display: Option<UserDisplayStyle>,
pub username_display_regex: Option<String>,
pub message_user_color: Option<bool>, pub message_user_color: Option<bool>,
pub default_room: Option<String>, pub default_room: Option<String>,
pub open_command: Option<Vec<String>>, pub open_command: Option<Vec<String>>,
@ -613,6 +621,7 @@ impl Tunables {
message_shortcode_display: self message_shortcode_display: self
.message_shortcode_display .message_shortcode_display
.or(other.message_shortcode_display), .or(other.message_shortcode_display),
normal_after_send: self.normal_after_send.or(other.normal_after_send),
reaction_display: self.reaction_display.or(other.reaction_display), reaction_display: self.reaction_display.or(other.reaction_display),
reaction_shortcode_display: self reaction_shortcode_display: self
.reaction_shortcode_display .reaction_shortcode_display
@ -626,6 +635,7 @@ impl Tunables {
typing_notice_display: self.typing_notice_display.or(other.typing_notice_display), typing_notice_display: self.typing_notice_display.or(other.typing_notice_display),
users: merge_maps(self.users, other.users), users: merge_maps(self.users, other.users),
username_display: self.username_display.or(other.username_display), username_display: self.username_display.or(other.username_display),
username_display_regex: self.username_display_regex.or(other.username_display_regex),
message_user_color: self.message_user_color.or(other.message_user_color), message_user_color: self.message_user_color.or(other.message_user_color),
default_room: self.default_room.or(other.default_room), default_room: self.default_room.or(other.default_room),
open_command: self.open_command.or(other.open_command), open_command: self.open_command.or(other.open_command),
@ -644,6 +654,7 @@ impl Tunables {
TunableValues { TunableValues {
log_level: self.log_level.map(Level::from).unwrap_or(Level::INFO), log_level: self.log_level.map(Level::from).unwrap_or(Level::INFO),
message_shortcode_display: self.message_shortcode_display.unwrap_or(false), message_shortcode_display: self.message_shortcode_display.unwrap_or(false),
normal_after_send: self.normal_after_send.unwrap_or(false),
reaction_display: self.reaction_display.unwrap_or(true), reaction_display: self.reaction_display.unwrap_or(true),
reaction_shortcode_display: self.reaction_shortcode_display.unwrap_or(false), reaction_shortcode_display: self.reaction_shortcode_display.unwrap_or(false),
read_receipt_send: self.read_receipt_send.unwrap_or(true), read_receipt_send: self.read_receipt_send.unwrap_or(true),
@ -655,6 +666,7 @@ impl Tunables {
typing_notice_display: self.typing_notice_display.unwrap_or(true), typing_notice_display: self.typing_notice_display.unwrap_or(true),
users: self.users.unwrap_or_default(), users: self.users.unwrap_or_default(),
username_display: self.username_display.unwrap_or_default(), username_display: self.username_display.unwrap_or_default(),
username_display_regex: self.username_display_regex,
message_user_color: self.message_user_color.unwrap_or(false), message_user_color: self.message_user_color.unwrap_or(false),
default_room: self.default_room, default_room: self.default_room,
open_command: self.open_command, open_command: self.open_command,
@ -837,14 +849,22 @@ pub struct ApplicationSettings {
} }
impl ApplicationSettings { impl ApplicationSettings {
fn get_xdg_config_home() -> Option<PathBuf> {
env::var("XDG_CONFIG_HOME").ok().map(PathBuf::from)
}
pub fn load(cli: Iamb) -> Result<Self, Box<dyn std::error::Error>> { pub fn load(cli: Iamb) -> Result<Self, Box<dyn std::error::Error>> {
let mut config_dir = cli.config_directory.or_else(dirs::config_dir).unwrap_or_else(|| { let mut config_dir = cli
usage!( .config_directory
"No user configuration directory found;\ .or_else(Self::get_xdg_config_home)
please specify one via -C.\n\n .or_else(dirs::config_dir)
For more information try '--help'" .unwrap_or_else(|| {
); usage!(
}); "No user configuration directory found;\
please specify one via -C.\n\n
For more information try '--help'"
);
});
config_dir.push("iamb"); config_dir.push("iamb");
let config_json = config_dir.join("config.json"); let config_json = config_dir.join("config.json");
@ -1048,6 +1068,20 @@ impl ApplicationSettings {
Cow::Borrowed(user_id.as_str()) Cow::Borrowed(user_id.as_str())
} }
}, },
(None, UserDisplayStyle::Regex) => {
let re = regex::Regex::new(
&self.tunables.username_display_regex.clone().unwrap_or("*".into()),
)
.unwrap();
if !re.is_match(user_id.as_str()) {
Cow::Borrowed(user_id.as_str())
} else if let Some(display) = info.display_names.get(user_id) {
Cow::Borrowed(display.as_str())
} else {
Cow::Borrowed(user_id.as_str())
}
},
}; };
Span::styled(name, style) Span::styled(name, style)
@ -1164,6 +1198,13 @@ mod tests {
let res: Tunables = let res: Tunables =
serde_json::from_str("{\"username_display\": \"displayname\"}").unwrap(); serde_json::from_str("{\"username_display\": \"displayname\"}").unwrap();
assert_eq!(res.username_display, Some(UserDisplayStyle::DisplayName)); assert_eq!(res.username_display, Some(UserDisplayStyle::DisplayName));
let res: Tunables = serde_json::from_str(
"{\"username_display\": \"regex\",\n\"username_display_regex\": \"foo\"}",
)
.unwrap();
assert_eq!(res.username_display, Some(UserDisplayStyle::Regex));
assert_eq!(res.username_display_regex.unwrap_or("FAILED".into()), "foo".to_string());
} }
#[test] #[test]

View file

@ -561,6 +561,9 @@ impl Application {
IambAction::ClearUnreads => { IambAction::ClearUnreads => {
let user_id = &store.application.settings.profile.user_id; let user_id = &store.application.settings.profile.user_id;
// Clear any notifications we displayed:
store.application.open_notifications.clear();
for room_id in store.application.sync_info.chats() { for room_id in store.application.sync_info.chats() {
if let Some(room) = store.application.rooms.get_mut(room_id) { if let Some(room) = store.application.rooms.get_mut(room_id) {
room.fully_read(user_id); room.fully_read(user_id);
@ -596,6 +599,9 @@ impl Application {
None None
}, },
IambAction::Send(act) => { IambAction::Send(act) => {
if store.application.settings.tunables.normal_after_send {
self.bindings.reset_mode();
}
self.screen.current_window_mut()?.send_command(act, ctx, store).await? self.screen.current_window_mut()?.send_command(act, ctx, store).await?
}, },

View file

@ -41,6 +41,8 @@ use crate::{
util::{join_cell_text, space_text}, util::{join_cell_text, space_text},
}; };
const QUOTE_COLOR: Color = Color::Indexed(236);
/// Generate bullet points from a [ListStyle]. /// Generate bullet points from a [ListStyle].
pub struct BulletIterator { pub struct BulletIterator {
style: ListStyle, style: ListStyle,
@ -351,11 +353,14 @@ impl StyleTreeNode {
printer.push_span_nobreak(span); printer.push_span_nobreak(span);
}, },
StyleTreeNode::Blockquote(child) => { StyleTreeNode::Blockquote(child) => {
let mut subp = printer.sub(4); let mut subp = printer.sub(3);
child.print(&mut subp, style); child.print(&mut subp, style);
for mut line in subp.finish() { for mut line in subp.finish() {
line.spans.insert(0, Span::styled(" ", style)); line.spans.insert(0, Span::styled(" ", style));
line.spans
.insert(0, Span::styled(line::THICK_VERTICAL, style.fg(QUOTE_COLOR)));
line.spans.insert(0, Span::styled(" ", style));
printer.push_line(line); printer.push_line(line);
} }
}, },
@ -524,11 +529,11 @@ impl StyleTree {
} }
pub struct TreeGenState { pub struct TreeGenState {
link_num: u8, pub link_num: u8,
} }
impl TreeGenState { impl TreeGenState {
fn next_link_char(&mut self) -> Option<char> { pub fn next_link_char(&mut self) -> Option<char> {
let num = self.link_num; let num = self.link_num;
if num < 62 { if num < 62 {
@ -1075,14 +1080,29 @@ pub mod tests {
let s = "<blockquote>Hello world!</blockquote>"; let s = "<blockquote>Hello world!</blockquote>";
let tree = parse_matrix_html(s); let tree = parse_matrix_html(s);
let text = tree.to_text(10, Style::default(), false, &settings); let text = tree.to_text(10, Style::default(), false, &settings);
let style = Style::new().fg(QUOTE_COLOR);
assert_eq!(text.lines.len(), 2); assert_eq!(text.lines.len(), 2);
assert_eq!( assert_eq!(
text.lines[0], text.lines[0],
Line::from(vec![Span::raw(" "), Span::raw("Hello"), Span::raw(" ")]) Line::from(vec![
Span::raw(" "),
Span::styled(line::THICK_VERTICAL, style),
Span::raw(" "),
Span::raw("Hello"),
Span::raw(" "),
Span::raw(" "),
])
); );
assert_eq!( assert_eq!(
text.lines[1], text.lines[1],
Line::from(vec![Span::raw(" "), Span::raw("world"), Span::raw("!")]) Line::from(vec![
Span::raw(" "),
Span::styled(line::THICK_VERTICAL, style),
Span::raw(" "),
Span::raw("world"),
Span::raw("!"),
Span::raw(" "),
])
); );
} }

View file

@ -72,6 +72,7 @@ mod state;
pub use self::compose::text_to_message; pub use self::compose::text_to_message;
use self::state::{body_cow_state, html_state}; use self::state::{body_cow_state, html_state};
pub use html::TreeGenState;
type ProtocolPreview<'a> = (&'a Protocol, u16, u16); type ProtocolPreview<'a> = (&'a Protocol, u16, u16);

View file

@ -8,6 +8,7 @@ use matrix_sdk::{
events::{room::message::MessageType, AnyMessageLikeEventContent, AnySyncTimelineEvent}, events::{room::message::MessageType, AnyMessageLikeEventContent, AnySyncTimelineEvent},
serde::Raw, serde::Raw,
MilliSecondsSinceUnixEpoch, MilliSecondsSinceUnixEpoch,
OwnedRoomId,
RoomId, RoomId,
}, },
Client, Client,
@ -24,6 +25,21 @@ const IAMB_XDG_NAME: &str = match option_env!("IAMB_XDG_NAME") {
Some(iamb) => iamb, Some(iamb) => iamb,
}; };
/// Handle for an open notification that should be closed when the user views it.
pub struct NotificationHandle(
#[cfg(all(feature = "desktop", unix, not(target_os = "macos")))]
Option<notify_rust::NotificationHandle>,
);
impl Drop for NotificationHandle {
fn drop(&mut self) {
#[cfg(all(feature = "desktop", unix, not(target_os = "macos")))]
if let Some(handle) = self.0.take() {
handle.close();
}
}
}
pub async fn register_notifications( pub async fn register_notifications(
client: &Client, client: &Client,
settings: &ApplicationSettings, settings: &ApplicationSettings,
@ -54,6 +70,7 @@ pub async fn register_notifications(
return; return;
} }
let room_id = room.room_id().to_owned();
match notification.event { match notification.event {
RawAnySyncOrStrippedTimelineEvent::Sync(e) => { RawAnySyncOrStrippedTimelineEvent::Sync(e) => {
match parse_full_notification(e, room, show_message).await { match parse_full_notification(e, room, show_message).await {
@ -66,8 +83,14 @@ pub async fn register_notifications(
return; return;
} }
send_notification(&notify_via, &store, &summary, body.as_deref()) send_notification(
.await; &notify_via,
&summary,
body.as_deref(),
room_id,
&store,
)
.await;
}, },
Err(err) => { Err(err) => {
tracing::error!("Failed to extract notification data: {err}") tracing::error!("Failed to extract notification data: {err}")
@ -86,13 +109,14 @@ pub async fn register_notifications(
async fn send_notification( async fn send_notification(
via: &NotifyVia, via: &NotifyVia,
store: &AsyncProgramStore,
summary: &str, summary: &str,
body: Option<&str>, body: Option<&str>,
room_id: OwnedRoomId,
store: &AsyncProgramStore,
) { ) {
#[cfg(feature = "desktop")] #[cfg(feature = "desktop")]
if via.desktop { if via.desktop {
send_notification_desktop(summary, body); send_notification_desktop(summary, body, room_id, store).await;
} }
#[cfg(not(feature = "desktop"))] #[cfg(not(feature = "desktop"))]
{ {
@ -110,7 +134,12 @@ async fn send_notification_bell(store: &AsyncProgramStore) {
} }
#[cfg(feature = "desktop")] #[cfg(feature = "desktop")]
fn send_notification_desktop(summary: &str, body: Option<&str>) { async fn send_notification_desktop(
summary: &str,
body: Option<&str>,
room_id: OwnedRoomId,
_store: &AsyncProgramStore,
) {
let mut desktop_notification = notify_rust::Notification::new(); let mut desktop_notification = notify_rust::Notification::new();
desktop_notification desktop_notification
.summary(summary) .summary(summary)
@ -125,8 +154,19 @@ fn send_notification_desktop(summary: &str, body: Option<&str>) {
desktop_notification.body(body); desktop_notification.body(body);
} }
if let Err(err) = desktop_notification.show() { match desktop_notification.show() {
tracing::error!("Failed to send notification: {err}") Err(err) => tracing::error!("Failed to send notification: {err}"),
Ok(handle) => {
#[cfg(all(unix, not(target_os = "macos")))]
_store
.lock()
.await
.application
.open_notifications
.entry(room_id)
.or_default()
.push(NotificationHandle(Some(handle)));
},
} }
} }

View file

@ -171,6 +171,7 @@ pub fn mock_tunables() -> TunableValues {
default_room: None, default_room: None,
log_level: Level::INFO, log_level: Level::INFO,
message_shortcode_display: false, message_shortcode_display: false,
normal_after_send: true,
reaction_display: true, reaction_display: true,
reaction_shortcode_display: false, reaction_shortcode_display: false,
read_receipt_send: true, read_receipt_send: true,
@ -189,6 +190,7 @@ pub fn mock_tunables() -> TunableValues {
open_command: None, open_command: None,
external_edit_file_suffix: String::from(".md"), external_edit_file_suffix: String::from(".md"),
username_display: UserDisplayStyle::Username, username_display: UserDisplayStyle::Username,
username_display_regex: Some(String::from(".*")),
message_user_color: false, message_user_color: false,
mouse: Default::default(), mouse: Default::default(),
notifications: Notifications { notifications: Notifications {

View file

@ -86,7 +86,14 @@ use crate::base::{
SendAction, SendAction,
}; };
use crate::message::{text_to_message, Message, MessageEvent, MessageKey, MessageTimeStamp}; use crate::message::{
text_to_message,
Message,
MessageEvent,
MessageKey,
MessageTimeStamp,
TreeGenState,
};
use crate::worker::Requester; use crate::worker::Requester;
use super::scrollback::{Scrollback, ScrollbackState}; use super::scrollback::{Scrollback, ScrollbackState};
@ -226,10 +233,14 @@ impl ChatState {
let links = if let Some(html) = &msg.html { let links = if let Some(html) = &msg.html {
html.get_links() html.get_links()
} else if let Ok(url) = Url::parse(&msg.event.body()) {
vec![('0', url)]
} else { } else {
vec![] linkify::LinkFinder::new()
.links(&msg.event.body())
.filter_map(|u| Url::parse(u.as_str()).ok())
.scan(TreeGenState { link_num: 0 }, |state, u| {
state.next_link_char().map(|c| (c, u))
})
.collect()
}; };
if links.is_empty() { if links.is_empty() {

View file

@ -502,19 +502,23 @@ async fn send_receipts_forever(client: &Client, store: &AsyncProgramStore) {
loop { loop {
interval.tick().await; interval.tick().await;
let locked = store.lock().await; let mut locked = store.lock().await;
let user_id = &locked.application.settings.profile.user_id; let ChatStore { settings, open_notifications, rooms, .. } = &mut locked.application;
let user_id = &settings.profile.user_id;
let mut updates = Vec::new(); let mut updates = Vec::new();
for room in client.joined_rooms() { for room in client.joined_rooms() {
let room_id = room.room_id(); let room_id = room.room_id();
let Some(info) = locked.application.rooms.get(&room_id) else { let Some(info) = rooms.get(room_id) else {
continue; continue;
}; };
let changed = info.receipts(user_id).filter_map(|(thread, new_receipt)| { let changed = info.receipts(user_id).filter_map(|(thread, new_receipt)| {
let old_receipt = sent.get(room_id).and_then(|ts| ts.get(thread)); let old_receipt = sent.get(room_id).and_then(|ts| ts.get(thread));
let changed = Some(new_receipt) != old_receipt; let changed = Some(new_receipt) != old_receipt;
if changed {
open_notifications.remove(room_id);
}
changed.then(|| (room_id.to_owned(), thread.to_owned(), new_receipt.to_owned())) changed.then(|| (room_id.to_owned(), thread.to_owned(), new_receipt.to_owned()))
}); });