From c60469796aafdf8b0c6ac858b3ee55afbde8ff69 Mon Sep 17 00:00:00 2001 From: Phil Kulak Date: Tue, 9 May 2023 16:56:25 -0700 Subject: [PATCH] Save files. --- Cargo.lock | 13 ++++---- Cargo.toml | 1 + README.md | 1 + src/handler.rs | 12 ++++--- src/matrix/matrix.rs | 27 +++++++++++++--- src/spawn.rs | 73 ++++++++++++++++++++++++++++++++++++++++++ src/widgets/chat.rs | 52 ++++++++++++++++++++++-------- src/widgets/error.rs | 12 ++++++- src/widgets/message.rs | 28 +++++++--------- 9 files changed, 173 insertions(+), 46 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 908c2c5..c579a9b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -51,9 +51,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "0.7.20" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc936419f96fa211c1b9166887b38e5e40b19958e5b895be7c1f93adec7071ac" +checksum = "67fc08ce920c31afb70f013dcce1bfc3a3195de6a228474e45e1f145b36f8d04" dependencies = [ "memchr", ] @@ -2230,6 +2230,7 @@ dependencies = [ "once_cell", "open", "rand 0.8.5", + "regex", "ruma", "serde", "simple-logging", @@ -3091,9 +3092,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.7.1" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48aaa5748ba571fb95cd2c85c09f629215d3a6ece942baa100950af03a34f733" +checksum = "af83e617f331cc6ae2da5443c602dfa5af81e517212d9d611a5b3ba1777b5370" dependencies = [ "aho-corasick", "memchr", @@ -3102,9 +3103,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.6.28" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "456c603be3e8d448b072f410900c09faf164fbce2d480456f50eea6e25f9c848" +checksum = "a5996294f19bd3aae0453a862ad728f60e6600695733dd5df01da90c54363a3c" [[package]] name = "reqwest" diff --git a/Cargo.toml b/Cargo.toml index 72a8a3e..036c19d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ notify = "5.1" once_cell = "1.17" open = "4.0" rand = "0.8.5" +regex = "1.8.1" serde = { version = "1.0", features = ["derive"] } simple-logging = "2.0" tempfile = "3" diff --git a/README.md b/README.md index efe79c8..9ed4dc3 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ complicated. Especially if you don't implement too many features. | k* | Select one line up. | | i | Create a new message using the external editor. | | Enter | Open the selected message (images, videos, urls, etc). | +| s | Save the selected message (images and videos). | | c | Edit the selected message in the external editor. | | r | React to the selected message. | | R | Reply to the selected message. | diff --git a/src/handler.rs b/src/handler.rs index 3930921..4aa5188 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -17,6 +17,7 @@ use ruma::events::AnyTimelineEvent; #[derive(Clone, Debug)] pub enum MatuiEvent { + Confirm(String, String), Error(String), LoginComplete, LoginRequired, @@ -50,6 +51,9 @@ pub struct Batch { pub fn handle_app_event(event: MatuiEvent, app: &mut App) { match event { + MatuiEvent::Confirm(header, msg) => { + app.set_popup(Popup::Error(Error::with_heading(header, msg))); + } MatuiEvent::Error(msg) => { app.set_popup(Popup::Error(Error::new(msg))); } @@ -62,6 +66,10 @@ pub fn handle_app_event(event: MatuiEvent, app: &mut App) { MatuiEvent::LoginComplete => { app.popup = None; } + MatuiEvent::ProgressStarted(msg, delay) => { + app.set_popup(Popup::Progress(Progress::new(&msg, delay))) + } + MatuiEvent::ProgressComplete => app.popup = None, // Let the chat update when we learn about room membership MatuiEvent::RoomMember(room, member) => { @@ -69,10 +77,6 @@ pub fn handle_app_event(event: MatuiEvent, app: &mut App) { c.room_member_event(room, member); } } - MatuiEvent::ProgressStarted(msg, delay) => { - app.set_popup(Popup::Progress(Progress::new(&msg, delay))) - } - MatuiEvent::ProgressComplete => app.popup = None, MatuiEvent::RoomSelected(room) => app.select_room(room), MatuiEvent::SyncStarted(st) => { match st { diff --git a/src/matrix/matrix.rs b/src/matrix/matrix.rs index 14723ee..83b48c5 100644 --- a/src/matrix/matrix.rs +++ b/src/matrix/matrix.rs @@ -51,7 +51,7 @@ use crate::handler::MatuiEvent::{ }; use crate::handler::{Batch, MatuiEvent, SyncType}; use crate::matrix::roomcache::{DecoratedRoom, RoomCache}; -use crate::spawn::view_file; +use crate::spawn::{save_file, view_file}; use super::mime::mime_from_path; use super::notify::Notify; @@ -65,6 +65,12 @@ pub struct Matrix { notify: Arc, } +/// What should we do with the file after we download it? +pub enum AfterDownload { + View, + Save, +} + impl Default for Matrix { fn default() -> Self { let rt = tokio::runtime::Builder::new_multi_thread() @@ -305,19 +311,20 @@ impl Matrix { }); } - pub fn open_content(&self, message: MessageType) { + pub fn download_content(&self, message: MessageType, after: AfterDownload) { let matrix = self.clone(); self.rt.spawn(async move { Matrix::send(ProgressStarted("Downloading file.".to_string(), 250)); - let (content_type, request) = match message { + let (content_type, request, file_name) = match message { Image(content) => ( content.info.unwrap().mimetype.unwrap(), MediaRequest { source: content.source, format: MediaFormat::File, }, + content.body, ), Video(content) => ( content.info.unwrap().mimetype.unwrap(), @@ -325,6 +332,7 @@ impl Matrix { source: content.source, format: MediaFormat::File, }, + content.body, ), _ => { Matrix::send(Error("Unknown file type.".to_string())); @@ -347,7 +355,18 @@ impl Matrix { Matrix::send(ProgressComplete); - tokio::task::spawn_blocking(move || view_file(handle)); + match after { + AfterDownload::View => { + tokio::task::spawn_blocking(move || view_file(handle)); + } + AfterDownload::Save => match save_file(handle, &file_name) { + Err(err) => Matrix::send(Error(err.to_string())), + Ok(path) => Matrix::send(MatuiEvent::Confirm( + "Download Complete".to_string(), + format!("Saved to {}", path.to_str().unwrap()), + )), + }, + }; }); } diff --git a/src/spawn.rs b/src/spawn.rs index e98704b..28a7d61 100644 --- a/src/spawn.rs +++ b/src/spawn.rs @@ -1,16 +1,23 @@ use anyhow::{bail, Context}; use image::imageops::FilterType; +use lazy_static::lazy_static; use linkify::LinkFinder; use log::error; use matrix_sdk::media::MediaFileHandle; use native_dialog::FileDialog; use notify_rust::Hint; +use regex::Regex; use std::env::var; +use std::fs; use std::io::{Cursor, Read}; use std::path::PathBuf; use std::process::{Command, Stdio}; use tempfile::Builder; +lazy_static! { + static ref FILE_RE: Regex = Regex::new(r"-([0-9]+)(\.|$)").unwrap(); +} + pub fn get_file_paths() -> anyhow::Result> { let home = dirs::home_dir().context("no home directory")?; @@ -93,6 +100,46 @@ pub fn view_file(handle: MediaFileHandle) -> anyhow::Result<()> { Ok(()) } +pub fn save_file(handle: MediaFileHandle, file_name: &str) -> anyhow::Result { + let mut destination = dirs::download_dir().context("no download directory")?; + destination.push(file_name); + let destination = make_unique(destination); + fs::copy(handle.path(), &destination)?; + Ok(destination) +} + +pub fn make_unique(mut path: PathBuf) -> PathBuf { + loop { + if !path.exists() { + return path; + } + + path.set_file_name(next_file_name( + path.file_name().expect("no file name").to_str().unwrap(), + )); + } +} + +fn next_file_name(og: &str) -> String { + // if there's already a version, increment + if let Some(cap) = FILE_RE.captures_iter(og).next() { + if let Ok(version) = cap[1].parse::() { + let replacement = format!("-{}$2", version + 1); + return FILE_RE.replace(og, replacement).to_string(); + } + } + + // if there's an extension, start a new version just before + if og.contains('.') { + let reversed: String = og.chars().rev().collect(); + let replaced = reversed.replacen('.', ".1-", 1); + return replaced.chars().rev().collect(); + } + + // otherwise, just throw it on the end + format!("{}-1", og) +} + pub fn view_text(text: &str) { let finder = LinkFinder::new(); @@ -127,3 +174,29 @@ pub fn send_notification(summary: &str, body: &str, image: Option>) -> a Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_next_file_first() { + assert_eq!(next_file_name("image.jpg"), "image-1.jpg"); + } + + #[test] + fn test_next_file_second() { + assert_eq!(next_file_name("image-1.jpg"), "image-2.jpg"); + } + + #[test] + fn test_next_file_too_many() { + assert_eq!(next_file_name("image-375.jpg"), "image-376.jpg"); + } + + #[test] + fn test_next_no_ext() { + assert_eq!(next_file_name("image"), "image-1"); + assert_eq!(next_file_name("image-42"), "image-43"); + } +} diff --git a/src/widgets/chat.rs b/src/widgets/chat.rs index f7c9525..08d3ead 100644 --- a/src/widgets/chat.rs +++ b/src/widgets/chat.rs @@ -17,7 +17,6 @@ use log::info; use matrix_sdk::room::{Joined, RoomMember}; use once_cell::sync::OnceCell; use ruma::events::receipt::ReceiptEventContent; -use ruma::events::room::member::MembershipState; use ruma::events::room::message::MessageType::Text; use ruma::events::AnyTimelineEvent; use ruma::{OwnedEventId, OwnedUserId}; @@ -175,6 +174,12 @@ impl Chat { } Ok(consumed!()) } + KeyCode::Char('s') => { + if let Some(message) = &self.selected_reply() { + message.save(self.matrix.clone()) + } + Ok(consumed!()) + } KeyCode::Char('c') => { let message = match self.selected_reply() { Some(m) => m, @@ -345,6 +350,7 @@ impl Chat { self.check_event_sender(&event); self.events.insert(OrderedEvent::new(event)); self.messages = make_message_list(&self.events, &self.members, &self.receipts); + self.pretty_members = OnceCell::new(); self.set_fully_read(); } @@ -388,6 +394,7 @@ impl Chat { if joined.room_id() == self.room.room_id() { self.receipts.apply_event(content); self.messages = make_message_list(&self.events, &self.members, &self.receipts); + self.pretty_members = OnceCell::new(); // make sure we fetch any users we don't know about for id in Receipts::get_senders(content) { @@ -429,9 +436,7 @@ impl Chat { } fn check_event_sender(&mut self, event: &AnyTimelineEvent) { - if let Some(user_id) = Message::get_sender(event) { - self.check_sender(user_id) - } + self.check_sender(&event.sender().to_owned()); } fn check_sender(&mut self, user_id: &OwnedUserId) { @@ -495,27 +500,46 @@ impl Chat { fn pretty_members(&self) -> &str { self.pretty_members.get_or_init(|| { - let mut names: Vec<&str> = self - .members + let mut members: Vec<&RoomMember> = vec![]; + + // first grab folks who have sent read receipts + let mut receipts = self.receipts.get_all(); + + while let Some(receipt) = receipts.pop() { + if let Some(member) = self.members.iter().find(|m| m.user_id() == receipt.user_id) { + members.push(member); + } + } + + // then walk all the events backwards, until we have a decent number + for event in self.events.iter().rev() { + if members.iter().any(|m| m.user_id() == event.sender()) { + continue; + } + + if let Some(member) = self.members.iter().find(|m| m.user_id() == event.sender()) { + members.push(member); + } + + if members.len() > 5 { + break; + } + } + + let names: Vec<&str> = members .iter() - .filter(|m| m.membership() == &MembershipState::Join) .map(|m| { m.display_name() - .or_else(|| Some(m.user_id().localpart())) - .unwrap() + .unwrap_or_else(|| m.user_id().localpart()) .split_whitespace() .next() .unwrap_or_default() }) .collect(); - names.sort(); - names.dedup(); - - let total = names.len(); let iter = names.into_iter().map(|n| n.to_string()); - pretty_list(limit_list(iter, 5, total, Some("at least"))) + pretty_list(limit_list(iter, 5, self.members.len(), Some("at least"))) }) } diff --git a/src/widgets/error.rs b/src/widgets/error.rs index 298982b..7e08f2d 100644 --- a/src/widgets/error.rs +++ b/src/widgets/error.rs @@ -10,6 +10,7 @@ use crate::widgets::button::Button; use super::{get_margin, EventResult}; pub struct Error { + heading: String, message: String, button: Button, } @@ -17,6 +18,15 @@ pub struct Error { impl Error { pub fn new(message: String) -> Self { Self { + heading: "Error".to_string(), + message, + button: Button::new("OK".to_string(), true), + } + } + + pub fn with_heading(heading: String, message: String) -> Self { + Self { + heading, message, button: Button::new("OK".to_string(), true), } @@ -61,7 +71,7 @@ impl Widget for ErrorWidget<'_> { .split(area); let block = Block::default() - .title("Error") + .title(&*self.error.heading) .title_alignment(Alignment::Center) .borders(Borders::ALL) .border_type(BorderType::Rounded) diff --git a/src/widgets/message.rs b/src/widgets/message.rs index a802d77..cce5241 100644 --- a/src/widgets/message.rs +++ b/src/widgets/message.rs @@ -3,7 +3,7 @@ use std::cell::Cell; use std::collections::BinaryHeap; use std::time::{Duration, SystemTime}; -use crate::matrix::matrix::{pad_emoji, Matrix}; +use crate::matrix::matrix::{pad_emoji, AfterDownload, Matrix}; use crate::matrix::username::Username; use crate::spawn::view_text; use crate::{limit_list, pretty_list}; @@ -148,13 +148,21 @@ impl Message { pub fn open(&self, matrix: Matrix) { match &self.body { - Image(_) => matrix.open_content(self.body.clone()), - Video(_) => matrix.open_content(self.body.clone()), + Image(_) => matrix.download_content(self.body.clone(), AfterDownload::View), + Video(_) => matrix.download_content(self.body.clone(), AfterDownload::View), Text(_) => view_text(self.display()), _ => {} } } + pub fn save(&self, matrix: Matrix) { + match &self.body { + Image(_) => matrix.download_content(self.body.clone(), AfterDownload::Save), + Video(_) => matrix.download_content(self.body.clone(), AfterDownload::Save), + _ => {} + } + } + pub fn edit(&mut self, new_body: MessageType) { let old = std::mem::replace(&mut self.body, new_body); self.history.push(old); @@ -355,20 +363,6 @@ impl Message { } } - pub fn get_sender(event: &AnyTimelineEvent) -> Option<&OwnedUserId> { - // reactions - if let MessageLike(Rctn(MessageLikeEvent::Original(c))) = event { - return Some(&c.sender); - } - - // messages - if let MessageLike(RoomMessage(MessageLikeEvent::Original(c))) = event { - return Some(&c.sender); - } - - None - } - pub fn update_senders(&mut self, members: &Vec) { // maybe we use a map, or sorted list at some point to avoid looping for member in members {