From d32d3e1782125633e338572b8cd4e18b5269d49d Mon Sep 17 00:00:00 2001 From: Maciek <108873945+rozwader@users.noreply.github.com> Date: Wed, 16 Sep 2026 18:34:50 +0200 Subject: [PATCH] feat: added tooltips (#837) --- .../src/components/active_cluster_panel.rs | 1 + .../oneclient_app/src/components/button.rs | 25 +- packages/oneclient_app/src/components/mod.rs | 2 + .../src/components/navbar/app_navbar.rs | 3 + .../src/components/navbar/mod.rs | 3 + .../src/components/pagination.rs | 31 +- .../src/components/screenshot_viewer.rs | 1 + .../oneclient_app/src/components/toasts.rs | 4 +- .../oneclient_app/src/components/tooltip.rs | 315 ++++++++++++++++++ .../oneclient_app/src/layout/cluster_shell.rs | 1 + .../oneclient_app/src/layout/root_layout.rs | 6 +- packages/oneclient_app/src/ui.rs | 11 + .../src/view/app/browser/package/gallery.rs | 1 + .../src/view/app/cluster/cluster_settings.rs | 5 + .../view/app/cluster/package_manager/views.rs | 1 + .../src/view/app/settings/accounts.rs | 9 +- 16 files changed, 408 insertions(+), 11 deletions(-) create mode 100644 packages/oneclient_app/src/components/tooltip.rs diff --git a/packages/oneclient_app/src/components/active_cluster_panel.rs b/packages/oneclient_app/src/components/active_cluster_panel.rs index 7e5cccaa..9b81eb45 100644 --- a/packages/oneclient_app/src/components/active_cluster_panel.rs +++ b/packages/oneclient_app/src/components/active_cluster_panel.rs @@ -141,6 +141,7 @@ fn cluster_settings_button(cluster_id: i64) -> impl IntoElement { Button::new() .ghost() .icon() + .tooltip("Cluster settings") .on_press(move |_| { let _ = RouterContext::get().push(Route::ClusterOverview { cluster_id }); }) diff --git a/packages/oneclient_app/src/components/button.rs b/packages/oneclient_app/src/components/button.rs index c8163a43..30a28be8 100644 --- a/packages/oneclient_app/src/components/button.rs +++ b/packages/oneclient_app/src/components/button.rs @@ -4,7 +4,7 @@ use std::borrow::Cow; use freya::prelude::*; use crate::{ - components::{Icon, IconTint, IconType}, + components::{Icon, IconTint, IconType, TooltipPlacement, tooltip::use_tooltip_anchor}, theme::colors, }; @@ -53,6 +53,8 @@ pub struct Button { enabled: bool, focusable: bool, alt: Option>, + tooltip: Option>, + tooltip_placement: TooltipPlacement, on_press: Option>>, elements: Vec, @@ -111,6 +113,8 @@ impl Button { enabled: true, focusable: true, alt: None, + tooltip: None, + tooltip_placement: TooltipPlacement::default(), on_press: None, elements: Vec::new(), key: DiffKey::None, @@ -203,6 +207,20 @@ impl Button { self } + pub fn tooltip(mut self, tooltip: impl Into>) -> Self { + let tooltip = tooltip.into(); + if self.alt.is_none() { + self.alt = Some(tooltip.clone()); + } + self.tooltip = Some(tooltip); + self + } + + pub fn tooltip_placement(mut self, placement: TooltipPlacement) -> Self { + self.tooltip_placement = placement; + self + } + pub fn cursor_icon(mut self, cursor_icon: CursorIcon) -> Self { self.cursor_icon = cursor_icon; self @@ -247,6 +265,8 @@ impl Component for Button { let a11y_id = use_a11y(); let focus = use_focus(a11y_id); + let tooltip = use_tooltip_anchor(self.tooltip.clone(), self.tooltip_placement); + let enabled = use_reactive(&self.enabled); let cursor_icon = self.cursor_icon; @@ -335,7 +355,7 @@ impl Component for Button { rect = rect.cursor(CursorIcon::NotAllowed); } - rect + tooltip.attach(rect) } fn render_key(&self) -> DiffKey { @@ -444,6 +464,7 @@ pub fn open_folder_button(folder: std::path::PathBuf) -> Button { Button::new() .secondary() .icon() + .tooltip("Open folder") .on_press(move |_| { std::fs::create_dir_all(&folder).ok(); crate::platform::open_path(&folder.to_string_lossy()); diff --git a/packages/oneclient_app/src/components/mod.rs b/packages/oneclient_app/src/components/mod.rs index 61f22f57..081cbfd5 100644 --- a/packages/oneclient_app/src/components/mod.rs +++ b/packages/oneclient_app/src/components/mod.rs @@ -42,6 +42,7 @@ mod tab_bar; mod text_input; mod toasts; mod toggle; +mod tooltip; mod update_prompt; mod version_card; pub mod upload_mclogs; @@ -94,5 +95,6 @@ pub use tab_bar::{TabBar, TabItem}; pub use text_input::{TextInput, validate_memory, validate_number}; pub use toasts::Toasts; pub use toggle::{toggle, toggle_controlled}; +pub use tooltip::{TooltipHost, TooltipPlacement, use_provide_tooltips}; pub use update_prompt::UpdatePromptOverlay; pub use version_card::VersionCard; diff --git a/packages/oneclient_app/src/components/navbar/app_navbar.rs b/packages/oneclient_app/src/components/navbar/app_navbar.rs index fca7e697..32fd70ab 100644 --- a/packages/oneclient_app/src/components/navbar/app_navbar.rs +++ b/packages/oneclient_app/src/components/navbar/app_navbar.rs @@ -261,17 +261,20 @@ impl Component for NavbarRight { .spacing(8.) .child( super::navbar_button() + .tooltip("Notifications") .child(notification_bell(unread)) .on_press(open_notifications), ) .child( super::navbar_button() + .tooltip("Settings") .child(Icon::new(IconType::Settings02).size(20.)) .on_press(open_settings), ) .child( super::navbar_button() .padding(0.0) + .tooltip("Switch account") .on_press(open_account_switcher) .child( Avatar::new(account_uuid) diff --git a/packages/oneclient_app/src/components/navbar/mod.rs b/packages/oneclient_app/src/components/navbar/mod.rs index 55b87014..5fba5620 100644 --- a/packages/oneclient_app/src/components/navbar/mod.rs +++ b/packages/oneclient_app/src/components/navbar/mod.rs @@ -44,16 +44,19 @@ pub fn window_controls() -> impl IntoElement { .layer(Layer::OverlayLevel(15)) .child( navbar_button() + .tooltip("Minimize") .child(Icon::new(IconType::Minus)) .on_press(minimize), ) .child( navbar_button() + .tooltip("Maximize") .child(Icon::new(IconType::Maximize01).size(20.)) .on_press(maximize), ) .child( navbar_button() + .tooltip("Close") .child(Icon::new(IconType::X)) .on_press(close), ) diff --git a/packages/oneclient_app/src/components/pagination.rs b/packages/oneclient_app/src/components/pagination.rs index ef5a7640..5e9f1211 100644 --- a/packages/oneclient_app/src/components/pagination.rs +++ b/packages/oneclient_app/src/components/pagination.rs @@ -1,6 +1,6 @@ use freya::prelude::*; -use crate::components::{Button, Icon, IconType}; +use crate::components::{Button, Icon, IconType, TooltipPlacement}; use crate::theme::colors; use crate::ui::border_all_color; @@ -52,13 +52,18 @@ impl Component for Pagination { let current = *page.read(); let last = total_pages.saturating_sub(1); - let icon_btn = move |target: usize, available: bool, icon: IconType| { + let icon_btn = move |target: usize, + available: bool, + icon: IconType, + tooltip: &'static str| { let mut page = page; let enabled = available && interactive; Button::new() .secondary() .icon() .enabled(enabled) + .tooltip(tooltip) + .tooltip_placement(TooltipPlacement::Top) .on_press(move |_| page.set(target)) .child(Icon::new(icon).size(14.).color(if enabled { colors::fg_primary() @@ -130,11 +135,17 @@ impl Component for Pagination { .main_align(Alignment::Center) .cross_align(Alignment::Center) .spacing(6.) - .child(icon_btn(0, current > 0, IconType::ChevronsLeft)) + .child(icon_btn( + 0, + current > 0, + IconType::ChevronsLeft, + "First page", + )) .child(icon_btn( current.saturating_sub(1), current > 0, IconType::ArrowLeft, + "Previous page", )); if start > 0 { @@ -155,7 +166,17 @@ impl Component for Pagination { row = row.child(num_btn(last)); } - row.child(icon_btn(current + 1, current < last, IconType::ArrowRight)) - .child(icon_btn(last, current < last, IconType::ChevronsRight)) + row.child(icon_btn( + current + 1, + current < last, + IconType::ArrowRight, + "Next page", + )) + .child(icon_btn( + last, + current < last, + IconType::ChevronsRight, + "Last page", + )) } } diff --git a/packages/oneclient_app/src/components/screenshot_viewer.rs b/packages/oneclient_app/src/components/screenshot_viewer.rs index 3a9ee50c..5caae228 100644 --- a/packages/oneclient_app/src/components/screenshot_viewer.rs +++ b/packages/oneclient_app/src/components/screenshot_viewer.rs @@ -324,6 +324,7 @@ fn header_row( Button::new() .ghost() .icon() + .tooltip("Close") .on_press(on_close) .child(Icon::new(IconType::XClose).size(18.)), ) diff --git a/packages/oneclient_app/src/components/toasts.rs b/packages/oneclient_app/src/components/toasts.rs index a3cbaa91..8a63f263 100644 --- a/packages/oneclient_app/src/components/toasts.rs +++ b/packages/oneclient_app/src/components/toasts.rs @@ -11,7 +11,7 @@ use oneclient_events::Level; use crate::{ ui::{divider}, - components::{Button, ButtonSize, Icon, IconType, progress_track}, + components::{Button, ButtonSize, Icon, IconType, TooltipPlacement, progress_track}, hooks::{use_dispatch, use_notifications_snapshot}, motion::use_animations_enabled, notifications::{InboxEntry, NotificationActionKind}, @@ -328,6 +328,8 @@ fn close_button(dispatch: crate::Actions, id: u64) -> impl IntoElement { .size(ButtonSize::Icon) .width(Size::px(28.)) .height(Size::px(28.)) + .tooltip("Dismiss") + .tooltip_placement(TooltipPlacement::Left) .on_press(move |_| dispatch.dismiss_toast(id)) .child( Icon::new(IconType::XClose) diff --git a/packages/oneclient_app/src/components/tooltip.rs b/packages/oneclient_app/src/components/tooltip.rs new file mode 100644 index 00000000..029cd694 --- /dev/null +++ b/packages/oneclient_app/src/components/tooltip.rs @@ -0,0 +1,315 @@ +use std::cell::Cell; +use std::rc::Rc; +use std::time::Duration; + +use freya::animation::{AnimNum, Ease, OnCreation, use_animation}; +use freya::prelude::*; + +use crate::components::OVERLAY_MAX_LEVEL; +use crate::theme::colors; +use crate::ui::{border_all_color, clamp_to_window, window_logical_size}; + +const SHOW_DELAY: Duration = Duration::from_millis(400); +const ANCHOR_GAP: f32 = 6.; +const MAX_WIDTH: f32 = 260.; +const FADE_MS: u64 = 110; +const HOVER_SLACK: f32 = 1.; + +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum TooltipPlacement { + Top, + #[default] + Bottom, + Left, + Right, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +struct AnchorRect { + x: f32, + y: f32, + width: f32, + height: f32, +} + +impl AnchorRect { + fn contains(&self, x: f32, y: f32) -> bool { + x >= self.x - HOVER_SLACK + && x <= self.x + self.width + HOVER_SLACK + && y >= self.y - HOVER_SLACK + && y <= self.y + self.height + HOVER_SLACK + } +} + +#[derive(Clone, PartialEq)] +struct ActiveTooltip { + owner: u64, + text: Box, + anchor: AnchorRect, + placement: TooltipPlacement, +} + +#[derive(Clone)] +struct Tooltips { + active: State>, + next_owner: Rc>, +} + +pub fn use_provide_tooltips() { + let active = use_state(|| None::); + let next_owner = use_hook(|| Rc::new(Cell::new(0u64))); + use_provide_root_context(move || Tooltips { active, next_owner }); +} + +#[derive(Clone)] +struct AnchorState { + tooltips: Tooltips, + owner: u64, + area: Rc>, + generation: Rc>, + text: Box, + placement: TooltipPlacement, +} + +pub struct TooltipAnchor(Option); + +pub fn use_tooltip_anchor(text: Option>, placement: TooltipPlacement) -> TooltipAnchor { + let tooltips = use_hook(try_consume_root_context::); + let owner = use_hook(|| { + tooltips.as_ref().map_or(0, |tooltips| { + let owner = tooltips.next_owner.get() + 1; + tooltips.next_owner.set(owner); + owner + }) + }); + let area = use_hook(|| Rc::new(Cell::new(AnchorRect::default()))); + let generation = use_hook(|| Rc::new(Cell::new(0u64))); + + let abandoned = generation.clone(); + use_drop(move || abandoned.set(abandoned.get() + 1)); + + TooltipAnchor(tooltips.zip(text).map(|(tooltips, text)| AnchorState { + tooltips, + owner, + area, + generation, + text, + placement, + })) +} + +impl TooltipAnchor { + pub fn attach(&self, rect: Rect) -> Rect { + let Some(state) = self.0.clone() else { + return rect; + }; + + let sized = state.clone(); + let enter = state.clone(); + let leave = state.clone(); + let pressed = state.clone(); + let scrolled = state; + + rect.on_sized(move |e: Event| { + let area = e.area; + sized.area.set(AnchorRect { + x: area.min_x(), + y: area.min_y(), + width: area.width(), + height: area.height(), + }); + }) + .on_pointer_enter(move |e: Event| { + let global = e.global_location(); + let element = e.element_location(); + + let mut anchor = enter.area.get(); + anchor.x = (global.x - element.x) as f32; + anchor.y = (global.y - element.y) as f32; + + let generation = enter.generation.get() + 1; + enter.generation.set(generation); + + let pending = enter.generation.clone(); + let mut active = enter.tooltips.active; + let tooltip = ActiveTooltip { + owner: enter.owner, + text: enter.text.clone(), + anchor, + placement: enter.placement, + }; + + spawn(async move { + tokio::time::sleep(SHOW_DELAY).await; + if pending.get() == generation { + active.set(Some(tooltip)); + } + }); + }) + .on_pointer_leave(move |_| hide(&leave)) + .on_mouse_down(move |_| hide(&pressed)) + .on_wheel(move |_| hide(&scrolled)) + } +} + +fn hide(state: &AnchorState) { + state.generation.set(state.generation.get() + 1); + clear_owner(state.tooltips.active, state.owner); +} + +fn clear_owner(mut active: State>, owner: u64) { + let shown = active.peek().as_ref().map(|tooltip| tooltip.owner); + if shown == Some(owner) { + active.set(None); + } +} + +#[derive(PartialEq)] +pub struct TooltipHost; + +impl Component for TooltipHost { + fn render(&self) -> impl IntoElement { + let Some(tooltips) = use_hook(try_consume_root_context::) else { + return rect(); + }; + + let state = tooltips.active; + let active = (*state.read()).clone(); + + let mut host = rect() + .layer(Layer::OverlayLevel(OVERLAY_MAX_LEVEL)) + .position(Position::new_global().top(0.).left(0.)) + .width(Size::window_percent(100.)) + .height(Size::window_percent(100.)) + .interactive(false); + + if let Some((owner, anchor)) = active.as_ref().map(|active| (active.owner, active.anchor)) { + host = host.on_global_pointer_move(move |e: Event| { + let cursor = e.global_location(); + if !anchor.contains(cursor.x as f32, cursor.y as f32) { + clear_owner(state, owner); + } + }); + } + + host.maybe_child(active.map(|active| { + let owner = active.owner; + TooltipBubble { + text: active.text, + anchor: active.anchor, + placement: active.placement, + key: DiffKey::None, + } + .key(owner) + .into_element() + })) + } +} + +#[derive(PartialEq)] +struct TooltipBubble { + text: Box, + anchor: AnchorRect, + placement: TooltipPlacement, + key: DiffKey, +} + +impl KeyExt for TooltipBubble { + fn write_key(&mut self) -> &mut DiffKey { + &mut self.key + } +} + +impl Component for TooltipBubble { + fn render(&self) -> impl IntoElement { + let mut size = use_state(Size2D::zero); + + let measured = *size.read(); + let placed = measured.width > 0.; + + let fade = use_animation(|conf| { + conf.on_creation(OnCreation::Run); + AnimNum::new(0., 1.).time(FADE_MS).ease(Ease::Out) + }); + + let (x, y) = place(self.anchor, self.placement, measured); + + rect() + .position(Position::new_global().top(y).left(x)) + .interactive(false) + .opacity(if placed { fade.read().value() } else { 0. }) + .max_width(Size::px(MAX_WIDTH)) + .padding(Gaps::new_symmetric(5., 9.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(colors::page_elevated()) + .border(border_all_color(1., colors::component_border())) + .on_sized(move |e: Event| { + let bubble = e.area.size; + let previous = *size.peek(); + if (bubble.width - previous.width).abs() > 0.5 + || (bubble.height - previous.height).abs() > 0.5 + { + size.set(bubble); + } + }) + .child( + label() + .text(self.text.to_string()) + .font_size(12.) + .font_weight(FontWeight::MEDIUM) + .max_lines(2) + .color(colors::fg_primary()), + ) + } + + fn render_key(&self) -> DiffKey { + self.key.clone().or(self.default_key()) + } +} + +fn place(anchor: AnchorRect, placement: TooltipPlacement, size: Size2D) -> (f32, f32) { + let window = window_logical_size(); + + let centered_x = anchor.x + (anchor.width - size.width) / 2.; + let centered_y = anchor.y + (anchor.height - size.height) / 2.; + + let above = anchor.y - size.height - ANCHOR_GAP; + let below = anchor.y + anchor.height + ANCHOR_GAP; + let before = anchor.x - size.width - ANCHOR_GAP; + let after = anchor.x + anchor.width + ANCHOR_GAP; + + let fits_above = above >= 0.; + let fits_below = below + size.height <= window.height; + let fits_before = before >= 0.; + let fits_after = after + size.width <= window.width; + + let (x, y) = match placement { + TooltipPlacement::Top => ( + centered_x, + if fits_above || !fits_below { above } else { below }, + ), + TooltipPlacement::Bottom => ( + centered_x, + if fits_below || !fits_above { below } else { above }, + ), + TooltipPlacement::Left => ( + if fits_before || !fits_after { + before + } else { + after + }, + centered_y, + ), + TooltipPlacement::Right => ( + if fits_after || !fits_before { + after + } else { + before + }, + centered_y, + ), + }; + + clamp_to_window(x, y, size.width, size.height) +} diff --git a/packages/oneclient_app/src/layout/cluster_shell.rs b/packages/oneclient_app/src/layout/cluster_shell.rs index a5458e7e..8f619cff 100644 --- a/packages/oneclient_app/src/layout/cluster_shell.rs +++ b/packages/oneclient_app/src/layout/cluster_shell.rs @@ -274,6 +274,7 @@ fn cluster_header( Button::new() .danger() .icon() + .tooltip("Stop game") .on_press(move |_| kill_dispatch.kill_cluster(cluster_id)) .child(Icon::new(IconType::Square).size(16.)), ) diff --git a/packages/oneclient_app/src/layout/root_layout.rs b/packages/oneclient_app/src/layout/root_layout.rs index 85a5c7fc..a291e302 100644 --- a/packages/oneclient_app/src/layout/root_layout.rs +++ b/packages/oneclient_app/src/layout/root_layout.rs @@ -3,8 +3,8 @@ use freya::router::*; use crate::components::{ AccountSwitcher, ClusterUpdatePopup, GenericPromptOverlay, JavaPromptOverlay, OptionalModsPopup, MicrosoftJavaPromptOverlay, - NotificationCenter, PackageUpdatePopup, SplashCurtain, StatusBar, Toasts, - UpdatePromptOverlay, + NotificationCenter, PackageUpdatePopup, SplashCurtain, StatusBar, Toasts, TooltipHost, + UpdatePromptOverlay, use_provide_tooltips, }; use crate::hooks::{SplashState, use_provide_overlay_claims, use_provide_splash}; #[cfg(not(target_os = "macos"))] @@ -24,6 +24,7 @@ impl Component for RootLayout { let home_ready = use_state(|| false); use_provide_splash(SplashState { active, home_ready }); use_provide_overlay_claims(); + use_provide_tooltips(); // macOS rounds the window natively so Freya must not round on top of it #[cfg(target_os = "macos")] @@ -83,6 +84,7 @@ impl Component for RootLayout { .child(OptionalModsPopup) .child(PackageUpdatePopup) .child(StatusBar) + .child(TooltipHost) .child(SplashCurtain) .child(AnimationClockDriver) .child(HomeArtPrefetch) diff --git a/packages/oneclient_app/src/ui.rs b/packages/oneclient_app/src/ui.rs index e79f3b7c..8715856e 100644 --- a/packages/oneclient_app/src/ui.rs +++ b/packages/oneclient_app/src/ui.rs @@ -214,3 +214,14 @@ impl ImageFallbackExt for ImageViewer { self.error_renderer(move |_: String| placeholder.clone()) } } + +pub fn window_logical_size() -> Size2D { + let platform = Platform::get(); + let scale = *platform.scale_factor.peek() as f32; + let size = *platform.root_size.peek(); + if scale <= 0. { + return size; + } + + Size2D::new(size.width / scale, size.height / scale) +} diff --git a/packages/oneclient_app/src/view/app/browser/package/gallery.rs b/packages/oneclient_app/src/view/app/browser/package/gallery.rs index 0183aa00..74736f58 100644 --- a/packages/oneclient_app/src/view/app/browser/package/gallery.rs +++ b/packages/oneclient_app/src/view/app/browser/package/gallery.rs @@ -352,6 +352,7 @@ fn header_row( Button::new() .ghost() .icon() + .tooltip("Close") .on_press(on_close) .child(Icon::new(IconType::XClose).size(18.)), ) diff --git a/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs b/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs index 3d1de122..78fe3fe3 100644 --- a/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs +++ b/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs @@ -185,6 +185,11 @@ fn reset_button(overridden: bool, on_reset: EventHandler<()>) -> impl IntoElemen .small() .ghost() .icon() + .tooltip(if overridden { + "Reset to the global setting" + } else { + "Already using the global setting" + }) .corner_radius(CornerRadius::new_all(7.)) .maybe(overridden, |el| el.on_press(move |_| on_reset.call(()))) .enabled(overridden) diff --git a/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs b/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs index c2b82416..23cc80e6 100644 --- a/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs +++ b/packages/oneclient_app/src/view/app/cluster/package_manager/views.rs @@ -296,6 +296,7 @@ impl Component for FilterButton { Button::new() .secondary() .icon() + .tooltip("Sort and filter") .width(Size::px(FILTER_BTN_W)) .height(Size::px(34.)) .on_press(move |e: Event| { diff --git a/packages/oneclient_app/src/view/app/settings/accounts.rs b/packages/oneclient_app/src/view/app/settings/accounts.rs index 79765e6c..57b23773 100644 --- a/packages/oneclient_app/src/view/app/settings/accounts.rs +++ b/packages/oneclient_app/src/view/app/settings/accounts.rs @@ -523,6 +523,7 @@ impl Component for AccountRow { Button::new() .ghost() .icon() + .tooltip("Refresh this account") .on_press(move |e: Event| { e.stop_propagation(); if *refreshing.peek() { @@ -548,7 +549,12 @@ impl Component for AccountRow { .into_element() })) .child( - Button::new().ghost().icon().enabled(false).child( + Button::new() + .ghost() + .icon() + .enabled(false) + .tooltip("Edit") + .child( Icon::new(IconType::Pencil01) .size(18.) .color(colors::fg_secondary()), @@ -558,6 +564,7 @@ impl Component for AccountRow { Button::new() .ghost() .icon() + .tooltip("Remove account") .on_press(move |e: Event| { e.stop_propagation(); remove.mutate(RemoveAccountKeys { id });