use std::{borrow::Borrow, sync::Arc}; use adw::prelude::*; use libsecret::prelude::{RetrievableExtManual, RetrievableExt}; use relm4::{gtk, loading_widgets::LoadingWidgets, prelude::{AsyncComponent, AsyncComponentController, AsyncComponentParts, AsyncController, ComponentController, Controller}, AsyncComponentSender, Component, RelmWidgetExt}; pub mod components { pub(crate) mod smart_summary; pub(crate) use smart_summary::{ SmartSummaryButton, Output as SmartSummaryOutput, Input as SmartSummaryInput }; pub(crate) mod post_editor; pub(crate) use post_editor::{ PostEditor, Input as PostEditorInput }; pub(crate) mod tag_pill; pub(crate) use tag_pill::{TagPill, TagPillDelete}; pub mod signin; pub use signin::{SignIn, Output as SignInOutput}; } use components::{post_editor::Post, PostEditorInput}; pub mod secrets; pub mod micropub; pub mod util; pub const APPLICATION_ID: &str = "xyz.vikanezrimaya.kittybox.Bowl"; pub const CLIENT_ID_STR: &str = "https://kittybox.fireburn.ru/bowl/"; pub const VISIBILITY: [&str; 2] = ["public", "private"]; #[derive(Debug)] pub struct App { state: AuthState, secret_schema: libsecret::Schema, http: soup::Session, signin: AsyncController, post_editor: Controller>, } #[derive(Debug)] enum AuthState { LoggedOut, LoggedIn { submit_busy_guard: Option, micropub: micropub::Client } } #[derive(Debug)] #[doc(hidden)] pub enum Input { SubmitButtonPressed, Authorize(Box), PostEditor(Option) } #[derive(Default, Debug)] pub struct AppRootWidgets { root: adw::ApplicationWindow, toolbar_view: adw::ToolbarView, top_bar: adw::HeaderBar, top_bar_btn: gtk::Button, } //#[relm4::component(pub async)] impl AsyncComponent for App { /// The type of the messages that this component can receive. type Input = Input; /// The type of the messages that this component can send. type Output = (); /// The type of data with which this component will be initialized. type Init = (); /// The type of the command outputs that this component can receive. type CommandOutput = (); type Widgets = AppRootWidgets; type Root = adw::ApplicationWindow; fn init_root() -> Self::Root { let window = Self::Root::default(); window.set_size_request(360, 294); window.set_default_size(360, 640); #[cfg(debug_assertions)] window.add_css_class("devel"); window } fn init_loading_widgets(_root: Self::Root) -> Option { let root = gtk::Box::default(); let spinner = gtk::Spinner::builder() .spinning(true) .halign(gtk::Align::Center) .valign(gtk::Align::Center) .build(); root.append(&spinner); Some(LoadingWidgets::new(root, spinner)) } /// Initialize the UI and model. async fn init( _init: Self::Init, window: Self::Root, sender: AsyncComponentSender, ) -> AsyncComponentParts { let schema = crate::secrets::get_schema(); let state = match libsecret::password_search_future(Some(&schema), { let mut attrs = std::collections::HashMap::default(); attrs.insert(crate::secrets::TOKEN_KIND, crate::secrets::ACCESS_TOKEN); attrs }, libsecret::SearchFlags::ALL).await { Ok(mut retrievables) => { if retrievables.is_empty() { AuthState::LoggedOut } else { retrievables.sort_by_key(|s| s.created()); let retrievable = retrievables.last().unwrap(); let attrs = retrievable.attributes(); let micropub_uri = attrs .get(crate::secrets::MICROPUB) .and_then(|v| glib::Uri::parse(v, glib::UriFlags::NONE).ok()) .unwrap(); AuthState::LoggedIn { micropub: crate::micropub::Client::new( micropub_uri, retrievable.retrieve_secret_future().await.unwrap().unwrap().text().unwrap().to_string() ), submit_busy_guard: None } } }, Err(err) => { log::warn!("Error retrieving secrets: {}", err); AuthState::LoggedOut }, }; let http = soup::Session::builder() .user_agent(concat!(env!("CARGO_PKG_NAME"),"/",env!("CARGO_PKG_VERSION")," ")) .build(); let model = App { state, http: http.clone(), secret_schema: crate::secrets::get_schema(), post_editor: components::PostEditor::builder() .launch(None) .forward(sender.input_sender(), Self::Input::PostEditor), signin: components::SignIn::builder() .launch((glib::Uri::parse( CLIENT_ID_STR, glib::UriFlags::NONE ).unwrap(), http)) .forward( sender.input_sender(), |o| Self::Input::Authorize(Box::new(o)) ) }; let mut widgets = Self::Widgets { root: window, ..Self::Widgets::default() }; widgets.toolbar_view.add_top_bar(&widgets.top_bar); widgets.top_bar.pack_end(&widgets.top_bar_btn); widgets.top_bar_btn.set_icon_name("document-send-symbolic"); widgets.top_bar_btn.set_tooltip("Send post"); widgets.top_bar_btn.connect_clicked(glib::clone!( #[strong] sender, move |_button| sender.input(Self::Input::SubmitButtonPressed) )); widgets.root.set_content(Some(&widgets.toolbar_view)); // Separate component choosing logic from initialization. We // already have all the parts here, might as well use them. model.update_view(&mut widgets, sender); AsyncComponentParts { model, widgets } } fn update_view(&self, widgets: &mut Self::Widgets, _sender: AsyncComponentSender) { // Bind the child component, if any, here. match &self.state { AuthState::LoggedOut => { widgets.root.set_title(Some("Sign in with your website")); widgets.toolbar_view.set_content(Some(self.signin.widget())); widgets.top_bar_btn.set_visible(false); }, AuthState::LoggedIn { submit_busy_guard, .. } => { widgets.root.set_title(Some("Create post")); widgets.toolbar_view.set_content(Some(self.post_editor.widget())); widgets.top_bar_btn.set_sensitive(submit_busy_guard.is_none()); widgets.top_bar_btn.set_visible(true); } } } async fn update( &mut self, message: Self::Input, _sender: AsyncComponentSender, _root: &Self::Root ) { match message { Input::Authorize(data) => { let schema = crate::secrets::get_schema(); let mut attributes = std::collections::HashMap::new(); let _me = data.me.to_string(); let _micropub = data.micropub.to_string(); attributes.insert(secrets::ME, _me.as_str()); attributes.insert(secrets::TOKEN_KIND, secrets::ACCESS_TOKEN); attributes.insert(secrets::MICROPUB, _micropub.as_str()); let exp = data.expires_in .as_ref() .map(std::time::Duration::as_secs) .as_ref() .map(u64::to_string); if let Some(expires_in) = exp.as_deref() { attributes.insert(secrets::EXPIRES_IN, expires_in); } match libsecret::password_store_future( Some(&schema), attributes.clone(), Some(libsecret::COLLECTION_DEFAULT), data.me.to_str().as_str(), &data.access_token ).await { Ok(()) => {}, Err(err) => log::error!("Failed to store access token to the secret store: {}", err), } if let Some(refresh_token) = data.refresh_token.as_deref() { attributes.insert(secrets::TOKEN_KIND, secrets::REFRESH_TOKEN); attributes.remove(secrets::EXPIRES_IN); match libsecret::password_store_future( Some(&schema), attributes, Some(libsecret::COLLECTION_DEFAULT), data.me.to_str().as_str(), refresh_token ).await { Ok(()) => {}, Err(err) => log::error!("Failed to store refresh token to the secret store: {}", err), } } self.state = AuthState::LoggedIn { micropub: crate::micropub::Client::new( data.micropub.clone(), data.access_token.clone() ), submit_busy_guard: None }; }, Input::SubmitButtonPressed => { if let AuthState::LoggedIn { ref mut submit_busy_guard, .. } = &mut self.state { *submit_busy_guard = Some(relm4::main_adw_application().mark_busy()); self.post_editor.emit(PostEditorInput::Submit); }; }, Input::PostEditor(None) => { if let AuthState::LoggedIn { ref mut submit_busy_guard, .. } = &mut self.state { *submit_busy_guard = None; } } Input::PostEditor(Some(post)) => { if let AuthState::LoggedIn { ref mut submit_busy_guard, ref micropub } = &mut self.state { let mf2 = post.into(); log::debug!("Submitting post: {:#}", serde_json::to_string(&mf2).unwrap()); match micropub.send_post(mf2).await { Ok(uri) => { self.post_editor.emit(PostEditorInput::SubmitDone(uri)); }, Err(err) => { log::warn!("Error sending post: {}", err); self.post_editor.emit(PostEditorInput::SubmitError(err)); } } *submit_busy_guard = None; } }, } } }