Launcher: registry mods show their 1-5 star rating
Horizon results and project pages show a project's rating from the registry service (GET playhorizon.dev/api/mods/ratings, fetched with the results, kept ten minutes): five pixel stars, the mean and how many, or "No ratings yet". Nothing shows when the service doesn't answer. The launcher has no GitHub account, so a project's page offers "Sign in with GitHub to rate": it opens the site's sign-in, which comes back to the project's page and its stars (one rating per GitHub account). HORIZON_MOD_RATINGS (a file or https:// address) points a test registry at its own ratings. The star icon is drawn for Horizon on the icons' grid. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
`crates/horizon-theme/icons/pixel/*.svg` are icons from
|
||||||
[Pixelarticons](https://github.com/halfmage/pixelarticons) by Gerrit Halfmann, under the MIT
|
[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.
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M10 1h4v2h-4ZM10 3h4v2h-4ZM8 5h8v2h-8ZM0 7h24v2h-24ZM2 9h20v2h-20ZM4 11h16v2h-16ZM6 13h12v2h-12ZM6 15h12v2h-12ZM4 17h6v2h-6ZM14 17h6v2h-6ZM4 19h4v2h-4ZM16 19h4v2h-4ZM2 21h4v2h-4ZM18 21h4v2h-4Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 318 B |
@@ -67,6 +67,8 @@ pub mod icons {
|
|||||||
"search",
|
"search",
|
||||||
"server",
|
"server",
|
||||||
"sliders",
|
"sliders",
|
||||||
|
// Not Pixelarticons': drawn for Horizon's ratings, in the same 2-unit grid.
|
||||||
|
"star",
|
||||||
"terminal",
|
"terminal",
|
||||||
"trash",
|
"trash",
|
||||||
"user",
|
"user",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use super::{
|
|||||||
download, installed, java, FileRef, Kind, Project, Query, Source, SourceId, Version, GAME,
|
download, installed, java, FileRef, Kind, Project, Query, Source, SourceId, Version, GAME,
|
||||||
};
|
};
|
||||||
use horizon_mods::index::{self, Index, Trust};
|
use horizon_mods::index::{self, Index, Trust};
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
@@ -27,6 +28,11 @@ const FRESH: Duration = Duration::from_secs(600);
|
|||||||
const MAX_INDEX: u64 = 16 << 20;
|
const MAX_INDEX: u64 = 16 << 20;
|
||||||
/// The last index fetched, kept in the game folder for when the registry can't be reached.
|
/// 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";
|
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
|
/// Where the index comes from: `HORIZON_MOD_INDEX` (a file or an `https://` address, for a
|
||||||
/// test registry), else [`INDEX_URL`].
|
/// test registry), else [`INDEX_URL`].
|
||||||
@@ -110,6 +116,92 @@ fn fetch_index() -> Result<(Index, String), String> {
|
|||||||
Ok((index, text))
|
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<Arc<HashMap<String, Rating>>>;
|
||||||
|
static RATINGS: Mutex<Option<(Instant, Ratings)>> = 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<Option<Rating>> {
|
||||||
|
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": {"<id>": {"average", "count"}}}`, keeping only well-formed entries.
|
||||||
|
fn parse_ratings(text: &str) -> HashMap<String, Rating> {
|
||||||
|
let Ok(json) = serde_json::from_str::<serde_json::Value>(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
|
/// The browser's kind for a registry kind; None for one the launcher doesn't install yet
|
||||||
/// (kinds newer than this launcher).
|
/// (kinds newer than this launcher).
|
||||||
fn kind_of(kind: &index::Kind) -> Option<Kind> {
|
fn kind_of(kind: &index::Kind) -> Option<Kind> {
|
||||||
@@ -245,7 +337,9 @@ impl Source for Registry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn search(&self, query: &Query) -> Result<Vec<Project>, String> {
|
fn search(&self, query: &Query) -> Result<Vec<Project>, String> {
|
||||||
Ok(search(&*index()?, query))
|
let found = search(&*index()?, query);
|
||||||
|
refresh_ratings();
|
||||||
|
Ok(found)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn project(&self, id: &str) -> Result<Project, String> {
|
fn project(&self, id: &str) -> Result<Project, String> {
|
||||||
@@ -275,6 +369,7 @@ impl Source for Registry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn details(&self, project: &Project) -> Result<Details, String> {
|
fn details(&self, project: &Project) -> Result<Details, String> {
|
||||||
|
refresh_ratings();
|
||||||
let index = index()?;
|
let index = index()?;
|
||||||
let p = index
|
let p = index
|
||||||
.project(&project.id)
|
.project(&project.id)
|
||||||
|
|||||||
@@ -228,3 +228,31 @@ fn versions_for_a_newer_horizon_are_not_installed() {
|
|||||||
assert_eq!(v.blocked.as_deref(), Some(NEEDS_NEWER));
|
assert_eq!(v.blocked.as_deref(), Some(NEEDS_NEWER));
|
||||||
assert_eq!(version_of(&index, p, &p.versions[0], Some(1)).blocked, None);
|
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::<Vec<_>>(),
|
||||||
|
[(
|
||||||
|
"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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<Div> {
|
||||||
|
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.
|
/// A checksum as the page shows it: the algorithm and the first and last hex digits.
|
||||||
fn short_sum(checksum: &str) -> String {
|
fn short_sum(checksum: &str) -> String {
|
||||||
let (algo, hex) = checksum.split_once(':').unwrap_or(("", checksum));
|
let (algo, hex) = checksum.split_once(':').unwrap_or(("", checksum));
|
||||||
@@ -978,6 +1006,11 @@ impl Launcher {
|
|||||||
.flex_col()
|
.flex_col()
|
||||||
.child(div().truncate().child(p.summary.clone()))
|
.child(div().truncate().child(p.summary.clone()))
|
||||||
.child(by.join(" · "))
|
.child(by.join(" · "))
|
||||||
|
.children(
|
||||||
|
(p.source == SourceId::Horizon)
|
||||||
|
.then(|| stars(&p.id, &t))
|
||||||
|
.flatten(),
|
||||||
|
)
|
||||||
.children(
|
.children(
|
||||||
support
|
support
|
||||||
.reason()
|
.reason()
|
||||||
|
|||||||
@@ -491,6 +491,21 @@ impl Launcher {
|
|||||||
.gap(px(24.))
|
.gap(px(24.))
|
||||||
.child(div().flex().child(back))
|
.child(div().flex().child(back))
|
||||||
.child(head);
|
.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() {
|
if let Some(why) = support.reason() {
|
||||||
page = page.child(
|
page = page.child(
|
||||||
text(theme::UI)
|
text(theme::UI)
|
||||||
|
|||||||
Reference in New Issue
Block a user