mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
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 <[email protected]>
This commit is contained in:
1 parent
590ca775c2
commit
3dd17ad7d7
7 files changed
+179
-2
No files matched your search
@@ -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.
|
||||
@@ -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",
|
||||
"server",
|
||||
"sliders",
|
||||
// Not Pixelarticons': drawn for Horizon's ratings, in the same 2-unit grid.
|
||||
"star",
|
||||
"terminal",
|
||||
"trash",
|
||||
"user",
|
||||
|
||||
@@ -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<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
|
||||
/// (kinds newer than this launcher).
|
||||
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> {
|
||||
Ok(search(&*index()?, query))
|
||||
let found = search(&*index()?, query);
|
||||
refresh_ratings();
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
fn project(&self, id: &str) -> Result<Project, String> {
|
||||
@@ -275,6 +369,7 @@ impl Source for Registry {
|
||||
}
|
||||
|
||||
fn details(&self, project: &Project) -> Result<Details, String> {
|
||||
refresh_ratings();
|
||||
let index = index()?;
|
||||
let p = index
|
||||
.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!(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.
|
||||
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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user