use std::sync::Arc; use enclose::enclose; use galaxy_core::ui::appearance::Appearance; use galaxy_graphql::billing::AddonCreditsOption; use galaxyui::elements::{ Border, ChildView, Container, CrossAxisAlignment, Empty, Flex, HighlightedHyperlink, MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Text, }; use galaxyui::fonts::Weight; use galaxyui::ui_components::button::ButtonVariant; use galaxyui::ui_components::components::{Coords, UiComponent as _, UiComponentStyles}; use galaxyui::{AppContext, Element, Entity, SingletonEntity as _, View, ViewContext, ViewHandle}; use itertools::Itertools as _; use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine}; use crate::features::FeatureFlag; use crate::menu::MenuItemFields; use crate::modal::{Modal, ModalEvent, MODAL_PADDING, MODAL_WIDTH}; use crate::pricing::{PricingInfoModel, PricingInfoModelEvent}; use crate::send_telemetry_from_ctx; use crate::server::telemetry::{AutoReloadModalAction, TelemetryEvent}; use crate::ui_components::blended_colors; use crate::view_components::{Dropdown, DropdownAction, ToastFlavor}; use crate::workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent}; const DENOMINATION_DROPDOWN_WIDTH: f32 = MODAL_WIDTH - 2. * MODAL_PADDING; #[derive(Default)] struct MouseStates { enable_button: MouseStateHandle, cancel_button: MouseStateHandle, } /// The body content of the enable auto-reload modal pub struct EnableAutoReloadModalBody { mouse_states: MouseStates, denomination_dropdown: ViewHandle>, addon_credits_options: Vec, selected_denomination_index: usize, update_workspace_settings_loading: bool, } /// The main modal that wraps the body pub struct EnableAutoReloadModal { modal: ViewHandle>, } /// Called when user clicks the 'x' OR cancel button fn send_auto_reload_dismissed_telemetry(ctx: &mut ViewContext) { send_telemetry_from_ctx!( TelemetryEvent::AutoReloadModalClosed { action: AutoReloadModalAction::Dismissed, selected_credits: None, banner_toggle_flag_enabled: FeatureFlag::BuildPlanAutoReloadBannerToggle.is_enabled(), post_purchase_modal_flag_enabled: FeatureFlag::BuildPlanAutoReloadPostPurchaseModal .is_enabled(), }, ctx ); } impl EnableAutoReloadModalBody { pub fn new(ctx: &mut ViewContext) -> Self { ctx.subscribe_to_model( &PricingInfoModel::handle(ctx), |me, _, event, ctx| match event { PricingInfoModelEvent::PricingInfoUpdated => { me.update_addon_credits_options(ctx); ctx.notify(); } }, ); ctx.subscribe_to_model( &UserWorkspaces::handle(ctx), |me, _handle, event, ctx| { match event { UserWorkspacesEvent::UpdateWorkspaceSettingsSuccess => { if me.update_workspace_settings_loading { me.update_workspace_settings_loading = false; // Emit telemetry for successful auto-reload enablement let selected_credits = me .addon_credits_options .get(me.selected_denomination_index) .map(|option| option.credits); send_telemetry_from_ctx!( TelemetryEvent::AutoReloadModalClosed { action: AutoReloadModalAction::EnabledAutoReload, selected_credits, banner_toggle_flag_enabled: FeatureFlag::BuildPlanAutoReloadBannerToggle.is_enabled(), post_purchase_modal_flag_enabled: FeatureFlag::BuildPlanAutoReloadPostPurchaseModal.is_enabled(), }, ctx ); ctx.emit(EnableAutoReloadModalBodyEvent::ShowToast { message: "Auto-reload settings updated".to_string(), flavor: ToastFlavor::Success, }); ctx.emit(EnableAutoReloadModalBodyEvent::Close); } } UserWorkspacesEvent::UpdateWorkspaceSettingsRejected(_err) if me.update_workspace_settings_loading => { me.update_workspace_settings_loading = false; ctx.emit(EnableAutoReloadModalBodyEvent::ShowToast { message: "Failed to enable auto-reload. Please try updating your settings in Billing & usage.".to_string(), flavor: ToastFlavor::Error, }); ctx.notify(); } _ => {} } }, ); let denomination_dropdown = ctx.add_typed_action_view(|ctx| { let mut dropdown = Dropdown::new(ctx); dropdown.set_top_bar_max_width(DENOMINATION_DROPDOWN_WIDTH); dropdown.set_menu_width(DENOMINATION_DROPDOWN_WIDTH, ctx); dropdown }); let mut me = Self { mouse_states: Default::default(), denomination_dropdown, addon_credits_options: Default::default(), selected_denomination_index: 0, update_workspace_settings_loading: false, }; me.update_addon_credits_options(ctx); me } fn update_addon_credits_options(&mut self, ctx: &mut ViewContext) { self.addon_credits_options = PricingInfoModel::as_ref(ctx) .addon_credits_options() .map(|opts| opts.to_vec()) .unwrap_or_default(); let base_rate = self .addon_credits_options .first() .map_or(0., |option| option.rate()); let items = self .addon_credits_options .iter() .enumerate() .map(|(index, option)| { let primary_text = format!( "${:.0} / {} credits", option.price_usd_cents as f32 / 100., option.credits ); let discount_percent = if base_rate > 0.0 { let actual_rate = option.rate(); ((base_rate - actual_rate) / base_rate * 100.0).round() as u32 } else { 0 }; if discount_percent > 0 { MenuItemFields::new_with_custom_label( Arc::new( enclose!((primary_text) move |is_selected, is_hovered, appearance, _| { let text_color = appearance.theme().main_text_color( if is_selected || is_hovered { appearance.theme().accent() } else { appearance.theme().surface_1() } ); let main_text = Text::new_inline( primary_text.clone(), appearance.ui_font_family(), appearance.ui_font_size(), ) .with_color(text_color.into()) .finish(); let discount_badge = Empty::new().finish(); Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) .with_main_axis_size(MainAxisSize::Max) .with_child(main_text) .with_child(discount_badge) .finish() }), ), Some(primary_text), ) .with_on_select_action(DropdownAction::select_action_and_close( Action::SelectDenomination(index), )) .into_item() } else { MenuItemFields::new(primary_text.clone()) .with_on_select_action(DropdownAction::select_action_and_close( Action::SelectDenomination(index), )) .into_item() } }) .collect_vec(); self.denomination_dropdown.update(ctx, |dropdown, ctx| { dropdown.set_rich_items(items, ctx); dropdown.set_selected_by_index(self.selected_denomination_index, ctx); }); } fn render_content(&self, appearance: &Appearance) -> Box { let theme = appearance.theme(); let explanation_fragments = vec![ FormattedTextFragment::plain_text("When enabled, "), FormattedTextFragment::bold("auto-reload"), FormattedTextFragment::plain_text( " will automatically purchase your selected package when you run out. ", ), FormattedTextFragment::hyperlink( "Learn more", "https://docs.warp.dev/support-and-community/plans-and-billing/add-on-credits#id-2.-enable-auto-reload", ), ]; let explanation_text = galaxyui::elements::FormattedTextElement::new( FormattedText::new([FormattedTextLine::Line(explanation_fragments)]), appearance.ui_font_size(), appearance.ui_font_family(), appearance.ui_font_family(), blended_colors::text_sub(theme, theme.surface_1()), HighlightedHyperlink::default(), ) .with_hyperlink_font_color(theme.accent().into_solid()) .register_default_click_handlers_with_action_support(|hyperlink_lens, _event, ctx| { match hyperlink_lens { galaxyui::elements::HyperlinkLens::Url(url) => { ctx.open_url(url); } galaxyui::elements::HyperlinkLens::Action(_action_ref) => {} } }) .finish(); let denomination_dropdown = Container::new(ChildView::new(&self.denomination_dropdown).finish()) .with_vertical_padding(16.) .finish(); Flex::column() .with_main_axis_size(MainAxisSize::Min) .with_child(explanation_text) .with_child(denomination_dropdown) .finish() } fn render_buttons(&self, appearance: &Appearance) -> Box { let cancel_button = appearance .ui_builder() .button( ButtonVariant::Secondary, self.mouse_states.cancel_button.clone(), ) .with_style(UiComponentStyles { font_weight: Some(Weight::Semibold), padding: Some(Coords { top: 6., bottom: 6., left: 12., right: 12., }), ..Default::default() }) .with_text_label("Cancel".to_string()) .build() .on_click(|ctx, _, _| { ctx.dispatch_typed_action(Action::Cancel); }) .finish(); let button_text = if self.update_workspace_settings_loading { "Saving...".to_string() } else { "Enable".to_string() }; let mut enable_button = appearance .ui_builder() .button( ButtonVariant::Accent, self.mouse_states.enable_button.clone(), ) .with_style(UiComponentStyles { font_weight: Some(Weight::Semibold), padding: Some(Coords { top: 6., bottom: 6., left: 12., right: 12., }), ..Default::default() }) .with_text_label(button_text) .build() .on_click(|ctx, _, _| { ctx.dispatch_typed_action(Action::Enable); }); if self.update_workspace_settings_loading { enable_button = enable_button.disable(); } Flex::row() .with_main_axis_alignment(MainAxisAlignment::End) .with_main_axis_size(MainAxisSize::Max) .with_spacing(8.) .with_child(cancel_button) .with_child(enable_button.finish()) .finish() } } #[derive(Clone, Debug)] pub enum EnableAutoReloadModalBodyEvent { Close, ShowToast { message: String, flavor: ToastFlavor, }, } impl Entity for EnableAutoReloadModalBody { type Event = EnableAutoReloadModalBodyEvent; } impl View for EnableAutoReloadModalBody { fn ui_name() -> &'static str { "EnableAutoReloadModalBody" } fn render(&self, app: &AppContext) -> Box { let appearance = Appearance::as_ref(app); let content = Container::new(self.render_content(appearance)) .with_horizontal_padding(MODAL_PADDING) .with_margin_top(0.) // let the header padding handle the top margin .finish(); let separator = Container::new(Empty::new().finish()) .with_border(Border::bottom(1.).with_border_fill(appearance.theme().outline())) .finish(); let buttons = Container::new(self.render_buttons(appearance)) .with_horizontal_padding(MODAL_PADDING) .with_vertical_padding(12.) .finish(); Flex::column() .with_main_axis_size(MainAxisSize::Min) .with_child(content) .with_child(separator) .with_child(buttons) .finish() } } #[derive(Clone, Debug, PartialEq)] pub enum Action { SelectDenomination(usize), Cancel, Enable, } impl galaxyui::TypedActionView for EnableAutoReloadModalBody { type Action = Action; fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext) { match action { Action::SelectDenomination(index) => { self.selected_denomination_index = *index; ctx.notify(); } Action::Cancel => { send_auto_reload_dismissed_telemetry(ctx); ctx.emit(EnableAutoReloadModalBodyEvent::Close); } Action::Enable => { let workspaces = UserWorkspaces::as_ref(ctx); let Some(team_uid) = workspaces.current_team_uid() else { ctx.emit(EnableAutoReloadModalBodyEvent::ShowToast { message: "Oops, something went wrong; your team's data could not be found." .to_string(), flavor: ToastFlavor::Error, }); return; }; // Set loading state before making the API call self.update_workspace_settings_loading = true; ctx.notify(); UserWorkspaces::handle(ctx).update(ctx, move |user_workspaces, ctx| { user_workspaces.update_addon_credits_settings( team_uid, Some(true), // TODO: consider allowing user to set max monthly spend too in this modal None, Some(self.addon_credits_options[self.selected_denomination_index].credits), ctx, ); }); } } } } // Main modal wrapper impl EnableAutoReloadModal { pub fn new(ctx: &mut ViewContext) -> Self { let body = ctx.add_typed_action_view(EnableAutoReloadModalBody::new); let modal = ctx.add_typed_action_view(|ctx| { Modal::new(Some("Enable auto reload?".to_string()), body.clone(), ctx).with_body_style( UiComponentStyles { // Padding of 0 here since we add a horizontal bar that needs to span the full width in the body // So we handle padding in the body itself padding: Some(Coords::uniform(0.)), ..Default::default() }, ) }); ctx.subscribe_to_view(&modal, |_, _, event, ctx| match event { ModalEvent::Close => { // "x" clicked send_auto_reload_dismissed_telemetry(ctx); ctx.emit(EnableAutoReloadModalEvent::Close); } }); ctx.subscribe_to_view(&body, |_, _, event, ctx| match event { EnableAutoReloadModalBodyEvent::Close => { ctx.emit(EnableAutoReloadModalEvent::Close); } EnableAutoReloadModalBodyEvent::ShowToast { message, flavor } => { ctx.emit(EnableAutoReloadModalEvent::ShowToast { message: message.clone(), flavor: *flavor, }); } }); Self { modal } } pub fn set_selected_denomination_by_credits( &mut self, credits: i32, ctx: &mut ViewContext, ) { self.modal.update(ctx, |modal, ctx| { modal.body().update(ctx, |body, ctx| { if let Some((index, _)) = body .addon_credits_options .iter() .enumerate() .find(|(_, option)| option.credits == credits) { body.selected_denomination_index = index; // Update the dropdown to reflect the new selection body.denomination_dropdown.update(ctx, |dropdown, ctx| { dropdown.set_selected_by_index(index, ctx); }); ctx.notify(); } }); }); } } #[derive(Clone, Debug)] pub enum EnableAutoReloadModalEvent { Close, ShowToast { message: String, flavor: ToastFlavor, }, } impl Entity for EnableAutoReloadModal { type Event = EnableAutoReloadModalEvent; } impl galaxyui::TypedActionView for EnableAutoReloadModal { type Action = (); } impl View for EnableAutoReloadModal { fn ui_name() -> &'static str { "EnableAutoReloadModal" } fn render(&self, _app: &AppContext) -> Box { ChildView::new(&self.modal).finish() } }