use std::sync::Arc; use ::settings::AppSettings; use chat::{ChatEvent, ChatRegistry}; use common::{CoopImageCache, download_dir}; use device::{DeviceEvent, DeviceRegistry}; use gpui::prelude::FluentBuilder; use gpui::{ Action, App, AppContext, Axis, Context, Entity, InteractiveElement, IntoElement, ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Window, div, image_cache, px, relative, }; use nostr_sdk::prelude::*; use person::{PersonRegistry, shorten_pubkey}; use serde::Deserialize; use smallvec::{SmallVec, smallvec}; use state::{IMAGE_CACHE_SIZE, NostrRegistry, StateEvent}; use theme::{ActiveTheme, SIDEBAR_WIDTH, Theme, ThemeRegistry}; use title_bar::TitleBar; use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; use ui::dock::{ClosePanel, DockArea, DockItem, DockPlacement, PanelView}; use ui::menu::{DropdownMenu, PopupMenuItem}; use ui::notification::{Notification, NotificationKind}; use ui::{Icon, IconName, Root, Sizable, WindowExtension, h_flex, v_flex}; use crate::dialogs::restore::RestoreEncryption; use crate::dialogs::{accounts, settings}; use crate::panels::{backup, contact_list, greeter, messaging_relays, profile, relay_list, trash}; use crate::sidebar; const PREPARE_MSG: &str = "Coop is preparing a new identity for you. This may take a moment..."; const ENC_MSG: &str = "Encryption Key is a special key that used to encrypt and decrypt your messages. \ Your identity is completely decoupled from all encryption processes to protect your privacy."; const ENC_WARN: &str = "By resetting your encryption key, you will lose access to \ all your encrypted messages before. This action cannot be undone."; pub fn init(window: &mut Window, cx: &mut App) -> Entity { cx.new(|cx| Workspace::new(window, cx)) } struct DeviceNotifcation; struct SignerNotifcation; struct RelayNotifcation; #[derive(Action, Clone, PartialEq, Eq, Deserialize)] #[action(namespace = workspace, no_json)] enum Command { ToggleTheme, ToggleAccount, RefreshMessagingRelays, BackupEncryption, ImportEncryption, RefreshEncryption, ResetEncryption, ShowRelayList, ShowMessaging, ShowProfile, ShowSettings, ShowBackup, ShowContactList, } pub struct Workspace { /// App's Title Bar titlebar: Entity, /// App's Dock Area dock: Entity, /// App's Image Cache image_cache: Entity, /// Event subscriptions _subscriptions: SmallVec<[Subscription; 5]>, } impl Workspace { fn new(window: &mut Window, cx: &mut Context) -> Self { let chat = ChatRegistry::global(cx); let device = DeviceRegistry::global(cx); let nostr = NostrRegistry::global(cx); let titlebar = cx.new(|_| TitleBar::new()); let dock = cx.new(|cx| DockArea::new(window, cx)); let image_cache = CoopImageCache::new(IMAGE_CACHE_SIZE, cx); let mut subscriptions = smallvec![]; subscriptions.push( // Observe system appearance and update theme cx.observe_window_appearance(window, |_this, window, cx| { Theme::sync_system_appearance(Some(window), cx); }), ); subscriptions.push( // Subscribe to the signer events cx.subscribe_in(&nostr, window, move |this, _state, event, window, cx| { match event { StateEvent::Creating => { let note = Notification::new() .id::() .title("Preparing a new identity") .message(PREPARE_MSG) .autohide(false) .with_kind(NotificationKind::Info); window.push_notification(note, cx); } StateEvent::Connecting => { let note = Notification::new() .id::() .message("Connecting to the bootstrap relays...") .with_kind(NotificationKind::Info); window.push_notification(note, cx); } StateEvent::Connected => { let note = Notification::new() .id::() .message("Connected to the bootstrap relays") .with_kind(NotificationKind::Success); window.push_notification(note, cx); } StateEvent::SignerSet => { this.set_center_layout(window, cx); // Clear the signer notification window.clear_notification::(cx); } StateEvent::Show => { this.account_selector(window, cx); } _ => {} }; }), ); subscriptions.push( // Observe all events emitted by the device registry cx.subscribe_in(&device, window, |_this, _device, event, window, cx| { match event { DeviceEvent::Requesting => { const MSG: &str = "Coop has sent a request for an encryption key. Please open the other client then approve the request."; let note = Notification::new() .id::() .autohide(false) .title("Wait for approval") .message(MSG) .with_kind(NotificationKind::Info); window.push_notification(note, cx); } DeviceEvent::Creating => { let note = Notification::new() .id::() .autohide(false) .message("Creating encryption key") .with_kind(NotificationKind::Info); window.push_notification(note, cx); } DeviceEvent::Set => { let note = Notification::new() .id::() .message("Encryption Key has been set") .with_kind(NotificationKind::Success); window.push_notification(note, cx); } DeviceEvent::Error(error) => { window.push_notification(Notification::error(error).autohide(false), cx); } }; }), ); subscriptions.push( // Observe all events emitted by the chat registry cx.subscribe_in(&chat, window, move |this, chat, ev, window, cx| { match ev { ChatEvent::OpenRoom(id) => { if let Some(room) = chat.read(cx).room(id, cx) { this.dock.update(cx, |this, cx| { this.add_panel( Arc::new(chat_ui::init(room, window, cx)), DockPlacement::Center, window, cx, ); }); } } ChatEvent::CloseRoom(..) => { this.dock.update(cx, |this, cx| { // Force focus to the tab panel this.focus_tab_panel(window, cx); // Dispatch the close panel action cx.defer_in(window, |_, window, cx| { window.dispatch_action(Box::new(ClosePanel), cx); window.close_all_modals(cx); }); }); } ChatEvent::Error(error) => { window.push_notification(Notification::error(error).autohide(false), cx); } _ => {} }; }), ); subscriptions.push( // Observe the chat registry cx.observe(&chat, move |this, chat, cx| { let ids = this.panel_ids(cx); chat.update(cx, |this, cx| { this.refresh_rooms(&ids, cx); }); }), ); // Set the layout at the end of cycle cx.defer_in(window, |this, window, cx| { this.set_layout(window, cx); }); Self { titlebar, dock, image_cache, _subscriptions: subscriptions, } } /// Add panel to the dock pub fn add_panel

(panel: P, placement: DockPlacement, window: &mut Window, cx: &mut App) where P: PanelView, { if let Some(root) = window.root::().flatten() && let Ok(workspace) = root.read(cx).view().clone().downcast::() { workspace.update(cx, |this, cx| { this.dock.update(cx, |this, cx| { this.add_panel(Arc::new(panel), placement, window, cx); }); }); } } /// Get all panel ids fn panel_ids(&self, cx: &App) -> Vec { self.dock .read(cx) .items .panel_ids(cx) .into_iter() .filter_map(|panel| panel.parse::().ok()) .collect() } /// Set the dock layout fn set_layout(&mut self, window: &mut Window, cx: &mut Context) { let left = DockItem::panel(Arc::new(sidebar::init(window, cx))); // Update the dock layout with sidebar on the left self.dock.update(cx, |this, cx| { this.set_left_dock(left, Some(SIDEBAR_WIDTH), true, window, cx); }); } /// Set the center dock layout fn set_center_layout(&mut self, window: &mut Window, cx: &mut Context) { let dock = self.dock.downgrade(); let greeter = Arc::new(greeter::init(window, cx)); let tabs = DockItem::tabs(vec![greeter], None, &dock, window, cx); let center = DockItem::split(Axis::Vertical, vec![tabs], &dock, window, cx); // Update the layout with center dock self.dock.update(cx, |this, cx| { this.set_center(center, window, cx); }); } /// Handle command events fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context) { match command { Command::ShowSettings => { let view = settings::init(window, cx); window.open_modal(cx, move |this, _window, _cx| { this.width(px(520.)) .show_close(true) .pb_2() .title("Preferences") .child(view.clone()) }); } Command::ShowProfile => { let nostr = NostrRegistry::global(cx); let signer = nostr.read(cx).signer(); if let Some(public_key) = signer.public_key() { self.dock.update(cx, |this, cx| { this.add_panel( Arc::new(profile::init(public_key, window, cx)), DockPlacement::Right, window, cx, ); }); } } Command::ShowContactList => { self.dock.update(cx, |this, cx| { this.add_panel( Arc::new(contact_list::init(window, cx)), DockPlacement::Right, window, cx, ); }); } Command::ShowBackup => { self.dock.update(cx, |this, cx| { this.add_panel( Arc::new(backup::init(window, cx)), DockPlacement::Right, window, cx, ); }); } Command::ShowMessaging => { self.dock.update(cx, |this, cx| { this.add_panel( Arc::new(messaging_relays::init(window, cx)), DockPlacement::Right, window, cx, ); }); } Command::RefreshMessagingRelays => { let chat = ChatRegistry::global(cx); // Trigger a refresh of the chat registry chat.update(cx, |this, cx| { this.refresh(window, cx); }); } Command::ShowRelayList => { self.dock.update(cx, |this, cx| { this.add_panel( Arc::new(relay_list::init(window, cx)), DockPlacement::Right, window, cx, ); }); } Command::RefreshEncryption => { let device = DeviceRegistry::global(cx); device.update(cx, |this, cx| { this.get_announcement(cx); }); } Command::ResetEncryption => { self.confirm_reset_encryption(window, cx); } Command::ToggleTheme => { self.theme_selector(window, cx); } Command::ToggleAccount => { self.account_selector(window, cx); } Command::BackupEncryption => { let device = DeviceRegistry::global(cx).downgrade(); let save_dialog = cx.prompt_for_new_path(download_dir(), Some("encryption.txt")); cx.spawn_in(window, async move |_this, cx| { // Get the output path from the save dialog let output_path = match save_dialog.await { Ok(Ok(Some(path))) => path, Ok(Ok(None)) | Err(_) => return Ok(()), Ok(Err(error)) => { cx.update(|window, cx| { let message = format!("Failed to pick save location: {error:#}"); let note = Notification::error(message).autohide(false); window.push_notification(note, cx); })?; return Ok(()); } }; // Get the backup task let backup = device.read_with(cx, |this, cx| this.backup(output_path.clone(), cx))?; // Run the backup task backup.await?; // Open the backup file with the system's default application cx.update(|_window, cx| { cx.open_with_system(output_path.as_path()); })?; Ok::<_, anyhow::Error>(()) }) .detach(); } Command::ImportEncryption => { self.import_encryption(window, cx); } } } fn confirm_reset_encryption(&mut self, window: &mut Window, cx: &mut Context) { let device = DeviceRegistry::global(cx); let ent = device.downgrade(); window.open_modal(cx, move |this, _window, cx| { let ent = ent.clone(); this.confirm() .show_close(true) .title("Reset Encryption Key") .child( v_flex() .gap_1() .text_sm() .child(SharedString::from(ENC_MSG)) .child( div() .italic() .text_color(cx.theme().text_danger) .child(SharedString::from(ENC_WARN)), ), ) .on_ok(move |_ev, _window, cx| { ent.update(cx, |this, cx| { this.set_announcement(Keys::generate(), cx); }) .ok(); // true to close modal true }) }); } fn import_encryption(&mut self, window: &mut Window, cx: &mut Context) { let restore = cx.new(|cx| RestoreEncryption::new(window, cx)); window.open_modal(cx, move |this, _window, _cx| { this.width(px(520.)) .title("Restore Encryption") .child(restore.clone()) }); } fn account_selector(&mut self, window: &mut Window, cx: &mut Context) { let accounts = accounts::init(window, cx); window.open_modal(cx, move |this, _window, _cx| { this.width(px(520.)) .title("Continue with") .show_close(false) .keyboard(false) .overlay_closable(false) .child(accounts.clone()) }); } fn theme_selector(&mut self, window: &mut Window, cx: &mut Context) { window.open_modal(cx, move |this, _window, cx| { let registry = ThemeRegistry::global(cx); let themes = registry.read(cx).themes(); this.width(px(520.)) .show_close(true) .title("Select theme") .child(v_flex().gap_2().w_full().children({ let mut items = vec![]; for (ix, (path, theme)) in themes.iter().enumerate() { items.push( h_flex() .id(ix) .group("") .px_2() .h_8() .w_full() .justify_between() .rounded(cx.theme().radius) .bg(cx.theme().ghost_element_background) .hover(|this| this.bg(cx.theme().ghost_element_hover)) .child( h_flex() .gap_1p5() .flex_1() .text_sm() .child(theme.name.clone()) .child( div() .text_xs() .italic() .text_color(cx.theme().text_muted) .child(theme.author.clone()), ), ) .child( h_flex() .gap_1() .invisible() .group_hover("", |this| this.visible()) .child( Button::new(format!("url-{ix}")) .icon(IconName::Link) .ghost() .small() .on_click({ let theme = theme.clone(); move |_ev, _window, cx| { cx.open_url(&theme.url); } }), ) .child( Button::new(format!("set-{ix}")) .icon(IconName::Check) .primary() .small() .on_click({ let path = path.clone(); move |_ev, window, cx| { let settings = AppSettings::global(cx); let path = path.clone(); settings.update(cx, |this, cx| { this.set_theme(path, window, cx); }) } }), ), ), ); } items })) }); } fn titlebar_left(&mut self, cx: &mut Context) -> impl IntoElement { let nostr = NostrRegistry::global(cx); let signer = nostr.read(cx).signer(); let current_user = signer.public_key(); h_flex() .flex_shrink_0() .gap_2() .when_none(¤t_user, |this| { this.child( div() .text_xs() .text_color(cx.theme().text_muted) .child(SharedString::from("Choose an account to continue...")), ) }) .when_some(current_user.as_ref(), |this, public_key| { let persons = PersonRegistry::global(cx); let profile = persons.read(cx).get(public_key, cx); let avatar = profile.avatar(); let name = profile.name(); this.child( Button::new("current-user") .child(Avatar::new(avatar.clone()).xsmall()) .small() .caret() .compact() .transparent() .dropdown_menu(move |this, _window, _cx| { let avatar = avatar.clone(); let name = name.clone(); this.min_w(px(256.)) .item(PopupMenuItem::element(move |_window, cx| { h_flex() .gap_1p5() .text_xs() .text_color(cx.theme().text_muted) .child(Avatar::new(avatar.clone()).xsmall()) .child(name.clone()) })) .separator() .menu_with_icon( "Profile", IconName::Profile, Box::new(Command::ShowProfile), ) .menu_with_icon( "Contact List", IconName::Book, Box::new(Command::ShowContactList), ) .menu_with_icon( "Backup", IconName::UserKey, Box::new(Command::ShowBackup), ) .menu_with_icon( "Themes", IconName::Sun, Box::new(Command::ToggleTheme), ) .separator() .menu_with_icon( "Accounts", IconName::Group, Box::new(Command::ToggleAccount), ) .menu_with_icon( "Settings", IconName::Settings, Box::new(Command::ShowSettings), ) }), ) }) } fn titlebar_right(&mut self, cx: &mut Context) -> impl IntoElement { let chat = ChatRegistry::global(cx); let initializing = chat.read(cx).initializing; let trash_messages = chat.read(cx).count_trash_messages(cx); let device = DeviceRegistry::global(cx); let device_initializing = device.read(cx).initializing; let nostr = NostrRegistry::global(cx); let signer = nostr.read(cx).signer(); let Some(public_key) = signer.public_key() else { return div(); }; let persons = PersonRegistry::global(cx); let profile = persons.read(cx).get(&public_key, cx); let announcement = profile.announcement(); h_flex() .when(!cx.theme().platform.is_mac(), |this| this.pr_2()) .gap_2() .when(trash_messages > 0, |this| { this.child( h_flex() .id("trash-messages") .h_6() .px_1() .gap_1() .rounded(cx.theme().radius) .hover(|this| this.bg(cx.theme().ghost_element_hover)) .child( Icon::new(IconName::Warning) .small() .text_color(cx.theme().text_danger), ) .child( div() .text_xs() .line_height(relative(1.)) .child(format!("{trash_messages}")), ) .on_click(move |_ev, window, cx| { cx.stop_propagation(); // Add the trash panel to the center workspace Self::add_panel( trash::init(window, cx), DockPlacement::Center, window, cx, ); }), ) }) .child( Button::new("key") .icon(IconName::UserKey) .tooltip("Decoupled encryption key") .small() .ghost() .loading(device_initializing) .when(device_initializing, |this| { this.label("Dekey") .xsmall() .tooltip("Loading decoupled encryption key...") }) .dropdown_menu(move |this, _window, _cx| { this.min_w(px(260.)) .label("Encryption Key") .when_some(announcement.as_ref(), |this, announcement| { let name = announcement.client_name(); let pkey = shorten_pubkey(announcement.public_key(), 8); this.item(PopupMenuItem::element(move |_window, cx| { h_flex() .gap_1() .text_sm() .child( Icon::new(IconName::Device) .small() .text_color(cx.theme().icon_muted), ) .child(name.clone()) })) .item(PopupMenuItem::element(move |_window, cx| { h_flex() .gap_1() .text_sm() .child( Icon::new(IconName::UserKey) .small() .text_color(cx.theme().icon_muted), ) .child(SharedString::from(pkey.clone())) })) }) .separator() .menu_with_icon( "Backup", IconName::Shield, Box::new(Command::BackupEncryption), ) .menu_with_icon( "Restore from secret key", IconName::Usb, Box::new(Command::ImportEncryption), ) .separator() .menu_with_icon( "Reload", IconName::Refresh, Box::new(Command::RefreshEncryption), ) .menu_with_icon( "Reset", IconName::Warning, Box::new(Command::ResetEncryption), ) }), ) .child( Button::new("inbox") .icon(IconName::Inbox) .small() .ghost() .loading(initializing) .when(initializing, |this| { this.label("Inbox") .xsmall() .tooltip("Getting inbox messages...") }) .dropdown_menu(move |this, _window, cx| { let urls: Vec<(SharedString, SharedString)> = profile .messaging_relays() .iter() .map(|url| { ( SharedString::from(url.to_string()), chat.read(cx).count_messages(url).to_string().into(), ) }) .collect(); // Header let menu = this.min_w(px(260.)).label("Messaging Relays"); // Content let menu = urls.into_iter().fold(menu, |this, (url, count)| { this.item(PopupMenuItem::element(move |_window, cx| { h_flex() .px_1() .w_full() .text_sm() .justify_between() .child( h_flex() .gap_2() .child( div() .size_1p5() .rounded_full() .bg(cx.theme().icon_accent), ) .child(url.clone()), ) .child( div() .text_xs() .text_color(cx.theme().text_muted) .child(count.clone()), ) })) }); // Footer menu.separator() .menu_with_icon( "Reload", IconName::Refresh, Box::new(Command::RefreshMessagingRelays), ) .menu_with_icon( "Manage gossip relays", IconName::Relay, Box::new(Command::ShowRelayList), ) .menu_with_icon( "Manage messaging relays", IconName::Settings, Box::new(Command::ShowMessaging), ) }), ) } } impl Render for Workspace { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let modal_layer = Root::render_modal_layer(window, cx); let notification_layer = Root::render_notification_layer(window, cx); // Titlebar elements let left = self.titlebar_left(cx).into_any_element(); let right = self.titlebar_right(cx).into_any_element(); // Update title bar children self.titlebar.update(cx, |this, _cx| { this.set_children(vec![left, right]); }); div() .id(SharedString::from("workspace")) .on_action(cx.listener(Self::on_command)) .relative() .size_full() .child( image_cache(self.image_cache.clone()) .relative() .size_full() .child( v_flex() .size_full() // Title Bar .child(self.titlebar.clone()) // Dock .child(self.dock.clone()), ), ) // Notifications .children(notification_layer) // Modals .children(modal_layer) } }