diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 34e64a4..1b5d652 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1 +1,27 @@ # Contributing to iamb + +## Building + +You can build `iamb` locally by using `cargo build`. + +## Pull Requests + +When making changes to `iamb`, please make sure to: + +- Add new tests for fixed bugs and new features whenever possible +- Add new documentation with new features + +If you're adding a large amount of new code, please make sure to look at a test +coverage report and ensure that your tests sufficiently cover your changes. + +You can generate an HTML report with [cargo-tarpaulin] by running: + +``` +% cargo tarpaulin --avoid-cfg-tarpaulin --out html +``` + +## Tests + +You can run the unit tests and documentation tests using `cargo test`. + +[cargo-tarpaulin]: https://github.com/xd009642/tarpaulin diff --git a/Cargo.toml b/Cargo.toml index 35b1237..67a67eb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ description = "A Matrix chat client that uses Vim keybindings" license = "Apache-2.0" exclude = [".github", "CONTRIBUTING.md"] keywords = ["matrix", "chat", "tui", "vim"] -rust-version = "1.65" +rust-version = "1.66" [dependencies] chrono = "0.4" diff --git a/README.md b/README.md index e590be1..4219f71 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ You can create a basic configuration in `$CONFIG_DIR/iamb/config.json` that look "profiles": { "example.com": { "url": "https://example.com", - "@user:example.com" + "user_id": "@user:example.com" } } } @@ -51,7 +51,7 @@ two other TUI clients and Element Web: | Display read markers | :x: ([#11]) | :x: | :x: | :heavy_check_mark: | | Sending Invites | :x: ([#7]) | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | | Accepting Invites | :x: ([#7]) | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | -| Typing Notification | :x: ([#9]) | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | +| Typing Notification | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | | E2E | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: | | Replies | :x: ([#3]) | :heavy_check_mark: | :x: | :heavy_check_mark: | | Attachment uploading | :x: ([#13]) | :x: | :heavy_check_mark: | :heavy_check_mark: | diff --git a/src/base.rs b/src/base.rs index 4857d04..2b1decd 100644 --- a/src/base.rs +++ b/src/base.rs @@ -8,7 +8,7 @@ use tracing::warn; use matrix_sdk::{ encryption::verification::SasVerification, - ruma::{OwnedRoomId, RoomId}, + ruma::{OwnedRoomId, OwnedUserId, RoomId}, }; use modalkit::{ @@ -32,10 +32,16 @@ use modalkit::{ }, input::bindings::SequenceStatus, input::key::TerminalKey, + tui::{ + buffer::Buffer, + layout::{Alignment, Rect}, + text::{Span, Spans}, + widgets::{Paragraph, Widget}, + }, }; use crate::{ - message::{Message, Messages}, + message::{user_style, Message, Messages}, worker::Requester, ApplicationSettings, }; @@ -167,12 +173,84 @@ pub struct RoomInfo { pub messages: Messages, pub fetch_id: RoomFetchStatus, pub fetch_last: Option, + pub users_typing: Option<(Instant, Vec)>, } impl RoomInfo { fn recently_fetched(&self) -> bool { self.fetch_last.map_or(false, |i| i.elapsed() < ROOM_FETCH_DEBOUNCE) } + + fn get_typers(&self) -> &[OwnedUserId] { + if let Some((t, users)) = &self.users_typing { + if t.elapsed() < Duration::from_secs(4) { + return users.as_ref(); + } else { + return &[]; + } + } else { + return &[]; + } + } + + fn get_typing_spans(&self) -> Spans { + let typers = self.get_typers(); + let n = typers.len(); + + match n { + 0 => Spans(vec![]), + 1 => { + let user = typers[0].as_str(); + let user = Span::styled(user, user_style(user)); + + Spans(vec![user, Span::from(" is typing...")]) + }, + 2 => { + let user1 = typers[0].as_str(); + let user1 = Span::styled(user1, user_style(user1)); + + let user2 = typers[1].as_str(); + let user2 = Span::styled(user2, user_style(user2)); + + Spans(vec![ + user1, + Span::raw(" and "), + user2, + Span::from(" are typing..."), + ]) + }, + n if n < 5 => Spans::from("Several people are typing..."), + _ => Spans::from("Many people are typing..."), + } + } + + pub fn set_typing(&mut self, user_ids: Vec) { + self.users_typing = (Instant::now(), user_ids).into(); + } + + pub fn render_typing( + &mut self, + area: Rect, + buf: &mut Buffer, + settings: &ApplicationSettings, + ) -> Rect { + if area.height <= 2 || area.width <= 20 { + return area; + } + + if !settings.tunables.typing_notice_display { + return area; + } + + let top = Rect::new(area.x, area.y, area.width, area.height - 1); + let bar = Rect::new(area.x, area.y + top.height, area.width, 1); + + Paragraph::new(self.get_typing_spans()) + .alignment(Alignment::Center) + .render(bar, buf); + + return top; + } } pub struct ChatStore { @@ -326,3 +404,74 @@ impl ApplicationInfo for IambInfo { type WindowId = IambId; type ContentId = IambBufferId; } + +#[cfg(test)] +pub mod tests { + use super::*; + use crate::tests::*; + + #[test] + fn test_typing_spans() { + let mut info = RoomInfo::default(); + + let users0 = vec![]; + let users1 = vec![TEST_USER1.clone()]; + let users2 = vec![TEST_USER1.clone(), TEST_USER2.clone()]; + let users4 = vec![ + TEST_USER1.clone(), + TEST_USER2.clone(), + TEST_USER3.clone(), + TEST_USER4.clone(), + ]; + let users5 = vec![ + TEST_USER1.clone(), + TEST_USER2.clone(), + TEST_USER3.clone(), + TEST_USER4.clone(), + TEST_USER5.clone(), + ]; + + // Nothing set. + assert_eq!(info.users_typing, None); + assert_eq!(info.get_typing_spans(), Spans(vec![])); + + // Empty typing list. + info.set_typing(users0); + assert!(info.users_typing.is_some()); + assert_eq!(info.get_typing_spans(), Spans(vec![])); + + // Single user typing. + info.set_typing(users1); + assert!(info.users_typing.is_some()); + assert_eq!( + info.get_typing_spans(), + Spans(vec![ + Span::styled("@user1:example.com", user_style("@user1:example.com")), + Span::from(" is typing...") + ]) + ); + + // Two users typing. + info.set_typing(users2); + assert!(info.users_typing.is_some()); + assert_eq!( + info.get_typing_spans(), + Spans(vec![ + Span::styled("@user1:example.com", user_style("@user1:example.com")), + Span::raw(" and "), + Span::styled("@user2:example.com", user_style("@user2:example.com")), + Span::raw(" are typing...") + ]) + ); + + // Four users typing. + info.set_typing(users4); + assert!(info.users_typing.is_some()); + assert_eq!(info.get_typing_spans(), Spans::from("Several people are typing...")); + + // Five users typing. + info.set_typing(users5); + assert!(info.users_typing.is_some()); + assert_eq!(info.get_typing_spans(), Spans::from("Many people are typing...")); + } +} diff --git a/src/config.rs b/src/config.rs index 96001db..bef88a5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -69,17 +69,73 @@ pub enum ConfigError { Invalid(#[from] serde_json::Error), } +#[derive(Clone)] +pub struct TunableValues { + pub typing_notice: bool, + pub typing_notice_display: bool, +} + +#[derive(Clone, Default, Deserialize)] +pub struct Tunables { + pub typing_notice: Option, + pub typing_notice_display: Option, +} + +impl Tunables { + fn merge(self, other: Self) -> Self { + Tunables { + typing_notice: self.typing_notice.or(other.typing_notice), + typing_notice_display: self.typing_notice_display.or(other.typing_notice_display), + } + } + + fn values(self) -> TunableValues { + TunableValues { + typing_notice: self.typing_notice.unwrap_or(true), + typing_notice_display: self.typing_notice.unwrap_or(true), + } + } +} + +#[derive(Clone)] +pub struct DirectoryValues { + pub cache: PathBuf, +} + +#[derive(Clone, Default, Deserialize)] +pub struct Directories { + pub cache: Option, +} + +impl Directories { + fn merge(self, other: Self) -> Self { + Directories { cache: self.cache.or(other.cache) } + } + + fn values(self) -> DirectoryValues { + DirectoryValues { + cache: self + .cache + .or_else(dirs::cache_dir) + .expect("no dirs.cache value configured!"), + } + } +} + #[derive(Clone, Deserialize)] pub struct ProfileConfig { pub user_id: OwnedUserId, pub url: Url, + pub settings: Option, + pub dirs: Option, } #[derive(Clone, Deserialize)] pub struct IambConfig { pub profiles: HashMap, pub default_profile: Option, - pub cache: Option, + pub settings: Option, + pub dirs: Option, } impl IambConfig { @@ -103,10 +159,11 @@ impl IambConfig { #[derive(Clone)] pub struct ApplicationSettings { pub matrix_dir: PathBuf, - pub cache_dir: PathBuf, pub session_json: PathBuf, pub profile_name: String, pub profile: ProfileConfig, + pub tunables: TunableValues, + pub dirs: DirectoryValues, } impl ApplicationSettings { @@ -122,12 +179,16 @@ impl ApplicationSettings { let mut config_json = config_dir.clone(); config_json.push("config.json"); - let IambConfig { mut profiles, default_profile, cache } = - IambConfig::load(config_json.as_path())?; + let IambConfig { + mut profiles, + default_profile, + dirs, + settings: global, + } = IambConfig::load(config_json.as_path())?; validate_profile_names(&profiles); - let (profile_name, profile) = if let Some(profile) = cli.profile.or(default_profile) { + let (profile_name, mut profile) = if let Some(profile) = cli.profile.or(default_profile) { profiles.remove_entry(&profile).unwrap_or_else(|| { usage!( "No configured profile with the name {:?} in {}", @@ -146,6 +207,10 @@ impl ApplicationSettings { ); }; + let tunables = global.unwrap_or_default(); + let tunables = profile.settings.take().unwrap_or_default().merge(tunables); + let tunables = tunables.values(); + let mut profile_dir = config_dir.clone(); profile_dir.push("profiles"); profile_dir.push(profile_name.as_str()); @@ -156,18 +221,17 @@ impl ApplicationSettings { let mut session_json = profile_dir; session_json.push("session.json"); - let cache_dir = cache.unwrap_or_else(|| { - let mut cache = dirs::cache_dir().expect("no user cache directory"); - cache.push("iamb"); - cache - }); + let dirs = dirs.unwrap_or_default(); + let dirs = profile.dirs.take().unwrap_or_default().merge(dirs); + let dirs = dirs.values(); let settings = ApplicationSettings { matrix_dir, - cache_dir, session_json, profile_name, profile, + tunables, + dirs, }; Ok(settings) diff --git a/src/main.rs b/src/main.rs index 23e2479..95ea7cb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -450,7 +450,7 @@ async fn main() -> IambResult<()> { // Set up the tracing subscriber so we can log client messages. let log_prefix = format!("iamb-log-{}", settings.profile_name); - let mut log_dir = settings.cache_dir.clone(); + let mut log_dir = settings.dirs.cache.clone(); log_dir.push("logs"); create_dir_all(settings.matrix_dir.as_path())?; diff --git a/src/message.rs b/src/message.rs index 8005626..a649b4e 100644 --- a/src/message.rs +++ b/src/message.rs @@ -66,6 +66,18 @@ const USER_GUTTER_EMPTY_SPAN: Span<'static> = Span { }, }; +pub(crate) fn user_color(user: &str) -> Color { + let mut hasher = DefaultHasher::new(); + user.hash(&mut hasher); + let color = hasher.finish() as usize % COLORS.len(); + + COLORS[color] +} + +pub(crate) fn user_style(user: &str) -> Style { + Style::default().fg(user_color(user)).add_modifier(StyleModifier::BOLD) +} + struct WrappedLinesIterator<'a> { iter: Lines<'a>, curr: Option<&'a str>, @@ -446,13 +458,7 @@ impl Message { fn show_sender(&self, align_right: bool) -> Span { let sender = self.sender.to_string(); - - let mut hasher = DefaultHasher::new(); - sender.hash(&mut hasher); - let color = hasher.finish() as usize % COLORS.len(); - let color = COLORS[color]; - - let bold = Style::default().fg(color).add_modifier(StyleModifier::BOLD); + let style = user_style(sender.as_str()); let sender = if align_right { format!("{: >width$} ", sender, width = 28) @@ -460,7 +466,7 @@ impl Message { format!("{: RoomInfo { messages: mock_messages(), fetch_id: RoomFetchStatus::NotStarted, fetch_last: None, + users_typing: None, } } pub fn mock_settings() -> ApplicationSettings { ApplicationSettings { matrix_dir: PathBuf::new(), - cache_dir: PathBuf::new(), session_json: PathBuf::new(), profile_name: "test".into(), profile: ProfileConfig { user_id: user_id!("@user:example.com").to_owned(), url: Url::parse("https://example.com").unwrap(), + settings: None, + dirs: None, }, + tunables: TunableValues { typing_notice: true, typing_notice_display: true }, + dirs: DirectoryValues { cache: PathBuf::new() }, } } diff --git a/src/windows/room/chat.rs b/src/windows/room/chat.rs index 8d52804..a6c84ae 100644 --- a/src/windows/room/chat.rs +++ b/src/windows/room/chat.rs @@ -83,6 +83,27 @@ impl ChatState { pub fn id(&self) -> &RoomId { &self.room_id } + + pub fn typing_notice( + &self, + act: &EditorAction, + ctx: &ProgramContext, + store: &mut ProgramStore, + ) { + if !self.focus.is_msgbar() || act.is_readonly(ctx) { + return; + } + + if matches!(act, EditorAction::History(_)) { + return; + } + + if !store.application.settings.tunables.typing_notice { + return; + } + + store.application.worker.typing_notice(self.room_id.clone()); + } } macro_rules! delegate { @@ -148,6 +169,8 @@ impl Editable for ChatState { ctx: &ProgramContext, store: &mut ProgramStore, ) -> EditResult { + self.typing_notice(act, ctx, store); + match delegate!(self, w => w.editor_command(act, ctx, store)) { res @ Ok(_) => res, Err(EditError::WrongBuffer(IambBufferId::Room(room_id, focus))) diff --git a/src/windows/room/scrollback.rs b/src/windows/room/scrollback.rs index efa0db7..8e4967f 100644 --- a/src/windows/room/scrollback.rs +++ b/src/windows/room/scrollback.rs @@ -1125,6 +1125,9 @@ impl<'a> StatefulWidget for Scrollback<'a> { type State = ScrollbackState; fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + let info = self.store.application.rooms.entry(state.room_id.clone()).or_default(); + let area = info.render_typing(area, buf, &self.store.application.settings); + state.set_term_info(area); let height = state.viewctx.get_height(); @@ -1137,8 +1140,6 @@ impl<'a> StatefulWidget for Scrollback<'a> { state.viewctx.corner = state.cursor.clone(); } - let info = self.store.application.get_room_info(state.room_id.clone()); - let cursor = &state.cursor; let cursor_key = if let Some(k) = cursor.to_key(info) { k @@ -1297,6 +1298,9 @@ mod tests { let prev = MoveDir2D::Up; let next = MoveDir2D::Down; + // Skip rendering typing notices. + store.application.settings.tunables.typing_notice_display = false; + assert_eq!(scrollback.cursor, MessageCursor::latest()); assert_eq!(scrollback.viewctx.dimensions, (0, 0)); assert_eq!(scrollback.viewctx.corner, MessageCursor::latest()); @@ -1425,6 +1429,9 @@ mod tests { let mut scrollback = ScrollbackState::new(TEST_ROOM1_ID.clone()); let ctx = ProgramContext::default(); + // Skip rendering typing notices. + store.application.settings.tunables.typing_notice_display = false; + // Set a terminal width of 60, and height of 3, rendering in scrollback as: // // |------------------------------------------------------------| diff --git a/src/windows/welcome.rs b/src/windows/welcome.rs index 8d17212..5c8c6da 100644 --- a/src/windows/welcome.rs +++ b/src/windows/welcome.rs @@ -21,8 +21,10 @@ pub struct WelcomeState { impl WelcomeState { pub fn new(store: &mut ProgramStore) -> Self { let buf = store.buffers.load_str(IambBufferId::Welcome, WELCOME_TEXT); + let mut tbox = TextBoxState::new(buf); + tbox.set_readonly(true); - WelcomeState { tbox: TextBoxState::new(buf) } + WelcomeState { tbox } } } diff --git a/src/worker.rs b/src/worker.rs index ad49d0a..1889278 100644 --- a/src/worker.rs +++ b/src/worker.rs @@ -32,6 +32,7 @@ use matrix_sdk::{ }, room::message::{MessageType, RoomMessageEventContent, TextMessageEventContent}, room::name::RoomNameEventContent, + typing::SyncTypingEvent, AnyMessageLikeEvent, AnyTimelineEvent, SyncMessageLikeEvent, @@ -104,6 +105,7 @@ pub enum WorkerTask { SpaceMembers(OwnedRoomId, ClientReply>>), Spaces(ClientReply>), SendMessage(OwnedRoomId, String, ClientReply>), + TypingNotice(OwnedRoomId), Verify(VerifyAction, SasVerification, ClientReply>), VerifyRequest(OwnedUserId, ClientReply>), } @@ -201,6 +203,10 @@ impl Requester { return response.recv(); } + pub fn typing_notice(&self, room_id: OwnedRoomId) { + self.tx.send(WorkerTask::TypingNotice(room_id)).unwrap(); + } + pub fn verify(&self, act: VerifyAction, sas: SasVerification) -> IambResult { let (reply, response) = oneshot(); @@ -333,6 +339,10 @@ impl ClientWorker { assert!(self.initialized); reply.send(self.send_message(room_id, msg).await); }, + WorkerTask::TypingNotice(room_id) => { + assert!(self.initialized); + self.typing_notice(room_id).await; + }, WorkerTask::Verify(act, sas, reply) => { assert!(self.initialized); reply.send(self.verify(act, sas).await); @@ -347,6 +357,24 @@ impl ClientWorker { async fn init(&mut self, store: AsyncProgramStore) { self.client.add_event_handler_context(store); + let _ = self.client.add_event_handler( + |ev: SyncTypingEvent, room: MatrixRoom, store: Ctx| { + async move { + let room_id = room.room_id().to_owned(); + let mut locked = store.lock().await; + + let users = ev + .content + .user_ids + .into_iter() + .filter(|u| u != &locked.application.settings.profile.user_id) + .collect(); + + locked.application.get_room_info(room_id).set_typing(users); + } + }, + ); + let _ = self.client.add_event_handler( |ev: SyncStateEvent, room: MatrixRoom, @@ -744,6 +772,12 @@ impl ClientWorker { return spaces; } + async fn typing_notice(&mut self, room_id: OwnedRoomId) { + if let Some(room) = self.client.get_joined_room(room_id.as_ref()) { + let _ = room.typing_notice(true).await; + } + } + async fn verify(&self, action: VerifyAction, sas: SasVerification) -> IambResult { match action { VerifyAction::Accept => {