Files
galaxy/app/src/drive/sharing/dialog/mod.rs
T

3004 lines
113 KiB
Rust

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<String>,
/// Invitees that are already guests on the object.
duplicate_guests: Vec<String>,
/// All invitees
invitee_emails: Vec<String>,
}
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<InheritanceState>,
}
/// UI state for link sharing.
#[derive(Default)]
struct LinkSharingState {
access_level: Option<SharingAccessLevel>,
tooltip_handle: MouseStateHandle,
inheritance: Option<InheritanceState>,
}
/// UI state for team sharing.
#[derive(Default)]
struct TeamSharingState {
team: Option<TeamKind>,
access_level: Option<SharingAccessLevel>,
tooltip_handle: MouseStateHandle,
inheritance: Option<InheritanceState>,
}
/// Container for fields related to the invite-by-email form.
struct EmailInviteForm {
email_editor: ViewHandle<WordBlockEditorView>,
selected_access_level: SharingAccessLevel,
access_level_menu: ViewHandle<Menu<SharingDialogAction>>,
}
pub struct SharingDialog {
self_handle: WeakViewHandle<SharingDialog>,
target: Option<ShareableObject>,
invite_form: EmailInviteForm,
guest_states: Vec<GuestState>,
guest_menu: ViewHandle<Menu<SharingDialogAction>>,
link_sharing_state: LinkSharingState,
link_sharing_menu: ViewHandle<Menu<SharingDialogAction>>,
team_sharing_state: TeamSharingState,
team_sharing_menu: ViewHandle<Menu<SharingDialogAction>>,
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<SharingAccessLevel>),
ToggleLinkSharingMenu,
ToggleTeamSharingMenu,
ToggleInviteAccessLevelMenu,
SetInviteAccessLevel(SharingAccessLevel),
ToggleGuestMenu(usize),
RemoveGuest,
SetGuestAccessLevel(SharingAccessLevel),
SendInvitations,
SetTeamPermissions(Option<SharingAccessLevel>),
}
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<ShareableObject>, ctx: &mut ViewContext<Self>) -> 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<Self>,
) {
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<Self>) {
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<Self>,
) {
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<Self>,
) {
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<ShareableObject>, ctx: &mut ViewContext<Self>) {
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<Self>) {
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<ServerId> {
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<Self>) {
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>) {
self.mode = SharingDialogMode::Access;
self.reset_invite_form(ctx);
ctx.notify();
}
fn owner(&self, app: &AppContext) -> Option<Subject> {
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<Self>,
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<SharingAccessLevel>,
ctx: &mut ViewContext<Self>,
) {
// 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<TeamAclData>,
ctx: &mut ViewContext<Self>,
) {
// 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<Self>) {
// 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<Self>) {
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<Self>) -> ViewHandle<Menu<SharingDialogAction>> {
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<Self>) {
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<Self>) {
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<TerminalView>,
ctx: &mut ViewContext<Self>,
) {
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<Self>,
) {
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<Self>,
) {
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<TerminalView>,
ctx: &mut ViewContext<Self>,
) {
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<Self>,
) {
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<Self>,
) {
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<String>,
access_level: SharingAccessLevel,
conversation_id: crate::ai::agent::conversation::AIConversationId,
ctx: &mut ViewContext<Self>,
) {
// 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<Self>,
) -> ViewHandle<Menu<SharingDialogAction>> {
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<Self>) {
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<dyn Element> {
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<Cow<'static, str>>,
appearance: &Appearance,
) -> Box<dyn Element> {
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<Self>,
) {
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<Self>) {
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>) {
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>) {
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<Menu<SharingDialogAction>>,
event: &menu::Event,
ctx: &mut ViewContext<Self>,
) {
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<Self>) {
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<Self>) {
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<dyn Element> {
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<Box<dyn Element>> {
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<Box<dyn Element>> {
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<Box<dyn Element>> {
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<Box<dyn Element>> {
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<Self>) {
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<Box<dyn Element>> {
// 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<Self>) {
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<dyn Element> {
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<Box<dyn Element>> {
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<Box<dyn Element>>,
appearance: &Appearance,
app: &AppContext,
) -> Box<dyn Element> {
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<String> {
self.target.as_ref().and_then(|target| target.link(app))
}
fn target_session_id(&self) -> Option<SessionId> {
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<WorkspaceAction>,
ctx: &mut ViewContext<Self>,
) {
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<Self>) {
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<u8>, ctx: &mut ViewContext<Self>) {
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<Self>) {
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<dyn Element> {
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<dyn Element> {
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<dyn Element> {
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<dyn Element> {
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<dyn Element> {
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<String>,
mouse_state: MouseStateHandle,
element: Box<dyn Element>,
appearance: &Appearance,
) -> Box<dyn Element> {
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<dyn Element> {
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<Self>) {
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<Self>) {
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),
}
}
}