Support uploading and downloading message attachments (#13)

This commit is contained in:
Ulyssa 2023-01-10 19:59:30 -08:00
parent 504b520fe1
commit b6f4b03c12
No known key found for this signature in database
GPG key ID: 1B3965A3D18B9B64
14 changed files with 684 additions and 247 deletions

View file

@ -59,6 +59,11 @@ pub enum VerifyAction {
Mismatch,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum MessageAction {
Download(Option<String>, bool),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SetRoomField {
Name(String),
@ -77,26 +82,46 @@ impl From<SetRoomField> for RoomAction {
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum SendAction {
Submit,
Upload(String),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum IambAction {
Message(MessageAction),
Room(RoomAction),
Send(SendAction),
Verify(VerifyAction, String),
VerifyRequest(String),
SendMessage(OwnedRoomId, String),
ToggleScrollbackFocus,
}
impl From<MessageAction> for IambAction {
fn from(act: MessageAction) -> Self {
IambAction::Message(act)
}
}
impl From<RoomAction> for IambAction {
fn from(act: RoomAction) -> Self {
IambAction::Room(act)
}
}
impl From<SendAction> for IambAction {
fn from(act: SendAction) -> Self {
IambAction::Send(act)
}
}
impl ApplicationAction for IambAction {
fn is_edit_sequence<C: EditContext>(&self, _: &C) -> SequenceStatus {
match self {
IambAction::Message(..) => SequenceStatus::Break,
IambAction::Room(..) => SequenceStatus::Break,
IambAction::SendMessage(..) => SequenceStatus::Break,
IambAction::Send(..) => SequenceStatus::Break,
IambAction::ToggleScrollbackFocus => SequenceStatus::Break,
IambAction::Verify(..) => SequenceStatus::Break,
IambAction::VerifyRequest(..) => SequenceStatus::Break,
@ -105,8 +130,9 @@ impl ApplicationAction for IambAction {
fn is_last_action<C: EditContext>(&self, _: &C) -> SequenceStatus {
match self {
IambAction::Message(..) => SequenceStatus::Atom,
IambAction::Room(..) => SequenceStatus::Atom,
IambAction::SendMessage(..) => SequenceStatus::Atom,
IambAction::Send(..) => SequenceStatus::Atom,
IambAction::ToggleScrollbackFocus => SequenceStatus::Atom,
IambAction::Verify(..) => SequenceStatus::Atom,
IambAction::VerifyRequest(..) => SequenceStatus::Atom,
@ -115,8 +141,9 @@ impl ApplicationAction for IambAction {
fn is_last_selection<C: EditContext>(&self, _: &C) -> SequenceStatus {
match self {
IambAction::Message(..) => SequenceStatus::Ignore,
IambAction::Room(..) => SequenceStatus::Ignore,
IambAction::SendMessage(..) => SequenceStatus::Ignore,
IambAction::Send(..) => SequenceStatus::Ignore,
IambAction::ToggleScrollbackFocus => SequenceStatus::Ignore,
IambAction::Verify(..) => SequenceStatus::Ignore,
IambAction::VerifyRequest(..) => SequenceStatus::Ignore,
@ -125,8 +152,9 @@ impl ApplicationAction for IambAction {
fn is_switchable<C: EditContext>(&self, _: &C) -> bool {
match self {
IambAction::Message(..) => false,
IambAction::Room(..) => false,
IambAction::SendMessage(..) => false,
IambAction::Send(..) => false,
IambAction::ToggleScrollbackFocus => false,
IambAction::Verify(..) => false,
IambAction::VerifyRequest(..) => false,
@ -173,6 +201,21 @@ pub enum IambError {
#[error("Serialization/deserialization error: {0}")]
Serde(#[from] serde_json::Error),
#[error("Selected message does not have any attachments")]
NoAttachment,
#[error("No message currently selected")]
NoSelectedMessage,
#[error("Current window is not a room or space")]
NoSelectedRoomOrSpace,
#[error("Current window is not a room")]
NoSelectedRoom,
#[error("You need to join the room before you can do that")]
NotJoined,
#[error("Unknown room identifier: {0}")]
UnknownRoom(OwnedRoomId),

View file

@ -8,10 +8,12 @@ use modalkit::{
use crate::base::{
IambAction,
IambId,
MessageAction,
ProgramCommand,
ProgramCommands,
ProgramContext,
RoomAction,
SendAction,
SetRoomField,
VerifyAction,
};
@ -149,13 +151,43 @@ fn iamb_set(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
return Ok(step);
}
fn iamb_upload(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
let mut args = desc.arg.strings()?;
if args.len() != 1 {
return Result::Err(CommandError::InvalidArgument);
}
let sact = SendAction::Upload(args.remove(0));
let iact = IambAction::from(sact);
let step = CommandStep::Continue(iact.into(), ctx.context.take());
return Ok(step);
}
fn iamb_download(desc: CommandDescription, ctx: &mut ProgContext) -> ProgResult {
let mut args = desc.arg.strings()?;
if args.len() > 1 {
return Result::Err(CommandError::InvalidArgument);
}
let mact = MessageAction::Download(args.pop(), desc.bang);
let iact = IambAction::from(mact);
let step = CommandStep::Continue(iact.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!["download".into()], f: iamb_download });
cmds.add_command(ProgramCommand { names: vec!["join".into()], f: iamb_join });
cmds.add_command(ProgramCommand { names: vec!["members".into()], f: iamb_members });
cmds.add_command(ProgramCommand { names: vec!["rooms".into()], f: iamb_rooms });
cmds.add_command(ProgramCommand { names: vec!["set".into()], f: iamb_set });
cmds.add_command(ProgramCommand { names: vec!["spaces".into()], f: iamb_spaces });
cmds.add_command(ProgramCommand { names: vec!["upload".into()], f: iamb_upload });
cmds.add_command(ProgramCommand { names: vec!["verify".into()], f: iamb_verify });
cmds.add_command(ProgramCommand { names: vec!["welcome".into()], f: iamb_welcome });
}

View file

@ -237,7 +237,11 @@ impl Directories {
fn values(self) -> DirectoryValues {
let cache = self
.cache
.or_else(dirs::cache_dir)
.or_else(|| {
let mut dir = dirs::cache_dir()?;
dir.push("iamb");
dir.into()
})
.expect("no dirs.cache value configured!");
let logs = self.logs.unwrap_or_else(|| {

View file

@ -9,6 +9,7 @@ use std::fs::{create_dir_all, File};
use std::io::{stdout, BufReader, Stdout};
use std::ops::DerefMut;
use std::process;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
@ -63,7 +64,6 @@ use crate::{
ProgramStore,
},
config::{ApplicationSettings, Iamb},
message::{Message, MessageContent, MessageTimeStamp},
windows::IambWindow,
worker::{ClientWorker, LoginStyle, Requester},
};
@ -226,7 +226,7 @@ impl Application {
}
}
fn action_run(
async fn action_run(
&mut self,
action: ProgramAction,
ctx: ProgramContext,
@ -257,7 +257,7 @@ impl Application {
},
// Simple delegations.
Action::Application(act) => self.iamb_run(act, ctx, store)?,
Action::Application(act) => self.iamb_run(act, ctx, store).await?,
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)?,
@ -314,7 +314,7 @@ impl Application {
return Ok(info);
}
fn iamb_run(
async fn iamb_run(
&mut self,
action: IambAction,
ctx: ProgramContext,
@ -327,24 +327,19 @@ impl Application {
None
},
IambAction::Message(act) => {
self.screen.current_window_mut()?.message_command(act, ctx, store).await?
},
IambAction::Room(act) => {
let acts = self.screen.current_window_mut()?.room_command(act, ctx, store)?;
let acts = self.screen.current_window_mut()?.room_command(act, ctx, store).await?;
self.action_prepend(acts);
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::Send(act) => {
self.screen.current_window_mut()?.send_command(act, ctx, store).await?
},
IambAction::Verify(act, user_dev) => {
if let Some(sas) = store.application.verifications.get(&user_dev) {
self.worker.verify(act, sas.clone())?
@ -378,7 +373,7 @@ impl Application {
let mut keyskip = false;
while let Some((action, ctx)) = self.action_pop(keyskip) {
match self.action_run(action, ctx, locked.deref_mut()) {
match self.action_run(action, ctx, locked.deref_mut()).await {
Ok(None) => {
// Continue processing.
continue;
@ -408,7 +403,7 @@ impl Application {
}
}
fn login(worker: Requester, settings: &ApplicationSettings) -> IambResult<()> {
async fn login(worker: Requester, settings: &ApplicationSettings) -> IambResult<()> {
println!("Logging in for {}...", settings.profile.user_id);
if settings.session_json.is_file() {
@ -447,38 +442,15 @@ fn print_exit<T: Display, N>(v: T) -> N {
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 log_dir = settings.dirs.logs.as_path();
create_dir_all(settings.matrix_dir.as_path())?;
create_dir_all(log_dir)?;
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");
async fn run(settings: ApplicationSettings) -> IambResult<()> {
// Set up the async worker thread and global store.
let worker = ClientWorker::spawn(settings.clone());
let worker = ClientWorker::spawn(settings.clone()).await;
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);
login(worker, &settings).await.unwrap_or_else(print_exit);
// Make sure panics clean up the terminal properly.
let orig_hook = std::panic::take_hook();
@ -495,5 +467,44 @@ async fn main() -> IambResult<()> {
// We can now run the application.
application.run().await?;
Ok(())
}
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 log_dir = settings.dirs.logs.as_path();
create_dir_all(settings.matrix_dir.as_path())?;
create_dir_all(log_dir)?;
let appender = tracing_appender::rolling::daily(log_dir, log_prefix);
let (appender, guard) = tracing_appender::non_blocking(appender);
let subscriber = FmtSubscriber::builder()
.with_writer(appender)
.with_max_level(Level::TRACE)
.finish();
tracing::subscriber::set_global_default(subscriber).expect("setting default subscriber failed");
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.thread_name_fn(|| {
static ATOMIC_ID: AtomicUsize = AtomicUsize::new(0);
let id = ATOMIC_ID.fetch_add(1, Ordering::SeqCst);
format!("iamb-worker-{}", id)
})
.build()
.unwrap();
rt.block_on(async move { run(settings).await })?;
drop(guard);
process::exit(0);
}

View file

@ -309,46 +309,41 @@ pub enum MessageContent {
Redacted,
}
impl AsRef<str> for MessageContent {
fn as_ref(&self) -> &str {
impl MessageContent {
pub fn show(&self) -> Cow<'_, 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
let s = match &ev.msgtype {
MessageType::Text(content) => content.body.as_ref(),
MessageType::Emote(content) => content.body.as_ref(),
MessageType::Notice(content) => content.body.as_str(),
MessageType::ServerNotice(content) => content.body.as_str(),
return "[server notice]";
},
MessageType::VerificationRequest(_) => {
// XXX: implement
return "[verification request]";
return Cow::Owned("[verification request]".into());
},
MessageType::Audio(..) => {
return "[audio]";
MessageType::Audio(content) => {
return Cow::Owned(format!("[Attached Audio: {}]", content.body));
},
MessageType::File(..) => {
return "[file]";
MessageType::File(content) => {
return Cow::Owned(format!("[Attached File: {}]", content.body));
},
MessageType::Image(..) => {
return "[image]";
MessageType::Image(content) => {
return Cow::Owned(format!("[Attached Image: {}]", content.body));
},
MessageType::Video(..) => {
return "[video]";
MessageType::Video(content) => {
return Cow::Owned(format!("[Attached Video: {}]", content.body));
},
_ => return "[unknown message type]",
}
_ => {
return Cow::Owned(format!("[Unknown message type: {:?}]", ev.msgtype()));
},
};
Cow::Borrowed(s)
},
MessageContent::Redacted => "[redacted]",
MessageContent::Redacted => Cow::Borrowed("[redacted]"),
}
}
}
@ -358,11 +353,12 @@ pub struct Message {
pub content: MessageContent,
pub sender: OwnedUserId,
pub timestamp: MessageTimeStamp,
pub downloaded: bool,
}
impl Message {
pub fn new(content: MessageContent, sender: OwnedUserId, timestamp: MessageTimeStamp) -> Self {
Message { content, sender, timestamp }
Message { content, sender, timestamp, downloaded: false }
}
pub fn show(
@ -373,7 +369,13 @@ impl Message {
settings: &ApplicationSettings,
) -> Text {
let width = vwctx.get_width();
let msg = self.as_ref();
let mut msg = self.content.show();
if self.downloaded {
msg.to_mut().push_str(" \u{2705}");
}
let msg = msg.as_ref();
let mut lines = vec![];
@ -391,7 +393,7 @@ impl Message {
let lw = width - USER_GUTTER - TIME_GUTTER;
for (i, (line, w)) in wrap(msg, lw).enumerate() {
let line = Span::styled(line, style);
let line = Span::styled(line.to_string(), style);
let trailing = Span::styled(space(lw.saturating_sub(w)), style);
if i == 0 {
@ -412,7 +414,7 @@ impl Message {
let lw = width - USER_GUTTER;
for (i, (line, w)) in wrap(msg, lw).enumerate() {
let line = Span::styled(line, style);
let line = Span::styled(line.to_string(), style);
let trailing = Span::styled(space(lw.saturating_sub(w)), style);
let prefix = if i == 0 {
@ -478,15 +480,9 @@ impl From<MessageEvent> for Message {
}
}
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()
self.content.show().into_owned()
}
}

View file

@ -1,6 +1,5 @@
use std::collections::{BTreeMap, HashMap};
use std::path::PathBuf;
use std::sync::mpsc::sync_channel;
use matrix_sdk::ruma::{
event_id,
@ -16,6 +15,7 @@ use matrix_sdk::ruma::{
use lazy_static::lazy_static;
use modalkit::tui::style::Color;
use tokio::sync::mpsc::unbounded_channel;
use url::Url;
use crate::{
@ -154,9 +154,11 @@ pub fn mock_settings() -> ApplicationSettings {
}
}
pub fn mock_store() -> ProgramStore {
let (tx, _) = sync_channel(5);
let worker = Requester { tx };
pub async fn mock_store() -> ProgramStore {
let (tx, _) = unbounded_channel();
let homeserver = Url::parse("https://localhost").unwrap();
let client = matrix_sdk::Client::new(homeserver).await.unwrap();
let worker = Requester { tx, client };
let mut store = ChatStore::new(worker, mock_settings());
let room_id = TEST_ROOM1_ID.clone();

View file

@ -54,13 +54,16 @@ use modalkit::{
use crate::base::{
ChatStore,
IambBufferId,
IambError,
IambId,
IambInfo,
IambResult,
MessageAction,
ProgramAction,
ProgramContext,
ProgramStore,
RoomAction,
SendAction,
};
use self::{room::RoomState, welcome::WelcomeState};
@ -102,6 +105,20 @@ fn selected_text(s: &str, selected: bool) -> Text {
Text::from(selected_span(s, selected))
}
fn room_cmp(a: &MatrixRoom, b: &MatrixRoom) -> Ordering {
let ca1 = a.canonical_alias();
let ca2 = b.canonical_alias();
let ord = match (ca1, ca2) {
(None, None) => Ordering::Equal,
(None, Some(_)) => Ordering::Greater,
(Some(_), None) => Ordering::Less,
(Some(ca1), Some(ca2)) => ca1.cmp(&ca2),
};
ord.then_with(|| a.room_id().cmp(b.room_id()))
}
#[inline]
fn room_prompt(
room_id: &RoomId,
@ -165,19 +182,42 @@ impl IambWindow {
}
}
pub fn room_command(
pub async fn message_command(
&mut self,
act: MessageAction,
ctx: ProgramContext,
store: &mut ProgramStore,
) -> IambResult<EditInfo> {
if let IambWindow::Room(w) = self {
w.message_command(act, ctx, store).await
} else {
return Err(IambError::NoSelectedRoom.into());
}
}
pub async fn room_command(
&mut self,
act: RoomAction,
ctx: ProgramContext,
store: &mut ProgramStore,
) -> IambResult<Vec<(Action<IambInfo>, ProgramContext)>> {
if let IambWindow::Room(w) = self {
w.room_command(act, ctx, store)
w.room_command(act, ctx, store).await
} else {
let msg = "No room currently focused!";
let err = UIError::Failure(msg.into());
return Err(IambError::NoSelectedRoomOrSpace.into());
}
}
return Err(err);
pub async fn send_command(
&mut self,
act: SendAction,
ctx: ProgramContext,
store: &mut ProgramStore,
) -> IambResult<EditInfo> {
if let IambWindow::Room(w) = self {
w.send_command(act, ctx, store).await
} else {
return Err(IambError::NoSelectedRoom.into());
}
}
}
@ -304,8 +344,13 @@ impl WindowOps<IambInfo> for IambWindow {
},
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());
let mut items = joined
.into_iter()
.map(|(id, name)| RoomItem::new(id, name, store))
.collect::<Vec<_>>();
items.sort();
state.set(items);
List::new(store)
.empty_message("You haven't joined any rooms yet")
@ -515,6 +560,26 @@ impl RoomItem {
}
}
impl PartialEq for RoomItem {
fn eq(&self, other: &Self) -> bool {
self.room.room_id() == other.room.room_id()
}
}
impl Eq for RoomItem {}
impl Ord for RoomItem {
fn cmp(&self, other: &Self) -> Ordering {
room_cmp(&self.room, &other.room)
}
}
impl PartialOrd for RoomItem {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
self.cmp(other).into()
}
}
impl ToString for RoomItem {
fn to_string(&self) -> String {
return self.name.clone();
@ -601,6 +666,26 @@ impl SpaceItem {
}
}
impl PartialEq for SpaceItem {
fn eq(&self, other: &Self) -> bool {
self.room.room_id() == other.room.room_id()
}
}
impl Eq for SpaceItem {}
impl Ord for SpaceItem {
fn cmp(&self, other: &Self) -> Ordering {
room_cmp(&self.room, &other.room)
}
}
impl PartialOrd for SpaceItem {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
self.cmp(other).into()
}
}
impl ToString for SpaceItem {
fn to_string(&self) -> String {
return self.room.room_id().to_string();

View file

@ -1,11 +1,21 @@
use std::borrow::Cow;
use std::ffi::OsStr;
use std::fs;
use std::path::{Path, PathBuf};
use matrix_sdk::{
attachment::AttachmentConfig,
media::{MediaFormat, MediaRequest},
room::Room as MatrixRoom,
ruma::{OwnedRoomId, RoomId},
ruma::{
events::room::message::{MessageType, RoomMessageEventContent, TextMessageEventContent},
OwnedRoomId,
RoomId,
},
};
use modalkit::tui::{buffer::Buffer, layout::Rect, widgets::StatefulWidget};
use modalkit::{
tui::{buffer::Buffer, layout::Rect, widgets::StatefulWidget},
widgets::textbox::{TextBox, TextBoxState},
widgets::TerminalCursor,
widgets::{PromptActions, WindowOps},
@ -18,10 +28,12 @@ use modalkit::editing::{
EditResult,
Editable,
EditorAction,
InfoMessage,
Jumpable,
PromptAction,
Promptable,
Scrollable,
UIError,
},
base::{CloseFlags, Count, MoveDir1D, PositionList, ScrollStyle, WordStyle},
context::Resolve,
@ -32,14 +44,19 @@ use modalkit::editing::{
use crate::base::{
IambAction,
IambBufferId,
IambError,
IambInfo,
IambResult,
MessageAction,
ProgramAction,
ProgramContext,
ProgramStore,
RoomFocus,
SendAction,
};
use crate::message::{Message, MessageContent, MessageTimeStamp};
use super::scrollback::{Scrollback, ScrollbackState};
pub struct ChatState {
@ -75,6 +92,169 @@ impl ChatState {
}
}
pub async fn message_command(
&mut self,
act: MessageAction,
_: ProgramContext,
store: &mut ProgramStore,
) -> IambResult<EditInfo> {
let client = &store.application.worker.client;
let settings = &store.application.settings;
let info = store.application.rooms.entry(self.room_id.clone()).or_default();
let msg = self.scrollback.get_mut(info).ok_or(IambError::NoSelectedMessage)?;
match act {
MessageAction::Download(filename, force) => {
if let MessageContent::Original(ev) = &msg.content {
let media = client.media();
let mut filename = match filename {
Some(f) => PathBuf::from(f),
None => settings.dirs.downloads.clone(),
};
let source = match &ev.msgtype {
MessageType::Audio(c) => {
if filename.is_dir() {
filename.push(c.body.as_str());
}
c.source.clone()
},
MessageType::File(c) => {
if filename.is_dir() {
if let Some(name) = &c.filename {
filename.push(name);
} else {
filename.push(c.body.as_str());
}
}
c.source.clone()
},
MessageType::Image(c) => {
if filename.is_dir() {
filename.push(c.body.as_str());
}
c.source.clone()
},
MessageType::Video(c) => {
if filename.is_dir() {
filename.push(c.body.as_str());
}
c.source.clone()
},
_ => {
return Err(IambError::NoAttachment.into());
},
};
if !force && filename.exists() {
let msg = format!(
"The file {} already exists; use :download! to overwrite it.",
filename.display()
);
let err = UIError::Failure(msg);
return Err(err);
}
let req = MediaRequest { source, format: MediaFormat::File };
let bytes =
media.get_media_content(&req, true).await.map_err(IambError::from)?;
fs::write(filename.as_path(), bytes.as_slice())?;
msg.downloaded = true;
let info = InfoMessage::from(format!(
"Attachment downloaded to {}",
filename.display()
));
return Ok(info.into());
}
Err(IambError::NoAttachment.into())
},
}
}
pub async fn send_command(
&mut self,
act: SendAction,
_: ProgramContext,
store: &mut ProgramStore,
) -> IambResult<EditInfo> {
let room = store
.application
.worker
.client
.get_joined_room(self.id())
.ok_or(IambError::NotJoined)?;
let (event_id, msg) = match act {
SendAction::Submit => {
let msg = self.tbox.get_text();
if msg.is_empty() {
return Ok(None);
}
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;
// Clear the TextBoxState contents now that the message is sent.
self.tbox.reset();
(event_id, msg)
},
SendAction::Upload(file) => {
let path = Path::new(file.as_str());
let mime = mime_guess::from_path(path).first_or(mime::APPLICATION_OCTET_STREAM);
let bytes = fs::read(path)?;
let name = path
.file_name()
.map(OsStr::to_string_lossy)
.unwrap_or_else(|| Cow::from("Attachment"));
let config = AttachmentConfig::new();
let resp = room
.send_attachment(name.as_ref(), &mime, bytes.as_ref(), config)
.await
.map_err(IambError::from)?;
// Mock up the local echo message for the scrollback.
let msg = TextMessageEventContent::plain(format!("[Attached File: {}]", name));
let msg = MessageType::Text(msg);
let msg = RoomMessageEventContent::new(msg);
(resp.event_id, msg)
},
};
let user = store.application.settings.profile.user_id.clone();
let info = store.application.get_room_info(self.id().to_owned());
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);
Ok(None)
}
pub fn focus_toggle(&mut self) {
self.focus = match self.focus {
RoomFocus::Scrollback => RoomFocus::MessageBar,
@ -229,17 +409,9 @@ impl PromptActions<ProgramContext, ProgramStore, IambInfo> for ChatState {
ctx: &ProgramContext,
_: &mut ProgramStore,
) -> EditResult<Vec<(ProgramAction, ProgramContext)>, IambInfo> {
let txt = self.tbox.reset_text();
let act = SendAction::Submit;
let act = if txt.is_empty() {
vec![]
} else {
let act = IambAction::SendMessage(self.room_id.clone(), txt).into();
vec![(act, ctx.clone())]
};
Ok(act)
Ok(vec![(IambAction::from(act).into(), ctx.clone())])
}
fn abort(

View file

@ -1,6 +1,4 @@
use matrix_sdk::room::Room as MatrixRoom;
use matrix_sdk::ruma::RoomId;
use matrix_sdk::DisplayName;
use matrix_sdk::{room::Room as MatrixRoom, ruma::RoomId, DisplayName};
use modalkit::tui::{
buffer::Buffer,
@ -37,13 +35,16 @@ use modalkit::{
};
use crate::base::{
IambError,
IambId,
IambInfo,
IambResult,
MessageAction,
ProgramAction,
ProgramContext,
ProgramStore,
RoomAction,
SendAction,
};
use self::chat::ChatState;
@ -92,7 +93,31 @@ impl RoomState {
}
}
pub fn room_command(
pub async fn message_command(
&mut self,
act: MessageAction,
ctx: ProgramContext,
store: &mut ProgramStore,
) -> IambResult<EditInfo> {
match self {
RoomState::Chat(chat) => chat.message_command(act, ctx, store).await,
RoomState::Space(_) => Err(IambError::NoSelectedMessage.into()),
}
}
pub async fn send_command(
&mut self,
act: SendAction,
ctx: ProgramContext,
store: &mut ProgramStore,
) -> IambResult<EditInfo> {
match self {
RoomState::Chat(chat) => chat.send_command(act, ctx, store).await,
RoomState::Space(_) => Err(IambError::NoSelectedRoom.into()),
}
}
pub async fn room_command(
&mut self,
act: RoomAction,
_: ProgramContext,

View file

@ -126,6 +126,14 @@ impl ScrollbackState {
self.viewctx.dimensions = (area.width as usize, area.height as usize);
}
pub fn get_mut<'a>(&mut self, info: &'a mut RoomInfo) -> Option<&'a mut Message> {
if let Some(k) = &self.cursor.timestamp {
info.messages.get_mut(k)
} else {
info.messages.last_entry().map(|o| o.into_mut())
}
}
pub fn messages<'a>(
&self,
range: EditRange<MessageCursor>,
@ -389,7 +397,7 @@ impl ScrollbackState {
continue;
}
if needle.is_match(msg.as_ref()) {
if needle.is_match(msg.content.show().as_ref()) {
mc = MessageCursor::from(key.clone()).into();
count -= 1;
}
@ -413,7 +421,7 @@ impl ScrollbackState {
break;
}
if needle.is_match(msg.as_ref()) {
if needle.is_match(msg.content.show().as_ref()) {
mc = MessageCursor::from(key.clone()).into();
count -= 1;
}
@ -659,7 +667,7 @@ impl EditorActions<ProgramContext, ProgramStore, IambInfo> for ScrollbackState {
let mut yanked = EditRope::from("");
for (_, msg) in self.messages(range, info) {
yanked += EditRope::from(msg.as_ref());
yanked += EditRope::from(msg.content.show().into_owned());
yanked += EditRope::from('\n');
}
@ -1159,12 +1167,14 @@ impl<'a> StatefulWidget for Scrollback<'a> {
};
let corner = &state.viewctx.corner;
let corner_key = match (&corner.timestamp, &cursor.timestamp) {
(_, None) => nth_key_before(cursor_key.clone(), height, info),
(None, _) => nth_key_before(cursor_key.clone(), height, info),
(Some(k), _) => k.clone(),
let corner_key = if let Some(k) = &corner.timestamp {
k.clone()
} else {
nth_key_before(cursor_key.clone(), height, info)
};
let full = cursor.timestamp.is_none();
let mut lines = vec![];
let mut sawit = false;
let mut prev = None;
@ -1175,8 +1185,10 @@ impl<'a> StatefulWidget for Scrollback<'a> {
prev = Some(item);
let incomplete_ok = !full || !sel;
for (row, line) in txt.lines.into_iter().enumerate() {
if sawit && lines.len() >= height {
if sawit && lines.len() >= height && incomplete_ok {
// Check whether we've seen the first line of the
// selected message and can fill the screen.
break;
@ -1224,10 +1236,10 @@ mod tests {
use super::*;
use crate::tests::*;
#[test]
fn test_search_messages() {
#[tokio::test]
async fn test_search_messages() {
let room_id = TEST_ROOM1_ID.clone();
let mut store = mock_store();
let mut store = mock_store().await;
let mut scrollback = ScrollbackState::new(room_id.clone());
let ctx = ProgramContext::default();
@ -1268,9 +1280,9 @@ mod tests {
assert_eq!(scrollback.cursor, MSG1_KEY.clone().into());
}
#[test]
fn test_movement() {
let mut store = mock_store();
#[tokio::test]
async fn test_movement() {
let mut store = mock_store().await;
let mut scrollback = ScrollbackState::new(TEST_ROOM1_ID.clone());
let ctx = ProgramContext::default();
@ -1302,9 +1314,9 @@ mod tests {
assert_eq!(scrollback.cursor, MSG1_KEY.clone().into());
}
#[test]
fn test_dirscroll() {
let mut store = mock_store();
#[tokio::test]
async fn test_dirscroll() {
let mut store = mock_store().await;
let mut scrollback = ScrollbackState::new(TEST_ROOM1_ID.clone());
let ctx = ProgramContext::default();
@ -1436,9 +1448,9 @@ mod tests {
assert_eq!(scrollback.viewctx.corner, MessageCursor::new(MSG3_KEY.clone(), 4));
}
#[test]
fn test_cursorpos() {
let mut store = mock_store();
#[tokio::test]
async fn test_cursorpos() {
let mut store = mock_store().await;
let mut scrollback = ScrollbackState::new(TEST_ROOM1_ID.clone());
let ctx = ProgramContext::default();

View file

@ -1,12 +1,14 @@
use std::convert::TryFrom;
use std::fmt::{Debug, Formatter};
use std::fs::File;
use std::io::BufWriter;
use std::str::FromStr;
use std::sync::mpsc::{sync_channel, Receiver, RecvTimeoutError, SyncSender};
use std::sync::mpsc::{sync_channel, Receiver, SyncSender};
use std::sync::Arc;
use std::time::Duration;
use gethostname::gethostname;
use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender};
use tokio::task::JoinHandle;
use tracing::error;
@ -31,7 +33,7 @@ use matrix_sdk::{
VerificationMethod,
},
room::{
message::{MessageType, RoomMessageEventContent, TextMessageEventContent},
message::{MessageType, RoomMessageEventContent},
name::RoomNameEventContent,
topic::RoomTopicEventContent,
},
@ -41,7 +43,6 @@ use matrix_sdk::{
SyncMessageLikeEvent,
SyncStateEvent,
},
OwnedEventId,
OwnedRoomId,
OwnedRoomOrAliasId,
OwnedUserId,
@ -67,6 +68,7 @@ fn initial_devname() -> String {
format!("{} on {}", IAMB_DEVICE_NAME, gethostname().to_string_lossy())
}
#[derive(Debug)]
pub enum LoginStyle {
SessionRestore(Session),
Password(String),
@ -95,8 +97,6 @@ fn oneshot<T>() -> (ClientReply<T>, ClientResponse<T>) {
return (reply, response);
}
type EchoPair = (OwnedEventId, RoomMessageEventContent);
pub enum WorkerTask {
DirectMessages(ClientReply<Vec<(MatrixRoom, DisplayName)>>),
Init(AsyncProgramStore, ClientReply<()>),
@ -108,16 +108,101 @@ pub enum WorkerTask {
Members(OwnedRoomId, ClientReply<IambResult<Vec<RoomMember>>>),
SpaceMembers(OwnedRoomId, ClientReply<IambResult<Vec<OwnedRoomId>>>),
Spaces(ClientReply<Vec<(MatrixRoom, DisplayName)>>),
SendMessage(OwnedRoomId, String, ClientReply<IambResult<EchoPair>>),
SetRoom(OwnedRoomId, SetRoomField, ClientReply<IambResult<()>>),
TypingNotice(OwnedRoomId),
Verify(VerifyAction, SasVerification, ClientReply<IambResult<EditInfo>>),
VerifyRequest(OwnedUserId, ClientReply<IambResult<EditInfo>>),
}
impl Debug for WorkerTask {
fn fmt(&self, f: &mut Formatter<'_>) -> Result<(), std::fmt::Error> {
match self {
WorkerTask::DirectMessages(_) => {
f.debug_tuple("WorkerTask::DirectMessages")
.field(&format_args!("_"))
.finish()
},
WorkerTask::Init(_, _) => {
f.debug_tuple("WorkerTask::Init")
.field(&format_args!("_"))
.field(&format_args!("_"))
.finish()
},
WorkerTask::LoadOlder(room_id, from, n, _) => {
f.debug_tuple("WorkerTask::LoadOlder")
.field(room_id)
.field(from)
.field(n)
.field(&format_args!("_"))
.finish()
},
WorkerTask::Login(style, _) => {
f.debug_tuple("WorkerTask::Login")
.field(style)
.field(&format_args!("_"))
.finish()
},
WorkerTask::GetRoom(room_id, _) => {
f.debug_tuple("WorkerTask::GetRoom")
.field(room_id)
.field(&format_args!("_"))
.finish()
},
WorkerTask::JoinRoom(s, _) => {
f.debug_tuple("WorkerTask::JoinRoom")
.field(s)
.field(&format_args!("_"))
.finish()
},
WorkerTask::JoinedRooms(_) => {
f.debug_tuple("WorkerTask::JoinedRooms").field(&format_args!("_")).finish()
},
WorkerTask::Members(room_id, _) => {
f.debug_tuple("WorkerTask::Members")
.field(room_id)
.field(&format_args!("_"))
.finish()
},
WorkerTask::SpaceMembers(room_id, _) => {
f.debug_tuple("WorkerTask::SpaceMembers")
.field(room_id)
.field(&format_args!("_"))
.finish()
},
WorkerTask::Spaces(_) => {
f.debug_tuple("WorkerTask::Spaces").field(&format_args!("_")).finish()
},
WorkerTask::SetRoom(room_id, field, _) => {
f.debug_tuple("WorkerTask::SetRoom")
.field(room_id)
.field(field)
.field(&format_args!("_"))
.finish()
},
WorkerTask::TypingNotice(room_id) => {
f.debug_tuple("WorkerTask::TypingNotice").field(room_id).finish()
},
WorkerTask::Verify(act, sasv1, _) => {
f.debug_tuple("WorkerTask::Verify")
.field(act)
.field(sasv1)
.field(&format_args!("_"))
.finish()
},
WorkerTask::VerifyRequest(user_id, _) => {
f.debug_tuple("WorkerTask::VerifyRequest")
.field(user_id)
.field(&format_args!("_"))
.finish()
},
}
}
}
#[derive(Clone)]
pub struct Requester {
pub tx: SyncSender<WorkerTask>,
pub client: Client,
pub tx: UnboundedSender<WorkerTask>,
}
impl Requester {
@ -152,14 +237,6 @@ impl Requester {
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();
@ -253,60 +330,57 @@ pub struct ClientWorker {
}
impl ClientWorker {
pub fn spawn(settings: ApplicationSettings) -> Requester {
let (tx, rx) = sync_channel(5);
pub async fn spawn(settings: ApplicationSettings) -> Requester {
let (tx, rx) = unbounded_channel();
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(30))
.pool_idle_timeout(Duration::from_secs(60))
.pool_max_idle_per_host(10)
.tcp_keepalive(Duration::from_secs(10))
.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: client.clone(),
sync_handle: None,
};
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 };
return Requester { client, tx };
}
async fn work(&mut self, rx: Receiver<WorkerTask>) {
async fn work(&mut self, mut rx: UnboundedReceiver<WorkerTask>) {
loop {
let t = rx.recv_timeout(Duration::from_secs(1));
let t = rx.recv().await;
match t {
Ok(task) => self.run(task).await,
Err(RecvTimeoutError::Timeout) => {},
Err(RecvTimeoutError::Disconnected) => {
Some(task) => self.run(task).await,
None => {
break;
},
}
@ -364,10 +438,6 @@ impl ClientWorker {
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::TypingNotice(room_id) => {
assert!(self.initialized);
self.typing_notice(room_id).await;
@ -615,33 +685,6 @@ impl ClientWorker {
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() {