From f25d3b68210e08573125be54dacb61dd8a6eadc2 Mon Sep 17 00:00:00 2001 From: Amanda Graven Date: Sat, 9 Jan 2021 19:11:10 +0100 Subject: [PATCH] Intial image support, proper time formatting Also feature gate unix exclusive code --- Cargo.toml | 14 +- src/main.rs | 6 +- src/matrix.rs | 80 ++++++++---- src/ui.rs | 347 +++++++++++++++++++++++++++++++++++--------------- 4 files changed, 315 insertions(+), 132 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 6261ebe..406bd6d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,22 +8,22 @@ edition = "2018" [dependencies] anyhow = "1.0" -async-trait = "0.1" -crossbeam-channel = "0.4" +async-stream = "0.3" dirs-next = "2.0" futures = "0.3" -iced = { version = "0.2", features = ["debug", "tokio_old", "image"] } -iced_futures = "0.2" -hostname = "0.3" +iced = { git = "https://github.com/hecrj/iced", rev = "31522e3", features = ["debug", "image", "tokio"] } +iced_futures = { git = "https://github.com/hecrj/iced", rev = "31522e3" } +#iced_glow = { git = "https://github.com/hecrj/iced", rev = "31522e3", features = ["image"] } #matrix-sdk-common-macros = { git = "https://github.com/matrix-org/matrix-rust-sdk", rev = "e65915e" } serde = { version = "1.0", features = ["derive"] } -tokio = { version = "0.2", features = ["sync"] } +time = "0.2" +tokio = { version = "1.0", features = ["sync"] } toml = "0.5" tracing-subscriber = { version = "0.2", features = ["parking_lot"] } [dependencies.matrix-sdk] git = "https://github.com/matrix-org/matrix-rust-sdk" -rev = "74998c8" +rev = "6435269" default_features = false features = ["encryption", "sqlite_cryptostore", "messages", "rustls-tls", "unstable-synapse-quirks"] diff --git a/src/main.rs b/src/main.rs index 1cfad68..cb11a71 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,7 @@ -extern crate crossbeam_channel as channel; extern crate dirs_next as dirs; -use std::fs::Permissions; -use std::os::unix::fs::PermissionsExt; +#[cfg(unix)] +use std::{fs::Permissions, os::unix::fs::PermissionsExt}; use iced::Application; @@ -16,6 +15,7 @@ fn main() -> Result<(), Box> { // Make sure config dir exists and is not accessible by other users. if !config_dir.is_dir() { std::fs::create_dir(&config_dir)?; + #[cfg(unix)] std::fs::set_permissions(&config_dir, Permissions::from_mode(0o700))?; } diff --git a/src/matrix.rs b/src/matrix.rs index c9c34e5..d5ff589 100644 --- a/src/matrix.rs +++ b/src/matrix.rs @@ -1,9 +1,16 @@ -use std::time::{Duration, SystemTime}; +use std::{ + convert::TryFrom, + time::{Duration, SystemTime}, +}; +use async_stream::stream; use matrix_sdk::{ api::r0::{account::register::Request as RegistrationRequest, uiaa::AuthData}, - events::{AnyRoomEvent, AnySyncRoomEvent, AnyToDeviceEvent}, - identifiers::{DeviceId, EventId, UserId}, + events::{ + room::message::{MessageEvent, MessageEventContent}, + AnyMessageEvent, AnyRoomEvent, AnySyncRoomEvent, AnyToDeviceEvent, + }, + identifiers::{DeviceId, EventId, ServerName, UserId}, reqwest::Url, Client, ClientConfig, LoopCtrl, SyncSettings, }; @@ -67,6 +74,7 @@ pub async fn signup( }); let response = client.register(request).await?; + client.sync_once(SyncSettings::new()).await?; let session = Session { access_token: response.access_token.unwrap(), @@ -103,7 +111,7 @@ pub async fn login( homeserver: server.to_owned(), }; write_session(&session)?; - //client.sync_once(SyncSettings::new()).await?; + client.sync_once(SyncSettings::new()).await?; Ok((client, session)) } @@ -150,6 +158,19 @@ fn write_session(session: &Session) -> Result<(), Error> { Ok(()) } +/// Break down an mxc url to its authority and path +pub fn parse_mxc(url: &str) -> Result<(Box, String), Error> { + let url = Url::parse(&url)?; + anyhow::ensure!(url.scheme() == "mxc", "Not an mxc url"); + let host = url.host_str().ok_or(anyhow::anyhow!("url"))?; + let server_name: Box = <&ServerName>::try_from(host)?.into(); + let path = url.path_segments().and_then(|mut p| p.next()); + match path { + Some(path) => Ok((server_name, path.to_owned())), + _ => Err(anyhow::anyhow!("Invalid mxc url")), + } +} + pub struct MatrixSync { client: matrix_sdk::Client, join: Option>, @@ -162,21 +183,6 @@ impl MatrixSync { } } -/*#[async_trait] -impl EventEmitter for Callback { - async fn on_room_message(&self, room: SyncRoom, event: &SyncMessageEvent) { - let room_id = if let matrix_sdk::RoomState::Joined(arc) = room { - let room = arc.read().await; - room.room_id.clone() - } else { - return; - }; - self.sender - .send(event.clone().into_full_event(room_id)) - .ok(); - } -}*/ - #[derive(Clone, Debug)] pub enum Event { Room(AnyRoomEvent), @@ -201,13 +207,13 @@ where mut self: Box, _input: iced_futures::BoxStream, ) -> iced_futures::BoxStream { - let (sender, receiver) = tokio::sync::mpsc::unbounded_channel(); + let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel(); let client = self.client.clone(); let join = tokio::task::spawn(async move { client .sync_with_callback( SyncSettings::new() - //.token(client.sync_token().await.unwrap()) + .token(client.sync_token().await.unwrap()) .timeout(Duration::from_secs(90)) .full_state(true), |response| async { @@ -241,14 +247,22 @@ where .await; }); self.join = Some(join); - Box::pin(receiver) + let stream = stream! { + while let Some(item) = receiver.recv().await { + yield item; + } + }; + Box::pin(stream) } } pub trait AnyRoomEventExt { + /// Gets the event id of the underlying event fn event_id(&self) -> &EventId; /// Gets the ´origin_server_ts` member of the underlying event fn origin_server_ts(&self) -> SystemTime; + /// Gets the mxc url in a message event if there is noe + fn image_url(&self) -> Option; } impl AnyRoomEventExt for AnyRoomEvent { @@ -269,4 +283,26 @@ impl AnyRoomEventExt for AnyRoomEvent { } .to_owned() } + fn image_url(&self) -> Option { + match self { + AnyRoomEvent::Message(message) => message.image_url(), + _ => None, + } + } +} + +pub trait AnyMessageEventExt { + fn image_url(&self) -> Option; +} + +impl AnyMessageEventExt for AnyMessageEvent { + fn image_url(&self) -> Option { + match self { + AnyMessageEvent::RoomMessage(MessageEvent { + content: MessageEventContent::Image(ref image), + .. + }) => image.url.clone(), + _ => None, + } + } } diff --git a/src/ui.rs b/src/ui.rs index 62e7af1..b9fb561 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -5,22 +5,23 @@ use std::{ use futures::executor::block_on; use iced::{ - Application, Button, Column, Command, Container, Element, Length, Row, Rule, Scrollable, + Align, Application, Button, Column, Command, Container, Element, Length, Row, Rule, Scrollable, Subscription, Text, TextInput, }; use matrix_sdk::{ - api::r0::message::get_message_events::{ - Request as MessageRequest, Response as MessageResponse, + api::r0::{ + media::get_content::Request as ImageRequest, + message::get_message_events::{Request as MessageRequest, Response as MessageResponse}, }, events::{ key::verification::cancel::CancelCode as VerificationCancelCode, - room::message::MessageEventContent, AnyMessageEvent, AnyMessageEventContent, AnyRoomEvent, - AnyStateEvent, AnyToDeviceEvent, + room::{member::MembershipState, message::MessageEventContent}, + AnyMessageEvent, AnyMessageEventContent, AnyRoomEvent, AnyStateEvent, AnyToDeviceEvent, }, identifiers::{EventId, RoomAliasId, RoomId, UserId}, }; -use crate::matrix::{self, AnyRoomEventExt}; +use crate::matrix::{self, AnyMessageEventExt, AnyRoomEventExt}; pub mod prompt; pub mod settings; @@ -39,31 +40,28 @@ pub enum RoomSorting { #[derive(Clone, Debug, Default)] pub struct RoomEntry { /// Cached calculated name - name: String, + pub name: String, /// Room topic - topic: String, + pub topic: String, /// Canonical alias - alias: Option, + pub alias: Option, /// Defined display name - display_name: Option, + pub display_name: Option, /// Person we're in a direct message with - direct: Option, + pub direct: Option, /// Cache of messages - messages: MessageBuffer, + pub messages: MessageBuffer, } impl RoomEntry { - /// Recalculate displayname - pub fn update_display_name(&mut self, id: &RoomId) { - self.name = if let Some(ref name) = self.display_name { - name.to_owned() - } else if let Some(ref user) = self.direct { - user.to_string() - } else if let Some(ref alias) = self.alias { - alias.to_string() - } else { - id.to_string() - }; + pub fn from_sdk(room: &matrix_sdk::JoinedRoom) -> Self { + Self { + direct: room.direct_target(), + name: block_on(async { room.display_name().await }), + topic: room.topic().unwrap_or_default(), + alias: room.canonical_alias(), + ..Default::default() + } } } @@ -82,6 +80,8 @@ pub struct MessageBuffer { end: Option, /// Most recent activity in the room updated: std::time::SystemTime, + /// Whether we're awaiting for backfill to be received + loading: bool, } impl MessageBuffer { @@ -117,6 +117,13 @@ impl MessageBuffer { self.sort(); self.update_time(); } + + /// Whather the message buffer has the room creation event + pub fn has_beginning(&self) -> bool { + self.messages + .iter() + .any(|e| matches!(e, AnyRoomEvent::State(AnyStateEvent::RoomCreate(_)))) + } } impl Default for MessageBuffer { @@ -127,6 +134,7 @@ impl Default for MessageBuffer { start: None, end: None, updated: SystemTime::UNIX_EPOCH, + loading: false, } } } @@ -153,6 +161,8 @@ pub struct MainView { sorting: RoomSorting, /// Room state rooms: BTreeMap, + /// A map of mxc urls to image data + images: BTreeMap, /// Room list entries for direct conversations dm_buttons: Vec, @@ -162,6 +172,10 @@ pub struct MainView { room_scroll: iced::scrollable::State, /// Message view scrollbar state message_scroll: iced::scrollable::State, + /// Backfill fetch button state + backfill_button: iced::button::State, + /// Button to go the room a tombstone points to + tombstone_button: iced::button::State, /// Message draft text input message_input: iced::text_input::State, /// Button to send drafted message @@ -186,8 +200,11 @@ impl MainView { sas: None, rooms: Default::default(), selected: None, + images: Default::default(), room_scroll: Default::default(), message_scroll: Default::default(), + backfill_button: Default::default(), + tombstone_button: Default::default(), message_input: Default::default(), dm_buttons: Vec::new(), group_buttons: Vec::new(), @@ -245,7 +262,12 @@ impl MainView { .map(|(idx, button)| { // TODO: highlight selected let (id, room) = dm_rooms[idx]; - Button::new(button, Text::new(&room.name)) + let name = if room.name.is_empty() { + "Missing name" + } else { + &room.name + }; + Button::new(button, Text::new(name)) .width(300.into()) .on_press(Message::SelectRoom(id.to_owned())) }) @@ -281,10 +303,17 @@ impl MainView { let mut message_col = Column::new().spacing(5).padding(5); let selected_room = match self.selected { - Some(ref selected) => self.rooms.get(selected), + Some(ref selected) => match ( + self.rooms.get(selected), + self.client.get_joined_room(selected), + ) { + (Some(room), Some(joined)) => Some((room, joined)), + _ => None, + }, None => None, }; - if let Some(room) = selected_room { + if let Some((room, joined)) = selected_room { + // Include user id or canonical alias in title when appropriate let title = if let Some(ref direct) = room.direct { format!("{} ({})", &room.name, direct) } else if let Some(ref alias) = room.alias { @@ -292,29 +321,43 @@ impl MainView { } else { room.name.clone() }; + message_col = message_col .push(Text::new(title).size(25)) .push(Rule::horizontal(2)); let mut scroll = Scrollable::new(&mut self.message_scroll) .scrollbar_width(2) .height(Length::Fill); + // Backfill button or loading message + let backfill: Element<_> = if room.messages.loading { + Text::new("Loading...").into() + } else if room.messages.has_beginning() { + let creation = joined.create_content().unwrap(); + let mut col = + Column::new().push(Text::new("This is the beginning of room history")); + if let Some(prevous) = creation.predecessor { + col = col.push( + Button::new(&mut self.backfill_button, Text::new("Go to older version")) + .on_press(Message::SelectRoom(prevous.room_id)), + ); + } + col.into() + } else { + Button::new(&mut self.backfill_button, Text::new("Load more messages")) + .on_press(Message::BackFill(self.selected.clone().unwrap())) + .into() + }; + scroll = scroll.push(Container::new(backfill).width(Length::Fill).center_x()); + // Messages for event in room.messages.messages.iter() { #[allow(clippy::single_match)] match event { AnyRoomEvent::Message(AnyMessageEvent::RoomMessage(message)) => { - let sender = { - match self.client.get_joined_room(&message.room_id) { - Some(backend) => { - match block_on(async { - backend.get_member(&message.sender).await - }) { - Some(member) => member.name().to_owned(), - None => message.sender.to_string(), - } - } + let sender = + match block_on(async { joined.get_member(&message.sender).await }) { + Some(member) => member.name().to_owned(), None => message.sender.to_string(), - } - }; + }; let content: Element<_> = match &message.content { MessageEventContent::Audio(audio) => { Text::new(format!("Audio message: {}", audio.body)) @@ -323,7 +366,7 @@ impl MainView { .into() } MessageEventContent::Emote(emote) => { - Text::new(format!("{} {}", sender, emote.body)) + Text::new(format!("* {} {}", sender, emote.body)) .width(Length::Fill) .into() } @@ -334,9 +377,24 @@ impl MainView { .into() } MessageEventContent::Image(image) => { - Text::new(format!("Image with description: {}", image.body)) - .width(Length::Fill) - .into() + if let Some(ref url) = image.url { + match self.images.get(url) { + Some(handle) => Container::new( + iced::Image::new(handle.to_owned()) + .width(800.into()) + .height(1200.into()), + ) + .width(Length::Fill) + .into(), + None => { + Text::new("Image not loaded").width(Length::Fill).into() + } + } + } else { + Text::new("Encrypted images not supported yet") + .width(Length::Fill) + .into() + } } MessageEventContent::Notice(notice) => { Text::new(¬ice.body).width(Length::Fill).into() @@ -367,6 +425,26 @@ impl MainView { _ => (), } } + // Tombstone + if let Some(tombstone) = joined.tombstone() { + let text = Text::new(format!( + "This room has been upgraded to a new version: {}", + tombstone.body + )); + let button = + Button::new(&mut self.tombstone_button, Text::new("Go to upgraded room")) + .on_press(Message::SelectRoom(tombstone.replacement_room)); + scroll = scroll.push( + Container::new( + Column::new() + .push(text) + .push(button) + .align_items(Align::Center), + ) + .center_x() + .width(Length::Fill), + ); + } message_col = message_col.push(scroll); } else { message_col = message_col.push( @@ -490,6 +568,10 @@ pub enum Message { BackFill(RoomId), /// Received backfille BackFilled(RoomId, MessageResponse), + /// Fetch an image pointed to by an mxc url + FetchImage(String), + /// Fetched an image + FetchedImage(String, iced::image::Handle), /// View messages from this room SelectRoom(RoomId), /// Set error message @@ -637,48 +719,25 @@ impl Application for Retrix { *self = Retrix::LoggedIn(MainView::new(client.clone(), session)); let mut commands: Vec> = Vec::new(); for room in client.joined_rooms().into_iter() { - let command = async move { + //let client = client.clone(); + let command: Command<_> = async move { let room = room.clone(); - let entry = RoomEntry { - direct: room.direct_target(), - name: block_on(async { room.calculate_name().await }), - topic: room.topic().unwrap_or_default(), - ..RoomEntry::default() - }; + let entry = RoomEntry::from_sdk(&room); // Display name calculation for DMs is bronk so we're doing it // ourselves - /*match entry.direct { - Some(ref direct) => { - let request = matrix_sdk::api::r0::profile::get_display_name::Request::new(direct); - if let Ok(response) = client.send(request).await { - if let Some(name) = response.displayname { - entry.name = name; - } + /*if let Some(ref direct) = entry.direct { + let request = DisplayNameRequest::new(direct); + if let Ok(response) = client.send(request).await { + if let Some(name) = response.displayname { + entry.name = name; } } - None => entry.name = room.display_name(), }*/ - /*let messages = room - .messages - .iter() - .cloned() - .map(|event| match event { - AnyPossiblyRedactedSyncMessageEvent::Redacted(e) => { - AnyRoomEvent::RedactedMessage( - e.into_full_event(id.clone()), - ) - } - AnyPossiblyRedactedSyncMessageEvent::Regular(e) => { - AnyRoomEvent::Message(e.into_full_event(id.clone())) - } - }) - .collect(); - entry.messages.messages = messages;*/ Message::ResetRoom(room.room_id().to_owned(), entry) } .into(); - commands.push(command) + commands.push(command); } return Command::batch(commands); } @@ -689,38 +748,57 @@ impl Application for Retrix { Message::ClearError => view.error = None, Message::SetSort(s) => view.sorting = s, Message::ResetRoom(id, room) => { - view.rooms.insert(id, room).and(Some(())).unwrap_or(()) + view.rooms.insert(id.clone(), room); + return async move { Message::BackFill(id) }.into(); } Message::SelectRoom(r) => { view.selected = Some(r.clone()); - return async move { Message::BackFill(r) }.into(); + if view.rooms.get(&r).unwrap().messages.messages.is_empty() { + return async move { Message::BackFill(r) }.into(); + } } Message::Sync(event) => match event { matrix::Event::Room(event) => match event { AnyRoomEvent::Message(event) => { let room = view.rooms.entry(event.room_id().clone()).or_default(); room.messages.push(AnyRoomEvent::Message(event.clone())); + let mut commands = Vec::new(); + let img_cmd = match event.image_url() { + Some(url) => async { Message::FetchImage(url) }.into(), + None => Command::none(), + }; + commands.push(img_cmd); // Set read marker if message is in selected room if view.selected.as_ref() == Some(event.room_id()) { + // Super duper gross ugly scroll to bottom hack + /*view.message_scroll = unsafe { + let mut tmp = std::mem::transmute::<_, (Option, f32)>( + view.message_scroll, + ); + tmp.1 = 999999.0; + std::mem::transmute::<_, iced::scrollable::State>(tmp) + };*/ + let client = view.client.clone(); - return Command::perform( - async move { - client - .read_marker( - event.room_id(), - event.event_id(), - Some(event.event_id()), - ) - .await - .err() - }, - |result| match result { + let marker_cmd = async move { + let result = client + .read_marker( + event.room_id(), + event.event_id(), + Some(event.event_id()), + ) + .await + .err(); + match result { Some(err) => Message::ErrorMessage(err.to_string()), // TODO: Make this an actual no-op None => Message::Login, - }, - ); + } + } + .into(); + commands.push(marker_cmd); } + return Command::batch(commands); } AnyRoomEvent::State(event) => match event { AnyStateEvent::RoomCanonicalAlias(ref alias) => { @@ -731,6 +809,9 @@ impl Application for Retrix { AnyStateEvent::RoomName(ref name) => { let room = view.rooms.entry(name.room_id.clone()).or_default(); room.display_name = name.content.name().map(String::from); + if let Some(joined) = view.client.get_joined_room(&name.room_id) { + room.name = block_on(async { joined.display_name().await }); + } room.messages.push(AnyRoomEvent::State(event)); } AnyStateEvent::RoomTopic(ref topic) => { @@ -738,6 +819,36 @@ impl Application for Retrix { room.topic = topic.content.topic.clone(); room.messages.push(AnyRoomEvent::State(event)); } + AnyStateEvent::RoomCreate(ref create) => { + // Add room to the entry list + let room = match view.client.get_joined_room(&create.room_id) { + Some(joined) => view + .rooms + .entry(create.room_id.clone()) + .or_insert_with(|| RoomEntry::from_sdk(&joined)), + None => view.rooms.entry(create.room_id.clone()).or_default(), + }; + room.messages.push(AnyRoomEvent::State(event)); + } + AnyStateEvent::RoomMember(ref member) => { + let room = view.rooms.entry(member.room_id.clone()).or_default(); + let client = view.client.clone(); + // If we left a room, remove it from the RoomEntry list + let own_id = block_on(async { client.user_id().await }) + .unwrap() + .to_string(); + if member.content.membership == MembershipState::Leave + && member.state_key == own_id + { + // Deselect room if we're leaving selected room + if view.selected.as_ref() == Some(&member.room_id) { + view.selected = None; + } + view.rooms.remove(&member.room_id); + return Command::none(); + } + room.messages.push(AnyRoomEvent::State(event)); + } ref any => { // Ensure room exists let room = view.rooms.entry(any.room_id().clone()).or_default(); @@ -768,7 +879,8 @@ impl Application for Retrix { } }, Message::BackFill(id) => { - let room = view.rooms.get(&id).unwrap(); + let room = view.rooms.entry(id.clone()).or_default(); + room.messages.loading = true; let client = view.client.clone(); let token = match room.messages.end.clone() { Some(end) => end, @@ -789,6 +901,7 @@ impl Application for Retrix { } Message::BackFilled(id, response) => { let room = view.rooms.get_mut(&id).unwrap(); + room.messages.loading = false; let events: Vec = response .chunk .into_iter() @@ -807,7 +920,41 @@ impl Application for Retrix { if let Some(end) = response.end { room.messages.end = Some(end); } + let commands: Vec> = events + .iter() + .filter_map(|e| e.image_url()) + .map(|url| async { Message::FetchImage(url) }.into()) + .collect(); room.messages.append(events); + return Command::batch(commands); + } + Message::FetchImage(url) => { + let (server, path) = match matrix::parse_mxc(&url) { + Ok((server, path)) => (server, path), + Err(e) => { + return async move { Message::ErrorMessage(e.to_string()) }.into() + } + }; + println!( + "Getting '{}' from '{}', with url '{}'", + &server, &path, &url + ); + let client = view.client.clone(); + return async move { + let request = ImageRequest::new(&path, &*server); + let response = client.send(request).await; + match response { + Ok(response) => Message::FetchedImage( + url, + iced::image::Handle::from_memory(response.file), + ), + Err(e) => Message::ErrorMessage(e.to_string()), + } + } + .into(); + } + Message::FetchedImage(url, handle) => { + view.images.insert(url, handle); } Message::SetVerification(v) => view.sas = v, Message::VerificationAccept => { @@ -965,13 +1112,13 @@ impl Application for Retrix { } fn format_systime(time: std::time::SystemTime) -> String { - let secs = time - .duration_since(std::time::SystemTime::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - format!( - "{:02}:{:02}", - (secs % (60 * 60 * 24)) / (60 * 60), - (secs % (60 * 60)) / 60 - ) + let offset = time::UtcOffset::try_current_local_offset().unwrap_or(time::UtcOffset::UTC); + let time = time::OffsetDateTime::from(time).to_offset(offset); + let today = time::OffsetDateTime::now_utc().to_offset(offset).date(); + // Display + if time.date() == today { + time.format("%T") + } else { + time.format("%F %T") + } }