diff --git a/packages/oneclient_app/src/hooks/queries/packages.rs b/packages/oneclient_app/src/hooks/queries/packages.rs index 0900bf57..4b8ceabe 100644 --- a/packages/oneclient_app/src/hooks/queries/packages.rs +++ b/packages/oneclient_app/src/hooks/queries/packages.rs @@ -1,3 +1,5 @@ +use std::time::Duration; + use freya::query::{Query, QueryCapability, UseQuery, use_query}; use oneclient_common::domain::GameLoader; use oneclient_content::packages::types::{ @@ -10,6 +12,12 @@ use oneclient_core::LauncherError; pub const BROWSE_PAGE_SIZE: usize = DEFAULT_PAGE_SIZE; pub const VERSIONS_PAGE_SIZE: usize = 20; +/// A published version list barely moves, and the browser listing now asks for +/// one per visible card to know what "latest" is. Holding them for a while +/// keeps scrolling from refiring a request per card every time one comes back +/// into view. +const VERSIONS_STALE: Duration = Duration::from_secs(5 * 60); + pub fn content_type_for_slug(slug: &str) -> ContentType { match slug { "shader" => ContentType::Shader, @@ -230,16 +238,19 @@ pub fn use_package_versions( loader: Option, page: usize, ) -> UseQuery { - use_query(Query::new( - PackageVersionsKeys { - provider, - project_id, - game_version, - loader, - page, - }, - PackageVersionsQuery, - )) + use_query( + Query::new( + PackageVersionsKeys { + provider, + project_id, + game_version, + loader, + page, + }, + PackageVersionsQuery, + ) + .stale_time(VERSIONS_STALE), + ) } pub fn version_list(query: &UseQuery) -> Vec { diff --git a/packages/oneclient_app/src/view/app/browser/index/cards.rs b/packages/oneclient_app/src/view/app/browser/index/cards.rs index 99f45d13..76804707 100644 --- a/packages/oneclient_app/src/view/app/browser/index/cards.rs +++ b/packages/oneclient_app/src/view/app/browser/index/cards.rs @@ -3,20 +3,22 @@ use super::*; use std::collections::HashMap; use freya::router::RouterContext; -use oneclient_content::packages::ProviderId; use oneclient_content::packages::types::ProjectSummary; +use oneclient_content::packages::{ContentType, ProviderId}; -use crate::components::{Icon, IconType}; +use crate::components::{Button, Icon, IconType}; +use crate::hooks::{ + content_type_for_slug, use_browser_compat, use_cluster, use_dispatch, use_installs_snapshot, + use_package_versions, version_list, +}; use crate::routes::Route; use crate::theme::colors; use crate::ui::border_all_color; type InstalledMap = HashMap<(ProviderId, String), Installed>; -fn installed_for(installed: &InstalledMap, item: &ProjectSummary) -> Option { - installed - .get(&(item.provider, item.id.clone())) - .map(|installed| installed.source) +fn installed_for(installed: &InstalledMap, item: &ProjectSummary) -> Option { + installed.get(&(item.provider, item.id.clone())).cloned() } pub(super) fn grid_row( @@ -27,7 +29,7 @@ pub(super) fn grid_row( ) -> impl IntoElement { let package_type = package_type.to_string(); let fill = GRID_COLUMNS - row.len(); - let installed: Vec> = row + let installed: Vec> = row .iter() .map(|item| installed_for(installed, item)) .collect(); @@ -68,7 +70,7 @@ struct PackageCard { item: ProjectSummary, cluster_id: i64, package_type: String, - installed: Option, + installed: Option, } impl PackageCard { @@ -76,7 +78,7 @@ impl PackageCard { item: ProjectSummary, cluster_id: i64, package_type: String, - installed: Option, + installed: Option, ) -> Self { Self { item, @@ -185,14 +187,23 @@ impl Component for PackageCard { .cross_align(Alignment::Center) .content(Content::Flex) .child(downloads_row(self.item.downloads)) - .maybe_child(self.installed.map(|installed| { + .child( rect() .horizontal() .width(Size::flex(1.0)) + .cross_align(Alignment::Center) .main_align(Alignment::End) - .child(installed_badge(installed, 10.)) - .into_element() - })), + .spacing(6.) + .maybe_child(self.installed.as_ref().map(|installed| { + installed_badge(installed.source, 10.).into_element() + })) + .child(InstallButton::new( + &self.item, + self.cluster_id, + &self.package_type, + self.installed.clone(), + )), + ), ), ) } @@ -217,7 +228,7 @@ struct ListRow { item: ProjectSummary, cluster_id: i64, package_type: String, - installed: Option, + installed: Option, } impl Component for ListRow { @@ -300,9 +311,113 @@ impl Component for ListRow { ) .maybe_child( self.installed - .map(|installed| installed_badge(installed, 11.).into_element()), + .as_ref() + .map(|installed| installed_badge(installed.source, 11.).into_element()), ) .child(downloads_row(item.downloads)) + .child(InstallButton::new( + &self.item, + self.cluster_id, + &self.package_type, + self.installed.clone(), + )) + } +} + +/// Installs a listing entry's latest compatible version without making the user +/// open its page first. Same version pick, same dispatch and same in-flight +/// handling as the install button on the package page. +#[derive(PartialEq)] +struct InstallButton { + provider: ProviderId, + project_id: String, + cluster_id: i64, + content_type: ContentType, + installed: Option, +} + +impl InstallButton { + fn new( + item: &ProjectSummary, + cluster_id: i64, + package_type: &str, + installed: Option, + ) -> Self { + Self { + provider: item.provider, + project_id: item.id.clone(), + cluster_id, + content_type: content_type_for_slug(package_type), + installed, + } + } +} + +impl Component for InstallButton { + fn render(&self) -> impl IntoElement { + let cluster_id = self.cluster_id; + let provider = self.provider; + let project_id = self.project_id.clone(); + + let dispatch = use_dispatch(); + let compat = *use_browser_compat().read(); + let cluster = use_cluster(cluster_id); + + // The same narrowing the package page does before calling the first + // entry of the list "latest", so both agree on what gets installed. + let (game_version, loader) = match (compat, &cluster) { + (true, Some(c)) => ( + Some(c.mc_version.clone()), + (self.content_type == ContentType::Mod).then_some(c.mc_loader), + ), + _ => (None, None), + }; + + let versions = version_list(&use_package_versions( + provider, + project_id.clone(), + game_version, + loader, + 0, + )); + let latest = versions.first().map(|v| v.version_id.clone()); + + let installing = use_installs_snapshot().is_installing(cluster_id, provider, &project_id); + // Nothing to do when the cluster already has exactly this version, and + // nothing to do twice while the install that was just started is running. + let have_latest = match (&self.installed, &latest) { + (Some(installed), Some(latest)) => installed.is_version(latest), + _ => false, + }; + + rect() + // The card behind this is one big press target. A press on the + // button is not a press on the card, so it stops here rather than + // opening the package page on top of the install. + .on_all_press(|e: Event| e.stop_propagation()) + .child( + Button::new() + .secondary() + .small() + .enabled(latest.is_some() && !have_latest && !installing) + .on_press(move |_| { + if let Some(version_id) = latest.clone() { + dispatch.install_package( + cluster_id, + provider, + project_id.clone(), + version_id, + ); + } + }) + .text(if installing { + "Installing..." + } else if have_latest { + "Installed" + } else { + "Install" + }), + ) } } diff --git a/packages/oneclient_app/src/view/app/browser/index/mod.rs b/packages/oneclient_app/src/view/app/browser/index/mod.rs index d2553aa0..b626a141 100644 --- a/packages/oneclient_app/src/view/app/browser/index/mod.rs +++ b/packages/oneclient_app/src/view/app/browser/index/mod.rs @@ -27,7 +27,7 @@ use crate::routes::Route; use crate::theme::colors; use crate::hooks::use_cluster; -use super::{InstallSource, Installed, PackageBanner, Thumbnail, installed_badge, installed_map}; +use super::{Installed, PackageBanner, Thumbnail, installed_badge, installed_map}; use crate::utils::{abbreviate_number, sort_clusters_for_home}; mod cards;