diff --git a/Cargo.lock b/Cargo.lock index ff5309cc..4bcfc046 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1463,6 +1463,7 @@ dependencies = [ "gpui-pre", "instant", "itertools 0.13.0", + "log", "nip60_ui", "nostr-sdk", "person", @@ -1681,6 +1682,7 @@ dependencies = [ "nostr", "nostr-sdk", "regex", + "sha2 0.10.9", "web-sys", ] diff --git a/crates/auto_update/src/lib.rs b/crates/auto_update/src/lib.rs index 3959bc6b..d3ffcc41 100644 --- a/crates/auto_update/src/lib.rs +++ b/crates/auto_update/src/lib.rs @@ -63,6 +63,18 @@ struct GlobalAutoUpdater(Entity); impl Global for GlobalAutoUpdater {} +/// The subset of updater state the UI renders, so views can hold it in a small +/// entity instead of observing the whole updater. +#[derive(Clone, PartialEq)] +pub struct UpdateDisplay { + /// Whether the running version is the newest release. + pub up_to_date: bool, + /// Whether a verified update is installed and waiting for a restart. + pub staged: bool, + /// A short, human-readable description of the current status. + pub status: SharedString, +} + pub struct AutoUpdater { updater: Entity, status: UpdateStatus, @@ -173,6 +185,15 @@ impl AutoUpdater { } } + /// Snapshot the state the UI renders. + pub fn display(&self) -> UpdateDisplay { + UpdateDisplay { + up_to_date: self.up_to_date(), + staged: self.staged(), + status: self.status(), + } + } + /// Check the release host for a newer version, then download and install it. pub fn check(&mut self, cx: &mut Context) { self.updater.update(cx, |updater, cx| { diff --git a/crates/browser-signer-proxy/src/lib.rs b/crates/browser-signer-proxy/src/lib.rs index fc63f29b..0a3afb7b 100644 --- a/crates/browser-signer-proxy/src/lib.rs +++ b/crates/browser-signer-proxy/src/lib.rs @@ -26,6 +26,13 @@ pub use self::error::Error; const DEFAULT_HTML: &str = include_str!("../index.html"); const JS: &str = include_str!("../proxy.js"); +/// Cap on the request line and headers so a peer cannot force unbounded reads +const MAX_HEADER_BYTES: usize = 16 * 1024; +/// Cap on the request body so a huge Content-Length cannot force a huge allocation +const MAX_BODY_BYTES: usize = 1024 * 1024; +/// Maximum time a connection may stay open before it is dropped +const CONNECTION_TIMEOUT: Duration = Duration::from_secs(30); + type PendingResponseMap = HashMap>>; #[derive(Debug, Deserialize)] @@ -303,12 +310,20 @@ impl BrowserSignerProxy { smol::future::or( async { - handle_connection( - stream, - state, - custom_html, - expected_host, - session_token, + smol::future::or( + async { + handle_connection( + stream, + state, + custom_html, + expected_host, + session_token, + ) + .await; + }, + async { + smol::Timer::after(CONNECTION_TIMEOUT).await; + }, ) .await; }, @@ -401,10 +416,19 @@ impl BrowserSignerProxy { #[inline] async fn _sign_event(&self, event: UnsignedEvent) -> Result { + let expected_id: EventId = event.compute_id(); let event: Event = self .request(RequestMethod::SignEvent, serde_json::to_value(event)?) .await?; event.verify()?; + + if event.id != expected_id { + return Err(Error::generic(format!( + "signed event doesn't match the requested event: expected ID {expected_id}, got {}", + event.id + ))); + } + Ok(event) } @@ -466,6 +490,54 @@ impl AsyncNip44 for BrowserSignerProxy { } } +/// Outcome of reading a line with [`read_line_capped`]. +enum HeaderLine { + /// A line, including the trailing newline when present + Line(Vec), + /// Clean EOF with no data + Eof, + /// The line exceeded the allowed length + TooLong, +} + +/// Read one `\n`-terminated line with a hard cap so a peer cannot force unbounded buffering. +async fn read_line_capped( + reader: &mut BufReader, + limit: usize, +) -> Result { + let mut line: Vec = Vec::new(); + loop { + let (data, line_complete) = { + let chunk = reader.fill_buf().await?; + if chunk.is_empty() { + break; + } + match chunk.iter().position(|byte| *byte == b'\n') { + Some(index) => (chunk[..=index].to_vec(), true), + None => (chunk.to_vec(), false), + } + }; + + if line.len() + data.len() > limit { + return Ok(HeaderLine::TooLong); + } + + let taken = data.len(); + line.extend_from_slice(&data); + reader.consume(taken); + + if line_complete { + return Ok(HeaderLine::Line(line)); + } + } + + Ok(if line.is_empty() { + HeaderLine::Eof + } else { + HeaderLine::Line(line) + }) +} + /// Handle a single HTTP connection. async fn handle_connection( stream: TcpStream, @@ -475,13 +547,26 @@ async fn handle_connection( session_token: String, ) { let mut reader = BufReader::new(stream); + let mut header_bytes: usize = 0; // Read the request line - let mut request_line = String::new(); - if reader.read_line(&mut request_line).await.is_err() { - return; - } - let request_line = request_line.trim_end().to_string(); + let request_line = match read_line_capped(&mut reader, MAX_HEADER_BYTES).await { + Ok(HeaderLine::Line(line)) => line, + Ok(HeaderLine::Eof) => Vec::new(), + Ok(HeaderLine::TooLong) => { + send_response(&mut reader, 400, "Bad Request", "", "").await; + return; + } + Err(_) => return, + }; + header_bytes += request_line.len(); + let request_line = match String::from_utf8(request_line) { + Ok(line) => line.trim_end().to_string(), + Err(_) => { + send_response(&mut reader, 400, "Bad Request", "", "").await; + return; + } + }; // Parse method, path, and HTTP version from request line let parts: Vec<&str> = request_line.split_whitespace().collect(); @@ -498,12 +583,29 @@ async fn handle_connection( let mut host: Option = None; loop { - let mut line = String::new(); - if reader.read_line(&mut line).await.is_err() { + let line = match read_line_capped(&mut reader, MAX_HEADER_BYTES).await { + Ok(HeaderLine::Line(line)) => line, + Ok(HeaderLine::Eof) => break, + Ok(HeaderLine::TooLong) => { + send_response(&mut reader, 400, "Bad Request", "", "").await; + return; + } + Err(_) => return, + }; + + header_bytes += line.len(); + if header_bytes > MAX_HEADER_BYTES { + send_response(&mut reader, 400, "Bad Request", "", "").await; return; } - let line = line.trim_end().to_string(); + let line = match String::from_utf8(line) { + Ok(line) => line.trim_end().to_string(), + Err(_) => { + send_response(&mut reader, 400, "Bad Request", "", "").await; + return; + } + }; if line.is_empty() { break; } @@ -521,6 +623,12 @@ async fn handle_connection( headers.push(line); } + // Reject oversized bodies before allocating the read buffer + if content_length > MAX_BODY_BYTES { + send_response(&mut reader, 413, "Payload Too Large", "", "").await; + return; + } + // DNS rebinding defense: only the exact bound host:port is accepted if host.as_deref() != Some(expected_host.as_str()) { send_response(&mut reader, 403, "Forbidden", "", "").await; @@ -594,7 +702,17 @@ async fn handle_connection( } }; - tracing::debug!("Received response from browser: {message:?}"); + // Never log the response value, it can contain decrypted plaintext + let outcome = if message.error.is_some() { + "error" + } else { + "ok" + }; + + tracing::debug!( + "Received response from browser: id={} outcome={outcome}", + message.id + ); let id: Uuid = message.id; let mut pending = state.pending_responses.lock().await; diff --git a/crates/chat/src/lib.rs b/crates/chat/src/lib.rs index 2c717d2d..18da14f8 100644 --- a/crates/chat/src/lib.rs +++ b/crates/chat/src/lib.rs @@ -396,10 +396,27 @@ impl ChatRegistry { } if !errors.is_empty() { + let mut newly_trashed = 0; trash.update(cx, |trash, cx| { - trash.extend(errors); + newly_trashed = errors + .iter() + .filter(|message| trash.insert((*message).clone())) + .count(); cx.notify(); })?; + + // Only newly trashed messages emit an error event so repeated + // reprocess attempts of the same message don't re-alert the user + if newly_trashed > 0 { + let reason = if newly_trashed == 1 { + "A message could not be decrypted".to_string() + } else { + format!("{newly_trashed} messages could not be decrypted") + }; + this.update(cx, |_, cx| { + cx.emit(ChatEvent::Error(reason)); + })?; + } } if eose && tracking.swap(false, Ordering::AcqRel) { diff --git a/crates/chat_ui/Cargo.toml b/crates/chat_ui/Cargo.toml index d41e9f78..accf867c 100644 --- a/crates/chat_ui/Cargo.toml +++ b/crates/chat_ui/Cargo.toml @@ -18,6 +18,7 @@ gpui.workspace = true instant.workspace = true nostr-sdk.workspace = true anyhow.workspace = true +log.workspace = true itertools.workspace = true smallvec.workspace = true serde.workspace = true diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs index 6701b8f3..abb8b740 100644 --- a/crates/chat_ui/src/lib.rs +++ b/crates/chat_ui/src/lib.rs @@ -1,3 +1,4 @@ +use std::cmp::Ordering; use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::{Arc, LazyLock, RwLock}; use std::time::Duration; @@ -220,7 +221,7 @@ impl ChatPanel { // Define all functions that will run after the current cycle cx.defer_in(window, |this, window, cx| { - this.connect(cx); + this.connect(window, cx); this.subscribe_room_events(window, cx); this.get_messages(window, cx); }); @@ -269,11 +270,24 @@ impl ChatPanel { } /// Get messaging relays and announcement for each member - fn connect(&mut self, cx: &mut Context) { - if let Some(room) = self.room.upgrade() { - let task = room.read(cx).connect(cx); - self.tasks.push(task); - } + fn connect(&mut self, window: &mut Window, cx: &mut Context) { + let Some(room) = self.room.upgrade() else { + return; + }; + + let connect = room.read(cx).connect(cx); + + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + if let Err(error) = connect.await { + log::error!("Connecting room relays failed: {error}"); + + this.update_in(cx, |this, window, cx| { + this.notify_failure("Connection failed", error, window, cx); + })?; + } + + Ok(()) + })); } /// Subscribe to room events @@ -317,23 +331,40 @@ impl ChatPanel { } /// Load all messages belonging to this room - fn get_messages(&mut self, _window: &mut Window, cx: &mut Context) { + fn get_messages(&mut self, window: &mut Window, cx: &mut Context) { let Ok(get_messages) = self.room.read_with(cx, |this, cx| this.get_messages(cx)) else { return; }; - self.tasks.push(cx.spawn(async move |this, cx| { - let events = get_messages.await?; - - // Update message list - this.update(cx, |this, cx| { - this.insert_messages(&events, cx); - })?; + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + match get_messages.await { + Ok(events) => { + this.update(cx, |this, cx| { + this.insert_messages(&events, cx); + })?; + } + Err(error) => { + log::error!("Loading room messages failed: {error}"); + this.update_in(cx, |this, window, cx| { + this.notify_failure("Failed to load messages", error, window, cx); + })?; + } + } Ok(()) })); } + /// Show a failure of a background operation to the user until dismissed + fn notify_failure(&self, title: &'static str, error: Error, window: &mut Window, cx: &mut App) { + window.push_notification( + Notification::error(error.to_string()) + .title(title) + .autohide(false), + cx, + ); + } + /// Get user input content and merged all attachments if available fn get_input_value(&self, cx: &Context) -> String { // Get input's value @@ -566,8 +597,9 @@ impl ChatPanel { for (i, message) in self.messages.iter().enumerate().skip(pos) { self.message_index.insert(message.id, i); } - let len = self.list_state.item_count(); - self.list_state.splice(len..len, 1); + // The welcome row occupies row zero, so message rows sit one ahead + let row = pos + 1; + self.list_state.splice(row..row, 1); if scroll { self.list_state.scroll_to(ListOffset { @@ -582,13 +614,62 @@ impl ChatPanel { /// Convert and insert a vector of nostr events into the chat panel fn insert_messages(&mut self, events: &[UnsignedEvent], cx: &mut Context) { + let mut incoming: Vec = Vec::with_capacity(events.len()); + for event in events.iter() { if event.kind == Kind::Reaction { self.insert_reaction(event, cx); + } else { + incoming.push(event.into()); + } + } + + incoming.sort(); + + let old_len = self.messages.len(); + let mut stored = std::mem::take(&mut self.messages).into_iter().peekable(); + let mut merged: Vec = Vec::with_capacity(old_len + incoming.len()); + let mut insert_start = None; + let mut inserted = 0usize; + + for message in incoming { + while let Some(old) = stored.next_if(|old| old.cmp(&message) == Ordering::Less) { + merged.push(old); + } + + let duplicate = stored + .peek() + .is_some_and(|old| old.cmp(&message) == Ordering::Equal) + || merged + .last() + .is_some_and(|last| last.cmp(&message) == Ordering::Equal); + + if duplicate { continue; } - // Bulk inserting messages, so no need to scroll to the latest message - self.insert_message(event, false, cx); + + if inserted == 0 { + insert_start = Some(merged.len()); + } + inserted += 1; + merged.push(message); + } + + merged.extend(stored); + + self.messages = merged; + + if let Some(start) = insert_start { + self.message_index = self + .messages + .iter() + .enumerate() + .map(|(index, message)| (message.id, index)) + .collect(); + + let row = start + 1; + self.list_state.splice(row..row, inserted); + cx.notify(); } } diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index aefb5ab9..2d70ccfe 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -9,6 +9,7 @@ gpui.workspace = true nostr.workspace = true instant.workspace = true nostr-sdk.workspace = true +sha2.workspace = true itertools.workspace = true chrono.workspace = true diff --git a/crates/common/src/event.rs b/crates/common/src/event.rs index b1fd1e74..bedcf1da 100644 --- a/crates/common/src/event.rs +++ b/crates/common/src/event.rs @@ -1,20 +1,18 @@ -use std::hash::{DefaultHasher, Hash, Hasher}; - use itertools::Itertools; use nostr_sdk::prelude::*; +use sha2::{Digest, Sha256}; + +const HEX_DIGITS: [u8; 16] = *b"0123456789abcdef"; pub trait EventExt { + /// Id over the event's participants, stable across Rust versions. fn uniq_id(&self) -> u64; fn extract_public_keys(&self) -> Vec; } impl EventExt for Event { fn uniq_id(&self) -> u64 { - let mut hasher = DefaultHasher::new(); - let mut pubkeys: Vec = self.extract_public_keys(); - pubkeys.sort(); - pubkeys.hash(&mut hasher); - hasher.finish() + pubkey_set_uniq_id(&self.extract_public_keys()) } fn extract_public_keys(&self) -> Vec { @@ -27,31 +25,62 @@ impl EventExt for Event { impl EventExt for UnsignedEvent { fn uniq_id(&self) -> u64 { - let mut hasher = DefaultHasher::new(); - let mut pubkeys: Vec = vec![]; - - // Add all public keys from event - pubkeys.push(self.pubkey); - pubkeys.extend(self.tags.public_keys().collect::>()); - - // Generate unique hash - pubkeys - .into_iter() - .unique() - .sorted() - .collect::>() - .hash(&mut hasher); - - hasher.finish() + pubkey_set_uniq_id(&self.extract_public_keys()) } fn extract_public_keys(&self) -> Vec { let mut public_keys: Vec = self.tags.public_keys().collect(); public_keys.push(self.pubkey); + public_keys.into_iter().unique().sorted().collect() } } +/// Digest over NUL-separated parts, so part boundaries stay unambiguous. +fn parts_digest(parts: &[&str]) -> [u8; 32] { + let mut hasher = Sha256::new(); + + for part in parts { + hasher.update(part.as_bytes()); + hasher.update([0]); + } + + hasher.finalize().into() +} + +/// Stable id over parts: hex-encoded SHA-256 truncated to 128 bits. +pub fn stable_id(parts: &[&str]) -> String { + let digest = parts_digest(parts); + + let mut id = String::with_capacity(32); + for byte in &digest[..16] { + id.push(HEX_DIGITS[usize::from(byte >> 4)] as char); + id.push(HEX_DIGITS[usize::from(byte & 0x0f)] as char); + } + + id +} + +/// Stable 64-bit id over parts, for callers that key ids on `u64`. +pub fn stable_id_u64(parts: &[&str]) -> u64 { + let digest = parts_digest(parts); + + let mut lead = [0u8; 8]; + for (slot, byte) in lead.iter_mut().zip(digest.iter()) { + *slot = *byte; + } + + u64::from_be_bytes(lead) +} + +/// Hashes the sorted hex forms of the participant set. +fn pubkey_set_uniq_id(public_keys: &[PublicKey]) -> u64 { + let mut parts: Vec = public_keys.iter().map(|key| key.to_string()).collect(); + parts.sort(); + let parts: Vec<&str> = parts.iter().map(String::as_str).collect(); + stable_id_u64(&parts) +} + /// Custom emoji (NIP-30) declared by `emoji` tags, first per shortcode wins pub fn emojis_from_tags(tags: &Tags) -> Vec<(String, Url)> { let mut emojis: Vec<(String, Url)> = Vec::new(); @@ -100,4 +129,51 @@ mod tests { Url::parse("https://example.com/soapbox.png").unwrap() ); } + + fn event_with_participants(public_key: PublicKey, participants: Vec) -> Event { + let tags = Tags::from_list(participants.into_iter().map(Tag::public_key).collect()); + + Event::new( + EventId::from_byte_array([0; 32]), + public_key, + Timestamp::from(0u64), + Kind::TextNote, + tags, + "content".to_string(), + Signature::from_byte_array([0; 64]), + ) + } + + #[test] + fn uniq_id_ignores_participant_order() { + let me = Keys::new(SecretKey::generate()).public_key(); + let alice = Keys::new(SecretKey::generate()).public_key(); + let bob = Keys::new(SecretKey::generate()).public_key(); + + let first = event_with_participants(me, vec![alice, bob]); + let second = event_with_participants(me, vec![bob, alice]); + + assert_eq!(first.uniq_id(), second.uniq_id()); + } + + #[test] + fn uniq_id_tracks_participants() { + let me = Keys::new(SecretKey::generate()).public_key(); + let alice = Keys::new(SecretKey::generate()).public_key(); + let bob = Keys::new(SecretKey::generate()).public_key(); + + let with_alice = event_with_participants(me, vec![alice]); + let with_bob = event_with_participants(me, vec![bob]); + + assert_ne!(with_alice.uniq_id(), with_bob.uniq_id()); + } + + #[test] + fn stable_id_is_deterministic_128_bit_hex() { + let id = stable_id(&["alpha", "beta"]); + + assert_eq!(id.len(), 32); + assert!(id.chars().all(|character| character.is_ascii_hexdigit())); + assert_eq!(id, stable_id(&["alpha", "beta"])); + } } diff --git a/crates/common/src/parser.rs b/crates/common/src/parser.rs index 8ca3f374..8ba84c76 100644 --- a/crates/common/src/parser.rs +++ b/crates/common/src/parser.rs @@ -184,20 +184,11 @@ impl Iterator for FindMatches<'_> { } } -enum HandleMatch { - Token(Token), - Recursion, -} - pub struct NostrParserIter<'a> { /// The original text text: &'a str, /// Matches found matches: FindMatches<'a>, - /// A pending match - pending_match: Option, - /// Last match end index - last_match_end: usize, } impl<'a> NostrParserIter<'a> { @@ -205,62 +196,65 @@ impl<'a> NostrParserIter<'a> { Self { text, matches: FindMatches::new(text), - pending_match: None, - last_match_end: 0, } } - fn handle_match(&mut self, mat: Match) -> HandleMatch { - // Update last match end - self.last_match_end = mat.end; - + fn handle_match(&self, mat: Match) -> Option { // Extract the matched string let data: &str = &self.text[mat.start..mat.end]; // Parse NIP-21 URI match Nip21::parse(data) { - Ok(uri) => HandleMatch::Token(Token { + Ok(uri) => Some(Token { value: uri, range: mat.start..mat.end, }), // If the nostr URI parsing is invalid, skip it - Err(_) => HandleMatch::Recursion, + Err(_) => None, } } } -impl<'a> Iterator for NostrParserIter<'a> { +impl Iterator for NostrParserIter<'_> { type Item = Token; fn next(&mut self) -> Option { - // Handle a pending match - if let Some(pending_match) = self.pending_match.take() { - return match self.handle_match(pending_match) { - HandleMatch::Token(token) => Some(token), - HandleMatch::Recursion => self.next(), - }; - } + // Iterative, not recursive: message text controls the match count + loop { + let mat = self.matches.next()?; - match self.matches.next() { - Some(mat) => { - // Skip any text before this match - if mat.start > self.last_match_end { - // Update pending match - // This will be handled at next iteration, in `handle_match` method. - self.pending_match = Some(mat); - - // Skip the text before the match - self.last_match_end = mat.start; - return self.next(); - } - - // Handle match - match self.handle_match(mat) { - HandleMatch::Token(token) => Some(token), - HandleMatch::Recursion => self.next(), - } + if let Some(token) = self.handle_match(mat) { + return Some(token); } - None => None, } } } + +#[cfg(test)] +mod tests { + use super::*; + + // A long run of scanner matches that fail `Nip21::parse`: the count is + // attacker-controlled, so it must be consumed iteratively. + #[test] + fn skips_long_run_of_invalid_uris() { + let content = "nostr:1q".repeat(50_000); + + let tokens: Vec = NostrParser::new().parse(&content).collect(); + + assert!(tokens.is_empty()); + } + + #[test] + fn yields_valid_uri_after_invalid_run() { + let public_key = Keys::new(SecretKey::generate()).public_key(); + let npub = public_key.to_bech32().unwrap(); + let content = format!("{}nostr:{npub}", "nostr:1q".repeat(50_000)); + + let tokens: Vec = NostrParser::new().parse(&content).collect(); + + assert_eq!(tokens.len(), 1); + assert_eq!(tokens[0].range.start, 400_000); + assert_eq!(tokens[0].value, Nip21::Pubkey(public_key)); + } +} diff --git a/crates/nip29/src/protocol.rs b/crates/nip29/src/protocol.rs index 26502327..6c00a192 100644 --- a/crates/nip29/src/protocol.rs +++ b/crates/nip29/src/protocol.rs @@ -1,10 +1,9 @@ use std::collections::HashSet; -use std::collections::hash_map::DefaultHasher; use std::fmt; -use std::hash::{Hash, Hasher}; use std::str::FromStr; use anyhow::{Result, anyhow, bail}; +use common::stable_id_u64; use nostr_sdk::prelude::*; pub(crate) const TIMELINE_WINDOW: usize = 50; @@ -289,9 +288,7 @@ impl GroupKey { } pub fn uniq_id(&self) -> u64 { - let mut hasher = DefaultHasher::new(); - self.hash(&mut hasher); - hasher.finish() + stable_id_u64(&[self.relay.as_str(), self.id.as_str()]) } } diff --git a/crates/nip29/src/utils.rs b/crates/nip29/src/utils.rs index 153540ed..3b93d005 100644 --- a/crates/nip29/src/utils.rs +++ b/crates/nip29/src/utils.rs @@ -1,7 +1,6 @@ use std::collections::HashSet; -use std::collections::hash_map::DefaultHasher; -use std::hash::{Hash, Hasher}; +use common::stable_id_u64; use nostr_sdk::prelude::*; use crate::{GroupId, GroupKey}; @@ -25,10 +24,10 @@ pub(crate) fn subscription_id(key: &GroupKey) -> SubscriptionId { } pub(crate) fn browse_subscription(relay: &RelayUrl) -> SubscriptionId { - let mut hasher = DefaultHasher::new(); - relay.hash(&mut hasher); - - SubscriptionId::new(format!("{BROWSE_PREFIX}{:08x}", hasher.finish() as u32)) + SubscriptionId::new(format!( + "{BROWSE_PREFIX}{:08x}", + stable_id_u64(&[relay.as_str()]) as u32 + )) } pub(crate) fn page_subscription(key: &GroupKey) -> SubscriptionId { diff --git a/crates/nip4e/src/lib.rs b/crates/nip4e/src/lib.rs index f52f4cf2..c4295f34 100644 --- a/crates/nip4e/src/lib.rs +++ b/crates/nip4e/src/lib.rs @@ -83,6 +83,8 @@ pub struct DeviceRegistry { resync: Arc, /// Whether a key rotation is in flight, to coalesce overlapping requests rotating: bool, + /// Whether a key approval is in flight, to guard against a double click + approving: bool, /// Person directory for requesters shown in the approval dialog directory: Entity, /// Relay notification listener @@ -155,6 +157,7 @@ impl DeviceRegistry { announcement_existed: Arc::new(AtomicBool::new(false)), resync: Arc::new(AtomicBool::new(false)), rotating: false, + approving: false, directory: Directory::new(cx), notification_listener: None, tasks: vec![], @@ -186,22 +189,31 @@ impl DeviceRegistry { continue; } - match event.kind { - Kind::Custom(10044) if current_user == Some(event.pubkey) => { - announcement_existed.store(true, Ordering::Relaxed); - tx.send_async(event.into_owned()).await?; - } - Kind::Custom(4454) if current_user == Some(event.pubkey) => { - tx.send_async(event.into_owned()).await?; - } - Kind::Custom(4455) if current_user == Some(event.pubkey) => { - tx.send_async(event.into_owned()).await?; - } - _ => {} + let relevant = matches!( + event.kind, + Kind::Custom(10044) | Kind::Custom(4454) | Kind::Custom(4455) + ) && current_user == Some(event.pubkey); + + if !relevant { + continue; + } + + if event.kind == Kind::Custom(10044) { + announcement_existed.store(true, Ordering::Relaxed); + } + + let event_id = event.id; + // Dropping keeps the listener alive when the consumer is gone; + // missed 4e events are re-synced on the next signer change + if let Err(error) = tx.send_async(event.into_owned()).await { + log::error!("Dropping device key event {event_id}: {error}"); } } } + // The stream only ends without being replaced when the relay source is + // gone, after which no 4e event is processed anymore + log::error!("Device key notification listener stopped"); Ok(()) })); @@ -399,10 +411,16 @@ impl DeviceRegistry { self.tasks.push(cx.spawn(async move |this, cx| { let result: Result = async { - // Archive the current key before replacing it - if let Ok(old) = get_keys(&client, &signer).await { - archive_key(&client, &signer, &old.secret_key().to_secret_hex()).await?; - } + // Archive the current key before replacing it; a failed load or + // archive aborts the rotation so the old key is never overwritten + // without a record + let old = get_keys(&client, &signer) + .await + .context("Failed to load the current encryption key before rotation")?; + + archive_key(&client, &signer, &old.secret_key().to_secret_hex()) + .await + .context("Failed to archive the current encryption key")?; let created = this .update(cx, |this, cx| this.create_encryption(Keys::generate(), cx))? @@ -772,12 +790,21 @@ impl DeviceRegistry { /// Approve requests for device keys from other devices fn approve(&mut self, event: &Event, window: &mut Window, cx: &mut Context) { + // An approval already in flight must not send the device secret twice; + // the toast's disabled state only applies after its next re-render + if self.approving { + return; + } + self.approving = true; + cx.notify(); + + let approved_id = event.id; + let event = event.clone(); + let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); let signer = nostr.read(cx).signer(); - let event = event.clone(); - let task: Task> = cx.background_spawn(async move { // Get device keys let keys = get_keys(&client, &signer).await?; @@ -834,17 +861,41 @@ impl DeviceRegistry { self.tasks.push(cx.spawn_in(window, async move |this, cx| { match task.await { Ok(_) => { - this.update(cx, |this, cx| { - this.pending_request = None; + let superseded = this.update(cx, |this, cx| { + this.approving = false; cx.notify(); + // Keep a newer request's pending state and toast + if this + .pending_request + .as_ref() + .is_some_and(|pending| pending.event.id == approved_id) + { + this.pending_request = None; + false + } else { + true + } })?; - cx.update(|window, cx| { - window.remove_notification::(cx); - }) - .ok(); + if !superseded { + cx.update(|window, cx| { + window.remove_notification::(cx); + }) + .ok(); + } } Err(e) => { + this.update(cx, |this, cx| { + this.approving = false; + cx.notify(); + + // Pushing a fresh toast replaces the wedged one whose + // button stays disabled + if let Some(pending) = this.pending_request.take() { + this.show_approval(pending.event, pending.client_key, cx); + } + })?; + cx.update(|window, cx| { window.push_notification( Notification::error(e.to_string()).autohide(false), @@ -1032,9 +1083,10 @@ impl DeviceRegistry { .on_click({ let loading = Rc::clone(&loading); move |_ev, window, cx| { - // Set loading state to true loading.set(true); - // Process to approve the request + // The toast caches its render, so force a redraw + // to disable the button while the approval is in flight + window.refresh(); view.update(cx, |this, cx| { this.approve(&event, window, cx); }) diff --git a/crates/nip60/src/lib.rs b/crates/nip60/src/lib.rs index 4e032cf7..085a64d3 100644 --- a/crates/nip60/src/lib.rs +++ b/crates/nip60/src/lib.rs @@ -193,6 +193,16 @@ impl WalletRegistry { ); } + /// Drop a pending invoice at the user's request. + pub fn dismiss_mint_quote(&mut self, quote_id: String, cx: &mut Context) { + if !self.quotes.iter().any(|quote| quote.quote_id == quote_id) { + return; + } + + self.quotes.retain(|quote| quote.quote_id != quote_id); + self.emit_updated(cx); + } + /// Pay a lightning invoice from the wallet. pub fn melt(&mut self, invoice: String, cx: &mut Context) { let Some(mint_url) = self.primary_mint(cx) else { @@ -896,7 +906,11 @@ impl WalletRegistry { }; match outcome { - ops::Outcome::Idle => {} + ops::Outcome::Idle => { + if matches!(job, ops::Job::ClaimQuote { .. }) { + self.emit_error("Invoice is not paid yet".to_string(), cx); + } + } ops::Outcome::Reconciled { updates } => { self.publish_failed = false; let changed = !updates.is_empty(); diff --git a/crates/nip60_ui/src/wallet.rs b/crates/nip60_ui/src/wallet.rs index 38d1a6b0..e3ed3c6b 100644 --- a/crates/nip60_ui/src/wallet.rs +++ b/crates/nip60_ui/src/wallet.rs @@ -1,7 +1,7 @@ use std::ops::Range; use std::rc::Rc; -use ::nip60::{HistoryEntry, WalletEvent, WalletRegistry}; +use ::nip60::{HistoryEntry, MintQuoteEntry, WalletEvent, WalletRegistry}; use common::TimestampExt; use gpui::prelude::FluentBuilder; use gpui::{ @@ -13,6 +13,7 @@ use gpui::{ use gpui_kit::assets::IconName; use gpui_kit::base::StyledExt as _; use gpui_kit::component::button::{Button, ButtonVariants as _}; +use gpui_kit::component::clipboard::Clipboard; use gpui_kit::component::dock::{Panel, PanelEvent}; use gpui_kit::component::input::{Input, InputEvent, InputState, NumberInput}; use gpui_kit::component::notification::Notification; @@ -108,6 +109,111 @@ impl WalletPanel { ) } + fn render_quotes(&self, quotes: Vec, cx: &Context) -> Option { + if quotes.is_empty() { + return None; + } + + Some( + v_flex() + .gap_2() + .child( + div() + .text_xs() + .font_semibold() + .text_color(cx.theme().muted_foreground) + .child("Pending invoices"), + ) + .children( + quotes + .iter() + .map(|quote| self.render_quote_entry(quote, cx)), + ) + .into_any_element(), + ) + } + + fn render_quote_entry(&self, quote: &MintQuoteEntry, cx: &Context) -> impl IntoElement { + let wallet = WalletRegistry::global(cx); + let quote_id = quote.quote_id.clone(); + + v_flex() + .gap_1() + .w_full() + .child( + h_flex() + .justify_between() + .gap_2() + .child( + h_flex() + .gap_2() + .min_w_0() + .child( + div() + .flex_shrink_0() + .text_color(cx.theme().success) + .child(SharedString::from(format!("{} sats", quote.amount))), + ) + .child( + div() + .truncate() + .text_color(cx.theme().muted_foreground) + .child(SharedString::from(quote.mint.to_string())), + ), + ) + .child( + h_flex() + .flex_shrink_0() + .gap_1() + .child( + Button::new(format!("mint-quote-check-{quote_id}")) + .label("Check") + .tooltip("Check payment and claim") + .ghost() + .small() + .on_click({ + let wallet = wallet.clone(); + let quote_id = quote_id.clone(); + move |_, _, cx| { + wallet.update(cx, |registry, cx| { + registry.claim_mint_quote(quote_id.clone(), cx) + }); + } + }), + ) + .child( + Clipboard::new(format!("mint-quote-copy-{quote_id}")) + .value(quote.request.clone()) + .tooltip("Copy invoice") + .small(), + ) + .child( + Button::new(format!("mint-quote-dismiss-{quote_id}")) + .icon(IconName::Close) + .tooltip("Dismiss") + .ghost() + .small() + .on_click({ + let wallet = wallet.clone(); + move |_, _, cx| { + wallet.update(cx, |registry, cx| { + registry.dismiss_mint_quote(quote_id.clone(), cx) + }); + } + }), + ), + ), + ) + .child( + div() + .overflow_hidden() + .whitespace_nowrap() + .text_ellipsis_middle() + .text_color(cx.theme().muted_foreground) + .child(SharedString::from(quote.request.clone())), + ) + } + fn render_history( &mut self, history: Vec, @@ -762,7 +868,9 @@ impl Focusable for WalletPanel { impl Render for WalletPanel { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let history = WalletRegistry::global(cx).read(cx).history().to_vec(); + let wallet = WalletRegistry::global(cx); + let history = wallet.read(cx).history().to_vec(); + let quotes = wallet.read(cx).mint_quotes().to_vec(); v_flex() .size_full() @@ -771,6 +879,7 @@ impl Render for WalletPanel { .text_xs() .child(self.render_balance(cx)) .child(self.render_actions()) + .children(self.render_quotes(quotes, cx)) .child( v_flex() .gap_2() diff --git a/crates/person/src/lib.rs b/crates/person/src/lib.rs index 1bf0d447..9e35dd83 100644 --- a/crates/person/src/lib.rs +++ b/crates/person/src/lib.rs @@ -1,5 +1,5 @@ use std::collections::{HashMap, HashSet}; -use std::sync::RwLock; +use std::sync::{Arc, RwLock}; use anyhow::{Error, anyhow}; use common::emojis_from_tags; @@ -64,7 +64,7 @@ enum Dispatch { #[derive(Debug)] pub struct PersonRegistry { /// Public keys with a metadata request in flight - in_flight: RwLock>, + in_flight: Arc>>, /// Web of trust graph of the signed-in user wot: Entity, sender: flume::Sender, @@ -102,8 +102,12 @@ impl PersonRegistry { let client3 = client.clone(); let executor = cx.background_executor().clone(); + + let in_flight = Arc::new(RwLock::new(HashSet::new())); + let in_flight_requests = Arc::clone(&in_flight); + tasks.push(cx.background_spawn(async move { - Self::handle_requests(&client3, &metadata_rx, executor).await; + Self::handle_requests(&client3, &metadata_rx, executor, &in_flight_requests).await; })); tasks.push(cx.spawn(async move |this, cx| { @@ -140,7 +144,7 @@ impl PersonRegistry { }); Self { - in_flight: RwLock::new(HashSet::new()), + in_flight, wot, sender: metadata_tx, tasks, @@ -205,6 +209,7 @@ impl PersonRegistry { client: &Client, rx: &flume::Receiver, executor: BackgroundExecutor, + in_flight: &RwLock>, ) { let mut batch: HashSet = HashSet::new(); @@ -216,34 +221,44 @@ impl PersonRegistry { result = rx.recv_async().fuse() => match result { Ok(public_key) => public_key, Err(_) => { - if !batch.is_empty() - && let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await - { - log::warn!("Failed to get metadata batch: {e}"); - } + Self::flush_batch(client, &mut batch, in_flight).await; return; } }, () = timer => { - if !batch.is_empty() - && let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await - { - log::warn!("Failed to get metadata batch: {e}"); - } + Self::flush_batch(client, &mut batch, in_flight).await; continue; } }; batch.insert(request); // Process the batch if it's full - if batch.len() >= 20 - && let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await - { - log::warn!("Failed to get metadata batch: {e}"); + if batch.len() >= 20 { + Self::flush_batch(client, &mut batch, in_flight).await; } } } + /// Send one metadata request for the batch, releasing its keys for retry on failure + async fn flush_batch( + client: &Client, + batch: &mut HashSet, + in_flight: &RwLock>, + ) { + let batch = std::mem::take(batch); + if batch.is_empty() { + return; + } + + if let Err(error) = get_metadata(client, batch.iter().cloned()).await { + Self::release_keys(in_flight, batch.iter().cloned()); + log::warn!( + "Failed to get metadata for {} public keys, released for retry: {error}", + batch.len() + ); + } + } + /// Subscribe to the signed-in user's emoji list fn get_emojis(&mut self, cx: &mut Context) { let nostr = NostrRegistry::global(cx); @@ -334,11 +349,21 @@ impl PersonRegistry { /// Allow future fetches for a key once its metadata has arrived fn release_in_flight(&self, public_key: &PublicKey) { - let mut in_flight = match self.in_flight.write() { + Self::release_keys(&self.in_flight, [*public_key]); + } + + /// Remove the given keys from the in-flight set + fn release_keys( + in_flight: &RwLock>, + keys: impl IntoIterator, + ) { + let mut in_flight = match in_flight.write() { Ok(in_flight) => in_flight, Err(poisoned) => poisoned.into_inner(), }; - in_flight.remove(public_key); + for public_key in keys { + in_flight.remove(&public_key); + } } } diff --git a/crates/workspace/src/dialogs/new_chat.rs b/crates/workspace/src/dialogs/new_chat.rs index 81d92ad0..a381dc57 100644 --- a/crates/workspace/src/dialogs/new_chat.rs +++ b/crates/workspace/src/dialogs/new_chat.rs @@ -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(); } }); diff --git a/crates/workspace/src/dialogs/profile.rs b/crates/workspace/src/dialogs/profile.rs index b3c83eb2..1f666266 100644 --- a/crates/workspace/src/dialogs/profile.rs +++ b/crates/workspace/src/dialogs/profile.rs @@ -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, /// Tasks tasks: Vec>>, + /// Keeps the avatar preview live while the URL is typed + _subscription: Option, } impl ProfileDialog { fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context) -> 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(&mut self, error: E, window: &mut Window, cx: &mut Context) + where + E: Into, + { + 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> { 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() diff --git a/crates/workspace/src/dialogs/screening.rs b/crates/workspace/src/dialogs/screening.rs index 6defb5bb..edff994a 100644 --- a/crates/workspace/src/dialogs/screening.rs +++ b/crates/workspace/src/dialogs/screening.rs @@ -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)), ), ), ) diff --git a/crates/workspace/src/dock/mod.rs b/crates/workspace/src/dock/mod.rs index 7dd33d90..af7394cc 100644 --- a/crates/workspace/src/dock/mod.rs +++ b/crates/workspace/src/dock/mod.rs @@ -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 { + let view = panel.panel(); + if let Some(chat) = view.as_any().downcast_ref::>() { + return Some(PanelKey::Room(chat.read(cx).room_id().clone())); + } + if view.as_any().downcast_ref::>().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, cx: &App) -> Option { + 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, +) { + 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, ) { - 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, +) { + 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::>() 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, @@ -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, + group_key: GroupKey, + panel: PanelHandle, + placement: DockPlacement, + window: &mut Window, + cx: &mut App, +) { + let Some(root) = window.root::().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); + }); +} diff --git a/crates/workspace/src/dock/skin.rs b/crates/workspace/src/dock/skin.rs index bc7a0c7e..b4d4b2eb 100644 --- a/crates/workspace/src/dock/skin.rs +++ b/crates/workspace/src/dock/skin.rs @@ -22,7 +22,6 @@ use super::tab_panel::CoopTabGroupSkin; /// State the skin shares with its per-container renderers. pub(crate) struct SkinShared { area: WeakEntity, - toggle_button_visible: Cell, /// The dock whose resize handle is being dragged, if any. Only one can be. resizing_dock: Cell>, } @@ -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> { &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), }), }) diff --git a/crates/workspace/src/dock/tab_panel.rs b/crates/workspace/src/dock/tab_panel.rs index c92101b4..93de7640 100644 --- a/crates/workspace/src/dock/tab_panel.rs +++ b/crates/workspace/src/dock/tab_panel.rs @@ -209,7 +209,7 @@ impl CoopTabGroupSkin { group: &TabGroupContext, cx: &mut App, ) -> Option