Compare commits

..

7 commits

12 changed files with 142 additions and 27 deletions

10
Cargo.lock generated
View file

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

View file

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

View file

@ -173,6 +173,9 @@ respective shortcodes.
.It Sy message_user_color
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
When this subsection is present, you can enable and configure push notifications.
See

View file

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

View file

@ -2,6 +2,7 @@
use std::borrow::Cow;
use std::collections::hash_map::DefaultHasher;
use std::collections::{BTreeMap, HashMap};
use std::env;
use std::fmt;
use std::fs::File;
use std::hash::{Hash, Hasher};
@ -560,6 +561,7 @@ impl SortOverrides {
pub struct TunableValues {
pub log_level: Level,
pub message_shortcode_display: bool,
pub normal_after_send: bool,
pub reaction_display: bool,
pub reaction_shortcode_display: bool,
pub read_receipt_send: bool,
@ -587,6 +589,7 @@ pub struct TunableValues {
pub struct Tunables {
pub log_level: Option<LogLevel>,
pub message_shortcode_display: Option<bool>,
pub normal_after_send: Option<bool>,
pub reaction_display: Option<bool>,
pub reaction_shortcode_display: Option<bool>,
pub read_receipt_send: Option<bool>,
@ -618,6 +621,7 @@ impl Tunables {
message_shortcode_display: self
.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_shortcode_display: self
.reaction_shortcode_display
@ -650,6 +654,7 @@ impl Tunables {
TunableValues {
log_level: self.log_level.map(Level::from).unwrap_or(Level::INFO),
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_shortcode_display: self.reaction_shortcode_display.unwrap_or(false),
read_receipt_send: self.read_receipt_send.unwrap_or(true),
@ -844,14 +849,22 @@ pub struct 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>> {
let mut config_dir = cli.config_directory.or_else(dirs::config_dir).unwrap_or_else(|| {
usage!(
"No user configuration directory found;\
please specify one via -C.\n\n
For more information try '--help'"
);
});
let mut config_dir = cli
.config_directory
.or_else(Self::get_xdg_config_home)
.or_else(dirs::config_dir)
.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");
let config_json = config_dir.join("config.json");

View file

@ -561,6 +561,9 @@ impl Application {
IambAction::ClearUnreads => {
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() {
if let Some(room) = store.application.rooms.get_mut(room_id) {
room.fully_read(user_id);
@ -596,6 +599,9 @@ impl Application {
None
},
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?
},

View file

@ -41,6 +41,8 @@ use crate::{
util::{join_cell_text, space_text},
};
const QUOTE_COLOR: Color = Color::Indexed(236);
/// Generate bullet points from a [ListStyle].
pub struct BulletIterator {
style: ListStyle,
@ -351,11 +353,14 @@ impl StyleTreeNode {
printer.push_span_nobreak(span);
},
StyleTreeNode::Blockquote(child) => {
let mut subp = printer.sub(4);
let mut subp = printer.sub(3);
child.print(&mut subp, style);
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);
}
},
@ -524,11 +529,11 @@ impl StyleTree {
}
pub struct TreeGenState {
link_num: u8,
pub link_num: u8,
}
impl TreeGenState {
fn next_link_char(&mut self) -> Option<char> {
pub fn next_link_char(&mut self) -> Option<char> {
let num = self.link_num;
if num < 62 {
@ -1075,14 +1080,29 @@ pub mod tests {
let s = "<blockquote>Hello world!</blockquote>";
let tree = parse_matrix_html(s);
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[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!(
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;
use self::state::{body_cow_state, html_state};
pub use html::TreeGenState;
type ProtocolPreview<'a> = (&'a Protocol, u16, u16);

View file

@ -8,6 +8,7 @@ use matrix_sdk::{
events::{room::message::MessageType, AnyMessageLikeEventContent, AnySyncTimelineEvent},
serde::Raw,
MilliSecondsSinceUnixEpoch,
OwnedRoomId,
RoomId,
},
Client,
@ -24,6 +25,21 @@ const IAMB_XDG_NAME: &str = match option_env!("IAMB_XDG_NAME") {
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(
client: &Client,
settings: &ApplicationSettings,
@ -54,6 +70,7 @@ pub async fn register_notifications(
return;
}
let room_id = room.room_id().to_owned();
match notification.event {
RawAnySyncOrStrippedTimelineEvent::Sync(e) => {
match parse_full_notification(e, room, show_message).await {
@ -66,8 +83,14 @@ pub async fn register_notifications(
return;
}
send_notification(&notify_via, &store, &summary, body.as_deref())
.await;
send_notification(
&notify_via,
&summary,
body.as_deref(),
room_id,
&store,
)
.await;
},
Err(err) => {
tracing::error!("Failed to extract notification data: {err}")
@ -86,13 +109,14 @@ pub async fn register_notifications(
async fn send_notification(
via: &NotifyVia,
store: &AsyncProgramStore,
summary: &str,
body: Option<&str>,
room_id: OwnedRoomId,
store: &AsyncProgramStore,
) {
#[cfg(feature = "desktop")]
if via.desktop {
send_notification_desktop(summary, body);
send_notification_desktop(summary, body, room_id, store).await;
}
#[cfg(not(feature = "desktop"))]
{
@ -110,7 +134,12 @@ async fn send_notification_bell(store: &AsyncProgramStore) {
}
#[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();
desktop_notification
.summary(summary)
@ -125,8 +154,19 @@ fn send_notification_desktop(summary: &str, body: Option<&str>) {
desktop_notification.body(body);
}
if let Err(err) = desktop_notification.show() {
tracing::error!("Failed to send notification: {err}")
match desktop_notification.show() {
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,
log_level: Level::INFO,
message_shortcode_display: false,
normal_after_send: true,
reaction_display: true,
reaction_shortcode_display: false,
read_receipt_send: true,

View file

@ -86,7 +86,14 @@ use crate::base::{
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 super::scrollback::{Scrollback, ScrollbackState};
@ -226,10 +233,14 @@ impl ChatState {
let links = if let Some(html) = &msg.html {
html.get_links()
} else if let Ok(url) = Url::parse(&msg.event.body()) {
vec![('0', url)]
} 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() {

View file

@ -502,19 +502,23 @@ async fn send_receipts_forever(client: &Client, store: &AsyncProgramStore) {
loop {
interval.tick().await;
let locked = store.lock().await;
let user_id = &locked.application.settings.profile.user_id;
let mut locked = store.lock().await;
let ChatStore { settings, open_notifications, rooms, .. } = &mut locked.application;
let user_id = &settings.profile.user_id;
let mut updates = Vec::new();
for room in client.joined_rooms() {
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;
};
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 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()))
});