use std::sync::Arc; use ::settings::AppSettings; use anyhow::Error; use auto_update::AutoUpdater; 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, Styled, Subscription, Task, Window, div, image_cache, px, }; 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 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, TitleBar, WindowExtension, h_flex, v_flex}; use crate::dialogs::import::ImportIdentity; use crate::dialogs::restore::RestoreEncryption; use crate::dialogs::settings; use crate::panels::{backup, contact_list, greeter, messaging_relays, profile, relay_list}; use crate::sidebar::Sidebar; mod dialogs; mod panels; mod sidebar; pub fn init(window: &mut Window, cx: &mut App) -> Entity { cx.new(|cx| Workspace::new(window, cx)) } struct DeviceNotifcation; struct MsgRelayNotification; #[derive(Action, Clone, PartialEq, Eq, Deserialize)] #[action(namespace = workspace, no_json)] enum Command { ToggleTheme, Update, RefreshMessagingRelays, BackupEncryption, ImportEncryption, RefreshEncryption, ResetEncryption, ShowRelayList, ShowMessaging, ShowProfile, ShowSettings, ShowBackup, ShowContactList, } pub struct Workspace { sidebar: Entity, /// App's Dock Area dock: Entity, /// App's Image Cache image_cache: Entity, /// Async tasks tasks: Vec>>, /// Event subscriptions _subscriptions: SmallVec<[Subscription; 6]>, } 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 sidebar = cx.new(|cx| Sidebar::new(window, cx)); 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 nostr events cx.subscribe_in(&nostr, window, move |this, _state, event, window, cx| { match event { StateEvent::SignerChanged => { window.close_all_modals(cx); } StateEvent::NoSigner => { this.import_identity(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 = "Please open other client and approve the request for encryption key."; let note = Notification::new() .id::() .autohide(false) .title("Wait for approval") .message(MSG) .with_kind(NotificationKind::Info); window.push_notification(note, cx); } DeviceEvent::NotSet => { const MSG: &str = "User're not setup encryption key yet. Do you want to create one?"; let note = Notification::new() .id::() .message(MSG) .with_kind(NotificationKind::Info) .action(|_this, _window, _cx| { Button::new("retry").label("Retry").on_click( move |_this, window, cx| { let device = DeviceRegistry::global(cx); device.update(cx, |this, cx| { this.set_announcement(Keys::generate(), cx); }); window.clear_notification::(cx); }, ) }); 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::InboxRelayNotFound => { const MSG: &str = "Messaging Relays not found. Cannot receive messages."; window.push_notification( Notification::warning(MSG) .id::() .autohide(false) .action(|_this, _window, _cx| { Button::new("retry").label("Retry").on_click( move |_this, window, cx| { let chat = ChatRegistry::global(cx); chat.update(cx, |this, cx| { this.get_metadata(cx); }); window.clear_notification::(cx); }, ) }), cx, ); } 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); } _ => {} }; }), ); cx.defer_in(window, |this, window, cx| { let dock = this.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); this.dock.update(cx, |this, cx| { this.set_center(center, window, cx); }); }); Self { sidebar, dock, image_cache, tasks: vec![], _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); }); }); } } /// 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); if let Some(public_key) = nostr.read(cx).current_user() { self.dock.update(cx, |this, cx| { this.add_panel( Arc::new(profile::init(public_key, window, cx)), DockPlacement::Left, window, cx, ); }); } } Command::ShowContactList => { self.dock.update(cx, |this, cx| { this.add_panel( Arc::new(contact_list::init(window, cx)), DockPlacement::Left, window, cx, ); }); } Command::ShowBackup => { self.dock.update(cx, |this, cx| { this.add_panel( Arc::new(backup::init(window, cx)), DockPlacement::Left, window, cx, ); }); } Command::ShowMessaging => { self.dock.update(cx, |this, cx| { this.add_panel( Arc::new(messaging_relays::init(window, cx)), DockPlacement::Left, window, cx, ); }); } Command::RefreshMessagingRelays => { let chat = ChatRegistry::global(cx); // Trigger a refresh of the chat registry chat.update(cx, |this, cx| { this.reload(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::BackupEncryption => { let device = DeviceRegistry::global(cx).downgrade(); let save_dialog = cx.prompt_for_new_path(download_dir(), Some("encryption.txt")); self.tasks.push(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(()) })); } Command::ImportEncryption => { self.import_encryption(window, cx); } Command::Update => { // No-op on managed distribution channels (Flatpak/Snap) where // the in-app updater is never initialized. if let Some(auto_updater) = AutoUpdater::try_global(cx) { auto_updater.update(cx, |this, cx| { this.updater.update(cx, |updater, cx| { updater.check(cx); }); }); } } } } fn confirm_reset_encryption(&mut self, window: &mut Window, cx: &mut Context) { 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."; 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(420.)) .title("Restore Encryption") .child(restore.clone()) }); } fn import_identity(&mut self, window: &mut Window, cx: &mut Context) { let import = cx.new(|cx| ImportIdentity::new(window, cx)); window.open_modal(cx, move |this, _window, _cx| { this.width(px(450.)) .show_close(false) .overlay_closable(false) .keyboard(false) .title("Onboarding") .child(import.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 current_user = nostr.read(cx).current_user(); 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("Import your identity 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), ) // Only offer in-app updates when auto-update is // enabled (managed channels update themselves). .when(AutoUpdater::is_available(cx), |this| { this.separator().menu_with_icon( "Check for Updates", IconName::Device, Box::new(Command::Update), ) }) .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 nip4e_enabled = AppSettings::get_nip4e(cx); let nostr = NostrRegistry::global(cx); let Some(public_key) = nostr.read(cx).current_user() else { return div(); }; let persons = PersonRegistry::global(cx); let profile = persons.read(cx).get(&public_key, cx); let announcement = profile.announcement(); // Update status is only shown when auto-update is available. On // managed distribution channels (Flatpak/Snap) no updater exists, so // nothing is rendered. let updater_status = AutoUpdater::try_global(cx).and_then(|updater| { let updater = updater.read(cx); (!updater.idle(cx)).then(|| updater.status(cx)) }); h_flex() .when(!cx.theme().platform.is_mac(), |this| this.pr_2()) .gap_2() .when_some(updater_status, |this, status| { this.child(div().text_xs().italic().child(status)) }) .when(nip4e_enabled, |this| { this.child( Button::new("key") .icon(IconName::UserKey) .tooltip("Decoupled encryption key") .small() .ghost() .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() .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(url.clone()) .child( div() .text_xs() .text_color(cx.theme().text_muted) .child(count.clone()), ) })) }); // Footer menu.separator() .menu_with_icon( "Manage gossip relays", IconName::Relay, Box::new(Command::ShowRelayList), ) .menu_with_icon( "Manage messaging relays", IconName::Relay, Box::new(Command::ShowMessaging), ) .separator() .menu_with_icon( "Reload", IconName::Refresh, Box::new(Command::RefreshMessagingRelays), ) }), ) } } 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); div() .id("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( TitleBar::new() .child(self.titlebar_left(cx)) .child(self.titlebar_right(cx)), ) // Main .child( h_flex() .size_full() .child( div() .flex_shrink_0() .h_full() .w(SIDEBAR_WIDTH) .child(self.sidebar.clone()), ) .child(self.dock.clone()), ), ), ) // Notifications .children(notification_layer) // Modals .children(modal_layer) } }