diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 63a1f7da37..e83ccd8403 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -17,4 +17,5 @@ by The Pixelify Sans Project Authors, licensed under the SIL Open Font License 1 `crates/horizon-theme/icons/pixel/*.svg` are icons from [Pixelarticons](https://github.com/halfmage/pixelarticons) by Gerrit Halfmann, under the MIT -license (`crates/horizon-theme/icons/pixel/LICENSE-pixelarticons.txt`). +license (`crates/horizon-theme/icons/pixel/LICENSE-pixelarticons.txt`). `star.svg` is Horizon's own, drawn on +the same grid. diff --git a/crates/horizon-theme/icons/pixel/star.svg b/crates/horizon-theme/icons/pixel/star.svg new file mode 100644 index 0000000000..a27348730a --- /dev/null +++ b/crates/horizon-theme/icons/pixel/star.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/horizon-theme/src/lib.rs b/crates/horizon-theme/src/lib.rs index dac5bf8d28..ca4704ead1 100644 --- a/crates/horizon-theme/src/lib.rs +++ b/crates/horizon-theme/src/lib.rs @@ -67,6 +67,8 @@ pub mod icons { "search", "server", "sliders", + // Not Pixelarticons': drawn for Horizon's ratings, in the same 2-unit grid. + "star", "terminal", "trash", "user", diff --git a/launcher/src/browser/horizon.rs b/launcher/src/browser/horizon.rs index 4799e7716c..8c2bc39d52 100644 --- a/launcher/src/browser/horizon.rs +++ b/launcher/src/browser/horizon.rs @@ -8,6 +8,7 @@ use super::{ download, installed, java, FileRef, Kind, Project, Query, Source, SourceId, Version, GAME, }; use horizon_mods::index::{self, Index, Trust}; +use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; @@ -27,6 +28,11 @@ const FRESH: Duration = Duration::from_secs(600); const MAX_INDEX: u64 = 16 << 20; /// The last index fetched, kept in the game folder for when the registry can't be reached. const CACHE: &str = "mod-browser/horizon-index.json"; +/// Players' ratings of every project (the registry service, crates/horizon-mod-registry). +const RATINGS_URL: &str = "https://playhorizon.dev/api/mods/ratings"; +/// Where a player rates a project: the site's GitHub sign-in, which comes back to its page. +/// The launcher has no GitHub account of its own. +const RATE: &str = "https://playhorizon.dev/api/mods/auth/login?back=/mods/"; /// Where the index comes from: `HORIZON_MOD_INDEX` (a file or an `https://` address, for a /// test registry), else [`INDEX_URL`]. @@ -110,6 +116,92 @@ fn fetch_index() -> Result<(Index, String), String> { Ok((index, text)) } +/// A project's ratings: 1 to 5 stars, their mean and how many. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Rating { + pub average: f32, + pub count: u64, +} + +/// When the ratings were fetched last, and every rated project's [`Rating`] (None: that failed). +type Ratings = Option>>; +static RATINGS: Mutex> = Mutex::new(None); + +/// A project's rating as fetched last, without asking the network: None when the ratings +/// aren't known (not fetched, or the service didn't answer), `Some(None)` when it has none. +pub fn rating(id: &str) -> Option> { + let ratings = RATINGS.lock().unwrap(); + let known = ratings.as_ref()?.1.as_ref()?; + Some(known.get(id).copied()) +} + +/// The page that signs the player in with GitHub on the site and lets them rate `id`. +pub fn rate_url(id: &str) -> String { + format!("{RATE}{id}") +} + +/// Fetches the ratings again when the last try is ten minutes old: from `HORIZON_MOD_RATINGS` +/// (a file or an `https://` address, for a test registry), else [`RATINGS_URL`]; a local test +/// registry has none unless given. After a failure the browser shows downloads only. +fn refresh_ratings() { + if RATINGS + .lock() + .unwrap() + .as_ref() + .is_some_and(|(at, _)| at.elapsed() < FRESH) + { + return; + } + let given = std::env::var("HORIZON_MOD_RATINGS") + .ok() + .filter(|v| !v.is_empty()); + if given.is_none() && local() { + return; + } + let at = given.unwrap_or_else(|| RATINGS_URL.to_owned()); + let text = if at.starts_with("https://") { + download::agent().get(&at).call().ok().and_then(|r| { + use std::io::Read; + let mut text = String::new(); + r.into_reader() + .take(MAX_INDEX) + .read_to_string(&mut text) + .ok() + .map(|_| text) + }) + } else { + fs::read_to_string(&at).ok() + }; + let fetched = text.map(|t| Arc::new(parse_ratings(&t))); + *RATINGS.lock().unwrap() = Some((Instant::now(), fetched)); +} + +/// `{"projects": {"": {"average", "count"}}}`, keeping only well-formed entries. +fn parse_ratings(text: &str) -> HashMap { + let Ok(json) = serde_json::from_str::(text) else { + return HashMap::new(); + }; + let Some(projects) = json["projects"].as_object() else { + return HashMap::new(); + }; + projects + .iter() + .filter_map(|(id, r)| { + let average = r["average"].as_f64().filter(|a| (1.0..=5.0).contains(a))?; + let count = r["count"].as_u64().filter(|&c| c > 0)?; + index::valid_id(id).then(|| { + ( + id.clone(), + Rating { + average: average as f32, + count, + }, + ) + }) + }) + .collect() +} + /// The browser's kind for a registry kind; None for one the launcher doesn't install yet /// (kinds newer than this launcher). fn kind_of(kind: &index::Kind) -> Option { @@ -245,7 +337,9 @@ impl Source for Registry { } fn search(&self, query: &Query) -> Result, String> { - Ok(search(&*index()?, query)) + let found = search(&*index()?, query); + refresh_ratings(); + Ok(found) } fn project(&self, id: &str) -> Result { @@ -275,6 +369,7 @@ impl Source for Registry { } fn details(&self, project: &Project) -> Result { + refresh_ratings(); let index = index()?; let p = index .project(&project.id) diff --git a/launcher/src/browser/horizon/tests.rs b/launcher/src/browser/horizon/tests.rs index bed79562f4..dd80d0b1d4 100644 --- a/launcher/src/browser/horizon/tests.rs +++ b/launcher/src/browser/horizon/tests.rs @@ -228,3 +228,31 @@ fn versions_for_a_newer_horizon_are_not_installed() { assert_eq!(v.blocked.as_deref(), Some(NEEDS_NEWER)); assert_eq!(version_of(&index, p, &p.versions[0], Some(1)).blocked, None); } + +#[test] +fn ratings_keep_only_what_is_well_formed() { + let text = r#"{"projects": { + "zoom": {"average": 4.25, "count": 12}, + "minimap": {"average": 9, "count": 3}, + "keystrokes": {"average": 3, "count": 0}, + "Bad Id": {"average": 4, "count": 1}, + "fps_overlay": {"average": "5", "count": 1} + }}"#; + let ratings = parse_ratings(text); + assert_eq!( + ratings.into_iter().collect::>(), + [( + "zoom".to_owned(), + Rating { + average: 4.25, + count: 12 + } + )] + ); + assert!(parse_ratings("not json").is_empty()); + assert!(parse_ratings(r#"{"projects": []}"#).is_empty()); + assert_eq!( + rate_url("zoom"), + "https://playhorizon.dev/api/mods/auth/login?back=/mods/zoom" + ); +} diff --git a/launcher/src/mods_page.rs b/launcher/src/mods_page.rs index 44f8998546..f74fc73c1f 100644 --- a/launcher/src/mods_page.rs +++ b/launcher/src/mods_page.rs @@ -175,6 +175,34 @@ fn count(n: u64) -> String { } } +/// A registry project's rating: five pixel stars, the mean's nearest whole number of them lit, the +/// mean and how many; None while the ratings aren't known. +pub(super) fn stars(id: &str, t: &theme::Theme) -> Option
{ + let row = div().flex().items_center().gap(px(2.)); + Some(match horizon::rating(id)? { + None => row.text_color(t.muted).child("No ratings yet"), + Some(r) => { + let lit = r.average.round() as usize; + row.children((0..5).map(|i| { + blocks::icon( + "star", + 12., + if i < lit { + t.warn + } else { + t.muted.opacity(0.6) + }, + ) + })) + .child(div().ml(px(4.)).child(format!( + "{:.1} ({})", + r.average, + count(r.count) + ))) + } + }) +} + /// A checksum as the page shows it: the algorithm and the first and last hex digits. fn short_sum(checksum: &str) -> String { let (algo, hex) = checksum.split_once(':').unwrap_or(("", checksum)); @@ -978,6 +1006,11 @@ impl Launcher { .flex_col() .child(div().truncate().child(p.summary.clone())) .child(by.join(" ยท ")) + .children( + (p.source == SourceId::Horizon) + .then(|| stars(&p.id, &t)) + .flatten(), + ) .children( support .reason() diff --git a/launcher/src/mods_project.rs b/launcher/src/mods_project.rs index 97d23e0aa2..72a95b142c 100644 --- a/launcher/src/mods_project.rs +++ b/launcher/src/mods_project.rs @@ -491,6 +491,21 @@ impl Launcher { .gap(px(24.)) .child(div().flex().child(back)) .child(head); + // Registry projects are rated on the site, signed in with GitHub (one rating per account). + if p.source == SourceId::Horizon { + let url = browser::horizon::rate_url(&p.id); + page = page.child( + div() + .flex() + .items_center() + .gap(px(16.)) + .children(super::mods_page::stars(&p.id, &t).map(|s| text(theme::UI).child(s))) + .child( + button("mods-rate", "Sign in with GitHub to rate", Kind::Ghost, &t) + .on_click(move |_, _, cx| cx.open_url(&url)), + ), + ); + } if let Some(why) = support.reason() { page = page.child( text(theme::UI)