Support sending and displaying typing notifications (#9)

This commit is contained in:
Ulyssa 2023-01-03 13:57:28 -08:00
parent c744d74e42
commit d038da6844
No known key found for this signature in database
GPG Key ID: 1B3965A3D18B9B64
12 changed files with 348 additions and 30 deletions

View File

@ -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

View File

@ -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"

View File

@ -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: |

View File

@ -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<Instant>,
pub users_typing: Option<(Instant, Vec<OwnedUserId>)>,
}
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<OwnedUserId>) {
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..."));
}
}

View File

@ -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<bool>,
pub typing_notice_display: Option<bool>,
}
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<PathBuf>,
}
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<Tunables>,
pub dirs: Option<Directories>,
}
#[derive(Clone, Deserialize)]
pub struct IambConfig {
pub profiles: HashMap<String, ProfileConfig>,
pub default_profile: Option<String>,
pub cache: Option<PathBuf>,
pub settings: Option<Tunables>,
pub dirs: Option<Directories>,
}
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)

View File

@ -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())?;

View File

@ -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!("{: <width$} ", sender, width = 28)
};
Span::styled(sender, bold)
Span::styled(sender, style)
}
}

View File

@ -20,7 +20,7 @@ use lazy_static::lazy_static;
use crate::{
base::{ChatStore, ProgramStore, RoomFetchStatus, RoomInfo},
config::{ApplicationSettings, ProfileConfig},
config::{ApplicationSettings, DirectoryValues, ProfileConfig, TunableValues},
message::{
Message,
MessageContent,
@ -35,6 +35,9 @@ lazy_static! {
pub static ref TEST_ROOM1_ID: OwnedRoomId = RoomId::new(server_name!("example.com")).to_owned();
pub static ref TEST_USER1: OwnedUserId = user_id!("@user1:example.com").to_owned();
pub static ref TEST_USER2: OwnedUserId = user_id!("@user2:example.com").to_owned();
pub static ref TEST_USER3: OwnedUserId = user_id!("@user2:example.com").to_owned();
pub static ref TEST_USER4: OwnedUserId = user_id!("@user2:example.com").to_owned();
pub static ref TEST_USER5: OwnedUserId = user_id!("@user2:example.com").to_owned();
pub static ref MSG1_KEY: MessageKey = (LocalEcho, EventId::new(server_name!("example.com")));
pub static ref MSG2_KEY: MessageKey =
(OriginServer(UInt::new(1).unwrap()), EventId::new(server_name!("example.com")));
@ -103,19 +106,23 @@ pub fn mock_room() -> 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() },
}
}

View File

@ -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<ProgramContext, ProgramStore, IambInfo> for ChatState {
ctx: &ProgramContext,
store: &mut ProgramStore,
) -> EditResult<EditInfo, IambInfo> {
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)))

View File

@ -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:
//
// |------------------------------------------------------------|

View File

@ -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 }
}
}

View File

@ -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<IambResult<Vec<OwnedRoomId>>>),
Spaces(ClientReply<Vec<(MatrixRoom, DisplayName)>>),
SendMessage(OwnedRoomId, String, ClientReply<IambResult<EchoPair>>),
TypingNotice(OwnedRoomId),
Verify(VerifyAction, SasVerification, ClientReply<IambResult<EditInfo>>),
VerifyRequest(OwnedUserId, ClientReply<IambResult<EditInfo>>),
}
@ -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<EditInfo> {
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<AsyncProgramStore>| {
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<RoomNameEventContent>,
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<EditInfo> {
match action {
VerifyAction::Accept => {