#![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, } impl From 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, command: impl Into, time_started: DateTime, ) -> 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 { 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 { 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 { 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 { 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), } fn pad(element: Box) -> Box { Container::new(element) .with_uniform_padding(PAGE_PADDING) .finish() } impl GetBlocksForUserRequestState { fn render( &self, appearance: &Appearance, list_state: UniformListState, scroll_state_handle: ScrollStateHandle, ) -> Box { 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::>() .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, list_state: UniformListState, overflow_menu: ViewHandle>, overflow_menu_index: Option, get_blocks_for_user_status: GetBlocksForUserRequestState, pending_unshared_block_index: Option, block_client: Arc, 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, ctx: &mut ViewContext) -> 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) { 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>, ctx: &mut ViewContext, ) { 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) { 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.overflow_menu_index = Some(index); ctx.notify(); } pub fn cancel_unshare(&mut self, ctx: &mut ViewContext) { self.pending_unshared_block_index = None; ctx.notify(); } pub fn confirm_unshare(&mut self, ctx: &mut ViewContext) { 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.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, ) { 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) { 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) { 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 { 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.load_blocks(ctx); } fn update_filter(&mut self, query: &str, ctx: &mut ViewContext) -> 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> for SettingsPageViewHandle { fn from(view_handle: ViewHandle) -> Self { SettingsPageViewHandle::SharedBlocks(view_handle) } } #[derive(Default)] struct ShowBlocksWidget {} impl ShowBlocksWidget { fn render_confirm_delete_block_dialog( &self, view: &ShowBlocksView, appearance: &Appearance, ) -> Box { 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 { 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() } }