Want a Matrix client that uses Vim keybindings (#1)

This commit is contained in:
Ulyssa 2022-12-29 18:00:59 -08:00
parent 704f631d54
commit 262c96b62f
No known key found for this signature in database
GPG key ID: 1B3965A3D18B9B64
22 changed files with 9050 additions and 7 deletions

1
.gitattributes vendored Normal file
View file

@ -0,0 +1 @@
* text eol=lf

60
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,60 @@
on:
push:
branches:
- main
pull_request:
branches:
- main
name: CI
jobs:
clippy_check:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v1
with:
submodules: true
- name: Install Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
components: clippy
override: true
- name: Check Clippy
uses: actions-rs/clippy-check@v1
with:
token: ${{ secrets.GITHUB_TOKEN }}
toolchain: stable
args:
test:
strategy:
matrix:
platform: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.platform }}
steps:
- name: Checkout code
uses: actions/checkout@v2
with:
submodules: true
- name: Install Rust
uses: actions-rs/toolchain@v1
with:
toolchain: nightly
override: true
components: rustfmt, clippy
- name: Cache cargo registry
uses: actions/cache@v1
with:
path: ~/.cargo/registry
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
- name: Check formatting
uses: actions-rs/cargo@v1
with:
command: fmt
args: --all -- --check
- name: Run tests
uses: actions-rs/cargo@v1
with:
command: test

1
.gitignore vendored
View file

@ -1 +1,2 @@
/target
/TODO

12
.rustfmt.toml Normal file
View file

@ -0,0 +1,12 @@
unstable_features = true
max_width = 100
fn_call_width = 90
struct_lit_width = 50
struct_variant_width = 50
chain_width = 75
binop_separator = "Back"
force_multiline_blocks = true
match_block_trailing_comma = true
imports_layout = "HorizontalVertical"
newline_style = "Unix"
overflow_delimited_expr = true

3166
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,13 +1,38 @@
[package]
name = "iamb"
version = "0.0.1"
edition = "2018"
authors = ["Ulyssa <git@ulyssa.dev>"]
repository = "https://github.com/ulyssa/iamb"
homepage = "http://iamb.chat"
readme = "README.md"
description = "A Matrix chat client with vim-style editing and keybindings"
description = "A Matrix chat client that uses Vim keybindings"
license = "Apache-2.0"
edition = "2018"
exclude = [".github", "CONTRIBUTING.md"]
keywords = ["matrix", "chat", "tui", "vim"]
rust-version = "1.65"
[dependencies]
chrono = "0.4"
clap = {version = "4.0", features = ["derive"]}
dirs = "4.0.0"
futures = "0.3.21"
gethostname = "0.4.1"
matrix-sdk = {version = "0.6", default-features = false, features = ["e2e-encryption", "sled", "rustls-tls"]}
modalkit = "0.0.7"
regex = "^1.5"
rpassword = "^7.2"
serde = "^1.0"
serde_json = "^1.0"
sled = "0.34"
thiserror = "^1.0.37"
tokio = {version = "1.17.0", features = ["full"]}
tracing = "~0.1.36"
tracing-appender = "~0.2.2"
tracing-subscriber = "0.3.16"
unicode-segmentation = "^1.7"
unicode-width = "0.1.10"
url = {version = "^2.2.2", features = ["serde"]}
[dev-dependencies]
lazy_static = "1.4.0"

View file

@ -2,10 +2,10 @@
## About
This is a vi-inspired terminal chat client for the Matrix protocol.
`iamb` is a Matrix client for the terminal that uses Vim keybindings.
__*Note that this project is still very much in its early stages and a
lot is subject to eventually change.*__
This project is a work-in-progress, and there's still a lot to be implemented,
but much of the basic client functionality is already present.
## Installation
@ -15,6 +15,21 @@ Install Rust and Cargo, and then run:
cargo install iamb
```
## Configuration
You can create a basic configuration in `$CONFIG_DIR/iamb/config.json` that looks like:
```json
{
"profiles": {
"example.com": {
"url": "https://example.com",
"@user:example.com"
}
}
}
```
## License
iamb is released under the [Apache License, Version 2.0].

328
src/base.rs Normal file
View file

@ -0,0 +1,328 @@
use std::collections::{HashMap, HashSet};
use std::hash::Hash;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::Mutex as AsyncMutex;
use tracing::warn;
use matrix_sdk::{
encryption::verification::SasVerification,
ruma::{OwnedRoomId, RoomId},
};
use modalkit::{
editing::{
action::{Action, UIError, UIResult},
application::{
ApplicationAction,
ApplicationContentId,
ApplicationError,
ApplicationInfo,
ApplicationStore,
ApplicationWindowId,
},
context::EditContext,
store::Store,
},
env::vim::{
command::{VimCommand, VimCommandMachine},
keybindings::VimMachine,
VimContext,
},
input::bindings::SequenceStatus,
input::key::TerminalKey,
};
use crate::{
message::{Message, Messages},
worker::Requester,
ApplicationSettings,
};
const ROOM_FETCH_DEBOUNCE: Duration = Duration::from_secs(3);
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum IambInfo {}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum VerifyAction {
Accept,
Cancel,
Confirm,
Mismatch,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum IambAction {
Verify(VerifyAction, String),
VerifyRequest(String),
SendMessage(OwnedRoomId, String),
ToggleScrollbackFocus,
}
impl ApplicationAction for IambAction {
fn is_edit_sequence<C: EditContext>(&self, _: &C) -> SequenceStatus {
match self {
IambAction::SendMessage(..) => SequenceStatus::Break,
IambAction::ToggleScrollbackFocus => SequenceStatus::Break,
IambAction::Verify(..) => SequenceStatus::Break,
IambAction::VerifyRequest(..) => SequenceStatus::Break,
}
}
fn is_last_action<C: EditContext>(&self, _: &C) -> SequenceStatus {
match self {
IambAction::SendMessage(..) => SequenceStatus::Atom,
IambAction::ToggleScrollbackFocus => SequenceStatus::Atom,
IambAction::Verify(..) => SequenceStatus::Atom,
IambAction::VerifyRequest(..) => SequenceStatus::Atom,
}
}
fn is_last_selection<C: EditContext>(&self, _: &C) -> SequenceStatus {
match self {
IambAction::SendMessage(..) => SequenceStatus::Ignore,
IambAction::ToggleScrollbackFocus => SequenceStatus::Ignore,
IambAction::Verify(..) => SequenceStatus::Ignore,
IambAction::VerifyRequest(..) => SequenceStatus::Ignore,
}
}
fn is_switchable<C: EditContext>(&self, _: &C) -> bool {
match self {
IambAction::SendMessage(..) => false,
IambAction::ToggleScrollbackFocus => false,
IambAction::Verify(..) => false,
IambAction::VerifyRequest(..) => false,
}
}
}
impl From<IambAction> for ProgramAction {
fn from(act: IambAction) -> Self {
Action::Application(act)
}
}
pub type ProgramAction = Action<IambInfo>;
pub type ProgramContext = VimContext<IambInfo>;
pub type Keybindings = VimMachine<TerminalKey, IambInfo>;
pub type ProgramCommand = VimCommand<ProgramContext, IambInfo>;
pub type ProgramCommands = VimCommandMachine<ProgramContext, IambInfo>;
pub type ProgramStore = Store<IambInfo>;
pub type AsyncProgramStore = Arc<AsyncMutex<ProgramStore>>;
pub type IambResult<T> = UIResult<T, IambInfo>;
#[derive(thiserror::Error, Debug)]
pub enum IambError {
#[error("Unknown room identifier: {0}")]
InvalidUserId(String),
#[error("Invalid verification user/device pair: {0}")]
InvalidVerificationId(String),
#[error("Cryptographic storage error: {0}")]
CryptoStore(#[from] matrix_sdk::encryption::CryptoStoreError),
#[error("HTTP client error: {0}")]
Http(#[from] matrix_sdk::HttpError),
#[error("Matrix client error: {0}")]
Matrix(#[from] matrix_sdk::Error),
#[error("Matrix client storage error: {0}")]
Store(#[from] matrix_sdk::StoreError),
#[error("Serialization/deserialization error: {0}")]
Serde(#[from] serde_json::Error),
#[error("Unknown room identifier: {0}")]
UnknownRoom(OwnedRoomId),
#[error("Verification request error: {0}")]
VerificationRequestError(#[from] matrix_sdk::encryption::identities::RequestVerificationError),
}
impl From<IambError> for UIError<IambInfo> {
fn from(err: IambError) -> Self {
UIError::Application(err)
}
}
impl ApplicationError for IambError {}
#[derive(Default)]
pub enum RoomFetchStatus {
Done,
HaveMore(String),
#[default]
NotStarted,
}
#[derive(Default)]
pub struct RoomInfo {
pub name: Option<String>,
pub messages: Messages,
pub fetch_id: RoomFetchStatus,
pub fetch_last: Option<Instant>,
}
impl RoomInfo {
fn recently_fetched(&self) -> bool {
self.fetch_last.map_or(false, |i| i.elapsed() < ROOM_FETCH_DEBOUNCE)
}
}
pub struct ChatStore {
pub worker: Requester,
pub rooms: HashMap<OwnedRoomId, RoomInfo>,
pub names: HashMap<String, OwnedRoomId>,
pub verifications: HashMap<String, SasVerification>,
pub settings: ApplicationSettings,
pub need_load: HashSet<OwnedRoomId>,
}
impl ChatStore {
pub fn new(worker: Requester, settings: ApplicationSettings) -> Self {
ChatStore {
worker,
settings,
names: Default::default(),
rooms: Default::default(),
verifications: Default::default(),
need_load: Default::default(),
}
}
pub fn mark_for_load(&mut self, room_id: OwnedRoomId) {
self.need_load.insert(room_id);
}
pub fn load_older(&mut self, limit: u32) {
let ChatStore { need_load, rooms, worker, .. } = self;
for room_id in std::mem::take(need_load).into_iter() {
let info = rooms.entry(room_id.clone()).or_default();
if info.recently_fetched() {
need_load.insert(room_id);
continue;
} else {
info.fetch_last = Instant::now().into();
}
let fetch_id = match &info.fetch_id {
RoomFetchStatus::Done => continue,
RoomFetchStatus::HaveMore(fetch_id) => Some(fetch_id.clone()),
RoomFetchStatus::NotStarted => None,
};
let res = worker.load_older(room_id.clone(), fetch_id, limit);
match res {
Ok((fetch_id, msgs)) => {
for msg in msgs.into_iter() {
let key = (msg.origin_server_ts().into(), msg.event_id().to_owned());
info.messages.insert(key, Message::from(msg));
}
info.fetch_id =
fetch_id.map_or(RoomFetchStatus::Done, RoomFetchStatus::HaveMore);
},
Err(e) => {
warn!(
room_id = room_id.as_str(),
err = e.to_string(),
"Failed to load older messages"
);
// Wait and try again.
need_load.insert(room_id);
},
}
}
}
pub fn get_room_info(&mut self, room_id: OwnedRoomId) -> &mut RoomInfo {
self.rooms.entry(room_id).or_default()
}
pub fn set_room_name(&mut self, room_id: &RoomId, name: &str) {
self.rooms.entry(room_id.to_owned()).or_default().name = name.to_string().into();
}
pub fn insert_sas(&mut self, sas: SasVerification) {
let key = format!("{}/{}", sas.other_user_id(), sas.other_device().device_id());
self.verifications.insert(key, sas);
}
}
impl ApplicationStore for ChatStore {}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub enum IambId {
Room(OwnedRoomId),
DirectList,
RoomList,
SpaceList,
VerifyList,
Welcome,
}
impl ApplicationWindowId for IambId {}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum RoomFocus {
Scrollback,
MessageBar,
}
impl RoomFocus {
pub fn is_scrollback(&self) -> bool {
matches!(self, RoomFocus::Scrollback)
}
pub fn is_msgbar(&self) -> bool {
matches!(self, RoomFocus::MessageBar)
}
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub enum IambBufferId {
Command,
Room(OwnedRoomId, RoomFocus),
DirectList,
RoomList,
SpaceList,
VerifyList,
Welcome,
}
impl IambBufferId {
pub fn to_window(&self) -> Option<IambId> {
match self {
IambBufferId::Command => None,
IambBufferId::Room(room, _) => Some(IambId::Room(room.clone())),
IambBufferId::DirectList => Some(IambId::DirectList),
IambBufferId::RoomList => Some(IambId::RoomList),
IambBufferId::SpaceList => Some(IambId::SpaceList),
IambBufferId::VerifyList => Some(IambId::VerifyList),
IambBufferId::Welcome => Some(IambId::Welcome),
}
}
}
impl ApplicationContentId for IambBufferId {}
impl ApplicationInfo for IambInfo {
type Error = IambError;
type Store = ChatStore;
type Action = IambAction;
type WindowId = IambId;
type ContentId = IambBufferId;
}

202
src/commands.rs Normal file
View file

@ -0,0 +1,202 @@
use modalkit::{
editing::{action::WindowAction, base::OpenTarget},
env::vim::command::{CommandContext, CommandDescription},
input::commands::{CommandError, CommandResult, CommandStep},
input::InputContext,
};
use crate::base::{
IambAction,
IambId,
ProgramCommand,
ProgramCommands,
ProgramContext,
VerifyAction,
};
type ProgContext = CommandContext<ProgramContext>;
type ProgResult = CommandResult<ProgramCommand>;
fn iamb_verify(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
let mut args = desc.arg.strings()?;
match args.len() {
0 => {
let open = WindowAction::Switch(OpenTarget::Application(IambId::VerifyList));
let step = CommandStep::Continue(open.into(), ctx.context.take());
return Ok(step);
},
1 => {
return Result::Err(CommandError::InvalidArgument);
},
2 => {
let act = match args[0].as_str() {
"accept" => VerifyAction::Accept,
"cancel" => VerifyAction::Cancel,
"confirm" => VerifyAction::Confirm,
"mismatch" => VerifyAction::Mismatch,
"request" => {
let iact = IambAction::VerifyRequest(args.remove(1));
let step = CommandStep::Continue(iact.into(), ctx.context.take());
return Ok(step);
},
_ => return Result::Err(CommandError::InvalidArgument),
};
let vact = IambAction::Verify(act, args.remove(1));
let step = CommandStep::Continue(vact.into(), ctx.context.take());
return Ok(step);
},
_ => {
return Result::Err(CommandError::InvalidArgument);
},
}
}
fn iamb_dms(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
if !desc.arg.text.is_empty() {
return Result::Err(CommandError::InvalidArgument);
}
let open = WindowAction::Switch(OpenTarget::Application(IambId::DirectList));
let step = CommandStep::Continue(open.into(), ctx.context.take());
return Ok(step);
}
fn iamb_rooms(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
if !desc.arg.text.is_empty() {
return Result::Err(CommandError::InvalidArgument);
}
let open = WindowAction::Switch(OpenTarget::Application(IambId::RoomList));
let step = CommandStep::Continue(open.into(), ctx.context.take());
return Ok(step);
}
fn iamb_spaces(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
if !desc.arg.text.is_empty() {
return Result::Err(CommandError::InvalidArgument);
}
let open = WindowAction::Switch(OpenTarget::Application(IambId::SpaceList));
let step = CommandStep::Continue(open.into(), ctx.context.take());
return Ok(step);
}
fn iamb_welcome(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
if !desc.arg.text.is_empty() {
return Result::Err(CommandError::InvalidArgument);
}
let open = WindowAction::Switch(OpenTarget::Application(IambId::Welcome));
let step = CommandStep::Continue(open.into(), ctx.context.take());
return Ok(step);
}
fn iamb_join(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
let mut args = desc.arg.filenames()?;
if args.len() != 1 {
return Result::Err(CommandError::InvalidArgument);
}
let open = WindowAction::Switch(args.remove(0));
let step = CommandStep::Continue(open.into(), ctx.context.take());
return Ok(step);
}
fn add_iamb_commands(cmds: &mut ProgramCommands) {
cmds.add_command(ProgramCommand { names: vec!["dms".into()], f: iamb_dms });
cmds.add_command(ProgramCommand { names: vec!["join".into()], f: iamb_join });
cmds.add_command(ProgramCommand { names: vec!["rooms".into()], f: iamb_rooms });
cmds.add_command(ProgramCommand { names: vec!["spaces".into()], f: iamb_spaces });
cmds.add_command(ProgramCommand { names: vec!["verify".into()], f: iamb_verify });
cmds.add_command(ProgramCommand { names: vec!["welcome".into()], f: iamb_welcome });
}
pub fn setup_commands() -> ProgramCommands {
let mut cmds = ProgramCommands::default();
add_iamb_commands(&mut cmds);
return cmds;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cmd_verify() {
let mut cmds = setup_commands();
let ctx = ProgramContext::default();
let res = cmds.input_cmd(":verify", ctx.clone()).unwrap();
let act = WindowAction::Switch(OpenTarget::Application(IambId::VerifyList));
assert_eq!(res, vec![(act.into(), ctx.clone())]);
let res = cmds.input_cmd(":verify request @user1:example.com", ctx.clone()).unwrap();
let act = IambAction::VerifyRequest("@user1:example.com".into());
assert_eq!(res, vec![(act.into(), ctx.clone())]);
let res = cmds
.input_cmd(":verify accept @user1:example.com/FOOBAR", ctx.clone())
.unwrap();
let act = IambAction::Verify(VerifyAction::Accept, "@user1:example.com/FOOBAR".into());
assert_eq!(res, vec![(act.into(), ctx.clone())]);
let res = cmds
.input_cmd(":verify mismatch @user2:example.com/QUUXBAZ", ctx.clone())
.unwrap();
let act = IambAction::Verify(VerifyAction::Mismatch, "@user2:example.com/QUUXBAZ".into());
assert_eq!(res, vec![(act.into(), ctx.clone())]);
let res = cmds
.input_cmd(":verify cancel @user3:example.com/MYDEVICE", ctx.clone())
.unwrap();
let act = IambAction::Verify(VerifyAction::Cancel, "@user3:example.com/MYDEVICE".into());
assert_eq!(res, vec![(act.into(), ctx.clone())]);
let res = cmds
.input_cmd(":verify confirm @user4:example.com/GOODDEV", ctx.clone())
.unwrap();
let act = IambAction::Verify(VerifyAction::Confirm, "@user4:example.com/GOODDEV".into());
assert_eq!(res, vec![(act.into(), ctx.clone())]);
let res = cmds.input_cmd(":verify confirm", ctx.clone());
assert_eq!(res, Err(CommandError::InvalidArgument));
let res = cmds.input_cmd(":verify cancel @user4:example.com MYDEVICE", ctx.clone());
assert_eq!(res, Err(CommandError::InvalidArgument));
let res = cmds.input_cmd(":verify mismatch a b c d e f", ctx.clone());
assert_eq!(res, Err(CommandError::InvalidArgument));
}
#[test]
fn test_cmd_join() {
let mut cmds = setup_commands();
let ctx = ProgramContext::default();
let res = cmds.input_cmd("join #foobar:example.com", ctx.clone()).unwrap();
let act = WindowAction::Switch(OpenTarget::Name("#foobar:example.com".into()));
assert_eq!(res, vec![(act.into(), ctx.clone())]);
let res = cmds.input_cmd("join #", ctx.clone()).unwrap();
let act = WindowAction::Switch(OpenTarget::Alternate);
assert_eq!(res, vec![(act.into(), ctx.clone())]);
let res = cmds.input_cmd("join", ctx.clone());
assert_eq!(res, Err(CommandError::InvalidArgument));
let res = cmds.input_cmd("join foo bar", ctx.clone());
assert_eq!(res, Err(CommandError::InvalidArgument));
}
}

203
src/config.rs Normal file
View file

@ -0,0 +1,203 @@
use std::collections::HashMap;
use std::fs::File;
use std::io::BufReader;
use std::path::{Path, PathBuf};
use std::process;
use clap::Parser;
use matrix_sdk::ruma::OwnedUserId;
use serde::Deserialize;
use url::Url;
macro_rules! usage {
( $($args: tt)* ) => {
println!($($args)*);
process::exit(2);
}
}
fn is_profile_char(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '.' || c == '-'
}
fn validate_profile_name(name: &str) -> bool {
if name.is_empty() {
return false;
}
let mut chars = name.chars();
if !chars.next().map_or(false, |c| c.is_ascii_alphanumeric()) {
return false;
}
name.chars().all(is_profile_char)
}
fn validate_profile_names(names: &HashMap<String, ProfileConfig>) {
for name in names.keys() {
if validate_profile_name(name.as_str()) {
continue;
}
usage!(
"{:?} is not a valid profile name.\n\n\
Profile names can only contain the characters \
a-z, A-Z, and 0-9. Period (.) and hyphen (-) are allowed after the first character.",
name
);
}
}
#[derive(Parser)]
#[clap(version, about, long_about = None)]
#[clap(propagate_version = true)]
pub struct Iamb {
#[clap(short = 'P', long, value_parser)]
pub profile: Option<String>,
#[clap(short = 'C', long, value_parser)]
pub config_directory: Option<PathBuf>,
}
#[derive(thiserror::Error, Debug)]
pub enum ConfigError {
#[error("Error reading configuration file: {0}")]
IO(#[from] std::io::Error),
#[error("Error loading configuration file: {0}")]
Invalid(#[from] serde_json::Error),
}
#[derive(Clone, Deserialize)]
pub struct ProfileConfig {
pub user_id: OwnedUserId,
pub url: Url,
}
#[derive(Clone, Deserialize)]
pub struct IambConfig {
pub profiles: HashMap<String, ProfileConfig>,
pub default_profile: Option<String>,
pub cache: Option<PathBuf>,
}
impl IambConfig {
pub fn load(config_json: &Path) -> Result<Self, ConfigError> {
if !config_json.is_file() {
usage!(
"Please create a configuration file at {}\n\n\
For more information try '--help'",
config_json.display(),
);
}
let file = File::open(config_json)?;
let reader = BufReader::new(file);
let config = serde_json::from_reader(reader)?;
Ok(config)
}
}
#[derive(Clone)]
pub struct ApplicationSettings {
pub matrix_dir: PathBuf,
pub cache_dir: PathBuf,
pub session_json: PathBuf,
pub profile_name: String,
pub profile: ProfileConfig,
}
impl ApplicationSettings {
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'"
);
});
config_dir.push("iamb");
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())?;
validate_profile_names(&profiles);
let (profile_name, 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 {}",
profile,
config_json.display()
);
})
} else if profiles.len() == 1 {
profiles.into_iter().next().unwrap()
} else {
usage!(
"No profile specified. \
Please use -P or add \"default_profile\" to {}.\n\n\
For more information try '--help'",
config_json.display()
);
};
let mut profile_dir = config_dir.clone();
profile_dir.push("profiles");
profile_dir.push(profile_name.as_str());
let mut matrix_dir = profile_dir.clone();
matrix_dir.push("matrix");
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 settings = ApplicationSettings {
matrix_dir,
cache_dir,
session_json,
profile_name,
profile,
};
Ok(settings)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_profile_name_invalid() {
assert_eq!(validate_profile_name(""), false);
assert_eq!(validate_profile_name(" "), false);
assert_eq!(validate_profile_name("a b"), false);
assert_eq!(validate_profile_name("foo^bar"), false);
assert_eq!(validate_profile_name("FOO/BAR"), false);
assert_eq!(validate_profile_name("-b-c"), false);
assert_eq!(validate_profile_name("-B-c"), false);
assert_eq!(validate_profile_name(".b-c"), false);
assert_eq!(validate_profile_name(".B-c"), false);
}
#[test]
fn test_profile_name_valid() {
assert_eq!(validate_profile_name("foo"), true);
assert_eq!(validate_profile_name("FOO"), true);
assert_eq!(validate_profile_name("a-b-c"), true);
assert_eq!(validate_profile_name("a-B-c"), true);
assert_eq!(validate_profile_name("a.b-c"), true);
assert_eq!(validate_profile_name("a.B-c"), true);
}
}

62
src/keybindings.rs Normal file
View file

@ -0,0 +1,62 @@
use modalkit::{
editing::action::WindowAction,
editing::base::WordStyle,
env::vim::keybindings::{InputStep, VimBindings},
env::vim::VimMode,
input::bindings::{EdgeEvent, EdgeRepeat, InputBindings},
input::key::TerminalKey,
};
use crate::base::{IambAction, Keybindings};
/// Find the boundaries for a Matrix username, room alias, or room ID.
///
/// Technically "[" and "]" should be here since IPv6 addresses are allowed
/// in the server name, but in practice that should be uncommon, and people
/// can just use `gf` and friends in Visual mode instead.
fn is_mxid_char(c: char) -> bool {
return c >= 'a' && c <= 'z' ||
c >= 'A' && c <= 'Z' ||
c >= '0' && c <= '9' ||
":-./@_#!".contains(c);
}
pub fn setup_keybindings() -> Keybindings {
let mut ism = Keybindings::empty();
let vim = VimBindings::default()
.submit_on_enter()
.cursor_open(WordStyle::CharSet(is_mxid_char));
vim.setup(&mut ism);
let ctrl_w = EdgeEvent::Key("<C-W>".parse::<TerminalKey>().unwrap());
let ctrl_m = EdgeEvent::Key("<C-M>".parse::<TerminalKey>().unwrap());
let ctrl_z = EdgeEvent::Key("<C-Z>".parse::<TerminalKey>().unwrap());
let key_m_lc = EdgeEvent::Key("m".parse::<TerminalKey>().unwrap());
let key_z_lc = EdgeEvent::Key("z".parse::<TerminalKey>().unwrap());
let cwz = vec![
(EdgeRepeat::Once, ctrl_w.clone()),
(EdgeRepeat::Once, key_z_lc),
];
let cwcz = vec![
(EdgeRepeat::Once, ctrl_w.clone()),
(EdgeRepeat::Once, ctrl_z),
];
let zoom = InputStep::new().actions(vec![WindowAction::ZoomToggle.into()]);
ism.add_mapping(VimMode::Normal, &cwz, &zoom);
ism.add_mapping(VimMode::Normal, &cwcz, &zoom);
let cwm = vec![
(EdgeRepeat::Once, ctrl_w.clone()),
(EdgeRepeat::Once, key_m_lc),
];
let cwcm = vec![(EdgeRepeat::Once, ctrl_w), (EdgeRepeat::Once, ctrl_m)];
let stoggle = InputStep::new().actions(vec![IambAction::ToggleScrollbackFocus.into()]);
ism.add_mapping(VimMode::Normal, &cwm, &stoggle);
ism.add_mapping(VimMode::Normal, &cwcm, &stoggle);
return ism;
}

View file

@ -1,3 +1,493 @@
fn main() {
unimplemented!();
#![allow(clippy::manual_range_contains)]
#![allow(clippy::needless_return)]
#![allow(clippy::result_large_err)]
#![allow(clippy::bool_assert_comparison)]
use std::collections::VecDeque;
use std::convert::TryFrom;
use std::fmt::Display;
use std::fs::{create_dir_all, File};
use std::io::{stdout, BufReader, Stdout};
use std::ops::DerefMut;
use std::process;
use std::sync::Arc;
use std::time::Duration;
use clap::Parser;
use tokio::sync::Mutex as AsyncMutex;
use tracing::{self, Level};
use tracing_subscriber::FmtSubscriber;
use matrix_sdk::ruma::OwnedUserId;
use modalkit::crossterm::{
self,
cursor::Show as CursorShow,
event::{poll, read, Event},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, SetTitle},
};
use modalkit::tui::{
backend::CrosstermBackend,
layout::Rect,
style::{Color, Style},
text::Span,
widgets::Paragraph,
Terminal,
};
mod base;
mod commands;
mod config;
mod keybindings;
mod message;
mod windows;
mod worker;
#[cfg(test)]
mod tests;
use crate::{
base::{
AsyncProgramStore,
ChatStore,
IambAction,
IambBufferId,
IambError,
IambId,
IambInfo,
IambResult,
ProgramAction,
ProgramCommands,
ProgramContext,
ProgramStore,
},
config::{ApplicationSettings, Iamb},
message::{Message, MessageContent, MessageTimeStamp},
windows::IambWindow,
worker::{ClientWorker, LoginStyle, Requester},
};
use modalkit::{
editing::{
action::{
Action,
Commandable,
EditError,
EditInfo,
Editable,
Jumpable,
Promptable,
Scrollable,
TabContainer,
TabCount,
WindowAction,
WindowContainer,
},
base::{OpenTarget, RepeatType},
context::Resolve,
key::KeyManager,
store::Store,
},
input::{bindings::BindingMachine, key::TerminalKey},
widgets::{
cmdbar::CommandBarState,
screen::{Screen, ScreenState},
TerminalCursor,
TerminalExtOps,
Window,
},
};
struct Application {
store: AsyncProgramStore,
worker: Requester,
terminal: Terminal<CrosstermBackend<Stdout>>,
bindings: KeyManager<TerminalKey, ProgramAction, RepeatType, ProgramContext>,
actstack: VecDeque<(ProgramAction, ProgramContext)>,
cmds: ProgramCommands,
screen: ScreenState<IambWindow, IambInfo>,
}
impl Application {
pub async fn new(
settings: ApplicationSettings,
store: AsyncProgramStore,
) -> IambResult<Application> {
let mut stdout = stdout();
crossterm::terminal::enable_raw_mode()?;
crossterm::execute!(stdout, EnterAlternateScreen)?;
let title = format!("iamb ({})", settings.profile.user_id);
crossterm::execute!(stdout, SetTitle(title))?;
let backend = CrosstermBackend::new(stdout);
let terminal = Terminal::new(backend)?;
let bindings = crate::keybindings::setup_keybindings();
let bindings = KeyManager::new(bindings);
let cmds = crate::commands::setup_commands();
let mut locked = store.lock().await;
let win = IambWindow::open(IambId::Welcome, locked.deref_mut()).unwrap();
let cmd = CommandBarState::new(IambBufferId::Command, locked.deref_mut());
let screen = ScreenState::new(win, cmd);
let worker = locked.application.worker.clone();
drop(locked);
let actstack = VecDeque::new();
Ok(Application {
store,
worker,
terminal,
bindings,
actstack,
cmds,
screen,
})
}
fn redraw(&mut self, full: bool, store: &mut ProgramStore) -> Result<(), std::io::Error> {
let modestr = self.bindings.showmode();
let cursor = self.bindings.get_cursor_indicator();
let sstate = &mut self.screen;
let term = &mut self.terminal;
if full {
term.clear()?;
}
term.draw(|f| {
let area = f.size();
let screen = Screen::new(store).showmode(modestr);
f.render_stateful_widget(screen, area, sstate);
if let Some((cx, cy)) = sstate.get_term_cursor() {
if let Some(c) = cursor {
let style = Style::default().fg(Color::Green);
let span = Span::styled(c.to_string(), style);
let para = Paragraph::new(span);
let inner = Rect::new(cx, cy, 1, 1);
f.render_widget(para, inner)
}
f.set_cursor(cx, cy);
}
store.application.load_older(area.height as u32);
})?;
Ok(())
}
async fn step(&mut self) -> Result<TerminalKey, std::io::Error> {
loop {
self.redraw(false, self.store.clone().lock().await.deref_mut())?;
if !poll(Duration::from_millis(500))? {
continue;
}
match read()? {
Event::Key(ke) => return Ok(ke.into()),
Event::Mouse(_) => {
// Do nothing for now.
},
Event::FocusGained | Event::FocusLost => {
// Do nothing for now.
},
Event::Resize(_, _) => {
// We'll redraw for the new size next time step() is called.
},
Event::Paste(_) => {
// Do nothing for now.
},
}
}
}
fn action_prepend(&mut self, acts: Vec<(ProgramAction, ProgramContext)>) {
let mut acts = VecDeque::from(acts);
acts.append(&mut self.actstack);
self.actstack = acts;
}
fn action_pop(&mut self, keyskip: bool) -> Option<(ProgramAction, ProgramContext)> {
if let res @ Some(_) = self.actstack.pop_front() {
return res;
}
if keyskip {
return None;
} else {
return self.bindings.pop();
}
}
fn action_run(
&mut self,
action: ProgramAction,
ctx: ProgramContext,
store: &mut ProgramStore,
) -> IambResult<EditInfo> {
let info = match action {
// Do nothing.
Action::NoOp => None,
Action::Editor(act) => {
match self.screen.editor_command(&act, &ctx, store) {
Ok(info) => info,
Err(EditError::WrongBuffer(content)) if act.is_switchable(&ctx) => {
// Switch to the right window.
if let Some(winid) = content.to_window() {
let open = OpenTarget::Application(winid);
let open = WindowAction::Switch(open);
let _ = self.screen.window_command(&open, &ctx, store)?;
// Run command again.
self.screen.editor_command(&act, &ctx, store)?
} else {
return Err(EditError::WrongBuffer(content).into());
}
},
Err(err) => return Err(err.into()),
}
},
// Simple delegations.
Action::Application(act) => self.iamb_run(act, ctx, store)?,
Action::CommandBar(act) => self.screen.command_bar(&act, &ctx)?,
Action::Macro(act) => self.bindings.macro_command(&act, &ctx, store)?,
Action::Scroll(style) => self.screen.scroll(&style, &ctx, store)?,
Action::Suspend => self.terminal.program_suspend()?,
Action::Tab(cmd) => self.screen.tab_command(&cmd, &ctx, store)?,
Action::Window(cmd) => self.screen.window_command(&cmd, &ctx, store)?,
Action::Jump(l, dir, count) => {
let count = ctx.resolve(&count);
let _ = self.screen.jump(l, dir, count, &ctx)?;
None
},
// UI actions.
Action::RedrawScreen => {
self.screen.clear_message();
self.redraw(true, store)?;
None
},
// Actions that create more Actions.
Action::Prompt(act) => {
let acts = self.screen.prompt(&act, &ctx, store)?;
self.action_prepend(acts);
None
},
Action::Command(act) => {
let acts = self.cmds.command(&act, &ctx)?;
self.action_prepend(acts);
None
},
Action::Repeat(rt) => {
self.bindings.repeat(rt, Some(ctx));
None
},
// Unimplemented.
Action::KeywordLookup => {
// XXX: implement
None
},
_ => {
// XXX: log unhandled actions? print message?
None
},
};
return Ok(info);
}
fn iamb_run(
&mut self,
action: IambAction,
_: ProgramContext,
store: &mut ProgramStore,
) -> IambResult<EditInfo> {
let info = match action {
IambAction::ToggleScrollbackFocus => {
self.screen.current_window_mut()?.focus_toggle();
None
},
IambAction::SendMessage(room_id, msg) => {
let (event_id, msg) = self.worker.send_message(room_id.clone(), msg)?;
let user = store.application.settings.profile.user_id.clone();
let info = store.application.get_room_info(room_id);
let key = (MessageTimeStamp::LocalEcho, event_id);
let msg = MessageContent::Original(msg.into());
let msg = Message::new(msg, user, MessageTimeStamp::LocalEcho);
info.messages.insert(key, msg);
None
},
IambAction::Verify(act, user_dev) => {
if let Some(sas) = store.application.verifications.get(&user_dev) {
self.worker.verify(act, sas.clone())?
} else {
return Err(IambError::InvalidVerificationId(user_dev).into());
}
},
IambAction::VerifyRequest(user_id) => {
if let Ok(user_id) = OwnedUserId::try_from(user_id.as_str()) {
self.worker.verify_request(user_id)?
} else {
return Err(IambError::InvalidUserId(user_id).into());
}
},
};
Ok(info)
}
pub async fn run(&mut self) -> Result<(), std::io::Error> {
self.terminal.clear()?;
let store = self.store.clone();
while self.screen.tabs() != 0 {
let key = self.step().await?;
self.bindings.input_key(key);
let mut locked = store.lock().await;
let mut keyskip = false;
while let Some((action, ctx)) = self.action_pop(keyskip) {
match self.action_run(action, ctx, locked.deref_mut()) {
Ok(None) => {
// Continue processing.
continue;
},
Ok(Some(info)) => {
self.screen.push_info(info);
// Continue processing; we'll redraw later.
continue;
},
Err(e) => {
self.screen.push_error(e);
// Skip processing any more keypress Actions until the next key.
keyskip = true;
continue;
},
}
}
}
crossterm::terminal::disable_raw_mode()?;
execute!(self.terminal.backend_mut(), LeaveAlternateScreen)?;
self.terminal.show_cursor()?;
return Ok(());
}
}
fn login(worker: Requester, settings: &ApplicationSettings) -> IambResult<()> {
println!("Logging in for {}...", settings.profile.user_id);
if settings.session_json.is_file() {
let file = File::open(settings.session_json.as_path())?;
let reader = BufReader::new(file);
let session = serde_json::from_reader(reader).map_err(IambError::from)?;
worker.login(LoginStyle::SessionRestore(session))?;
return Ok(());
}
loop {
let password = rpassword::prompt_password("Password: ")?;
match worker.login(LoginStyle::Password(password)) {
Ok(info) => {
if let Some(msg) = info {
println!("{}", msg);
}
break;
},
Err(err) => {
println!("Failed to login: {}", err);
continue;
},
}
}
Ok(())
}
fn print_exit<T: Display, N>(v: T) -> N {
println!("{}", v);
process::exit(2);
}
#[tokio::main]
async fn main() -> IambResult<()> {
// Parse command-line flags.
let iamb = Iamb::parse();
// Load configuration and set up the Matrix SDK.
let settings = ApplicationSettings::load(iamb).unwrap_or_else(print_exit);
// 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();
log_dir.push("logs");
create_dir_all(settings.matrix_dir.as_path())?;
create_dir_all(log_dir.as_path())?;
let appender = tracing_appender::rolling::daily(log_dir, log_prefix);
let (appender, _) = tracing_appender::non_blocking(appender);
let subscriber = FmtSubscriber::builder()
.with_writer(appender)
.with_max_level(Level::WARN)
.finish();
tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed");
// Set up the async worker thread and global store.
let worker = ClientWorker::spawn(settings.clone());
let store = ChatStore::new(worker.clone(), settings.clone());
let store = Store::new(store);
let store = Arc::new(AsyncMutex::new(store));
worker.init(store.clone());
login(worker, &settings).unwrap_or_else(print_exit);
// Make sure panics clean up the terminal properly.
let orig_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic_info| {
let _ = crossterm::terminal::disable_raw_mode();
let _ = crossterm::execute!(stdout(), LeaveAlternateScreen);
let _ = crossterm::execute!(stdout(), CursorShow);
orig_hook(panic_info);
process::exit(1);
}));
let mut application = Application::new(settings, store).await?;
// We can now run the application.
application.run().await?;
process::exit(0);
}

644
src/message.rs Normal file
View file

@ -0,0 +1,644 @@
use std::borrow::Cow;
use std::cmp::{Ord, Ordering, PartialOrd};
use std::collections::hash_map::DefaultHasher;
use std::collections::BTreeMap;
use std::convert::TryFrom;
use std::hash::{Hash, Hasher};
use std::str::Lines;
use chrono::{DateTime, NaiveDateTime, Utc};
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
use matrix_sdk::ruma::{
events::{
room::message::{MessageType, RoomMessageEventContent},
MessageLikeEvent,
},
MilliSecondsSinceUnixEpoch,
OwnedEventId,
OwnedUserId,
UInt,
};
use modalkit::tui::{
style::{Color, Modifier as StyleModifier, Style},
text::{Span, Spans, Text},
};
use modalkit::editing::{base::ViewportContext, cursor::Cursor};
use crate::base::{IambResult, RoomInfo};
pub type MessageEvent = MessageLikeEvent<RoomMessageEventContent>;
pub type MessageFetchResult = IambResult<(Option<String>, Vec<MessageEvent>)>;
pub type MessageKey = (MessageTimeStamp, OwnedEventId);
pub type Messages = BTreeMap<MessageKey, Message>;
const COLORS: [Color; 13] = [
Color::Blue,
Color::Cyan,
Color::Green,
Color::LightBlue,
Color::LightGreen,
Color::LightCyan,
Color::LightMagenta,
Color::LightRed,
Color::LightYellow,
Color::Magenta,
Color::Red,
Color::Reset,
Color::Yellow,
];
const USER_GUTTER: usize = 30;
const TIME_GUTTER: usize = 12;
const MIN_MSG_LEN: usize = 30;
const USER_GUTTER_EMPTY: &str = " ";
const USER_GUTTER_EMPTY_SPAN: Span<'static> = Span {
content: Cow::Borrowed(USER_GUTTER_EMPTY),
style: Style {
fg: None,
bg: None,
add_modifier: StyleModifier::empty(),
sub_modifier: StyleModifier::empty(),
},
};
struct WrappedLinesIterator<'a> {
iter: Lines<'a>,
curr: Option<&'a str>,
width: usize,
}
impl<'a> WrappedLinesIterator<'a> {
fn new(input: &'a str, width: usize) -> Self {
WrappedLinesIterator { iter: input.lines(), curr: None, width }
}
}
impl<'a> Iterator for WrappedLinesIterator<'a> {
type Item = (&'a str, usize);
fn next(&mut self) -> Option<Self::Item> {
if self.curr.is_none() {
self.curr = self.iter.next();
}
if let Some(s) = self.curr.take() {
let width = UnicodeWidthStr::width(s);
if width <= self.width {
return Some((s, width));
} else {
// Find where to split the line.
let mut width = 0;
let mut idx = 0;
for (i, g) in UnicodeSegmentation::grapheme_indices(s, true) {
let gw = UnicodeWidthStr::width(g);
idx = i;
if width + gw > self.width {
break;
}
width += gw;
}
self.curr = Some(&s[idx..]);
return Some((&s[..idx], width));
}
} else {
return None;
}
}
}
fn wrap(input: &str, width: usize) -> WrappedLinesIterator<'_> {
WrappedLinesIterator::new(input, width)
}
fn space(width: usize) -> String {
" ".repeat(width)
}
#[derive(thiserror::Error, Debug)]
pub enum TimeStampIntError {
#[error("Integer conversion error: {0}")]
IntError(#[from] std::num::TryFromIntError),
#[error("UInt conversion error: {0}")]
UIntError(<UInt as TryFrom<u64>>::Error),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MessageTimeStamp {
OriginServer(UInt),
LocalEcho,
}
impl MessageTimeStamp {
fn show(&self) -> Option<Span> {
match self {
MessageTimeStamp::OriginServer(ts) => {
let time = i64::from(*ts) / 1000;
let time = NaiveDateTime::from_timestamp_opt(time, 0)?;
let time = DateTime::<Utc>::from_utc(time, Utc);
let time = time.format("%T");
let time = format!(" [{}]", time);
Span::raw(time).into()
},
MessageTimeStamp::LocalEcho => None,
}
}
fn is_local_echo(&self) -> bool {
matches!(self, MessageTimeStamp::LocalEcho)
}
}
impl Ord for MessageTimeStamp {
fn cmp(&self, other: &Self) -> Ordering {
match (self, other) {
(MessageTimeStamp::OriginServer(_), MessageTimeStamp::LocalEcho) => Ordering::Less,
(MessageTimeStamp::OriginServer(a), MessageTimeStamp::OriginServer(b)) => a.cmp(b),
(MessageTimeStamp::LocalEcho, MessageTimeStamp::OriginServer(_)) => Ordering::Greater,
(MessageTimeStamp::LocalEcho, MessageTimeStamp::LocalEcho) => Ordering::Equal,
}
}
}
impl PartialOrd for MessageTimeStamp {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
self.cmp(other).into()
}
}
impl From<MilliSecondsSinceUnixEpoch> for MessageTimeStamp {
fn from(millis: MilliSecondsSinceUnixEpoch) -> Self {
MessageTimeStamp::OriginServer(millis.0)
}
}
impl TryFrom<&MessageTimeStamp> for usize {
type Error = TimeStampIntError;
fn try_from(ts: &MessageTimeStamp) -> Result<Self, Self::Error> {
let n = match ts {
MessageTimeStamp::LocalEcho => 0,
MessageTimeStamp::OriginServer(u) => usize::try_from(u64::from(*u))?,
};
Ok(n)
}
}
impl TryFrom<usize> for MessageTimeStamp {
type Error = TimeStampIntError;
fn try_from(u: usize) -> Result<Self, Self::Error> {
if u == 0 {
Ok(MessageTimeStamp::LocalEcho)
} else {
let n = u64::try_from(u)?;
let n = UInt::try_from(n).map_err(TimeStampIntError::UIntError)?;
Ok(MessageTimeStamp::OriginServer(n))
}
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct MessageCursor {
/// When timestamp is None, the corner is determined by moving backwards from
/// the most recently received message.
pub timestamp: Option<MessageKey>,
/// A row within the [Text] representation of a [Message].
pub text_row: usize,
}
impl MessageCursor {
pub fn new(timestamp: MessageKey, text_row: usize) -> Self {
MessageCursor { timestamp: Some(timestamp), text_row }
}
/// Get a cursor that refers to the most recent message.
pub fn latest() -> Self {
MessageCursor::default()
}
pub fn to_key<'a>(&'a self, info: &'a RoomInfo) -> Option<&'a MessageKey> {
if let Some(ref key) = self.timestamp {
Some(key)
} else {
Some(info.messages.last_key_value()?.0)
}
}
pub fn from_cursor(cursor: &Cursor, info: &RoomInfo) -> Option<Self> {
let ev_hash = u64::try_from(cursor.get_x()).ok()?;
let ev_term = OwnedEventId::try_from("$").ok()?;
let ts_start = MessageTimeStamp::try_from(cursor.get_y()).ok()?;
let start = (ts_start, ev_term);
let mut mc = None;
for ((ts, event_id), _) in info.messages.range(start..) {
let mut hasher = DefaultHasher::new();
event_id.hash(&mut hasher);
if hasher.finish() == ev_hash {
mc = Self::from((*ts, event_id.clone())).into();
break;
}
if mc.is_none() {
mc = Self::from((*ts, event_id.clone())).into();
}
if ts > &ts_start {
break;
}
}
return mc;
}
pub fn to_cursor(&self, info: &RoomInfo) -> Option<Cursor> {
let (ts, event_id) = self.to_key(info)?;
let y: usize = usize::try_from(ts).ok()?;
let mut hasher = DefaultHasher::new();
event_id.hash(&mut hasher);
let x = usize::try_from(hasher.finish()).ok()?;
Cursor::new(y, x).into()
}
}
impl From<Option<MessageKey>> for MessageCursor {
fn from(key: Option<MessageKey>) -> Self {
MessageCursor { timestamp: key, text_row: 0 }
}
}
impl From<MessageKey> for MessageCursor {
fn from(key: MessageKey) -> Self {
MessageCursor { timestamp: Some(key), text_row: 0 }
}
}
impl Ord for MessageCursor {
fn cmp(&self, other: &Self) -> Ordering {
match (&self.timestamp, &other.timestamp) {
(None, None) => self.text_row.cmp(&other.text_row),
(None, Some(_)) => Ordering::Greater,
(Some(_), None) => Ordering::Less,
(Some(st), Some(ot)) => {
let pcmp = st.cmp(ot);
let tcmp = self.text_row.cmp(&other.text_row);
pcmp.then(tcmp)
},
}
}
}
impl PartialOrd for MessageCursor {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
self.cmp(other).into()
}
}
#[derive(Clone)]
pub enum MessageContent {
Original(Box<RoomMessageEventContent>),
Redacted,
}
impl AsRef<str> for MessageContent {
fn as_ref(&self) -> &str {
match self {
MessageContent::Original(ev) => {
match &ev.msgtype {
MessageType::Text(content) => {
return content.body.as_ref();
},
MessageType::Emote(content) => {
return content.body.as_ref();
},
MessageType::Notice(content) => {
return content.body.as_str();
},
MessageType::ServerNotice(_) => {
// XXX: implement
return "[server notice]";
},
MessageType::VerificationRequest(_) => {
// XXX: implement
return "[verification request]";
},
MessageType::Audio(..) => {
return "[audio]";
},
MessageType::File(..) => {
return "[file]";
},
MessageType::Image(..) => {
return "[image]";
},
MessageType::Video(..) => {
return "[video]";
},
_ => return "[unknown message type]",
}
},
MessageContent::Redacted => "[redacted]",
}
}
}
#[derive(Clone)]
pub struct Message {
pub content: MessageContent,
pub sender: OwnedUserId,
pub timestamp: MessageTimeStamp,
}
impl Message {
pub fn new(content: MessageContent, sender: OwnedUserId, timestamp: MessageTimeStamp) -> Self {
Message { content, sender, timestamp }
}
pub fn show(&self, selected: bool, vwctx: &ViewportContext<MessageCursor>) -> Text {
let width = vwctx.get_width();
let msg = self.as_ref();
let mut lines = vec![];
let mut style = Style::default();
if selected {
style = style.add_modifier(StyleModifier::REVERSED)
}
if self.timestamp.is_local_echo() {
style = style.add_modifier(StyleModifier::ITALIC);
}
if USER_GUTTER + TIME_GUTTER + MIN_MSG_LEN <= width {
let lw = width - USER_GUTTER - TIME_GUTTER;
for (i, (line, w)) in wrap(msg, lw).enumerate() {
let line = Span::styled(line, style);
let trailing = Span::styled(space(lw.saturating_sub(w)), style);
if i == 0 {
let user = self.show_sender(true);
if let Some(time) = self.timestamp.show() {
lines.push(Spans(vec![user, line, trailing, time]))
} else {
lines.push(Spans(vec![user, line, trailing]))
}
} else {
let space = USER_GUTTER_EMPTY_SPAN;
lines.push(Spans(vec![space, line, trailing]))
}
}
} else if USER_GUTTER + MIN_MSG_LEN <= width {
let lw = width - USER_GUTTER;
for (i, (line, w)) in wrap(msg, lw).enumerate() {
let line = Span::styled(line, style);
let trailing = Span::styled(space(lw.saturating_sub(w)), style);
let prefix = if i == 0 {
self.show_sender(true)
} else {
USER_GUTTER_EMPTY_SPAN
};
lines.push(Spans(vec![prefix, line, trailing]))
}
} else {
lines.push(Spans::from(self.show_sender(false)));
for (line, _) in wrap(msg, width.saturating_sub(2)) {
let line = format!(" {}", line);
let line = Span::styled(line, style);
lines.push(Spans(vec![line]))
}
}
return Text { lines };
}
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 sender = if align_right {
format!("{: >width$} ", sender, width = 28)
} else {
format!("{: <width$} ", sender, width = 28)
};
Span::styled(sender, bold)
}
}
impl From<MessageEvent> for Message {
fn from(event: MessageEvent) -> Self {
match event {
MessageLikeEvent::Original(ev) => {
let content = MessageContent::Original(ev.content.into());
Message::new(content, ev.sender, ev.origin_server_ts.into())
},
MessageLikeEvent::Redacted(ev) => {
Message::new(MessageContent::Redacted, ev.sender, ev.origin_server_ts.into())
},
}
}
}
impl AsRef<str> for Message {
fn as_ref(&self) -> &str {
self.content.as_ref()
}
}
impl ToString for Message {
fn to_string(&self) -> String {
self.as_ref().to_string()
}
}
#[cfg(test)]
pub mod tests {
use super::*;
use crate::tests::*;
#[test]
fn test_wrapped_lines_ascii() {
let s = "hello world!\nabcdefghijklmnopqrstuvwxyz\ngoodbye";
let mut iter = wrap(s, 100);
assert_eq!(iter.next(), Some(("hello world!", 12)));
assert_eq!(iter.next(), Some(("abcdefghijklmnopqrstuvwxyz", 26)));
assert_eq!(iter.next(), Some(("goodbye", 7)));
assert_eq!(iter.next(), None);
let mut iter = wrap(s, 5);
assert_eq!(iter.next(), Some(("hello", 5)));
assert_eq!(iter.next(), Some((" worl", 5)));
assert_eq!(iter.next(), Some(("d!", 2)));
assert_eq!(iter.next(), Some(("abcde", 5)));
assert_eq!(iter.next(), Some(("fghij", 5)));
assert_eq!(iter.next(), Some(("klmno", 5)));
assert_eq!(iter.next(), Some(("pqrst", 5)));
assert_eq!(iter.next(), Some(("uvwxy", 5)));
assert_eq!(iter.next(), Some(("z", 1)));
assert_eq!(iter.next(), Some(("goodb", 5)));
assert_eq!(iter.next(), Some(("ye", 2)));
assert_eq!(iter.next(), None);
}
#[test]
fn test_wrapped_lines_unicode() {
let s = "";
let mut iter = wrap(s, 14);
assert_eq!(iter.next(), Some((s, 14)));
assert_eq!(iter.next(), None);
let mut iter = wrap(s, 5);
assert_eq!(iter.next(), Some(("", 4)));
assert_eq!(iter.next(), Some(("", 4)));
assert_eq!(iter.next(), Some(("", 4)));
assert_eq!(iter.next(), Some(("", 2)));
assert_eq!(iter.next(), None);
}
#[test]
fn test_mc_cmp() {
let mc1 = MessageCursor::from(MSG1_KEY.clone());
let mc2 = MessageCursor::from(MSG2_KEY.clone());
let mc3 = MessageCursor::from(MSG3_KEY.clone());
let mc4 = MessageCursor::from(MSG4_KEY.clone());
let mc5 = MessageCursor::from(MSG5_KEY.clone());
// Everything is equal to itself.
assert_eq!(mc1.cmp(&mc1), Ordering::Equal);
assert_eq!(mc2.cmp(&mc2), Ordering::Equal);
assert_eq!(mc3.cmp(&mc3), Ordering::Equal);
assert_eq!(mc4.cmp(&mc4), Ordering::Equal);
assert_eq!(mc5.cmp(&mc5), Ordering::Equal);
// Local echo is always greater than an origin server timestamp.
assert_eq!(mc1.cmp(&mc2), Ordering::Greater);
assert_eq!(mc1.cmp(&mc3), Ordering::Greater);
assert_eq!(mc1.cmp(&mc4), Ordering::Greater);
assert_eq!(mc1.cmp(&mc5), Ordering::Greater);
// mc2 is the smallest timestamp.
assert_eq!(mc2.cmp(&mc1), Ordering::Less);
assert_eq!(mc2.cmp(&mc3), Ordering::Less);
assert_eq!(mc2.cmp(&mc4), Ordering::Less);
assert_eq!(mc2.cmp(&mc5), Ordering::Less);
// mc3 should be less than mc4 because of its event ID.
assert_eq!(mc3.cmp(&mc1), Ordering::Less);
assert_eq!(mc3.cmp(&mc2), Ordering::Greater);
assert_eq!(mc3.cmp(&mc4), Ordering::Less);
assert_eq!(mc3.cmp(&mc5), Ordering::Less);
// mc4 should be greater than mc3 because of its event ID.
assert_eq!(mc4.cmp(&mc1), Ordering::Less);
assert_eq!(mc4.cmp(&mc2), Ordering::Greater);
assert_eq!(mc4.cmp(&mc3), Ordering::Greater);
assert_eq!(mc4.cmp(&mc5), Ordering::Less);
// mc5 is the greatest OriginServer timestamp.
assert_eq!(mc5.cmp(&mc1), Ordering::Less);
assert_eq!(mc5.cmp(&mc2), Ordering::Greater);
assert_eq!(mc5.cmp(&mc3), Ordering::Greater);
assert_eq!(mc5.cmp(&mc4), Ordering::Greater);
}
#[test]
fn test_mc_to_key() {
let info = mock_room();
let mc1 = MessageCursor::from(MSG1_KEY.clone());
let mc2 = MessageCursor::from(MSG2_KEY.clone());
let mc3 = MessageCursor::from(MSG3_KEY.clone());
let mc4 = MessageCursor::from(MSG4_KEY.clone());
let mc5 = MessageCursor::from(MSG5_KEY.clone());
let mc6 = MessageCursor::latest();
let k1 = mc1.to_key(&info).unwrap();
let k2 = mc2.to_key(&info).unwrap();
let k3 = mc3.to_key(&info).unwrap();
let k4 = mc4.to_key(&info).unwrap();
let k5 = mc5.to_key(&info).unwrap();
let k6 = mc6.to_key(&info).unwrap();
// These should all be equal to their MSGN_KEYs.
assert_eq!(k1, &MSG1_KEY.clone());
assert_eq!(k2, &MSG2_KEY.clone());
assert_eq!(k3, &MSG3_KEY.clone());
assert_eq!(k4, &MSG4_KEY.clone());
assert_eq!(k5, &MSG5_KEY.clone());
// MessageCursor::latest() turns into the largest key (our local echo message).
assert_eq!(k6, &MSG1_KEY.clone());
// MessageCursor::latest() fails to convert for a room w/o messages.
let info_empty = RoomInfo::default();
assert_eq!(mc6.to_key(&info_empty), None);
}
#[test]
fn test_mc_to_from_cursor() {
let info = mock_room();
let mc1 = MessageCursor::from(MSG1_KEY.clone());
let mc2 = MessageCursor::from(MSG2_KEY.clone());
let mc3 = MessageCursor::from(MSG3_KEY.clone());
let mc4 = MessageCursor::from(MSG4_KEY.clone());
let mc5 = MessageCursor::from(MSG5_KEY.clone());
let mc6 = MessageCursor::latest();
let identity = |mc: &MessageCursor| {
let c = mc.to_cursor(&info).unwrap();
MessageCursor::from_cursor(&c, &info).unwrap()
};
// These should all convert to a Cursor and back to the original value.
assert_eq!(identity(&mc1), mc1);
assert_eq!(identity(&mc2), mc2);
assert_eq!(identity(&mc3), mc3);
assert_eq!(identity(&mc4), mc4);
assert_eq!(identity(&mc5), mc5);
// MessageCursor::latest() should point at the most recent message after conversion.
assert_eq!(identity(&mc6), mc1);
}
}

133
src/tests.rs Normal file
View file

@ -0,0 +1,133 @@
use std::collections::BTreeMap;
use matrix_sdk::ruma::{
event_id,
events::room::message::RoomMessageEventContent,
server_name,
user_id,
EventId,
OwnedRoomId,
OwnedUserId,
RoomId,
UInt,
};
use std::path::PathBuf;
use std::sync::mpsc::sync_channel;
use url::Url;
use lazy_static::lazy_static;
use crate::{
base::{ChatStore, ProgramStore, RoomFetchStatus, RoomInfo},
config::{ApplicationSettings, ProfileConfig},
message::{
Message,
MessageContent,
MessageKey,
MessageTimeStamp::{LocalEcho, OriginServer},
Messages,
},
worker::Requester,
};
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 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")));
pub static ref MSG3_KEY: MessageKey = (
OriginServer(UInt::new(2).unwrap()),
event_id!("$5jRz3KfVhaUzXtVj7k:example.com").to_owned()
);
pub static ref MSG4_KEY: MessageKey = (
OriginServer(UInt::new(2).unwrap()),
event_id!("$JP6qFV7WyXk5ZnexM3:example.com").to_owned()
);
pub static ref MSG5_KEY: MessageKey =
(OriginServer(UInt::new(8).unwrap()), EventId::new(server_name!("example.com")));
}
pub fn mock_message1() -> Message {
let content = RoomMessageEventContent::text_plain("writhe");
let content = MessageContent::Original(content.into());
Message::new(content, TEST_USER1.clone(), MSG1_KEY.0)
}
pub fn mock_message2() -> Message {
let content = RoomMessageEventContent::text_plain("helium");
let content = MessageContent::Original(content.into());
Message::new(content, TEST_USER2.clone(), MSG2_KEY.0)
}
pub fn mock_message3() -> Message {
let content = RoomMessageEventContent::text_plain("this\nis\na\nmultiline\nmessage");
let content = MessageContent::Original(content.into());
Message::new(content, TEST_USER2.clone(), MSG3_KEY.0)
}
pub fn mock_message4() -> Message {
let content = RoomMessageEventContent::text_plain("help");
let content = MessageContent::Original(content.into());
Message::new(content, TEST_USER1.clone(), MSG4_KEY.0)
}
pub fn mock_message5() -> Message {
let content = RoomMessageEventContent::text_plain("character");
let content = MessageContent::Original(content.into());
Message::new(content, TEST_USER2.clone(), MSG5_KEY.0)
}
pub fn mock_messages() -> Messages {
let mut messages = BTreeMap::new();
messages.insert(MSG1_KEY.clone(), mock_message1());
messages.insert(MSG2_KEY.clone(), mock_message2());
messages.insert(MSG3_KEY.clone(), mock_message3());
messages.insert(MSG4_KEY.clone(), mock_message4());
messages.insert(MSG5_KEY.clone(), mock_message5());
messages
}
pub fn mock_room() -> RoomInfo {
RoomInfo {
name: Some("Watercooler Discussion".into()),
messages: mock_messages(),
fetch_id: RoomFetchStatus::NotStarted,
fetch_last: 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(),
},
}
}
pub fn mock_store() -> ProgramStore {
let (tx, _) = sync_channel(5);
let worker = Requester { tx };
let mut store = ChatStore::new(worker, mock_settings());
let room_id = TEST_ROOM1_ID.clone();
let info = mock_room();
store.rooms.insert(room_id, info);
ProgramStore::new(store)
}

696
src/windows/mod.rs Normal file
View file

@ -0,0 +1,696 @@
use std::cmp::{Ord, Ordering, PartialOrd};
use std::collections::hash_map::Entry;
use matrix_sdk::{
encryption::verification::{format_emojis, SasVerification},
room::Room as MatrixRoom,
ruma::RoomId,
DisplayName,
};
use modalkit::tui::{
buffer::Buffer,
layout::Rect,
style::{Modifier as StyleModifier, Style},
text::{Span, Spans, Text},
widgets::{Block, Borders, Widget},
};
use modalkit::{
editing::{
action::{
EditError,
EditInfo,
EditResult,
Editable,
EditorAction,
Jumpable,
PromptAction,
Promptable,
Scrollable,
UIError,
WindowAction,
},
base::{
CloseFlags,
MoveDir1D,
OpenTarget,
PositionList,
ScrollStyle,
ViewportContext,
WordStyle,
},
},
widgets::{
list::{ListCursor, ListItem, ListState},
TermOffset,
TerminalCursor,
Window,
WindowOps,
},
};
use super::base::{
ChatStore,
IambBufferId,
IambId,
IambInfo,
IambResult,
ProgramAction,
ProgramContext,
ProgramStore,
};
use self::{room::RoomState, welcome::WelcomeState};
pub mod room;
pub mod welcome;
#[inline]
fn selected_style(selected: bool) -> Style {
if selected {
Style::default().add_modifier(StyleModifier::REVERSED)
} else {
Style::default()
}
}
#[inline]
fn selected_span(s: &str, selected: bool) -> Span {
Span::styled(s, selected_style(selected))
}
#[inline]
fn selected_text(s: &str, selected: bool) -> Text {
Text::from(selected_span(s, selected))
}
#[inline]
fn room_prompt(
room_id: &RoomId,
act: &PromptAction,
ctx: &ProgramContext,
) -> EditResult<Vec<(ProgramAction, ProgramContext)>, IambInfo> {
match act {
PromptAction::Submit => {
let room = IambId::Room(room_id.to_owned());
let open = WindowAction::Switch(OpenTarget::Application(room));
let acts = vec![(open.into(), ctx.clone())];
Ok(acts)
},
PromptAction::Abort(_) => {
let msg = "Cannot abort entry inside a list";
let err = EditError::Failure(msg.into());
Err(err)
},
PromptAction::Recall(_, _) => {
let msg = "Cannot recall history inside a list";
let err = EditError::Failure(msg.into());
Err(err)
},
_ => Err(EditError::Unimplemented("unknown prompt action".to_string())),
}
}
macro_rules! delegate {
($s: expr, $id: ident => $e: expr) => {
match $s {
IambWindow::Room($id) => $e,
IambWindow::DirectList($id) => $e,
IambWindow::RoomList($id) => $e,
IambWindow::SpaceList($id) => $e,
IambWindow::VerifyList($id) => $e,
IambWindow::Welcome($id) => $e,
}
};
}
pub enum IambWindow {
DirectList(DirectListState),
Room(RoomState),
VerifyList(VerifyListState),
RoomList(RoomListState),
SpaceList(SpaceListState),
Welcome(WelcomeState),
}
impl IambWindow {
pub fn focus_toggle(&mut self) {
if let IambWindow::Room(w) = self {
w.focus_toggle()
} else {
return;
}
}
pub fn get_title(&self, store: &mut ProgramStore) -> String {
match self {
IambWindow::Room(w) => w.get_title(store),
IambWindow::DirectList(_) => "Direct Messages".to_string(),
IambWindow::RoomList(_) => "Rooms".to_string(),
IambWindow::SpaceList(_) => "Spaces".to_string(),
IambWindow::VerifyList(_) => "Verifications".to_string(),
IambWindow::Welcome(_) => "Welcome to iamb".to_string(),
}
}
}
pub type DirectListState = ListState<DirectItem, IambInfo>;
pub type RoomListState = ListState<RoomItem, IambInfo>;
pub type SpaceListState = ListState<SpaceItem, IambInfo>;
pub type VerifyListState = ListState<VerifyItem, IambInfo>;
impl From<RoomState> for IambWindow {
fn from(room: RoomState) -> Self {
IambWindow::Room(room)
}
}
impl From<VerifyListState> for IambWindow {
fn from(list: VerifyListState) -> Self {
IambWindow::VerifyList(list)
}
}
impl From<DirectListState> for IambWindow {
fn from(list: DirectListState) -> Self {
IambWindow::DirectList(list)
}
}
impl From<RoomListState> for IambWindow {
fn from(list: RoomListState) -> Self {
IambWindow::RoomList(list)
}
}
impl From<SpaceListState> for IambWindow {
fn from(list: SpaceListState) -> Self {
IambWindow::SpaceList(list)
}
}
impl From<WelcomeState> for IambWindow {
fn from(win: WelcomeState) -> Self {
IambWindow::Welcome(win)
}
}
impl Editable<ProgramContext, ProgramStore, IambInfo> for IambWindow {
fn editor_command(
&mut self,
act: &EditorAction,
ctx: &ProgramContext,
store: &mut ProgramStore,
) -> EditResult<EditInfo, IambInfo> {
delegate!(self, w => w.editor_command(act, ctx, store))
}
}
impl Jumpable<ProgramContext, IambInfo> for IambWindow {
fn jump(
&mut self,
list: PositionList,
dir: MoveDir1D,
count: usize,
ctx: &ProgramContext,
) -> IambResult<usize> {
delegate!(self, w => w.jump(list, dir, count, ctx))
}
}
impl Scrollable<ProgramContext, ProgramStore, IambInfo> for IambWindow {
fn scroll(
&mut self,
style: &ScrollStyle,
ctx: &ProgramContext,
store: &mut ProgramStore,
) -> EditResult<EditInfo, IambInfo> {
delegate!(self, w => w.scroll(style, ctx, store))
}
}
impl Promptable<ProgramContext, ProgramStore, IambInfo> for IambWindow {
fn prompt(
&mut self,
act: &PromptAction,
ctx: &ProgramContext,
store: &mut ProgramStore,
) -> EditResult<Vec<(ProgramAction, ProgramContext)>, IambInfo> {
delegate!(self, w => w.prompt(act, ctx, store))
}
}
impl TerminalCursor for IambWindow {
fn get_term_cursor(&self) -> Option<TermOffset> {
delegate!(self, w => w.get_term_cursor())
}
}
impl WindowOps<IambInfo> for IambWindow {
fn draw(&mut self, area: Rect, buf: &mut Buffer, focused: bool, store: &mut ProgramStore) {
let title = self.get_title(store);
let block = Block::default().title(title.as_str()).borders(Borders::ALL);
let inner = block.inner(area);
block.render(area, buf);
match self {
IambWindow::Room(state) => state.draw(inner, buf, focused, store),
IambWindow::DirectList(state) => {
let dms = store.application.worker.direct_messages();
let items = dms.into_iter().map(|(id, name)| DirectItem::new(id, name, store));
state.set(items.collect());
state.draw(inner, buf, focused, store);
},
IambWindow::RoomList(state) => {
let joined = store.application.worker.joined_rooms();
let items = joined.into_iter().map(|(id, name)| RoomItem::new(id, name, store));
state.set(items.collect());
state.draw(inner, buf, focused, store);
},
IambWindow::SpaceList(state) => {
let spaces = store.application.worker.spaces();
let items =
spaces.into_iter().map(|(room, name)| SpaceItem::new(room, name, store));
state.set(items.collect());
state.draw(inner, buf, focused, store);
},
IambWindow::VerifyList(state) => {
let verifications = &store.application.verifications;
let mut items = verifications.iter().map(VerifyItem::from).collect::<Vec<_>>();
// Sort the active verifications towards the top.
items.sort();
state.set(items);
state.draw(inner, buf, focused, store);
},
IambWindow::Welcome(state) => state.draw(inner, buf, focused, store),
}
}
fn dup(&self, store: &mut ProgramStore) -> Self {
delegate!(self, w => w.dup(store).into())
}
fn close(&mut self, flags: CloseFlags, store: &mut ProgramStore) -> bool {
delegate!(self, w => w.close(flags, store))
}
fn get_cursor_word(&self, style: &WordStyle) -> Option<String> {
delegate!(self, w => w.get_cursor_word(style))
}
fn get_selected_word(&self) -> Option<String> {
delegate!(self, w => w.get_selected_word())
}
}
impl Window<IambInfo> for IambWindow {
fn id(&self) -> IambId {
match self {
IambWindow::Room(room) => IambId::Room(room.id().to_owned()),
IambWindow::DirectList(_) => IambId::DirectList,
IambWindow::RoomList(_) => IambId::RoomList,
IambWindow::SpaceList(_) => IambId::SpaceList,
IambWindow::VerifyList(_) => IambId::VerifyList,
IambWindow::Welcome(_) => IambId::Welcome,
}
}
fn open(id: IambId, store: &mut ProgramStore) -> IambResult<Self> {
match id {
IambId::Room(room_id) => {
let (room, name) = store.application.worker.get_room(room_id)?;
let room = RoomState::new(room, name, store);
return Ok(room.into());
},
IambId::DirectList => {
let list = DirectListState::new(IambBufferId::DirectList, vec![]);
return Ok(list.into());
},
IambId::RoomList => {
let list = RoomListState::new(IambBufferId::RoomList, vec![]);
return Ok(list.into());
},
IambId::SpaceList => {
let list = SpaceListState::new(IambBufferId::SpaceList, vec![]);
return Ok(list.into());
},
IambId::VerifyList => {
let list = VerifyListState::new(IambBufferId::VerifyList, vec![]);
return Ok(list.into());
},
IambId::Welcome => {
let win = WelcomeState::new(store);
return Ok(win.into());
},
}
}
fn find(name: String, store: &mut ProgramStore) -> IambResult<Self> {
let ChatStore { names, worker, .. } = &mut store.application;
match names.entry(name) {
Entry::Vacant(v) => {
let room_id = worker.join_room(v.key().to_string())?;
v.insert(room_id.clone());
let (room, name) = store.application.worker.get_room(room_id)?;
let room = RoomState::new(room, name, store);
Ok(room.into())
},
Entry::Occupied(o) => {
let id = IambId::Room(o.get().clone());
IambWindow::open(id, store)
},
}
}
fn posn(index: usize, _: &mut ProgramStore) -> IambResult<Self> {
let msg = format!("Cannot find indexed buffer (index = {})", index);
let err = UIError::Unimplemented(msg);
Err(err)
}
}
#[derive(Clone)]
pub struct RoomItem {
room: MatrixRoom,
name: String,
}
impl RoomItem {
fn new(room: MatrixRoom, name: DisplayName, store: &mut ProgramStore) -> Self {
let name = name.to_string();
store.application.set_room_name(room.room_id(), name.as_str());
RoomItem { room, name }
}
}
impl ToString for RoomItem {
fn to_string(&self) -> String {
return self.name.clone();
}
}
impl ListItem<IambInfo> for RoomItem {
fn show(&self, selected: bool, _: &ViewportContext<ListCursor>, _: &mut ProgramStore) -> Text {
selected_text(self.name.as_str(), selected)
}
}
impl Promptable<ProgramContext, ProgramStore, IambInfo> for RoomItem {
fn prompt(
&mut self,
act: &PromptAction,
ctx: &ProgramContext,
_: &mut ProgramStore,
) -> EditResult<Vec<(ProgramAction, ProgramContext)>, IambInfo> {
room_prompt(self.room.room_id(), act, ctx)
}
}
#[derive(Clone)]
pub struct DirectItem {
room: MatrixRoom,
name: String,
}
impl DirectItem {
fn new(room: MatrixRoom, name: DisplayName, store: &mut ProgramStore) -> Self {
let name = name.to_string();
store.application.set_room_name(room.room_id(), name.as_str());
DirectItem { room, name }
}
}
impl ToString for DirectItem {
fn to_string(&self) -> String {
return self.name.clone();
}
}
impl ListItem<IambInfo> for DirectItem {
fn show(&self, selected: bool, _: &ViewportContext<ListCursor>, _: &mut ProgramStore) -> Text {
selected_text(self.name.as_str(), selected)
}
}
impl Promptable<ProgramContext, ProgramStore, IambInfo> for DirectItem {
fn prompt(
&mut self,
act: &PromptAction,
ctx: &ProgramContext,
_: &mut ProgramStore,
) -> EditResult<Vec<(ProgramAction, ProgramContext)>, IambInfo> {
room_prompt(self.room.room_id(), act, ctx)
}
}
#[derive(Clone)]
pub struct SpaceItem {
room: MatrixRoom,
name: String,
}
impl SpaceItem {
fn new(room: MatrixRoom, name: DisplayName, store: &mut ProgramStore) -> Self {
let name = name.to_string();
store.application.set_room_name(room.room_id(), name.as_str());
SpaceItem { room, name }
}
}
impl ToString for SpaceItem {
fn to_string(&self) -> String {
return self.room.room_id().to_string();
}
}
impl ListItem<IambInfo> for SpaceItem {
fn show(&self, selected: bool, _: &ViewportContext<ListCursor>, _: &mut ProgramStore) -> Text {
selected_text(self.name.as_str(), selected)
}
}
impl Promptable<ProgramContext, ProgramStore, IambInfo> for SpaceItem {
fn prompt(
&mut self,
act: &PromptAction,
ctx: &ProgramContext,
_: &mut ProgramStore,
) -> EditResult<Vec<(ProgramAction, ProgramContext)>, IambInfo> {
room_prompt(self.room.room_id(), act, ctx)
}
}
#[derive(Clone)]
pub struct VerifyItem {
user_dev: String,
sasv1: SasVerification,
}
impl VerifyItem {
fn new(user_dev: String, sasv1: SasVerification) -> Self {
VerifyItem { user_dev, sasv1 }
}
fn show_item(&self) -> String {
let state = if self.sasv1.is_done() {
"done"
} else if self.sasv1.is_cancelled() {
"cancelled"
} else if self.sasv1.emoji().is_some() {
"accepted"
} else {
"not accepted"
};
if self.sasv1.is_self_verification() {
let device = self.sasv1.other_device();
if let Some(display_name) = device.display_name() {
format!("Device verification with {} ({})", display_name, state)
} else {
format!("Device verification with device {} ({})", device.device_id(), state)
}
} else {
format!("User Verification with {} ({})", self.sasv1.other_user_id(), state)
}
}
}
impl PartialEq for VerifyItem {
fn eq(&self, other: &Self) -> bool {
self.user_dev == other.user_dev
}
}
impl Eq for VerifyItem {}
impl Ord for VerifyItem {
fn cmp(&self, other: &Self) -> Ordering {
fn state_val(sas: &SasVerification) -> usize {
if sas.is_done() {
return 3;
} else if sas.is_cancelled() {
return 2;
} else {
return 1;
}
}
fn device_val(sas: &SasVerification) -> usize {
if sas.is_self_verification() {
return 1;
} else {
return 2;
}
}
let state1 = state_val(&self.sasv1);
let state2 = state_val(&other.sasv1);
let dev1 = device_val(&self.sasv1);
let dev2 = device_val(&other.sasv1);
let scmp = state1.cmp(&state2);
let dcmp = dev1.cmp(&dev2);
scmp.then(dcmp).then_with(|| {
let did1 = self.sasv1.other_device().device_id();
let did2 = other.sasv1.other_device().device_id();
did1.cmp(did2)
})
}
}
impl PartialOrd for VerifyItem {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
self.cmp(other).into()
}
}
impl From<(&String, &SasVerification)> for VerifyItem {
fn from((user_dev, sasv1): (&String, &SasVerification)) -> Self {
VerifyItem::new(user_dev.clone(), sasv1.clone())
}
}
impl ToString for VerifyItem {
fn to_string(&self) -> String {
if self.sasv1.is_done() {
String::new()
} else if self.sasv1.is_cancelled() {
format!(":verify request {}", self.sasv1.other_user_id())
} else if self.sasv1.emoji().is_some() {
format!(":verify confirm {}", self.user_dev)
} else {
format!(":verify accept {}", self.user_dev)
}
}
}
impl ListItem<IambInfo> for VerifyItem {
fn show(&self, selected: bool, _: &ViewportContext<ListCursor>, _: &mut ProgramStore) -> Text {
let mut lines = vec![];
let bold = Style::default().add_modifier(StyleModifier::BOLD);
let item = Span::styled(self.show_item(), selected_style(selected));
lines.push(Spans::from(item));
if self.sasv1.is_done() {
// Print nothing.
} else if self.sasv1.is_cancelled() {
if let Some(info) = self.sasv1.cancel_info() {
lines.push(Spans::from(format!(" Cancelled: {}", info.reason())));
lines.push(Spans::from(""));
}
lines.push(Spans::from(" You can start a new verification request with:"));
} else if let Some(emoji) = self.sasv1.emoji() {
lines.push(Spans::from(
" Both devices should see the following Emoji sequence:".to_string(),
));
lines.push(Spans::from(""));
for line in format_emojis(emoji).lines() {
lines.push(Spans::from(format!(" {}", line)));
}
lines.push(Spans::from(""));
lines.push(Spans::from(" If they don't match, run:"));
lines.push(Spans::from(""));
lines.push(Spans::from(Span::styled(
format!(":verify mismatch {}", self.user_dev),
bold,
)));
lines.push(Spans::from(""));
lines.push(Spans::from(" If everything looks right, you can confirm with:"));
} else {
lines.push(Spans::from(" To accept this request, run:"));
}
let cmd = self.to_string();
if !cmd.is_empty() {
lines.push(Spans::from(""));
lines.push(Spans(vec![Span::from(" "), Span::styled(cmd, bold)]));
lines.push(Spans::from(""));
lines.push(Spans(vec![
Span::from("You can copy the above command with "),
Span::styled("yy", bold),
Span::from(" and then execute it with "),
Span::styled("@\"", bold),
]));
}
Text { lines }
}
}
impl Promptable<ProgramContext, ProgramStore, IambInfo> for VerifyItem {
fn prompt(
&mut self,
act: &PromptAction,
_: &ProgramContext,
_: &mut ProgramStore,
) -> EditResult<Vec<(ProgramAction, ProgramContext)>, IambInfo> {
match act {
PromptAction::Submit => Ok(vec![]),
PromptAction::Abort(_) => {
let msg = "Cannot abort entry inside a list";
let err = EditError::Failure(msg.into());
Err(err)
},
PromptAction::Recall(_, _) => {
let msg = "Cannot recall history inside a list";
let err = EditError::Failure(msg.into());
Err(err)
},
_ => Err(EditError::Unimplemented("unknown prompt action".to_string())),
}
}
}

319
src/windows/room/chat.rs Normal file
View file

@ -0,0 +1,319 @@
use matrix_sdk::{
room::Room as MatrixRoom,
ruma::{OwnedRoomId, RoomId},
};
use modalkit::tui::{buffer::Buffer, layout::Rect, widgets::StatefulWidget};
use modalkit::{
widgets::textbox::{TextBox, TextBoxState},
widgets::TerminalCursor,
widgets::{PromptActions, WindowOps},
};
use modalkit::editing::{
action::{
EditError,
EditInfo,
EditResult,
Editable,
EditorAction,
Jumpable,
PromptAction,
Promptable,
Scrollable,
},
base::{CloseFlags, Count, MoveDir1D, PositionList, ScrollStyle, WordStyle},
context::Resolve,
history::{self, HistoryList},
rope::EditRope,
};
use crate::base::{
IambAction,
IambBufferId,
IambInfo,
IambResult,
ProgramAction,
ProgramContext,
ProgramStore,
RoomFocus,
};
use super::scrollback::{Scrollback, ScrollbackState};
pub struct ChatState {
room_id: OwnedRoomId,
tbox: TextBoxState<IambInfo>,
sent: HistoryList<EditRope>,
sent_scrollback: history::ScrollbackState,
scrollback: ScrollbackState,
focus: RoomFocus,
}
impl ChatState {
pub fn new(room: MatrixRoom, store: &mut ProgramStore) -> Self {
let room_id = room.room_id().to_owned();
let scrollback = ScrollbackState::new(room_id.clone());
let id = IambBufferId::Room(room_id.clone(), RoomFocus::MessageBar);
let ebuf = store.load_buffer(id);
let tbox = TextBoxState::new(ebuf);
ChatState {
room_id,
tbox,
sent: HistoryList::new(EditRope::from(""), 100),
sent_scrollback: history::ScrollbackState::Pending,
scrollback,
focus: RoomFocus::MessageBar,
}
}
pub fn focus_toggle(&mut self) {
self.focus = match self.focus {
RoomFocus::Scrollback => RoomFocus::MessageBar,
RoomFocus::MessageBar => RoomFocus::Scrollback,
};
}
pub fn id(&self) -> &RoomId {
&self.room_id
}
}
macro_rules! delegate {
($s: expr, $id: ident => $e: expr) => {
match $s.focus {
RoomFocus::Scrollback => {
match $s {
ChatState { scrollback: $id, .. } => $e,
}
},
RoomFocus::MessageBar => {
match $s {
ChatState { tbox: $id, .. } => $e,
}
},
}
};
}
impl WindowOps<IambInfo> for ChatState {
fn draw(&mut self, area: Rect, buf: &mut Buffer, focused: bool, store: &mut ProgramStore) {
Chat::new(store).focus(focused).render(area, buf, self)
}
fn dup(&self, store: &mut ProgramStore) -> Self {
// XXX: I want each WindowSlot to have its own shared buffer, instead of each Room; need to
// find a good way to pass that info here so that it can be part of the content id.
let id = IambBufferId::Room(self.room_id.clone(), RoomFocus::MessageBar);
let ebuf = store.load_buffer(id);
let tbox = TextBoxState::new(ebuf);
ChatState {
room_id: self.room_id.clone(),
tbox,
sent: self.sent.clone(),
sent_scrollback: history::ScrollbackState::Pending,
scrollback: self.scrollback.dup(store),
focus: self.focus,
}
}
fn close(&mut self, _: CloseFlags, _: &mut ProgramStore) -> bool {
// XXX: what's the right closing behaviour for a room?
// Should write send a message?
true
}
fn get_cursor_word(&self, style: &WordStyle) -> Option<String> {
delegate!(self, w => w.get_cursor_word(style))
}
fn get_selected_word(&self) -> Option<String> {
delegate!(self, w => w.get_selected_word())
}
}
impl Editable<ProgramContext, ProgramStore, IambInfo> for ChatState {
fn editor_command(
&mut self,
act: &EditorAction,
ctx: &ProgramContext,
store: &mut ProgramStore,
) -> EditResult<EditInfo, IambInfo> {
match delegate!(self, w => w.editor_command(act, ctx, store)) {
res @ Ok(_) => res,
Err(EditError::WrongBuffer(IambBufferId::Room(room_id, focus)))
if room_id == self.room_id && act.is_switchable(ctx) =>
{
// Switch focus.
self.focus = focus;
// Run command again.
delegate!(self, w => w.editor_command(act, ctx, store))
},
res @ Err(_) => res,
}
}
}
impl TerminalCursor for ChatState {
fn get_term_cursor(&self) -> Option<(u16, u16)> {
delegate!(self, w => w.get_term_cursor())
}
}
impl Jumpable<ProgramContext, IambInfo> for ChatState {
fn jump(
&mut self,
list: PositionList,
dir: MoveDir1D,
count: usize,
ctx: &ProgramContext,
) -> IambResult<usize> {
delegate!(self, w => w.jump(list, dir, count, ctx))
}
}
impl Scrollable<ProgramContext, ProgramStore, IambInfo> for ChatState {
fn scroll(
&mut self,
style: &ScrollStyle,
ctx: &ProgramContext,
store: &mut ProgramStore,
) -> EditResult<EditInfo, IambInfo> {
// Send all scroll commands to the scrollback.
//
// If there's enough message text for scrolling to be necessary,
// navigating with movement keys should be enough to do the job.
self.scrollback.scroll(style, ctx, store)
}
}
impl PromptActions<ProgramContext, ProgramStore, IambInfo> for ChatState {
fn submit(
&mut self,
ctx: &ProgramContext,
_: &mut ProgramStore,
) -> EditResult<Vec<(ProgramAction, ProgramContext)>, IambInfo> {
let txt = self.tbox.reset_text();
let act = if txt.is_empty() {
vec![]
} else {
let act = IambAction::SendMessage(self.room_id.clone(), txt).into();
vec![(act, ctx.clone())]
};
Ok(act)
}
fn abort(
&mut self,
empty: bool,
_: &ProgramContext,
_: &mut ProgramStore,
) -> EditResult<Vec<(ProgramAction, ProgramContext)>, IambInfo> {
let text = self.tbox.get();
if empty && text.is_blank() {
return Ok(vec![]);
}
let text = self.tbox.reset().trim();
if text.is_empty() {
let _ = self.sent.end();
} else {
self.sent.select(text);
}
return Ok(vec![]);
}
fn recall(
&mut self,
dir: &MoveDir1D,
count: &Count,
ctx: &ProgramContext,
_: &mut ProgramStore,
) -> EditResult<Vec<(ProgramAction, ProgramContext)>, IambInfo> {
let count = ctx.resolve(count);
let rope = self.tbox.get();
let text = self.sent.recall(&rope, &mut self.sent_scrollback, *dir, count);
if let Some(text) = text {
self.tbox.set_text(text);
}
Ok(vec![])
}
}
impl Promptable<ProgramContext, ProgramStore, IambInfo> for ChatState {
fn prompt(
&mut self,
act: &PromptAction,
ctx: &ProgramContext,
store: &mut ProgramStore,
) -> EditResult<Vec<(ProgramAction, ProgramContext)>, IambInfo> {
if let RoomFocus::Scrollback = self.focus {
return Ok(vec![]);
}
match act {
PromptAction::Submit => self.submit(ctx, store),
PromptAction::Abort(empty) => self.abort(*empty, ctx, store),
PromptAction::Recall(dir, count) => self.recall(dir, count, ctx, store),
_ => Err(EditError::Unimplemented("unknown prompt action".to_string())),
}
}
}
pub struct Chat<'a> {
store: &'a mut ProgramStore,
focused: bool,
}
impl<'a> Chat<'a> {
pub fn new(store: &'a mut ProgramStore) -> Chat<'a> {
Chat { store, focused: false }
}
pub fn focus(mut self, focused: bool) -> Self {
self.focused = focused;
self
}
}
impl<'a> StatefulWidget for Chat<'a> {
type State = ChatState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
let lines = state.tbox.has_lines(5).max(1) as u16;
let drawh = area.height;
let texth = lines.min(drawh).clamp(1, 5);
let scrollh = drawh.saturating_sub(texth);
let scrollarea = Rect::new(area.x, area.y, area.width, scrollh);
let textarea = Rect::new(scrollarea.x, scrollarea.y + scrollh, scrollarea.width, texth);
let scrollback_focused = state.focus.is_scrollback() && self.focused;
let scrollback = Scrollback::new(self.store).focus(scrollback_focused);
scrollback.render(scrollarea, buf, &mut state.scrollback);
let prompt = if self.focused { "> " } else { " " };
let tbox = TextBox::new().prompt(prompt);
tbox.render(textarea, buf, &mut state.tbox);
}
}

182
src/windows/room/mod.rs Normal file
View file

@ -0,0 +1,182 @@
use matrix_sdk::room::Room as MatrixRoom;
use matrix_sdk::ruma::RoomId;
use matrix_sdk::DisplayName;
use modalkit::tui::{buffer::Buffer, layout::Rect, widgets::StatefulWidget};
use modalkit::{
editing::action::{
EditInfo,
EditResult,
Editable,
EditorAction,
Jumpable,
PromptAction,
Promptable,
Scrollable,
},
editing::base::{CloseFlags, MoveDir1D, PositionList, ScrollStyle, WordStyle},
widgets::{TermOffset, TerminalCursor, WindowOps},
};
use crate::base::{IambInfo, IambResult, ProgramAction, ProgramContext, ProgramStore};
use self::chat::ChatState;
use self::space::{Space, SpaceState};
mod chat;
mod scrollback;
mod space;
macro_rules! delegate {
($s: expr, $id: ident => $e: expr) => {
match $s {
RoomState::Chat($id) => $e,
RoomState::Space($id) => $e,
}
};
}
pub enum RoomState {
Chat(ChatState),
Space(SpaceState),
}
impl From<ChatState> for RoomState {
fn from(chat: ChatState) -> Self {
RoomState::Chat(chat)
}
}
impl From<SpaceState> for RoomState {
fn from(space: SpaceState) -> Self {
RoomState::Space(space)
}
}
impl RoomState {
pub fn new(room: MatrixRoom, name: DisplayName, store: &mut ProgramStore) -> Self {
let room_id = room.room_id().to_owned();
let info = store.application.get_room_info(room_id);
info.name = name.to_string().into();
if room.is_space() {
SpaceState::new(room).into()
} else {
ChatState::new(room, store).into()
}
}
pub fn get_title(&self, store: &mut ProgramStore) -> String {
store
.application
.rooms
.get(self.id())
.and_then(|i| i.name.as_ref())
.map(String::from)
.unwrap_or_else(|| "Untitled Matrix Room".to_string())
}
pub fn focus_toggle(&mut self) {
match self {
RoomState::Chat(chat) => chat.focus_toggle(),
RoomState::Space(_) => return,
}
}
pub fn id(&self) -> &RoomId {
match self {
RoomState::Chat(chat) => chat.id(),
RoomState::Space(space) => space.id(),
}
}
}
impl Editable<ProgramContext, ProgramStore, IambInfo> for RoomState {
fn editor_command(
&mut self,
act: &EditorAction,
ctx: &ProgramContext,
store: &mut ProgramStore,
) -> EditResult<EditInfo, IambInfo> {
delegate!(self, w => w.editor_command(act, ctx, store))
}
}
impl Jumpable<ProgramContext, IambInfo> for RoomState {
fn jump(
&mut self,
list: PositionList,
dir: MoveDir1D,
count: usize,
ctx: &ProgramContext,
) -> IambResult<usize> {
delegate!(self, w => w.jump(list, dir, count, ctx))
}
}
impl Scrollable<ProgramContext, ProgramStore, IambInfo> for RoomState {
fn scroll(
&mut self,
style: &ScrollStyle,
ctx: &ProgramContext,
store: &mut ProgramStore,
) -> EditResult<EditInfo, IambInfo> {
delegate!(self, w => w.scroll(style, ctx, store))
}
}
impl Promptable<ProgramContext, ProgramStore, IambInfo> for RoomState {
fn prompt(
&mut self,
act: &PromptAction,
ctx: &ProgramContext,
store: &mut ProgramStore,
) -> EditResult<Vec<(ProgramAction, ProgramContext)>, IambInfo> {
delegate!(self, w => w.prompt(act, ctx, store))
}
}
impl TerminalCursor for RoomState {
fn get_term_cursor(&self) -> Option<TermOffset> {
delegate!(self, w => w.get_term_cursor())
}
}
impl WindowOps<IambInfo> for RoomState {
fn draw(&mut self, area: Rect, buf: &mut Buffer, focused: bool, store: &mut ProgramStore) {
match self {
RoomState::Chat(chat) => chat.draw(area, buf, focused, store),
RoomState::Space(space) => {
Space::new(store).focus(focused).render(area, buf, space);
},
}
}
fn dup(&self, store: &mut ProgramStore) -> Self {
match self {
RoomState::Chat(chat) => RoomState::Chat(chat.dup(store)),
RoomState::Space(space) => RoomState::Space(space.dup(store)),
}
}
fn close(&mut self, _: CloseFlags, _: &mut ProgramStore) -> bool {
// XXX: what's the right closing behaviour for a room?
// Should write send a message?
true
}
fn get_cursor_word(&self, style: &WordStyle) -> Option<String> {
match self {
RoomState::Chat(chat) => chat.get_cursor_word(style),
RoomState::Space(space) => space.get_cursor_word(style),
}
}
fn get_selected_word(&self) -> Option<String> {
match self {
RoomState::Chat(chat) => chat.get_selected_word(),
RoomState::Space(space) => space.get_selected_word(),
}
}
}

File diff suppressed because it is too large Load diff

105
src/windows/room/space.rs Normal file
View file

@ -0,0 +1,105 @@
use std::ops::{Deref, DerefMut};
use matrix_sdk::{
room::Room as MatrixRoom,
ruma::{OwnedRoomId, RoomId},
};
use modalkit::tui::{buffer::Buffer, layout::Rect, widgets::StatefulWidget};
use modalkit::{
widgets::list::{List, ListState},
widgets::{TermOffset, TerminalCursor, WindowOps},
};
use crate::base::{IambBufferId, IambInfo, ProgramStore, RoomFocus};
use crate::windows::RoomItem;
pub struct SpaceState {
room_id: OwnedRoomId,
list: ListState<RoomItem, IambInfo>,
}
impl SpaceState {
pub fn new(room: MatrixRoom) -> Self {
let room_id = room.room_id().to_owned();
let content = IambBufferId::Room(room_id.clone(), RoomFocus::Scrollback);
let list = ListState::new(content, vec![]);
SpaceState { room_id, list }
}
pub fn id(&self) -> &RoomId {
&self.room_id
}
pub fn dup(&self, store: &mut ProgramStore) -> Self {
SpaceState {
room_id: self.room_id.clone(),
list: self.list.dup(store),
}
}
}
impl TerminalCursor for SpaceState {
fn get_term_cursor(&self) -> Option<TermOffset> {
self.list.get_term_cursor()
}
}
impl Deref for SpaceState {
type Target = ListState<RoomItem, IambInfo>;
fn deref(&self) -> &Self::Target {
&self.list
}
}
impl DerefMut for SpaceState {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.list
}
}
pub struct Space<'a> {
focused: bool,
store: &'a mut ProgramStore,
}
impl<'a> Space<'a> {
pub fn new(store: &'a mut ProgramStore) -> Self {
Space { focused: false, store }
}
pub fn focus(mut self, focused: bool) -> Self {
self.focused = focused;
self
}
}
impl<'a> StatefulWidget for Space<'a> {
type State = SpaceState;
fn render(self, area: Rect, buffer: &mut Buffer, state: &mut Self::State) {
let members = self.store.application.worker.space_members(state.room_id.clone()).unwrap();
let items = members
.into_iter()
.filter_map(|id| {
let (room, name) = self.store.application.worker.get_room(id.clone()).ok()?;
if id != state.room_id {
Some(RoomItem::new(room, name, self.store))
} else {
None
}
})
.collect();
state.list.set(items);
List::new(self.store)
.focus(self.focused)
.render(area, buffer, &mut state.list)
}
}

44
src/windows/welcome.md Normal file
View file

@ -0,0 +1,44 @@
# Welcome to iamb!
## Useful Keybindings
- `<Enter>` will send a typed message
- `O`/`o` can be used to insert blank lines before and after the cursor line
- `^O` can be used in Insert mode to enter a single Normal mode keybinding sequence
- `^Wm` can be used to toggle whether the message bar or scrollback is selected
- `^Wz` can be used to toggle whether the current window takes up the full screen
## Room Commands
- `:dms` will open a list of direct messages
- `:rooms` will open a list of joined rooms
- `:spaces` will open a list of joined spaces
- `:join` can be used to switch to join a new room or start a direct message
- `:split` and `:vsplit` can be used to open rooms in a new window
## Verification Commands
The `:verify` command has several different subcommands for working with
verification requests. When used without any arguments, it will take you to a
list of current verifications, where you can see and compare the Emoji.
The different subcommands are:
- `:verify request USERNAME` will send a verification request to a user
- `:verify confirm USERNAME/DEVICE` will confirm a verification
- `:verify mismatch USERNAME/DEVICE` will cancel a verification where the Emoji don't match
- `:verify cancel USERNAME/DEVICE` will cancel a verification
## Other Useful Commands
- `:welcome` will take you back to this screen
## Additional Configuration
You can customize iamb in your `$CONFIG_DIR/iamb/config.json` file, where
`$CONFIG_DIR` is your system's per-user configuration directory.
You can edit the following values in the file:
- `"default_profile"`, a profile name to use when starting iamb if one wasn't specified
- `"cache"`, a directory for cached iamb

71
src/windows/welcome.rs Normal file
View file

@ -0,0 +1,71 @@
use std::ops::{Deref, DerefMut};
use modalkit::tui::{buffer::Buffer, layout::Rect};
use modalkit::{
widgets::textbox::TextBoxState,
widgets::WindowOps,
widgets::{TermOffset, TerminalCursor},
};
use modalkit::editing::base::{CloseFlags, WordStyle};
use crate::base::{IambBufferId, IambInfo, ProgramStore};
const WELCOME_TEXT: &str = include_str!("welcome.md");
pub struct WelcomeState {
tbox: TextBoxState<IambInfo>,
}
impl WelcomeState {
pub fn new(store: &mut ProgramStore) -> Self {
let buf = store.buffers.load_str(IambBufferId::Welcome, WELCOME_TEXT);
WelcomeState { tbox: TextBoxState::new(buf) }
}
}
impl Deref for WelcomeState {
type Target = TextBoxState<IambInfo>;
fn deref(&self) -> &Self::Target {
return &self.tbox;
}
}
impl DerefMut for WelcomeState {
fn deref_mut(&mut self) -> &mut Self::Target {
return &mut self.tbox;
}
}
impl TerminalCursor for WelcomeState {
fn get_term_cursor(&self) -> Option<TermOffset> {
self.tbox.get_term_cursor()
}
}
impl WindowOps<IambInfo> for WelcomeState {
fn draw(&mut self, area: Rect, buf: &mut Buffer, focused: bool, store: &mut ProgramStore) {
self.tbox.draw(area, buf, focused, store)
}
fn dup(&self, store: &mut ProgramStore) -> Self {
let tbox = self.tbox.dup(store);
WelcomeState { tbox }
}
fn close(&mut self, flags: CloseFlags, store: &mut ProgramStore) -> bool {
self.tbox.close(flags, store)
}
fn get_cursor_word(&self, style: &WordStyle) -> Option<String> {
self.tbox.get_cursor_word(style)
}
fn get_selected_word(&self) -> Option<String> {
self.tbox.get_selected_word()
}
}

813
src/worker.rs Normal file
View file

@ -0,0 +1,813 @@
use std::convert::TryFrom;
use std::fs::File;
use std::io::BufWriter;
use std::str::FromStr;
use std::sync::mpsc::{sync_channel, Receiver, RecvTimeoutError, SyncSender};
use std::sync::Arc;
use std::time::Duration;
use gethostname::gethostname;
use tokio::task::JoinHandle;
use tracing::error;
use matrix_sdk::{
config::{RequestConfig, StoreConfig, SyncSettings},
encryption::verification::{SasVerification, Verification},
event_handler::Ctx,
reqwest,
room::{Messages, MessagesOptions, Room as MatrixRoom},
ruma::{
api::client::{
room::create_room::v3::{Request as CreateRoomRequest, RoomPreset},
room::Visibility,
space::get_hierarchy::v1::Request as SpaceHierarchyRequest,
},
events::{
key::verification::{
done::{OriginalSyncKeyVerificationDoneEvent, ToDeviceKeyVerificationDoneEvent},
key::{OriginalSyncKeyVerificationKeyEvent, ToDeviceKeyVerificationKeyEvent},
request::ToDeviceKeyVerificationRequestEvent,
start::{OriginalSyncKeyVerificationStartEvent, ToDeviceKeyVerificationStartEvent},
VerificationMethod,
},
room::message::{MessageType, RoomMessageEventContent, TextMessageEventContent},
room::name::RoomNameEventContent,
AnyMessageLikeEvent,
AnyTimelineEvent,
SyncMessageLikeEvent,
SyncStateEvent,
},
OwnedEventId,
OwnedRoomId,
OwnedRoomOrAliasId,
OwnedUserId,
},
Client,
DisplayName,
Session,
};
use modalkit::editing::action::{EditInfo, InfoMessage, UIError};
use crate::{
base::{AsyncProgramStore, IambError, IambResult, VerifyAction},
message::{Message, MessageFetchResult, MessageTimeStamp},
ApplicationSettings,
};
const IAMB_DEVICE_NAME: &str = "iamb";
const IAMB_USER_AGENT: &str = "iamb";
const REQ_TIMEOUT: Duration = Duration::from_secs(60);
fn initial_devname() -> String {
format!("{} on {}", IAMB_DEVICE_NAME, gethostname().to_string_lossy())
}
pub enum LoginStyle {
SessionRestore(Session),
Password(String),
}
pub struct ClientResponse<T>(Receiver<T>);
pub struct ClientReply<T>(SyncSender<T>);
impl<T> ClientResponse<T> {
fn recv(self) -> T {
self.0.recv().expect("failed to receive response from client thread")
}
}
impl<T> ClientReply<T> {
fn send(self, t: T) {
self.0.send(t).unwrap();
}
}
fn oneshot<T>() -> (ClientReply<T>, ClientResponse<T>) {
let (tx, rx) = sync_channel(1);
let reply = ClientReply(tx);
let response = ClientResponse(rx);
return (reply, response);
}
type EchoPair = (OwnedEventId, RoomMessageEventContent);
pub enum WorkerTask {
DirectMessages(ClientReply<Vec<(MatrixRoom, DisplayName)>>),
Init(AsyncProgramStore, ClientReply<()>),
LoadOlder(OwnedRoomId, Option<String>, u32, ClientReply<MessageFetchResult>),
Login(LoginStyle, ClientReply<IambResult<EditInfo>>),
GetRoom(OwnedRoomId, ClientReply<IambResult<(MatrixRoom, DisplayName)>>),
JoinRoom(String, ClientReply<IambResult<OwnedRoomId>>),
JoinedRooms(ClientReply<Vec<(MatrixRoom, DisplayName)>>),
SpaceMembers(OwnedRoomId, ClientReply<IambResult<Vec<OwnedRoomId>>>),
Spaces(ClientReply<Vec<(MatrixRoom, DisplayName)>>),
SendMessage(OwnedRoomId, String, ClientReply<IambResult<EchoPair>>),
Verify(VerifyAction, SasVerification, ClientReply<IambResult<EditInfo>>),
VerifyRequest(OwnedUserId, ClientReply<IambResult<EditInfo>>),
}
#[derive(Clone)]
pub struct Requester {
pub tx: SyncSender<WorkerTask>,
}
impl Requester {
pub fn init(&self, store: AsyncProgramStore) {
let (reply, response) = oneshot();
self.tx.send(WorkerTask::Init(store, reply)).unwrap();
return response.recv();
}
pub fn load_older(
&self,
room_id: OwnedRoomId,
fetch_id: Option<String>,
limit: u32,
) -> MessageFetchResult {
let (reply, response) = oneshot();
self.tx
.send(WorkerTask::LoadOlder(room_id, fetch_id, limit, reply))
.unwrap();
return response.recv();
}
pub fn login(&self, style: LoginStyle) -> IambResult<EditInfo> {
let (reply, response) = oneshot();
self.tx.send(WorkerTask::Login(style, reply)).unwrap();
return response.recv();
}
pub fn send_message(&self, room_id: OwnedRoomId, msg: String) -> IambResult<EchoPair> {
let (reply, response) = oneshot();
self.tx.send(WorkerTask::SendMessage(room_id, msg, reply)).unwrap();
return response.recv();
}
pub fn direct_messages(&self) -> Vec<(MatrixRoom, DisplayName)> {
let (reply, response) = oneshot();
self.tx.send(WorkerTask::DirectMessages(reply)).unwrap();
return response.recv();
}
pub fn get_room(&self, room_id: OwnedRoomId) -> IambResult<(MatrixRoom, DisplayName)> {
let (reply, response) = oneshot();
self.tx.send(WorkerTask::GetRoom(room_id, reply)).unwrap();
return response.recv();
}
pub fn join_room(&self, name: String) -> IambResult<OwnedRoomId> {
let (reply, response) = oneshot();
self.tx.send(WorkerTask::JoinRoom(name, reply)).unwrap();
return response.recv();
}
pub fn joined_rooms(&self) -> Vec<(MatrixRoom, DisplayName)> {
let (reply, response) = oneshot();
self.tx.send(WorkerTask::JoinedRooms(reply)).unwrap();
return response.recv();
}
pub fn space_members(&self, space: OwnedRoomId) -> IambResult<Vec<OwnedRoomId>> {
let (reply, response) = oneshot();
self.tx.send(WorkerTask::SpaceMembers(space, reply)).unwrap();
return response.recv();
}
pub fn spaces(&self) -> Vec<(MatrixRoom, DisplayName)> {
let (reply, response) = oneshot();
self.tx.send(WorkerTask::Spaces(reply)).unwrap();
return response.recv();
}
pub fn verify(&self, act: VerifyAction, sas: SasVerification) -> IambResult<EditInfo> {
let (reply, response) = oneshot();
self.tx.send(WorkerTask::Verify(act, sas, reply)).unwrap();
return response.recv();
}
pub fn verify_request(&self, user_id: OwnedUserId) -> IambResult<EditInfo> {
let (reply, response) = oneshot();
self.tx.send(WorkerTask::VerifyRequest(user_id, reply)).unwrap();
return response.recv();
}
}
pub struct ClientWorker {
initialized: bool,
settings: ApplicationSettings,
client: Client,
sync_handle: Option<JoinHandle<()>>,
}
impl ClientWorker {
pub fn spawn(settings: ApplicationSettings) -> Requester {
let (tx, rx) = sync_channel(5);
let _ = tokio::spawn(async move {
let account = &settings.profile;
// Set up a custom client that only uses HTTP/1.
//
// During my testing, I kept stumbling across something weird with sync and HTTP/2 that
// will need to be revisited in the future.
let http = reqwest::Client::builder()
.user_agent(IAMB_USER_AGENT)
.timeout(Duration::from_secs(60))
.pool_idle_timeout(Duration::from_secs(120))
.pool_max_idle_per_host(5)
.http1_only()
.build()
.unwrap();
// Set up the Matrix client for the selected profile.
let client = Client::builder()
.http_client(Arc::new(http))
.homeserver_url(account.url.clone())
.store_config(StoreConfig::default())
.sled_store(settings.matrix_dir.as_path(), None)
.expect("Failed to setup up sled store for Matrix SDK")
.request_config(
RequestConfig::new().timeout(REQ_TIMEOUT).retry_timeout(REQ_TIMEOUT),
)
.build()
.await
.expect("Failed to instantiate Matrix client");
let mut worker = ClientWorker {
initialized: false,
settings,
client,
sync_handle: None,
};
worker.work(rx).await;
});
return Requester { tx };
}
async fn work(&mut self, rx: Receiver<WorkerTask>) {
loop {
let t = rx.recv_timeout(Duration::from_secs(1));
match t {
Ok(task) => self.run(task).await,
Err(RecvTimeoutError::Timeout) => {},
Err(RecvTimeoutError::Disconnected) => {
break;
},
}
}
if let Some(handle) = self.sync_handle.take() {
handle.abort();
}
}
async fn run(&mut self, task: WorkerTask) {
match task {
WorkerTask::DirectMessages(reply) => {
assert!(self.initialized);
reply.send(self.direct_messages().await);
},
WorkerTask::Init(store, reply) => {
assert_eq!(self.initialized, false);
self.init(store).await;
reply.send(());
},
WorkerTask::JoinRoom(room_id, reply) => {
assert!(self.initialized);
reply.send(self.join_room(room_id).await);
},
WorkerTask::GetRoom(room_id, reply) => {
assert!(self.initialized);
reply.send(self.get_room(room_id).await);
},
WorkerTask::JoinedRooms(reply) => {
assert!(self.initialized);
reply.send(self.joined_rooms().await);
},
WorkerTask::LoadOlder(room_id, fetch_id, limit, reply) => {
assert!(self.initialized);
reply.send(self.load_older(room_id, fetch_id, limit).await);
},
WorkerTask::Login(style, reply) => {
assert!(self.initialized);
reply.send(self.login_and_sync(style).await);
},
WorkerTask::SpaceMembers(space, reply) => {
assert!(self.initialized);
reply.send(self.space_members(space).await);
},
WorkerTask::Spaces(reply) => {
assert!(self.initialized);
reply.send(self.spaces().await);
},
WorkerTask::SendMessage(room_id, msg, reply) => {
assert!(self.initialized);
reply.send(self.send_message(room_id, msg).await);
},
WorkerTask::Verify(act, sas, reply) => {
assert!(self.initialized);
reply.send(self.verify(act, sas).await);
},
WorkerTask::VerifyRequest(user_id, reply) => {
assert!(self.initialized);
reply.send(self.verify_request(user_id).await);
},
}
}
async fn init(&mut self, store: AsyncProgramStore) {
self.client.add_event_handler_context(store);
let _ = self.client.add_event_handler(
|ev: SyncStateEvent<RoomNameEventContent>,
room: MatrixRoom,
store: Ctx<AsyncProgramStore>| {
async move {
if let SyncStateEvent::Original(ev) = ev {
if let Some(room_name) = ev.content.name {
let room_id = room.room_id().to_owned();
let room_name = Some(room_name.to_string());
let mut locked = store.lock().await;
let mut info =
locked.application.rooms.entry(room_id.to_owned()).or_default();
info.name = room_name;
}
}
}
},
);
let _ = self.client.add_event_handler(
|ev: SyncMessageLikeEvent<RoomMessageEventContent>,
room: MatrixRoom,
client: Client,
store: Ctx<AsyncProgramStore>| {
async move {
let room_id = room.room_id();
let room_name = room.display_name().await.ok();
let room_name = room_name.as_ref().map(ToString::to_string);
if let Some(msg) = ev.as_original() {
if let MessageType::VerificationRequest(_) = msg.content.msgtype {
if let Some(request) = client
.encryption()
.get_verification_request(ev.sender(), ev.event_id())
.await
{
request.accept().await.expect("Failed to accept request");
}
}
}
let mut locked = store.lock().await;
let mut info = locked.application.get_room_info(room_id.to_owned());
info.name = room_name;
let event_id = ev.event_id().to_owned();
let key = (ev.origin_server_ts().into(), event_id.clone());
let msg = Message::from(ev.into_full_event(room_id.to_owned()));
info.messages.insert(key, msg);
// Remove the echo.
let key = (MessageTimeStamp::LocalEcho, event_id);
let _ = info.messages.remove(&key);
}
},
);
let _ = self.client.add_event_handler(
|ev: OriginalSyncKeyVerificationStartEvent,
client: Client,
store: Ctx<AsyncProgramStore>| {
async move {
let tx_id = ev.content.relates_to.event_id.as_ref();
if let Some(Verification::SasV1(sas)) =
client.encryption().get_verification(&ev.sender, tx_id).await
{
sas.accept().await.unwrap();
store.lock().await.application.insert_sas(sas)
}
}
},
);
let _ = self.client.add_event_handler(
|ev: OriginalSyncKeyVerificationKeyEvent,
client: Client,
store: Ctx<AsyncProgramStore>| {
async move {
let tx_id = ev.content.relates_to.event_id.as_ref();
if let Some(Verification::SasV1(sas)) =
client.encryption().get_verification(&ev.sender, tx_id).await
{
store.lock().await.application.insert_sas(sas);
}
}
},
);
let _ = self.client.add_event_handler(
|ev: OriginalSyncKeyVerificationDoneEvent,
client: Client,
store: Ctx<AsyncProgramStore>| {
async move {
let tx_id = ev.content.relates_to.event_id.as_ref();
if let Some(Verification::SasV1(sas)) =
client.encryption().get_verification(&ev.sender, tx_id).await
{
store.lock().await.application.insert_sas(sas);
}
}
},
);
let _ = self.client.add_event_handler(
|ev: ToDeviceKeyVerificationRequestEvent, client: Client| {
async move {
let request = client
.encryption()
.get_verification_request(&ev.sender, &ev.content.transaction_id)
.await
.unwrap();
request.accept().await.unwrap();
}
},
);
let _ = self.client.add_event_handler(
|ev: ToDeviceKeyVerificationStartEvent,
client: Client,
store: Ctx<AsyncProgramStore>| {
async move {
let tx_id = ev.content.transaction_id;
if let Some(Verification::SasV1(sas)) =
client.encryption().get_verification(&ev.sender, tx_id.as_ref()).await
{
sas.accept().await.unwrap();
store.lock().await.application.insert_sas(sas);
}
}
},
);
let _ = self.client.add_event_handler(
|ev: ToDeviceKeyVerificationKeyEvent, client: Client, store: Ctx<AsyncProgramStore>| {
async move {
let tx_id = ev.content.transaction_id;
if let Some(Verification::SasV1(sas)) =
client.encryption().get_verification(&ev.sender, tx_id.as_ref()).await
{
store.lock().await.application.insert_sas(sas);
}
}
},
);
let _ = self.client.add_event_handler(
|ev: ToDeviceKeyVerificationDoneEvent,
client: Client,
store: Ctx<AsyncProgramStore>| {
async move {
let tx_id = ev.content.transaction_id;
if let Some(Verification::SasV1(sas)) =
client.encryption().get_verification(&ev.sender, tx_id.as_ref()).await
{
store.lock().await.application.insert_sas(sas);
}
}
},
);
self.initialized = true;
}
async fn login_and_sync(&mut self, style: LoginStyle) -> IambResult<EditInfo> {
let client = self.client.clone();
match style {
LoginStyle::SessionRestore(session) => {
client.restore_login(session).await.map_err(IambError::from)?;
},
LoginStyle::Password(password) => {
let resp = client
.login_username(&self.settings.profile.user_id, &password)
.initial_device_display_name(initial_devname().as_str())
.send()
.await
.map_err(IambError::from)?;
let file = File::create(self.settings.session_json.as_path())?;
let writer = BufWriter::new(file);
let session = Session::from(resp);
serde_json::to_writer(writer, &session).map_err(IambError::from)?;
},
}
let handle = tokio::spawn(async move {
loop {
let settings = SyncSettings::default();
let _ = client.sync(settings).await;
}
});
self.sync_handle = Some(handle);
self.client
.sync_once(SyncSettings::default())
.await
.map_err(IambError::from)?;
Ok(Some(InfoMessage::from("Successfully logged in!")))
}
async fn send_message(&mut self, room_id: OwnedRoomId, msg: String) -> IambResult<EchoPair> {
let room = if let r @ Some(_) = self.client.get_joined_room(&room_id) {
r
} else if self.client.join_room_by_id(&room_id).await.is_ok() {
self.client.get_joined_room(&room_id)
} else {
None
};
if let Some(room) = room {
let msg = TextMessageEventContent::plain(msg);
let msg = MessageType::Text(msg);
let msg = RoomMessageEventContent::new(msg);
// XXX: second parameter can be a locally unique transaction id.
// Useful for doing retries.
let resp = room.send(msg.clone(), None).await.map_err(IambError::from)?;
let event_id = resp.event_id;
// XXX: need to either give error messages and retry when needed!
return Ok((event_id, msg));
} else {
Err(IambError::UnknownRoom(room_id).into())
}
}
async fn direct_message(&mut self, user: OwnedUserId) -> IambResult<(MatrixRoom, DisplayName)> {
for (room, name) in self.direct_messages().await {
if room.get_member(user.as_ref()).await.map_err(IambError::from)?.is_some() {
return Ok((room, name));
}
}
let mut request = CreateRoomRequest::new();
let invite = [user.clone()];
request.is_direct = true;
request.invite = &invite;
request.visibility = Visibility::Private;
request.preset = Some(RoomPreset::PrivateChat);
match self.client.create_room(request).await {
Ok(resp) => self.get_room(resp.room_id).await,
Err(e) => {
error!(
user_id = user.as_str(),
err = e.to_string(),
"Failed to create direct message room"
);
let msg = format!("Could not open a room with {}", user);
let err = UIError::Failure(msg);
Err(err)
},
}
}
async fn get_room(&mut self, room_id: OwnedRoomId) -> IambResult<(MatrixRoom, DisplayName)> {
if let Some(room) = self.client.get_room(&room_id) {
let name = room.display_name().await.map_err(IambError::from)?;
Ok((room, name))
} else {
Err(IambError::UnknownRoom(room_id).into())
}
}
async fn join_room(&mut self, name: String) -> IambResult<OwnedRoomId> {
if let Ok(alias_id) = OwnedRoomOrAliasId::from_str(name.as_str()) {
match self.client.join_room_by_id_or_alias(&alias_id, &[]).await {
Ok(resp) => Ok(resp.room_id),
Err(e) => {
let msg = e.to_string();
let err = UIError::Failure(msg);
return Err(err);
},
}
} else if let Ok(user) = OwnedUserId::try_from(name.as_str()) {
let room = self.direct_message(user).await?.0;
return Ok(room.room_id().to_owned());
} else {
let msg = format!("{:?} is not a valid room or user name", name.as_str());
let err = UIError::Failure(msg);
return Err(err);
}
}
async fn direct_messages(&mut self) -> Vec<(MatrixRoom, DisplayName)> {
let mut rooms = vec![];
for room in self.client.joined_rooms().into_iter() {
if room.is_space() || !room.is_direct() {
continue;
}
if let Ok(name) = room.display_name().await {
rooms.push((MatrixRoom::from(room), name))
}
}
return rooms;
}
async fn joined_rooms(&mut self) -> Vec<(MatrixRoom, DisplayName)> {
let mut rooms = vec![];
for room in self.client.joined_rooms().into_iter() {
if room.is_space() || room.is_direct() {
continue;
}
if let Ok(name) = room.display_name().await {
rooms.push((MatrixRoom::from(room), name))
}
}
return rooms;
}
async fn load_older(
&mut self,
room_id: OwnedRoomId,
fetch_id: Option<String>,
limit: u32,
) -> MessageFetchResult {
if let Some(room) = self.client.get_room(room_id.as_ref()) {
let mut opts = match &fetch_id {
Some(id) => MessagesOptions::backward().from(id.as_str()),
None => MessagesOptions::backward(),
};
opts.limit = limit.into();
let Messages { end, chunk, .. } = room.messages(opts).await.map_err(IambError::from)?;
let msgs = chunk.into_iter().filter_map(|ev| {
match ev.event.deserialize() {
Ok(AnyTimelineEvent::MessageLike(msg)) => {
if let AnyMessageLikeEvent::RoomMessage(msg) = msg {
Some(msg)
} else {
None
}
},
Ok(AnyTimelineEvent::State(_)) => None,
Err(_) => None,
}
});
Ok((end, msgs.collect()))
} else {
Err(IambError::UnknownRoom(room_id).into())
}
}
async fn space_members(&mut self, space: OwnedRoomId) -> IambResult<Vec<OwnedRoomId>> {
let mut req = SpaceHierarchyRequest::new(&space);
req.limit = Some(1000u32.into());
req.max_depth = Some(1u32.into());
let resp = self.client.send(req, None).await.map_err(IambError::from)?;
let rooms = resp.rooms.into_iter().map(|chunk| chunk.room_id).collect();
Ok(rooms)
}
async fn spaces(&mut self) -> Vec<(MatrixRoom, DisplayName)> {
let mut spaces = vec![];
for room in self.client.joined_rooms().into_iter() {
if !room.is_space() {
continue;
}
if let Ok(name) = room.display_name().await {
spaces.push((MatrixRoom::from(room), name));
}
}
return spaces;
}
async fn verify(&self, action: VerifyAction, sas: SasVerification) -> IambResult<EditInfo> {
match action {
VerifyAction::Accept => {
sas.accept().await.map_err(IambError::from)?;
Ok(Some(InfoMessage::from("Accepted verification request")))
},
VerifyAction::Confirm => {
if sas.is_done() || sas.is_cancelled() {
let msg = "Can only confirm in-progress verifications!";
let err = UIError::Failure(msg.into());
return Err(err);
}
sas.confirm().await.map_err(IambError::from)?;
Ok(Some(InfoMessage::from("Confirmed verification")))
},
VerifyAction::Cancel => {
if sas.is_done() || sas.is_cancelled() {
let msg = "Can only cancel in-progress verifications!";
let err = UIError::Failure(msg.into());
return Err(err);
}
sas.cancel().await.map_err(IambError::from)?;
Ok(Some(InfoMessage::from("Cancelled verification")))
},
VerifyAction::Mismatch => {
if sas.is_done() || sas.is_cancelled() {
let msg = "Can only cancel in-progress verifications!";
let err = UIError::Failure(msg.into());
return Err(err);
}
sas.mismatch().await.map_err(IambError::from)?;
Ok(Some(InfoMessage::from("Cancelled verification")))
},
}
}
async fn verify_request(&self, user_id: OwnedUserId) -> IambResult<EditInfo> {
let enc = self.client.encryption();
match enc.get_user_identity(user_id.as_ref()).await.map_err(IambError::from)? {
Some(identity) => {
let methods = vec![VerificationMethod::SasV1];
let request = identity.request_verification_with_methods(methods);
let _req = request.await.map_err(IambError::from)?;
let info = format!("Sent verification request to {}", user_id);
Ok(InfoMessage::from(info).into())
},
None => {
let msg = format!("Could not find identity information for {}", user_id);
let err = UIError::Failure(msg);
Err(err)
},
}
}
}