Initial commit

This commit is contained in:
Bnyro 2023-06-18 16:53:53 +02:00
commit 6b8eae051e
25 changed files with 3959 additions and 0 deletions

2
.cargo/config.toml Normal file
View File

@ -0,0 +1,2 @@
[build]
rustflags = ["-C", "link-arg=-fuse-ld=mold"]

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target

2655
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

12
Cargo.toml Normal file
View File

@ -0,0 +1,12 @@
[package]
name = "lemmy-gtk"
version = "0.1.0"
edition = "2021"
[dependencies]
relm4 = "0.6.0"
relm4-components = { version = "0.6.0", features = ["web"] }
reqwest = { version = "0.11.16", features = ["json", "blocking"] }
serde = { version = "1.0.160", features = ["derive"] }
serde_json = "1.0.96"
lemmy_api_common = { git = "https://github.com/LemmyNet/lemmy.git", tag = "0.17.2" }

20
src/api/communities.rs Normal file
View File

@ -0,0 +1,20 @@
use lemmy_api_common::{community::{ListCommunities, ListCommunitiesResponse}, lemmy_db_schema::{SortType, SearchType}, lemmy_db_views_actor::structs::CommunityView};
use crate::components::CLIENT;
use super::search;
pub fn fetch_communities(page: i64, query: Option<String>) -> std::result::Result<Vec<CommunityView>, reqwest::Error> {
if query.is_none() || query.clone().unwrap().trim().is_empty() {
let params = ListCommunities {
sort: Some(SortType::TopMonth),
page: Some(page),
..Default::default()
};
let url = format!("{}/community/list", super::get_api_url());
Ok(CLIENT.get(&url).query(&params).send()?.json::<ListCommunitiesResponse>()?.communities)
} else {
Ok(search::fetch_search(page, query.unwrap(), Some(SearchType::Communities))?.communities)
}
}

17
src/api/community.rs Normal file
View File

@ -0,0 +1,17 @@
use lemmy_api_common::community::{GetCommunity, GetCommunityResponse};
use crate::components::CLIENT;
pub fn get_community(name: String) -> std::result::Result<GetCommunityResponse, reqwest::Error> {
let params = GetCommunity {
name: Some(name),
..Default::default()
};
let url = format!("{}/community", super::get_api_url());
CLIENT.get(&url).query(&params).send()?.json()
}
pub fn default_community() -> GetCommunityResponse {
serde_json::from_str(include_str!("../examples/community.json")).unwrap()
}

14
src/api/mod.rs Normal file
View File

@ -0,0 +1,14 @@
use crate::settings::get_prefs;
pub mod communities;
pub mod community;
pub mod post;
pub mod posts;
pub mod search;
pub mod user;
static API_VERSION: &str = "v3";
pub fn get_api_url() -> String {
format!("{}/api/{}", get_prefs().instance_url, API_VERSION).to_string()
}

34
src/api/post.rs Normal file
View File

@ -0,0 +1,34 @@
use lemmy_api_common::{post::{GetPost, GetPostResponse}, lemmy_db_schema::{newtypes::PostId, CommentSortType, ListingType}, comment::{GetComments, GetCommentsResponse}, lemmy_db_views::structs::CommentView};
use crate::components::CLIENT;
pub fn get_post(id: PostId) -> std::result::Result<GetPostResponse, reqwest::Error> {
let params = GetPost {
id: Some(id),
..Default::default()
};
let url = format!("{}/post", super::get_api_url());
CLIENT.get(&url).query(&params).send()?.json()
}
pub fn get_comments(post_id: PostId) -> std::result::Result<Vec<CommentView>, reqwest::Error> {
let params = GetComments {
post_id: Some(post_id),
sort: Some(CommentSortType::Hot),
type_: Some(ListingType::All),
..Default::default()
};
let url = format!("{}/comment/list", super::get_api_url());
let mut comments = CLIENT.get(&url).query(&params).send()?.json::<GetCommentsResponse>()?.comments;
// hide removed comments
comments.retain(|c| !c.comment.deleted && !c.comment.removed);
Ok(comments)
}
pub fn default_post() -> GetPostResponse {
serde_json::from_str(include_str!("../examples/post.json")).unwrap()
}

14
src/api/posts.rs Normal file
View File

@ -0,0 +1,14 @@
use lemmy_api_common::{post::{GetPostsResponse, GetPosts}, lemmy_db_views::structs::PostView};
use crate::components::CLIENT;
pub fn list_posts(page: i64, community_name: Option<String>) -> std::result::Result<Vec<PostView>, reqwest::Error> {
let params = GetPosts {
page: Some(page),
community_name,
..Default::default()
};
let url = format!("{}/post/list", super::get_api_url());
Ok(CLIENT.get(&url).query(&params).send()?.json::<GetPostsResponse>()?.posts)
}

16
src/api/search.rs Normal file
View File

@ -0,0 +1,16 @@
use lemmy_api_common::{site::{SearchResponse, Search}, lemmy_db_schema::{SortType, SearchType}};
use crate::components::CLIENT;
pub fn fetch_search(page: i64, query: String, search_type: Option<SearchType>) -> std::result::Result<SearchResponse, reqwest::Error> {
let params = Search {
q: query,
sort: Some(SortType::TopMonth),
page: Some(page),
type_: search_type,
..Default::default()
};
let url = format!("{}/search", super::get_api_url());
CLIENT.get(&url).query(&params).send()?.json()
}

17
src/api/user.rs Normal file
View File

@ -0,0 +1,17 @@
use lemmy_api_common::{person::{GetPersonDetailsResponse, GetPersonDetails}};
use crate::components::CLIENT;
pub fn get_user(username: String, page: i64) -> std::result::Result<GetPersonDetailsResponse, reqwest::Error> {
let params = GetPersonDetails {
page: Some(page),
username: Some(username),
..Default::default()
};
let url = format!("{}/user", super::get_api_url());
CLIENT.get(&url).query(&params).send()?.json()
}
pub fn default_person() -> GetPersonDetailsResponse {
serde_json::from_str(include_str!("../examples/person.json")).unwrap()
}

View File

@ -0,0 +1,101 @@
use lemmy_api_common::lemmy_db_views::structs::CommentView;
use relm4::prelude::*;
use gtk::prelude::*;
use relm4_components::web_image::WebImage;
use crate::util::get_web_image_url;
#[derive(Debug)]
pub struct CommentRow {
comment: CommentView,
avatar: Controller<WebImage>
}
#[derive(Debug)]
pub enum CommentRowMsg {
OpenPerson,
}
#[relm4::factory(pub)]
impl FactoryComponent for CommentRow {
type Init = CommentView;
type Input = CommentRowMsg;
type Output = crate::AppMsg;
type CommandOutput = ();
type Widgets = PostViewWidgets;
type ParentInput = crate::AppMsg;
type ParentWidget = gtk::Box;
view! {
root = gtk::Box {
set_orientation: gtk::Orientation::Vertical,
set_spacing: 10,
set_margin_end: 10,
set_margin_start: 10,
set_margin_top: 10,
gtk::Box {
set_orientation: gtk::Orientation::Horizontal,
set_spacing: 10,
set_vexpand: false,
if self.comment.creator.avatar.is_some() {
gtk::Box {
set_hexpand: false,
#[local_ref]
community_image -> gtk::Box {}
}
} else {
gtk::Box {}
},
gtk::Button {
set_label: &self.comment.creator.name,
connect_clicked => CommentRowMsg::OpenPerson,
},
},
gtk::Label {
set_label: &self.comment.comment.content,
set_halign: gtk::Align::Start,
},
gtk::Label {
set_label: &format!("{} score", self.comment.counts.score),
set_halign: gtk::Align::Start,
},
gtk::Separator {}
}
}
fn forward_to_parent(output: Self::Output) -> Option<Self::ParentInput> {
Some(output)
}
fn init_model(value: Self::Init, _index: &DynamicIndex, _sender: FactorySender<Self>) -> Self {
let avatar = WebImage::builder().launch(get_web_image_url(value.community.clone().icon)).detach();
Self { comment: value, avatar }
}
fn init_widgets(
&mut self,
_index: &Self::Index,
root: &Self::Root,
_returned_widget: &<Self::ParentWidget as relm4::factory::FactoryView>::ReturnedWidget,
sender: FactorySender<Self>,
) -> Self::Widgets {
let community_image = self.avatar.widget();
let widgets = view_output!();
widgets
}
fn update(&mut self, message: Self::Input, sender: FactorySender<Self>) {
match message {
CommentRowMsg::OpenPerson => {
sender.output(crate::AppMsg::OpenPerson(self.comment.creator.name.clone()))
}
}
}
}

View File

@ -0,0 +1,123 @@
use lemmy_api_common::{community::GetCommunityResponse, lemmy_db_views::structs::PostView};
use relm4::{prelude::*, factory::FactoryVecDeque};
use gtk::prelude::*;
use relm4_components::web_image::WebImage;
use std::cell::RefCell;
use crate::{api, util::get_web_image_msg};
use super::post_row::PostRow;
pub struct CommunityPage {
info: RefCell<GetCommunityResponse>,
avatar: Controller<WebImage>,
posts: FactoryVecDeque<PostRow>
}
#[derive(Debug)]
pub enum CommunityInput {
UpdateCommunity(GetCommunityResponse),
DoneFetchPosts(Vec<PostView>)
}
#[relm4::component(pub)]
impl SimpleComponent for CommunityPage {
type Init = GetCommunityResponse;
type Input = CommunityInput;
type Output = crate::AppMsg;
view! {
gtk::ScrolledWindow {
set_vexpand: false,
gtk::Box {
set_orientation: gtk::Orientation::Vertical,
set_vexpand: false,
set_margin_all: 10,
#[local_ref]
avatar -> gtk::Box {
set_size_request: (100, 100),
set_margin_bottom: 20,
set_margin_top: 20,
},
gtk::Label {
#[watch]
set_text: &model.info.borrow().community_view.community.name,
add_css_class: "font-very-bold",
},
gtk::Label {
#[watch]
set_text: &model.info.borrow().clone().community_view.community.description.unwrap_or("".to_string()),
},
gtk::Label {
#[watch]
set_text: &format!("{} subscribers, ", model.info.borrow().community_view.counts.subscribers),
},
gtk::Box {
set_orientation: gtk::Orientation::Horizontal,
set_margin_top: 10,
set_margin_bottom: 10,
set_hexpand: false,
set_halign: gtk::Align::Center,
gtk::Label {
#[watch]
set_text: &format!("{} posts, ", model.info.borrow().community_view.counts.posts),
},
gtk::Label {
#[watch]
set_text: &format!("{} comments", model.info.borrow().clone().community_view.counts.comments),
},
},
gtk::Separator {},
#[local_ref]
posts -> gtk::Box {
set_orientation: gtk::Orientation::Vertical,
}
}
}
}
fn init(
init: Self::Init,
root: &Self::Root,
sender: relm4::ComponentSender<Self>,
) -> relm4::ComponentParts<Self> {
let avatar = WebImage::builder().launch("".to_string()).detach();
let posts = FactoryVecDeque::new(gtk::Box::default(), sender.output_sender());
let model = CommunityPage { info: RefCell::new(init), avatar, posts };
let avatar = model.avatar.widget();
let posts = model.posts.widget();
let widgets = view_output!();
ComponentParts { model, widgets }
}
fn update(&mut self, message: Self::Input, sender: ComponentSender<Self>) {
match message {
CommunityInput::UpdateCommunity(community) => {
*self.info.borrow_mut() = community.clone();
self.avatar.emit(get_web_image_msg(community.community_view.community.icon));
self.posts.guard().clear();
std::thread::spawn(move || {
if community.community_view.counts.posts == 0 { return; }
let community_posts = api::posts::list_posts(1, Some(community.community_view.community.name));
if let Ok(community_posts) = community_posts {
sender.input(CommunityInput::DoneFetchPosts(community_posts));
}
});
}
CommunityInput::DoneFetchPosts(posts) => {
for post in posts {
self.posts.guard().push_back(post);
}
}
}
}
}

View File

@ -0,0 +1,102 @@
use lemmy_api_common::lemmy_db_views_actor::structs::CommunityView;
use relm4::prelude::*;
use gtk::prelude::*;
use relm4_components::web_image::WebImage;
use crate::util::get_web_image_url;
#[derive(Debug)]
pub struct CommunityRow {
community: CommunityView,
community_image: Controller<WebImage>,
}
#[derive(Debug)]
pub enum CommunityRowMsg {
OpenCommunity,
}
#[relm4::factory(pub)]
impl FactoryComponent for CommunityRow {
type Init = CommunityView;
type Input = CommunityRowMsg;
type Output = crate::AppMsg;
type CommandOutput = ();
type Widgets = PostViewWidgets;
type ParentInput = crate::AppMsg;
type ParentWidget = gtk::Box;
view! {
root = gtk::Box {
set_orientation: gtk::Orientation::Vertical,
set_spacing: 10,
set_margin_end: 10,
set_margin_start: 10,
set_vexpand: false,
gtk::Box {
set_orientation: gtk::Orientation::Horizontal,
set_spacing: 10,
if self.community.community.icon.is_some() {
gtk::Box {
set_hexpand: false,
#[local_ref]
community_image -> gtk::Box {}
}
} else {
gtk::Box {}
},
gtk::Label {
set_label: &self.community.community.title,
},
gtk::Box {
set_hexpand: true,
},
gtk::Label {
set_label: &format!("{} subscribers, {} posts", self.community.counts.subscribers, self.community.counts.posts),
},
gtk::Button {
set_label: "View",
connect_clicked => CommunityRowMsg::OpenCommunity,
},
},
gtk::Separator {}
}
}
fn forward_to_parent(output: Self::Output) -> Option<Self::ParentInput> {
Some(output)
}
fn init_model(value: Self::Init, _index: &DynamicIndex, _sender: FactorySender<Self>) -> Self {
let community_image= WebImage::builder().launch(get_web_image_url(value.community.clone().icon)).detach();
Self { community: value, community_image }
}
fn init_widgets(
&mut self,
_index: &Self::Index,
root: &Self::Root,
_returned_widget: &<Self::ParentWidget as relm4::factory::FactoryView>::ReturnedWidget,
sender: FactorySender<Self>,
) -> Self::Widgets {
let community_image = self.community_image.widget();
let widgets = view_output!();
widgets
}
fn update(&mut self, message: Self::Input, sender: FactorySender<Self>) {
match message {
CommunityRowMsg::OpenCommunity => {
sender.output(crate::AppMsg::OpenCommunity(self.community.community.name.clone()))
}
}
}
}

13
src/components/mod.rs Normal file
View File

@ -0,0 +1,13 @@
pub mod post_row;
pub mod community_row;
pub mod profile_page;
pub mod community_page;
pub mod post_page;
pub mod comment_row;
use reqwest::blocking::Client;
use relm4::once_cell::sync::Lazy;
pub static CLIENT: Lazy<Client> = Lazy::new(|| {
Client::new()
});

204
src/components/post_page.rs Normal file
View File

@ -0,0 +1,204 @@
use lemmy_api_common::{lemmy_db_views::structs::{CommentView}, post::GetPostResponse};
use relm4::{prelude::*, factory::FactoryVecDeque};
use gtk::prelude::*;
use relm4_components::web_image::WebImage;
use std::cell::RefCell;
use crate::{api, util::{get_web_image_msg, get_web_image_url}};
use super::comment_row::CommentRow;
pub struct PostPage {
info: RefCell<GetPostResponse>,
image: Controller<WebImage>,
creator_avatar: Controller<WebImage>,
community_avatar: Controller<WebImage>,
comments: FactoryVecDeque<CommentRow>
}
#[derive(Debug)]
pub enum PostInput {
UpdatePost(GetPostResponse),
DoneFetchComments(Vec<CommentView>),
OpenPerson,
OpenCommunity,
OpenLink
}
#[relm4::component(pub)]
impl SimpleComponent for PostPage {
type Init = GetPostResponse;
type Input = PostInput;
type Output = crate::AppMsg;
view! {
gtk::ScrolledWindow {
gtk::Box {
set_orientation: gtk::Orientation::Vertical,
set_vexpand: false,
set_margin_all: 10,
#[local_ref]
image -> gtk::Box {
set_height_request: 100,
set_margin_bottom: 20,
set_margin_top: 20,
},
gtk::Label {
#[watch]
set_text: &model.info.borrow().post_view.post.name,
add_css_class: "font-very-bold",
},
gtk::Label {
#[watch]
set_text: &model.info.borrow().clone().post_view.post.body.unwrap_or("".to_string()),
set_margin_top: 10,
},
gtk::Box {
set_orientation: gtk::Orientation::Horizontal,
set_margin_top: 10,
set_spacing: 10,
set_vexpand: false,
gtk::Label {
set_text: "posted by "
},
if model.info.borrow().post_view.creator.avatar.is_some() {
gtk::Box {
set_hexpand: false,
set_margin_start: 10,
#[local_ref]
creator_avatar -> gtk::Box {}
}
} else {
gtk::Box {}
},
gtk::Button {
set_label: &model.info.borrow().post_view.creator.name,
connect_clicked => PostInput::OpenPerson,
},
gtk::Label {
set_text: " in "
},
if model.info.borrow().community_view.community.icon.is_some() {
gtk::Box {
set_hexpand: false,
#[local_ref]
community_avatar -> gtk::Box {}
}
} else {
gtk::Box {}
},
gtk::Button {
set_label: &model.info.borrow().community_view.community.title,
connect_clicked => PostInput::OpenCommunity,
},
gtk::Box {
set_hexpand: true,
},
gtk::Button {
set_label: "View",
connect_clicked => PostInput::OpenLink,
}
},
gtk::Box {
set_orientation: gtk::Orientation::Horizontal,
set_margin_top: 10,
set_margin_bottom: 10,
gtk::Label {
#[watch]
set_text: &format!("{} comments, ", model.info.borrow().post_view.counts.comments),
},
gtk::Label {
#[watch]
set_text: &format!("{} score", model.info.borrow().post_view.counts.score),
},
},
gtk::Separator {},
#[local_ref]
comments -> gtk::Box {
set_orientation: gtk::Orientation::Vertical,
}
}
}
}
fn init(
init: Self::Init,
root: &Self::Root,
sender: relm4::ComponentSender<Self>,
) -> relm4::ComponentParts<Self> {
let image = WebImage::builder().launch("".to_string()).detach();
let comments = FactoryVecDeque::new(gtk::Box::default(), sender.output_sender());
let creator_avatar = WebImage::builder().launch("".to_string()).detach();
let community_avatar = WebImage::builder().launch("".to_string()).detach();
let model = PostPage { info: RefCell::new(init), image, comments, creator_avatar, community_avatar };
let image = model.image.widget();
let comments = model.comments.widget();
let creator_avatar = model.creator_avatar.widget();
let community_avatar = model.community_avatar.widget();
let widgets = view_output!();
ComponentParts { model, widgets }
}
fn update(&mut self, message: Self::Input, sender: ComponentSender<Self>) {
match message {
PostInput::UpdatePost(post) => {
*self.info.borrow_mut() = post.clone();
self.image.emit(get_web_image_msg(post.post_view.post.thumbnail_url));
self.community_avatar.emit(get_web_image_msg(post.community_view.community.icon));
self.creator_avatar.emit(get_web_image_msg(post.post_view.creator.avatar));
self.comments.guard().clear();
std::thread::spawn(move || {
if post.post_view.counts.comments == 0 { return; }
let comments = api::post::get_comments(post.post_view.post.id);
if let Ok(comments) = comments {
sender.input(PostInput::DoneFetchComments(comments));
}
});
}
PostInput::DoneFetchComments(comments) => {
for comment in comments {
self.comments.guard().push_back(comment);
}
}
PostInput::OpenPerson => {
let name = self.info.borrow().post_view.creator.name.clone();
let _ = sender.output(crate::AppMsg::OpenPerson(name));
}
PostInput::OpenCommunity => {
let community_name = self.info.borrow().community_view.community.name.clone();
let _ = sender.output(crate::AppMsg::OpenCommunity(community_name));
}
PostInput::OpenLink => {
let post = self.info.borrow().post_view.post.clone();
let mut link = get_web_image_url(post.url);
if link.is_empty() {
link = get_web_image_url(post.thumbnail_url);
}
if link.is_empty() {
link = get_web_image_url(post.embed_video_url);
}
if link.is_empty() { return; }
gtk::show_uri(None::<&relm4::gtk::Window>, &link, 0);
}
}
}
}

140
src/components/post_row.rs Normal file
View File

@ -0,0 +1,140 @@
use lemmy_api_common::lemmy_db_views::structs::PostView;
use relm4::prelude::*;
use gtk::prelude::*;
use relm4_components::web_image::WebImage;
use crate::util::get_web_image_url;
#[derive(Debug)]
pub struct PostRow {
post: PostView,
author_image: Controller<WebImage>,
community_image: Controller<WebImage>,
}
#[derive(Debug)]
pub enum PostViewMsg {
OpenPost,
OpenCommunity,
OpenPerson
}
#[relm4::factory(pub)]
impl FactoryComponent for PostRow {
type Init = PostView;
type Input = PostViewMsg;
type Output = crate::AppMsg;
type CommandOutput = ();
type Widgets = PostViewWidgets;
type ParentInput = crate::AppMsg;
type ParentWidget = gtk::Box;
view! {
root = gtk::Box {
set_orientation: gtk::Orientation::Vertical,
set_spacing: 10,
set_margin_end: 10,
set_margin_start: 10,
gtk::Box {
set_orientation: gtk::Orientation::Horizontal,
set_margin_top: 10,
set_spacing: 10,
set_vexpand: false,
set_hexpand: true,
if self.post.community.icon.clone().is_some() {
gtk::Box {
set_hexpand: false,
#[local_ref]
community_image -> gtk::Box {}
}
} else {
gtk::Box {}
},
gtk::Button {
set_label: &self.post.community.title,
connect_clicked => PostViewMsg::OpenCommunity,
},
if self.post.creator.avatar.clone().is_some() {
gtk::Box {
set_hexpand: false,
set_margin_start: 10,
#[local_ref]
author_image -> gtk::Box {}
}
} else {
gtk::Box {}
},
gtk::Button {
set_label: &self.post.creator.name,
connect_clicked => PostViewMsg::OpenPerson,
},
gtk::Box {
set_hexpand: true,
},
gtk::Button {
set_label: "View",
set_margin_end: 10,
connect_clicked => PostViewMsg::OpenPost,
}
},
gtk::Label {
set_halign: gtk::Align::Start,
set_text: &self.post.post.name,
add_css_class: "font-bold",
},
gtk::Label {
set_halign: gtk::Align::Start,
set_text: &format!("{} score, {} comments", self.post.counts.score, self.post.clone().counts.comments),
},
gtk::Separator {
set_margin_top: 10,
}
}
}
fn forward_to_parent(output: Self::Output) -> Option<Self::ParentInput> { Some(output) }
fn init_model(value: Self::Init, _index: &DynamicIndex, _sender: FactorySender<Self>) -> Self {
let author_image= WebImage::builder().launch(get_web_image_url(value.creator.clone().avatar)).detach();
let community_image= WebImage::builder().launch(get_web_image_url(value.creator.clone().avatar)).detach();
Self { post: value, author_image, community_image }
}
fn init_widgets(
&mut self,
_index: &Self::Index,
root: &Self::Root,
_returned_widget: &<Self::ParentWidget as relm4::factory::FactoryView>::ReturnedWidget,
sender: FactorySender<Self>,
) -> Self::Widgets {
let author_image = self.author_image.widget();
let community_image = self.community_image.widget();
let widgets = view_output!();
widgets
}
fn update(&mut self, message: Self::Input, sender: FactorySender<Self>) {
match message {
PostViewMsg::OpenCommunity => {
sender.output(crate::AppMsg::OpenCommunity(self.post.community.name.clone()))
}
PostViewMsg::OpenPerson => {
sender.output(crate::AppMsg::OpenPerson(self.post.creator.name.clone()))
}
PostViewMsg::OpenPost => {
sender.output(crate::AppMsg::OpenPost(self.post.post.id.clone()))
}
}
}
}

View File

@ -0,0 +1,106 @@
use lemmy_api_common::person::GetPersonDetailsResponse;
use relm4::{prelude::*, factory::FactoryVecDeque};
use gtk::prelude::*;
use relm4_components::web_image::WebImage;
use std::cell::RefCell;
use crate::util::get_web_image_msg;
use super::post_row::PostRow;
pub struct ProfilePage {
info: RefCell<GetPersonDetailsResponse>,
avatar: Controller<WebImage>,
posts: FactoryVecDeque<PostRow>
}
#[derive(Debug)]
pub enum ProfileInput {
UpdatePerson(GetPersonDetailsResponse),
}
#[relm4::component(pub)]
impl SimpleComponent for ProfilePage {
type Init = GetPersonDetailsResponse;
type Input = ProfileInput;
type Output = crate::AppMsg;
view! {
gtk::ScrolledWindow {
gtk::Box {
set_orientation: gtk::Orientation::Vertical,
set_vexpand: false,
set_margin_all: 10,
#[local_ref]
avatar -> gtk::Box {
set_size_request: (100, 100),
set_margin_bottom: 20,
set_margin_top: 20,
},
gtk::Label {
#[watch]
set_text: &model.info.borrow().person_view.person.name,
add_css_class: "font-very-bold",
},
gtk::Label {
#[watch]
set_text: &model.info.borrow().clone().person_view.person.bio.unwrap_or("".to_string()),
},
gtk::Box {
set_orientation: gtk::Orientation::Horizontal,
set_margin_top: 10,
set_margin_bottom: 10,
set_hexpand: false,
set_halign: gtk::Align::Center,
gtk::Label {
#[watch]
set_text: &format!("{} posts, ", model.info.borrow().person_view.counts.post_count),
},
gtk::Label {
#[watch]
set_text: &format!("{} comments", model.info.borrow().person_view.counts.comment_count),
},
},
gtk::Separator {},
#[local_ref]
posts -> gtk::Box {
set_orientation: gtk::Orientation::Vertical,
}
}
}
}
fn init(
init: Self::Init,
root: &Self::Root,
sender: relm4::ComponentSender<Self>,
) -> relm4::ComponentParts<Self> {
let avatar = WebImage::builder().launch("".to_string()).detach();
let posts = FactoryVecDeque::new(gtk::Box::default(), sender.output_sender());
let model = ProfilePage { info: RefCell::new(init), avatar, posts };
let avatar = model.avatar.widget();
let posts = model.posts.widget();
let widgets = view_output!();
ComponentParts { model, widgets }
}
fn update(&mut self, message: Self::Input, _sender: ComponentSender<Self>) {
match message {
ProfileInput::UpdatePerson(person) => {
*self.info.borrow_mut() = person.clone();
self.avatar.emit(get_web_image_msg(person.person_view.person.avatar));
self.posts.guard().clear();
for post in person.posts {
self.posts.guard().push_back(post);
}
}
}
}
}

File diff suppressed because one or more lines are too long

1
src/examples/person.json Normal file

File diff suppressed because one or more lines are too long

1
src/examples/post.json Normal file

File diff suppressed because one or more lines are too long

311
src/main.rs Normal file
View File

@ -0,0 +1,311 @@
pub mod settings;
pub mod api;
pub mod components;
pub mod util;
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, post::GetPostResponse, community::GetCommunityResponse};
use relm4::{prelude::*, factory::FactoryVecDeque, set_global_css};
static APP_ID: &str = "com.lemmy-gtk.lemoa";
#[derive(Debug, Clone, Copy)]
enum AppState {
Loading,
Posts,
ChooseInstance,
Communities,
Community,
Person,
Post
}
struct App {
state: AppState,
posts: FactoryVecDeque<PostRow>,
communities: FactoryVecDeque<CommunityRow>,
profile_page: Controller<ProfilePage>,
community_page: Controller<CommunityPage>,
post_page: Controller<PostPage>
}
#[derive(Debug)]
pub enum AppMsg {
ChooseInstance,
DoneChoosingInstance(String),
StartFetchPosts,
DoneFetchPosts(Result<Vec<PostView>, reqwest::Error>),
DoneFetchCommunities(Result<Vec<CommunityView>, reqwest::Error>),
ViewCommunities(Option<String>),
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! {
gtk::Window {
set_title: Some("Lemoa"),
set_default_size: (300, 100),
#[wrap(Some)]
set_titlebar = &gtk::HeaderBar {
pack_end = match model.state {
AppState::ChooseInstance => {
&gtk::Box {}
}
_ => {
&gtk::Button {
set_label: "Reset",
connect_clicked => AppMsg::ChooseInstance,
}
}
},
pack_start = &gtk::Button {
set_label: "Posts",
connect_clicked => AppMsg::StartFetchPosts,
},
pack_start = &gtk::Button {
set_label: "Communities",
connect_clicked => AppMsg::ViewCommunities(None),
},
},
#[name(stack)]
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.buffer().text().as_str().to_string();
instance_url.buffer().set_text("");
sender.input(AppMsg::DoneChoosingInstance(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.buffer().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 {}
}
}
}
}
}
// Initialize the component.
fn init(
_init: Self::Init,
root: &Self::Root,
sender: ComponentSender<Self>,
) -> ComponentParts<Self> {
let instance_url = settings::get_prefs().instance_url;
let state = if instance_url.is_empty() { AppState::ChooseInstance } else { AppState::Loading };
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()).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 };
if !instance_url.is_empty() { sender.input(AppMsg::StartFetchPosts) };
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!();
ComponentParts { model, widgets }
}
fn update(&mut self, msg: Self::Input, sender: ComponentSender<Self>) {
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);
}
AppMsg::ChooseInstance => {
self.state = AppState::ChooseInstance;
}
AppMsg::StartFetchPosts => {
std::thread::spawn(move || {
let posts = api::posts::list_posts(1, None);
sender.input(AppMsg::DoneFetchPosts(posts));
});
}
AppMsg::DoneFetchPosts(posts) => {
self.state = AppState::Posts;
if let Ok(posts) = 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 communities = api::communities::fetch_communities(1, query);
sender.input(AppMsg::DoneFetchCommunities(communities));
});
}
AppMsg::DoneFetchCommunities(communities) => {
self.state = AppState::Communities;
if let Ok(communities) = 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 person = api::user::get_user(person_name, 1);
if let Ok(person) = person {
sender.input(AppMsg::DoneFetchPerson(person));
}
});
}
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 community = api::community::get_community(community_name);
if let Ok(community) = community {
sender.input(AppMsg::DoneFetchCommunity(community));
}
});
}
AppMsg::DoneFetchCommunity(community) => {
self.community_page.sender().emit(community_page::CommunityInput::UpdateCommunity(community));
self.state = AppState::Community;
}
AppMsg::OpenPost(post_id) => {
self.state = AppState::Loading;
std::thread::spawn(move || {
let post = api::post::get_post(post_id);
if let Ok(post) = post {
sender.input(AppMsg::DoneFetchPost(post));
}
});
}
AppMsg::DoneFetchPost(post) => {
self.post_page.sender().emit(post_page::PostInput::UpdatePost(post));
self.state = AppState::Post;
}
}
}
}
fn main() {
let app = RelmApp::new(APP_ID);
set_global_css(include_str!("style.css"));
app.run::<App>(());
}

33
src/settings.rs Normal file
View File

@ -0,0 +1,33 @@
use std::{fs::File, path::PathBuf};
use crate::gtk::glib;
use serde::{Deserialize, Serialize};
use crate::APP_ID;
#[derive(Deserialize, Serialize, Default)]
pub struct Preferences {
pub instance_url: String,
}
pub fn data_path() -> PathBuf {
let mut path = glib::user_data_dir();
path.push(APP_ID);
std::fs::create_dir_all(&path).expect("Could not create directory.");
path.push("data.json");
path
}
pub fn save_prefs(prefs: &Preferences) {
let file = File::create(data_path()).expect("Could not create json file.");
serde_json::to_writer(file, &prefs).expect("Could not write data to json file");
}
pub fn get_prefs() -> Preferences {
if let Ok(file) = File::open(data_path()) {
// Deserialize data from file to vector
let prefs: Result<Preferences, serde_json::Error> = serde_json::from_reader(file);
if prefs.is_ok() {
return prefs.unwrap();
}
}
return Preferences::default();
}

7
src/style.css Normal file
View File

@ -0,0 +1,7 @@
.font-bold {
font-size: 1.2rem;
}
.font-very-bold {
font-size: 2rem;
}

14
src/util.rs Normal file
View File

@ -0,0 +1,14 @@
use lemmy_api_common::lemmy_db_schema::newtypes::DbUrl;
use relm4_components::web_image::WebImageMsg;
pub fn get_web_image_msg(url: Option<DbUrl>) -> WebImageMsg {
return if let Some(url) = url {
WebImageMsg::LoadImage(url.to_string())
} else { WebImageMsg::Unload };
}
pub fn get_web_image_url(url: Option<DbUrl>) -> String {
return if let Some(url) = url {
url.to_string()
} else { String::from("") }
}