app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit 153d1ff6e81c9a928cbeab78d411fe2d9522bad1
parent 8e7b52ca0edad7a8f560ad64d43d41564f00f886
Author: triesap <tyson@radroots.org>
Date:   Sun, 24 May 2026 10:06:46 +0000

app: add typed buyer notices

Diffstat:
Mcrates/launchers/desktop/src/source_guards.rs | 8++++++++
Mcrates/launchers/desktop/src/window.rs | 221++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Mcrates/shared/i18n/src/keys.rs | 2++
Mcrates/shared/i18n/src/lib.rs | 8++++++++
Mi18n/locales/en/messages.json | 2++
5 files changed, 178 insertions(+), 63 deletions(-)

diff --git a/crates/launchers/desktop/src/source_guards.rs b/crates/launchers/desktop/src/source_guards.rs @@ -61,12 +61,16 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[ "buyer.order_open_failed", "buyer.repeat_demand_failed", "buyer.section_select_failed", + "buyer_notice", "bunker://466d7fcae563e5cb09a0d1870bb580344804617879a14949cf22285f1bae3f27?relay=wss%3A%2F%2Frelay.radroots.example", "buyer.fulfillment_filter_update_failed", "buyer.search_query_update_failed", + "clock", "customer_labels.txt", + "desktop runtime paths should resolve", "desktop runtime roots require HOME for macos", "disk unavailable", + "eggs", "failed to add buyer product to cart", "failed to open buyer order detail", "failed to place buyer order", @@ -253,6 +257,7 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[ "remote signer connection failed: relay refused the request", "remote signer did not respond yet", "runtime unavailable", + "radroots_home_view_{label}_{suffix}", "sign_event:kind:1", "shell", "shell-account-entry", @@ -293,6 +298,7 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[ "switch_relays", "startup-title-radroots", "startup-title-starting", + "wss://relay.example", "wss://relay.radroots.example", "{currency_code} {dollars}.{cents:02}", "{}, {}", @@ -372,6 +378,8 @@ const REQUIRED_WINDOW_COPY_KEYS: &[&str] = &[ "AppTextKey::PersonalSearchEmptyBody", "AppTextKey::PersonalBrowsePlaceholderBody", "AppTextKey::PersonalSearchPlaceholderBody", + "AppTextKey::PersonalMarketplaceRefreshFailedNotice", + "AppTextKey::PersonalDetailOpenFailedNotice", "AppTextKey::PersonalCartPlaceholderBody", "AppTextKey::PersonalOrdersSurfaceBody", "AppTextKey::PersonalOrdersEmptyTitle", diff --git a/crates/launchers/desktop/src/window.rs b/crates/launchers/desktop/src/window.rs @@ -299,6 +299,25 @@ enum HomeAutoFocusTarget { OrdersDetailMarkCompleted, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum BuyerWorkspaceNotice { + MarketplaceRefreshFailed, + DetailOpenFailed, +} + +impl BuyerWorkspaceNotice { + fn text_key(self) -> AppTextKey { + match self { + Self::MarketplaceRefreshFailed => AppTextKey::PersonalMarketplaceRefreshFailedNotice, + Self::DetailOpenFailed => AppTextKey::PersonalDetailOpenFailedNotice, + } + } + + fn text(self) -> String { + app_text(self.text_key()) + } +} + impl HomeView { pub fn new(runtime: DesktopAppRuntime) -> Self { Self { @@ -1131,18 +1150,20 @@ impl HomeView { } fn select_personal_section(&mut self, section: PersonalSection, cx: &mut Context<Self>) { + if self.select_personal_section_update(section) { + cx.notify(); + } + } + + fn select_personal_section_update(&mut self, section: PersonalSection) -> bool { match self.runtime.select_personal_section(section) { Ok(true) => { self.products_stock_editor = None; self.product_editor_form = None; self.clear_buyer_workspace_notice(); - cx.notify(); - } - Ok(false) => { - if self.clear_buyer_workspace_notice() { - cx.notify(); - } + true } + Ok(false) => self.clear_buyer_workspace_notice(), Err(runtime_error) => { error!( target: "shell", @@ -1151,8 +1172,7 @@ impl HomeView { error = %runtime_error, "failed to select buyer section" ); - self.set_buyer_workspace_notice(runtime_error.to_string()); - cx.notify(); + self.set_buyer_workspace_notice(BuyerWorkspaceNotice::MarketplaceRefreshFailed) } } } @@ -1276,9 +1296,14 @@ impl HomeView { } let value = state.read(cx).value().to_string(); - match self.runtime.set_personal_search_query(value.as_str()) { - Ok(true) => cx.notify(), - Ok(false) => {} + if self.set_personal_search_query_update(value.as_str()) { + cx.notify(); + } + } + + fn set_personal_search_query_update(&mut self, value: &str) -> bool { + match self.runtime.set_personal_search_query(value) { + Ok(changed) => self.clear_buyer_workspace_notice() || changed, Err(runtime_error) => { error!( target: "buyer", @@ -1286,6 +1311,7 @@ impl HomeView { error = %runtime_error, "failed to update buyer search query" ); + self.set_buyer_workspace_notice(BuyerWorkspaceNotice::MarketplaceRefreshFailed) } } } @@ -1335,12 +1361,21 @@ impl HomeView { enabled: bool, cx: &mut Context<Self>, ) { + if self.set_personal_search_fulfillment_method_update(method, enabled) { + cx.notify(); + } + } + + fn set_personal_search_fulfillment_method_update( + &mut self, + method: FarmOrderMethod, + enabled: bool, + ) -> bool { match self .runtime .set_personal_search_fulfillment_method(method, enabled) { - Ok(true) => cx.notify(), - Ok(false) => {} + Ok(changed) => self.clear_buyer_workspace_notice() || changed, Err(runtime_error) => { error!( target: "buyer", @@ -1349,6 +1384,7 @@ impl HomeView { method = method.storage_key(), "failed to update buyer fulfillment filter" ); + self.set_buyer_workspace_notice(BuyerWorkspaceNotice::MarketplaceRefreshFailed) } } } @@ -1359,19 +1395,25 @@ impl HomeView { product_id: ProductId, cx: &mut Context<Self>, ) { + if self.open_personal_product_detail_update(section, product_id) { + cx.notify(); + } + } + + fn open_personal_product_detail_update( + &mut self, + section: PersonalSection, + product_id: ProductId, + ) -> bool { match self .runtime .open_personal_product_detail(section, product_id) { Ok(true) => { self.clear_buyer_workspace_notice(); - cx.notify(); - } - Ok(false) => { - if self.clear_buyer_workspace_notice() { - cx.notify(); - } + true } + Ok(false) => self.clear_buyer_workspace_notice(), Err(runtime_error) => { error!( target: "buyer", @@ -1379,13 +1421,13 @@ impl HomeView { error = %runtime_error, "failed to open buyer product detail" ); - self.set_buyer_workspace_notice(runtime_error.to_string()); - cx.notify(); + self.set_buyer_workspace_notice(BuyerWorkspaceNotice::DetailOpenFailed) } } } - fn set_buyer_workspace_notice(&mut self, notice: String) -> bool { + fn set_buyer_workspace_notice(&mut self, notice: BuyerWorkspaceNotice) -> bool { + let notice = notice.text(); let changed = self.buyer_workspace_notice.as_deref() != Some(notice.as_str()); self.buyer_workspace_notice = Some(notice); changed @@ -12860,33 +12902,33 @@ fn home_farm_order_method_label_key(method: FarmOrderMethod) -> AppTextKey { #[cfg(test)] mod tests { use super::{ - APP_UI_THEME, AppTextKey, FarmerHomeFarmState, HomeAutoFocusState, HomeAutoFocusTarget, - HomeStage, HomeView, LabelValueRow, PackDayBatchPrintActionPresentation, - PackDayBatchPrintStatusPresentation, PackDayExportStatusPresentation, - PackDayHostHandoffActionPresentation, PackDayHostHandoffStatusPresentation, - PackDayPrintActionPresentation, PackDayPrintStatusPresentation, ReminderActionTarget, - SETTINGS_FARM_PANEL_SECTIONS, SETTINGS_NAVIGATION_ORDER, - SETTINGS_OPERATIONS_PANEL_SECTIONS, SettingsAutoFocusTarget, SettingsInventorySectionSpec, - SettingsPanelViewKey, StartupHomeSurface, StartupSignerConnectState, - about_conflict_action_specs, about_conflict_aggregate_text, about_conflict_detail_rows, - about_conflict_review_body_key, about_manual_refresh_enabled, about_runtime_rows, - about_status_rows, app_text, buyer_orders_status_key, farm_setup_onboarding_card_spec, - farmer_home_farm_state, farmer_pack_day_available, home_auto_focus_target, - home_content_scroll_id, home_saved_farm, home_sidebar_navigation_sections, home_stage, - home_window_launch_size_px, home_window_minimum_size_px, - pack_day_batch_print_action_presentation, pack_day_batch_print_status_presentation, - pack_day_export_action_enabled, pack_day_export_action_label_key, - pack_day_export_artifact_names, pack_day_export_detail_rows, - pack_day_export_status_presentation, pack_day_host_handoff_action_presentations, - pack_day_host_handoff_status_presentation, pack_day_print_action_presentations, - pack_day_print_status_presentation, parse_optional_product_editor_stock_input, - parse_product_editor_price_input, presented_farmer_reminder, product_display_title, - reminder_action_target, reminder_deadline_text, reminder_delivery_state_key, - reminder_urgency_color, reminder_urgency_key, settings_auto_focus_target, - startup_home_surface, startup_issue_summary_text, startup_notice_text, - startup_signer_preview_summary, startup_signer_preview_summary_for_connect_state, - startup_signer_source_input_is_editable, startup_signer_status_spec, - startup_signer_transport_failure_requires_notice, + APP_UI_THEME, AppTextKey, BuyerWorkspaceNotice, FarmerHomeFarmState, HomeAutoFocusState, + HomeAutoFocusTarget, HomeStage, HomeView, LabelValueRow, + PackDayBatchPrintActionPresentation, PackDayBatchPrintStatusPresentation, + PackDayExportStatusPresentation, PackDayHostHandoffActionPresentation, + PackDayHostHandoffStatusPresentation, PackDayPrintActionPresentation, + PackDayPrintStatusPresentation, ReminderActionTarget, SETTINGS_FARM_PANEL_SECTIONS, + SETTINGS_NAVIGATION_ORDER, SETTINGS_OPERATIONS_PANEL_SECTIONS, SettingsAutoFocusTarget, + SettingsInventorySectionSpec, SettingsPanelViewKey, StartupHomeSurface, + StartupSignerConnectState, about_conflict_action_specs, about_conflict_aggregate_text, + about_conflict_detail_rows, about_conflict_review_body_key, about_manual_refresh_enabled, + about_runtime_rows, about_status_rows, app_text, buyer_orders_status_key, + farm_setup_onboarding_card_spec, farmer_home_farm_state, farmer_pack_day_available, + home_auto_focus_target, home_content_scroll_id, home_saved_farm, + home_sidebar_navigation_sections, home_stage, home_window_launch_size_px, + home_window_minimum_size_px, pack_day_batch_print_action_presentation, + pack_day_batch_print_status_presentation, pack_day_export_action_enabled, + pack_day_export_action_label_key, pack_day_export_artifact_names, + pack_day_export_detail_rows, pack_day_export_status_presentation, + pack_day_host_handoff_action_presentations, pack_day_host_handoff_status_presentation, + pack_day_print_action_presentations, pack_day_print_status_presentation, + parse_optional_product_editor_stock_input, parse_product_editor_price_input, + presented_farmer_reminder, product_display_title, reminder_action_target, + reminder_deadline_text, reminder_delivery_state_key, reminder_urgency_color, + reminder_urgency_key, settings_auto_focus_target, startup_home_surface, + startup_issue_summary_text, startup_notice_text, startup_signer_preview_summary, + startup_signer_preview_summary_for_connect_state, startup_signer_source_input_is_editable, + startup_signer_status_spec, startup_signer_transport_failure_requires_notice, }; use crate::runtime::{ DesktopAppRuntimeMetadataSummary, DesktopAppRuntimeSummary, DesktopAppSyncConflictSummary, @@ -12905,10 +12947,10 @@ mod tests { PackDayBatchPrintArtifact, PackDayBatchPrintFailureKind, PackDayExportArtifact, PackDayExportArtifactKind, PackDayExportBundle, PackDayHostHandoffKind, PackDayHostHandoffStatus, PackDayPrintFailureKind, PackDayPrintKind, PackDayPrintStatus, - PackDayProductTotalRow, PackDayProjection, PersonalSection, ReminderDeadlineProjection, - ReminderDeliveryState, ReminderId, ReminderKind, ReminderSurface, ReminderUrgency, - RepeatDemandEligibility, RepeatDemandHandoffProjection, ShellSection, - TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, + PackDayProductTotalRow, PackDayProjection, PersonalSection, ProductId, + ReminderDeadlineProjection, ReminderDeliveryState, ReminderId, ReminderKind, + ReminderSurface, ReminderUrgency, RepeatDemandEligibility, RepeatDemandHandoffProjection, + ShellSection, TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, }; use radroots_app_remote_signer::{ RadrootsAppRemoteSignerApprovedSession, RadrootsAppRemoteSignerPendingSession, @@ -12959,7 +13001,7 @@ mod tests { path } - fn test_home_view(label: &str) -> (HomeView, PathBuf) { + fn test_home_view(label: &str) -> (HomeView, AppDesktopRuntimePaths, PathBuf) { let suffix = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("clock") @@ -12974,29 +13016,82 @@ mod tests { ) .expect("desktop runtime paths should resolve"); let runtime = crate::runtime::DesktopAppRuntime::bootstrap_with_paths( - paths, + paths.clone(), "wss://relay.example".to_owned(), ); - (HomeView::new(runtime), home_dir) + (HomeView::new(runtime), paths, home_dir) + } + + fn block_shared_local_events_database(paths: &AppDesktopRuntimePaths) { + let database_path = paths.shared_local_events_database_path().unwrap(); + if let Some(parent) = database_path.parent() { + fs::create_dir_all(parent).unwrap(); + } + if database_path.is_file() { + fs::remove_file(&database_path).unwrap(); + } else if database_path.is_dir() { + fs::remove_dir_all(&database_path).unwrap(); + } + fs::create_dir(&database_path).unwrap(); } #[test] fn buyer_workspace_notice_tracks_visible_buyer_runtime_errors() { - let (mut view, home_dir) = test_home_view("buyer_notice"); + let (mut view, _, home_dir) = test_home_view("buyer_notice"); - assert!( - view.set_buyer_workspace_notice("open shared local events database failed".to_owned()) + assert!(view.set_buyer_workspace_notice(BuyerWorkspaceNotice::MarketplaceRefreshFailed)); + assert_eq!( + view.buyer_workspace_notice.as_deref(), + Some(app_text(AppTextKey::PersonalMarketplaceRefreshFailedNotice).as_str()) + ); + assert!(!view.set_buyer_workspace_notice(BuyerWorkspaceNotice::MarketplaceRefreshFailed)); + assert!(view.clear_buyer_workspace_notice()); + assert_eq!(view.buyer_workspace_notice, None); + + let _ = fs::remove_dir_all(home_dir); + } + + #[test] + fn buyer_browse_refresh_failure_uses_typed_visible_notice() { + let (mut view, paths, home_dir) = test_home_view("buyer_notice"); + block_shared_local_events_database(&paths); + + assert!(view.select_personal_section_update(PersonalSection::Browse)); + assert_eq!( + view.buyer_workspace_notice.as_deref(), + Some(app_text(AppTextKey::PersonalMarketplaceRefreshFailedNotice).as_str()) ); + + let _ = fs::remove_dir_all(home_dir); + } + + #[test] + fn buyer_search_refresh_failure_uses_typed_visible_notice() { + let (mut view, paths, home_dir) = test_home_view("buyer_notice"); + block_shared_local_events_database(&paths); + + assert!(view.set_personal_search_query_update("eggs")); assert_eq!( view.buyer_workspace_notice.as_deref(), - Some("open shared local events database failed") + Some(app_text(AppTextKey::PersonalMarketplaceRefreshFailedNotice).as_str()) ); + + let _ = fs::remove_dir_all(home_dir); + } + + #[test] + fn buyer_detail_open_failure_uses_typed_visible_notice() { + let (mut view, paths, home_dir) = test_home_view("buyer_notice"); + block_shared_local_events_database(&paths); + assert!( - !view.set_buyer_workspace_notice("open shared local events database failed".to_owned()) + view.open_personal_product_detail_update(PersonalSection::Browse, ProductId::new()) + ); + assert_eq!( + view.buyer_workspace_notice.as_deref(), + Some(app_text(AppTextKey::PersonalDetailOpenFailedNotice).as_str()) ); - assert!(view.clear_buyer_workspace_notice()); - assert_eq!(view.buyer_workspace_notice, None); let _ = fs::remove_dir_all(home_dir); } diff --git a/crates/shared/i18n/src/keys.rs b/crates/shared/i18n/src/keys.rs @@ -122,6 +122,8 @@ define_app_text_keys! { PersonalSearchEmptyBody => "personal.search.empty.body", PersonalBrowsePlaceholderBody => "personal.browse.placeholder.body", PersonalSearchPlaceholderBody => "personal.search.placeholder.body", + PersonalMarketplaceRefreshFailedNotice => "personal.marketplace.refresh_failed.notice", + PersonalDetailOpenFailedNotice => "personal.detail.open_failed.notice", PersonalCartPlaceholderBody => "personal.cart.placeholder.body", PersonalOrdersSurfaceBody => "personal.orders.surface.body", PersonalOrdersEmptyTitle => "personal.orders.empty.title", diff --git a/crates/shared/i18n/src/lib.rs b/crates/shared/i18n/src/lib.rs @@ -337,6 +337,14 @@ mod tests { app_text(AppTextKey::PersonalDetailReplaceCartAction), "Replace cart" ); + assert_eq!( + app_text(AppTextKey::PersonalMarketplaceRefreshFailedNotice), + "Couldn't refresh marketplace listings. Your saved local state is still here; try again in a moment." + ); + assert_eq!( + app_text(AppTextKey::PersonalDetailOpenFailedNotice), + "Couldn't open that listing. Refresh the marketplace and try again." + ); } #[test] diff --git a/i18n/locales/en/messages.json b/i18n/locales/en/messages.json @@ -102,6 +102,8 @@ "personal.search.empty.body": "Try a different search or clear a fulfillment filter.", "personal.browse.placeholder.body": "Products from local farms will appear here when they are available.", "personal.search.placeholder.body": "Search will use the same marketplace listings and stay focused on products, farms, and pickup options.", + "personal.marketplace.refresh_failed.notice": "Couldn't refresh marketplace listings. Your saved local state is still here; try again in a moment.", + "personal.detail.open_failed.notice": "Couldn't open that listing. Refresh the marketplace and try again.", "personal.cart.placeholder.body": "Add items from one farm to start an order.", "personal.orders.surface.body": "Review orders placed on this device.", "personal.orders.empty.title": "No orders yet",