5 Commits
Author SHA1 Message Date
reya 3484c2c71b add experimental tags and group experimental settings 2026-11-13 08:25:17 +07:00
reya 8b9c8bd35e Prune dead code and key elements by stable ids 2026-11-13 04:37:13 +07:00
reya e9f29dc398 stabilize ids and harden release pipeline 2026-11-12 20:42:50 +07:00
reya fd802a8870 harden proxy auth and event ordering 2026-11-12 17:59:28 +07:00
reya fe0ca04817 add plan 2026-11-12 15:41:15 +07:00
57 changed files with 3204 additions and 878 deletions
Generated
+2 -8
View File
@@ -381,7 +381,6 @@ dependencies = [
name = "assets" name = "assets"
version = "2.0.0-alpha3" version = "2.0.0-alpha3"
dependencies = [ dependencies = [
"anyhow",
"gpui-kit", "gpui-kit",
"gpui-pre", "gpui-pre",
"rust-embed", "rust-embed",
@@ -1448,7 +1447,6 @@ dependencies = [
"rand 0.10.3", "rand 0.10.3",
"settings", "settings",
"smallvec", "smallvec",
"smol",
"state", "state",
] ]
@@ -1463,6 +1461,7 @@ dependencies = [
"gpui-pre", "gpui-pre",
"instant", "instant",
"itertools 0.13.0", "itertools 0.13.0",
"log",
"nip60_ui", "nip60_ui",
"nostr-sdk", "nostr-sdk",
"person", "person",
@@ -1681,6 +1680,7 @@ dependencies = [
"nostr", "nostr",
"nostr-sdk", "nostr-sdk",
"regex", "regex",
"sha2 0.10.9",
"web-sys", "web-sys",
] ]
@@ -1819,7 +1819,6 @@ dependencies = [
"settings", "settings",
"state", "state",
"tracing-subscriber", "tracing-subscriber",
"ui",
"workspace", "workspace",
] ]
@@ -1847,7 +1846,6 @@ dependencies = [
"settings", "settings",
"state", "state",
"tracing-wasm", "tracing-wasm",
"ui",
"universal-time 0.3.1 (git+https://github.com/shadowylab/universal-time)", "universal-time 0.3.1 (git+https://github.com/shadowylab/universal-time)",
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
@@ -6050,7 +6048,6 @@ dependencies = [
"gpui-pre", "gpui-pre",
"log", "log",
"nostr-sdk", "nostr-sdk",
"person",
"serde_json", "serde_json",
"smallvec", "smallvec",
"state", "state",
@@ -6117,7 +6114,6 @@ dependencies = [
"sha2 0.10.9", "sha2 0.10.9",
"smallvec", "smallvec",
"state", "state",
"tokio",
] ]
[[package]] [[package]]
@@ -10346,8 +10342,6 @@ dependencies = [
"nostr-sdk", "nostr-sdk",
"person", "person",
"smallvec", "smallvec",
"uuid",
"web-time",
] ]
[[package]] [[package]]
-1
View File
@@ -6,7 +6,6 @@ publish.workspace = true
[dependencies] [dependencies]
gpui.workspace = true gpui.workspace = true
anyhow.workspace = true
rust-embed.workspace = true rust-embed.workspace = true
gpui-kit.workspace = true gpui-kit.workspace = true
+21
View File
@@ -63,6 +63,18 @@ struct GlobalAutoUpdater(Entity<AutoUpdater>);
impl Global for GlobalAutoUpdater {} 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 { pub struct AutoUpdater {
updater: Entity<Updater>, updater: Entity<Updater>,
status: UpdateStatus, 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. /// Check the release host for a newer version, then download and install it.
pub fn check(&mut self, cx: &mut Context<Self>) { pub fn check(&mut self, cx: &mut Context<Self>) {
self.updater.update(cx, |updater, cx| { self.updater.update(cx, |updater, cx| {
+3 -3
View File
@@ -5,7 +5,7 @@ async function pollForRequests() {
isPolling = true; isPolling = true;
try { try {
const response = await fetch('/api/pending'); const response = await fetch('/api/pending?token=' + encodeURIComponent(window.proxySessionToken));
const data = await response.json(); const data = await response.json();
console.log('Polled for requests, got:', data); console.log('Polled for requests, got:', data);
@@ -94,7 +94,7 @@ async function handleNip07Request(request) {
console.log('Sending response:', responsePayload); console.log('Sending response:', responsePayload);
await fetch('/api/response', { await fetch('/api/response?token=' + encodeURIComponent(window.proxySessionToken), {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -117,7 +117,7 @@ async function handleNip07Request(request) {
console.log('Sending error response:', errorPayload); console.log('Sending error response:', errorPayload);
await fetch('/api/response', { await fetch('/api/response?token=' + encodeURIComponent(window.proxySessionToken), {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
+218 -80
View File
@@ -26,6 +26,13 @@ pub use self::error::Error;
const DEFAULT_HTML: &str = include_str!("../index.html"); const DEFAULT_HTML: &str = include_str!("../index.html");
const JS: &str = include_str!("../proxy.js"); 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<Uuid, channel::Sender<Result<Value, String>>>; type PendingResponseMap = HashMap<Uuid, channel::Sender<Result<Value, String>>>;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -265,6 +272,10 @@ impl BrowserSignerProxy {
let state: Arc<ProxyState> = self.inner.state.clone(); let state: Arc<ProxyState> = self.inner.state.clone();
let custom_html: &'static str = self.inner.options.custom_html; let custom_html: &'static str = self.inner.options.custom_html;
let shutdown: Arc<ShutdownEvent> = self.inner.shutdown.clone(); let shutdown: Arc<ShutdownEvent> = self.inner.shutdown.clone();
// Regenerated on every startup, never part of the opened URL
let session_token: String = Uuid::new_v4().simple().to_string();
// Only the exact host:port the server binds may appear in the Host header
let expected_host: String = addr.to_string();
smol::spawn(async move { smol::spawn(async move {
tracing::info!("Starting proxy server on {addr}"); tracing::info!("Starting proxy server on {addr}");
@@ -291,13 +302,30 @@ impl BrowserSignerProxy {
AcceptEvent::Connection(Ok((stream, _))) => { AcceptEvent::Connection(Ok((stream, _))) => {
let state: Arc<ProxyState> = state.clone(); let state: Arc<ProxyState> = state.clone();
let shutdown: Arc<ShutdownEvent> = shutdown.clone(); let shutdown: Arc<ShutdownEvent> = shutdown.clone();
let expected_host: String = expected_host.clone();
let session_token: String = session_token.clone();
smol::spawn(async move { smol::spawn(async move {
let shutdown_listener = shutdown.listen(); let shutdown_listener = shutdown.listen();
smol::future::or( smol::future::or(
async { async {
handle_connection(stream, state, custom_html).await; smol::future::or(
async {
handle_connection(
stream,
state,
custom_html,
expected_host,
session_token,
)
.await;
},
async {
smol::Timer::after(CONNECTION_TIMEOUT).await;
},
)
.await;
}, },
async { async {
shutdown_listener.await; shutdown_listener.await;
@@ -336,6 +364,18 @@ impl BrowserSignerProxy {
outgoing_requests.push(request); outgoing_requests.push(request);
} }
/// Drop a request from both queues so timed-out request data is not retained.
#[inline]
async fn cancel_request(&self, id: Uuid) {
self.inner.state.pending_responses.lock().await.remove(&id);
self.inner
.state
.outgoing_requests
.lock()
.await
.retain(|request| request.id != id);
}
async fn request<T>(&self, method: RequestMethod, params: Value) -> Result<T, Error> async fn request<T>(&self, method: RequestMethod, params: Value) -> Result<T, Error>
where where
T: DeserializeOwned, T: DeserializeOwned,
@@ -345,6 +385,7 @@ impl BrowserSignerProxy {
// Construct the request // Construct the request
let request: RequestData = RequestData::new(method, params); let request: RequestData = RequestData::new(method, params);
let request_id = request.id;
// Create a bounded channel of size 1 as a oneshot replacement // Create a bounded channel of size 1 as a oneshot replacement
let (tx, rx) = channel::bounded::<Result<Value, String>>(1); let (tx, rx) = channel::bounded::<Result<Value, String>>(1);
@@ -361,7 +402,10 @@ impl BrowserSignerProxy {
match response { match response {
Ok(Ok(res)) => Ok(serde_json::from_value(res)?), Ok(Ok(res)) => Ok(serde_json::from_value(res)?),
Ok(Err(error)) => Err(Error::generic(error)), Ok(Err(error)) => Err(Error::generic(error)),
Err(TimeoutError) => Err(Error::timeout()), Err(TimeoutError) => {
self.cancel_request(request_id).await;
Err(Error::timeout())
}
} }
} }
@@ -372,10 +416,19 @@ impl BrowserSignerProxy {
#[inline] #[inline]
async fn _sign_event(&self, event: UnsignedEvent) -> Result<Event, Error> { async fn _sign_event(&self, event: UnsignedEvent) -> Result<Event, Error> {
let expected_id: EventId = event.compute_id();
let event: Event = self let event: Event = self
.request(RequestMethod::SignEvent, serde_json::to_value(event)?) .request(RequestMethod::SignEvent, serde_json::to_value(event)?)
.await?; .await?;
event.verify()?; 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) Ok(event)
} }
@@ -437,18 +490,83 @@ impl AsyncNip44 for BrowserSignerProxy {
} }
} }
// ── Minimal HTTP server ────────────────────────────────────────────────── /// Outcome of reading a line with [`read_line_capped`].
enum HeaderLine {
/// A line, including the trailing newline when present
Line(Vec<u8>),
/// 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<TcpStream>,
limit: usize,
) -> Result<HeaderLine, std::io::Error> {
let mut line: Vec<u8> = 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. /// Handle a single HTTP connection.
async fn handle_connection(stream: TcpStream, state: Arc<ProxyState>, custom_html: &'static str) { async fn handle_connection(
stream: TcpStream,
state: Arc<ProxyState>,
custom_html: &'static str,
expected_host: String,
session_token: String,
) {
let mut reader = BufReader::new(stream); let mut reader = BufReader::new(stream);
let mut header_bytes: usize = 0;
// Read the request line // Read the request line
let mut request_line = String::new(); let request_line = match read_line_capped(&mut reader, MAX_HEADER_BYTES).await {
if reader.read_line(&mut request_line).await.is_err() { Ok(HeaderLine::Line(line)) => line,
Ok(HeaderLine::Eof) => Vec::new(),
Ok(HeaderLine::TooLong) => {
send_response(&mut reader, 400, "Bad Request", "", "").await;
return; return;
} }
let request_line = request_line.trim_end().to_string(); 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 // Parse method, path, and HTTP version from request line
let parts: Vec<&str> = request_line.split_whitespace().collect(); let parts: Vec<&str> = request_line.split_whitespace().collect();
@@ -462,24 +580,72 @@ async fn handle_connection(stream: TcpStream, state: Arc<ProxyState>, custom_htm
// Read headers until empty line // Read headers until empty line
let mut headers = Vec::new(); let mut headers = Vec::new();
let mut content_length: usize = 0; let mut content_length: usize = 0;
let mut host: Option<String> = None;
loop { loop {
let mut line = String::new(); let line = match read_line_capped(&mut reader, MAX_HEADER_BYTES).await {
if reader.read_line(&mut line).await.is_err() { Ok(HeaderLine::Line(line)) => line,
Ok(HeaderLine::Eof) => break,
Ok(HeaderLine::TooLong) => {
send_response(&mut reader, 400, "Bad Request", "", "").await;
return; return;
} }
let line = line.trim_end().to_string(); Err(_) => return,
};
header_bytes += line.len();
if header_bytes > MAX_HEADER_BYTES {
send_response(&mut reader, 400, "Bad Request", "", "").await;
return;
}
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() { if line.is_empty() {
break; break;
} }
if let Some(value) = line.strip_prefix("content-length:") { if let Some(value) = line.strip_prefix("content-length:") {
content_length = value.trim().parse().unwrap_or(0); content_length = value.trim().parse().unwrap_or(0);
} else if let Some(value) = line.strip_prefix("Content-Length:") { } else if let Some(value) = line.strip_prefix("Content-Length:") {
content_length = value.trim().parse().unwrap_or(0); content_length = value.trim().parse().unwrap_or(0);
} else if let Some(value) = line.strip_prefix("host:") {
host = Some(value.trim().to_string());
} else if let Some(value) = line.strip_prefix("Host:") {
host = Some(value.trim().to_string());
} }
headers.push(line); headers.push(line);
} }
match (method.as_str(), path.as_str()) { // 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;
return;
}
let (route, query) = match path.split_once('?') {
Some((route, query)) => (route, Some(query)),
None => (path.as_str(), None),
};
if route.starts_with("/api/") && query_param(query, "token") != Some(session_token.as_str()) {
send_response(&mut reader, 403, "Forbidden", "", "").await;
return;
}
match (method.as_str(), route) {
// Serve the HTML proxy page // Serve the HTML proxy page
("GET", "/") => { ("GET", "/") => {
let html = if custom_html.is_empty() { let html = if custom_html.is_empty() {
@@ -487,7 +653,8 @@ async fn handle_connection(stream: TcpStream, state: Arc<ProxyState>, custom_htm
} else { } else {
custom_html custom_html
}; };
send_response(&mut reader, 200, "OK", "text/html", html).await; let html = inject_session_token(html, &session_token);
send_response(&mut reader, 200, "OK", "text/html", &html).await;
} }
// Serve the JS proxy script // Serve the JS proxy script
("GET", "/proxy.js") => { ("GET", "/proxy.js") => {
@@ -516,7 +683,7 @@ async fn handle_connection(stream: TcpStream, state: Arc<ProxyState>, custom_htm
// Clear the outgoing requests after sending them // Clear the outgoing requests after sending them
outgoing.clear(); outgoing.clear();
send_response_cors_json(&mut reader, 200, "OK", &json).await; send_response(&mut reader, 200, "OK", "application/json", &json).await;
} }
// Receive response from browser extension // Receive response from browser extension
("POST", "/api/response") => { ("POST", "/api/response") => {
@@ -535,33 +702,32 @@ async fn handle_connection(stream: TcpStream, state: Arc<ProxyState>, custom_htm
} }
}; };
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 id: Uuid = message.id;
let mut pending = state.pending_responses.lock().await; let mut pending = state.pending_responses.lock().await;
match pending.remove(&id) { match pending.remove(&id) {
Some(sender) => { Some(sender) => match sender.try_send(message.into_result()) {
// Use try_send since we already hold the lock Ok(()) => tracing::info!("Forwarded response for request {id}"),
let _ = sender.try_send(message.into_result()); Err(e) => {
tracing::info!("Forwarded response for request {id}"); tracing::warn!("Failed to forward response for request {id}: {e}")
} }
},
None => tracing::warn!("No pending request found for {id}"), None => tracing::warn!("No pending request found for {id}"),
} }
send_response_cors(&mut reader, 200, "OK", "text/plain", "OK").await; send_response(&mut reader, 200, "OK", "text/plain", "OK").await;
}
// CORS preflight
("OPTIONS", _) => {
let response = "HTTP/1.1 200 OK\r\n\
Access-Control-Allow-Origin: *\r\n\
Access-Control-Allow-Methods: GET, POST, OPTIONS\r\n\
Access-Control-Allow-Headers: Content-Type\r\n\
Content-Length: 0\r\n\
Connection: close\r\n\
\r\n";
let _ = reader.get_mut().write_all(response.as_bytes()).await;
let _ = reader.get_mut().flush().await;
} }
// 404 - not found // 404 - not found
_ => { _ => {
@@ -585,68 +751,44 @@ async fn send_response(
} }
response.push_str(&format!("Content-Length: {}\r\n", body.len())); response.push_str(&format!("Content-Length: {}\r\n", body.len()));
response.push_str("Access-Control-Allow-Origin: *\r\n");
response.push_str("Connection: close\r\n"); response.push_str("Connection: close\r\n");
response.push_str("\r\n"); response.push_str("\r\n");
response.push_str(body); response.push_str(body);
let _ = stream.write_all(response.as_bytes()).await; write_response(stream, &response).await;
let _ = stream.flush().await;
} }
/// Write a response with CORS headers and JSON content type. async fn write_response(stream: &mut (impl AsyncWriteExt + Unpin), response: &str) {
async fn send_response_cors_json( if let Err(e) = stream.write_all(response.as_bytes()).await {
stream: &mut (impl AsyncWriteExt + Unpin), tracing::debug!("Failed to write response to peer: {e}");
status: u16, }
status_text: &str, if let Err(e) = stream.flush().await {
body: &str, tracing::debug!("Failed to flush response to peer: {e}");
) { }
let mut response = format!("HTTP/1.1 {status} {status_text}\r\n");
response.push_str("Content-Type: application/json\r\n");
response.push_str(&format!("Content-Length: {}\r\n", body.len()));
response.push_str("Access-Control-Allow-Origin: *\r\n");
response.push_str("Connection: close\r\n");
response.push_str("\r\n");
response.push_str(body);
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.flush().await;
} }
/// Write a response with CORS headers. /// Get a parameter value from an optional query string.
async fn send_response_cors( fn query_param<'a>(query: Option<&'a str>, name: &str) -> Option<&'a str> {
stream: &mut (impl AsyncWriteExt + Unpin), let query = query?;
status: u16, query.split('&').find_map(|pair| {
status_text: &str, let (key, value) = pair.split_once('=')?;
content_type: &str, (key == name).then_some(value)
body: &str, })
) {
let mut response = format!("HTTP/1.1 {status} {status_text}\r\n");
if !content_type.is_empty() {
response.push_str(&format!("Content-Type: {content_type}\r\n"));
} }
response.push_str(&format!("Content-Length: {}\r\n", body.len())); fn inject_session_token(html: &str, token: &str) -> String {
response.push_str("Access-Control-Allow-Origin: *\r\n"); let script = format!("<script>window.proxySessionToken=\"{token}\";</script>");
response.push_str("Connection: close\r\n"); match html.rfind("</body>") {
response.push_str("\r\n"); Some(index) => format!("{}{script}{}", &html[..index], &html[index..]),
response.push_str(body); None => format!("{html}{script}"),
}
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.flush().await;
} }
// ── Timeout helper ───────────────────────────────────────────────────────
/// An error indicating that an operation timed out. /// An error indicating that an operation timed out.
#[derive(Debug)] #[derive(Debug)]
struct TimeoutError; struct TimeoutError;
/// Races a channel receive against a duration. /// Races a channel receive against a duration.
///
/// Returns the channel value on success, or [`TimeoutError`] if the duration
/// elapses first or the channel is closed.
async fn race_timeout<T>( async fn race_timeout<T>(
duration: Duration, duration: Duration,
recv: impl Future<Output = Result<T, channel::RecvError>>, recv: impl Future<Output = Result<T, channel::RecvError>>,
@@ -677,10 +819,6 @@ async fn race_timeout<T>(
} }
} }
// ── Utility ──────────────────────────────────────────────────────────────
/// Gets the current time in seconds since the Unix epoch (1970-01-01). If the
/// time is before the epoch, returns 0.
#[inline] #[inline]
fn current_time() -> u64 { fn current_time() -> u64 {
SystemTime::now() SystemTime::now()
-3
View File
@@ -24,6 +24,3 @@ flume.workspace = true
futures.workspace = true futures.workspace = true
fuzzy-matcher = "0.3.7" fuzzy-matcher = "0.3.7"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
smol.workspace = true
+18 -1
View File
@@ -396,10 +396,27 @@ impl ChatRegistry {
} }
if !errors.is_empty() { if !errors.is_empty() {
let mut newly_trashed = 0;
trash.update(cx, |trash, cx| { trash.update(cx, |trash, cx| {
trash.extend(errors); newly_trashed = errors
.iter()
.filter(|message| trash.insert((*message).clone()))
.count();
cx.notify(); 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) { if eose && tracking.swap(false, Ordering::AcqRel) {
+1
View File
@@ -18,6 +18,7 @@ gpui.workspace = true
instant.workspace = true instant.workspace = true
nostr-sdk.workspace = true nostr-sdk.workspace = true
anyhow.workspace = true anyhow.workspace = true
log.workspace = true
itertools.workspace = true itertools.workspace = true
smallvec.workspace = true smallvec.workspace = true
serde.workspace = true serde.workspace = true
+107 -24
View File
@@ -1,3 +1,4 @@
use std::cmp::Ordering;
use std::collections::{BTreeMap, HashMap, HashSet}; use std::collections::{BTreeMap, HashMap, HashSet};
use std::sync::{Arc, LazyLock, RwLock}; use std::sync::{Arc, LazyLock, RwLock};
use std::time::Duration; use std::time::Duration;
@@ -144,7 +145,6 @@ impl ChatPanel {
}) })
.unwrap_or(("Unknown".into(), "Message...".into())); .unwrap_or(("Unknown".into(), "Message...".into()));
// Define input state
let input = cx.new(|cx| { let input = cx.new(|cx| {
TextareaState::new(window, cx) TextareaState::new(window, cx)
.placeholder(format!("Message {}", name)) .placeholder(format!("Message {}", name))
@@ -152,7 +152,6 @@ impl ChatPanel {
.clean_on_escape() .clean_on_escape()
}); });
// Define dictation state
let speech = cx.new(|cx| { let speech = cx.new(|cx| {
let state = SpeechState::new(cx) let state = SpeechState::new(cx)
.recognizer(speech::recognizer()) .recognizer(speech::recognizer())
@@ -165,11 +164,9 @@ impl ChatPanel {
state state
}); });
// Define subject input state
let subject_input = cx.new(|cx| InputState::new(window, cx).placeholder("New subject...")); let subject_input = cx.new(|cx| InputState::new(window, cx).placeholder("New subject..."));
let subject_bar = cx.new(|_cx| false); let subject_bar = cx.new(|_cx| false);
// Define subscriptions
let mut subscriptions = smallvec![]; let mut subscriptions = smallvec![];
subscriptions.push( subscriptions.push(
@@ -218,9 +215,8 @@ impl ChatPanel {
}), }),
); );
// Define all functions that will run after the current cycle
cx.defer_in(window, |this, window, cx| { cx.defer_in(window, |this, window, cx| {
this.connect(cx); this.connect(window, cx);
this.subscribe_room_events(window, cx); this.subscribe_room_events(window, cx);
this.get_messages(window, cx); this.get_messages(window, cx);
}); });
@@ -269,11 +265,24 @@ impl ChatPanel {
} }
/// Get messaging relays and announcement for each member /// Get messaging relays and announcement for each member
fn connect(&mut self, cx: &mut Context<Self>) { fn connect(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if let Some(room) = self.room.upgrade() { let Some(room) = self.room.upgrade() else {
let task = room.read(cx).connect(cx); return;
self.tasks.push(task); };
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 /// Subscribe to room events
@@ -317,23 +326,40 @@ impl ChatPanel {
} }
/// Load all messages belonging to this room /// Load all messages belonging to this room
fn get_messages(&mut self, _window: &mut Window, cx: &mut Context<Self>) { fn get_messages(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Ok(get_messages) = self.room.read_with(cx, |this, cx| this.get_messages(cx)) else { let Ok(get_messages) = self.room.read_with(cx, |this, cx| this.get_messages(cx)) else {
return; return;
}; };
self.tasks.push(cx.spawn(async move |this, cx| { self.tasks.push(cx.spawn_in(window, async move |this, cx| {
let events = get_messages.await?; match get_messages.await {
Ok(events) => {
// Update message list
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.insert_messages(&events, 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(()) 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 /// Get user input content and merged all attachments if available
fn get_input_value(&self, cx: &Context<Self>) -> String { fn get_input_value(&self, cx: &Context<Self>) -> String {
// Get input's value // Get input's value
@@ -566,8 +592,9 @@ impl ChatPanel {
for (i, message) in self.messages.iter().enumerate().skip(pos) { for (i, message) in self.messages.iter().enumerate().skip(pos) {
self.message_index.insert(message.id, i); self.message_index.insert(message.id, i);
} }
let len = self.list_state.item_count(); // The welcome row occupies row zero, so message rows sit one ahead
self.list_state.splice(len..len, 1); let row = pos + 1;
self.list_state.splice(row..row, 1);
if scroll { if scroll {
self.list_state.scroll_to(ListOffset { self.list_state.scroll_to(ListOffset {
@@ -582,13 +609,62 @@ impl ChatPanel {
/// Convert and insert a vector of nostr events into the chat panel /// Convert and insert a vector of nostr events into the chat panel
fn insert_messages(&mut self, events: &[UnsignedEvent], cx: &mut Context<Self>) { fn insert_messages(&mut self, events: &[UnsignedEvent], cx: &mut Context<Self>) {
let mut incoming: Vec<Message> = Vec::with_capacity(events.len());
for event in events.iter() { for event in events.iter() {
if event.kind == Kind::Reaction { if event.kind == Kind::Reaction {
self.insert_reaction(event, cx); 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<Message> = 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; 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();
} }
} }
@@ -598,16 +674,23 @@ impl ChatPanel {
return; return;
} }
let content = SharedString::from(&event.content);
let emojis = emojis_from_tags(&event.tags); let emojis = emojis_from_tags(&event.tags);
let image = Message::reaction_image(&event.content, &emojis) let image = Message::reaction_image(&event.content, &emojis)
.map(|url| SharedUri::from(url.to_string())); .map(|url| SharedUri::from(url.to_string()));
for id in event.tags.event_ids() { for id in event.tags.event_ids() {
self.reactions.entry(id).or_default().push(( let entries = self.reactions.entry(id).or_default();
SharedString::from(&event.content), // NIP-25: a newer reaction by the same author replaces their previous one
image.clone(), if let Some(existing) = entries
event.pubkey, .iter_mut()
)); .find(|(_, _, author)| *author == event.pubkey)
{
*existing = (content.clone(), image.clone(), event.pubkey);
} else {
entries.push((content.clone(), image.clone(), event.pubkey));
}
} }
cx.notify(); cx.notify();
+1
View File
@@ -9,6 +9,7 @@ gpui.workspace = true
nostr.workspace = true nostr.workspace = true
instant.workspace = true instant.workspace = true
nostr-sdk.workspace = true nostr-sdk.workspace = true
sha2.workspace = true
itertools.workspace = true itertools.workspace = true
chrono.workspace = true chrono.workspace = true
+99 -23
View File
@@ -1,20 +1,18 @@
use std::hash::{DefaultHasher, Hash, Hasher};
use itertools::Itertools; use itertools::Itertools;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use sha2::{Digest, Sha256};
const HEX_DIGITS: [u8; 16] = *b"0123456789abcdef";
pub trait EventExt { pub trait EventExt {
/// Id over the event's participants, stable across Rust versions.
fn uniq_id(&self) -> u64; fn uniq_id(&self) -> u64;
fn extract_public_keys(&self) -> Vec<PublicKey>; fn extract_public_keys(&self) -> Vec<PublicKey>;
} }
impl EventExt for Event { impl EventExt for Event {
fn uniq_id(&self) -> u64 { fn uniq_id(&self) -> u64 {
let mut hasher = DefaultHasher::new(); pubkey_set_uniq_id(&self.extract_public_keys())
let mut pubkeys: Vec<PublicKey> = self.extract_public_keys();
pubkeys.sort();
pubkeys.hash(&mut hasher);
hasher.finish()
} }
fn extract_public_keys(&self) -> Vec<PublicKey> { fn extract_public_keys(&self) -> Vec<PublicKey> {
@@ -27,31 +25,62 @@ impl EventExt for Event {
impl EventExt for UnsignedEvent { impl EventExt for UnsignedEvent {
fn uniq_id(&self) -> u64 { fn uniq_id(&self) -> u64 {
let mut hasher = DefaultHasher::new(); pubkey_set_uniq_id(&self.extract_public_keys())
let mut pubkeys: Vec<PublicKey> = vec![];
// Add all public keys from event
pubkeys.push(self.pubkey);
pubkeys.extend(self.tags.public_keys().collect::<Vec<_>>());
// Generate unique hash
pubkeys
.into_iter()
.unique()
.sorted()
.collect::<Vec<_>>()
.hash(&mut hasher);
hasher.finish()
} }
fn extract_public_keys(&self) -> Vec<PublicKey> { fn extract_public_keys(&self) -> Vec<PublicKey> {
let mut public_keys: Vec<PublicKey> = self.tags.public_keys().collect(); let mut public_keys: Vec<PublicKey> = self.tags.public_keys().collect();
public_keys.push(self.pubkey); public_keys.push(self.pubkey);
public_keys.into_iter().unique().sorted().collect() 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<String> = 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 /// Custom emoji (NIP-30) declared by `emoji` tags, first per shortcode wins
pub fn emojis_from_tags(tags: &Tags) -> Vec<(String, Url)> { pub fn emojis_from_tags(tags: &Tags) -> Vec<(String, Url)> {
let mut emojis: Vec<(String, Url)> = Vec::new(); let mut emojis: Vec<(String, Url)> = Vec::new();
@@ -100,4 +129,51 @@ mod tests {
Url::parse("https://example.com/soapbox.png").unwrap() Url::parse("https://example.com/soapbox.png").unwrap()
); );
} }
fn event_with_participants(public_key: PublicKey, participants: Vec<PublicKey>) -> 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"]));
}
} }
+141 -45
View File
@@ -1,65 +1,161 @@
use std::sync::LazyLock;
use gpui::SharedUri; use gpui::SharedUri;
use regex::Regex; use regex::Regex;
/// Extracts media URLs from a string and returns both /// Matches image URLs with common image extensions
/// the extracted URLs and the string with media URLs removed static IMAGE_RE: LazyLock<Regex> = LazyLock::new(|| {
struct MediaExtractor { Regex::new(
image_regex: Regex, r#"(?i)\bhttps?://[^\s<>"']+\.(?:jpg|jpeg|png|gif|bmp|webp|svg|ico)(?:\?[^\s<>"']*)?\b"#,
} )
.unwrap()
});
/// Extracts media URLs from a string and returns the URLs and the cleaned text
struct MediaExtractor;
impl MediaExtractor { impl MediaExtractor {
/// Creates a new MediaExtractor with compiled regex patterns
fn new() -> Self {
MediaExtractor {
// Match common image extensions
image_regex: Regex::new(
r#"(?i)\bhttps?://[^\s<>"']+\.(?:jpg|jpeg|png|gif|bmp|webp|svg|ico)(?:\?[^\s<>"']*)?\b"#,
).unwrap(),
}
}
/// Extracts all media URLs from a string /// Extracts all media URLs from a string
fn extract_media_urls(&self, text: &str) -> Vec<SharedUri> { fn extract_media_urls(text: &str) -> Vec<SharedUri> {
let mut urls = Vec::new(); IMAGE_RE
.find_iter(text)
// Extract image URLs .map(|capture| capture.as_str().to_string().into())
for capture in self.image_regex.find_iter(text) { .collect()
urls.push(capture.as_str().to_string().into());
}
urls
} }
/// Removes all media URLs from a string and returns the cleaned text /// Removes all media URLs from a string and returns the cleaned text
fn remove_media_urls(&self, text: &str) -> String { fn remove_media_urls(text: &str) -> String {
let mut result = text.to_string(); let mut cleaned = String::with_capacity(text.len());
let mut last_end = 0;
// Whitespace bordered a removed URL, so one space stands in for it
let mut spaced = false;
// Remove image URLs for url in IMAGE_RE.find_iter(text) {
result = self.image_regex.replace_all(&result, "").to_string(); let raw_gap = &text[last_end..url.start()];
let surrounded = raw_gap.ends_with(char::is_whitespace)
|| text[url.end()..].starts_with(char::is_whitespace);
// Clean up extra whitespace that might result from removal let gap = if last_end == 0 {
self.cleanup_text(&result) raw_gap.trim_end()
} else {
raw_gap.trim()
};
if !gap.is_empty() {
if spaced && !cleaned.is_empty() {
cleaned.push(' ');
}
cleaned.push_str(gap);
}
spaced |= surrounded;
last_end = url.end();
}
let tail = text[last_end..].trim_start();
if spaced && !cleaned.is_empty() && !tail.is_empty() {
cleaned.push(' ');
}
cleaned.push_str(tail);
cleaned.trim().to_string()
} }
/// Extracts media URLs and removes them from the string, returning both /// Extracts media URLs and removes them from the string, returning both
fn extract_and_remove(&self, text: &str) -> (Vec<SharedUri>, String) { fn extract_and_remove(text: &str) -> (Vec<SharedUri>, String) {
let urls = self.extract_media_urls(text); (
let cleaned_text = self.remove_media_urls(text); Self::extract_media_urls(text),
(urls, cleaned_text) Self::remove_media_urls(text),
} )
/// Helper function to clean up text after URL removal
fn cleanup_text(&self, text: &str) -> String {
let text = text.trim();
// Remove multiple consecutive spaces
let re = Regex::new(r"\s+").unwrap();
re.replace_all(text, " ").trim().to_string()
} }
} }
/// Convenience function for one-time extraction and removal /// Convenience function for one-time extraction and removal
pub fn extract_and_remove_media_urls(text: &str) -> (Vec<SharedUri>, String) { pub fn extract_and_remove_media_urls(text: &str) -> (Vec<SharedUri>, String) {
let extractor = MediaExtractor::new(); MediaExtractor::extract_and_remove(text)
extractor.extract_and_remove(text) }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn multi_line_text_without_urls_keeps_line_breaks() {
let text = "first line\n\nsecond line";
let (urls, cleaned) = extract_and_remove_media_urls(text);
assert!(urls.is_empty());
assert_eq!(cleaned, text);
}
#[test]
fn removes_url_and_tidies_around_it() {
let (urls, cleaned) =
extract_and_remove_media_urls("check https://example.com/pic.png here");
assert_eq!(urls, vec![SharedUri::from("https://example.com/pic.png")]);
assert_eq!(cleaned, "check here");
}
#[test]
fn removing_url_keeps_other_line_breaks() {
let (urls, cleaned) = extract_and_remove_media_urls(
"first\nsecond https://example.com/pic.png third\nfourth",
);
assert_eq!(urls.len(), 1);
assert_eq!(cleaned, "first\nsecond third\nfourth");
}
#[test]
fn url_on_its_own_line_joins_neighbors_with_one_space() {
let (urls, cleaned) =
extract_and_remove_media_urls("first\n\nhttps://example.com/pic.png\n\nlast");
assert_eq!(urls.len(), 1);
assert_eq!(cleaned, "first last");
}
#[test]
fn multiple_urls_collapse_to_single_spaces() {
let (urls, cleaned) = extract_and_remove_media_urls(
"a https://example.com/a.png b https://example.com/b.png c",
);
assert_eq!(urls.len(), 2);
assert_eq!(cleaned, "a b c");
}
#[test]
fn message_with_only_a_url_becomes_empty() {
let (urls, cleaned) = extract_and_remove_media_urls("https://example.com/pic.png");
assert_eq!(urls.len(), 1);
assert_eq!(cleaned, "");
}
#[test]
fn digits_glued_to_url_scheme_prevent_match() {
let text = "v1.2https://example.com/pic.png";
let (urls, cleaned) = extract_and_remove_media_urls(text);
assert!(urls.is_empty());
assert_eq!(cleaned, text);
}
#[test]
fn digits_around_removed_url_survive() {
let (urls, cleaned) = extract_and_remove_media_urls("123 https://example.com/pic.png 456");
assert_eq!(urls.len(), 1);
assert_eq!(cleaned, "123 456");
}
#[test]
fn emoji_around_removed_url_survives() {
let (urls, cleaned) = extract_and_remove_media_urls("👍 https://example.com/pic.png 👍");
assert_eq!(urls.len(), 1);
assert_eq!(cleaned, "👍 👍");
}
} }
+36 -42
View File
@@ -184,20 +184,11 @@ impl Iterator for FindMatches<'_> {
} }
} }
enum HandleMatch {
Token(Token),
Recursion,
}
pub struct NostrParserIter<'a> { pub struct NostrParserIter<'a> {
/// The original text /// The original text
text: &'a str, text: &'a str,
/// Matches found /// Matches found
matches: FindMatches<'a>, matches: FindMatches<'a>,
/// A pending match
pending_match: Option<Match>,
/// Last match end index
last_match_end: usize,
} }
impl<'a> NostrParserIter<'a> { impl<'a> NostrParserIter<'a> {
@@ -205,62 +196,65 @@ impl<'a> NostrParserIter<'a> {
Self { Self {
text, text,
matches: FindMatches::new(text), matches: FindMatches::new(text),
pending_match: None,
last_match_end: 0,
} }
} }
fn handle_match(&mut self, mat: Match) -> HandleMatch { fn handle_match(&self, mat: Match) -> Option<Token> {
// Update last match end
self.last_match_end = mat.end;
// Extract the matched string // Extract the matched string
let data: &str = &self.text[mat.start..mat.end]; let data: &str = &self.text[mat.start..mat.end];
// Parse NIP-21 URI // Parse NIP-21 URI
match Nip21::parse(data) { match Nip21::parse(data) {
Ok(uri) => HandleMatch::Token(Token { Ok(uri) => Some(Token {
value: uri, value: uri,
range: mat.start..mat.end, range: mat.start..mat.end,
}), }),
// If the nostr URI parsing is invalid, skip it // 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; type Item = Token;
fn next(&mut self) -> Option<Self::Item> { fn next(&mut self) -> Option<Self::Item> {
// Handle a pending match // Iterative, not recursive: message text controls the match count
if let Some(pending_match) = self.pending_match.take() { loop {
return match self.handle_match(pending_match) { let mat = self.matches.next()?;
HandleMatch::Token(token) => Some(token),
HandleMatch::Recursion => self.next(), if let Some(token) = self.handle_match(mat) {
}; return Some(token);
}
}
}
} }
match self.matches.next() { #[cfg(test)]
Some(mat) => { mod tests {
// Skip any text before this match use super::*;
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 // A long run of scanner matches that fail `Nip21::parse`: the count is
self.last_match_end = mat.start; // attacker-controlled, so it must be consumed iteratively.
return self.next(); #[test]
fn skips_long_run_of_invalid_uris() {
let content = "nostr:1q".repeat(50_000);
let tokens: Vec<Token> = NostrParser::new().parse(&content).collect();
assert!(tokens.is_empty());
} }
// Handle match #[test]
match self.handle_match(mat) { fn yields_valid_uri_after_invalid_run() {
HandleMatch::Token(token) => Some(token), let public_key = Keys::new(SecretKey::generate()).public_key();
HandleMatch::Recursion => self.next(), let npub = public_key.to_bech32().unwrap();
} let content = format!("{}nostr:{npub}", "nostr:1q".repeat(50_000));
}
None => None, let tokens: Vec<Token> = 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));
} }
} }
-1
View File
@@ -11,7 +11,6 @@ futures.workspace = true
serde_json.workspace = true serde_json.workspace = true
state = { path = "../state" } state = { path = "../state" }
common = { path = "../common" } common = { path = "../common" }
person = { path = "../person" }
chat = { path = "../chat" } chat = { path = "../chat" }
gpui.workspace = true gpui.workspace = true
+16 -3
View File
@@ -13,7 +13,7 @@ use crate::LOCAL_KEYS;
use crate::protocol::{ use crate::protocol::{
ACTIVITY_KINDS, Activity, GroupAdmins, GroupId, GroupKey, GroupMembers, GroupMetadata, ACTIVITY_KINDS, Activity, GroupAdmins, GroupId, GroupKey, GroupMembers, GroupMetadata,
GroupPins, GroupRoles, MEMBERSHIP_KINDS, Membership, PIN_LIST, Pin, RENDER_KINDS, STATE_KINDS, GroupPins, GroupRoles, MEMBERSHIP_KINDS, Membership, PIN_LIST, Pin, RENDER_KINDS, STATE_KINDS,
TimelineWindow, tag_value, TimelineWindow, is_newer_event, tag_value,
}; };
const REPLAY_LIMIT: usize = 100; const REPLAY_LIMIT: usize = 100;
@@ -91,7 +91,8 @@ pub struct Group {
membership: Membership, membership: Membership,
membership_events: Vec<Event>, membership_events: Vec<Event>,
elsewhere: Option<GroupKey>, elsewhere: Option<GroupKey>,
state_ids: HashMap<Kind, EventId>, /// The last accepted (created_at, id) per state kind.
state_ids: HashMap<Kind, (Timestamp, EventId)>,
rows: Vec<Row>, rows: Vec<Row>,
index: HashSet<EventId>, index: HashSet<EventId>,
reactions: HashMap<EventId, Vec<Reaction>>, reactions: HashMap<EventId, Vec<Reaction>>,
@@ -694,6 +695,15 @@ impl Group {
} }
fn absorb_state(&mut self, event: &Event, me: PublicKey) -> bool { fn absorb_state(&mut self, event: &Event, me: PublicKey) -> bool {
// Relays deliver same-kind state events in any order.
if !is_newer_event(
self.state_ids.get(&event.kind).copied(),
event.created_at,
event.id,
) {
return false;
}
match event.kind { match event.kind {
Kind::GroupMetadata => match GroupMetadata::parse(event) { Kind::GroupMetadata => match GroupMetadata::parse(event) {
Ok(metadata) => self.metadata = Some(metadata), Ok(metadata) => self.metadata = Some(metadata),
@@ -724,7 +734,10 @@ impl Group {
_ => return false, _ => return false,
} }
self.state_ids.insert(event.kind, event.id) != Some(event.id) self.state_ids
.insert(event.kind, (event.created_at, event.id));
true
} }
fn adopt_roster_membership(&mut self, me: PublicKey) -> bool { fn adopt_roster_membership(&mut self, me: PublicKey) -> bool {
+37 -11
View File
@@ -76,6 +76,8 @@ pub struct GroupsRegistry {
groups: Vec<Entity<Group>>, groups: Vec<Entity<Group>>,
previews: HashMap<GroupKey, Entity<Group>>, previews: HashMap<GroupKey, Entity<Group>>,
list: GroupList, list: GroupList,
/// The (created_at, id) of the last accepted group list event.
list_stamp: Option<(Timestamp, EventId)>,
browse: HashMap<SubscriptionId, RelayUrl>, browse: HashMap<SubscriptionId, RelayUrl>,
routes: HashMap<SubscriptionId, GroupKey>, routes: HashMap<SubscriptionId, GroupKey>,
observers: HashMap<GroupKey, Subscription>, observers: HashMap<GroupKey, Subscription>,
@@ -132,6 +134,7 @@ impl GroupsRegistry {
groups: Vec::new(), groups: Vec::new(),
previews: HashMap::new(), previews: HashMap::new(),
list: GroupList::default(), list: GroupList::default(),
list_stamp: None,
browse: HashMap::new(), browse: HashMap::new(),
routes: HashMap::new(), routes: HashMap::new(),
observers: HashMap::new(), observers: HashMap::new(),
@@ -455,6 +458,8 @@ impl GroupsRegistry {
self.fork_authors.clear(); self.fork_authors.clear();
self.syncing.clear(); self.syncing.clear();
self.list = GroupList::default(); self.list = GroupList::default();
// A new signer's list events must not be judged against the old stamp.
self.list_stamp = None;
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
@@ -488,24 +493,26 @@ impl GroupsRegistry {
let signer = nostr.read(cx).signer(); let signer = nostr.read(cx).signer();
// Show the cached groups before the relays answer. // Show the cached groups before the relays answer.
let task: Task<Result<GroupList, Error>> = cx.background_spawn(async move { let task: Task<Result<(GroupList, Option<Event>), Error>> =
cx.background_spawn(async move {
let me = signer.get_public_key_async().await?; let me = signer.get_public_key_async().await?;
let filter = Filter::new().author(me).kind(Kind::SimpleGroups).limit(1); let filter = Filter::new().author(me).kind(Kind::SimpleGroups).limit(1);
match client.database().query(filter).await?.into_iter().next() { match client.database().query(filter).await?.into_iter().next() {
Some(event) => { Some(event) => {
let list = GroupList::parse(&event)?; let list =
Ok(Self::hydrate_list(list, &signer, &me).await) Self::hydrate_list(GroupList::parse(&event)?, &signer, &me).await;
Ok((list, Some(event)))
} }
None => Ok(GroupList::default()), None => Ok((GroupList::default(), None)),
} }
}); });
self.tasks self.tasks
.push(cx.spawn(async move |this, cx| match task.await { .push(cx.spawn(async move |this, cx| match task.await {
Ok(list) => { Ok((list, event)) => {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.merge(list, cx); this.merge(list, event.as_ref(), cx);
}) })
.ok(); .ok();
} }
@@ -538,7 +545,16 @@ impl GroupsRegistry {
self.watch_forks(cx); self.watch_forks(cx);
} }
fn merge(&mut self, list: GroupList, cx: &mut Context<Self>) { fn merge(&mut self, list: GroupList, event: Option<&Event>, cx: &mut Context<Self>) {
if let Some(event) = event {
// Relays replay list events out of order; only strictly newer applies.
if !is_newer_event(self.list_stamp, event.created_at, event.id) {
return;
}
self.list_stamp = Some((event.created_at, event.id));
}
// Carry the event's private section so a later publish can preserve it. // Carry the event's private section so a later publish can preserve it.
self.list self.list
.set_content(list.content().map(str::to_owned), list.content_readable()); .set_content(list.content().map(str::to_owned), list.content_readable());
@@ -589,20 +605,21 @@ impl GroupsRegistry {
}; };
if list.content().is_none() { if list.content().is_none() {
self.merge(list, cx); self.merge(list, Some(&event), cx);
return; return;
} }
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let signer = nostr.read(cx).signer(); let signer = nostr.read(cx).signer();
let Some(me) = nostr.read(cx).current_user() else { let Some(me) = nostr.read(cx).current_user() else {
self.merge(list, cx); self.merge(list, Some(&event), cx);
return; return;
}; };
self.tasks.push(cx.spawn(async move |this, cx| { self.tasks.push(cx.spawn(async move |this, cx| {
let list = Self::hydrate_list(list, &signer, &me).await; let list = Self::hydrate_list(list, &signer, &me).await;
this.update(cx, |this, cx| this.merge(list, cx)).ok(); this.update(cx, |this, cx| this.merge(list, Some(&event), cx))
.ok();
})); }));
} }
@@ -864,7 +881,7 @@ impl GroupsRegistry {
let me = nostr.read(cx).current_user(); let me = nostr.read(cx).current_user();
let list = self.list.clone(); let list = self.list.clone();
self.tasks.push(cx.spawn(async move |_this, _cx| { self.tasks.push(cx.spawn(async move |this, cx| {
// Re-encrypt the private section when it carries local entries; a // Re-encrypt the private section when it carries local entries; a
// section this session could not read is carried verbatim. // section this session could not read is carried verbatim.
let content = match (list.has_private(), list.content_readable()) { let content = match (list.has_private(), list.content_readable()) {
@@ -893,6 +910,15 @@ impl GroupsRegistry {
} }
}; };
// Our own publish is the newest list until a newer event arrives.
if let Err(error) = this.update(cx, |this, _cx| {
if is_newer_event(this.list_stamp, event.created_at, event.id) {
this.list_stamp = Some((event.created_at, event.id));
}
}) {
log::warn!("recording the published list failed: {error}");
}
match client.send_event(&event).to_nip65().await { match client.send_event(&event).to_nip65().await {
Ok(output) if output.failed.is_empty() => {} Ok(output) if output.failed.is_empty() => {}
Ok(output) => { Ok(output) => {
+26 -5
View File
@@ -1,10 +1,9 @@
use std::collections::HashSet; use std::collections::HashSet;
use std::collections::hash_map::DefaultHasher;
use std::fmt; use std::fmt;
use std::hash::{Hash, Hasher};
use std::str::FromStr; use std::str::FromStr;
use anyhow::{Result, anyhow, bail}; use anyhow::{Result, anyhow, bail};
use common::stable_id_u64;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
pub(crate) const TIMELINE_WINDOW: usize = 50; pub(crate) const TIMELINE_WINDOW: usize = 50;
@@ -50,6 +49,15 @@ pub(crate) fn tag_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> {
.and_then(|tag| tag.content()) .and_then(|tag| tag.content())
} }
/// Whether `(created_at, id)` is strictly newer than the last accepted stamp.
pub(crate) fn is_newer_event(
accepted: Option<(Timestamp, EventId)>,
created_at: Timestamp,
id: EventId,
) -> bool {
accepted.is_none_or(|last| (created_at, id) > last)
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct GroupId(String); pub struct GroupId(String);
@@ -280,9 +288,7 @@ impl GroupKey {
} }
pub fn uniq_id(&self) -> u64 { pub fn uniq_id(&self) -> u64 {
let mut hasher = DefaultHasher::new(); stable_id_u64(&[self.relay.as_str(), self.id.as_str()])
self.hash(&mut hasher);
hasher.finish()
} }
} }
@@ -1152,4 +1158,19 @@ mod tests {
let malformed = Tag::custom("previous", ["nothex"]); let malformed = Tag::custom("previous", ["nothex"]);
assert_eq!(window.references_seen(Some(&malformed)), Some(false)); assert_eq!(window.references_seen(Some(&malformed)), Some(false));
} }
#[test]
fn is_newer_event_accepts_only_strictly_newer_events() {
let lower = EventId::from_byte_array([0x00; 32]);
let higher = EventId::from_byte_array([0xff; 32]);
let earlier = Timestamp::from(100);
let later = Timestamp::from(200);
assert!(is_newer_event(None, earlier, lower));
assert!(!is_newer_event(Some((earlier, lower)), earlier, lower));
assert!(is_newer_event(Some((earlier, lower)), later, lower));
assert!(!is_newer_event(Some((later, lower)), earlier, higher));
assert!(is_newer_event(Some((earlier, lower)), earlier, higher));
assert!(!is_newer_event(Some((earlier, higher)), earlier, lower));
}
} }
+5 -6
View File
@@ -1,7 +1,6 @@
use std::collections::HashSet; 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 nostr_sdk::prelude::*;
use crate::{GroupId, GroupKey}; use crate::{GroupId, GroupKey};
@@ -25,10 +24,10 @@ pub(crate) fn subscription_id(key: &GroupKey) -> SubscriptionId {
} }
pub(crate) fn browse_subscription(relay: &RelayUrl) -> SubscriptionId { pub(crate) fn browse_subscription(relay: &RelayUrl) -> SubscriptionId {
let mut hasher = DefaultHasher::new(); SubscriptionId::new(format!(
relay.hash(&mut hasher); "{BROWSE_PREFIX}{:08x}",
stable_id_u64(&[relay.as_str()]) as u32
SubscriptionId::new(format!("{BROWSE_PREFIX}{:08x}", hasher.finish() as u32)) ))
} }
pub(crate) fn page_subscription(key: &GroupKey) -> SubscriptionId { pub(crate) fn page_subscription(key: &GroupKey) -> SubscriptionId {
+2 -2
View File
@@ -1065,7 +1065,8 @@ impl GroupPanel {
cx, cx,
) )
}) })
.element(ix.into(), window, cx), // Key by event id: parse state is kept per element id and indices shift on prepend
.element(SharedString::from(message.id.to_hex()).into(), window, cx),
}; };
let admin = nostr let admin = nostr
@@ -1077,7 +1078,6 @@ impl GroupPanel {
let pinned = group.pins().pins().contains(&Pin::Event(message.id)); let pinned = group.pins().pins().contains(&Pin::Event(message.id));
message::render( message::render(
ix,
message, message,
content, content,
show_author, show_author,
+4 -4
View File
@@ -35,7 +35,6 @@ pub(crate) struct Actions {
} }
pub(crate) fn render( pub(crate) fn render(
ix: usize,
message: &Message, message: &Message,
content: AnyElement, content: AnyElement,
show_author: bool, show_author: bool,
@@ -60,7 +59,8 @@ pub(crate) fn render(
nostr.read(cx).current_user(), nostr.read(cx).current_user(),
); );
let row = MessageRow::new(ix) // Key by event id: indices shift on prepend and would re-key descendants' state
let row = MessageRow::new(SharedString::from(message.id.to_hex()))
.show_author(show_author) .show_author(show_author)
.hide_avatar(hide_avatar) .hide_avatar(hide_avatar)
.avatar( .avatar(
@@ -337,7 +337,7 @@ fn replies(
let mut items = Vec::with_capacity(targets.len()); let mut items = Vec::with_capacity(targets.len());
for (ix, id) in targets.iter().enumerate() { for id in targets.iter() {
let context = entity.read_with(cx, |group, cx| { let context = entity.read_with(cx, |group, cx| {
let message = group.message(*id)?; let message = group.message(*id)?;
let author = persons.read(cx).person(&message.author, cx); let author = persons.read(cx).person(&message.author, cx);
@@ -356,7 +356,7 @@ fn replies(
let panel = panel.clone(); let panel = panel.clone();
items.push( items.push(
ReplyPreview::new(ix, author, preview) ReplyPreview::new(SharedString::from(format!("reply-{id}")), author, preview)
.on_click(move |_event, _window, cx| { .on_click(move |_event, _window, cx| {
if let Err(error) = panel.update(cx, |panel, cx| { if let Err(error) = panel.update(cx, |panel, cx| {
panel.scroll_to(&id, cx); panel.scroll_to(&id, cx);
+72 -20
View File
@@ -83,6 +83,8 @@ pub struct DeviceRegistry {
resync: Arc<AtomicBool>, resync: Arc<AtomicBool>,
/// Whether a key rotation is in flight, to coalesce overlapping requests /// Whether a key rotation is in flight, to coalesce overlapping requests
rotating: bool, 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 /// Person directory for requesters shown in the approval dialog
directory: Entity<Directory>, directory: Entity<Directory>,
/// Relay notification listener /// Relay notification listener
@@ -155,6 +157,7 @@ impl DeviceRegistry {
announcement_existed: Arc::new(AtomicBool::new(false)), announcement_existed: Arc::new(AtomicBool::new(false)),
resync: Arc::new(AtomicBool::new(false)), resync: Arc::new(AtomicBool::new(false)),
rotating: false, rotating: false,
approving: false,
directory: Directory::new(cx), directory: Directory::new(cx),
notification_listener: None, notification_listener: None,
tasks: vec![], tasks: vec![],
@@ -186,22 +189,31 @@ impl DeviceRegistry {
continue; continue;
} }
match event.kind { let relevant = matches!(
Kind::Custom(10044) if current_user == Some(event.pubkey) => { 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); 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?; let event_id = event.id;
} // Dropping keeps the listener alive when the consumer is gone;
Kind::Custom(4455) if current_user == Some(event.pubkey) => { // missed 4e events are re-synced on the next signer change
tx.send_async(event.into_owned()).await?; 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(()) Ok(())
})); }));
@@ -399,10 +411,16 @@ impl DeviceRegistry {
self.tasks.push(cx.spawn(async move |this, cx| { self.tasks.push(cx.spawn(async move |this, cx| {
let result: Result<Keys, Error> = async { let result: Result<Keys, Error> = async {
// Archive the current key before replacing it // Archive the current key before replacing it; a failed load or
if let Ok(old) = get_keys(&client, &signer).await { // archive aborts the rotation so the old key is never overwritten
archive_key(&client, &signer, &old.secret_key().to_secret_hex()).await?; // 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 let created = this
.update(cx, |this, cx| this.create_encryption(Keys::generate(), cx))? .update(cx, |this, cx| this.create_encryption(Keys::generate(), cx))?
@@ -772,12 +790,21 @@ impl DeviceRegistry {
/// Approve requests for device keys from other devices /// Approve requests for device keys from other devices
fn approve(&mut self, event: &Event, window: &mut Window, cx: &mut Context<Self>) { fn approve(&mut self, event: &Event, window: &mut Window, cx: &mut Context<Self>) {
// 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 nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
let signer = nostr.read(cx).signer(); let signer = nostr.read(cx).signer();
let event = event.clone();
let task: Task<Result<(), Error>> = cx.background_spawn(async move { let task: Task<Result<(), Error>> = cx.background_spawn(async move {
// Get device keys // Get device keys
let keys = get_keys(&client, &signer).await?; let keys = get_keys(&client, &signer).await?;
@@ -834,17 +861,41 @@ impl DeviceRegistry {
self.tasks.push(cx.spawn_in(window, async move |this, cx| { self.tasks.push(cx.spawn_in(window, async move |this, cx| {
match task.await { match task.await {
Ok(_) => { Ok(_) => {
this.update(cx, |this, cx| { let superseded = this.update(cx, |this, cx| {
this.pending_request = None; this.approving = false;
cx.notify(); 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
}
})?; })?;
if !superseded {
cx.update(|window, cx| { cx.update(|window, cx| {
window.remove_notification::<DeviceNotification>(cx); window.remove_notification::<DeviceNotification>(cx);
}) })
.ok(); .ok();
} }
}
Err(e) => { 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| { cx.update(|window, cx| {
window.push_notification( window.push_notification(
Notification::error(e.to_string()).autohide(false), Notification::error(e.to_string()).autohide(false),
@@ -1032,9 +1083,10 @@ impl DeviceRegistry {
.on_click({ .on_click({
let loading = Rc::clone(&loading); let loading = Rc::clone(&loading);
move |_ev, window, cx| { move |_ev, window, cx| {
// Set loading state to true
loading.set(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| { view.update(cx, |this, cx| {
this.approve(&event, window, cx); this.approve(&event, window, cx);
}) })
-1
View File
@@ -20,7 +20,6 @@ serde_json.workspace = true
futures.workspace = true futures.workspace = true
hkdf.workspace = true hkdf.workspace = true
sha2.workspace = true sha2.workspace = true
tokio = { version = "1", default-features = false, features = [ "rt" ] }
cdk.workspace = true cdk.workspace = true
cdk-redb.workspace = true cdk-redb.workspace = true
+72 -44
View File
@@ -31,7 +31,6 @@ impl Global for GlobalWalletRegistry {}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum WalletEvent { pub enum WalletEvent {
Updated, Updated,
TokenCreated(String),
Error(String), Error(String),
} }
@@ -55,6 +54,9 @@ pub struct HistoryEntry {
pub created_at: Timestamp, pub created_at: Timestamp,
} }
/// How many times a failed nutzap redemption is retried before giving up
const MAX_REDEEM_ATTEMPTS: u32 = 3;
pub struct WalletRegistry { pub struct WalletRegistry {
wallet: Option<keys::WalletData>, wallet: Option<keys::WalletData>,
tokens: HashMap<EventId, TokenEvent>, tokens: HashMap<EventId, TokenEvent>,
@@ -69,6 +71,10 @@ pub struct WalletRegistry {
pending_nutzaps: Vec<Event>, pending_nutzaps: Vec<Event>,
/// Nutzaps with a redemption job in flight /// Nutzaps with a redemption job in flight
redeeming: HashSet<EventId>, redeeming: HashSet<EventId>,
/// Redemption attempts per nutzap, reset on success
redeem_attempts: HashMap<EventId, u32>,
/// A job errored after possibly mutating the store
publish_failed: bool,
store: Option<Arc<WalletRedbDatabase>>, store: Option<Arc<WalletRedbDatabase>>,
wallets: HashMap<MintUrl, Wallet>, wallets: HashMap<MintUrl, Wallet>,
loading: bool, loading: bool,
@@ -119,6 +125,8 @@ impl WalletRegistry {
redeemed: HashSet::new(), redeemed: HashSet::new(),
pending_nutzaps: Vec::new(), pending_nutzaps: Vec::new(),
redeeming: HashSet::new(), redeeming: HashSet::new(),
redeem_attempts: HashMap::new(),
publish_failed: false,
store: None, store: None,
wallets: HashMap::new(), wallets: HashMap::new(),
loading: false, loading: false,
@@ -184,34 +192,14 @@ impl WalletRegistry {
); );
} }
/// Pay a lightning invoice from the wallet. /// Drop a pending invoice at the user's request.
pub fn melt(&mut self, invoice: String, cx: &mut Context<Self>) { pub fn dismiss_mint_quote(&mut self, quote_id: String, cx: &mut Context<Self>) {
let Some(mint_url) = self.primary_mint(cx) else { if !self.quotes.iter().any(|quote| quote.quote_id == quote_id) {
return; return;
};
self.schedule_job(
ops::Job::Melt {
mint: mint_url,
invoice,
},
cx,
);
} }
/// Send ecash as a token string the recipient can paste anywhere. self.quotes.retain(|quote| quote.quote_id != quote_id);
pub fn send_token(&mut self, amount: u64, cx: &mut Context<Self>) { self.emit_updated(cx);
let Some(mint_url) = self.primary_mint(cx) else {
return;
};
self.schedule_job(
ops::Job::SendToken {
mint: mint_url,
amount,
},
cx,
);
} }
/// Send a NIP-61 nutzap to a peer, optionally nutzapping one of their events. /// Send a NIP-61 nutzap to a peer, optionally nutzapping one of their events.
@@ -378,6 +366,8 @@ impl WalletRegistry {
self.redeemed.clear(); self.redeemed.clear();
self.pending_nutzaps.clear(); self.pending_nutzaps.clear();
self.redeeming.clear(); self.redeeming.clear();
self.redeem_attempts.clear();
self.publish_failed = false;
self.store = None; self.store = None;
self.wallets.clear(); self.wallets.clear();
self.loading = false; self.loading = false;
@@ -397,6 +387,7 @@ impl WalletRegistry {
let signer = nostr.read(cx).signer().clone(); let signer = nostr.read(cx).signer().clone();
let (tx, rx) = flume::bounded::<Event>(100); let (tx, rx) = flume::bounded::<Event>(100);
let consumer_signer = signer.clone();
self.notification_listener = Some(cx.background_spawn(async move { self.notification_listener = Some(cx.background_spawn(async move {
const MAX_PROCESSED: usize = 1000; const MAX_PROCESSED: usize = 1000;
@@ -417,7 +408,7 @@ impl WalletRegistry {
| Kind::CashuWalletSpendingHistory | Kind::CashuWalletSpendingHistory
); );
let wallet_event = ours && is_cashu || is_token_deletion(&event); let wallet_event = ours && (is_cashu || is_token_deletion(&event));
let nutzap = event.kind == Kind::Custom(9321) let nutzap = event.kind == Kind::Custom(9321)
&& me.is_some_and(|me| event.tags.public_keys().any(|key| key == me)); && me.is_some_and(|me| event.tags.public_keys().any(|key| key == me));
@@ -443,21 +434,22 @@ impl WalletRegistry {
})); }));
self.tasks.push(cx.spawn(async move |this, cx| { self.tasks.push(cx.spawn(async move |this, cx| {
let me = consumer_signer.get_public_key_async().await.ok();
while let Ok(event) = rx.recv_async().await { while let Ok(event) = rx.recv_async().await {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.absorb(event, cx); this.absorb(event, me, cx);
}) })
.ok(); .ok();
} }
})); }));
} }
fn absorb(&mut self, event: Event, cx: &mut Context<Self>) { fn absorb(&mut self, event: Event, me: Option<PublicKey>, cx: &mut Context<Self>) {
match event.kind { match event.kind {
Kind::CashuWallet => self.absorb_wallet(event, cx), Kind::CashuWallet => self.absorb_wallet(event, cx),
Kind::CashuWalletUnspentProof => self.absorb_token(event, cx), Kind::CashuWalletUnspentProof => self.absorb_token(event, cx),
Kind::CashuWalletSpendingHistory => self.absorb_history(event, cx), Kind::CashuWalletSpendingHistory => self.absorb_history(event, cx),
Kind::EventDeletion => self.absorb_deletion(event, cx), Kind::EventDeletion => self.absorb_deletion(event, me, cx),
Kind::Custom(9321) => self.absorb_nutzap(event, cx), Kind::Custom(9321) => self.absorb_nutzap(event, cx),
_ => {} _ => {}
} }
@@ -549,7 +541,15 @@ impl WalletRegistry {
})); }));
} }
fn absorb_deletion(&mut self, event: Event, cx: &mut Context<Self>) { fn absorb_deletion(&mut self, event: Event, me: Option<PublicKey>, cx: &mut Context<Self>) {
if me != Some(event.pubkey) {
log::warn!(
"ignoring kind 5 deletion {} from a foreign author",
event.id
);
return;
}
let mut removed_any = false; let mut removed_any = false;
for tag in event.tags.iter() { for tag in event.tags.iter() {
if tag.kind() != "e" { if tag.kind() != "e" {
@@ -807,6 +807,9 @@ impl WalletRegistry {
self.job_running = true; self.job_running = true;
let job_handle = job.clone();
let publish_failed = self.publish_failed;
self.tasks.push(cx.spawn(async move |this, cx| { self.tasks.push(cx.spawn(async move |this, cx| {
let result = Tokio::spawn_result(cx, async move { let result = Tokio::spawn_result(cx, async move {
let ctx = ops::JobCtx { let ctx = ops::JobCtx {
@@ -815,6 +818,7 @@ impl WalletRegistry {
signer, signer,
client, client,
tokens, tokens,
publish_failed,
}; };
job.run(ctx).await job.run(ctx).await
@@ -823,7 +827,7 @@ impl WalletRegistry {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.job_running = false; this.job_running = false;
this.apply_outcome(result, cx); this.apply_outcome(job_handle, result, cx);
for job in std::mem::take(&mut this.job_queued) { for job in std::mem::take(&mut this.job_queued) {
this.schedule_job(job, cx); this.schedule_job(job, cx);
@@ -833,18 +837,51 @@ impl WalletRegistry {
})); }));
} }
fn apply_outcome(&mut self, result: Result<ops::Outcome, Error>, cx: &mut Context<Self>) { fn apply_outcome(
&mut self,
job: ops::Job,
result: Result<ops::Outcome, Error>,
cx: &mut Context<Self>,
) {
let outcome = match result { let outcome = match result {
Ok(outcome) => outcome, Ok(outcome) => outcome,
Err(error) => { Err(error) => {
if !matches!(job, ops::Job::CreateQuote { .. }) {
self.publish_failed = true;
}
if !matches!(job, ops::Job::CreateQuote { .. } | ops::Job::Reconcile) {
self.schedule_job(ops::Job::Reconcile, cx);
}
if let ops::Job::RedeemNutzap { nutzap_id, .. } = &job {
self.redeeming.remove(nutzap_id);
let attempts = self.redeem_attempts.entry(*nutzap_id).or_default();
*attempts += 1;
if *attempts <= MAX_REDEEM_ATTEMPTS {
self.job_queued.push(job.clone());
} else {
log::warn!(
"giving up redeeming nutzap {nutzap_id} after {MAX_REDEEM_ATTEMPTS} attempts"
);
}
}
self.emit_error(format!("{error:#}"), cx); self.emit_error(format!("{error:#}"), cx);
return; return;
} }
}; };
match outcome { 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 } => { ops::Outcome::Reconciled { updates } => {
self.publish_failed = false;
let changed = !updates.is_empty(); let changed = !updates.is_empty();
for update in updates { for update in updates {
@@ -879,16 +916,6 @@ impl WalletRegistry {
self.emit_updated(cx); self.emit_updated(cx);
log::info!("minted {amount} sats from quote {quote_id}"); log::info!("minted {amount} sats from quote {quote_id}");
} }
ops::Outcome::Melted { update, amount } => {
self.apply_token_update(update);
self.emit_updated(cx);
log::info!("melted {amount} sats");
}
ops::Outcome::Sent { update, token } => {
self.apply_token_update(update);
cx.emit(WalletEvent::TokenCreated(token));
self.emit_updated(cx);
}
ops::Outcome::NutzapSent { update, amount } => { ops::Outcome::NutzapSent { update, amount } => {
self.apply_token_update(update); self.apply_token_update(update);
self.emit_updated(cx); self.emit_updated(cx);
@@ -905,6 +932,7 @@ impl WalletRegistry {
amount, amount,
} => { } => {
self.redeeming.remove(&nutzap_id); self.redeeming.remove(&nutzap_id);
self.redeem_attempts.remove(&nutzap_id);
self.redeemed.insert(nutzap_id); self.redeemed.insert(nutzap_id);
self.apply_token_update(update); self.apply_token_update(update);
self.emit_updated(cx); self.emit_updated(cx);
+119 -106
View File
@@ -8,8 +8,8 @@ use cdk::amount::SplitTarget;
use cdk::mint_url::MintUrl; use cdk::mint_url::MintUrl;
use cdk::nuts::nut00::KnownMethod; use cdk::nuts::nut00::KnownMethod;
use cdk::nuts::{ use cdk::nuts::{
CurrencyUnit, Id, KeySetInfo, MeltQuoteState, MintQuoteState, PaymentMethod, Proof, Proofs, CurrencyUnit, Id, KeySetInfo, MintQuoteState, PaymentMethod, Proof, Proofs, SecretKey,
SecretKey, SpendingConditions, State, SpendingConditions, State,
}; };
use cdk::secret::Secret; use cdk::secret::Secret;
use cdk::wallet::types::ProofInfo; use cdk::wallet::types::ProofInfo;
@@ -35,14 +35,6 @@ pub(crate) enum Job {
mint: MintUrl, mint: MintUrl,
quote_id: String, quote_id: String,
}, },
Melt {
mint: MintUrl,
invoice: String,
},
SendToken {
mint: MintUrl,
amount: u64,
},
SendNutzap { SendNutzap {
recipient: PublicKey, recipient: PublicKey,
amount: u64, amount: u64,
@@ -117,67 +109,6 @@ impl Job {
amount, amount,
}) })
} }
Job::Melt { mint, invoice } => {
let wallet = ctx.wallet(&mint)?;
let quote = wallet
.melt_quote(
PaymentMethod::Known(KnownMethod::Bolt11),
invoice,
None,
None,
)
.await?;
let finalized = wallet
.prepare_melt(&quote.id, HashMap::new())
.await?
.confirm()
.await?;
if finalized.state() != MeltQuoteState::Paid {
return Err(anyhow!("melt did not settle: {:?}", finalized.state()));
}
let amount = u64::from(finalized.total_amount());
let update = ctx
.sync_token_events(
wallet,
nostr_mint(&mint)?,
&ctx.previous_ids(&mint),
Some(keys::History {
direction: TransactionDirection::Out,
amount,
redeemed: Vec::new(),
sender: None,
}),
)
.await?;
Ok(Outcome::Melted { update, amount })
}
Job::SendToken { mint, amount } => {
let wallet = ctx.wallet(&mint)?;
let prepared = wallet
.prepare_send(Amount::from(amount), SendOptions::default())
.await?;
let token = prepared.confirm(None).await?.to_string();
let update = ctx
.sync_token_events(
wallet,
nostr_mint(&mint)?,
&ctx.previous_ids(&mint),
Some(keys::History {
direction: TransactionDirection::Out,
amount,
redeemed: Vec::new(),
sender: None,
}),
)
.await?;
Ok(Outcome::Sent { update, token })
}
Job::SendNutzap { Job::SendNutzap {
recipient, recipient,
amount, amount,
@@ -330,14 +261,6 @@ pub(crate) enum Outcome {
update: TokenUpdate, update: TokenUpdate,
amount: u64, amount: u64,
}, },
Melted {
update: TokenUpdate,
amount: u64,
},
Sent {
update: TokenUpdate,
token: String,
},
NutzapSent { NutzapSent {
update: TokenUpdate, update: TokenUpdate,
amount: u64, amount: u64,
@@ -361,6 +284,8 @@ pub(crate) struct JobCtx<S> {
pub(crate) client: Client, pub(crate) client: Client,
/// Relay token events at schedule time. /// Relay token events at schedule time.
pub(crate) tokens: HashMap<EventId, TokenEvent>, pub(crate) tokens: HashMap<EventId, TokenEvent>,
/// A previous job errored after possibly mutating the store
pub(crate) publish_failed: bool,
} }
impl<S> JobCtx<S> impl<S> JobCtx<S>
@@ -426,11 +351,71 @@ where
log::warn!("saga recovery failed for {mint_url}: {error:#}"); log::warn!("saga recovery failed for {mint_url}: {error:#}");
} }
let minted = wallet.mint_unissued_quotes().await?; let local_ys = proof_ys(wallet.get_unspent_proofs().await?.iter())?;
if minted == Amount::ZERO { let states = vec![State::Pending, State::Reserved, State::PendingSpent];
let in_flight = wallet.get_proofs_by_states(states).await?;
let in_flight_ys = proof_ys(in_flight.iter())?;
let mut relay_ys = HashSet::new();
let mut unseen = Vec::new();
for token in tokens
.values()
.filter(|token| token.mint.as_str() == mint_url.to_string())
{
for proof in &token.proofs {
let info = match proof_info(proof, mint_url) {
Ok(info) => info,
Err(error) => {
log::warn!("skipping proof: {error:#}");
continue;
}
};
if !relay_ys.insert(info.y) {
continue; continue;
} }
if local_ys.contains(&info.y) || in_flight_ys.contains(&info.y) {
continue;
}
unseen.push(info);
}
}
if unseen.is_empty() {
continue;
}
let unseen_proofs: Proofs = unseen.iter().map(|info| info.proof.clone()).collect();
let spent = match wallet.check_proofs_spent(unseen_proofs).await {
Ok(states) => states
.into_iter()
.filter(|state| state.state == State::Spent)
.map(|state| state.y)
.collect::<HashSet<_>>(),
Err(error) => {
log::warn!("mint state check failed, accepting unseen proofs: {error:#}");
HashSet::new()
}
};
let added: Vec<ProofInfo> = unseen
.into_iter()
.filter(|info| !spent.contains(&info.y))
.collect();
if !added.is_empty() {
wallet.localstore.update_proofs(added, Vec::new()).await?;
}
}
for (mint_url, wallet) in &self.wallets {
let minted = wallet.mint_unissued_quotes().await?;
if minted != Amount::ZERO {
let update = self let update = self
.sync_token_events( .sync_token_events(
wallet, wallet,
@@ -452,39 +437,39 @@ where
updates.push(update); updates.push(update);
} }
for (mint_url, wallet) in &self.wallets { let relay_ys = relay_proof_ys(&tokens, mint_url);
let mut relay_ys = HashSet::new(); let local_ys = proof_ys(wallet.get_unspent_proofs().await?.iter())?;
let mut added = Vec::new();
for token in tokens if local_ys != relay_ys {
.values() if self.publish_failed {
.filter(|token| token.mint.as_str() == mint_url.to_string()) let update = self
{ .sync_token_events(
for proof in &token.proofs { wallet,
let info = match proof_info(proof, mint_url) { nostr_mint(mint_url)?,
Ok(info) => info, &previous_ids(&tokens, mint_url),
Err(error) => { None,
log::warn!("skipping proof: {error:#}"); )
continue; .await?;
}
};
if relay_ys.insert(info.y) {
added.push(info);
}
}
}
let unspent = wallet.get_unspent_proofs().await?; for id in &update.destroyed {
let removed = unspent tokens.remove(id);
.iter() }
tokens.insert(update.event_id, update.token.clone());
updates.push(update);
} else {
let removed = wallet
.get_unspent_proofs()
.await?
.into_iter()
.filter(|proof| match proof.y() { .filter(|proof| match proof.y() {
Ok(y) => !relay_ys.contains(&y), Ok(y) => !relay_ys.contains(&y),
Err(_) => false, Err(_) => false,
}) })
.map(|proof| proof.y()) .map(|proof| proof.y())
.collect::<Result<Vec<_>, _>>()?; .collect::<Result<Vec<_>, _>>()?;
wallet.localstore.update_proofs(Vec::new(), removed).await?;
wallet.localstore.update_proofs(added, removed).await?; }
}
} }
Ok(Outcome::Reconciled { updates }) Ok(Outcome::Reconciled { updates })
@@ -499,6 +484,34 @@ fn previous_ids(tokens: &HashMap<EventId, TokenEvent>, mint: &MintUrl) -> Vec<Ev
.collect() .collect()
} }
fn proof_ys<'a>(
proofs: impl Iterator<Item = &'a Proof>,
) -> Result<HashSet<cdk::nuts::PublicKey>, Error> {
proofs.map(|proof| proof.y().map_err(Error::from)).collect()
}
/// Proof ys referenced by the relay token events of one mint
fn relay_proof_ys(
tokens: &HashMap<EventId, TokenEvent>,
mint: &MintUrl,
) -> HashSet<cdk::nuts::PublicKey> {
let mut ys = HashSet::new();
for token in tokens
.values()
.filter(|token| token.mint.as_str() == mint.to_string())
{
for proof in &token.proofs {
match proof_info(proof, mint) {
Ok(info) => {
ys.insert(info.y);
}
Err(error) => log::warn!("skipping proof: {error:#}"),
}
}
}
ys
}
/// Resolve a recipient's 10019 wallet info and pick a mint we share with them. /// Resolve a recipient's 10019 wallet info and pick a mint we share with them.
async fn recipient_wallet_info<S>( async fn recipient_wallet_info<S>(
ctx: &JobCtx<S>, ctx: &JobCtx<S>,
+112 -3
View File
@@ -1,7 +1,7 @@
use std::ops::Range; use std::ops::Range;
use std::rc::Rc; use std::rc::Rc;
use ::nip60::{HistoryEntry, WalletEvent, WalletRegistry}; use ::nip60::{HistoryEntry, MintQuoteEntry, WalletEvent, WalletRegistry};
use common::TimestampExt; use common::TimestampExt;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
@@ -13,6 +13,7 @@ use gpui::{
use gpui_kit::assets::IconName; use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _; use gpui_kit::base::StyledExt as _;
use gpui_kit::component::button::{Button, ButtonVariants 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::dock::{Panel, PanelEvent};
use gpui_kit::component::input::{Input, InputEvent, InputState, NumberInput}; use gpui_kit::component::input::{Input, InputEvent, InputState, NumberInput};
use gpui_kit::component::notification::Notification; use gpui_kit::component::notification::Notification;
@@ -51,7 +52,6 @@ impl WalletPanel {
WalletEvent::Error(error) => { WalletEvent::Error(error) => {
window.push_notification(Notification::error(error.clone()), cx); window.push_notification(Notification::error(error.clone()), cx);
} }
WalletEvent::TokenCreated(_) => {}
}, },
)); ));
@@ -108,6 +108,111 @@ impl WalletPanel {
) )
} }
fn render_quotes(&self, quotes: Vec<MintQuoteEntry>, cx: &Context<Self>) -> Option<AnyElement> {
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<Self>) -> 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( fn render_history(
&mut self, &mut self,
history: Vec<HistoryEntry>, history: Vec<HistoryEntry>,
@@ -748,6 +853,7 @@ impl Panel for WalletPanel {
.text_color(cx.theme().muted_foreground), .text_color(cx.theme().muted_foreground),
) )
.child(self.name.clone()) .child(self.name.clone())
.child(ui::experimental_tag(cx))
.into_any_element() .into_any_element()
} }
} }
@@ -762,7 +868,9 @@ impl Focusable for WalletPanel {
impl Render for WalletPanel { impl Render for WalletPanel {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> 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() v_flex()
.size_full() .size_full()
@@ -771,6 +879,7 @@ impl Render for WalletPanel {
.text_xs() .text_xs()
.child(self.render_balance(cx)) .child(self.render_balance(cx))
.child(self.render_actions()) .child(self.render_actions())
.children(self.render_quotes(quotes, cx))
.child( .child(
v_flex() v_flex()
.gap_2() .gap_2()
+45 -20
View File
@@ -1,5 +1,5 @@
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::sync::RwLock; use std::sync::{Arc, RwLock};
use anyhow::{Error, anyhow}; use anyhow::{Error, anyhow};
use common::emojis_from_tags; use common::emojis_from_tags;
@@ -64,7 +64,7 @@ enum Dispatch {
#[derive(Debug)] #[derive(Debug)]
pub struct PersonRegistry { pub struct PersonRegistry {
/// Public keys with a metadata request in flight /// Public keys with a metadata request in flight
in_flight: RwLock<HashSet<PublicKey>>, in_flight: Arc<RwLock<HashSet<PublicKey>>>,
/// Web of trust graph of the signed-in user /// Web of trust graph of the signed-in user
wot: Entity<WebOfTrust>, wot: Entity<WebOfTrust>,
sender: flume::Sender<PublicKey>, sender: flume::Sender<PublicKey>,
@@ -102,8 +102,12 @@ impl PersonRegistry {
let client3 = client.clone(); let client3 = client.clone();
let executor = cx.background_executor().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 { 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| { tasks.push(cx.spawn(async move |this, cx| {
@@ -140,7 +144,7 @@ impl PersonRegistry {
}); });
Self { Self {
in_flight: RwLock::new(HashSet::new()), in_flight,
wot, wot,
sender: metadata_tx, sender: metadata_tx,
tasks, tasks,
@@ -205,6 +209,7 @@ impl PersonRegistry {
client: &Client, client: &Client,
rx: &flume::Receiver<PublicKey>, rx: &flume::Receiver<PublicKey>,
executor: BackgroundExecutor, executor: BackgroundExecutor,
in_flight: &RwLock<HashSet<PublicKey>>,
) { ) {
let mut batch: HashSet<PublicKey> = HashSet::new(); let mut batch: HashSet<PublicKey> = HashSet::new();
@@ -216,34 +221,44 @@ impl PersonRegistry {
result = rx.recv_async().fuse() => match result { result = rx.recv_async().fuse() => match result {
Ok(public_key) => public_key, Ok(public_key) => public_key,
Err(_) => { Err(_) => {
if !batch.is_empty() Self::flush_batch(client, &mut batch, in_flight).await;
&& let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await
{
log::warn!("Failed to get metadata batch: {e}");
}
return; return;
} }
}, },
() = timer => { () = timer => {
if !batch.is_empty() Self::flush_batch(client, &mut batch, in_flight).await;
&& let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await
{
log::warn!("Failed to get metadata batch: {e}");
}
continue; continue;
} }
}; };
batch.insert(request); batch.insert(request);
// Process the batch if it's full // Process the batch if it's full
if batch.len() >= 20 if batch.len() >= 20 {
&& let Err(e) = get_metadata(client, std::mem::take(&mut batch)).await Self::flush_batch(client, &mut batch, in_flight).await;
{
log::warn!("Failed to get metadata batch: {e}");
} }
} }
} }
/// Send one metadata request for the batch, releasing its keys for retry on failure
async fn flush_batch(
client: &Client,
batch: &mut HashSet<PublicKey>,
in_flight: &RwLock<HashSet<PublicKey>>,
) {
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 /// Subscribe to the signed-in user's emoji list
fn get_emojis(&mut self, cx: &mut Context<Self>) { fn get_emojis(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
@@ -334,11 +349,21 @@ impl PersonRegistry {
/// Allow future fetches for a key once its metadata has arrived /// Allow future fetches for a key once its metadata has arrived
fn release_in_flight(&self, public_key: &PublicKey) { 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<HashSet<PublicKey>>,
keys: impl IntoIterator<Item = PublicKey>,
) {
let mut in_flight = match in_flight.write() {
Ok(in_flight) => in_flight, Ok(in_flight) => in_flight,
Err(poisoned) => poisoned.into_inner(), Err(poisoned) => poisoned.into_inner(),
}; };
in_flight.remove(public_key); for public_key in keys {
in_flight.remove(&public_key);
}
} }
} }
-29
View File
@@ -1,6 +1,3 @@
use std::cmp::Ordering;
use std::hash::{Hash, Hasher};
use gpui::SharedString; use gpui::SharedString;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use state::Announcement; use state::Announcement;
@@ -20,32 +17,6 @@ pub struct Person {
messaging_relays: Vec<RelayUrl>, messaging_relays: Vec<RelayUrl>,
} }
impl PartialEq for Person {
fn eq(&self, other: &Self) -> bool {
self.public_key == other.public_key
}
}
impl Eq for Person {}
impl PartialOrd for Person {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Person {
fn cmp(&self, other: &Self) -> Ordering {
self.name().cmp(&other.name())
}
}
impl Hash for Person {
fn hash<H: Hasher>(&self, state: &mut H) {
self.public_key.hash(state)
}
}
impl Person { impl Person {
pub fn new(public_key: PublicKey, metadata: Metadata) -> Self { pub fn new(public_key: PublicKey, metadata: Metadata) -> Self {
Self { Self {
+79 -18
View File
@@ -223,6 +223,11 @@ impl Global for GlobalAppSettings {}
pub struct AppSettings { pub struct AppSettings {
/// Settings /// Settings
inner: Entity<Settings>, inner: Entity<Settings>,
/// The pending save task, so writes to the settings file complete in order
#[cfg(not(target_arch = "wasm32"))]
save_task: Option<Task<()>>,
/// Set after a failed load so its own save cannot overwrite the file with defaults
suppress_next_save: bool,
/// Event subscriptions /// Event subscriptions
_subscriptions: SmallVec<[Subscription; 2]>, _subscriptions: SmallVec<[Subscription; 2]>,
} }
@@ -315,11 +320,16 @@ impl AppSettings {
// Run at the end of current cycle // Run at the end of current cycle
cx.defer(move |cx| { cx.defer(move |cx| {
entity.update(cx, |this, cx| this.load(cx)).ok(); if let Err(error) = entity.update(cx, |this, cx| this.load(cx)) {
log::error!("failed to load settings: {error:#}");
}
}); });
Self { Self {
inner, inner,
#[cfg(not(target_arch = "wasm32"))]
save_task: None,
suppress_next_save: false,
_subscriptions: subscriptions, _subscriptions: subscriptions,
} }
} }
@@ -381,27 +391,46 @@ impl AppSettings {
/// Load settings /// Load settings
fn load(&mut self, cx: &mut Context<Self>) { fn load(&mut self, cx: &mut Context<Self>) {
let task: Task<Result<Settings, Error>> = cx.background_spawn(async move { let task: Task<Result<Option<Settings>, Error>> = cx.background_spawn(async move {
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
{ {
let path = config_dir().join(".settings"); let path = config_dir().join(".settings");
if let Ok(content) = smol::fs::read_to_string(&path).await { match smol::fs::read_to_string(&path).await {
return Ok(serde_json::from_str(&content)?); Ok(content) => {
let settings: Settings =
serde_json::from_str(&content).map_err(|error| {
anyhow!("failed to parse {}: {error}", path.display())
})?;
return Ok(Some(settings));
}
// No settings file yet on a fresh install, so keep the defaults
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(anyhow!("failed to read {}: {error}", path.display()));
}
} }
} }
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
{ {
if let Some(content) = common::web_store::get(SETTINGS_KEY)? { if let Some(content) = common::web_store::get(SETTINGS_KEY)? {
return Ok(serde_json::from_str(&content)?); let settings: Settings = serde_json::from_str(&content)
.map_err(|error| anyhow!("failed to parse saved settings: {error}"))?;
return Ok(Some(settings));
} }
} }
Err(anyhow!("Not found")) Ok(None)
}); });
cx.spawn(async move |this, cx| { cx.spawn(async move |this, cx| {
let mut settings = task.await.unwrap_or(Settings::default()); let (mut settings, load_failed) = match task.await {
Ok(settings) => (settings.unwrap_or_default(), false),
Err(error) => {
log::error!("failed to load settings, keeping defaults: {error:#}");
(Settings::default(), true)
}
};
// Move settings still pointed at the old default file server over to the new one // Move settings still pointed at the old default file server over to the new one
if settings.file_server.host_str() == Some(LEGACY_FILE_SERVER) { if settings.file_server.host_str() == Some(LEGACY_FILE_SERVER) {
@@ -409,31 +438,63 @@ impl AppSettings {
} }
// Update settings // Update settings
this.update(cx, |this, cx| { if let Err(error) = this.update(cx, |this, cx| {
// A failed load must not trigger the save that would overwrite the file
this.suppress_next_save = load_failed;
this.set_settings(settings, cx); this.set_settings(settings, cx);
this.apply_theme(cx); this.apply_theme(cx);
cx.refresh_windows(); cx.refresh_windows();
}) }) {
.ok(); log::error!("failed to apply loaded settings: {error:#}");
}
}) })
.detach(); .detach();
} }
/// Save settings /// Save settings
pub fn save(&mut self, cx: &mut Context<Self>) { pub fn save(&mut self, cx: &mut Context<Self>) {
// Consume the failed-load suppression, so the next user change saves again
if self.suppress_next_save {
self.suppress_next_save = false;
return;
}
let settings = self.inner.read(cx); let settings = self.inner.read(cx);
if let Ok(content) = serde_json::to_string(&settings) { let content = match serde_json::to_string(settings) {
Ok(content) => content,
Err(error) => {
log::error!("failed to serialize settings: {error:#}");
return;
}
};
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
cx.background_spawn(async move { {
let previous_task = self.save_task.take();
self.save_task = Some(cx.background_spawn(async move {
// Await the previous write, so saves to the file complete in order
if let Some(previous_task) = previous_task {
previous_task.await;
}
let path = config_dir().join(".settings"); let path = config_dir().join(".settings");
smol::fs::write(&path, content).await.ok(); let temporary_path = config_dir().join(".settings.tmp");
})
.detach(); let result: std::io::Result<()> = async {
smol::fs::write(&temporary_path, &content).await?;
smol::fs::rename(&temporary_path, &path).await
}
.await;
if let Err(error) = result {
log::error!("failed to save settings to {}: {error}", path.display());
}
}));
}
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
if let Err(e) = common::web_store::set(SETTINGS_KEY, &content) { if let Err(error) = common::web_store::set(SETTINGS_KEY, &content) {
log::warn!("failed to persist settings: {e}"); log::error!("failed to persist settings: {error:#}");
}
} }
} }
+52 -31
View File
@@ -1,5 +1,5 @@
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::OnceLock; use std::sync::{Mutex, OnceLock};
use std::time::Duration; use std::time::Duration;
use anyhow::{Context as _, Result, anyhow}; use anyhow::{Context as _, Result, anyhow};
@@ -30,7 +30,7 @@ impl SpeechRecognizer for ParakeetRecognizer {
cx: &mut App, cx: &mut App,
) -> Result<Box<dyn RecognitionSession>, SpeechError> { ) -> Result<Box<dyn RecognitionSession>, SpeechError> {
if super::model::installed() { if super::model::installed() {
worker().send_or_log(Command::Warm(super::model::dir())); send(Command::Warm(super::model::dir())).map_err(SpeechError::recognizer)?;
} else if super::model::status().is_downloading() { } else if super::model::status().is_downloading() {
return Err(SpeechError::recognizer(anyhow!( return Err(SpeechError::recognizer(anyhow!(
"The speech model is still downloading. Try again when it finishes." "The speech model is still downloading. Try again when it finishes."
@@ -79,7 +79,11 @@ impl RecognitionSession for ParakeetSession {
} }
let (sender, receiver) = flume::unbounded(); let (sender, receiver) = flume::unbounded();
worker().send_or_log(Command::Transcribe { samples, sender });
if let Err(error) = send(Command::Transcribe { samples, sender }) {
sink.error(SpeechError::recognizer(error), cx);
return;
}
self._result = Some(cx.spawn(async move |cx| { self._result = Some(cx.spawn(async move |cx| {
let result = receiver.recv_async().await; let result = receiver.recv_async().await;
@@ -109,23 +113,50 @@ enum Command {
Unload, Unload,
} }
/// A handle to the single inference worker. fn worker_slot() -> &'static Mutex<Option<flume::Sender<Command>>> {
fn worker() -> &'static flume::Sender<Command> { static WORKER: OnceLock<Mutex<Option<flume::Sender<Command>>>> = OnceLock::new();
static WORKER: OnceLock<flume::Sender<Command>> = OnceLock::new(); WORKER.get_or_init(|| Mutex::new(None))
WORKER.get_or_init(|| {
let (sender, receiver) = flume::unbounded::<Command>();
let spawned = std::thread::Builder::new()
.name("speech-parakeet".into())
.spawn(move || run_worker(receiver));
if let Err(error) = spawned {
log::error!("failed to spawn the speech worker: {error}");
} }
sender /// A handle to the live worker, spawning a replacement after an idle exit.
}) fn worker() -> Result<flume::Sender<Command>> {
let mut slot = worker_slot()
.lock()
.unwrap_or_else(|error| error.into_inner());
if slot.as_ref().is_some_and(|sender| sender.is_disconnected()) {
*slot = None;
}
if let Some(sender) = slot.as_ref() {
return Ok(sender.clone());
}
let (sender, receiver) = flume::unbounded::<Command>();
match std::thread::Builder::new()
.name("speech-parakeet".into())
.spawn(move || run_worker(receiver))
{
Ok(_) => {
*slot = Some(sender.clone());
Ok(sender)
}
Err(error) => {
log::error!("failed to spawn the speech worker: {error}");
Err(anyhow!("Could not start the speech worker: {error}"))
}
}
}
/// Deliver a command, retrying once on a fresh worker if the previous one exited.
fn send(command: Command) -> Result<()> {
let command = match worker()?.send(command) {
Ok(()) => return Ok(()),
Err(error) => error.into_inner(),
};
// The worker can exit between the liveness check and the send, so retry once.
worker()?.send(command).context("the speech worker exited")
} }
fn run_worker(receiver: flume::Receiver<Command>) { fn run_worker(receiver: flume::Receiver<Command>) {
@@ -192,21 +223,11 @@ fn transcribe(cached: &mut Option<(PathBuf, ParakeetTDT)>, samples: Vec<f32>) ->
.map_err(|error| anyhow!("Could not transcribe this recording: {error}")) .map_err(|error| anyhow!("Could not transcribe this recording: {error}"))
} }
trait SendOrLog {
fn send_or_log(&self, command: Command);
}
impl SendOrLog for flume::Sender<Command> {
fn send_or_log(&self, command: Command) {
if self.send(command).is_err() {
log::debug!("speech worker is gone; command dropped");
}
}
}
/// Retire cached weights, e.g. after the model was deleted. /// Retire cached weights, e.g. after the model was deleted.
pub(crate) fn unload() { pub(crate) fn unload() {
worker().send_or_log(Command::Unload); if let Err(error) = send(Command::Unload) {
log::warn!("failed to unload the speech model: {error}");
}
} }
fn flatten_panic<T>(result: std::thread::Result<T>) -> Result<T> { fn flatten_panic<T>(result: std::thread::Result<T>) -> Result<T> {
+1
View File
@@ -51,6 +51,7 @@ sqlite-wasm-vfs = "0.2"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies] [target.'cfg(not(target_arch = "wasm32"))'.dependencies]
browser-signer-proxy = { path = "../browser-signer-proxy" } browser-signer-proxy = { path = "../browser-signer-proxy" }
nostr-lmdb.workspace = true nostr-lmdb.workspace = true
nostr-memory.workspace = true
smol.workspace = true smol.workspace = true
gpui_tokio.workspace = true gpui_tokio.workspace = true
rustls = "0.23" rustls = "0.23"
+4 -12
View File
@@ -376,12 +376,6 @@ impl DownloadedFile {
LocalFile::Url(_) => Ok(()), LocalFile::Url(_) => Ok(()),
} }
} }
/// Save the file to the user's downloads (web)
#[cfg(target_arch = "wasm32")]
pub fn save(&self) -> Result<(), Error> {
crate::web_file::download(&self.name, &self.mime, &self.data)
}
} }
/// Download and decrypt a file attachment to the user's device /// Download and decrypt a file attachment to the user's device
@@ -403,7 +397,7 @@ pub async fn download_and_decrypt_file(
.clone() .clone()
.unwrap_or_else(|| fallback_name(&file.url)); .unwrap_or_else(|| fallback_name(&file.url));
let local = store_file(&name, &file.mime, &data).await?; let local = store_file(&file.mime, &data).await?;
Ok(DownloadedFile { Ok(DownloadedFile {
local, local,
@@ -415,10 +409,11 @@ pub async fn download_and_decrypt_file(
} }
/// Persist decrypted bytes somewhere the platform can render or open them /// Persist decrypted bytes somewhere the platform can render or open them
async fn store_file(name: &str, mime: &str, data: &[u8]) -> Result<LocalFile, Error> { async fn store_file(mime: &str, data: &[u8]) -> Result<LocalFile, Error> {
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
{ {
let digest = sha256_hex(name.as_bytes()); // Key the cache entry on the content hash, so same-named attachments can't collide
let digest = sha256_hex(data);
let extension = mime_guess::get_mime_extensions_str(mime) let extension = mime_guess::get_mime_extensions_str(mime)
.and_then(|extensions| extensions.first()) .and_then(|extensions| extensions.first())
.copied() .copied()
@@ -428,7 +423,6 @@ async fn store_file(name: &str, mime: &str, data: &[u8]) -> Result<LocalFile, Er
.join("coop-files") .join("coop-files")
.join(format!("{digest}.{extension}")); .join(format!("{digest}.{extension}"));
if smol::fs::metadata(&path).await.is_err() {
let Some(parent) = path.parent() else { let Some(parent) = path.parent() else {
bail!("Invalid file path"); bail!("Invalid file path");
}; };
@@ -438,14 +432,12 @@ async fn store_file(name: &str, mime: &str, data: &[u8]) -> Result<LocalFile, Er
let partial = path.with_extension("download"); let partial = path.with_extension("download");
smol::fs::write(&partial, data).await?; smol::fs::write(&partial, data).await?;
smol::fs::rename(&partial, &path).await?; smol::fs::rename(&partial, &path).await?;
}
Ok(LocalFile::Path(path)) Ok(LocalFile::Path(path))
} }
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
{ {
let _ = name;
let url = crate::web_file::blob_url(mime, data)?; let url = crate::web_file::blob_url(mime, data)?;
Ok(LocalFile::Url(url)) Ok(LocalFile::Url(url))
} }
+35 -17
View File
@@ -16,6 +16,8 @@ use nostr_connect::prelude::*;
use nostr_gossip_memory::prelude::*; use nostr_gossip_memory::prelude::*;
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
use nostr_lmdb::prelude::*; use nostr_lmdb::prelude::*;
#[cfg(not(target_arch = "wasm32"))]
use nostr_memory::prelude::MemoryDatabase;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
mod blossom; mod blossom;
@@ -89,8 +91,6 @@ pub struct NostrRegistry {
signer: UniversalSigner, signer: UniversalSigner,
/// Current user's public key /// Current user's public key
current_user: Option<PublicKey>, current_user: Option<PublicKey>,
/// Whether the stored credential needs a passphrase to unlock
passphrase_required: bool,
/// Tasks for asynchronous operations /// Tasks for asynchronous operations
tasks: Vec<Task<Result<(), Error>>>, tasks: Vec<Task<Result<(), Error>>>,
} }
@@ -114,15 +114,27 @@ impl NostrRegistry {
let signer = UniversalSigner::new(Keys::generate()); let signer = UniversalSigner::new(Keys::generate());
let authenticator = SignerAuthenticator::new(signer.clone()); let authenticator = SignerAuthenticator::new(signer.clone());
// NostrLmdb::open schedules nothing back onto the GPUI executor, so blocking here can't deadlock
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
let database = cx.foreground_executor().block_on(async move { let (database, database_error) = match cx
NostrLmdb::open(config_dir().join("nostr")) .foreground_executor()
.await .block_on(NostrLmdb::open(config_dir().join("nostr")))
.expect("Failed to initialize database") {
}); Ok(database) => (Arc::new(database) as Arc<dyn NostrDatabase>, None),
Err(e) => {
log::error!("Failed to open the local database, falling back to memory: {e}");
(
Arc::new(MemoryDatabase::unbounded()) as Arc<dyn NostrDatabase>,
Some(format!(
"Local database unavailable, using an in-memory database instead: {e}"
)),
)
}
};
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
let database = web_database::take(); let (database, database_error) = (web_database::take(), None::<String>);
// Construct the nostr client // Construct the nostr client
let client = ClientBuilder::default() let client = ClientBuilder::default()
@@ -141,6 +153,10 @@ impl NostrRegistry {
// Connect to bootstrap relays once the registry has been returned to the app // Connect to bootstrap relays once the registry has been returned to the app
cx.defer(move |cx| { cx.defer(move |cx| {
let _ = entity.update(cx, |this, cx| { let _ = entity.update(cx, |this, cx| {
if let Some(error) = database_error {
cx.emit(StateEvent::error(error));
}
this.connect_bootstrap_relays(cx); this.connect_bootstrap_relays(cx);
if let Some(secret) = cli_key { if let Some(secret) = cli_key {
@@ -157,7 +173,6 @@ impl NostrRegistry {
client, client,
signer, signer,
current_user: None, current_user: None,
passphrase_required: false,
tasks: vec![], tasks: vec![],
} }
} }
@@ -191,7 +206,6 @@ impl NostrRegistry {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.signer.swap_inner(new_signer); this.signer.swap_inner(new_signer);
this.current_user = Some(public_key); this.current_user = Some(public_key);
this.passphrase_required = false;
cx.emit(StateEvent::SignerChanged); cx.emit(StateEvent::SignerChanged);
cx.notify(); cx.notify();
@@ -273,8 +287,7 @@ impl NostrRegistry {
this.set_signer(signer, cx); this.set_signer(signer, cx);
})?; })?;
} else if content.starts_with("ncryptsec1") { } else if content.starts_with("ncryptsec1") {
this.update(cx, |this, cx| { this.update(cx, |_this, cx| {
this.passphrase_required = true;
cx.emit(StateEvent::PassphraseRequired); cx.emit(StateEvent::PassphraseRequired);
})?; })?;
} else if content == "proxy" { } else if content == "proxy" {
@@ -445,8 +458,14 @@ impl NostrRegistry {
}; };
let keys = Keys::generate(); let keys = Keys::generate();
let credential = with_master_key(&uri_string, &keys);
let write = credentials::write(cx, "bunker", credential.as_bytes()); // The URI embeds the client key, so never persist it where web stores it unencrypted
#[cfg(not(target_arch = "wasm32"))]
let write =
credentials::write(cx, "bunker", with_master_key(&uri_string, &keys).as_bytes());
#[cfg(target_arch = "wasm32")]
let write = Task::ready(Ok::<(), Error>(()));
cx.spawn(async move |this, cx| { cx.spawn(async move |this, cx| {
let signer = NostrConnect::builder(connect_uri, keys) let signer = NostrConnect::builder(connect_uri, keys)
@@ -468,9 +487,8 @@ impl NostrRegistry {
/// Sign out and remove the stored credential /// Sign out and remove the stored credential
pub fn sign_out(&mut self, cx: &mut Context<Self>) { pub fn sign_out(&mut self, cx: &mut Context<Self>) {
self.current_user = None; self.current_user = None;
self.passphrase_required = false;
credentials::delete(cx).detach_and_log_err(cx); credentials::delete(cx).detach_and_log_err(cx);
cx.emit(StateEvent::SignerChanged); cx.emit(StateEvent::SignerChanged);
cx.emit(StateEvent::NoSigner); cx.emit(StateEvent::NoSigner);
cx.notify(); cx.notify();
@@ -495,7 +513,6 @@ impl NostrRegistry {
credentials::write(cx, "extension", b"extension").detach_and_log_err(cx); credentials::write(cx, "extension", b"extension").detach_and_log_err(cx);
this.signer.swap_inner(signer); this.signer.swap_inner(signer);
this.current_user = Some(public_key); this.current_user = Some(public_key);
this.passphrase_required = false;
cx.emit(StateEvent::SignerChanged); cx.emit(StateEvent::SignerChanged);
cx.notify(); cx.notify();
@@ -709,6 +726,7 @@ fn extract_master_key(credential: &str) -> (&str, Keys) {
} }
// Embed the master key in a bunker URI so reconnects reuse the same local key // Embed the master key in a bunker URI so reconnects reuse the same local key
#[cfg(not(target_arch = "wasm32"))]
fn with_master_key(uri: &str, keys: &Keys) -> String { fn with_master_key(uri: &str, keys: &Keys) -> String {
let separator = if uri.contains('?') { '&' } else { '?' }; let separator = if uri.contains('?') { '&' } else { '?' };
let nsec = keys.secret_key().to_bech32().expect("infallible"); let nsec = keys.secret_key().to_bech32().expect("infallible");
-3
View File
@@ -12,8 +12,5 @@ gpui.workspace = true
gpui-base.workspace = true gpui-base.workspace = true
gpui-kit.workspace = true gpui-kit.workspace = true
nostr-sdk.workspace = true nostr-sdk.workspace = true
web-time = "1.1"
smallvec.workspace = true smallvec.workspace = true
log.workspace = true log.workspace = true
uuid = "1.10"
+41 -7
View File
@@ -1,3 +1,6 @@
use std::collections::HashMap;
use std::sync::{LazyLock, Mutex};
use gpui::{ use gpui::{
App, Div, Hsla, InteractiveElement, IntoElement, ParentElement, Pixels, RenderOnce, App, Div, Hsla, InteractiveElement, IntoElement, ParentElement, Pixels, RenderOnce,
SharedString, StyleRefinement, Styled, Window, div, px, SharedString, StyleRefinement, Styled, Window, div, px,
@@ -20,6 +23,10 @@ const MIN_FILLED: usize = 5;
/// Seed for an avatar that has neither a picture nor a seed of its own. /// Seed for an avatar that has neither a picture nor a seed of its own.
const FALLBACK_SEED: &str = "coop"; const FALLBACK_SEED: &str = "coop";
/// Memoized seed-derived patterns; PixelAvatar is recreated on every frame.
static PATTERN_CACHE: LazyLock<Mutex<HashMap<String, AvatarPattern>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
/// An avatar for a user or other entity. /// An avatar for a user or other entity.
#[derive(IntoElement)] #[derive(IntoElement)]
pub struct Avatar { pub struct Avatar {
@@ -93,7 +100,10 @@ impl RenderOnce for Avatar {
.child(AvatarImage::new(url).size_full().rounded_full()) .child(AvatarImage::new(url).size_full().rounded_full())
.into_any_element(), .into_any_element(),
None => PixelAvatar { None => PixelAvatar {
seed: fnv1a(self.seed.unwrap_or_else(|| FALLBACK_SEED.into()).as_bytes()), seed: self
.seed
.map(SharedString::from)
.unwrap_or_else(|| SharedString::new_static(FALLBACK_SEED)),
side, side,
style: StyleRefinement::default(), style: StyleRefinement::default(),
} }
@@ -114,7 +124,7 @@ impl RenderOnce for Avatar {
/// A deterministic, offline pixel-art avatar. /// A deterministic, offline pixel-art avatar.
#[derive(IntoElement)] #[derive(IntoElement)]
struct PixelAvatar { struct PixelAvatar {
seed: u64, seed: SharedString,
side: Pixels, side: Pixels,
style: StyleRefinement, style: StyleRefinement,
} }
@@ -128,11 +138,11 @@ impl Styled for PixelAvatar {
impl RenderOnce for PixelAvatar { impl RenderOnce for PixelAvatar {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme(); let theme = cx.theme();
let pattern = pattern(self.seed); let AvatarPattern { hash, grid } = cached_pattern(&self.seed);
let mut cells = Vec::new(); let mut cells = Vec::new();
let main = Hsla { let main = Hsla {
h: self.seed as f32 / u64::MAX as f32, h: hash as f32 / u64::MAX as f32,
s: 0.6, s: 0.6,
l: if theme.is_dark() { 0.6 } else { 0.45 }, l: if theme.is_dark() { 0.6 } else { 0.45 },
a: 1., a: 1.,
@@ -152,7 +162,7 @@ impl RenderOnce for PixelAvatar {
for row in 0..GRID_SIZE { for row in 0..GRID_SIZE {
for col in 0..GRID_SIZE { for col in 0..GRID_SIZE {
let value = pattern[row * GRID_SIZE + col]; let value = grid[row * GRID_SIZE + col];
if value != 0 { if value != 0 {
let color = if value == 2 { shade } else { main }; let color = if value == 2 { shade } else { main };
cells.push( cells.push(
@@ -186,6 +196,32 @@ impl RenderOnce for PixelAvatar {
} }
} }
/// The seed-derived data a PixelAvatar renders, theme-dependent styling is applied per render.
#[derive(Clone, Copy)]
struct AvatarPattern {
hash: u64,
grid: [u8; GRID_SIZE * GRID_SIZE],
}
/// `seed`'s hash and pixel grid, computing and caching them on first use.
fn cached_pattern(seed: &str) -> AvatarPattern {
let mut cache = PATTERN_CACHE
.lock()
.unwrap_or_else(|error| error.into_inner());
if let Some(cached) = cache.get(seed) {
return *cached;
}
let hash = fnv1a(seed.as_bytes());
let value = AvatarPattern {
hash,
grid: pattern(hash),
};
cache.insert(seed.to_string(), value);
value
}
/// The rendered side length of an avatar at `size`. /// The rendered side length of an avatar at `size`.
fn side_length(size: Size) -> Pixels { fn side_length(size: Size) -> Pixels {
match size { match size {
@@ -202,8 +238,6 @@ fn pattern(seed: u64) -> [u8; GRID_SIZE * GRID_SIZE] {
let mut pattern = [0u8; GRID_SIZE * GRID_SIZE]; let mut pattern = [0u8; GRID_SIZE * GRID_SIZE];
let mut filled = 0usize; let mut filled = 0usize;
// Only the inner rows and the inner left half are candidates; mirroring
// then keeps the art within the same inset, leaving the outer ring empty.
let art_rows = GRID_SIZE - 2 * MARGIN; let art_rows = GRID_SIZE - 2 * MARGIN;
let art_columns = GRID_SIZE / 2 - MARGIN; let art_columns = GRID_SIZE / 2 - MARGIN;
+54
View File
@@ -60,3 +60,57 @@ pub fn settings_block(
) )
.child(control) .child(control)
} }
/// An amber marker for features that are not ready for prime time.
pub fn experimental_tag(cx: &App) -> Div {
div()
.flex_shrink_0()
.px_1p5()
.rounded_full()
.bg(cx.theme().warning.opacity(0.15))
.text_color(cx.theme().warning)
.text_xs()
.font_medium()
.child("Experimental")
}
/// A [`settings_row`] whose label carries a tag, e.g. [`experimental_tag`].
pub fn settings_row_tagged(
label: impl Into<SharedString>,
tag: Div,
description: impl Into<SharedString>,
control: impl IntoElement,
cx: &App,
) -> Div {
h_flex()
.w_full()
.gap_4()
.items_center()
.justify_between()
.child(
v_flex()
.flex_1()
.min_w_0()
.child(
h_flex()
.gap_1p5()
.items_center()
.child(div().text_sm().font_semibold().child(label.into()))
.child(tag),
)
.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(description.into()),
),
)
.child(
h_flex()
.w_40()
.flex_shrink_0()
.justify_end()
.items_center()
.child(control),
)
}
+9 -8
View File
@@ -25,7 +25,7 @@ type SharedClickListener = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
type DropdownBuilder = Rc<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu>; type DropdownBuilder = Rc<dyn Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu>;
/// Messages from the same author within this window are grouped into one run. /// Messages from the same author within this window are grouped into one run.
pub const RUN_WINDOW_SECS: u64 = 300; pub(crate) const RUN_WINDOW_SECS: u64 = 300;
/// Whether a message starts a new run, given the previous message's author and unix time. /// Whether a message starts a new run, given the previous message's author and unix time.
pub fn opens_run<T: PartialEq>(previous: Option<(T, u64)>, current: (T, u64)) -> bool { pub fn opens_run<T: PartialEq>(previous: Option<(T, u64)>, current: (T, u64)) -> bool {
@@ -758,14 +758,19 @@ impl RenderOnce for MessageActions {
/// A message's text revealed word by word, paced by the estimated speech duration. /// A message's text revealed word by word, paced by the estimated speech duration.
#[derive(IntoElement)] #[derive(IntoElement)]
pub struct RevealText { pub struct RevealText {
text: SharedString, words: Vec<SharedString>,
duration: Duration, duration: Duration,
} }
impl RevealText { impl RevealText {
/// Create text that reveals its words over `duration`.
pub fn new(text: impl Into<SharedString>, duration: Duration) -> Self { pub fn new(text: impl Into<SharedString>, duration: Duration) -> Self {
Self { Self {
text: text.into(), words: text
.into()
.split_whitespace()
.map(SharedString::from)
.collect(),
duration, duration,
} }
} }
@@ -773,11 +778,7 @@ impl RevealText {
impl RenderOnce for RevealText { impl RenderOnce for RevealText {
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
let words: Vec<SharedString> = self let words = self.words;
.text
.split_whitespace()
.map(SharedString::from)
.collect();
let total = words.len().max(1) as f32; let total = words.len().max(1) as f32;
div().flex().flex_wrap().gap_x_1().with_animation( div().flex().flex_wrap().gap_x_1().with_animation(
+2 -2
View File
@@ -8,7 +8,7 @@ use person::Directory;
/// A span of the source content, by byte range, that renders as `text` instead. /// A span of the source content, by byte range, that renders as `text` instead.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct InlineReplacement { pub(crate) struct InlineReplacement {
pub range: Range<usize>, pub range: Range<usize>,
pub text: SharedString, pub text: SharedString,
/// When set, the span renders as this image (NIP-30 custom emoji) /// When set, the span renders as this image (NIP-30 custom emoji)
@@ -44,7 +44,7 @@ pub struct RenderedText {
} }
impl RenderedText { impl RenderedText {
pub fn new(content: &str, replacements: &[InlineReplacement]) -> Self { pub(crate) fn new(content: &str, replacements: &[InlineReplacement]) -> Self {
let mut text = String::with_capacity(content.len()); let mut text = String::with_capacity(content.len());
let mut last = 0; let mut last = 0;
+3
View File
@@ -60,6 +60,9 @@ impl NewChat {
let subscription = cx.subscribe_in(&input, window, |this, _input, event, window, cx| { let subscription = cx.subscribe_in(&input, window, |this, _input, event, window, cx| {
if let InputEvent::PressEnter { .. } = event { if let InputEvent::PressEnter { .. } = event {
this.submit(window, cx) this.submit(window, cx)
} else if matches!(event, InputEvent::Change) {
// The contact list only re-filters when the dialog re-renders
cx.notify();
} }
}); });
+5 -3
View File
@@ -19,7 +19,7 @@ use gpui_kit::prelude::FluentBuilder;
use rand::seq::IndexedRandom; use rand::seq::IndexedRandom;
use settings::AppSettings; use settings::AppSettings;
use smallvec::{SmallVec, smallvec}; use smallvec::{SmallVec, smallvec};
use ui::{settings_block, settings_row}; use ui::{experimental_tag, settings_block, settings_row, settings_row_tagged};
use super::settings::{FONT_SIZE_RANGE, RADIUS_RANGE}; use super::settings::{FONT_SIZE_RANGE, RADIUS_RANGE};
@@ -400,8 +400,9 @@ impl Render for Onboarding {
}), }),
cx, cx,
)) ))
.child(settings_row( .child(settings_row_tagged(
"Decoupling Encryption Key", "Decoupling Encryption Key",
experimental_tag(cx),
NIP4E, NIP4E,
Switch::new("onboarding-nip4e").checked(nip4e).on_click( Switch::new("onboarding-nip4e").checked(nip4e).on_click(
move |checked, _window, cx| { move |checked, _window, cx| {
@@ -412,8 +413,9 @@ impl Render for Onboarding {
)); ));
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
let features = features.child(settings_row( let features = features.child(settings_row_tagged(
"Wallet", "Wallet",
experimental_tag(cx),
WALLET, WALLET,
Switch::new("onboarding-wallet") Switch::new("onboarding-wallet")
.checked(AppSettings::get_wallet(cx)) .checked(AppSettings::get_wallet(cx))
+87 -7
View File
@@ -5,16 +5,17 @@ use chat::{ChatRegistry, Room, RoomKind};
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
App, AppContext, Context, Div, Entity, IntoElement, ParentElement, Render, SharedString, 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::assets::IconName;
use gpui_kit::base::StyledExt as _; use gpui_kit::base::StyledExt as _;
use gpui_kit::component::button::{Button, ButtonVariants}; use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::clipboard::Clipboard; use gpui_kit::component::clipboard::Clipboard;
use gpui_kit::component::dock::DockPlacement; 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::notification::Notification;
use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex}; use gpui_kit::component::{ActiveTheme, Disableable, Sizable, WindowExt, h_flex, v_flex};
use instant::Duration;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use person::{Directory, Person, PersonRegistry, shorten_pubkey}; use person::{Directory, Person, PersonRegistry, shorten_pubkey};
use settings::AppSettings; use settings::AppSettings;
@@ -51,13 +52,19 @@ struct ProfileDialog {
uploading: bool, uploading: bool,
/// Updating state /// Updating state
updating: bool, updating: bool,
/// Error message
error: Option<SharedString>,
/// Tasks /// Tasks
tasks: Vec<Task<Result<(), Error>>>, tasks: Vec<Task<Result<(), Error>>>,
/// Keeps the avatar preview live while the URL is typed
_subscription: Option<Subscription>,
} }
impl ProfileDialog { impl ProfileDialog {
fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context<Self>) -> Self { fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context<Self>) -> Self {
let directory = Directory::new(cx);
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let is_current_user = nostr let is_current_user = nostr
.read(cx) .read(cx)
.current_user() .current_user()
@@ -75,7 +82,17 @@ impl ProfileDialog {
.placeholder("A short introduce about you.") .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.update(cx, |directory, cx| {
directory.track([public_key], cx); directory.track([public_key], cx);
}); });
@@ -91,7 +108,9 @@ impl ProfileDialog {
website_input, website_input,
uploading: false, uploading: false,
updating: false, updating: false,
error: None,
tasks: vec![], tasks: vec![],
_subscription: Some(subscription),
} }
} }
@@ -190,6 +209,26 @@ impl ProfileDialog {
cx.notify(); 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 /// Set the metadata for the current user
fn publish(&self, metadata: &Metadata, cx: &App) -> Task<Result<(), Error>> { fn publish(&self, metadata: &Metadata, cx: &App) -> Task<Result<(), Error>> {
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
@@ -218,19 +257,51 @@ impl ProfileDialog {
let bio = self.bio_input.read(cx).value(); let bio = self.bio_input.read(cx).value();
let website = self.website_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 // Construct the new metadata
let mut new_metadata = old_metadata let mut new_metadata = old_metadata
.display_name(name.as_ref()) .display_name(name.as_ref())
.name(name.as_ref()) .name(name.as_ref())
.about(bio.as_ref()); .about(bio.as_ref());
// Verify the avatar URL before adding it // An empty input keeps the saved value, a valid URL replaces it
if let Ok(url) = Url::from_str(&avatar) { if let Some(url) = avatar_url {
new_metadata = new_metadata.picture(url); new_metadata = new_metadata.picture(url);
} }
// Verify the website URL before adding it if let Some(url) = website_url {
if let Ok(url) = Url::from_str(&website) {
new_metadata = new_metadata.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)) 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( .child(
h_flex() h_flex()
.gap_1() .gap_1()
+59 -21
View File
@@ -10,6 +10,7 @@ use gpui::{
use gpui_kit::assets::IconName; use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _; use gpui_kit::base::StyledExt as _;
use gpui_kit::component::button::{Button, ButtonVariants}; use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::notification::Notification;
use gpui_kit::component::spinner::Spinner; use gpui_kit::component::spinner::Spinner;
use gpui_kit::component::{ActiveTheme, Disableable, Icon, Sizable, WindowExt, h_flex, v_flex}; use gpui_kit::component::{ActiveTheme, Disableable, Icon, Sizable, WindowExt, h_flex, v_flex};
use instant::Duration; 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)) 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 { pub struct Screening {
/// Public Key of the person being screened. /// Public Key of the person being screened.
public_key: PublicKey, public_key: PublicKey,
/// Whether the person's address is verified. /// Outcome of the NIP-05 address verification
verified: bool, identifier_status: IdentifierStatus,
/// Whether the person is followed by current user. /// Whether the person is followed by current user.
followed: bool, followed: bool,
/// Last time the person was active. /// Last time the person was active.
@@ -49,9 +62,8 @@ impl Screening {
let mut subscriptions = smallvec![]; 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(); this.tasks.clear();
window.close_all_dialogs(cx);
})); }));
subscriptions.push(cx.subscribe(&wot, |_this, _wot, _event, cx| { subscriptions.push(cx.subscribe(&wot, |_this, _wot, _event, cx| {
@@ -71,7 +83,7 @@ impl Screening {
Self { Self {
public_key, public_key,
verified: false, identifier_status: IdentifierStatus::Pending,
followed: false, followed: false,
last_active: None, last_active: None,
mutual_contacts: vec![], mutual_contacts: vec![],
@@ -221,10 +233,17 @@ impl Screening {
cx.background_spawn(async move { address.verify(&http_client, &public_key).await }); cx.background_spawn(async move { address.verify(&http_client, &public_key).await });
self.tasks.push(cx.spawn(async move |this, cx| { 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.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(); cx.notify();
}) })
.ok(); .ok();
@@ -270,14 +289,26 @@ impl Screening {
Ok(()) Ok(())
}); });
self.tasks.push(cx.spawn_in(window, async move |_, cx| { self.tasks
if task.await.is_ok() { .push(cx.spawn_in(window, async move |_, cx| match task.await {
Ok(_) => {
cx.update(|window, cx| { cx.update(|window, cx| {
window.close_dialog(cx); window.close_dialog(cx);
window.push_notification("Report submitted successfully", cx); window.push_notification("Report submitted successfully", cx);
}) })
.ok(); .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();
}
})); }));
} }
@@ -341,6 +372,9 @@ impl Render for Screening {
const NO_MUTUAL: &str = "You don't have any mutual contacts."; 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_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_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"; const NO_NIP05: &str = "This person has not set up their friendly address";
let persons = PersonRegistry::global(cx); let persons = PersonRegistry::global(cx);
@@ -349,6 +383,20 @@ impl Render for Screening {
let last_active = self.last_active.map(|_| true); 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 // Pull from the web of trust once loaded, else use the database fallback
let mutual_contacts = { let mutual_contacts = {
let wot = persons.read(cx).wot().read(cx); let wot = persons.read(cx).wot().read(cx);
@@ -491,7 +539,7 @@ impl Render for Screening {
h_flex() h_flex()
.items_start() .items_start()
.gap_2() .gap_2()
.child(status_badge(Some(self.verified), cx)) .child(status_badge(identifier_badge, cx))
.child( .child(
v_flex() v_flex()
.text_xs() .text_xs()
@@ -508,17 +556,7 @@ impl Render for Screening {
div() div()
.line_clamp(1) .line_clamp(1)
.text_color(cx.theme().muted_foreground) .text_color(cx.theme().muted_foreground)
.child({ .child(SharedString::from(identifier_message)),
if self.address(cx).is_some() {
if self.verified {
SharedString::from(NIP05_MATCH)
} else {
SharedString::from(NIP05_NOT_MATCH)
}
} else {
SharedString::from(NO_NIP05)
}
}),
), ),
), ),
) )
+17 -9
View File
@@ -14,6 +14,7 @@ use gpui::{
Subscription, Window, div, Subscription, Window, div,
}; };
use gpui_kit::assets::IconName; use gpui_kit::assets::IconName;
use gpui_kit::base::StyledExt as _;
use gpui_kit::component::button::{Button, ButtonVariants}; use gpui_kit::component::button::{Button, ButtonVariants};
use gpui_kit::component::input::{Input, InputEvent, InputState, NumberInput}; use gpui_kit::component::input::{Input, InputEvent, InputState, NumberInput};
use gpui_kit::component::kbd::Kbd; use gpui_kit::component::kbd::Kbd;
@@ -218,10 +219,10 @@ impl Preferences {
fn render_wallet( fn render_wallet(
&mut self, &mut self,
#[cfg_attr(target_arch = "wasm32", allow(unused_variables))] cx: &mut Context<Self>, #[cfg_attr(target_arch = "wasm32", allow(unused_variables))] cx: &mut Context<Self>,
) -> AnyElement { ) -> Option<AnyElement> {
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
{ {
return div().into_any_element(); return None;
} }
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
@@ -230,17 +231,18 @@ impl Preferences {
let enabled = AppSettings::get_wallet(cx); let enabled = AppSettings::get_wallet(cx);
let has_wallet = nip60::WalletRegistry::global(cx).read(cx).has_wallet(); let has_wallet = nip60::WalletRegistry::global(cx).read(cx).has_wallet();
Some(
v_flex() v_flex()
.w_full() .w_full()
.gap_3() .gap_3()
.child(settings_row( .child(settings_row(
"Wallet", "Wallet",
"Show the wallet and allow ecash payments.", "Show the wallet and allow ecash payments.",
Switch::new("wallet") Switch::new("wallet").checked(enabled).on_click(
.checked(enabled) move |checked, _window, cx| {
.on_click(move |checked, _window, cx| {
AppSettings::update_wallet(*checked, cx); AppSettings::update_wallet(*checked, cx);
}), },
),
cx, cx,
)) ))
.when(enabled, |this| { .when(enabled, |this| {
@@ -262,7 +264,8 @@ impl Preferences {
cx, cx,
)) ))
}) })
.into_any_element() .into_any_element(),
)
} }
} }
@@ -656,6 +659,11 @@ impl Render for Preferences {
)), )),
) )
.child(Separator::horizontal()) .child(Separator::horizontal())
.child(
v_flex()
.w_full()
.gap_3()
.child(div().text_sm().font_semibold().child("Experimental"))
.child(settings_row( .child(settings_row(
"Decoupling Encryption Key", "Decoupling Encryption Key",
NIP4E, NIP4E,
@@ -682,6 +690,8 @@ impl Render for Preferences {
}), }),
cx, cx,
)) ))
.when_some(self.render_wallet(cx), |this, wallet| this.child(wallet)),
)
.child(Separator::horizontal()) .child(Separator::horizontal())
.child(self.render_speech(cx)) .child(self.render_speech(cx))
.child(self.render_dictation_shortcut(window, cx)) .child(self.render_dictation_shortcut(window, cx))
@@ -703,7 +713,5 @@ impl Render for Preferences {
), ),
cx, cx,
)) ))
.child(Separator::horizontal())
.child(self.render_wallet(cx))
} }
} }
+118 -30
View File
@@ -1,9 +1,11 @@
use std::sync::Arc; use std::sync::Arc;
use gpui::{App, Pixels, WeakEntity, Window, px}; use chat_ui::ChatPanel;
use gpui_kit::component::dock::{ use gpui::{App, Entity, Pixels, SharedString, WeakEntity, Window, px};
DockArea, DockPlacement, InsertTarget, NodeId, PaneRef, PanelHandle, PanelId, 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 invalid_panel;
mod skin; mod skin;
@@ -14,39 +16,83 @@ pub use skin::CoopDockSkin;
/// The fixed height of the tab bar, which doubles as the window title bar. /// The fixed height of the tab bar, which doubles as the window title bar.
pub const TAB_BAR_HEIGHT: Pixels = px(44.); pub const TAB_BAR_HEIGHT: Pixels = px(44.);
/// Dock placements.
/// The panel in any region of `area whose id is `key. const DOCK_PLACEMENTS: [DockPlacement; 4] = [
fn find_panel(area: &DockArea, key: PanelId, _cx: &App) -> Option<(PanelId, NodeId, usize)> {
let placements = [
DockPlacement::Center, DockPlacement::Center,
DockPlacement::Left, DockPlacement::Left,
DockPlacement::Right, DockPlacement::Right,
DockPlacement::Bottom, DockPlacement::Bottom,
]; ];
for placement in placements { /// 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),
}
/// 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 { let Some(tree) = area.layout(placement) else {
continue; continue;
}; };
for id in tree.panels() { for id in tree.panels() {
if id != key { let Some(view) = area.panel(id) else {
continue;
}
let node = tree.find_panel_node(id)?;
let PaneRef::Tabs { panels, .. } = tree.find_node(node)?.kind() else {
continue; continue;
}; };
let ix = panels.iter().position(|panel| *panel == key)?; if docked_panel_key(view, cx).is_some_and(|found| &found == key) {
return Some((id, node, ix)); return Some((id, placement));
}
} }
} }
None 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( pub fn add_panel(
area: &mut DockArea, area: &mut DockArea,
panel: PanelHandle, panel: PanelHandle,
@@ -54,24 +100,44 @@ pub fn add_panel(
window: &mut Window, window: &mut Window,
cx: &mut gpui::Context<DockArea>, cx: &mut gpui::Context<DockArea>,
) { ) {
let key = panel.panel_id(cx); let Some(key) = panel_key(&panel, cx) else {
if let Some((id, node, ix)) = find_panel(area, key, cx) { area.add_panel_view(Arc::new(panel), placement, None, window, cx);
area.move_panel( return;
id, };
InsertTarget::Tabs {
node, if let Some((id, _)) = find_panel(area, &key, cx) {
ix: Some(ix), focus_panel(area, id, window, cx);
activate: true,
},
window,
cx,
);
return; return;
} }
area.add_panel_view(Arc::new(panel), placement, None, window, cx); 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. /// Add a panel to a dock reached through a weak handle.
pub fn add_panel_to( pub fn add_panel_to(
dock: &WeakEntity<DockArea>, dock: &WeakEntity<DockArea>,
@@ -86,3 +152,25 @@ pub fn add_panel_to(
log::warn!("Failed to add panel to dock: {error}"); 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);
});
}
-6
View File
@@ -22,7 +22,6 @@ use super::tab_panel::CoopTabGroupSkin;
/// State the skin shares with its per-container renderers. /// State the skin shares with its per-container renderers.
pub(crate) struct SkinShared { pub(crate) struct SkinShared {
area: WeakEntity<DockArea>, area: WeakEntity<DockArea>,
toggle_button_visible: Cell<bool>,
/// The dock whose resize handle is being dragged, if any. Only one can be. /// The dock whose resize handle is being dragged, if any. Only one can be.
resizing_dock: Cell<Option<DockPlacement>>, resizing_dock: Cell<Option<DockPlacement>>,
} }
@@ -32,10 +31,6 @@ impl SkinShared {
&self.area &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>> { pub(crate) fn resizing_dock(&self) -> &Cell<Option<DockPlacement>> {
&self.resizing_dock &self.resizing_dock
} }
@@ -52,7 +47,6 @@ impl CoopDockSkin {
Rc::new(Self { Rc::new(Self {
shared: Rc::new(SkinShared { shared: Rc::new(SkinShared {
area: cx.weak_entity(), area: cx.weak_entity(),
toggle_button_visible: Cell::new(true),
resizing_dock: Cell::new(None), resizing_dock: Cell::new(None),
}), }),
}) })
+1 -1
View File
@@ -209,7 +209,7 @@ impl CoopTabGroupSkin {
group: &TabGroupContext, group: &TabGroupContext,
cx: &mut App, cx: &mut App,
) -> Option<Button> { ) -> Option<Button> {
if group.is_zoomed() || !self.shared.is_toggle_button_visible() { if group.is_zoomed() {
return None; return None;
} }
+48 -14
View File
@@ -1,4 +1,5 @@
use std::cell::Cell; use std::cell::Cell;
use std::collections::HashMap;
use std::rc::Rc; use std::rc::Rc;
use std::sync::Arc; use std::sync::Arc;
@@ -17,7 +18,7 @@ use gpui_kit::component::button::Button;
use gpui_kit::component::command::{ use gpui_kit::component::command::{
Command as CommandPalette, CommandGroup, CommandItem, CommandState, 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::notification::{Notification, NotificationType};
use gpui_kit::component::{ use gpui_kit::component::{
ActiveTheme, IndexPath, Theme, ThemeConfig, ThemeRegistry, WindowExt, v_flex, ActiveTheme, IndexPath, Theme, ThemeConfig, ThemeRegistry, WindowExt, v_flex,
@@ -51,7 +52,7 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity<Workspace> {
cx.new(|cx| Workspace::new(window, cx)) cx.new(|cx| Workspace::new(window, cx))
} }
struct DeviceNotifcation; struct DeviceNotification;
struct MsgRelayNotification; struct MsgRelayNotification;
#[derive(Action, Clone, PartialEq, Eq, Deserialize)] #[derive(Action, Clone, PartialEq, Eq, Deserialize)]
@@ -80,6 +81,9 @@ enum Command {
pub struct Workspace { pub struct Workspace {
dock: Entity<DockArea>, dock: Entity<DockArea>,
sidebar: Entity<Sidebar>, 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 /// Async tasks
tasks: Vec<Task<Result<(), Error>>>, tasks: Vec<Task<Result<(), Error>>>,
/// Event subscriptions /// Event subscriptions
@@ -137,7 +141,7 @@ impl Workspace {
one shown there before approving."; one shown there before approving.";
let note = Notification::new() let note = Notification::new()
.id::<DeviceNotifcation>() .id::<DeviceNotification>()
.autohide(false) .autohide(false)
.title(format!("Wait for approval · {code}")) .title(format!("Wait for approval · {code}"))
.message(MSG) .message(MSG)
@@ -151,7 +155,7 @@ impl Workspace {
from the settings."; from the settings.";
let note = Notification::new() let note = Notification::new()
.id::<DeviceNotifcation>() .id::<DeviceNotification>()
.message(MSG) .message(MSG)
.with_type(NotificationType::Info) .with_type(NotificationType::Info)
.action(|_this, _window, _cx| { .action(|_this, _window, _cx| {
@@ -161,7 +165,7 @@ impl Workspace {
device.update(cx, |this, cx| { device.update(cx, |this, cx| {
this.get_announcement(cx); this.get_announcement(cx);
}); });
window.remove_notification::<DeviceNotifcation>(cx); window.remove_notification::<DeviceNotification>(cx);
}, },
) )
}); });
@@ -170,7 +174,7 @@ impl Workspace {
} }
DeviceEvent::Set => { DeviceEvent::Set => {
let note = Notification::new() let note = Notification::new()
.id::<DeviceNotifcation>() .id::<DeviceNotification>()
.message("Encryption Key has been set") .message("Encryption Key has been set")
.with_type(NotificationType::Success); .with_type(NotificationType::Success);
@@ -227,14 +231,6 @@ impl Workspace {
::nip60::WalletEvent::Error(error) => { ::nip60::WalletEvent::Error(error) => {
window.push_notification(Notification::error(error.clone()), cx); window.push_notification(Notification::error(error.clone()), cx);
} }
::nip60::WalletEvent::TokenCreated(_) => {
window.push_notification(
Notification::new()
.message("Cashu token created, copy it from the wallet panel")
.with_type(NotificationType::Success),
cx,
);
}
::nip60::WalletEvent::Updated => {} ::nip60::WalletEvent::Updated => {}
}, },
)); ));
@@ -285,6 +281,7 @@ impl Workspace {
Self { Self {
dock, dock,
sidebar, sidebar,
group_panels: HashMap::new(),
tasks: vec![], tasks: vec![],
_subscriptions: subscriptions, _subscriptions: subscriptions,
} }
@@ -319,6 +316,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 /// Handle command events
fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context<Self>) { fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context<Self>) {
match command { match command {
+57 -9
View File
@@ -1,4 +1,5 @@
use anyhow::Error; use anyhow::Error;
use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement,
ParentElement, Render, SharedString, Styled, Task, Window, div, 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::input::{Input, InputState};
use gpui_kit::component::separator::Separator; use gpui_kit::component::separator::Separator;
use gpui_kit::component::{ActiveTheme, v_flex}; use gpui_kit::component::{ActiveTheme, v_flex};
use instant::Duration;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use state::USER_KEYRING; use state::USER_KEYRING;
@@ -23,14 +25,12 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity<BackupPanel> {
pub struct BackupPanel { pub struct BackupPanel {
name: SharedString, name: SharedString,
focus_handle: FocusHandle, focus_handle: FocusHandle,
/// Public key input /// Public key input
npub_input: Entity<InputState>, npub_input: Entity<InputState>,
/// Secret key input /// Secret key input
nsec_input: Entity<InputState>, nsec_input: Entity<InputState>,
/// Error message
/// Background tasks error: Option<SharedString>,
tasks: Vec<Task<Result<(), Error>>>, tasks: Vec<Task<Result<(), Error>>>,
} }
@@ -49,6 +49,7 @@ impl BackupPanel {
focus_handle: cx.focus_handle(), focus_handle: cx.focus_handle(),
npub_input, npub_input,
nsec_input, nsec_input,
error: None,
tasks: vec![], tasks: vec![],
} }
} }
@@ -57,20 +58,58 @@ impl BackupPanel {
let keyring = cx.read_credentials(USER_KEYRING); let keyring = cx.read_credentials(USER_KEYRING);
self.tasks.push(cx.spawn_in(window, async move |this, cx| { self.tasks.push(cx.spawn_in(window, async move |this, cx| {
if let Some((_, secret)) = keyring.await? { let result: Result<(), Error> = async {
let secret = SecretKey::from_slice(&secret)?; let Some((_, secret)) = keyring.await? else {
let keys = Keys::new(secret); 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.update_in(cx, |this, window, cx| {
this.npub_input.update(cx, |this, 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.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(()) Ok(())
})); }));
@@ -110,6 +149,15 @@ impl Render for BackupPanel {
.child(SharedString::from(MSG)), .child(SharedString::from(MSG)),
) )
.child(Separator::horizontal()) .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( .child(
v_flex() v_flex()
.gap_2() .gap_2()
+3 -2
View File
@@ -227,10 +227,11 @@ impl BrowsePanel {
/// Open the group's panel to preview (or revisit) it. /// Open the group's panel to preview (or revisit) it.
fn open_panel(dock: &WeakEntity<DockArea>, key: GroupKey, window: &mut Window, cx: &mut App) { fn open_panel(dock: &WeakEntity<DockArea>, key: GroupKey, window: &mut Window, cx: &mut App) {
let registry = GroupsRegistry::global(cx); 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, dock,
key,
PanelHandle::new(nip29_ui::init(group, window, cx)), PanelHandle::new(nip29_ui::init(group, window, cx)),
DockPlacement::Center, DockPlacement::Center,
window, window,
+36 -32
View File
@@ -29,26 +29,17 @@ pub fn init(window: &mut Window, cx: &mut App) -> Entity<ContactListPanel> {
pub struct ContactListPanel { pub struct ContactListPanel {
name: SharedString, name: SharedString,
focus_handle: FocusHandle, focus_handle: FocusHandle,
/// Npub input /// Npub input
input: Entity<InputState>, input: Entity<InputState>,
/// Whether the panel is updating /// Whether the panel is updating
updating: bool, updating: bool,
/// Error message /// Error message
error: Option<SharedString>, error: Option<SharedString>,
/// All contacts /// All contacts
contacts: HashSet<PublicKey>, contacts: HashSet<Contact>,
/// Person directory for the contacts /// Person directory for the contacts
directory: Entity<Directory>, directory: Entity<Directory>,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 1]>, _subscriptions: SmallVec<[Subscription; 1]>,
/// Background tasks
tasks: Vec<Task<Result<(), Error>>>, tasks: Vec<Task<Result<(), Error>>>,
} }
@@ -92,33 +83,50 @@ impl ContactListPanel {
return; return;
}; };
let task: Task<Result<HashSet<PublicKey>, Error>> = cx.background_spawn(async move { let task: Task<Result<HashSet<Contact>, Error>> = cx.background_spawn(async move {
let filter = Filter::new() let filter = Filter::new()
.author(public_key) .author(public_key)
.kind(Kind::ContactList) .kind(Kind::ContactList)
.limit(1); .limit(1);
let contact_list: HashSet<PublicKey> = client let contact_list: HashSet<Contact> = client
.database() .database()
.query(filter) .query(filter)
.await? .await?
.into_iter() .into_iter()
.next() .next()
.map(|event| event.tags.public_keys().collect()) .map(|event| {
event
.tags
.iter()
.filter_map(|tag| match Nip02Tag::try_from(tag) {
Ok(Nip02Tag::PublicKey {
public_key,
relay_hint,
alias,
}) => Some(Contact {
public_key,
relay_url: relay_hint,
alias,
}),
Err(_) => None,
})
.collect()
})
.unwrap_or_default(); .unwrap_or_default();
Ok(contact_list) Ok(contact_list)
}); });
self.tasks.push(cx.spawn_in(window, async move |this, cx| { self.tasks.push(cx.spawn_in(window, async move |this, cx| {
let public_keys = task.await?; let contacts = task.await?;
// Update state // Update state
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.directory.update(cx, |directory, cx| { this.directory.update(cx, |directory, cx| {
directory.track(public_keys.iter().copied(), cx) directory.track(contacts.iter().map(|contact| contact.public_key), cx)
}); });
this.contacts.extend(public_keys); this.contacts.extend(contacts);
cx.notify(); cx.notify();
})?; })?;
@@ -130,7 +138,7 @@ impl ContactListPanel {
let value = self.input.read(cx).value().to_string(); let value = self.input.read(cx).value().to_string();
if let Ok(public_key) = PublicKey::parse(&value) { if let Ok(public_key) = PublicKey::parse(&value) {
if self.contacts.insert(public_key) { if self.contacts.insert(Contact::new(public_key)) {
self.directory self.directory
.update(cx, |directory, cx| directory.track([public_key], cx)); .update(cx, |directory, cx| directory.track([public_key], cx));
self.input.update(cx, |this, cx| { self.input.update(cx, |this, cx| {
@@ -144,7 +152,8 @@ impl ContactListPanel {
} }
fn remove(&mut self, public_key: &PublicKey, cx: &mut Context<Self>) { fn remove(&mut self, public_key: &PublicKey, cx: &mut Context<Self>) {
self.contacts.remove(public_key); self.contacts
.retain(|contact| contact.public_key != *public_key);
cx.notify(); cx.notify();
} }
@@ -184,11 +193,7 @@ impl ContactListPanel {
let signer = nostr.read(cx).signer(); let signer = nostr.read(cx).signer();
// Get contacts // Get contacts
let contacts: Vec<Contact> = self let contacts: Vec<Contact> = self.contacts.iter().cloned().collect();
.contacts
.iter()
.map(|public_key| Contact::new(*public_key))
.collect();
// Set updating state // Set updating state
self.set_updating(true, cx); self.set_updating(true, cx);
@@ -230,12 +235,14 @@ impl ContactListPanel {
fn render_list_items(&mut self, cx: &mut Context<Self>) -> Vec<impl IntoElement> { fn render_list_items(&mut self, cx: &mut Context<Self>) -> Vec<impl IntoElement> {
let mut items = Vec::new(); let mut items = Vec::new();
for (ix, public_key) in self.contacts.iter().enumerate() { for contact in &self.contacts {
let profile = self.directory.read(cx).person(public_key, cx); let public_key = contact.public_key;
let hex = public_key.to_hex();
let profile = self.directory.read(cx).person(&public_key, cx);
items.push( items.push(
h_flex() h_flex()
.id(ix) .id(hex.clone())
.group("") .group("")
.flex_1() .flex_1()
.w_full() .w_full()
@@ -256,18 +263,15 @@ impl ContactListPanel {
.child(profile.name()), .child(profile.name()),
) )
.child( .child(
Button::new("remove_{ix}") Button::new(format!("remove-{hex}"))
.icon(IconName::X) .icon(IconName::X)
.xsmall() .xsmall()
.ghost() .ghost()
.invisible() .invisible()
.group_hover("", |this| this.visible()) .group_hover("", |this| this.visible())
.on_click({ .on_click(cx.listener(move |this, _ev, _window, cx| {
let public_key = public_key.to_owned();
cx.listener(move |this, _ev, _window, cx| {
this.remove(&public_key, cx); this.remove(&public_key, cx);
}) })),
}),
), ),
) )
} }
+8 -8
View File
@@ -213,17 +213,17 @@ impl GreeterPanel {
} else { } else {
requests requests
.iter() .iter()
.enumerate() .map(|(room, preview)| {
.map(|(index, (room, preview))| {
let room = room.clone(); let room = room.clone();
let dock = dock.clone(); let dock = dock.clone();
let room_id = room.read(cx).id;
let name = room.read(cx).display_name(cx); let name = room.read(cx).display_name(cx);
let seed = room.read(cx).display_image_seed(cx); let seed = room.read(cx).display_image_seed(cx);
let created_at = room.read(cx).created_at.to_ago(); let created_at = room.read(cx).created_at.to_ago();
let preview = preview.clone(); let preview = preview.clone();
h_flex() h_flex()
.id(format!("request-{index}")) .id(format!("request-{room_id}"))
.group("") .group("")
.w_full() .w_full()
.h_9() .h_9()
@@ -280,7 +280,7 @@ impl GreeterPanel {
), ),
) )
.child( .child(
Button::new(format!("response-{index}")) Button::new(format!("response-{room_id}"))
.label("Response") .label("Response")
.xsmall() .xsmall()
.ghost() .ghost()
@@ -332,17 +332,17 @@ impl GreeterPanel {
let rows: Vec<AnyElement> = self let rows: Vec<AnyElement> = self
.follow_ups .follow_ups
.iter() .iter()
.enumerate() .map(|(room, preview)| {
.map(|(index, (room, preview))| {
let room = room.clone(); let room = room.clone();
let dock = dock.clone(); let dock = dock.clone();
let room_id = room.read(cx).id;
let name = room.read(cx).display_name(cx); let name = room.read(cx).display_name(cx);
let seed = room.read(cx).display_image_seed(cx); let seed = room.read(cx).display_image_seed(cx);
let created_at = room.read(cx).created_at.to_ago(); let created_at = room.read(cx).created_at.to_ago();
let preview = preview.clone(); let preview = preview.clone();
h_flex() h_flex()
.id(format!("follow-up-{index}")) .id(format!("follow-up-{room_id}"))
.group("") .group("")
.w_full() .w_full()
.h_9() .h_9()
@@ -399,7 +399,7 @@ impl GreeterPanel {
), ),
) )
.child( .child(
Button::new(format!("reply-{index}")) Button::new(format!("reply-{room_id}"))
.label("Reply") .label("Reply")
.xsmall() .xsmall()
.ghost() .ghost()
+15 -11
View File
@@ -1,12 +1,12 @@
#[cfg(not(target_arch = "wasm32"))] #[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::prelude::FluentBuilder;
use gpui::{ use gpui::{
Anchor, App, Context, Div, InteractiveElement, IntoElement, ParentElement, SharedString, Anchor, App, Context, Div, InteractiveElement, IntoElement, ParentElement, SharedString,
Stateful, Styled, Window, div, px, Stateful, Styled, Window, div, px,
}; };
#[cfg(not(target_arch = "wasm32"))]
use gpui::{AnyElement, Entity};
use gpui_kit::base::StyledExt as _; use gpui_kit::base::StyledExt as _;
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
use gpui_kit::component::Disableable; use gpui_kit::component::Disableable;
@@ -35,7 +35,7 @@ impl Sidebar {
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Stateful<Div> { ) -> Stateful<Div> {
#[cfg(not(target_arch = "wasm32"))] #[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 profile = self.directory.read(cx).person(current_user, cx);
let avatar = profile.avatar(); let avatar = profile.avatar();
let encryption_key = profile let encryption_key = profile
@@ -78,8 +78,8 @@ impl Sidebar {
let actions = h_flex().gap_1(); let actions = h_flex().gap_1();
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
let actions = actions.when_some(updater, |this, updater| { let actions = actions.when_some(updater_display, |this, display| {
this.child(self.render_updater(updater, cx)) this.child(self.render_updater(display))
}); });
actions actions
@@ -233,10 +233,12 @@ impl Sidebar {
} }
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
fn render_updater(&self, updater: Entity<AutoUpdater>, cx: &mut App) -> AnyElement { fn render_updater(&self, display: UpdateDisplay) -> AnyElement {
let status = updater.read(cx).status(); let UpdateDisplay {
let up_to_date = updater.read(cx).up_to_date(); up_to_date,
let staged = updater.read(cx).staged(); staged,
status,
} = display;
h_flex() h_flex()
.gap_2() .gap_2()
@@ -257,10 +259,12 @@ impl Sidebar {
.tooltip("Quit and relaunch into the installed update") .tooltip("Quit and relaunch into the installed update")
.small() .small()
.ghost() .ghost()
.on_click(move |_, _window, cx| { .on_click(|_, _window, cx| {
if let Some(updater) = AutoUpdater::try_global(cx) {
updater.update(cx, |this, cx| { updater.update(cx, |this, cx| {
this.restart(cx); this.restart(cx);
}); });
}
}), }),
) )
}) })
+57 -1
View File
@@ -34,11 +34,42 @@ impl ListSection {
} }
} }
/// Cheap snapshot of the cached rows' inputs, compared on every render; covers
/// inputs that never notify the sidebar (pin edits, room kind promotions, and
/// the visibility flags mutated across the sidebar's modules).
#[derive(Default, PartialEq)]
pub(super) struct RowsSignature {
contacts_open: bool,
requests_open: bool,
contacts_loaded: bool,
pins_open: bool,
groups_open: bool,
chats_open: bool,
pinned_rooms: Vec<u64>,
pinned_groups: Vec<String>,
/// Kinds of the rooms held by the cache; a request can turn ongoing with
/// no event or registry notify the sidebar can hear
room_kinds: Vec<(u64, RoomKind)>,
}
/// Read the id and kind of every room row held by the given cache.
fn room_signature(rows: &[SidebarRow], cx: &App) -> Vec<(u64, RoomKind)> {
rows.iter()
.filter_map(|row| match row {
SidebarRow::Room { room } => {
let room = room.read(cx);
Some((room.id, room.kind))
}
_ => None,
})
.collect()
}
impl Sidebar { impl Sidebar {
pub(super) fn render_list(&mut self, cx: &mut Context<Self>) -> AnyElement { pub(super) fn render_list(&mut self, cx: &mut Context<Self>) -> AnyElement {
let requests_open = self.requests_open; let requests_open = self.requests_open;
let contacts_open = self.contacts_open && !requests_open; let contacts_open = self.contacts_open && !requests_open;
let rows = Rc::new(self.rows_for(cx)); let rows = self.cached_rows(cx);
// Whether to show the create button for the contact picker // Whether to show the create button for the contact picker
let create_button = contacts_open && !self.selected_contacts.is_empty(); let create_button = contacts_open && !self.selected_contacts.is_empty();
@@ -175,6 +206,31 @@ impl Sidebar {
.into_any_element() .into_any_element()
} }
/// Rebuild the rows only when a dirty source fired or the snapshot changed.
fn cached_rows(&mut self, cx: &mut Context<Self>) -> Rc<Vec<SidebarRow>> {
let mut signature = RowsSignature {
contacts_open: self.contacts_open,
requests_open: self.requests_open,
contacts_loaded: self.contacts.is_some(),
pins_open: self.pins_open,
groups_open: self.is_groups_open(cx),
chats_open: self.is_chats_open(cx),
pinned_rooms: AppSettings::get_pinned_rooms(cx),
pinned_groups: AppSettings::get_pinned_groups(cx),
room_kinds: room_signature(&self.rows, cx),
};
if self.rows_dirty || self.rows_signature != signature {
self.rows = Rc::new(self.rows_for(cx));
self.rows_dirty = false;
// Describe the rebuilt cache, so the next render compares apples to apples
signature.room_kinds = room_signature(&self.rows, cx);
}
self.rows_signature = signature;
Rc::clone(&self.rows)
}
fn rows_for(&self, cx: &App) -> Vec<SidebarRow> { fn rows_for(&self, cx: &App) -> Vec<SidebarRow> {
if self.contacts_open { if self.contacts_open {
return self.contacts_rows(); return self.contacts_rows();
+53 -14
View File
@@ -1,6 +1,9 @@
use std::collections::HashSet; use std::collections::HashSet;
use std::rc::Rc;
use anyhow::Error; use anyhow::Error;
#[cfg(not(target_arch = "wasm32"))]
use auto_update::{AutoUpdater, UpdateDisplay};
use chat::{ChatEvent, ChatRegistry}; use chat::{ChatEvent, ChatRegistry};
use common::DebouncedDelay; use common::DebouncedDelay;
use gpui::prelude::FluentBuilder; use gpui::prelude::FluentBuilder;
@@ -16,12 +19,13 @@ use gpui_kit::component::notification::Notification;
use gpui_kit::component::progress::ProgressCircle; use gpui_kit::component::progress::ProgressCircle;
use gpui_kit::component::{ActiveTheme, WindowExt, h_flex, v_flex}; use gpui_kit::component::{ActiveTheme, WindowExt, h_flex, v_flex};
use instant::Duration; use instant::Duration;
use list::RowsSignature;
use nip29::{Group, GroupKey, GroupsEvent, GroupsRegistry}; use nip29::{Group, GroupKey, GroupsEvent, GroupsRegistry};
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use person::{Directory, PersonRegistry}; use person::{Directory, PersonRegistry};
use rows::SidebarRow;
pub(crate) use rows::open_room; pub(crate) use rows::open_room;
use search::SearchResult; use search::SearchResult;
use settings::AppSettings;
use smallvec::{SmallVec, smallvec}; use smallvec::{SmallVec, smallvec};
use state::{FIND_DELAY, NostrRegistry, StateEvent}; use state::{FIND_DELAY, NostrRegistry, StateEvent};
use ui::title_bar_drag_handlers; use ui::title_bar_drag_handlers;
@@ -44,7 +48,6 @@ pub struct Sidebar {
dock: WeakEntity<DockArea>, dock: WeakEntity<DockArea>,
/// Random background shown behind the signed-out screen. /// Random background shown behind the signed-out screen.
banner: SharedString, banner: SharedString,
/// The group whose information the sidebar is browsing, if any. /// The group whose information the sidebar is browsing, if any.
group: Option<WeakEntity<Group>>, group: Option<WeakEntity<Group>>,
/// Whether the sidebar is showing the group's information instead of its list. /// Whether the sidebar is showing the group's information instead of its list.
@@ -53,14 +56,12 @@ pub struct Sidebar {
admins_open: bool, admins_open: bool,
/// Whether the group view's members section is expanded. /// Whether the group view's members section is expanded.
members_open: bool, members_open: bool,
/// Whether the main list's pinned section is expanded. /// Whether the main list's pinned section is expanded.
pins_open: bool, pins_open: bool,
/// Whether the user expanded or collapsed the main list's groups section. /// Whether the user expanded or collapsed the main list's groups section.
groups_open: Option<bool>, groups_open: Option<bool>,
/// Same as `groups_open` for the chats section. /// Same as `groups_open` for the chats section.
chats_open: Option<bool>, chats_open: Option<bool>,
/// Whether the main list is showing contacts to start a chat with. /// Whether the main list is showing contacts to start a chat with.
contacts_open: bool, contacts_open: bool,
/// The signed-in user's contacts, loaded when the contact picker opens. /// The signed-in user's contacts, loaded when the contact picker opens.
@@ -69,11 +70,9 @@ pub struct Sidebar {
directory: Entity<Directory>, directory: Entity<Directory>,
/// Contacts selected in the contact picker. /// Contacts selected in the contact picker.
selected_contacts: HashSet<PublicKey>, selected_contacts: HashSet<PublicKey>,
/// Whether the main list is showing chat requests instead of chats and groups. /// Whether the main list is showing chat requests instead of chats and groups.
requests_open: bool, requests_open: bool,
new_requests: bool, new_requests: bool,
/// Whether the search bar is shown below the header. /// Whether the search bar is shown below the header.
search_open: bool, search_open: bool,
/// Whether searches also cover global (relay) profiles. /// Whether searches also cover global (relay) profiles.
@@ -90,12 +89,19 @@ pub struct Sidebar {
global_results: Option<Vec<PublicKey>>, global_results: Option<Vec<PublicKey>>,
/// The async global profile search. /// The async global profile search.
global_task: Option<Task<()>>, global_task: Option<Task<()>>,
/// Simulated gift wrap processing progress, shown while the backlog loads /// Simulated gift wrap processing progress, shown while the backlog loads
chat_progress: f32, chat_progress: f32,
/// Simulated web of trust sync progress /// Simulated web of trust sync progress
sync_progress: f32, sync_progress: f32,
/// Cached main list rows; rebuilt only when invalidation is signaled
rows: Rc<Vec<SidebarRow>>,
/// Snapshot of the cached rows' inputs, compared on every render
rows_signature: RowsSignature,
/// Whether the rows changed through a source the snapshot cannot see
rows_dirty: bool,
/// 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]>, tasks: SmallVec<[Task<Result<(), Error>>; 1]>,
_subscriptions: SmallVec<[Subscription; 4]>, _subscriptions: SmallVec<[Subscription; 4]>,
} }
@@ -105,7 +111,6 @@ impl Sidebar {
let chat = ChatRegistry::global(cx); let chat = ChatRegistry::global(cx);
let groups = GroupsRegistry::global(cx); let groups = GroupsRegistry::global(cx);
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let settings = AppSettings::global(cx);
let directory = Directory::new(cx); let directory = Directory::new(cx);
let find_input = cx.new(|cx| { let find_input = cx.new(|cx| {
@@ -122,10 +127,12 @@ impl Sidebar {
match event { match event {
ChatEvent::Ping => { ChatEvent::Ping => {
this.new_requests = true; this.new_requests = true;
this.rows_dirty = true;
cx.notify(); cx.notify();
} }
ChatEvent::Loaded => { ChatEvent::Loaded => {
this.chat_progress = 0.0; this.chat_progress = 0.0;
this.rows_dirty = true;
cx.notify(); cx.notify();
} }
_ => {} _ => {}
@@ -134,9 +141,10 @@ impl Sidebar {
); );
subscriptions.push( subscriptions.push(
cx.subscribe_in(&groups, window, |_this, _, event, window, cx| { cx.subscribe_in(&groups, window, |this, _, event, window, cx| {
match event { match event {
GroupsEvent::Updated => { GroupsEvent::Updated => {
this.rows_dirty = true;
// TODO: we must not trigger re-render directly like this // TODO: we must not trigger re-render directly like this
cx.notify(); cx.notify();
} }
@@ -149,11 +157,24 @@ impl Sidebar {
} => { } => {
window.push_notification(Notification::warning(warning.clone()), cx); window.push_notification(Notification::warning(warning.clone()), cx);
} }
// New groups can arrive without `Updated` and without a
// registry notify, so only the event itself can dirty the cache
GroupsEvent::Synced | GroupsEvent::Created { .. } => {
this.rows_dirty = true;
}
_ => {} _ => {}
}; };
}), }),
); );
subscriptions.push(cx.observe(&chat, |this, _, _| {
this.rows_dirty = true;
}));
subscriptions.push(cx.observe(&groups, |this, _, _| {
this.rows_dirty = true;
}));
subscriptions.push( subscriptions.push(
cx.subscribe_in( cx.subscribe_in(
&nostr, &nostr,
@@ -209,10 +230,23 @@ impl Sidebar {
}, },
)); ));
// The user header reads settings and the tracked profiles on render, so #[cfg(not(target_arch = "wasm32"))]
// the encryption button reacts to toggles and announcement arrivals let updater_display =
subscriptions.push(cx.observe(&settings, |_, _, cx| cx.notify())); cx.new(|cx| AutoUpdater::try_global(cx).map(|updater| updater.read(cx).display()));
subscriptions.push(cx.observe(&directory, |_, _, cx| cx.notify()));
// 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| { tasks.push(cx.spawn(async move |this, cx| {
let timer = Duration::from_millis(PROGRESS_TICK_MS); let timer = Duration::from_millis(PROGRESS_TICK_MS);
@@ -251,6 +285,11 @@ impl Sidebar {
global_task: None, global_task: None,
chat_progress: 0.0, chat_progress: 0.0,
sync_progress: 0.0, sync_progress: 0.0,
rows: Rc::default(),
rows_signature: RowsSignature::default(),
rows_dirty: true,
#[cfg(not(target_arch = "wasm32"))]
updater_display,
tasks, tasks,
_subscriptions: subscriptions, _subscriptions: subscriptions,
} }
+32 -15
View File
@@ -9,7 +9,7 @@ use gpui::{
}; };
use gpui_kit::base::StyledExt as _; use gpui_kit::base::StyledExt as _;
use gpui_kit::component::dialog::DialogButtonProps; 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::menu::{ContextMenuExt as _, PopupMenuItem};
use gpui_kit::component::{ActiveTheme, Icon, Selectable, Sizable, WindowExt, h_flex}; use gpui_kit::component::{ActiveTheme, Icon, Selectable, Sizable, WindowExt, h_flex};
use nip29::Group; use nip29::Group;
@@ -30,9 +30,12 @@ pub(crate) fn open_room(
window: &mut Window, window: &mut Window,
cx: &mut App, 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( crate::dock::add_panel_to(
dock, dock,
PanelHandle::new(chat_ui::init(room.downgrade(), window, cx)), PanelHandle::new(panel),
DockPlacement::Center, DockPlacement::Center,
window, window,
cx, cx,
@@ -41,8 +44,11 @@ pub(crate) fn open_room(
if room.read(cx).kind != RoomKind::Ongoing && AppSettings::get_screening(cx) { if room.read(cx).kind != RoomKind::Ongoing && AppSettings::get_screening(cx) {
let peer = room.read(cx).display_member(cx).public_key(); let peer = room.read(cx).display_member(cx).public_key();
let view = screening::init(peer, window, cx); let view = screening::init(peer, window, cx);
let dock = dock.clone();
window.open_alert_dialog(cx, move |alert, _window, _cx| { window.open_alert_dialog(cx, move |alert, _window, _cx| {
let dock = dock.clone();
let room_id = room_id.clone();
alert alert
.confirm() .confirm()
.child(view.clone()) .child(view.clone())
@@ -52,7 +58,11 @@ pub(crate) fn open_room(
.ok_text("Response"), .ok_text("Response"),
) )
.on_cancel(move |_event, window, cx| { .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 true
}) })
}); });
@@ -95,10 +105,7 @@ impl Sidebar {
rows.get(range.clone()) rows.get(range.clone())
.into_iter() .into_iter()
.flatten() .flatten()
.enumerate() .map(|row| {
.map(|(offset, row)| {
let index = range.start + offset;
let element = match row { let element = match row {
SidebarRow::Section(section) => { SidebarRow::Section(section) => {
let section = *section; let section = *section;
@@ -171,7 +178,7 @@ impl Sidebar {
let dock = self.dock.clone(); let dock = self.dock.clone();
let room = room.clone(); let room = room.clone();
let nav = Nav::new(index) let nav = Nav::new(id)
.label(name) .label(name)
.text_sm() .text_sm()
.when(!hide_avatar, |this| { .when(!hide_avatar, |this| {
@@ -210,7 +217,7 @@ impl Sidebar {
}); });
div() div()
.id(index) .id(id)
.w_full() .w_full()
.child(nav) .child(nav)
.context_menu(move |menu, _window, _cx| { .context_menu(move |menu, _window, _cx| {
@@ -241,7 +248,7 @@ impl Sidebar {
let sidebar = cx.entity().downgrade(); let sidebar = cx.entity().downgrade();
let public_key = public_key.to_owned(); let public_key = public_key.to_owned();
Nav::new(index) Nav::new(public_key.to_hex())
.label(person.name()) .label(person.name())
.text_sm() .text_sm()
.when(!hide_avatar, |this| { .when(!hide_avatar, |this| {
@@ -265,7 +272,7 @@ impl Sidebar {
let person = self.directory.read(cx).person(public_key, cx); let person = self.directory.read(cx).person(public_key, cx);
let public_key = public_key.to_owned(); let public_key = public_key.to_owned();
Nav::new(index) Nav::new(public_key.to_hex())
.label(person.name()) .label(person.name())
.text_sm() .text_sm()
.when(!hide_avatar, |this| { .when(!hide_avatar, |this| {
@@ -298,7 +305,7 @@ impl Sidebar {
.or_else(|| group.read(cx).elsewhere().map(|_| "Moved")) .or_else(|| group.read(cx).elsewhere().map(|_| "Moved"))
.map(SharedString::from); .map(SharedString::from);
let nav = Nav::new(index) let nav = Nav::new(tag.clone())
.label(name) .label(name)
.text_sm() .text_sm()
.when(!hide_avatar, |this| { .when(!hide_avatar, |this| {
@@ -318,8 +325,11 @@ impl Sidebar {
) )
}) })
.on_click(move |_event, window, cx| { .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, &dock,
group_key,
PanelHandle::new(nip29_ui::init(group.clone(), window, cx)), PanelHandle::new(nip29_ui::init(group.clone(), window, cx)),
DockPlacement::Center, DockPlacement::Center,
window, window,
@@ -334,7 +344,7 @@ impl Sidebar {
}); });
div() div()
.id(index) .id(tag.clone())
.w_full() .w_full()
.child(nav) .child(nav)
.context_menu(move |menu, _window, _cx| { .context_menu(move |menu, _window, _cx| {
@@ -370,8 +380,15 @@ impl Sidebar {
let dock = self.dock.clone(); let dock = self.dock.clone();
let room = hit.room.clone(); let room = hit.room.clone();
let content = hit.content.clone(); let content = hit.content.clone();
// Several hits can share a room, so the id digests the whole hit
let hit_id = common::stable_id_u64(&[
hit.room.read(cx).id.to_string().as_str(),
hit.author.to_string().as_str(),
hit.created_at.as_secs().to_string().as_str(),
hit.content.as_ref(),
]);
Nav::new(index) Nav::new(hit_id)
.label(content) .label(content)
.text_sm() .text_sm()
.font_medium() .font_medium()
-1
View File
@@ -34,7 +34,6 @@ info_plist_path = "resources/Info.plist"
[dependencies] [dependencies]
assets = { path = "../crates/assets" } assets = { path = "../crates/assets" }
workspace = { path = "../crates/workspace" } workspace = { path = "../crates/workspace" }
ui = { path = "../crates/ui" }
state = { path = "../crates/state" } state = { path = "../crates/state" }
nip4e = { path = "../crates/nip4e" } nip4e = { path = "../crates/nip4e" }
nip60 = { path = "../crates/nip60" } nip60 = { path = "../crates/nip60" }
File diff suppressed because it is too large Load Diff
-1
View File
@@ -10,7 +10,6 @@ crate-type = ["cdylib", "rlib"]
[dependencies] [dependencies]
assets = { path = "../crates/assets" } assets = { path = "../crates/assets" }
workspace = { path = "../crates/workspace" } workspace = { path = "../crates/workspace" }
ui = { path = "../crates/ui" }
state = { path = "../crates/state" } state = { path = "../crates/state" }
nip4e = { path = "../crates/nip4e" } nip4e = { path = "../crates/nip4e" }
chat = { path = "../crates/chat" } chat = { path = "../crates/chat" }