mirror of
https://github.com/youwen5/iamb.git
synced 2025-06-19 05:09:51 -07:00
Want a Matrix client that uses Vim keybindings (#1)
This commit is contained in:
parent
704f631d54
commit
262c96b62f
22 changed files with 9050 additions and 7 deletions
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
* text eol=lf
|
60
.github/workflows/ci.yml
vendored
Normal file
60
.github/workflows/ci.yml
vendored
Normal 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
1
.gitignore
vendored
|
@ -1 +1,2 @@
|
|||
/target
|
||||
/TODO
|
||||
|
|
12
.rustfmt.toml
Normal file
12
.rustfmt.toml
Normal 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
3166
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
29
Cargo.toml
29
Cargo.toml
|
@ -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"
|
||||
|
|
21
README.md
21
README.md
|
@ -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
328
src/base.rs
Normal 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
202
src/commands.rs
Normal 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
203
src/config.rs
Normal 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
62
src/keybindings.rs
Normal 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;
|
||||
}
|
494
src/main.rs
494
src/main.rs
|
@ -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
644
src/message.rs
Normal 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 = "CHICKEN";
|
||||
|
||||
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(("CH", 4)));
|
||||
assert_eq!(iter.next(), Some(("IC", 4)));
|
||||
assert_eq!(iter.next(), Some(("KE", 4)));
|
||||
assert_eq!(iter.next(), Some(("N", 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
133
src/tests.rs
Normal 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
696
src/windows/mod.rs
Normal 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
319
src/windows/room/chat.rs
Normal 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
182
src/windows/room/mod.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
1471
src/windows/room/scrollback.rs
Normal file
1471
src/windows/room/scrollback.rs
Normal file
File diff suppressed because it is too large
Load diff
105
src/windows/room/space.rs
Normal file
105
src/windows/room/space.rs
Normal 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
44
src/windows/welcome.md
Normal 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
71
src/windows/welcome.rs
Normal 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
813
src/worker.rs
Normal 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)
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue