use std::borrow::Cow; use email_address::EmailAddress; use inheritance::{InheritanceDetails, InheritanceState}; use itertools::Itertools; use pathfinder_color::ColorU; use pathfinder_geometry::vector::vec2f; use session_sharing_protocol::common::{Guest, PendingGuest, SessionId, TeamAclData}; use galaxy_core::ui::appearance::Appearance; use galaxy_core::ui::theme::Fill as ThemeFill; use warp_editor::editor::NavigationKey; use warpui::clipboard::ClipboardContent; use warpui::elements::{ Align, Border, ChildAnchor, ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Dismiss, Empty, Fill, Flex, Highlight, MainAxisAlignment, MainAxisSize, MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement, PositionedElementAnchor, PositionedElementOffsetBounds, Radius, SavePosition, ScrollStateHandle, Scrollable, ScrollableElement, ScrollbarWidth, Shrinkable, Stack, UniformList, UniformListState, }; use warpui::fonts::{Properties, Weight}; use warpui::keymap::FixedBinding; use warpui::platform::{Cursor, SaveFilePickerConfiguration}; use warpui::ui_components::button::{ButtonVariant, TextAndIcon, TextAndIconAlignment}; use warpui::ui_components::components::{Coords, UiComponent, UiComponentStyles}; use warpui::{ AppContext, Element, Entity, FocusContext, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle, WeakViewHandle, }; use super::qr_code::{qr_matrix_for_url, qr_png_for_url, QrMatrix, QUIET_ZONE_MODULES}; use super::{ style, ContentEditability, LinkSharingSubjectType, ShareableObject, SharingAccessLevel, Subject, SubjectExt, TeamKind, UserKind, }; use crate::ai::blocklist::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel}; use crate::auth::AuthStateProvider; use crate::cloud_object::model::persistence::{CloudModel, CloudModelEvent}; use crate::cloud_object::model::view::CloudViewModel; use crate::cloud_object::{CloudObject, Owner, ServerGuestSubject}; use crate::editor::PropagateAndNoOpNavigationKeys; use crate::menu::{self, Menu, MenuItem, MenuItemFields}; use crate::server::cloud_objects::update_manager::{ ObjectOperation, UpdateManager, UpdateManagerEvent, }; use crate::server::ids::ServerId; use crate::server::telemetry::{ CloudObjectTelemetryMetadata, OpenedSharingDialogEvent, SharingDialogSource, }; use crate::terminal::shared_session::permissions_manager::{ SessionPermissionsEvent, SessionPermissionsManager, }; use crate::terminal::shared_session::SharedSessionActionSource; use crate::terminal::TerminalView; use crate::ui_components::buttons::icon_button_with_color; use crate::ui_components::icons::Icon; use crate::view_components::DismissibleToast; use crate::word_block_editor::{ WordBlockEditorStyles, WordBlockEditorView, WordBlockEditorViewEvent, WordBlockLayout, WordBlockStyles, }; use crate::workspace::{ToastStack, WorkspaceAction}; use crate::workspaces::user_workspaces::UserWorkspaces; use crate::{send_telemetry_from_ctx, TelemetryEvent}; mod inheritance; const MENU_WIDTH: f32 = 200.; const GUEST_LIST_MAX_HEIGHT: f32 = 400.; /// Width constraints for the invitation email editor. This is both: const EMAIL_CHIP_WIDTH: f32 = 100.; const EMAIL_EDITOR_WIDTH: f32 = 100.; const SHARING_DIALOG_WIDTH: f32 = 425.; const QR_DIALOG_WIDTH: f32 = 400.; const QR_CARD_SIZE: f32 = 192.; const QR_VISUAL_SIZE: f32 = 160.; const QR_ICON_BUTTON_SIZE: f32 = 32.; const QR_EXPORT_SIZE: u32 = 1024; const NO_ACCESS_LABEL: &str = "No access"; #[derive(Default)] struct UiStateHandles { invite_button: MouseStateHandle, invite_access_level_button: MouseStateHandle, owner_tooltip: MouseStateHandle, link_sharing_menu_button: MouseStateHandle, team_sharing_menu_button: MouseStateHandle, copy_link_button: MouseStateHandle, qr_code_button: MouseStateHandle, qr_back_button: MouseStateHandle, qr_copy_button: MouseStateHandle, qr_download_button: MouseStateHandle, qr_close_button: MouseStateHandle, guest_list_state: UniformListState, guest_scroll_state: ScrollStateHandle, } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] enum SharingDialogMode { #[default] Access, QrCode, } /// State for which menu is currently open. /// /// This helps with two things: /// 1. Ensuring the menu is rendered last, on top of any other elements /// 2. Ensuring that only one menu is open at a time. #[derive(Debug, Clone, Copy, Default, PartialEq)] enum OpenMenuState { #[default] None, LinkSharing, TeamSharing, InviteAccessLevel, Guest(usize), } struct InviteFormValidationState { /// Invitees with invalid email addresses. invalid_emails: Vec, /// Invitees that are already guests on the object. duplicate_guests: Vec, /// All invitees invitee_emails: Vec, } impl InviteFormValidationState { pub fn is_valid(&self) -> bool { self.invalid_emails.is_empty() && self.duplicate_guests.is_empty() && !self.invitee_emails.is_empty() } } /// UI state for object guests. struct GuestState { menu_button_handle: MouseStateHandle, tooltip_handle: MouseStateHandle, current_access_level: SharingAccessLevel, subject: Subject, inheritance: Option, } /// UI state for link sharing. #[derive(Default)] struct LinkSharingState { access_level: Option, tooltip_handle: MouseStateHandle, inheritance: Option, } /// UI state for team sharing. #[derive(Default)] struct TeamSharingState { team: Option, access_level: Option, tooltip_handle: MouseStateHandle, inheritance: Option, } /// Container for fields related to the invite-by-email form. struct EmailInviteForm { email_editor: ViewHandle, selected_access_level: SharingAccessLevel, access_level_menu: ViewHandle>, } pub struct SharingDialog { self_handle: WeakViewHandle, target: Option, invite_form: EmailInviteForm, guest_states: Vec, guest_menu: ViewHandle>, link_sharing_state: LinkSharingState, link_sharing_menu: ViewHandle>, team_sharing_state: TeamSharingState, team_sharing_menu: ViewHandle>, ui_state_handles: UiStateHandles, open_menu_state: OpenMenuState, mode: SharingDialogMode, } #[derive(Debug, Clone)] pub enum SharingDialogEvent { Close, } #[derive(Debug, Clone)] pub enum SharingDialogAction { Close, CopyLink, ShowQrCode, BackToAccessDialog, DownloadQrCode, #[allow(dead_code)] SetLinkPermissions(Option), ToggleLinkSharingMenu, ToggleTeamSharingMenu, ToggleInviteAccessLevelMenu, SetInviteAccessLevel(SharingAccessLevel), ToggleGuestMenu(usize), RemoveGuest, SetGuestAccessLevel(SharingAccessLevel), SendInvitations, SetTeamPermissions(Option), } pub fn init(app: &mut AppContext) { use galaxyui::keymap::macros::*; app.register_fixed_bindings([FixedBinding::new( "escape", SharingDialogAction::Close, id!(SharingDialog::ui_name()), )]) } impl SharingDialog { pub fn new(target: Option, ctx: &mut ViewContext) -> Self { let link_sharing_menu = ctx.add_typed_action_view(|_ctx| Menu::new().with_drop_shadow().with_width(MENU_WIDTH)); ctx.subscribe_to_view(&link_sharing_menu, |me, menu, event, ctx| { me.handle_menu_event(menu, event, ctx) }); let team_sharing_menu = ctx.add_typed_action_view(|_ctx| Menu::new().with_drop_shadow().with_width(MENU_WIDTH)); ctx.subscribe_to_view(&team_sharing_menu, |me, menu, event, ctx| { me.handle_menu_event(menu, event, ctx) }); let update_manager = UpdateManager::handle(ctx); ctx.subscribe_to_model(&update_manager, |me, _, event, ctx| { me.handle_update_manager_event(event, ctx); }); ctx.subscribe_to_model(&CloudModel::handle(ctx), |me, _, event, ctx| { me.handle_cloud_model_event(event, ctx); }); ctx.subscribe_to_model( &SessionPermissionsManager::handle(ctx), |me, _, event, ctx| { me.handle_session_permissions_event(event, ctx); }, ); ctx.subscribe_to_model( &BlocklistAIHistoryModel::handle(ctx), |me, _, event, ctx| { me.handle_ai_history_event(event, ctx); }, ); let invite_form = EmailInviteForm { email_editor: ctx.add_typed_action_view(|ctx| { let mut view = WordBlockEditorView::new( ctx, "Emails", 13., vec![' ', ','], EMAIL_CHIP_WIDTH, Box::new(EmailAddress::is_valid), ) .with_layout(WordBlockLayout::Horizontal { editor_min_width: EMAIL_EDITOR_WIDTH, }) .with_styles(ctx, Self::email_invite_form_styles); view.set_propagate_navigation_keys(PropagateAndNoOpNavigationKeys::Always, ctx); view }), selected_access_level: SharingAccessLevel::View, access_level_menu: Self::build_invite_access_level_menu(ctx), }; ctx.subscribe_to_view(&invite_form.email_editor, |me, _, event, ctx| { me.handle_email_invite_editor_event(event, ctx); }); Self { self_handle: ctx.handle(), target, invite_form, guest_states: vec![], guest_menu: Self::build_guest_menu(ctx), link_sharing_state: Default::default(), link_sharing_menu, team_sharing_state: Default::default(), team_sharing_menu, ui_state_handles: Default::default(), open_menu_state: Default::default(), mode: Default::default(), } } fn handle_update_manager_event( &mut self, event: &UpdateManagerEvent, ctx: &mut ViewContext, ) { let UpdateManagerEvent::ObjectOperationComplete { result } = event else { return; }; if let ObjectOperation::UpdatePermissions = result.operation { self.refresh_object_permission_states(ctx); } } fn handle_cloud_model_event(&mut self, event: &CloudModelEvent, ctx: &mut ViewContext) { if let Some(target_server_id) = self.target_cloud_object_id(ctx) { let event_object_id = match event { CloudModelEvent::ObjectMoved { type_and_id, .. } => type_and_id, CloudModelEvent::ObjectUpdated { type_and_id, .. } => type_and_id, CloudModelEvent::ObjectTrashed { .. } => return, CloudModelEvent::ObjectUntrashed { .. } => return, CloudModelEvent::NotebookEditorChangedFromServer { .. } => return, CloudModelEvent::ObjectCreated { type_and_id } => type_and_id, CloudModelEvent::ObjectPermissionsUpdated { type_and_id, .. } => type_and_id, CloudModelEvent::ObjectDeleted { .. } => return, CloudModelEvent::ObjectForceExpanded { .. } => return, CloudModelEvent::ObjectSynced { .. } | CloudModelEvent::InitialLoadCompleted => { return } }; if event_object_id.sync_id().into_server() == Some(target_server_id) { self.refresh_object_permission_states(ctx); } } } fn handle_session_permissions_event( &mut self, event: &SessionPermissionsEvent, ctx: &mut ViewContext, ) { match event { SessionPermissionsEvent::GuestsUpdated { session_id, guests, pending_guests, } => { self.update_session_guests(ctx, session_id, guests, pending_guests); } SessionPermissionsEvent::LinkPermissionsUpdated { session_id, access_level, } => { self.update_session_link_permissions(*session_id, *access_level, ctx); } SessionPermissionsEvent::TeamPermissionsUpdated { session_id, team_acl, } => self.update_session_team_permissions(session_id, team_acl.clone(), ctx), } } fn handle_ai_history_event( &mut self, event: &BlocklistAIHistoryEvent, ctx: &mut ViewContext, ) { if let BlocklistAIHistoryEvent::UpdatedConversationMetadata { conversation_id, .. } = event { // Check if this event is for the conversation we're currently showing if let Some(ShareableObject::AIConversation(target_id)) = &self.target { if target_id == conversation_id { self.refresh_object_permission_states(ctx); } } } } /// Sets the target object whose ACLs are shown. pub fn set_target(&mut self, target: Option, ctx: &mut ViewContext) { self.target = target; self.mode = SharingDialogMode::Access; self.reset_invite_form(ctx); self.refresh_object_permission_states(ctx); ctx.notify(); } pub fn has_target(&self) -> bool { self.target.is_some() } pub fn has_shared_session_target(&self) -> bool { self.target .as_ref() .is_some_and(|target| matches!(target, ShareableObject::Session { .. })) } pub fn show_qr_code(&mut self, ctx: &mut ViewContext) { if matches!(self.target, Some(ShareableObject::Session { .. })) { self.set_open_menu(OpenMenuState::None, ctx); self.mode = SharingDialogMode::QrCode; ctx.focus_self(); ctx.notify(); } } /// Returns `true` if the target is an AI conversation that cannot be shared. /// This happens when the conversation hasn't been synced to the cloud. pub fn is_unsharable_conversation(&self, app: &AppContext) -> bool { if let Some(ShareableObject::AIConversation(id)) = &self.target { !BlocklistAIHistoryModel::as_ref(app).can_conversation_be_shared(id) } else { false } } /// The Warp Drive server ID for the target object. `None` if the target is not a Warp Drive /// object or AI conversation. fn target_cloud_object_id(&self, app: &AppContext) -> Option { match self.target.as_ref() { Some(ShareableObject::WarpDriveObject(id)) => Some(*id), Some(ShareableObject::AIConversation(id)) => BlocklistAIHistoryModel::as_ref(app) .get_server_conversation_metadata(id) .map(|m| ServerId::from_string_lossy(m.metadata.uid.uid())), _ => None, } } /// The targeted Warp Drive object, or `None` if the target is not a known Warp Drive object. fn target_cloud_object<'a>(&self, app: &'a AppContext) -> Option<&'a dyn CloudObject> { self.target_cloud_object_id(app) .and_then(|id| CloudModel::as_ref(app).get_by_uid(&id.uid())) } /// The name of the targeted object. fn targeted_object_name(&self, app: &AppContext) -> String { self.target .as_ref() .and_then(|target| match target { ShareableObject::WarpDriveObject(server_id) => CloudModel::as_ref(app) .get_by_uid(&server_id.uid()) .map(|object| object.display_name()), ShareableObject::Session { .. } => Some("session".to_string()), ShareableObject::AIConversation(_) => Some("conversation".to_string()), }) .unwrap_or_else(|| "unknown".to_string()) } /// Whether or not the current user is allowed to *edit* sharing settings. fn can_edit_access(&self, app: &AppContext) -> bool { self.target.is_some() && self.access_level(app).can_edit_access() } fn can_anyone_with_link_share(&self, app: &AppContext) -> bool { UserWorkspaces::as_ref(app).is_anyone_with_link_sharing_enabled() } fn can_direct_link_share(&self, app: &AppContext) -> bool { UserWorkspaces::as_ref(app).is_direct_link_sharing_enabled() } /// The editability state of the object. /// * Users who can edit access have the full sharing dialog /// * Users who can edit object contents can see who the object is shared with /// * Users that are view-only do not need to see permissions pub fn editability(&self, app: &AppContext) -> ContentEditability { match self.target.as_ref() { // Always treat session contents as "editable," so that the sharing dialog is shown. Some(ShareableObject::Session { .. }) => ContentEditability::Editable, Some(ShareableObject::WarpDriveObject(id)) => { CloudViewModel::as_ref(app).object_editability(&id.uid(), app) } // Always treat AI conversations as "editable," so that the sharing dialog is shown. Some(ShareableObject::AIConversation(_)) => ContentEditability::Editable, None => ContentEditability::ReadOnly, } } /// The current user's access level on the shared object. fn access_level(&self, app: &AppContext) -> SharingAccessLevel { match self.target.as_ref() { Some(ShareableObject::WarpDriveObject(id)) => { CloudViewModel::as_ref(app).access_level(&id.uid(), app) } Some(ShareableObject::AIConversation(id)) => { // Get access level from conversation metadata permissions match BlocklistAIHistoryModel::as_ref(app).get_server_conversation_metadata(id) { Some(server_metadata) => { let permissions = &server_metadata.permissions; // Conversation has server metadata, check permissions AuthStateProvider::as_ref(app) .get() .user_id() .and_then(|user_uid| { // Check if user is owner if let Owner::User { user_uid: owner_uid, } = permissions.space { if owner_uid == user_uid { return Some(SharingAccessLevel::Full); } } // Check if user is on the owning team (for team-owned conversations) if let Owner::Team { team_uid } = permissions.space { if UserWorkspaces::as_ref(app).current_team_uid() == Some(team_uid) { return Some(SharingAccessLevel::Full); } } // Check if user is in guests let user_firebase_uid = user_uid.to_string(); permissions.guests.iter().find_map(|guest| { if let ServerGuestSubject::User { firebase_uid } = &guest.subject { if firebase_uid == &user_firebase_uid { return Some(guest.access_level.into()); } } None }) }) .unwrap_or(SharingAccessLevel::View) } None => { // No server metadata yet - conversation hasn't been shared // The owner (logged in user) should have full access SharingAccessLevel::Full } } } Some(ShareableObject::Session { ref handle, .. }) => { // Sharer always has Full access. if handle.upgrade(app).is_some_and(|handle| { handle .as_ref(app) .sharer_session_kind() .is_some_and(|kind| kind.is_sharer()) }) { return SharingAccessLevel::Full; } if let Some(owner) = self.owner(app) { // If we are the user owner, we have Full access. if let Some(user_uid) = AuthStateProvider::as_ref(app).get().user_id() { if owner.is_user(user_uid) { return SharingAccessLevel::Full; } } // Team members of owning team have Full access. if let Subject::Team(team_kind) = owner { if UserWorkspaces::as_ref(app) .current_team_uid() .is_some_and(|current| current == team_kind.team_uid()) { return SharingAccessLevel::Full; } } } // For viewers, compute effective access as the max across all channels. let mut level = SharingAccessLevel::View; if let Some(link_level) = self.link_sharing_state.access_level { level = level.max(link_level); } if let Some(team_level) = self.team_sharing_state.access_level { if let Some(TeamKind::SharedSessionTeam { ref team_uid, .. }) = self.team_sharing_state.team { if UserWorkspaces::as_ref(app) .current_team_uid() .is_some_and(|current| current == *team_uid) { level = level.max(team_level); } } } if let Some(user_uid) = AuthStateProvider::as_ref(app).get().user_id() { if let Some(guest_level) = self .guest_states .iter() .find(|guest| guest.subject.is_user(user_uid)) .map(|guest| guest.current_access_level) { level = level.max(guest_level); } } level } None => SharingAccessLevel::Full, } } /// Report a telemetry event for opening this sharing dialog. /// /// This should be called by views that contain a sharing dialog whenever they open it (i.e. /// panes and the Warp Drive index). pub fn report_open(&self, source: SharingDialogSource, ctx: &mut ViewContext) { let event = match self.target.as_ref() { Some(ShareableObject::WarpDriveObject(id)) => { match CloudModel::as_ref(ctx).get_by_uid(&id.uid()) { Some(object) => TelemetryEvent::OpenedSharingDialog(OpenedSharingDialogEvent { source, object_metadata: Some(CloudObjectTelemetryMetadata { object_type: (&object.cloud_object_type_and_id()).into(), object_uid: object.sync_id().into_server(), space: Some(object.space(ctx).into()), team_uid: match object.permissions().owner { Owner::Team { team_uid, .. } => Some(team_uid), Owner::User { .. } => None, }, }), session_id: None, }), None => return, } } Some(ShareableObject::Session { session_id, .. }) => { TelemetryEvent::OpenedSharingDialog(OpenedSharingDialogEvent { source, object_metadata: None, session_id: Some(*session_id), }) } // Skip telemetry for AI conversations Some(ShareableObject::AIConversation(_)) => return, None => return, }; send_telemetry_from_ctx!(event, ctx); } fn reset_editable_state(&mut self, ctx: &mut ViewContext) { self.mode = SharingDialogMode::Access; self.reset_invite_form(ctx); ctx.notify(); } fn owner(&self, app: &AppContext) -> Option { match self.target.as_ref()? { ShareableObject::WarpDriveObject(id) => { let owner = CloudModel::as_ref(app) .get_by_uid(&id.uid())? .permissions() .owner; Some(Subject::from_owner(owner)) } ShareableObject::Session { handle, .. } => { // Check if team has Full access - if so, team is the owner. if let Some(TeamKind::SharedSessionTeam { team_uid, name }) = self.team_sharing_state.team.as_ref() { if self.team_sharing_state.access_level == Some(SharingAccessLevel::Full) { return Some(Subject::Team(TeamKind::SharedSessionTeam { team_uid: *team_uid, name: name.clone(), })); } } // Otherwise, the sharer is the owner. // The sharer doesn't store their own participant info, so if it's unset, we assume // the current user. handle .upgrade(app) .and_then(|handle| handle.as_ref(app).shared_session_presence_manager()) .and_then(|presence| presence.as_ref(app).get_sharer()) .map(|sharer| { UserKind::SharedSessionParticipant(sharer.info.profile_data.clone()) }) .or_else(|| { AuthStateProvider::as_ref(app) .get() .user_id() .map(UserKind::Account) }) .map(Subject::User) } ShareableObject::AIConversation(id) => { // Get owner from conversation's server metadata BlocklistAIHistoryModel::as_ref(app) .get_server_conversation_metadata(id) .map(|m| Subject::from_owner(m.permissions.space)) } } } fn update_session_guests( &mut self, ctx: &mut ViewContext, session_id: &SessionId, guests: &[Guest], pending_guests: &[PendingGuest], ) { // We should only update the guests if the dialog is targeting the // correct session. match self.target.as_ref() { Some(ShareableObject::Session { session_id: target_session_id, .. }) => { if session_id != target_session_id { return; } } _ => return, } let guests_iter = guests.iter().map(|guest| GuestState { menu_button_handle: Default::default(), tooltip_handle: Default::default(), current_access_level: guest.direct_acl.into(), subject: Subject::User(UserKind::SharedSessionParticipant( guest.profile_data.clone(), )), inheritance: None, }); let pending_guests_iter = pending_guests.iter().map(|guest| GuestState { menu_button_handle: Default::default(), tooltip_handle: Default::default(), current_access_level: guest.direct_acl.into(), subject: Subject::PendingUser { email: Some(guest.email.clone()), }, inheritance: None, }); self.guest_states = guests_iter.chain(pending_guests_iter).collect(); self.guest_states .sort_by_cached_key(|guest| guest.subject.name(ctx)); ctx.notify(); } fn update_session_link_permissions( &mut self, session_id: SessionId, access_level: Option, ctx: &mut ViewContext, ) { // Ensure we're targeting the correct session. let Some(ShareableObject::Session { session_id: target_session_id, .. }) = self.target else { return; }; if session_id != target_session_id { return; } self.link_sharing_state = LinkSharingState { access_level, tooltip_handle: Default::default(), inheritance: None, }; ctx.notify() } fn update_session_team_permissions( &mut self, session_id: &SessionId, team_acl: Option, ctx: &mut ViewContext, ) { // Ensure we're targeting the correct session. match self.target.as_ref() { Some(ShareableObject::Session { session_id: target_session_id, .. }) => { if session_id != target_session_id { return; } } _ => return, } self.team_sharing_state = TeamSharingState { access_level: team_acl.as_ref().map(|team_acl| team_acl.acl.into()), team: team_acl.map(|team_acl| TeamKind::SharedSessionTeam { team_uid: ServerId::from_string_lossy(team_acl.uid), name: team_acl.name, }), tooltip_handle: Default::default(), inheritance: None, }; ctx.notify() } /// Refreshes all permissions that have cached UI state. fn refresh_object_permission_states(&mut self, ctx: &mut ViewContext) { // The permission states for shared sessions are managed differently // than other cloud objects. We return to avoid resetting the // permissions for sessions. if matches!(self.target, Some(ShareableObject::Session { .. })) { return; } // Handle AI conversations separately if let Some(ShareableObject::AIConversation(conversation_id)) = &self.target { // Use the helper that checks both loaded conversations and historical metadata if let Some(server_metadata) = BlocklistAIHistoryModel::as_ref(ctx) .get_server_conversation_metadata(conversation_id) { let permissions = &server_metadata.permissions; // Populate guest states from conversation's server permissions self.guest_states = permissions .guests .iter() .filter_map(|guest| { // Convert ServerGuestSubject to Subject let subject = match &guest.subject { ServerGuestSubject::User { firebase_uid } => { let user_uid = crate::auth::UserUid::new(firebase_uid); Some(super::Subject::User(super::UserKind::Account(user_uid))) } ServerGuestSubject::PendingUser { email } => { Some(super::Subject::PendingUser { email: email.clone(), }) } ServerGuestSubject::Team { team_uid } => { Some(super::Subject::Team(super::TeamKind::Team { team_uid: *team_uid, })) } }?; Some(GuestState { menu_button_handle: Default::default(), subject, current_access_level: guest.access_level.into(), tooltip_handle: Default::default(), inheritance: None, // AI conversations don't support inheritance yet }) }) .collect(); // Handle link sharing state self.link_sharing_state = match &permissions.anyone_link_sharing { Some(link_sharing) => LinkSharingState { access_level: Some(link_sharing.access_level.into()), tooltip_handle: Default::default(), inheritance: None, }, None => Default::default(), }; self.guest_states .sort_by_cached_key(|guest| guest.subject.name(ctx)); ctx.notify(); return; } // If permissions not found, clear states self.guest_states.clear(); self.guest_states.shrink_to_fit(); self.link_sharing_state = Default::default(); self.team_sharing_state = Default::default(); ctx.notify(); return; } match self.target_cloud_object(ctx) { Some(object) => { let object_id = object.sync_id(); self.guest_states = object .permissions() .guests .iter() .map(move |guest| GuestState { menu_button_handle: Default::default(), subject: guest.subject.clone(), current_access_level: guest.access_level, tooltip_handle: Default::default(), inheritance: InheritanceState::from_object_and_source( &object_id, guest.source.as_ref(), ), }) .collect(); self.link_sharing_state = match &object.permissions().anyone_with_link { Some(link_sharing) => LinkSharingState { access_level: Some(link_sharing.access_level), tooltip_handle: Default::default(), inheritance: InheritanceState::from_object_and_source( &object_id, link_sharing.source.as_ref(), ), }, None => Default::default(), } } None => { self.guest_states.clear(); self.guest_states.shrink_to_fit(); self.link_sharing_state = Default::default(); self.team_sharing_state = Default::default(); } } self.guest_states .sort_by_cached_key(|guest| guest.subject.name(ctx)); ctx.notify(); } /// Saved position ID for a particular guest's menu button. fn guest_access_button_id(&self, idx: usize) -> String { format!( "sharing_dialog_guest_access_button_{}_{}", idx, self.self_handle.id() ) } /// Copy the object's URL to the clipboard. pub fn copy_link(&self, ctx: &mut ViewContext) { if let Some(url) = self.target.as_ref().and_then(|target| target.link(ctx)) { let event = match self.target { Some(ShareableObject::Session { .. }) => { Some(TelemetryEvent::CopiedSharedSessionLink { source: SharedSessionActionSource::SharingDialog, }) } Some(ShareableObject::WarpDriveObject(_)) | Some(ShareableObject::AIConversation(_)) => { Some(TelemetryEvent::ObjectLinkCopied { link: url.clone() }) } None => None, }; if let Some(event) = event { send_telemetry_from_ctx!(event, ctx); } ctx.clipboard().write(ClipboardContent::plain_text(url)); let window_id = ctx.window_id(); let object_name = self.targeted_object_name(ctx); ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| { let toast = DismissibleToast::default(format!("Copied link to {object_name}.")); toast_stack.add_ephemeral_toast(toast, window_id, ctx); }); } } /// Create the menu for editing guest access. fn build_guest_menu(ctx: &mut ViewContext) -> ViewHandle> { let menu = ctx.add_typed_action_view(|_| { Menu::new() .prevent_interaction_with_other_elements() .with_drop_shadow() }); ctx.subscribe_to_view(&menu, |me, menu, event, ctx| { me.handle_menu_event(menu, event, ctx); }); menu } /// Configure the guest dropdown menu for a newly-selected guest. fn reset_guest_menu(&mut self, guest_index: usize, ctx: &mut ViewContext) { if let Some(guest) = self.guest_states.get(guest_index) { let current_access_level = guest.current_access_level; let inherited_access = guest.inheritance.is_some(); let is_ai_conversation = matches!(self.target, Some(ShareableObject::AIConversation(_))); // Check if this is a team guest - team removal is only supported for non-session targets let is_team_guest = matches!(guest.subject, Subject::Team(_)); let is_session = matches!(self.target, Some(ShareableObject::Session { .. })); self.guest_menu.update(ctx, |menu, ctx| { let mut items = vec![MenuItemFields::new(SharingAccessLevel::View.label()) .with_on_select_action(SharingDialogAction::SetGuestAccessLevel( SharingAccessLevel::View, )) .with_disabled( inherited_access && current_access_level >= SharingAccessLevel::View, ) .into_item()]; // Only add Edit option if not an AI conversation if !is_ai_conversation { items.push( MenuItemFields::new(SharingAccessLevel::Edit.label()) .with_on_select_action(SharingDialogAction::SetGuestAccessLevel( SharingAccessLevel::Edit, )) .with_disabled( inherited_access && current_access_level >= SharingAccessLevel::Edit, ) .into_item(), ); } // Add Remove option for non-team guests, or for team guests in non-session contexts // (team removal is supported for WarpDrive objects and AI conversations, but not sessions) if !is_team_guest || !is_session { items.push(MenuItem::Separator); items.push( MenuItemFields::new("Remove") .with_on_select_action(SharingDialogAction::RemoveGuest) .with_disabled(inherited_access) .into_item(), ); } menu.set_items(items, ctx); menu.set_selected_by_index( match current_access_level { SharingAccessLevel::View => 0, SharingAccessLevel::Edit => { if is_ai_conversation { 0 } else { 1 } } // Not yet supported, so default to view. SharingAccessLevel::Full => 0, }, ctx, ); }) } } /// Remove the guest currently targeted via dropdown menu, fn remove_targeted_guest(&mut self, ctx: &mut ViewContext) { let OpenMenuState::Guest(idx) = self.open_menu_state else { return; }; let Some(guest) = self.guest_states.get(idx) else { return; }; // Ensure we don't try to update inherited ACLs. if guest.inheritance.is_some() { return; } match &self.target { Some(ShareableObject::WarpDriveObject(object_id)) => { let guest_identifier = guest.subject.to_guest_identifier(ctx); if let Some(guest_identifier) = guest_identifier { let object_id = *object_id; UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| { update_manager.remove_object_guest(object_id, guest_identifier, ctx); }); } } Some(ShareableObject::Session { handle, .. }) => { self.remove_targeted_guest_for_session(idx, handle.clone(), ctx); } Some(ShareableObject::AIConversation(conversation_id)) => { self.remove_targeted_guest_for_conversation(idx, *conversation_id, ctx); } None => (), } self.set_open_menu(OpenMenuState::None, ctx); } fn remove_targeted_guest_for_session( &mut self, guest_idx: usize, handle: WeakViewHandle, ctx: &mut ViewContext, ) { let Some(guest) = self.guest_states.get(guest_idx) else { return; }; let Some(handle) = handle.upgrade(ctx) else { log::error!( "Unable to upgrade handle to TerminalView when removing guest from session" ); return; }; if let Some(user_uid) = guest.subject.user_uid() { // User is a full guest. handle.update(ctx, |view, ctx| { view.remove_guest(user_uid, ctx); }); } else if let Some(email) = guest.subject.email(ctx) { // User is a pending guest. let email = email.to_owned(); handle.update(ctx, |view, ctx| { view.remove_pending_guest(email, ctx); }); } } /// Set the currently-targeted guest's access level. fn set_targeted_guest_access( &mut self, access_level: SharingAccessLevel, ctx: &mut ViewContext, ) { let OpenMenuState::Guest(idx) = self.open_menu_state else { return; }; let Some(guest) = self.guest_states.get_mut(idx) else { return; }; // Optimistically update the dialog label. guest.current_access_level = access_level; ctx.notify(); match &self.target { Some(ShareableObject::WarpDriveObject(object_id)) => { self.set_targeted_guest_access_for_object(idx, access_level, *object_id, ctx); } Some(ShareableObject::Session { handle, .. }) => { self.set_targeted_guest_access_for_session(idx, access_level, handle.clone(), ctx); } Some(ShareableObject::AIConversation(conversation_id)) => { self.set_targeted_guest_access_for_conversation( idx, access_level, *conversation_id, ctx, ); } None => (), }; } fn set_targeted_guest_access_for_object( &mut self, guest_idx: usize, access_level: SharingAccessLevel, object_id: ServerId, ctx: &mut ViewContext, ) { let (guest_email, is_inherited) = match self.guest_states.get(guest_idx) { Some(guest) => match guest.subject.email(ctx) { Some(email) => (email.to_owned(), guest.inheritance.is_some()), None => return, }, None => return, }; UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| { // If there's only an inherited guest ACL, we have to add a new guest to the descendant // object. if is_inherited { update_manager.add_object_guests( object_id, vec![guest_email], access_level.into(), ctx, ); } else { update_manager.update_object_guests( object_id, vec![guest_email], access_level.into(), ctx, ); } }); self.set_open_menu(OpenMenuState::None, ctx); } fn set_targeted_guest_access_for_session( &mut self, guest_idx: usize, access_level: SharingAccessLevel, handle: WeakViewHandle, ctx: &mut ViewContext, ) { let Some(guest) = self.guest_states.get(guest_idx) else { return; }; let Some(handle) = handle.upgrade(ctx) else { log::error!( "Unable to upgrade handle to TerminalView when setting guest ACL for session" ); return; }; if let Some(user_uid) = guest.subject.user_uid() { // User is a full guest. handle.update(ctx, |view, ctx| { view.update_role_for_user(user_uid.to_owned(), access_level.into(), ctx); }); } else if let Some(email) = guest.subject.email(ctx) { // User is a pending guest. let email = email.to_owned(); handle.update(ctx, |view, ctx| { view.update_role_for_pending_user(email, access_level.into(), ctx); }); } self.set_open_menu(OpenMenuState::None, ctx); } fn remove_targeted_guest_for_conversation( &mut self, guest_idx: usize, conversation_id: crate::ai::agent::conversation::AIConversationId, ctx: &mut ViewContext, ) { let Some(guest) = self.guest_states.get(guest_idx) else { return; }; // Get the conversation's server_id from metadata let server_id = match BlocklistAIHistoryModel::as_ref(ctx) .get_server_conversation_metadata(&conversation_id) .map(|m| ServerId::from_string_lossy(m.metadata.uid.uid())) { Some(id) => id, None => { log::warn!( "AI conversation {:?} has no server_id for permission update", conversation_id ); return; } }; let guest_identifier = guest.subject.to_guest_identifier(ctx); let Some(guest_identifier) = guest_identifier else { return; }; // Call UpdateManager to remove the guest UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| { update_manager.remove_ai_conversation_guest( server_id, conversation_id, guest_identifier, ctx, ); }); } fn set_targeted_guest_access_for_conversation( &mut self, guest_idx: usize, access_level: SharingAccessLevel, conversation_id: crate::ai::agent::conversation::AIConversationId, ctx: &mut ViewContext, ) { let guest_email = match self.guest_states.get(guest_idx) { Some(guest) => match guest.subject.email(ctx) { Some(email) => email.to_owned(), None => return, }, None => return, }; // Get the conversation's server_id from metadata let server_id = match BlocklistAIHistoryModel::as_ref(ctx) .get_server_conversation_metadata(&conversation_id) .map(|m| ServerId::from_string_lossy(m.metadata.uid.uid())) { Some(id) => id, None => { log::warn!( "AI conversation {:?} has no server_id for permission update", conversation_id ); return; } }; // Call UpdateManager to update the guest's access level UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| { update_manager.update_ai_conversation_guests( server_id, conversation_id, vec![guest_email], access_level.into(), ctx, ); }); } fn add_guests_for_conversation( &mut self, guest_emails: Vec, access_level: SharingAccessLevel, conversation_id: crate::ai::agent::conversation::AIConversationId, ctx: &mut ViewContext, ) { // Get the conversation's server_id from metadata let server_id = match BlocklistAIHistoryModel::as_ref(ctx) .get_server_conversation_metadata(&conversation_id) .map(|m| ServerId::from_string_lossy(m.metadata.uid.uid())) { Some(id) => id, None => { log::warn!( "AI conversation {:?} has no server_id for permission update", conversation_id ); return; } }; // Call UpdateManager to add guests UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| { update_manager.add_ai_conversation_guests( server_id, conversation_id, guest_emails, access_level.into(), ctx, ); }); } /// Create the access level selector dropdown for the email invitation form. fn build_invite_access_level_menu( ctx: &mut ViewContext, ) -> ViewHandle> { let menu = ctx.add_typed_action_view(|_| { let mut menu = Menu::new(); // Note: Items will be updated dynamically in reset_invite_access_level_menu // based on whether the target is an AI conversation menu.add_items([ MenuItemFields::new(SharingAccessLevel::View.label()) .with_on_select_action(SharingDialogAction::SetInviteAccessLevel( SharingAccessLevel::View, )) .into_item(), MenuItemFields::new(SharingAccessLevel::Edit.label()) .with_on_select_action(SharingDialogAction::SetInviteAccessLevel( SharingAccessLevel::Edit, )) .into_item(), ]); menu.prevent_interaction_with_other_elements() .with_drop_shadow() }); ctx.subscribe_to_view(&menu, |me, menu, event, ctx| { me.handle_menu_event(menu, event, ctx); }); menu } /// Reset the invite access level menu based on the current target. fn reset_invite_access_level_menu(&mut self, ctx: &mut ViewContext) { let is_ai_conversation = matches!(self.target, Some(ShareableObject::AIConversation(_))); self.invite_form.access_level_menu.update(ctx, |menu, ctx| { let mut items = vec![MenuItemFields::new(SharingAccessLevel::View.label()) .with_on_select_action(SharingDialogAction::SetInviteAccessLevel( SharingAccessLevel::View, )) .into_item()]; // Only add Edit option if not an AI conversation if !is_ai_conversation { items.push( MenuItemFields::new(SharingAccessLevel::Edit.label()) .with_on_select_action(SharingDialogAction::SetInviteAccessLevel( SharingAccessLevel::Edit, )) .into_item(), ); } menu.set_items(items, ctx); // Always select View (index 0) for AI conversations menu.set_selected_by_index( match self.invite_form.selected_access_level { SharingAccessLevel::View => 0, SharingAccessLevel::Edit => { if is_ai_conversation { 0 } else { 1 } } SharingAccessLevel::Full => 0, }, ctx, ); }); } fn invite_access_level_button_id(&self) -> String { format!("invite_form_access_level_{}", self.self_handle.id()) } /// Render the form for inviting new guests. fn render_invite_form(&self, appearance: &Appearance, app: &AppContext) -> Box { let editor = ChildView::new(&self.invite_form.email_editor).finish(); let access_level = SavePosition::new( appearance .ui_builder() .button( ButtonVariant::Text, self.ui_state_handles.invite_access_level_button.clone(), ) .with_centered_text_label( self.invite_form.selected_access_level.label().to_string(), ) .build() .on_click(|ctx, _, _| { ctx.dispatch_typed_action(SharingDialogAction::ToggleInviteAccessLevelMenu); }) .finish(), &self.invite_access_level_button_id(), ) .finish(); let editor_container = Container::new( Flex::row() .with_children([Shrinkable::new(1., editor).finish(), access_level]) .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) .with_cross_axis_alignment(CrossAxisAlignment::Center) .finish(), ) .with_border(Border::all(1.).with_border_color(style::form_border_color(appearance))) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.))) .with_margin_right(style::ACL_ITEM_PADDING / 2.) // Match the word block editor's left padding. .with_padding_right(8.) .finish(); let validation_state = self.invite_form_state(app); let mut invite_button = appearance .ui_builder() .button( ButtonVariant::Accent, self.ui_state_handles.invite_button.clone(), ) .with_centered_text_label("Invite".into()) .with_style(UiComponentStyles { // Adjust the height to match the email editor's padding. height: Some(style::ACL_ITEM_HEIGHT + 6.), ..Default::default() }); if !validation_state.is_valid() { invite_button = invite_button.disabled(); } // For Warp Drive targets, we can't update permissions while there's a pending change. if self .target_cloud_object(app) .is_some_and(|object| object.metadata().has_pending_online_only_change()) { invite_button = invite_button.disabled(); } let invite_button = invite_button .build() .with_cursor(Cursor::PointingHand) .on_click(|ctx, _, _| { ctx.dispatch_typed_action(SharingDialogAction::SendInvitations); }) .finish(); let form = Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child(Shrinkable::new(1., editor_container).finish()) .with_child(invite_button) .finish(); let contents = if validation_state.is_valid() { form } else { let mut contents = Flex::column() .with_child(form) .with_main_axis_alignment(MainAxisAlignment::Start) .with_cross_axis_alignment(CrossAxisAlignment::Start); if !validation_state.duplicate_guests.is_empty() { let error_text = format!( "Already shared with {}", validation_state.duplicate_guests.iter().format(", ") ); contents.add_child(self.render_error_message(error_text, appearance)); } if !validation_state.invalid_emails.is_empty() { let error_text = format!( "Invalid address: {}", validation_state.invalid_emails.iter().format(", ") ); contents.add_child(self.render_error_message(error_text, appearance)); } contents.finish() }; Container::new(contents) .with_horizontal_padding(style::ACL_ITEM_PADDING) .with_vertical_margin(style::ACL_ITEM_GAP / 2.) .finish() } fn render_error_message( &self, error: impl Into>, appearance: &Appearance, ) -> Box { appearance .ui_builder() .paragraph(error) .with_style(UiComponentStyles { font_color: Some(appearance.theme().ui_error_color()), ..Default::default() }) .build() .with_vertical_margin(style::ACL_ITEM_GAP / 2.) .finish() } fn email_invite_form_styles(app: &AppContext) -> WordBlockEditorStyles { let appearance = Appearance::as_ref(app); let text_color = style::acl_primary_text_color(appearance); WordBlockEditorStyles { font_family: appearance.ui_font_family(), editor_font_color: text_color, background: style::dialog_background(appearance).into(), valid_word_styles: WordBlockStyles { font_color: text_color, background: style::form_chip_background(appearance), }, invalid_word_styles: WordBlockStyles { font_color: text_color, background: appearance.theme().ui_error_color().into(), }, } } fn invite_form_state(&self, app: &AppContext) -> InviteFormValidationState { let invite_editor = self.invite_form.email_editor.as_ref(app); let invitees = invite_editor.get_list_of_words(app); let owner = self.owner(app); let duplicate_guests = self .guest_states .iter() .map(|g| &g.subject) .chain(owner.as_ref()) .filter_map(|guest| { invitees .iter() .find(|invitee| guest.matches_email(invitee, app)) .cloned() }) .collect(); InviteFormValidationState { invalid_emails: invite_editor.get_list_of_invalid_words(app), duplicate_guests, invitee_emails: invitees, } } fn handle_email_invite_editor_event( &mut self, event: &WordBlockEditorViewEvent, ctx: &mut ViewContext, ) { match event { WordBlockEditorViewEvent::WordListValidityChanged => { // Re-render to enable/disable the invite button. ctx.notify(); } WordBlockEditorViewEvent::Enter => { self.send_invitations(ctx); } WordBlockEditorViewEvent::Escape => ctx.emit(SharingDialogEvent::Close), WordBlockEditorViewEvent::Navigate(key) => match key { NavigationKey::Tab | NavigationKey::Down => { self.set_open_menu(OpenMenuState::InviteAccessLevel, ctx); } _ => (), }, } } /// Send all pending email invitations. fn send_invitations(&mut self, ctx: &mut ViewContext) { let form_state = self.invite_form_state(ctx); if !form_state.is_valid() { return; } match &self.target { Some(ShareableObject::WarpDriveObject(object_id)) => { UpdateManager::handle(ctx).update(ctx, |update_manager, ctx| { update_manager.add_object_guests( *object_id, form_state.invitee_emails, self.invite_form.selected_access_level.into(), ctx, ); }); } Some(ShareableObject::Session { handle, .. }) => { let Some(handle) = handle.upgrade(ctx) else { log::error!("Unable to upgrade handle to TerminalView when sending email invitations for session"); return; }; handle.update(ctx, |view, ctx| { view.add_guests( form_state.invitee_emails, self.invite_form.selected_access_level.into(), ctx, ); }); } Some(ShareableObject::AIConversation(conversation_id)) => { self.add_guests_for_conversation( form_state.invitee_emails, self.invite_form.selected_access_level, *conversation_id, ctx, ); } None => return, } self.reset_invite_form(ctx); ctx.notify(); } /// Reset all state for the invite form. fn reset_invite_form(&mut self, ctx: &mut ViewContext) { self.invite_form .email_editor .update(ctx, |editor, ctx| editor.clear_list_of_words(ctx)); self.invite_form.selected_access_level = SharingAccessLevel::View; self.invite_form .access_level_menu .update(ctx, |menu, ctx| menu.set_selected_by_index(0, ctx)) } pub fn add_invitee_email(&mut self, invitee_email: String, ctx: &mut ViewContext) { self.invite_form.email_editor.update(ctx, |editor, ctx| { editor.clear_list_of_words(ctx); editor.set_editor_buffer_text(&invitee_email, ctx); ctx.notify(); }); ctx.focus(&self.invite_form.email_editor); } fn handle_menu_event( &mut self, _menu: ViewHandle>, event: &menu::Event, ctx: &mut ViewContext, ) { match event { menu::Event::ItemSelected | menu::Event::ItemHovered => {} menu::Event::Close { .. } => { self.set_open_menu(OpenMenuState::None, ctx); } } } /// Toggle a particular menu open or closed. fn toggle_menu(&mut self, menu: OpenMenuState, ctx: &mut ViewContext) { let new_state = if menu == self.open_menu_state { OpenMenuState::None } else { menu }; self.set_open_menu(new_state, ctx); } /// Set which menu/dropdown is currently open. fn set_open_menu(&mut self, open_menu: OpenMenuState, ctx: &mut ViewContext) { match open_menu { OpenMenuState::None => { if self.open_menu_state == OpenMenuState::InviteAccessLevel { // If the invite form menu _was_ open, switch focus to the email editor. ctx.focus(&self.invite_form.email_editor); } else { ctx.focus_self(); } } OpenMenuState::LinkSharing => { self.reset_link_sharing_menu(ctx); ctx.focus(&self.link_sharing_menu) } OpenMenuState::TeamSharing => { self.reset_team_sharing_menu(ctx); ctx.focus(&self.team_sharing_menu) } OpenMenuState::InviteAccessLevel => { self.reset_invite_access_level_menu(ctx); ctx.focus(&self.invite_form.access_level_menu) } OpenMenuState::Guest(idx) => { self.reset_guest_menu(idx, ctx); ctx.focus(&self.guest_menu); } } self.open_menu_state = open_menu; ctx.notify(); } /// Render the open menu, if any. fn render_menu(&self, stack: &mut Stack, app: &AppContext) { if !self.can_edit_access(app) { return; } match self.open_menu_state { OpenMenuState::None => (), OpenMenuState::LinkSharing => { stack.add_positioned_overlay_child( ChildView::new(&self.link_sharing_menu).finish(), OffsetPositioning::offset_from_save_position_element( self.link_sharing_menu_button_id(), vec2f(0., 0.), PositionedElementOffsetBounds::WindowByPosition, PositionedElementAnchor::TopRight, ChildAnchor::TopLeft, ), ); } OpenMenuState::TeamSharing => { stack.add_positioned_overlay_child( ChildView::new(&self.team_sharing_menu).finish(), OffsetPositioning::offset_from_save_position_element( self.team_sharing_menu_button_id(), vec2f(0., 0.), PositionedElementOffsetBounds::WindowByPosition, PositionedElementAnchor::TopRight, ChildAnchor::TopLeft, ), ); } OpenMenuState::InviteAccessLevel => stack.add_positioned_overlay_child( ChildView::new(&self.invite_form.access_level_menu).finish(), OffsetPositioning::offset_from_save_position_element( self.invite_access_level_button_id(), vec2f(0., 4.), PositionedElementOffsetBounds::WindowByPosition, PositionedElementAnchor::BottomLeft, ChildAnchor::TopLeft, ), ), OpenMenuState::Guest(idx) => { // Check that the guest is in bounds, in case it was removed but we haven't yet // refreshed `self.guest_states`. if idx < self.guest_states.len() { stack.add_positioned_overlay_child( ChildView::new(&self.guest_menu).finish(), OffsetPositioning::offset_from_save_position_element( self.guest_access_button_id(idx), vec2f(0., 4.), PositionedElementOffsetBounds::WindowByPosition, PositionedElementAnchor::BottomLeft, ChildAnchor::TopLeft, ), ) } } } } /// Render the "Who has access" header shown above the ACL list. fn render_access_header(&self, appearance: &Appearance) -> Box { appearance .ui_builder() .span("Who has access") .with_style(UiComponentStyles { font_color: Some(style::label_text(appearance)), font_size: Some(style::PRIMARY_TEXT_SIZE), ..Default::default() }) .build() .with_horizontal_padding(style::ACL_ITEM_PADDING) .with_vertical_margin(style::ACL_ITEM_GAP / 2.) .finish() } /// Renders a header with live session details only for shared session sharers. fn render_session_header( &self, appearance: &Appearance, app: &AppContext, ) -> Option> { let Some(ShareableObject::Session { started_at, .. }) = self.target else { return None; }; if !self.can_edit_access(app) { return None; } let text = appearance .ui_builder() .wrappable_text( format!( "Live session started at {} on {}", started_at.format("%l:%M%P"), started_at.format("%m/%d"), ), true, ) .with_style(UiComponentStyles { font_color: Some(style::acl_primary_text_color(appearance)), font_size: Some(style::HEADER_TEXT_SIZE), ..Default::default() }) .build() .finish(); Some( Container::new(text) .with_horizontal_padding(style::ACL_ITEM_PADDING) .with_padding_top(style::ACL_ITEM_PADDING / 2.) .with_padding_bottom(style::ACL_ITEM_PADDING) .finish(), ) } /// Renders a clarification label if the user is not allowed to edit permissions. fn render_restricted_access_label( &self, appearance: &Appearance, app: &AppContext, ) -> Option> { let access_level = self.access_level(app); if access_level.can_edit_access() { return None; } const PREFIX: &str = "You must have full access to manage permissions. You have "; const SUFFIX: &str = " access."; let access_level_start = PREFIX.chars().count(); let access_level_end = access_level_start + access_level.name().chars().count(); let text = appearance .ui_builder() .wrappable_text(format!("{PREFIX}{}{SUFFIX}", access_level.name()), true) .with_style(UiComponentStyles { font_color: Some(style::label_text(appearance)), ..Default::default() }) .with_highlights( (access_level_start..access_level_end).collect(), Highlight::new().with_properties(Properties::default().weight(Weight::Bold)), ) .build() .finish(); Some( Container::new(text) .with_horizontal_padding(style::ACL_ITEM_PADDING) .with_vertical_margin(style::ACL_ITEM_GAP / 2.) .finish(), ) } fn render_owner(&self, appearance: &Appearance, app: &AppContext) -> Option> { let owner = self.owner(app)?; let tooltip_text = match owner { Subject::Team(_) => "Team objects automatically grant full permissions to team members", _ => "Owners always have full permissions on their objects", }; let owner_access_label = render_with_detail_tooltip( tooltip_text, self.ui_state_handles.owner_tooltip.clone(), appearance .ui_builder() .span(SharingAccessLevel::Full.label()) .with_style(UiComponentStyles { font_color: Some( appearance .theme() .disabled_text_color(style::dialog_background(appearance).into()) .into_solid(), ), ..Default::default() }) .build() .finish(), appearance, ); Some( Container::new( Flex::row() .with_main_axis_size(MainAxisSize::Max) .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child(self.render_subject(&owner, None, appearance, app)) .with_child(owner_access_label) .finish(), ) .with_horizontal_padding(style::ACL_ITEM_PADDING) .with_vertical_margin(style::ACL_ITEM_GAP / 2.) .finish(), ) } fn link_sharing_menu_button_id(&self) -> String { format!("link_sharing_menu_button_{}", self.self_handle.id()) } fn render_link_sharing_subject( &self, appearance: &Appearance, app: &AppContext, ) -> Option> { let link_sharing_subject_type = match self.link_sharing_state.access_level { Some(_) => LinkSharingSubjectType::Anyone, None => LinkSharingSubjectType::None, }; let can_edit_access = self.can_edit_access(app); if !can_edit_access && self.link_sharing_state.access_level.is_none() { return None; } let (inherited_label, inherited_tooltip) = self .link_sharing_state .inheritance .as_ref() .map(|inheritance| { let InheritanceDetails { source_label, tooltip_text, } = inheritance.details(appearance, app); (source_label, tooltip_text) }) .unzip(); let mut subject_row = Flex::row(); subject_row.add_child( Container::new(self.render_subject( &Subject::AnyoneWithLink(link_sharing_subject_type), inherited_label, appearance, app, )) .with_horizontal_padding(style::ACL_ITEM_PADDING) .with_vertical_margin(style::ACL_ITEM_GAP / 2.) .finish(), ); let menu_button_label = match self.link_sharing_state.access_level { Some(access_level) => access_level.label(), None => NO_ACCESS_LABEL, }; let mut menu_button = appearance .ui_builder() .button( ButtonVariant::Text, self.ui_state_handles.link_sharing_menu_button.clone(), ) .with_centered_text_label(menu_button_label.to_string()) .with_style(UiComponentStyles { padding: Some(Coords::default()), ..Default::default() }); if !can_edit_access { menu_button = menu_button.disabled(); } let menu_button = SavePosition::new( menu_button .build() .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(SharingDialogAction::ToggleLinkSharingMenu); }) .finish(), &self.link_sharing_menu_button_id(), ) .finish(); subject_row.add_child(match inherited_tooltip { Some(tooltip) => render_with_detail_tooltip( tooltip, self.link_sharing_state.tooltip_handle.clone(), menu_button, appearance, ), None => menu_button, }); Some( Container::new( subject_row .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_main_axis_size(MainAxisSize::Max) .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) .finish(), ) .with_padding_right(style::ACL_ITEM_PADDING) .finish(), ) } fn reset_link_sharing_menu(&mut self, ctx: &mut ViewContext) { let inherited_access = self.link_sharing_state.inheritance.is_some(); let current_access_level = self.link_sharing_state.access_level; let is_ai_conversation = matches!(self.target, Some(ShareableObject::AIConversation(_))); let mut items = vec![ MenuItemFields::new("Only people invited") .with_on_select_action(SharingDialogAction::SetLinkPermissions(None)) .with_icon(Icon::Lock) .with_disabled(inherited_access) .into_item(), MenuItem::Separator, MenuItemFields::new("Anyone with the link") .with_no_interaction_on_hover() .with_icon(Icon::Globe) .into_item(), MenuItemFields::new(SharingAccessLevel::View.label()) .with_on_select_action(SharingDialogAction::SetLinkPermissions(Some( SharingAccessLevel::View, ))) .with_disabled( inherited_access && current_access_level >= Some(SharingAccessLevel::View), ) .into_item(), ]; // Only add Edit option if not an AI conversation if !is_ai_conversation { items.push( MenuItemFields::new(SharingAccessLevel::Edit.label()) .with_on_select_action(SharingDialogAction::SetLinkPermissions(Some( SharingAccessLevel::Edit, ))) .with_disabled( inherited_access && current_access_level >= Some(SharingAccessLevel::Edit), ) .into_item(), ); } self.link_sharing_menu.update(ctx, |menu, ctx| { menu.set_items(items, ctx); menu.set_selected_by_index( match current_access_level { None => 0, Some(SharingAccessLevel::View) => 3, Some(SharingAccessLevel::Edit) => { if is_ai_conversation { 3 } else { 4 } } Some(SharingAccessLevel::Full) => 3, }, ctx, ); }) } fn team_sharing_menu_button_id(&self) -> String { format!("team_sharing_menu_button_{}", self.self_handle.id()) } /// Renders the entry for the team sharing ACL, if the correct conditions /// are met. fn render_team_sharing_subject( &self, appearance: &Appearance, app: &AppContext, ) -> Option> { // Currently, we only allow editing the team ACL for sessions. if !matches!(self.target, Some(ShareableObject::Session { .. })) { return None; } let can_edit_access = self.can_edit_access(app); // If there's no team ACL, and the user can't edit it, show nothing. if !can_edit_access && self.team_sharing_state.access_level.is_none() { return None; } let (inherited_label, inherited_tooltip) = self .team_sharing_state .inheritance .as_ref() .map(|inheritance| { let InheritanceDetails { source_label, tooltip_text, } = inheritance.details(appearance, app); (source_label, tooltip_text) }) .unzip(); // This logic assumes that the sharer's team is the one we would want // to add permissions for. let team_kind = if can_edit_access { TeamKind::Team { team_uid: UserWorkspaces::as_ref(app).current_team_uid()?, } } else { self.team_sharing_state.team.clone()? }; // If this team is the owner of the object, don't render this team sharing ACL since // we already rendered the team as the owner (and you can't change ACLs on it). if let Some(Subject::Team(team_owner)) = self.owner(app) { if team_owner.team_uid() == team_kind.team_uid() { return None; } } let mut subject_row = Flex::row(); subject_row.add_child( Container::new(self.render_subject( &Subject::Team(team_kind), inherited_label, appearance, app, )) .with_horizontal_padding(style::ACL_ITEM_PADDING) .with_vertical_margin(style::ACL_ITEM_GAP / 2.) .finish(), ); let menu_button = { let label = match self.team_sharing_state.access_level { Some(access_level) => access_level.label(), None => NO_ACCESS_LABEL, }; let button = appearance .ui_builder() .button( ButtonVariant::Text, self.ui_state_handles.team_sharing_menu_button.clone(), ) .with_centered_text_label(label.to_string()) .with_style(UiComponentStyles { padding: Some(Coords::default()), ..Default::default() }); if can_edit_access { button } else { button.disabled() } }; let menu_button = SavePosition::new( menu_button .build() .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(SharingDialogAction::ToggleTeamSharingMenu); }) .finish(), &self.team_sharing_menu_button_id(), ) .finish(); subject_row.add_child(match inherited_tooltip { Some(tooltip) => render_with_detail_tooltip( tooltip, self.team_sharing_state.tooltip_handle.clone(), menu_button, appearance, ), None => menu_button, }); Some( Container::new( subject_row .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_main_axis_size(MainAxisSize::Max) .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) .finish(), ) .with_padding_right(style::ACL_ITEM_PADDING) .finish(), ) } fn reset_team_sharing_menu(&mut self, ctx: &mut ViewContext) { let inherited_access = self.team_sharing_state.inheritance.is_some(); let current_access_level = self.team_sharing_state.access_level; let items = [ MenuItemFields::new("Only invited teammates") .with_on_select_action(SharingDialogAction::SetTeamPermissions(None)) .with_icon(Icon::Lock) .with_disabled(inherited_access) .into_item(), MenuItem::Separator, MenuItemFields::new("Teammates with the link") .with_no_interaction_on_hover() .with_icon(Icon::Users) .into_item(), MenuItemFields::new(SharingAccessLevel::View.label()) .with_on_select_action(SharingDialogAction::SetTeamPermissions(Some( SharingAccessLevel::View, ))) .with_disabled( inherited_access && current_access_level >= Some(SharingAccessLevel::View), ) .into_item(), MenuItemFields::new(SharingAccessLevel::Edit.label()) .with_on_select_action(SharingDialogAction::SetTeamPermissions(Some( SharingAccessLevel::Edit, ))) .with_disabled( inherited_access && current_access_level >= Some(SharingAccessLevel::Edit), ) .into_item(), ]; self.team_sharing_menu.update(ctx, |menu, ctx| { menu.set_items(items, ctx); menu.set_selected_by_index( match current_access_level { None => 0, Some(SharingAccessLevel::View) => 3, Some(SharingAccessLevel::Edit) => 4, // Not yet supported, so default to view. Some(SharingAccessLevel::Full) => 3, }, ctx, ); }) } /// Renders a guest ACL. fn render_guest( &self, guest: &GuestState, index: usize, appearance: &Appearance, app: &AppContext, ) -> Box { let (inherited_label, inherited_tooltip) = guest .inheritance .as_ref() .map(|inheritance| { let InheritanceDetails { source_label, tooltip_text, } = inheritance.details(appearance, app); (source_label, tooltip_text) }) .unzip(); let mut access_level_button = appearance .ui_builder() .button(ButtonVariant::Text, guest.menu_button_handle.clone()) .with_centered_text_label(guest.current_access_level.label().to_string()) .with_style(UiComponentStyles { padding: Some(Coords::default()), ..Default::default() }); if !self.can_edit_access(app) { access_level_button = access_level_button.disabled(); } let access_level_label = SavePosition::new( access_level_button .build() .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(SharingDialogAction::ToggleGuestMenu(index)); }) .finish(), &self.guest_access_button_id(index), ) .finish(); let row = Flex::row() .with_children([ self.render_subject(&guest.subject, inherited_label, appearance, app), match inherited_tooltip { Some(tooltip) => render_with_detail_tooltip( tooltip, guest.tooltip_handle.clone(), access_level_label, appearance, ), None => access_level_label, }, ]) .with_main_axis_size(MainAxisSize::Max) .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) .with_cross_axis_alignment(CrossAxisAlignment::Center) .finish(); Container::new( ConstrainedBox::new(row) // Each guest row must have the exact same height for UniformList to scroll correctly. .with_height(style::ACL_GUEST_HEIGHT) .finish(), ) .with_horizontal_padding(style::ACL_ITEM_PADDING) .with_vertical_margin(style::ACL_ITEM_GAP / 2.) .finish() } /// Render all guest ACLs on the object. If there are no guests, returns `None` to not take up /// space. fn render_guests(&self, appearance: &Appearance) -> Option> { if self.guest_states.is_empty() { return None; } let self_handle = self.self_handle.clone(); let list = UniformList::new( self.ui_state_handles.guest_list_state.clone(), self.guest_states.len(), move |range, app| { let appearance = Appearance::as_ref(app); let me = self_handle .upgrade(app) .expect("Dialog handle must be upgradeable if rendered") .as_ref(app); // Create a temporary Vec to avoid borrowing AppContext for too long. let guests = me .guest_states .iter() .enumerate() .skip(range.start) .take(range.end - range.start) .map(|(idx, guest)| me.render_guest(guest, idx, appearance, app)) .collect_vec(); guests.into_iter() }, ); let scrollable_list = Scrollable::vertical( self.ui_state_handles.guest_scroll_state.clone(), list.finish_scrollable(), ScrollbarWidth::Auto, appearance.theme().nonactive_ui_detail().into(), appearance.theme().active_ui_detail().into(), Fill::None, ) .with_overlayed_scrollbar() .finish(); Some( ConstrainedBox::new(scrollable_list) .with_max_height(GUEST_LIST_MAX_HEIGHT) .finish(), ) } /// Renders a single subject, with their name, avatar, and detail subtext. fn render_subject( &self, subject: &Subject, detail_override: Option>, appearance: &Appearance, app: &AppContext, ) -> Box { let avatar = Container::new(subject.avatar(appearance, app).build().finish()) .with_padding_right(10.) .finish(); let name_text = subject.name(app).unwrap_or(Cow::Borrowed("Unknown")); let name_label = appearance .ui_builder() .span(name_text) .with_style(UiComponentStyles { font_size: Some(style::PRIMARY_TEXT_SIZE), font_color: Some(style::acl_primary_text_color(appearance)), ..Default::default() }) .build() .finish(); let detail_element = detail_override.or_else(|| { subject .detail(app) .map(|detail| style::detail_text(detail, appearance).build().finish()) }); let info = match detail_element { Some(detail) => Flex::column() .with_children([name_label, detail]) .with_main_axis_alignment(MainAxisAlignment::Center) .with_cross_axis_alignment(CrossAxisAlignment::Start) .finish(), None => name_label, }; Flex::row() .with_main_axis_alignment(MainAxisAlignment::Start) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_children([avatar, info]) .finish() } fn target_link(&self, app: &AppContext) -> Option { self.target.as_ref().and_then(|target| target.link(app)) } fn target_session_id(&self) -> Option { match self.target { Some(ShareableObject::Session { session_id, .. }) => Some(session_id), _ => None, } } fn qr_filename(&self) -> String { match self.target_session_id() { Some(session_id) => format!("warp-session-qr-code-{session_id}.png"), None => "warp-session-qr-code.png".to_string(), } } fn show_ephemeral_toast( &self, toast: DismissibleToast, ctx: &mut ViewContext, ) { let window_id = ctx.window_id(); ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| { toast_stack.add_ephemeral_toast(toast, window_id, ctx); }); } fn download_qr_code(&self, ctx: &mut ViewContext) { let Some(url) = self.target_link(ctx) else { self.show_ephemeral_toast( DismissibleToast::error("Unable to download QR code.".to_string()), ctx, ); return; }; let png = match qr_png_for_url(&url, QR_EXPORT_SIZE) { Ok(png) => png, Err(_) => { self.show_ephemeral_toast( DismissibleToast::error("Unable to download QR code.".to_string()), ctx, ); return; } }; ctx.open_save_file_picker( move |path, me, ctx| { let Some(path) = path else { return; }; me.write_qr_png(path, png, ctx); }, SaveFilePickerConfiguration::new().with_default_filename(self.qr_filename()), ); } fn write_qr_png(&self, path: String, png: Vec, ctx: &mut ViewContext) { ctx.spawn( async move { async_fs::write(path, png).await }, |me, result, ctx| me.handle_qr_write_result(result, ctx), ); } fn handle_qr_write_result(&self, result: std::io::Result<()>, ctx: &mut ViewContext) { match result { Ok(()) => self.show_ephemeral_toast( DismissibleToast::success("QR code downloaded.".to_string()), ctx, ), Err(_) => self.show_ephemeral_toast( DismissibleToast::error("Unable to download QR code.".to_string()), ctx, ), } } fn render_footer_icon_button( &self, icon: Icon, action: SharingDialogAction, tooltip: &'static str, mouse_state: MouseStateHandle, appearance: &Appearance, ) -> Box { let button_background = appearance.theme().surface_2(); let button_foreground = appearance.theme().main_text_color(button_background); let ui_builder = appearance.ui_builder().clone(); icon_button_with_color( appearance, icon, false, mouse_state, ThemeFill::Solid(button_foreground.into()), ) .with_tooltip(move || ui_builder.tool_tip(tooltip.to_string()).build().finish()) .with_style(UiComponentStyles { width: Some(style::ACL_ITEM_HEIGHT), height: Some(style::ACL_ITEM_HEIGHT), padding: Some(Coords::uniform(4.)), font_color: Some(button_foreground.into()), background: Some(button_background.into()), border_color: Some(style::form_border_color(appearance).into()), border_radius: Some(CornerRadius::with_all(Radius::Pixels(4.))), ..Default::default() }) .with_clicked_styles(UiComponentStyles { background: Some(appearance.theme().surface_2().into()), ..Default::default() }) .with_hovered_styles(UiComponentStyles { background: Some(appearance.theme().surface_3().into()), ..Default::default() }) .build() .on_click(move |ctx, _, _| ctx.dispatch_typed_action(action.clone())) .finish() } fn render_qr_matrix(&self, matrix: &QrMatrix) -> Box { let modules_with_quiet_zone = matrix.width().saturating_add(QUIET_ZONE_MODULES * 2); let module_size = QR_VISUAL_SIZE / modules_with_quiet_zone as f32; let mut column = Flex::column().with_main_axis_size(MainAxisSize::Max); for y in 0..modules_with_quiet_zone { let mut row = Flex::row().with_main_axis_size(MainAxisSize::Max); for x in 0..modules_with_quiet_zone { let matrix_x = x.saturating_sub(QUIET_ZONE_MODULES); let matrix_y = y.saturating_sub(QUIET_ZONE_MODULES); let is_dark = x >= QUIET_ZONE_MODULES && y >= QUIET_ZONE_MODULES && matrix_x < matrix.width() && matrix_y < matrix.width() && matrix.is_dark(matrix_x, matrix_y); let color = if is_dark { ColorU::black() } else { ColorU::white() }; row.add_child( ConstrainedBox::new( Container::new(Empty::new().finish()) .with_background(color) .finish(), ) .with_width(module_size) .with_height(module_size) .finish(), ); } column.add_child(row.finish()); } ConstrainedBox::new(column.finish()) .with_width(QR_VISUAL_SIZE) .with_height(QR_VISUAL_SIZE) .finish() } fn render_qr_header(&self, appearance: &Appearance) -> Box { let foreground = style::acl_primary_text_color(appearance); let icon_button_styles = UiComponentStyles { width: Some(QR_ICON_BUTTON_SIZE), height: Some(QR_ICON_BUTTON_SIZE), font_color: Some(foreground), border_radius: Some(CornerRadius::with_all(Radius::Pixels(4.))), ..Default::default() }; let icon_hover_styles = UiComponentStyles { background: Some(appearance.theme().surface_2().into()), ..Default::default() }; let back_button = icon_button_with_color( appearance, Icon::ArrowLeft, false, self.ui_state_handles.qr_back_button.clone(), ThemeFill::Solid(foreground), ) .with_style(icon_button_styles) .with_hovered_styles(icon_hover_styles) .build() .on_click(|ctx, _, _| ctx.dispatch_typed_action(SharingDialogAction::BackToAccessDialog)) .finish(); let title = appearance .ui_builder() .span("Share session QR code") .with_style(UiComponentStyles { font_color: Some(foreground), font_size: Some(style::HEADER_TEXT_SIZE), ..Default::default() }) .build() .finish(); let esc_hint = appearance .ui_builder() .span("ESC") .with_style(UiComponentStyles { font_color: Some(style::acl_secondary_text_color(appearance)), font_size: Some(12.), background: Some(appearance.theme().surface_2().into()), border_radius: Some(CornerRadius::with_all(Radius::Pixels(4.))), padding: Some(Coords::default().left(6.).right(6.).top(2.).bottom(2.)), ..Default::default() }) .build() .finish(); let close_button = icon_button_with_color( appearance, Icon::X, false, self.ui_state_handles.qr_close_button.clone(), ThemeFill::Solid(foreground), ) .with_style(icon_button_styles) .with_hovered_styles(icon_hover_styles) .build() .on_click(|ctx, _, _| ctx.dispatch_typed_action(SharingDialogAction::Close)) .finish(); Container::new( Flex::row() .with_main_axis_size(MainAxisSize::Max) .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_children([ back_button, Shrinkable::new(1., title).finish(), esc_hint, close_button, ]) .finish(), ) .with_horizontal_padding(style::ACL_ITEM_PADDING) .with_padding_top(8.) .with_padding_bottom(8.) .finish() } fn render_qr_dialog(&self, appearance: &Appearance, app: &AppContext) -> Box { let qr_contents = self .target_link(app) .and_then(|url| qr_matrix_for_url(&url).ok()) .map(|matrix| { let card = Container::new(Align::new(self.render_qr_matrix(&matrix)).finish()) .with_background(ColorU::white()) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.))) .finish(); let action_row = Flex::row() .with_main_axis_alignment(MainAxisAlignment::Center) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_children([ self.render_footer_icon_button( Icon::Copy, SharingDialogAction::CopyLink, "Copy link", self.ui_state_handles.qr_copy_button.clone(), appearance, ), Container::new(self.render_footer_icon_button( Icon::Download, SharingDialogAction::DownloadQrCode, "Download QR code", self.ui_state_handles.qr_download_button.clone(), appearance, )) .with_margin_left(8.) .finish(), ]) .finish(); Flex::column() .with_main_axis_alignment(MainAxisAlignment::Center) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_children([ ConstrainedBox::new(card) .with_width(QR_CARD_SIZE) .with_height(QR_CARD_SIZE) .finish(), Container::new(action_row).with_margin_top(12.).finish(), ]) .finish() }) .unwrap_or_else(|| { appearance .ui_builder() .paragraph("Unable to create QR code for this session link.") .with_style(UiComponentStyles { font_color: Some(style::acl_secondary_text_color(appearance)), ..Default::default() }) .build() .finish() }); Flex::column() .with_main_axis_size(MainAxisSize::Min) .with_children([ self.render_qr_header(appearance), Container::new(Align::new(qr_contents).finish()) .with_vertical_padding(32.) .finish(), ]) .finish() } /// Renders a link to the shared object, with a CTA to copy the URL. fn render_object_link(&self, appearance: &Appearance, app: &AppContext) -> Box { let url = match self.target_link(app) { Some(url) => url, None => return Empty::new().finish(), }; let link_text = appearance .ui_builder() .span(url) .with_style(UiComponentStyles { font_color: Some(style::acl_secondary_text_color(appearance)), ..Default::default() }) .build() .finish(); let link = Container::new(Align::new(link_text).left().finish()) .with_border(Border::all(1.).with_border_color(style::form_border_color(appearance))) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.))) .with_uniform_padding(8.) .finish(); let copy_button_background = appearance.theme().surface_2(); let copy_button_foreground = appearance.theme().main_text_color(copy_button_background); let copy_button = appearance .ui_builder() .button( ButtonVariant::Basic, self.ui_state_handles.copy_link_button.clone(), ) .with_text_and_icon_label( TextAndIcon::new( TextAndIconAlignment::IconFirst, "Copy link", Icon::Link.to_galaxyui_icon(copy_button_foreground), MainAxisSize::Min, MainAxisAlignment::SpaceBetween, vec2f(12., 12.), ) .with_inner_padding(4.), ) .with_style(UiComponentStyles { font_color: Some(copy_button_foreground.into()), background: Some(copy_button_background.into()), border_color: Some(style::form_border_color(appearance).into()), border_radius: Some(CornerRadius::with_all(Radius::Pixels(4.))), ..Default::default() }) .with_clicked_styles(UiComponentStyles { background: Some(appearance.theme().surface_2().into()), ..Default::default() }) .with_hovered_styles(UiComponentStyles { background: Some(appearance.theme().surface_3().into()), ..Default::default() }) .build() .on_click(|ctx, _, _| ctx.dispatch_typed_action(SharingDialogAction::CopyLink)) .finish(); let qr_button = matches!(self.target, Some(ShareableObject::Session { .. })).then(|| { self.render_footer_icon_button( Icon::QrCode, SharingDialogAction::ShowQrCode, "Show QR code", self.ui_state_handles.qr_code_button.clone(), appearance, ) }); let mut link_row = Flex::row() .with_main_axis_size(MainAxisSize::Max) .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child(Shrinkable::new(1., link).finish()); if let Some(qr_button) = qr_button { link_row.add_child(Container::new(qr_button).with_margin_left(8.).finish()); } link_row.add_child(Container::new(copy_button).with_margin_left(8.).finish()); let link_form = ConstrainedBox::new(link_row.finish()) .with_min_height(style::ACL_ITEM_HEIGHT) .finish(); Container::new(link_form) .with_horizontal_padding(style::ACL_ITEM_PADDING) .with_vertical_padding(style::ACL_ITEM_PADDING / 2.) .with_vertical_margin(style::ACL_ITEM_GAP / 2.) .finish() } } /// Render a tooltip that explains an ACL. fn render_with_detail_tooltip( tooltip: impl Into, mouse_state: MouseStateHandle, element: Box, appearance: &Appearance, ) -> Box { appearance.ui_builder().tool_tip_on_element( tooltip.into(), mouse_state, element, ParentAnchor::TopLeft, ChildAnchor::TopRight, vec2f(-4., 0.), ) } impl Entity for SharingDialog { type Event = SharingDialogEvent; } impl View for SharingDialog { fn ui_name() -> &'static str { "SharingDialog" } fn render(&self, app: &AppContext) -> Box { let appearance = Appearance::as_ref(app); let theme = appearance.theme(); let (contents, width) = if self.mode == SharingDialogMode::QrCode && matches!(self.target, Some(ShareableObject::Session { .. })) { (self.render_qr_dialog(appearance, app), QR_DIALOG_WIDTH) } else { let mut contents = Flex::column(); contents.extend(self.render_session_header(appearance, app)); if self.can_edit_access(app) && self.can_direct_link_share(app) { contents.add_child(self.render_invite_form(appearance, app)); } contents.add_child(self.render_access_header(appearance)); contents.extend(self.render_restricted_access_label(appearance, app)); if self.can_anyone_with_link_share(app) { contents.extend(self.render_link_sharing_subject(appearance, app)); } contents.extend(self.render_team_sharing_subject(appearance, app)); contents.extend(self.render_owner(appearance, app)); if let Some(guest_list) = self.render_guests(appearance) { contents.add_child(guest_list); } contents.add_child(self.render_object_link(appearance, app)); let mut stack = Stack::new(); stack.add_child(contents.finish()); self.render_menu(&mut stack, app); let contents = Container::new(stack.finish()) .with_vertical_padding(style::ACL_ITEM_PADDING) .finish(); (contents, SHARING_DIALOG_WIDTH) }; let dialog = Container::new(contents) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.))) .with_border(Border::all(1.).with_border_color(theme.surface_3().into())) .with_background(style::dialog_background(appearance)); Dismiss::new( ConstrainedBox::new(dialog.finish()) .with_width(width) .finish(), ) .on_dismiss(|ctx, _app| ctx.dispatch_typed_action(SharingDialogAction::Close)) .prevent_interaction_with_other_elements() .finish() } fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext) { if focus_ctx.is_self_focused() && self.mode == SharingDialogMode::Access { ctx.focus(&self.invite_form.email_editor); } } } impl TypedActionView for SharingDialog { type Action = SharingDialogAction; fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext) { match action { SharingDialogAction::Close => { self.reset_editable_state(ctx); ctx.emit(SharingDialogEvent::Close) } SharingDialogAction::SetLinkPermissions(access_level) => { self.set_open_menu(OpenMenuState::None, ctx); if let Some(ShareableObject::WarpDriveObject(id)) = self.target.as_ref() { UpdateManager::handle(ctx).update(ctx, move |update_manager, ctx| { update_manager.set_object_link_permissions(*id, *access_level, ctx); }); } else if let Some(ShareableObject::Session { handle, .. }) = self.target.as_ref() { if let Some(view) = handle.upgrade(ctx) { let role = access_level.map(|access_level| access_level.into()); view.update(ctx, |view, ctx| { view.update_session_link_permissions(role, ctx) }); } } else if let Some(ShareableObject::AIConversation(conversation_id)) = self.target.as_ref() { // Get the conversation's server_id from metadata if let Some(server_id) = BlocklistAIHistoryModel::as_ref(ctx) .get_server_conversation_metadata(conversation_id) .map(|m| ServerId::from_string_lossy(m.metadata.uid.uid())) { UpdateManager::handle(ctx).update(ctx, move |update_manager, ctx| { update_manager.set_ai_conversation_link_permissions( server_id, *conversation_id, *access_level, ctx, ); }); } else { log::warn!( "AI conversation {:?} has no server_id for link permission update", conversation_id ); } } ctx.notify(); } SharingDialogAction::SetTeamPermissions(access_level) => { self.set_open_menu(OpenMenuState::None, ctx); // So far, we only support setting team permissions for sessions. if let Some(ShareableObject::Session { handle, .. }) = self.target.as_ref() { let Some(view) = handle.upgrade(ctx) else { return; }; let Some(team_uid) = UserWorkspaces::as_ref(ctx).current_team_uid() else { return; }; let role = access_level.map(|access_level| access_level.into()); view.update(ctx, |view, ctx| { view.update_session_team_permissions(role, team_uid.uid(), ctx); }); } ctx.notify(); } SharingDialogAction::CopyLink => self.copy_link(ctx), SharingDialogAction::ShowQrCode => self.show_qr_code(ctx), SharingDialogAction::BackToAccessDialog => { self.mode = SharingDialogMode::Access; ctx.notify(); } SharingDialogAction::DownloadQrCode => self.download_qr_code(ctx), SharingDialogAction::ToggleLinkSharingMenu => { self.toggle_menu(OpenMenuState::LinkSharing, ctx); } SharingDialogAction::ToggleTeamSharingMenu => { self.toggle_menu(OpenMenuState::TeamSharing, ctx); } SharingDialogAction::ToggleInviteAccessLevelMenu => { self.toggle_menu(OpenMenuState::InviteAccessLevel, ctx); } SharingDialogAction::SetInviteAccessLevel(level) => { self.invite_form.selected_access_level = *level; ctx.notify(); } SharingDialogAction::SendInvitations => self.send_invitations(ctx), SharingDialogAction::ToggleGuestMenu(index) => { self.toggle_menu(OpenMenuState::Guest(*index), ctx); } SharingDialogAction::SetGuestAccessLevel(level) => { self.set_targeted_guest_access(*level, ctx) } SharingDialogAction::RemoveGuest => self.remove_targeted_guest(ctx), } } }