diff --git a/Cargo.lock b/Cargo.lock index 03e3ab3..a0f108d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -826,9 +826,11 @@ dependencies = [ name = "egui-test" version = "0.1.0" dependencies = [ + "crossbeam-channel", "eframe", "futures", "matrix-sdk", + "ron", "serde", "tokio", "url", diff --git a/Cargo.toml b/Cargo.toml index f2ed5da..bb7fda8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,8 +7,10 @@ resolver = "2" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +crossbeam-channel = "0.5" eframe = { version = "0.14", features = ["persistence", "http"] } futures = "0.3" +ron = "0.6" serde = { version = "1.0", features = ["derive"] } tokio = { version = "*", features = ["full"] } url = { version = "2.2", features = ["serde"] } diff --git a/src/main.rs b/src/main.rs index f2c587f..74f8378 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ pub mod matrix; +pub mod sync; pub mod ui; use eframe::NativeOptions; @@ -6,51 +7,7 @@ use eframe::NativeOptions; #[cfg(not(target_arch = "wasm32"))] fn main() { let app = ui::App::default(); - let options = NativeOptions::default(); + let mut options = NativeOptions::default(); + options.transparent = true; eframe::run_native(Box::new(app), options); } - -/*#[derive(Clone, Debug, Default)] -struct Login { - username: String, - password: String, -} - -impl epi::App for Login { - fn name(&self) -> &str { - "retrix" - } - - fn update(&mut self, ctx: &egui::CtxRef, _frame: &mut epi::Frame<'_>) { - egui::CentralPanel::default().show(ctx, |ui| { - ui.style_mut().body_text_style = egui::TextStyle::Button; - ui.add_space(ui.available_height() / 3.0); - ui.vertical_centered(|ui| { - //ui.style_mut() .visuals .widgets .noninteractive .bg_stroke .color = egui::Color32::TRANSPARENT; - ui.group(|ui| { - //ui.reset_style(); - ui.set_max_width(300.0); - ui.vertical(|ui| { - ui.heading("Test"); - ui.label("Username"); - ui.text_edit_singleline(&mut self.username); - ui.label("Password:"); - ui.text_edit_singleline(&mut self.password); - }) - }) - }); - /*let mut ui = ui.child_ui( - egui::Rect::from_center_size( - (ui.available_width() / 2.0, ui.available_height() / 2.0).into(), - (300.0, 500.0).into(), - ), - egui::Layout::top_down(egui::Align::Min), - ); - ui.heading("Test"); - ui.label("Username"); - ui.text_edit_singleline(&mut self.username); - ui.label("Password:"); - ui.text_edit_singleline(&mut self.password);*/ - }); - } -}*/ diff --git a/src/matrix.rs b/src/matrix.rs index 6dbb6aa..02cadac 100644 --- a/src/matrix.rs +++ b/src/matrix.rs @@ -1,4 +1,9 @@ -use std::path::{Path, PathBuf}; +//! Utility functions for working matrix-related tasks + +use std::{ + fs::File, + path::{Path, PathBuf}, +}; use matrix_sdk::{ config::ClientConfig, @@ -6,6 +11,7 @@ use matrix_sdk::{ ruma::{DeviceIdBox, UserId}, Client, }; +use ron::ser::PrettyConfig; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -20,6 +26,29 @@ pub struct Session { pub device_id: DeviceIdBox, } +impl Session { + /// Read a `Session` from the filesystem. + pub fn from_fs() -> Result { + let path = path()?.join("session.ron"); + let file = File::open(&path)?; + let session = ron::de::from_reader(file)?; + Ok(session) + } + + /// Create a matrix client and restore this session. + pub fn restore(self) -> Result { + let session = matrix_sdk::Session { + access_token: self.access_token, + user_id: self.user_id, + device_id: self.device_id, + }; + let client = Client::new(self.homeserver)?; + let runtime = tokio::runtime::Runtime::new().unwrap(); + runtime.block_on(client.restore_login(session))?; + Ok(client) + } +} + /// Create a matrix client and log it in to the server at the given URL with the /// given credentials. pub fn login(url: &str, user: &str, password: &str) -> Result { @@ -37,6 +66,8 @@ pub fn login(url: &str, user: &str, password: &str) -> Result write!(f, "Invalid homeserver address"), LoginError::Sdk(e) => write!(f, "{}", e), LoginError::Io(e) => write!(f, "Filesystem error: {}", e), + LoginError::Ron(e) => write!(f, "Serialization error: {}", e), } } } @@ -81,6 +115,12 @@ impl From for LoginError { } } +impl From for LoginError { + fn from(e: ron::Error) -> Self { + LoginError::Ron(e) + } +} + /// Configuration for `Clients`. fn config() -> Result { Ok(ClientConfig::new().store_path(&path()?)) diff --git a/src/sync.rs b/src/sync.rs new file mode 100644 index 0000000..b77a760 --- /dev/null +++ b/src/sync.rs @@ -0,0 +1,129 @@ +//! Synchronozation mechanism between gui thread and async runtime + +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, +}; + +use crossbeam_channel::Sender; +use matrix_sdk::{ruma::RoomId, Client}; +use tokio::sync::{mpsc::UnboundedReceiver, Notify}; +use url::Url; + +static QUIT: Notify = Notify::const_new(); + +/// Request to perform an action or retrieve data. +pub enum Request { + /// Perform login + Login(Url, String, String), + /// Restore session + Restore(matrix_sdk::Session), + /// Get the calculated name for a room + RoomName(RoomId), + /// Stop syncing + Quit, +} + +/// Response to a request. +pub enum Response { + /// Response from the synchronization loop + Sync(matrix_sdk::deserialized_responses::SyncResponse), + /// Calculated the name for a room + RoomName(RoomId, String), + /// An error happened while responding to a request + Error(matrix_sdk::Error), +} + +/// Run the synchronization loop +#[allow(unused)] +pub fn run(client: Client, mut request: UnboundedReceiver, response: Sender) { + let runtime = tokio::runtime::Runtime::new().unwrap(); + runtime.block_on(async move { + let quit = Arc::new(AtomicBool::new(false)); + let sync_handle = { + let client = client.clone(); + let response = response.clone(); + let quit = quit.clone(); + tokio::spawn(async move { + client + .sync_with_callback(Default::default(), |sync| async { + response.send(Response::Sync(sync)).ok(); + match quit.load(Ordering::Acquire) { + false => matrix_sdk::LoopCtrl::Continue, + true => dbg!(matrix_sdk::LoopCtrl::Break), + } + }) + .await + }) + }; + loop { + let request = tokio::select! { + request = request.recv() => match request { + Some(request) => request, + None => break, + }, + notif = QUIT.notified() => break, + }; + tokio::spawn(handle_request( + request, + client.clone(), + response.clone(), + quit.clone(), + )); + } + sync_handle.await; + }); +} + +async fn handle_request( + request: Request, + client: Client, + response: Sender, + quit: Arc, +) -> Result<(), SyncError> { + match request { + Request::Login(_, _, _) => todo!(), + Request::Restore(session) => client.restore_login(session).await?, + Request::RoomName(room_id) => match client.get_room(&room_id) { + Some(room) => response.send(Response::RoomName(room_id, room.display_name().await?))?, + None => (), + }, + Request::Quit => { + dbg!(quit.store(true, Ordering::SeqCst)); + QUIT.notify_one(); + } + }; + Ok(()) +} + +#[derive(Debug)] +enum SyncError { + Sdk(matrix_sdk::Error), + Send, +} + +impl std::fmt::Display for SyncError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "sync error") + } +} + +impl std::error::Error for SyncError {} + +impl From for SyncError { + fn from(e: matrix_sdk::Error) -> Self { + Self::Sdk(e) + } +} + +impl From> for SyncError { + fn from(_: crossbeam_channel::SendError) -> Self { + Self::Send + } +} + +impl From for SyncError { + fn from(e: matrix_sdk::StoreError) -> Self { + Self::Sdk(e.into()) + } +} diff --git a/src/ui.rs b/src/ui.rs index 520dd22..c2aa941 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,6 +1,9 @@ use eframe::{egui, epi}; -use crate::matrix; +use crate::{ + matrix::{self, Session}, + sync, +}; pub mod login; pub mod session; @@ -16,23 +19,63 @@ impl epi::App for App { "retrix" } + fn setup( + &mut self, + _ctx: &egui::CtxRef, + _frame: &mut epi::Frame<'_>, + storage: Option<&dyn epi::Storage>, + ) { + let client = match Session::from_fs().and_then(Session::restore) { + Ok(session) => session, + _ => return, + }; + let view = self.view.make_main(client); + if let Some(storage) = storage { + storage + .get_string("rooms") + .and_then(|s| ron::from_str(&s).ok()) + .map(|list| view.room_list = list); + } + } + + fn save(&mut self, storage: &mut dyn epi::Storage) { + match self.view { + View::Login(ref login) => { + if let Ok(login) = ron::to_string(login) { + storage.set_string("login", login); + } + } + View::Main(ref main) => { + if let Ok(rooms) = ron::to_string(&main.room_list) { + storage.set_string("rooms", rooms) + } + } + } + } + + fn on_exit(&mut self) { + match self.view { + View::Main(ref mut main) => { + main.request.send(sync::Request::Quit).ok(); + main.sync_handle.take().map(|t| t.join()); + } + _ => (), + }; + } + fn update(&mut self, ctx: &egui::CtxRef, _frame: &mut epi::Frame<'_>) { match self.view { View::Login(ref mut login) => { if login.update(ctx) { let client = - match matrix::login(&login.homeserver, &login.username, &login.password) { + match matrix::login(&login.homeserver, &login.user, &login.password) { Ok(client) => client, Err(e) => { login.error = Some(e.to_string()); return; } }; - self.view = View::Main(session::App::with_client(client.clone())); - std::thread::spawn(move || { - let rt = tokio::runtime::Runtime::new().unwrap(); - rt.block_on(client.sync(Default::default())) - }); + self.view.make_main(client); } } View::Main(ref mut view) => view.update(ctx), @@ -47,6 +90,29 @@ pub enum View { Main(session::App), } +impl View { + pub fn make_main(&mut self, client: matrix_sdk::Client) -> &mut session::App { + let (req_tx, req_rx) = tokio::sync::mpsc::unbounded_channel(); + let (res_tx, res_rx) = crossbeam_channel::bounded(128); + let client2 = client.clone(); + let handle = std::thread::spawn(move || { + sync::run(client2, req_rx, res_tx); + }); + let view = session::App::new(client, req_tx, res_rx, handle); + for room in view.client.rooms() { + view.request + .send(sync::Request::RoomName(room.room_id().clone())) + .ok(); + } + + *self = View::Main(view); + match *self { + View::Main(ref mut main) => main, + _ => unreachable!(), + } + } +} + impl Default for View { fn default() -> Self { View::Login(login::Login::default()) @@ -56,4 +122,6 @@ impl Default for View { #[derive(Debug, Clone, Copy, Hash)] pub enum Id { RoomPanel, + RoomSummary, + MemberList, } diff --git a/src/ui/login.rs b/src/ui/login.rs index 67d5eed..71f67f6 100644 --- a/src/ui/login.rs +++ b/src/ui/login.rs @@ -1,9 +1,12 @@ use eframe::egui::{self, Color32, TextEdit}; +use serde::{Deserialize, Serialize}; -#[derive(Clone, Debug, Default)] +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[serde(default)] pub struct Login { pub homeserver: String, - pub username: String, + pub user: String, + #[serde(skip)] pub password: String, pub error: Option, } @@ -15,12 +18,14 @@ impl Login { ui.add_space(ui.available_height() / 3.0); ui.vertical_centered(|ui| { //ui.style_mut() .visuals .widgets .noninteractive .bg_stroke .color = egui::Color32::TRANSPARENT; + ui.heading("Agrix"); + ui.add_space(10.0); ui.group(|ui| { //ui.reset_style(); + ui.set_max_width(300.0); if let Some(ref error) = self.error { ui.colored_label(Color32::from_rgb(255, 0, 0), error); } - ui.set_max_width(300.0); ui.vertical(|ui| { ui.heading("Log in"); ui.label("Homeserver:"); @@ -29,7 +34,7 @@ impl Login { .hint_text("https://example.net"), ); ui.label("Username:"); - ui.add(TextEdit::singleline(&mut self.username).hint_text("alice")); + ui.add(TextEdit::singleline(&mut self.user).hint_text("alice")); ui.label("Password:"); ui.add(TextEdit::singleline(&mut self.password).password(true)); if ui.button("Log in").clicked() { diff --git a/src/ui/session.rs b/src/ui/session.rs index 8779b5e..e6ed39d 100644 --- a/src/ui/session.rs +++ b/src/ui/session.rs @@ -1,43 +1,179 @@ -use eframe::egui::{self, Sense}; -use futures::executor::block_on; -use matrix_sdk::Client; +use std::{borrow::Cow, collections::HashMap}; + +use crossbeam_channel::Receiver; +use eframe::egui::{self, Color32, ScrollArea, Sense}; +use matrix_sdk::{ + deserialized_responses::SyncResponse, + room::Room, + ruma::{events::AnyRoomEvent, RoomId, UserId}, + Client, RoomMember, +}; +use serde::{Deserialize, Serialize}; +use tokio::sync::mpsc::UnboundedSender; + +use crate::sync; use super::Id; /// Logged in application state #[derive(Debug)] pub struct App { - client: matrix_sdk::Client, - selected_room: Option, + pub client: matrix_sdk::Client, + pub request: UnboundedSender, + pub response: Receiver, + pub sync_handle: Option>, + pub error: Option, + + pub room_list: RoomList, + pub timelines: HashMap, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +pub struct RoomList { + pub selected_room: Option, + pub room_name: HashMap, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +pub struct Timeline { + messages: Vec, + #[serde(skip)] + member: HashMap, +} + +impl RoomList { + fn room_name<'a>(&'a self, room: &matrix_sdk::BaseRoom) -> Cow<'a, str> { + if let Some(name) = self.room_name.get(room.room_id()) { + name.into() + } else if let Some(name) = room.name() { + name.into() + } else if let Some(alias) = room.canonical_alias() { + String::from(alias).into() + } else { + room.room_id().to_string().into() + } + } + + fn is_selected(&self, room_id: &RoomId) -> bool { + self.selected_room + .as_ref() + .map_or(false, |id| id == room_id) + } + + fn selected_room(&self, client: &Client) -> Option { + match self.selected_room { + Some(ref selected) => client.get_room(selected), + None => None, + } + } } impl App { - pub fn with_client(client: Client) -> Self { + pub fn new( + client: Client, + request: UnboundedSender, + response: Receiver, + sync_handle: std::thread::JoinHandle<()>, + ) -> Self { Self { client, - selected_room: None, + request, + response, + + sync_handle: Some(sync_handle), + room_list: RoomList::default(), + error: None, + timelines: HashMap::new(), } } pub fn update(&mut self, ctx: &egui::CtxRef) { + if let Ok(response) = self.response.try_recv() { + self.handle_response(response); + } + egui::SidePanel::left(Id::RoomPanel) .max_width(800.0) + .default_width(400.0) .show(ctx, |ui| { ui.add(egui::Label::new("Joined").strong()); for room in self.client.joined_rooms() { - let response = ui.group(|ui| { - if let Some(name) = room.name() { - ui.label(name.to_string()); - } - if let Some(alias) = room.canonical_alias() { - ui.label(alias.to_string()); - } + let group = if self.room_list.is_selected(room.room_id()) { + egui::Frame::group(&Default::default()) + } else { + egui::Frame::group(&Default::default()).fill(Color32::from_rgb(0, 0, 0)) + }; + let response = group.show(ui, |ui| { + ui.set_width(ui.available_width()); + let name = self.room_list.room_name(&*room); + ui.label(&*name); }); let response = response.response.interact(Sense::click()); if response.clicked() { - self.selected_room = Some(room.room_id().clone()); + self.room_list.selected_room = Some(room.room_id().clone()); } } }); + + let room = match self.room_list.selected_room(&self.client) { + Some(room) => room, + _ => return, + }; + egui::TopBottomPanel::top(Id::RoomSummary).show(ctx, |ui| { + ui.horizontal(|ui| { + ui.set_width(ui.available_width()); + ui.heading(&*self.room_list.room_name(&room)); + if let Some(ref target) = room.direct_target() { + ui.label(target.as_str()); + } else if let Some(ref alias) = room.canonical_alias() { + ui.label(alias.as_str()); + } + if let Some(ref topic) = room.topic() { + ui.label(topic); + } + }) + }); + + egui::SidePanel::right(Id::MemberList).show(ctx, |ui| { + ui.heading("Members"); + }); + + let room = match room { + Room::Joined(room) => room, + _ => return, + }; + let timeline = self.timelines.entry(room.room_id().clone()).or_default(); + egui::CentralPanel::default().show(ctx, |ui| { + ScrollArea::auto_sized().show(ui, |ui| { + for message in timeline.messages.iter() { + ui.label(message.sender().as_str()); + } + }) + }); + } + + fn handle_response(&mut self, response: sync::Response) { + match response { + sync::Response::RoomName(room, name) => { + self.room_list.room_name.insert(room, name); + } + sync::Response::Error(e) => { + self.error = Some(e.to_string()); + } + sync::Response::Sync(sync) => self.handle_sync(sync), + }; + } + + fn handle_sync(&mut self, sync: SyncResponse) { + for (id, room) in sync.rooms.join { + let timeline = self.timelines.entry(id.clone()).or_default(); + for event in room.timeline.events { + let event = match event.event.deserialize() { + Ok(event) => event, + Err(_) => continue, + }; + timeline.messages.push(event.into_full_event(id.clone())); + } + } } }