chore: changed the UX for optional mods modal (#878)

* chore: changed the UX for optional mods modal

* refactor: fold cancel_optional_mods into close_optional_mods

* fix: forget optional mod picks once the modal closes

* chore: rustfmt the optional mods import

* chore: say what the optional mods buttons actually do

---------

Co-authored-by: LynithDev <61880709+LynithDev@users.noreply.github.com>
This commit is contained in:
Maciek 2026-09-28 17:24:10 +02:00 • committed by GitHub
parent 8e66212596
commit 35dcd584d0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 57 additions and 44 deletions

View file

@ -12,7 +12,9 @@ use crate::hooks::{
package_meta_batch, use_cached_image, use_dispatch, use_notifications_snapshot,
use_package_meta_batch,
};
use crate::notifications::{ClusterUpdateItem, OptionalModRef, OptionalModsGroup};
use crate::notifications::{
ClusterUpdateItem, OptionalModRef, OptionalModsGroup, OptionalModsOutcome,
};
use crate::theme::colors;
use crate::ui::border_all_color;
@ -21,6 +23,8 @@ const DIALOG_W: f32 = 720.;
const DIALOG_PAD: f32 = 22.;
const LIST_MAX_H: f32 = 306.;
const ICON_SIZE: f32 = 40.;
const SKIP_NOTE: &str =
"Anything you don't pick won't be offered again, but you can add it later from the Mods tab.";
type MetaMap = HashMap<(ProviderId, String), CachedPackageMeta>;
@ -33,7 +37,7 @@ impl Component for OptionalModsPopup {
fn render(&self) -> impl IntoElement {
let snapshot = use_notifications_snapshot();
let dispatch = use_dispatch();
let picked = use_state(Picked::new);
let mut picked = use_state(Picked::new);
let groups = snapshot.optional_mods.clone();
@ -57,11 +61,12 @@ impl Component for OptionalModsPopup {
};
let close = dispatch.clone();
let dismissed: Vec<(i64, OptionalModRef)> =
groups.iter().flat_map(|group| group.offers()).collect();
OverlayPopup::new()
.on_close(move |_| close.decline_optional_mods(dismissed.clone()))
.on_close(move |_| {
picked.set(Picked::new());
close.close_optional_mods(OptionalModsOutcome::Cancel);
})
.child(
rect()
.width(Size::window_percent(100.))
@ -150,7 +155,7 @@ fn dialog(
fn content(
groups: &[OptionalModsGroup],
meta: &MetaMap,
picked: State<Picked>,
mut picked: State<Picked>,
dispatch: crate::Actions,
) -> impl IntoElement {
let total: usize = groups.iter().map(|group| group.mods.len()).sum();
@ -168,24 +173,23 @@ fn content(
let plural = if total == 1 { "" } else { "s" };
let subtitle = match groups {
[only] => format!(
"{} includes {total} optional mod{plural} that {} off by default. Pick the {} to turn on.",
"{} includes {total} optional mod{plural} that {} off by default. Pick the {} to turn on. {SKIP_NOTE}",
only.cluster_name,
if total == 1 { "is" } else { "are" },
if total == 1 { "one" } else { "ones" }
),
_ => format!(
"Your bundles include {total} optional mod{plural} across {} clusters that are off by default. Pick the ones to turn on.",
"Your bundles include {total} optional mod{plural} across {} clusters that are off by default. Pick the ones to turn on. {SKIP_NOTE}",
groups.len()
),
};
let cancel = dispatch.clone();
let declined = offers.clone();
let enable_text = if enable.is_empty() {
"Enable".to_string()
let primary_text = if enable.is_empty() {
"Launch".to_string()
} else {
format!("Enable {}", enable.len())
format!("Install {} & launch", enable.len())
};
rect()
@ -210,7 +214,7 @@ fn content(
label()
.text(subtitle)
.font_size(12.5)
.max_lines(3)
.max_lines(4)
.color(colors::fg_secondary()),
),
)
@ -225,22 +229,25 @@ fn content(
.child(
Button::new()
.ghost()
.on_press(move |_| cancel.decline_optional_mods(declined.clone()))
.on_press(move |_| {
picked.set(Picked::new());
cancel.close_optional_mods(OptionalModsOutcome::Cancel);
})
.text("Cancel"),
)
.maybe_child((!offers.is_empty()).then(|| {
.child({
let apply = dispatch.clone();
let install = !enable.is_empty();
Button::new()
.primary()
.disabled(enable.is_empty())
.on_press(move |_| {
picked.set(Picked::new());
apply.record_skipped_optional_mods(skip.clone());
apply.enable_optional_mods(enable.clone());
})
.child(Icon::new(IconType::Check).size(15.))
.text(enable_text.clone())
.into_element()
})),
.maybe_child(install.then(|| Icon::new(IconType::Check).size(15.)))
.text(primary_text)
}),
)
}

View file

@ -26,7 +26,7 @@ use tokio::sync::mpsc;
use crate::components::IconType;
use crate::notifications::{
ClusterUpdateSummary, NotificationAction, NotificationSpec, OptionalModRef, OptionalModsGroup,
PackageUpdateGroup, PendingPrompt,
OptionalModsOutcome, PackageUpdateGroup, PendingPrompt,
};
use crate::state::{AppChannel, AppState, AsyncStatus, RelocationState};
use crate::{invalidate_java_queries, launcher};
@ -727,18 +727,13 @@ impl Actions {
pub fn open_optional_mods(
&self,
groups: Vec<OptionalModsGroup>,
done: Option<tokio::sync::oneshot::Sender<()>>,
done: Option<tokio::sync::oneshot::Sender<OptionalModsOutcome>>,
) {
self.with_engine(move |state| state.notifications.open_optional_mods(groups, done));
}
pub fn close_optional_mods(&self) {
self.with_engine(|state| state.notifications.finish_optional_mods());
}
pub fn decline_optional_mods(&self, mods: Vec<(ClusterId, OptionalModRef)>) {
self.close_optional_mods();
self.record_skipped_optional_mods(mods);
pub fn close_optional_mods(&self, outcome: OptionalModsOutcome) {
self.with_engine(move |state| state.notifications.finish_optional_mods(outcome));
}
pub fn record_skipped_optional_mods(&self, mods: Vec<(ClusterId, OptionalModRef)>) {
@ -768,7 +763,7 @@ impl Actions {
/// `mods/` once at startup, so finishing after the process starts is useless
pub fn enable_optional_mods(&self, mods: Vec<(ClusterId, OptionalModRef)>) {
if mods.is_empty() {
self.close_optional_mods();
self.close_optional_mods(OptionalModsOutcome::Launch);
return;
}
@ -780,7 +775,7 @@ impl Actions {
spawn_forever(async move {
let Ok(state) = launcher::state() else {
actions.close_optional_mods();
actions.close_optional_mods(OptionalModsOutcome::Launch);
return;
};
let content = state.services.content();
@ -886,7 +881,7 @@ impl Actions {
.send();
}
actions.close_optional_mods();
actions.close_optional_mods(OptionalModsOutcome::Launch);
});
}
@ -1641,7 +1636,7 @@ impl Actions {
&self,
state: &Arc<oneclient_core::LauncherState>,
cluster_id: ClusterId,
) {
) -> OptionalModsOutcome {
let pending = match oneclient_core::pending_optional_mods(
cluster_id,
state.bundles.as_ref(),
@ -1656,7 +1651,7 @@ impl Actions {
error = %err,
"could not read queued optional mods, launching anyway"
);
return;
return OptionalModsOutcome::Launch;
}
};
@ -1665,7 +1660,7 @@ impl Actions {
let Some(group) =
crate::install::pending_optional_group(cluster_id, &pending, &state.services).await
else {
return;
return OptionalModsOutcome::Launch;
};
let (done, wait) = tokio::sync::oneshot::channel();
@ -1676,7 +1671,7 @@ impl Actions {
state.center_open = false;
});
let _ = wait.await;
wait.await.unwrap_or(OptionalModsOutcome::Launch)
}
async fn prompt_package_updates(
@ -1833,9 +1828,14 @@ async fn launch(actions: &Actions, cluster_id: ClusterId) {
.await;
// After the updates so the player never faces two modals at once
actions
if actions
.resolve_optional_mods_before_launch(&state, cluster_id)
.await;
.await
== OptionalModsOutcome::Cancel
{
events.game_stage(cluster_id, oneclient_events::LaunchStage::Exited);
return;
}
if let Err(err) = oneclient_core::launch_cluster(&state, cluster_id, &account, true).await {
// A missing file is the one failure the launcher can fix itself and a

View file

@ -38,6 +38,12 @@ pub struct PackageUpdateGroup {
pub type OptionalModRef = (String, String);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum OptionalModsOutcome {
Launch,
Cancel,
}
#[derive(Clone, Debug, PartialEq)]
pub struct ClusterUpdateItem {
pub provider: ProviderId,
@ -233,7 +239,7 @@ pub struct NotificationState {
/// Held here not by the launch task
/// because every way the modal can end goes through this state
package_updates_done: Option<oneshot::Sender<Vec<BrowserPackageUpdate>>>,
optional_mods_done: Option<oneshot::Sender<()>>,
optional_mods_done: Option<oneshot::Sender<OptionalModsOutcome>>,
}
const CATEGORY_ORDER: [TaskCategory; 7] = [
@ -365,7 +371,7 @@ impl NotificationState {
pub fn open_optional_mods(
&mut self,
groups: Vec<OptionalModsGroup>,
done: Option<oneshot::Sender<()>>,
done: Option<oneshot::Sender<OptionalModsOutcome>>,
) {
let groups: Vec<OptionalModsGroup> = groups
.into_iter()
@ -374,12 +380,12 @@ impl NotificationState {
if groups.is_empty() {
if let Some(done) = done {
let _ = done.send(());
let _ = done.send(OptionalModsOutcome::Launch);
}
return;
}
self.finish_optional_mods();
self.finish_optional_mods(OptionalModsOutcome::Launch);
self.optional_mods = Some(groups);
self.optional_mods_done = done;
}
@ -388,10 +394,10 @@ impl NotificationState {
self.optional_mods = None;
}
pub fn finish_optional_mods(&mut self) {
pub fn finish_optional_mods(&mut self, outcome: OptionalModsOutcome) {
self.optional_mods = None;
if let Some(done) = self.optional_mods_done.take() {
let _ = done.send(());
let _ = done.send(outcome);
}
}