stabilize ids and harden release pipeline
This commit is contained in:
@@ -60,6 +60,9 @@ impl NewChat {
|
||||
let subscription = cx.subscribe_in(&input, window, |this, _input, event, window, cx| {
|
||||
if let InputEvent::PressEnter { .. } = event {
|
||||
this.submit(window, cx)
|
||||
} else if matches!(event, InputEvent::Change) {
|
||||
// The contact list only re-filters when the dialog re-renders
|
||||
cx.notify();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -5,16 +5,17 @@ use chat::{ChatRegistry, Room, RoomKind};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
App, AppContext, Context, Div, Entity, IntoElement, ParentElement, Render, SharedString,
|
||||
Styled, Task, Window, div, retain_all,
|
||||
Styled, Subscription, Task, Window, div, retain_all,
|
||||
};
|
||||
use gpui_kit::assets::IconName;
|
||||
use gpui_kit::base::StyledExt as _;
|
||||
use gpui_kit::component::button::{Button, ButtonVariants};
|
||||
use gpui_kit::component::clipboard::Clipboard;
|
||||
use gpui_kit::component::dock::DockPlacement;
|
||||
use gpui_kit::component::input::{Input, InputState, Textarea, TextareaState};
|
||||
use gpui_kit::component::input::{Input, InputEvent, InputState, Textarea, TextareaState};
|
||||
use gpui_kit::component::notification::Notification;
|
||||
use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex};
|
||||
use instant::Duration;
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::{Directory, Person, PersonRegistry, shorten_pubkey};
|
||||
use settings::AppSettings;
|
||||
@@ -51,13 +52,19 @@ struct ProfileDialog {
|
||||
uploading: bool,
|
||||
/// Updating state
|
||||
updating: bool,
|
||||
/// Error message
|
||||
error: Option<SharedString>,
|
||||
/// Tasks
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
/// Keeps the avatar preview live while the URL is typed
|
||||
_subscription: Option<Subscription>,
|
||||
}
|
||||
|
||||
impl ProfileDialog {
|
||||
fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let directory = Directory::new(cx);
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
|
||||
let is_current_user = nostr
|
||||
.read(cx)
|
||||
.current_user()
|
||||
@@ -75,7 +82,17 @@ impl ProfileDialog {
|
||||
.placeholder("A short introduce about you.")
|
||||
});
|
||||
|
||||
let directory = Directory::new(cx);
|
||||
let subscription = cx.subscribe_in(
|
||||
&avatar_input,
|
||||
window,
|
||||
|_this, _avatar_input, event, _window, cx| {
|
||||
if matches!(event, InputEvent::Change) {
|
||||
// The avatar preview only re-renders when the dialog does
|
||||
cx.notify();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
directory.update(cx, |directory, cx| {
|
||||
directory.track([public_key], cx);
|
||||
});
|
||||
@@ -91,7 +108,9 @@ impl ProfileDialog {
|
||||
website_input,
|
||||
uploading: false,
|
||||
updating: false,
|
||||
error: None,
|
||||
tasks: vec![],
|
||||
_subscription: Some(subscription),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,6 +209,26 @@ impl ProfileDialog {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn set_error<E>(&mut self, error: E, window: &mut Window, cx: &mut Context<Self>)
|
||||
where
|
||||
E: Into<SharedString>,
|
||||
{
|
||||
self.error = Some(error.into());
|
||||
cx.notify();
|
||||
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
cx.background_executor().timer(Duration::from_secs(3)).await;
|
||||
|
||||
// Clear the error message after a delay
|
||||
this.update(cx, |this, cx| {
|
||||
this.error = None;
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
/// Set the metadata for the current user
|
||||
fn publish(&self, metadata: &Metadata, cx: &App) -> Task<Result<(), Error>> {
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
@@ -218,19 +257,51 @@ impl ProfileDialog {
|
||||
let bio = self.bio_input.read(cx).value();
|
||||
let website = self.website_input.read(cx).value();
|
||||
|
||||
// Validate both URLs before publishing anything so a partial profile is never sent
|
||||
let avatar_url = if avatar.is_empty() {
|
||||
None
|
||||
} else {
|
||||
match Url::from_str(&avatar) {
|
||||
Ok(url) => Some(url),
|
||||
Err(_) => {
|
||||
self.set_error(
|
||||
"Avatar URL is invalid. Include the full address, e.g. https://example.com/avatar.jpg",
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let website_url = if website.is_empty() {
|
||||
None
|
||||
} else {
|
||||
match Url::from_str(&website) {
|
||||
Ok(url) => Some(url),
|
||||
Err(_) => {
|
||||
self.set_error(
|
||||
"Website URL is invalid. Include the full address, e.g. https://example.com",
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Construct the new metadata
|
||||
let mut new_metadata = old_metadata
|
||||
.display_name(name.as_ref())
|
||||
.name(name.as_ref())
|
||||
.about(bio.as_ref());
|
||||
|
||||
// Verify the avatar URL before adding it
|
||||
if let Ok(url) = Url::from_str(&avatar) {
|
||||
// An empty input keeps the saved value, a valid URL replaces it
|
||||
if let Some(url) = avatar_url {
|
||||
new_metadata = new_metadata.picture(url);
|
||||
}
|
||||
|
||||
// Verify the website URL before adding it
|
||||
if let Ok(url) = Url::from_str(&website) {
|
||||
if let Some(url) = website_url {
|
||||
new_metadata = new_metadata.website(url);
|
||||
}
|
||||
|
||||
@@ -429,6 +500,15 @@ impl Render for ProfileDialog {
|
||||
this.child(field("Web of Trust", score, "Not loaded yet", cx))
|
||||
})
|
||||
})
|
||||
.when_some(self.error.as_ref(), |this, error| {
|
||||
this.child(
|
||||
div()
|
||||
.italic()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().danger)
|
||||
.child(error.clone()),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
|
||||
@@ -10,6 +10,7 @@ use gpui::{
|
||||
use gpui_kit::assets::IconName;
|
||||
use gpui_kit::base::StyledExt as _;
|
||||
use gpui_kit::component::button::{Button, ButtonVariants};
|
||||
use gpui_kit::component::notification::Notification;
|
||||
use gpui_kit::component::spinner::Spinner;
|
||||
use gpui_kit::component::{ActiveTheme, Disableable, Icon, Sizable, WindowExt, h_flex, v_flex};
|
||||
use instant::Duration;
|
||||
@@ -23,11 +24,23 @@ pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity<
|
||||
cx.new(|cx| Screening::new(public_key, window, cx))
|
||||
}
|
||||
|
||||
/// Outcome of verifying the person's NIP-05 address
|
||||
enum IdentifierStatus {
|
||||
/// The check is still running
|
||||
Pending,
|
||||
/// The address matches the person's public key
|
||||
Verified,
|
||||
/// The address does not match the person's public key
|
||||
Mismatched,
|
||||
/// The check could not run, e.g. the network is unreachable
|
||||
Failed,
|
||||
}
|
||||
|
||||
pub struct Screening {
|
||||
/// Public Key of the person being screened.
|
||||
public_key: PublicKey,
|
||||
/// Whether the person's address is verified.
|
||||
verified: bool,
|
||||
/// Outcome of the NIP-05 address verification
|
||||
identifier_status: IdentifierStatus,
|
||||
/// Whether the person is followed by current user.
|
||||
followed: bool,
|
||||
/// Last time the person was active.
|
||||
@@ -49,9 +62,8 @@ impl Screening {
|
||||
|
||||
let mut subscriptions = smallvec![];
|
||||
|
||||
subscriptions.push(cx.on_release_in(window, move |this, window, cx| {
|
||||
subscriptions.push(cx.on_release_in(window, move |this, _window, _cx| {
|
||||
this.tasks.clear();
|
||||
window.close_all_dialogs(cx);
|
||||
}));
|
||||
|
||||
subscriptions.push(cx.subscribe(&wot, |_this, _wot, _event, cx| {
|
||||
@@ -71,7 +83,7 @@ impl Screening {
|
||||
|
||||
Self {
|
||||
public_key,
|
||||
verified: false,
|
||||
identifier_status: IdentifierStatus::Pending,
|
||||
followed: false,
|
||||
last_active: None,
|
||||
mutual_contacts: vec![],
|
||||
@@ -221,10 +233,17 @@ impl Screening {
|
||||
cx.background_spawn(async move { address.verify(&http_client, &public_key).await });
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let result = task.await.unwrap_or(false);
|
||||
let result = task.await;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
this.verified = result;
|
||||
this.identifier_status = match result {
|
||||
Ok(true) => IdentifierStatus::Verified,
|
||||
Ok(false) => IdentifierStatus::Mismatched,
|
||||
Err(error) => {
|
||||
log::error!("failed to verify NIP-05 address: {error:#}");
|
||||
IdentifierStatus::Failed
|
||||
}
|
||||
};
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
@@ -270,15 +289,27 @@ impl Screening {
|
||||
Ok(())
|
||||
});
|
||||
|
||||
self.tasks.push(cx.spawn_in(window, async move |_, cx| {
|
||||
if task.await.is_ok() {
|
||||
cx.update(|window, cx| {
|
||||
window.close_dialog(cx);
|
||||
window.push_notification("Report submitted successfully", cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}));
|
||||
self.tasks
|
||||
.push(cx.spawn_in(window, async move |_, cx| match task.await {
|
||||
Ok(_) => {
|
||||
cx.update(|window, cx| {
|
||||
window.close_dialog(cx);
|
||||
window.push_notification("Report submitted successfully", cx);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
Err(error) => {
|
||||
log::error!("failed to submit report: {error:#}");
|
||||
|
||||
cx.update(|window, cx| {
|
||||
window.push_notification(
|
||||
Notification::error(error.to_string()).autohide(false),
|
||||
cx,
|
||||
);
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
fn mutual_contacts(
|
||||
@@ -341,6 +372,9 @@ impl Render for Screening {
|
||||
const NO_MUTUAL: &str = "You don't have any mutual contacts.";
|
||||
const NIP05_MATCH: &str = "The address matches the user's public key.";
|
||||
const NIP05_NOT_MATCH: &str = "The address does not match the user's public key.";
|
||||
const NIP05_PENDING: &str = "Checking the address…";
|
||||
const NIP05_FAILED: &str =
|
||||
"The address could not be verified, the network may be unavailable.";
|
||||
const NO_NIP05: &str = "This person has not set up their friendly address";
|
||||
|
||||
let persons = PersonRegistry::global(cx);
|
||||
@@ -349,6 +383,20 @@ impl Render for Screening {
|
||||
|
||||
let last_active = self.last_active.map(|_| true);
|
||||
|
||||
let (identifier_badge, identifier_message) = match self.identifier_status {
|
||||
IdentifierStatus::Pending => (None, NIP05_PENDING),
|
||||
IdentifierStatus::Verified => (Some(true), NIP05_MATCH),
|
||||
IdentifierStatus::Mismatched => (Some(false), NIP05_NOT_MATCH),
|
||||
IdentifierStatus::Failed => (Some(false), NIP05_FAILED),
|
||||
};
|
||||
|
||||
// Without an address the check never runs
|
||||
let (identifier_badge, identifier_message) = if self.address(cx).is_some() {
|
||||
(identifier_badge, identifier_message)
|
||||
} else {
|
||||
(Some(false), NO_NIP05)
|
||||
};
|
||||
|
||||
// Pull from the web of trust once loaded, else use the database fallback
|
||||
let mutual_contacts = {
|
||||
let wot = persons.read(cx).wot().read(cx);
|
||||
@@ -491,7 +539,7 @@ impl Render for Screening {
|
||||
h_flex()
|
||||
.items_start()
|
||||
.gap_2()
|
||||
.child(status_badge(Some(self.verified), cx))
|
||||
.child(status_badge(identifier_badge, cx))
|
||||
.child(
|
||||
v_flex()
|
||||
.text_xs()
|
||||
@@ -508,17 +556,7 @@ impl Render for Screening {
|
||||
div()
|
||||
.line_clamp(1)
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child({
|
||||
if self.address(cx).is_some() {
|
||||
if self.verified {
|
||||
SharedString::from(NIP05_MATCH)
|
||||
} else {
|
||||
SharedString::from(NIP05_NOT_MATCH)
|
||||
}
|
||||
} else {
|
||||
SharedString::from(NO_NIP05)
|
||||
}
|
||||
}),
|
||||
.child(SharedString::from(identifier_message)),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use gpui::{App, Pixels, WeakEntity, Window, px};
|
||||
use gpui_kit::component::dock::{
|
||||
DockArea, DockPlacement, InsertTarget, NodeId, PaneRef, PanelHandle, PanelId,
|
||||
};
|
||||
use chat_ui::ChatPanel;
|
||||
use gpui::{App, Entity, Pixels, SharedString, WeakEntity, Window, px};
|
||||
use gpui_base::dock::PanelView;
|
||||
use gpui_kit::component::dock::{DockArea, DockPlacement, PanelHandle, PanelId};
|
||||
use nip29::GroupKey;
|
||||
use nip29_ui::GroupPanel;
|
||||
|
||||
mod invalid_panel;
|
||||
mod skin;
|
||||
@@ -14,39 +16,83 @@ pub use skin::CoopDockSkin;
|
||||
|
||||
/// The fixed height of the tab bar, which doubles as the window title bar.
|
||||
pub const TAB_BAR_HEIGHT: Pixels = px(44.);
|
||||
/// Dock placements.
|
||||
const DOCK_PLACEMENTS: [DockPlacement; 4] = [
|
||||
DockPlacement::Center,
|
||||
DockPlacement::Left,
|
||||
DockPlacement::Right,
|
||||
DockPlacement::Bottom,
|
||||
];
|
||||
|
||||
/// The panel in any region of `area whose id is `key.
|
||||
fn find_panel(area: &DockArea, key: PanelId, _cx: &App) -> Option<(PanelId, NodeId, usize)> {
|
||||
let placements = [
|
||||
DockPlacement::Center,
|
||||
DockPlacement::Left,
|
||||
DockPlacement::Right,
|
||||
DockPlacement::Bottom,
|
||||
];
|
||||
/// What makes a panel the same panel across opens.
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
enum PanelKey {
|
||||
/// A panel of which there should be one, keyed by its `panel_name`.
|
||||
Named(&'static str),
|
||||
/// A chat panel, keyed by the id of the room it displays.
|
||||
Room(SharedString),
|
||||
}
|
||||
|
||||
for placement in placements {
|
||||
/// The key a panel handle carries, or `None` when it cannot be read off the panel.
|
||||
fn panel_key(panel: &PanelHandle, cx: &App) -> Option<PanelKey> {
|
||||
let view = panel.panel();
|
||||
if let Some(chat) = view.as_any().downcast_ref::<Entity<ChatPanel>>() {
|
||||
return Some(PanelKey::Room(chat.read(cx).room_id().clone()));
|
||||
}
|
||||
if view.as_any().downcast_ref::<Entity<GroupPanel>>().is_some() {
|
||||
return None;
|
||||
}
|
||||
Some(PanelKey::Named(view.panel_name(cx)))
|
||||
}
|
||||
|
||||
/// The key of a panel docked in `area`, recovered through the handle base holds.
|
||||
fn docked_panel_key(view: &Arc<dyn PanelView>, cx: &App) -> Option<PanelKey> {
|
||||
let handle = PanelHandle::of(view)?;
|
||||
panel_key(handle, cx)
|
||||
}
|
||||
|
||||
/// The docked panel whose key is `key`, and the dock it sits in.
|
||||
fn find_panel(area: &DockArea, key: &PanelKey, cx: &App) -> Option<(PanelId, DockPlacement)> {
|
||||
for placement in DOCK_PLACEMENTS {
|
||||
let Some(tree) = area.layout(placement) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
for id in tree.panels() {
|
||||
if id != key {
|
||||
continue;
|
||||
}
|
||||
|
||||
let node = tree.find_panel_node(id)?;
|
||||
let PaneRef::Tabs { panels, .. } = tree.find_node(node)?.kind() else {
|
||||
let Some(view) = area.panel(id) else {
|
||||
continue;
|
||||
};
|
||||
let ix = panels.iter().position(|panel| *panel == key)?;
|
||||
return Some((id, node, ix));
|
||||
if docked_panel_key(view, cx).is_some_and(|found| &found == key) {
|
||||
return Some((id, placement));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Add `panel to the area, or move it to the end of its existing tab group.
|
||||
/// Bring the docked panel `id` on screen.
|
||||
pub fn focus_panel(
|
||||
area: &mut DockArea,
|
||||
id: PanelId,
|
||||
window: &mut Window,
|
||||
cx: &mut gpui::Context<DockArea>,
|
||||
) {
|
||||
let Some(placement) = DOCK_PLACEMENTS.into_iter().find(|placement| {
|
||||
area.layout(*placement)
|
||||
.is_some_and(|tree| tree.panels().any(|panel| panel == id))
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
|
||||
// The center region has no open state, and `toggle_dock` would ignore it.
|
||||
if placement != DockPlacement::Center && !area.is_dock_open(placement) {
|
||||
area.toggle_dock(placement, window, cx);
|
||||
}
|
||||
area.select_panel(id, window, cx);
|
||||
}
|
||||
|
||||
/// Add `panel` to the area, or focus the panel already showing the same thing.
|
||||
pub fn add_panel(
|
||||
area: &mut DockArea,
|
||||
panel: PanelHandle,
|
||||
@@ -54,24 +100,44 @@ pub fn add_panel(
|
||||
window: &mut Window,
|
||||
cx: &mut gpui::Context<DockArea>,
|
||||
) {
|
||||
let key = panel.panel_id(cx);
|
||||
if let Some((id, node, ix)) = find_panel(area, key, cx) {
|
||||
area.move_panel(
|
||||
id,
|
||||
InsertTarget::Tabs {
|
||||
node,
|
||||
ix: Some(ix),
|
||||
activate: true,
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
let Some(key) = panel_key(&panel, cx) else {
|
||||
area.add_panel_view(Arc::new(panel), placement, None, window, cx);
|
||||
return;
|
||||
};
|
||||
|
||||
if let Some((id, _)) = find_panel(area, &key, cx) {
|
||||
focus_panel(area, id, window, cx);
|
||||
return;
|
||||
}
|
||||
|
||||
area.add_panel_view(Arc::new(panel), placement, None, window, cx);
|
||||
}
|
||||
|
||||
/// Close the docked chat panel showing `room_id`, if there is one.
|
||||
pub fn close_room_panel(
|
||||
area: &mut DockArea,
|
||||
room_id: &SharedString,
|
||||
window: &mut Window,
|
||||
cx: &mut gpui::Context<DockArea>,
|
||||
) {
|
||||
let key = PanelKey::Room(room_id.clone());
|
||||
|
||||
let Some((id, _)) = find_panel(area, &key, cx) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(view) = area.panel(id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some(chat) = view.as_any().downcast_ref::<Entity<ChatPanel>>() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let chat = chat.clone();
|
||||
area.remove_panel(chat, window, cx);
|
||||
}
|
||||
|
||||
/// Add a panel to a dock reached through a weak handle.
|
||||
pub fn add_panel_to(
|
||||
dock: &WeakEntity<DockArea>,
|
||||
@@ -86,3 +152,25 @@ pub fn add_panel_to(
|
||||
log::warn!("Failed to add panel to dock: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a group panel to a dock, or focus the panel already showing that group.
|
||||
///
|
||||
/// Group panels hide their group, so the workspace records which panel it
|
||||
/// docked per key; without that record, reopening a group stacks another tab.
|
||||
pub fn add_group_panel_to(
|
||||
dock: &WeakEntity<DockArea>,
|
||||
group_key: GroupKey,
|
||||
panel: PanelHandle,
|
||||
placement: DockPlacement,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let Some(root) = window.root::<crate::Workspace>().flatten() else {
|
||||
add_panel_to(dock, panel, placement, window, cx);
|
||||
return;
|
||||
};
|
||||
|
||||
root.update(cx, |this, cx| {
|
||||
this.add_group_panel_to_dock(group_key, panel, placement, window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ use super::tab_panel::CoopTabGroupSkin;
|
||||
/// State the skin shares with its per-container renderers.
|
||||
pub(crate) struct SkinShared {
|
||||
area: WeakEntity<DockArea>,
|
||||
toggle_button_visible: Cell<bool>,
|
||||
/// The dock whose resize handle is being dragged, if any. Only one can be.
|
||||
resizing_dock: Cell<Option<DockPlacement>>,
|
||||
}
|
||||
@@ -32,10 +31,6 @@ impl SkinShared {
|
||||
&self.area
|
||||
}
|
||||
|
||||
pub(crate) fn is_toggle_button_visible(&self) -> bool {
|
||||
self.toggle_button_visible.get()
|
||||
}
|
||||
|
||||
pub(crate) fn resizing_dock(&self) -> &Cell<Option<DockPlacement>> {
|
||||
&self.resizing_dock
|
||||
}
|
||||
@@ -52,7 +47,6 @@ impl CoopDockSkin {
|
||||
Rc::new(Self {
|
||||
shared: Rc::new(SkinShared {
|
||||
area: cx.weak_entity(),
|
||||
toggle_button_visible: Cell::new(true),
|
||||
resizing_dock: Cell::new(None),
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -209,7 +209,7 @@ impl CoopTabGroupSkin {
|
||||
group: &TabGroupContext,
|
||||
cx: &mut App,
|
||||
) -> Option<Button> {
|
||||
if group.is_zoomed() || !self.shared.is_toggle_button_visible() {
|
||||
if group.is_zoomed() {
|
||||
return None;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use std::cell::Cell;
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -17,7 +18,7 @@ use gpui_kit::component::button::Button;
|
||||
use gpui_kit::component::command::{
|
||||
Command as CommandPalette, CommandGroup, CommandItem, CommandState,
|
||||
};
|
||||
use gpui_kit::component::dock::{DockArea, DockLayout, DockPlacement, Panel, PanelHandle};
|
||||
use gpui_kit::component::dock::{DockArea, DockLayout, DockPlacement, Panel, PanelHandle, PanelId};
|
||||
use gpui_kit::component::notification::{Notification, NotificationType};
|
||||
use gpui_kit::component::{
|
||||
ActiveTheme, IndexPath, Theme, ThemeConfig, ThemeRegistry, WindowExt, v_flex,
|
||||
@@ -80,6 +81,9 @@ enum Command {
|
||||
pub struct Workspace {
|
||||
dock: Entity<DockArea>,
|
||||
sidebar: Entity<Sidebar>,
|
||||
/// Group panels docked by group key, so reopening a group focuses its panel.
|
||||
/// Group panels hide their group, so the dock cannot tell them apart.
|
||||
group_panels: HashMap<GroupKey, PanelId>,
|
||||
/// Async tasks
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
/// Event subscriptions
|
||||
@@ -285,6 +289,7 @@ impl Workspace {
|
||||
Self {
|
||||
dock,
|
||||
sidebar,
|
||||
group_panels: HashMap::new(),
|
||||
tasks: vec![],
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
@@ -319,6 +324,43 @@ impl Workspace {
|
||||
});
|
||||
}
|
||||
|
||||
fn add_group_panel_to_dock(
|
||||
&mut self,
|
||||
group_key: GroupKey,
|
||||
panel: PanelHandle,
|
||||
placement: DockPlacement,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let panel_id = panel.panel_id(cx);
|
||||
let docked = self.group_panels.get(&group_key).copied();
|
||||
|
||||
let mut added = false;
|
||||
let mut focused = false;
|
||||
|
||||
self.dock.update(cx, |area, cx| {
|
||||
if let Some(id) = docked
|
||||
&& area.panel(id).is_some()
|
||||
{
|
||||
crate::dock::focus_panel(area, id, window, cx);
|
||||
focused = true;
|
||||
return;
|
||||
}
|
||||
crate::dock::add_panel(area, panel, placement, window, cx);
|
||||
added = area.panel(panel_id).is_some();
|
||||
});
|
||||
|
||||
if focused {
|
||||
return;
|
||||
}
|
||||
|
||||
if added {
|
||||
self.group_panels.insert(group_key, panel_id);
|
||||
} else {
|
||||
self.group_panels.remove(&group_key);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle command events
|
||||
fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context<Self>) {
|
||||
match command {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use anyhow::Error;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement,
|
||||
ParentElement, Render, SharedString, Styled, Task, Window, div,
|
||||
@@ -9,6 +10,7 @@ use gpui_kit::component::dock::{Panel, PanelEvent};
|
||||
use gpui_kit::component::input::{Input, InputState};
|
||||
use gpui_kit::component::separator::Separator;
|
||||
use gpui_kit::component::{ActiveTheme, v_flex};
|
||||
use instant::Duration;
|
||||
use nostr_sdk::prelude::*;
|
||||
use state::USER_KEYRING;
|
||||
|
||||
@@ -23,14 +25,12 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity<BackupPanel> {
|
||||
pub struct BackupPanel {
|
||||
name: SharedString,
|
||||
focus_handle: FocusHandle,
|
||||
|
||||
/// Public key input
|
||||
npub_input: Entity<InputState>,
|
||||
|
||||
/// Secret key input
|
||||
nsec_input: Entity<InputState>,
|
||||
|
||||
/// Background tasks
|
||||
/// Error message
|
||||
error: Option<SharedString>,
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ impl BackupPanel {
|
||||
focus_handle: cx.focus_handle(),
|
||||
npub_input,
|
||||
nsec_input,
|
||||
error: None,
|
||||
tasks: vec![],
|
||||
}
|
||||
}
|
||||
@@ -57,20 +58,58 @@ impl BackupPanel {
|
||||
let keyring = cx.read_credentials(USER_KEYRING);
|
||||
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
if let Some((_, secret)) = keyring.await? {
|
||||
let secret = SecretKey::from_slice(&secret)?;
|
||||
let keys = Keys::new(secret);
|
||||
let result: Result<(), Error> = async {
|
||||
let Some((_, secret)) = keyring.await? else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let keys = Keys::new(SecretKey::from_slice(&secret)?);
|
||||
|
||||
// Bech32 encoding of a valid key cannot fail today, but handle it instead of panicking
|
||||
let npub = keys.public_key().to_bech32()?;
|
||||
let nsec = keys.secret_key().to_bech32()?;
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.npub_input.update(cx, |this, cx| {
|
||||
this.set_value(keys.public_key().to_bech32().unwrap(), window, cx);
|
||||
this.set_value(npub.as_str(), window, cx);
|
||||
});
|
||||
|
||||
this.nsec_input.update(cx, |this, cx| {
|
||||
this.set_value(keys.secret_key().to_bech32().unwrap(), window, cx);
|
||||
this.set_value(nsec.as_str(), window, cx);
|
||||
});
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
if let Err(error) = result {
|
||||
log::error!("failed to load account keys: {error:#}");
|
||||
|
||||
this.update_in(cx, |this, window, cx| {
|
||||
this.set_error(format!("Failed to load your keys: {error}"), window, cx);
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
}
|
||||
|
||||
fn set_error<E>(&mut self, error: E, window: &mut Window, cx: &mut Context<Self>)
|
||||
where
|
||||
E: Into<SharedString>,
|
||||
{
|
||||
self.error = Some(error.into());
|
||||
cx.notify();
|
||||
|
||||
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
|
||||
cx.background_executor().timer(Duration::from_secs(2)).await;
|
||||
|
||||
// Clear the error message after a delay
|
||||
this.update(cx, |this, cx| {
|
||||
this.error = None;
|
||||
cx.notify();
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}));
|
||||
@@ -110,6 +149,15 @@ impl Render for BackupPanel {
|
||||
.child(SharedString::from(MSG)),
|
||||
)
|
||||
.child(Separator::horizontal())
|
||||
.when_some(self.error.as_ref(), |this, error| {
|
||||
this.child(
|
||||
div()
|
||||
.italic()
|
||||
.text_xs()
|
||||
.text_color(cx.theme().danger)
|
||||
.child(error.clone()),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
|
||||
@@ -227,10 +227,11 @@ impl BrowsePanel {
|
||||
/// Open the group's panel to preview (or revisit) it.
|
||||
fn open_panel(dock: &WeakEntity<DockArea>, key: GroupKey, window: &mut Window, cx: &mut App) {
|
||||
let registry = GroupsRegistry::global(cx);
|
||||
let group = registry.update(cx, |registry, cx| registry.open(key, cx));
|
||||
let group = registry.update(cx, |registry, cx| registry.open(key.clone(), cx));
|
||||
|
||||
crate::dock::add_panel_to(
|
||||
crate::dock::add_group_panel_to(
|
||||
dock,
|
||||
key,
|
||||
PanelHandle::new(nip29_ui::init(group, window, cx)),
|
||||
DockPlacement::Center,
|
||||
window,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use auto_update::AutoUpdater;
|
||||
use auto_update::{AutoUpdater, UpdateDisplay};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use gpui::AnyElement;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
Anchor, App, Context, Div, InteractiveElement, IntoElement, ParentElement, SharedString,
|
||||
Stateful, Styled, Window, div, px,
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use gpui::{AnyElement, Entity};
|
||||
use gpui_kit::base::StyledExt as _;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use gpui_kit::component::Disableable;
|
||||
@@ -35,7 +35,7 @@ impl Sidebar {
|
||||
cx: &mut Context<Self>,
|
||||
) -> Stateful<Div> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let updater = AutoUpdater::try_global(cx);
|
||||
let updater_display = self.updater_display.read(cx).clone();
|
||||
let profile = self.directory.read(cx).person(current_user, cx);
|
||||
let avatar = profile.avatar();
|
||||
let encryption_key = profile
|
||||
@@ -78,8 +78,8 @@ impl Sidebar {
|
||||
let actions = h_flex().gap_1();
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let actions = actions.when_some(updater, |this, updater| {
|
||||
this.child(self.render_updater(updater, cx))
|
||||
let actions = actions.when_some(updater_display, |this, display| {
|
||||
this.child(self.render_updater(display))
|
||||
});
|
||||
|
||||
actions
|
||||
@@ -233,10 +233,12 @@ impl Sidebar {
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn render_updater(&self, updater: Entity<AutoUpdater>, cx: &mut App) -> AnyElement {
|
||||
let status = updater.read(cx).status();
|
||||
let up_to_date = updater.read(cx).up_to_date();
|
||||
let staged = updater.read(cx).staged();
|
||||
fn render_updater(&self, display: UpdateDisplay) -> AnyElement {
|
||||
let UpdateDisplay {
|
||||
up_to_date,
|
||||
staged,
|
||||
status,
|
||||
} = display;
|
||||
|
||||
h_flex()
|
||||
.gap_2()
|
||||
@@ -257,10 +259,12 @@ impl Sidebar {
|
||||
.tooltip("Quit and relaunch into the installed update")
|
||||
.small()
|
||||
.ghost()
|
||||
.on_click(move |_, _window, cx| {
|
||||
updater.update(cx, |this, cx| {
|
||||
this.restart(cx);
|
||||
});
|
||||
.on_click(|_, _window, cx| {
|
||||
if let Some(updater) = AutoUpdater::try_global(cx) {
|
||||
updater.update(cx, |this, cx| {
|
||||
this.restart(cx);
|
||||
});
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use anyhow::Error;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use auto_update::{AutoUpdater, UpdateDisplay};
|
||||
use chat::{ChatEvent, ChatRegistry};
|
||||
use common::DebouncedDelay;
|
||||
use gpui::prelude::FluentBuilder;
|
||||
@@ -21,7 +23,6 @@ use nostr_sdk::prelude::*;
|
||||
use person::{Directory, PersonRegistry};
|
||||
pub(crate) use rows::open_room;
|
||||
use search::SearchResult;
|
||||
use settings::AppSettings;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::{FIND_DELAY, NostrRegistry, StateEvent};
|
||||
use ui::title_bar_drag_handlers;
|
||||
@@ -44,7 +45,6 @@ pub struct Sidebar {
|
||||
dock: WeakEntity<DockArea>,
|
||||
/// Random background shown behind the signed-out screen.
|
||||
banner: SharedString,
|
||||
|
||||
/// The group whose information the sidebar is browsing, if any.
|
||||
group: Option<WeakEntity<Group>>,
|
||||
/// Whether the sidebar is showing the group's information instead of its list.
|
||||
@@ -53,14 +53,12 @@ pub struct Sidebar {
|
||||
admins_open: bool,
|
||||
/// Whether the group view's members section is expanded.
|
||||
members_open: bool,
|
||||
|
||||
/// Whether the main list's pinned section is expanded.
|
||||
pins_open: bool,
|
||||
/// Whether the user expanded or collapsed the main list's groups section.
|
||||
groups_open: Option<bool>,
|
||||
/// Same as `groups_open` for the chats section.
|
||||
chats_open: Option<bool>,
|
||||
|
||||
/// Whether the main list is showing contacts to start a chat with.
|
||||
contacts_open: bool,
|
||||
/// The signed-in user's contacts, loaded when the contact picker opens.
|
||||
@@ -69,11 +67,9 @@ pub struct Sidebar {
|
||||
directory: Entity<Directory>,
|
||||
/// Contacts selected in the contact picker.
|
||||
selected_contacts: HashSet<PublicKey>,
|
||||
|
||||
/// Whether the main list is showing chat requests instead of chats and groups.
|
||||
requests_open: bool,
|
||||
new_requests: bool,
|
||||
|
||||
/// Whether the search bar is shown below the header.
|
||||
search_open: bool,
|
||||
/// Whether searches also cover global (relay) profiles.
|
||||
@@ -90,12 +86,13 @@ pub struct Sidebar {
|
||||
global_results: Option<Vec<PublicKey>>,
|
||||
/// The async global profile search.
|
||||
global_task: Option<Task<()>>,
|
||||
|
||||
/// Simulated gift wrap processing progress, shown while the backlog loads
|
||||
chat_progress: f32,
|
||||
/// Simulated web of trust sync progress
|
||||
sync_progress: f32,
|
||||
|
||||
/// The updater facts the header renders, mirrored so updater churn cannot invalidate the cached sidebar
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
updater_display: Entity<Option<UpdateDisplay>>,
|
||||
tasks: SmallVec<[Task<Result<(), Error>>; 1]>,
|
||||
_subscriptions: SmallVec<[Subscription; 4]>,
|
||||
}
|
||||
@@ -105,7 +102,6 @@ impl Sidebar {
|
||||
let chat = ChatRegistry::global(cx);
|
||||
let groups = GroupsRegistry::global(cx);
|
||||
let nostr = NostrRegistry::global(cx);
|
||||
let settings = AppSettings::global(cx);
|
||||
let directory = Directory::new(cx);
|
||||
|
||||
let find_input = cx.new(|cx| {
|
||||
@@ -209,10 +205,23 @@ impl Sidebar {
|
||||
},
|
||||
));
|
||||
|
||||
// The user header reads settings and the tracked profiles on render, so
|
||||
// the encryption button reacts to toggles and announcement arrivals
|
||||
subscriptions.push(cx.observe(&settings, |_, _, cx| cx.notify()));
|
||||
subscriptions.push(cx.observe(&directory, |_, _, cx| cx.notify()));
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let updater_display =
|
||||
cx.new(|cx| AutoUpdater::try_global(cx).map(|updater| updater.read(cx).display()));
|
||||
|
||||
// Mirror only the header's rendered facts; updater churn must not invalidate the whole cached sidebar
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(updater) = AutoUpdater::try_global(cx) {
|
||||
subscriptions.push(cx.observe(&updater, |this, updater, cx| {
|
||||
let display = updater.read(cx).display();
|
||||
this.updater_display.update(cx, |state, cx| {
|
||||
if state.as_ref() != Some(&display) {
|
||||
*state = Some(display);
|
||||
cx.notify();
|
||||
}
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
tasks.push(cx.spawn(async move |this, cx| {
|
||||
let timer = Duration::from_millis(PROGRESS_TICK_MS);
|
||||
@@ -251,6 +260,8 @@ impl Sidebar {
|
||||
global_task: None,
|
||||
chat_progress: 0.0,
|
||||
sync_progress: 0.0,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
updater_display,
|
||||
tasks,
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ use gpui::{
|
||||
};
|
||||
use gpui_kit::base::StyledExt as _;
|
||||
use gpui_kit::component::dialog::DialogButtonProps;
|
||||
use gpui_kit::component::dock::{ClosePanel, DockArea, DockPlacement, PanelHandle};
|
||||
use gpui_kit::component::dock::{DockArea, DockPlacement, PanelHandle};
|
||||
use gpui_kit::component::menu::{ContextMenuExt as _, PopupMenuItem};
|
||||
use gpui_kit::component::{ActiveTheme, Icon, Selectable, Sizable, WindowExt, h_flex};
|
||||
use nip29::Group;
|
||||
@@ -30,9 +30,12 @@ pub(crate) fn open_room(
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let panel = chat_ui::init(room.downgrade(), window, cx);
|
||||
let room_id = panel.read(cx).room_id().clone();
|
||||
|
||||
crate::dock::add_panel_to(
|
||||
dock,
|
||||
PanelHandle::new(chat_ui::init(room.downgrade(), window, cx)),
|
||||
PanelHandle::new(panel),
|
||||
DockPlacement::Center,
|
||||
window,
|
||||
cx,
|
||||
@@ -41,8 +44,11 @@ pub(crate) fn open_room(
|
||||
if room.read(cx).kind != RoomKind::Ongoing && AppSettings::get_screening(cx) {
|
||||
let peer = room.read(cx).display_member(cx).public_key();
|
||||
let view = screening::init(peer, window, cx);
|
||||
let dock = dock.clone();
|
||||
|
||||
window.open_alert_dialog(cx, move |alert, _window, _cx| {
|
||||
let dock = dock.clone();
|
||||
let room_id = room_id.clone();
|
||||
alert
|
||||
.confirm()
|
||||
.child(view.clone())
|
||||
@@ -52,7 +58,11 @@ pub(crate) fn open_room(
|
||||
.ok_text("Response"),
|
||||
)
|
||||
.on_cancel(move |_event, window, cx| {
|
||||
window.dispatch_action(Box::new(ClosePanel), cx);
|
||||
if let Err(error) = dock.update(cx, |area, cx| {
|
||||
crate::dock::close_room_panel(area, &room_id, window, cx);
|
||||
}) {
|
||||
log::warn!("Failed to close the request room's tab: {error}");
|
||||
}
|
||||
true
|
||||
})
|
||||
});
|
||||
@@ -318,8 +328,11 @@ impl Sidebar {
|
||||
)
|
||||
})
|
||||
.on_click(move |_event, window, cx| {
|
||||
crate::dock::add_panel_to(
|
||||
let group_key = group.read(cx).key().clone();
|
||||
|
||||
crate::dock::add_group_panel_to(
|
||||
&dock,
|
||||
group_key,
|
||||
PanelHandle::new(nip29_ui::init(group.clone(), window, cx)),
|
||||
DockPlacement::Center,
|
||||
window,
|
||||
|
||||
Reference in New Issue
Block a user