mirror of
https://github.com/youwen5/iamb.git
synced 2025-06-20 13:49:52 -07:00
Support uploading and downloading message attachments (#13)
This commit is contained in:
parent
504b520fe1
commit
b6f4b03c12
14 changed files with 684 additions and 247 deletions
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue