OneLauncher-Cracked/packages/oneclient_app/src/components/notifications.rs
sakuyma 45a970c38a
Small QoL fixes: Java refresh, clipboard toasts, custom image endpoints (#852)
* fix(java): add refresh button for stale runtimes

* fix(notifications): keep clipboard toasts out of the notification center

* fix(images): allow custom endpoints on non-public hosts

* chore: make toast_only notifications not increment notification counter

* chore: reorder navbar links

---------

Co-authored-by: LynithDev <61880709+LynithDev@users.noreply.github.com>
2026-09-18 23:43:49 +02:00

533 lines
16 KiB
Rust

use freya::{
animation::{AnimNum, Ease, OnCreation, use_animation},
prelude::*,
};
use oneclient_events::Level;
use crate::{
components::{Button, ButtonVariant, Icon, IconType, OverlayPopup, ScrollArea, progress_track},
hooks::{use_dispatch, use_notifications_snapshot},
notifications::{InboxEntry, NotificationActionKind},
theme::colors,
transfer::TransferStats,
ui::{divider, relative_time},
utils::{format_durations, format_size},
};
#[derive(PartialEq)]
pub struct NotificationCenter;
impl Component for NotificationCenter {
fn render(&self) -> impl IntoElement {
let open = use_notifications_snapshot().center_open;
let dispatch = use_dispatch();
if !open {
return rect().into_element();
}
OverlayPopup::new()
.position(Position::new_global().top(72.).right(40.))
.on_close(move |()| dispatch.close_notification_center())
.child(NotificationPanel)
.into_element()
}
}
#[derive(PartialEq)]
struct NotificationPanel;
impl Component for NotificationPanel {
fn render(&self) -> impl IntoElement {
let inbox = use_notifications_snapshot().inbox;
let intro = use_animation(|conf| {
conf.on_creation(OnCreation::Run);
AnimNum::new(0., 1.).time(200).ease(Ease::Out)
});
let progress = intro.read().value();
let mut rows: Vec<Element> = Vec::new();
// Toast-only notices are ephemeral: never surface them in the center
let visible: Vec<InboxEntry> = inbox
.into_iter()
.filter(|entry| !entry.toast_only)
.collect();
if visible.is_empty() {
rows.push(
label()
.text("No notifications")
.font_size(13.)
.color(colors::fg_secondary())
.into_element(),
);
} else {
let last = visible.len() - 1;
for (i, entry) in visible.into_iter().enumerate() {
let id = entry.id;
rows.push(NotifEntryRow::new(entry, i != last).key(id).into_element());
}
}
let content = ScrollArea::new()
.width(Size::fill())
.height(Size::flex(1.0))
.spacing(8.)
.children(rows);
rect()
.vertical()
.content(Content::Flex)
.width(Size::px(368.))
.height(Size::px(480.))
.padding(Gaps::new_all(12.))
.spacing(8.)
.opacity(progress)
.margin(Gaps::new((1.0 - progress) * -8.0, 0., 0., 0.))
.background(colors::page_elevated().with_a(220))
.blur(12.)
.corner_radius(CornerRadius::new_all(12.))
.border(
Border::new()
.fill(colors::component_border())
.width(1.)
.alignment(BorderAlignment::Inner),
)
.shadow(Shadow::from((
0.,
8.,
32.,
0.,
Color::from_argb(120, 0, 0, 0),
)))
.child(
label()
.text("Notifications")
.font_size(18.)
.font_weight(FontWeight::MEDIUM)
.a11y_role(AccessibilityRole::TitleBar),
)
.child(divider())
.child(content)
.child(divider())
.child(Footer)
}
}
#[derive(PartialEq)]
struct NotifEntryRow {
entry: InboxEntry,
show_divider: bool,
key: DiffKey,
}
impl KeyExt for NotifEntryRow {
fn write_key(&mut self) -> &mut DiffKey {
&mut self.key
}
}
impl NotifEntryRow {
fn new(entry: InboxEntry, show_divider: bool) -> Self {
Self {
entry,
show_divider,
key: DiffKey::None,
}
}
}
impl Component for NotifEntryRow {
fn render(&self) -> impl IntoElement {
let entry = self.entry.clone();
let id = entry.id;
let dispatch = use_dispatch();
let enter_dispatch = dispatch.clone();
let body_dispatch = dispatch.clone();
let click_dismissable = entry.click_dismissable();
let has_actions = !entry.actions.is_empty();
let mut hovered = use_state(|| false);
let expanded = use_state(|| false);
let content = rect()
.horizontal()
.width(Size::fill())
.spacing(13.)
.cross_align(Alignment::Start)
.padding(Gaps::new_all(8.))
.corner_radius(CornerRadius::new_all(8.))
.maybe(*hovered.read() && !has_actions, |el| {
el.background(colors::ghost_overlay_hover())
})
.on_pointer_enter(move |_| {
hovered.set(true);
enter_dispatch.mark_notification_read(id);
})
.on_pointer_leave(move |_| hovered.set(false))
.maybe(click_dismissable, |el| {
el.on_press(move |_| dispatch.dismiss_notification(id))
})
.child(
rect()
.width(Size::px(32.))
.height(Size::px(32.))
.corner_radius(CornerRadius::new_all(6.))
.center()
.child(
Icon::new(icon_for(&entry))
.size(24.)
.color(level_color(&entry.level)),
),
)
.child(row_body(&entry, body_dispatch));
rect()
.vertical()
.width(Size::fill())
.spacing(8.)
.child(content)
.maybe(!entry.tasks.is_empty(), |el| {
el.child(tasks_section(&entry, expanded))
})
.maybe(self.show_divider, |el| el.child(divider()))
}
}
const TASK_LIMIT: usize = 12;
fn tasks_section(entry: &InboxEntry, mut expanded: State<bool>) -> impl IntoElement {
let is_expanded = *expanded.read();
let count = entry.tasks.len();
let mut section = rect()
.vertical()
.width(Size::fill())
.spacing(6.)
.margin(Gaps::new(2., 0., 0., 44.))
.child(
rect()
.horizontal()
.cross_align(Alignment::Center)
.spacing(6.)
.cursor(CursorIcon::Pointer)
.on_press(move |_| {
let now = !*expanded.peek();
expanded.set(now);
})
.child(ChevronToggle {
expanded: is_expanded,
})
.child(
label()
.text(if is_expanded {
"Hide tasks".to_string()
} else {
format!("Show {count} tasks")
})
.font_size(11.)
.color(colors::fg_secondary()),
),
);
if is_expanded {
let mut list = rect().vertical().width(Size::fill()).spacing(4.);
for task in entry.tasks.iter().take(TASK_LIMIT) {
list = list.child(task_row(task));
}
if count > TASK_LIMIT {
list = list.child(
label()
.text(format!("+{} more", count - TASK_LIMIT))
.font_size(10.)
.color(colors::fg_secondary()),
);
}
section = section.child(list);
}
section.into_element()
}
fn task_row(task: &crate::notifications::TaskView) -> impl IntoElement {
let percent = ((task.current as f32 / task.total.max(1) as f32) * 100.0).round() as u64;
rect()
.horizontal()
.width(Size::fill())
.cross_align(Alignment::Center)
.spacing(8.)
.content(Content::Flex)
.child(phase_badge(task.phase))
.child(
label()
.text(task.label.clone())
.font_size(11.)
.max_lines(1)
.width(Size::flex(1.0))
.color(colors::fg_primary()),
)
.maybe(task.total_count > 1, |el| {
el.child(
label()
.text(format!("{}/{}", task.done_count, task.total_count))
.font_size(10.)
.color(colors::fg_secondary()),
)
})
.child(
label()
.text(format!("{percent}%"))
.font_size(10.)
.color(colors::fg_secondary()),
)
.into_element()
}
#[derive(PartialEq)]
struct ChevronToggle {
expanded: bool,
}
impl Component for ChevronToggle {
fn render(&self) -> impl IntoElement {
let expanded = self.expanded;
let anim = use_animation(|_| AnimNum::new(0., 90.).time(160).ease(Ease::Out));
use_side_effect_with_deps(&expanded, move |&expanded| {
let mut anim = anim;
if expanded {
anim.start();
} else {
anim.reverse();
}
});
let deg = anim.get().value();
rect().rotate(deg).child(
Icon::new(IconType::ChevronRight)
.size(14.)
.color(colors::fg_secondary()),
)
}
}
fn phase_badge(phase: &str) -> impl IntoElement {
let color = match phase {
"Verifying" => colors::code_warn(),
"Extracting" => colors::success(),
"Installing" => colors::brand(),
_ => colors::brand(),
};
rect()
.center()
.padding(Gaps::new_symmetric(1., 7.))
.corner_radius(CornerRadius::new_all(999.))
.background(color.with_a(40))
.child(
label()
.text(phase.to_string())
.font_size(9.)
.font_weight(FontWeight::MEDIUM)
.color(color),
)
.into_element()
}
fn row_body(entry: &InboxEntry, dispatch: crate::Actions) -> impl IntoElement {
rect()
.vertical()
.width(Size::flex(1.0))
.spacing(2.)
.child(
rect()
.horizontal()
.width(Size::fill())
.content(Content::Flex)
.cross_align(Alignment::Start)
.child(
label()
.text(entry.title.clone())
.font_size(14.)
.font_weight(FontWeight::MEDIUM)
.color(level_color(&entry.level))
.width(Size::flex(1.0)),
)
.child(
rect()
.horizontal()
.cross_align(Alignment::Center)
.spacing(6.)
.child(
label()
.text(relative_time(entry.created_at))
.font_size(10.)
.color(colors::fg_secondary()),
)
.maybe(!entry.read, |el| {
el.child(
rect()
.width(Size::px(6.))
.height(Size::px(6.))
.corner_radius(CornerRadius::new_all(3.))
.background(colors::brand()),
)
}),
),
)
.child(
label()
.text(entry.body.clone())
.font_size(12.)
.color(colors::fg_secondary()),
)
.child(row_extra(entry, dispatch))
}
fn row_extra(entry: &InboxEntry, dispatch: crate::Actions) -> impl IntoElement {
if let Some((current, total)) = entry.progress.filter(|_| entry.is_loading) {
let frac = (current as f32 / total.max(1) as f32).clamp(0.0, 1.0);
let bar = progress_track(
frac * 100.0,
6.,
colors::brand(),
colors::brand().with_a(128),
);
return rect()
.vertical()
.width(Size::fill())
.spacing(5.)
.margin(Gaps::new(8., 0., 0., 0.))
.child(bar)
.maybe_child(entry.transfer.map(transfer_footer))
.into_element();
}
if !entry.actions.is_empty() {
let id = entry.id;
let dismiss_dispatch = dispatch.clone();
let mut row = rect()
.horizontal()
.width(Size::fill())
.content(Content::Flex)
.cross_align(Alignment::Center)
.spacing(8.)
.margin(Gaps::new(8., 0., 0., 0.))
.child(
Button::new()
.small()
.secondary()
.width(Size::flex(1.0))
.text("Dismiss")
.on_press(move |_| dismiss_dispatch.dismiss_notification(id)),
);
for (i, action) in entry.actions.iter().enumerate() {
let action_dispatch = dispatch.clone();
let kind = action.kind.clone();
row = row.child(
Button::new()
.small()
.width(Size::flex(1.0))
.text(action.label.clone())
.variant(if i == 0 {
ButtonVariant::Primary
} else {
ButtonVariant::Secondary
})
.on_press(move |_| run_action(&action_dispatch, &kind)),
);
}
return row.into_element();
}
rect().into_element()
}
fn transfer_footer(stats: TransferStats) -> Element {
let speed = format!("{}/s", format_size(stats.speed_bps as u64));
let eta = stats
.eta_secs
.map(|secs| format!("{} left", format_durations(secs as i64)));
rect()
.horizontal()
.width(Size::fill())
.content(Content::Flex)
.cross_align(Alignment::Center)
.main_align(Alignment::SpaceBetween)
.child(
label()
.text(speed)
.font_size(10.)
.color(colors::fg_secondary()),
)
.maybe_child(eta.map(|eta| {
label()
.text(eta)
.font_size(10.)
.color(colors::fg_secondary())
.into_element()
}))
.into_element()
}
fn run_action(dispatch: &crate::Actions, kind: &NotificationActionKind) {
match kind {
NotificationActionKind::OpenClusterUpdate(summaries) => {
dispatch.open_cluster_update(summaries.clone());
}
}
}
#[derive(PartialEq)]
struct Footer;
impl Component for Footer {
fn render(&self) -> impl IntoElement {
let dispatch = use_dispatch();
let is_empty = use_notifications_snapshot().inbox.is_empty();
rect()
.horizontal()
.width(Size::fill())
.content(Content::Flex)
.cross_align(Alignment::Center)
.child(
Button::new()
.ghost()
.small()
.disabled(is_empty)
.on_press(move |_| dispatch.clear_notification_inbox())
.child(Icon::new(IconType::Trash01).size(16.).color(if is_empty {
colors::fg_primary_disabled()
} else {
colors::fg_primary()
}))
.text("Clear all"),
)
}
}
fn icon_for(entry: &InboxEntry) -> IconType {
if let Some(icon) = entry.icon {
return icon;
}
if entry.is_loading && entry.progress.is_some() {
return IconType::DownloadCloud02;
}
match entry.level {
Level::Error => IconType::AlertTriangle,
Level::Info => IconType::InfoCircle,
}
}
fn level_color(level: &Level) -> Color {
match level {
Level::Info => colors::fg_primary(),
Level::Error => colors::danger(),
}
}