Compare commits
3
Commits
master
...
stable-audit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9f29dc398 | ||
|
|
fd802a8870 | ||
|
|
fe0ca04817 |
Generated
+2
@@ -1463,6 +1463,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 +1682,7 @@ dependencies = [
|
|||||||
"nostr",
|
"nostr",
|
||||||
"nostr-sdk",
|
"nostr-sdk",
|
||||||
"regex",
|
"regex",
|
||||||
|
"sha2 0.10.9",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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| {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
return;
|
Ok(HeaderLine::Eof) => Vec::new(),
|
||||||
}
|
Ok(HeaderLine::TooLong) => {
|
||||||
let request_line = request_line.trim_end().to_string();
|
send_response(&mut reader, 400, "Bad Request", "", "").await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
header_bytes += request_line.len();
|
||||||
|
let request_line = match String::from_utf8(request_line) {
|
||||||
|
Ok(line) => line.trim_end().to_string(),
|
||||||
|
Err(_) => {
|
||||||
|
send_response(&mut reader, 400, "Bad Request", "", "").await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Parse method, path, and HTTP version from request line
|
// 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;
|
||||||
|
}
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
header_bytes += line.len();
|
||||||
|
if header_bytes > MAX_HEADER_BYTES {
|
||||||
|
send_response(&mut reader, 400, "Bad Request", "", "").await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let line = line.trim_end().to_string();
|
|
||||||
|
let line = match String::from_utf8(line) {
|
||||||
|
Ok(line) => line.trim_end().to_string(),
|
||||||
|
Err(_) => {
|
||||||
|
send_response(&mut reader, 400, "Bad Request", "", "").await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
if line.is_empty() {
|
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.
|
|
||||||
async fn send_response_cors(
|
|
||||||
stream: &mut (impl AsyncWriteExt + Unpin),
|
|
||||||
status: u16,
|
|
||||||
status_text: &str,
|
|
||||||
content_type: &str,
|
|
||||||
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()));
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Timeout helper ───────────────────────────────────────────────────────
|
/// Get a parameter value from an optional query string.
|
||||||
|
fn query_param<'a>(query: Option<&'a str>, name: &str) -> Option<&'a str> {
|
||||||
|
let query = query?;
|
||||||
|
query.split('&').find_map(|pair| {
|
||||||
|
let (key, value) = pair.split_once('=')?;
|
||||||
|
(key == name).then_some(value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inject_session_token(html: &str, token: &str) -> String {
|
||||||
|
let script = format!("<script>window.proxySessionToken=\"{token}\";</script>");
|
||||||
|
match html.rfind("</body>") {
|
||||||
|
Some(index) => format!("{}{script}{}", &html[..index], &html[index..]),
|
||||||
|
None => format!("{html}{script}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 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()
|
||||||
|
|||||||
+18
-1
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+111
-23
@@ -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;
|
||||||
@@ -220,7 +221,7 @@ impl ChatPanel {
|
|||||||
|
|
||||||
// Define all functions that will run after the current cycle
|
// 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 +270,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 +331,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 +597,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 +614,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 +679,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();
|
||||||
|
|||||||
@@ -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
@@ -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"]));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, "👍 👍");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-44
@@ -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(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
match self.matches.next() {
|
if let Some(token) = self.handle_match(mat) {
|
||||||
Some(mat) => {
|
return Some(token);
|
||||||
// Skip any text before this match
|
|
||||||
if mat.start > self.last_match_end {
|
|
||||||
// Update pending match
|
|
||||||
// This will be handled at next iteration, in `handle_match` method.
|
|
||||||
self.pending_match = Some(mat);
|
|
||||||
|
|
||||||
// Skip the text before the match
|
|
||||||
self.last_match_end = mat.start;
|
|
||||||
return self.next();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle match
|
|
||||||
match self.handle_match(mat) {
|
|
||||||
HandleMatch::Token(token) => Some(token),
|
|
||||||
HandleMatch::Recursion => self.next(),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
None => None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// A long run of scanner matches that fail `Nip21::parse`: the count is
|
||||||
|
// attacker-controlled, so it must be consumed iteratively.
|
||||||
|
#[test]
|
||||||
|
fn skips_long_run_of_invalid_uris() {
|
||||||
|
let content = "nostr:1q".repeat(50_000);
|
||||||
|
|
||||||
|
let tokens: Vec<Token> = NostrParser::new().parse(&content).collect();
|
||||||
|
|
||||||
|
assert!(tokens.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn yields_valid_uri_after_invalid_run() {
|
||||||
|
let public_key = Keys::new(SecretKey::generate()).public_key();
|
||||||
|
let npub = public_key.to_bech32().unwrap();
|
||||||
|
let content = format!("{}nostr:{npub}", "nostr:1q".repeat(50_000));
|
||||||
|
|
||||||
|
let tokens: Vec<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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
+43
-17
@@ -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>> =
|
||||||
let me = signer.get_public_key_async().await?;
|
cx.background_spawn(async move {
|
||||||
let filter = Filter::new().author(me).kind(Kind::SimpleGroups).limit(1);
|
let me = signer.get_public_key_async().await?;
|
||||||
|
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)),
|
||||||
}
|
}
|
||||||
None => Ok(GroupList::default()),
|
});
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
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) => {
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
+78
-26
@@ -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,
|
||||||
announcement_existed.store(true, Ordering::Relaxed);
|
Kind::Custom(10044) | Kind::Custom(4454) | Kind::Custom(4455)
|
||||||
tx.send_async(event.into_owned()).await?;
|
) && current_user == Some(event.pubkey);
|
||||||
}
|
|
||||||
Kind::Custom(4454) if current_user == Some(event.pubkey) => {
|
if !relevant {
|
||||||
tx.send_async(event.into_owned()).await?;
|
continue;
|
||||||
}
|
}
|
||||||
Kind::Custom(4455) if current_user == Some(event.pubkey) => {
|
|
||||||
tx.send_async(event.into_owned()).await?;
|
if event.kind == Kind::Custom(10044) {
|
||||||
}
|
announcement_existed.store(true, Ordering::Relaxed);
|
||||||
_ => {}
|
}
|
||||||
|
|
||||||
|
let event_id = event.id;
|
||||||
|
// Dropping keeps the listener alive when the consumer is gone;
|
||||||
|
// missed 4e events are re-synced on the next signer change
|
||||||
|
if let Err(error) = tx.send_async(event.into_owned()).await {
|
||||||
|
log::error!("Dropping device key event {event_id}: {error}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The stream only ends without being replaced when the relay source is
|
||||||
|
// gone, after which no 4e event is processed anymore
|
||||||
|
log::error!("Device key notification listener stopped");
|
||||||
Ok(())
|
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
|
||||||
|
}
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
cx.update(|window, cx| {
|
if !superseded {
|
||||||
window.remove_notification::<DeviceNotification>(cx);
|
cx.update(|window, 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);
|
||||||
})
|
})
|
||||||
|
|||||||
+77
-8
@@ -55,6 +55,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 +72,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 +126,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,6 +193,16 @@ impl WalletRegistry {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drop a pending invoice at the user's request.
|
||||||
|
pub fn dismiss_mint_quote(&mut self, quote_id: String, cx: &mut Context<Self>) {
|
||||||
|
if !self.quotes.iter().any(|quote| quote.quote_id == quote_id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.quotes.retain(|quote| quote.quote_id != quote_id);
|
||||||
|
self.emit_updated(cx);
|
||||||
|
}
|
||||||
|
|
||||||
/// Pay a lightning invoice from the wallet.
|
/// Pay a lightning invoice from the wallet.
|
||||||
pub fn melt(&mut self, invoice: String, cx: &mut Context<Self>) {
|
pub fn melt(&mut self, invoice: String, cx: &mut Context<Self>) {
|
||||||
let Some(mint_url) = self.primary_mint(cx) else {
|
let Some(mint_url) = self.primary_mint(cx) else {
|
||||||
@@ -378,6 +397,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 +418,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 +439,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 +465,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 +572,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 +838,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 +849,7 @@ impl WalletRegistry {
|
|||||||
signer,
|
signer,
|
||||||
client,
|
client,
|
||||||
tokens,
|
tokens,
|
||||||
|
publish_failed,
|
||||||
};
|
};
|
||||||
|
|
||||||
job.run(ctx).await
|
job.run(ctx).await
|
||||||
@@ -823,7 +858,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 +868,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 {
|
||||||
@@ -905,6 +973,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);
|
||||||
|
|||||||
+128
-38
@@ -361,6 +361,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,35 +428,14 @@ 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];
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let update = self
|
let in_flight = wallet.get_proofs_by_states(states).await?;
|
||||||
.sync_token_events(
|
let in_flight_ys = proof_ys(in_flight.iter())?;
|
||||||
wallet,
|
|
||||||
nostr_mint(mint_url)?,
|
|
||||||
&previous_ids(&tokens, mint_url),
|
|
||||||
Some(keys::History {
|
|
||||||
direction: TransactionDirection::In,
|
|
||||||
amount: u64::from(minted),
|
|
||||||
redeemed: Vec::new(),
|
|
||||||
sender: None,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
for id in &update.destroyed {
|
|
||||||
tokens.remove(id);
|
|
||||||
}
|
|
||||||
tokens.insert(update.event_id, update.token.clone());
|
|
||||||
updates.push(update);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (mint_url, wallet) in &self.wallets {
|
|
||||||
let mut relay_ys = HashSet::new();
|
let mut relay_ys = HashSet::new();
|
||||||
let mut added = Vec::new();
|
let mut unseen = Vec::new();
|
||||||
|
|
||||||
for token in tokens
|
for token in tokens
|
||||||
.values()
|
.values()
|
||||||
@@ -468,23 +449,104 @@ where
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if relay_ys.insert(info.y) {
|
|
||||||
added.push(info);
|
if !relay_ys.insert(info.y) {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if local_ys.contains(&info.y) || in_flight_ys.contains(&info.y) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
unseen.push(info);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let unspent = wallet.get_unspent_proofs().await?;
|
if unseen.is_empty() {
|
||||||
let removed = unspent
|
continue;
|
||||||
.iter()
|
}
|
||||||
.filter(|proof| match proof.y() {
|
|
||||||
Ok(y) => !relay_ys.contains(&y),
|
|
||||||
Err(_) => false,
|
|
||||||
})
|
|
||||||
.map(|proof| proof.y())
|
|
||||||
.collect::<Result<Vec<_>, _>>()?;
|
|
||||||
|
|
||||||
wallet.localstore.update_proofs(added, removed).await?;
|
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
|
||||||
|
.sync_token_events(
|
||||||
|
wallet,
|
||||||
|
nostr_mint(mint_url)?,
|
||||||
|
&previous_ids(&tokens, mint_url),
|
||||||
|
Some(keys::History {
|
||||||
|
direction: TransactionDirection::In,
|
||||||
|
amount: u64::from(minted),
|
||||||
|
redeemed: Vec::new(),
|
||||||
|
sender: None,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for id in &update.destroyed {
|
||||||
|
tokens.remove(id);
|
||||||
|
}
|
||||||
|
tokens.insert(update.event_id, update.token.clone());
|
||||||
|
updates.push(update);
|
||||||
|
}
|
||||||
|
|
||||||
|
let relay_ys = relay_proof_ys(&tokens, mint_url);
|
||||||
|
let local_ys = proof_ys(wallet.get_unspent_proofs().await?.iter())?;
|
||||||
|
|
||||||
|
if local_ys != relay_ys {
|
||||||
|
if self.publish_failed {
|
||||||
|
let update = self
|
||||||
|
.sync_token_events(
|
||||||
|
wallet,
|
||||||
|
nostr_mint(mint_url)?,
|
||||||
|
&previous_ids(&tokens, mint_url),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for id in &update.destroyed {
|
||||||
|
tokens.remove(id);
|
||||||
|
}
|
||||||
|
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() {
|
||||||
|
Ok(y) => !relay_ys.contains(&y),
|
||||||
|
Err(_) => false,
|
||||||
|
})
|
||||||
|
.map(|proof| proof.y())
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
wallet.localstore.update_proofs(Vec::new(), removed).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(Outcome::Reconciled { updates })
|
Ok(Outcome::Reconciled { updates })
|
||||||
@@ -499,6 +561,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>,
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -108,6 +109,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>,
|
||||||
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+82
-21
@@ -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>) {
|
||||||
let settings = self.inner.read(cx);
|
// Consume the failed-load suppression, so the next user change saves again
|
||||||
if let Ok(content) = serde_json::to_string(&settings) {
|
if self.suppress_next_save {
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
self.suppress_next_save = false;
|
||||||
cx.background_spawn(async move {
|
return;
|
||||||
let path = config_dir().join(".settings");
|
}
|
||||||
smol::fs::write(&path, content).await.ok();
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
let settings = self.inner.read(cx);
|
||||||
if let Err(e) = common::web_store::set(SETTINGS_KEY, &content) {
|
let content = match serde_json::to_string(settings) {
|
||||||
log::warn!("failed to persist settings: {e}");
|
Ok(content) => content,
|
||||||
|
Err(error) => {
|
||||||
|
log::error!("failed to serialize settings: {error:#}");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
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 temporary_path = config_dir().join(".settings.tmp");
|
||||||
|
|
||||||
|
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")]
|
||||||
|
if let Err(error) = common::web_store::set(SETTINGS_KEY, &content) {
|
||||||
|
log::error!("failed to persist settings: {error:#}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(|| {
|
/// A handle to the live worker, spawning a replacement after an idle exit.
|
||||||
let (sender, receiver) = flume::unbounded::<Command>();
|
fn worker() -> Result<flume::Sender<Command>> {
|
||||||
|
let mut slot = worker_slot()
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|error| error.into_inner());
|
||||||
|
|
||||||
let spawned = std::thread::Builder::new()
|
if slot.as_ref().is_some_and(|sender| sender.is_disconnected()) {
|
||||||
.name("speech-parakeet".into())
|
*slot = None;
|
||||||
.spawn(move || run_worker(receiver));
|
}
|
||||||
|
|
||||||
if let Err(error) = spawned {
|
if let Some(sender) = slot.as_ref() {
|
||||||
log::error!("failed to spawn the speech worker: {error}");
|
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}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
sender
|
/// 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> {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
+12
-14
@@ -403,7 +403,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 +415,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,24 +429,21 @@ 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");
|
};
|
||||||
};
|
smol::fs::create_dir_all(parent).await?;
|
||||||
smol::fs::create_dir_all(parent).await?;
|
|
||||||
|
|
||||||
// Write under a temporary name first, so an interrupted download is never reused
|
// Write under a temporary name first, so an interrupted download is never reused
|
||||||
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))
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-8
@@ -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;
|
||||||
@@ -114,15 +116,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 +155,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 {
|
||||||
@@ -445,8 +463,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)
|
||||||
@@ -709,6 +733,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");
|
||||||
|
|||||||
@@ -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,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()
|
||||||
|
|||||||
@@ -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,15 +289,27 @@ 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 {
|
||||||
cx.update(|window, cx| {
|
Ok(_) => {
|
||||||
window.close_dialog(cx);
|
cx.update(|window, cx| {
|
||||||
window.push_notification("Report submitted successfully", cx);
|
window.close_dialog(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();
|
||||||
|
}
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn mutual_contacts(
|
fn mutual_contacts(
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
const DOCK_PLACEMENTS: [DockPlacement; 4] = [
|
||||||
|
DockPlacement::Center,
|
||||||
|
DockPlacement::Left,
|
||||||
|
DockPlacement::Right,
|
||||||
|
DockPlacement::Bottom,
|
||||||
|
];
|
||||||
|
|
||||||
/// The panel in any region of `area whose id is `key.
|
/// What makes a panel the same panel across opens.
|
||||||
fn find_panel(area: &DockArea, key: PanelId, _cx: &App) -> Option<(PanelId, NodeId, usize)> {
|
#[derive(Clone, PartialEq, Eq)]
|
||||||
let placements = [
|
enum PanelKey {
|
||||||
DockPlacement::Center,
|
/// A panel of which there should be one, keyed by its `panel_name`.
|
||||||
DockPlacement::Left,
|
Named(&'static str),
|
||||||
DockPlacement::Right,
|
/// A chat panel, keyed by the id of the room it displays.
|
||||||
DockPlacement::Bottom,
|
Room(SharedString),
|
||||||
];
|
}
|
||||||
|
|
||||||
for placement in placements {
|
/// The key a panel handle carries, or `None` when it cannot be read off the panel.
|
||||||
|
fn panel_key(panel: &PanelHandle, cx: &App) -> Option<PanelKey> {
|
||||||
|
let view = panel.panel();
|
||||||
|
if let Some(chat) = view.as_any().downcast_ref::<Entity<ChatPanel>>() {
|
||||||
|
return Some(PanelKey::Room(chat.read(cx).room_id().clone()));
|
||||||
|
}
|
||||||
|
if view.as_any().downcast_ref::<Entity<GroupPanel>>().is_some() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(PanelKey::Named(view.panel_name(cx)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The key of a panel docked in `area`, recovered through the handle base holds.
|
||||||
|
fn docked_panel_key(view: &Arc<dyn PanelView>, cx: &App) -> Option<PanelKey> {
|
||||||
|
let handle = PanelHandle::of(view)?;
|
||||||
|
panel_key(handle, cx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The docked panel whose key is `key`, and the dock it sits in.
|
||||||
|
fn find_panel(area: &DockArea, key: &PanelKey, cx: &App) -> Option<(PanelId, DockPlacement)> {
|
||||||
|
for placement in DOCK_PLACEMENTS {
|
||||||
let Some(tree) = area.layout(placement) else {
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -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
|
||||||
@@ -285,6 +289,7 @@ impl Workspace {
|
|||||||
Self {
|
Self {
|
||||||
dock,
|
dock,
|
||||||
sidebar,
|
sidebar,
|
||||||
|
group_panels: HashMap::new(),
|
||||||
tasks: vec![],
|
tasks: vec![],
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
}
|
}
|
||||||
@@ -319,6 +324,43 @@ impl Workspace {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn add_group_panel_to_dock(
|
||||||
|
&mut self,
|
||||||
|
group_key: GroupKey,
|
||||||
|
panel: PanelHandle,
|
||||||
|
placement: DockPlacement,
|
||||||
|
window: &mut Window,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) {
|
||||||
|
let panel_id = panel.panel_id(cx);
|
||||||
|
let docked = self.group_panels.get(&group_key).copied();
|
||||||
|
|
||||||
|
let mut added = false;
|
||||||
|
let mut focused = false;
|
||||||
|
|
||||||
|
self.dock.update(cx, |area, cx| {
|
||||||
|
if let Some(id) = docked
|
||||||
|
&& area.panel(id).is_some()
|
||||||
|
{
|
||||||
|
crate::dock::focus_panel(area, id, window, cx);
|
||||||
|
focused = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
crate::dock::add_panel(area, panel, placement, window, cx);
|
||||||
|
added = area.panel(panel_id).is_some();
|
||||||
|
});
|
||||||
|
|
||||||
|
if focused {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if added {
|
||||||
|
self.group_panels.insert(group_key, panel_id);
|
||||||
|
} else {
|
||||||
|
self.group_panels.remove(&group_key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Handle command events
|
/// 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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,8 +235,9 @@ 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 (ix, contact) in self.contacts.iter().enumerate() {
|
||||||
let profile = self.directory.read(cx).person(public_key, cx);
|
let public_key = contact.public_key;
|
||||||
|
let profile = self.directory.read(cx).person(&public_key, cx);
|
||||||
|
|
||||||
items.push(
|
items.push(
|
||||||
h_flex()
|
h_flex()
|
||||||
@@ -262,12 +268,9 @@ impl ContactListPanel {
|
|||||||
.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();
|
this.remove(&public_key, cx);
|
||||||
cx.listener(move |this, _ev, _window, cx| {
|
})),
|
||||||
this.remove(&public_key, cx);
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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| {
|
||||||
updater.update(cx, |this, cx| {
|
if let Some(updater) = AutoUpdater::try_global(cx) {
|
||||||
this.restart(cx);
|
updater.update(cx, |this, cx| {
|
||||||
});
|
this.restart(cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
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;
|
||||||
@@ -21,7 +23,6 @@ use nostr_sdk::prelude::*;
|
|||||||
use person::{Directory, PersonRegistry};
|
use person::{Directory, PersonRegistry};
|
||||||
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 +45,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 +53,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 +67,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 +86,13 @@ 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,
|
||||||
|
/// 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 +102,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| {
|
||||||
@@ -209,10 +205,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 +260,8 @@ impl Sidebar {
|
|||||||
global_task: None,
|
global_task: None,
|
||||||
chat_progress: 0.0,
|
chat_progress: 0.0,
|
||||||
sync_progress: 0.0,
|
sync_progress: 0.0,
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
updater_display,
|
||||||
tasks,
|
tasks,
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
@@ -318,8 +328,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,
|
||||||
|
|||||||
@@ -0,0 +1,996 @@
|
|||||||
|
# Coop Stability Audit — Pre-Release
|
||||||
|
|
||||||
|
Date: 2026-11-12 · Revision audited: `8fa79aef` (master) · Scope: entire workspace (~35k lines, 18 crates + `desktop` + `web`)
|
||||||
|
|
||||||
|
> **Remediation status (2026-11-12, later the same day):** all ten P0 items from §8 have been
|
||||||
|
> implemented and validated (workspace clippy clean, `cargo fmt --check` clean, 35 tests passing).
|
||||||
|
> See §9 for the per-fix log. The P1 list (committed as `fd802a88` base) has also been processed —
|
||||||
|
> see §10.
|
||||||
|
|
||||||
|
## 1. Scope and Method
|
||||||
|
|
||||||
|
Every crate was audited file-by-file against the project conventions (`AGENTS.md`), Rust best
|
||||||
|
practices (ownership, error handling, async, API design, anti-patterns), and GPUI lifecycle rules
|
||||||
|
(entity updates, task/subscription ownership, `ElementId` stability, render purity). Severe claims
|
||||||
|
were verified against the vendored dependency sources (`gpui-pre 0.3.12`, `gpui-base 0.9.1`,
|
||||||
|
`gpui-component 0.9.1`, rust-nostr 0.45.6, `gpui-updater-pre 0.2.0`), and the highest-severity
|
||||||
|
findings were independently re-verified by hand.
|
||||||
|
|
||||||
|
Tooling results: `cargo clippy --workspace --all-targets` — **clean, zero warnings**.
|
||||||
|
`cargo fmt --all --check` — **clean**. `cargo check -p workspace` and `-p person` — pass.
|
||||||
|
|
||||||
|
## 2. Executive Summary
|
||||||
|
|
||||||
|
**Verdict: not ready for stable release yet.** The codebase is well-structured — clean clippy,
|
||||||
|
disciplined GPUI entity/subscription hygiene in most crates, good hostile-input handling in the
|
||||||
|
crypto-adjacent code, and no lock-across-await anywhere. But there are **3 Critical** and
|
||||||
|
**~27 High** findings that must be addressed first, concentrated in three areas:
|
||||||
|
|
||||||
|
1. **Money and key safety** (`nip60`, `state`, `browser-signer-proxy`): a one-event wallet-wipe
|
||||||
|
vector, a startup crash loop, a plaintext client key in web `localStorage`, and an
|
||||||
|
unauthenticated localhost signing API.
|
||||||
|
2. **Silent failure paths**: errors from wallet ops, message loads, relay connects, settings
|
||||||
|
persistence, and backup/keyring reads are swallowed; several failure states strand data or
|
||||||
|
wedge the UI (stuck `loading_more`, permanently-blacklisted profile fetches, dictation dying
|
||||||
|
after 30 s).
|
||||||
|
3. **Wrong results visible to users**: reaction double-counting, multi-line messages flattened to
|
||||||
|
one line, contact-list updates that erase relay hints and petnames, stale group state from
|
||||||
|
out-of-order relay delivery, duplicate tabs.
|
||||||
|
|
||||||
|
### Must fix before stable (P0)
|
||||||
|
|
||||||
|
| # | Severity | Location | Issue |
|
||||||
|
|---|----------|----------|-------|
|
||||||
|
| 1 | Critical | `nip60/src/lib.rs:420,552` | Spoofed kind:5 deletion from **any** author wipes wallet proofs (operator precedence bug + missing author check in `absorb_deletion`) |
|
||||||
|
| 2 | Critical | `state/src/lib.rs:117-122` | LMDB open `block_on` + `.expect` → unrecoverable startup crash loop on any DB error |
|
||||||
|
| 3 | Critical | `state/src/lib.rs:447-449` | Plaintext NIP-46 client secret key persisted to `localStorage` on web |
|
||||||
|
| 4 | High | `browser-signer-proxy/src/lib.rs:443-571` | Unauthenticated localhost signing API with `Access-Control-Allow-Origin: *`; plaintext theft, request suppression, response forgery |
|
||||||
|
| 5 | High | `nip60/src/ops.rs:455-488` | Reconcile marks relay proofs `Unspent` unconditionally → resurrects locally-spent proofs (double-spend risk) |
|
||||||
|
| 6 | High | `nip60/src/ops.rs:381-417` | Publish failure after store commit strands sent/received proofs; no retry path |
|
||||||
|
| 7 | High | `state/src/file.rs:418-443` | Decrypted-file cache keyed by filename only → user can be served another attachment's contents |
|
||||||
|
| 8 | High | `chat_ui/src/lib.rs:605-611` | Own reactions counted twice (backup gift-wrap re-delivery, no dedup) |
|
||||||
|
| 9 | High | `common/src/media_extractor.rs:52-58` | Whitespace collapse destroys line breaks in every message |
|
||||||
|
| 10 | High | `workspace/src/panels/contact_list.rs:187-191` | Contact-list Update silently strips NIP-02 relay hints and petnames from the published event |
|
||||||
|
| 11 | High | `nip29/src/lib.rs:541-555` + `group.rs:696-728` | No `created_at` guards: stale relay data resurrects left groups; group state applied last-write-wins |
|
||||||
|
| 12 | High | `settings/src/lib.rs:403-404,423-438` | Failed settings load silently resets and overwrites the user's file; saves discard errors and race |
|
||||||
|
| 13 | High | `speech/src/parakeet.rs:134` | Dictation worker exits after 30 s idle → longer dictations silently lost |
|
||||||
|
| 14 | High | `browser-signer-proxy/src/lib.rs:339-366` | Timed-out signing requests leak plaintext in memory forever |
|
||||||
|
| 15 | High | `dock/mod.rs:50-73` + `sidebar/rows.rs:54-57` | Tab dedup is dead code (duplicate tabs); "Ignore" screening action dispatches to a handler that can never receive it |
|
||||||
|
| 16 | High | `nip60_ui/src/wallet.rs:615-726` + `nip60/src/lib.rs:147-185` | Lightning invoice flow broken end-to-end (quotes never rendered or claimable) |
|
||||||
|
| 17 | High | `state/src/lib.rs:553-554` | Proxy credential write silently discarded (`save.await.ok()`) |
|
||||||
|
| 18 | High | `person/src/lib.rs:310-333` | One 2 s metadata timeout permanently blacklists those profiles until restart |
|
||||||
|
| 19 | High | `chat/src/lib.rs:398-403` | Failed-to-decrypt messages are trashed with no UI surface anywhere |
|
||||||
|
| 20 | High | `chat_ui/src/lib.rs:325-334`, `workspace/src/dialogs/profile.rs:227-235`, `workspace/src/panels/backup.rs:59-76` | Silent failure cluster: room loads, profile URL saves, keyring reads |
|
||||||
|
|
||||||
|
The full finding list follows, crate by crate.
|
||||||
|
|
||||||
|
## 3. Systemic Patterns (cross-cutting)
|
||||||
|
|
||||||
|
**S1 — Task handles accumulate unboundedly (~20 containers, ~48 declaration sites).**
|
||||||
|
`tasks: Vec<Task<_>>` / `SmallVec<[Task<_>; N]>` fields are pushed on every operation and never
|
||||||
|
drained; finished `Task` handles held in a container are not reaped by GPUI. Worst offenders:
|
||||||
|
`nip60/src/lib.rs:79` (a handle per relay event, whole session), `nip4e/src/lib.rs:90` (runs on
|
||||||
|
every settings change), `chat_ui/src/lib.rs:110`, `nip29/src/lib.rs:90`, `nip29_ui/src/lib.rs:337`,
|
||||||
|
`person/src/directory.rs:110`, `state/src/lib.rs:95`, plus every workspace dialog. The codebase
|
||||||
|
already contains the correct patterns: single `Option<Task<_>>` replacement
|
||||||
|
(`screening.rs`, `greeter.rs`, `debounced_delay.rs`) and reset-on-teardown (`nip29::reset`).
|
||||||
|
Fix convention-wide: replace-per-spawn for one-shots, `detach_and_log_err` for true fire-and-forget.
|
||||||
|
|
||||||
|
**S2 — Silent error discards (~156 `let _ =` / `.ok()` sites outside tests).**
|
||||||
|
Project rule forbids `let _ =` on fallible ops; `AGENTS.md` requires async errors to reach the UI.
|
||||||
|
Beyond the P0s above, the recurring case is `_ = this.update(cx, …)` — mostly benign
|
||||||
|
(entity dropped at shutdown) but should be `.log_err()` for visibility. Files with the most sites:
|
||||||
|
`dock/tab_panel.rs` (6), `nip60/src/lib.rs` (10), `nip4e/src/lib.rs` (8).
|
||||||
|
|
||||||
|
**S3 — `chat_ui` ↔ `nip29_ui` message-panel duplication.**
|
||||||
|
`ListState` sync + splice bookkeeping (including divergent `ix + 1` vs `ix + 2` offsets),
|
||||||
|
`rendered_texts_by_id` cache (both share the stale-mention bug), speech reveal + `RevealText`
|
||||||
|
fallback timer + `end_events` loop, `Speaker` wiring, reaction grouping, `MessageRow` assembly are
|
||||||
|
near-verbatim copies — and they have already diverged: `nip29_ui` splices at the prepend position
|
||||||
|
while `chat_ui` always appends (bug 5.7.4). Extract a shared message-panel controller in `ui`.
|
||||||
|
|
||||||
|
**S4 — Dialog error-banner + auto-clear timer duplicated four times** with drifted timings
|
||||||
|
(`restore.rs` 3 s, `import.rs` 3 s, `relays.rs` 2 s, `contact_list.rs` 2 s). One shared
|
||||||
|
`error: Entity<Option<SharedString>>` helper removes the drift.
|
||||||
|
|
||||||
|
**S5 — `DefaultHasher` used for network-persisted IDs.**
|
||||||
|
`common::EventExt::uniq_id` (`event.rs:12-18`) is hashed into published `r` tags by
|
||||||
|
`chat/src/lib.rs:1031-1041`; `DefaultHasher`'s algorithm is not stable across Rust versions, so a
|
||||||
|
toolchain bump silently changes every published room id. Also hand-rolled in three places
|
||||||
|
(`nip29/protocol.rs:282-286`, `nip29/utils.rs:27-32`). Replace with a stable hash (SHA-256 of
|
||||||
|
sorted hex pubkeys) in one shared helper.
|
||||||
|
|
||||||
|
**S6 — Index-based element ids.** Rows keyed by list index (`sidebar/rows.rs:174+`,
|
||||||
|
`contact_list.rs:238`, `greeter.rs:226+`, `nip29_ui` reply previews `message.rs:359`) reassign to
|
||||||
|
different data after mutations; benign today because handlers capture data, but any future
|
||||||
|
stateful element (hover/focus) in a row will mis-attach. Prefer domain ids (pubkey hex, event id,
|
||||||
|
room id) as `group_view.rs` already does.
|
||||||
|
|
||||||
|
## 4. Findings by Crate
|
||||||
|
|
||||||
|
Severity: **[C]** Critical · **[H]** High · **[M]** Medium · **[L]** Low
|
||||||
|
|
||||||
|
### 4.1 `state` — central Nostr registry, signers, persistence, files
|
||||||
|
|
||||||
|
- **[C] `lib.rs:117-122` — Startup LMDB open: `block_on` + `.expect` = crash loop.** The
|
||||||
|
`block_on` itself is acceptable (runs once before the window opens; `NostrLmdb::open` schedules
|
||||||
|
nothing back onto the GPUI executor — add a comment saying so). The real bug: a corrupted or
|
||||||
|
unwritable data dir makes **every** launch panic with no dialog and no recovery except manually
|
||||||
|
deleting data. The web path already does it right (`web_database.rs:30-33` falls back to memory
|
||||||
|
with a logged error). Fix: mirror that on desktop — memory DB fallback + `StateEvent::Error` so
|
||||||
|
the UI can tell the user local history is unavailable.
|
||||||
|
- **[C] `lib.rs:447-449` (+ `credentials.rs:29-38`, `common/web_store.rs:21-25`) — Plaintext
|
||||||
|
bunker client key in web `localStorage`.** `import_bunker` embeds a fresh `Keys::generate()` into
|
||||||
|
the bunker URI and stores the credential unencrypted on wasm. XSS/malicious extension/shared
|
||||||
|
machine = signature capability through the bunker (bunkers commonly auto-approve registered
|
||||||
|
clients). Desktop stores it in the OS keyring, which is fine. Fix: on wasm, passphrase-encrypt
|
||||||
|
(NIP-49) like `import_nsec` does, or keep the session memory-only.
|
||||||
|
- **[H] `lib.rs:553-554` — Proxy credential write silently discarded.**
|
||||||
|
`cx.background_spawn(async move { save.await.ok() }).detach();` — keyring failure = user silently
|
||||||
|
dropped to login next launch. Log + emit `StateEvent::error`.
|
||||||
|
- **[H] `file.rs:418-443` — Decrypted-file cache collides by filename.** Cache key is
|
||||||
|
`sha256(name)` and the `metadata().is_err()` guard skips the write, so two attachments named
|
||||||
|
`invoice.pdf` from different chats resolve to the **first** file's path. Fix: include the
|
||||||
|
attachment sha256/URL in the key, or always overwrite via the atomic-rename path.
|
||||||
|
- **[M] `lib.rs:373-386` — Web `ncryptsec` stored with `KeySecurity::Medium` (2¹⁶ PBKDF2).**
|
||||||
|
Encrypted, but offline guessing at 2¹⁶ defeats human passphrases. Raise to `Strong` and/or move
|
||||||
|
to IndexedDB + session-only keys. Acceptable if documented as a tradeoff.
|
||||||
|
- **[M] `lib.rs:564-589` — Stale proxy monitor can log out a newer session.** The monitor's guard
|
||||||
|
is only `current_user.is_some()`; if the user disconnects the proxy and signs in with nsec/bunker
|
||||||
|
before the next 5 s tick, the monitor swaps the **active** signer for `Keys::generate()`. Fix:
|
||||||
|
capture the proxy pubkey at spawn and only act if it's still the active signer.
|
||||||
|
- **[M] `lib.rs:95` — `tasks` vec grows forever** (see S1; `connect_proxy` alone pushes four).
|
||||||
|
- **[L] `lib.rs:714` `to_bech32().expect("infallible")`; `signer.rs` ×7
|
||||||
|
`.expect("RwLock poisoned")`.** Practically unreachable, but a poisoned signer lock is the worst
|
||||||
|
failure mode for a signer — prefer `unwrap_or_else(|e| e.into_inner())`.
|
||||||
|
- **[L] `lib.rs:93` — `passphrase_required` written, never read** (UI learns via
|
||||||
|
`StateEvent::PassphraseRequired`). Delete or expose.
|
||||||
|
- **[L] `file.rs:381-384` — `DownloadedFile::save` (wasm) has no callers.** Remove or wire up.
|
||||||
|
- **[L] `lib.rs:647-656` — Dead search-relay guard.** Vendored nostr-sdk: `Client::relay()` returns
|
||||||
|
`Result<Option<_>>`, so `is_ok()` is always true and the "Failed to add search relay" branch is
|
||||||
|
unreachable. Test `is_ok_and(|r| r.is_some())` or drop the check.
|
||||||
|
- **[L] `file.rs:221-258` — No size limit on pick/upload** (`MAX_FILE_SIZE` enforced only on
|
||||||
|
download); a multi-GB pick is read fully into memory. Check size before reading.
|
||||||
|
- **[L] `file.rs:427-441` — Plaintext decrypted files in `temp_dir()/coop-files`, default
|
||||||
|
permissions, never purged.** Consider 0600 + best-effort purge on exit. Also `file.rs:150`
|
||||||
|
builds `file://{path}` by string concat (breaks on `#`/`?`/`%`).
|
||||||
|
- **[L] `file.rs:291` — Blossom rejection classified by `message.contains("415")`** — match the
|
||||||
|
HTTP status instead. `file.rs:172` — 16-byte GCM nonce; 12 bytes is the norm.
|
||||||
|
- **[L] `lib.rs:280-289` — `restore_session` cfg branches compile to empty bodies on wrong
|
||||||
|
target**, silently emitting no event. `debug_assert!` or fall through to `NoSigner`.
|
||||||
|
- **[L] `file.rs:446-451` + `web_file.rs:164-174` — Web blob URLs never revoked** when replaced;
|
||||||
|
every decrypted file pins its bytes for the page lifetime.
|
||||||
|
- **[L] `lib.rs:683-687` — Search results can contain duplicate pubkeys.** Dedupe.
|
||||||
|
|
||||||
|
Positives: device key stored only NIP-44-encrypted to self in the local DB; bunker credentials
|
||||||
|
verified before persisting; `UniversalSigner` clones the inner `Arc` out of the lock before
|
||||||
|
awaiting (no locks across `.await`); downloads size-capped and hash-verified; `Keys`' vendored
|
||||||
|
`Debug` hides secrets.
|
||||||
|
|
||||||
|
### 4.2 `nip60` — NIP-60 Cashu wallet engine
|
||||||
|
|
||||||
|
- **[C] `lib.rs:420` + `lib.rs:552-572` — Spoofed kind:5 deletion can wipe the wallet.**
|
||||||
|
`let wallet_event = ours && is_cashu || is_token_deletion(&event);` parses as
|
||||||
|
`(ours && is_cashu) || is_token_deletion(&event)` — deletion events from **any** author are
|
||||||
|
forwarded, and `absorb_deletion` never checks `event.pubkey == me`. Kind 7375 event ids are
|
||||||
|
public on relays; one attacker event removes our tokens, marks them stale, and the Reconcile
|
||||||
|
deletes the real proofs from the store (restore-only recovery). Fix:
|
||||||
|
`wallet_event = ours && (is_cashu || is_token_deletion(&event))` + author check inside
|
||||||
|
`absorb_deletion` for defense in depth.
|
||||||
|
- **[H] `ops.rs:455-488` (with `proof_info` `ops.rs:601-617`) — Reconcile resurrects spent
|
||||||
|
proofs.** Every relay-token proof is added to the store hardcoded `State::Unspent`, ignoring the
|
||||||
|
proof's existing store state. After any failed-publish op, reconcile re-adds proofs this device
|
||||||
|
itself marked spent/reserved (incl. offline sends the recipient also holds) → bogus balance,
|
||||||
|
real double-spend race. Fix: skip `y`s already in the store with non-Unspent state; verify
|
||||||
|
unknown proofs against the mint (NUT-08) before adding.
|
||||||
|
- **[H] `ops.rs:381-417` + `lib.rs:836-843` — Publish failure after store commit strands funds.**
|
||||||
|
If `send_event` fails, the confirmed `TokenUpdate` is dropped: for `SendToken` the token string
|
||||||
|
is lost (proofs spent locally, recipient never gets it); for `Receive` proofs sit unpublished and
|
||||||
|
a retry errors as duplicate. Reconcile only republishes when `mint_unissued_quotes() != 0`
|
||||||
|
(`ops.rs:429-432`), so it doesn't heal this. Fix: retain the confirmed update on error; force
|
||||||
|
republish whenever the store's unspent set differs from relay state.
|
||||||
|
- **[H] `ops.rs:420-453` — Quote-minting publish can `del` events whose proofs aren't converged
|
||||||
|
yet.** `previous_ids` is the schedule-time relay snapshot; if another device's token was absorbed
|
||||||
|
but not yet converged, the published event `del`s the id while omitting its proofs — those proofs
|
||||||
|
get pruned everywhere while remaining spendable at the mint. Fix: converge before publish, or
|
||||||
|
only `del` when the published proof set fully covers the id.
|
||||||
|
- **[H] `lib.rs:616,651` + `lib.rs:902-912` — Failed nutzap redemption stuck until restart.**
|
||||||
|
`redeeming.insert` happens before scheduling; the `Err` branch never removes the id and
|
||||||
|
`absorb_nutzap` skips anything in `redeeming`, and nothing requeues to `pending_nutzaps`. Fix:
|
||||||
|
remove + requeue with bounded retries on error.
|
||||||
|
- **[H] `lib.rs:79` — `tasks` grows by a handle per relay event** (see S1).
|
||||||
|
- **[H] `lib.rs:147-149,172-185` — `mint_quotes()`/`claim_mint_quote()` dead → created invoices
|
||||||
|
are never displayable or claimable.** `Outcome::QuoteCreated` pushes into `self.quotes` and
|
||||||
|
nothing ever reads it (workspace-wide grep). Quotes also accumulate forever. Fix with the invoice finding in 4.3 below.
|
||||||
|
- **[M] `lib.rs:188-215` — `melt()` and `send_token()` unreachable; `WalletEvent::TokenCreated`
|
||||||
|
emitted only from the dead path** while `workspace/src/lib.rs:226-236` promises "copy it from
|
||||||
|
the wallet panel". Wire into UI or remove; fix the notification text regardless.
|
||||||
|
- **[M] `lib.rs:285-370` — Concurrent `setup()` can fork the wallet key** (only guarded by
|
||||||
|
`loading`); absorption tie-breaks with `created_at >=` on second-granularity timestamps, and
|
||||||
|
stores are keyed by wallet pubkey — the losing key orphans its funds. Fix: `setup_in_progress`
|
||||||
|
guard; deterministic tie-break (event id).
|
||||||
|
- **[M] `lib.rs:430-432` — `processed_events.clear()` at 1000 entries** re-delivers a burst;
|
||||||
|
evict oldest instead.
|
||||||
|
- **[M] `lib.rs:615-661` — Nutzap `unit` tag never validated**; a `usd` nutzap passes local
|
||||||
|
validation. Require `unit == "sat"` in `Nutzap::from_event`.
|
||||||
|
- **[L] `lib.rs:301-303,699-701` — Silent no-ops**: `setup()` when mint already listed;
|
||||||
|
`ensure_wallets` on unparseable mint URLs forever. Surface feedback.
|
||||||
|
- **[L] `ops.rs:107` — ClaimQuote history records `quote.amount.unwrap_or_default()`** (0-sat
|
||||||
|
inflow when mint omits amount); use the summed proof amount.
|
||||||
|
- **[L] `Cargo.toml:23` — `tokio` dep unused** (only `gpui_tokio::Tokio` is referenced). Remove.
|
||||||
|
|
||||||
|
Positives: store-before-publish enforced via cdk prepare/confirm sagas; all relay-derived content
|
||||||
|
author-checked before decryption; `keys.rs` secret handling clean (no Debug on `WalletData`, no
|
||||||
|
secrets in logs, HKDF salt pinned by test); single-flight job queue; `WalletEvent::Error` reaches
|
||||||
|
notifications even with the panel closed.
|
||||||
|
|
||||||
|
### 4.3 `nip60_ui` — wallet panel + dialogs
|
||||||
|
|
||||||
|
- **[H] `wallet.rs:615-726` — Invoice button creates an invoice the user can never see or pay.**
|
||||||
|
After `create_mint_quote` the bolt11 string lands in `WalletRegistry::quotes`, which nothing
|
||||||
|
renders. Receive-via-lightning is broken end-to-end, silently. Fix: render pending quotes
|
||||||
|
(request + copy + check/claim) in the dialog/panel, refreshed on `WalletEvent::Updated`.
|
||||||
|
- **[M] `wallet.rs:765` — `render()` clones the whole history `Vec` every frame** (history is
|
||||||
|
unbounded). Snapshot into `Rc` only on `WalletEvent::Updated`. Same for `filtered_contacts`
|
||||||
|
(`wallet.rs:542`) — cache on input change.
|
||||||
|
- **[L] `wallet.rs:206,284,356` — `SendDialog::tasks` accumulates** (see S1).
|
||||||
|
- **[L] `wallet.rs:262-264` — `load_contacts` gives up silently pre-login**; retry on
|
||||||
|
`StateEvent::SignerChanged` (sidebar already does this).
|
||||||
|
- **[L] `wallet.rs:728-733` vs `lib.rs:28-34` — amount parsing duplicated.** Share `parse_amount`.
|
||||||
|
|
||||||
|
Positives: `cx.notify()` on all mutating paths; per-key hex ElementIds; stored subscriptions;
|
||||||
|
history list windowed via `uniform_list`.
|
||||||
|
|
||||||
|
### 4.4 `browser-signer-proxy` — NIP-46 localhost bridge
|
||||||
|
|
||||||
|
- **[H] `lib.rs:443-571` — Unauthenticated API + `ACAO: *`.** Every response sends
|
||||||
|
`Access-Control-Allow-Origin: *` (verified at :556, :588, :607, :631); no `Host`/`Origin` checks.
|
||||||
|
Consequences: (1) `GET /api/pending` returns full `sign_event`/`nip44_encrypt` params (message
|
||||||
|
plaintext) readable by any web page in Firefox-family browsers and by DNS rebinding anywhere;
|
||||||
|
(2) `:516-517` clears the queue on read → third party can steal requests so the real extension
|
||||||
|
page never sees them; (3) `POST /api/response` accepts any `{id, result}` → forged
|
||||||
|
`get_public_key` substitutes an attacker pubkey as "the user". Fix: drop CORS entirely (the
|
||||||
|
served page is same-origin), reject non-`127.0.0.1:7400` Host, gate `/api/*` on a per-session
|
||||||
|
random token embedded in the served page.
|
||||||
|
- **[H] `lib.rs:339-366` — Timed-out requests leak forever.** The `RequestData` and oneshot sender
|
||||||
|
stay in the maps after the 30 s timeout — plaintext retained for the session, re-served to
|
||||||
|
whoever polls next, map grows unboundedly. Remove entries on timeout (self-removing guard or
|
||||||
|
id-returning `request`).
|
||||||
|
- **[M] `lib.rs:374-380` — `_sign_event` verifies the signature but not the event.** Nothing checks
|
||||||
|
the returned event matches the requested `UnsignedEvent` (pubkey/content/tags/kind/created_at);
|
||||||
|
a compromised signer page can substitute content. Compare before accepting.
|
||||||
|
- **[M] `lib.rs:538` — Decrypted plaintext debug-logged.** `tracing::debug!("Received response …
|
||||||
|
{message:?}")` leaks `nip44_decrypt` output and event content the moment anyone runs with
|
||||||
|
`RUST_LOG=debug`. Log id + method only.
|
||||||
|
- **[M] `lib.rs:447,465-480,523` — Unbounded reads/allocations**: header line grows unbounded;
|
||||||
|
`vec![0u8; content_length]` trusts `Content-Length` (4 GiB reservation from one POST); no
|
||||||
|
connection timeout. Cap header block (16 KiB), cap body (1 MiB), add idle timeout.
|
||||||
|
- **[L] `lib.rs:650-678` — `race_timeout` reports channel-closed as timeout.** Distinguish.
|
||||||
|
- **[L] `lib.rs:574-638` — Three near-identical response writers**; one function with a header
|
||||||
|
options struct (also makes the CORS removal enforceable in one place).
|
||||||
|
- **[L] `lib.rs:546,563,593,612,636` — bare `let _ =` on writes**; centralize the
|
||||||
|
best-effort-write rationale in the helper.
|
||||||
|
|
||||||
|
Positives: loopback-only bind, graceful shutdown tied to handle lifetime, per-request timeouts,
|
||||||
|
UUID ids, bounded channels.
|
||||||
|
|
||||||
|
### 4.5 `nip4e` — per-device keys
|
||||||
|
|
||||||
|
- **[M] `lib.rs:1036,774-860` — Approval button wedged + double-clickable.** `loading.set(true)`
|
||||||
|
without `cx.notify()` keeps the old button enabled (double-click sends the secret in two 4455
|
||||||
|
events); on failure `loading` is never reset and the notification isn't removed. Notify after
|
||||||
|
setting, reset on error, rebuild the notification on both outcomes.
|
||||||
|
- **[M] `lib.rs:403-405` — Rotation skips archiving silently, then overwrites the key.** If the DB
|
||||||
|
read/decrypt fails transiently, `archive_key` is skipped but `set_keys` still overwrites → old
|
||||||
|
chats permanently undecryptable. Make archive failure abort rotation (or log loudly).
|
||||||
|
- **[M] `lib.rs:175-206` — Relay listener can die silently.** `tx.send_async(...).await?` aborts on
|
||||||
|
full bounded(100) channel or dead consumer; the task's `Err` is never inspected. All 4e event
|
||||||
|
processing stops until the next signer change. Log and handle backpressure.
|
||||||
|
- **[M] `lib.rs:90` — `tasks` grows on every settings change** (`get_announcement` runs per
|
||||||
|
settings mutation) — see S1.
|
||||||
|
- **[M] `lib.rs:177,184` — `processed_events` inserts **every** relay event** (insert precedes the
|
||||||
|
kind filter) and grows unbounded. Filter first; bound the set.
|
||||||
|
- **[L] `lib.rs:303-309` — Desktop key backup file written 0644** (subject to umask); use 0600.
|
||||||
|
- **[L] `lib.rs:178` — `current_user` snapshotted per listener**; events between signer change and
|
||||||
|
`get_public_key_async()` resolving are dropped, and a stale snapshot can briefly forward one
|
||||||
|
account's events to the next handler. Re-check per event.
|
||||||
|
- **[L] `storage.rs:29-32` — Malformed stored `created_at` silently resets the rotation
|
||||||
|
interval.** Log the fallback.
|
||||||
|
- **[L] Zeroization gaps** (`app_keys`, decrypted secrets in `decrypt_approval`/`approve`): Low —
|
||||||
|
the nostr stack itself doesn't zeroize; note as defense-in-depth.
|
||||||
|
- **[L] `lib.rs:141,246,426,458,845,854,894,1041` — `.ok()` discards** (see S2); `lib.rs:479`
|
||||||
|
single-letter `n`.
|
||||||
|
- **[L] `lib.rs:136,241,888,914` — `cx.windows().first()` targets an arbitrary window** (matters
|
||||||
|
only if multi-window ever lands).
|
||||||
|
|
||||||
|
Positives: secret never published/logged; transfer encrypted to a fresh one-time keypair;
|
||||||
|
approvals re-validated against the live announcement; stale requests dropped by age; bounded
|
||||||
|
archive; account switch wipes pending state.
|
||||||
|
|
||||||
|
### 4.6 `chat` — chat registry + rooms
|
||||||
|
|
||||||
|
- **[H] `lib.rs:331-338` + `message.rs:203-220` — Failed-to-decrypt messages silently trashed;
|
||||||
|
nothing renders `trash`.** The `trash: Entity<BTreeSet<FailedMessage>>` is written, cleared, and
|
||||||
|
reprocessed, but has **no reader or renderer workspace-wide**. A user whose incoming message
|
||||||
|
can't be decrypted gets zero feedback — core E2EE UX gap. Emit a `ChatEvent` or render an
|
||||||
|
indicator.
|
||||||
|
- **[M] `lib.rs:284,327,620-635,726,733,854,892` — `RwLock` `.read().unwrap()`/`.write().unwrap()`
|
||||||
|
poisoning crash loop.** `search.write()` runs on a background thread; one panic while holding it
|
||||||
|
turns every later foreground read into a panic. These maps are reconstructible heuristics — use
|
||||||
|
the tolerant `poisoned.into_inner()` pattern the codebase already has
|
||||||
|
(`person/src/directory.rs:226-233`).
|
||||||
|
- **[M] `lib.rs:779-780` — Off-by-one index stored in `room_map`** (`rooms.len()` after push).
|
||||||
|
Currently unreachable (batch rooms come from an id-keyed set) but any future reader panics at
|
||||||
|
`self.rooms[index]`. Store `len() - 1`, or drop the never-usefully-read index bookkeeping.
|
||||||
|
- **[M] `lib.rs:440-443,454-461,825-833` — Discarded subscribe result + `unwrap_or_default()`.**
|
||||||
|
`_ = client.subscribe(...)` (rule violation); a DB error becomes "not found" → false
|
||||||
|
"Messaging Relays not found" retry prompt, and a contacts DB error classifies peer rooms as
|
||||||
|
`Request` instead of `Ongoing` (wrong trust classification). Distinguish errors from empty.
|
||||||
|
- **[M] `lib.rs:110,199,232-358,362-414` — Stored task results never inspected** (see S1/S2); a
|
||||||
|
dead notification listener silently stops message delivery — at least `detach_and_log_err`.
|
||||||
|
- **[L] `room.rs:637-646` — `deliver<S>` generic has one implementation**; inline it.
|
||||||
|
- **[L] `lib.rs:867` — `max_by_key().unwrap()`** relies on an invariant three lines up;
|
||||||
|
`let Some(...) else { continue }` documents it.
|
||||||
|
- **[L] `lib.rs:332-338` — Empty-tags rumor is both trashed and delivered** (appears as failure
|
||||||
|
entry *and* rendered message until reprocess). Pick one path.
|
||||||
|
- **[L] `room.rs:350` — `self.members[0]` safe only via a cross-crate invariant**
|
||||||
|
(`extract_public_keys` always appends the author). Add `debug_assert!` or a comment.
|
||||||
|
- **[L] `Cargo.toml:28-29` — `smol` unused.** Remove.
|
||||||
|
- **[L] `lib.rs:182-186` — `let _ = entity.update(...)`** in deferred init; `.log_err()`.
|
||||||
|
|
||||||
|
Positives: signal batching bounds per-update work; wasm yield loop; gift-wrap dedup; seal
|
||||||
|
verification; `WeakEntity` room handles; `ChatEvent::Error`/`Loaded`/`Ping` genuinely surfaced.
|
||||||
|
|
||||||
|
### 4.7 `chat_ui` — chat panel
|
||||||
|
|
||||||
|
- **[H] `lib.rs:605-611` — Your own reactions count twice.** `insert_reaction` pushes
|
||||||
|
unconditionally (keyed only by target id); with `backup: true` (default) the reaction comes back
|
||||||
|
as your own backup gift wrap → inserted again → "👍 2". Text messages are dedup'd by
|
||||||
|
`binary_search`; reactions aren't. The dormant `RoomEvent::Reload` path double-counts too.
|
||||||
|
Track seen reaction event ids.
|
||||||
|
- **[H] `lib.rs:325-334` (+ `272-277`) — Room message-load and connect failures swallowed.** The
|
||||||
|
stored task's `Result` is never inspected: user sees an empty room with no error; relay
|
||||||
|
subscription failures invisible. Push a notification on error.
|
||||||
|
- **[M] `lib.rs:557-581` — Bulk load is O(n²) on the foreground thread.** Per-event
|
||||||
|
binary-search + index rebuild + splice inside one `update`; multi-second stall on big rooms.
|
||||||
|
Add a bulk variant (sort once, rebuild index once, splice once).
|
||||||
|
- **[M] `lib.rs:569-570` — Insert position and `ListState::splice` disagree.** Messages are
|
||||||
|
binary-inserted by `created_at` but the list always appends at `len..len`; mid-`Vec` arrivals
|
||||||
|
shift rows while the list state learns "appended one" → stale height caches, scroll jumps.
|
||||||
|
`nip29_ui/src/lib.rs:516` does it right: `splice(pos + 1..pos + 1, 1)`.
|
||||||
|
- **[M] `lib.rs:1183-1198` — `rendered_texts_by_id` caches mention names forever** (same defect as
|
||||||
|
`nip29_ui` 4.8). A message rendered before the profile loaded shows `@npub1…` all session.
|
||||||
|
Key the cache on directory generation or drop the resolved-text cache.
|
||||||
|
- **[M] `lib.rs:50-51,395-398` — `\p{Emoji}` matches plain digits: typing "2" while replying to
|
||||||
|
exactly one message sends a "2" reaction instead of a message.** Require
|
||||||
|
`[\p{Emoji_Presentation}\p{Extended_Pictographic}\u{200D}\u{FE0F}\u{20E3}]`.
|
||||||
|
- **[M] `lib.rs:110` — `tasks: Vec<Task>` grows without bound** (see S1).
|
||||||
|
- **[M] `lib.rs:547-549,618,623` — `reports_by_id: Arc<RwLock<...>>` nothing else shares.** Plain
|
||||||
|
`BTreeMap` field; removes the poisoning risk for zero benefit.
|
||||||
|
- **[L] `lib.rs:499` — `rumor.id.expect("rumor must have an id")`** — invariant holds today, but
|
||||||
|
degrade + notify instead of panic per house style (also `to_bech32().unwrap()` at 652, 1072).
|
||||||
|
- **[L] `lib.rs:362-370,797-802` — `.ok()` discards `room.update`** (subject change, retry action).
|
||||||
|
- **[L] `lib.rs:722-727` — `encrypted` snapshotted before the file-picker await**; typing while the
|
||||||
|
picker is open classifies the file by stale input state. Re-read after the await.
|
||||||
|
- **[L] `lib.rs:1170-1178` — `load_file` invoked from the render path** (guarded, but render should
|
||||||
|
be pure — move to a subscription).
|
||||||
|
- **[L] `lib.rs:147-221` — Summary comments** ("Define input state", …) violate the comment rule.
|
||||||
|
|
||||||
|
### 4.8 `nip29` — NIP-29 registry + protocol
|
||||||
|
|
||||||
|
- **[H] `lib.rs:541-555` (merge), `582-607` (absorb_list) — Stale group-list events resurrect left
|
||||||
|
groups and get republished.** The kind 10004 list is absorbed without comparing `created_at` and
|
||||||
|
`merge` never removes entries absent from older snapshots; a relay still holding the pre-leave
|
||||||
|
version re-adds the left group, `ensure()` re-creates subscription, and the next
|
||||||
|
`refresh_entry`/`publish_list` republishes the left group to all write relays (privacy angle).
|
||||||
|
Track the last accepted list event's `created_at`; ignore older.
|
||||||
|
- **[H] `group.rs:696-728` (absorb_state) — Group state applied last-write-wins, no ordering
|
||||||
|
guard.** Out-of-order relay delivery (newest-first is common) leaves the *oldest*
|
||||||
|
metadata/members/admins/roles/pins displayed indefinitely. Cache hydration is safe (sorted
|
||||||
|
ascending at `group.rs:507`); the live path is not. Keep `created_at` per state kind (the
|
||||||
|
`forks` map at `lib.rs:669-675` shows the pattern).
|
||||||
|
- **[M] `lib.rs:173-174,189` — Join published optimistically; refusal classified by relay error
|
||||||
|
prose** (`reason.starts_with("duplicate")`); a refused join stays in the public list. Drop the
|
||||||
|
entry on refusal; detect duplicates via a stable marker.
|
||||||
|
- **[M] `lib.rs:173,825-834` — Rejoin flips a private list entry to public** (`join_key` hardcodes
|
||||||
|
`private: false`, `upsert` replaces the whole entry). Preserve the existing flag.
|
||||||
|
- **[M] `group.rs:418-448` + `lib.rs:383-397,1146-1159` — Failed page load leaves `loading_more`
|
||||||
|
stuck true forever** (subscribe error path emits no signal; guard then permanently blocks
|
||||||
|
pagination for the session). Route a synthetic settle on error.
|
||||||
|
- **[M] `lib.rs:308-330` + `discovery.rs:253,267,274,281` — Network failures silently produce
|
||||||
|
"empty" results** (`and_connect().await.ok()`, discarded sync errors): browse shows zero
|
||||||
|
candidates, discovery silently stale. Surface `GroupsEvent::Error`.
|
||||||
|
- **[M] `discovery.rs:240-252,264-267` — Empty contact list → `Filter::authors(∅)` serializes as
|
||||||
|
absent → the sync reconciles **every user's** list, and the pool-wide sweep (kinds
|
||||||
|
`[GroupMetadata, GroupMembers]`, no ids/limit) pulls all NIP-29 state from all relays.**
|
||||||
|
Early-return when `authors.is_empty()`; constrain the sweep.
|
||||||
|
- **[M] `group.rs:457-511` + `lib.rs:906-941,1042-1071` — Every live event triggers a full
|
||||||
|
unlimited `load_envelopes` reload** (re-query + JSON-decode + sort of entire group history per
|
||||||
|
message) and every event is cached forever. Use the single-event absorb path live; cap/prune the
|
||||||
|
cache.
|
||||||
|
- **[M] `lib.rs:90` — `tasks: SmallVec<[Task; 2]>` accumulates per incoming event** (see S1).
|
||||||
|
- **[L] `protocol.rs:389-399,534,650,964` — One malformed optional tag fails the whole parse**
|
||||||
|
(one bad `supported_kinds` value blanks a group's metadata display). Skip + log instead.
|
||||||
|
- **[L] `protocol.rs:24` — Custom kind 9010 not in rust-nostr 0.45.6's NIP-29 range**; confirm
|
||||||
|
against target relay implementations before stable.
|
||||||
|
- **[L] `group.rs:602-611` — `previous` mismatch only logs a warning**; an empty `previous` tag
|
||||||
|
counts as fully verified (`protocol.rs:1046-1056`). Align comment with behavior or escalate.
|
||||||
|
- **[L] `protocol.rs:995-1016` — `TimelineWindow.seen` grows unbounded** (u32/event, never pruned).
|
||||||
|
- **[L] `lib.rs:582-607,661-677` — No client-side author re-check on list/fork subscriptions.**
|
||||||
|
Mitigated by `verify_subscriptions(true)` + `ban_relay_on_mismatch(true)`; note the residual
|
||||||
|
NIP-29 trust model rests on relay admission policy.
|
||||||
|
- **[L] `lib.rs:860-904` — Concurrent `publish_list` can complete out of order** (each snapshots at
|
||||||
|
call time; older snapshot can win). Serialize publishes.
|
||||||
|
- **[L] `lib.rs:1225-1230` — Negentropy/foreign-EOSE cross-talk** fires spurious `Synced` signals
|
||||||
|
from unrelated subscriptions.
|
||||||
|
- **[L] `utils.rs:24,31,35` — Subscription ids truncate the digest to 32 bits**; collision would
|
||||||
|
cross-wire routes. Negligible at this cardinality; use the full digest anyway.
|
||||||
|
- **[L] `lib.rs:1198-1200` — `processed` dedup wholesale-cleared at 10k**; evict oldest instead.
|
||||||
|
- **[L] `lib.rs:723-736` — `previews` grow unbounded per session** (entity + subscription each).
|
||||||
|
- **[L] `group.rs:197-202` — `Group::message(id)` O(n) scan** called per reply preview in render
|
||||||
|
paths → O(n²) timelines. Build an id→row map.
|
||||||
|
- **[L] `Cargo.toml:14` — `person` dep unused.** Remove.
|
||||||
|
- **[L] `lib.rs:1208-1229` — `.ok()` on signal sends** (benign; log or comment per rule).
|
||||||
|
- **[L] `lib.rs:358,949,975` — Inline global-handle chains** (rule: bind to a local).
|
||||||
|
|
||||||
|
Positives: zero reachable panics; tag parsing consistently `get`/`skip`/`first`;
|
||||||
|
`saturating_sub` timestamp math; tasks/subscriptions stored; `WeakEntity` spawns; membership
|
||||||
|
derivation order-independent; strong publish-error propagation.
|
||||||
|
|
||||||
|
### 4.9 `nip29_ui` — group panel + dialogs
|
||||||
|
|
||||||
|
- **[H] `lib.rs:1053-1067` — `rendered_texts_by_id` bakes mention names in permanently.**
|
||||||
|
Computed once per `EventId`; when the profile arrives later the body keeps `@npub1ab…` (the
|
||||||
|
header recovers via re-render, the body doesn't). Key on directory generation or don't cache the
|
||||||
|
resolved text (only the markdown source needs caching).
|
||||||
|
- **[M] `lib.rs:337` — `tasks` accumulates** from every send/upload/react/delete/pin (see S1).
|
||||||
|
- **[M] `lib.rs:1068` — Content `TextView` keyed by row index.** Prepending shifts every index →
|
||||||
|
every visible `TextViewState` re-parses markdown in one frame ("Load earlier messages" jank).
|
||||||
|
Use the message id as element id.
|
||||||
|
- **[M] `message.rs:359` + `lib.rs:1068` — Sibling ElementId collision: reply previews use bare
|
||||||
|
reply index as sibling of the row-index TextView**; when `replies.count > row_index` both sides
|
||||||
|
share `…/[N]/[N]`. Latent today, breaks silently when element types coincide. Namespaced ids.
|
||||||
|
- **[L] `message.rs:192,227,238` — `let _ = panel.update_in(...)`** where sibling call sites log
|
||||||
|
(`log::warn!` at 110-160). Mirror the logged pattern.
|
||||||
|
- **[L] `lib.rs:630` — File-picker failure silently swallowed** (task result never inspected).
|
||||||
|
- **[L] `lib.rs:436-445` — Utterance-end loop clears the reveal unconditionally**; interrupted
|
||||||
|
utterances still fire `on_utterance_end` on many backends, cancelling B's reveal while B's audio
|
||||||
|
plays. Guard with the session id like `message.rs:144` does.
|
||||||
|
- **[L] `message.rs:97,130,168-180` — Per-frame per-row allocations** (`preview()` computed twice
|
||||||
|
per row per frame, custom-emoji `Vec` rebuilt + cloned into the submenu closure every render —
|
||||||
|
and gpui-pre redraws the window on every keystroke). Compute once per render.
|
||||||
|
- **[L] `lib.rs:677,956` — Inline global chains** (rule).
|
||||||
|
- **[L] `lib.rs:407-419` — Subscribe + observe both run `sync` + `track_participants`** (events
|
||||||
|
and notifies fire together) → double O(rows) scan per change.
|
||||||
|
- **[L] `message.rs:322` — Empty role list renders "is now "** (trailing space, no roles). Emit
|
||||||
|
"now a regular member" for the empty case.
|
||||||
|
|
||||||
|
### 4.10 `common` — shared utilities
|
||||||
|
|
||||||
|
- **[H] `media_extractor.rs:52-58` — Every message body has all whitespace collapsed, destroying
|
||||||
|
line breaks.** `cleanup_text`'s `\s+ → " "` runs on the entire remaining text unconditionally,
|
||||||
|
so multi-line messages render as one line in both chat and groups (via `chat/message.rs:99-103`).
|
||||||
|
Normalize only around removed URLs; never touch `\n` in untouched regions.
|
||||||
|
- **[M] `media_extractor.rs:15-18,56,62-65` — Regexes compiled per message** (the cache struct's
|
||||||
|
own entry point defeats it). `static LazyLock<Regex>`.
|
||||||
|
- **[M] `parser.rs:240,254,260` — `NostrParserIter::next` recurses per token; depth is
|
||||||
|
attacker-controlled** (~9 bytes/token of crafted `nostr:` URIs). Desktop's 8 MB stack survives;
|
||||||
|
the wasm32 web build can crash the tab. Rewrite as a loop.
|
||||||
|
- **[M] `event.rs:12-18` — `uniq_id` uses `DefaultHasher` and is persisted to published events**
|
||||||
|
(see S5).
|
||||||
|
- **[M] `paths.rs:7,14,23,31` — `expect()` on `dirs::download_dir()` etc.** — legitimately `None`
|
||||||
|
on Linux without XDG user-dirs → panic on export (`workspace/src/lib.rs:405`). Return `Option`
|
||||||
|
and handle.
|
||||||
|
- **[L] `display.rs:18,20,39` — `as i64` on relay-controlled timestamps** (contained by
|
||||||
|
`LocalResult`); fallback magic strings `"9999"`/`"1m"`.
|
||||||
|
- **[L] `event.rs:12-53` — `Event`/`UnsignedEvent` impls duplicate the same logic with drift**
|
||||||
|
(one sorts, one doesn't). Factor into one helper — same helper fixes S5.
|
||||||
|
- **[L] `debounced_delay.rs:33` — `_ = channel.send(())`** explicit silent discard (harmless;
|
||||||
|
satisfy the rule with a comment + `if let Err`).
|
||||||
|
|
||||||
|
Positives: `web_store.rs` error handling exemplary; parser char-boundary-safe throughout;
|
||||||
|
`DebouncedDelay` cancellation correct; time formatting degrades gracefully.
|
||||||
|
|
||||||
|
### 4.11 `person` — profiles + web of trust
|
||||||
|
|
||||||
|
- **[H] `lib.rs:310-333` + `204-245` — One failed metadata batch permanently blacklists those
|
||||||
|
pubkeys.** `in_flight` removal happens only on metadata arrival; `get_metadata` uses
|
||||||
|
`TIMEOUT = 2 s` (tight for bootstrap relays + EOSE) and on failure keys stay in `in_flight`
|
||||||
|
forever → those people show truncated-pubkey placeholders until restart. Return failed keys on
|
||||||
|
error and release them; raise the request timeout independently of the batching window.
|
||||||
|
- **[M] `lib.rs:175` — Malformed metadata wipes a good profile.**
|
||||||
|
`Metadata::from_json(...).unwrap_or_default()` replaces previously-loaded data with a default
|
||||||
|
`Person`; content is attacker-influenceable (anyone can publish metadata for any key). Skip on
|
||||||
|
parse failure.
|
||||||
|
- **[M] `directory.rs:110` (+ `lib.rs:255-262`) — Task handles accumulate per `track`** (see S1).
|
||||||
|
- **[M] `directory.rs:161-172` — `person()` performs side effects (lock write + network fetch) from
|
||||||
|
a render-time getter.** Deduped via `pending`, but order-dependent render behavior; consider an
|
||||||
|
explicit `ensure_tracked` from update paths.
|
||||||
|
- **[L] `wot.rs:100,343` — DB errors silently become empty results**; add `log::warn`.
|
||||||
|
- **[L] `wot.rs:150-153` — `let _ = this.update(...)`** (commented, periodic; noted for the rule).
|
||||||
|
- **[L] `person.rs:23-47` — `PartialEq/Eq/PartialOrd/Ord/Hash` for `Person` unused
|
||||||
|
workspace-wide**; the `Ord` impl clones `Metadata` twice per comparison. Delete.
|
||||||
|
- **[L] `person.rs:66-68,124-138` — `metadata()` clones per call; `name()` calls it twice** per
|
||||||
|
invocation → 2+ clones per avatar/name row per frame. Borrow or cache.
|
||||||
|
- **[L] `person.rs:166-177` — `shorten_pubkey` doc example doesn't match output; panics if
|
||||||
|
`len ≥ 63`** (callers use 4/8 today). Note: the `let Ok(...) =` pattern compiles only because
|
||||||
|
`ToBech32::Err = Infallible` — worth a comment since it looks like a compile error.
|
||||||
|
|
||||||
|
Positives: WoT builder caps hostile publishers; poisoning-tolerant lock helpers; bounded dispatch
|
||||||
|
channel; `limit(1)` newest-event lookups verified correct against nostr-lmdb ordering.
|
||||||
|
|
||||||
|
### 4.12 `speech` — dictation + TTS
|
||||||
|
|
||||||
|
- **[H] `parakeet.rs:134` — Inference worker exits after 30 s idle, permanently disabling
|
||||||
|
dictation.** `recv_timeout(30s)` kills the thread; the `OnceLock` sender survives, so after
|
||||||
|
that every `send` fails — and `send_or_log` (`:200-204`) logs at **debug** and drops. A
|
||||||
|
`MAX_SECONDS = 120` dictation records fine, then `finish()`'s `Transcribe` goes into the void;
|
||||||
|
gpui-kit's 180 s `stop_timeout` then force-ends with an **empty** transcript. Net: dictations
|
||||||
|
longer than 30 s silently lost after up to a 3-minute hang. Fix: block on `recv()` (an `Unload`
|
||||||
|
command already exists for explicit retirement), and make `finish()` surface send failure via
|
||||||
|
`sink.error`.
|
||||||
|
- **[M] `parakeet.rs:63-68` — Audio past 120 s silently discarded** mid-sentence with no
|
||||||
|
indication. Surface once or stop capture at the cap.
|
||||||
|
- **[M] `parakeet.rs:119-128` — Worker-spawn failure leaves a permanently dead channel** (receiver
|
||||||
|
dropped, sender cached in the `OnceLock` forever). Don't cache on failure.
|
||||||
|
- **[M] `speaker.rs:36-53` — `Tts::default()` constructed lazily inside `speak()` invoked from
|
||||||
|
render paths**; first-use engine init can take tens-to-hundreds of ms on the UI thread
|
||||||
|
mid-render. Initialize eagerly at panel creation or on the background executor.
|
||||||
|
- **[L] `model.rs:82-97` — `status()` does filesystem I/O per call** while the settings dialog
|
||||||
|
polls every 500 ms on the foreground. Cache status in the state mutex.
|
||||||
|
- **[L] `model.rs:183` — `let _unused = fs::remove_file(...)`**; log per rule.
|
||||||
|
- **[L] `web.rs:23-25` — `is_available` hardcodes `true`**; mic button renders enabled in browsers
|
||||||
|
without `SpeechRecognition`. Reflect actual support.
|
||||||
|
- **[L] `web.rs:200,218` — `let _ignored` abort/stop** — commented, best-effort; fine.
|
||||||
|
|
||||||
|
Positives: `catch_unwind` isolation around model load/transcribe; staged download with SHA-256 +
|
||||||
|
size cap + atomic rename; dedicated threads for inference/download; all other fallible paths
|
||||||
|
reach `sink.error`.
|
||||||
|
|
||||||
|
### 4.13 `settings` — persistence + themes
|
||||||
|
|
||||||
|
- **[H] `lib.rs:403-404` + `311-314` — Failed load silently resets settings and destroys the saved
|
||||||
|
file.** `unwrap_or(Settings::default())` swallows I/O and JSON errors; the observer then
|
||||||
|
immediately writes defaults over the user's file (losing `trusted_relays`, pins, wallet prefs)
|
||||||
|
with no log. Log, keep defaults in memory, but don't persist until a real user change.
|
||||||
|
- **[H] `lib.rs:423-438` — Saves discard errors and race each other.** `.await.ok()` hides
|
||||||
|
disk-full/permission failures; every change detaches a new concurrent write to the same path —
|
||||||
|
last-completion-wins can overwrite newer snapshots and interleaved truncate/write can corrupt
|
||||||
|
the file (font-size input ticks each trigger a save). Single writer slot + temp-file + rename +
|
||||||
|
`log_err`.
|
||||||
|
- **[L] `lib.rs:425` — Serialization failure silently skips saving**; log.
|
||||||
|
- **[L] `lib.rs:596-639,671-683` — `apply_persisted_theme` re-reads and re-parses the settings
|
||||||
|
file four times.** Parse once.
|
||||||
|
- **[L] `lib.rs:211,408` — `Url::parse(DEFAULT_FILE_SERVER).unwrap()`**; `OnceLock` or a reasoned
|
||||||
|
`expect`.
|
||||||
|
|
||||||
|
Positives: accessor macros keep 20 settings consistent; serde default/alias migration fields with
|
||||||
|
tests covering old formats; wasm path handled.
|
||||||
|
|
||||||
|
### 4.14 `workspace` — core + dock + sidebar
|
||||||
|
|
||||||
|
- **[H] `dock/mod.rs:50-73` + `lib.rs:309-320` — Tab dedup is unreachable; repeated opens stack
|
||||||
|
duplicate tabs.** `find_panel` matches on `PanelId`, which is always `PanelId::from(entity_id())`
|
||||||
|
of a panel created in the same statement — so it can never match (verified against gpui-base
|
||||||
|
0.9.1 `add_panel_inner`, which inserts without dedup). Clicking the same chat row twice opens
|
||||||
|
two live panels for one room; ShowInbox/Browse/Contacts/Backup multiply tabs. Key panels by
|
||||||
|
domain identity (singleton entities per panel name, or room/group key). The ~55 lines of
|
||||||
|
`find_panel`/`move_panel` are dead until then.
|
||||||
|
- **[H] `sidebar/rows.rs:54-57` — "Ignore" dispatches `ClosePanel` where it can never be handled.**
|
||||||
|
The only handler is on the tab-group frame; the alert dialog holds focus at that moment, and
|
||||||
|
dispatch resolves along the focus node's ancestor path — the dock tree isn't on it. Clicking
|
||||||
|
"Ignore" is a silent no-op for a destructive-intent action. Close the room via the dock handle
|
||||||
|
already in scope, or restore focus first.
|
||||||
|
- **[H] `sidebar/header.rs:38,236-268` — Updater buttons render stale status.** Nothing observes
|
||||||
|
the `AutoUpdater` entity (gpui-pre has no read-tracking); a staged update can sit invisible on
|
||||||
|
an idle window indefinitely. Add `cx.observe(&updater, …)`.
|
||||||
|
- **[M] `sidebar/mod.rs:192-206` + `search.rs:70-117` — Full message-index scan on the foreground
|
||||||
|
thread for every keystroke.** Only the *global* search is debounced; local search synchronously
|
||||||
|
scans every indexed message, sorts the entire match set, then truncates. Debounce + background
|
||||||
|
spawn + partial top-`limit` select.
|
||||||
|
- **[M] `sidebar/list.rs:41,178-248` + `group_view.rs:88-136` — Row model rebuilt on every sidebar
|
||||||
|
render** (clone + partition + pin-tag scans ×visible rows; sidebar re-renders on 500 ms ticks,
|
||||||
|
keystrokes, group updates). Cache the row model in state, refresh on registry events.
|
||||||
|
- **[M] `sidebar/search.rs:152-155` — Global search failure renders as "No profiles found".**
|
||||||
|
Surface the error state.
|
||||||
|
- **[M] `lib.rs:410-421` — Save-dialog failure silently cancels the backup** (`Err(_) => return`):
|
||||||
|
indistinguishable from user cancel; user believes the key was backed up. Split arms + notify.
|
||||||
|
- **[M] `sidebar/mod.rs:139-142` — Whole-sidebar re-render on every `GroupsEvent::Updated`**
|
||||||
|
(acknowledged TODO; compounds the row-rebuild cost). Gate on visibility.
|
||||||
|
- **[L] `dock/skin.rs:25,35-37,55` — `toggle_button_visible: Cell<bool>` is write-once** (set true
|
||||||
|
in ctor, never elsewhere) → dead state + dead branch at `tab_panel.rs:212`. Delete.
|
||||||
|
- **[L] `sidebar/rows.rs:174-374` — Index-based row ids** (see S6).
|
||||||
|
- **[L] `dock/tab_panel.rs` ×6, `skin.rs:262`, `sidebar/search.rs:164,178`, `lib.rs:641` — `let _ =
|
||||||
|
`/`.ok()` on fallible updates** (see S2); `lib.rs:641` is pre-destructive-reset — make it visible.
|
||||||
|
- **[L] `rows.rs:220-222,346-351`, `list.rs:53-55` — Inline global chains** (rule).
|
||||||
|
- **[L] `lib.rs:84`, `sidebar/mod.rs:99`, `sidebar/inbox.rs:68,118` — `tasks` accumulate** (see S1).
|
||||||
|
- **[L] `lib.rs:54` — Typo: `DeviceNotifcation`.**
|
||||||
|
|
||||||
|
Positives: careful `WeakEntity` hygiene; virtualized lists via `uniform_list` + `Rc` snapshots;
|
||||||
|
`InvalidPanel` preserves unknown panels across load/save; `theme_name_at` maps the palette's
|
||||||
|
original `IndexPath` correctly; `cargo check -p workspace` passes.
|
||||||
|
|
||||||
|
### 4.15 `workspace` — dialogs + panels
|
||||||
|
|
||||||
|
- **[H] `panels/contact_list.rs:187-191` — "Update" republishes the contact list with relay hints
|
||||||
|
and petnames stripped.** `load()` keeps only `tags.public_keys()`; `update()` rebuilds every
|
||||||
|
entry as `Contact::new(*public_key)` (`relay_url: None, alias: None`) while the UI reports
|
||||||
|
success. Silent NIP-02 data loss on relays. Carry the original `Contact`s through.
|
||||||
|
- **[H] `dialogs/new_chat.rs:60-64` — Contact list doesn't re-filter while typing.** No
|
||||||
|
`InputEvent::Change` handling; the input renders its own state entity so typing doesn't notify
|
||||||
|
`NewChat`; results stay stale until an unrelated click/Enter. `cx.notify()` on Change. Same
|
||||||
|
pattern, cosmetic: `profile.rs:320-326`.
|
||||||
|
- **[H] `panels/backup.rs:59-76` — Keyring read failures silently swallowed**; backup panel just
|
||||||
|
shows empty inputs. Log + surface.
|
||||||
|
- **[H] `dialogs/profile.rs:227-235` — Invalid avatar/website URLs silently dropped on save** while
|
||||||
|
"Profile updated successfully" shows. Validate both up front; inline error.
|
||||||
|
- **[M] `dialogs/screening.rs:52-55` — `on_release_in` calls `window.close_all_dialogs(cx)`**,
|
||||||
|
nuking unrelated stacked dialogs (including the mutual-contacts dialog this view opens). Close
|
||||||
|
just the hosting dialog / cancel tasks only.
|
||||||
|
- **[M] `dialogs/screening.rs:126-168` — DB-fallback "mutual contacts" has different semantics than
|
||||||
|
the WoT list** (followers vs. your-contacts-who-follow). Intersect or relabel.
|
||||||
|
- **[M] `dialogs/screening.rs:211-232` — NIP-05 network errors render as "address does not
|
||||||
|
match"** (`unwrap_or(false)` asserts infrastructure failure as fact about the person). Tri-state.
|
||||||
|
- **[M] `dialogs/screening.rs:250-282` — Report submit gives no failure feedback.**
|
||||||
|
- **[M] `dialogs/import.rs:129-185` — Enter bypasses the loading guard** (no in-method guard; all
|
||||||
|
three `PressEnter` subscriptions call `login()` directly) → concurrent double import. Same class:
|
||||||
|
`contact_list.rs:176-228`, `relays.rs:377,521`.
|
||||||
|
- **[M] `dialogs/import.rs:31-40` — Unlock dialog is inescapable** (no cancel/logout path for a
|
||||||
|
forgotten passphrase). Add a "start over" escape hatch for stable.
|
||||||
|
- **[M] `panels/browse.rs:484-493,316-348` — Card list recomputed per render *and* per visible
|
||||||
|
row** (O(n) clone + registry reads ×(rows+1) per frame during scroll). Compute once per render
|
||||||
|
(`new_chat.rs`'s `Rc<Vec>` pattern).
|
||||||
|
- **[M] `panels/greeter.rs:127-137` — Every refresh fetches the full message history of every room
|
||||||
|
to take `.last()`.** Query latest-per-room instead.
|
||||||
|
- **[M] `dialogs/new_chat.rs:138-160,388` — Filter allocates per contact per keystroke-scale
|
||||||
|
render** (`to_hex().to_lowercase()` per contact). Cache lowercase names / filter once per change.
|
||||||
|
- **[M] `dialogs/settings.rs:124-148` — Speech-model errors can be consumed before the status
|
||||||
|
change check** and dropped. Check `error` before the equality early-return.
|
||||||
|
- **[M] `new_chat.rs:282-315` vs `profile.rs:274-313` — Duplicated room-open logic with divergent
|
||||||
|
dedup.** Unify on find-or-create.
|
||||||
|
- **[L] `panels/contact_list.rs:238,259` + `greeter.rs:226-402` — HashSet/index-based element ids**
|
||||||
|
(see S6).
|
||||||
|
- **[L] `panels/contact_list.rs:288` — Copy bug: contacts panel says "Please add some relays."**
|
||||||
|
- **[L] `dialogs/new_group.rs:392` — `let _ = this.update(...)`** (see S2; `relays.rs:623-629` is
|
||||||
|
the correct logged pattern).
|
||||||
|
- **[L] `panels/backup.rs:66,70` — `.unwrap()` on bech32** (infallible today; still banned pattern).
|
||||||
|
- **[L] `dialogs/restore.rs:52-62`, `contact_list.rs:130`, `relays.rs:352-355,497-500` — Untrimmed
|
||||||
|
paste input fails parse** ("trailing newline = invalid key"); `relays` has a weak
|
||||||
|
`starts_with("ws")` check.
|
||||||
|
- **[L] `dialogs/relays.rs:124-140,360-366` — Relay metadata can never be reset; duplicate add is a
|
||||||
|
silent no-op** that can't update metadata.
|
||||||
|
- **[L] `dialogs/settings.rs:517-543` — Toggles compute `!rendered_value` instead of the switch's
|
||||||
|
`checked`**; two fast clicks collapse into one. Match `onboarding.rs:398-399`.
|
||||||
|
- **[L] Dialog `tasks` fields never pruned** (`import.rs:63`, `restore.rs:24`, `relays.rs:45,57`,
|
||||||
|
`contact_list.rs:52`, `profile.rs:55`, `new_chat.rs:50`, `new_group.rs:94`) — see S1;
|
||||||
|
`screening.rs`/`greeter.rs` show the right patterns.
|
||||||
|
- **[L] `new_chat.rs:93-95`, `profile.rs:278-280`, `screening.rs:90-92` — Signed-out paths return
|
||||||
|
silently**; no retry when login completes later.
|
||||||
|
- **[L] Dialogs can be stacked repeatedly** — no `has_active_dialog` checks in any `open()`.
|
||||||
|
- **[L] `new_group.rs:137-140` — `GroupsEvent::Created { warning, .. }` discards the non-fatal
|
||||||
|
warning.**
|
||||||
|
|
||||||
|
Positives: `limit(1)` newest-event pattern verified correct on both backends; entities created
|
||||||
|
before `open_dialog` per gpui-kit contract; `new_group.rs` has a solid error lifecycle; heavy work
|
||||||
|
consistently on background; no unused deps in the crate.
|
||||||
|
|
||||||
|
### 4.16 `ui` — shared components
|
||||||
|
|
||||||
|
- **[M] `avatar.rs:128-186` — `PixelAvatar` regenerates the full pattern every frame** (FNV-1a +
|
||||||
|
xorshift 64-cell grid + ~50 child divs per avatar; ~30 visible avatars, redrawn on every
|
||||||
|
keystroke) — the biggest hot-path cost in the main view. Memoize the deterministic pattern
|
||||||
|
(seed → `[u8; 64]` cache); hoist `FALLBACK_SEED` to a `&'static str` path.
|
||||||
|
- **[L] `text.rs:11-38` — `InlineReplacement` is `pub` but unreachable externally**; same for
|
||||||
|
`RUN_WINDOW_SECS` (`message.rs:28`). `pub(crate)` or document as extension points.
|
||||||
|
- **[L] `message.rs:766` — `RevealText::new` is the only public constructor without a doc line.**
|
||||||
|
- **[L] `message.rs:775-796` — `RevealText` re-splits text into a `Vec<SharedString>` every
|
||||||
|
animation frame**; split once in `new`.
|
||||||
|
- **[L] `text.rs:89-95` — Non-web, non-nevent Nostr links are silently dead** (`npub`/`nprofile`
|
||||||
|
clicks do nothing). Route to a profile view or drop the link markup.
|
||||||
|
- **[L] `Cargo.toml:15,19` — `web-time` and `uuid` unused.** Remove.
|
||||||
|
|
||||||
|
Positives: consistent `RenderOnce` + `Styled` builder pattern; `#[must_use]` builders; documented
|
||||||
|
public items; char-boundary-safe slicing; truncating casts provably bounded.
|
||||||
|
|
||||||
|
### 4.17 `auto_update`
|
||||||
|
|
||||||
|
- **[M] `lib.rs:92` — Checksum-only verification; no signing key configured.** SHA256SUMS comes
|
||||||
|
from the same host as the artifact — protects against corruption, not a compromised release
|
||||||
|
host/account. The engine already supports minisign/Ed25519; publish a sidecar signature and pin
|
||||||
|
the public key before stable.
|
||||||
|
- **Verified non-issue:** the `futures::executor::block_on` in `source.rs:152,170` runs on the GPUI
|
||||||
|
**background** executor (verified through `gpui-updater-pre 0.2.0`'s `Updater::check` →
|
||||||
|
`engine.check()` → `fetch_latest` call chain) — no UI freeze. Severity Low: it parks one
|
||||||
|
background thread up to ~60 s during a check. Optional tidy-up only.
|
||||||
|
- **[L] `source.rs:156-163,180-183` — Asset URLs not scheme-checked**; assert `https://`.
|
||||||
|
- **[L] `lib.rs:119-131,145-147` — A failed check shows for 5 s then reports "Up to date"**
|
||||||
|
(`error_revert` → `Idle`, and `Idle` counts as up-to-date). Distinct "check failed" state.
|
||||||
|
- **[L] `lib.rs:103,128` — `.ok()` on entity updates** (see S2).
|
||||||
|
- **[L] `lib.rs:134-139` — Auto-download/stage ~2 min after startup without a prompt**; fine on
|
||||||
|
unmetered, worth a setting.
|
||||||
|
|
||||||
|
Positives: strict `latest > current` gate (no downgrades); drafts/prereleases skipped; 30 s
|
||||||
|
timeouts; defensive checksum parsing; good tests.
|
||||||
|
|
||||||
|
### 4.18 `gpui_tokio`
|
||||||
|
|
||||||
|
- **[L] `lib.rs:14` — `.expect("Failed to initialize Tokio")`**; per the no-panic rule, `init`
|
||||||
|
returning `Result` would be more in keeping (realistically unreachable).
|
||||||
|
- **[L] `lib.rs:38` — `pub struct Tokio {}`** namespace struct; `pub struct Tokio;` or free
|
||||||
|
functions.
|
||||||
|
|
||||||
|
Positives: the `defer`-guard cancellation pattern is correct in both completion and drop cases;
|
||||||
|
`shutdown_background()` avoids blocking exit; `spawn_result` maps `JoinError` into `anyhow`.
|
||||||
|
No deadlock risk across the two runtimes (verified: nothing waits on GPUI from tokio or holds a
|
||||||
|
lock across the boundary).
|
||||||
|
|
||||||
|
### 4.19 `desktop` / `web` / `assets` entries
|
||||||
|
|
||||||
|
- **[M] `desktop/src/main.rs:20-26,153-170` — Secret key accepted as a CLI argument.** `--sec
|
||||||
|
nsec1…` puts the master key in the process list (`ps`, `/proc`, shell history); the parse-error
|
||||||
|
path echoes the raw (near-)key to stderr. Read from stdin/env; print error kind only, never the
|
||||||
|
input.
|
||||||
|
- **[L] `desktop/src/main.rs:131-150` — Seven startup unwraps on embedded assets.** Deterministic,
|
||||||
|
but one corrupt font aborts launch; skip bad fonts with a warning and fail only if zero loaded.
|
||||||
|
`open_window().expect` is fair (genuinely unrecoverable). The startup `block_on` for fonts
|
||||||
|
(main.rs:135) is intentional and acceptable — noted so it isn't confused with the anti-pattern.
|
||||||
|
- **[L] `web/src/lib.rs:122` — `expect("Failed to open window…")` aborts the wasm module** (blank
|
||||||
|
page + console error; `panic = "abort"`). A DOM-level error would be kinder.
|
||||||
|
- **[L] `web/src/lib.rs:43` — `.ok()` on console-log init** (justified; noted for completeness).
|
||||||
|
- **[L] `desktop/Cargo.toml:37` + `web/Cargo.toml:13` — `ui` dep unused in both entries.** Remove
|
||||||
|
unless it exists to force feature unification (then comment why).
|
||||||
|
- **[L] `assets/Cargo.toml:9` — `anyhow` unused** (code uses `gpui::Result`). Remove.
|
||||||
|
|
||||||
|
Positives: `web` opens IndexedDB before `run()`, panic hook + both logging stacks via `OnceLock`;
|
||||||
|
`assets` ships the theme-schema test.
|
||||||
|
|
||||||
|
## 5. Tooling Validation
|
||||||
|
|
||||||
|
| Check | Result |
|
||||||
|
|---|---|
|
||||||
|
| `cargo clippy --workspace --all-targets` | clean — zero warnings |
|
||||||
|
| `cargo fmt --all --check` | clean |
|
||||||
|
| `cargo check -p workspace`, `-p person` | pass |
|
||||||
|
| `let _ =` / `.ok()` sites (non-test) | ~156 — mostly entity-update-expected-error cases; see S2 |
|
||||||
|
| `tasks:` container declarations | ~48 sites across 20 owners — see S1 |
|
||||||
|
| Startup `block_on` sites | 3 (`state` DB open, `desktop` fonts — both acceptable-at-startup; `auto_update` — background thread, non-issue) |
|
||||||
|
|
||||||
|
## 6. Verified Non-Issues (checked, do not re-flag)
|
||||||
|
|
||||||
|
- `auto_update/source.rs` `block_on`: background executor only (verified through the
|
||||||
|
`gpui-updater-pre 0.2.0` call chain) — no UI freeze.
|
||||||
|
- `nip29/src/protocol.rs:1072` `block_on`: test-only.
|
||||||
|
- No locks held across `.await` anywhere in the workspace (all audited crates).
|
||||||
|
- `person.rs:170` `let Ok(pubkey) = to_bech32()` — irrefutable (`Err = Infallible`).
|
||||||
|
- `stored_contact_list`'s `limit(1)` + `.next()` — correct: nostr-lmdb orders by `created_at`
|
||||||
|
descending.
|
||||||
|
- `nip60`/`nip60_ui` have zero non-test `unwrap`/`expect`/`let _ =`; indexing is guarded; no
|
||||||
|
truncating casts.
|
||||||
|
- `nip29_ui`'s `sync()` splice math traced against `ListState` semantics — correct (it's `chat_ui`
|
||||||
|
' that has the mismatch).
|
||||||
|
- gpui-pre's entity read-tracking re-renders panels on `Directory`/`SpeechState` notifications —
|
||||||
|
the missing-`cx.notify()` suspicion in speech handlers is fine; the stale-cache bugs above are
|
||||||
|
real, the observation isn't.
|
||||||
|
- `nip60::init` startup ordering is sound for normal flows; the CLI-key path fires `SignerChanged`
|
||||||
|
before wallet subscription exists (app-wide init-order caveat, affects all registries equally).
|
||||||
|
- `nip60` melt double-spend paths are latent (dead API), not live.
|
||||||
|
|
||||||
|
## 7. What's Done Well
|
||||||
|
|
||||||
|
- **Clippy-clean, fmt-clean workspace** with no reachable panics in `nip29`, `nip60`, `ui`,
|
||||||
|
`nip29_ui`, `person` production paths; hostile-input discipline in the WoT builder, speech
|
||||||
|
downloads (SHA-256 + size cap + atomic rename), and auto-update (fail-closed checksums, no
|
||||||
|
downgrades).
|
||||||
|
- **`verify_subscriptions(true)` + `ban_relay_on_mismatch(true)`** in `state` is the single most
|
||||||
|
valuable client-side trust boundary, and it's on.
|
||||||
|
- **Entity/subscription hygiene is strong overall**: subscriptions stored in `_subscriptions`,
|
||||||
|
`WeakEntity` used for room/group/dock back-references, no strong entity cycles found anywhere,
|
||||||
|
no dropped-subscription bugs.
|
||||||
|
- **Secrets discipline**: no secret is ever logged or published in `nip4e`/`nip60`/`state` desktop
|
||||||
|
paths (verified against every log call); `Keys`' vendored `Debug` hides secrets.
|
||||||
|
- **Errors generally do reach the UI** where it matters most (group writes via `util::report`,
|
||||||
|
wallet events, speech errors, import/restore flows) — the gaps are enumerated above.
|
||||||
|
|
||||||
|
## 8. Recommended Remediation Order
|
||||||
|
|
||||||
|
**P0 — before any release candidate (blocks release):**
|
||||||
|
1. nip60 deletion spoofing (4.2 #1) — one-line precedence fix + author check.
|
||||||
|
2. browser-signer-proxy auth/CORS/token + timeout cleanup (4.4 #1-2).
|
||||||
|
3. state startup DB fallback (4.1 #1) and web localStorage encryption (4.1 #2).
|
||||||
|
4. nip60 reconcile/republish money-safety trio (4.2 #2-4) + stuck `redeeming` (4.2 #5).
|
||||||
|
5. state file-cache collision (4.1 #4) — wrong-file disclosure.
|
||||||
|
6. chat_ui reaction double-count (4.7 #1) and common whitespace collapse (4.10 #1).
|
||||||
|
7. contact-list Update data loss (4.15 #1).
|
||||||
|
8. speech parakeet worker lifetime (4.12 #1).
|
||||||
|
9. settings load/save durability (4.13 #1-2).
|
||||||
|
10. nip29 `created_at` guards (4.8 #1-2).
|
||||||
|
|
||||||
|
**P1 — fix during the release cycle:**
|
||||||
|
Dock panel identity + "Ignore" dispatch (4.14 #1-2); updater observation (4.14 #3); invoice flow
|
||||||
|
or removal (4.3 #1); person `in_flight` release (4.11 #1); chat trash UI (4.6 #1); chat_ui load
|
||||||
|
errors + splice + quadratic insert (4.7 #2-4); profile URL validation, backup keyring surfacing,
|
||||||
|
new_chat re-filter, screening dialog cluster (4.15); parser recursion for wasm (4.10 #3);
|
||||||
|
`DefaultHasher` → stable hash before more room ids ship (S5); nip4e approval button + rotation
|
||||||
|
archive + listener death (4.5 #1-3); proxy plaintext leak + event-substitution check (4.4 #3-4).
|
||||||
|
|
||||||
|
**P2 — hygiene, in one mechanical sweep:**
|
||||||
|
S1 (`tasks` containers → replace-per-spawn / `detach_and_log_err`), S2 (`let _ =`/`.ok()` →
|
||||||
|
`.log_err()`), S6 (domain-keyed element ids), unused deps (`chat`: smol; `nip29`: person;
|
||||||
|
`nip60`: tokio; `ui`: web-time, uuid; `assets`: anyhow; entries: ui), dead code
|
||||||
|
(`nip60` melt/send/quotes decision, `state::passphrase_required`, `DownloadedFile::save`,
|
||||||
|
`dock::find_panel` fix-or-delete, `skin.rs` write-once flag, `Person` trait impls), avatar
|
||||||
|
memoization, sidebar row-model caching, rename `DeviceNotifcation`, summary-comment cleanup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Method note: every crate's sources were read in full by a dedicated audit pass; GPUI/fork API
|
||||||
|
claims were verified against the vendored `gpui-pre 0.3.12`, `gpui-base 0.9.1`,
|
||||||
|
`gpui-component 0.9.1`, and rust-nostr sources; the Critical and top-High findings were
|
||||||
|
independently re-verified by hand against the working tree at `8fa79aef`. No files were modified
|
||||||
|
during the audit.*
|
||||||
|
|
||||||
|
## 9. P0 Remediation Log (2026-11-12)
|
||||||
|
|
||||||
|
All ten P0 items fixed. Validation after the full batch: `cargo clippy --workspace --all-targets`
|
||||||
|
zero warnings, `cargo fmt --all --check` clean, `cargo test --workspace` 35 passed / 0 failed.
|
||||||
|
Diff: 15 files, +816/−273.
|
||||||
|
|
||||||
|
1. **nip60 deletion spoofing** — `nip60/src/lib.rs`: precedence fixed to
|
||||||
|
`ours && (is_cashu || is_token_deletion(..))`; `absorb`/`absorb_deletion` now take `me` and
|
||||||
|
reject foreign-authored kind 5 events (defense in depth; the live notification gate was the
|
||||||
|
only protection before, and `absorb_deletion` had no author check). The DB load path was
|
||||||
|
already author-filtered and is unchanged.
|
||||||
|
2. **browser-signer-proxy hardening** — per-session UUID token generated at startup, embedded in
|
||||||
|
the served page (`inject_session_token`), required on `/api/*` (403 otherwise); exact `Host`
|
||||||
|
match (DNS-rebinding defense); all CORS headers and the OPTIONS preflight removed;
|
||||||
|
`cancel_request` removes timed-out requests from both maps so plaintext is dropped;
|
||||||
|
centralized best-effort `write_response`. `proxy.js` authenticates all three fetches. Token
|
||||||
|
never appears in the URL the app opens.
|
||||||
|
3. **state startup crash loop** — `NostrLmdb::open` failure now logs and falls back to
|
||||||
|
`MemoryDatabase::unbounded()` (nostr-memory added to the desktop deps), emitting
|
||||||
|
`StateEvent::error` via the existing deferred path; the `block_on` stays (safe: the LMDB open
|
||||||
|
schedules nothing back onto the GPUI executor — now commented).
|
||||||
|
4. **state web localStorage key** — `import_bunker` on wasm32 no longer persists the bunker
|
||||||
|
credential (session is memory-only; desktop keyring unchanged); `restore_session` degrades to
|
||||||
|
`NoSigner` when nothing is stored (verified). **Known behavior change: web users re-enter their
|
||||||
|
bunker URI after each reload.**
|
||||||
|
5. **state file-cache collision** — cache filename now derived from the decrypted content's
|
||||||
|
sha256 instead of the filename; the metadata guard is gone and every store goes through
|
||||||
|
temp-write + atomic rename.
|
||||||
|
6. **nip60 reconcile money-safety** — `ops.rs::reconcile` rewritten: relay tokens are converged
|
||||||
|
into the store BEFORE any publish (closes the `del`-before-converge race); relay proofs already
|
||||||
|
tracked locally (unspent or in-flight Pending/Reserved/PendingSpent) are never re-added, and
|
||||||
|
unseen proofs are checked against the mint (NUT-08 `check_proofs_spent`, best-effort) before
|
||||||
|
being accepted as Unspent; when a prior job errored after mutating the store
|
||||||
|
(`publish_failed` flag), reconcile republishes the store's unspent set instead of trusting
|
||||||
|
relay state — healing stranded sends/receives; without the flag the relay-trust removal path
|
||||||
|
(cross-device spend convergence) is preserved. A failing Reconcile does not reschedule itself
|
||||||
|
(no infinite loop).
|
||||||
|
7. **nip60 stuck nutzap redemptions** — `apply_outcome` now receives the failed job; on
|
||||||
|
`RedeemNutzap` error the id is removed from `redeeming` and requeued up to
|
||||||
|
`MAX_REDEEM_ATTEMPTS = 3` (cleared on success, reset on signer change), so transient mint
|
||||||
|
failures no longer wedge redemptions until restart.
|
||||||
|
8. **Reactions double-count** — `chat_ui::insert_reaction` replaces per (author, target) per
|
||||||
|
NIP-25, making the backup gift-wrap echo idempotent (verified: optimistic rumor and the
|
||||||
|
re-delivered seal carry the same event id) and history reloads non-duplicating.
|
||||||
|
9. **Whitespace collapse** — `common/media_extractor.rs` rewritten: whitespace is tidied only at
|
||||||
|
URL-removal boundaries; line breaks in untouched regions survive; regex hoisted to
|
||||||
|
`LazyLock`; 9 unit tests added (multi-line preservation, URL-only messages, digit/emoji edges).
|
||||||
|
10. **Contact-list data loss** — `contact_list.rs` now parses the stored event into full
|
||||||
|
`nostr::nips::nip02::Contact` values (relay hint + alias preserved through load → add/remove
|
||||||
|
→ republish); new contacts still get `Contact::new`.
|
||||||
|
11. **Speech worker lifetime** — `parakeet.rs`: the `OnceLock<Sender>` became a resettable
|
||||||
|
`Mutex<Option<Sender>>`; a dead worker (30 s idle exit) is detected via `is_disconnected`, the
|
||||||
|
slot cleared, and a replacement thread spawned lazily on the next command; spawn failure no
|
||||||
|
longer caches a dead sender; failures surface through `sink.error` / returned `Err` instead
|
||||||
|
of a debug-level swallow (one retry covers the liveness-check/send race).
|
||||||
|
12. **Settings durability** — a failed load logs and keeps defaults in memory without
|
||||||
|
persisting (`suppress_next_save`, consumed by the next real change); fresh-install NotFound is
|
||||||
|
no longer conflated with read/parse failures; saves are serialized through a single
|
||||||
|
`save_task` slot (chained, FIFO order) with temp-file + atomic rename; serialization and
|
||||||
|
write failures are logged.
|
||||||
|
13. **nip29 ordering guards** — shared `is_newer_event` (strict `(created_at, id)` total order):
|
||||||
|
`Group::absorb_state` keeps a per-kind stamp (written only after a successful apply, so a
|
||||||
|
malformed newer event can't poison the guard) and rejects stale replays while ascending cache
|
||||||
|
hydration still applies; the registry's `list_stamp` seeds from the cached list event at
|
||||||
|
startup, is checked in `merge` before applying, updated by `publish_list` (closing the
|
||||||
|
leave→resurrect window), and reset on signer change. 3 new tests across protocol/group.
|
||||||
|
14. **nip60_ui invoice flow** — *not* in this batch: it is P1 (the audit lists it as High but the
|
||||||
|
P0 list ranks it 16th via `nip60_ui/wallet.rs`); `mint_quotes()`/`claim_mint_quote()` remain
|
||||||
|
dead until the P1 UI work lands. No other P0 code paths depend on it.
|
||||||
|
|
||||||
|
Behavior changes to be aware of when testing:
|
||||||
|
- Web: bunker credentials are no longer persisted (re-connect after each reload).
|
||||||
|
- Desktop: a corrupted/unreadable local database now starts with an empty in-memory history and
|
||||||
|
shows an error event instead of crash-looping; relays re-sync as before.
|
||||||
|
- nip60: reconcile now performs one NUT-08 mint round-trip when unseen relay proofs appear.
|
||||||
|
- Reactions render per NIP-25 replace semantics (one reaction per author per target).
|
||||||
|
- Messages keep their line breaks; only URL-adjacent whitespace is tidied.
|
||||||
|
|
||||||
|
## 10. P1 Remediation Log (2026-11-12)
|
||||||
|
|
||||||
|
All eleven P1 items from §8 fixed on top of the P0 commit (`fd802a88`). Validation after the
|
||||||
|
full batch: `cargo clippy --workspace --all-targets` zero warnings, `cargo fmt --all --check`
|
||||||
|
clean, `cargo test --workspace` 38 passed / 0 failed. Diff: 25 files, +1072/−247.
|
||||||
|
|
||||||
|
1. **Dock panel dedup made real** (`workspace/src/dock/mod.rs`, `lib.rs`, `skin.rs`):
|
||||||
|
`PanelKey` — `Named(panel_name)` for singletons, `Room(room_id)` for chat panels — recovered
|
||||||
|
from docked panels via `PanelHandle::of` + downcasts; `add_panel` now focuses the existing
|
||||||
|
tab (reopening a collapsed dock first) instead of stacking a duplicate; group panels dedupe
|
||||||
|
through `Workspace.group_panels: HashMap<GroupKey, PanelId>` (group identity is not readable
|
||||||
|
off a `GroupPanel`), with stale ids pruned via `area.panel(id)` validation. The dead
|
||||||
|
`find_panel`/`move_panel`/`InsertTarget` machinery is deleted, along with `skin.rs`'s
|
||||||
|
write-once `toggle_button_visible`. Known residual: the panel opened by the new-group dialog
|
||||||
|
(`dialogs/new_group.rs`, still plain `add_panel_to`) isn't registered — P2.
|
||||||
|
2. **"Ignore" screening action** (`sidebar/rows.rs`, `dock/mod.rs`): closes the request room's
|
||||||
|
tab via `dock::close_room_panel` (find by room key → `DockArea::remove_panel`) instead of
|
||||||
|
dispatching `ClosePanel` into a focus path that can never reach its handler.
|
||||||
|
3. **Updater observation** (`sidebar/mod.rs`, `sidebar/header.rs`, `auto_update/src/lib.rs`):
|
||||||
|
`AutoUpdater` now exposes `UpdateDisplay` (`up_to_date`, `staged`, `status` text) via
|
||||||
|
`display()`; the Sidebar mirrors it into a small `Entity<Option<UpdateDisplay>>`, updating it
|
||||||
|
only when a rendered fact actually changes, and the header renders from that snapshot (the
|
||||||
|
restart click fetches the global entity at click time). Observing the whole updater and
|
||||||
|
notifying the entire cached sidebar was rejected as too blunt: download-progress ticks would
|
||||||
|
have invalidated the panel on every byte.
|
||||||
|
4. **Wallet invoice flow** (`nip60_ui/wallet.rs`, `nip60/src/lib.rs`): pending mint quotes render
|
||||||
|
in the panel (amount, mint, bolt11 with middle-ellipsis, copy via the house `Clipboard`
|
||||||
|
component, check/claim wired to the existing `Job::ClaimQuote` path, per-quote dismiss via the
|
||||||
|
new `dismiss_mint_quote`); claim results refresh through the existing `WalletEvent::Updated`;
|
||||||
|
an unpaid check emits "Invoice is not paid yet" (gated on `ClaimQuote` — flagged behavior
|
||||||
|
change). Receive-via-lightning is functional end-to-end.
|
||||||
|
5. **Person fetch blacklisting** (`person/src/lib.rs`): `in_flight` is now shared
|
||||||
|
(`Arc<RwLock<HashSet<_>>>`) with the batching task; all three duplicated `get_metadata` error
|
||||||
|
arms factored into `flush_batch`, which releases the whole batch for retry on failure and
|
||||||
|
logs. A 2 s timeout now costs a retry, not a session-long placeholder.
|
||||||
|
6. **Chat trash surfaced** (`chat/src/lib.rs`): the signal consumer counts *freshly* trashed
|
||||||
|
messages (BTreeSet insert dedupes reprocess attempts) and emits one batched
|
||||||
|
`ChatEvent::Error` ("N messages could not be decrypted"), which the workspace already renders
|
||||||
|
as an error notification.
|
||||||
|
7. **chat_ui list fixes** (`chat_ui/src/lib.rs`): room connect/load failures now push persistent
|
||||||
|
error notifications (`notify_failure`, `.autohide(false)`) instead of silently empty rooms;
|
||||||
|
message inserts splice at the real row (`pos + 1`, welcome row accounted for — append case is
|
||||||
|
byte-identical to the old behavior); bulk loads are O(n log n) sort-merge with a single splice
|
||||||
|
and one index rebuild, preserving the old first-arrival-wins timestamp dedup exactly.
|
||||||
|
8. **Dialog fixes** (`workspace/src/dialogs/{profile,new_chat,screening}.rs`, `panels/backup.rs`):
|
||||||
|
profile validates avatar/website URLs before publishing (inline error banner; empty still
|
||||||
|
means "keep saved value"); backup surfaces keyring/decode failures in-panel and its bech32
|
||||||
|
`unwrap`s are gone; new_chat re-filters on `InputEvent::Change` (and profile's avatar preview
|
||||||
|
tracks typing); screening no longer closes all stacked dialogs on release, NIP-05 verification
|
||||||
|
is tri-state (pending/verified/mismatched/failed — network failure no longer asserts "does not
|
||||||
|
match"), and report submission shows failures.
|
||||||
|
9. **Parser recursion** (`common/src/parser.rs`): `NostrParserIter::next` is a loop; the
|
||||||
|
pending-match fields are gone (provably passthrough); 50,000 consecutive malformed `nostr:`
|
||||||
|
URIs now cost loop iterations, not stack frames (regression tests added) — wasm tab-crash
|
||||||
|
vector closed.
|
||||||
|
10. **Stable room ids** (`common/src/event.rs`, `nip29`): `DefaultHasher` replaced by SHA-256
|
||||||
|
(`common::stable_id` 128-bit hex, `stable_id_u64` for u64-keyed consumers); `Event` and
|
||||||
|
`UnsignedEvent` `uniq_id` impls factored into one `pubkey_set_uniq_id` helper; nip29's
|
||||||
|
`GroupKey::uniq_id` and `browse_subscription` unified (verified session-local). **Room id
|
||||||
|
values change** (new hash domain) — existing cached groupings regroup per session as rumors
|
||||||
|
re-arrive; release notes should mention it. Follow-up if 128-bit ids are wanted: chat's id
|
||||||
|
fields are u64-typed; migrating to `String` ids is mechanical.
|
||||||
|
11. **nip4e lifecycle** (`nip4e/src/lib.rs`): approval has an `approving` re-entry guard (the
|
||||||
|
device secret can no longer be double-sent), toast lifecycle is correct on both outcomes
|
||||||
|
(success clears only the matching request; failure rebuilds the toast with a fresh enabled
|
||||||
|
button); rotation aborts before overwrite when load/archive fails (surfaces via
|
||||||
|
`DeviceEvent::error`); the relay listener logs dropped events and its own death instead of
|
||||||
|
stopping silently.
|
||||||
|
12. **Proxy hardening round 2** (`browser-signer-proxy/src/lib.rs`): signed events are checked
|
||||||
|
for correspondence (`event.compute_id()` vs returned id — proves field equality, covers
|
||||||
|
stale ids), closing the event-substitution hole; the response log carries id + ok/error only,
|
||||||
|
never values; request lines/headers are capped (16 KiB → 400), `Content-Length` capped
|
||||||
|
(1 MiB → 413), invalid UTF-8 → 400, and connections are dropped after 30 s (slowloris
|
||||||
|
bound).
|
||||||
|
|
||||||
|
### P1 behavior changes to be aware of when testing
|
||||||
|
|
||||||
|
- Opening an already-open room/group/panel focuses the existing tab; the "Ignore" action closes
|
||||||
|
the request room's tab.
|
||||||
|
- Wallet panel shows a "Pending invoices" section; unpaid checks report "Invoice is not paid
|
||||||
|
yet"; quotes are dismissable.
|
||||||
|
- Room groupings regenerate under a new id scheme — first launch after this change rebuilds
|
||||||
|
rooms from cached rumors (history is kept; grouping self-heals).
|
||||||
|
- Profile saves with an invalid URL now stop with an inline error instead of silently dropping
|
||||||
|
the field; NIP-05 shows "could not be verified" on network failure instead of "does not
|
||||||
|
match"; undecryptable messages and room load/connect failures produce visible notifications.
|
||||||
|
- First message-send/receive after the room-id change republishes `r` tags under the new ids.
|
||||||
Reference in New Issue
Block a user