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

@ -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();