pub mod settings; pub mod api; pub mod components; pub mod util; pub mod dialogs; use api::{user::default_person, community::default_community, post::default_post}; use components::{post_row::PostRow, community_row::CommunityRow, profile_page::{ProfilePage, self}, community_page::{CommunityPage, self}, post_page::{PostPage, self}}; use gtk::prelude::*; use lemmy_api_common::{lemmy_db_views_actor::structs::CommunityView, lemmy_db_views::structs::PostView, person::GetPersonDetailsResponse, lemmy_db_schema::{newtypes::PostId, ListingType}, post::GetPostResponse, community::GetCommunityResponse}; use relm4::{prelude::*, factory::FactoryVecDeque, set_global_css, actions::{RelmAction, RelmActionGroup}}; static APP_ID: &str = "com.lemmy-gtk.lemoa"; #[derive(Debug, Clone, Copy)] enum AppState { Loading, Posts, ChooseInstance, Communities, Community, Person, Post, Login, Message } struct App { state: AppState, message: Option, latest_action: Option, posts: FactoryVecDeque, communities: FactoryVecDeque, profile_page: Controller, community_page: Controller, post_page: Controller } #[derive(Debug, Clone)] pub enum AppMsg { ChooseInstance, ShowLogin, Login(String, String), Logout, Retry, ShowMessage(String), DoneChoosingInstance(String), StartFetchPosts(Option), DoneFetchPosts(Vec), DoneFetchCommunities(Vec), ViewCommunities(Option), OpenCommunity(String), DoneFetchCommunity(GetCommunityResponse), OpenPerson(String), DoneFetchPerson(GetPersonDetailsResponse), OpenPost(PostId), DoneFetchPost(GetPostResponse) } #[relm4::component] impl SimpleComponent for App { type Init = (); type Input = AppMsg; type Output = (); view! { #[root] main_window = gtk::ApplicationWindow { set_title: Some("Lemoa"), set_default_size: (300, 100), #[wrap(Some)] set_titlebar = >k::HeaderBar { pack_end = >k::MenuButton { set_icon_name: "view-more", set_menu_model: Some(&menu_model), }, pack_start = >k::Button { set_label: "Recommended", connect_clicked => AppMsg::StartFetchPosts(None), }, pack_start = >k::Button { set_label: "Subscribed", connect_clicked => AppMsg::StartFetchPosts(Some(ListingType::Subscribed)), }, pack_start = >k::Button { set_label: "Communities", connect_clicked => AppMsg::ViewCommunities(None), }, }, match model.state { AppState::Posts => gtk::ScrolledWindow { set_vexpand: true, set_hexpand: true, #[local_ref] posts_box -> gtk::Box { set_orientation: gtk::Orientation::Vertical, set_spacing: 5, } }, AppState::Loading => gtk::Box { set_hexpand: true, set_orientation: gtk::Orientation::Vertical, set_spacing: 12, set_valign: gtk::Align::Center, set_halign: gtk::Align::Center, gtk::Spinner { set_spinning: true, set_height_request: 80, }, gtk::Label { set_text: "Loading", }, }, AppState::ChooseInstance => gtk::Box { set_hexpand: true, set_orientation: gtk::Orientation::Vertical, set_spacing: 12, set_margin_all: 20, set_valign: gtk::Align::Center, set_halign: gtk::Align::Center, gtk::Label { set_text: "Please enter the URL of a valid lemmy instance", }, #[name(instance_url)] gtk::Entry { set_tooltip_text: Some("Instance"), }, gtk::Button { set_label: "Done", connect_clicked[sender, instance_url] => move |_| { let text = instance_url.text().as_str().to_string(); instance_url.set_text(""); sender.input(AppMsg::DoneChoosingInstance(text)); }, } }, AppState::Login => gtk::Box { set_hexpand: true, set_orientation: gtk::Orientation::Vertical, set_spacing: 12, set_margin_all: 20, set_valign: gtk::Align::Center, set_hexpand: true, gtk::Label { set_text: "Login", add_css_class: "font-bold", }, #[name(username)] gtk::Entry { set_placeholder_text: Some("Username or E-Mail"), }, #[name(password)] gtk::PasswordEntry { set_placeholder_text: Some("Password"), set_show_peek_icon: true, }, gtk::Box { set_orientation: gtk::Orientation::Horizontal, set_halign: gtk::Align::End, gtk::Button { set_label: "Cancel", connect_clicked => AppMsg::StartFetchPosts(None), set_margin_end: 10, }, gtk::Button { set_label: "Login", connect_clicked[sender, username, password] => move |_| { let username_text = username.text().as_str().to_string(); username.set_text(""); let password_text = password.text().as_str().to_string(); password.set_text(""); sender.input(AppMsg::Login(username_text, password_text)); }, }, } }, AppState::Communities => gtk::Box { gtk::ScrolledWindow { set_vexpand: true, set_hexpand: true, gtk::Box { set_orientation: gtk::Orientation::Vertical, set_spacing: 10, gtk::Box { set_margin_all: 10, #[name(community_search_query)] gtk::Entry { set_hexpand: true, set_tooltip_text: Some("Search"), set_margin_end: 10, }, gtk::Button { set_label: "Search", connect_clicked[sender, community_search_query] => move |_| { let text = community_search_query.text().as_str().to_string(); sender.input(AppMsg::ViewCommunities(Some(text))); }, } }, #[local_ref] communities_box -> gtk::Box { set_orientation: gtk::Orientation::Vertical, set_spacing: 5, } } } } AppState::Person => { gtk::Box { #[local_ref] profile_page -> gtk::ScrolledWindow {} } } AppState::Community => { gtk::Box { #[local_ref] community_page -> gtk::ScrolledWindow {} } } AppState::Post => { gtk::Box { #[local_ref] post_page -> gtk::ScrolledWindow {} } } AppState::Message => { gtk::Box { set_orientation: gtk::Orientation::Vertical, set_margin_all: 40, gtk::Label { #[watch] set_text: &model.message.clone().unwrap_or("".to_string()), }, gtk::Button { set_label: "Go Home", connect_clicked => AppMsg::Retry, } } } } } } menu! { menu_model: { "Choose Instance" => ChangeInstanceAction, "Login" => LoginAction, "Logout" => LogoutAction } } // Initialize the component. fn init( _init: Self::Init, root: &Self::Root, sender: ComponentSender, ) -> ComponentParts { let instance_url = settings::get_prefs().instance_url; let state = if instance_url.is_empty() { AppState::ChooseInstance } else { AppState::Loading }; // initialize all controllers and factories let posts = FactoryVecDeque::new(gtk::Box::default(), sender.input_sender()); let communities = FactoryVecDeque::new(gtk::Box::default(), sender.input_sender()); let profile_page = ProfilePage::builder().launch(default_person()).forward(sender.input_sender(), |msg| msg); let community_page = CommunityPage::builder().launch(default_community().community_view).forward(sender.input_sender(), |msg| msg); let post_page = PostPage::builder().launch(default_post()).forward(sender.input_sender(), |msg| msg); let model = App { state, posts, communities, profile_page, community_page, post_page, message: None, latest_action: None }; // fetch posts if that's the initial page if !instance_url.is_empty() { sender.input(AppMsg::StartFetchPosts(None)) }; // setup all widgets and different stack pages let posts_box = model.posts.widget(); let communities_box = model.communities.widget(); let profile_page = model.profile_page.widget(); let community_page = model.community_page.widget(); let post_page = model.post_page.widget(); let widgets = view_output!(); // create the header bar menu and its actions let instance_sender = sender.clone(); let instance_action: RelmAction = RelmAction::new_stateless(move |_| { instance_sender.input(AppMsg::ChooseInstance); }); let login_sender = sender.clone(); let login_action: RelmAction = RelmAction::new_stateless(move |_| { login_sender.input(AppMsg::ShowLogin); }); let logout_action: RelmAction = RelmAction::new_stateless(move |_| { sender.input(AppMsg::ChooseInstance); }); let mut group = RelmActionGroup::::new(); group.add_action(instance_action); group.add_action(login_action); group.add_action(logout_action); group.register_for_widget(&widgets.main_window); ComponentParts { model, widgets } } fn update(&mut self, msg: Self::Input, sender: ComponentSender) { match msg { AppMsg::DoneChoosingInstance(instance_url) => { if instance_url.trim().is_empty() { return; } let mut preferences = settings::get_prefs(); preferences.instance_url = instance_url; settings::save_prefs(&preferences); self.state = AppState::Loading; sender.input(AppMsg::StartFetchPosts(None)); } AppMsg::ChooseInstance => { self.state = AppState::ChooseInstance; } AppMsg::StartFetchPosts(type_) => { std::thread::spawn(move || { let message = match api::posts::list_posts(1, None, type_) { Ok(posts) => AppMsg::DoneFetchPosts(posts), Err(err) => AppMsg::ShowMessage(err.to_string()) }; sender.input(message); }); } AppMsg::DoneFetchPosts(posts) => { self.state = AppState::Posts; self.posts.guard().clear(); for post in posts { self.posts.guard().push_back(post); } } AppMsg::ViewCommunities(query) => { self.state = AppState::Communities; if (query.is_none() || query.clone().unwrap().trim().is_empty()) && !self.communities.is_empty() { return; } std::thread::spawn(move || { let message = match api::communities::fetch_communities(1, query) { Ok(communities) => AppMsg::DoneFetchCommunities(communities), Err(err) => AppMsg::ShowMessage(err.to_string()) }; sender.input(message); }); } AppMsg::DoneFetchCommunities(communities) => { self.state = AppState::Communities; self.communities.guard().clear(); for community in communities { self.communities.guard().push_back(community); } } AppMsg::OpenPerson(person_name) => { self.state = AppState::Loading; std::thread::spawn(move || { let message = match api::user::get_user(person_name, 1) { Ok(person) => AppMsg::DoneFetchPerson(person), Err(err) => AppMsg::ShowMessage(err.to_string()) }; sender.input(message); }); } AppMsg::DoneFetchPerson(person) => { self.profile_page.sender().emit(profile_page::ProfileInput::UpdatePerson(person)); self.state = AppState::Person; } AppMsg::OpenCommunity(community_name) => { self.state = AppState::Loading; std::thread::spawn(move || { let message = match api::community::get_community(community_name) { Ok(community) => AppMsg::DoneFetchCommunity(community), Err(err) => AppMsg::ShowMessage(err.to_string()) }; sender.input(message); }); } AppMsg::DoneFetchCommunity(community) => { self.community_page.sender().emit(community_page::CommunityInput::UpdateCommunity(community.community_view)); self.state = AppState::Community; } AppMsg::OpenPost(post_id) => { self.state = AppState::Loading; std::thread::spawn(move || { let message = match api::post::get_post(post_id) { Ok(post) => AppMsg::DoneFetchPost(post), Err(err) => AppMsg::ShowMessage(err.to_string()) }; sender.input(message); }); } AppMsg::DoneFetchPost(post) => { self.post_page.sender().emit(post_page::PostInput::UpdatePost(post)); self.state = AppState::Post; } AppMsg::ShowLogin => { self.state = AppState::Login; } AppMsg::Login(username, password) => { self.state = AppState::Loading; std::thread::spawn(move || { let message = match api::auth::login(username, password) { Ok(login) => { if let Some(token) = login.jwt { util::set_auth_token(Some(token)); AppMsg::StartFetchPosts(None) } else { AppMsg::ShowMessage("Wrong credentials!".to_string()) } } Err(err) => AppMsg::ShowMessage(err.to_string()) }; sender.input(message); }); } AppMsg::Logout => { util::set_auth_token(None); } AppMsg::ShowMessage(message) => { self.message = Some(message); self.state = AppState::Message; } AppMsg::Retry => { sender.input(self.latest_action.clone().unwrap_or(AppMsg::StartFetchPosts(None))); } } } } relm4::new_action_group!(WindowActionGroup, "win"); relm4::new_stateless_action!(ChangeInstanceAction, WindowActionGroup, "instance"); relm4::new_stateless_action!(LoginAction, WindowActionGroup, "login"); relm4::new_stateless_action!(LogoutAction, WindowActionGroup, "logout"); fn main() { let app = RelmApp::new(APP_ID); set_global_css(include_str!("style.css")); app.run::(()); }