OneLauncher-Cracked/packages/oneclient_app/src/components/notifications.rs

501 lines
15 KiB
Rust

use freya::{
animation::{AnimNum, Ease, OnCreation, use_animation},
prelude::*,
router::RouterContext,
};
use oneclient_core::notification::NotificationLevel;
use crate::{
Route,
components::{Button, ButtonVariant, Icon, IconType, OverlayPopup, ScrollArea},
hooks::{use_dispatch, use_notifications_snapshot},
notifications::{InboxEntry, NotificationActionKind},
theme::colors,
};
#[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 entries = inbox.len();
let mut rows: Vec<Element> = Vec::new();
if entries == 0 {
rows.push(
label()
.text("No notifications")
.font_size(13.)
.color(colors::fg_secondary())
.into_element(),
);
} else {
let last = entries - 1;
for (i, entry) in inbox.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.)
.on_pointer_enter(|_| Cursor::set(CursorIcon::Pointer))
.on_pointer_leave(|_| Cursor::set(CursorIcon::default()))
.on_press(move |_| {
let now = !*expanded.peek();
expanded.set(now);
})
.child(
Icon::new(if is_expanded {
IconType::ChevronDown
} else {
IconType::ChevronRight
})
.size(14.)
.color(colors::fg_secondary()),
)
.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()),
)
.child(
label()
.text(format!("{percent}%"))
.font_size(10.)
.color(colors::fg_secondary()),
)
.into_element()
}
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::BridgeDispatch) -> 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))
.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::BridgeDispatch) -> 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);
return rect()
.width(Size::fill())
.height(Size::px(6.))
.margin(Gaps::new(8., 0., 0., 0.))
.corner_radius(CornerRadius::new_all(50.))
.background(colors::brand().with_a(128))
.child(
rect()
.width(Size::percent(frac * 100.0))
.height(Size::px(6.))
.corner_radius(CornerRadius::new_all(50.))
.background(colors::brand()),
)
.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 run_action(dispatch: &crate::BridgeDispatch, kind: &NotificationActionKind) {
match kind {
NotificationActionKind::OpenClusterUpdate(summary) => {
dispatch.open_cluster_update(summary.clone());
}
}
}
fn divider() -> impl IntoElement {
rect()
.width(Size::fill())
.height(Size::px(1.))
.background(colors::component_border())
}
#[derive(PartialEq)]
struct Footer;
impl Component for Footer {
fn render(&self) -> impl IntoElement {
let dispatch = use_dispatch();
let clear_dispatch = dispatch.clone();
let open_settings = move |_| {
dispatch.close_notification_center();
let _ = RouterContext::get().push(Route::SettingsAppearance {});
};
rect()
.horizontal()
.width(Size::fill())
.content(Content::Flex)
.cross_align(Alignment::Center)
.main_align(Alignment::SpaceBetween)
.child(
Button::new()
.ghost()
.small()
.on_press(move |_| clear_dispatch.clear_notification_inbox())
.child(
Icon::new(IconType::Trash01)
.size(16.)
.color(colors::fg_primary()),
)
.text("Clear all"),
)
.child(
Button::new()
.ghost()
.icon()
.on_press(open_settings)
.child(Icon::new(IconType::Settings02).size(18.)),
)
}
}
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 {
NotificationLevel::Error => IconType::AlertTriangle,
NotificationLevel::Info => IconType::InfoCircle,
}
}
fn level_color(level: &NotificationLevel) -> Color {
match level {
NotificationLevel::Info => colors::fg_primary(),
NotificationLevel::Error => colors::danger(),
}
}
fn relative_time(entry: &InboxEntry) -> String {
let secs = entry.created_at.elapsed().as_secs();
if secs < 60 {
"Just now".to_string()
} else if secs < 3600 {
format!("{}m ago", secs / 60)
} else if secs < 86400 {
format!("{}h ago", secs / 3600)
} else {
format!("{}d ago", secs / 86400)
}
}