Files
galaxy/app/src/settings_view/show_blocks_view.rs
T

814 lines
29 KiB
Rust

#![allow(dead_code, unused_imports, unused_variables)]
use super::{
settings_page::{
render_page_title, MatchData, PageType, SettingsPageMeta, SettingsPageViewHandle,
SettingsWidget, HEADER_FONT_SIZE, PAGE_PADDING,
},
SettingsSection,
};
use crate::auth::AuthStateProvider;
use crate::{
appearance::Appearance,
channel::{Channel, ChannelState},
menu::{Event as MenuEvent, Event, Menu, MenuItem, MenuItemFields},
server::{block::Block, server_api::block::BlockClient},
view_components::ToastFlavor,
};
use anyhow::Result;
use chrono::{DateTime, FixedOffset, Local};
use galaxy_core::ui::theme::color::internal_colors;
use galaxyui::ui_components::button::ButtonVariant;
use galaxyui::ui_components::components::{UiComponent, UiComponentStyles};
use galaxyui::{
clipboard::ClipboardContent,
elements::{
Align, ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
Dismiss, Expanded, Fill, Flex, Hoverable, Icon, MouseStateHandle, OffsetPositioning,
ParentAnchor, ParentElement, ParentOffsetBounds, PositionedElementAnchor,
PositionedElementOffsetBounds, SavePosition, ScrollStateHandle, Scrollable,
ScrollableElement, Shrinkable, Stack, UniformList, UniformListState,
},
};
use galaxyui::{color::ColorU, elements::Radius};
use galaxyui::{elements::ScrollbarWidth, fonts::Weight};
use galaxyui::{
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
use pathfinder_geometry::vector::vec2f;
use std::sync::Arc;
const SCROLLBAR_WIDTH: ScrollbarWidth = ScrollbarWidth::Auto;
const UNSHARE_BLOCK_CONFIRMATION_DIALOG_TEXT: &str =
"Are you sure you want to unshare this block?\n\
\nIt will no longer be accessible by link and will be permanently deleted from Warp servers.";
#[derive(Clone, Debug)]
struct UserOwnedBlock {
id: String,
command: String,
link_mouse_state_handle: MouseStateHandle,
copy_button_mouse_state_handle: MouseStateHandle,
overflow_button_mouse_state_handle: MouseStateHandle,
unshare_request_status: UnshareBlockRequestState,
time_started: DateTime<FixedOffset>,
}
impl From<Block> for UserOwnedBlock {
fn from(block: Block) -> Self {
UserOwnedBlock::new(
block.id.unwrap_or_default(),
block.command.unwrap_or_default(),
block.time_started_term,
)
}
}
impl UserOwnedBlock {
fn new(
id: impl Into<String>,
command: impl Into<String>,
time_started: DateTime<FixedOffset>,
) -> Self {
Self {
id: id.into(),
command: command.into(),
unshare_request_status: UnshareBlockRequestState::NotStarted,
link_mouse_state_handle: Default::default(),
copy_button_mouse_state_handle: Default::default(),
overflow_button_mouse_state_handle: Default::default(),
time_started,
}
}
fn is_shared(&self) -> bool {
!matches!(self.unshare_request_status, UnshareBlockRequestState::Done)
}
fn block_url(&self) -> String {
// New block IDs are 22 characters long and are accessible at /block/{id}, whereas as old
// (hashId) block IDs are 6 characters long and are accessible at /{id}.
let mut url = if self.id.len() == 22 {
format!(
"{}/block/{}",
ChannelState::server_root_url(),
self.id.as_str()
)
} else {
format!("{}/{}", ChannelState::server_root_url(), self.id.as_str())
};
// If this is a preview build, ensure the link routes to a preview build.
if matches!(ChannelState::channel(), Channel::Preview) {
url.push_str("?preview=true");
}
url
}
fn render_overflow_icon(&self, appearance: &Appearance, index: usize) -> Box<dyn Element> {
let mut hoverable =
Hoverable::new(self.overflow_button_mouse_state_handle.clone(), |state| {
let container = Container::new(
ConstrainedBox::new(
Icon::new("bundled/svg/overflow.svg", ColorU::new(179, 186, 184, 255))
.finish(),
)
.with_height(20.)
.with_width(20.)
.finish(),
)
.with_uniform_padding(4.)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(5.)));
let container = if state.is_clicked() || state.is_hovered() {
container.with_background(appearance.theme().surface_2())
} else {
container
};
container.finish()
});
// Disable the overflow button if the request is in flight since the user shouldn't be able
// to unshare it again.
if self.unshare_request_status == UnshareBlockRequestState::InFlight {
hoverable = hoverable.disable();
} else {
hoverable = hoverable.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(ShowBlocksAction::OverflowClick(index));
});
};
SavePosition::new(
hoverable.finish(),
format!("show_blocks_view:overflow_{index}").as_str(),
)
.finish()
}
fn copy_link_button(&self, appearance: &Appearance, block_url: String) -> Box<dyn Element> {
let button = appearance
.ui_builder()
.button(
ButtonVariant::Basic,
self.copy_button_mouse_state_handle.clone(),
)
.with_text_label("Copy link".into());
let button = if self.unshare_request_status == UnshareBlockRequestState::InFlight {
button.disabled().build()
} else {
button.build().on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(ShowBlocksAction::CopyUrl(block_url.clone()));
})
};
button.finish()
}
fn link_text(&self, appearance: &Appearance, block_url: String) -> Box<dyn Element> {
if self.unshare_request_status == UnshareBlockRequestState::InFlight {
appearance
.ui_builder()
.label("Deleting...")
.with_style(
UiComponentStyles::default()
.set_font_family_id(appearance.monospace_font_family())
.set_font_size(14.),
)
.build()
.finish()
} else {
appearance
.ui_builder()
.link(
block_url.clone(),
Some(block_url),
None,
self.link_mouse_state_handle.clone(),
)
.soft_wrap(false)
.with_style(UiComponentStyles::default().set_font_size(14.))
.build()
.finish()
}
}
fn render(&self, appearance: &Appearance, index: usize) -> Box<dyn Element> {
let block_url = self.block_url();
let command = appearance
.ui_builder()
.label(self.command.clone())
.with_style(UiComponentStyles {
font_size: Some(14.),
font_family_id: Some(appearance.monospace_font_family()),
font_weight: Some(Weight::Bold),
..Default::default()
})
.build()
.finish();
let command_row = Container::new(
Flex::row()
.with_child(Shrinkable::new(1., Align::new(command).left().finish()).finish())
.with_child(
Container::new(self.render_overflow_icon(appearance, index))
.with_padding_left(15.)
.finish(),
)
.finish(),
)
.finish();
let url_row = Container::new(
Flex::row()
.with_child(
Shrinkable::new(
1.,
Container::new(self.link_text(appearance, block_url.clone())).finish(),
)
.finish(),
)
.with_child(
Shrinkable::new(
0.3,
Container::new(
Align::new(self.copy_link_button(appearance, block_url))
.right()
.finish(),
)
.finish(),
)
.finish(),
)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.finish(),
)
.finish();
let timestamp_row = Container::new(
appearance
.ui_builder()
.label(format!(
"Executed on: {}",
self.time_started
.with_timezone(&Local)
.format("%a, %b %-d %Y at %-I:%M %p")
))
.with_style(
UiComponentStyles::default()
.set_font_color(
appearance
.theme()
.hint_text_color(appearance.theme().surface_2())
.into(),
)
// make it slightly smaller than the url text
.set_font_size(appearance.ui_builder().ui_font_size() - 2.),
)
.build()
.finish(),
)
.finish();
Container::new(
ConstrainedBox::new(
Flex::column()
.with_child(Shrinkable::new(1.0, command_row).finish())
.with_child(Shrinkable::new(1., url_row).finish())
.with_child(timestamp_row)
.finish(),
)
.with_max_height(90.)
.finish(),
)
.finish()
}
}
/// Status for the request to fetch the blocks owned by the current user.
#[derive(Debug)]
enum GetBlocksForUserRequestState {
NotStarted,
InFlight,
Failed,
Done(Vec<UserOwnedBlock>),
}
fn pad(element: Box<dyn Element>) -> Box<dyn Element> {
Container::new(element)
.with_uniform_padding(PAGE_PADDING)
.finish()
}
impl GetBlocksForUserRequestState {
fn render(
&self,
appearance: &Appearance,
list_state: UniformListState,
scroll_state_handle: ScrollStateHandle,
) -> Box<dyn Element> {
let ui_builder = appearance.ui_builder();
match self {
GetBlocksForUserRequestState::NotStarted => pad(ui_builder
.label("You don't have any shared blocks yet.")
.build()
.finish()),
GetBlocksForUserRequestState::InFlight => {
pad(ui_builder.label("Getting blocks...").build().finish())
}
GetBlocksForUserRequestState::Failed => pad(ui_builder
.label("Failed to load blocks. Please try again.")
.build()
.finish()),
GetBlocksForUserRequestState::Done(user_blocks) => {
let user_blocks = user_blocks.clone();
// Only consider the unshared blocks when rendering. We don't remove them from the
// list of blocks so that the indexing is consistent for the lifetime of the app.
let num_visible_blocks =
user_blocks.iter().filter(|block| block.is_shared()).count();
if num_visible_blocks > 0 {
let list =
UniformList::new(list_state, num_visible_blocks, move |range, app| {
let appearance = Appearance::as_ref(app);
user_blocks
.iter()
.enumerate()
.filter(|(_, block)| block.is_shared())
.skip(range.start)
.take(range.end - range.start)
.enumerate()
.map(|(visible_index, (index, user_block))| {
let user_block_element =
Container::new(user_block.render(appearance, index))
.with_uniform_padding(10.);
// Add a background on alternating blocks.
if visible_index % 2 == 0 {
user_block_element
.with_background(internal_colors::fg_overlay_1(
appearance.theme(),
))
.finish()
} else {
user_block_element.finish()
}
})
.collect::<Vec<_>>()
.into_iter()
});
Scrollable::vertical(
scroll_state_handle,
list.finish_scrollable(),
SCROLLBAR_WIDTH,
Fill::Solid(appearance.theme().nonactive_ui_detail().into_solid()),
Fill::Solid(appearance.theme().active_ui_detail().into_solid()),
Fill::None, // Leave the background transparent
)
.with_padding_start(5.)
.finish()
} else {
pad(ui_builder
.label("You don't have any shared blocks yet.")
.build()
.finish())
}
}
}
}
}
/// Status for the request to unshare a block.
#[derive(Debug, Clone, PartialEq)]
enum UnshareBlockRequestState {
NotStarted,
InFlight,
Failed,
Done,
}
#[derive(Default)]
struct StateHandles {
scroll_state_handle: ScrollStateHandle,
confirm_dialog_handle: MouseStateHandle,
cancel_dialog_handle: MouseStateHandle,
}
/// A view that lists all the blocks owned by the user.
pub struct ShowBlocksView {
page: PageType<Self>,
list_state: UniformListState,
overflow_menu: ViewHandle<Menu<ShowBlocksAction>>,
overflow_menu_index: Option<usize>,
get_blocks_for_user_status: GetBlocksForUserRequestState,
pending_unshared_block_index: Option<usize>,
block_client: Arc<dyn BlockClient>,
state_handles: StateHandles,
}
#[derive(Debug, Clone)]
pub enum ShowBlocksAction {
CopyUrl(String),
OverflowClick(usize),
Unshare,
ConfirmUnshare,
CancelUnshare,
}
#[derive(Debug, Clone)]
pub enum ShowBlocksEvent {
ShowToast {
message: String,
flavor: ToastFlavor,
},
}
impl ShowBlocksView {
pub fn new(block_client: Arc<dyn BlockClient>, ctx: &mut ViewContext<Self>) -> Self {
let menu = ctx.add_typed_action_view(|ctx| {
let mut menu = Menu::new().prevent_interaction_with_other_elements();
menu.set_items(
vec![MenuItem::Item(
MenuItemFields::new("Unshare").with_on_select_action(ShowBlocksAction::Unshare),
)],
ctx,
);
menu
});
ctx.subscribe_to_view(&menu, move |me, _, event, ctx| {
me.handle_overflow_menu_event(event, ctx);
});
let page = PageType::new_monolith(ShowBlocksWidget::default(), None, false);
Self {
page,
list_state: Default::default(),
state_handles: Default::default(),
overflow_menu: menu,
overflow_menu_index: None,
get_blocks_for_user_status: GetBlocksForUserRequestState::NotStarted,
block_client,
pending_unshared_block_index: None,
}
}
fn load_blocks(&mut self, ctx: &mut ViewContext<Self>) {
if !matches!(
self.get_blocks_for_user_status,
GetBlocksForUserRequestState::InFlight
) {
let block_client = self.block_client.clone();
self.get_blocks_for_user_status = GetBlocksForUserRequestState::InFlight;
let _ = ctx.spawn(
async move {
block_client
.blocks_owned_by_user()
.await
.map(|blocks| blocks.into_iter().map(Into::into).collect())
},
Self::on_load_complete,
);
}
}
fn on_load_complete(
&mut self,
result: Result<Vec<UserOwnedBlock>>,
ctx: &mut ViewContext<Self>,
) {
match result {
Ok(mut blocks) => {
blocks.sort_by(|b1, b2| b2.time_started.cmp(&b1.time_started));
self.get_blocks_for_user_status = GetBlocksForUserRequestState::Done(blocks)
}
Err(_) => {
log::info!("Failed to fetch blocks owned by user from server");
self.get_blocks_for_user_status = GetBlocksForUserRequestState::Failed;
}
}
ctx.notify()
}
pub fn copy_url(&mut self, block_url: &str, ctx: &mut ViewContext<Self>) {
ctx.clipboard()
.write(ClipboardContent::plain_text(block_url.to_string()));
ctx.emit(ShowBlocksEvent::ShowToast {
message: "Link copied.".to_string(),
flavor: ToastFlavor::Default,
})
}
pub fn overflow_click(&mut self, index: usize, ctx: &mut ViewContext<Self>) {
self.overflow_menu_index = Some(index);
ctx.notify();
}
pub fn cancel_unshare(&mut self, ctx: &mut ViewContext<Self>) {
self.pending_unshared_block_index = None;
ctx.notify();
}
pub fn confirm_unshare(&mut self, ctx: &mut ViewContext<Self>) {
if let Some(selected_index) = self.pending_unshared_block_index.take() {
if let GetBlocksForUserRequestState::Done(blocks) = &mut self.get_blocks_for_user_status
{
// Only attempt to unshare if there isn't already an inflight request to unshare
// the block.
let user_block = &mut blocks[selected_index];
if !matches!(
user_block.unshare_request_status,
UnshareBlockRequestState::InFlight
) {
user_block.unshare_request_status = UnshareBlockRequestState::InFlight;
let block_client = self.block_client.clone();
let block_id = user_block.id.clone();
let _ = ctx.spawn(
async move { (block_client.unshare_block(block_id).await, selected_index) },
Self::on_block_unshare_complete,
);
}
}
}
ctx.notify();
}
pub fn unshare_button_click(&mut self, ctx: &mut ViewContext<Self>) {
self.pending_unshared_block_index = self.overflow_menu_index.take();
ctx.notify();
}
fn on_block_unshare_complete(
&mut self,
(request_result, block_index): (Result<()>, usize),
ctx: &mut ViewContext<Self>,
) {
log::info!(
"on_block_unshare_complete with result {:?}",
&request_result
);
if let GetBlocksForUserRequestState::Done(blocks) = &mut self.get_blocks_for_user_status {
let user_block = &mut blocks[block_index];
match request_result {
Ok(_) => {
ctx.emit(ShowBlocksEvent::ShowToast {
message: "Block was successfully unshared.".to_string(),
flavor: ToastFlavor::Success,
});
user_block.unshare_request_status = UnshareBlockRequestState::Done;
}
Err(_) => {
ctx.emit(ShowBlocksEvent::ShowToast {
message: "Failed to unshare block. Please try again.".to_string(),
flavor: ToastFlavor::Error,
});
user_block.unshare_request_status = UnshareBlockRequestState::Failed;
}
}
ctx.notify();
}
}
fn handle_overflow_menu_event(&mut self, event: &MenuEvent, ctx: &mut ViewContext<Self>) {
if let Event::Close { via_select_item: _ } = event {
self.overflow_menu_index = None;
ctx.notify();
}
}
}
impl Entity for ShowBlocksView {
type Event = ShowBlocksEvent;
}
impl TypedActionView for ShowBlocksView {
type Action = ShowBlocksAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
use ShowBlocksAction::*;
match action {
CopyUrl(url) => self.copy_url(url, ctx),
OverflowClick(index) => self.overflow_click(*index, ctx),
Unshare => self.unshare_button_click(ctx),
ConfirmUnshare => self.confirm_unshare(ctx),
CancelUnshare => self.cancel_unshare(ctx),
}
}
}
impl View for ShowBlocksView {
fn ui_name() -> &'static str {
"ShowBlockView"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
self.page.render(self, app)
}
}
impl SettingsPageMeta for ShowBlocksView {
fn section() -> SettingsSection {
SettingsSection::SharedBlocks
}
fn should_render(&self, ctx: &AppContext) -> bool {
let is_anonymous = AuthStateProvider::as_ref(ctx)
.get()
.is_anonymous_or_logged_out();
!is_anonymous
}
fn on_page_selected(&mut self, _: bool, ctx: &mut ViewContext<Self>) {
self.load_blocks(ctx);
}
fn update_filter(&mut self, query: &str, ctx: &mut ViewContext<Self>) -> MatchData {
self.page.update_filter(query, ctx)
}
fn scroll_to_widget(&mut self, widget_id: &'static str) {
self.page.scroll_to_widget(widget_id)
}
fn clear_highlighted_widget(&mut self) {
self.page.clear_highlighted_widget();
}
}
impl From<ViewHandle<ShowBlocksView>> for SettingsPageViewHandle {
fn from(view_handle: ViewHandle<ShowBlocksView>) -> Self {
SettingsPageViewHandle::SharedBlocks(view_handle)
}
}
#[derive(Default)]
struct ShowBlocksWidget {}
impl ShowBlocksWidget {
fn render_confirm_delete_block_dialog(
&self,
view: &ShowBlocksView,
appearance: &Appearance,
) -> Box<dyn Element> {
let ui_builder = appearance.ui_builder();
ConstrainedBox::new(
Container::new(
Flex::column()
.with_child(
Align::new(
ui_builder
.label("Unshare block")
.with_style(UiComponentStyles {
font_size: Some(appearance.header_font_size()),
..Default::default()
})
.build()
.finish(),
)
.top_center()
.finish(),
)
.with_child(
Container::new(
ui_builder
.paragraph(UNSHARE_BLOCK_CONFIRMATION_DIALOG_TEXT)
.with_style(UiComponentStyles {
font_size: Some(appearance.ui_font_size() * 1.16),
..Default::default()
})
.build()
.finish(),
)
.with_padding_top(9.)
.finish(),
)
.with_child(
Container::new(
Align::new(
Flex::row()
.with_child(
ui_builder
.button(
ButtonVariant::Basic,
view.state_handles.cancel_dialog_handle.clone(),
)
.with_text_label("Cancel".into())
.build()
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(
ShowBlocksAction::CancelUnshare,
);
})
.finish(),
)
.with_child(
Container::new(
ui_builder
.button(
ButtonVariant::Accent,
view.state_handles
.confirm_dialog_handle
.clone(),
)
.with_text_label("Unshare".into())
.build()
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(
ShowBlocksAction::ConfirmUnshare,
);
})
.finish(),
)
.with_padding_left(10.)
.finish(),
)
.finish(),
)
.top_center()
.finish(),
)
.with_padding_top(20.)
.finish(),
)
.finish(),
)
.with_background(appearance.theme().surface_2())
.with_uniform_padding(20.)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
.finish(),
)
.with_height(220.)
.with_width(300.)
.finish()
}
}
impl SettingsWidget for ShowBlocksWidget {
type View = ShowBlocksView;
fn search_terms(&self) -> &str {
"shared blocks"
}
fn render(
&self,
view: &Self::View,
appearance: &Appearance,
_app: &AppContext,
) -> Box<dyn Element> {
let element_for_state = view.get_blocks_for_user_status.render(
appearance,
view.list_state.clone(),
view.state_handles.scroll_state_handle.clone(),
);
let inner_container = SavePosition::new(
Container::new(element_for_state).finish(),
"show_blocks_view:modal",
)
.finish();
let mut stack = Stack::new().with_child(inner_container);
if let Some(menu_index) = view.overflow_menu_index {
stack.add_positioned_overlay_child(
ChildView::new(&view.overflow_menu).finish(),
OffsetPositioning::offset_from_save_position_element(
format!("show_blocks_view:overflow_{menu_index}").as_str(),
vec2f(0., 2.),
PositionedElementOffsetBounds::Unbounded,
PositionedElementAnchor::BottomLeft,
ChildAnchor::TopLeft,
),
);
}
if view.pending_unshared_block_index.is_some() {
stack.add_positioned_child(
Dismiss::new(
Align::new(self.render_confirm_delete_block_dialog(view, appearance)).finish(),
)
.on_dismiss(|ctx, _app| ctx.dispatch_typed_action(ShowBlocksAction::CancelUnshare))
.finish(),
OffsetPositioning::offset_from_parent(
vec2f(0., 0.),
ParentOffsetBounds::ParentByPosition,
ParentAnchor::Center,
ChildAnchor::Center,
),
);
}
let header = render_page_title("Shared blocks", HEADER_FONT_SIZE, appearance);
let col = Flex::column()
.with_child(Container::new(header).with_margin_bottom(24.).finish())
.with_child(Expanded::new(1., stack.finish()).finish());
col.finish()
}
}